Files
olivv-cs-ComfyUI-FunPack/movie_editor/server.py
T
digital-garbageandClaude Opus 5 b6fa5b177d feat: About panel reports the host's hardware and software
About showed only version/commit/branch. Add the facts you actually want to
check on a fresh rental: chip, memory, GPU (name, VRAM, compute capability),
free disk, OS, ComfyUI, Python, torch (+CUDA), and which fast-attention
backend is importable — the last one answers "is sage actually installed
here" without reading the launch args.

Everything describes the machine ComfyUI runs on, not the browser. On a
rental those differ, and the host is the one worth looking at, so the panel
says so in a line under the facts.

Every probe is individually guarded and degrades to a dash: About is the one
screen that should still render when the install is broken, so no missing
psutil, torch, or shell binary may raise. The frontend optional-chains the
call, so a frontend sharing settings_window.js without the endpoint keeps the
old version-only panel instead of erroring.

Facts move to a two-column grid; values now range from "3.14" to a full GPU
name, and only a shared column keeps the keys aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:15:30 +03:00

2569 lines
110 KiB
Python

"""Movie Editor routes, served by ComfyUI's own aiohttp server.
UI: GET /funpack/movie/ (and static assets under /funpack/movie/<file>)
API: /funpack/movie/api/*
Registered on PromptServer like the other /funpack/* routes (see templates.py,
batch_training.py). Pure logic lives in backend/ (timeline, projects, workflow);
bridge.py talks to ComfyUI (parse/library in-process, queue/history/view over loopback).
"""
from __future__ import annotations
import mimetypes
from typing import Optional
try:
from aiohttp import web
from server import PromptServer
except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_deps, pipeline_wiring, projects, sysinfo, workflow_import
from .backend.nle_effects import zoompan_z_expr
from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_export
from .backend.timeline import (
Project,
build_combined_prompt,
build_generation_scene_segments,
collapse_generative_units,
continuity_media_refs,
effective_anchor,
effective_negative_prompt,
effective_postfix,
gen_unit_id,
group_generative_units,
is_video_clip,
)
UI_PREFIX = "/funpack/movie"
def _restart_comfy() -> None:
"""Relaunch the ComfyUI process in place. Mirrors ComfyUI-Manager's reboot so it
works the same whether launched directly, as a module, or via comfy-cli."""
import os
import sys
try:
sys.stdout.close_log() # type: ignore[attr-defined] # Manager's tee logger, if present
except Exception:
pass
# comfy-cli watches for a .reboot file and relaunches us itself.
if "__COMFY_CLI_SESSION__" in os.environ:
try:
open(os.environ["__COMFY_CLI_SESSION__"] + ".reboot", "w").close()
except Exception:
pass
print("\n[FunPack] Restarting ComfyUI...\n", flush=True)
os._exit(0)
sys_argv = sys.argv.copy()
if "--windows-standalone-build" in sys_argv:
sys_argv.remove("--windows-standalone-build")
if sys_argv and sys_argv[0].endswith("__main__.py"): # python -m comfy
module_name = os.path.basename(os.path.dirname(sys_argv[0]))
cmds = [sys.executable, "-m", module_name] + sys_argv[1:]
elif sys.platform.startswith("win32"):
cmds = ['"' + sys.executable + '"', '"' + sys_argv[0] + '"'] + sys_argv[1:]
else:
cmds = [sys.executable] + sys_argv
print(f"\n[FunPack] Restarting ComfyUI... {cmds}\n", flush=True)
os.execv(sys.executable, cmds)
# ── helpers ──────────────────────────────────────────────────────────────────
def _media_from_history(hist_entry: dict) -> list[dict]:
out: list[dict] = []
for node_id, node_out in (hist_entry.get("outputs") or {}).items():
for key in ("gifs", "videos", "images"):
for item in node_out.get(key, []) or []:
fn = item.get("filename")
if not fn:
continue
out.append({
"node_id": node_id, "kind": key, "filename": fn,
"subfolder": item.get("subfolder", ""), "type": item.get("type", "output"),
"format": item.get("format"),
})
return out
def _scene_layout_from_history(hist_entry: dict, fps: float) -> Optional[list]:
from .backend.chain_layout import layout_from_history_entry
return layout_from_history_entry(hist_entry, fps)
def _resolve_comfy_media_path(filename: str, subfolder: str = "", type_: str = "output") -> Optional[str]:
"""Absolute path to a ComfyUI output/temp media file (None if dirs unavailable).
Refuses paths that escape the output/temp base directory ('..' traversal)."""
import os
if not filename:
return None
try:
import folder_paths
base = folder_paths.get_output_directory() if type_ == "output" else folder_paths.get_temp_directory()
except Exception:
return None
base = os.path.abspath(base)
full = os.path.abspath(os.path.join(base, subfolder or "", os.path.basename(filename)))
if os.path.commonpath((full, base)) != base:
return None
return full
_TEMP_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
_TEMP_VIDEO_EXT = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".gif"}
_TEMP_AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus"}
def _temp_kind(ext: str) -> Optional[str]:
ext = ext.lower()
if ext in _TEMP_VIDEO_EXT:
return "video"
if ext in _TEMP_IMAGE_EXT:
return "image"
if ext in _TEMP_AUDIO_EXT:
return "audio"
return None
def _list_temp_media() -> list[dict]:
"""Media files (image/video/audio) sitting in ComfyUI's temp directory — scene
previews and other transient outputs that are wiped on restart. Newest first."""
import os
try:
import folder_paths
base = folder_paths.get_temp_directory()
except Exception:
return []
out: list[dict] = []
for root, _dirs, files in os.walk(base):
for name in files:
kind = _temp_kind(os.path.splitext(name)[1])
if not kind:
continue
full = os.path.join(root, name)
try:
st = os.stat(full)
except OSError:
continue
subfolder = os.path.relpath(root, base)
out.append({
"filename": name,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, "/"),
"kind": kind,
"size": st.st_size,
"mtime": st.st_mtime,
})
out.sort(key=lambda i: i.get("mtime", 0), reverse=True)
return out
def _resolve_clip_src_path(clip: dict) -> str:
"""Absolute path to a render or media-bin file referenced by an export/import clip spec."""
import os
ref = clip.get("bin_media_ref")
if ref:
mp = media.path_for(ref)
if mp is None:
raise FileNotFoundError("Media bin file not found — it may have been deleted.")
return str(mp)
fp = _resolve_comfy_media_path(
clip.get("filename", ""),
clip.get("subfolder", ""),
clip.get("type", "output"),
)
if not fp or not os.path.isfile(fp):
raise FileNotFoundError("Source clip file not found on disk — regenerate then try again.")
return fp
def _clip_needs_trim(clip: dict) -> bool:
inn = float(clip.get("in") or 0)
dur = clip.get("dur")
return inn > 0.001 or dur is not None
def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur, fast: bool = False) -> None:
import shutil
import subprocess
ff = shutil.which("ffmpeg")
if not ff:
raise RuntimeError("ffmpeg not found on PATH — install it to export or import clips.")
cmd = [ff, "-y"]
if inn is not None and float(inn) > 0:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", src_path, "-c:v", "libx264", "-pix_fmt", "yuv420p"]
if fast:
# Preview segments: latency matters far more than a few % of bitrate — the player
# is waiting on this encode to unfreeze a scrub across a chain boundary.
cmd += ["-preset", "veryfast"]
cmd += [
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
_ISOBMFF_EXTS = (".mp4", ".m4v", ".mov") # containers where moov/mdat atom order applies
def _is_isobmff(path: str) -> bool:
import os
return os.path.splitext(path)[1].lower() in _ISOBMFF_EXTS
def _moov_position(path: str) -> str:
"""Where the MP4's moov atom sits: "front" (before mdat — browsers can decode from a
progressive/Range fetch), "end" (after mdat — ComfyUI's own VHS_VideoCombine output; a
complete file, but deep-seeking it raw is undecodable in the browser), or "none" (no moov
anywhere — the file is still being written, or a save was aborted; ffmpeg can't read it
either, so it must never be served as video). Distinguishing "end" from "none" is what
lets /result tell "remux me" apart from "come back later"."""
import os
try:
with open(path, "rb") as f:
size = os.fstat(f.fileno()).st_size
offset = 0
seen_mdat = False
while offset + 8 <= size:
f.seek(offset)
header = f.read(8)
if len(header) < 8:
break
box_size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if box_type == b"moov":
return "end" if seen_mdat else "front"
if box_type == b"mdat":
seen_mdat = True
if box_size == 1:
ext = f.read(8)
if len(ext) < 8:
break
box_size = int.from_bytes(ext, "big")
elif box_size == 0:
# Box runs to EOF without a size (mdat still being streamed to disk —
# ffmpeg backfills the size on finalize): nothing after it, no moov.
return "none"
if box_size < 8:
break
offset += box_size
except OSError:
pass
return "none"
def _file_sig(path: str) -> tuple:
"""Change detector for cache keys: same (mtime_ns, size) ⇒ same bytes for our purposes."""
import os
st = os.stat(path)
return (st.st_mtime_ns, st.st_size)
def _remux_faststart(src_path: str, out_path: str) -> None:
"""Stream-copy remux (no re-encode) that just relocates moov to the front."""
import shutil
import subprocess
ff = shutil.which("ffmpeg")
if not ff:
raise RuntimeError("ffmpeg not found on PATH.")
cmd = [ff, "-y", "-i", src_path, "-c", "copy", "-movflags", "+faststart", out_path]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ffmpeg failed")[-1000:])
def _playback_render_from_query(query) -> Optional[dict]:
"""Optional live render override from preview-segment query params."""
filename = query.get("filename") if query else None
if not filename:
return None
return {
"inSec": float(query.get("render_in") or 0),
"media": {
"filename": filename,
"subfolder": query.get("subfolder") or "",
"type": query.get("type") or "output",
},
}
def _ghost_playback_clip_spec(query) -> Optional[dict]:
"""Preview-segment clip spec for a GHOST (removed scene whose render is still shown):
the scene no longer exists on the project, so the trim window comes entirely from the
query — in-point (render_in, absolute in the source file) and duration (dur)."""
filename = query.get("filename") if query else None
dur = query.get("dur") if query else None
if not filename or dur is None:
return None
try:
inn = float(query.get("render_in") or 0)
dur = float(dur)
except (TypeError, ValueError):
return None
return {
"filename": filename,
"subfolder": query.get("subfolder") or "",
"type": query.get("type") or "output",
"in": inn,
"dur": dur,
}
def _scene_playback_clip_spec(
project: Project,
scene_id: str,
*,
render_override: Optional[dict] = None,
) -> dict:
"""Export-matching trim spec for one scene's preview segment."""
scene = next((s for s in project.scenes if s.id == scene_id), None)
if not scene or scene.excluded:
raise KeyError(f"scene {scene_id}")
if render_override is not None:
render = render_override
else:
render = (project.scene_renders or {}).get(scene_id) or {}
media = render.get("media") or {}
if not media.get("filename"):
raise KeyError(f"no render for {scene_id}")
in_sec = float(scene.source_in or 0) + float(render.get("inSec") or 0)
if scene.source_dur is not None:
dur = float(scene.source_dur)
else:
fps = scene.eff_fps(project) or 25
dur = scene.eff_frames(project) / float(fps)
return {
"filename": media.get("filename", ""),
"subfolder": media.get("subfolder") or "",
"type": media.get("type") or "output",
"in": in_sec,
"dur": dur,
}
_preview_segment_cache: dict[str, str] = {}
_preview_segment_locks: dict = {} # cache key -> asyncio.Lock (dedupe concurrent encodes)
_faststart_cache: dict[str, tuple] = {} # source path -> (source sig, remuxed faststart path)
_faststart_failed: dict[str, tuple] = {} # source path -> (sig, monotonic ts of last failed remux attempt)
_faststart_locks: dict = {} # source path -> asyncio.Lock (dedupe concurrent remuxes)
_FASTSTART_RETRY_SEC = 30.0 # min gap between remux re-attempts on a file that failed to remux
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
return (
f"{pid}:{scene_id}:{clip.get('filename')}:{clip.get('subfolder') or ''}:"
f"{clip.get('type') or 'output'}:{clip.get('in')}:{clip.get('dur')}"
)
def _clip_bytes_for_media(clip: dict) -> bytes:
"""Read (and optionally trim) a clip spec into bytes for the media bin."""
import os
import time
from pathlib import Path
src = _resolve_clip_src_path(clip)
if not _clip_needs_trim(clip):
return Path(src).read_bytes()
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
raise RuntimeError(f"Temp directory unavailable: {e}") from e
out_path = os.path.join(tempdir, f"funpack_import_{int(time.time())}.mp4")
try:
_ffmpeg_trim_clip(src, out_path, clip.get("in"), clip.get("dur"))
return Path(out_path).read_bytes()
finally:
Path(out_path).unlink(missing_ok=True)
def _copy_scene_media(ref: str, indir: str) -> Optional[str]:
import os
import shutil
path = media.path_for(ref)
if not path:
return None
fn = f"funpack_movie_{ref}{path.suffix}"
try:
shutil.copy(str(path), os.path.join(indir, fn))
except OSError:
return None
return fn
def _prepare_references(proj: Project) -> list[dict]:
"""Marked ("R") media, in mark order, copied into ComfyUI's input folder.
Returns [{id, kind, name, filename, index}] for the builder, which injects a loader per
reference wherever one is wired. An id whose file has gone is dropped here and reported
by the builder against the socket that wanted it, rather than failing the whole run.
"""
import os
try:
import folder_paths
indir = folder_paths.get_input_directory()
except Exception:
return []
out: list[dict] = []
for i, mid in enumerate(proj.references or []):
item = media.get(mid)
if not item:
continue
fn = _copy_scene_media(mid, indir)
if not fn:
continue
out.append({"id": mid, "kind": item.get("kind") or "image",
"name": item.get("name") or mid, "filename": fn, "index": i + 1})
return out
def _prepare_media(proj: Project, extra_refs: Optional[list] = None, *, chain_available: bool = True) -> Optional[dict]:
"""Copy scene image assets into ComfyUI's input folder.
Returns {"primary": {filename, target}, "by_scene": {scene_id: {filename, media_ref, target}}}
for the builder (run anchor) and sampler guide lookups. `extra_refs` copies additional
media-bin ids (e.g. prior-scene guide images for solo mixed runs).
"""
import os
try:
import folder_paths
indir = folder_paths.get_input_directory()
except Exception:
return None
primary = None
by_scene: dict = {}
seen_refs: set = set()
def _add_ref(ref: str, tgt=None, sid: Optional[str] = None, *, set_primary: bool = True) -> None:
nonlocal primary
if not ref or ref in seen_refs:
return
fn = _copy_scene_media(ref, indir)
if not fn:
return
seen_refs.add(ref)
entry = {"filename": fn, "media_ref": ref, "target": tgt}
if sid:
by_scene[sid] = entry
else:
by_scene[f"_ref_{ref}"] = entry
if set_primary and primary is None:
primary = {"filename": fn, "target": tgt}
for sc in proj.scenes:
if sc.excluded:
continue
src = sc.source
ref = getattr(src, "media_ref", None)
tgt = getattr(src, "target", None)
stype = pipeline_caps.effective_source_type(sc, chain_available)
# anchor_guide feeds its image into the pipeline like any anchor (e.g. an Image
# Transform that derives width/height); the i2v node is bypassed elsewhere so the
# latent stays empty. So it copies as a normal anchor here.
if stype not in ("image", "generated_frame", "mixed", "anchor_guide") or not ref:
continue
_add_ref(ref, tgt, sc.id, set_primary=True)
for ref in (extra_refs or []):
# Guide-only assets (identity pin, prior-scene borrow) must not become the run anchor.
_add_ref(str(ref), set_primary=False)
if not by_scene:
return None
return {"primary": primary, "by_scene": by_scene}
def _project_or_404(pid: str) -> Project:
try:
p = projects.get(pid)
except Exception as e: # noqa: BLE001 — corrupt JSON or schema-incompatible project file
# list_projects() skips unreadable files, so such a project still appears in the list
# but used to 500 on open with no detail. Surface a clear, recoverable error instead.
msg = " ".join(str(e).split())[:200]
raise web.HTTPUnprocessableEntity(
reason=f"Project '{pid}' could not be loaded (corrupt or incompatible file): {msg}")
if p is None:
raise web.HTTPNotFound(reason=f"Project {pid} not found")
return p
def _parse_has_scenes(parsed: Optional[dict]) -> bool:
return isinstance(parsed, dict) and bool(parsed.get("scenes"))
def _parse_prompt_variants(prompt: str, seed: int) -> tuple[dict, dict]:
"""Run Studio/raw/verbatim splits independently; never fail all because one path threw."""
parsed = parsed_raw = parsed_verbatim = None
errors: dict[str, str] = {}
for key, fn in (
("parsed", lambda: bridge.parse_timeline(prompt, seed=seed)),
("parsed_raw", lambda: bridge.parse_timeline_raw(prompt)),
("parsed_verbatim", lambda: bridge.parse_timeline_verbatim(prompt)),
):
try:
value = fn()
except Exception as exc: # noqa: BLE001
errors[key] = bridge.format_funpack_error(exc)
continue
if key == "parsed":
parsed = value
elif key == "parsed_raw":
parsed_raw = value
else:
parsed_verbatim = value
return (
{
"parsed": parsed,
"parsed_raw": parsed_raw,
"parsed_verbatim": parsed_verbatim,
"combined_prompt": prompt,
**({"parse_errors": errors} if errors else {}),
},
errors,
)
def _project_models(p: Optional[Project]) -> dict:
"""The project's own pipeline config, or the global default when it has none yet."""
m = getattr(p, "models", None)
if not isinstance(m, dict):
return nodes.load_models()
# Treat slots: [] as intentional (user removed all loaders) — do not fall back to global.
if "slots" in m or m.get("links") or m.get("core_overrides") or m.get("disable_core") or m.get("full_control"):
return _inherit_model_family(m)
return nodes.load_models()
def _inherit_model_family(m: dict) -> dict:
"""Fill in a missing `model_family` from the global default — but only for a project
that has no pipeline of its own to contradict it.
Every project saved before the creation-time seeding was fixed carries no family, so the
builder silently treated it as LTXAV. Backfilling is safe exactly when the project has no
loaders and no links: there is then no wiring built under the old assumption that
changing the answer could break. A project that HAS its own slots keeps its silence —
those were wired as LTXAV and must go on being read that way until the user says
otherwise in the pipeline dialog, which now shows them the truth.
"""
if m.get("model_family") or m.get("slots") or m.get("links"):
return m
family = nodes.load_models().get("model_family")
if not family:
return m
return {**m, "model_family": family}
def _solo(p: Project, only_scene: Optional[str]) -> Project:
if not only_scene:
return collapse_generative_units(p)
scene = next((s for s in p.scenes if s.id == only_scene), None)
if scene is None:
raise web.HTTPNotFound(reason=f"Scene {only_scene} not found")
uid = gen_unit_id(scene)
ids = [s.id for s in p.scenes if gen_unit_id(s) == uid and not s.excluded]
return collapse_generative_units(_segment(p, ids))
def _run_studio_inputs(
target: Project,
active_scenes: list,
*,
prompt_changed: bool = False,
models: Optional[dict] = None,
) -> dict:
"""Studio widget overrides for this run, incl. the RLHF rating of the run's last
render (Studio refines from it).
Single-scene runs: one scene rating maps to the Studio rating widget (same as before).
Multi-scene chain runs: per-scene ratings are passed in studio_settings.refiner
(movie_editor_scene_ratings); the top-level rating is continue/fresh so global learning
does not collapse multiple scene ratings into one.
When no scene was rated: send MOVIE_EDITOR_CONTINUE_RATING so Studio still applies
session memory / active repairs but does not treat the run as '-Just forget it-'.
When the generation prompt changed since the last queue, send
MOVIE_EDITOR_FRESH_PROMPT_RATING instead — clears stale repairs only, keeps training.
refine_v2 uses Initial discovery when there is no previous run to refine from."""
if not pipeline_caps.uses_funpack_studio(target, models):
return {}
si = dict(target.studio_inputs or {})
try:
from conditioning import (
MOVIE_EDITOR_CONTINUE_RATING,
MOVIE_EDITOR_FRESH_PROMPT_RATING,
)
except ImportError:
try:
from ..conditioning import ( # type: ignore
MOVIE_EDITOR_CONTINUE_RATING,
MOVIE_EDITOR_FRESH_PROMPT_RATING,
)
except ImportError:
MOVIE_EDITOR_CONTINUE_RATING = "__funpack_continue__"
MOVIE_EDITOR_FRESH_PROMPT_RATING = "__funpack_fresh_prompt__"
default_rating = (
MOVIE_EDITOR_FRESH_PROMPT_RATING if prompt_changed else MOVIE_EDITOR_CONTINUE_RATING
)
scene_ratings = []
for i, sc in enumerate(active_scenes):
label = (getattr(sc, "rating", "") or "").strip()
if label:
scene_ratings.append({"index": i, "rating": label})
if len(active_scenes) > 1 and scene_ratings:
si["rating"] = default_rating
si["_movie_editor_scene_ratings"] = scene_ratings
elif scene_ratings:
si["rating"] = scene_ratings[0]["rating"]
else:
si["rating"] = default_rating
return si
def _shortcut_seed(project: Project) -> int:
"""Seed for prompt shortcut / wildcard expansion in split preview."""
si = project.sampler_inputs or {}
if si.get("seed") is not None:
return int(si["seed"])
return 0
def _resolve_run_seed(target: Project) -> int:
"""Sampler seed for one Generate click — fixed when set in Chain Sampler, else random."""
import random
si = target.sampler_inputs or {}
if si.get("seed") is not None:
return int(si["seed"])
return random.randint(1, 0xFFFFFFFFFFFFFFFF)
def _expanded_link_texts(target: Project, prompt: str) -> dict[str, str]:
"""The project's texts as a node OUTSIDE Studio needs them — shortcut triggers expanded
and `$name` variables resolved, in Studio's own order.
Only linked inputs (Models → Linked inputs) read these. Studio keeps receiving the raw
prompt on its own port: it expands per scene, at the point where it also splits the
timeline and attributes refinement keys, and pre-expanding for it would flatten that.
`prompt` is the combined generation prompt, so it already carries the anchor; the anchor
is offered separately for a node that wants to compose the two itself.
"""
variables = list(target.variables or [])
def ex(text):
return bridge.expand_prompt_for_node(text, variables)
body = ex(prompt)
postfix = ex(effective_postfix(target))
return {
"prompt": body,
"anchor": ex(effective_anchor(target)),
"postfix": postfix,
"negative_prompt": ex(effective_negative_prompt(target)),
"full_prompt": " ".join(t for t in (body, postfix) if t),
}
def _run_sampler_inputs(
target: Project,
scene_count: int,
full: Optional[Project] = None,
*,
models: Optional[dict] = None,
) -> dict:
"""Sampler widget overrides for one generation run.
Auto continuity (project.continuity_settings) builds guides and sampler knobs per run
for carry, mixed, image, and empty sources. Manual guide_settings.stack_enabled wins.
"""
if not pipeline_caps.uses_chain_sampler(target, models):
return {}
import json
from .backend.timeline import (
build_auto_continuity_guides,
build_scene_guides_payload,
build_self_image_guides,
continuity_settings_for_run,
is_mixed_source,
merge_scene_guide_payloads,
normalize_guide_settings,
)
proj = full or target
samp = dict(target.sampler_inputs or {})
active = [s for s in target.scenes if not s.excluded]
cs = continuity_settings_for_run(proj)
manual_stack = normalize_guide_settings(proj.guide_settings)["stack_enabled"]
auto_guides = build_auto_continuity_guides(proj, target) if cs["auto_enabled"] else None
manual_guides = build_scene_guides_payload(target) if manual_stack else None
guides = manual_guides or auto_guides
# Self-image guides: a scene's own image as a frame-0 guide (anchor_guide → empty
# latent; mixed → reinforces the anchor so the first scene still gets a guide).
# Independent of continuity/manual stacks, so merge it in rather than replace.
guides = merge_scene_guide_payloads(guides, build_self_image_guides(target))
if guides:
samp["funpack_scene_guides"] = json.dumps(guides)
samp["carry_i2v_guides"] = False
solo_run = scene_count == 1 and len(active) == 1
if solo_run:
samp["frame_overlap"] = 0
if not guides:
from .backend.timeline import source_type
if source_type(active[0]) in ("image", "generated_frame", "mixed", "empty"):
samp["carry_i2v_guides"] = False
if scene_count > 1 and not guides:
samp["carry_i2v_guides"] = True
if cs["auto_enabled"] and scene_count > 1 and cs["mid_scene_guide"]:
samp["mid_scene_guide"] = True
samp["mid_scene_guide_strength"] = cs["mid_scene_guide_strength"]
return samp
def _scene_media_ref_map(media_pack: Optional[dict]) -> dict[str, str]:
if not media_pack:
return {}
by_scene = media_pack.get("by_scene") or {}
return {
entry["media_ref"]: entry["filename"]
for entry in by_scene.values()
if isinstance(entry, dict) and entry.get("media_ref") and entry.get("filename")
}
def _missing_continuity_media(full: Project, target: Project) -> list[str]:
"""Media-bin ids referenced by active continuity or manual guide stacks."""
from .backend.timeline import continuity_media_refs
missing: list[str] = []
for ref in continuity_media_refs(full, target):
if not media.path_for(ref):
missing.append(ref)
return missing
def _missing_scene_anchor_media(target: Project, *, chain_available: bool = True) -> list[str]:
"""i2v anchor images referenced on scenes but missing from the media bin."""
if not chain_available:
return []
from .backend.timeline import scene_anchor_media_refs
missing: list[str] = []
for ref in scene_anchor_media_refs(target):
if not media.path_for(ref):
missing.append(ref)
return missing
def _apply_node_overrides(graph: dict, overrides: Optional[list]) -> list[str]:
"""Force widget inputs on built-graph nodes (e.g. the user's 'Anchor as guide' i2v
bypass — set a node's toggle/combo to a chosen state). Each override is
{node, input, value}; ``node`` is a built-graph key ('studio', a slot id, or
'slot_<id>'). Returns the keys actually applied (for the report)."""
applied: list[str] = []
for ov in overrides or []:
if not isinstance(ov, dict):
continue
nid = str(ov.get("node") or "").strip()
inp = ov.get("input")
if not nid or not inp:
continue
key = nid if nid in graph else ("slot_" + nid if ("slot_" + nid) in graph else None)
if not key:
continue
cur = graph[key].get("inputs", {}).get(inp)
# Never clobber a wired socket ([node_id, idx]) — only widget values are forced.
if isinstance(cur, list):
continue
graph[key].setdefault("inputs", {})[inp] = ov.get("value")
applied.append(f"{key}.{inp}")
return applied
def _attach_scene_anchors(samp: dict, media_pack: Optional[dict], target: Project) -> dict:
"""Mixed-source i2v anchors (Img2Video starting latents), separate from guides."""
import json
from .backend.timeline import build_scene_anchors_payload
by_ref = _scene_media_ref_map(media_pack)
if by_ref:
samp["funpack_scene_media_refs"] = json.dumps(by_ref)
anchors = build_scene_anchors_payload(target)
if not anchors or not media_pack:
return samp
by_scene = media_pack.get("by_scene") or {}
enriched: dict = {}
for idx, meta in anchors.items():
sid = meta.get("scene_id")
file_entry = by_scene.get(sid) if sid else None
fn = (file_entry or {}).get("filename")
if fn:
enriched[idx] = {**meta, "filename": fn}
if enriched:
samp["funpack_scene_anchors"] = json.dumps(enriched)
return samp
_XFADE_MAP = {
"crossfade": "fade", "fadeblack": "fadeblack",
"wipeleft": "wipeleft", "wiperight": "wiperight", "dissolve": "dissolve",
}
def _build_render_filter(clips: list, tracks: Optional[list] = None,
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].
Extra `tracks` (inserted audio, input index base_input+j) are delayed to their start and
volume-scaled, then everything is amixed into [aout]. keep_original=False drops the
original LTXAV audio entirely; with no tracks either, the output is silent (no [aout]).
Returns (filter_complex, has_audio).
"""
tracks = tracks or []
n = len(clips)
parts: list[str] = []
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}"
)
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]
gap = float(prev.get("gap_after") or 0)
if gap > 0.001:
gv = f"[vgap{i}]"
ga = f"[agap{i}]"
parts.append(
f"color=c=black:s={cw}x{ch}:r={cfps:g}:d={gap:.3f},format=yuv420p,setsar=1{gv}"
)
parts.append(
f"anullsrc=r=48000:cl=stereo:d={gap:.3f},"
f"aformat=sample_fmts=fltp:channel_layouts=stereo{ga}"
)
ov = f"[vcgap{i}]"
parts.append(f"{acc_v}{gv}concat=n=2:v=1:a=0{ov}")
if keep_original:
oa = f"[acgap{i}]"
parts.append(f"{acc_a}{ga}concat=n=2:v=0:a=1{oa}")
acc_a = oa
acc_v = ov
acc_dur += gap
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] = []
if keep_original and n > 0:
mix.append(acc_a)
for j, t in enumerate(tracks):
idx = base_input + j
start = float(t.get("start_sec") or 0)
tvol = float(t.get("volume", 1.0))
ms = int(max(0.0, start) * 1000)
lbl = f"[at{j}]"
chain = f"[{idx}:a:0]aformat=sample_fmts=fltp:channel_layouts=stereo,volume={max(0.0, tvol):.3f}"
if ms > 0:
chain += f",adelay={ms}|{ms}"
parts.append(f"{chain}{lbl}")
mix.append(lbl)
if not mix:
return ";".join(parts), False # silent output (video only)
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 _append_overlay_filters(
parts: list[str],
export_overlays: list[dict],
*,
canvas_w: int,
canvas_h: int,
image_paths: list[str],
image_input_base: int,
) -> None:
"""Append overlay compositing after [vbase]; sets final [vout]."""
if not export_overlays:
parts.append("[vbase]null[vout]")
return
labels = [f"[{image_input_base + i}:v:0]" for i in range(len(image_paths))]
ov_parts, final = build_overlay_video_filter(
"[vbase]", export_overlays, canvas_w=canvas_w, canvas_h=canvas_h, image_input_labels=labels,
)
parts.extend(ov_parts)
if final == "[vbase]":
parts.append("[vbase]null[vout]")
else:
parts.append(f"{final}null[vout]")
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] = {}
def _ffmpeg_concat_clips_plain(clips: list) -> dict:
"""Join trimmed clips with hard cuts only — no effects, transitions, or extra audio mix."""
import os
import shutil
import subprocess
import time as _time
if not clips:
raise RenderJobError("Nothing to export.")
ff = shutil.which("ffmpeg")
if not ff:
raise RenderJobError("ffmpeg not found on PATH — install it to export clips.")
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
raise RenderJobError(f"Output directory unavailable: {e}") from e
stamp = int(_time.time())
if len(clips) == 1:
out_name = f"funpack_export_{stamp}.mp4"
out_path = os.path.join(tempdir, out_name)
try:
src_path = _resolve_clip_src_path(clips[0])
_ffmpeg_trim_clip(src_path, out_path, clips[0].get("in"), clips[0].get("dur"))
except FileNotFoundError as e:
raise RenderJobError(str(e)) from e
except RuntimeError as e:
raise RenderJobError(str(e)) from e
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": 1,
}
seg_paths: list[str] = []
for i, c in enumerate(clips):
try:
src_path = _resolve_clip_src_path(c)
seg = os.path.join(tempdir, f"funpack_seg_{stamp}_{i}.mp4")
_ffmpeg_trim_clip(src_path, seg, c.get("in"), c.get("dur"))
seg_paths.append(seg)
except FileNotFoundError as e:
raise RenderJobError(str(e)) from e
except RuntimeError as e:
raise RenderJobError(str(e)) from e
out_name = f"funpack_export_{stamp}.mp4"
out_path = os.path.join(tempdir, out_name)
list_path = os.path.join(tempdir, f"funpack_concat_{stamp}.txt")
with open(list_path, "w", encoding="utf-8") as f:
for p in seg_paths:
esc = p.replace("'", "'\\''")
f.write(f"file '{esc}'\n")
cmd = [
ff, "-y", "-f", "concat", "-safe", "0", "-i", list_path,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-1000:]
raise RenderJobError(f"ffmpeg concat failed: {tail}")
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": len(clips),
}
def _ffmpeg_stitch_final(proj, clips: list) -> dict:
"""Blocking ffmpeg stitch — run via asyncio.to_thread from the render job handler."""
import os
import shutil
import subprocess
import time as _time
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.")
try:
import folder_paths
outdir = folder_paths.get_output_directory()
tempdir = folder_paths.get_temp_directory()
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 "")
if mp is None:
return ""
return str(mp)
base = outdir if c.get("type", "output") == "output" else tempdir
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
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)) 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 = []
for t in (getattr(proj, "audio_tracks", None) or []):
is_sep = t.get("kind") == "separated" or (
t.get("scene_id") and not t.get("media_ref") and t.get("kind") != "overlay"
)
if is_sep:
sid = t.get("scene_id")
pinned = t.get("pinned_media") if isinstance(t.get("pinned_media"), dict) else None
pth = None
if pinned and pinned.get("filename"):
pth = _resolve_comfy_media_path(
pinned.get("filename", ""),
pinned.get("subfolder", ""),
pinned.get("type", "output"),
)
bin_ref = t.get("pinned_bin_ref")
if (not pth or not os.path.isfile(pth)) and bin_ref:
mp = media.path_for(str(bin_ref))
if mp is not None:
pth = str(mp)
c = clip_by_scene.get(sid) if sid else None
if not pth or not os.path.isfile(pth):
if not c:
continue
pth = _resolve(c)
if not os.path.isfile(pth):
continue
src_in = t.get("pinned_in_sec", t.get("source_in_sec"))
if src_in is None:
src_in = (c.get("in") if c else None) or 0
src_dur = t.get("pinned_dur", t.get("source_dur"))
if src_dur is None:
src_dur = (c.get("dur") if c else None) or 0
separated_tracks.append({
"path": pth,
"source_in": float(src_in),
"source_dur": float(src_dur),
"start_sec": t.get("start_sec") or 0,
"volume": t.get("volume", 1.0),
})
continue
mp = media.path_for(t.get("media_ref") or "")
if mp is None:
continue
entry = {
"path": str(mp),
"start_sec": t.get("start_sec") or 0,
"volume": t.get("volume", 1.0),
}
src_in = t.get("source_in_sec")
src_dur = t.get("source_dur")
if src_in is not None:
entry["source_in"] = float(src_in)
if src_dur is not None:
entry["source_dur"] = float(src_dur)
media_tracks.append(entry)
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
cmd = [ff, "-y"]
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"]]
else:
cmd += ["-i", t["path"]]
for t in separated_tracks:
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
tracks = media_tracks + separated_tracks
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
export_overlays, overlay_image_paths = prepare_overlay_export(
list(getattr(proj, "overlay_tracks", None) or []),
list(getattr(proj, "overlay_lanes", None) or []),
canvas_w=cw,
canvas_h=ch,
tempdir=tempdir,
resolve_image_path=_overlay_image_path,
)
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,
blank_canvas=blank_canvas,
)
filt_parts = filt.split(";") if filt else []
ov_input_base = n + len(tracks)
_append_overlay_filters(
filt_parts, export_overlays, canvas_w=cw, canvas_h=ch,
image_paths=overlay_image_paths, image_input_base=ov_input_base,
)
filt = ";".join(filt_parts)
cmd += ["-filter_complex", filt, "-map", "[vout]"]
if has_audio:
cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k"]
cmd += [
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-1000:]
raise RenderJobError(f"ffmpeg render failed: {tail}")
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": n,
}
def _segment(p: Project, scene_ids: list) -> Project:
"""Project clone holding only `scene_ids` (in their project order). Used to
generate one chain run: its first scene supplies the i2v anchor, the rest are
carries that overlap inside the run (one ComfyUI chain-sampler request)."""
ids = set(scene_ids)
clone = Project.from_dict(p.to_dict())
clone.scenes = [s for s in clone.scenes if s.id in ids]
if not clone.scenes:
raise web.HTTPNotFound(reason="No scenes matched the requested segment")
return collapse_generative_units(clone)
def _serve_static(tail: str) -> "web.Response":
root = config.FRONTEND_DIR.resolve()
rel = (tail or "index.html").lstrip("/")
target = (root / rel).resolve()
if root not in target.parents and target != root:
raise web.HTTPForbidden()
if target.is_dir():
target = target / "index.html"
if not target.is_file():
raise web.HTTPNotFound()
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
if target.suffix == ".js":
ctype = "text/javascript" # be explicit; some platforms guess text/plain
return web.Response(
body=target.read_bytes(), content_type=ctype,
headers={"Cache-Control": "no-store, max-age=0"}, # editor iterates fast; never cache
)
# ── registration ─────────────────────────────────────────────────────────────
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
config.ensure_dirs()
bridge.install_log_capture() # tee ComfyUI stdout/stderr for the Log viewer
# --- API: projects ---
@routes.get(UI_PREFIX + "/api/health")
async def _health(_req):
return web.json_response({
"ok": True,
"comfy_url": config.comfy_base_url(),
"template_exists": config.TEMPLATE_PATH.is_file(),
"reference_loaded": bool(builder.load_reference()),
"configured_slots": len(nodes.load_models().get("slots", [])),
})
@routes.get(UI_PREFIX + "/api/projects")
async def _list(_req):
return web.json_response({"projects": projects.list_projects()})
@routes.post(UI_PREFIX + "/api/projects")
async def _create(req):
body = await req.json()
p = projects.create(str(body.get("name") or "Untitled"))
# Seed the new project's pipeline config from the global default.
#
# This used to be gated on `glob.get("slots") or glob.get("links")`, which silently
# dropped EVERYTHING ELSE in the global config whenever the user had no loader slots
# — most damagingly `model_family`. The project then had no family at all, so the
# builder fell back to LTXAV while the setup dialog (which reads the global) went on
# reporting H3: an H3 project wired with LTXAV rules, whose audio VAE was routed to
# LTXVAudioVAEDecode, a node H3's core does not contain. `full_control` and
# `core_overrides` were lost the same way. The global IS the default for new
# projects, so copy it whole and let the per-key rules below decide the rest.
glob = nodes.load_models()
if isinstance(glob, dict) and glob:
p.models = glob
projects.save(p)
return web.json_response(p.to_dict())
@routes.post(UI_PREFIX + "/api/projects/import")
async def _import(req):
try:
body = await req.json()
except Exception:
return web.json_response({"detail": "Request body must be a project JSON."}, status=400)
if not isinstance(body, dict) or "scenes" not in body:
return web.json_response({"detail": "Payload does not look like a project file."}, status=400)
import time as _time
body.pop("id", None)
body["created_at"] = _time.time()
body["updated_at"] = _time.time()
try:
p = projects.save(Project.from_dict(body))
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Invalid project file: {e}"}, status=400)
return web.json_response(p.to_dict())
@routes.get(UI_PREFIX + "/api/projects/{pid}")
async def _get(req):
return web.json_response(_project_or_404(req.match_info["pid"]).to_dict())
@routes.put(UI_PREFIX + "/api/projects/{pid}")
async def _update(req):
pid = req.match_info["pid"]
existing = _project_or_404(pid)
body = await req.json()
body["id"] = pid
body.setdefault("created_at", existing.created_at)
return web.json_response(projects.save(Project.from_dict(body)).to_dict())
@routes.delete(UI_PREFIX + "/api/projects/{pid}")
async def _delete(req):
pid = req.match_info["pid"]
if not projects.delete(pid):
raise web.HTTPNotFound(reason=f"Project {pid} not found")
return web.json_response({"deleted": pid})
@routes.get(UI_PREFIX + "/api/projects/{pid}/download")
async def _download(req):
p = _project_or_404(req.match_info["pid"])
safe_name = "".join(c if c.isalnum() or c in "._- " else "_" for c in p.name).strip()[:64]
filename = f"{safe_name or p.id}.funpack_project.json"
import json as _json
return web.Response(
body=_json.dumps(p.to_dict(), indent=2).encode(),
content_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store"},
)
# --- API: timeline preview (round-trip integrity check) ---
@routes.get(UI_PREFIX + "/api/projects/{pid}/preview")
async def _preview(req):
p = _project_or_404(req.match_info["pid"])
include_excluded = req.query.get("include_excluded") == "true"
for_generation = req.query.get("for_generation", "true") == "true"
preview_target = p
if not include_excluded:
preview_target = Project.from_dict(p.to_dict())
preview_target.scenes = [s for s in preview_target.scenes if not s.excluded]
if for_generation:
try:
validation = bridge.validate_generation_prompt(p, preview_target)
except Exception as e: # noqa: BLE001
return web.json_response({
"combined_prompt": "",
"display_prompt": "",
"for_generation": True,
"parse_error": str(e),
"validation_error": str(e),
})
prompt = validation["generation_prompt"]
result = {
"combined_prompt": prompt,
"display_prompt": validation["display_prompt"],
"for_generation": True,
"validation": validation,
}
try:
parsed = bridge.parse_timeline(prompt, seed=_shortcut_seed(p))
result["parsed"] = parsed
result["parsed_raw"] = bridge.parse_timeline_raw(prompt)
result["parsed_verbatim"] = bridge.parse_timeline_verbatim(prompt)
result["expected_scenes"] = validation["expected_scenes"]
result["parsed_scenes"] = len(parsed.get("scenes", []))
result["scene_refinement_keys"] = bridge.scene_refinement_keys(
prompt, p.refinement_key)
result["refinement_key_pool"] = bridge.refinement_key_pool(prompt)
# Postfix is appended to every scene at generation but lives outside the prompt —
# surface it (expanded) so the preview mirrors what Studio actually encodes.
result["postfix"] = bridge.expand_prompt_fragment(effective_postfix(p))
except Exception as e: # noqa: BLE001
result["parse_error"] = str(e)
return web.json_response(result)
prompt = build_combined_prompt(p, include_excluded=include_excluded)
result = {"combined_prompt": prompt, "for_generation": False}
try:
parsed = bridge.parse_timeline(prompt, seed=_shortcut_seed(p))
result["parsed"] = parsed
result["parsed_raw"] = bridge.parse_timeline_raw(prompt)
result["parsed_verbatim"] = bridge.parse_timeline_verbatim(prompt)
expected = len([s for s in p.scenes if include_excluded or not s.excluded])
got = len(parsed.get("scenes", []))
result["expected_scenes"] = expected
result["parsed_scenes"] = got
result["scene_refinement_keys"] = bridge.scene_refinement_keys(
prompt, p.refinement_key)
result["refinement_key_pool"] = bridge.refinement_key_pool(prompt)
if expected and got != expected:
result["warning"] = (
f"Studio split produced {got} scene(s) but the timeline has {expected}. "
f"Check transition markers (each scene needs a recognized trigger before it)."
)
except Exception as e: # noqa: BLE001
result["parse_error"] = str(e)
return web.json_response(result)
# --- API: parse an arbitrary prompt (global-prompt → anchor/scenes/transitions) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/parse")
async def _parse(req):
p = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
prompt = str(body.get("prompt", ""))
payload, errors = _parse_prompt_variants(prompt, _shortcut_seed(p))
usable = any(_parse_has_scenes(payload.get(k)) for k in ("parsed", "parsed_raw", "parsed_verbatim"))
if errors and not usable:
detail = "; ".join(errors.values())
return web.json_response({"detail": detail, "parse_errors": errors}, status=502)
return web.json_response(payload)
# --- API: library (shortcuts + transitions, FunPack in-process) ---
@routes.get(UI_PREFIX + "/api/library/transitions")
async def _transitions(_req):
try:
return web.json_response(bridge.transitions())
except Exception as e: # noqa: BLE001
return web.json_response({"transitions": [], "error": str(e)})
@routes.post(UI_PREFIX + "/api/library/transitions")
async def _transition_save(req):
try:
return web.json_response(bridge.save_transition(await req.json()))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.delete(UI_PREFIX + "/api/library/transitions/{name}")
async def _transition_delete(req):
try:
return web.json_response(bridge.delete_transition(req.match_info["name"]))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.get(UI_PREFIX + "/api/library/shortcuts")
async def _shortcuts(_req):
try:
return web.json_response(bridge.shortcuts())
except Exception as e: # noqa: BLE001
return web.json_response({"shortcuts": [], "error": str(e)})
@routes.get(UI_PREFIX + "/api/library/suggestion_stats")
async def _suggestion_stats(_req):
"""Shortcut usage mined from all saved projects (pairs / follows / counts) —
feeds the prompt Ideas popover. Empty stats on any error, never a 500."""
try:
return web.json_response(bridge.suggestion_stats())
except Exception as e: # noqa: BLE001
return web.json_response({"scenes": 0, "counts": {}, "pairs": [], "follows": [], "error": str(e)})
@routes.post(UI_PREFIX + "/api/library/shortcuts")
async def _shortcut_save(req):
try:
return web.json_response(bridge.save_shortcut(await req.json()))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.delete(UI_PREFIX + "/api/library/shortcuts/{name}")
async def _shortcut_delete(req):
try:
return web.json_response(bridge.delete_shortcut(req.match_info["name"]))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.get(UI_PREFIX + "/api/library/revolver")
async def _revolver_get(_req):
try:
return web.json_response(bridge.revolver_settings())
except Exception as e: # noqa: BLE001
return web.json_response({"enabled": False, "random": False, "error": str(e)})
@routes.post(UI_PREFIX + "/api/library/revolver")
async def _revolver_set(req):
try:
return web.json_response(bridge.set_revolver_settings(await req.json()))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/categories")
async def _category_save(req):
try:
return web.json_response(bridge.save_category(await req.json()))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.get(UI_PREFIX + "/api/library/nle")
async def _nle_library(_req):
try:
from .backend import nle_library
return web.json_response(nle_library.load())
except Exception as e: # noqa: BLE001
return web.json_response({"effects": [], "video_transitions": [], "error": str(e)})
@routes.get(UI_PREFIX + "/api/library/shortcuts/export")
async def _shortcuts_export(_req):
try:
data = bridge.export_shortcuts()
return web.json_response(data, headers={
"Content-Disposition": "attachment; filename=funpack_shortcuts.json",
"Cache-Control": "no-store",
})
except Exception as e: # noqa: BLE001
raise web.HTTPInternalServerError(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/shortcuts/import")
async def _shortcuts_import(req):
try:
incoming = await req.json()
if not isinstance(incoming, dict) or "shortcuts" not in incoming:
raise web.HTTPBadRequest(reason="Payload must be a shortcuts database JSON.")
replace = req.query.get("mode", "merge") == "replace"
return web.json_response(bridge.import_shortcuts(incoming, replace=replace))
except web.HTTPException:
raise
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/shortcuts/clear")
async def _shortcuts_clear(_req):
try:
return web.json_response(bridge.clear_shortcuts())
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.get(UI_PREFIX + "/api/library/transitions/export")
async def _transitions_export(_req):
try:
data = bridge.export_transitions()
return web.json_response(data, headers={
"Content-Disposition": "attachment; filename=funpack_promptsplit.json",
"Cache-Control": "no-store",
})
except Exception as e: # noqa: BLE001
raise web.HTTPInternalServerError(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/transitions/import")
async def _transitions_import(req):
try:
incoming = await req.json()
if not isinstance(incoming, dict) or "transitions" not in incoming:
raise web.HTTPBadRequest(reason="Payload must be a transitions database JSON.")
replace = req.query.get("mode", "merge") == "replace"
return web.json_response(bridge.import_transitions(incoming, replace=replace))
except web.HTTPException:
raise
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.post(UI_PREFIX + "/api/library/transitions/clear")
async def _transitions_clear(_req):
try:
return web.json_response(bridge.clear_transitions())
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
# --- API: FunPack file manager (Composer ▸ Files) ---
@routes.get(UI_PREFIX + "/api/files")
async def _files_list(_req):
try:
return web.json_response(bridge.list_funpack_files())
except Exception as e: # noqa: BLE001
return web.json_response({"groups": [], "error": str(e)})
@routes.delete(UI_PREFIX + "/api/files/{group}/{name}")
async def _files_delete(req):
try:
return web.json_response(
bridge.delete_funpack_file(req.match_info["group"], req.match_info["name"]))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
@routes.post(UI_PREFIX + "/api/files/{group}/clear")
async def _files_clear(req):
try:
return web.json_response(bridge.clear_funpack_files(req.match_info["group"]))
except Exception as e: # noqa: BLE001
raise web.HTTPBadRequest(reason=str(e))
# --- API: media bin ---
@routes.get(UI_PREFIX + "/api/media")
async def _media_list(_req):
return web.json_response({"media": media.list_media()})
@routes.post(UI_PREFIX + "/api/media")
async def _media_upload(req):
reader = await req.multipart()
saved = []
async for part in reader:
if part.name not in ("file", "files"):
continue
data = await part.read(decode=False)
if data:
saved.append(media.save_upload(part.filename or "upload.bin", data))
if not saved:
raise web.HTTPBadRequest(reason="No file in upload.")
return web.json_response({"media": saved})
@routes.get(UI_PREFIX + "/api/media/{mid}")
async def _media_get(req):
p = media.path_for(req.match_info["mid"])
if p is None:
raise web.HTTPNotFound()
return web.Response(body=p.read_bytes(), content_type=media.content_type(req.match_info["mid"]))
@routes.delete(UI_PREFIX + "/api/media/{mid}")
async def _media_delete(req):
if not media.delete(req.match_info["mid"]):
raise web.HTTPNotFound()
return web.json_response({"deleted": req.match_info["mid"]})
@routes.patch(UI_PREFIX + "/api/media/{mid}")
async def _media_rename(req):
body = await req.json() if req.can_read_body else {}
name = (body.get("name") or "").strip()
if not name:
raise web.HTTPBadRequest(reason="Name is required.")
entry = media.rename(req.match_info["mid"], name)
if entry is None:
raise web.HTTPNotFound()
return web.json_response({"media": entry})
@routes.post(UI_PREFIX + "/api/media/import-clip")
async def _media_import_clip(req):
body = await req.json() if req.can_read_body else {}
clip = body.get("clip") or {}
name = (body.get("name") or "clip.mp4").strip() or "clip.mp4"
if not clip.get("bin_media_ref") and not clip.get("filename"):
return web.json_response({"detail": "Missing clip source."}, status=400)
if not name.lower().endswith((".mp4", ".webm", ".mov", ".mkv", ".avi")):
name = f"{name}.mp4"
try:
data = _clip_bytes_for_media(clip)
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except RuntimeError as e:
return web.json_response({"detail": str(e)}, status=503)
entry = media.save_upload(name, data)
return web.json_response({"media": entry})
# --- API: generate / status / result ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/generate")
async def _generate(req):
p = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
scene_ids = body.get("scene_ids")
if scene_ids:
target = _segment(p, scene_ids)
else:
target = _solo(p, body.get("only_scene"))
try:
validation = bridge.validate_generation_prompt(p, target)
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Prompt validation failed: {e}"}, status=400)
prompt = validation["generation_prompt"]
if not prompt.strip():
return web.json_response({"detail": "Nothing to generate — no active scene text."}, status=400)
prompt_changed = bool(validation.get("prompt_changed_since_last_queue"))
reset_session = bool(body.get("reset_session"))
try:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Node registry unavailable: {e}"}, status=502)
models_cfg = _project_models(p)
caps = pipeline_caps.capabilities(p, models_cfg)
if not caps["studio"]:
reset_session = False
bridge.reset_progress()
bridge.current_progress() # ensure the sampler step-progress hook is installed
active_scenes = [s for s in target.scenes if not s.excluded and not is_video_clip(s)]
active_scene_count = len(active_scenes)
if not active_scene_count:
return web.json_response(
{"detail": "Nothing to generate — all clips are video-only or excluded."},
status=400,
)
missing_media = _missing_continuity_media(p, target) if caps["chain_sampler"] else []
if missing_media:
ids = ", ".join(missing_media)
return web.json_response(
{"detail": f"Missing media for continuity or guide stack ({ids}). Re-upload or clear the reference in Engine settings / Scene inspector."},
status=400,
)
missing_anchors = _missing_scene_anchor_media(target, chain_available=caps["chain_sampler"])
if missing_anchors:
ids = ", ".join(missing_anchors)
return web.json_response(
{"detail": f"Missing i2v anchor image in Media bin ({ids}). Re-save the frame or pick another anchor."},
status=400,
)
# V1 uniform chain: if trimmed scenes all agree on a frame count, use it.
# This makes the timeline trim handle actually affect generation length,
# provided the user has linked EmptyLTXVLatent.num_frames → Project Frames
# in Models → Linked inputs.
trimmed = [
s.eff_frames(target)
for s in active_scenes
if s.frames_mode != "project" and s.frames is not None
]
effective_frames = (
trimmed[0]
if trimmed and all(f == trimmed[0] for f in trimmed)
else target.num_frames_per_scene
)
try:
media_pack = _prepare_media(
target,
continuity_media_refs(p, target) if caps["chain_sampler"] else [],
chain_available=caps["chain_sampler"],
)
sampler_inputs = _run_sampler_inputs(target, active_scene_count, full=p, models=models_cfg)
if caps["chain_sampler"]:
_attach_scene_anchors(sampler_inputs, media_pack, target)
graph, report = builder.build(oi, models_cfg, {
"prompt": prompt, "seed": _resolve_run_seed(target),
"num_frames_per_scene": effective_frames,
"frame_rate": target.frame_rate,
"width": target.width, "height": target.height,
"negative_prompt": effective_negative_prompt(target) or None,
"max_scenes": active_scene_count,
"studio_inputs": _run_studio_inputs(
target, active_scenes, prompt_changed=prompt_changed, models=models_cfg,
),
# Scene boundaries handed to Studio structurally (the editor knows them) so the
# generation prompt stays clean — no injected `scene N` delimiters.
"scene_segments": build_generation_scene_segments(target),
"sampler_inputs": sampler_inputs,
# Text for LINKED node inputs only (Models → Linked inputs). A node that
# encodes on its own does none of what Studio does to a prompt, so these are
# shortcut-expanded and $variable-resolved here; the built-in path above still
# gets the raw text, which Studio expands itself at the right moment.
"expanded": _expanded_link_texts(target, prompt),
"variables": list(target.variables or []),
# Reference media is wired to node inputs now (Media Bin "R" → Models &
# Pipeline), so the editor no longer drives Studio's built-in ref2va list.
# Project.h3_references stays readable for old project files; it is inert.
"references": _prepare_references(target),
"reset_session": reset_session,
"refinement_key": (target.refinement_key or "default"),
}, media=(media_pack or {}).get("primary") if media_pack else None)
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Workflow build failed: {e}"}, status=502)
if report["blocking"]:
detail = "Generation blocked — " + "; ".join(report["blocking"])
return web.json_response({"detail": detail, "report": report}, status=400)
# A scene whose source mode wants an anchor but has none picked is skipped
# silently when anchors are assembled — it still renders, just without the
# anchor it was configured for. Report it rather than block: the run is valid,
# it just may not be the shot the user set up.
anchorless = pipeline_caps.scenes_missing_anchor_media(target, caps["chain_sampler"])
if anchorless:
report["unsatisfied"].append(
"No anchor image picked for scene(s) " + ", ".join(anchorless)
+ " — they will generate without an i2v anchor."
)
# Anchor-as-guide i2v bypass: force the user-declared node's widget(s) to the
# configured state so the latent stays empty. Only honoured for runs the client
# marked as carrying an anchor_guide scene.
applied_ovr = _apply_node_overrides(graph, body.get("node_overrides"))
if applied_ovr:
report.setdefault("node_overrides", []).extend(applied_ovr)
# Stamp the queued prompt with our client_id + run identity so a reloaded UI can
# re-attach to this generation (block Generate, expose Interrupt, record the result).
fp_meta = {
"pid": p.id,
"scene_ids": list(scene_ids) if scene_ids else None,
"only_scene": body.get("only_scene") or None,
}
try:
result = await bridge.queue_prompt(
graph, client_id=bridge.EDITOR_CLIENT_ID, extra_data={"funpack": fp_meta},
)
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Failed to queue with ComfyUI: {e}"}, status=502)
prompt_id = result.get("prompt_id")
p.generation_meta = {
**(p.generation_meta or {}),
"prompt_hash": validation["prompt_hash"],
"run_hash": validation["run_hash"],
}
projects.save(p)
return web.json_response({
"prompt_id": result.get("prompt_id"),
"report": report,
"validation": validation,
"prompt_repairs_cleared": prompt_changed,
"reset_session": reset_session,
})
@routes.get(UI_PREFIX + "/api/projects/{pid}/status/{prompt_id}")
async def _status(req):
prompt_id = req.match_info["prompt_id"]
proj = _project_or_404(req.match_info["pid"])
run_fps = float(getattr(proj, "frame_rate", None) or 25.0)
try:
hist = await bridge.history(prompt_id)
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"history unavailable: {e}")
if prompt_id in hist:
entry = hist[prompt_id]
raw_status = entry.get("status") or {}
media = _media_from_history(entry)
scene_layout = _scene_layout_from_history(entry, run_fps)
# Extract ComfyUI execution errors from history so the frontend can show them.
exec_error: Optional[str] = None
for _kind, payload in raw_status.get("messages") or []:
if _kind == "execution_error" and isinstance(payload, dict):
node_type = payload.get("node_type", "unknown node")
exc = payload.get("exception_message") or payload.get("traceback") or "unknown error"
exec_error = f"{node_type}: {exc}"
break
is_error = raw_status.get("status_str") == "error"
payload = {
"state": "error" if (is_error and not media) else "completed",
"media": media,
"error": exec_error,
"status": raw_status,
}
if scene_layout:
payload["scene_layout"] = scene_layout
return web.json_response(payload)
try:
running = await bridge.is_running(prompt_id)
except Exception: # noqa: BLE001
running = True
return web.json_response({"state": "running" if running else "pending", "media": []})
@routes.get(UI_PREFIX + "/api/rating-labels")
async def _rating_labels(_req):
try:
return web.json_response(bridge.rating_labels())
except Exception as e: # noqa: BLE001
return web.json_response({"labels": [], "error": str(e)})
@routes.get(UI_PREFIX + "/api/progress")
async def _progress(_req):
return web.json_response(bridge.current_progress())
@routes.get(UI_PREFIX + "/api/log")
async def _log(req):
try:
limit = int(req.query.get("limit", "600"))
except (TypeError, ValueError):
limit = 600
return web.json_response({"lines": bridge.recent_log(limit)})
@routes.post(UI_PREFIX + "/api/interrupt")
async def _interrupt(_req):
try:
return web.json_response(await bridge.interrupt())
except Exception as e: # noqa: BLE001
return web.json_response({"detail": str(e)}, status=502)
@routes.get(UI_PREFIX + "/api/active")
async def _active(_req):
# The editor's own in-flight generation, recovered from ComfyUI's queue so a
# reloaded UI can re-attach (block Generate, expose Interrupt). Never raises —
# an unreachable queue just means "nothing we can re-attach to".
try:
return web.json_response(await bridge.active_generation())
except Exception as e: # noqa: BLE001
return web.json_response(
{"running": False, "prompt_id": None, "pid": None,
"scene_ids": [], "only_scene": None, "pending": 0, "error": str(e)})
@routes.get(UI_PREFIX + "/api/temp")
async def _temp_list(_req):
return web.json_response({"files": _list_temp_media()})
async def _faststart_path(src_path: str) -> str:
"""Serve a stream-copy-remuxed (moov-at-front) copy in place of a raw ComfyUI render.
VHS_VideoCombine writes moov at the end; deep-seeking that directly in the browser is
exactly what preview-segment already routes chain clips around, but a "shallow" single
clip (or just opening one) hits /result directly and gets the same undecodable-on-seek
file. Cached + lock-deduped like the segment cache below; skipped entirely (no-op) for
anything that already has moov up front, e.g. FunPack's own segment/export/final-render
output, which already sets +faststart itself. MP4/MOV only (moov/mdat is an ISO-BMFF
concept — a non-.mp4 container falling through the caller's extension gate would just
get echoed back as-is here, not silently mis-remuxed into a mismatched container).
A file with NO moov (still being written, or an aborted save) raises 503 instead of
being served: raw moov-less bytes are undecodable, and once the write finishes the
same URL would start serving different bytes — a browser mid-Range-stream then mixes
offsets (PIPELINE decode errors) or fails If-Range and restarts the clip from 0.
Caches are keyed on the source's (mtime, size) signature and the remux output name is
derived from path+signature, so a rewritten source can't serve a stale copy and two
sources sharing a basename can't overwrite each other's remux mid-stream.
A moov-at-end file must NEVER be served raw, whatever goes wrong here: deep seeks
into it are undecodable in the browser (Firefox: NS_ERROR_DOM_MEDIA_DECODE_ERR /
AppleVTDecoder reference-missing), and the element's cache-busted retries then fail
identically forever. A remux failure answers 502 (rate-limited re-attempts) instead
of falling back to raw bytes — an honest "Preview unavailable" beats undecodable video."""
import asyncio
import hashlib
import os
import time
if not _is_isobmff(src_path):
return src_path
try:
sig = _file_sig(src_path)
except OSError:
return src_path # caller re-checks existence / falls through to 404
cached = _faststart_cache.get(src_path)
if cached and cached[0] == sig and os.path.isfile(cached[1]):
return cached[1]
lock = _faststart_locks.setdefault(src_path, asyncio.Lock())
async with lock:
try:
sig = _file_sig(src_path)
except OSError:
return src_path
cached = _faststart_cache.get(src_path)
if cached and cached[0] == sig and os.path.isfile(cached[1]):
return cached[1]
pos = _moov_position(src_path)
if pos == "front":
return src_path
if pos == "none":
# Mid-write (or truncated junk): never serve — undecodable, and the bytes
# will change under this URL. 503 lets the player's retry loop try again.
raise web.HTTPServiceUnavailable(
reason="Video file is still being written.", headers={"Retry-After": "2"})
failed = _faststart_failed.get(src_path)
if failed and failed[0] == sig and time.monotonic() - failed[1] < _FASTSTART_RETRY_SEC:
raise web.HTTPBadGateway(
reason="Video could not be prepared for playback (remux failed).")
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception:
# No temp dir means no seek-safe copy can be made; raw moov-at-end bytes
# would be undecodable, so tell the player to retry rather than serve them.
raise web.HTTPServiceUnavailable(
reason="Temp directory unavailable.", headers={"Retry-After": "5"})
digest = hashlib.md5(f"{src_path}|{sig[0]}|{sig[1]}".encode()).hexdigest()[:16]
ext = os.path.splitext(src_path)[1].lower()
out_path = os.path.join(tempdir, f"funpack_faststart_{digest}{ext}")
err = None
try:
# to_thread: same event-loop-freeze hazard as preview-segment encodes below —
# this is a stream copy (no re-encode) so it's fast, but still blocking I/O.
await asyncio.to_thread(_remux_faststart, src_path, out_path)
ok = os.path.getsize(out_path) > 0 and _moov_position(out_path) == "front"
except Exception as e: # noqa: BLE001
ok = False
err = e
try:
sig_now = _file_sig(src_path)
except OSError:
sig_now = None # deleted under us — retry will 404 definitively
if ok and sig_now == sig:
_faststart_cache[src_path] = (sig, out_path)
_faststart_failed.pop(src_path, None)
return out_path
try:
os.remove(out_path)
except OSError:
pass
if sig_now != sig:
# Source changed under the remux — it's being (re)written right now.
raise web.HTTPServiceUnavailable(
reason="Video file is still being written.", headers={"Retry-After": "2"})
print(f"[FunPack] faststart remux failed for {src_path}: {err}")
_faststart_failed[src_path] = (sig, time.monotonic())
raise web.HTTPBadGateway(
reason="Video could not be prepared for playback (remux failed).")
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
async def _result(req):
import os
path = _resolve_comfy_media_path(
req.query.get("filename", ""),
req.query.get("subfolder", ""),
req.query.get("type", "output"),
)
if req.method == "HEAD":
_project_or_404(req.match_info["pid"])
if not path or not os.path.isfile(path):
raise web.HTTPNotFound()
# Mirror the GET path's mid-write gate: a moov-less mp4 would 503 on GET, so a
# HEAD claiming 200 would lie — the player probes HEAD after a media error to
# tell "render still being written" (show "processing") apart from a dead file.
if _is_isobmff(path) and _moov_position(path) == "none":
raise web.HTTPServiceUnavailable(
reason="Video file is still being written.", headers={"Retry-After": "2"})
return web.Response()
# Serve straight from disk: FileResponse streams with Range support, so the player
# can seek and a busy ComfyUI can't stall the transfer. The old path buffered the
# WHOLE video through a loopback /view fetch into one non-range response — during a
# concurrent run's save that crawled or aborted, leaving clips unplayable.
if path and os.path.isfile(path):
import mimetypes as _mt
ctype = _mt.guess_type(path)[0] or "application/octet-stream"
# The render's own filename is already unique per run (builder.py's per-run
# uuid filename_prefix) — but with no Content-Disposition, "Save Video As" falls
# back to whatever the browser derives from the URL path, which has no filename
# here (it's in the query string) and defaults to a generic name like "result.mp4"
# every time. Surface the real name so downloads don't collide/need renaming
# (Easy Gen has no export button, so this native browser save is the only path out).
dl_name = req.query.get("filename", "") or os.path.basename(path)
if ctype.startswith("video/"):
path = await _faststart_path(path)
return web.FileResponse(path, headers={
"Content-Type": ctype,
"Content-Disposition": f'inline; filename="{dl_name}"',
})
# Fallback (custom output dirs the resolver doesn't know): loopback /view fetch.
try:
data, ctype = await bridge.fetch_view(
req.query.get("filename", ""),
req.query.get("subfolder", ""),
req.query.get("type", "output"),
)
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"could not fetch result: {e}")
fname = req.query.get("filename", "")
if _is_isobmff(fname):
# A buffered full-body mp4/mov response has no Range support and, for a raw
# ComfyUI render, moov at the end — both make seeking undecodable in the
# browser. Spool the fetched bytes to temp and serve through the same
# remux-and-FileResponse path as resolver hits.
import hashlib
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception:
raise web.HTTPServiceUnavailable(
reason="Temp directory unavailable.", headers={"Retry-After": "5"})
ext = os.path.splitext(fname)[1].lower()
digest = hashlib.md5(
f"{fname}|{req.query.get('subfolder', '')}|"
f"{req.query.get('type', 'output')}|{len(data)}".encode()).hexdigest()[:16]
spool = os.path.join(tempdir, f"funpack_viewspool_{digest}{ext}")
if not os.path.isfile(spool) or os.path.getsize(spool) != len(data):
tmp = spool + ".part"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, spool) # atomic: no reader ever sees a partial spool
path = await _faststart_path(spool)
return web.FileResponse(path, headers={
"Content-Type": ctype.split(";")[0],
"Content-Disposition": f'inline; filename="{fname}"',
})
return web.Response(
body=data, content_type=ctype.split(";")[0],
headers={"Content-Disposition": f'inline; filename="{fname}"'},
)
# --- API: final render (async job — ffmpeg can exceed Cloudflare tunnel timeouts) ---
import asyncio
import uuid as _uuid
async def _run_render_job(job_id: str, proj, clips: list) -> None:
_render_jobs[job_id] = {"state": "running"}
try:
result = await asyncio.to_thread(_ffmpeg_stitch_final, proj, clips)
_render_jobs[job_id] = {"state": "done", **result}
except RenderJobError as e:
_render_jobs[job_id] = {"state": "error", "detail": str(e)}
except Exception as e: # noqa: BLE001
_render_jobs[job_id] = {"state": "error", "detail": f"Render failed: {e}"}
@routes.post(UI_PREFIX + "/api/projects/{pid}/render")
async def _render_final(req):
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 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))
return web.json_response({"job_id": job_id})
@routes.get(UI_PREFIX + "/api/projects/{pid}/render/{job_id}")
async def _render_job_status(req):
_project_or_404(req.match_info["pid"])
job = _render_jobs.get(req.match_info["job_id"])
if not job:
return web.json_response({"detail": "Render job not found."}, status=404)
return web.json_response(job)
# --- API: preview segment (ffmpeg trim + faststart, same as export per scene) ---
@routes.get(UI_PREFIX + "/api/projects/{pid}/preview-segment/{scene_id}")
async def _preview_segment(req):
import os
pid = req.match_info["pid"]
scene_id = req.match_info["scene_id"]
proj = _project_or_404(pid)
render_override = _playback_render_from_query(req.query)
try:
clip = _scene_playback_clip_spec(proj, scene_id, render_override=render_override)
except KeyError as e:
# Ghosts: the scene was removed from the project but its render still previews;
# the trim window (in/dur) comes from the query instead of the scene.
clip = _ghost_playback_clip_spec(req.query)
if clip is None:
return web.json_response({"detail": str(e)}, status=404)
try:
src_path = _resolve_clip_src_path(clip)
src_sig = _file_sig(src_path)
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except OSError as e:
return web.json_response({"detail": str(e)}, status=503)
if _is_isobmff(src_path) and _moov_position(src_path) == "none":
# No moov ⇒ still being written (VHS finalizes it last) or an aborted save:
# ffmpeg can't trim it and the segment would come out truncated/undecodable.
return web.json_response(
{"detail": "Source video is still being written."},
status=503, headers={"Retry-After": "2"})
# Source signature in the key: a re-rendered source at the SAME path must not keep
# serving the segment encoded from the old bytes (stale previews).
key = _preview_segment_cache_key(pid, scene_id, clip) + f":{src_sig[0]}:{src_sig[1]}"
# Serialize per segment: the pool preloads every clip at once after a run, and a
# scrub can re-request the same URL — without this lock each request would start
# its own duplicate encode of the same segment.
lock = _preview_segment_locks.setdefault(key, asyncio.Lock())
async with lock:
cached = _preview_segment_cache.get(key)
if cached and os.path.isfile(cached):
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=3600"})
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Temp directory unavailable: {e}"}, status=500)
import time as _time
out_path = os.path.join(
tempdir,
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
)
try:
# to_thread: this encode takes seconds — run inline it would freeze the ONE
# aiohttp event loop ComfyUI has, stalling every playing /result stream and
# the whole editor API for its duration (stale/black previews on scrub).
await asyncio.to_thread(
_ffmpeg_trim_clip, src_path, out_path, clip.get("in"), clip.get("dur"),
fast=True,
)
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except RuntimeError as e:
return web.json_response({"detail": str(e)}, status=503)
_preview_segment_cache[key] = out_path
return web.FileResponse(out_path, headers={"Cache-Control": "private, max-age=3600"})
# --- API: export one clip (ffmpeg trim from a chain output using in/dur) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clip")
async def _export_clip(req):
_project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
clip = body.get("clip") or {}
if not clip.get("filename") and not clip.get("bin_media_ref"):
return web.json_response({"detail": "Missing clip filename."}, status=400)
import os
import time as _time
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Output directory unavailable: {e}"}, status=500)
out_name = f"funpack_export_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
try:
src_path = _resolve_clip_src_path(clip)
# to_thread: same event-loop-freeze hazard as preview segments — an export
# during playback would stall every streaming /result response.
await asyncio.to_thread(_ffmpeg_trim_clip, src_path, out_path, clip.get("in"), clip.get("dur"))
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except RuntimeError as e:
return web.json_response({"detail": str(e)}, status=503)
return web.json_response({
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
})
import asyncio
import uuid as _uuid_export
async def _run_export_concat_job(job_id: str, clips: list) -> None:
_export_jobs[job_id] = {"state": "running"}
try:
result = await asyncio.to_thread(_ffmpeg_concat_clips_plain, clips)
_export_jobs[job_id] = {"state": "done", **result}
except RenderJobError as e:
_export_jobs[job_id] = {"state": "error", "detail": str(e)}
except Exception as e: # noqa: BLE001
_export_jobs[job_id] = {"state": "error", "detail": f"Export failed: {e}"}
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clips")
async def _export_clips(req):
_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 export."}, status=400)
job_id = _uuid_export.uuid4().hex
_export_jobs[job_id] = {"state": "queued"}
asyncio.create_task(_run_export_concat_job(job_id, clips))
return web.json_response({"job_id": job_id})
@routes.get(UI_PREFIX + "/api/projects/{pid}/export-clips/{job_id}")
async def _export_clips_status(req):
_project_or_404(req.match_info["pid"])
job = _export_jobs.get(req.match_info["job_id"])
if not job:
return web.json_response({"detail": "Export job not found."}, status=404)
return web.json_response(job)
# --- API: models / pluggable node slots ---
@routes.get(UI_PREFIX + "/api/node-roles")
async def _node_roles(_req):
return web.json_response({"roles": nodes.roles_payload()})
@routes.get(UI_PREFIX + "/api/node-candidates/{role}")
async def _node_candidates(req):
role = req.match_info["role"]
refresh = req.query.get("refresh") == "true"
try:
oi = await bridge.object_info(refresh=refresh)
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
try:
return web.json_response({"role": role, "candidates": nodes.candidates(oi, role)})
except Exception as e: # noqa: BLE001
import traceback
print(f"[FunPack][movie] node-candidates({role}) failed:\n{traceback.format_exc()}")
raise web.HTTPInternalServerError(reason=f"candidates({role}) failed: {e}")
@routes.get(UI_PREFIX + "/api/pipeline-deps")
async def _pipeline_deps(_req):
try:
oi = await bridge.object_info()
except Exception:
oi = {}
mgr = await pipeline_deps.manager_available()
# Report the family of the project being asked about, not the global default. Reading
# the global here is what let the setup dialog show "MiniMax H3" as already chosen for
# a project that had no family at all: the user saw the right answer, closed the
# dialog, and nothing was ever written. `pid` is optional so callers without an open
# project (Easy Gen, first run) still get the global default.
pid = _req.query.get("pid")
models = nodes.load_models()
if pid:
try:
p = projects.get(pid)
except Exception: # corrupt/incompatible file — the global default still answers
p = None
if p is not None:
models = _project_models(p)
return web.json_response(pipeline_deps.status_payload(
oi,
manager_available=mgr,
manager_on_disk=pipeline_deps.manager_dir_on_disk(),
family=pipeline_wiring.family_of(models),
))
@routes.post(UI_PREFIX + "/api/pipeline-deps/install")
async def _pipeline_deps_install(req):
import asyncio
body = await req.json() if req.can_read_body else {}
if body.get("install_manager"):
if pipeline_deps.manager_dir_on_disk():
return web.json_response(
{"detail": "ComfyUI-Manager is already present on disk. Restart ComfyUI to load it."},
status=400,
)
job = pipeline_deps.create_manager_install_job()
asyncio.create_task(pipeline_deps.run_manager_install_job(job["job_id"], _restart_comfy))
return web.json_response({"job_id": job["job_id"], "total": job["total"], "kind": "manager"})
if not await pipeline_deps.manager_available():
return web.json_response(
{"detail": "ComfyUI-Manager is not installed or not reachable. Install it first."},
status=503,
)
pack_ids = body.get("pack_ids") or body.get("packs") or []
if not isinstance(pack_ids, list) or not pack_ids:
return web.json_response({"detail": "pack_ids required."}, status=400)
job = pipeline_deps.create_install_job([str(x) for x in pack_ids])
asyncio.create_task(pipeline_deps.run_install_job(job["job_id"], _restart_comfy))
return web.json_response({"job_id": job["job_id"], "total": job["total"], "kind": "packs"})
@routes.get(UI_PREFIX + "/api/pipeline-deps/install/{job_id}")
async def _pipeline_deps_install_status(req):
job = pipeline_deps.get_install_job(req.match_info["job_id"])
if not job:
raise web.HTTPNotFound(reason="Install job not found")
return web.json_response(job)
@routes.post(UI_PREFIX + "/api/pipeline-deps/install/{job_id}/cancel")
async def _pipeline_deps_install_cancel(req):
import asyncio
job_id = req.match_info["job_id"]
if not pipeline_deps.cancel_install_job(job_id):
job = pipeline_deps.get_install_job(job_id)
if not job:
raise web.HTTPNotFound(reason="Install job not found")
return web.json_response({"detail": "Install cannot be cancelled."}, status=409)
asyncio.create_task(pipeline_deps._reset_manager_queue())
return web.json_response({"cancelled": True, "job_id": job_id})
@routes.get(UI_PREFIX + "/api/pipeline-ports")
async def _pipeline_ports(_req):
try:
oi = await bridge.object_info()
except Exception:
oi = None
fam = pipeline_wiring.family_of(nodes.load_models())
return web.json_response({
"family": fam,
"ports": nodes.pipeline_ports(oi, fam),
"core_producers": nodes.core_producers(oi),
"requirements": nodes.pipeline_requirements(fam),
"wiring": pipeline_wiring.wiring_rules_payload(fam),
})
@routes.get(UI_PREFIX + "/api/image-targets")
async def _image_targets(req):
try:
oi = await bridge.object_info()
except Exception:
oi = {}
out = []
for p in nodes.pipeline_ports(oi):
if nodes.type_accepts(p.get("type") or "", "IMAGE"):
out.append({"value": "port:" + p["id"], "label": p["label"]})
pid = req.query.get("pid")
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
for slot in models_cfg.get("slots", []):
nd = oi.get(slot.get("node_class")) or {}
for ci in nodes.connection_inputs(nd):
if nodes.type_accepts(ci["type"], "IMAGE"):
label = (slot.get("label") or slot.get("node_class")) + " · " + ci["name"]
out.append({"value": f"node:{slot['id']}:{ci['name']}", "label": label})
return web.json_response({"targets": out})
@routes.get(UI_PREFIX + "/api/core-graph")
async def _core_graph(req):
try:
oi = await bridge.object_info()
except Exception:
oi = {}
pid = req.query.get("pid")
models_cfg = _project_models(projects.get(pid)) if pid else nodes.load_models()
return web.json_response({"nodes": builder.core_graph(oi, models_cfg)})
@routes.get(UI_PREFIX + "/api/all-nodes")
async def _all_nodes(_req):
try:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
try:
return web.json_response({"nodes": nodes.all_nodes(oi)})
except Exception as e: # noqa: BLE001
import traceback
print(f"[FunPack][movie] all-nodes failed:\n{traceback.format_exc()}")
raise web.HTTPInternalServerError(reason=f"all-nodes failed: {e}")
@routes.get(UI_PREFIX + "/api/node/{cls}")
async def _node(req):
try:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
spec = nodes.describe_node(oi, req.match_info["cls"])
if spec is None:
raise web.HTTPNotFound(reason="Unknown node class")
return web.json_response(spec)
@routes.get(UI_PREFIX + "/api/system/info")
async def _system_info(_req):
# Describes the ComfyUI host, not the browser — on a rental those are different
# machines and the host is the one worth looking at.
return web.json_response(sysinfo.collect())
@routes.get(UI_PREFIX + "/api/git/status")
async def _git_status(_req):
return web.json_response(git_update.status())
@routes.post(UI_PREFIX + "/api/git/update")
async def _git_update(req):
import asyncio
body = await req.json() if req.can_read_body else {}
branch = body.get("branch")
try:
result = git_update.pull(str(branch).strip() if branch else None)
except git_update.GitUpdateError as e:
return web.json_response({"detail": str(e)}, status=400)
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True, **result})
@routes.post(UI_PREFIX + "/api/git/checkout")
async def _git_checkout(req):
import asyncio
body = await req.json() if req.can_read_body else {}
branch = str(body.get("branch") or "").strip()
if not branch:
return web.json_response({"detail": "Branch name is required."}, status=400)
try:
result = git_update.checkout(branch, pull_after=True)
except git_update.GitUpdateError as e:
return web.json_response({"detail": str(e)}, status=400)
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True, **result})
@routes.post(UI_PREFIX + "/api/restart")
async def _restart(_req):
import asyncio
# defer so this 200 flushes to the browser before the process is replaced
asyncio.get_event_loop().call_later(0.7, _restart_comfy)
return web.json_response({"restarting": True})
@routes.post(UI_PREFIX + "/api/workflow/parse")
async def _workflow_parse(req):
body = await req.json()
workflow = body.get("workflow")
try:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
result = workflow_import.parse_workflow(workflow, oi)
if result.get("error"):
return web.json_response(result, status=400)
return web.json_response(result)
@routes.post(UI_PREFIX + "/api/projects/{pid}/workflow/apply")
async def _workflow_apply(req):
p = _project_or_404(req.match_info["pid"])
body = await req.json()
workflow = body.get("workflow")
bindings = body.get("bindings") or {}
try:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}")
try:
config_data = workflow_import.apply_workflow(workflow, bindings, oi)
except ValueError as e:
return web.json_response({"detail": str(e)}, status=400)
p.models = config_data
projects.save(p)
nodes.save_models(config_data)
return web.json_response(config_data)
@routes.post(UI_PREFIX + "/api/models/refresh")
async def _models_refresh(_req):
try:
await bridge.object_info(refresh=True)
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"refresh failed: {e}")
return web.json_response({"ok": True})
@routes.get(UI_PREFIX + "/api/models")
async def _models_get(_req):
return web.json_response(nodes.load_models())
@routes.put(UI_PREFIX + "/api/models")
async def _models_put(req):
body = await req.json()
return web.json_response(nodes.save_models(body))
# Per-project pipeline config (slots + links). Falls back to the global default
# when the project has none yet; saving also updates the global default so the
# next NEW project inherits your latest loaders.
@routes.get(UI_PREFIX + "/api/projects/{pid}/pipeline-caps")
async def _pipeline_caps(req):
p = _project_or_404(req.match_info["pid"])
return web.json_response(pipeline_caps.capabilities(p, _project_models(p)))
@routes.get(UI_PREFIX + "/api/projects/{pid}/models")
async def _project_models_get(req):
return web.json_response(_project_models(_project_or_404(req.match_info["pid"])))
@routes.put(UI_PREFIX + "/api/projects/{pid}/models")
async def _project_models_put(req):
p = _project_or_404(req.match_info["pid"])
body = await req.json()
if not isinstance(body, dict):
body = {"slots": []}
body.setdefault("slots", [])
p.models = body
projects.save(p)
# Keep the global default in sync (it seeds new projects) — but never let a save that
# simply omits `model_family` erase the one already recorded there. A caller that
# means to change the family sends it; one that is only rearranging loaders does not,
# and silently clearing it is how every later project ends up with no family at all.
glob = dict(body)
if not glob.get("model_family"):
inherited = nodes.load_models().get("model_family")
if inherited:
glob["model_family"] = inherited
nodes.save_models(glob)
return web.json_response(body)
# --- UI: static frontend (must be registered AFTER api routes) ---
@routes.get(UI_PREFIX)
async def _root_redirect(_req):
raise web.HTTPFound(UI_PREFIX + "/")
@routes.get(UI_PREFIX + "/")
async def _index(_req):
return _serve_static("index.html")
@routes.get(UI_PREFIX + "/{tail:.*}")
async def _static(req):
return _serve_static(req.match_info["tail"])
print(f"[FunPack] Movie Editor available at {config.comfy_display_url()}{UI_PREFIX}/")