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:
digital-garbage
2026-06-07 09:50:37 +03:00
co-authored by Claude Opus 4.8
parent 468a349204
commit b3daa8dab9
5 changed files with 137 additions and 76 deletions
+91
View File
@@ -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.
+11
View File
@@ -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()
+11 -31
View File
@@ -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()
+17 -43
View File
@@ -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);
+7 -2
View File
@@ -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)