Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion freevideo_engine/comfy_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ def engine_environment(root, source, environ=None):
def generate(prompt, width, height, seconds, seed, output_directory, *,
source=None, environ=None, metadata=None, progress=None, interrupted=None,
release_models=None, export_inputs=None, two_pass=True, encoder_prewarm=None,
force_regenerate=False, base_steps=8, refine_steps=3):
force_regenerate=False, base_steps=8, refine_steps=3, comfy_metadata=None):
if type(two_pass) is not bool:
raise ValueError('Two-pass generation must be a boolean')
if type(force_regenerate) is not bool:
Expand Down Expand Up @@ -626,6 +626,11 @@ def asset_progress(message):
or any(engine.get('geometry', {}).get(k) != canvas[k] for k in ('width', 'height', 'frames'))):
from .failure_details import generation_failure
raise RuntimeError('FreeVideo did not complete the requested video.\n' + generation_failure(run))
if comfy_metadata:
# Dropping the video on the canvas restores this graph, as with ComfyUI's own video nodes.
# Written before the result cache records the file's size and hash.
from .comfy_metadata import embed_comfy_metadata
state['workflow_in_video'] = embed_comfy_metadata(output, **comfy_metadata)
state.update(status='complete', request_seconds=report.get('request_seconds'),
engine_report=str(output.with_suffix('.engine.json')))
# Do not index a result against inputs/models that changed while it ran.
Expand Down
49 changes: 49 additions & 0 deletions freevideo_engine/comfy_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Keep the ComfyUI workflow inside saved videos.

ComfyUI's own video nodes store the prompt and the workflow as MP4 metadata
(QuickTime keys, moov first). The frontend reads those two keys when a video is
dropped on the canvas, so a FreeVideo result restores its graph the same way.
"""
import json
import logging
from pathlib import Path
import uuid


def embed_comfy_metadata(path, *, prompt=None, workflow=None):
"""Rewrite the MP4 at `path` with the graph in its metadata, copying the
streams unchanged. Returns True when the file was rewritten; on any failure
the original video is left as it was."""
tags = {key: json.dumps(value) for key, value in (('prompt', prompt), ('workflow', workflow)) if value is not None}
if not tags:
return False
path = Path(path)
temporary = path.with_name(f'{path.stem}.{uuid.uuid4().hex[:8]}.metadata{path.suffix}')
try:
import av
# faststart puts moov, and with it the metadata, before the media data.
with av.open(str(path)) as source, av.open(str(temporary), 'w', format='mp4',
options={'movflags': 'use_metadata_tags+faststart'}) as target:
for key, value in tags.items():
target.metadata[key] = value
streams = {stream: _copy_stream(target, stream) for stream in source.streams
if stream.type in ('video', 'audio') and stream.codec_context is not None}
if not streams:
raise ValueError('The video has no audio or video stream to copy')
for packet in source.demux(*streams):
if packet.dts is not None:
packet.stream = streams[packet.stream]
target.mux(packet)
temporary.replace(path)
return True
except Exception:
logging.warning('FreeVideo could not store the workflow in %s; the video is unchanged.', path, exc_info=True)
temporary.unlink(missing_ok=True)
return False


def _copy_stream(container, template):
try:
return container.add_stream_from_template(template=template, opaque=True)
except (AttributeError, TypeError): # PyAV releases without add_stream_from_template(opaque=...)
return container.add_stream(template=template)
10 changes: 8 additions & 2 deletions freevideo_engine/comfy_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def define_schema(cls):
tooltip='Generate again even when an identical completed video is saved locally.'),
],
outputs=[io.Video.Output('video'), io.String.Output('report', display_name='Report JSON')],
hidden=[io.Hidden.unique_id, io.Hidden.extra_pnginfo],
hidden=[io.Hidden.unique_id, io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, not_idempotent=True,
)

Expand Down Expand Up @@ -114,13 +114,19 @@ def release():
memory.unload_all_models()
memory.soft_empty_cache()
metadata = cls.hidden.extra_pnginfo or {}
try:
from comfy.cli_args import args as comfy_args
embed = not comfy_args.disable_metadata
except (ImportError, AttributeError):
embed = True
graph = dict(prompt=getattr(cls.hidden, 'prompt', None), workflow=metadata.get('workflow')) if embed else None
output_root = Path(folder_paths.get_output_directory()).resolve()
publish(PromptServer.instance, node_id, {'label': 'Preparing video', 'new_request': True})
try:
output = comfy_bridge.generate(text, width, height, seconds, seed, output_root,
metadata=metadata.get('workflow', metadata), progress=progress, two_pass=two_pass,
encoder_prewarm=prewarm, force_regenerate=force_regenerate,
base_steps=base_steps, refine_steps=refine_steps,
base_steps=base_steps, refine_steps=refine_steps, comfy_metadata=graph,
interrupted=memory.throw_exception_if_processing_interrupted, release_models=release,
export_inputs=lambda run, canvas: export(run, canvas, first=first, last=last,
references=references, loras=loras, conditioning=conditioning, assets=media))
Expand Down
27 changes: 24 additions & 3 deletions freevideo_engine/comfy_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

The browser supplies a small, text-only presentation frame using its own fonts.
Video composition uses CPU PyAV and copies the existing audio packets. No model,
CUDA context, cloud service, prompt, or full report is involved.
CUDA context, cloud service or full report is involved. The saved ComfyUI
workflow travels with both exports, so dropping one on the canvas restores it.
"""
import asyncio
import base64
Expand All @@ -17,6 +18,23 @@
from .comfy_library import _video, _report


def saved_graph(path):
"""The workflow and prompt a saved video carries, as ComfyUI stores them (JSON text)."""
import av
try:
with av.open(str(path)) as reader:
graph = {key: value for key, value in reader.metadata.items() if key in ('workflow', 'prompt') and value}
except Exception:
graph = {}
if 'workflow' not in graph:
# Videos saved before the workflow was embedded keep it next to them.
try:
graph['workflow'] = json.dumps(json.loads(path.with_name('workflow.json').read_text(encoding='utf-8')))
except (OSError, ValueError):
pass
return graph


def details(root, identity):
import av
from .comfy_assets import output_summary
Expand All @@ -43,6 +61,7 @@ def details(root, identity):
result['sampling_plan'] = {k: v for k, v in plan.items() if k in ('enabled', 'base_steps', 'refine_steps')
and type(v) in (int, bool)}
result['id'] = identity
result['graph'] = saved_graph(path)
return result


Expand Down Expand Up @@ -88,7 +107,7 @@ def video_export(root, identity, data, stop):
_report(path)
info = path.stat()
template, rect, key = layout(data)
key = hashlib.sha256(f'{key}:{info.st_size}:{info.st_mtime_ns}:v1'.encode()).hexdigest()[:20]
key = hashlib.sha256(f'{key}:{info.st_size}:{info.st_mtime_ns}:v2'.encode()).hexdigest()[:20]
target = path.with_name('video.share-' + key + '.mp4')
if target.is_symlink():
raise ValueError('Invalid sharing destination')
Expand All @@ -98,7 +117,9 @@ def video_export(root, identity, data, stop):
x, y, width, height = rect
try:
with av.open(str(path)) as reader, av.open(str(temporary), 'w', format='mp4',
options={'movflags': '+faststart'}) as writer:
options={'movflags': 'use_metadata_tags+faststart'}) as writer:
for name, value in saved_graph(path).items():
writer.metadata[name] = value
source = reader.streams.video[0]; source.thread_count = 1
if abs(width / height - source.width / source.height) > 2 / height:
raise ValueError('Sharing must preserve the original aspect ratio')
Expand Down
18 changes: 7 additions & 11 deletions freevideo_engine/release_notes.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
{
"schema": 1,
"product_version": "0.2.1",
"product_version": "0.2.2",
"en": {
"summary": "A redesigned quality slider, result view and sharing card.",
"summary": "Saved videos and sharing exports keep their workflow.",
"changes": [
"The quality slider is larger and easier to drag, with clearer motion between levels.",
"A finished video now has one action bar: Download video, Share, and a Report menu that holds both the diagnostic report and the generation record.",
"Generation times and memory peaks sit together in one card, in Create and in Creations.",
"Sharing images and videos are simpler: generation time, GPU and quality level."
"Saved videos, and the images and videos exported for sharing, now carry the ComfyUI workflow that made them. Drag one onto the canvas to restore the graph with its prompt, seed and settings, the same way as with ComfyUI's own outputs.",
"Videos open faster when streamed, because the index is stored at the start of the file."
]
},
"zh": {
"summary": "质量滑块、生成结果区和分享卡片重新设计。",
"summary": "生成的视频和分享导出会保存工作流。",
"changes": [
"质量滑块更大、更好拖动,切换档位的动效更清晰。",
"视频生成后只有一排操作:下载视频、分享,以及一个报告菜单,诊断报告和生成记录都在里面。",
"耗时和显存、内存峰值合并为一张卡片,创作页和作品库一致。",
"分享图片和视频更简洁,只保留生成耗时、显卡和质量档位。"
"生成的视频,以及分享导出的图片和视频,都会保存产生它的 ComfyUI 工作流。拖到画布上即可还原当时的工作流、提示词、种子和各项参数,与 ComfyUI 自带的输出一致。",
"视频索引移到了文件开头,在线播放时加载更快。"
]
}
}
16 changes: 15 additions & 1 deletion web/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ const button=(text,action,cls='fv-quiet')=>{const b=el('button',cls,text);b.type
const identity=r=>/^FreeVideo\/(\d{4}-\d{2}-\d{2}\/[a-f0-9]{32})\/video\.mp4$/.exec(r?.video||'')?.[1];
const image=src=>new Promise((resolve,reject)=>{const im=new Image();im.onload=()=>resolve(im);im.onerror=reject;im.src=src;});
const duration=n=>Number.isFinite(n)&&n>=0?`${n.toFixed(1)} s`:'—';
// ComfyUI reads a PNG's graph from tEXt chunks named workflow and prompt; add them after IHDR.
const crcTable=(()=>{const t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=c&1?0xedb88320^(c>>>1):c>>>1;t[n]=c>>>0;}return t;})();
const crc32=bytes=>{let c=0xffffffff;for(const b of bytes)c=crcTable[(c^b)&255]^(c>>>8);return(c^0xffffffff)>>>0;};
async function withGraph(blob,graph){
const entries=Object.entries(graph||{}).filter(([key,value])=>['workflow','prompt'].includes(key)&&typeof value==='string'&&value);
if(!entries.length)return blob;
const png=new Uint8Array(await blob.arrayBuffer()),end=8+12+new DataView(png.buffer).getUint32(8);
const chunks=entries.map(([key,text])=>{
const data=new TextEncoder().encode(key+'\0'+text),chunk=new Uint8Array(12+data.length),view=new DataView(chunk.buffer);
view.setUint32(0,data.length);chunk.set([116,69,88,116],4);chunk.set(data,8);view.setUint32(8+data.length,crc32(chunk.subarray(4,8+data.length)));
return chunk;
});
return new Blob([png.subarray(0,end),...chunks,png.subarray(end)],{type:'image/png'});
}
const tint=(hex,alpha)=>{const v=parseInt(hex.slice(1),16);return `rgba(${v>>16},${v>>8&255},${v&255},${alpha})`;};
let opened;

Expand Down Expand Up @@ -119,7 +133,7 @@ export async function openShare(record,t){
async function download(){
if(!ready||busy)return;
if(kind==='image'){
canvas.toBlob(blob=>{if(!blob||disposed)return;const url=URL.createObjectURL(blob);const a=el('a');a.href=url;a.download=`FreeVideo_share_${id.split('/')[1].slice(0,12)}.png`;a.click();setTimeout(()=>URL.revokeObjectURL(url),60000);},'image/png');return;
canvas.toBlob(async blob=>{if(!blob||disposed)return;blob=await withGraph(blob,metadata?.graph);const url=URL.createObjectURL(blob);const a=el('a');a.href=url;a.download=`FreeVideo_share_${id.split('/')[1].slice(0,12)}.png`;a.click();setTimeout(()=>URL.revokeObjectURL(url),60000);},'image/png');return;
}
busy=true;save.disabled=true;cancel.hidden=false;tabs.querySelectorAll('button').forEach(b=>b.disabled=true);
message.textContent=t('Preparing sharing video…','正在导出分享视频…');dialog.dataset.busy='true';
Expand Down