feat: camera moves are randomised per shot (chance, 1-3 moves, X to Y or not) and declare the camera
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
4c30deb6a2
commit
0fc080b057
+7
-5
@@ -9276,14 +9276,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
return head.rsplit(" ", 1)[0].strip(), True
|
||||
|
||||
@staticmethod
|
||||
def _v2_camera_moves(text, where):
|
||||
def _v2_camera_moves(text, where, seed=None, chance=1.0):
|
||||
"""`text` with a camera move added to each [Shot N] block that lacks one."""
|
||||
try:
|
||||
try:
|
||||
from . import shot_camera as _sc
|
||||
except ImportError:
|
||||
import shot_camera as _sc
|
||||
out, report = _sc.add_camera_moves(text)
|
||||
out, report = _sc.add_camera_moves(text, seed=seed, chance=chance)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[FunPackVideoRefinerV2] camera moves: failed | {where}: {e}; prompt left as written")
|
||||
return text
|
||||
@@ -10165,7 +10165,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
split_by_transitions=False, split_transition_placement="start", reference_injection=False,
|
||||
value_guidance=True, latent=None, seed_output_connected=False,
|
||||
steer_mode="relative", absolute_strength=0.6,
|
||||
h3_phrase_emphasis=False, h3_phrase_variability=0.0, camera_moves=False,
|
||||
h3_phrase_emphasis=False, h3_phrase_variability=0.0, camera_moves=False, camera_moves_chance=0.7,
|
||||
prompt_enhance=False, prompt_enhance_system="", prompt_enhance_reference_intro="",
|
||||
prompt_enhance_temperature=0.7, prompt_enhance_top_p=0.92,
|
||||
prompt_enhance_max_length=400, prompt_enhance_thinking=False,
|
||||
@@ -10842,7 +10842,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
# Camera moves for MiniMax's own `[Shot N]` blocks (not timeline scenes): after
|
||||
# shortcuts and $variables, before the enhancer, so the enhancer sees them.
|
||||
if camera_moves and not split_by_transitions:
|
||||
prompt_to_encode = self._v2_camera_moves(prompt_to_encode, "prompt")
|
||||
prompt_to_encode = self._v2_camera_moves(prompt_to_encode, "prompt", seed, camera_moves_chance)
|
||||
|
||||
# ── prompt enhancer ────────────────────────────────────────────────────────────
|
||||
# Runs BEFORE the prompt becomes conditioning, so what ComfyUI generates from is the
|
||||
@@ -11155,7 +11155,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
_resolve_variables(t, _prompt_variables)[0] for t in split_scene_texts
|
||||
]
|
||||
if camera_moves and split_scene_texts:
|
||||
split_scene_texts = [self._v2_camera_moves(t, f"scene {_i + 1}")
|
||||
split_scene_texts = [self._v2_camera_moves(t, f"scene {_i + 1}", f"{seed}:{_i}",
|
||||
camera_moves_chance)
|
||||
for _i, t in enumerate(split_scene_texts)]
|
||||
# Enhanced PER SCENE, not as one blob. The editor's scene list is
|
||||
# authoritative on count, so rewriting the scenes as a single paragraph
|
||||
@@ -14168,6 +14169,7 @@ class FunPackStudio:
|
||||
positive_conditioning=positive_conditioning,
|
||||
h3_phrase_emphasis=bool(rf.get("h3_phrase_emphasis", False)),
|
||||
camera_moves=bool(rf.get("camera_moves", False)),
|
||||
camera_moves_chance=float(rf.get("camera_moves_chance", 0.7)),
|
||||
h3_phrase_variability=float(rf.get("h3_phrase_variability", 0.0) or 0.0),
|
||||
prompt_enhance=bool(rf.get("prompt_enhance", False)),
|
||||
prompt_enhance_output=bool(rf.get("prompt_enhance_output", False)),
|
||||
|
||||
@@ -253,6 +253,7 @@ _OWNED_BY = {
|
||||
"negative_erase_strength": ("negative_erase", ('true',)),
|
||||
"output_guidance_strength": ("output_guidance", None),
|
||||
"h3_phrase_variability": ("h3_phrase_emphasis", ('true',)),
|
||||
"camera_moves_chance": ("camera_moves", ('true',)),
|
||||
"trajectory_guidance_strength": ("trajectory_guidance", None),
|
||||
"phase_scale": ("identity_transfer_enabled", None),
|
||||
"score_slider_strength": ("score_slider", None),
|
||||
@@ -339,6 +340,7 @@ _EDITOR_DEFAULTS = {
|
||||
"h3_block_repeat_last_steps": 0,
|
||||
"h3_phrase_emphasis": False,
|
||||
"camera_moves": False,
|
||||
"camera_moves_chance": 0.7,
|
||||
"h3_phrase_variability": 0.0,
|
||||
"h3_repr_steering": False,
|
||||
"h3_repr_steering_block": "25",
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
hint: "EXPERIMENTAL, unvalidated. Boosts the attention paid to phrases the rating said were MISSING, by biasing their attention logits in H3's packed stream. Needs a rated run first. Turn it off if generations drift away from the prompt — and note it forces SLA to run dense." },
|
||||
{ name: "camera_moves", label: "Camera moves for [Shot N] (H3)", kind: "bool", default: false,
|
||||
hint: "Adds a camera move (close-up, focus, zoom in...) to each [Shot N] block that has none, aimed at the most specific thing in it — a part of the subject, never the subject itself. No language model; instant. Blocks that already name a camera move, and music text, are left alone." },
|
||||
{ name: "camera_moves_chance", label: "Camera move chance", kind: "float", default: 0.7, min: 0.0, max: 1.0, step: 0.05,
|
||||
dependsOn: "camera_moves", dependsVals: [true],
|
||||
hint: "How often a [Shot N] block gets camera moves at all. Each one that does gets one to three (mostly one), sometimes travelling from the first thing to the last, sometimes ending with a pull-back — drawn per shot from the seed, so no pattern repeats in every prompt. 1 = every eligible shot." },
|
||||
{ name: "h3_phrase_variability", label: "Phrase emphasis variability", kind: "float", default: 0.0, min: 0.0, max: 1.0, step: 0.05,
|
||||
dependsOn: "h3_phrase_emphasis", dependsVals: [true],
|
||||
hint: "0 = only what the rating has cemented gets extra attention, nothing else. 1 = that emphasis is switched off entirely, so untrained phrasing and actions compete on equal footing and can bleed into the scene — including ones you have rated against before. Same bias channel as the toggle above, just scaled down." },
|
||||
|
||||
+66
-19
@@ -9,6 +9,7 @@ the first shot) is a constant, not a target. Sound text (music, score...) is lef
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
|
||||
SHOT = re.compile(r"\[\s*Shot\s+(\d+)\s*\]", re.I)
|
||||
@@ -31,10 +32,19 @@ 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 = ("Close-up on {x}.", "Focus at {x}.", "Zoom in at {x}.")
|
||||
MOVES_OTHER = ("Zoom in at {x}.", "Move around {x}.", "Rotate the camera to {x}.")
|
||||
MOVES_TRAVEL = ("Camera moves from {x} to {y}.", "Pan from {x} to {y}.",
|
||||
"Focus shifts from {x} to {y}.")
|
||||
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}.")
|
||||
# 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.")
|
||||
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"}
|
||||
POSSESSIVE_PRONOUNS = {"his", "her", "their", "its", "my", "your", "our"}
|
||||
|
||||
@@ -153,8 +163,51 @@ def _topics(pool):
|
||||
return out
|
||||
|
||||
|
||||
def add_camera_moves(text):
|
||||
"""-> (new prompt, [per-shot report dicts]). Unchanged when there is no `[Shot N]`."""
|
||||
def _pick(options, last, rng):
|
||||
"""One of `options`, never the one the previous shot opened with when others exist."""
|
||||
pool = [o for o in options if o != last] or list(options)
|
||||
return rng.choice(pool) if rng else pool[0]
|
||||
|
||||
|
||||
def _plan(pool, last, rng):
|
||||
"""-> (moves text, target description, opening template). Fixed and minimal without
|
||||
`rng` (one move, X -> Y when the shot has two topics); with it, how many moves, whether
|
||||
they travel and which words are all drawn, so no shot pattern repeats by construction."""
|
||||
topics = _topics(pool)
|
||||
best = max(pool, key=_score)
|
||||
if rng is None:
|
||||
k, travel = 1, len(topics) >= 2
|
||||
else:
|
||||
k = rng.choices((1, 2, 3), COUNT_WEIGHTS)[0]
|
||||
travel = k == 1 and len(topics) >= 2 and rng.random() < TRAVEL_SHARE
|
||||
if travel:
|
||||
x, y = _name(topics[0]), _name(topics[-1])
|
||||
move = _pick(MOVES_TRAVEL, last, rng)
|
||||
return move.format(x=x, y=y), f"{x} -> {y}", move
|
||||
first = topics[0] if (k > 1 and topics) else best
|
||||
moves = MOVES_DETAIL if (first[2] or first[0] in PARTS) else MOVES_OTHER
|
||||
opening = _pick(moves, last, rng)
|
||||
target = _name(first)
|
||||
sentences = [opening.format(x=target)]
|
||||
desc = target
|
||||
if k > 1:
|
||||
if len(topics) >= 2:
|
||||
y = _name(topics[-1])
|
||||
sentences.append(_pick(MOVES_THEN, None, rng).format(y=y))
|
||||
desc += f" -> {y}"
|
||||
else:
|
||||
sentences.append(_pick(MOVES_FINISH, None, rng))
|
||||
if k > 2 and len(topics) >= 2:
|
||||
sentences.append(_pick(MOVES_FINISH, None, rng))
|
||||
return " ".join(sentences), desc, opening
|
||||
|
||||
|
||||
def add_camera_moves(text, seed=None, chance=1.0):
|
||||
"""-> (new prompt, [per-shot report dicts]). Unchanged when there is no `[Shot N]`.
|
||||
|
||||
`seed=None`: one plain move per shot (deterministic). With a `seed`, each shot is left
|
||||
alone with probability 1 - `chance`, and otherwise gets one to three moves, drawn per
|
||||
shot from the seed, so the same prompt and seed always give the same text."""
|
||||
marks = list(SHOT.finditer(text or ""))
|
||||
if not marks:
|
||||
return text, []
|
||||
@@ -174,28 +227,22 @@ def add_camera_moves(text):
|
||||
for i, m in enumerate(marks):
|
||||
picture, sound = parts[i]
|
||||
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):
|
||||
entry["why"] = "already has a camera move"
|
||||
elif not pool:
|
||||
entry["why"] = "nothing specific to aim at"
|
||||
elif rng is not None and rng.random() >= chance:
|
||||
entry["why"] = "left as written by chance"
|
||||
else:
|
||||
topics = _topics(pool)
|
||||
if len(topics) >= 2:
|
||||
x, y = _name(topics[0]), _name(topics[-1])
|
||||
move = next((mv for mv in MOVES_TRAVEL if mv != last), MOVES_TRAVEL[0])
|
||||
entry.update(move=move.format(x=x, y=y), target=f"{x} -> {y}")
|
||||
else:
|
||||
best = max(pool, key=_score)
|
||||
moves = MOVES_DETAIL if (best[2] or best[0] in PARTS) else MOVES_OTHER
|
||||
move = next((mv for mv in moves if mv != last), moves[0])
|
||||
target = _name(best)
|
||||
entry.update(move=move.format(x=target), target=target)
|
||||
last = move
|
||||
moves, desc, opening = _plan(pool, last, rng)
|
||||
entry.update(move=moves, target=desc)
|
||||
last = opening
|
||||
core = picture.rstrip()
|
||||
gap = picture[len(core):] or (" " if sound else "")
|
||||
picture = core + ("" if core.endswith((".", "!", "?")) else ".") + " " \
|
||||
+ entry["move"] + gap
|
||||
+ moves + gap
|
||||
out.append(m.group(0) + picture + sound)
|
||||
report.append(entry)
|
||||
return "".join(out), report
|
||||
|
||||
@@ -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 "Close-up on <Subject 1>'s hand." in out
|
||||
assert "Camera moves into a close-up on <Subject 1>'s hand." in out
|
||||
|
||||
|
||||
def test_moves_sit_inside_their_shot_and_before_the_music():
|
||||
@@ -69,7 +69,7 @@ def test_the_same_move_does_not_repeat_between_shots():
|
||||
pytest.importorskip("spacy")
|
||||
p = HEAD + "[Shot 1] <Subject 1> raises <Subject 1>'s hand. [Shot 2] <Subject 2> lifts <Subject 2>'s chin."
|
||||
_out, rep = sc.add_camera_moves(p)
|
||||
assert rep[0]["move"].split(" ")[0] != rep[1]["move"].split(" ")[0]
|
||||
assert rep[0]["move"].split(" ")[1:3] != rep[1]["move"].split(" ")[1:3]
|
||||
|
||||
|
||||
def test_without_spacy_the_rules_still_find_owned_parts(monkeypatch):
|
||||
@@ -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 "Close-up on <Subject 1>'s hand." in out
|
||||
assert "Camera moves into a close-up on <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"
|
||||
|
||||
@@ -117,3 +117,38 @@ def test_three_topics_go_from_the_first_to_the_last():
|
||||
"then opens the window.")
|
||||
_out, rep = sc.add_camera_moves(p)
|
||||
assert "chin" in rep[0]["move"] and "window" in rep[0]["move"] and "lamp" not in rep[0]["move"]
|
||||
|
||||
|
||||
TWO = HEAD + "[Shot 1] <Subject 1> touches <Subject 1>'s chin, then reaches for the lamp."
|
||||
ONE = HEAD + "[Shot 1] <Subject 1> touches <Subject 1>'s chin."
|
||||
|
||||
|
||||
def test_varied_moves_are_reproducible_from_the_seed():
|
||||
pytest.importorskip("spacy")
|
||||
assert sc.add_camera_moves(PROMPT, seed=7) == sc.add_camera_moves(PROMPT, seed=7)
|
||||
assert len({sc.add_camera_moves(PROMPT, seed=s)[0] for s in range(20)}) > 5
|
||||
|
||||
|
||||
def test_chance_zero_leaves_every_shot_alone_and_says_why():
|
||||
pytest.importorskip("spacy")
|
||||
out, rep = sc.add_camera_moves(PROMPT, seed=1, chance=0.0)
|
||||
assert out == PROMPT and all("chance" in r["why"] for r in rep)
|
||||
|
||||
|
||||
def test_no_single_pattern_is_every_prompt():
|
||||
"""Over many seeds a shot with two topics gets one, two and three moves, some travelling
|
||||
X -> Y and some not: 'zoom in, move to Y, zoom out' is never the rule."""
|
||||
pytest.importorskip("spacy")
|
||||
sentences, travel = set(), set()
|
||||
for seed in range(200):
|
||||
_out, rep = sc.add_camera_moves(TWO, seed=seed)
|
||||
sentences.add(rep[0]["move"].count(". ") + 1)
|
||||
travel.add(" from " in rep[0]["move"])
|
||||
assert sentences == {1, 2, 3} and travel == {True, False}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user