build_combined_prompt had a bug: when no anchor text was set, need_sep was
False for scene 1 so its text landed in Studio's segments[0] (anchor slot)
and never generated. An N-scene project with no anchor only produced N-1 scenes.
Fix: always emit a separator before every scene. Studio splits on separators,
so segments[0] is always the anchor (empty if no anchor text is set) and
segments[1..N] are the generated scenes. Fallback label is now "scene {i+1}"
(correctly numbered) instead of the previous "scene {label_n+1}" off-by-one.
Also: pass max_scenes = active scene count from the generate route into the
builder so the chain sampler always knows the exact number of scenes to produce.
Note on per-scene images: only the source image on scene 1 (project anchor
image) is wired to studio.source_image. Scene 2+ anchoring works via the
carry_i2v_guides flag on the chain sampler, which carries each scene's last
frame as the i2v source for the next scene. Per-scene i2v requires carry_i2v_guides
to be enabled in the sampler inspector — it is off by default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
536 lines
22 KiB
Python
536 lines
22 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
|
|
|
|
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 _prepare_media(proj: Project) -> Optional[dict]:
|
|
"""If a scene has an image asset + a chosen target, copy it into ComfyUI's input
|
|
folder (ephemeral) and return {filename, target} for the builder to LoadImage."""
|
|
import os
|
|
import shutil
|
|
try:
|
|
import folder_paths
|
|
indir = folder_paths.get_input_directory()
|
|
except Exception:
|
|
return None
|
|
for sc in proj.scenes:
|
|
src = sc.source
|
|
ref = getattr(src, "media_ref", None)
|
|
tgt = getattr(src, "target", None)
|
|
if not (src and getattr(src, "type", "") == "image" and ref and tgt):
|
|
continue
|
|
path = media.path_for(ref)
|
|
if not path:
|
|
continue
|
|
fn = f"funpack_movie_{ref}{path.suffix}"
|
|
try:
|
|
shutil.copy(str(path), os.path.join(indir, fn))
|
|
except OSError:
|
|
return None
|
|
return {"filename": fn, "target": tgt}
|
|
return None
|
|
|
|
|
|
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 _solo(p: Project, only_scene: Optional[str]) -> Project:
|
|
if not only_scene:
|
|
return 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")
|
|
clone = Project.from_dict(p.to_dict())
|
|
clone.scenes = [scene]
|
|
return 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()
|
|
|
|
# --- 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()
|
|
return web.json_response(projects.create(str(body.get("name") or "Untitled")).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"
|
|
prompt = build_combined_prompt(p, include_excluded=include_excluded)
|
|
result = {"combined_prompt": prompt}
|
|
try:
|
|
parsed = bridge.parse_timeline(prompt, seed=p.seed)
|
|
result["parsed"] = parsed
|
|
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: 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/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 {}
|
|
target = _solo(p, body.get("only_scene"))
|
|
prompt = build_combined_prompt(target)
|
|
if not prompt.strip():
|
|
return web.json_response({"detail": "Nothing to generate — no active scene text."}, status=400)
|
|
try:
|
|
oi = await bridge.object_info()
|
|
except Exception as e: # noqa: BLE001
|
|
return web.json_response({"detail": f"Node registry unavailable: {e}"}, status=502)
|
|
active_scene_count = len([s for s in target.scenes if not s.excluded])
|
|
graph, report = builder.build(oi, nodes.load_models(), {
|
|
"prompt": prompt, "seed": target.seed,
|
|
"num_frames_per_scene": target.num_frames_per_scene,
|
|
"frame_rate": target.frame_rate,
|
|
"width": target.width, "height": target.height,
|
|
"negative_prompt": target.negative_prompt or None,
|
|
"max_scenes": active_scene_count,
|
|
"studio_inputs": target.studio_inputs if target.conditioning_slot == "funpack" else {},
|
|
"sampler_inputs": target.sampler_inputs if target.sampler_slot == "funpack" else {},
|
|
}, media=_prepare_media(target))
|
|
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)
|
|
return web.json_response({"prompt_id": result.get("prompt_id"), "report": report})
|
|
|
|
@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]
|
|
return web.json_response({
|
|
"state": "completed",
|
|
"media": _media_from_history(entry),
|
|
"status": entry.get("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/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: 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"]})
|
|
for slot in nodes.load_models().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/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))
|
|
|
|
# --- 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}/")
|