diff --git a/conditioning.py b/conditioning.py index e156b897..70bc5ef2 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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. diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index f0dc013a..25674987 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -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() diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 164e2844..fe7d9edf 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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() diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 721d1c9d..7ee52d16 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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); diff --git a/movie_editor/server.py b/movie_editor/server.py index f0229ae7..871eccd9 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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)