Fix editor timeline losing scenes when a trigger is also a shortcut
split_timeline_verbatim expanded shortcuts before detecting transitions, so
a cut marker that is ALSO a shortcut (e.g. qcut/cut, common now that cut
markers carry refinement keys) had its trigger word replaced before
detection -> the split was lost or misplaced ("scene 2 removed, scene 1
shows scene 2's prompt"). It now ALSO scans the original verbatim text for
triggers and merges with the expanded scan; trigger-shortcuts are caught at
their true offset, shortcut-driven splits and plain triggers are unchanged,
and the dedup collapses duplicates. Lossless round-trip preserved.
- conditioning.py: original-text trigger scan in split_timeline_verbatim.
- tests: user's qcut/cut case (2 scenes, actions 10|20,30, lossless) +
shortcut-driven split still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3b719d098e
commit
2fde2e8506
+3
-1
@@ -10,7 +10,9 @@
|
||||
|
||||
**Session Reset now wipes every key a run trains, not just the default.** Because per-scene multi-key learning trains *every* refinement key whose shortcut fired in the prompt, a Studio Session Reset now clears all of those keys too (project/default key + each non-default key activated by a shortcut), so no stale per-key state survives a reset (`FunPackVideoRefinerV2._v2_reset_prompt_keys`). The Movie Editor's "Reset Studio session" now confirms first, listing exactly which keys will be wiped: *"This action will reset Studio learning for keys: default, key1, key2… To avoid resetting a non-default key, remove the shortcut that activates it from the prompt. Proceed?"* — disarming a mis-armed reset does not re-prompt.
|
||||
|
||||
## [3.0.0] - 2026-06-11
|
||||
### Fixed
|
||||
|
||||
**Editor timeline dropped a scene when a transition trigger was also a shortcut.** If a scene-cut marker (e.g. `qcut`, `cut`) was *also* a shortcut — common now that cut markers can carry refinement keys (e.g. `qcut` → "cuts" key) — the lossless splitter expanded the trigger word away *before* detecting transitions, so the split was lost or misplaced. Classic symptom: "scene 2 removed from the timeline, scene 1 shows scene 2's prompt" until reload. `split_timeline_verbatim` now also scans the original (verbatim) text for triggers, catching trigger-shortcuts at their true position; genuine shortcut-driven splits (trigger only appears after expansion) and plain triggers are unchanged, and the result still round-trips losslessly.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
+18
-6
@@ -742,21 +742,33 @@ def split_timeline_verbatim(prompt):
|
||||
else:
|
||||
split_pattern = re.compile(_GENERIC_SCENE_LABEL_PATTERN, re.IGNORECASE)
|
||||
|
||||
def _effect(group):
|
||||
info = custom_map.get(re.sub(r"\s+", " ", group.strip().lower())) or {}
|
||||
eff = info.get("visual_effect") or None
|
||||
return (None if eff == "none" else eff), info.get("placement")
|
||||
|
||||
cuts = [] # (orig_offset, effect)
|
||||
# (1) Scan the EXPANDED text: catches shortcut-driven splits, i.e. a shortcut whose
|
||||
# trigger word is NOT a transition but whose EXPANSION resolves to one.
|
||||
for m in split_pattern.finditer(expanded):
|
||||
info = custom_map.get(re.sub(r"\s+", " ", m.group(0).strip().lower())) or {}
|
||||
effect = info.get("visual_effect") or None
|
||||
if effect == "none":
|
||||
effect = None
|
||||
effect, placement = _effect(m.group(0))
|
||||
# 'start' -> cut before the trigger (leads new scene); otherwise after it.
|
||||
if info.get("placement") == "start":
|
||||
if placement == "start":
|
||||
orig = _project_offset(pieces, m.start(), "before")
|
||||
else:
|
||||
orig = _project_offset(pieces, m.end(), "after")
|
||||
cuts.append((orig, effect))
|
||||
# (2) Scan the ORIGINAL text too: a transition trigger that is ALSO a shortcut (e.g.
|
||||
# 'qcut'/'cut') gets expanded away in (1) before detection, so its split would be lost
|
||||
# or misplaced. Matching the verbatim text catches it at its true offset (no projection
|
||||
# needed). Non-shortcut triggers yield the same offset in both scans and dedupe to one.
|
||||
for m in split_pattern.finditer(text):
|
||||
effect, placement = _effect(m.group(0))
|
||||
orig = m.start() if placement == "start" else m.end()
|
||||
cuts.append((orig, effect))
|
||||
|
||||
# dedupe boundaries that map to the same original offset (e.g. several triggers inside
|
||||
# one shortcut expansion) and drop any at the very start.
|
||||
# one shortcut expansion, or the same trigger seen by both scans) and drop any at the start.
|
||||
seen, uniq = set(), []
|
||||
for off, eff in sorted(cuts, key=lambda c: c[0]):
|
||||
if off <= 0 or off in seen:
|
||||
|
||||
@@ -44,6 +44,45 @@ def _norm(s):
|
||||
return _re.sub(r"\s+", " ", str(s or "")).strip()
|
||||
|
||||
|
||||
def test_verbatim_split_when_trigger_is_also_a_shortcut(monkeypatch):
|
||||
"""Regression: a transition trigger that is ALSO a shortcut (qcut/cut) was expanded away
|
||||
before transition detection, losing/misplacing the split -> 'scene 2 removed, scene 1 shows
|
||||
scene 2's prompt'. The verbatim splitter must also scan the original text and catch it."""
|
||||
db = _db({
|
||||
"char": (["CHAR:"], ["A cinematic film."], ""),
|
||||
"man": (["man"], ["a tall man"], ""),
|
||||
"qcut": (["qcut"], ["fast paced editing"], "cuts"), # word 'qcut' disappears on expand
|
||||
"cut": (["cut"], ["Scene cut."], ""),
|
||||
"10": (["10"], ["an explosion"], "key10"),
|
||||
"20": (["20"], ["a chase"], "key20"),
|
||||
"30": (["30"], ["a kiss"], "key30"),
|
||||
})
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
|
||||
monkeypatch.setattr(templates, "load_custom_transition_triggers",
|
||||
lambda: {"qcut": {"placement": "start", "visual_effect": None},
|
||||
"cut": {"placement": "silent", "visual_effect": None}})
|
||||
v = conditioning.split_timeline_verbatim("CHAR: a woman man qcut 10 cut 20 30")
|
||||
assert len(v["scenes"]) == 2
|
||||
assert _norm(v["anchor"]) == "CHAR: a woman man"
|
||||
# action 10 in scene 1, actions 20 & 30 in scene 2 (cut is silent -> trails scene 1)
|
||||
assert "10" in v["scenes"][0]["text"] and "20" in v["scenes"][1]["text"] and "30" in v["scenes"][1]["text"]
|
||||
# lossless round-trip
|
||||
rejoined = " ".join(x for x in [v["anchor"]] + [s["text"] for s in v["scenes"]] if x)
|
||||
assert _norm(rejoined) == _norm("CHAR: a woman man qcut 10 cut 20 30")
|
||||
|
||||
|
||||
def test_verbatim_split_shortcut_driven_split_still_works(monkeypatch):
|
||||
"""A shortcut whose trigger is NOT a transition but whose EXPANSION resolves to one must
|
||||
still create a boundary (the supported shortcut-driven split)."""
|
||||
db = _db({"break": (["<break>"], ["Scene cut."], "")})
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
|
||||
monkeypatch.setattr(templates, "load_custom_transition_triggers",
|
||||
lambda: {"cut": {"placement": "silent", "visual_effect": None}})
|
||||
v = conditioning.split_timeline_verbatim("intro shot <break> the night falls")
|
||||
assert len(v["scenes"]) == 1
|
||||
assert "<break>" in v["anchor"]
|
||||
|
||||
|
||||
def test_verbatim_split_still_splits_on_literal_cuts(monkeypatch):
|
||||
"""The fix must not suppress real, user-typed scene cuts."""
|
||||
db = _db({"closeup": (["<closeup>"], ["a tight close-up"], "closeupkey")}) # no cut in replacement
|
||||
|
||||
Reference in New Issue
Block a user