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:
co-authored by
Claude Sonnet 4.6
parent
c237558571
commit
e6410986fd
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user