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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 09:21:44 +03:00
co-authored by Claude Opus 4.8
parent 1ffb7abe29
commit e1e3261ca0
3 changed files with 50 additions and 30 deletions
+10 -1
View File
@@ -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()
+37 -28
View File
@@ -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() {
+3 -1
View File
@@ -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)