feat: camera-move wording follows H3's own prompt guide; a shot opening with a cut still gets a move

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 17:12:23 +03:00
co-authored by Claude Sonnet 5.5
parent 0fc080b057
commit 4b182524e6
2 changed files with 31 additions and 16 deletions
+17 -13
View File
@@ -20,9 +20,13 @@ NAMES = ("Mariel", "Tobias", "Odette", "Rurik", "Sabine", "Teodor", "Ilsa", "Cor
STAND_IN = re.compile(r"\b(" + "|".join(NAMES) + r")\b")
AUDIO = re.compile(r"\b(music|soundtrack|score|melody|instrumental|beat|bassline|synth|"
r"song|tempo|audio|ambient sound|sound of)\b", re.I)
# H3's own vocabulary (VIDEO_PROMPT_WRITING_GUIDE_base_en.md): a shot opening "the camera cuts
# to" is a cut, not a camera move, and must not make the shot look like it already has one.
CUT = re.compile(r"\b(?:the )?(?:camera|shot) (?:cuts|transitions|changes|switches) to\b", re.I)
CAMERA = re.compile(r"\b(camera|zoom(?:s|ing)?|close-?up|pan(?:s|ning)?|dolly|tracking|"
r"rack focus|focus(?:es|ing)? (?:on|at)|tilt(?:s|ing)?|rotate|orbit|"
r"push[- ]in|pull[- ]back|wide shot|handheld|crane)\b", re.I)
r"rack(?:s|ing)? focus|focus(?:es|ing)? (?:on|at)|tilt(?:s|ing)?|rotate|orbit|"
r"push(?:es)?[- ]in|pull(?:s)?[- ](?:out|back)|arc(?:s)? |truck(?:s|ing)?|"
r"pedestal|roll(?:s)? (?:clockwise|counter)|pov|wide shot|handheld|crane)\b", re.I)
GENERIC = {"camera", "video", "scene", "shot", "frame", "background", "foreground", "view",
"image", "screen", "moment", "time", "way", "side", "front", "middle", "one",
"other", "something", "everything", "thing", "music", "sound", "style", "lighting",
@@ -32,17 +36,17 @@ PARTS = {"lip", "mouth", "tongue", "tooth", "teeth", "eye", "eyes", "face", "che
"skin", "expression", "smile", "gaze", "back", "chin", "brow"}
PLACES = {"room", "street", "kitchen", "bedroom", "bed", "table", "floor", "stage", "hall",
"garden", "beach", "forest", "city", "car", "office", "bathroom", "field"}
MOVES_DETAIL = ("Camera moves into a close-up on {x}.", "Camera focuses at {x}.",
"Camera zooms in at {x}.")
MOVES_OTHER = ("Camera zooms in at {x}.", "Camera moves around {x}.",
"Camera rotates to {x}.")
MOVES_TRAVEL = ("Camera moves from {x} to {y}.", "Camera pans from {x} to {y}.",
"Camera shifts focus from {x} to {y}.")
MOVES_DETAIL = ("The camera pushes in toward {x}.", "The camera racks focus to {x}.",
"The camera zooms in on {x}.")
MOVES_OTHER = ("The camera zooms in on {x}.", "The camera arcs around {x}.",
"The camera pushes in toward {x}.")
MOVES_TRAVEL = ("The camera pans from {x} to {y}.", "The camera racks focus from {x} to {y}.",
"The camera moves from {x} to {y}.")
# Chained moves (varied mode): a follow-up to a first move, and a closing pull-back.
MOVES_THEN = ("Then the camera moves to {y}.", "Then the camera pans to {y}.",
"Then the camera focuses at {y}.")
MOVES_FINISH = ("Then the camera zooms out.", "Then the camera pulls back.",
"Then the camera pulls back to a wider view.")
MOVES_THEN = ("Then the camera pans to {y}.", "Then the camera racks focus to {y}.",
"Then the camera pushes in toward {y}.")
MOVES_FINISH = ("Then the camera pulls out.", "Then the camera zooms out.",
"Then the camera pulls out to a wider view.")
COUNT_WEIGHTS = (6, 3, 1) # one move / two / three, in a shot that gets any
TRAVEL_SHARE = 0.5 # of one-move shots with two topics, how many travel X -> Y
DETERMINERS = {"the", "a", "an", "this", "that", "these", "those"}
@@ -229,7 +233,7 @@ def add_camera_moves(text, seed=None, chance=1.0):
entry = {"shot": int(m.group(1)), "move": None, "target": None, "why": ""}
rng = random.Random(f"{seed}:{i}") if seed is not None else None
pool = [c for c in cands[i] if c[0] not in constant]
if CAMERA.search(picture):
if CAMERA.search(CUT.sub("", picture)):
entry["why"] = "already has a camera move"
elif not pool:
entry["why"] = "nothing specific to aim at"
+14 -3
View File
@@ -29,7 +29,7 @@ def test_move_aims_at_a_part_of_the_subject_never_the_subject():
assert rep[1]["target"] == "<Subject 1>'s lower lip"
for r in rep:
assert r["move"] and "<Subject 1>." not in r["move"].replace("'s", "")
assert "Camera moves into a close-up on <Subject 1>'s hand." in out
assert rep[0]["move"] in out and "<Subject 1>'s hand" in rep[0]["move"]
def test_moves_sit_inside_their_shot_and_before_the_music():
@@ -90,7 +90,7 @@ def test_refiner_wrapper_returns_the_prompt_and_says_what_it_did(capsys):
pytest.importorskip("spacy")
import conditioning
out = conditioning.FunPackVideoRefinerV2._v2_camera_moves(PROMPT, "prompt")
assert "Camera moves into a close-up on <Subject 1>'s hand." in out
assert "The camera" in out and "<Subject 1>'s hand" in out
assert "camera moves: Active" in capsys.readouterr().out
assert conditioning.FunPackVideoRefinerV2._v2_camera_moves("no shots here", "prompt") == "no shots here"
@@ -151,4 +151,15 @@ def test_a_single_topic_shot_can_still_chain_with_a_pull_back():
pytest.importorskip("spacy")
moves = {sc.add_camera_moves(ONE, seed=s)[1][0]["move"] for s in range(60)}
assert any("Then the camera" in m for m in moves) and any("Then" not in m for m in moves)
assert all(m.startswith("Camera") for m in moves)
assert all(m.startswith("The camera") for m in moves)
def test_a_shot_that_opens_with_a_cut_is_not_taken_for_having_a_camera_move():
"""H3 writes cuts at a shot's opening: "At 00:03.500, the camera cuts to ..."."""
pytest.importorskip("spacy")
p = (HEAD + "[Shot 1] <Subject 1> raises <Subject 1>'s hand. "
"[Shot 2] At 00:03.500, the camera cuts to <Subject 2> who lifts <Subject 2>'s chin.")
_out, rep = sc.add_camera_moves(p)
assert rep[1]["move"] and "already" not in rep[1]["why"]
p2 = HEAD + "[Shot 1] <Subject 1> raises <Subject 1>'s hand as the camera pushes in slowly."
assert "already" in sc.add_camera_moves(p2)[1][0]["why"]