Characters live in a shared library (left bin → Characters tab), not the project inspector. Select a timeline clip and click characters to toggle them on that scene; their prompt text merges per scene, face refs drive per-scene identity pins, and assigned names show on clips and in the inspector. Legacy project.character_bible still works when no scene characters are assigned.
1111 lines
46 KiB
Python
1111 lines
46 KiB
Python
"""Movie Editor routes, served by ComfyUI's own aiohttp server.
|
|
|
|
UI: GET /funpack/movie/ (and static assets under /funpack/movie/<file>)
|
|
API: /funpack/movie/api/*
|
|
|
|
Registered on PromptServer like the other /funpack/* routes (see templates.py,
|
|
batch_training.py). Pure logic lives in backend/ (timeline, projects, workflow);
|
|
bridge.py talks to ComfyUI (parse/library in-process, queue/history/view over loopback).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import mimetypes
|
|
from typing import Optional
|
|
|
|
try:
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
except Exception: # pragma: no cover - only available inside ComfyUI
|
|
web = None
|
|
PromptServer = None
|
|
|
|
from .backend import bridge, builder, config, media, nodes, projects
|
|
from .backend.timeline import (
|
|
Project,
|
|
build_combined_prompt,
|
|
collapse_generative_units,
|
|
effective_negative_prompt,
|
|
gen_unit_id,
|
|
group_generative_units,
|
|
)
|
|
|
|
UI_PREFIX = "/funpack/movie"
|
|
|
|
|
|
def _restart_comfy() -> None:
|
|
"""Relaunch the ComfyUI process in place. Mirrors ComfyUI-Manager's reboot so it
|
|
works the same whether launched directly, as a module, or via comfy-cli."""
|
|
import os
|
|
import sys
|
|
try:
|
|
sys.stdout.close_log() # type: ignore[attr-defined] # Manager's tee logger, if present
|
|
except Exception:
|
|
pass
|
|
# comfy-cli watches for a .reboot file and relaunches us itself.
|
|
if "__COMFY_CLI_SESSION__" in os.environ:
|
|
try:
|
|
open(os.environ["__COMFY_CLI_SESSION__"] + ".reboot", "w").close()
|
|
except Exception:
|
|
pass
|
|
print("\n[FunPack] Restarting ComfyUI...\n", flush=True)
|
|
os._exit(0)
|
|
sys_argv = sys.argv.copy()
|
|
if "--windows-standalone-build" in sys_argv:
|
|
sys_argv.remove("--windows-standalone-build")
|
|
if sys_argv and sys_argv[0].endswith("__main__.py"): # python -m comfy
|
|
module_name = os.path.basename(os.path.dirname(sys_argv[0]))
|
|
cmds = [sys.executable, "-m", module_name] + sys_argv[1:]
|
|
elif sys.platform.startswith("win32"):
|
|
cmds = ['"' + sys.executable + '"', '"' + sys_argv[0] + '"'] + sys_argv[1:]
|
|
else:
|
|
cmds = [sys.executable] + sys_argv
|
|
print(f"\n[FunPack] Restarting ComfyUI... {cmds}\n", flush=True)
|
|
os.execv(sys.executable, cmds)
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
def _media_from_history(hist_entry: dict) -> list[dict]:
|
|
out: list[dict] = []
|
|
for node_id, node_out in (hist_entry.get("outputs") or {}).items():
|
|
for key in ("gifs", "videos", "images"):
|
|
for item in node_out.get(key, []) or []:
|
|
fn = item.get("filename")
|
|
if not fn:
|
|
continue
|
|
out.append({
|
|
"node_id": node_id, "kind": key, "filename": fn,
|
|
"subfolder": item.get("subfolder", ""), "type": item.get("type", "output"),
|
|
"format": item.get("format"),
|
|
})
|
|
return out
|
|
|
|
|
|
def _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_media(proj: Project, extra_refs: Optional[list] = None) -> 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) -> 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 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 = getattr(src, "type", "") if src else ""
|
|
if stype not in ("image", "generated_frame", "mixed") or not ref:
|
|
continue
|
|
_add_ref(ref, tgt, sc.id)
|
|
for ref in (extra_refs or []):
|
|
_add_ref(str(ref))
|
|
if not by_scene:
|
|
return None
|
|
return {"primary": primary, "by_scene": by_scene}
|
|
|
|
|
|
def _project_or_404(pid: str) -> Project:
|
|
p = projects.get(pid)
|
|
if p is None:
|
|
raise web.HTTPNotFound(reason=f"Project {pid} not found")
|
|
return p
|
|
|
|
|
|
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) or {}
|
|
if m.get("slots") or m.get("links") or m.get("core_overrides") or m.get("disable_core"):
|
|
return m
|
|
return nodes.load_models()
|
|
|
|
|
|
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) -> dict:
|
|
"""Studio widget overrides for this run, incl. the RLHF rating of the run's last
|
|
render (Studio refines from it). Uses the first user-rated scene in the run.
|
|
|
|
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 target.conditioning_slot != "funpack":
|
|
return {}
|
|
si = dict(target.studio_inputs or {})
|
|
rating = next((s.rating for s in active_scenes if (getattr(s, "rating", "") or "").strip()), None)
|
|
if rating:
|
|
si["rating"] = rating
|
|
else:
|
|
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__"
|
|
si["rating"] = (
|
|
MOVIE_EDITOR_FRESH_PROMPT_RATING if prompt_changed else MOVIE_EDITOR_CONTINUE_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 _run_sampler_inputs(target: Project, scene_count: int, full: Optional[Project] = 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 target.sampler_slot != "funpack":
|
|
return {}
|
|
import json
|
|
from .backend.timeline import (
|
|
build_auto_continuity_guides,
|
|
build_scene_guides_payload,
|
|
continuity_settings_for_run,
|
|
is_mixed_source,
|
|
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
|
|
|
|
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 _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
|
|
|
|
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)
|
|
by_ref = {
|
|
entry["media_ref"]: entry["filename"]
|
|
for entry in by_scene.values()
|
|
if isinstance(entry, dict) and entry.get("media_ref") and entry.get("filename")
|
|
}
|
|
if by_ref:
|
|
samp["funpack_scene_media_refs"] = json.dumps(by_ref)
|
|
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) -> 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.
|
|
|
|
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] = []
|
|
|
|
# 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:
|
|
# VIRTUAL zoom: zoompan keeps the output fixed at the canvas size and scales the
|
|
# content within it (never changes the actual frame size). in: 1.0->1.2 (push in);
|
|
# out: 1.2->1.0 (pull back). Centered.
|
|
nframes = max(1, round(dur * cfps))
|
|
z = f"1+0.20*on/{nframes}" if zoom == "in" else f"1.20-0.20*on/{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]
|
|
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[vout]")
|
|
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 _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(),
|
|
"reference_loaded": bool(builder.load_reference().get("nodes")),
|
|
"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
|
|
glob = nodes.load_models()
|
|
if glob.get("slots") or glob.get("links"):
|
|
p.models = glob
|
|
projects.save(p)
|
|
return web.json_response(p.to_dict())
|
|
|
|
@routes.post(UI_PREFIX + "/api/projects/import")
|
|
async def _import(req):
|
|
try:
|
|
body = await req.json()
|
|
except Exception:
|
|
return web.json_response({"detail": "Request body must be a project JSON."}, status=400)
|
|
if not isinstance(body, dict) or "scenes" not in body:
|
|
return web.json_response({"detail": "Payload does not look like a project file."}, status=400)
|
|
import time as _time
|
|
body.pop("id", None)
|
|
body["created_at"] = _time.time()
|
|
body["updated_at"] = _time.time()
|
|
p = projects.save(Project.from_dict(body))
|
|
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:
|
|
validation = bridge.validate_generation_prompt(p, preview_target)
|
|
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", []))
|
|
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
|
|
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", ""))
|
|
try:
|
|
parsed = bridge.parse_timeline(prompt, seed=_shortcut_seed(p))
|
|
return web.json_response({
|
|
"parsed": parsed,
|
|
"parsed_raw": bridge.parse_timeline_raw(prompt),
|
|
"parsed_verbatim": bridge.parse_timeline_verbatim(prompt),
|
|
"combined_prompt": prompt,
|
|
})
|
|
except Exception as e: # noqa: BLE001
|
|
return web.json_response({"detail": f"Parse failed: {e}"}, status=502)
|
|
|
|
# --- 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.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/characters")
|
|
async def _characters(_req):
|
|
try:
|
|
from .backend import characters as char_lib
|
|
return web.json_response(char_lib.list_characters())
|
|
except Exception as e: # noqa: BLE001
|
|
return web.json_response({"characters": [], "error": str(e)})
|
|
|
|
@routes.post(UI_PREFIX + "/api/library/characters")
|
|
async def _character_save(req):
|
|
try:
|
|
from .backend import characters as char_lib
|
|
return web.json_response(char_lib.save_character(await req.json()))
|
|
except Exception as e: # noqa: BLE001
|
|
raise web.HTTPBadRequest(reason=str(e))
|
|
|
|
@routes.delete(UI_PREFIX + "/api/library/characters/{cid}")
|
|
async def _character_delete(req):
|
|
try:
|
|
from .backend import characters as char_lib
|
|
return web.json_response(char_lib.delete_character(req.match_info["cid"]))
|
|
except Exception as e: # noqa: BLE001
|
|
raise web.HTTPBadRequest(reason=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.")
|
|
return web.json_response(bridge.import_shortcuts(incoming))
|
|
except web.HTTPException:
|
|
raise
|
|
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_transitions.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.")
|
|
return web.json_response(bridge.import_transitions(incoming))
|
|
except web.HTTPException:
|
|
raise
|
|
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"]})
|
|
|
|
# --- 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"))
|
|
validation = bridge.validate_generation_prompt(p, target)
|
|
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)
|
|
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]
|
|
active_scene_count = len(active_scenes)
|
|
# 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
|
|
)
|
|
from .backend.timeline import continuity_media_refs
|
|
media_pack = _prepare_media(target, continuity_media_refs(p, target))
|
|
sampler_inputs = _run_sampler_inputs(target, active_scene_count, full=p)
|
|
_attach_scene_anchors(sampler_inputs, media_pack, target)
|
|
graph, report = builder.build(oi, _project_models(target), {
|
|
"prompt": prompt, "seed": _resolve_run_seed(target),
|
|
"num_frames_per_scene": effective_frames,
|
|
"frame_rate": target.frame_rate,
|
|
"width": target.width, "height": target.height,
|
|
"negative_prompt": effective_negative_prompt(target) or None,
|
|
"max_scenes": active_scene_count,
|
|
"studio_inputs": _run_studio_inputs(target, active_scenes, prompt_changed=prompt_changed),
|
|
"sampler_inputs": sampler_inputs,
|
|
"reset_session": reset_session,
|
|
}, media=(media_pack or {}).get("primary") if media_pack else None)
|
|
if report["blocking"]:
|
|
detail = "Generation blocked — " + "; ".join(report["blocking"])
|
|
return web.json_response({"detail": detail, "report": report}, status=400)
|
|
try:
|
|
result = await bridge.queue_prompt(graph)
|
|
except Exception as e: # noqa: BLE001
|
|
return web.json_response({"detail": f"Failed to queue with ComfyUI: {e}"}, status=502)
|
|
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"]
|
|
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)
|
|
# 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"
|
|
return web.json_response({
|
|
"state": "error" if (is_error and not media) else "completed",
|
|
"media": media,
|
|
"error": exec_error,
|
|
"status": raw_status,
|
|
})
|
|
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/projects/{pid}/result")
|
|
async def _result(req):
|
|
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}")
|
|
return web.Response(body=data, content_type=ctype.split(";")[0])
|
|
|
|
# --- API: final render (hard-cut concat of kept clips, with per-clip in/out) ---
|
|
@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 [] # ordered [{filename, subfolder, type, in?, dur?, volume?}]
|
|
if not clips:
|
|
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time as _time
|
|
ff = shutil.which("ffmpeg")
|
|
if not ff:
|
|
return web.json_response({"detail": "ffmpeg not found on PATH — install it to render the final video."}, status=503)
|
|
try:
|
|
import folder_paths
|
|
outdir = folder_paths.get_output_directory()
|
|
tempdir = folder_paths.get_temp_directory()
|
|
except Exception as e: # noqa: BLE001
|
|
return web.json_response({"detail": f"Output directory unavailable: {e}"}, status=500)
|
|
|
|
def _resolve(c):
|
|
base = outdir if c.get("type", "output") == "output" else tempdir
|
|
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
|
|
|
|
paths = [_resolve(c) for c in clips]
|
|
missing = [p for p in paths if not os.path.isfile(p)]
|
|
if missing:
|
|
return web.json_response({"detail": f"{len(missing)} clip file(s) not found on disk — regenerate then render."}, status=400)
|
|
|
|
# Inserted audio tracks (project-level): resolve each media asset to a file. Skip any
|
|
# that no longer exist on disk rather than failing the whole render.
|
|
keep_original = bool(getattr(proj, "keep_original_audio", True))
|
|
tracks = []
|
|
for t in (getattr(proj, "audio_tracks", None) or []):
|
|
mp = media.path_for(t.get("media_ref") or "")
|
|
if mp is None:
|
|
continue
|
|
tracks.append({"path": str(mp), "start_sec": t.get("start_sec") or 0, "volume": t.get("volume", 1.0)})
|
|
|
|
# The final render is ephemeral (temp dir) — persist it via the Export Save dialog.
|
|
out_name = f"funpack_final_{int(_time.time())}.mp4"
|
|
out_path = os.path.join(tempdir, out_name)
|
|
# Per-clip in/out via -ss/-t before each input, then a filter graph that applies
|
|
# per-clip video effects + seam transitions and the audio mix (per-clip volume,
|
|
# original-audio toggle, inserted tracks). Re-encode keeps/mixes the audio.
|
|
cmd = [ff, "-y"]
|
|
for c, pth in zip(clips, paths):
|
|
inn, dur = c.get("in"), c.get("dur")
|
|
if inn is not None:
|
|
cmd += ["-ss", f"{float(inn):.3f}"]
|
|
if dur is not None:
|
|
cmd += ["-t", f"{float(dur):.3f}"]
|
|
cmd += ["-i", pth]
|
|
n = len(clips)
|
|
for t in tracks: # inserted-audio inputs follow the clip inputs (indices n..n+k-1)
|
|
cmd += ["-i", t["path"]]
|
|
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
|
|
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:]
|
|
return web.json_response({"detail": f"ffmpeg render failed: {tail}"}, status=500)
|
|
return web.json_response({
|
|
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
|
|
"clips": n,
|
|
})
|
|
|
|
# --- 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-ports")
|
|
async def _pipeline_ports(_req):
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception:
|
|
oi = None
|
|
return web.json_response({
|
|
"ports": nodes.pipeline_ports(oi),
|
|
"core_producers": nodes.core_producers(),
|
|
"requirements": nodes.pipeline_requirements(),
|
|
})
|
|
|
|
@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 p.get("type") == "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 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.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/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}/models")
|
|
async def _project_models_get(req):
|
|
return web.json_response(_project_models(_project_or_404(req.match_info["pid"])))
|
|
|
|
@routes.put(UI_PREFIX + "/api/projects/{pid}/models")
|
|
async def _project_models_put(req):
|
|
p = _project_or_404(req.match_info["pid"])
|
|
body = await req.json()
|
|
if not isinstance(body, dict):
|
|
body = {"slots": []}
|
|
body.setdefault("slots", [])
|
|
p.models = body
|
|
projects.save(p)
|
|
nodes.save_models(body) # keep the global default in sync (seeds new projects)
|
|
return web.json_response(body)
|
|
|
|
# --- UI: static frontend (must be registered AFTER api routes) ---
|
|
@routes.get(UI_PREFIX)
|
|
async def _root_redirect(_req):
|
|
raise web.HTTPFound(UI_PREFIX + "/")
|
|
|
|
@routes.get(UI_PREFIX + "/")
|
|
async def _index(_req):
|
|
return _serve_static("index.html")
|
|
|
|
@routes.get(UI_PREFIX + "/{tail:.*}")
|
|
async def _static(req):
|
|
return _serve_static(req.match_info["tail"])
|
|
|
|
print(f"[FunPack] Movie Editor available at {UI_PREFIX}/")
|