fix: shots are only cut between two shortcuts, never inside one

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 17:46:34 +03:00
co-authored by Claude Sonnet 5.5
parent cf5accae0d
commit c5ae2d8163
3 changed files with 66 additions and 16 deletions
+22 -1
View File
@@ -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
+13 -7
View File
@@ -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() + " ", ""])
+31 -8
View File
@@ -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 = "<Subject 1> touches <Subject 1>'s chin and smiles."
PB = "Then <Subject 2> opens the window and the curtain sways."
PC = "<Subject 1> laughs and pours a glass of wine."
PIECES = [PA, PB, PC]
CUTS = ("Two friends in a sunlit loft, <Subject 1> is a tall woman, <Subject 2> is a man. "
"[Shot 1] <Subject 1> touches <Subject 1>'s chin and smiles. Then <Subject 2> opens the "
"window and the curtain sways. [Shot 2] <Subject 1> 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] <Subject 1> waves. [Shot 2] <Subject 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."]