feat: rule-based camera moves for [Shot N] blocks, Editor toggle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 16:47:07 +03:00
co-authored by Claude Sonnet 5.5
parent a01ffbd154
commit f57910da2d
8 changed files with 325 additions and 1 deletions
+1
View File
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Camera moves for `[Shot N]` prompts (Engine settings, H3, off by default): each shot without a camera move gets one — close-up, focus, zoom in, move around — aimed at the most specific thing in it, never a bare `<Subject N>`. No language model; uses spaCy when installed (`spacy` in requirements).
- H3 rating picker: "Disliked — bad image" and "Disliked — bad composition". Everything else reads them as Disliked; shot memory, decisiveness, late-branch and STAS learn only from composition blame, and DynaShift discards the latent when composition was the fault.
- Late-branch guidance now measures how hard it actually pushed; a rating counts up to 2x (down to 0.25x) when that run was unusually strong (weak) for the key, after 3 runs of history.
+33 -1
View File
@@ -9275,6 +9275,29 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
return head[:cut + 1].strip(), True
return head.rsplit(" ", 1)[0].strip(), True
@staticmethod
def _v2_camera_moves(text, where):
"""`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)
except Exception as e: # noqa: BLE001
print(f"[FunPackVideoRefinerV2] camera moves: failed | {where}: {e}; prompt left as written")
return text
if not report:
return text
added = [r for r in report if r["move"]]
skipped = "; ".join(f"shot {r['shot']} {r['why']}" for r in report if not r["move"])
print(f"[FunPackVideoRefinerV2] camera moves: "
+ (f"Active | {where}: {len(added)} of {len(report)} shot(s) — "
+ ", ".join(f"shot {r['shot']} {r['move'].rstrip('.')}" for r in added)
if added else f"Inactive | {where}: no shot got one")
+ (f" ({skipped})" if skipped else ""))
return out
def _v2_note_enhanced(self, item):
"""Record one rewrite for the node's ui output AND publish it live, so the Editor
can show it while the video is still rendering (and the user can stop a bad one)."""
@@ -10142,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,
h3_phrase_emphasis=False, h3_phrase_variability=0.0, camera_moves=False,
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,
@@ -10816,6 +10839,11 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
if _prompt_variables:
prompt_to_encode = _resolve_variables(prompt_to_encode, _prompt_variables)[0]
# 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 enhancer ────────────────────────────────────────────────────────────
# Runs BEFORE the prompt becomes conditioning, so what ComfyUI generates from is the
# enhanced text and nothing downstream has to know this happened. After shortcuts and
@@ -11126,6 +11154,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
split_scene_texts = [
_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}")
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
# and re-splitting could return a different number of scenes and desync
@@ -14136,6 +14167,7 @@ class FunPackStudio:
refinement_key_input=refinement_key_input,
positive_conditioning=positive_conditioning,
h3_phrase_emphasis=bool(rf.get("h3_phrase_emphasis", False)),
camera_moves=bool(rf.get("camera_moves", False)),
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)),
+1
View File
@@ -338,6 +338,7 @@ _EDITOR_DEFAULTS = {
"h3_block_repeat_video_only": False,
"h3_block_repeat_last_steps": 0,
"h3_phrase_emphasis": False,
"camera_moves": False,
"h3_phrase_variability": 0.0,
"h3_repr_steering": False,
"h3_repr_steering_block": "25",
+2
View File
@@ -119,6 +119,8 @@
hint: "Puts each word back to its original strength after the change, so the result reads as 'less of that thing' rather than 'quieter prompt'. Off is the raw result." },
{ name: "h3_phrase_emphasis", label: "Rating-driven phrase emphasis (H3)", kind: "bool", default: false,
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: "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." },
+3
View File
@@ -14,3 +14,6 @@ gguf
Pillow
insightface
opencv-python-headless
# Picks the target of a camera move in [Shot N] prompts. Optional: without it (or without
# its model, `python -m spacy download en_core_web_sm`) simpler word rules are used.
spacy
+178
View File
@@ -0,0 +1,178 @@
"""Add a camera move to each `[Shot N]` block of an H3 prompt that has none, aimed at the
most specific thing in that shot. No language model: a small spaCy tagger (rules-only
fallback when it is not installed) picks the target, a few words pick the move.
`[Shot N]` here is MiniMax's own in-prompt shot, never a timeline scene. `<Subject N>` tags
are the subjects every shot is about, so they are never a target on their own; a part or
belonging of one ("<Subject 1>'s hand") is. A thing named in most shots (or in the text before
the first shot) is a constant, not a target. Sound text (music, score...) is left alone.
"""
import math
import re
SHOT = re.compile(r"\[\s*Shot\s+(\d+)\s*\]", re.I)
SUBJECT = re.compile(r"<\s*Subject\s*(\d+)\s*>", re.I)
# A `<Subject N>` tag confuses the tagger ("Subject1 bites" reads as a noun); a real-looking
# rare name parses like a person. Swapped in before tagging, swapped back after.
NAMES = ("Mariel", "Tobias", "Odette", "Rurik", "Sabine", "Teodor", "Ilsa", "Corwin", "Yvette")
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)
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)
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",
"light", "atmosphere", "mood", "setting"}
PARTS = {"lip", "mouth", "tongue", "tooth", "teeth", "eye", "eyes", "face", "cheek", "hand",
"finger", "hair", "neck", "shoulder", "waist", "hip", "leg", "foot", "feet", "arm",
"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}.")
DETERMINERS = {"the", "a", "an", "this", "that", "these", "those"}
POSSESSIVE_PRONOUNS = {"his", "her", "their", "its", "my", "your", "our"}
_nlp = None
_tried = False
def _spacy():
"""The small English tagger, or None (said once) when it is not installed."""
global _nlp, _tried
if not _tried:
_tried = True
try:
import spacy
_nlp = spacy.load("en_core_web_sm")
except Exception as e: # noqa: BLE001
try:
import funpack_log as _log
except ImportError:
from . import funpack_log as _log
_log.failed("FunPackCameraMoves", "spaCy tagger", e,
"using the simple word rules; run `pip install spacy` and "
"`python -m spacy download en_core_web_sm` for better targets")
return _nlp
def _hide(text):
return SUBJECT.sub(lambda m: NAMES[(int(m.group(1)) - 1) % len(NAMES)], text)
def _show(text):
return STAND_IN.sub(lambda m: f"<Subject {NAMES.index(m.group(1)) + 1}>", text)
def _is_subject(text):
return bool(STAND_IN.fullmatch(re.sub(r"^(?:the\s+)", "", text.strip(), flags=re.I)))
def _split_sound(body):
"""-> (picture text, sound text): everything from the first sentence about sound on."""
for m in re.finditer(r"[^.!?]+[.!?]?\s*", body):
if AUDIO.search(m.group(0)):
return body[:m.start()], body[m.start():]
return body, ""
def _phrase(tok):
"""A chunk head with the words that name it ("Subject1's hand", "red lips")."""
keep = {tok}
for c in tok.children:
if c.dep_ in ("poss", "compound", "amod") and c.i < tok.i:
keep.add(c)
if c.dep_ == "poss":
keep.update(g for g in c.children if g.dep_ == "case")
toks = sorted(keep, key=lambda t: t.i)
text = " ".join(t.text for t in toks).strip().replace(" 's", "'s").replace(" ’s", "’s")
return text, any(c.dep_ == "poss" for c in tok.children)
def candidates(picture):
"""[(lemma, phrase, owned, position, dep)] for the nouns of one shot's picture text."""
hidden = _hide(picture)
nlp = _spacy()
out = []
if nlp is not None:
doc = nlp(hidden)
for chunk in doc.noun_chunks:
root = chunk.root
prev = doc[root.i - 1] if root.i else None
if prev is not None and STAND_IN.fullmatch(prev.text) and prev.dep_ != "poss":
continue # "<Subject 1> laughs": the tagger misread a verb
if root.pos_ in ("PRON", "NUM") or _is_subject(chunk.text):
continue
lemma = root.lemma_.lower()
if root.dep_ == "pobj" and root.head.lower_ == "of":
continue # "a glass of wine": the glass is the thing
text, owned = _phrase(root)
if _is_subject(text) or lemma in GENERIC or STAND_IN.fullmatch(root.text):
continue
out.append((lemma, text, owned, chunk.start, root.dep_))
return out
for m in re.finditer(rf"(?:({'|'.join(NAMES)})'s|\b(his|her|their|the|a|an))\s+((?:\w+\s+)?\w+)",
hidden, re.I):
words = m.group(3).split()
lemma = words[-1].lower().rstrip("s") if words[-1].lower() not in PARTS else words[-1].lower()
if lemma in GENERIC:
continue
owner = m.group(1) or (m.group(2) if (m.group(2) or "").lower() in POSSESSIVE_PRONOUNS else "")
text = (f"{owner}'s " if m.group(1) else f"{owner} " if owner else "") + " ".join(words)
out.append((lemma, text.strip(), bool(owner), m.start(), "obj"))
return out
def _score(c):
lemma, _text, owned, pos, dep = c
return ((2 if owned or lemma in PARTS else 0) + (1 if dep in ("dobj", "pobj") else 0)
- pos * 1e-4)
def add_camera_moves(text):
"""-> (new prompt, [per-shot report dicts]). Unchanged when there is no `[Shot N]`."""
marks = list(SHOT.finditer(text or ""))
if not marks:
return text, []
header = text[:marks[0].start()]
bodies = [text[m.end():(marks[i + 1].start() if i + 1 < len(marks) else len(text))]
for i, m in enumerate(marks)]
parts = [_split_sound(b) for b in bodies]
cands = [candidates(p[0]) for p in parts]
header_lemmas = {c[0] for c in candidates(header)}
need = max(2, math.ceil(0.6 * len(bodies))) if len(bodies) > 1 else 10 ** 9
seen = {}
for lst in cands:
for lemma in {c[0] for c in lst}:
seen[lemma] = seen.get(lemma, 0) + 1
constant = {l for l, n in seen.items() if n >= need} | header_lemmas
report, out, last = [], [header], None
for i, m in enumerate(marks):
picture, sound = parts[i]
entry = {"shot": int(m.group(1)), "move": None, "target": None, "why": ""}
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"
else:
best = max(pool, key=_score)
detail = best[2] or best[0] in PARTS
moves = MOVES_DETAIL if detail else MOVES_OTHER
move = next((mv for mv in moves if mv != last), moves[0])
target = _show(best[1])
if not re.match(r"(?:the|a|an|<Subject|his|her|their)\b", target, re.I) \
and "'s " not in target:
target = "the " + target
entry.update(move=move.format(x=target), target=target)
last = move
core = picture.rstrip()
gap = picture[len(core):] or (" " if sound else "")
picture = core + ("" if core.endswith((".", "!", "?")) else ".") + " " \
+ entry["move"] + gap
out.append(m.group(0) + picture + sound)
report.append(entry)
return "".join(out), report
+102
View File
@@ -0,0 +1,102 @@
"""Camera moves for [Shot N] blocks. Fixtures are neutral stand-ins with the real shape:
<Subject N> tags everywhere, one shot per block, a music sentence at the end."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import shot_camera as sc # noqa: E402
HEAD = ("Two friends in a sunlit loft, <Subject 1> is a tall woman with red hair, "
"<Subject 2> is a man in a grey coat. Cinematic 35mm look. ")
S1 = "[Shot 1] <Subject 1> is waving <Subject 1>'s hand, greeting <Subject 2>. "
S2 = "[Shot 2] <Subject 1> bites <Subject 1>'s lower lip while <Subject 2> smiles at <Subject 1>. "
S3 = "[Shot 3] <Subject 2> hands <Subject 1> a glass of wine across the table. <Subject 1> laughs. "
MUSIC = "Upbeat jazz music with a soft bassline plays throughout."
PROMPT = HEAD + S1 + S2 + S3 + MUSIC
def test_no_shot_markers_leaves_the_prompt_alone():
assert sc.add_camera_moves("A woman walks. Music plays.") == ("A woman walks. Music plays.", [])
def test_move_aims_at_a_part_of_the_subject_never_the_subject():
pytest.importorskip("spacy")
out, rep = sc.add_camera_moves(PROMPT)
assert rep[0]["target"] == "<Subject 1>'s hand"
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
def test_moves_sit_inside_their_shot_and_before_the_music():
pytest.importorskip("spacy")
out, rep = sc.add_camera_moves(PROMPT)
assert out.startswith(HEAD + "[Shot 1]") # intro untouched
assert out.endswith(" " + MUSIC) # music untouched, last
assert out.index(rep[2]["move"]) < out.index(MUSIC)
assert out.index("[Shot 2]") < out.index(rep[1]["move"]) < out.index("[Shot 3]")
def test_a_shot_with_a_move_already_is_left_alone():
pytest.importorskip("spacy")
p = HEAD + "[Shot 1] The camera zooms in on <Subject 1>'s hand as it waves. " + S2
out, rep = sc.add_camera_moves(p)
assert rep[0]["move"] is None and "already" in rep[0]["why"]
assert "[Shot 1] The camera zooms in on <Subject 1>'s hand as it waves. [Shot 2]" in out
def test_a_thing_in_most_shots_is_not_a_target():
pytest.importorskip("spacy")
p = (HEAD + "[Shot 1] <Subject 1> holds a rose. [Shot 2] <Subject 1> smells the rose. "
"[Shot 3] <Subject 2> hugs <Subject 1>'s shoulder near the rose.")
_out, rep = sc.add_camera_moves(p)
assert all(r["target"] != "the rose" for r in rep)
assert rep[2]["target"] == "<Subject 1>'s shoulder"
def test_a_shot_with_only_subjects_gets_nothing():
pytest.importorskip("spacy")
_out, rep = sc.add_camera_moves(HEAD + "[Shot 1] <Subject 1> laughs with <Subject 2>. "
"[Shot 2] <Subject 2> nods at <Subject 1>.")
assert [r["move"] for r in rep] == [None, None]
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]
def test_without_spacy_the_rules_still_find_owned_parts(monkeypatch):
monkeypatch.setattr(sc, "_nlp", None)
monkeypatch.setattr(sc, "_tried", True)
_out, rep = sc.add_camera_moves(PROMPT)
assert rep[0]["target"] == "<Subject 1>'s hand"
def test_tags_survive_the_round_trip():
pytest.importorskip("spacy")
out, _rep = sc.add_camera_moves(PROMPT)
assert out.count("<Subject 1>") >= PROMPT.count("<Subject 1>")
assert not any(n in out for n in sc.NAMES)
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: Active" in capsys.readouterr().out
assert conditioning.FunPackVideoRefinerV2._v2_camera_moves("no shots here", "prompt") == "no shots here"
def test_refiner_wrapper_never_breaks_a_run(monkeypatch, capsys):
import conditioning
monkeypatch.setattr(sc, "add_camera_moves", lambda t: 1 / 0)
assert conditioning.FunPackVideoRefinerV2._v2_camera_moves(PROMPT, "prompt") == PROMPT
assert "camera moves: failed" in capsys.readouterr().out
+5
View File
@@ -1138,6 +1138,11 @@ function openPanel(node) {
settings.refiner.h3_phrase_emphasis = phraseEmph.checked;
});
body.append(row("Rating-driven phrase emphasis (H3)", phraseEmph));
const camMoves = el("input"); camMoves.type = "checkbox";
camMoves.checked = !!settings.refiner.camera_moves;
camMoves.addEventListener("change", () => { settings.refiner.camera_moves = camMoves.checked; });
body.append(row("Camera moves for [Shot N] (H3)", camMoves));
body.append(el("div", "funpack-studio-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 "