"""The final cut: trimmed clips joined with their seams, mixed with extra audio and overlays, into one file. `build_filter` is pure: clips in, an ffmpeg `filter_complex` out, so what a render does can be read and tested without running ffmpeg. `render` and `concat` run it. Video: every clip is normalised to one canvas (xfade and concat need identical size, fps and aspect), given its effects, then folded left to right with `xfade` (overlap) or `concat` (hard cut), with black gaps where the editor left one. Audio: each clip's own sound (a clip with none contributes silence, so one silent clip cannot sink the render) follows the same fold, then extra tracks are delayed to their start and mixed in. """ from __future__ import annotations import itertools import os import time from ..._core import media from . import effects, files, overlays #: Seam name (the editor's `video_transition`) -> ffmpeg xfade transition. XFADE = {"crossfade": "fade", "fadeblack": "fadeblack", "wipeleft": "wipeleft", "wiperight": "wiperight", "dissolve": "dissolve"} STEREO = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo" RATE = 48000 class RenderError(Exception): """A render that cannot be made, in words for the person who pressed Render.""" def _f(value, default=0.0) -> float: try: v = float(value) except (TypeError, ValueError): return default return default if v != v or v in (float("inf"), float("-inf")) else v def build_filter(clips: list[dict], tracks: list[dict] | None = None, *, keep_original: bool = True, base_input: int = 0, blank: dict | None = None) -> tuple[str, bool]: """-> (filter_complex, has_audio). Input i is clip i; extra audio track j is input `base_input + j`. With `blank`, input 0 is a plain colour source and there are no clips. Raises RenderError when a clip cannot be made as asked (reverse too long).""" tracks = tracks or [] parts: list[str] = [] audio: str | None = None total = 0.01 if blank: total = max(0.01, _f(blank.get("dur"), 0.01)) parts.append("[0:v]format=yuv420p,setsar=1[vbase]") else: if not clips: raise RenderError("There is nothing to render: no clips, no overlays and no audio.") cw = int(_f(clips[0].get("w")) or 768) ch = int(_f(clips[0].get("h")) or 768) cw, ch = cw - cw % 2, ch - ch % 2 # yuv420p cannot be encoded at an odd size fps = _f(clips[0].get("fps")) or 25.0 for i, c in enumerate(clips): dur = _f(c.get("dur")) try: vf = effects.clip_filters(c.get("fx") or {}, cw, ch, fps, dur) except ValueError as exc: raise RenderError(f"Clip {i + 1}: {exc}") from exc # One timebase for every clip: xfade refuses to join a concat result to a 1/fps stream. # Exactly `dur` of picture (hold the last frame if the file runs a little short, cut if # whole-frame rounding ran long), so seam offsets and the sound stay on one clock. fit = f",tpad=stop_mode=clone:stop_duration=1000,trim=duration={dur:.3f},setpts=PTS-STARTPTS" if dur > 0 else "" parts.append(f"[{i}:v:0]{','.join(vf)}{fit},settb=AVTB[v{i}]") if keep_original: parts.append(_clip_audio(i, c, dur)) acc_v, acc_a = "[v0]", "[a0]" acc_dur = _f(clips[0].get("dur")) for i in range(1, len(clips)): prev, dur_i = clips[i - 1], _f(clips[i].get("dur")) gap = _f(prev.get("gap_after")) if gap > 0.001: parts.append(f"color=c=black:s={cw}x{ch}:r={fps:g}:d={gap:.3f},format=yuv420p,setsar=1,settb=AVTB[vgap{i}]") parts.append(f"{acc_v}[vgap{i}]concat=n=2:v=1:a=0[vcgap{i}]") acc_v, acc_dur = f"[vcgap{i}]", acc_dur + gap if keep_original: parts.append(f"anullsrc=r={RATE}:cl=stereo:d={gap:.3f},{STEREO}[agap{i}]") parts.append(f"{acc_a}[agap{i}]concat=n=2:v=0:a=1[acgap{i}]") acc_a = f"[acgap{i}]" name = str(prev.get("transition") or "").strip() td = _f(prev.get("tdur")) # A seam longer than either side cannot overlap: it is a hard cut, said nowhere # because xfade itself refuses it. if name in XFADE and td > 0 and acc_dur > td and dur_i > td: parts.append(f"{acc_v}[v{i}]xfade=transition={XFADE[name]}:duration={td:.3f}:" f"offset={max(0.0, acc_dur - td):.3f}[vx{i}]") acc_v, acc_dur = f"[vx{i}]", acc_dur + dur_i - td if keep_original: parts.append(f"{acc_a}[a{i}]acrossfade=d={td:.3f}[ax{i}]") acc_a = f"[ax{i}]" else: parts.append(f"{acc_v}[v{i}]concat=n=2:v=1:a=0[vc{i}]") acc_v, acc_dur = f"[vc{i}]", acc_dur + dur_i if keep_original: parts.append(f"{acc_a}[a{i}]concat=n=2:v=0:a=1[ac{i}]") acc_a = f"[ac{i}]" parts.append(f"{acc_v}null[vbase]") total = max(0.01, acc_dur) audio = acc_a if keep_original else None mix = [audio] if audio else [] for j, t in enumerate(tracks): ms = int(max(0.0, _f(t.get("start_sec"))) * 1000) chain = (f"[{base_input + j}:a:0]{STEREO},volume={max(0.0, _f(t.get('volume'), 1.0)):.3f}" + (f",adelay={ms}|{ms}" if ms > 0 else "")) parts.append(f"{chain}[at{j}]") mix.append(f"[at{j}]") if not mix: return ";".join(parts), False if len(mix) == 1: parts.append(f"{mix[0]}atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]") else: parts.append("".join(mix) + f"amix=inputs={len(mix)}:normalize=0:duration=longest[amx]") parts.append(f"[amx]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]") return ";".join(parts), True def _clip_audio(i: int, clip: dict, dur: float) -> str: """The filter that makes clip i's sound: its own (volume, reversed with the picture), or silence.""" if not clip.get("has_audio", True): return f"anullsrc=r={RATE}:cl=stereo:d={max(dur, 0.04):.3f},{STEREO}[a{i}]" fx = clip.get("fx") or {} af = STEREO if fx.get("reverse"): af += ",areverse" # a reversed picture must not play its soundtrack forwards vol = _f(clip.get("volume"), 1.0) if abs(vol - 1.0) > 1e-3: af += f",volume={max(0.0, vol):.3f}" if dur > 0: # exactly the picture's length: a short sound must not pull later clips early af += f",apad,atrim=0:{dur:.3f},asetpts=PTS-STARTPTS" return f"[{i}:a:0]{af}[a{i}]" def _live_tracks(project, clips_by_scene: dict) -> list[dict]: """The lanes that can sound: a clip's own lane goes quiet with its clip (excluded, or not in this cut).""" return [t for t in project.audio_tracks if t.get("kind") != "separated" or not t.get("scene_id") or t["scene_id"] in clips_by_scene] def _clip_starts(clips: list[dict]) -> dict: """Where each clip begins in THIS render: the same clock build_filter keeps (gaps add, a seam that overlaps takes its length back).""" out, acc = {}, 0.0 for i, c in enumerate(clips): dur = _f(c.get("dur")) if i == 0: at, acc = 0.0, dur else: prev = clips[i - 1] gap = _f(prev.get("gap_after")) if gap > 0.001: acc += gap td = _f(prev.get("tdur")) if str(prev.get("transition") or "").strip() in XFADE and td > 0 and acc > td and dur > td: at, acc = max(0.0, acc - td), acc + dur - td else: at, acc = acc, acc + dur if c.get("scene_id"): out[c["scene_id"]] = at return out def _audio_tracks(project, clips_by_scene: dict, resolve, starts: dict | None = None) -> list[dict]: """The project's extra audio lanes as {path, start_sec, volume[, source_in, source_dur]}. A "separated" lane is a clip's own sound pulled onto a lane: it plays the audio pinned at separation time (so a later regeneration of the picture keeps the old sound), else the clip's file. A lane whose file cannot be found is left out.""" out = [] for t in _live_tracks(project, clips_by_scene): separated = t.get("kind") == "separated" or (t.get("scene_id") and not t.get("media_ref") and t.get("kind") != "overlay") start, vol = _f(t.get("start_sec")), _f(t.get("volume"), 1.0) if separated and starts and t.get("scene_id") in starts: # a separated lane rides its clip: where the clip is in this render, plus how far it was slid start = max(0.0, starts[t["scene_id"]] + _f(t.get("offset_sec"))) if separated: clip = clips_by_scene.get(t.get("scene_id")) or {} path = None pinned = t.get("pinned_media") if isinstance(t.get("pinned_media"), dict) else None if pinned and pinned.get("filename"): path = files.comfy_path(pinned["filename"], pinned.get("subfolder") or "", pinned.get("type") or "output") ref = t.get("pinned_bin_ref") if (not path or not os.path.isfile(path)) and isinstance(ref, str) and media.is_id(ref): found = media.path_for(ref) path = str(found) if found else None if (not path or not os.path.isfile(path)) and clip: path = resolve(clip) if not path or not os.path.isfile(path) or not files.has_audio(path): continue src_in = t.get("pinned_in_sec", t.get("source_in_sec")) src_dur = t.get("pinned_dur", t.get("source_dur")) out.append({"path": path, "start_sec": start, "volume": vol, "source_in": _f(src_in if src_in is not None else clip.get("in")), "source_dur": _f(src_dur if src_dur is not None else clip.get("dur"))}) continue ref = t.get("media_ref") found = media.path_for(ref) if isinstance(ref, str) and media.is_id(ref) else None if found is None or not files.has_audio(str(found)): continue entry = {"path": str(found), "start_sec": start, "volume": vol} if t.get("source_in_sec") is not None and t.get("source_dur") is not None: entry.update(source_in=_f(t["source_in_sec"]), source_dur=_f(t["source_dur"])) out.append(entry) return out def _file_seconds(t: dict): """The length of a lane's own file, for a lane whose editor never recorded one.""" ref = t.get("media_ref") found = media.path_for(ref) if isinstance(ref, str) and media.is_id(ref) else None return files.duration(str(found)) if found else None def _track_end(t: dict, probed=None) -> float: start = _f(t.get("start_sec")) for key in ("source_dur", "pinned_dur"): if t.get(key) is not None: return start + _f(t[key]) return start + (probed if probed else 1.0) def graphics_duration(project) -> float: """How long the audio lanes and overlays run: the length of a render with no clips.""" end = 0.0 for ov in project.overlay_tracks: end = max(end, _f(ov.get("start_sec")) + _f(ov.get("duration_sec"))) for t in _live_tracks(project, {}): # only reached with no clips: a clip's own lane has nothing to sound over end = max(end, _track_end(t, _file_seconds(t))) return max(end, 0.01) def has_graphics(project) -> bool: return bool(_live_tracks(project, {})) or any(_f(ov.get("duration_sec")) > 0 for ov in project.overlay_tracks) _stamp_n = itertools.count() def _stamp() -> str: """Unique per job: two jobs in one second must not write the same file.""" return f"{time.time_ns()}_{next(_stamp_n)}" def _clip_spec_ok(c) -> bool: return isinstance(c, dict) and (c.get("bin_media_ref") or c.get("filename")) def export_canvas(project, clips: list[dict], paths: list[str], fallback: tuple[int, int]) -> tuple[int, int]: """The size the final render is made at: the real resolution of the clip the person chose (default: the first), never the project's Width x Height unless asked for -- a project setting must not rescale the material.""" choice = getattr(project, "export_size_from", "") or "" if choice == "project": return fallback idx = next((i for i, c in enumerate(clips) if choice and c.get("scene_id") == choice), 0) got = files.dimensions(paths[idx]) if got: return got return int(_f(clips[idx].get("w")) or fallback[0]), int(_f(clips[idx].get("h")) or fallback[1]) def render(project, clips: list[dict]) -> dict: """Run the final render: -> {"media": {...temp file...}, "clips": n}. Blocking: call it off the event loop.""" clips = [c for c in clips if _clip_spec_ok(c)] if isinstance(clips, list) else [] blank = not clips if blank and not has_graphics(project): raise RenderError("Nothing to render: add overlays, audio, or generated clips.") cw, ch, fps = int(project.width or 768), int(project.height or 512), float(project.frame_rate or 25) paths: list[str] = [] try: paths = [files.clip_path(c) for c in clips] except files.ClipError as exc: raise RenderError(str(exc)) from exc if clips: cw, ch = export_canvas(project, clips, paths, (cw, ch)) for c in clips: c["w"], c["h"] = cw, ch # build_filter reads the canvas off the clips fps = _f(clips[0].get("fps")) or fps for i, (c, p) in enumerate(zip(clips, paths)): c["has_audio"] = files.has_audio(p) have, want = files.duration(p), _f(c.get("dur")) have = None if have is None else have - _f(c.get("in")) if have is not None and want > 0 and have < want - max(0.5, 0.1 * want): raise RenderError(f"Clip {i + 1}'s render has {max(have, 0):.1f}s of picture where the timeline " f"expects {want:.1f}s: generate it again, then render.") blank_canvas = None else: cw, ch = cw - cw % 2, ch - ch % 2 blank_canvas = {"w": cw, "h": ch, "fps": fps, "dur": graphics_duration(project)} try: ffmpeg = files.ffmpeg() tempdir = os.path.dirname(files.temp_file("x")) except files.ClipError as exc: raise RenderError(str(exc)) from exc by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")} tracks = _audio_tracks(project, by_scene, files.clip_path, _clip_starts(clips)) if clips or project.audio_tracks else [] keep = bool(project.keep_original_audio) and not blank cmd = [ffmpeg, "-y"] if blank: cmd += ["-f", "lavfi", "-i", f"color=c=black:s={cw}x{ch}:r={fps:g}:d={blank_canvas['dur']:.3f}"] else: for c, path in zip(clips, paths): if c.get("in") is not None: cmd += ["-ss", f"{_f(c['in']):.3f}"] if _f(c.get("dur")) > 0: cmd += ["-t", f"{_f(c['dur']):.3f}"] cmd += ["-i", path] base = 1 if blank else len(clips) for t in tracks: if t.get("source_in") is not None: cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}"] cmd += ["-i", t["path"]] def image_path(ref): found = media.path_for(ref) if isinstance(ref, str) and media.is_id(ref) else None return str(found) if found else None drawn, pictures = overlays.prepare(project.overlay_tracks, project.overlay_lanes, tempdir=tempdir, image_path=image_path) warnings = [] if len(drawn) < len(project.overlay_tracks): warnings.append(f"{len(project.overlay_tracks) - len(drawn)} overlay(s) left out: their picture is gone from the media library.") live = len(_live_tracks(project, by_scene)) if len(tracks) < live: warnings.append(f"{live - len(tracks)} audio lane(s) left out: their file is gone or has no sound.") for path in pictures: cmd += ["-i", path] graph, has_audio = build_filter(clips, tracks, keep_original=keep, base_input=base, blank=blank_canvas) lines = graph.split(";") if graph else [] labels = [f"[{base + len(tracks) + i}:v:0]" for i in range(len(pictures))] ov_lines, final = overlays.composite("[vbase]", drawn, canvas_w=cw, image_labels=labels) lines += ov_lines lines.append(f"{final}null[vout]") cmd += ["-filter_complex", ";".join(lines), "-map", "[vout]"] if has_audio: cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k"] name = f"funpack_final_{_stamp()}.mp4" out = os.path.join(tempdir, name) cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out] try: files.run(cmd) except files.ClipError as exc: raise RenderError(f"ffmpeg could not make the render: {exc}") from exc return {"media": {"filename": name, "subfolder": "", "type": "temp", "kind": "videos"}, "clips": len(clips) or 1, "warnings": warnings} def concat(clips: list[dict]) -> dict: """Hard-cut the trimmed clips together with no effects, seams or extra audio (the selection export): -> {"media": {...}, "clips": n}. Blocking.""" clips = [c for c in clips if _clip_spec_ok(c)] if isinstance(clips, list) else [] if not clips: raise RenderError("There is nothing to export.") stamp = _stamp() parts = [] fps = None try: for i, c in enumerate(clips): out = files.temp_file(f"funpack_seg_{stamp}_{i}.mp4") src = files.clip_path(c) if fps is None: fps = files.frame_rate(src) # the first clip's rate: the joiner keeps one files.trim(src, out, c.get("in"), c.get("dur"), fps=fps) parts.append(out) got, want = files.duration(out), _f(c.get("dur")) if got is not None and (got <= 0 or (want > 0 and got < want - max(0.5, 0.1 * want))): raise files.ClipError( f"Clip {i + 1} has {got:.1f}s of picture where the timeline expects {want:.1f}s: " f"its render is shorter than the window cut from it (or the window starts past " f"its end). Generate it again, then export.") sound = [files.has_audio(p) for p in parts] if any(sound) and not all(sound) and len(parts) > 1: # the joiner keeps only the first part's streams for k, p in enumerate(parts): if not sound[k]: fixed = files.temp_file(f"funpack_seg_{stamp}_{k}_s.mp4") files.add_silence(p, fixed) os.remove(p) parts[k] = fixed name = f"funpack_export_{stamp}.mp4" out = files.temp_file(name) if len(parts) == 1: os.replace(parts[0], out) else: listing = files.temp_file(f"funpack_concat_{stamp}.txt") with open(listing, "w", encoding="utf-8") as fh: for p in parts: fh.write("file '" + p.replace("'", "'\\''") + "'\n") try: files.run([files.ffmpeg(), "-y", "-f", "concat", "-safe", "0", "-i", listing, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", out]) finally: for p in parts + [listing]: try: os.remove(p) except OSError: pass except files.ClipError as exc: for p in parts: try: os.remove(p) except OSError: pass raise RenderError(str(exc)) from exc return {"media": {"filename": name, "subfolder": "", "type": "temp", "kind": "videos"}, "clips": len(clips)}