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:
co-authored by
Claude Opus 4.8
parent
d133fde995
commit
7b4162c0be
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
httpx>=0.27
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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}/")
|
||||
Reference in New Issue
Block a user