Movie Editor: anchor bug, error messages, sync-preview, save/load, cond/sampler roles

- 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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-06 23:35:05 +03:00
co-authored by Claude Sonnet 4.6
parent c237558571
commit e6410986fd
7 changed files with 173 additions and 10 deletions
+6
View File
@@ -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())),
)
+2
View File
@@ -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) =>
+22 -2
View File
@@ -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) {
+39
View File
@@ -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(); }
+69 -4
View File
@@ -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,
+2
View File
@@ -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); }
+33 -4
View File
@@ -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}")