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:
co-authored by
Claude Sonnet 5.5
parent
cf5accae0d
commit
c5ae2d8163
+22
-1
@@ -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
@@ -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() + " ", ""])
|
||||
|
||||
@@ -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."]
|
||||
|
||||
Reference in New Issue
Block a user