Movie Editor: run inside ComfyUI's server, drop FastAPI sidecar

Per user: serve from ComfyUI's own aiohttp PromptServer at /funpack/movie/
instead of a separate FastAPI/uvicorn process. Removes fastapi/uvicorn/httpx
entirely (all on ComfyUI's bundled aiohttp); backend/requirements.txt deleted,
root requirements.txt needs nothing new.

- server.py: aiohttp route registration on PromptServer (projects/preview/
  generate/status/result/library + static UI), guarded for standalone tests;
  wired into root __init__.py like batch_training
- bridge.py: parse_timeline/transitions IN-PROCESS (direct FunPack imports);
  /prompt /history /view over loopback HTTP
- config.comfy_base_url(): detect the REAL port from PromptServer.instance /
  comfy.cli_args so non-default --port works; browser uses same-origin relative
  URLs (api.js derives base from window.location) so the port is automatic
- delete app.py, routes/, comfy_client.py, backend/requirements.txt
- README updated; tests still 8/8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 13:41:59 +03:00
co-authored by Claude Opus 4.8
parent d133fde995
commit 7b4162c0be
15 changed files with 401 additions and 443 deletions
+4
View File
@@ -26,6 +26,10 @@ if __package__:
from . import batch_training # noqa: F401 registers /funpack/batch/* routes
except Exception as _e:
print(f"[FunPack] batch_training routes unavailable: {_e}")
try:
from .movie_editor import server as _movie_editor_server # noqa: F401 registers /funpack/movie/* routes
except Exception as _e:
print(f"[FunPack] Movie Editor routes unavailable: {_e}")
else:
# Standalone tests may not have the full ComfyUI/CUDA runtime loaded.
from conditioning import (
+25 -22
View File
@@ -1,9 +1,10 @@
# FunPack Movie Editor (V1)
A standalone web app for assembling LTXAV montages over the FunPack pipeline: scenes on
an editable timeline, per-scene prompts, a transition library between scenes, and one-click
generation. ComfyUI is the inference backend — the app drives it over its HTTP API and
reuses FunPack's `/funpack/*` routes for parsing.
A web app for assembling LTXAV montages over the FunPack pipeline: scenes on an editable
timeline, per-scene prompts, a transition library between scenes, and one-click generation.
It runs **inside ComfyUI** — routes are registered on ComfyUI's own server, so the editor
opens at **`http://<your-comfyui-host>:<port>/funpack/movie/`** (same host/port as ComfyUI;
no separate process, no extra dependencies).
> **V1 scope.** Editable timeline + transition library feeding the **existing** uniform
> Studio → Chain Sampler chain (one generation pass). Per-scene length/FPS, Empty/Image
@@ -14,11 +15,17 @@ reuses FunPack's `/funpack/*` routes for parsing.
## Architecture
```
Browser (vanilla JS) → FastAPI sidecar (light, no torch) → ComfyUI
project store + prompt assembly /funpack/* (parse, libraries)
/prompt /history /view (generate)
Browser ──(same origin)──► ComfyUI server (aiohttp)
vanilla JS frontend /funpack/movie/ static UI
/funpack/movie/api/* this module (project store, prompt assembly)
├─ in-process: parse_timeline / transitions (FunPack functions)
└─ loopback HTTP: /prompt /history /view (queue + results)
```
No FastAPI/uvicorn/httpx — everything rides on ComfyUI's bundled aiohttp. The port is
detected from the running server, so non-default `--port` works automatically; the browser
uses same-origin relative URLs so it never needs to know the port at all.
## Step 0 (required for generation): export your workflow
The app queues **your** working graph. Export it from ComfyUI:
@@ -34,30 +41,26 @@ If the app can't find the text node that feeds Studio's prompt, create
{ "prompt": [{ "node_id": "12", "input_key": "text" }] }
```
(Find node ids with `GET /api/health` then inspect the template, or read the exported JSON.)
Editing the timeline and the split preview work **without** the template — only the
Generate button needs it.
(Find node ids via `GET /funpack/movie/api/health` then inspect the template, or read the
exported JSON.) Editing the timeline and the split preview work **without** the template —
only the Generate button needs it.
## Run
```bash
# 1. Start ComfyUI (with ComfyUI-FunPack installed) on the GPU box, e.g. :8188
# 2. Start the sidecar:
pip install -r movie_editor/backend/requirements.txt
FUNPACK_COMFY_URL=http://127.0.0.1:8188 \
python -m movie_editor.backend.app
# 3. Open http://127.0.0.1:8200
Nothing to start separately — the editor loads with ComfyUI:
```
1. Start ComfyUI normally (any --port). ComfyUI-FunPack registers the routes on load.
2. Open http://<comfyui-host>:<port>/funpack/movie/
```
### Environment
### Environment (all optional)
| Var | Default | Meaning |
|-----|---------|---------|
| `FUNPACK_COMFY_URL` | `http://127.0.0.1:8188` | ComfyUI base URL |
| `FUNPACK_MOVIE_HOST` / `_PORT` | `127.0.0.1` / `8200` | sidecar bind |
| `FUNPACK_MOVIE_DATA` | `~/.funpack_movie` | project store dir |
| `FUNPACK_MOVIE_TEMPLATE` | `backend/templates/ltxav_chain.api.json` | workflow template |
| `FUNPACK_MOVIE_CORS` | `*` | CORS origins |
| `FUNPACK_COMFY_URL` | auto (detected from the running server) | override only if loopback self-calls need a different address |
## How the timeline maps to Studio
@@ -66,7 +69,7 @@ first trigger) is the **anchor** prepended to every scene. The app therefore emi
before every scene: `intro_transition` separates the anchor from scene 1, then each scene is
preceded by the previous scene's transition. Empty markers fall back to generic `scene N`
labels. The **Split preview** panel shows exactly what Studio will see (live round-trip
through `/funpack/parse_timeline`) and warns if the scene count doesn't match.
through the same split logic Studio uses) and warns if the scene count doesn't match.
## Tests
+7
View File
@@ -0,0 +1,7 @@
"""FunPack Movie Editor — runs inside ComfyUI.
Importing `movie_editor.server` registers the /funpack/movie/* routes on ComfyUI's
aiohttp PromptServer. The root package __init__ does that at custom-node load time.
This module stays import-light so `movie_editor.backend.*` can be unit-tested without
ComfyUI present.
"""
-51
View File
@@ -1,51 +0,0 @@
"""FunPack Movie Editor sidecar — FastAPI app.
Run: uvicorn movie_editor.backend.app:app --host 127.0.0.1 --port 8200
(or: python -m movie_editor.backend.app)
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from . import config
from .routes import generate as generate_routes
from .routes import library as library_routes
from .routes import projects as project_routes
from .routes import timeline as timeline_routes
config.ensure_dirs()
app = FastAPI(title="FunPack Movie Editor", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(project_routes.router)
app.include_router(timeline_routes.router)
app.include_router(generate_routes.router)
app.include_router(library_routes.router)
@app.get("/api/health")
def health():
return {"ok": True, "comfy_url": config.COMFY_URL, "template": str(config.TEMPLATE_PATH)}
# Serve the frontend at root (mounted last so /api/* wins).
if config.FRONTEND_DIR.exists():
app.mount("/", StaticFiles(directory=str(config.FRONTEND_DIR), html=True), name="frontend")
def main():
import uvicorn
uvicorn.run(app, host=config.HOST, port=config.PORT)
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
"""Bridge to ComfyUI from inside its own process.
Parse/library calls go DIRECT to FunPack functions (no HTTP). Queue/history/view
go over loopback HTTP to ComfyUI's own server so we reuse its prompt validation and
output handling exactly.
"""
from __future__ import annotations
import uuid
from typing import Any, Optional
from . import config
# ── In-process FunPack calls (no HTTP) ───────────────────────────────────────
def _funpack_imports():
try:
from conditioning import parse_timeline_segments
from templates import apply_prompt_shortcuts, load_transition_db, transition_items
except ImportError: # imported as a package submodule
from ...conditioning import parse_timeline_segments # type: ignore
from ...templates import ( # type: ignore
apply_prompt_shortcuts, load_transition_db, transition_items,
)
return parse_timeline_segments, apply_prompt_shortcuts, load_transition_db, transition_items
def parse_timeline(prompt: str, seed: int = 0) -> dict:
"""{anchor, scenes, transitions} — the canonical split Studio will see."""
parse_timeline_segments, apply_prompt_shortcuts, *_ = _funpack_imports()
expanded, _applied = apply_prompt_shortcuts(str(prompt or ""), seed=int(seed or 0))
return parse_timeline_segments(expanded)
def transitions() -> dict:
_, _, load_transition_db, transition_items = _funpack_imports()
data = load_transition_db()
return {"data": data, "transitions": transition_items(data)}
# ── Loopback HTTP to ComfyUI (queue + results) ───────────────────────────────
def _url(path: str) -> str:
return f"{config.comfy_base_url()}{path}"
async def _session():
import aiohttp
return aiohttp.ClientSession()
class ComfyError(RuntimeError):
pass
async def queue_prompt(graph: dict, client_id: Optional[str] = None) -> dict:
payload = {"prompt": graph, "client_id": client_id or uuid.uuid4().hex}
async with await _session() as s:
async with s.post(_url("/prompt"), json=payload) as r:
data = await r.json()
if r.status >= 400 or data.get("node_errors"):
raise ComfyError(data.get("error") or data.get("node_errors") or f"HTTP {r.status}")
return data
async def history(prompt_id: str) -> dict:
async with await _session() as s:
async with s.get(_url(f"/history/{prompt_id}")) as r:
r.raise_for_status()
return await r.json()
async def is_running(prompt_id: str) -> bool:
async with await _session() as s:
async with s.get(_url("/queue")) as r:
r.raise_for_status()
state = await r.json()
for bucket in ("queue_running", "queue_pending"):
for item in state.get(bucket, []):
if len(item) > 1 and item[1] == prompt_id:
return True
return False
async def fetch_view(filename: str, subfolder: str = "", type_: str = "output") -> tuple[bytes, str]:
from urllib.parse import urlencode
q = urlencode({"filename": filename, "subfolder": subfolder, "type": type_})
async with await _session() as s:
async with s.get(_url(f"/view?{q}")) as r:
r.raise_for_status()
return await r.read(), r.headers.get("content-type", "application/octet-stream")
-101
View File
@@ -1,101 +0,0 @@
"""Async client for a running ComfyUI: native API + FunPack /funpack/* routes.
The sidecar stays light — no torch/comfy. Everything that needs the model goes
through ComfyUI over HTTP.
"""
from __future__ import annotations
import uuid
from typing import Any, Optional
import httpx
from . import config
class ComfyError(RuntimeError):
pass
def _url(path: str) -> str:
return f"{config.COMFY_URL}{path}"
async def _get_json(path: str, params: Optional[dict] = None) -> Any:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(_url(path), params=params)
r.raise_for_status()
return r.json()
async def _post_json(path: str, payload: dict) -> Any:
async with httpx.AsyncClient(timeout=60) as client:
r = await client.post(_url(path), json=payload)
r.raise_for_status()
return r.json()
# ── FunPack routes (parse/library; reuse Studio's logic server-side) ──────────
async def parse_timeline(prompt: str, seed: int = 0) -> dict:
"""{anchor, scenes, transitions} — the canonical split Studio will see."""
return await _post_json("/funpack/parse_timeline", {"prompt": prompt, "seed": seed})
async def transitions() -> dict:
return await _get_json("/funpack/transitions")
async def scenes() -> dict:
return await _get_json("/funpack/scenes")
async def shortcuts() -> dict:
return await _get_json("/funpack/shortcuts")
# ── Native ComfyUI API (queue + results) ─────────────────────────────────────
async def queue_prompt(graph: dict, client_id: Optional[str] = None) -> dict:
"""POST an API-format graph to /prompt. Returns {prompt_id, number, node_errors}."""
payload = {"prompt": graph, "client_id": client_id or uuid.uuid4().hex}
result = await _post_json("/prompt", payload)
if result.get("node_errors"):
raise ComfyError(f"ComfyUI rejected the graph: {result['node_errors']}")
return result
async def history(prompt_id: str) -> dict:
"""/history/{id} — empty dict until the prompt completes."""
return await _get_json(f"/history/{prompt_id}")
async def queue_state() -> dict:
return await _get_json("/queue")
async def is_running(prompt_id: str) -> bool:
state = await queue_state()
for bucket in ("queue_running", "queue_pending"):
for item in state.get(bucket, []):
# item is [number, prompt_id, prompt, extra, outputs]
if len(item) > 1 and item[1] == prompt_id:
return True
return False
def view_url(filename: str, subfolder: str = "", type_: str = "output") -> str:
"""Absolute URL for an output file, so the frontend can stream it via our proxy."""
from urllib.parse import urlencode
q = urlencode({"filename": filename, "subfolder": subfolder, "type": type_})
return _url(f"/view?{q}")
async def fetch_view(filename: str, subfolder: str = "", type_: str = "output") -> tuple[bytes, str]:
"""Download an output file (bytes, content-type) for proxying to the browser."""
from urllib.parse import urlencode
q = urlencode({"filename": filename, "subfolder": subfolder, "type": type_})
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(_url(f"/view?{q}"))
r.raise_for_status()
return r.content, r.headers.get("content-type", "application/octet-stream")
+32 -11
View File
@@ -1,14 +1,13 @@
"""Movie Editor sidecar configuration.
"""Movie Editor configuration.
All values are overridable via environment variables so the app can point at a
remote ComfyUI on a rental box without code changes.
The editor now runs INSIDE ComfyUI (routes registered on ComfyUI's aiohttp
PromptServer), so there is no separate server to bind. We only need storage paths
and the template location; self-HTTP calls target ComfyUI's own host:port, resolved
at runtime from comfy.cli_args.
"""
import os
from pathlib import Path
# ComfyUI inference backend (the GPU box). No trailing slash.
COMFY_URL = os.environ.get("FUNPACK_COMFY_URL", "http://127.0.0.1:8188").rstrip("/")
# Where the editor stores projects (JSON) and, later, cached assets/latents.
DATA_DIR = Path(os.environ.get("FUNPACK_MOVIE_DATA", Path.home() / ".funpack_movie"))
PROJECTS_DIR = DATA_DIR / "projects"
@@ -20,12 +19,34 @@ TEMPLATE_PATH = Path(
)
FRONTEND_DIR = BACKEND_DIR.parent / "frontend"
# Sidecar bind.
HOST = os.environ.get("FUNPACK_MOVIE_HOST", "127.0.0.1")
PORT = int(os.environ.get("FUNPACK_MOVIE_PORT", "8200"))
# Comma-separated CORS origins; "*" by default for local dev.
CORS_ORIGINS = [o.strip() for o in os.environ.get("FUNPACK_MOVIE_CORS", "*").split(",") if o.strip()]
def comfy_base_url() -> str:
"""Base URL of the ComfyUI server we run inside, for loopback /prompt calls.
The browser never needs this — the frontend is served by ComfyUI, so its API
calls are same-origin (relative) and hit the correct port automatically. This is
only for the backend talking to ComfyUI's own endpoints. We resolve the REAL port
(not the 8188 default) from the running server / CLI args; env override wins.
"""
override = os.environ.get("FUNPACK_COMFY_URL")
if override:
return override.rstrip("/")
port = None
# 1) The live server instance knows the port it actually bound.
try:
from server import PromptServer
port = getattr(PromptServer.instance, "port", None)
except Exception:
pass
# 2) CLI args (--port).
if not port:
try:
from comfy.cli_args import args
port = getattr(args, "port", None)
except Exception:
pass
port = int(port or 8188)
return f"http://127.0.0.1:{port}" # always loopback for self-calls
def ensure_dirs() -> None:
-3
View File
@@ -1,3 +0,0 @@
fastapi>=0.110
uvicorn[standard]>=0.29
httpx>=0.27
-124
View File
@@ -1,124 +0,0 @@
"""Generate: assemble prompt -> fill template -> queue ComfyUI -> track -> serve result.
V1 drives the EXISTING uniform chain in ONE pass: the whole timeline (minus excluded
scenes) generates together. Per-scene "generate only this" / true selective regen is
Phase 3 — it needs the per-scene latent store. We still accept the request shape now so
the frontend is stable; `only_scene` falls back to a single-scene combined prompt.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from typing import Optional
from .. import comfy_client, projects, workflow
from ..timeline import Project, build_combined_prompt
router = APIRouter(prefix="/api/projects", tags=["generate"])
class GenerateBody(BaseModel):
# Phase-3 forward-compat: a single scene id to generate in isolation. In V1 this
# builds a one-scene prompt (anchor + that scene) and runs the chain on it.
only_scene: Optional[str] = None
def _media_from_history(hist_entry: dict) -> list[dict]:
"""Pull output files (videos/gifs/images) from a /history entry's outputs."""
out: list[dict] = []
outputs = hist_entry.get("outputs", {})
for node_id, node_out in outputs.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 _project_for_request(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 HTTPException(404, f"Scene {only_scene} not found")
solo = Project.from_dict(p.to_dict())
solo.scenes = [scene]
return solo
@router.post("/{project_id}/generate")
async def generate(project_id: str, body: GenerateBody):
p = projects.get(project_id)
if p is None:
raise HTTPException(404, "Project not found")
target = _project_for_request(p, body.only_scene)
prompt = build_combined_prompt(target)
if not prompt.strip():
raise HTTPException(400, "Nothing to generate — timeline has no active scene text.")
try:
graph = workflow.load_template()
graph, applied = workflow.inject(graph, {
"prompt": prompt,
"seed": target.seed,
"num_frames_per_scene": target.num_frames_per_scene,
"frame_rate": target.frame_rate,
"max_scenes": target.max_scenes,
})
except workflow.WorkflowError as e:
raise HTTPException(400, str(e))
try:
result = await comfy_client.queue_prompt(graph)
except Exception as e:
raise HTTPException(502, f"Failed to queue on ComfyUI: {e}")
return {"prompt_id": result.get("prompt_id"), "injected": applied}
@router.get("/{project_id}/status/{prompt_id}")
async def status(project_id: str, prompt_id: str):
try:
hist = await comfy_client.history(prompt_id)
except Exception as e:
raise HTTPException(502, f"ComfyUI history unavailable: {e}")
if prompt_id in hist:
entry = hist[prompt_id]
media = _media_from_history(entry)
status_obj = entry.get("status", {})
return {
"state": "completed",
"media": media,
"status": status_obj,
}
try:
running = await comfy_client.is_running(prompt_id)
except Exception:
running = True
return {"state": "running" if running else "pending", "media": []}
@router.get("/{project_id}/result")
async def result(
project_id: str,
filename: str,
subfolder: str = "",
type: str = "output",
):
"""Proxy an output file so the browser can play it without CORS to ComfyUI."""
try:
data, content_type = await comfy_client.fetch_view(filename, subfolder, type)
except Exception as e:
raise HTTPException(502, f"Could not fetch result: {e}")
return Response(content=data, media_type=content_type)
-30
View File
@@ -1,30 +0,0 @@
"""Proxy the FunPack libraries (transitions/scenes/shortcuts) for editor palettes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from .. import comfy_client
router = APIRouter(prefix="/api/library", tags=["library"])
async def _safe(coro, what: str):
try:
return await coro
except Exception as e: # ComfyUI down / route missing -> empty, not fatal for editing
raise HTTPException(502, f"ComfyUI {what} unavailable: {e}")
@router.get("/transitions")
async def transitions():
return await _safe(comfy_client.transitions(), "transitions")
@router.get("/scenes")
async def scenes():
return await _safe(comfy_client.scenes(), "scenes")
@router.get("/shortcuts")
async def shortcuts():
return await _safe(comfy_client.shortcuts(), "shortcuts")
-49
View File
@@ -1,49 +0,0 @@
"""Project CRUD + timeline read/write."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import projects
from ..timeline import Project
router = APIRouter(prefix="/api/projects", tags=["projects"])
class CreateBody(BaseModel):
name: str = "Untitled"
@router.get("")
def list_all():
return {"projects": projects.list_projects()}
@router.post("")
def create(body: CreateBody):
return projects.create(body.name).to_dict()
@router.get("/{project_id}")
def get(project_id: str):
p = projects.get(project_id)
if p is None:
raise HTTPException(404, "Project not found")
return p.to_dict()
@router.put("/{project_id}")
def update(project_id: str, body: dict):
existing = projects.get(project_id)
if existing is None:
raise HTTPException(404, "Project not found")
body["id"] = project_id # id is immutable
body.setdefault("created_at", existing.created_at)
return projects.save(Project.from_dict(body)).to_dict()
@router.delete("/{project_id}")
def delete(project_id: str):
if not projects.delete(project_id):
raise HTTPException(404, "Project not found")
return {"deleted": project_id}
-38
View File
@@ -1,38 +0,0 @@
"""Timeline preview: assemble the combined prompt and show the canonical split.
This is the round-trip integrity check from the plan — the editor calls it live so
the user always sees exactly how Studio will split their timeline before generating.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from .. import comfy_client, projects
from ..timeline import build_combined_prompt
router = APIRouter(prefix="/api/projects", tags=["timeline"])
@router.get("/{project_id}/preview")
async def preview(project_id: str, include_excluded: bool = False):
p = projects.get(project_id)
if p is None:
raise HTTPException(404, "Project not found")
prompt = build_combined_prompt(p, include_excluded=include_excluded)
result = {"combined_prompt": prompt}
try:
parsed = await comfy_client.parse_timeline(prompt, seed=p.seed)
result["parsed"] = parsed
active = [s for s in p.scenes if include_excluded or not s.excluded]
expected = len(active)
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:
result["parse_error"] = f"ComfyUI parse_timeline unavailable: {e}"
return result
+18 -14
View File
@@ -1,5 +1,9 @@
// Thin backend client for the Movie Editor sidecar.
// Thin backend client. Served by ComfyUI, so all URLs are same-origin (relative):
// the browser automatically uses whatever host:port ComfyUI runs on.
(function () {
// Base = the directory this app is served from, e.g. /funpack/movie
const BASE = window.location.pathname.replace(/\/+$/, "").replace(/\/index\.html$/, "");
const API = (p) => `${BASE}/api${p}`;
async function j(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
@@ -15,30 +19,30 @@
return res.status === 204 ? null : res.json();
}
const API = {
health: () => j("GET", "/api/health"),
const ClientAPI = {
health: () => j("GET", API("/health")),
// projects
listProjects: () => j("GET", "/api/projects"),
createProject: (name) => j("POST", "/api/projects", { name }),
getProject: (id) => j("GET", `/api/projects/${id}`),
saveProject: (id, data) => j("PUT", `/api/projects/${id}`, data),
deleteProject: (id) => j("DELETE", `/api/projects/${id}`),
listProjects: () => j("GET", API("/projects")),
createProject: (name) => j("POST", API("/projects"), { name }),
getProject: (id) => j("GET", API(`/projects/${id}`)),
saveProject: (id, data) => j("PUT", API(`/projects/${id}`), data),
deleteProject: (id) => j("DELETE", API(`/projects/${id}`)),
// timeline preview
preview: (id, includeExcluded) =>
j("GET", `/api/projects/${id}/preview?include_excluded=${includeExcluded ? "true" : "false"}`),
j("GET", API(`/projects/${id}/preview?include_excluded=${includeExcluded ? "true" : "false"}`)),
// libraries
transitions: () => j("GET", "/api/library/transitions"),
transitions: () => j("GET", API("/library/transitions")),
// generate
generate: (id, onlyScene) => j("POST", `/api/projects/${id}/generate`, { only_scene: onlyScene || null }),
status: (id, promptId) => j("GET", `/api/projects/${id}/status/${promptId}`),
generate: (id, onlyScene) => j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
resultUrl: (id, m) =>
`/api/projects/${id}/result?filename=${encodeURIComponent(m.filename)}` +
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
`&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`,
};
window.MovieEditorAPI = API;
window.MovieEditorAPI = ClientAPI;
})();
+223
View File
@@ -0,0 +1,223 @@
"""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, config, projects, workflow
from .backend.timeline import Project, build_combined_prompt
UI_PREFIX = "/funpack/movie"
# ── 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 _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"
return web.Response(body=target.read_bytes(), content_type=ctype)
# ── 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(),
"template": str(config.TEMPLATE_PATH),
"template_exists": config.TEMPLATE_PATH.exists(),
})
@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.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})
# --- 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 ---
@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)})
# --- 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():
raise web.HTTPBadRequest(reason="Nothing to generate — no active scene text.")
try:
graph = workflow.load_template()
graph, applied = workflow.inject(graph, {
"prompt": prompt, "seed": target.seed,
"num_frames_per_scene": target.num_frames_per_scene,
"frame_rate": target.frame_rate, "max_scenes": target.max_scenes,
})
except workflow.WorkflowError as e:
raise web.HTTPBadRequest(reason=str(e))
try:
result = await bridge.queue_prompt(graph)
except Exception as e: # noqa: BLE001
raise web.HTTPBadGateway(reason=f"Failed to queue: {e}")
return web.json_response({"prompt_id": result.get("prompt_id"), "injected": applied})
@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])
# --- 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}/")