From 77bf2f8439b8eecc4103eda478d2a3b7350dccd1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:15:46 +0300 Subject: [PATCH] Movie Editor: fix multi-scene runs + anchor/export/fallback + studio toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1 Multi-scene run only generated scene 1: a carry scene has no leading transition so Studio merged it. Generation prompt now injects a separator before any scene lacking a leading trigger (transition_to_next or generic 'scene N') so Studio splits into exactly one conditioning per scene. Display/global prompt stays verbatim (for_generation flag). - #2 'Use as anchor' no longer defaults to scene 1 — requires picking a scene, so it can't silently overwrite an existing image. Anchor capture drops the obsolete target. - #3 Export uses unique names: _sceneN.mp4. - #4 A scene whose image was deleted falls back to carry (i2v guides) in run splitting. - #5 Timeline/inspector edit-guard could stick (focused control removed w/o focusout), freezing refresh. Guards now self-clear when focus is actually gone. - #6 Expose Studio refiner toggles (vision conditioning, reference injection, prompt repair) in the inspector -> studio_settings.refiner. - #7 Embed-guidance mode (relative/absolute) now selectable (sampler combo knob). Co-Authored-By: Claude Opus 4.8 --- movie_editor/backend/timeline.py | 64 ++++++++++++++++++++++++------ movie_editor/frontend/inspector.js | 34 +++++++++++++++- movie_editor/frontend/player.js | 18 ++++----- movie_editor/frontend/store.js | 15 ++++++- movie_editor/frontend/timeline.js | 8 +++- movie_editor/server.py | 2 +- 6 files changed, 114 insertions(+), 27 deletions(-) diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index d30f6da3..8a9d9989 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -171,24 +171,64 @@ class Project: return d -def build_combined_prompt(project: Project, include_excluded: bool = False) -> str: - """The verbatim master prompt = anchor + scene texts, joined with single spaces. +_TRIGGER_RE = None +_TRIGGER_RE_BUILT = False - The editor stores each scene as a VERBATIM chunk of the global prompt: transition - triggers and shortcuts stay in-text exactly as the user typed them, so concatenating - anchor + scenes reproduces the global prompt. No markers are injected and nothing is - expanded here — Studio expands shortcuts, splits by transitions and applies the - anchor at generation time (its normal pipeline). `include_excluded=False` drops - scenes flagged excluded (their chunk is omitted from the prompt). + +def _leading_trigger_re(): + """Regex matching a leading transition trigger (direct DB trigger or generic 'scene N' + label) at the start of a scene's text. Cached. None if nothing is available.""" + global _TRIGGER_RE, _TRIGGER_RE_BUILT + if _TRIGGER_RE_BUILT: + return _TRIGGER_RE + _TRIGGER_RE_BUILT = True + import re + try: + try: + from templates import load_custom_transition_triggers + except ImportError: + from ...templates import load_custom_transition_triggers # type: ignore + trigs = list(load_custom_transition_triggers().keys()) + except Exception: + trigs = [] + parts = [re.escape(t) for t in sorted(trigs, key=len, reverse=True)] + parts.append(r"scene\s+[-+]?\d+") # built-in generic split label + _TRIGGER_RE = re.compile(r"^\s*(?:" + "|".join(parts) + r")\b", re.IGNORECASE) + return _TRIGGER_RE + + +def build_combined_prompt(project: Project, include_excluded: bool = False, + for_generation: bool = False) -> str: + """The master prompt = anchor + scene texts. + + Display (for_generation=False): a verbatim join — transition triggers/shortcuts stay + in-text exactly as typed, so it round-trips with the global prompt. + + Generation (for_generation=True): Studio must split into EXACTLY one scene per timeline + scene, but a `carry` scene's text has no leading transition, so Studio would merge it + with the previous one (only the first scene gets generated). So before any scene whose + text doesn't already begin with a transition trigger we inject a separator (the seam's + transition_to_next, else the generic 'scene N' label) to force the split. This affects + only what's sent to Studio, never the displayed global prompt. """ + scenes = [s for s in project.scenes if include_excluded or not s.excluded] parts: list[str] = [] anchor = (project.anchor or "").strip() if anchor: parts.append(anchor) - for scene in project.scenes: - if not include_excluded and scene.excluded: - continue + trig_re = _leading_trigger_re() if for_generation else None + for i, scene in enumerate(scenes): text = (scene.text or "").strip() + if for_generation: + has_lead = bool(text and trig_re and trig_re.match(text)) + if not has_lead: + if i == 0: + marker = (project.intro_transition or "").strip() + else: + marker = (scenes[i - 1].transition_to_next or "").strip() + if not marker: + marker = f"scene {i + 1}" + parts.append(marker) if text: parts.append(text) - return " ".join(parts).strip() + return " ".join(p for p in parts if p).strip() diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 48383ebe..b3e91f84 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -227,6 +227,13 @@ if (!p.sampler_slot || p.sampler_slot === "funpack") renderSamplerSettings(st); } + // Studio refiner flags (live in studio_settings.refiner). Booleans the editor exposes. + const STUDIO_REFINER_FLAGS = [ + { name: "vision_conditioning", label: "Vision conditioning", default: true }, + { name: "reference_injection", label: "Reference injection", default: false }, + { name: "prompt_repair", label: "Prompt repair", default: true }, + ]; + function renderStudioSettings(st) { const p = st.project; const si = p.studio_inputs || {}; @@ -234,6 +241,20 @@ try { curSettings = JSON.parse(si.studio_settings || "{}"); } catch (_) {} const samplers = curSettings.samplers || null; + // Studio refiner toggles + const rfTag = el("div", "insp-tag sp-sub"); rfTag.textContent = "Studio · refiner"; body.append(rfTag); + const rf = (curSettings.refiner && typeof curSettings.refiner === "object") ? curSettings.refiner : {}; + STUDIO_REFINER_FLAGS.forEach((f) => { + const cur = rf[f.name] != null ? rf[f.name] : f.default; + const ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!cur; ctrl.style.width = "auto"; + ctrl.dataset.k = "rf-" + f.name; + ctrl.onchange = () => { + const next = { ...curSettings, refiner: { ...rf, [f.name]: ctrl.checked } }; + S.setStudioInputNow("studio_settings", JSON.stringify(next)); + }; + body.append(field(f.label, ctrl)); + }); + const tag = el("div", "insp-tag sp-sub"); tag.textContent = "Studio · sampler algorithm"; body.append(tag); function persist(updatedSamplers, quiet) { @@ -261,6 +282,7 @@ { name: "mid_scene_guide", label: "Mid-scene guide", kind: "bool", default: false }, { name: "mid_scene_guide_strength", label: "Guide strength", kind: "float", default: 0.25, min: 0, max: 1, step: 0.05, dependsOn: "mid_scene_guide" }, { name: "embed_guidance", label: "Embed guidance", kind: "bool", default: false }, + { name: "embed_guidance_source", label: "Embed mode", kind: "combo", choices: ["relative", "absolute"], default: "relative", dependsOn: "embed_guidance" }, { name: "embed_guidance_strength", label: "Embed strength", kind: "float", default: 0.02, min: 0, max: 0.5, step: 0.005, dependsOn: "embed_guidance" }, { name: "decode_noise_scale", label: "Decode noise scale", kind: "float", default: 0.0, min: 0, max: 1, step: 0.01 }, { name: "decode_timestep", label: "Decode timestep", kind: "float", default: 0.05, min: 0, max: 1, step: 0.01 }, @@ -283,6 +305,10 @@ ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!val; ctrl.style.width = "auto"; ctrl.dataset.k = "si-" + k.name; ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.checked); + } else if (k.kind === "combo") { + ctrl = el("select"); ctrl.dataset.k = "si-" + k.name; + (k.choices || []).forEach((c) => { const o = el("option", null, c); o.value = c; if (c === val) o.selected = true; ctrl.append(o); }); + ctrl.onchange = () => S.setSamplerInputNow(k.name, ctrl.value); } else { ctrl = el("input"); ctrl.type = "number"; if (k.step != null) ctrl.step = String(k.step); @@ -438,7 +464,13 @@ let _editing = false; function render(st) { - if (_editing) return; // editing in progress — leave the DOM (and the user's caret) alone + if (_editing) { + // Only skip if a field is actually still focused; else the flag got stuck (focused + // element removed without focusout) — clear it so the inspector resumes updating. + const a = document.activeElement; + if (a && body.contains(a) && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT") && a.dataset.k) return; + _editing = false; + } const scrollTop = body.scrollTop; // preserve scroll across the rebuild clear(body); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 1ccdeaf3..44dd2f95 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -236,18 +236,19 @@ const anchorSel = el("select", "pm-anchor-sel"); anchorSel.title = "Scene that will receive this frame as its i2v anchor"; - // Default: currently selected scene, or first scene - const curId = S.get().selectedSceneId; + // Require an explicit choice so the capture never silently overwrites a scene's + // existing image (defaulting to scene 1 was clobbering it). + anchorSel.append(new Option("— apply to scene… —", "")); scenes.forEach((s, i) => { const label = `Scene ${i + 1}` + (s.text ? ": " + s.text.substring(0, 22) : ""); - const o = new Option(label, s.id); - if (s.id === curId) o.selected = true; - anchorSel.append(o); + anchorSel.append(new Option(label, s.id)); }); const anchorBtn = el("button", "btn ghost tiny pm-anchor-btn", "📌 Use as anchor"); - anchorBtn.title = "Capture this frame and set it as the i2v starting image for the selected scene"; + anchorBtn.title = "Capture this frame and set it as the i2v starting image for the chosen scene"; anchorBtn.onclick = async () => { + const sceneId = anchorSel.value; + if (!sceneId) { alert("Pick which scene to apply this frame to first."); return; } anchorBtn.disabled = true; anchorBtn.textContent = "Capturing…"; try { const blob = await window.Player.captureFrame(); @@ -256,7 +257,6 @@ alert("No video frame available — make sure the playhead is on a rendered segment."); return; } - const sceneId = anchorSel.value; const sc = scenes.find((s) => s.id === sceneId); const name = `anchor_scene${sc ? (S.get().project.scenes.indexOf(sc) + 1) : ""}_${Date.now()}.png`; const file = new File([blob], name, { type: "image/png" }); @@ -265,9 +265,7 @@ const asset = bin[bin.length - 1]; if (!asset) { alert("Upload failed."); return; } // Store as generated_frame type (builder accepts it like "image") - S.patchScene(sceneId, { - source: { type: "generated_frame", media_ref: asset.id, target: "port:FunPackStudio.source_image" }, - }); + S.patchScene(sceneId, { source: { type: "generated_frame", media_ref: asset.id } }); anchorBtn.textContent = "✓ Applied"; setTimeout(() => _renderTransport(), 1600); } catch (e) { diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index aba7258e..6e5d2886 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -362,12 +362,22 @@ // Split the active scenes into chain runs. A run starts at an anchored scene // (empty / image / generated_frame); a "carry" scene appends to the current run // so it overlaps the previous scene (one chain-sampler request per run). + // True if a scene's image anchor still exists in the media bin (deleted image → fall + // back to carry / i2v guides, not a broken anchor). + function _anchorAvailable(s) { + const t = s.source && s.source.type; + if (t !== "image" && t !== "generated_frame") return false; + const ref = s.source.media_ref; + return !!(ref && (state.mediaBin || []).some((m) => m.id === ref)); + } function _runs() { const active = state.project.scenes.filter((s) => !s.excluded); const runs = []; for (const s of active) { const t = (s.source && s.source.type) || "empty"; - if (t === "carry" && runs.length) runs[runs.length - 1].push(s.id); + // carry OR an anchor whose image is gone -> continue the previous run (i2v guides). + const isCarry = t === "carry" || ((t === "image" || t === "generated_frame") && !_anchorAvailable(s)); + if (isCarry && runs.length) runs[runs.length - 1].push(s.id); else runs.push([s.id]); } return runs; @@ -535,7 +545,8 @@ const r = state.sceneRenders[id]; if (!r || !r.media) { 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, r.media), `scene_${idx >= 0 ? idx + 1 : "x"}.mp4`); + const proj = (state.project.name || "montage").replace(/[^\w.-]+/g, "_"); + await _saveBlobAs(API.resultUrl(state.project.id, r.media), `${proj}_scene${idx >= 0 ? idx + 1 : "x"}.mp4`); } // Stitch the kept clips (in/out per clip, hard cut, video + audio) into one final file. diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index ce25bded..6e8bc795 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -237,7 +237,13 @@ let _tlEditing = false; function render(st) { - if (_tlEditing) return; + if (_tlEditing) { + // Only hold off if a control is genuinely still focused; otherwise the flag got + // stuck (focused element removed without a focusout) — clear it and re-render. + const a = document.activeElement; + if (a && body.contains(a) && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName)) return; + _tlEditing = false; + } clear(body); clear(meta); if (!st.project) { body.append(el("div", "empty-stage", "Open a project to start cutting.")); return; } const p = st.project; diff --git a/movie_editor/server.py b/movie_editor/server.py index cbc1bb12..8e6b99d7 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -438,7 +438,7 @@ if web is not None and PromptServer is not None: target = _segment(p, scene_ids) else: target = _solo(p, body.get("only_scene")) - prompt = build_combined_prompt(target) + prompt = build_combined_prompt(target, for_generation=True) if not prompt.strip(): return web.json_response({"detail": "Nothing to generate — no active scene text."}, status=400) try: