"""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. `` tags are the subjects every shot is about, so they are never a target on their own; a part or belonging of one ("'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 `` 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 '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, "FunPack 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"", 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 # " 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| 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:").""" 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()