From c5ae2d8163205951ec31917cbec5ad75d391620c Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:46:34 +0300 Subject: [PATCH] fix: shots are only cut between two shortcuts, never inside one Co-Authored-By: Claude Sonnet 5.5 --- conditioning.py | 23 ++++++++++++++++++++++- shot_camera.py | 20 +++++++++++++------- tests/test_shot_camera.py | 39 +++++++++++++++++++++++++++++++-------- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/conditioning.py b/conditioning.py index 0c860980..069f4adb 100644 --- a/conditioning.py +++ b/conditioning.py @@ -775,6 +775,26 @@ def split_prompt_by_transitions(prompt, placement="start"): return [(text, None)] +def _shortcut_texts(min_len=15): + """Every enabled shortcut's replacement text, longest first: how shot cuts tell where one + of the user's shortcuts ends and the next begins in an already-expanded prompt.""" + try: + try: + from .templates import load_shortcut_db, _shortcut_replacements + except ImportError: + from templates import load_shortcut_db, _shortcut_replacements + out = set() + for sc in (load_shortcut_db().get("shortcuts", {}) or {}).values(): + if isinstance(sc, dict) and sc.get("enabled", True): + for r in _shortcut_replacements(sc.get("replacements", sc.get("replacement", []))): + r = str(r or "").strip() + if len(r) >= min_len: + out.add(r) + return sorted(out, key=len, reverse=True) + except Exception: # noqa: BLE001 + return [] + + def _expand_with_map(text): """Expand shortcuts EXACTLY like apply_prompt_shortcuts, but record a position map so boundaries found in the expanded text can be projected back onto the original. @@ -9307,7 +9327,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): from . import shot_camera as _sc except ImportError: import shot_camera as _sc - out, info = _sc.add_shot_cuts(text, seconds, seed=seed, chance=chance) + out, info = _sc.add_shot_cuts(text, seconds, seed=seed, chance=chance, + pieces=_shortcut_texts() if chance > 0 else ()) except Exception as e: # noqa: BLE001 print(f"[FunPackVideoRefinerV2] shot cuts: failed | {where}: {e}; prompt left as written") return text diff --git a/shot_camera.py b/shot_camera.py index db7d6880..d281471d 100644 --- a/shot_camera.py +++ b/shot_camera.py @@ -288,23 +288,29 @@ def _sentence_topics(sentence, constant): return {c[0] for c in candidates(sentence) if c[0] not in constant and _score(c) > 0.5} -def _switch_point(picture, constant): - """Index of the first sentence whose topics share nothing with everything before it (and - both sides have some), or None: the shot's main point changes there.""" +def _switch_point(picture, constant, pieces): + """Index of the first sentence that (a) starts exactly where a known shortcut text starts and + (b) shares no topic with everything before it, both sides having some: the shot's main + point changes between two of the user's shortcuts. Never inside one.""" sentences = SENTENCE.findall(picture) - seen = set() + seen, offset = set(), 0 for j, sent in enumerate(sentences): topics = _sentence_topics(sent, constant) - if j and seen and topics and not (topics & seen): + starts_piece = j and any(picture.startswith(r, offset + len(sent) - len(sent.lstrip())) + for r in pieces) + if starts_piece and seen and topics and not (topics & seen): return j, sentences seen |= topics + offset += len(sent) return None, sentences -def add_shot_cuts(text, seconds, seed=0, chance=0.5): +def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=()): """-> (new prompt, info). Shots whose main point changes are split in two; every shot after the first then opens with its cut time, spread over `seconds` by text length and rounded to whole seconds. A prompt that already carries cut times is left alone. + `pieces`: the texts of the user's shortcuts; a shot is only ever cut BETWEEN two of them, + so with none given nothing is split (times are still added). info = {"before", "after", "times": [...], "why": str}.""" marks = list(SHOT.finditer(text or "")) info = {"before": len(marks), "after": len(marks), "times": [], "why": ""} @@ -331,7 +337,7 @@ def add_shot_cuts(text, seconds, seed=0, chance=0.5): blocks = [] # [picture text, sound text] for i, (picture, sound) in enumerate(parts): rng = random.Random(f"{seed}:cut:{i}") - j, sentences = _switch_point(picture, constant) + j, sentences = _switch_point(picture, constant, pieces) if j is not None and rng.random() < chance: first, second = "".join(sentences[:j]), "".join(sentences[j:]) blocks.append([first.rstrip() + " ", ""]) diff --git a/tests/test_shot_camera.py b/tests/test_shot_camera.py index f3d00528..5a650dea 100644 --- a/tests/test_shot_camera.py +++ b/tests/test_shot_camera.py @@ -223,15 +223,17 @@ def test_the_final_prompt_is_published_for_the_composer_with_the_enhancer_off(): assert run_phase._state()["enhanced"]["items"][0]["after"] == "after with moves" +PA = " touches 's chin and smiles." +PB = "Then opens the window and the curtain sways." +PC = " laughs and pours a glass of wine." +PIECES = [PA, PB, PC] CUTS = ("Two friends in a sunlit loft, is a tall woman, is a man. " - "[Shot 1] touches 's chin and smiles. Then opens the " - "window and the curtain sways. [Shot 2] laughs and pours a glass of wine. " - "Upbeat jazz music plays.") + f"[Shot 1] {PA} {PB} [Shot 2] {PC} Upbeat jazz music plays.") def test_a_shot_whose_point_changes_is_cut_in_two_with_whole_second_times(): pytest.importorskip("spacy") - out, info = sc.add_shot_cuts(CUTS, 12, seed=1, chance=1.0) + out, info = sc.add_shot_cuts(CUTS, 12, seed=1, chance=1.0, pieces=PIECES) assert (info["before"], info["after"]) == (2, 3) assert info["times"] == ["00:04.000", "00:08.000"] assert "[Shot 3] At 00:08.000," in out and "[Shot 2] At 00:04.000," in out @@ -242,7 +244,7 @@ def test_a_shot_whose_point_changes_is_cut_in_two_with_whole_second_times(): def test_cut_times_rise_and_stay_inside_the_video(): pytest.importorskip("spacy") for secs in (5, 9, 14, 30): - _out, info = sc.add_shot_cuts(CUTS, secs, seed=2, chance=1.0) + _out, info = sc.add_shot_cuts(CUTS, secs, seed=2, chance=1.0, pieces=PIECES) ts = [int(t[3:5]) + 60 * int(t[:2]) for t in info["times"]] assert ts == sorted(set(ts)) and all(0 < t < secs for t in ts) @@ -252,7 +254,7 @@ def test_no_length_or_existing_times_or_no_room_leave_the_prompt_alone(): assert sc.add_shot_cuts(CUTS, None)[0] == CUTS and "length" in sc.add_shot_cuts(CUTS, None)[1]["why"] assert sc.add_shot_cuts(CUTS, 1)[0] == CUTS stamped = CUTS.replace("[Shot 2] ", "[Shot 2] At 00:05.000, the camera cuts to ") - out, info = sc.add_shot_cuts(stamped, 12, chance=1.0) + out, info = sc.add_shot_cuts(stamped, 12, chance=1.0, pieces=PIECES) assert out == stamped and "already" in info["why"] assert sc.add_shot_cuts("no shots", 10)[0] == "no shots" @@ -265,12 +267,14 @@ def test_chance_zero_still_stamps_the_existing_shots_but_splits_nothing(): def test_shot_cuts_are_reproducible_from_the_seed(): pytest.importorskip("spacy") - assert sc.add_shot_cuts(CUTS, 12, seed=5, chance=1.0) == sc.add_shot_cuts(CUTS, 12, seed=5, chance=1.0) + assert (sc.add_shot_cuts(CUTS, 12, seed=5, chance=1.0, pieces=PIECES) + == sc.add_shot_cuts(CUTS, 12, seed=5, chance=1.0, pieces=PIECES)) -def test_refiner_wrapper_reports_cuts(capsys): +def test_refiner_wrapper_reports_cuts(capsys, monkeypatch): pytest.importorskip("spacy") import conditioning + monkeypatch.setattr(conditioning, "_shortcut_texts", lambda: PIECES) R = conditioning.FunPackVideoRefinerV2 out = R._v2_shot_cuts(CUTS, "scene 1", 1, 1.0, 12) assert "shot cuts: Active" in capsys.readouterr().out and "00:04.000" in out @@ -309,3 +313,22 @@ def test_refiner_wrapper_reports_views(capsys): P = HEAD + "[Shot 1] waves. [Shot 2] nods." out = conditioning.FunPackVideoRefinerV2._v2_shot_views(P, "scene 1", 1, 1.0) assert "shot views: Active" in capsys.readouterr().out and out != P + + +def test_a_shot_is_never_cut_inside_one_shortcut(): + """The topic changes between PA and PB. If PA+PB is ONE shortcut, nothing may be split.""" + pytest.importorskip("spacy") + one = f"{PA} {PB}" + out, info = sc.add_shot_cuts(CUTS, 12, seed=1, chance=1.0, pieces=[one, PC]) + assert info["after"] == 2 and out.count("[Shot ") == 2 + out, info = sc.add_shot_cuts(CUTS, 12, seed=1, chance=1.0) # no shortcut texts known + assert info["after"] == 2 + + +def test_shortcut_texts_come_from_the_library(monkeypatch): + import conditioning, templates + monkeypatch.setattr(templates, "load_shortcut_db", lambda: {"shortcuts": { + "a": {"enabled": True, "replacements": ["A long enough replacement text."]}, + "b": {"enabled": False, "replacements": ["Disabled but long enough text."]}, + "c": {"enabled": True, "replacements": ["short"]}}}) + assert conditioning._shortcut_texts() == ["A long enough replacement text."]