Allow final render from overlays and audio without video clips.

Generate a blank lavfi canvas at project resolution, composite overlays, mix audio tracks, and download without rewriting scene renders.
This commit is contained in:
digital-garbage
2026-06-12 15:39:48 +03:00
parent 2652044c2f
commit 2ff89a8456
3 changed files with 201 additions and 100 deletions
+38 -14
View File
@@ -3074,10 +3074,16 @@
async function renderFinal() {
if (!state.project) return;
const rc = _renderClips();
if (!rc.length) { alert("No generated clips to stitch yet — generate first."); return; }
if (!confirm(
`Stitch ${rc.length} clip(s) into one final video?\n\nThis replaces per-scene renders with a single stitched file on the timeline.`
)) return;
const totalSec = previewTotalSec();
const graphicsOnly = !rc.length && hasOverlayOrAudioContent() && totalSec > 0;
if (!rc.length && !graphicsOnly) {
alert("Nothing to render yet — generate clips or add overlay/audio tracks first.");
return;
}
const confirmMsg = graphicsOnly
? `Render the overlay/audio timeline (${totalSec.toFixed(1)}s) to a final video?\n\nUses a blank canvas at project resolution (${state.project.width || 768}×${state.project.height || 512}).`
: `Stitch ${rc.length} clip(s) into one final video?\n\nThis replaces per-scene renders with a single stitched file on the timeline.`;
if (!confirm(confirmMsg)) return;
const clips = rc.map((c) => ({
filename: c.media?.filename, subfolder: c.media?.subfolder || "", type: c.media?.type || "output",
bin_media_ref: c.bin_media_ref || "",
@@ -3086,20 +3092,38 @@
volume: c.volume, scene_id: c.sceneId,
}));
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} clip(s)…` } });
set({
gen: {
state: "running",
promptId: null,
media: [],
msg: graphicsOnly ? `Rendering ${totalSec.toFixed(1)}s overlay/audio…` : `Stitching ${clips.length} clip(s)…`,
},
});
try {
const queued = await API.renderFinal(state.project.id, clips);
const r = queued?.job_id
? await _pollRenderJob(queued.job_id, clips.length)
? await _pollRenderJob(queued.job_id, clips.length || 1)
: queued;
// Play the stitched file across the whole timeline (single source, in-point 0).
const order = state.project.scenes.filter((s) => !s.excluded);
state.sceneRenders = {};
state.sceneGhosts = [];
_syncEditorStateToProject();
let acc = 0;
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} clip(s) — saving…` } });
if (!graphicsOnly) {
// Play the stitched file across the whole timeline (single source, in-point 0).
const order = state.project.scenes.filter((s) => !s.excluded);
state.sceneRenders = {};
state.sceneGhosts = [];
_syncEditorStateToProject();
let acc = 0;
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
}
set({
gen: {
state: "done",
promptId: null,
media: [r.media],
msg: graphicsOnly
? "Final overlay/audio video rendered — saving…"
: `Final video rendered from ${r.clips} clip(s) — saving…`,
},
});
const name = (state.project.name || "montage").replace(/[^\w.-]+/g, "_") + `_final_${_stamp()}.mp4`;
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
} catch (e) {
+137 -85
View File
@@ -552,11 +552,13 @@ _XFADE_MAP = {
def _build_render_filter(clips: list, tracks: Optional[list] = None,
keep_original: bool = True, base_input: int = 0) -> tuple[str, bool]:
keep_original: bool = True, base_input: int = 0,
*, blank_canvas: Optional[dict] = None) -> tuple[str, bool]:
"""ffmpeg filter_complex for the final stitch.
Video — per clip: normalize to one canvas, then blur / fade in-out / virtual Ken-Burns
zoom (zoompan, fixed output size). Fold left-to-right with xfade (overlap) or concat.
When `blank_canvas` is set, input 0 is a lavfi color source (no scene clips).
Audio — per-clip original audio gets its volume; when keep_original the per-clip streams
are folded alongside the video (acrossfade at crossfades, concat otherwise) into [aorig].
@@ -570,73 +572,80 @@ def _build_render_filter(clips: list, tracks: Optional[list] = None,
n = len(clips)
parts: list[str] = []
# One canvas for the whole render (clips may have differing native sizes/fps — xfade and
# concat require identical size/fps/sar, so every clip is normalized to this).
cw = int(clips[0].get("w") or 0) or 768
ch = int(clips[0].get("h") or 0) or 768
cfps = float(clips[0].get("fps") or 0) or 25.0
if blank_canvas:
total = max(0.01, float(blank_canvas.get("dur") or 0.01))
parts.append("[0:v]format=yuv420p,setsar=1[vbase]")
acc_a = None
else:
if not clips:
raise ValueError("clips required when blank_canvas is not set")
# One canvas for the whole render (clips may have differing native sizes/fps — xfade and
# concat require identical size/fps/sar, so every clip is normalized to this).
cw = int(clips[0].get("w") or 0) or 768
ch = int(clips[0].get("h") or 0) or 768
cfps = float(clips[0].get("fps") or 0) or 25.0
for i, c in enumerate(clips):
fx = c.get("fx") or {}
dur = float(c.get("dur") or 0) or 0.0
# Normalize first: fit into the canvas (letterbox), fixed fps + square pixels.
vf: list[str] = [
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease",
f"pad={cw}:{ch}:-1:-1:color=black",
"setsar=1", f"fps={cfps:g}",
]
zoom = fx.get("zoom")
if zoom in ("in", "out") and dur > 0:
nframes = max(1, round(dur * cfps))
z = zoompan_z_expr(zoom, fx, nframes)
vf.append(
f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f":s={cw}x{ch}:fps={cfps:g}"
)
for i, c in enumerate(clips):
fx = c.get("fx") or {}
dur = float(c.get("dur") or 0) or 0.0
# Normalize first: fit into the canvas (letterbox), fixed fps + square pixels.
vf: list[str] = [
f"scale={cw}:{ch}:force_original_aspect_ratio=decrease",
f"pad={cw}:{ch}:-1:-1:color=black",
"setsar=1", f"fps={cfps:g}",
]
zoom = fx.get("zoom")
if zoom in ("in", "out") and dur > 0:
nframes = max(1, round(dur * cfps))
z = zoompan_z_expr(zoom, fx, nframes)
vf.append(
f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
f":s={cw}x{ch}:fps={cfps:g}"
)
vf.append("setsar=1")
blur = float(fx.get("blur") or 0)
if blur > 0:
vf.append(f"gblur=sigma={blur * 20:.2f}")
fi = float(fx.get("fade_in") or 0)
if fi > 0:
vf.append(f"fade=t=in:st=0:d={fi:.3f}")
fo = float(fx.get("fade_out") or 0)
if fo > 0 and dur > 0:
vf.append(f"fade=t=out:st={max(0.0, dur - fo):.3f}:d={fo:.3f}")
vf.append("format=yuv420p")
vf.append("setsar=1")
blur = float(fx.get("blur") or 0)
if blur > 0:
vf.append(f"gblur=sigma={blur * 20:.2f}")
fi = float(fx.get("fade_in") or 0)
if fi > 0:
vf.append(f"fade=t=in:st=0:d={fi:.3f}")
fo = float(fx.get("fade_out") or 0)
if fo > 0 and dur > 0:
vf.append(f"fade=t=out:st={max(0.0, dur - fo):.3f}:d={fo:.3f}")
vf.append("format=yuv420p")
vf.append("setsar=1")
parts.append(f"[{i}:v:0]{','.join(vf)}[v{i}]")
if keep_original:
vol = float(c.get("volume", 1.0))
af = "aformat=sample_fmts=fltp:channel_layouts=stereo"
if abs(vol - 1.0) > 1e-3:
af += f",volume={max(0.0, vol):.3f}"
parts.append(f"[{i}:a:0]{af}[a{i}]")
# Fold video (and the original audio when kept) left-to-right.
acc_v, acc_a = "[v0]", "[a0]"
acc_dur = float(clips[0].get("dur") or 0) or 0.0
for i in range(1, n):
prev = clips[i - 1]
trans = (prev.get("transition") or "").strip()
td = float(prev.get("tdur") or 0)
dur_i = float(clips[i].get("dur") or 0) or 0.0
if trans in _XFADE_MAP and td > 0 and acc_dur > td:
off = max(0.0, acc_dur - td)
ov = f"[vx{i}]"
parts.append(f"{acc_v}[v{i}]xfade=transition={_XFADE_MAP[trans]}:duration={td:.3f}:offset={off:.3f}{ov}")
parts.append(f"[{i}:v:0]{','.join(vf)}[v{i}]")
if keep_original:
oa = f"[ax{i}]"; parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i - td
else:
ov = f"[vc{i}]"
parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0{ov}")
if keep_original:
oa = f"[ac{i}]"; parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i
vol = float(c.get("volume", 1.0))
af = "aformat=sample_fmts=fltp:channel_layouts=stereo"
if abs(vol - 1.0) > 1e-3:
af += f",volume={max(0.0, vol):.3f}"
parts.append(f"[{i}:a:0]{af}[a{i}]")
parts.append(f"{acc_v}null[vbase]")
total = max(0.01, acc_dur)
# Fold video (and the original audio when kept) left-to-right.
acc_v, acc_a = "[v0]", "[a0]"
acc_dur = float(clips[0].get("dur") or 0) or 0.0
for i in range(1, n):
prev = clips[i - 1]
trans = (prev.get("transition") or "").strip()
td = float(prev.get("tdur") or 0)
dur_i = float(clips[i].get("dur") or 0) or 0.0
if trans in _XFADE_MAP and td > 0 and acc_dur > td:
off = max(0.0, acc_dur - td)
ov = f"[vx{i}]"
parts.append(f"{acc_v}[v{i}]xfade=transition={_XFADE_MAP[trans]}:duration={td:.3f}:offset={off:.3f}{ov}")
if keep_original:
oa = f"[ax{i}]"; parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i - td
else:
ov = f"[vc{i}]"
parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0{ov}")
if keep_original:
oa = f"[ac{i}]"; parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1{oa}"); acc_a = oa
acc_v, acc_dur = ov, acc_dur + dur_i
parts.append(f"{acc_v}null[vbase]")
total = max(0.01, acc_dur)
# Assemble the audio mix: original (if kept) + each inserted track (delayed/volume'd).
mix: list[str] = []
@@ -692,6 +701,30 @@ class RenderJobError(Exception):
pass
def _audio_track_end_sec(t: dict) -> float:
start = float(t.get("start_sec") or 0)
for key in ("source_dur", "pinned_dur"):
if t.get(key) is not None:
return start + float(t[key])
return start + 1.0
def _timeline_duration_sec(proj) -> float:
total = 0.0
for ov in getattr(proj, "overlay_tracks", None) or []:
end = float(ov.get("start_sec") or 0) + float(ov.get("duration_sec") or 0)
total = max(total, end)
for t in getattr(proj, "audio_tracks", None) or []:
total = max(total, _audio_track_end_sec(t))
return max(total, 0.01)
def _has_graphics_export_content(proj) -> bool:
if list(getattr(proj, "audio_tracks", None) or []):
return True
return any(float(ov.get("duration_sec") or 0) > 0 for ov in (getattr(proj, "overlay_tracks", None) or []))
_render_jobs: dict[str, dict] = {}
_export_jobs: dict[str, dict] = {}
@@ -772,8 +805,9 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
import subprocess
import time as _time
if not clips:
raise RenderJobError("Nothing to render — no generated clips.")
blank_mode = not clips
if blank_mode and not _has_graphics_export_content(proj):
raise RenderJobError("Nothing to render — add overlays, audio, or generated clips.")
ff = shutil.which("ffmpeg")
if not ff:
raise RenderJobError("ffmpeg not found on PATH — install it to render the final video.")
@@ -784,6 +818,12 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
except Exception as e: # noqa: BLE001
raise RenderJobError(f"Output directory unavailable: {e}") from e
cw = int(getattr(proj, "width", 768) or 768)
ch = int(getattr(proj, "height", 512) or 512)
cfps = float(getattr(proj, "frame_rate", 25) or 25)
blank_canvas = None
n_video = 0
def _resolve(c):
if c.get("bin_media_ref"):
mp = media.path_for(c.get("bin_media_ref") or "")
@@ -793,12 +833,21 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
base = outdir if c.get("type", "output") == "output" else tempdir
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
paths = [_resolve(c) for c in clips]
missing = [p for p in paths if not os.path.isfile(p)]
if missing:
raise RenderJobError(f"{len(missing)} clip file(s) not found on disk — regenerate then render.")
paths: list = []
if blank_mode:
duration = _timeline_duration_sec(proj)
blank_canvas = {"w": cw, "h": ch, "fps": cfps, "dur": duration}
n_video = 1
else:
paths = [_resolve(c) for c in clips]
missing = [p for p in paths if not os.path.isfile(p)]
if missing:
raise RenderJobError(f"{len(missing)} clip file(s) not found on disk — regenerate then render.")
cw = int(clips[0].get("w") or 0) or cw
ch = int(clips[0].get("h") or 0) or ch
n_video = len(clips)
keep_original = bool(getattr(proj, "keep_original_audio", True))
keep_original = bool(getattr(proj, "keep_original_audio", True)) and not blank_mode
clip_by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")}
media_tracks: list = []
separated_tracks: list = []
@@ -861,14 +910,17 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
cmd = [ff, "-y"]
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
if inn is not None:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = len(clips)
if blank_mode:
cmd += ["-f", "lavfi", "-i", f"color=c=black:s={cw}x{ch}:r={cfps:g}:d={blank_canvas['dur']:.3f}"]
else:
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
if inn is not None:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = n_video
for t in media_tracks:
if t.get("source_in") is not None and t.get("source_dur") is not None:
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
@@ -878,9 +930,6 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
tracks = media_tracks + separated_tracks
cw = int(clips[0].get("w") or 0) or int(getattr(proj, "width", 768) or 768)
ch = int(clips[0].get("h") or 0) or int(getattr(proj, "height", 512) or 512)
def _overlay_image_path(media_ref: str | None) -> str | None:
mp = media.path_for(media_ref or "")
return str(mp) if mp is not None else None
@@ -896,7 +945,10 @@ def _ffmpeg_stitch_final(proj, clips: list) -> dict:
for pth in overlay_image_paths:
cmd += ["-i", pth]
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
filt, has_audio = _build_render_filter(
clips, tracks=tracks, keep_original=keep_original, base_input=n,
blank_canvas=blank_canvas,
)
filt_parts = filt.split(";") if filt else []
ov_input_base = n + len(tracks)
_append_overlay_filters(
@@ -1506,8 +1558,8 @@ if web is not None and PromptServer is not None:
proj = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
clips = body.get("clips") or []
if not clips:
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
if not clips and not _has_graphics_export_content(proj):
return web.json_response({"detail": "Nothing to render — add overlays, audio, or generated clips."}, status=400)
job_id = _uuid.uuid4().hex
_render_jobs[job_id] = {"state": "queued"}
asyncio.create_task(_run_render_job(job_id, proj, clips))
+26 -1
View File
@@ -5,8 +5,10 @@ from pathlib import Path
from movie_editor.backend import bridge
from movie_editor.backend.timeline import Project, Scene
from movie_editor.server import (
_build_render_filter,
_clip_bytes_for_media,
_clip_needs_trim,
_has_graphics_export_content,
_parse_has_scenes,
_parse_prompt_variants,
_playback_render_from_query,
@@ -16,6 +18,7 @@ from movie_editor.server import (
_run_studio_inputs,
_scene_playback_clip_spec,
_shortcut_seed,
_timeline_duration_sec,
)
CONTINUE = "__funpack_continue__"
@@ -331,4 +334,26 @@ def test_project_models_keeps_empty_slots(monkeypatch):
p = _project(scenes=[{"id": "s1", "text": "a"}])
p.models = {"slots": [], "full_control": False}
assert _project_models(p)["slots"] == []
assert _project_models(p)["slots"] != nodes.load_models()["slots"]
assert _project_models(p)["slots"] != nodes.load_models()["slots"]
def test_timeline_duration_sec_from_overlays_and_audio():
p = _project(scenes=[])
p.scenes = []
p.overlay_tracks = [{"start_sec": 2, "duration_sec": 5}]
p.audio_tracks = [{"start_sec": 10, "source_dur": 3}]
assert _timeline_duration_sec(p) == 13
assert _has_graphics_export_content(p) is True
def test_build_render_filter_blank_canvas():
filt, has_audio = _build_render_filter(
[],
tracks=[{"start_sec": 0, "volume": 1.0}],
keep_original=False,
base_input=1,
blank_canvas={"w": 768, "h": 512, "fps": 25, "dur": 8.0},
)
assert "[0:v]format=yuv420p,setsar=1[vbase]" in filt
assert "[aout]" in filt
assert has_audio is True