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:
co-authored by
Claude Opus 4.8
parent
7ffee3e688
commit
77bf2f8439
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user