From 7b4162c0be07aca704cbb7af2f45475c65b13ac1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:41:59 +0300 Subject: [PATCH] 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 --- __init__.py | 4 + movie_editor/README.md | 47 ++--- movie_editor/__init__.py | 7 + movie_editor/backend/app.py | 51 ------ movie_editor/backend/bridge.py | 92 ++++++++++ movie_editor/backend/comfy_client.py | 101 ----------- movie_editor/backend/config.py | 43 +++-- movie_editor/backend/requirements.txt | 3 - movie_editor/backend/routes/__init__.py | 0 movie_editor/backend/routes/generate.py | 124 ------------- movie_editor/backend/routes/library.py | 30 ---- movie_editor/backend/routes/projects.py | 49 ------ movie_editor/backend/routes/timeline.py | 38 ---- movie_editor/frontend/api.js | 32 ++-- movie_editor/server.py | 223 ++++++++++++++++++++++++ 15 files changed, 401 insertions(+), 443 deletions(-) create mode 100644 movie_editor/__init__.py delete mode 100644 movie_editor/backend/app.py create mode 100644 movie_editor/backend/bridge.py delete mode 100644 movie_editor/backend/comfy_client.py delete mode 100644 movie_editor/backend/requirements.txt delete mode 100644 movie_editor/backend/routes/__init__.py delete mode 100644 movie_editor/backend/routes/generate.py delete mode 100644 movie_editor/backend/routes/library.py delete mode 100644 movie_editor/backend/routes/projects.py delete mode 100644 movie_editor/backend/routes/timeline.py create mode 100644 movie_editor/server.py diff --git a/__init__.py b/__init__.py index afdfa27d..766ebea9 100644 --- a/__init__.py +++ b/__init__.py @@ -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 ( diff --git a/movie_editor/README.md b/movie_editor/README.md index 5acb5fdf..6a033c8a 100644 --- a/movie_editor/README.md +++ b/movie_editor/README.md @@ -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://:/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://:/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 diff --git a/movie_editor/__init__.py b/movie_editor/__init__.py new file mode 100644 index 00000000..d067c823 --- /dev/null +++ b/movie_editor/__init__.py @@ -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. +""" diff --git a/movie_editor/backend/app.py b/movie_editor/backend/app.py deleted file mode 100644 index 6ac58cf0..00000000 --- a/movie_editor/backend/app.py +++ /dev/null @@ -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() diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py new file mode 100644 index 00000000..71d2b9ae --- /dev/null +++ b/movie_editor/backend/bridge.py @@ -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") diff --git a/movie_editor/backend/comfy_client.py b/movie_editor/backend/comfy_client.py deleted file mode 100644 index c73b18ff..00000000 --- a/movie_editor/backend/comfy_client.py +++ /dev/null @@ -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") diff --git a/movie_editor/backend/config.py b/movie_editor/backend/config.py index d1cf2351..4de982c0 100644 --- a/movie_editor/backend/config.py +++ b/movie_editor/backend/config.py @@ -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: diff --git a/movie_editor/backend/requirements.txt b/movie_editor/backend/requirements.txt deleted file mode 100644 index a2b84601..00000000 --- a/movie_editor/backend/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -fastapi>=0.110 -uvicorn[standard]>=0.29 -httpx>=0.27 diff --git a/movie_editor/backend/routes/__init__.py b/movie_editor/backend/routes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/movie_editor/backend/routes/generate.py b/movie_editor/backend/routes/generate.py deleted file mode 100644 index b5e244fb..00000000 --- a/movie_editor/backend/routes/generate.py +++ /dev/null @@ -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) diff --git a/movie_editor/backend/routes/library.py b/movie_editor/backend/routes/library.py deleted file mode 100644 index 4e345690..00000000 --- a/movie_editor/backend/routes/library.py +++ /dev/null @@ -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") diff --git a/movie_editor/backend/routes/projects.py b/movie_editor/backend/routes/projects.py deleted file mode 100644 index 06b90005..00000000 --- a/movie_editor/backend/routes/projects.py +++ /dev/null @@ -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} diff --git a/movie_editor/backend/routes/timeline.py b/movie_editor/backend/routes/timeline.py deleted file mode 100644 index d0428af8..00000000 --- a/movie_editor/backend/routes/timeline.py +++ /dev/null @@ -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 diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 6e16e4d0..315d4df3 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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; })(); diff --git a/movie_editor/server.py b/movie_editor/server.py new file mode 100644 index 00000000..f65c9a52 --- /dev/null +++ b/movie_editor/server.py @@ -0,0 +1,223 @@ +"""Movie Editor routes, served by ComfyUI's own aiohttp server. + +UI: GET /funpack/movie/ (and static assets under /funpack/movie/) +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}/")