From e6410986fda469249a7ec525eecbd8ee7eaef6f1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:35:05 +0300 Subject: [PATCH] Movie Editor: anchor bug, error messages, sync-preview, save/load, cond/sampler roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bug: anchor prompt editing (selectedSceneId=null) no longer resets to scene 1 after autosave — commit() now only resets selection when a non-null id disappears. - Bug: generate errors now return {detail: '...'} JSON (400/502) so the frontend shows the actual reason (missing node, blocking port, etc.) instead of 'Bad Request'. - Transition autodetect: Split preview now shows the detected visual_effect badge on each seam; a '↺ Sync scenes from preview' button distributes the parsed anchor/scenes/ transitions back into the project (with confirm dialog). - Project save/load: File menu gains 'Save Project File…' (downloads .funpack_project.json) and 'Load Project File…' (file picker → POST /api/projects/import → new project, safe id, auto-opens). Useful for backup/restore when the ComfyUI instance is lost. - Conditioning/Sampler roles: Project model gains conditioning_slot + sampler_slot ('funpack' by default). Generate menu shows current selection and opens a numeric prompt picker to switch to any configured slot node. Stored in project JSON; full builder wiring is a future phase. Co-Authored-By: Claude Sonnet 4.6 --- movie_editor/backend/timeline.py | 6 +++ movie_editor/frontend/api.js | 2 + movie_editor/frontend/inspector.js | 24 +++++++++- movie_editor/frontend/menubar.js | 39 ++++++++++++++++ movie_editor/frontend/store.js | 73 ++++++++++++++++++++++++++++-- movie_editor/frontend/styles.css | 2 + movie_editor/server.py | 37 +++++++++++++-- 7 files changed, 173 insertions(+), 10 deletions(-) diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 5945ef2f..8f3b3a4b 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -98,6 +98,10 @@ class Project: width: int = 768 height: int = 512 max_scenes: int = 8 + # Pluggable role slots: "funpack" = built-in Studio/ChainSampler; any other value + # is a slot id from models.json. Stored now, full builder wiring in a future phase. + conditioning_slot: str = "funpack" + sampler_slot: str = "funpack" created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) @@ -115,6 +119,8 @@ class Project: width=int(d.get("width", 768)), height=int(d.get("height", 512)), max_scenes=int(d.get("max_scenes", 8)), + conditioning_slot=str(d.get("conditioning_slot", "funpack")), + sampler_slot=str(d.get("sampler_slot", "funpack")), created_at=float(d.get("created_at", time.time())), updated_at=float(d.get("updated_at", time.time())), ) diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 8dada37d..dbdf46e5 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -28,6 +28,8 @@ getProject: (id) => j("GET", API(`/projects/${id}`)), saveProject: (id, data) => j("PUT", API(`/projects/${id}`), data), deleteProject: (id) => j("DELETE", API(`/projects/${id}`)), + downloadProjectUrl: (id) => API(`/projects/${id}/download`), + importProject: (data) => j("POST", API("/projects/import"), data), // timeline preview preview: (id, includeExcluded) => diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 758f8b4f..0f0b3ade 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -143,9 +143,29 @@ if (pv.parse_error) { const w = el("div", "pv-warn"); w.append(el("span", null, "▲")); w.append(el("span", null, "ComfyUI offline — preview paused")); box.append(w); } const parsed = pv.parsed || {}; if (parsed.anchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); } - (parsed.scenes || []).forEach((s, i) => { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S" + (i + 1))); l.append(el("span", null, s.text || "(empty)")); box.append(l); }); + (parsed.scenes || []).forEach((s, i) => { + const l = el("div", "pv-line"); + l.append(el("span", "pv-badge", "S" + (i + 1))); + l.append(el("span", null, s.text || "(empty)")); + // Show detected transition after this scene + const t = (parsed.transitions || []).find((tr) => tr.after_scene === i); + if (t?.visual_effect) l.append(el("span", "pv-badge trans", "→ " + t.visual_effect)); + box.append(l); + }); const raw = el("details", "pv-raw"); raw.append(el("summary", null, "combined prompt")); raw.append(el("pre", null, pv.combined_prompt || "")); box.append(raw); - wrap.append(box); body.append(wrap); + wrap.append(box); + + // Sync button — lets the user push what was parsed back into the scene data + if ((parsed.scenes || []).length > 0) { + const syncBtn = el("button", "btn ghost tiny sync-preview-btn", "↺ Sync scenes from preview"); + syncBtn.title = "Distribute the parsed anchor / scene texts / transitions back into the timeline"; + syncBtn.onclick = () => { + if (confirm("This will overwrite scene texts and transitions with what the parser detected. Continue?")) + S.syncFromPreview(); + }; + wrap.append(syncBtn); + } + body.append(wrap); } function exposedControl(desc, value, on) { diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index 3bdf916f..5b9ef2d6 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -9,6 +9,15 @@ function sel() { return S.get().selectedSceneId; } function hasProject() { return !!S.get().project; } + // hidden file input for importing a project file + const importFileInput = (function () { + const inp = document.createElement("input"); + inp.type = "file"; inp.accept = ".json"; inp.style.display = "none"; + inp.onchange = () => { if (inp.files[0]) { S.importProject(inp.files[0]); inp.value = ""; } }; + document.body.appendChild(inp); + return inp; + })(); + async function restartComfy() { if (!confirm("Restart ComfyUI now?\n\nThe server will be down for ~10–40s and any running generation will be lost. This page reloads automatically when it's back.")) return; const ov = el("div", "restart-overlay"); @@ -41,6 +50,9 @@ { menulabel: "Open recent" }, ...(recent.length ? recent : [{ label: "No projects", disabled: true }]), { sep: true }, + { label: "Save Project File…", disabled: !hasProject(), hint: "⬇", action: () => S.downloadProject() }, + { label: "Load Project File…", action: () => importFileInput.click() }, + { sep: true }, { label: "Import Media…", soon: true, disabled: true }, { label: "Delete Current Project", danger: true, disabled: !hasProject(), action: () => { const p = S.get().project; if (p && confirm(`Delete "${p.name}"?`)) S.deleteProject(p.id); } }, @@ -63,6 +75,11 @@ { label: "Generate Whole Montage", hint: "▶", disabled: !hasProject(), action: () => S.generate(null) }, { label: "Generate Selected Scene", disabled: !sel(), action: () => S.generate(sel()) }, { sep: true }, + { label: `Conditioning: ${_roleLabel(st.project?.conditioning_slot, "FunPack Studio")}`, disabled: !hasProject(), + action: () => _pickRole("conditioning_slot", "Conditioning node", st) }, + { label: `Sampler: ${_roleLabel(st.project?.sampler_slot, "FunPack Chain Sampler")}`, disabled: !hasProject(), + action: () => _pickRole("sampler_slot", "Sampler node", st) }, + { sep: true }, { label: "Project Settings", disabled: !hasProject(), action: () => S.selectScene(null) }, { label: "Render / Export…", soon: true, disabled: true }, ], @@ -82,6 +99,28 @@ }; } + function _roleLabel(slotId, defaultLabel) { + if (!slotId || slotId === "funpack") return defaultLabel; + const slot = (S.get().models?.slots || []).find((s) => s.id === slotId); + return slot ? (slot.label || slot.node_class || slotId) : slotId; + } + + function _pickRole(field, title, st) { + closeAll(); + const slots = (st.models?.slots || []); + const cur = st.project?.[field] || "funpack"; + const opts = [{ id: "funpack", label: field === "conditioning_slot" ? "FunPack Studio (built-in)" : "FunPack Chain Sampler (built-in)" }, + ...slots.map((s) => ({ id: s.id, label: s.label || s.node_class || s.id }))]; + const chosen = window.prompt( + `${title}\n\nOptions:\n${opts.map((o, i) => `${i}: ${o.label}${o.id === cur ? " ✓" : ""}`).join("\n")}\n\nEnter number:`, + String(opts.findIndex((o) => o.id === cur))); + if (chosen == null || chosen === "") return; + const idx = parseInt(chosen, 10); + if (isNaN(idx) || idx < 0 || idx >= opts.length) return; + if (field === "conditioning_slot") S.setConditioningSlot(opts[idx].id); + else S.setSamplerSlot(opts[idx].id); + } + function closeAll() { openName = null; veil.hidden = true; render(); } function openMenu(name) { openName = name; veil.hidden = false; render(); } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 27d52e00..2f89866d 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -53,6 +53,32 @@ else notify(); } + function downloadProject() { + if (!state.project) return; + const a = document.createElement("a"); + a.href = API.downloadProjectUrl(state.project.id); + a.download = ""; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + } + + async function importProject(file) { + try { + const text = await file.text(); + const data = JSON.parse(text); + const p = await API.importProject(data); + await refreshProjectList(); + await loadProject(p.id); + } catch (e) { alert("Import failed: " + e.message); } + } + + // ── conditioning / sampler role ──────────────────────────────────────────────── + function setConditioningSlot(slotId) { + patchProject({ conditioning_slot: slotId || "funpack" }); + } + function setSamplerSlot(slotId) { + patchProject({ sampler_slot: slotId || "funpack" }); + } + // ── editing ──────────────────────────────────────────────────────────────── function scheduleSave() { state.saving = true; notify(); @@ -69,8 +95,10 @@ try { state.project = await API.saveProject(state.project.id, state.project); state.saving = false; - // keep selection valid - if (!state.project.scenes.some((s) => s.id === state.selectedSceneId)) + // Preserve selectedSceneId: null means "project settings" — keep it. + // Only reset to first scene if a NON-null id is no longer in the scene list. + if (state.selectedSceneId !== null && + !state.project.scenes.some((s) => s.id === state.selectedSceneId)) state.selectedSceneId = state.project.scenes[0]?.id || null; notify(); refreshProjectList(); @@ -154,6 +182,42 @@ notify(); scheduleSave(); } + // ── sync scenes from preview (distribute parsed anchor/transitions back) ────── + function syncFromPreview() { + if (!state.project || !state.preview) return; + const parsed = state.preview.parsed || {}; + const parsedScenes = parsed.scenes || []; + const parsedTrans = parsed.transitions || []; + if (!parsedScenes.length) return; + + // Sync anchor + if (parsed.anchor != null) state.project.anchor = parsed.anchor; + + // Sync scene texts (only if count matches to avoid destructive mismatches) + const activeScenes = state.project.scenes.filter((s) => !s.excluded); + if (parsedScenes.length === activeScenes.length) { + parsedScenes.forEach((ps, i) => { if (ps.text) activeScenes[i].text = ps.text; }); + } + + // Sync transitions: parsed.transitions[i] → scene[i].transition_to_next + // We derive the trigger from the library by matching visual_effect. + parsedTrans.forEach((t) => { + const idx = t.after_scene; + const sceneIdx = idx >= 0 ? idx : -1; + if (sceneIdx >= 0 && sceneIdx < activeScenes.length) { + const s = activeScenes[sceneIdx]; + if (!s.transition_to_next && t.visual_effect) { + const lib = (state.transitions || []).find((tr) => + (tr.visual_effect || "none") === t.visual_effect); + if (lib) s.transition_to_next = lib.trigger || lib.name || ""; + } + } + }); + + notify(); + scheduleSave(); + } + // ── preview ────────────────────────────────────────────────────────────────── let previewTimer = null; function refreshPreview() { @@ -298,10 +362,11 @@ window.Store = { get, set, subscribe, init, - refreshProjectList, loadProject, newProject, deleteProject, + refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject, patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, selectScene, addScene, removeScene, moveScene, scene, resizeScene, splitScene, snapFrames, - refreshPreview, generate, loadModels, loadImageTargets, setModelInput, setModelLink, + refreshPreview, syncFromPreview, generate, loadModels, loadImageTargets, setModelInput, setModelLink, + setConditioningSlot, setSamplerSlot, loadMedia, uploadMedia, deleteMedia, assignMediaToScene, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions, applyTransitionToSelection, insertShortcutIntoSelection, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 16bad628..833a0ea9 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -252,6 +252,8 @@ input[type=number] { font-family: var(--font-mono); } .pv-line { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12.5px; } .pv-badge { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; color: var(--teal); border: 1px solid #57d0c655; border-radius: 4px; padding: 1px 5px; flex: 0 0 auto; } .pv-badge.anchor { color: var(--amber); border-color: #f3a93c55; } +.pv-badge.trans { color: var(--faint); border-color: transparent; font-size: 9px; } +.sync-preview-btn { margin-top: 8px; width: 100%; } .pv-warn { color: var(--amber-2); font-size: 12px; display: flex; gap: 7px; margin-bottom: 8px; } .pv-raw { margin-top: 8px; } .pv-raw summary { cursor: pointer; color: var(--faint); font-size: 11px; font-family: var(--font-mono); } diff --git a/movie_editor/server.py b/movie_editor/server.py index 6607f875..99b70699 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -164,6 +164,21 @@ if web is not None and PromptServer is not None: body = await req.json() return web.json_response(projects.create(str(body.get("name") or "Untitled")).to_dict()) + @routes.post(UI_PREFIX + "/api/projects/import") + async def _import(req): + try: + body = await req.json() + except Exception: + return web.json_response({"detail": "Request body must be a project JSON."}, status=400) + if not isinstance(body, dict) or "scenes" not in body: + return web.json_response({"detail": "Payload does not look like a project file."}, status=400) + import time as _time + body.pop("id", None) + body["created_at"] = _time.time() + body["updated_at"] = _time.time() + p = projects.save(Project.from_dict(body)) + return web.json_response(p.to_dict()) + @routes.get(UI_PREFIX + "/api/projects/{pid}") async def _get(req): return web.json_response(_project_or_404(req.match_info["pid"]).to_dict()) @@ -184,6 +199,19 @@ if web is not None and PromptServer is not None: raise web.HTTPNotFound(reason=f"Project {pid} not found") return web.json_response({"deleted": pid}) + @routes.get(UI_PREFIX + "/api/projects/{pid}/download") + async def _download(req): + p = _project_or_404(req.match_info["pid"]) + safe_name = "".join(c if c.isalnum() or c in "._- " else "_" for c in p.name).strip()[:64] + filename = f"{safe_name or p.id}.funpack_project.json" + import json as _json + return web.Response( + body=_json.dumps(p.to_dict(), indent=2).encode(), + content_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store"}, + ) + # --- API: timeline preview (round-trip integrity check) --- @routes.get(UI_PREFIX + "/api/projects/{pid}/preview") async def _preview(req): @@ -336,11 +364,11 @@ if web is not None and PromptServer is not None: 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.") + return web.json_response({"detail": "Nothing to generate — no active scene text."}, status=400) try: oi = await bridge.object_info() except Exception as e: # noqa: BLE001 - raise web.HTTPBadGateway(reason=f"object_info unavailable: {e}") + return web.json_response({"detail": f"Node registry unavailable: {e}"}, status=502) graph, report = builder.build(oi, nodes.load_models(), { "prompt": prompt, "seed": target.seed, "num_frames_per_scene": target.num_frames_per_scene, @@ -348,11 +376,12 @@ if web is not None and PromptServer is not None: "width": target.width, "height": target.height, }, media=_prepare_media(target)) if report["blocking"]: - raise web.HTTPBadRequest(reason="Incomplete config — " + "; ".join(report["blocking"])) + detail = "Generation blocked — " + "; ".join(report["blocking"]) + return web.json_response({"detail": detail, "report": report}, status=400) try: result = await bridge.queue_prompt(graph) except Exception as e: # noqa: BLE001 - raise web.HTTPBadGateway(reason=f"Failed to queue: {e}") + return web.json_response({"detail": f"Failed to queue with ComfyUI: {e}"}, status=502) return web.json_response({"prompt_id": result.get("prompt_id"), "report": report}) @routes.get(UI_PREFIX + "/api/projects/{pid}/status/{prompt_id}")