Movie Editor: ephemeral renders + Export/Save dialogs; fix global-prompt splitting

Renders are now ephemeral — VHS writes to ComfyUI's temp dir (save_output=False)
and the final stitch is written to temp too; both clear on ComfyUI restart.
Persisting is the user's job:
- 'Export' toolbar button saves the selected clip's render via a Save dialog
  (showSaveFilePicker where available, else download). Rendered clips show a green
  dot; the button is disabled until the selected clip has a render.
- 'Render Final Video' stitches available runs to temp and opens a Save dialog.

Fix regression from the shortcut-verbatim change: scene/transition structure can
be produced by shortcut EXPANSION, so the global-prompt / sync now take structure
from whichever split yields more scenes (expanded wins when raw under-splits),
keeping verbatim text only when the raw split structures the prompt equally well.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 09:35:12 +03:00
co-authored by Claude Opus 4.8
parent 0ce686c427
commit 468a349204
5 changed files with 85 additions and 24 deletions
+5 -3
View File
@@ -220,9 +220,11 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
graph["sampler"]["inputs"]["seed"] = params["seed"]
if params.get("max_scenes") is not None:
graph["sampler"]["inputs"]["max_scenes"] = params["max_scenes"]
# the result must be written to the output dir so the editor can fetch it back
# (the reference graph used preview-only save_output=False).
graph["vhs"]["inputs"]["save_output"] = True
# UI renders are EPHEMERAL: write to ComfyUI's temp dir (cleared on restart), not
# the output dir. Persisting a result is the user's job via Export (Save dialog).
# save_output=False routes VHS_VideoCombine to temp; history reports type="temp",
# which the result proxy + final-render concat resolve against the temp directory.
graph["vhs"]["inputs"]["save_output"] = False
# split_by_transitions MUST be True — Studio defaults to False (single-scene mode)
# but the Movie Editor always builds multi-scene combined prompts. Force it here
# BEFORE the user's studio_inputs override block so it cannot be accidentally
+64 -17
View File
@@ -216,13 +216,14 @@
// ── sync scenes from preview (distribute parsed anchor/transitions back) ──────
function syncFromPreview() {
if (!state.project || !state.preview) return;
// raw = verbatim split (shortcuts preserved); exp = expanded (transition detection)
// structure from whichever split defines more scenes; verbatim text on a tie.
const raw = state.preview.parsed_raw || state.preview.parsed || {};
const exp = state.preview.parsed || raw;
const parsedScenes = raw.scenes || [];
const src = _structureSource(raw, exp);
const parsedScenes = src.scenes || [];
if (!parsedScenes.length) return;
if (raw.anchor != null) state.project.anchor = raw.anchor;
if (src.anchor != null) state.project.anchor = src.anchor;
// Sync scene texts verbatim (only if count matches to avoid destructive mismatches)
const activeScenes = state.project.scenes.filter((s) => !s.excluded);
@@ -231,7 +232,7 @@
}
// Transitions: only fill seams the user hasn't set, mapped via the library.
const trans = (((exp.scenes || []).length === parsedScenes.length ? exp.transitions : raw.transitions) || []);
const trans = (((exp.scenes || []).length === parsedScenes.length ? exp.transitions : src.transitions) || []);
trans.forEach((t) => {
const idx = t.after_scene;
if (idx >= 0 && idx < activeScenes.length && t.visual_effect) {
@@ -257,30 +258,40 @@
let res;
try { res = await API.parsePrompt(state.project.id, text); }
catch (e) { alert("Could not parse the global prompt: " + e.message); return; }
// raw = verbatim split (shortcuts kept in scene text); exp = expanded split, used
// ONLY to detect transitions (incl. those a shortcut expands into).
const raw = res.parsed_raw || res.parsed;
// raw = verbatim split (shortcuts kept in text); exp = expanded split. The
// STRUCTURE (scene count / anchor / transitions) can be created by shortcut
// expansion, so take it from whichever splits MORE. Verbatim text is kept only
// when the raw split structures the prompt just as well as the expanded one.
const raw = res.parsed_raw || res.parsed || {};
const exp = res.parsed || raw;
if (!raw || !(raw.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; }
const src = _structureSource(raw, exp);
if (!(src.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; }
if (raw.anchor != null) state.project.anchor = raw.anchor;
if (src.anchor != null) state.project.anchor = src.anchor;
const old = state.project.scenes || [];
const next = (raw.scenes || []).map((ps, i) => {
const base = old[i] ? JSON.parse(JSON.stringify(old[i])) : { source: { type: "empty" }, excluded: false };
base.text = ps.text || ""; // verbatim — shortcuts preserved
const next = (src.scenes || []).map((ps, i) => {
const base = old[i] ? JSON.parse(JSON.stringify(old[i])) : { source: { type: "carry" }, excluded: false };
base.text = ps.text || "";
base.transition_to_next = "";
return base;
});
_applyDetectedTransitions(next, raw, exp);
_applyDetectedTransitions(next, src, exp);
state.project.scenes = next;
state.selectedSceneId = null; // show project view so the result is visible
notify(); scheduleSave();
}
// Pick the split that defines the most scenes — prefer raw (verbatim) on a tie so
// descriptive shortcuts stay unexpanded; fall back to expanded when it splits more.
function _structureSource(raw, exp) {
const rawN = (raw.scenes || []).length, expN = (exp.scenes || []).length;
return (rawN >= expN && rawN > 0) ? raw : exp;
}
// Map detected transitions (visual_effect) onto scene seams via the library. Prefers
// the expanded split (catches shortcut→transition) when its scene count matches.
function _applyDetectedTransitions(scenes, raw, exp) {
const trans = (((exp.scenes || []).length === scenes.length ? exp.transitions : raw.transitions) || []);
function _applyDetectedTransitions(scenes, src, exp) {
const trans = (((exp.scenes || []).length === scenes.length ? exp.transitions : src.transitions) || []);
trans.forEach((t) => {
const idx = t.after_scene;
if (idx >= 0 && idx < scenes.length && t.visual_effect) {
@@ -425,6 +436,40 @@
set({ gen: { state: "done", promptId: null, media: state.gen.media, msg: `${runs.length} run(s) generated — use Render Final Video to stitch them.` } });
}
// Fetch a render and offer a Save dialog (File System Access API where available,
// else a normal download). Renders live in ComfyUI's temp dir, so persisting one
// is the user's job — this is how they do it.
async function _saveBlobAs(url, name) {
let blob;
try { const r = await fetch(url); if (!r.ok) throw new Error(r.statusText); blob = await r.blob(); }
catch (e) { alert("Could not fetch the render: " + e.message); return; }
if (window.showSaveFilePicker) {
try {
const h = await window.showSaveFilePicker({
suggestedName: name,
types: [{ description: "MP4 video", accept: { "video/mp4": [".mp4"] } }],
});
const w = await h.createWritable(); await w.write(blob); await w.close();
return;
} catch (e) { if (e && e.name === "AbortError") return; } // cancelled, or unsupported → fall through
}
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}
// Export the render covering the selected clip via a Save dialog.
async function exportSelected() {
if (!state.project) return;
const id = state.selectedSceneId;
if (!id) { alert("Select a clip to export."); return; }
const seg = (state.renderedSegments || []).find((s) => s.media && (s.sceneIds || []).includes(id));
if (!seg) { alert("That clip hasn't been generated yet — generate it first."); return; }
const idx = state.project.scenes.findIndex((s) => s.id === id);
await _saveBlobAs(API.resultUrl(state.project.id, seg.media), `scene_${idx >= 0 ? idx + 1 : "x"}.mp4`);
}
// Stitch the rendered runs (hard cut, video + audio) into one final file.
async function renderFinal() {
if (!state.project) return;
@@ -439,7 +484,9 @@
const r = await API.renderFinal(state.project.id, clips);
// Show the stitched file across the whole timeline in the program monitor.
state.renderedSegments = [{ sceneIds: order, media: r.media }];
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} run(s).` } });
set({ gen: { state: "done", promptId: null, media: [r.media], msg: `Final video rendered from ${r.clips} run(s) — saving…` } });
const name = (state.project.name || "montage").replace(/\s+/g, "_") + ".mp4";
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Render failed: " + e.message } });
}
@@ -547,7 +594,7 @@
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, selectScene, addScene, removeScene, moveScene, scene,
resizeScene, splitScene, snapFrames,
refreshPreview, syncFromPreview, applyGlobalPrompt, generate, generateMontage, renderFinal, loadModels, loadImageTargets, setModelInput, setModelLink,
refreshPreview, syncFromPreview, applyGlobalPrompt, generate, generateMontage, renderFinal, exportSelected, loadModels, loadImageTargets, setModelInput, setModelLink,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, setStudioInput, setStudioInputNow,
loadMedia, uploadMedia, deleteMedia, assignMediaToScene,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions,
+3
View File
@@ -346,6 +346,9 @@ input[type=number] { font-family: var(--font-mono); }
.clip.selected::before { background: var(--teal); opacity: 1; }
.clip.excluded { opacity: .42; }
.clip.excluded::before { background: var(--faint); }
/* a clip that has a rendered video available (in temp) */
.clip.rendered::after { content: "●"; position: absolute; top: 4px; right: 6px; font-size: 7px;
color: #57c98e; text-shadow: 0 0 5px rgba(87,201,142,.7); pointer-events: none; z-index: 4; }
.clip.trimming { border-color: var(--amber); z-index: 5; }
.clip-head { display: flex; align-items: center; gap: 6px; }
.clip-no { font-family: var(--font-mono); font-size: 11px; color: var(--amber); font-weight: 600; }
+9 -2
View File
@@ -25,6 +25,9 @@
const sFrames = (sc, p) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
const sDur = (sc, p) => sFrames(sc, p) / sFps(sc, p);
const hasRender = (st, sceneId) =>
(st.renderedSegments || []).some((s) => s.media && (s.sceneIds || []).includes(sceneId));
const p2 = (n) => String(n).padStart(2, "0");
function timecode(sec, fps) {
sec = Math.max(0, sec);
@@ -70,7 +73,7 @@
// ── clip ───────────────────────────────────────────────────────────────────────
function clipEl(st, p, scene, index, leftPx, widthPx) {
const clip = el("div", "clip" + (scene.id === st.selectedSceneId ? " selected" : "") + (scene.excluded ? " excluded" : ""));
const clip = el("div", "clip" + (scene.id === st.selectedSceneId ? " selected" : "") + (scene.excluded ? " excluded" : "") + (hasRender(st, scene.id) ? " rendered" : ""));
clip.style.left = leftPx + "px";
clip.style.width = Math.max(widthPx, 8) + "px";
clip.onclick = () => S.selectScene(scene.id);
@@ -191,7 +194,11 @@
const del = el("button", "btn ghost tiny danger", "✕ Remove");
del.title = "Remove the selected clip (Delete / Backspace)"; del.disabled = !hasSel;
del.onclick = () => { if (st.selectedSceneId) S.removeScene(st.selectedSceneId); };
bar.append(split); bar.append(del);
const exp = el("button", "btn ghost tiny", "⤓ Export");
exp.title = "Save the selected clip's rendered video to disk (renders are temporary)";
exp.disabled = !(hasSel && hasRender(st, st.selectedSceneId));
exp.onclick = () => S.exportSelected();
bar.append(split); bar.append(del); bar.append(exp);
const spacer = el("div", "tl-spacer"); bar.append(spacer);
const keys = el("span", "tl-keys", "S split · ⌫ remove"); keys.title = "Select a clip, then: S splits it at the playhead · Delete/Backspace removes it";
+4 -2
View File
@@ -535,8 +535,10 @@ if web is not None and PromptServer is not None:
if missing:
return web.json_response({"detail": f"{len(missing)} clip file(s) not found on disk — regenerate then render."}, status=400)
# The final render is ALSO ephemeral (temp dir) — the user persists it via the
# Export Save dialog, not by us writing to the output directory.
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(outdir, out_name)
out_path = os.path.join(tempdir, out_name)
# concat demuxer + re-encode → tolerates differing codecs/params between runs
# and keeps BOTH the video and the LTXAV audio stream.
list_fd, list_path = tempfile.mkstemp(suffix=".txt")
@@ -561,7 +563,7 @@ if web is not None and PromptServer is not None:
except OSError:
pass
return web.json_response({
"media": {"filename": out_name, "subfolder": "", "type": "output", "kind": "videos"},
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": len(paths),
})