Movie Editor: verbatim global prompt as source of truth (lossless split)
The global prompt is now the verbatim source of truth. New conditioning helper split_timeline_verbatim() splits it into anchor + scene chunks using shortcut-aware transition boundaries but WITHOUT expanding shortcuts or dropping any word, so anchor + scene texts concatenated reproduce exactly what the user typed. A 'start' trigger leads the new scene; 'end'/'silent' trails the previous chunk. - build_combined_prompt is now a pure join (anchor + scene texts, single spaces) — no injected scene markers. Generation sends this verbatim prompt to Studio, which expands shortcuts / splits by transitions / applies the anchor as it always does. So editing a scene rewrites only its chunk and the global prompt = concatenation (nothing un-typed ever reappears). - bridge.parse_timeline_verbatim + parsed_verbatim on /parse and /preview. - applyGlobalPrompt / syncFromPreview consume parsed_verbatim (authoritative). Example: 'CHAR: a woman, man qcut hug cut hug kiss' -> anchor 'CHAR: a woman, man', scene1 'qcut hug cut', scene2 'hug kiss'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
468a349204
commit
b3daa8dab9
@@ -616,6 +616,97 @@ def split_prompt_by_transitions(prompt, placement="start"):
|
||||
return [(text, None)]
|
||||
|
||||
|
||||
def _verbatim_boundary_triggers():
|
||||
"""{trigger_lower: {placement, visual_effect}} for tokens that mark a scene boundary:
|
||||
every direct transition trigger, PLUS any shortcut whose expansion resolves to a
|
||||
transition (e.g. 'qcut' -> '...cuts to the next scene'). Used for lossless timeline
|
||||
splitting where the trigger word itself is kept verbatim in the text."""
|
||||
try:
|
||||
from .templates import (load_custom_transition_triggers, load_shortcut_db,
|
||||
shortcut_list, _shortcut_replacements)
|
||||
except ImportError:
|
||||
from templates import (load_custom_transition_triggers, load_shortcut_db,
|
||||
shortcut_list, _shortcut_replacements)
|
||||
direct = load_custom_transition_triggers()
|
||||
out = dict(direct)
|
||||
if direct:
|
||||
parts = "|".join(re.escape(t) for t in sorted(direct, key=len, reverse=True))
|
||||
dpat = re.compile(r"\b(?:" + parts + r")\b", re.IGNORECASE)
|
||||
try:
|
||||
sc = load_shortcut_db()
|
||||
except Exception:
|
||||
sc = {}
|
||||
for shortcut in (sc.get("shortcuts", {}) or {}).values():
|
||||
if not isinstance(shortcut, dict) or not shortcut.get("enabled", True):
|
||||
continue
|
||||
reps = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
|
||||
hit = None
|
||||
for rep in reps:
|
||||
mm = dpat.search(rep or "")
|
||||
if mm:
|
||||
hit = direct.get(re.sub(r"\s+", " ", mm.group(0).strip().lower()))
|
||||
if hit:
|
||||
break
|
||||
if hit:
|
||||
for trig in shortcut_list(shortcut.get("triggers", [])):
|
||||
key = re.sub(r"\s+", " ", str(trig or "").strip().lower())
|
||||
if key and key not in out:
|
||||
out[key] = hit
|
||||
return out
|
||||
|
||||
|
||||
def split_timeline_verbatim(prompt):
|
||||
"""Lossless split of the master prompt into anchor + scenes for the editor.
|
||||
|
||||
Boundaries are transition triggers (direct, or shortcuts that expand to one), but
|
||||
NOTHING is expanded and NO word is dropped: `anchor` + scene texts, concatenated with
|
||||
single spaces, reproduce the prompt. A 'start' trigger leads the new scene; an
|
||||
'end'/'silent'/global trigger trails the previous chunk. Each scene records the
|
||||
visual_effect of the transition that precedes it (anchor->scene1 effect is omitted).
|
||||
"""
|
||||
text = str(prompt or "")
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return {"anchor": "", "scenes": [], "transitions": []}
|
||||
|
||||
def _single():
|
||||
return {"anchor": "", "scenes": [{"index": 0, "text": stripped}], "transitions": []}
|
||||
|
||||
triggers = _verbatim_boundary_triggers()
|
||||
if not triggers:
|
||||
return _single()
|
||||
|
||||
def _pat(t):
|
||||
end = r"\b" if re.search(r"\w$", t) else r"(?=\s|$)"
|
||||
return r"\b" + re.escape(t) + end
|
||||
combined = "|".join(_pat(t) for t in sorted(triggers, key=len, reverse=True))
|
||||
pat = re.compile(r"(?:" + combined + r")", re.IGNORECASE)
|
||||
|
||||
cuts = [] # (char_index_of_cut, visual_effect_before_following_scene)
|
||||
for m in pat.finditer(text):
|
||||
info = triggers.get(re.sub(r"\s+", " ", m.group(0).strip().lower())) or {}
|
||||
effect = info.get("visual_effect") or None
|
||||
if effect == "none":
|
||||
effect = None
|
||||
# 'start' -> cut before the trigger (it leads the new scene); otherwise cut
|
||||
# after it (the trigger trails the previous chunk).
|
||||
idx = m.start() if info.get("placement") == "start" else m.end()
|
||||
cuts.append((idx, effect))
|
||||
if not cuts:
|
||||
return _single()
|
||||
|
||||
positions = [0] + [c[0] for c in cuts] + [len(text)]
|
||||
chunks = [text[positions[i]:positions[i + 1]].strip() for i in range(len(positions) - 1)]
|
||||
scene_texts = chunks[1:]
|
||||
effects = [c[1] for c in cuts] # effects[i] precedes scene_texts[i]
|
||||
scenes = [{"index": i, "text": t} for i, t in enumerate(scene_texts)]
|
||||
transitions = [
|
||||
{"after_scene": i - 1, "visual_effect": effects[i]}
|
||||
for i in range(1, len(scene_texts)) if effects[i]
|
||||
]
|
||||
return {"anchor": chunks[0], "scenes": scenes, "transitions": transitions}
|
||||
|
||||
|
||||
def parse_timeline_segments(prompt):
|
||||
"""Return {"anchor": str, "scenes": [...], "transitions": [...]} for timeline preview.
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ def parse_timeline_raw(prompt: str) -> dict:
|
||||
return parse_timeline_segments(str(prompt or ""))
|
||||
|
||||
|
||||
def parse_timeline_verbatim(prompt: str) -> dict:
|
||||
"""LOSSLESS split into anchor + scenes: boundaries from shortcut-aware transitions,
|
||||
but no expansion and no dropped words (anchor + scenes reproduce the prompt). This is
|
||||
what the editor uses to map the global prompt onto the timeline."""
|
||||
try:
|
||||
from conditioning import split_timeline_verbatim
|
||||
except ImportError: # package submodule
|
||||
from ...conditioning import split_timeline_verbatim # type: ignore
|
||||
return split_timeline_verbatim(str(prompt or ""))
|
||||
|
||||
|
||||
def transitions() -> dict:
|
||||
_, _, load_transition_db, transition_items = _funpack_imports()
|
||||
data = load_transition_db()
|
||||
|
||||
@@ -168,43 +168,23 @@ class Project:
|
||||
|
||||
|
||||
def build_combined_prompt(project: Project, include_excluded: bool = False) -> str:
|
||||
"""Assemble the single prompt string Studio splits into scenes.
|
||||
"""The verbatim master prompt = anchor + scene texts, joined with single spaces.
|
||||
|
||||
Studio's split puts segments[0] (text before the FIRST transition trigger) as the
|
||||
anchor and segments[1:] as the generated scenes. So to get N scenes we must emit a
|
||||
trigger before EACH scene: `intro_transition` separates the anchor from scene 0,
|
||||
then each scene is preceded by the previous scene's `transition_to_next`.
|
||||
|
||||
A separator is required at every seam or two scenes would merge. When a marker is
|
||||
empty we fall back to a generic `scene N` label (the built-in split pattern,
|
||||
no visual effect). NOTE: under Studio's default split placement="start" a generic
|
||||
label can leak into the scene text; the live parse_timeline preview makes the
|
||||
actual split visible so the user can pick a real library transition instead.
|
||||
|
||||
Markers may themselves be shortcuts that expand to transition phrases, so emit
|
||||
them verbatim — Studio runs shortcuts -> transitions -> split (memory
|
||||
project-director-vision). `include_excluded=False` drops scenes flagged excluded.
|
||||
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).
|
||||
"""
|
||||
parts: list[str] = []
|
||||
anchor = (project.anchor or "").strip()
|
||||
if anchor:
|
||||
parts.append(anchor)
|
||||
|
||||
scenes = [s for s in project.scenes if include_excluded or not s.excluded]
|
||||
# Always emit a separator marker before every scene. Studio's split puts
|
||||
# segments[0] as the character anchor and segments[1..N] as generated scenes.
|
||||
# Without a separator before scene 1, scene 1's text would land in segments[0]
|
||||
# (the anchor slot) and be skipped for generation — only N-1 scenes would be
|
||||
# produced for an N-scene project.
|
||||
prev_marker = (project.intro_transition or "").strip()
|
||||
for i, scene in enumerate(scenes):
|
||||
for scene in project.scenes:
|
||||
if not include_excluded and scene.excluded:
|
||||
continue
|
||||
text = (scene.text or "").strip()
|
||||
marker = prev_marker
|
||||
if not marker:
|
||||
marker = f"scene {i + 1}"
|
||||
parts.append(marker)
|
||||
if text:
|
||||
parts.append(text)
|
||||
prev_marker = (scene.transition_to_next or "").strip()
|
||||
|
||||
return "\n".join(p for p in parts if p).strip()
|
||||
return " ".join(parts).strip()
|
||||
|
||||
@@ -216,33 +216,19 @@
|
||||
// ── sync scenes from preview (distribute parsed anchor/transitions back) ──────
|
||||
function syncFromPreview() {
|
||||
if (!state.project || !state.preview) return;
|
||||
// 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 src = _structureSource(raw, exp);
|
||||
const parsedScenes = src.scenes || [];
|
||||
// Authoritative lossless split (verbatim text, shortcut-aware boundaries).
|
||||
const v = state.preview.parsed_verbatim || state.preview.parsed_raw || state.preview.parsed || {};
|
||||
const parsedScenes = v.scenes || [];
|
||||
if (!parsedScenes.length) return;
|
||||
|
||||
if (src.anchor != null) state.project.anchor = src.anchor;
|
||||
state.project.anchor = v.anchor || "";
|
||||
|
||||
// Sync scene texts verbatim (only if count matches to avoid destructive mismatches)
|
||||
const activeScenes = state.project.scenes.filter((s) => !s.excluded);
|
||||
if (parsedScenes.length === activeScenes.length) {
|
||||
parsedScenes.forEach((ps, i) => { if (ps.text) activeScenes[i].text = ps.text; });
|
||||
}
|
||||
|
||||
// Transitions: only fill seams the user hasn't set, mapped via the library.
|
||||
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) {
|
||||
const s = activeScenes[idx];
|
||||
if (!s.transition_to_next) {
|
||||
const lib = (state.transitions || []).find((tr) => (tr.visual_effect || "none") === t.visual_effect);
|
||||
if (lib) s.transition_to_next = lib.trigger || lib.name || "";
|
||||
}
|
||||
}
|
||||
});
|
||||
_applyDetectedTransitions(activeScenes, v.transitions);
|
||||
|
||||
notify();
|
||||
scheduleSave();
|
||||
@@ -258,41 +244,29 @@
|
||||
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 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;
|
||||
const src = _structureSource(raw, exp);
|
||||
if (!(src.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; }
|
||||
// The verbatim split is authoritative: correct (shortcut-aware) boundaries, scene
|
||||
// text kept exactly as typed, anchor + scenes reproduce the global prompt.
|
||||
const v = res.parsed_verbatim || res.parsed_raw || res.parsed || {};
|
||||
if (!(v.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; }
|
||||
|
||||
if (src.anchor != null) state.project.anchor = src.anchor;
|
||||
state.project.anchor = v.anchor || "";
|
||||
const old = state.project.scenes || [];
|
||||
const next = (src.scenes || []).map((ps, i) => {
|
||||
const next = (v.scenes || []).map((ps, i) => {
|
||||
const base = old[i] ? JSON.parse(JSON.stringify(old[i])) : { source: { type: "carry" }, excluded: false };
|
||||
base.text = ps.text || "";
|
||||
base.text = ps.text || ""; // verbatim chunk of the global prompt
|
||||
base.transition_to_next = "";
|
||||
return base;
|
||||
});
|
||||
_applyDetectedTransitions(next, src, exp);
|
||||
_applyDetectedTransitions(next, v.transitions);
|
||||
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, src, exp) {
|
||||
const trans = (((exp.scenes || []).length === scenes.length ? exp.transitions : src.transitions) || []);
|
||||
trans.forEach((t) => {
|
||||
// Map detected transitions (visual_effect) onto scene seams via the library (display
|
||||
// metadata; the trigger word itself already lives verbatim in the scene text).
|
||||
function _applyDetectedTransitions(scenes, transitions) {
|
||||
(transitions || []).forEach((t) => {
|
||||
const idx = t.after_scene;
|
||||
if (idx >= 0 && idx < scenes.length && t.visual_effect) {
|
||||
const lib = (state.transitions || []).find((tr) => (tr.visual_effect || "none") === t.visual_effect);
|
||||
|
||||
@@ -260,6 +260,7 @@ if web is not None and PromptServer is not None:
|
||||
parsed = bridge.parse_timeline(prompt, seed=p.seed)
|
||||
result["parsed"] = parsed
|
||||
result["parsed_raw"] = bridge.parse_timeline_raw(prompt)
|
||||
result["parsed_verbatim"] = bridge.parse_timeline_verbatim(prompt)
|
||||
expected = len([s for s in p.scenes if include_excluded or not s.excluded])
|
||||
got = len(parsed.get("scenes", []))
|
||||
result["expected_scenes"] = expected
|
||||
@@ -281,8 +282,12 @@ if web is not None and PromptServer is not None:
|
||||
prompt = str(body.get("prompt", ""))
|
||||
try:
|
||||
parsed = bridge.parse_timeline(prompt, seed=p.seed)
|
||||
parsed_raw = bridge.parse_timeline_raw(prompt)
|
||||
return web.json_response({"parsed": parsed, "parsed_raw": parsed_raw, "combined_prompt": prompt})
|
||||
return web.json_response({
|
||||
"parsed": parsed,
|
||||
"parsed_raw": bridge.parse_timeline_raw(prompt),
|
||||
"parsed_verbatim": bridge.parse_timeline_verbatim(prompt),
|
||||
"combined_prompt": prompt,
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"detail": f"Parse failed: {e}"}, status=502)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user