Movie Editor: fix multi-scene runs + anchor/export/fallback + studio toggles

- #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: <project>_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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 19:15:46 +03:00
co-authored by Claude Opus 4.8
parent 7ffee3e688
commit 77bf2f8439
6 changed files with 114 additions and 27 deletions
+52 -12
View File
@@ -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()
+33 -1
View File
@@ -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);
+8 -10
View File
@@ -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) {
+13 -2
View File
@@ -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.
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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: