Reads the safetensors header's key names (same signatures core uses), so the pipeline wires for whatever is actually loaded. An unidentifiable file proposes no family rather than defaulting to LTX. Frame geometry migrates on change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2672 lines
116 KiB
Python
2672 lines
116 KiB
Python
"""Movie Editor routes, served by ComfyUI's own aiohttp server.
|
|
|
|
UI: GET /funpack/ (and static assets under /funpack/<file>)
|
|
API: /funpack/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 json
|
|
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, model_probe, 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"
|
|
|
|
|
|
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:
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception:
|
|
oi = None
|
|
# ...except when the global is an IMPORTED WORKFLOW. That is one project's
|
|
# graph, not a template: copying it starts every new project with a pile of
|
|
# third-party loaders to wire, which is precisely what FunPack's own loaders
|
|
# exist to remove. The files it picked are carried over; the graph is not.
|
|
p.models = pipeline_wiring.new_project_models(glob, oi)
|
|
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)
|
|
studio_inputs = _run_studio_inputs(
|
|
target, active_scenes, prompt_changed=prompt_changed, models=models_cfg,
|
|
)
|
|
# Simple mode generates what was asked for and nothing else. Applied to the
|
|
# RUN, never written back: the project keeps the Editor's settings.
|
|
if bool(body.get("simple")):
|
|
_settings = {}
|
|
try:
|
|
_settings = json.loads(studio_inputs.get("studio_settings") or "{}")
|
|
except Exception: # noqa: BLE001
|
|
_settings = {}
|
|
sampler_inputs, _settings = pipeline_caps.apply_simple_mode(sampler_inputs, _settings)
|
|
studio_inputs["studio_settings"] = json.dumps(_settings)
|
|
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": studio_inputs,
|
|
# 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.
|
|
if bool(body.get("simple")):
|
|
report["unsatisfied"].append(
|
|
"Simple mode: refinement (rating-driven steering, taste and value guidance, "
|
|
"DynaShift, velocity bias) was switched off for this run — there is no "
|
|
"rating UI here to feed it. Everything else ran as configured. Your project "
|
|
"settings are unchanged."
|
|
)
|
|
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
|
|
# The family decides which ports exist and which wires are legal, and it lives on the
|
|
# PROJECT. Answering from the global default described whatever project was saved last,
|
|
# so opening one on the other family read its saved wires back as "(not allowed)" and
|
|
# let reconcile drop them against stale overrides.
|
|
pid = req.query.get("pid")
|
|
fam = pipeline_wiring.family_of(
|
|
_project_models(projects.get(pid)) if pid else 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),
|
|
# What a project with no pipeline yet should start as: FunPack's own loaders,
|
|
# already wired. Sent with the rules because it is derived from the same family.
|
|
"default_slots": pipeline_wiring.default_pipeline_slots(fam, oi) if oi else [],
|
|
})
|
|
|
|
@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):
|
|
p = _project_or_404(req.match_info["pid"])
|
|
models = _project_models(p)
|
|
# First look at a pipeline that has never been set up: it becomes FunPack's own
|
|
# loaders, already wired. Seeded HERE rather than in the panel so it happens once,
|
|
# server-side, and never travels into the global default the way a save does.
|
|
if not models.get("defaults_seeded") and not models.get("slots"):
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception:
|
|
oi = None
|
|
if pipeline_wiring.seed_default_pipeline(models, oi).get("slots"):
|
|
p.models = models
|
|
projects.save(p)
|
|
return web.json_response(models)
|
|
|
|
@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", [])
|
|
# A pipeline that has never been set up starts as FunPack's own loaders, already
|
|
# wired — the wizard's family step reaches here before the Models panel is ever
|
|
# opened, so seeding only in the panel would leave "Continue" with no pipeline.
|
|
# The family follows the CHECKPOINT, not a button. Picking LTX while loading an H3
|
|
# file used to wire the whole graph for the wrong model and surface as a stray port
|
|
# rather than as a family error. Detection is a header read, so it costs the same on
|
|
# a 40 GB file as on a small one. A file we cannot identify leaves the existing
|
|
# family alone — guessing is what this replaced.
|
|
probe = model_probe.probe_models(body)
|
|
if probe.get("family") and probe["family"] != body.get("model_family"):
|
|
body["model_family"] = probe["family"]
|
|
body["model_family_probe"] = probe
|
|
|
|
seeded_now = False
|
|
if not body.get("defaults_seeded"):
|
|
oi = None
|
|
if not body.get("slots"):
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception:
|
|
oi = None
|
|
before = len(body.get("slots") or [])
|
|
pipeline_wiring.seed_default_pipeline(body, oi)
|
|
seeded_now = len(body.get("slots") or []) > before
|
|
# The family step can land here after seeding, and a pipeline nobody has touched
|
|
# must follow the answer: being handed LTX's nodes after choosing H3 is the setup
|
|
# contradicting the user. Only an untouched seed is rebuilt.
|
|
elif body.get("model_family") != (getattr(p, "models", None) or {}).get("model_family") \
|
|
and pipeline_wiring.is_seeded_pipeline(body):
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception:
|
|
oi = None
|
|
if pipeline_wiring.reseed_for_family(body, oi):
|
|
seeded_now = True
|
|
p.models = body
|
|
projects.save(p)
|
|
# A SEED is not a user edit and must not reach the global default at all: opening a
|
|
# new project would otherwise replace the loaders someone configured with empty ones.
|
|
if seeded_now:
|
|
return web.json_response(body)
|
|
glob = pipeline_wiring.global_template_update(nodes.load_models(), body)
|
|
if glob is not None:
|
|
nodes.save_models(glob)
|
|
return web.json_response(body)
|
|
|
|
# --- UI: legacy entry points ---
|
|
# /funpack/movie/ and /funpack/easy/ were the Cutting Room and Easy Gen when they were
|
|
# two apps. Both are the same app now, so old bookmarks land on it rather than 404.
|
|
# Registered BEFORE the catch-all below, which would otherwise swallow them.
|
|
#
|
|
# The API prefix has to move too. Those frontends build their API base from
|
|
# location.pathname, so a page still open on the old URL after an update polls
|
|
# /funpack/<old>/api/health. Answering 404 leaves its restart overlay spinning on
|
|
# "Reloading ComfyUI" forever — it only reloads once health says ok, and the reload is
|
|
# what would have reached the redirect below. 307 keeps the method and body.
|
|
for _old in ("/movie", "/easy"):
|
|
@routes.route("*", UI_PREFIX + _old + "/api/{tail:.*}")
|
|
async def _legacy_api(req):
|
|
target = f"{UI_PREFIX}/api/{req.match_info['tail']}"
|
|
raise web.HTTPTemporaryRedirect(
|
|
f"{target}?{req.query_string}" if req.query_string else target)
|
|
|
|
for _path in (UI_PREFIX + _old, UI_PREFIX + _old + "/"):
|
|
@routes.get(_path)
|
|
async def _legacy_ui(_req):
|
|
raise web.HTTPFound(UI_PREFIX + "/")
|
|
|
|
# --- 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] FunPack available at {config.comfy_display_url()}{UI_PREFIX}/")
|