Files
olivv-cs-ComfyUI-FunPack/modules/conditioning/shot_camera/engine.py
T
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

1087 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 random
import re
from ..._core import addons, log
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)
# 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.
# Every cut or transition verb this module writes or a user is likely to write after "the shot" / "the camera".
_CUT_VERB = r"(?:cuts|transitions|changes|switches|cross-dissolves|cross-fades|dissolves|wipes|fades)(?:\s+(?:out|across))?"
# A cut or transition a user may open a shot with: "The shot slowly fades to", "Then the camera cuts to", "Smash cut to", "Fade to black".
_LEAD_PHRASE = (r"(?:(?:then|suddenly|next)[,\s]+)?(?:(?:the\s+)?(?:camera|shot|scene)\s+(?:(?:then|slowly|quickly|suddenly)\s+)?" + _CUT_VERB
+ r"|(?:an?\s+)?(?:smash\s+cut|hard\s+cut|match\s+cut|jump\s+cut|cut|cross-?dissolve|dissolve|cross-?fade|fade|wipe)(?:\s+(?:out|in|through\s+black))?)"
r"\s+(?:to|into|in\s+on)\b")
CUT = re.compile(r"\b(?:the\s+)?(?:camera|shot)\s+" + _CUT_VERB + r"\s+to\b", re.I)
# Whether a shot ALREADY has a camera move. Words like tilt, roll, pull, push, arc, focus are
# ordinary body movement ("tilts <Subject 1>'s head up and down", "pulls out a knife"), and
# "the camera" is often only where someone is looking, so those count only when the CAMERA
# does them, or when a sentence opens with the bare camera verb ("Pan right to reveal...").
_CAMERA_VERBS = (r"(?:moves?|pushes|pulls|pans|tilts|zooms|tracks|follows|drifts|circles|orbits|"
r"rises|lowers|glides|sweeps|racks|arcs|dollies|trucks|rolls|rotates|holds|"
r"shakes|stays|remains|sways|slides|cranes|swings|reframes|widens|tightens|"
r"starts|begins|continues|lingers|settles|zoom|pan|tilt|push|pull)")
CAMERA = re.compile(
r"\b(?:zoom(?:s|ed|ing)?\s+(?:in|out)|zoom-(?:in|out)|dolly|rack(?:s|ing)? focus|close-?up|"
r"handheld|wide shot|wide-angle shot|tracking shot|crane shot|static shot|whip pan|"
r"camera shake)\b"
r"|\bcamera(?:'s)?\s+(?:\w+ly\s+)?" + _CAMERA_VERBS + r"\b"
r"|\bcamera(?:'s)? (?:movement|motion|move)\b"
r"|(?:^|[.!?]\s+)(?:slowly |quickly |smoothly )?(?:pan|tilt|truck|pedestal|arc|orbit|roll|"
r"zoom|track|dolly|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"}
# A liked detail may name one of these. Hair, face, skin and the like are appearance: the old
# refiner refused to auto-inject those, and a like of a clip cannot tell the person from the act.
DETAIL_PARTS = PARTS - {"hair", "face", "skin", "expression", "smile", "gaze", "back"}
# Adjectives that locate a part ("lower lip") are not a detail worth repeating everywhere.
_NOT_A_DETAIL = {"left", "right", "other", "same", "whole", "own", "both", "each", "another",
"first", "next", "last", "upper", "lower", "front", "back"}
PLACES = {"room", "street", "kitchen", "bedroom", "bed", "table", "floor", "stage", "hall",
"garden", "beach", "forest", "city", "car", "office", "bathroom", "field"}
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 pans to {y}.", "Then the camera racks focus to {y}.",
"Then the camera pushes in toward {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
INFUSED_TRAVEL = 0.5 # of infused shots with a subject of their own, how many travel between the two
TRAVEL_SHARE = 0.25 # of one-move shots with two topics, how many travel X -> Y
HOLD_SHARE = 0.25 # of the other one-move shots, how many stay still on one thing
MOVES_HOLD = ("The camera holds steady on {x}.", "The camera stays still, focused on {x}.",
"The camera stays fixed, focused on {x}.")
DETERMINERS = {"the", "a", "an", "this", "that", "these", "those"}
POSSESSIVE_PRONOUNS = {"his", "her", "their", "its", "my", "your", "our"}
_nlp = None
_tried = False
def _say(what, error, effect):
"""One log line, once, when something falls back."""
log.once(f"shot_camera:{what}", log.ALERT, "FunUI Shot Camera", f"{what}: {type(error).__name__}: {error} -- {effect}")
def _spacy():
"""The small English tagger, or None (said once) when it is not installed."""
global _nlp, _tried
if not addons.enabled("camera"): # switched off, or never installed: word rules, quietly
return None
if not _tried:
_tried = True
try:
import spacy
_nlp = spacy.load("en_core_web_sm")
except Exception as e: # noqa: BLE001
fix = ("spacy not installed, run `pip install spacy`" if isinstance(e, ImportError)
else "model missing, run `python -m spacy download en_core_web_sm`")
_say("spaCy tagger", e, f"simple word rules used; {fix}")
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, ""
# Nouns that only mean something as part of a bigger thing: "the base" of what?
RELATIONAL = {"base", "tip", "top", "bottom", "side", "edge", "end", "middle", "part", "front",
"back", "surface", "inside", "outside", "center", "centre", "head", "bit", "area",
"spot", "line", "corner", "half"}
def _words(tok, with_det=False):
keep = {tok}
for c in tok.children:
if c.i < tok.i and (c.dep_ in ("poss", "compound", "amod")
or (with_det and c.dep_ == "det")):
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)
return " ".join(t.text for t in toks).strip().replace(" 's", "'s").replace(" ’s", "’s")
def _phrase(tok):
"""-> (text, owned, has_of): a chunk head with the words that name it ("Subject1's hand",
"red lips", "base of the lamp"). An "of ..." that follows belongs to the noun and is kept:
a target cut off before it ("the base") names nothing."""
text = _words(tok)
has_of = False
for c in tok.children:
if c.dep_ == "prep" and c.lower_ == "of":
pobj = next((g for g in c.children if g.dep_ == "pobj"), None)
if pobj is not None:
text += " of " + _words(pobj, with_det=True)
has_of = True
return text, any(c.dep_ == "poss" for c in tok.children), has_of
def _resolve(lemma, text, owned, has_of, names):
"""The candidate's phrase made whole, or None when it cannot be: a relational noun with
nothing to be the base/tip/side OF, or a body part with no owner when the shot has more
than one person ("the skin": whose?). With exactly one person in the shot, they own it."""
if has_of or owned:
return text, True
if lemma in RELATIONAL:
return None
if lemma in PARTS:
if len(names) == 1:
return f"{next(iter(names))}'s {text}", True
return None
return text, owned
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, has_of = _phrase(root)
if _is_subject(text) or lemma in GENERIC or STAND_IN.fullmatch(root.text):
continue
whole = _resolve(lemma, text, owned, has_of, set(STAND_IN.findall(hidden)))
if whole is None:
continue
dep, head = root.dep_, root
while dep == "conj" and head.head is not head: # "a banana and a cherry": both objects
head = head.head
dep = head.dep_
out.append((lemma, whole[0], whole[1], chunk.start, 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 _content_amods(root):
"""Adjectives that describe `root`, ignoring ones that only say which side it is on."""
return sorted((c for c in root.children
if c.dep_ == "amod" and c.is_alpha and c.lemma_.lower() not in _NOT_A_DETAIL),
key=lambda t: t.i)
def detail_phrases(picture):
"""[(lemma, phrase)] for a short detail already written on a body part ("detailed lips").
A bare noun is not a detail. Neither is a place, a person, or hair and face: those are who
the clip is about, and a like must not paste them onto the next one. With no tagger, one
adjective in front of a body-part word is the same idea."""
hidden = _hide(picture or "")
nlp = _spacy()
if nlp is None:
return _detail_phrases_plain(hidden)
out, seen = [], set()
for root in (c.root for c in nlp(hidden).noun_chunks):
lemma = root.lemma_.lower()
if lemma not in DETAIL_PARTS:
continue
amods = _content_amods(root)
if not amods:
continue
phrase = " ".join(c.text.lower() for c in amods) + " " + root.text.lower()
phrase = re.sub(r"\s+", " ", phrase).strip()
if not phrase or phrase in seen or len(phrase.split()) > 5:
continue
seen.add(phrase)
out.append((lemma, phrase))
return out
def _detail_phrases_plain(hidden):
parts = "|".join(sorted(DETAIL_PARTS, key=len, reverse=True))
skip = DETERMINERS | POSSESSIVE_PRONOUNS | _NOT_A_DETAIL | {"and", "with", "from", "into", "onto", "over"}
out, seen = [], set()
for m in re.finditer(rf"\b([A-Za-z]{{3,}})\s+({parts})s?\b", hidden):
adj = m.group(1).lower()
if adj in skip:
continue
word = m.group(2).lower()
lemma = word if word in DETAIL_PARTS else word[:-1]
phrase = f"{adj} {m.group(2).lower()}"
if phrase in seen:
continue
seen.add(phrase)
out.append((lemma, phrase))
return out
def qualified_lemmas(picture):
"""Body-part lemmas this shot already describes with an adjective. A second detail would
argue with that adjective, so nothing is added onto them."""
hidden = _hide(picture or "")
nlp = _spacy()
if nlp is None:
return {lemma for lemma, _phrase in _detail_phrases_plain(hidden)}
out = set()
for tok in nlp(hidden):
if tok.lemma_.lower() in DETAIL_PARTS and any(c.dep_ == "amod" and c.is_alpha for c in tok.children):
out.add(tok.lemma_.lower())
return out
def detail_phrases_in(text):
"""Every detail phrase in a prompt, one per wording, read per shot so the sound track is skipped."""
marks = list(SHOT.finditer(text or ""))
chunks = ([text] if not marks else
[text[m.end():(marks[i + 1].start() if i + 1 < len(marks) else len(text))] for i, m in enumerate(marks)])
out, seen = [], set()
for chunk in chunks:
picture, _sound = _split_sound(chunk)
for lemma, phrase in detail_phrases(picture):
if phrase not in seen:
seen.add(phrase)
out.append((lemma, phrase))
return out
def _as_sentence(phrase):
phrase = (phrase or "").strip().rstrip(".")
return (phrase[:1].upper() + phrase[1:] + ".") if phrase else ""
def add_detail_notes(text, seed=0, chance=0.5, bank=()):
"""-> (prompt, {"added": [{"shot", "lemma", "phrase"}], "why"}).
A banked detail is added once, as its own sentence at the end of a shot, and only when that
shot already names the body part, the part is not already described, and the words are not
already there. One detail per shot: the one ratings have kept most. The original sentence
is never rewritten, so it cannot be made ungrammatical. `chance` of 0 or 1 is kept as set;
anything between is the caller's, already tilted per phrase."""
marks = list(SHOT.finditer(text or ""))
info = {"added": [], "why": ""}
if not marks:
info["why"] = "no shots"
return text, info
usable = [b for b in bank or () if isinstance(b, dict) and b.get("lemma") and b.get("phrase") and float(b.get("good") or 0) >= 1]
if not usable:
info["why"] = "nothing learned yet"
return text, info
out = [text[:marks[0].start()]]
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
picture, sound = _split_sound(text[m.end():end])
present = {c[0] for c in candidates(picture)}
taken = qualified_lemmas(picture)
low = picture.lower()
options = [b for b in usable
if b["lemma"] in present and b["lemma"] not in taken and b["phrase"].lower() not in low]
options.sort(key=lambda b: (-(float(b["good"]) - float(b["bad"])), -float(b["good"]), b["phrase"]))
if options and random.Random(f"{seed}:detail:{i}:{options[0]['phrase']}").random() < float(options[0].get("chance", chance)):
sentence = _as_sentence(options[0]["phrase"])
picture = picture.rstrip() + " " + sentence + " "
info["added"].append({"shot": int(m.group(1)), "lemma": options[0]["lemma"], "phrase": options[0]["phrase"]})
out.append(m.group(0) + picture + sound)
if not info["added"]:
info["why"] = "nothing matched"
return "".join(out), info
def _score(c, prior=None):
"""How much `c` deserves the camera: what it is in its own prompt (an owned part, an object
of a verb), plus, when given, `prior`'s bonus for words the user's prompts keep returning to."""
lemma, _text, owned, pos, dep = c
return ((2 if owned or lemma in PARTS else 0) + (1 if dep in ("dobj", "pobj") else 0)
+ (prior or {}).get(lemma, 0.0) - pos * 1e-4)
def _name(c):
"""The phrase of candidate `c` as it reads in the prompt, tags restored, with an article."""
text = _show(c[1])
if not re.match(r"(?:the|a|an|<Subject|his|her|their)\b", text, re.I) and "'s " not in text:
text = "the " + text
return text
def _topics(pool):
"""The distinct things a shot dwells on, in the order the text reaches them. Only things
with a role beyond being the doer (owned parts, objects of a verb or preposition)."""
seen, out = set(), []
for c in sorted(pool, key=lambda c: c[3]):
if _score(c) > 0.5 and c[0] not in seen:
seen.add(c[0])
out.append(c)
return out
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]
HABIT_TEMPERATURE = 0.7 # lower = the habit decides more; higher = closer to a coin toss
MENTION_BONUS = 0.5 # per extra mention inside the shot, capped at 1.0: what a shot keeps returning to
def _draw(cands, prior, rng, n):
"""`n` distinct candidates. Only those within a role-step of the best plain score compete (a
habit can never lift a background word over a real target); among them, with an `rng`, the
pick is drawn with odds exp(score / T) rather than always taking the top, so a word the user
has used a hundred times does not win every shot it merely appears in."""
top = max(_score(c) for c in cands)
eligible = [c for c in cands if _score(c) >= top - 0.99] or list(cands)
if rng is None:
return sorted(eligible, key=lambda c: -_score(c, prior))[:n]
chosen, pool = [], list(eligible)
while pool and len(chosen) < n:
weights = [math.exp(_score(c, prior) / HABIT_TEMPERATURE) for c in pool]
pick = rng.choices(range(len(pool)), weights)[0]
chosen.append(pool.pop(pick))
return chosen
def _tilt(base, key, arms):
"""`base` odds tilted by how the ratings treated arm `key` (0.5-1.5x; 1 when unrated)."""
g, b = (arms or {}).get(key, (0.0, 0.0))
return base * (0.5 + (g + 1.0) / (g + b + 2.0))
def _plan(pool, last, rng, prior=None, force=None, mode="auto", arms=None):
"""-> (moves text, target description, opening template, style). 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.
`arms`: {"style:hold": (good, bad), ...} what ratings taught; tilts those draws. `style` is
the arm that was drawn ("hold", "travel", "k1".."k3"), None when a person decided."""
topics = _topics(pool)
best = _draw(pool, prior, rng, 1)[0]
if prior and len(topics) > 2: # two of them, weighted by habit, in text order
topics = sorted(_draw(topics, prior, rng, 2), key=lambda c: c[3])
if force: # the user picked what to aim at, in this order
best, topics = force[0], list(force)
if mode == "hold":
hold = _pick(MOVES_HOLD, last, rng)
target = _name(best)
return hold.format(x=target), target, hold, None
if force and len(force) >= 2:
# "from here to there (and on to...)": the camera visits the picks in the order given.
move = _pick(MOVES_TRAVEL, last, rng)
sentences = [move.format(x=_name(force[0]), y=_name(force[1]))]
sentences += [_pick(MOVES_THEN, None, rng).format(y=_name(c)) for c in force[2:]]
return " ".join(sentences), " -> ".join(_name(c) for c in force), move, None
if mode == "move":
k, travel = 1, False
elif rng is None:
k, travel = 1, len(topics) >= 2
else:
k = rng.choices((1, 2, 3), [_tilt(w, f"style:k{n}", arms) for n, w in zip((1, 2, 3), COUNT_WEIGHTS)])[0]
travel = k == 1 and len(topics) >= 2 and rng.random() < _tilt(TRAVEL_SHARE, "style:travel", arms)
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, "travel" if rng else None
if rng is not None and k == 1 and rng.random() < _tilt(HOLD_SHARE, "style:hold", arms):
hold = _pick(MOVES_HOLD, last, rng) # some shots want one fixed focus
target = _name(best)
return hold.format(x=target), target, hold, "hold"
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, (f"k{k}" if rng is not None and mode == "auto" else None)
def _analyse(text):
"""-> (marks, header, parts, pools): the `[Shot N]` markers, the text before the first, each
shot's (picture, sound) and its candidate targets with the things that are in (nearly) every
shot, or in the intro, taken out."""
marks = list(SHOT.finditer(text or ""))
if not marks:
return [], "", [], []
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(own_words_removed(p[0])) for p in parts]
header_lemmas = {c[0] for c in candidates(header)}
constant = _constants([p[0] for p in parts], cands, header_lemmas)
return marks, header, parts, [[c for c in lst if c[0] not in constant] for lst in cands]
def _content_key(picture):
"""Hash of a shot's own words. Cut times, views and camera sentences are not part of it."""
import hashlib
body = own_words_removed(picture or "")
body = re.sub(r"\s+", " ", body).strip().lower()
return hashlib.md5(body.encode("utf-8", "replace")).hexdigest()[:16]
def shot_key(picture, occurrence=0):
"""A stable name for one take of a shot. The same whatever cut opener, view or camera
sentence was added, so a choice still finds it after the prompt is regenerated.
The same shortcut used again is a later take (`:1`, `:2`), so a focus or view chosen
for the first does not lock the next."""
base = _content_key(picture)
return base if not occurrence else f"{base}:{int(occurrence)}"
def _take_keys():
"""A counter: the first copy of a shot's words keeps the plain key, the next get :1, :2."""
seen = {}
def key(picture):
base = _content_key(picture)
n = seen.get(base, 0)
seen[base] = n + 1
return shot_key(picture, n)
return key
def _constants(pictures, lists, header_lemmas):
"""Lemmas in (nearly) every distinct shot, plus whatever the intro already named.
Two copies of the same shortcut count once: DANCE DANCE must not strip the only
things the next take can focus on. 70%: with three different shots, a noun shared
by two of them is still a topic of those two."""
groups = {}
for picture, lst in zip(pictures, lists):
groups.setdefault(_content_key(picture), set()).update(c[0] for c in lst)
if len(groups) <= 1:
return set(header_lemmas)
need = max(2, math.ceil(0.7 * len(groups)))
seen = {}
for lemmas in groups.values():
for lemma in lemmas:
seen[lemma] = seen.get(lemma, 0) + 1
return {lemma for lemma, n in seen.items() if n >= need} | set(header_lemmas)
def focus_options(text, prior=None):
"""What the camera could aim at in each `[Shot N]`, for a person to choose from.
-> [{"shot", "key", "already", "candidates": [{"lemma", "text", "score"}], "auto": str|None}]
`auto` is what the rewriter would pick with no say from anyone (its plain, top choice)."""
marks, _header, parts, pools = _analyse(text)
out, keys = [], _take_keys()
for i, m in enumerate(marks):
picture = parts[i][0]
pool = pools[i]
opts, seen = [], set()
for c in sorted(pool, key=lambda c: -_score(c, prior)):
if c[0] in seen or _score(c) <= 0.5:
continue
seen.add(c[0])
opts.append({"lemma": c[0], "text": _name(c), "score": round(_score(c, prior), 2)})
auto = None
if opts and not CAMERA.search(CUT.sub("", picture)):
auto = _plan(pool, None, None, prior)[1]
out.append({"shot": int(m.group(1)), "key": keys(picture),
"auto_lemma": opts[0]["lemma"] if opts else None,
"already": bool(CAMERA.search(CUT.sub("", picture))),
"candidates": opts, "auto": auto})
return out
# ── focus infusion ──────────────────────────────────────────────────────────────────
def _own_words():
"""Every word this module writes into a prompt about cuts, the camera and views: never a target."""
texts = (*OUR_OPENERS, *MOVES_DETAIL, *MOVES_OTHER, *MOVES_TRAVEL, *MOVES_THEN, *MOVES_FINISH, *MOVES_HOLD,
*REFRAMES, *REFRAMES_PLAIN, *VIEWS)
return {w for t in texts for w in re.findall(r"[a-z]+", re.sub(r"\{[a-z]+\}", " ", t.lower()))}
ORDINARY = {"back", "side", "front", "pan", "track", "arc", "hold"}
def _infused(picture, taken, bank, rng):
"""-> a candidate for the camera to aim at, drawn from the words the user's prompts keep returning to
(`bank`: [(lemma, weight)]), or None. Never a word the shot already names (that would change nothing)
and never one of this module's own words."""
own = _own_words() - ORDINARY # words that are also plain nouns (a person's back, a frying pan) stay available
mine = lambda lemma: lemma not in ORDINARY and (lemma in own or any(lemma + end in own for end in ("s", "es", "d", "ed", "ing"))) # "zoom" for "zooms", "fade" for "fades"
named = _hide(picture).lower()
options = [(lemma, w) for lemma, w in bank if not mine(lemma) and lemma not in taken and w > 0 and not re.search(rf"\b{re.escape(lemma)}", named)]
if not options:
return None
lemma = rng.choices([o[0] for o in options], weights=[o[1] for o in options])[0]
whole = _resolve(lemma, lemma, False, False, set(STAND_IN.findall(_hide(picture))))
text, owned = whole if whole else (lemma, False)
return (lemma, text, owned, 0, "dobj")
def add_camera_moves(text, seed=None, chance=1.0, prior=None, choices=None, arms=None, infuse=0.0, bank=()):
"""-> (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.
`arms`: what ratings taught (see _plan); each report entry's "arms" lists the choices drawn
on their own for the rating that follows ("move:yes"/"move:no", a style, "word:<lemma>")."""
marks, header, parts, pools = _analyse(text)
if not marks:
return text, []
report, out, last, keys = [], [header], None, _take_keys()
for i, m in enumerate(marks):
picture, sound = parts[i]
entry = {"shot": int(m.group(1)), "move": None, "target": None, "why": "", "lemmas": [],
"key": keys(picture), "arms": []}
rng = random.Random(f"{seed}:{i}") if seed is not None else None
pool = pools[i]
# What the person said about this shot, if anything: "none", or a target and/or a mode.
said = (choices or {}).get(entry["key"]) or {}
mode = said.get("mode") or "auto"
wanted = said.get("lemmas") or ([said["lemma"]] if said.get("lemma") else [])
force = [c for c in (next((c for c in pool if c[0] == l), None) for l in wanted) if c]
entry["lemmas"] = [c[0] for c in pool if _score(c) > 0.5] # the words this shot dwells on
# A word the shot keeps coming back to is what it is about: a small lift per extra mention.
mentions = {}
for c in pool:
mentions[c[0]] = mentions.get(c[0], 0) + 1
boost = {l: min(1.0, MENTION_BONUS * (n - 1)) for l, n in mentions.items() if n > 1}
shot_prior = ({l: (prior or {}).get(l, 0.0) + boost.get(l, 0.0) for l in {*(prior or {}), *boost}}
if boost else prior)
if CAMERA.search(CUT.sub("", picture)):
entry["why"] = "already has a camera move"
elif not pool:
entry["why"] = "nothing specific to aim at"
elif mode == "none":
entry["why"] = "no move, as you chose"
elif rng is not None and mode == "auto" and not force and rng.random() >= chance:
entry["why"] = "left as written by chance"
entry["arms"] = ["move:no"]
else:
infused, infuse_arm = None, None # focus infusion: now and then aim at a word the prompts keep returning to
if rng is not None and mode == "auto" and not force and infuse > 0 and bank:
roll = random.Random(f"{seed}:infuse:{i}")
if roll.random() < infuse:
infused = _infused(picture, {c[0] for c in pool}, bank, roll)
else:
infuse_arm = "infuse:no"
aim = [infused] if infused else force
if infused and roll.random() < INFUSED_TRAVEL: # "from X to Y": the infused word and the shot's own subject, either way round
own = _draw(pool, shot_prior, roll, 1)[0]
aim = [infused, own] if roll.random() < 0.5 else [own, infused]
moves, desc, opening, style = _plan(pool, last, rng, shot_prior, force=aim, mode=mode, arms=arms)
entry.update(move=moves, target=desc, infused=infused[0] if infused else None)
if rng is not None and mode == "auto" and not force: # only what nobody dictated is learned
entry["arms"] = ["move:yes"] + ([f"style:{style}"] if style else [])
names = desc.split(" -> ")
entry["arms"] += [f"word:{c[0]}" for c in {c[0]: c for c in pool if _name(c) in names}.values()]
entry["arms"] += ["infuse:yes", f"infused:{infused[0]}"] if infused else [infuse_arm] if infuse_arm else []
last = opening
core = picture.rstrip()
gap = picture[len(core):] or (" " if sound else "")
picture = core + ("" if core.endswith((".", "!", "?")) else ".") + " " \
+ moves + gap
out.append(m.group(0) + picture + sound)
report.append(entry)
return "".join(out), report
# ── shot cuts ───────────────────────────────────────────────────────────────────────
# H3 wants every shot after the first to open with its cut time and a cut phrase
# ("[Shot 2] At 00:03.000, the camera cuts to ..."), times increasing and inside the video.
# A shot that holds several shortcuts is cut between them, the same shortcut used again
# included ("[Shot 1] DANCE KISS", "[Shot 1] DANCE DANCE"), so each part can take a time,
# a view and a focus. Never inside one shortcut. Nothing in a prompt knows the video's
# length, so the caller hands it in (seconds).
STAMP = re.compile(r"\b\d\d:\d\d\.\d{3}\b")
ANY_STAMP = re.compile(r"\bAt\s+\d{1,2}:\d{2}(?:\.\d+)?\s*,\s*(?:(?:the\s+)?(?:shot|camera)\s+(?:\w+ly\s+)?" + _CUT_VERB + r"|(?:\w+\s+)?cut\s+(?:to|away|back|into)|" + _CUT_VERB + r")\b|\b\d\d:\d\d\.\d{3}\b", re.I)
# The official MiniMax-H3 guide (docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md) approves these ordinary cut phrases.
# Dissolve, fade and wipe are for when the user asks for them, never default.
# Each cut says what is new: a new angle, place, subject, viewpoint, state or moment. Only these four verbs.
# The guide allows dissolve, fade and wipe only when the user asks for one: the transition slider is that request.
# Generic on purpose: a phrase that claims a new place or subject would be false for a cut inside one scene.
TRANSITIONS = (("dissolve", "the shot cross-dissolves to a new angle"),
("dissolve", "the shot cross-dissolves to the next moment"),
("fade", "the shot fades out to black and fades in on the next moment"),
("fade", "the shot fades to white and back to what follows"),
("wipe", "the shot wipes across to a new angle"),
("wipe", "the shot wipes across to what follows"))
# The approved cut verbs only (cuts, transitions, changes, switches); never a distance-only change, which the guide leaves to camera motion.
CUT_OPENERS = ("the camera cuts to a new angle", "the camera cuts to the next moment",
"the shot cuts to a new angle", "the shot cuts to what follows",
"the shot transitions to the next moment", "the shot transitions to a new view",
"the shot changes to a new view", "the shot changes to what comes next",
"the shot switches to what follows", "the shot switches to the next moment")
OUR_OPENERS = CUT_OPENERS + tuple(phrase for _, phrase in TRANSITIONS) # everything this module writes after a cut time
LEAD_CUT = re.compile(r"^\s*" + _LEAD_PHRASE, re.I)
MIN_SHOT_SECONDS = 2
def _stamp(t):
return f"{int(t) // 60:02d}:{int(t) % 60:02d}.000"
def _whole_piece(picture, i, piece):
"""True when `piece` starts at `i` on a word edge, not as a prefix of a longer word."""
end = i + len(piece)
before = i == 0 or picture[i - 1].isspace()
after = end >= len(picture) or picture[end].isspace() or picture[end] in ".!?,;:"
return before and after and picture.startswith(piece, i)
def _cut_points(picture, pieces):
"""Indexes where a later shortcut starts inside this one shot. The same text used again
counts, so DANCE DANCE is two takes. Longest shortcut wins, so a cut never lands inside one."""
ordered = sorted({p.strip() for p in pieces if isinstance(p, str) and p.strip()}, key=len, reverse=True)
if not ordered or not picture:
return []
hits, i = [], 0
while i < len(picture):
match = next((p for p in ordered if _whole_piece(picture, i, p)), None) if (
i == 0 or picture[i - 1].isspace()) else None
if match:
hits.append(i)
i += len(match)
else:
i += 1
return hits[1:]
def _fresh_piece(text):
"""A part cut off the front of a shot: drop a leading 'Then', keep the capital."""
text = re.sub(r"^\s*(?:then|next|after that|afterwards),?\s+", "", text or "", flags=re.I)
return (text[:1].upper() + text[1:]) if text else text
def _opener(rng, difference, last):
"""-> (opener, transition kind or None). `difference` is the user's slider: 0 is always a plain cut, 1 always a
transition, and in between that share of cuts is one. Transitions are mixed: never the same kind twice running."""
if rng.random() >= difference:
return rng.choice(CUT_OPENERS), None
kind, phrase = rng.choice([t for t in TRANSITIONS if t[0] != last] or list(TRANSITIONS))
return phrase, kind
def _screen_time(blocks, seed, uneven, arms):
"""-> one weight per shot, or None when every shot gets an equal share (`uneven` 0, or one shot).
With odds `uneven` a shot's share is changed, longer or shorter; the busier its text compared with the
others, the likelier longer and the more of it (damped, so no shot is a blink beside another).
1 changes every shot. `arms` collects "length:yes" / "length:no" for the rating that follows."""
if uneven <= 0 or len(blocks) < 2:
return None
words = [max(1, len(blocks[k][0].split())) for k in range(len(blocks))]
mean = sum(words) / len(words)
weights = []
for k, count in enumerate(words):
rng = random.Random(f"{seed}:length:{k}")
if rng.random() >= uneven:
weights.append(1.0)
arms.append("length:no")
continue
busy = count / mean
longer = rng.random() < min(0.9, max(0.1, 0.5 + 0.4 * (busy - 1)))
weights.append((1.4 + 0.6 * rng.random() if longer else 0.5 + 0.3 * rng.random()) * busy ** 0.5)
arms.append("length:yes")
return weights
def add_shot_cuts(text, seconds, seed=0, chance=0.5, pieces=(), transition=0.0, uneven=0.0):
"""-> (new prompt, info). A shot is cut between the shortcuts inside it, including the same
shortcut used again; every shot after the first then opens with its cut time, spread evenly
over `seconds` and rounded to whole seconds. A prompt that already carries cut times is left
alone. `pieces`: the texts of the user's shortcuts. 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": "", "arms": []}
if not marks:
return text, info
if not seconds or seconds < MIN_SHOT_SECONDS:
info["why"] = "the video's length is not known here" if not seconds else "the video is too short"
return text, info
bodies = [text[m.end():(marks[i + 1].start() if i + 1 < len(marks) else len(text))]
for i, m in enumerate(marks)]
if any(ANY_STAMP.search(b) for b in bodies):
info["why"] = "cut times are already written, left as they are"
return text, info
header = text[:marks[0].start()]
parts = [_split_sound(b) for b in bodies]
blocks = [] # [picture text, sound text]
for i, (picture, sound) in enumerate(parts):
chosen = []
for n, at in enumerate(_cut_points(picture, pieces)):
rng = random.Random(f"{seed}:cut:{i}" if n == 0 else f"{seed}:cut:{i}:{n}")
take = rng.random() < chance
info["arms"].append("split:yes" if take else "split:no")
if take:
chosen.append(at)
if not chosen:
blocks.append([picture, sound])
continue
prev = 0
for at in chosen:
blocks.append([picture[prev:at].rstrip() + " ", ""])
prev = at
blocks.append([_fresh_piece(picture[prev:]), sound])
if len(blocks) > len(marks) and (len(blocks) > seconds or seconds / len(blocks) < MIN_SHOT_SECONDS):
blocks = [[p, s] for p, s in parts] # not enough seconds for the splits
info["arms"] = []
# Equal shares of the scene, rounded to whole seconds, no shot shorter than MIN_SHOT_SECONDS
# (a shortcut's text length says nothing about how long its beat should last: weighting by
# it gave one-second shots next to five-second ones).
n = len(blocks)
gap = MIN_SHOT_SECONDS if n * MIN_SHOT_SECONDS <= seconds else 1
weights = _screen_time(blocks, seed, uneven, info["arms"])
cum = [0.0]
for w in weights or ():
cum.append(cum[-1] + w)
times = []
for k in range(1, n):
t = round(k * seconds / n) if weights is None else round(seconds * cum[k] / cum[-1])
t = max(t, (times[-1] if times else 0) + gap) # not too close behind
t = min(t, int(seconds) - (n - k) * gap) # leave room for the shots after
times.append(t)
if times and (times[0] < 1 or times[-1] >= seconds or any(b <= a for a, b in zip(times, times[1:]))):
info["why"] = f"{len(blocks)} shots do not fit in {seconds}s at whole seconds"
info["arms"] = []
return text, info
out = [header]
last, kinds = None, [] # the transition kind of the cut before this one, and every kind used
for k, (picture, sound) in enumerate(blocks):
label = f"[Shot {k + 1}]"
body = picture
if k:
stamp = f"At {_stamp(times[k - 1])}, "
rng = random.Random(f"{seed}:opener:{k}")
if LEAD_CUT.match(body):
body = " " + stamp + LEAD_CUT.sub(lambda m: m.group(0).strip().lower(), body.lstrip(), 1)
last = None
else:
opener, last = _opener(rng, transition, last)
if last:
kinds.append(last)
body = " " + stamp + opener + ". " + body.lstrip()
else:
body = body if body.startswith(" ") else " " + body
out.append(label + body + sound)
info.update(after=len(blocks), times=[_stamp(t) for t in times], transitions=kinds)
return "".join(out), info
# ── reframes ────────────────────────────────────────────────────────────────────────
# A shortcut boundary inside a shot that is NOT cut can move the camera instead: one continuous
# take, the actions follow on, the camera travels to what the next action is about. A reframe
# moves the camera and, when what follows has something new, changes the focus; the view may
# turn or stay ("What matters is focus change and movement"). Only `chance` decides whether a
# boundary reframes: 1.0 is every boundary. Run after the cuts, so the boundaries
# left are exactly the ones that were kept whole.
REFRAMES = ("Without a cut, the camera pans over to {x}{view}.",
"Without a cut, the camera glides to {x}{view}.",
"Without a cut, the camera pushes in on {x}{view}.",
"Without a cut, the camera tracks over to {x}{view}.")
# When what follows has nothing new to aim at, the camera still moves: the chance is the only
# thing that decides whether a boundary reframes, so 1.0 means every boundary.
REFRAMES_PLAIN = ("Without a cut, the camera pushes in closer{view}.",
"Without a cut, the camera drifts sideways{view}.",
"Without a cut, the camera pulls back a little{view}.")
_REFRAME = re.compile(r"\s*Without a cut, the camera [^.!?]*[.!?]", re.I)
KEEP_VIEW_SHARE = 0.5 # of reframes, how many keep the view and only move to the new focus
def _a_view(view):
return "a " + (view if view.startswith("POV") else view.lower())
def _focus(text, avoid=None):
"""The most specific thing `text` is about, skipping the lemma `avoid`: -> candidate or None."""
cands = [c for c in candidates(own_words_removed(text)) if _score(c) > 0.5 and c[0] != avoid]
return _draw(cands, None, None, 1)[0] if cands else None
def add_reframes(text, seed=0, chance=0.5, pieces=(), stats=None, arms=None, infuse=0.0, bank=()):
"""-> (new prompt, info). At each shortcut boundary inside a shot, with odds `chance`, a
sentence moves the camera, without a cut, to the most specific thing in what follows that
differs from what the action before was about; with no such thing, the camera still moves.
About half keep the view (tilted by ratings), the rest turn to one that can show the part.
info = {"added": [{"shot", "view" (None = kept), "traits", "target"}],
"arms": ["reframe:yes"|"reframe:no", "reframe:keep"|"reframe:turn", ...]}."""
marks = list(SHOT.finditer(text or ""))
info = {"added": [], "arms": []}
if not marks:
return text, info
out = [text[:marks[0].start()]]
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
picture, sound = _split_sound(text[m.end():end])
points = _cut_points(picture, pieces)
before = _focus(picture[:points[0]]) if points else None
chosen = []
for n, at in enumerate(points):
following = picture[at:(points[n + 1] if n + 1 < len(points) else len(picture))]
target = _focus(following, avoid=before[0] if before else None)
rng = random.Random(f"{seed}:reframe:{i}:{n}")
take = rng.random() < chance
info["arms"].append("reframe:yes" if take else "reframe:no")
before = target or _focus(following) or before
if not take:
continue
if infuse > 0 and bank: # focus infusion: aim at a word the prompts keep returning to instead
roll = random.Random(f"{seed}:infuse:{i}:{n}")
if roll.random() < infuse:
pick = _infused(following, {target[0]} if target else set(), bank, roll)
if pick:
target = pick
info["arms"] += ["infuse:yes", f"infused:{pick[0]}"]
else:
info["arms"].append("infuse:no")
traits = view_traits(following)
view = None
if rng.random() >= _tilt(KEEP_VIEW_SHARE, "reframe:keep", arms):
allowed = allowed_views(traits)
view = rng.choices(allowed, weights=[_view_weight(v, traits, stats) for v in allowed])[0]
info["arms"].append("reframe:turn" if view else "reframe:keep")
turn = f", turning to {_a_view(view)}" if view else ""
sentence = (rng.choice(REFRAMES).format(x=_name(target), view=turn) if target
else rng.choice(REFRAMES_PLAIN).format(view=turn))
chosen.append((at, sentence))
info["added"].append({"shot": int(m.group(1)), "view": view, "traits": traits,
"target": _name(target) if target else None})
for at, sentence in reversed(chosen): # right to left: earlier indexes stay valid
head = picture[:at].rstrip()
picture = head + ("" if head.endswith((".", "!", "?")) else ".") + " " + sentence + " " \
+ _fresh_piece(picture[at:])
out.append(m.group(0) + picture + sound)
return "".join(out), info
# ── views ───────────────────────────────────────────────────────────────────────────
# Chosen, not detected: a prompt seldom says which view it wants. The user's own trusted
# wording, verbatim. Shot 1 is skipped: it may sit on a reference image or a pinned first
# frame, which a new view would contradict; every later shot starts on a new angle anyway.
VIEWS = ("POV view", "POV view from above", "POV view from below", "Side view",
"View from above", "View from below", "View from behind", "Front view")
# A view the shot already asks for. "from behind" / "from above" alone are also body actions
# ("enters from behind", "drips from above"), so they count only when a camera word goes with
# them ("seen from behind", "shot from above", "camera from below").
VIEW_STATED = re.compile(r"\b(pov|(?:side|front|rear|back|top|overhead|bird'?s[- ]eye)[- ]view|view from|"
r"(?:seen|shot|filmed|viewed|captured|camera|angle|footage)\b[^.!?,;]{0,20}?"
r"\bfrom (?:above|below|behind))\b", re.I)
OPENER = re.compile(r"^\s*At \d\d:\d\d\.\d{3},[^.!?]*[.!?]\s*")
# What a shot's own words say about which views can show it. A view that cannot show what the
# shot describes (the back of someone whose face is the point, the front of someone walking
# away) only confuses the model, so it is never offered. Traits also key what the ratings
# teach: a view that works for a face shot may not work for a hands shot.
VIEW_TRAITS = {
"face": re.compile(r"\b(faces?|eyes?|eye contact|mouths?|lips|smil\w*|expressions?|gaze|"
r"cheeks?|facing|looks? (?:at|into))\b", re.I),
"back": re.compile(r"\b(back(?! and| to| into| off| up| down)|rear|spine|shoulder blades?|nape|"
r"walks? away|turned away|from behind)\b", re.I),
"hands": re.compile(r"\b(hands?|fingers?|you|your|reach\w*|hold\w*|touch\w*)\b", re.I),
}
_FRONT_ONLY = ("POV view", "POV view from above", "POV view from below", "Front view")
def view_traits(picture):
body = own_words_removed(picture or "")
return sorted(t for t, rx in VIEW_TRAITS.items() if rx.search(body))
def allowed_views(traits):
"""The views that can show a shot with these traits: a face excludes the view from behind;
a back (or someone leaving) excludes every view from the front, POV included."""
out = list(VIEWS)
if "face" in traits:
out = [v for v in out if v != "View from behind"]
if "back" in traits:
out = [v for v in out if v not in _FRONT_ONLY]
return out
def _view_weight(view, traits, stats):
"""How much a view is liked for a shot like this: the smoothed good-rate of the view, and
of the view on each of this shot's traits, averaged. 0.5 when nothing was rated yet."""
rates = []
for key in [view] + [f"{view}@{t}" for t in (traits or ["none"])]:
g, b = (stats or {}).get(key, (0.0, 0.0))
if g + b > 0:
rates.append((g + 1.0) / (g + b + 2.0))
return 0.15 + (sum(rates) / len(rates) if rates else 0.5)
def shot_texts(text):
"""{shot number: its picture text, without what this module wrote into it}: what the
Reactive focus review shows so a choice is about a shot the person can read."""
out = {}
marks = list(SHOT.finditer(text or ""))
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
picture, _sound = _split_sound(text[m.end():end])
out[int(m.group(1))] = " ".join(own_words_removed(picture).split())
return out
def view_options(text, stats=None):
"""What each `[Shot N]` after the first could open with, for a person to choose from.
-> [{"shot", "key", "traits", "already", "candidates": [{"view", "score"}], "auto"}]"""
marks = list(SHOT.finditer(text or ""))
out, keys = [], _take_keys()
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
picture, _sound = _split_sound(text[m.end():end])
key = keys(picture) # count shot 1 too, even though it is not offered
if not i and len(marks) > 1: # see add_shot_views: a lone shot may take a view
continue
traits = view_traits(picture)
cands = sorted(((v, _view_weight(v, traits, stats)) for v in allowed_views(traits)),
key=lambda c: -c[1])
stated = VIEW_STATED.search(CUT.sub("", _REFRAME.sub("", picture)))
out.append({"shot": int(m.group(1)), "key": key, "traits": traits,
"already": bool(stated), "stated": stated.group(0) if stated else "",
"candidates": [{"view": v, "score": round(w, 2)} for v, w in cands],
"auto": cands[0][0] if cands else None})
return out
def add_shot_views(text, seed=0, chance=0.4, stats=None, choices=None, skipped=None):
"""-> (new prompt, [{"shot", "view", "traits"}]). Shots 2+ (or the only shot) that state no view get one of the
views that can show them as a sentence of its own (after the cut opener, if there is one),
never the same as the shot before, drawn per shot from the seed and weighted by `stats`
(what ratings taught). `choices` {shot_key: {"mode": "auto"|"none"|"pick", "view": str}}:
a person's pick is always used, "none" leaves the shot alone. The same shortcut used again
is a different key, so one pick does not lock the next take. `skipped`, if a list, gets one
"shot N: reason" per shot that got no view."""
marks = list(SHOT.finditer(text or ""))
if not marks:
return text, []
# Shot 1 of several may sit on a reference image or a pinned first frame, which a view would
# contradict, so views open shots 2+. A prompt with ONE shot has nothing else to open.
lone = len(marks) == 1
out, added, last, keys = [text[:marks[0].start()]], [], None, _take_keys()
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
body = text[m.end():end]
rng = random.Random(f"{seed}:view:{i}")
picture, sound = _split_sound(body)
key = keys(picture)
view = None
stated = VIEW_STATED.search(CUT.sub("", _REFRAME.sub("", picture))) if (i or lone) else None
if stated and skipped is not None:
skipped.append(f"shot {m.group(1)}: already states “{stated.group(0)}”")
if (i or lone) and not stated:
traits = view_traits(picture)
said = (choices or {}).get(key) or {}
allowed = allowed_views(traits)
if said.get("mode") == "none":
pass
elif said.get("views") or said.get("view"):
# "fine from these": a pick is always used; with several, one of them is drawn.
wanted = [v for v in (said.get("views") or [said.get("view")]) if v in allowed]
pool = [v for v in wanted if v != last] or wanted
if pool:
view = rng.choices(pool, weights=[_view_weight(v, traits, stats) for v in pool])[0]
elif rng.random() < chance:
pool = [v for v in allowed if v != last] or allowed
if pool:
view = rng.choices(pool, weights=[_view_weight(v, traits, stats) for v in pool])[0]
if not view and skipped is not None:
skipped.append(f"shot {m.group(1)}: " + ("no view, as you chose" if said.get("mode") == "none"
else "left as written by chance"))
if view:
head = OPENER.match(picture)
cut = head.end() if head else len(picture) - len(picture.lstrip())
picture = picture[:cut].rstrip() + (" " if cut else " ") + view + ". " + picture[cut:].lstrip()
body = picture + sound
added.append({"shot": int(m.group(1)), "view": view, "traits": traits})
last = view
out.append(m.group(0) + body)
return "".join(out), added
_VIEW_SENTENCE = re.compile(r"^\s*(?:" + "|".join(re.escape(v) for v in VIEWS) + r")\.\s*", re.I)
def own_words_removed(picture):
"""`picture` without what this module wrote into it (its own cut openers and views) and
without the bare cut words of a user-written opener ("At 00:03.500, the camera cuts to"),
whose content after them is kept: those words are not the shot's content and must never
become a target or a topic."""
picture = _OUR_OPENER.sub("", picture, count=1)
picture = _CUT_WORDS.sub("", picture, count=1)
return _REFRAME.sub("", _VIEW_SENTENCE.sub("", picture, count=1))
_OUR_OPENER = re.compile(r"^\s*At \d\d:\d\d\.\d{3},\s*(?:" + "|".join(re.escape(o) for o in OUR_OPENERS)
+ r")\.\s*", re.I)
_CUT_WORDS = re.compile(r"^\s*(?:At \d\d:\d\d\.\d{3},\s*)?" + _LEAD_PHRASE + r"\s*", re.I)
def content_fingerprint(text):
"""A hash of a prompt's own content: the same whatever cut times, openers, views or shot
labels this module added on that run, so regenerating one prompt is one prompt."""
import hashlib
body = re.sub(r"\[\s*Shot\s+\d+\s*\]", " ", text or "", flags=re.I)
body = re.sub(r"At \d\d:\d\d\.\d{3},\s*(?:" + "|".join(re.escape(o) for o in OUR_OPENERS) + r")\.",
" ", body, flags=re.I)
body = re.sub(r"At \d\d:\d\d\.\d{3},\s*(?=" + _LEAD_PHRASE + r")", " ", body, flags=re.I) # the time before a cut phrase the user wrote
body = _REFRAME.sub(" ", body)
body = re.sub(r"\s+", " ", body)
body = re.sub(r"(?:^|(?<=[.!?]\s))(?:" + "|".join(re.escape(v) for v in VIEWS) + r")\.", " ", body)
body = re.sub(r"\s+", " ", body).strip().lower()
return hashlib.md5(body.encode("utf-8", "replace")).hexdigest()