From e1e3261ca0ed5dbdd64128e0e0f09b160a362106 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:21:44 +0300 Subject: [PATCH] Movie Editor: keep shortcuts verbatim outside preview/generation Global prompt and 'Sync from preview' were writing shortcut-EXPANDED scene text into scenes (parse_timeline expands before splitting), so the verbatim combined prompt then showed expanded shortcuts. Add bridge.parse_timeline_raw() (split without expansion) exposed as parsed_raw on /parse and /preview; apply/sync now take scene text from the raw split (shortcuts preserved) and use the expanded split only to DETECT transitions (incl. shortcut->transition), mapping them to the library by visual_effect. Generation and the split preview still expand. Co-Authored-By: Claude Opus 4.8 --- movie_editor/backend/bridge.py | 11 +++++- movie_editor/frontend/store.js | 65 +++++++++++++++++++--------------- movie_editor/server.py | 4 ++- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 19abb3f1..f0dc013a 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -27,12 +27,21 @@ def _funpack_imports(): def parse_timeline(prompt: str, seed: int = 0) -> dict: - """{anchor, scenes, transitions} — the canonical split Studio will see.""" + """{anchor, scenes, transitions} — the canonical split Studio will see. + Shortcuts are expanded first (as generation does).""" parse_timeline_segments, apply_prompt_shortcuts, *_ = _funpack_imports() expanded, _applied = apply_prompt_shortcuts(str(prompt or ""), seed=int(seed or 0)) return parse_timeline_segments(expanded) +def parse_timeline_raw(prompt: str) -> dict: + """Split WITHOUT expanding shortcuts — scene texts stay verbatim (shortcuts and + transition triggers preserved). Used by editing views so the user keeps operating + on shortcuts unchanged; only the full preview / generation expand them.""" + parse_timeline_segments, *_ = _funpack_imports() + return parse_timeline_segments(str(prompt or "")) + + def transitions() -> dict: _, _, load_transition_db, transition_items = _funpack_imports() data = load_transition_db() diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index b1390d5a..8baa74fa 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -213,30 +213,28 @@ // ── sync scenes from preview (distribute parsed anchor/transitions back) ────── function syncFromPreview() { if (!state.project || !state.preview) return; - const parsed = state.preview.parsed || {}; - const parsedScenes = parsed.scenes || []; - const parsedTrans = parsed.transitions || []; + // raw = verbatim split (shortcuts preserved); exp = expanded (transition detection) + const raw = state.preview.parsed_raw || state.preview.parsed || {}; + const exp = state.preview.parsed || raw; + const parsedScenes = raw.scenes || []; if (!parsedScenes.length) return; - // Sync anchor - if (parsed.anchor != null) state.project.anchor = parsed.anchor; + if (raw.anchor != null) state.project.anchor = raw.anchor; - // Sync scene texts (only if count matches to avoid destructive mismatches) + // 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; }); } - // Sync transitions: parsed.transitions[i] → scene[i].transition_to_next - // We derive the trigger from the library by matching visual_effect. - parsedTrans.forEach((t) => { + // Transitions: only fill seams the user hasn't set, mapped via the library. + const trans = (((exp.scenes || []).length === parsedScenes.length ? exp.transitions : raw.transitions) || []); + trans.forEach((t) => { const idx = t.after_scene; - const sceneIdx = idx >= 0 ? idx : -1; - if (sceneIdx >= 0 && sceneIdx < activeScenes.length) { - const s = activeScenes[sceneIdx]; - if (!s.transition_to_next && t.visual_effect) { - const lib = (state.transitions || []).find((tr) => - (tr.visual_effect || "none") === t.visual_effect); + 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 || ""; } } @@ -253,31 +251,42 @@ async function applyGlobalPrompt(text) { if (!state.project) return; state.project.global_prompt = text; - let parsed; - try { parsed = (await API.parsePrompt(state.project.id, text)).parsed; } + let res; + try { res = await API.parsePrompt(state.project.id, text); } catch (e) { alert("Could not parse the global prompt: " + e.message); return; } - if (!parsed || !(parsed.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; } + // raw = verbatim split (shortcuts kept in scene text); exp = expanded split, used + // ONLY to detect transitions (incl. those a shortcut expands into). + const raw = res.parsed_raw || res.parsed; + const exp = res.parsed || raw; + if (!raw || !(raw.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; } - if (parsed.anchor != null) state.project.anchor = parsed.anchor; + if (raw.anchor != null) state.project.anchor = raw.anchor; const old = state.project.scenes || []; - const next = (parsed.scenes || []).map((ps, i) => { + const next = (raw.scenes || []).map((ps, i) => { const base = old[i] ? JSON.parse(JSON.stringify(old[i])) : { source: { type: "empty" }, excluded: false }; - base.text = ps.text || ""; + base.text = ps.text || ""; // verbatim — shortcuts preserved base.transition_to_next = ""; return base; }); - (parsed.transitions || []).forEach((t) => { - const idx = t.after_scene; - if (idx >= 0 && idx < next.length && t.visual_effect) { - const lib = (state.transitions || []).find((tr) => (tr.visual_effect || "none") === t.visual_effect); - if (lib) next[idx].transition_to_next = lib.trigger || lib.name || ""; - } - }); + _applyDetectedTransitions(next, raw, exp); state.project.scenes = next; state.selectedSceneId = null; // show project view so the result is visible notify(); scheduleSave(); } + // 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, raw, exp) { + const trans = (((exp.scenes || []).length === scenes.length ? exp.transitions : raw.transitions) || []); + trans.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); + if (lib) scenes[idx].transition_to_next = lib.trigger || lib.name || ""; + } + }); + } + // ── preview ────────────────────────────────────────────────────────────────── let previewTimer = null; function refreshPreview() { diff --git a/movie_editor/server.py b/movie_editor/server.py index 3daf686f..6778f31e 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -245,6 +245,7 @@ if web is not None and PromptServer is not None: try: parsed = bridge.parse_timeline(prompt, seed=p.seed) result["parsed"] = parsed + result["parsed_raw"] = bridge.parse_timeline_raw(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 @@ -266,7 +267,8 @@ if web is not None and PromptServer is not None: prompt = str(body.get("prompt", "")) try: parsed = bridge.parse_timeline(prompt, seed=p.seed) - return web.json_response({"parsed": parsed, "combined_prompt": prompt}) + parsed_raw = bridge.parse_timeline_raw(prompt) + return web.json_response({"parsed": parsed, "parsed_raw": parsed_raw, "combined_prompt": prompt}) except Exception as e: # noqa: BLE001 return web.json_response({"detail": f"Parse failed: {e}"}, status=502)