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:
co-authored by
Claude Opus 4.8
parent
0ce686c427
commit
468a349204
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user