diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 0571c23..9c00f57 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -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: @@ -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. diff --git a/freevideo_engine/comfy_metadata.py b/freevideo_engine/comfy_metadata.py new file mode 100644 index 0000000..25f5e95 --- /dev/null +++ b/freevideo_engine/comfy_metadata.py @@ -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) diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 8f18889..e43c78f 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -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, ) @@ -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)) diff --git a/freevideo_engine/comfy_share.py b/freevideo_engine/comfy_share.py index 1b96c99..be476b8 100644 --- a/freevideo_engine/comfy_share.py +++ b/freevideo_engine/comfy_share.py @@ -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 @@ -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 @@ -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 @@ -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') @@ -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') diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 2482937..cc52991 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -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 自带的输出一致。", + "视频索引移到了文件开头,在线播放时加载更快。" ] } } diff --git a/web/share.js b/web/share.js index 61561c4..68463dd 100644 --- a/web/share.js +++ b/web/share.js @@ -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; @@ -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';