Files
DumasNodes/dumas_h3_longvideos_upstream.py
T

10931 lines
622 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.
# H3-LongVideos -- https://github.com/Smite79/MiniMax-H3-LongVideos
# Copyright (c) 2026 Smite79. All rights reserved.
# Redistribution, in whole or in part, requires written permission.
# This notice may not be removed or altered. See LICENSE.
"""
H3 Long Videos -- chain MiniMax-H3 shots into one continuous video with audio.
Rebuilt from scratch. The previous version grew a large prompt-engineering layer
that wrote continuity guards into every shot; measured, the user's own beat was
under 4% of the conditioning and the rest was boilerplate arguing with it. None of
that is here. What a shot is told is: your scene text, then your beat, verbatim.
What this node does is the part a prompt cannot do -- the mechanics of chaining:
* splits the prompt into beats on blank lines, one beat per shot;
* gives every shot the SAME length, so one seed is one noise field across the
chain (noise is drawn to the latent's shape, so unequal lengths mean unrelated
noise from the same seed, and detail resets at every cut);
* hands each shot the previous shot's last frame as its keyframe, encoded the
way H3 expects a keyframe to be encoded (one frame -> the 5f grid point);
* keeps identity references on every shot, which is the only fixed anchor a long
chain has against drift;
* anchors the audio branch to real silence on shots with no quoted line, because
H3 is a joint model and an unconditioned audio stream invents a voice that the
picture then lip-syncs to.
Everything about what the video should CONTAIN is yours to write.
"""
import gc
import json
import math
import os
import re
import sys
import time
import torch
import nodes
import comfy.utils
import comfy.sample
import comfy.samplers
import comfy.nested_tensor
import comfy.model_management as mm
import latent_preview
import node_helpers
# The prompt engine: scene state, read beat by beat, rendered once per shot.
# Imported by file path rather than by name so it resolves the same whether
# ComfyUI loads this package as `custom_nodes.H3-LongVideos-V1` or bare.
import importlib.util as _ilu
_eng_spec = _ilu.spec_from_file_location(
"h3_engine", os.path.join(os.path.dirname(os.path.abspath(__file__)),
"dumas_h3_longvideos_engine.py"))
engine = _ilu.module_from_spec(_eng_spec)
_eng_spec.loader.exec_module(engine)
H3_FPS = 24 # H3 renders 24 fps, always
AUDIO_LATENT_FPS = 40 # audio latent frames per second
RES_MULTIPLE = 32
KEYFRAME_SAFE_AUG = 0.99 # below this, a ref aug would soften the keyframe too
AUTO_TILE_T = 8 # temporal chunk for a tiled decode
MAX_FRAMES = 362 # H3's own ceiling (~15s)
# Latent frames decoded from the PRE-upscale latent to source the handoff. Enough
# for the VAE's temporal context to produce a clean last frame, and cheap.
HANDOFF_LATENT_TAIL = 8
GB = 1024 ** 3
# H3-Base is trained at 768 on the short edge; below that the whole frame softens.
NATIVE_RES = {
"16:9": (1344, 768),
"9:16": (768, 1344),
"4:3": (1024, 768),
"3:4": (768, 1024),
"1:1": (768, 768),
"21:9": (1536, 672),
"9:21": (672, 1536),
}
CANVAS_MULTIPLE = 32
REF_IMAGE_SHORT_EDGE = 2048
_LAST_MODEL_FP = {"fp": None}
_SILENT_UNIT = {"lat": None}
def _call_node(cls, model, shift_video, shift_audio):
"""Call the H3 sampling node whether it uses the V1 (INPUT_TYPES/FUNCTION) or
V3 (define_schema/execute) API, mapping the shift args by name."""
inst = cls()
# V1 API
if hasattr(cls, "INPUT_TYPES") and getattr(cls, "FUNCTION", None):
req = cls.INPUT_TYPES().get("required", {})
kwargs = {}
for name in req:
low = name.lower()
if low == "model":
kwargs[name] = model
elif "video" in low:
kwargs[name] = float(shift_video)
elif "audio" in low:
kwargs[name] = float(shift_audio)
out = getattr(inst, cls.FUNCTION)(**kwargs)
# A V3 node exposes INPUT_TYPES and a truthy FUNCTION ('EXECUTE_NORMALIZED')
# for compatibility, so this branch runs on 0.31+ too -- and there it returns
# a NodeOutput, not a tuple. Without the unwrap the caller got the wrapper
# object where a MODEL belonged. Unreachable today because the direct patch
# succeeds first, which is exactly why it went unnoticed.
out = getattr(out, "result", out)
return out[0] if isinstance(out, (tuple, list)) else out
# V3 API: an execute()/patch() classmethod taking model + shift kwargs
fn = None
for cand in ("execute", "patch", "apply"):
if hasattr(inst, cand):
fn = getattr(inst, cand); break
if fn is None:
raise RuntimeError("unknown node API")
out = fn(model=model, shift_video=float(shift_video), shift_audio=float(shift_audio))
out = getattr(out, "result", out) # V3 NodeOutput
return out[0] if isinstance(out, (tuple, list)) else out
def _is_audio_vae(v):
"""True when v looks like the H3 audio VAE (DAC/BigVGAN), False when it looks
like a video/image VAE, None when it can't be told. The video VAEs carry a
3-tuple upscale_ratio (t, y, x); the audio VAE carries a scalar and reports
latent_dim 2 with an audio_sample_rate."""
ur = getattr(v, "upscale_ratio", None)
if isinstance(ur, (tuple, list)):
return False
if getattr(v, "audio_sample_rate", None) or getattr(v, "audio_sample_rate_output", None):
return True
if isinstance(ur, (int, float)) and getattr(v, "latent_dim", None) == 2:
return True
return None
# --- sizing -----------------------------------------------------------------
def align_frame_count(n):
"""Up to the next valid H3 frame count. The grid is 17k+5."""
n = max(5, int(n))
while n % 17 != 5:
n += 1
return min(n, MAX_FRAMES)
def align_frame_count_nearest(n):
"""The NEAREST 17k+5 grid point, not the next one up.
align_frame_count always rounds up, which is right for a length you asked for
-- never give back less than requested. It is wrong for an ESTIMATE: the grid
steps 17 frames (~0.7s), and rounding an estimate up lengthens the shot in the
one direction that causes trouble."""
n = max(5, int(n))
lo = n - ((n - 5) % 17)
hi = lo + 17
return min(MAX_FRAMES, lo if (n - lo) <= (hi - n) else hi)
def video_latent_t(fc):
return 2 if fc <= 5 else ((fc - 5) // 17) * 5 + 2
def temporal_shape(length, fps=H3_FPS):
"""(frame count, video latent frames, audio latent frames) for a shot.
`fps` is accepted but deliberately IGNORED: the audio latent has to line up
with 24 fps video or the shot's sound is stretched against its picture."""
fc = align_frame_count(length)
return fc, video_latent_t(fc), round(fc / H3_FPS * AUDIO_LATENT_FPS)
def parse_resolution(choice):
text = (choice or "").strip()
if text in NATIVE_RES:
return NATIVE_RES[text]
m = re.search(r"(\d+)\s*x\s*(\d+)", text)
if m:
return int(m.group(1)), int(m.group(2))
return NATIVE_RES["16:9"]
def scale_to_megapixels(w, h, mp, multiple=RES_MULTIPLE):
"""Scale (w, h) to `mp` megapixels keeping the ratio, snapped to the grid.
mp <= 0 keeps the preset's own size."""
if not mp or mp <= 0:
return w, h
scale = math.sqrt((mp * 1024 * 1024) / float(w * h))
sw = max(multiple, int(round(w * scale / multiple)) * multiple)
sh = max(multiple, int(round(h * scale / multiple)) * multiple)
return sw, sh
# --- prompt -> beats --------------------------------------------------------
def split_beats(prompt):
"""(scene, beats). Paragraphs are separated by a BLANK line.
The first paragraph is the SCENE: it is prepended to every shot verbatim, and
nothing is stripped from it. Every paragraph after it is one beat, one shot.
A single-paragraph prompt is one shot with no separate scene text.
Deliberately the whole of the text handling. The previous version rewrote beats
-- binding descriptions, collapsing repeated names, scrubbing the scene, adding
continuity clauses -- and the result was a shot whose own action was a few
percent of what the model was told. What you type is what the shot gets."""
paras = paragraphs(prompt)
if not paras:
return "", []
if len(paras) == 1:
return "", paras
return paras[0], paras[1:]
def paragraphs(text):
"""Non-empty paragraphs, separated by a BLANK line."""
return [p.strip() for p in re.split(r"\n\s*\n", (text or "").strip()) if p.strip()]
# A line of a character sheet: `Name: attributes`. The directive lines are excluded
# by name -- they are instructions to this node, not people.
_SHEET_LINE = re.compile(r"^\s*(?!(?:remove|off|add|wear|wardrobe)\s*:)"
r"[A-Z][\w'-]{0,24}\s*:\s*\S", re.I)
def is_character_sheet(par):
"""A paragraph that DESCRIBES people rather than staging an action.
Every line reads `Name: attributes` -- "McKenna: 22, blonde, grey coat." Handed
to the model as a beat, a sheet spends a whole shot rendering a static
description. Worse, the wardrobe then lives in ONE shot instead of being
re-stamped into all of them: later shots describe no clothing at all, so the
model invents it, and a removal has nothing to scrub because what it would
scrub was never in the scene.
A sheet lists ATTRIBUTES. A line that stages an action is a beat, however it is
labelled -- "McKenna: thrashes in her restraints" and "Camera: pushes in slowly"
are shots, not descriptions. Getting that wrong is expensive in one direction
only: a sheet mistaken for a beat costs one visible shot, while a beat mistaken
for a sheet never renders AND has its words stamped onto every other shot. So
anything that opens with a verb is treated as a beat.
A line with speech in it is a beat too -- 'Dan: "Hello."' stages something."""
lines = [ln for ln in (par or "").splitlines() if ln.strip()]
if not lines or _QUOTED.search(par) or _DIALOGUE_TAG.search(par):
return False
return all(_SHEET_LINE.match(ln) and not _ACTION_AFTER_LABEL.search(ln)
for ln in lines)
# What follows `Name:` in a sheet is an attribute -- a pronoun, an age, a colour, a
# <Picture N> tag. An inflected verb there means the line stages something instead.
# The participles excepted below introduce attributes rather than actions.
_ACTION_AFTER_LABEL = re.compile(
r":\s*(?!(?:wearing|dressed|carrying|holding|sporting|wrapped|covered)\b)"
r"(?:is|are|was|were|has|have|had|does|do|[\w-]+(?:s|es|ed|ing))\b", re.I)
def pull_character_sheets(beats):
"""(the beats that stage something, the sheet paragraphs joined)."""
beats = beats or []
sheets = [b for b in beats if is_character_sheet(b)]
return [b for b in beats if not is_character_sheet(b)], "\n".join(sheets)
def sheet_lines(sheet):
"""[(name or None, line)] for a character sheet, in order. A line with no
`Name:` label belongs to everyone and is never dropped."""
out = []
for ln in (sheet or "").splitlines():
if not ln.strip():
continue
# UP TO THREE CAPITALISED WORDS. One word only, and "Mistress Vale:",
# "Miss Kane:", "Aunt May:" all failed to parse -- so the line kept its
# description and lost its name, and an unlabelled line belongs to
# everyone and is never dropped. A full physical description of a woman
# then rode into EVERY shot with no name on it, beside the character it
# was meant to be. Reported as a duplicate Mistress in the first beat.
#
# Each extra word has to be capitalised too, so "Both women: tired" and
# "The room: dim" stay unlabelled and global, as they were.
m = re.match(r"\s*([A-Z][\w'-]{0,24}(?:\s+[A-Z][\w'-]{0,24}){0,2})"
r"\s*:\s*\S", ln)
out.append((m.group(1) if m else None, ln.strip()))
return out
# A beat about the GROUP. "They sit down", "both of them wait", "the two of them
# walk out" -- none of these names anybody, and "they" sits in _PRONOUN_SET as a
# SINGULAR group (the pronoun a nonbinary character declares), so a plural "they"
# resolved to whoever the last beat happened to keep. One of the two people in the
# shot then had no sheet line, and a person the text does not describe is a person
# the model invents -- including their clothes. Reported as clothing invented for
# somebody who had been out of shot.
#
# "each other" and "one another" are plural by definition: they need two people.
_PLURAL_CUE = re.compile(
r"\b(?:both|each\s+other|one\s+another|the\s+two\s+of\s+(?:them|us|you)|"
r"the\s+pair\s+of\s+(?:them|us|you)|all\s+of\s+(?:them|us|you))\b", re.I)
# ...and a bare THEY -- nominative only, and only when nobody's sheet claims it.
#
# NOT "them" or "their". Those are the object and possessive forms, and a garment
# claims them as often as a person does: "takes off her shorts and steps out of
# THEM" is the shorts, "puts THEIR keys down" is the keys. Reading either as the
# group put the other character into a shot he was not in -- which is the very
# failure the pronoun resolver below exists to avoid, reintroduced by the group
# fix. A bare "they" cannot be an object, so it is always a subject and always
# more than one person.
_THEY = re.compile(r"\bthey\b", re.I)
def group_beat(beat, rows):
"""Does this beat talk about the people as a GROUP rather than an individual?
`rows` is sheet_lines(sheet). A they/them that some entry DECLARES as its own
pronoun is that person, not the group -- so it is only a group cue when nobody
on the sheet uses it."""
b = beat or ""
if _PLURAL_CUE.search(b):
return True
if not _THEY.search(b):
return False
return not any(sheet_pronoun(ln) == "they" for n, ln in (rows or []) if n)
def entry_heads(line):
"""Every head noun in one sheet entry's wardrobe, whatever kind of thing it is.
garments_in knows garments and restraint_words knows hardware, and a chastity
belt is neither: it is in no garment list and "belt" is not a restraint word,
so both readers return nothing for it. This is the list used to decide WHOSE
thing a beat is handling, and for that the category does not matter -- only
that the sheet gave this person that item.
Age, pronoun and bare adjectives are not things: an entry has to end in a word
that could be a noun, and the numeric and pronoun entries are dropped."""
out = []
for item in re.split(r"[,;.]", str(line or "").split(":", 1)[-1]):
# A <Picture N> tag ANYWHERE in the entry, not just at its head. "chastity
# belt <Picture 2>" ends in "2>", so the head noun was the tag and the
# wearer was never matched -- the same trap scene_name_for hit.
item = re.sub(r"<\s*picture\s+\d+\s*>", " ", item, flags=re.I)
item = _LEADING_TAG.sub("", re.sub(r"\s+", " ", item)).strip()
if not item:
continue
head = item.split()[-1].lower().strip("-")
if (len(head) < 3 or head.isdigit() or head in _NOT_A_GARMENT
or head in {"she", "he", "they", "her", "his", "them", "old"}):
continue
if head not in out:
out.append(head)
return out
# Speech-stripping lives in the engine: this file and that one had identical
# copies, written the same day, which is the duplication this port exists to
# end. A name inside a line of dialogue is being SAID, not staged.
_SPOKEN_SPAN = engine._SPOKEN_SPAN
_outside_speech = engine._outside_speech
def sheet_for_beat(sheet, beat, previous=None):
"""(the sheet lines for the people this beat involves, the names kept).
The sheet is re-stamped into every shot so clothing holds -- but describing
EVERYONE in every shot puts everyone in every shot. A beat about one person
renders two, because the text standing beside it says the other one is there,
and a described person is a person the model draws.
A PRONOUN counts as naming someone: "Jon takes her jacket off" is about both of
them, and dropping Maya there would leave the garment being removed undescribed
in the very shot that removes it. Who "her" refers to is not resolvable from the
sentence, so it keeps whoever the last beat kept.
A beat that names nobody at all keeps the last beat's people too, so "She lies
still." does not empty the frame."""
rows = sheet_lines(sheet)
# CASE-SENSITIVE. Prose capitalises a name, and matching without case made the
# word "will" find a character called Will, and "grace" find Grace.
#
# A NAME INSIDE SPEECH IS BEING SAID, NOT STAGED. Reported: a beat where one
# character calls for another --
#
# Dana opens the door and calls out: "McKenna where are you?"
#
# -- put McKenna's whole sheet line into the shot, so the model was handed
# "McKenna: she, 27, green dress" and drew her standing there. She is the one
# person the beat says is NOT in the room. Calling for somebody is the
# commonest way to write their absence and it was reading as their presence.
#
# So presence is decided on the beat with its spoken spans removed. A name
# said aloud AND staged outside the quote still counts -- "Dana turns to
# McKenna and says: 'McKenna, wait'" keeps her, because the staging half
# names her. Only a name that appears nowhere but inside the speech is
# dropped.
# One reader, in the engine: it strips speech and matches case-sensitively,
# and this file's rows keep their own order because nothing here needs the
# sentence order the engine's wearer logic does.
_here = set(engine.names_in(beat, [n for n, _ in rows if n]))
named = [n for n, _ in rows if n in _here]
# THE WEARER of anything the beat handles. "Dan unlocks the chastity belt"
# names only Dan, so the shot described only Dan -- and her sheet line went,
# taking BOTH her <Picture N> tags with it. The shot then unlocked her belt
# while carrying no reference at all: the belt had nothing to look like, and
# she was in the frame undescribed and unpinned, which renders as somebody
# else. A garment cannot be acted on without the person wearing it.
#
# Head nouns only, and only from that person's own entry: "jeans" in Dan's
# entry must not pull McKenna in because her shorts are jean shorts.
for n, ln in rows:
if not n or n in named:
continue
if any(re.search(r"\b" + re.escape(g) + r"\b", beat or "", re.I)
for g in entry_heads(ln)):
named.append(n)
# THE GROUP. A plural cue means more than one person is in the shot, so it can
# never resolve to a single name. Whoever the beat names plus whoever the last
# beat kept; if that still does not reach two, everyone on the sheet.
#
# Erring towards MORE people here on purpose: one too many is a person
# described who is not in frame, which the beat's own words contradict. One too
# few is a person in frame with no description at all, and that is the one the
# model dresses out of nothing.
if group_beat(beat, rows):
everyone = [n for n, _ in rows if n]
for n in (previous or []):
if n in everyone and n not in named:
named.append(n)
named = ([n for n in everyone if n in named] if len(named) >= 2
else everyone)
return "\n".join(ln for n, ln in rows if n in named), named
used = {m.group(0).lower() for m in _PRONOUN.finditer(beat or "")}
if used:
# Resolve a pronoun to the person whose sheet DECLARES it. Adding the whole
# previous cast on any pronoun put someone in a shot they were not in --
# "Jon walks out and shuts the door behind him" kept the other character,
# because "him" was read as evidence that somebody else was present.
# ONE PRONOUN IS ONE PERSON. Resolved per pronoun GROUP, not per sheet entry:
# walking the entries and taking everyone who declares "she" is fine with one
# woman on the sheet and a guess with two, and it used to take BOTH -- a third
# character pulled into a shot that named two.
matched = False
for group, words in _PRONOUN_SET.items():
if not used & words:
continue
# Already accounted for by somebody the beat names outright: "Nora and Dan
# look at her hands" needs nobody else for "her".
if any(sheet_pronoun(ln) == group for n, ln in rows if n and n in named):
matched = True
continue
cands = [n for n, ln in rows
if n and n not in named and sheet_pronoun(ln) == group]
if len(cands) == 1:
named.append(cands[0])
matched = True
elif len(cands) > 1:
# Two people declare it. The scene continuing is the only evidence
# available, so take the one who was in the last beat -- and if that
# does not single anybody out, add NOBODY. Naming a person the beat
# did not is the failure being fixed; leaving them to the keyframe is
# recoverable.
narrowed = [n for n in cands if n in (previous or [])]
if len(narrowed) == 1:
named.append(narrowed[0])
matched = True
# A sheet that declares no pronouns tells us nothing, so fall back to the
# last beat's people rather than guessing.
if not matched:
named += [n for n in (previous or []) if n not in named]
# Somebody is in it, but the beat does not say who -- "Someone knocks at the
# door." Keep the last beat's people, since a scene usually continues with them.
# With nobody before it, describing the WHOLE sheet is the same failure in
# miniature: it puts everyone in a shot on the strength of not knowing. One
# person on the sheet is unambiguous and still resolves; two or more is a guess,
# and the guard exists precisely not to make it.
if not named:
named = list(previous or [])
if not named:
_all = [n for n, _ in rows if n]
named = _all if len(_all) == 1 else []
keep = [ln for n, ln in rows if n is None or n in named]
return "\n".join(keep), named
# A beat that stages somebody ARRIVING. The chain is right for these: the previous
# shot's last frame is where they walk in from. A beat that stages no entrance is
# describing where somebody already IS, and there is no frame to inherit that has them
# in it.
_ENTRANCE = re.compile(
r"\b(?:walk|step|come|run|stride|hurry|move|wander|burst|barge|slip|climb)"
r"(?:s|ed|ing)?\s+(?:in|into|through|up|over|back|out\s+of)\b"
r"|\benter(?:s|ed|ing)?\b|\barriv(?:es?|ed|ing)\b"
r"|\bjoin(?:s|ed|ing)?\b|\breturn(?:s|ed|ing)?\b|\bfollow(?:s|ed|ing)?\b"
r"|\blets?\s+\w+\s+in\b", re.I)
# APPEARING IS NOT ARRIVING, and the difference is the whole reason this list
# exists. A staged arrival keeps the previous frame as the keyframe, because
# somebody walking in through a door has a path into a frame that does not have
# them in it -- they cross the edge of it. "Appears", "shows up", "turns up"
# describe the RESULT, not the movement: there is no path, so the only way for
# the model to put them into that frame is to fade them up inside it. Reported as
# ghosting on a character introduction, which is exactly what that looks like.
#
# So they are introductions in position instead, and the shot cuts to her already
# there -- which is what the words mean.
#
# Taking them out of the list above is the whole fix. A guard that ALSO looked
# for them and cancelled an arrival was written here and removed: with the words
# gone from the list it never changed an answer, and the one case it did reach --
# a beat with a real entrance and an "appears" in it, "walks in and appears calm"
# -- it got wrong, cancelling an arrival that plainly happens. The disable-check
# is what showed it was dead: reverting it left every case green.
def arrives_in(text):
"""Does this beat stage somebody arriving -- moving into the frame?
A word that only says they are suddenly THERE does not count, however much it
reads like an entrance -- see the note on _ENTRANCE."""
return bool(_ENTRANCE.search(text or ""))
def unresolved_pronouns(sheet, beat, previous=None):
"""[(pronoun group, the people who could answer to it)] this beat cannot settle.
Two people declaring "she" and a beat saying "her" is a guess, and the guard makes
none: it adds nobody rather than both. Nobody being described is recoverable --
the keyframe still carries them -- but it is worth saying, because the fix is to
write the name instead of the pronoun."""
rows = sheet_lines(sheet)
named = [n for n, _ in rows
if n and re.search(r"\b" + re.escape(n) + r"\b", beat or "")]
used = {m.group(0).lower() for m in _PRONOUN.finditer(beat or "")}
out = []
for group, words in _PRONOUN_SET.items():
if not used & words:
continue
if any(sheet_pronoun(ln) == group for n, ln in rows if n and n in named):
continue
cands = [n for n, ln in rows
if n and n not in named and sheet_pronoun(ln) == group]
if len(cands) > 1 and len([n for n in cands if n in (previous or [])]) != 1:
out.append((group, cands))
return out
_PRONOUN_SET = {"she": {"she", "her", "hers"},
"he": {"he", "him", "his"},
"they": {"they", "them", "their", "theirs"}}
def sheet_pronoun(line):
"""Which pronoun this sheet entry declares for its person, or None.
Writing the pronoun into the sheet -- "Maya: 27, she, grey coat" -- is what lets
"her coat" in a beat be resolved to Maya rather than to whoever was in the last
shot."""
body = (line or "").split(":", 1)[-1]
for group, words in _PRONOUN_SET.items():
if any(re.search(r"\b" + w + r"\b", body, re.I) for w in words):
return group
return None
_PRONOUN = re.compile(r"\b(?:she|he|her|hers|his|him|they|them|their|theirs)\b", re.I)
# A determiner in front means the capitalised word DESCRIBES something rather than
# doing something: "her Nike leggings" names a garment, not somebody in the room.
_DETERMINER = frozenset("a an the her his its their our my your this that".split())
_CAPITALISED = re.compile(r"\b([A-Z][a-z'-]{1,24})\b")
def unknown_people(beats, sheet):
"""{name: [1-based shot numbers]} -- names the beats use as PEOPLE that the
character sheet never describes.
A person the sheet does not describe is a person no shot describes. The guard
keeps the entries for the people a beat names, and there is no entry to keep, so
the beat stages somebody the model has been told nothing about -- no age, no
clothes, no face -- and it invents them, differently in each shot. Worse, a beat
whose ONLY person is undescribed falls back to the previous beat's cast, so the
shot describes someone who is not in it and stays silent about the one who is.
It is also how one person written under two names becomes two people, one of
them a stranger.
A capitalised word only counts once it has appeared MID-sentence somewhere in
the script. That is what separates a name from an ordinary word that happens to
open a sentence, and it needs no list of ordinary words to do it.
Reported, never acted on: whether a name is somebody already on the sheet under
another name or a third person in the room is not answerable from the text, and
guessing would be the node rewriting the script."""
known = {n.lower() for n, _ in sheet_lines(sheet) if n}
seen, mid_sentence = {}, set()
for i, beat in enumerate(beats or [], 1):
for m in _CAPITALISED.finditer(beat or ""):
# "Jon's kitchen" is Jon. The apostrophe is in the class for O'Neill.
word = re.sub(r"[']s$", "", m.group(1))
before = (beat[:m.start()]).rstrip()
prev = re.search(r"([\w'-]+)\W*$", before)
if prev and prev.group(1).lower() in _DETERMINER:
continue
# Opening a sentence -- or a quoted line -- capitalises anything, so
# only a mid-sentence appearance is evidence of a name.
if before and before[-1] not in ".!?:\"”":
mid_sentence.add(word)
if i not in seen.setdefault(word, []):
seen[word].append(i)
return {w: s for w, s in seen.items()
if w in mid_sentence and w.lower() not in known}
# Where a beat says something becomes VISIBLE. The other half of a removal: "cuts
# off her coat to expose the jumper" names the coat as coming off AND the jumper as
# what was under it.
_EXPOSE_CUE = re.compile(r"\b(?:to\s+expose|to\s+reveal|to\s+show|exposing|revealing|"
r"showing|uncovering|baring)\b", re.I)
# LAYERING LIVES IN THE ENGINE, beside the garment vocabulary it reads --
# keeping them apart is what let a chastity belt be underwear to one file
# and a bare "belt" to the other. The CLAUSES stay here, because saying a
# thing in a sentence belongs where a shot is assembled.
_UNDER_BY_REGION = engine._UNDER_BY_REGION
_OUTER_BY_REGION = engine._OUTER_BY_REGION
implied_layers = engine.implied_layers
hidden_layers = engine.hidden_layers
is_undergarment = engine.is_undergarment
def exposed_by(beat, scene):
"""Garments this beat says become visible. [] when none."""
out = []
for m in _EXPOSE_CUE.finditer(beat or ""):
tail = beat[m.end():]
cut = re.search(r"[,;.]|\band\s+(?:then|he|she|they)\b", tail, re.I)
span = tail[:cut.start()] if cut else tail
for word in re.findall(r"\b[\w-]{3,}\b", span):
low = word.lower().strip("-")
if not low or low in out or low in _NOT_A_GARMENT:
continue
if _RESTRAINT_WORD.match(low) or not _is_entry_head(word, scene):
continue
# "jeans shorts" is one garment; "jeans" there is a modifier, and
# matching it against another character's entry took their trousers off.
if _modifier_of_a_named_entry(word, span, scene):
continue
out.append(low)
return out
def infer_layers(bodies, scene):
"""{under: over} -- which garment covers which, read from the script's own words.
A sheet lists every layer at once, which tells the model all of them are on show
simultaneously. Nothing says which is hidden, so the under layer bleeds through
the top one -- and by the last frame, where only the text governs, it is simply
drawn on top.
The script already says what covers what: a beat that takes A off "to expose B"
has stated that B was under A. Read it from there rather than asking for it."""
covers = {}
for body in bodies or []:
off = infer_removals(body, scene)
for under in exposed_by(body, scene):
for over in off:
if under != over:
covers.setdefault(under, over)
return covers
# Layering that needs no telling: underwear goes under. infer_layers only learns what
# the SCRIPT states -- "takes A off to expose B" -- so a sheet listing panties beside
# shorts, with no beat ever saying one is under the other, left both described in every
# shot. A layer the model is told about is a layer it draws, and it draws it through
# whatever is over it. Reported as underwear and a chastity belt showing through the
# clothes.
#
# By REGION, because that is what covering means: a bra is not hidden by trousers.
def revealed_by(covers, gone):
"""Under-layers brought into view because the thing over them has just come off."""
return [u for u, o in (covers or {}).items() if o in (gone or [])]
# Which region of the body a garment leaves uncovered when it comes off. Only what
# the node can place with certainty; a garment it cannot place gets no clause, since
# a wrong region is worse than none.
_REGION_OF = engine._REGION_RX
def bare_clause(gone, covers=None, worn=""):
"""Say the uncovered region is BARE, when the sheet names nothing under it.
A removal clause is emphatic -- off the body, dropped out of frame -- and then
says nothing about what occupies the space it left. An unspecified region is
where the model's own prior fills in, and for legs that prior is legwear: the
shot invents leggings, tights or stockings that appear nowhere in the prompt,
and the keyframe then carries the invention into every later shot.
Positively phrased, and it names a BODY PART, never a garment. At cfg 1 there
is no negative prompt, so "no leggings" would be read as leggings; "the legs
are bare" fills the same region with something that is actually wanted.
Silent when the sheet already answers the question -- reveal_clause covers the
case where something IS underneath, and the two must never both speak -- and
silent when another garment the character still wears covers the same region."""
if not gone:
return ""
regions = []
for item in gone:
r = engine.region_of(item)
if r and r not in regions:
regions.append(r)
return bare_hold(regions, covers, worn, gone)
def bare_hold(regions, covers=None, worn="", gone=(), whose=""):
"""Say those regions are bare -- from STATE, so it outlives its beat.
The same suppression as the removal beat, because it is the same sentence:
silent when the sheet names a layer underneath (reveal_clause has that one),
and silent when a garment still worn covers the region.
The reason it exists apart from bare_clause is the report: a bra coming back
on somebody topless, on a character with no bra anywhere on the sheet. The
clause only ever fired on the beat that uncovered the region, so every shot
after it said nothing about that region -- and an unspecified region is
filled by the model's own prior. Nothing was restoring the bra. The prior was
inventing one, and the keyframe then carried the invention forward."""
if not regions:
return ""
under = {str(u).lower() for u in (covers or {})}
said, out = set(), []
for _region in regions:
for rx, region, sentence in _REGION_OF:
if region != _region or region in said:
continue
# Something else still on the body covers this region: not bare.
if any(rx.search(w) for w in (worn or "").split(",")
if not names_any(w, gone)):
said.add(region)
break
# The sheet named a layer underneath: reveal_clause has this one, and
# the two must never both speak. Matched against the region's UNDER
# vocabulary as well as its own -- panties sit in the leg region but
# are not legwear, and testing only the outer list let this clause
# call the legs bare while reveal_clause said the panties show.
if any(rx.search(u) or re.search(_UNDER_BY_REGION.get(
"lower" if region == "legs" else
"upper" if region == "torso" else "", "(?!)"), u, re.I)
for u in under):
said.add(region)
break
said.add(region)
out.append(sentence)
break
if not out:
return ""
# One region is the normal case. Two is a full strip, and past that the clause
# would outweigh the beat it is protecting. Only the first stays capitalised:
# joined as written it read "and The feet and ankles are bare".
out = out[:2]
joined = out[0] + "".join(", and " + s[0].lower() + s[1:] for s in out[1:])
# WHOSE, when the shot describes somebody else as well. An unattributed "the
# chest is bare" in a shot about two people is a region belonging to nobody,
# and the model picks. The hardware hold has said "on <name>" for the same
# reason since it was written.
if whose:
joined = f"{whose}'s " + joined[4:] if joined.startswith("The ") else \
f"{whose}: " + joined
return " " + joined + ", with nothing else worn there."
def defer_tag_for(text, items):
"""Take the <Picture N> off an item that is covered THIS SHOT, keeping its
words. The tag comes back the moment the cover comes off.
THIS IS A DEFERRAL, NOT A REMOVAL, and the distinction is the whole point.
The item stays in the character memory in every shot, exactly as written. What
waits is its reference, and only on the shots where the thing is under
something else.
It waits because a reference is an instruction to REPRODUCE AN IMAGE. At the
near-clean ref_noise_aug this node runs at, the node's own report says so:
"that asks the model to REPRODUCE them, framing and background included". A
picture of a chastity belt, handed to the model for a shot in which the belt
is under a skirt, is an instruction to draw the belt, and it outweighs any
sentence about what is on top of what. Measured twice, from two different
directions: every configuration that sent the picture while the garment was
covered rendered it through the cover, including one where the cover was
described as whole, opaque and unbroken.
There is no third option available. Reference strength is ref_noise_aug and it
is one number for every image, so the belt's picture cannot be weakened
without weakening the face. The tag is what routes the image, so the tag is
what waits -- leaving it in while withholding the image would name a picture
the shot does not carry, which is its own bug."""
out = str(text or "")
for item in items or []:
if not str(item).strip():
continue
w = re.escape(str(item).strip())
# Either side of the item, which is where a sheet puts it: "<Picture 2> a
# chastity belt" and "a chastity belt <Picture 2>" are both written.
out = re.sub(r"<\s*Picture\s*\d+\s*>\s*((?:a|an|the)\s+)?" + w,
lambda m: (m.group(1) or "") + str(item).strip(), out,
flags=re.I)
out = re.sub(w + r"\s*<\s*Picture\s*\d+\s*>", str(item).strip(), out,
flags=re.I)
return out
def under_clause(pairs):
"""Say that an under-layer is UNDER, rather than deleting it from the sheet.
Layering used to work by scrubbing: a garment read as covered came out of the
shot text entirely, and its <Picture N> with it. The reasoning was sound as
far as it went -- a described thing is a drawn thing, and an under-layer
described flatly beside its cover gets drawn on top of it -- but the cost was
the author's own words disappearing, which was reported three times, the last
of them a chastity belt with a reference image attached to it.
Deleting a thing is not the only way to stop it being drawn on top. Saying
where it is works better and keeps the text: the model is told the belt is
under the jeans, which is a spatial fact it can render, rather than being
told nothing and left to guess. Positively phrased, because at cfg 1 there is
no negative prompt -- this says where the thing IS, never where it is not.
Panties, knickers, thongs, briefs, boxers, underwear, bras, corsets and
chastity belts, devices and cages are all in _UNDER_BY_REGION, so they are
always the under-layer whatever order the sheet lists them in."""
pairs = [(p[0], p[1], p[2] if len(p) > 2 else "")
for p in (pairs or []) if p[0] and p[1]]
if not pairs:
return ""
def _plural(w):
return w.endswith("s") and not w.endswith("ss")
def _one(u, o, who=""):
# WHOSE, when more than one person is in the shot. "The chastity belt is
# worn under the skirt" beside two women says nothing about which of them
# wears it, and an unattributed garment lands on whoever the model finds
# convenient -- the same failure as hardware on nobody's wrists. Named
# once, at the front, and never run through .capitalize(), which lowers
# the rest of a name and turned McKenna into Mckenna.
#
# Each garment takes its own number: panties ARE worn, a bra IS; jeans
# cover THEM, a skirt covers IT.
#
# THE COVER IS THE PART TO DESCRIBE. "the belt is under the jeans" asks
# the model to work out an occlusion from a spatial word, which it does
# badly, and the belt came through the denim. What it renders well is a
# surface: say the jeans are whole and unbroken over that part of the
# body and there is nothing for the belt to show through. Positively
# phrased, as everything here has to be at cfg 1 -- this describes the
# cloth that IS there, never the thing that must not show.
cover = "cover" if _plural(o) else "covers"
whose = f"{who}'s " if who else "The "
# THE COVER ONLY. This used to open with "{u} is worn under the {o}",
# which names the hidden garment in the one shot that must not show it --
# and at cfg 1 there is no negative prompt, so naming a thing draws it.
# Measured: with the picture already withheld, the belt was still named
# twice in a covered shot, once by the author's sheet entry and once
# here. This clause was the half that could be removed.
#
# What survives is the half that works: a SURFACE, which the model
# renders well, described as unbroken over the part of the body in
# question. `u` is deliberately unused -- it is the thing not to mention.
return (f"{whose}{o} {cover} the hips and waist completely: whole, "
f"opaque and unbroken, the outermost layer there and the only "
f"one in view.")
return " " + " ".join(_one(*p) for p in pairs[:2])
def reveal_clause(items):
"""Say what is underneath is what shows now, on the shot that uncovers it.
The removal clause is emphatic and specific -- off the body, dropped out of frame
-- while the layer beneath is one item in an attribute list. Against a model whose
prior for trousers coming off is bare skin, a list entry does not compete. It has
to be told what fills the space the garment left."""
if not items:
return ""
said = " and ".join(f"the {i}" for i in items[:2])
plural = len(items) > 1 or items[0].endswith("s")
return (f" {said[0].upper()}{said[1:]} underneath {'are' if plural else 'is'} what "
f"shows there now, still on and unchanged.")
_TAGGED_FRAGMENT = re.compile(r"<\s*Picture\s*\d+\s*>", re.I)
def tagged_items(sheet):
"""Head nouns of sheet entries that carry a <Picture N> of their own.
An item the author has attached a reference to is one they have said, as
plainly as this node allows, that they want drawn. It is also identity
wiring: the tag is how ref_image_N reaches the shot, and a reference no text
claims is read as an extra subject -- the worst failure this node has.
So a tagged item is never held back as merely HIDDEN. Reported as a chastity
belt with a picture reference disappearing out of the character memory: it
was inferred to be under the jeans, went into `covered` with the ordinary
under-layers, and scrub_removed dropped the fragment -- taking <Picture 2>
with it, so the image was loaded, counted in the report as going "where
tagged", and tagged nowhere."""
out = set()
for _n, line in sheet_lines(sheet or ""):
for frag in str(line).split(","):
if not _TAGGED_FRAGMENT.search(frag):
continue
for head in entry_heads(frag):
out.add(head)
return out
def merge_sheets(*sources):
"""(one sheet, the names that were described more than once).
character_memory and a `Name:` paragraph in the prompt are the same channel by
two routes, and using both -- the natural thing to do once the widget exists --
put the person in every shot TWICE:
A basement. Maya: 27, silver hair, grey coat. Maya: 27, silver hair, grey
coat. Maya lies still on the floor.
A model told about one person twice renders two of them. One entry per name, and
no line repeated. The earlier source wins, so character_memory overrides a sheet
left in the prompt."""
seen_names, seen_lines, out, dupes = set(), set(), [], []
for src in sources:
for name, line in sheet_lines(src):
key = name.lower() if name else None
if key and key in seen_names:
if name not in dupes:
dupes.append(name)
continue
if line in seen_lines:
continue
if key:
seen_names.add(key)
seen_lines.add(line)
out.append(line)
return "\n".join(out), dupes
def terminate_lines(text):
"""Give every line a full stop, so what follows does not run into it.
The sheet is assembled ahead of the beat, and a line ending "grey coat" welds
onto the beat as "grey coat Maya lies still". A name fused to the end of an
attribute list reads as one more item in the list -- another person in shot."""
out = []
for ln in (text or "").splitlines():
s = ln.rstrip().rstrip(",;:")
if s and s[-1] not in ".!?":
s += "."
if s:
out.append(s)
return "\n".join(out)
def build_scene(anchor, first_para, character_memory, sheet):
"""The text every shot carries, in reading order: the anchor frames the film,
the opening paragraph sets the scene, and the character sheet says who is in it
and what they are wearing.
One string on purpose -- a removal scrubs all of it. The previous node kept the
anchor immutable, and clothing written there could never be taken off: the
anchor put it back on every shot, under a beat that had just removed it."""
parts = [(anchor or "").strip(), (first_para or "").strip(),
(character_memory or "").strip(), (sheet or "").strip()]
return "\n".join(terminate_lines(p) for p in parts if p)
_QUOTED = re.compile(r'["“][^"”]+["”]')
# H3's OWN dialogue delimiter. comfy/text_encoders/minimax.py registers <d> and </d>
# as special tokens, alongside a caption channel (<|caption_start|>...) and a lyrics
# one -- so the model distinguishes speech, captions and lyrics explicitly. Text in
# plain quotes is not marked as any of them, and a model with a caption channel is
# entitled to read it as a caption, which renders as text ON the picture.
_DIALOGUE_TAG = re.compile(r"<\s*d\s*>(.+?)<\s*/\s*d\s*>", re.I | re.S)
# Tokens that ASK for text on the frame. If one of these is in the prompt, the
# subtitles are being requested, not invented.
_CAPTION_TOKEN = re.compile(r"<\|(?:caption|lyrics)_(?:start|end)\|>", re.I)
# A shot LONGER than its action does not get filled with more action -- it gets
# filled by performing the same action more slowly, which reads as the whole film
# being in slow motion. Measured: "Maya walks to the window" is a few steps, under
# two seconds of real movement, and the old constants gave it a 4.5s shot.
#
# The base was the larger error. It was meant as setup and settle, but a chained shot
# continues from the previous frame -- it opens mid-scene, with nothing to set up.
BEAT_BASE_SEC = 0.8 # a little room to settle, not a whole beat of it
SECONDS_PER_ACTION = 2.2 # screen time one staged action clause needs
WORDS_PER_SEC = 2.5 # spoken delivery
# A new coordinated verb phrase starts a new action.
#
# A PLAIN COMMA between verb phrases is one too, and it is the commonest way
# anybody writes a sequence: "walks in, drops her bag, takes off her coat, hangs
# it up". Only " and " used to split that, so ten actions counted as TWO and the
# beat was sized for two -- the shot then performed all ten inside it, which is a
# walk down a hallway arriving as a cut to the far end. Reported as scenes being
# cut short and missing their detail.
#
# The comma has to be followed by an INFLECTED verb, so a list of adjectives or of
# garments does not split: "a red, tattered coat" is one thing, and a character
# sheet is not a sequence of actions.
_CLAUSE_SPLIT = re.compile(
r"(?:[.!?;]+|,?\s+(?:and then|then|and|before|after|while|as|until)\s+"
r"|,\s+(?=\w+(?:ing|es|s|ed)\b))")
def beat_seconds(beat):
"""Roughly how much screen time this beat's content asks for.
Action and dialogue OVERLAP -- people talk while they move -- so it is the
larger of the two, not the sum. Deliberately rough: the point is not to size
the shot (the node does not), it is to notice when a shot is much longer than
anything the beat gives it to do."""
text = _DIALOGUE_TAG.sub(" ", _QUOTED.sub(" ", beat or ""))
text = _REMOVE_LINE.sub("", _ADD_LINE.sub("", text))
clauses = [p for p in _CLAUSE_SPLIT.split(text) if p and len(p.split()) >= 2]
action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * len(clauses)) if clauses else 0.0
spoken = sum(len(q.split()) for q in _QUOTED.findall(beat or "")) \
+ sum(len(q.split()) for q in _DIALOGUE_TAG.findall(beat or ""))
return max(action, (spoken / WORDS_PER_SEC + 1.0) if spoken else 0.0)
MIN_AUTO_FRAMES = 73 # ~3.0s: the shortest shot that can hold one action
def plan_lengths(beats, ceiling_frames, from_beat, pace=1.0):
"""Frames for each shot. Returns (lengths, note).
'fixed' gives every shot the ceiling. 'from the beat' sizes each shot from what
its own line stages, capped by that same ceiling and floored at one action's
worth -- so a beat with one action stops getting a shot with room for two, which
is what makes an action carry on past its end.
The estimate leans SHORT deliberately. A shot that ends before its action does
hands a mid-motion frame to the next shot, and the chain is built to continue
from exactly that. A shot that outlasts its action does not invent more action --
it performs the same action more slowly, which is what slow-looking footage is.
`pace` scales the whole estimate: below 1.0 the shots get shorter and the motion
in them brisker, above 1.0 they get longer and slower."""
if not from_beat:
return [ceiling_frames] * len(beats), ""
pace = max(0.05, float(pace if pace else 1.0))
lens, capped = [], []
for b in beats:
need = beat_seconds(b) * pace
want = align_frame_count_nearest(int(round(need * H3_FPS))) if need else MIN_AUTO_FRAMES
# A beat that wants MORE than shot_seconds allows is compressed into it,
# silently. The shot then performs the whole beat faster -- a walk down a
# hallway becomes a cut to the far end -- and nothing in the report said the
# length was the reason. Reported as scenes being cut short.
if want > ceiling_frames:
capped.append((len(lens) + 1, want))
lens.append(max(MIN_AUTO_FRAMES, min(want, ceiling_frames)))
note = ""
if capped:
note = ("shot(s) "
+ ", ".join(f"{n} (wants {w / H3_FPS:.1f}s)" for n, w in capped[:6])
+ f" stage more than shot_seconds allows, so they are cut to "
f"{ceiling_frames / H3_FPS:.1f}s and perform the whole beat faster "
f"-- which is a walk down a hallway arriving as a cut to the far "
f"end. Raise shot_seconds (H3's own ceiling is "
f"{MAX_FRAMES / H3_FPS:.1f}s), or give the beat fewer actions and "
f"let the next one carry the rest. ")
if len(set(lens)) > 1:
note += (
"shot lengths are sized from each beat ("
+ ", ".join(f"{n}f/{n / H3_FPS:.1f}s" for n in lens)
+ "). They differ, so one seed does not give them one noise field -- "
"noise is drawn to the latent's shape -- and surface detail resets at "
"each cut. Set shot_length to 'fixed' if that matters more than pacing")
return lens, note
def pace_clause(need, have):
"""Spread a short action across a long shot. "" when the shot is not long.
thin_beats has always been able to SEE this -- one action sitting in a ten
second shot -- and only ever reported it. The shot was still told what happens
and nothing about when, so the action was performed at once and the spare
seconds filled by carrying on: the same movement repeated on whatever was
nearest. Reported as actions running way ahead of schedule.
A timing anchor, the same shape the node already uses for a door ("open at the
first frame and shut by the last") and for a removal ("away by the last
frame"). It names WHEN, not how fast: "slowly" is a style instruction and this
is not one -- it says the action occupies the shot it was given.
Only where the gap is real. thin_beats' own thresholds: at least 2.5 spare
seconds and a quarter again longer than the content, so a shot that only
slightly outlasts a long beat stays quiet."""
try:
need, have = float(need), float(have)
except (TypeError, ValueError):
return ""
if need <= 0 or (have - need) < 2.5 or have <= need * 1.25:
return ""
return (" What the beat stages runs at an even pace across the whole shot, "
"beginning at the first frame and still finishing on the last.")
def thin_beats(beats, seconds):
"""Beats with far less content than the shot they are given.
A shot that outlasts its action leaves the model seconds it was told nothing
about, and the cheapest way to fill them is to CARRY ON: an action that has
finished its object repeats it on whatever is nearest. Pure arithmetic -- it
cannot know whether "walks
across the room" is two seconds or ten, but it can see one action sitting in a
ten second shot and say so before the render."""
out = []
for i, b in enumerate(beats or [], 1):
need = beat_seconds(b)
# The GAP matters more than the ratio: "takes off her coat and hangs it up"
# asks for about 7s, and in a 10s shot the three spare seconds are enough for
# the action to run on past the thing it was given. A small ratio guard
# keeps it quiet when the shot only slightly outlasts a long beat.
if need and (seconds - need) >= 2.5 and seconds > need * 1.25:
out.append(f"shot {i}: ~{need:.0f}s of content in a {seconds:.0f}s shot")
return out
# Effort and reaction: the beats where a face has something to do.
_EXERTION = re.compile(
r"\b(?:thrash(?:es|ing|ed)?|struggl(?:e|es|ing|ed)|writh(?:e|es|ing|ed)|"
r"strain(?:s|ing|ed)?|fight(?:s|ing)?|kick(?:s|ing|ed)?|jerk(?:s|ing|ed)?|"
r"gasp(?:s|ing|ed)?|pant(?:s|ing|ed)?|cr(?:y|ies|ying)|sob(?:s|bing|bed)?|"
r"scream(?:s|ing|ed)?|shout(?:s|ing|ed)?|yell(?:s|ing|ed)?|moan(?:s|ing|ed)?|"
r"whimper(?:s|ing|ed)?|laugh(?:s|ing|ed)?|flinch(?:es|ing|ed)?|"
r"trembl(?:e|es|ing|ed)|shak(?:e|es|ing)|shiver(?:s|ing|ed)?|"
r"freak(?:s|ing)?\s+out|wakes?\s+up|woke\s+up|panic(?:s|king|ked)?)\b", re.I)
# What a hand closes on under effort. A railing, a wheel, a bag or a door handle is
# somebody steadying themselves and is deliberately not here.
_EFFORT_OBJ = (r"(?:her|his|their|the)\s+(?:backs?|hips?|thighs?|shoulders?|arms?|"
r"wrists?|waist|hair|neck|sheets?|bedding|blankets?|pillows?|"
r"mattress|headboard|bars?|restraints?)")
# The generic MOTION verbs, which mean effort only in context.
#
# These were added bare, and bare they are wrong: arch, buck, clench, clutch, grind,
# grip, rock and thrust are ordinary English. "He grinds the coffee", "she grips the
# railing", "the truck rocks over the kerb" and "she arches an eyebrow" all read as
# vocal effort, which put "unsteady breathing, with gasps and moans of effort" into
# the prompt of a scenery beat, opened its audio branch and took the mouth guard off
# it. On a joint model that is a close-up of a panting face where a wide shot of a
# hallway was asked for, and an invented speaker to go with the invented voice.
#
# THE TRADE RUNS THE OTHER WAY FROM WHAT I ASSUMED. A wrong OPEN branch costs
# moaning text, a free mouth and a babbling stream that drags the framing with it; a
# wrong CLOSED one costs a silent shot, and built foley now covers part of even
# that. So these must corroborate, never fire alone.
#
# `clench` is gone except standing alone: a clenched jaw or fist is silent tension,
# which is the opposite of a sound cue.
_EXERTION_NARROW_SRC = (
# arching a back, not an eyebrow
r"arch(?:es|ed|ing)?\s+(?:her|his|their)\s+backs?\b|"
r"arch(?:es|ed|ing)?\s+(?:up|upwards?|off)\b|"
# sustained movement, always against or with something
r"(?:rock|grind|thrust|buck|push|move)(?:s|ed|ing)?\s+"
r"(?:against|into|together|beneath|underneath|under|onto)\b|"
# ...or the same verbs with no object at all, which is the intransitive sense
r"(?:rocks?|rocked|rocking|grinds?|ground|grinding|thrusts?|thrusting|"
r"bucks?|bucked|bucking|clench(?:es|ed|ing)?)\s*(?=[.,;!?]|$)|"
# a hand closing on a body or the bedding, not on a railing
r"(?:clutch(?:es|ed|ing)?|grip(?:s|ped|ping)?|claw(?:s|ed|ing)?)\s+"
r"(?:at\s+)?" + _EFFORT_OBJ + r"|"
r"(?:clutch(?:es|ed|ing)?|grip(?:s|ped|ping)?|claw(?:s|ed|ing)?)\s+at\b|"
# involuntary, and rarely said of a prop
r"shudder(?:s|ed|ing)?\b")
_EXERTION_NARROW = re.compile(r"\b(?:" + _EXERTION_NARROW_SRC + r")", re.I)
def exertion_in(beat):
"""Does this beat stage effort or reaction -- something a face performs?
Two lists: verbs that are inherently about distress or exertion and mean it
wherever they appear, and generic motion verbs that mean it only with the right
complement. See _EXERTION_NARROW for why the second group may not fire alone."""
b = beat or ""
return bool(_EXERTION.search(b) or _EXERTION_NARROW.search(b))
# Sound the text asks for. H3 is joint, so the same prose conditions the audio
# branch -- a scene is scored by describing it, not by a setting.
_SOUND_CUE = re.compile(
r"\b(?:sounds?|noises?|echo(?:e?s|ing)?|silence|rattl(?:e|es|ing)|clank(?:s|ing)?|"
r"clink(?:s|ing)?|creak(?:s|ing)?|scrap(?:e|es|ing)|thud(?:s|ding)?|bang(?:s|ing)?|"
r"slam(?:s|ming)?|clatter(?:s|ing)?|jingl(?:e|es|ing)|squeak(?:s|ing)?|"
r"footsteps?|breath(?:s|es|ing)?|pant(?:s|ing)?|gasp(?:s|ing)?|sigh(?:s|ing)?|"
r"whimper(?:s|ing)?|moan(?:s|ing)?|groan(?:s|ing)?|sob(?:s|bing)?|"
r"scream(?:s|ing)?|shout(?:s|ing)?|whisper(?:s|ing)?|laugh(?:s|ing|ter)?|"
r"hum(?:s|ming)?|buzz(?:es|ing)?|hiss(?:es|ing)?|drip(?:s|ping)?|"
r"rustl(?:e|es|ing)|click(?:s|ing)?|snap(?:s|ping)?|zip(?:s|ping)?|"
r"rings?|ringing|wind|rain|thunder|traffic|music|hollow|muffled|reverb|"
# How a sound is usually written when the noun is not itself a sound word.
# "her boots loud on the concrete" describes a sound and named none of the above,
# so it was read as staging nothing audible and the shot was silenced -- which is
# the one thing the docs tell you to do to score a silent shot.
# Adverbs only where the bare adjective describes something other than a sound --
# "quietly closes the door" is a sound being made, while "the workshop is quiet",
# "she is quiet" and "a faint smile" are the absence of one or nothing to do with
# one. Opening the branch on those is a free branch with no line in the shot,
# which is where an invented voice comes from.
r"loud(?:ly)?|quietly|faintly|audible|noisy|deafening|"
r"scuff(?:s|ing|ed)?|crunch(?:es|ing|ed)?|thump(?:s|ing|ed)?|"
r"patter(?:s|ing)?|whirr?(?:s|ing)?|whine(?:s|d)?|whining|rumbl(?:e|es|ing)|"
r"growl(?:s|ing)?|roar(?:s|ing)?|chime(?:s|d)?|ticking|"
r"knock(?:s|ing)?|tap(?:s|ping)?|whoosh(?:es|ing)?|sizzl(?:e|es|ing))\b", re.I)
# A BREATH IS NOT ENOUGH SOUND TO HOLD A BRANCH OPEN.
#
# "Dana takes a breath." is the beat people write immediately before a line, and
# it read as the author asking for a sound -- so the audio branch stayed open for
# the whole shot with nothing in it but half a second of breathing. An open
# branch on a joint model fills itself, and at 4-8 steps the last audio step
# clears 30-50% of the denoising in one jump, so what it fills with is a voice.
# Reported as micro-babble at the start of a scene, just as somebody goes to talk.
#
# Only the PREPARATORY breath, and only when it is all there is.
#
# Sustained breathing is a different thing and does fill a shot: "she breathes
# hard through the gag" is the sound of that shot, and silencing it would be
# taking away a sound somebody asked for by name. So would sighs, gasps, moans.
# What this catches is the single indrawn breath before a line -- one gesture,
# half a second, against a whole shot of open branch.
#
# And only when nothing else is making a noise: "takes a breath as the chain
# rattles" still opens it, because the chain has something to say for the rest.
_BREATH_WORD = re.compile(r"\bbreath(?:s|es|ing)?\b|\bbreathe[sd]?\b", re.I)
_BREATH_PREP = re.compile(
r"\b(?:takes?|took|taking|draws?|drew|drawing|catch(?:es)?|caught|"
r"suck(?:s|ed)?|pull(?:s|ed)?|lets?\s+out|releases?)\s+"
r"(?:in\s+)?(?:a|an|her|his|their|one|another|deep|long|slow|sharp|\s)*"
r"breath\b|\bwith\s+a\s+breath\b|\ba\s+(?:deep\s+|long\s+|slow\s+|sharp\s+)?"
r"breath\b", re.I)
def sound_described(text):
"""Does this beat ask for a sound the audio branch should make?
A breath on its own does not: see _BREATH_ONLY."""
t = text or ""
hits = [h for h in (m.group(0).strip() for m in _SOUND_CUE.finditer(t)) if h]
if not hits:
return False
if all(_BREATH_WORD.fullmatch(h) for h in hits) and _BREATH_PREP.search(t):
return False
return True
# What a staged action sounds like. The beat already says what happens; the sound it
# makes follows from that, so it does not have to be written twice.
#
# Matched against the BEAT only, never the scene. Sourcing it from the scene as well
# would put a chain rattling into a shot where nobody moves, because the scene says
# there is a chain -- the beat is what decides whether anything makes a noise.
# The six, as their own table, because two readers need them and a second copy
# would drift. sounds_for suppresses a LONE vocal -- the beat carries it verbatim
# and the node has nothing to add over the top -- and named_vocals_in below does
# not, because a shot whose clause is spoken as a CLOSED list has to name it.
_VOCAL_FROM = (
(r"\bwhimper(?:s|ing|ed)?\b", "whimpering"),
(r"\bsob(?:s|bing|bed)?\b", "sobbing"),
(r"\bmoan(?:s|ing|ed)?\b", "moaning"),
(r"\bgroan(?:s|ing|ed)?\b", "groaning"),
(r"\bscream(?:s|ing|ed)?\b", "screaming"),
(r"\bwhin(?:e|es|ing|ed)\b", "whining"),
)
_SOUND_FROM = (
# A VOCAL THE BEAT NAMES IS THE ONE THE SHOT MAKES, and it goes FIRST.
#
# These were missing entirely: sound_described() reads them off _SOUND_CUE and
# opens the audio branch, but nothing put them into the sound clause, so the
# word the author wrote reached neither branch. What the shot was told instead
# was inferred from the MOTION verb beside it -- "she whimpers and thrashes"
# produced "unsteady breathing, with gasps and moans of effort" and no whimper.
#
# Two failures came back from that, and they are the same substitution:
#
# - The clause is emitted with only=True, a CLOSED list. "The only sounds
# are ... moans of effort" does not merely omit the whimpering, it asserts
# the whimpering is not there, against a beat that says it is.
# - "moans" is the one vocal that reads as readily as pleasure. The face
# follows the audio branch on a joint model (see sound_clause), so a shot
# of distress conditioned on moans of effort renders a woman smiling.
# Reported exactly that way.
#
# FIRST in the tuple because sounds_for stops at MAX_SOUNDS, and on the beat
# this was reported from the budget was already full of engine, restraints and
# the inferred effort phrase before any vocal could be reached. What the author
# wrote outranks what the node inferred; that is the whole of the ordering rule.
#
# Speech verbs are NOT here. shout and whisper are lines being delivered and
# belong to the dialogue path, which suppresses the mouth guard and opens the
# branch on purpose. These six are non-speech vocalisations only.
*_VOCAL_FROM,
(r"\b(?:walk(?:s|ed|ing)?|step(?:s|ped|ping)?|pace[sd]?|enters?|runs?|"
r"approach(?:es|ed)?|creep(?:s|ing)?|crept|sneak(?:s|ing)?|shuffl(?:e|es|ing)|"
r"stumbl(?:e|es|ing)|stagger(?:s|ing)?|feet)\b", "footsteps"),
(r"\bchains?\b", "chain links dragging"),
# BEFORE the generic cuffs entry, because both match and the first wins. Cuffs
# being APPLIED are a ratchet, which is the sound anyone picturing the moment
# expects; "cuffs knocking" is what they do afterwards, hanging on a wrist.
# BOTH conditions as lookaheads anchored at \A, so each scans the WHOLE beat.
# A lookahead placed mid-pattern only looks FORWARD from wherever the engine is
# standing, so "the cuffs ratchet closed" failed -- the hardware is named before
# the verb, and by the time the verb matched the cuffs were behind it. Written
# this way the order in the sentence stops mattering. See the restraint entry
# below, which had the same defect and lost its sound on exactly that wording.
(r"\A(?=[\s\S]*\b(?:handcuff|cuff|shackle|manacle)\w*\b)"
r"(?=[\s\S]*\b(?:ratchet(?:s|ed|ing)?|clos(?:e|es|ing|ed)|snap(?:s|ped|ping)?|"
r"lock(?:s|ed|ing)?|tighten(?:s|ed|ing)?|click(?:s|ed|ing)?)\b)",
"cuffs ratcheting closed"),
(r"\b(?:handcuff(?:s|ed)?|cuffs?|cuffed|shackle[sd]?|manacle[sd]?)\b",
"cuffs knocking"),
# A bolt is not something dragging on the floor, which is what the drag entry
# below was giving it. Ahead of that entry, because "slides the bolt" matches
# both and the first match is the one that is kept.
(r"\b(?:bolt|latch|catch)(?:es|ed|ing)?\b", "a metal bolt sliding"),
# NOT "locks eyes with her" -- that is a look, and it was giving the shot the
# sound of a padlock closing.
(r"\b(?:padlock(?:s|ed)?|locks?|locked|locking)\b(?!\s+(?:eyes|gaze|horns|onto))",
"a lock snapping shut"),
(r"\b(?:drag(?:s|ged|ging)?|haul(?:s|ed|ing)?|shov(?:e|es|ing)|slid(?:e|es|ing))\b",
"something dragging on the floor"),
(r"\b(?:buckle(?:s|d)?|unbuckle(?:s|d)?|clasp(?:s|ed)?|strap(?:s|ped)?|harness)\b",
"a buckle and leather creaking"),
(r"\b(?:pour(?:s|ed|ing)?|water|splash(?:es|ed)?|wet|puddle)\b",
"water"),
(r"\b(?:van|car|engine|truck|motor)\b", "an engine outside"),
(r"\b(?:fabric|cloth|coat|jacket|shirt|dress|skirt)\b", "fabric rustling"),
(r"\b(?:scissors|shears|cut(?:s|ting)?)\b", "blades through fabric"),
(r"\bdoors?\b", "a door on its hinges"),
(r"\b(?:drops?|dropped|throw(?:s|n)?|threw|toss(?:es|ed)?)\b",
"something landing"),
(r"\b(?:smack(?:s|ed)?|slap(?:s|ped)?|hits?|strikes?|struck)\b", "a sharp impact"),
# Only where there is something to pull against. "McKenna thrashes on the bed"
# was getting restraints she is not wearing, because the verb alone armed it.
# Anchored at \A with BOTH conditions as lookaheads, so the hardware and the
# verb may appear in either order. Before this the lookahead sat mid-pattern and
# only looked forward: "she strains against the cuffs" worked and "the cuffs
# hold her wrists as she strains" silently did not, which is the same sentence.
(r"\A(?=[\s\S]*\b(?:cuffs?|handcuffs?|shackles?|manacles?|chains?|ropes?|cords?|"
r"straps?|restraints?|bindings?|ties?|tape|harness|collar)\b)"
r"(?=[\s\S]*\b(?:thrash(?:es|ing|ed)?|struggl(?:e|es|ing|ed)|writh(?:e|es|ing|ed)|"
r"strain(?:s|ing|ed)?|pull(?:s|ing|ed)?\s+against)\b)",
"restraints pulling taut"),
# A body under effort makes a VOICE, not only movement. H3 is joint, so this is
# also what stops the face going flat: conditioning the audio on silence tells the
# model the person makes no sound, and a person making no sound is rendered still.
# A beat that already names the sound is left alone -- "she moans" is in
# _SOUND_CUE, so what you wrote wins and none of this is added.
# THE TWO LISTS HAVE TO AGREE, and they are now built from the same source so
# they cannot drift again. The generic motion verbs were bare here too, so "he
# grinds the coffee" was given moans of effort as prompt text -- and prompt text
# on a joint model steers the picture, which is how a wide shot became a
# close-up of a panting face. See _EXERTION_NARROW.
(r"\b(?:thrash(?:es|ing|ed)?|struggl(?:e|es|ing|ed)|writh(?:e|es|ing|ed)|"
r"strain(?:s|ing|ed)?|trembl(?:e|es|ing|ed)|shiver(?:s|ed|ing)?)\b"
r"|\b(?:" + _EXERTION_NARROW_SRC + r")",
"unsteady breathing, with gasps and "
"moans of effort"),
(r"\b(?:zip(?:s|ped|ping)?|unzip(?:s|ped|ping)?|zipper)\b", "a zip running"),
(r"\btap(?:e|es|ed|ing)\b", "tape pulling off"),
# Gaps found by listing the beats this is actually asked for and reading what
# came back. Each of these returned NOTHING, on a shot whose whole point is the
# sound: velcro, a rope going tight, and the lower-body garments -- the fabric
# entry listed coat, jacket, shirt, dress, skirt and stopped there, so taking
# off a pair of shorts was silent while taking off a coat was not.
# FURNITURE UNDER SUSTAINED MOVEMENT. Both conditions, either order, because a
# bed standing in the scene must not creak in a shot where nobody moves -- the
# same rule the room tone follows. This is the NON-VOCAL half: a frame and a
# mattress working. The vocal half is not built anywhere and cannot be, since
# this synthesiser shapes noise and a voice is not noise; it comes from the
# model, on a branch the effort verbs open. See _EXERTION.
(r"\A(?=[\s\S]*\b(?:bed|mattress|springs?|bunk|couch|sofa|headboard|"
r"frame|table|desk|floorboards?)\b)"
r"(?=[\s\S]*\b(?:rock(?:s|ed|ing)?|thrust(?:s|ing)?|grind(?:s|ing)?|"
r"buck(?:s|ed|ing)?|writh(?:e|es|ing|ed)|arch(?:es|ed|ing)?|"
r"thrash(?:es|ing|ed)?|struggl(?:e|es|ing|ed)|move(?:s|d)?\s+together|"
r"shift(?:s|ed|ing)?\s+under)\b)", "a bed frame working"),
(r"\bvelcro\b", "velcro tearing open"),
(r"\b(?:rope|cord|twine|zip\s?tie)s?\b", "rope creaking as it goes tight"),
(r"\b(?:shorts|trousers|pants|jeans|leggings|tights|socks|boots|shoes|"
r"gloves|top|vest|jumper|sweater|hoodie|trousers)\b", "fabric rustling"),
(r"\bkeys?\b", "keys on a ring"),
(r"\b(?:wakes?\s+up|woke|gasp(?:s|ing)?|pant(?:s|ing)?|breath(?:es|ing)?)\b",
"breathing"),
)
MAX_SOUNDS = 3 # a shot's audio needs a cue, not an inventory
# {specific: (generals it retires)} -- see sounds_for.
# The inferred effort phrase and the bare breath are what a NAMED vocal replaces:
# one mouth is making one sound, and saying it twice spends two of three slots on
# the same thing -- the crowding this table exists to stop. The effort phrase also
# retires the bare "breathing" on its own, with no vocal named at all: "wakes up"
# and "thrashes" both fired and a shot came back listing "unsteady breathing, with
# gasps and moans of effort AND breathing".
_VOCAL_RETIRES = ("unsteady breathing, with gasps and moans of effort", "breathing")
# The six above, as a set: see the tail of sounds_for for why they are special-cased.
_NAMED_VOCALS = frozenset(("whimpering", "sobbing", "moaning", "groaning",
"screaming", "whining"))
_SOUND_SUPERSEDES = {
"cuffs ratcheting closed": ("cuffs knocking",),
"unsteady breathing, with gasps and moans of effort": ("breathing",),
"whimpering": _VOCAL_RETIRES,
"sobbing": _VOCAL_RETIRES,
"moaning": _VOCAL_RETIRES,
"groaning": _VOCAL_RETIRES,
"screaming": _VOCAL_RETIRES,
"whining": _VOCAL_RETIRES,
}
# The SPACE, as opposed to the things in it. Read from the scene, and this is the one
# thing that safely can be: a chain standing in the scene must not rattle in a shot
# where nobody moves, but a concrete room is hard in every shot whatever happens in
# it. That is the difference between a recording and a sound effect -- real footage
# has a bed under the events, and digital silence between them is what makes a scene
# sound staged.
_ROOM_TONE = (
(r"\b(?:bathroom|shower|tiled?|tiles)\b", "tiled walls ringing"),
(r"\b(?:basement|cellar|warehouse|garage|hangar|tunnel|stairwell|"
r"corridor|concrete|stone|brick|bare walls?)\b", "hard walls giving the sound back"),
# "shallow depth of field" and "field of view" are the LENS, not a location.
# Every anchor written for this node says one of them, so every interior scene
# was being told it sounds like open air.
(r"\b(?:outside|outdoors|street|road|yard|garden|forest|beach|park)\b"
r"|(?<!depth of )\bfield\b(?! of view)", "open air with no walls close by"),
(r"\b(?:carpet(?:ed)?|curtains?|bedroom|sofa|cushions?)\b",
"a soft room with little echo"),
(r"\b(?:barn|attic|loft|shed|workshop|hall|church)\b", "a large room with a long tail"),
)
# AMBIENT read from the scene, not from the beat. room_tone says how a space
# SOUNDS -- an acoustic, "hard walls giving the sound back" -- which shapes a sound
# that is already there and makes none of its own. A shot with no line and no
# written sound was therefore pinned to real silence: not "no speech" but no
# footsteps, no room tone, nothing, which is what makes a scene sound staged.
#
# Asked for: read the anchor and the scene and score the film from them, so the
# ambience does not have to be typed into every beat.
#
# Ordered most specific first -- weather and named places before the generic
# interior, so "a rainy street" is rain and traffic rather than "a room".
# Each phrase has to read naturally after "The only sound is ...", so none of them
# is a plural noun phrase, and NONE of them names a voice: "low talk" was in this
# table for one revision and is exactly the thing that puts a second speaker in a
# silent shot. Ambience is weather, machines, rooms and distance -- never people.
_AMBIENT = (
(r"\brain(?:ing|y)?\b|\bdownpour\b|\bdrizzl", "rain against the glass"),
(r"\bstorm|\bthunder", "a storm somewhere outside"),
(r"\bwind(?:y)?\b|\bgale\b", "wind against the building"),
(r"\bbeach\b|\bsea\b|\bocean\b|\bshore\b", "the sea a long way off"),
(r"\bforest\b|\bwoods?\b", "wind in the trees"),
(r"\bgarden\b|\byard\b|\bpark\b", "birdsong"),
(r"\bstreet\b|\broad\b|\btraffic\b|\bcity\b|\bpavement\b",
"traffic somewhere off the street"),
(r"\bcar\b|\bvan\b|\btruck\b|\bdriving\b", "an engine idling"),
(r"\bkitchen\b", "a fridge humming"),
(r"\bbathroom\b|\bshower\b", "water moving in the pipes"),
(r"\bnursery\b|\bbaby\b|\bcot\b|\bcrib\b", "a clock ticking"),
(r"\bbedroom\b", "the quiet of a bedroom"),
(r"\boffice\b|\bstudy\b", "a computer fan"),
(r"\bworkshop\b|\bgarage\b|\bfactory\b", "a strip light humming"),
(r"\bbasement\b|\bcellar\b|\bboiler\b", "a low hum off the strip light"),
(r"\bhospital\b|\bward\b|\bclinic\b", "a monitor somewhere down the corridor"),
(r"\bcafe\b|\bbar\b|\brestaurant\b|\bpub\b", "cutlery and moving chairs"),
(r"\bschool\b|\bclassroom\b", "a corridor beyond the door"),
(r"\bchurch\b|\bhall\b", "the air of a large empty room"),
(r"\bstairs?\b|\bstairwell\b|\bhallway\b|\bcorridor\b",
"the hollow quiet of a hallway"),
(r"\bnight\b|\blate evening\b", "the quiet of a night"),
# The generic interior LAST, so a named room wins.
(r"\bhome\b|\bhouse\b|\bflat\b|\bapartment\b|\bliving room\b|\blounge\b"
r"|\bindoors?\b|\broom\b", "the quiet of a house"),
)
def scene_ambient(*texts):
"""One ambient bed for the film, read from the anchor and the scene. "" if none.
First match wins, and the table is ordered most specific first: weather and
named places before the generic interior. ONE bed, not a list -- a shot told
four things to sound like is a shot inventing which."""
joined = " ".join(str(t or "") for t in texts)
if not joined.strip():
return ""
for pat, phrase in _AMBIENT:
if re.search(pat, joined, re.I):
return phrase
return ""
def room_tone(scene, opening=""):
"""How the space itself sounds. One room, one acoustic -- the first match wins.
`opening` is the first beat, and it is read only when the scene names no space at
all. With `anchor` set there is no scene PARAGRAPH -- the anchor is the whole of
it -- and an anchor describes the CAMERA, not the room. The location is then
written in the first beat, so reading nothing but the lens line left the acoustic
to be guessed from words like "depth of field"."""
for text in (scene, opening):
for pat, phrase in _ROOM_TONE:
if re.search(pat, text or "", re.I):
return phrase
return ""
# Sounds that are a THING IN MOTION, and which thing. H3 is joint: the prose
# conditions the audio branch and the picture follows the audio, so "a door on its
# hinges" is not a decoration on a shot with a door in it -- it is a request for a
# door to swing. Asked for beside a sentence holding that same door shut, the sound
# wins, because it describes something happening and the hold describes something
# not happening.
#
# Reported exactly that way: the doors started closed, as the hold asked, and were
# then opened. Two guards, one contradicting the other.
_SOUND_OF_MOVING = {"a door on its hinges": ("door",)}
def sounds_for(beat, held=()):
"""The sounds this beat's own action implies. [] when it stages nothing audible.
`held` is the scenery this shot is holding still. A sound of one of those moving
is dropped: the shot cannot be asked to keep the doors shut and to sound like a
door swinging."""
held = set(held or ())
out = []
for pat, phrase in _SOUND_FROM:
if len(out) >= MAX_SOUNDS:
break
if held.intersection(_SOUND_OF_MOVING.get(phrase, ())):
continue
if phrase not in out and re.search(pat, beat or "", re.I):
out.append(phrase)
# A specific sound retires the general one for the same object. Cuffs being
# applied are a ratchet; "cuffs knocking" is what they do afterwards, hanging on
# a wrist. Both in one shot is one object described making two noises, and the
# budget is three sounds -- spending two of them on the same pair of cuffs
# crowds out whatever else the beat stages.
for specific, general in _SOUND_SUPERSEDES.items():
if specific in out:
out = [p for p in out if p == specific or p not in general]
# WHAT YOU WROTE WINS -- and when it is ALL you wrote, winning means the node
# says nothing. "She moans." is already the sound of its shot, in the beat, going
# to the model verbatim; a sentence adding "the only sound is moaning" over the
# top of it is the node restating the author to the author's own reader.
#
# But that only holds while the vocal is the WHOLE list. The clause is emitted
# closed -- "The only sounds are ..." -- so as soon as anything else is in it,
# leaving the vocal out stops being silence and becomes a denial: a beat reading
# "she starts whimpering and thrashes in her restraints" was conditioned on "the
# only sounds are an engine outside, restraints pulling taut and unsteady
# breathing, with gasps and moans of effort", which asserts the whimpering is not
# happening and substitutes a vocal that is not a distress word. The face follows
# the audio branch, so that shot came back smiling. Both halves were reported.
if out and all(p in _NAMED_VOCALS for p in out):
return []
return out
def named_vocals_in(beat):
"""The non-speech vocals THIS BEAT NAMES, in the order the table lists them.
sounds_for deliberately returns [] when a vocal is all the beat says: the beat
goes to the model verbatim and the node has nothing to add over the top of it.
That is right where the node then says nothing -- and wrong the moment it says
something EXCLUSIVE.
"She screams." is sound_described, so _own is true and the inferred list is
zeroed; exertion_in is also true, so _voiced keeps the branch open rather than
letting the shot be muted; then the ambient bed is appended and only=not _speaks
closes the list. The shot was conditioned on "The only sound is an engine
idling" -- an exclusive claim, against a beat that says she screams, on the one
kind of shot whose branch is open and therefore has to fill itself with
something. Reproduced on "She screams.", "She sobs quietly." and "She starts
whimpering and thrashes in her restraints."
So the closed list gets the author's own vocal put back into it. This adds
nothing the node inferred -- these are the author's words, matched literally --
and it is what keeps the exclusive sentence true."""
b = str(beat or "")
return [phrase for pat, phrase in _VOCAL_FROM if re.search(pat, b, re.I)]
def sound_clause(phrases, only=False):
"""One sentence naming what the shot is heard as.
`only` closes the list. H3 is joint, so the audio branch drives the face: a shot
whose audio is left free but only loosely described will fill the rest with a
VOICE, and the mouth moves to it in a shot that has no line. Saying these are the
only sounds leaves nothing for a voice to fill.
Positively phrased, because that is the only phrasing this model gets: at cfg 1
H3 is CFG-free and no negative prompt is evaluated, so "nobody speaks" is not a
prohibition, it is the word "speaks" in the prompt. "The only sound is X" excludes
speech by saying what IS there.
Plain prose, and deliberately not a labelled line: `sound:` at the start of a
line is read as text to DRAW and turns up on screen, which is the whole reason
the old node's field labels had to be stripped out."""
if not phrases:
return ""
if len(phrases) == 1:
heard = phrases[0]
else:
heard = ", ".join(phrases[:-1]) + " and " + phrases[-1]
if only:
verb = "is" if len(phrases) == 1 else "are"
return f" The only sound{'' if len(phrases) == 1 else 's'} {verb} {heard}."
return f" It sounds like {heard}."
# A LINE THAT IS NOT COMING OUT OF ANYBODY IN THE ROOM.
#
# Reported: she appeared to be mouthing what was on the television. H3 is joint, so
# the face follows the audio branch -- and the branch has no idea a voice belongs to
# a device. A shot with 'The TV says: "..."' in it reads as a speaking shot, which
# opens the branch AND suppresses the mouth guard, so the only face in frame gets
# handed the line.
#
# The branch must stay open: the television is supposed to be heard. What has to
# change is who the voice is attributed to.
_TALKER_DEVICE = (r"(?:televisions?|tvs?|telly|screens?|radios?|speakers?|stereos?|"
r"tannoys?|intercoms?|phones?|telephones?|laptops?|monitors?|"
r"record\s+players?|pa\s+systems?|answerphones?|announcements?)")
_DEVICE_SAYS = re.compile(
r"\b" + _TALKER_DEVICE + r"\b(?:\s+[\w,']+){0,3}?\s+"
r"(?:says?|said|announces?|announced|blares?|blared|plays?|played|calls?|called|"
r"reads?|talks?|talking|goes|went|crackles?|drones?|repeats?|asks?)\b", re.I)
# Somebody in the room speaking. Kept deliberately generous: if there is any chance a
# person has the line, the person keeps it. Muting a real line is far worse than a
# mouth moving, and this decides whether the mouth guard applies.
# The capitalised-word branch is a stand-in for a name, so it has to refuse the words
# that are capitalised for being at the start of a sentence -- "The TV says" was
# reading as a person called The -- and the machines themselves, which are capitalised
# as often as not ("TV", "PA").
_NOT_A_NAME = (r"(?!(?:The|A|An|It|This|That|These|Those|There|Then|Here|His|Her|Their|"
r"Its|Our|My|Your|When|While|As|But|And|One|Now|So|No|Yes|Somebody|"
r"Someone|Nobody|Everyone|"
r"TV|TVs|PA|Television|Televisions|Telly|Radio|Radios|Screen|Screens|"
r"Speaker|Speakers|Stereo|Intercom|Phone|Telephone|Laptop|Monitor)\b)")
# The verbs that give somebody a line. ONE list: this was written out three times
# -- in _PERSON_SAYS, in the sheet-name check inside speech_is_a_devices, and in
# speakers_in -- and the three had already drifted apart. The middle copy was
# missing a dozen of them, so "Mara murmured: ..." read as a person speaking in
# two places and not in the third, which decides whether a line belongs to a
# person or to a television.
_SAYS = (r"says?|said|asks?|asked|whispers?|whispered|shouts?|shouted|calls?|"
r"called|repl(?:y|ies|ied)|answers?|answered|adds?|added|murmurs?|"
r"murmured|mutters?|muttered|tells?|told|begs?|begged|snaps?|snapped|"
r"breathes?|breathed|hisses|hissed")
_PERSON_SAYS = re.compile(
r"\b(?:he|she|they|i|we|you|" + _NOT_A_NAME + r"[A-Z][\w-]+)\s+"
r"(?:[\w,']+\s+){0,2}?(?:" + _SAYS + r")\b")
def speech_is_a_devices(beat, sheet=""):
"""Is the only spoken line in this beat coming out of a machine?
False whenever a person might have it, including when nothing attributes the
line at all -- an unattributed quote in a beat about people is a person talking."""
b = beat or ""
if not has_speech(b) or not _DEVICE_SAYS.search(b):
return False
if _PERSON_SAYS.search(b):
return False
# A name from the sheet with a speech verb after it, which the pattern above
# only catches when the name happens to be capitalised in the beat.
for n, _ in sheet_lines(sheet):
if n and re.search(r"\b" + re.escape(n) + r"\b(?:\s+[\w,']+){0,2}?\s+"
r"(?:" + _SAYS + r")\b", b, re.I):
return False
return True
def device_voice_clause(beat):
"""Say which machine the voice is coming out of, so no face is given it."""
m = re.search(r"\b" + _TALKER_DEVICE + r"\b", beat or "", re.I)
if not m:
return ""
# As the author spelled it. Lowercasing turned "TV" into "tv", and a set is not
# improved by the node correcting its capitalisation.
thing = re.sub(r"\s+", " ", m.group(0))
return (f" The voice in this shot is the {thing}'s, coming out of it across the "
f"room, and the people listening hold still and let it play.")
def speakers_in(beat, sheet=""):
"""Who this beat gives a line to. [] when it names nobody.
A shot where one of two people speaks is a SPEAKING shot, so the mouth guard
stood down for both -- and the listener's mouth was left as free as the
speaker's. That is the commonest scene there is, and the lip-sync problem the
guard exists for lands squarely on the person saying nothing."""
b, out = beat or "", []
for n, _ in sheet_lines(sheet):
if not n:
continue
# The gap may not contain a CONJUNCTION. "Kate approaches Sam and asks"
# gave the line to Sam: he is nearer the verb, but "and" starts a new
# predicate whose subject is still Kate, so the shot was told the wrong
# person speaks -- and the mouth guard then held the actual speaker's mouth
# shut. Filler like "then"/"quietly" is still allowed through.
if re.search(r"\b" + re.escape(n) + r"\b"
r"(?:\s+(?!and\b|but\b|then\b|who\b|,\s*who\b)[\w,']+){0,2}?\s+"
r"(?:" + _SAYS + r")\b", b, re.I):
out.append(n)
# INVERTED attribution: the verb comes first. '"Sure thing," says Dan.' is the
# commonest form in prose after the plain one, and the pattern above only ever
# looked for name-then-verb, so it resolved nobody -- and a line nobody is
# credited with leaves both mouths free, which is where the second voice comes
# from.
#
# ONLY AFTER A CLOSING QUOTE. Bare verb-then-name is far more often the
# ADDRESSEE than the speaker -- "she tells Dan", "she asks Dan", "she begs Dan"
# -- and crediting the addressee is worse than crediting nobody: the shot then
# says "Only Dan speaks; every other mouth closed", which holds the actual
# speaker's mouth shut and moves the listener's. The voice comes out of the
# wrong face. The quote is what marks the real inversion.
if not out:
for n, _ in sheet_lines(sheet):
if not n:
continue
if re.search(r"[\"'”’]|</d>", b) and re.search(
r"(?:[\"'”’]|</d>)\s*[,.;]?\s*(?:" + _SAYS + r")\s+"
+ re.escape(n) + r"\b", b, re.I):
out.append(n)
# Still nobody, and somebody is speaking. The name nearest the START of the beat
# is the subject: "In the living room, Dan looks up. '...'" and "The door opens
# and Dan walks in. '...'" are both Dan, and neither begins with his name -- the
# old fallback read only the beat's FIRST WORD, so any beat that opened with
# scenery credited nobody.
if not out and has_speech(b):
# Names AND declared pronouns, whichever comes FIRST. A name alone is not
# enough: "She tells Dan to wait" holds one name and he is the ADDRESSEE,
# so taking the only name credited the listener -- and the shot then said
# "Only Dan speaks", holding the actual speaker's mouth shut and moving
# his. A pronoun in subject position beats a name that comes after it.
at = {}
for n, ln in sheet_lines(sheet):
if not n:
continue
m = re.search(r"\b" + re.escape(n) + r"\b", b)
if m:
at[n] = m.start()
group = sheet_pronoun(ln)
if not group:
continue
# Only where this pronoun picks out ONE person: with two women on the
# sheet "she" resolves nobody, and guessing is how a line lands on the
# wrong face.
if sum(1 for _n, _l in sheet_lines(sheet)
if _n and sheet_pronoun(_l) == group) != 1:
continue
pm = re.search(r"\b(?:" + "|".join(sorted(_PRONOUN_SET[group]))
+ r")\b", b, re.I)
if pm and (n not in at or pm.start() < at[n]):
at[n] = pm.start()
if at:
out.append(min(at, key=at.get))
return out
# The mouth half AND the voice half. This said only that the other mouths stay
# closed, which is the PICTURE -- and on a joint model the face follows the audio:
# a second voice in the stream puts a second mouth in motion whatever the prose
# says about jaws. So the shot has to be told how many voices there are, not just
# how many mouths, and the prose is what conditions the audio branch.
#
# Positively phrased: "one voice" names what IS there. "Nobody else speaks" asks
# the model to render an absence, and at cfg 1 there is no negative prompt to carry
# it. {who} is named ONCE -- naming a person twice in one shot is what put a second
# copy of them in frame.
# SAID ONCE, and what fills the rest. A line is a second or two; the shot is five
# to ten, and the audio branch is open for all of it. Told only that there is one
# voice, the model still has seconds of open branch to fill on either side of the
# line -- and the only thing it knows is happening in this shot is somebody
# talking, so it invents more talking to occupy the lead-in. Reported exactly that
# way: babble before the dialogue starts.
#
# Two statements fix the gap, and both name something that IS there rather than an
# absence, because at cfg 1 there is no negative prompt: the line is said ONCE, and
# what occupies the time around it is ROOM TONE. A branch with a bed to lay down
# does not need to invent a voice to fill the space.
# The specific acoustic belongs to the sound clause, which already says it where
# the scene names a space. Here it is the generic bed, so the sentence reads the
# same whatever room this is.
# SHORT. Every word here is speech vocabulary -- speaks, voice, line, said -- and
# on a joint model the prose conditions the AUDIO branch as much as the picture.
# A longer version of this clause ("one voice in the shot, the line said once, with
# room tone either side of it") was added to stop a listener babbling and was
# reported as causing it: more speech words on a shot is more reason for the branch
# to make speech. Say who has the line and hold the other mouths; nothing else.
# WHICH LANGUAGE the line is in. H3 is joint and multilingual: the prose conditions
# the audio branch, and a branch told a line is spoken but never told in what will
# pick one. Babble that is not babble at all -- a real language, fluently delivered,
# and not the one the script is written in -- sounds exactly like gibberish to
# somebody expecting English.
#
# Positively phrased, and stated once: at cfg 1 there is no negative prompt, so
# "not in another language" would name the other language. Naming the wanted one is
# the whole mechanism.
# The FALLBACK, not the rule. This was the rule -- the clause named English and
# only English -- so a script written in any other language was told its own line
# is spoken in English, and the delivery fought the words. Users asked for that
# restriction to come out.
#
# Naming NOTHING is not the way out: unnamed is where the branch picks a language
# on its own, which is the "sounds like gibberish" report this clause answers. So
# the language is read off the line, and this is only what stands in when the line
# is too short to tell.
SPOKEN_LANGUAGE = "English"
# THE LANGUAGE, AND NOT THE FACT THAT IT IS SPOKEN. ea58d3c took the speech
# vocabulary back out of the speech guard on the evidence of a render, and wrote
# the finding down: "Every word I added is speech vocabulary -- speaks, voice,
# line, said -- and on a joint model the prose conditions the AUDIO branch as much
# as the picture. A clause meant to suppress a second voice was itself priming
# speech." What it kept is "who has the line and holds the other mouths, which is
# what it said before this session and what was not babbling".
#
# 6943916 put `line` and `spoken` back, on EVERY speaking shot, twelve and a half
# hours later (2026-09-05 23:16 -> 2026-09-06 11:53). Nothing was wrong with its
# purpose -- a branch told a line is spoken but never told in WHAT picks a language,
# and that was a real report -- but it carried two of the four words the render had
# just convicted, into the one clause that lands on exactly the shots with a voice
# in them.
#
# The dropped half was redundant anyway, which is why this costs nothing. <d> and
# </d> (151669/151670) are real tokens the model was trained with, and they are
# what marks a span as spoken; the language is the one thing they cannot carry,
# and it is all this sentence needs to say. Verified across English, Spanish,
# French, German, Russian and Japanese.
LANGUAGE_HOLD = " The language is {lang}."
# Characters that are not plain Latin text. A stray CJK, Cyrillic or Arabic glyph in
# a prompt is a strong signal to a multilingual model about what language to speak,
# and one pasted quotation mark is easy to miss by eye. Reported rather than
# stripped: the node passes the author's words through, and silently editing them is
# the thing it does not do.
#
# Latin-1 and Latin Extended cover the accented letters, and U+0300-U+036F the
# COMBINING marks -- "cafe" plus a combining acute is the decomposed spelling of
# the same word, and flagging it would report every accented character typed on a
# Mac. Curly quotes and dashes are ordinary punctuation, not a language signal.
_NON_LATIN = re.compile(
r"[^\x00-\x7F\u00C0-\u024F\u0300-\u036F"
r"\u2018\u2019\u201C\u201D\u2013\u2014\u2026]")
# Things inside a line that have no single spoken form: a number, a time, a date,
# an abbreviation, an acronym, a symbol. The model reads the line as text and
# picks one -- "7:30" as "seven thirty" or "seven three zero", "Dr." as "doctor"
# or "dee arr" -- and the picking is what mispronounced dialogue is.
#
# The abbreviations are a LIST, not a shape. "[A-Z][a-z]{0,3}\." also matches the
# end of any short sentence, so "No." would have been reported as an abbreviation
# in every script that has somebody saying no.
_HARD_TO_SAY = re.compile(
r"\b\d[\d:.,/\-]*\d\b|\b\d\b"
r"|\b(?:Mr|Mrs|Ms|Dr|Prof|Sgt|Lt|Capt|Rev|Hon|St|Ave|Rd|Blvd|Jr|Sr|"
r"vs|etc|approx|dept|Inc|Ltd|Co)\."
r"|[&%$#@+=]", re.I)
def non_latin_in(text):
"""The distinct non-Latin characters in this text, in order. [] when clean."""
out = []
for ch in str(text or ""):
if _NON_LATIN.match(ch) and ch not in out:
out.append(ch)
return out
# A LINE THAT ORDERS AN ACTION. "Dana says to McKenna: \"Take off your shorts and
# lie down on the change table.\"" -- the node no longer STAGES that (the readers
# refuse quoted speech), but the words are still in the shot, because beats are
# passed through verbatim and that is the oldest promise this file makes. A video
# model does not distinguish a quoted instruction from a stage direction: it
# renders what the words describe, and the action arrives a beat early.
#
# The words cannot be removed. What can be added is something for the LISTENER to
# be doing, so the shot has an answer for them other than the instruction --
# positively phrased, because at cfg 1 "does not do it yet" names the thing.
_ORDERED = re.compile(
r"\b(?:take|takes|taking|pull|pulls|remove|removes|undo|undoes|unfasten|"
r"unbuckle|unzip|slip|slips|step|steps|get|gets|lie|lies|lay|lays|sit|sits|"
r"kneel|kneels|stand|stands|turn|turns|come|comes|go|goes|put|puts|hold|"
r"holds|open|opens|close|closes)\b", re.I)
def told_to_act(beat, speakers, described):
"""Who is being TOLD to do something in this beat's dialogue. [] when nobody.
Only where the quoted line contains an action verb, and only for people the
shot describes who are not the one speaking -- the listener is the one whose
body the instruction is about, and the one the model will move early."""
b = str(beat or "")
if not b:
return []
said = " ".join(m.group(0) for m in _QUOTED.finditer(b))
if not said or not _ORDERED.search(said):
return []
talking = {n for n in (speakers or []) if n}
return [n for n in (described or []) if n and n not in talking]
def told_hold(listeners):
"""Give the listener something to be doing while the line is said."""
who = [n for n in (listeners or []) if n]
if not who:
return ""
# ONE naming each. A described person is a person the model draws, and naming
# somebody twice in one shot is what put a second copy of them in frame.
if len(who) == 1:
return f" {who[0]} listens, still, wearing what the sheet already lists."
said = ", ".join(who[:-1]) + " and " + who[-1]
return f" {said} listen, still, wearing what the sheet already lists."
MOUTH_HOLD_OTHERS = (" Only {who} speaks; every other mouth in the shot stays "
"closed, jaws still.")
# ...and when the line has no name on it. Two people, one line, nobody named: the
# speaker cannot be identified, so neither mouth could be held and BOTH were free
# to move -- which on a joint model is two voices in the stream and the second one
# is the babble. Saying how many voices there are does not require knowing whose.
ONE_VOICE = (" Only the person speaking has their mouth moving; every other jaw "
"in the shot stays still.")
# H3'S OWN DIALOGUE MARKER. <d> and </d> are special tokens the model was trained
# with -- comfy/text_encoders/minimax.py registers them as 151669 and 151670 -- and
# they mark a span as SPEECH rather than as scene description.
#
# This file warned about them for a long time and never used them, which left the
# real problem unfixed: a quoted line is just words in the prompt, and a video
# model renders what the words describe. "Take off your shorts and lie down on the
# change table" is an imperative sentence, and it was performed a beat before
# anybody said it. Refusing to STAGE it -- which every reader here now does -- does
# nothing about the model reading it.
#
# So the quotes become the marker the model actually understands. Every word the
# author wrote is kept, in order; only the quotation marks are exchanged for the
# tokens that say "this is spoken". That is less of an edit than the sentences this
# node already appends, and it is the difference between a line being heard and a
# line being acted out.
_PLAIN_QUOTED = re.compile(r"[\"“]([^\"“”]{1,400}?)[\"”]")
def mark_dialogue(beat):
"""Wrap plainly-quoted speech in H3's <d>...</d>. Unchanged when there is none.
Left alone where the author has already marked it, and where a quote is not
speech at all. A LINE ends in terminal punctuation and a scare quote does not:
"Wait." is one word and is speech, a "vintage" coat is emphasis. Counting words
got both of those backwards."""
b = str(beat or "")
if not b or "<d>" in b:
return b
def _wrap(m):
said = m.group(1).strip()
if not said:
return m.group(0)
# A LINE ends in terminal punctuation; a scare quote does not. "Wait." is
# one word and is speech; a "vintage" coat is two characters of emphasis.
# Word count alone got that backwards both ways.
if said[-1] in ".!?":
return "<d>" + said + "</d>"
# No terminal punctuation: it needs BOTH a speech cue and more than one
# word. A cue alone is not enough -- _SAYS contains "called", so 'he
# called it a "problem"' read as an introduction to a line. A determiner
# and one word is a noun, whatever verb came before it.
if len(said.split()) < 2:
return m.group(0)
before = b[max(0, m.start() - 40):m.start()]
if re.search(r"(?:" + _SAYS + r")\b[^.]{0,12}$|[:,]\s*$", before, re.I):
return "<d>" + said + "</d>"
# ...or the cue comes AFTER it. '"Come here," Dana says.' is how half of
# written dialogue is punctuated, and only the text BEFORE the quote was
# ever consulted -- so that form was never marked at all, and an unmarked
# line is a line the audio branch was never told is spoken. Quotation
# marks say nothing to the model on their own.
after = b[m.end():m.end() + 40]
if re.match(r"[\s,]*(?:[A-Za-z][\w'-]*\s+){0,2}?(?:" + _SAYS + r")\b",
after, re.I):
return "<d>" + said + "</d>"
return m.group(0)
return _PLAIN_QUOTED.sub(_wrap, b)
def has_speech(beat):
"""Does this beat contain a scripted line?
Either H3's own <d>...</d> marker or plain double quotes. Only checking quotes
meant a beat written the way the model expects was treated as silent, and its
audio muted."""
text = beat or ""
return bool(_DIALOGUE_TAG.search(text) or _QUOTED.search(text))
_PICTURE_TAG = re.compile(r"<\s*picture[\s_\-]*(\d+)\s*>", re.I)
def picture_tags(text):
return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")})
def resolve_tags(text, ref_list):
"""(text with its tags renumbered, the images that shot carries, dropped slots).
A <Picture N> tag is the BINDING between an image and the subject the prompt
describes, and it belongs IN the prompt. comfy_extras/nodes_minimax_h3.py says so
outright: "Ordinals are 1-based per type, so the prompt refers to them as
<Picture i>", and the node's own description is "Use the same tags when
prompting."
The rule that follows governs every reference decision in this file:
a picture the prompt REFERS TO is that subject;
a picture the prompt does NOT refer to is ANOTHER subject.
So taking a tag out of the text does not remove a spare person, it CREATES one --
the image arrives labelled and unclaimed, and the model renders it as somebody
else. It is also why the handoff frame must not enter this channel at all: no
wording refers to it, so it would arrive as a stranger.
comfy/text_encoders/minimax.py writes the "<Picture N>: " label itself, numbering
by the order it receives the images -- so a shot that uses only <Picture 2>
receives that image labelled <Picture 1>, and text still saying <Picture 2> points
at nothing. The tags are renumbered per shot to match what the shot actually
carries: slot 2 alone becomes <Picture 1>; slots 2 and 4 become <Picture 1> and
<Picture 2>.
A tag naming a slot with no image connected refers to nothing at all, so it is
removed from the text rather than left for the encoder to puzzle over."""
wanted = picture_tags(text)
live = [n for n in wanted if 1 <= n <= len(ref_list or [])]
dropped = [n for n in wanted if n not in live]
renum = {old: new for new, old in enumerate(live, 1)}
def sub(m):
n = int(m.group(1))
return f"<Picture {renum[n]}>" if n in renum else ""
out = _PICTURE_TAG.sub(sub, text or "")
out = re.sub(r"\s+([,.;:])", r"\1", out) # " ," left by a removed tag
out = re.sub(r"([:,;])\s*,", r"\1", out) # ",," where the tag was the only item
out = re.sub(r"\s{2,}", " ", out)
return out.strip(), [ref_list[n - 1] for n in live], dropped
def check_audio_vae_loaded(audio_vae):
"""Catch an UNCONVERTED audio VAE checkpoint.
comfy/ldm/minimax/audio_vae.py loads a checkpoint whose weight-norm has been
folded into plain "*.weight" tensors. Feed it the raw upstream file (172
weight_g/weight_v pairs, no latents_mean/latents_std) and load_state_dict
reports the misses as a WARNING, not an error: every weight-normed conv keeps
its random init and the two normalization buffers stay torch.empty(), i.e.
uninitialized memory. Decoding then multiplies the latents by garbage and the
audio comes out as noise -- with nothing in the log at render time to say why.
latents_std is the cheapest tell: it is a real per-channel scale, so a
non-finite or absurd value means the buffer was never filled."""
m = getattr(audio_vae, "first_stage_model", None)
mean, std = getattr(m, "latents_mean", None), getattr(m, "latents_std", None)
if mean is None or std is None:
return
try:
bad = (not torch.isfinite(mean).all() or not torch.isfinite(std).all()
or float(std.min()) <= 0.0 or float(std.max()) > 1e3
or float(mean.abs().max()) > 1e3)
except Exception:
return # never block a render on a failed introspection
if bad:
raise RuntimeError(
"the audio VAE loaded but its weights are NOT initialized -- this is the raw "
"upstream MiniMax-H3 audio checkpoint (weight_g/weight_v weight-norm pairs, no "
"latents_mean/latents_std). ComfyUI's loader needs the CONVERTED file, with "
"weight-norm folded into plain '*.weight' tensors. Look for the 'Missing VAE keys' "
"warning in the log when the VAE loaded. Download the repackaged H3 audio VAE from "
"the Comfy-Org release; rendering with this one produces noise, not speech.")
def ref_image_canvas(w, h, gen_w, gen_h, mode="match"):
"""Pure: the (width, height) a reference image is encoded at.
'match' scales it (DOWN only, aspect kept) to the generation's pixel area, so a
reference costs about as much as one frame of the shot. 'max' goes to the
reference pipeline's 2048 short edge for the best identity fidelity, which on a
long chain is several times slower because the rows are re-attended every step
of every shot. Never upscales: a small reference stays small."""
w, h = max(1, int(w)), max(1, int(h))
if mode == "max":
scale = min(1.0, REF_IMAGE_SHORT_EDGE / min(w, h))
else:
scale = min(1.0, math.sqrt((int(gen_w) * int(gen_h)) / float(w * h)))
snap = lambda v: max(CANVAS_MULTIPLE, round(v * scale / CANVAS_MULTIPLE) * CANVAS_MULTIPLE)
return snap(w), snap(h)
def shot_latent_cells(w, h, frames, fps):
"""Latent cells in one shot: what sampling VRAM actually scales with.
Not a byte figure -- the constant depends on the quantisation path -- but it is
exactly linear in both shot length and area, so ratios between settings are
right even though the absolute number is not a prediction."""
_, lt, _ = temporal_shape(frames, fps)
return max(1, int(lt)) * max(1, w // 16) * max(1, h // 16)
def model_fingerprint(model):
"""A cheap, stable identity for the loaded DiT: (quant format, layer count,
weight bytes, class name). Changes whenever the checkpoint changes -- a
different quant, a pruned-vs-full build, or a different model entirely -- while
staying identical across shots of the same run. Deliberately avoids hashing
weights, which would cost more than the flush it guards."""
try:
dm = getattr(getattr(model, "model", None), "diffusion_model", None)
fmts, n = {}, 0
if dm is not None and hasattr(dm, "modules"):
for mod in dm.modules():
n += 1
f = getattr(mod, "quant_format", None)
if f:
fmts[f] = fmts.get(f, 0) + 1
top = max(fmts.items(), key=lambda kv: kv[1])[0] if fmts else "none"
size = 0
try:
size = int(model.model_size())
except Exception:
pass
cls = type(dm).__name__ if dm is not None else "unknown"
return (top, n, size, cls)
except Exception:
return None
# --- H3 plumbing, carried over unchanged: these were arrived at against the real
# model and the real VAEs, and none of it is prompt logic.
def _resize(image, width, height, crop):
s = image[..., :3].movedim(-1, 1)
s = comfy.utils.common_upscale(s, width, height, "lanczos", crop)
return s.movedim(1, -1)
def _empty_av_latent(width, height, length, fps, batch_size=1):
fc, lt, at = temporal_shape(length, fps)
video = torch.zeros([batch_size, 24, lt, height // 16, width // 16], device=mm.intermediate_device())
audio = torch.zeros([batch_size, 32, 2, at], device=mm.intermediate_device())
return {"samples": comfy.nested_tensor.NestedTensor((video, audio))}, fc
def _auto_tile_t(n_latent_frames, requested=None):
"""Temporal tile for a tiled decode. An explicit value wins.
The decode_tile_frames widget is gone, so this is where the value comes from
now. It has to come from somewhere: ComfyUI's decode_tiled_3d defaults tile_t
to 999, i.e. SPATIAL tiles only, and expanding the whole clip's time axis at
once is the single largest allocation in a run. A "tiled" decode that keeps the
full temporal extent barely lowers the peak, so the OOM retry that switches
tiling on was, without this, retrying with almost the same footprint."""
if requested:
return int(requested)
n = int(n_latent_frames or 0)
return AUTO_TILE_T if n > AUTO_TILE_T else None
def _decode_video(vae, out_latent, tiled, free_first=None, tile_t=None, tile_xy=None,
keep=()):
"""Decode the video latent.
`free_first` is the diffusion model: sampling is finished, and the video VAE
needs the room for THIS decode -- the free runs immediately before it, not to
make room for the next shot. On a card where the DiT is most of the VRAM, the
decode does not fit until it goes.
`keep` is what must NOT be evicted on the way. It was `keep_loaded=[]`, which
unloaded every resident model -- including the video VAE, which ComfyUI then
reloaded three lines later to run the decode. An evict-and-reload of the thing
about to be used, once per shot, on every card. Peak VRAM is identical either
way, since the VAE has to be resident to decode; the round trip was pure cost.
memory_required is ASKED FOR HONESTLY, which it was not. It was 1e30, and
free_memory computes `memory_to_free = memory_required - get_free_memory(device)`
(model_management.py:887), so 1e30 means "unload everything not in keep_loaded",
every shot, in full -- skipping partially_unload entirely.
What that evicts is the DiT, three lines before the next shot needs it again. On
a machine whose RAM is already full of finished frames there is nowhere for it to
go but disk, so the reload is a read from the drive, once per shot. Reported as
thrashing that slows the preload, and it is exactly that: the same weights being
read back at every boundary.
The VAE knows what its own decode costs -- ComfyUI sizes it with
memory_used_decode and uses that number everywhere else. Asked for that instead,
a card with headroom frees NOTHING and the DiT simply stays. A card without
headroom frees what it needs and no more, which is what partially_unload is for.
1e30 remains the fallback for a VAE that cannot estimate itself."""
latent = out_latent["samples"]
if latent.is_nested:
latent = latent.unbind()[0]
if free_first is not None:
try:
mm.free_memory(_decode_headroom(vae, latent), mm.get_torch_device(),
keep_loaded=_resident(keep or (vae,)))
except Exception:
pass
# A VAE THAT ALREADY TILES DOES NOT NEED TO BE ASKED TO, AND ASKING COSTS 3x.
#
# MiniMaxH3VideoVAE.decode_tiled is, in full:
#
# def decode_tiled(self, z, **kwargs):
# return self.decode(z)
#
# Every tile_t/overlap_t/tile_x/tile_y this function computes is discarded, so
# the tiling the widget promises is not happening here -- the model tiles
# internally either way (256px spatial, 17-frame temporal), which is why
# comfy/sd.py sets handles_tiling on it.
#
# What the detour costs is the OUTPUT BUFFER. comfy's VAE.decode preallocates
# ONE result at vae_output_dtype and hands it to the model as output_buffer=,
# and MiniMaxH3VideoVAE.decode_temporal writes finalized chunks straight into
# it. Going through decode_tiled instead reaches _decode_tiled_owned, which
# calls the model with output_buffer=None -- so decode_temporal allocates its
# own at torch.float32 -- and then makes an fp16 `copy=True` of that. Two
# buffers, the larger of them at double width:
#
# tiled : fp32 2.60GB + fp16 copy 1.30GB = 3.90GB per shot
# decode: one preallocated fp16 = 1.30GB per shot
#
# at 362 frames of 1056x608. Every shot, on the node's own default.
#
# So: when the VAE owns its tiling AND can be written into, the un-tiled call IS
# the tiled one, minus the copies. Anything else keeps the old path -- this is a
# detour around a detour, not a claim that tiling is useless.
_owns_tiling = bool(getattr(vae, "handles_tiling", False) and getattr(
getattr(vae, "first_stage_model", None), "comfy_has_chunked_io", False))
if tiled and _owns_tiling:
imgs = vae.decode(latent)
elif tiled:
# Temporal + spatial tiling. Without tile_t the VAE expands the WHOLE latent
# clip at once, which on a 243-frame 1344x768 shot is the single largest
# allocation in the run -- and on an unpruned checkpoint that is already
# streaming, it is what tips the card over. Decoding in temporal chunks
# trades a little speed for a much lower peak; None keeps ComfyUI's defaults.
args = {}
tile_t = _auto_tile_t(latent.shape[2] if latent.ndim >= 5 else 0, tile_t)
if tile_t:
args["tile_t"] = int(tile_t)
args["overlap_t"] = max(1, int(tile_t) // 8)
if tile_xy:
args["tile_x"] = int(tile_xy)
args["tile_y"] = int(tile_xy)
try:
imgs = vae.decode_tiled(latent, **args) if args else vae.decode_tiled(latent)
except TypeError:
imgs = vae.decode_tiled(latent) # older signature without tile_t
else:
imgs = vae.decode(latent)
if len(imgs.shape) == 5:
imgs = imgs.reshape(-1, imgs.shape[-3], imgs.shape[-2], imgs.shape[-1])
return imgs
def _decode_audio(audio_vae, out_latent):
latent = out_latent["samples"]
if latent.is_nested:
latent = latent.unbind()[-1]
audio = audio_vae.decode(latent).movedim(-1, 1)
std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
std[std < 1.0] = 1.0
audio = audio / std
sr = getattr(audio_vae, "audio_sample_rate_output", getattr(audio_vae, "audio_sample_rate", 44100))
return {"waveform": audio, "sample_rate": sr}
# SYNTHESISING THE BED, from the description the node already read off the scene.
#
# No file to wire and no second model pass. Room tone is physically shaped noise --
# air, rumble, plant, a mains hum -- so it can be built rather than fetched, and
# built noise cannot speak, which is the whole problem with getting ambience out of
# a joint model.
#
# Each recipe is: spectral tilt (0 white, 1 pink, 2 brown), a low-pass corner, an
# optional high-pass, an optional tonal hum with its harmonic, and an optional slow
# amplitude movement. Ordered, first match wins, most specific first.
#
# HONEST LIMIT: this makes TONE, not events. "birdsong", "cutlery and moving chairs"
# and "a monitor somewhere down the corridor" get the ROOM those things are in, not
# the things -- synthesising a convincing bird is not something a noise shaper does,
# and a bad one is worse than the room alone. `info` says when that has happened.
_BED_EVENTFUL = ("birdsong", "cutlery", "monitor somewhere", "corridor beyond")
# Target level for a built bed, before ambient_level scales it. -22 dBFS RMS, so
# the default 0.25 lands near -34 dBFS: present, and well under a spoken line.
_BED_RMS = 0.08
_BED_RECIPE = (
(r"\brain\b", dict(tilt=0.8, cut=9000, hp=250, mod=(0.30, 0.18))),
(r"\bstorm\b|\bthunder", dict(tilt=1.7, cut=700, mod=(0.13, 0.40))),
(r"\bwind\b|\btrees\b", dict(tilt=1.2, cut=2600, mod=(0.18, 0.42))),
(r"\bsea\b|\bocean\b", dict(tilt=1.3, cut=1700, mod=(0.11, 0.50))),
(r"\btraffic\b", dict(tilt=1.7, cut=900, mod=(0.07, 0.22))),
(r"\bengine\b", dict(tilt=1.5, cut=520, hum=(60.0, 0.30),
mod=(0.09, 0.12))),
(r"\bpipes\b|\bwater\b", dict(tilt=1.3, cut=1250, mod=(0.55, 0.45))),
(r"\bclock\b|\bticking", dict(tilt=1.6, cut=800, tick=(1.0, 0.22))),
# The hum family: a fridge, a fan, a strip light, a monitor. Tonal, not noise.
(r"\bhum(?:ming|s)?\b|\bfan\b|\bfridge\b|\bstrip light\b|\bmonitor\b",
dict(tilt=1.4, cut=1500, hum=(100.0, 0.22))),
(r"\btiled\b|\bringing\b", dict(tilt=0.9, cut=6000, hp=180)),
(r"\bhard walls\b|\bgiving the sound back\b", dict(tilt=1.6, cut=950)),
(r"\bopen air\b|\bno walls close\b|\bbirdsong\b", dict(tilt=1.0, cut=7000)),
(r"\bhollow quiet\b|\bhallway\b|\bcorridor\b|\blarge empty room\b|\blong tail\b",
dict(tilt=1.6, cut=700)),
(r"\bcutlery\b|\bchairs\b", dict(tilt=1.1, cut=4500)),
(r"\bsoft room\b|\blittle echo\b", dict(tilt=1.8, cut=520)),
(r"\bnight\b|\bbedroom\b|\bhouse\b|\bquiet\b", dict(tilt=1.9, cut=380)),
)
def bed_recipe(phrase):
"""How to build the bed this phrase describes. The neutral room if none match."""
p = str(phrase or "").lower()
for pat, rec in _BED_RECIPE:
if re.search(pat, p):
return dict(rec)
return dict(tilt=1.8, cut=420)
def synth_ambient(phrase, n, sr, seed=0, channels=2):
"""Build `n` samples of the ambience `phrase` describes. [C, n], or None.
Shaped in the FREQUENCY domain -- white noise, an envelope, back again -- which
gives exact spectral control in one pass and, unlike a per-sample filter, does
not walk a million-sample loop in Python.
Generated at the FULL length of the film, so unlike a wired file there is no
loop and therefore no join to hide.
Defensive like everything else on this path: any failure returns None and the
soundtrack goes out as the model made it."""
try:
n, sr = int(n), int(sr)
if n < 64 or sr <= 0:
return None
rec = bed_recipe(phrase)
g = torch.Generator().manual_seed(int(seed) & 0x7fffffff)
w = torch.randn((int(channels), n), generator=g)
f = torch.fft.rfftfreq(n, d=1.0 / sr).clamp(min=1.0)
# Amplitude goes as f^(-tilt/2), so POWER goes as f^-tilt: tilt 1 is pink,
# 2 is brown. Then a gentle low-pass, and a high-pass where the recipe wants
# the bottom out of it.
env = f.pow(-float(rec.get("tilt", 1.8)) / 2.0)
env = env / (1.0 + (f / float(rec.get("cut", 420))) ** 2)
if rec.get("hp"):
env = env * (f / (f + float(rec["hp"])))
y = torch.fft.irfft(torch.fft.rfft(w, dim=-1) * env, n=n, dim=-1)
t = torch.arange(n, dtype=torch.float32) / sr
# Slow movement, so a bed does not sit perfectly still and read as a hiss.
if rec.get("mod"):
rate, depth = rec["mod"]
y = y * (1.0 + float(depth) * torch.sin(2 * math.pi * float(rate) * t))
# A tonal hum is a TONE, not noise: a fridge and a strip light are pitched.
if rec.get("hum"):
hz, amp = rec["hum"]
hum = (torch.sin(2 * math.pi * float(hz) * t)
+ 0.35 * torch.sin(2 * math.pi * float(hz) * 2 * t))
y = y + float(amp) * hum.unsqueeze(0)
if rec.get("tick"):
rate, amp = rec["tick"]
step = max(1, int(sr / max(float(rate), 0.01)))
click = torch.zeros(n)
idx = torch.arange(0, n, step)
click[idx] = 1.0
decay = torch.exp(-torch.arange(min(step, int(sr * 0.05)),
dtype=torch.float32) / (sr * 0.004))
click = torch.nn.functional.conv1d(
click.view(1, 1, -1), decay.flip(0).view(1, 1, -1),
padding=decay.numel() - 1)[0, 0, :n]
y = y + float(amp) * (click * torch.randn(n, generator=g)).unsqueeze(0)
# NORMALISE BY RMS, NOT PEAK. Peak-normalising made the loudness depend on
# the recipe's crest factor rather than on the setting: measured across the
# beds, a strip-light hum came out at -8.2 dBFS and a ticking clock at
# -34.1, a 26 dB spread from one ambient_level. RMS puts them all at the
# same subjective level, so the widget means the same thing in every room.
rms = float(y.pow(2).mean().sqrt())
if not (rms > 0.0) or not torch.isfinite(y).all():
return None
y = y * (_BED_RMS / rms)
# ...then hold the peak down, because a peaky recipe (the clock) would
# otherwise reach 2.8 at that RMS and clip before the mix even sees it.
peak = float(y.abs().max())
if peak > 0.95:
y = y * (0.95 / peak)
return y
except Exception:
return None # a bed is a nicety, a render is not
# FOLEY: the sounds an action MAKES, built and mixed rather than asked of the model.
#
# auto_sound already reads these out of the beat, but only as TEXT in the prompt --
# and text can never open a shot's audio branch, because an open branch on a joint
# model invents a voice. So a wordless shot staging cuffs going on was pinned to
# silence and the cue was dropped: the one shot whose whole point is a sound made
# none, and the only way to get it was to write the sound into the beat by hand.
#
# Mixing solves that the same way the ambient bed does. A built sound asks nothing
# of the model, so it cannot babble, and it goes into THAT SHOT'S span of the
# soundtrack rather than under the whole film.
#
# HONEST LIMIT, and it is worth stating rather than discovering: this is synthesis,
# not a recording. It reads as a click, a rattle, a rustle -- serviceable and in the
# right place, not a foley stage. Wire a recording to ambient_audio, or write the
# sound into the beat and let the model make it, where that is not enough.
#
# NOTHING VOCAL IS EVER BUILT. Breathing and effort are in the sound table too, and
# they are a VOICE: the one thing this file must not manufacture. They are absent
# from the recipes below on purpose, and a phrase with no recipe is simply skipped.
# A struck object rings at SEVERAL frequencies at once, and they are not a
# harmonic series -- a bar or a shell has inharmonic modes, which is exactly why a
# cuff reads as metal and not as a note. One resonator is one tone colour, and one
# tone colour over a whole train of hits is the sound of a filter rather than the
# sound of a thing.
#
# Ratios are deliberately irrational-ish. Integer multiples would make a pitched
# tone, which is a different and worse kind of fake. Higher modes get less gain and
# a lower Q, because in a real object they are both weaker and more damped.
#
# The upper-mode gains are a MEASURED TRADE, not a guess. Swept against modal
# density (count of spectral peaks) and against how far the cluster drags the
# centroid off what each recipe was tuned to as a single resonator:
#
# gain scale 1.00 0.75 0.60 0.50 0.40 0.30
# modes 478 381 326 284 238 208 (was 200)
# centroid 1.61x 1.52x 1.45x 1.40x 1.35x 1.28x
#
# 0.60 keeps about 1.6x the spectral density of the single resonator while moving
# the centre 1.45x rather than 1.61x. Density is the realism; the centroid shift is
# a change to a character that was already tuned, so it is spent, not maximised.
_MODES = ((1.00, 1.000, 1.00), (1.48, 0.270, 0.75),
(2.13, 0.132, 0.55), (3.31, 0.060, 0.40))
def _band(x, sr, f0, q=4.0, order=3):
"""Resonant filter by spectral envelope: a mode cluster around f0, one pass.
ORDER 3, which was measured. A single resonator's skirt falls off as 1/f, and
against noise -- equal energy per Hz, spread over 20 kHz -- enough survives above
the centre that the result is bright whatever f0 says: footsteps aimed at 130 Hz
came back with a spectral centroid of 3.6 kHz, and every recipe sounded like the
same hiss. Cubing the response is what makes f0 mean something.
The f0/q interface is unchanged, so every recipe gets the mode cluster without
being rewritten -- this is the one place all 21 of them pass through."""
n = int(x.shape[-1])
X = torch.fft.rfft(x)
f = torch.fft.rfftfreq(n, d=1.0 / sr).clamp(min=1.0)
resp = torch.zeros_like(f)
for ratio, gain, qs in _MODES:
fc = float(f0) * ratio
if fc >= sr * 0.45: # past Nyquist is not a mode, it is aliasing
continue
qq = max(0.7, float(q) * qs)
# BANDWIDTH COMPENSATION, and it is not optional. A resonator's absolute
# bandwidth is fc/Q, so a mode an octave up passes twice the noise for the
# same gain -- and these are excited by noise, which has equal energy per
# Hz. Uncompensated, the cluster came out about 2x brighter across every
# recipe and put a footstep at 428 Hz against the 130 it is aimed at, which
# is the "a footstep is a hiss" failure the order-3 skirt was fixed for.
# Energy through a mode goes as gain^2 * fc / Q, so scaling the gain by
# sqrt(Q/fc) makes the numbers above mean the loudness they look like.
g_i = float(gain) * math.sqrt(float(qs) / float(ratio))
# ...and the cluster itself scales with Q, because Q IS how much the thing
# rings. Metal at q 5-8 has strong upper modes; a footstep at q 1.6 is a
# broadband thud on a floor and has almost none. Applied only above the
# fundamental, so a low-Q recipe collapses back to the single resonator it
# was tuned as -- which is what keeps a footstep at 130 Hz a footstep.
if ratio > 1.0:
g_i *= min(1.0, float(q) / 4.0)
resp = resp + g_i * (1.0 / torch.sqrt(
1.0 + (qq * (f / fc - fc / f)) ** 2)) ** int(order)
return torch.fft.irfft(X * resp, n=n)
def _hits(n, sr, g, times, decay, amp=1.0):
"""Decaying noise bursts at the given times (seconds). The excitation for a
click, a rattle, a footfall -- everything percussive here is this plus a band.
EVERY HIT DIFFERS. They used to be identical -- same level, same decay, same
everything -- and thirty-three identical clicks is not a chain, it is a machine.
Nothing gives a synthetic sound away faster: the ear is far better at spotting a
repeat than at judging a timbre, so a rattle whose links are all the same reads
as fake even when each single link sounds right.
Level varies about +/-5 dB and decay by about a third, which is the spread a
real repeated contact has from hitting at a different point and angle."""
x = torch.zeros(n)
for t in times:
i = int(t * sr)
if i < 0 or i >= n:
continue
a = float(amp) * float(torch.exp((torch.rand(1, generator=g) - 0.5) * 1.1))
d = float(decay) * float(1.0 + (torch.rand(1, generator=g) - 0.5) * 0.7)
L = max(4, int(d * sr))
m = min(L, n - i)
env = torch.exp(-torch.arange(m, dtype=torch.float32)
/ max(d * sr / 4.0, 1.0))
x[i:i + m] += torch.randn(m, generator=g) * env * a
return x
def _room(x, sr, secs=0.11, wet=0.16, seed=0):
"""A small room around the sound. Convolution with a decaying noise tail plus
three early reflections.
The dryness was the loudest tell. Every one of these was rendered anechoic --
no reflections, no tail -- and nothing in the physical world sounds like that;
the ear reads a bone-dry impact as "not in a place" before it judges anything
else about it. The tail is rolled off above 2.2 kHz because a real room absorbs
highs faster than lows, and a bright tail is its own kind of wrong.
Linear convolution, not circular: the transform is padded past n + L so a tail
cannot wrap round and appear before the hit that caused it."""
n = int(x.shape[-1])
L = max(8, int(float(secs) * sr))
if n < 8 or not (float(wet) > 0.0):
return x
g = torch.Generator().manual_seed(int(seed) & 0x7fffffff)
t = torch.arange(L, dtype=torch.float32)
ir = torch.randn(L, generator=g) * torch.exp(-t / max(L / 5.0, 1.0))
ir[0] = 0.0
for d, a in ((0.0071, 0.50), (0.0133, 0.34), (0.0211, 0.23)):
i = int(d * sr)
if i < L:
ir[i] += a
m = 1
while m < n + L:
m <<= 1
F = torch.fft.rfftfreq(m, d=1.0 / sr).clamp(min=1.0)
IR = torch.fft.rfft(ir, n=m) / (1.0 + F / 2200.0)
wet_sig = torch.fft.irfft(torch.fft.rfft(x, n=m) * IR, n=m)[:n]
p, q = float(wet_sig.abs().max()), float(x.abs().max())
if not (p > 0.0) or not torch.isfinite(wet_sig).all():
return x
wet_sig = wet_sig * (q / p)
return x * (1.0 - float(wet)) + wet_sig * float(wet)
def _even(start, count, gap, jitter, g):
"""Click times, with a little jitter so a rattle is not a drum machine."""
j = (torch.rand(int(count), generator=g) - 0.5) * 2.0 * float(jitter)
return [float(start + i * gap + j[i]) for i in range(int(count))]
# phrase -> how to build it. `secs` is the shot length, so a rattle runs the shot
# while a ratchet is one event placed a third of the way in.
_FOLEY = {
"cuffs ratcheting closed":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.33, 9, 0.030, 0.004, g),
0.020), sr, 3200, 6.0),
"cuffs knocking":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.25, 4, 0.22, 0.06, g),
0.035), sr, 2600, 5.0),
"chain links dragging":
lambda n, sr, g, secs: _band(_hits(n, sr, g,
_even(0.05, max(4, int(secs * 11)), 0.09, 0.035, g),
0.028), sr, 4200, 7.0),
"restraints pulling taut":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 3, 0.35, 0.10, g),
0.30), sr, 700, 2.5),
"rope creaking as it goes tight":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 4, 0.28, 0.09, g),
0.28), sr, 620, 2.5),
"a lock snapping shut":
lambda n, sr, g, secs: _band(_hits(n, sr, g, [secs * 0.5], 0.045), sr, 2100, 5.0),
"a metal bolt sliding":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.4, 6, 0.035, 0.010, g),
0.030), sr, 1800, 4.0),
"keys on a ring":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 7, 0.055, 0.025, g),
0.030), sr, 5200, 8.0),
"a zip running":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.35, 70, 0.0065, 0.0012, g),
0.006), sr, 4800, 5.0),
"velcro tearing open":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.35, 120, 0.004, 0.0015, g),
0.005), sr, 3000, 1.6),
"tape pulling off":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 90, 0.007, 0.002, g),
0.008), sr, 2400, 2.0),
"fabric rustling":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(0.1, max(3, int(secs * 3)), 0.30,
0.12, g), 0.10), sr, 2800, 1.8),
"blades through fabric":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 5, 0.18, 0.05, g),
0.09), sr, 3600, 2.2),
# A slow rhythm of frame creaks. Low and wooden, and the rate is deliberately
# unhurried: the point is that the room is not silent, not that the shot has a
# metronome in it.
"a bed frame working":
lambda n, sr, g, secs: _band(_hits(n, sr, g,
_even(0.15, max(3, int(secs * 1.6)), 0.62,
0.05, g), 0.16), sr, 240, 3.0),
"a buckle and leather creaking":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 4, 0.20, 0.07, g),
0.12), sr, 1200, 3.0),
"footsteps":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(0.25, max(2, int(secs / 0.55)),
0.55, 0.05, g), 0.10), sr, 130, 1.6),
"something dragging on the floor":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.2, max(6, int(secs * 8)),
0.12, 0.05, g), 0.14), sr, 420, 1.4),
"something landing":
lambda n, sr, g, secs: _band(_hits(n, sr, g, [secs * 0.5], 0.14), sr, 110, 1.5),
"a sharp impact":
lambda n, sr, g, secs: _band(_hits(n, sr, g, [secs * 0.45], 0.07), sr, 900, 1.5),
"a door on its hinges":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(secs * 0.3, 8, 0.09, 0.03, g),
0.13), sr, 780, 6.0),
"water":
lambda n, sr, g, secs: _band(_hits(n, sr, g, _even(0.05, max(8, int(secs * 14)), 0.07,
0.03, g), 0.06), sr, 1400, 1.5),
}
def foley_for(phrase, n, sr, seed=0):
"""Build the sound `phrase` names, `n` samples long. None when there is no
recipe -- which includes every vocal phrase, deliberately."""
try:
n, sr = int(n), int(sr)
make = _FOLEY.get(str(phrase or ""))
if make is None or n < 64 or sr <= 0:
return None
g = torch.Generator().manual_seed(int(seed) & 0x7fffffff)
y = make(n, sr, g, n / float(sr))
# The room goes on LAST and on everything, which is what a room does: it is
# a property of the place, not of the prop. Applied here rather than in the
# recipes so all 21 get it and none can forget it.
y = _room(y, sr, seed=int(seed) + 977)
peak = float(y.abs().max())
if not (peak > 0.0) or not torch.isfinite(y).all():
return None
return y * (0.7 / peak)
except Exception:
return None
def plain_bed(n, sr, seed=0, channels=2):
"""The last-resort bed: noise and a moving average, and nothing else.
synth_ambient is defensive, so it can return None -- and a built bed that comes
back empty leaves the output with no ambience at all. Wiring a file is NOT the
remedy for that: the built bed is the feature, and a file is only ever an
override for a real location. So there is a floor under it.
Deliberately primitive. No FFT, no envelope, no recipe -- a cumulative-sum box
filter over white noise, which is a rumble, and which cannot fail on any input
the caller can hand it. It is not as good as the shaped bed and does not try to
be; it is the difference between a quiet room and nothing at all."""
try:
n, sr, channels = int(n), int(sr), max(1, int(channels))
if n < 8 or sr <= 0:
return None
g = torch.Generator().manual_seed(int(seed) & 0x7fffffff)
y = torch.randn((channels, n), generator=g)
# Box filter by cumulative sum: out[i] = mean(w[i-k:i]). k sets the corner.
#
# CASCADED THREE TIMES, which was measured rather than assumed. One pass is
# a sinc, whose first sidelobe is only -13 dB -- against white noise, which
# has equal energy per Hz, enough leaks through the whole top of the band to
# put the spectral centroid at 3.3 kHz. That is a hiss, not the rumble this
# is meant to be. Three passes is sinc^3, and the centroid lands where the
# description says.
k = max(2, min(n // 4, int(sr / 200))) # ~200 Hz
for _ in range(3):
c = torch.cumsum(torch.nn.functional.pad(y, (k, 0)), dim=-1)
y = (c[..., k:] - c[..., :-k])[..., :n] / float(k)
rms = float(y.pow(2).mean().sqrt())
if not (rms > 0.0) or not torch.isfinite(y).all():
return None
y = y * (_BED_RMS / rms)
peak = float(y.abs().max())
return y * (0.95 / peak) if peak > 0.95 else y
except Exception:
return None
def _seamless_loop(x, n, sr):
"""[C, M] -> [C, n], looped with a crossfade so the join does not click.
Plain tiling puts a discontinuity at every repeat, once per loop length. In a
bed that is meant to sit under everything unnoticed, a regular click is the one
thing that gets noticed -- the same objection that made the silence latent
ping-pong its interior rather than tile it. Here the material is real audio
being PLAYED rather than a latent being conditioned on, so it cannot be
reversed: a room tone read backwards is fine, but footsteps are not. Crossfade
instead, which works on both."""
m = int(x.shape[-1])
if m <= 0:
return None
if m >= n:
return x[..., :n]
fade = min(int(0.25 * sr), m // 4)
if fade < 1:
reps = -(-n // m)
return x.repeat(1, reps)[..., :n]
# OVERLAP-ADD the tail onto the head, and shorten the unit by the overlap. The
# unit then runs x[m-fade] .. x[m-fade-1], so tiling it steps between samples
# that were adjacent in the source and there is no discontinuity anywhere.
#
# Measured, because the obvious construction is wrong: appending the crossfade
# to the END of a full-length unit leaves it finishing on x[fade-1] while the
# next repeat starts on x[0], which are not adjacent -- a 2s tone that does not
# divide evenly gave a 64x jump at the join, worse than plain tiling's 41x.
t = torch.linspace(0.0, 1.0, fade, dtype=x.dtype, device=x.device)
head = x[..., :fade] * t + x[..., m - fade:] * (1.0 - t)
unit = torch.cat([head, x[..., fade:m - fade]], dim=-1)
if int(unit.shape[-1]) < 1:
reps = -(-n // m)
return x.repeat(1, reps)[..., :n]
reps = -(-n // int(unit.shape[-1]))
return unit.repeat(1, reps)[..., :n]
def mix_ambient(audio, sr, bed, level):
"""Lay an ambient bed UNDER a finished soundtrack. -> (waveform, note).
The bed is PLAYED, not conditioned on: it is the file, at the level asked for,
under whatever the model generated. That is the whole reason to do it here
rather than in the sampler -- ambience needs no cooperation from a joint model,
has nothing to lip-sync to, and so cannot put a voice in a wordless shot. The
conditioning path can only steer the branch toward something bed-LIKE, and on a
shot with a line it competes with the line.
Defensive throughout, like the silence latent: any failure returns the audio
untouched with a note saying so, because a bed is a nicety and a render is not.
"""
try:
if audio is None or bed is None or float(level or 0.0) <= 0.0:
return audio, ""
w = bed.get("waveform") if isinstance(bed, dict) else None
if w is None or not int(getattr(w, "ndim", 0)):
return audio, ("ambient_audio is wired but carries no waveform, so nothing "
"was laid under the soundtrack")
w = w[0] if w.dim() == 3 else w # [B, C, M] -> [C, M]
if w.dim() != 2 or w.shape[-1] < 2:
return audio, ("ambient_audio is too short to loop, so nothing was laid "
"under the soundtrack")
w = w.detach().to(dtype=audio.dtype, device=audio.device)
b_sr = int((bed.get("sample_rate") if isinstance(bed, dict) else 0) or 0)
# RESAMPLE, or the bed plays at the wrong speed and pitch. Linear is coarse
# for music and inaudible on a room tone, which is what this input is for.
resampled = ""
if b_sr > 0 and b_sr != int(sr):
want = max(2, int(round(w.shape[-1] * float(sr) / float(b_sr))))
w = torch.nn.functional.interpolate(
w.unsqueeze(0), size=want, mode="linear", align_corners=False)[0]
resampled = f", resampled from {b_sr} Hz"
ch = int(audio.shape[1])
if int(w.shape[0]) != ch:
w = (w.mean(dim=0, keepdim=True).repeat(ch, 1) if int(w.shape[0]) > ch
else w[:1].repeat(ch, 1))
n = int(audio.shape[-1])
loop = _seamless_loop(w, n, int(sr))
if loop is None:
return audio, ""
out = audio + loop.unsqueeze(0) * float(level)
# NORMALISE rather than clip. Clipping a bed that pushed a loud line over
# the top distorts the LINE, which is the thing worth keeping.
peak = float(out.abs().max())
gain = ""
if peak > 1.0:
out = out / peak
gain = f", and the mix was scaled by {1.0 / peak:.2f} to stop it clipping"
secs = w.shape[-1] / float(sr)
return out, (f"an ambient bed was laid under the whole soundtrack at level "
f"{float(level):.2f} -- {secs:.1f}s of audio{resampled}, looped "
f"with a crossfade so the join does not click{gain}. It is your "
f"file, played under what the model generated: it conditions "
f"nothing, so it cannot put a voice in a wordless shot the way "
f"an inferred bed did. Shots pinned to silence keep their silent "
f"conditioning and get the bed on top, which is what makes a "
f"wordless shot sound like a room instead of a mute")
except Exception as exc:
return audio, (f"the ambient bed could not be mixed ({type(exc).__name__}), so "
f"the soundtrack is unchanged")
def _is_oom(e):
return isinstance(e, torch.cuda.OutOfMemoryError) or "out of memory" in str(e).lower()
def _deep_cleanup():
"""Release VRAM + RAM between shots so a long chain doesn't accumulate and OOM.
Runs a Python GC pass (frees dereferenced tensors / CPU buffers), then empties
the CUDA allocator's cached blocks and IPC handles. Cheap relative to sampling;
called once per beat.
It unloads NOTHING. soft_empty_cache(force) ignores `force` in current ComfyUI
(model_management.py:2050) -- the body only reaches empty_cache() and
ipc_collect() -- so this drops cached blocks, not models. The `True` is kept
only for older builds that read it; the older comment here claimed this took an
unload_all_models path, and it does not."""
gc.collect()
try:
mm.soft_empty_cache(True)
except TypeError:
mm.soft_empty_cache()
try:
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
except Exception:
pass
DECODE_HEADROOM = 1.25 # over ComfyUI's own estimate, for working allocations
SAMPLE_HEADROOM = 1.35 # likewise for sampling, which is the longer stretch
def _decode_headroom(vae, latent):
"""VRAM this decode actually needs, by the VAE's own estimate. 1e30 if unknown.
ComfyUI sizes every VAE with memory_used_decode and uses that number itself, so
it is the honest figure to hand free_memory. The alternative -- and what was here
-- is 1e30, which means "unload everything" and evicts the DiT before every
decode, three lines before the next shot reloads it.
1e30 on failure rather than 0: a bad estimate that frees too little turns a slow
render into an OOM, and a wrong guess should fall back to the behaviour that has
been running, not to no freeing at all."""
try:
dtype = getattr(vae, "vae_dtype", None) or latent.dtype
need = float(vae.memory_used_decode(tuple(latent.shape), dtype))
if need > 0:
return need * DECODE_HEADROOM
except Exception:
pass
return 1e30
def _resident(models):
"""The LoadedModel entries ComfyUI currently holds for `models`.
That is the form free_memory's keep_loaded wants: it compares against the
entries in current_loaded_models, not against the ModelPatcher objects a node
is holding. Anything not matched is simply not kept, so a model that is not
resident costs nothing here."""
out = []
for lm in list(getattr(mm, "current_loaded_models", [])):
for m in models or ():
if m is None:
continue
try:
if lm.model is m or getattr(lm, "model", None) is getattr(m, "model", None):
if lm not in out:
out.append(lm)
except Exception:
pass
return out
def _image_out_dtype():
"""The dtype ComfyUI itself hands between nodes on THIS install.
The join used to end in a hard-coded .float(), commented "back to what every
downstream node expects". That was true when it was written and is not a
constant: ComfyUI has --fp16-intermediates, and on an install running it the
VAE's own decode already returns fp16 -- VAE.vae_output_dtype() IS
model_management.intermediate_dtype() (comfy/sd.py) -- as do EmptyLatentImage
and the rest of nodes.py. So on that install the node was taking frames the
VAE handed it in fp16, widening them to fp32 nothing had asked for, and
handing them to nodes whose own convention is fp16.
It is the largest thing this node holds, so the widening is not free: the
2580-frame chain costed at the join is 9.3GB as fp16 and 18.5GB as fp32,
against 44.6GB of staged weights on a 62GB machine -- which is the difference
between the render finishing and the OOM killer taking the server. Reported as
exactly that, twice.
Asked, not assumed, and never widened: whatever ComfyUI says it wants between
nodes is what the chain is built in. An install with the flag off is told
float32 and gets float32, byte for byte what it got before. Older builds have
no intermediate_dtype at all, so the fallback is the old constant."""
try:
return mm.intermediate_dtype()
except Exception:
return torch.float32
def _evict_all_but(keep_model, latent=None):
"""Unload every model EXCEPT the diffusion model from the GPU.
This is the fix for VRAM ratcheting across a long chain. soft_empty_cache()
only drops the CUDA allocator's cached blocks -- it does NOT unload models, so
ComfyUI keeps the Qwen3-VL text encoder (~14.6GB) and both VAEs resident in
current_loaded_models alongside the DiT. Each shot re-encodes the prompt
(text encoder), encodes the handoff keyframe (video VAE), then samples (DiT),
so all three compete for the card.
ComfyUI does free ahead of each load -- load_models_gpu() calls free_memory()
for what it is about to need (model_management.py:975), so the weight path is
not purely reactive. What it cannot size for is a long chain's ACTIVATIONS on
a card where the DiT is most of the VRAM. Freeing explicitly, right after
conditioning is built and before sampling, keeps only what the sampler needs.
ASKED FOR HONESTLY, and this is the expensive one. free_memory computes
`memory_to_free = memory_required - get_free_memory(device)`, so 1e30 meant
"unload everything but the DiT" on every shot, unconditionally -- on a 48GB card
with room for all of it as readily as on a 16GB one. What it unloads is the
~14.6GB text encoder and both VAEs, and the next shot re-encodes the prompt and
the handoff keyframe, so all three come straight back. On a machine whose RAM is
already full of finished frames they come back from DISK, once per shot, which is
the thrashing this was reported as.
The DiT can size its own activations -- memory_required(shape) is what ComfyUI
itself calls before a load -- so ask for that. A card with room frees nothing and
keeps the encoder resident; a card without frees exactly as much as it must.
1e30 stays the fallback, because a bad estimate that frees too little turns a
slow render into an OOM."""
need = 1e30
try:
if latent is not None:
shape = latent["samples"].shape if isinstance(latent, dict) else latent.shape
need = float(keep_model.model.memory_required(tuple(shape))) * SAMPLE_HEADROOM
if not (need > 0):
need = 1e30
except Exception:
need = 1e30
try:
mm.free_memory(need, mm.get_torch_device(),
keep_loaded=_resident([keep_model]))
except Exception:
try:
mm.soft_empty_cache(True)
except Exception:
pass
def check_vae_wiring(vae, audio_vae):
"""Catch the commonest miswire -- the video VAE dropped into BOTH VAE inputs.
Without this the run samples a whole shot, decodes the video fine, then dies
deep inside comfy/sd.py with 'IndexError: tuple index out of range' when the
video memory estimator indexes shape[4] of the 4-D audio latent."""
if _is_audio_vae(audio_vae) is False:
raise RuntimeError(
"audio_vae is a video/image VAE, not the H3 audio VAE. Load the audio "
"autoencoder (the DAC/BigVGAN one shipped with MiniMax-H3, e.g. "
"minimax_h3_audio_vae.safetensors) in its own VAELoader and wire that "
"into 'audio_vae'; the video VAE belongs on 'vae' only.")
check_audio_vae_loaded(audio_vae)
if _is_audio_vae(vae) is True:
raise RuntimeError(
"vae is the H3 audio VAE -- the video and audio VAE inputs are swapped. "
"Wire the video VAE into 'vae' and the audio VAE into 'audio_vae'.")
def flush_for_model_change(model):
"""Detect a checkpoint swap since the last run and, if one happened, hard-flush
GPU state before doing anything else.
Why this matters: ComfyUI keeps previously-loaded models in current_loaded_models
and only evicts reactively. Swapping checkpoints mid-session (e.g. NVFP4 -> FP8 ->
MXFP8 while comparing quality) leaves the OLD DiT resident alongside the new one,
plus any hooks/injections a previous LoRA installed and stale cached allocator
blocks sized for the old model's layers. The result is a card that is already
half full before the first shot samples -- which looks exactly like the node
over-spilling, when in fact the budget was computed against memory the previous
checkpoint never released.
Returns a note for `info` when a change was detected (empty string otherwise)."""
fp = model_fingerprint(model)
prev = _LAST_MODEL_FP.get("fp")
_LAST_MODEL_FP["fp"] = fp
if prev is None or fp is None or prev == fp:
return ""
try:
mm.unload_all_models() # drop every resident model, not just the cache
except Exception:
pass
# Never let a cleanup failure abort the run: the flush is best-effort hygiene,
# and a partially-flushed card is still better than raising here.
for _ in range(2): # 2nd pass frees blocks released by the 1st
try:
_deep_cleanup()
except Exception:
pass
old_fmt, _n, old_sz, _c = prev
new_fmt = fp[0]
return (f"model changed since last run ({old_fmt} ~{old_sz / GB:.1f}GB -> {new_fmt} "
f"~{fp[2] / GB:.1f}GB): flushed all resident models and VRAM caches")
# Whether the silence conditioning ACTUALLY went on, per run. _silent_audio_latent
# is defensive by design -- every failure returns None so a render never dies for a
# nicety -- but the info note reported the silence_nonspeech FLAG, not the result.
# A shot whose latent could not be built was described as "conditioned on real
# silence" while its audio branch was wide open, which is a shot that babbles with
# no scripted line and nothing in the report saying why. Counted here so the note
# can say what happened instead of what was asked for.
_SILENCE_STATUS = {"asked": 0, "applied": 0, "why": ""}
# How much silence to encode, and how much of each end to throw away. The encoder
# pads at the edges, so the first and last few latent frames carry an artifact that
# is not silence: measured on the H3 audio VAE, the frame-to-frame delta runs 0.224
# at the first join and 0.172 at the last against 0.002 in the interior. Four
# frames off each end clears it with room to spare.
_SILENT_SECONDS = 2
_SILENT_EDGE = 4
def _silent_audio_latent(audio_vae, frame_count, fps):
"""A keyframe audio latent of actual SILENCE, or None if it cannot be made.
H3 is a JOINT model: the mouth follows the audio branch. On a shot with no
scripted line the branch is otherwise unconditioned, and an unconditioned audio
branch invents a voice -- which the picture then lip-syncs to. The lips-closed
sentence is arguing with a stream that has already decided someone is talking.
REBUILT 2026-09-05, from measurements against the real VAE rather than from
reasoning. The previous version encoded one second, kept a SINGLE interior
frame and repeated it, on the argument that silence is homogeneous. It is not,
in latent space: encoded silence has genuine frame-to-frame variation (delta
mean 0.002-0.004, max 0.021), and a repeated frame has a delta of exactly
0.000000. That is a flat signal no encoder produces, and a model handed
conditioning outside its own distribution has every reason to disregard it --
which is an audio branch back to inventing a voice, with the report saying
silence went on.
The fix that version was avoiding is real too: tiling the whole encoded second
end to end leaves a 25x spike at each join (0.554 against 0.022), once per
second, which is a metronome in the conditioning of a joint model.
So: encode two seconds, drop the padded ends, and PING-PONG the interior --
forward, reversed, forward. Every join repeats a frame, so there is no seam,
and the interior statistics are the encoder's own. Measured over a 9s shot:
one frame repeated peak 0.000686 delta mean 0.000000 max 0.000000
whole 2s tiled peak 0.000314 delta mean 0.017451 max 0.554715
interior ping-pong peak 0.000566 delta mean 0.002039 max 0.021159
where the encoder's own interior is mean 0.0021, max 0.0212. Decoded peak
0.000566 on a +/-1.0 scale is about -65 dBFS: silence.
Everything here stays defensive. Shapes are CHECKED against what the layout
expects rather than assumed, and any failure returns None so the shot falls
back to an unconditioned branch instead of breaking the render -- the caller
reports when that happens, so it is no longer a silent failure.
"""
try:
sr = int(getattr(audio_vae, "audio_sample_rate", 0) or 0)
if sr <= 0:
return None
_, _, want_t = temporal_shape(frame_count, fps)
if want_t <= 0:
return None
block = _SILENT_UNIT.get("lat")
if block is None:
# CHANNELS LAST. comfy.sd.VAE.encode() does `pixel_samples.movedim(-1, 1)`
# before handing off, so the audio VAE -- which wants [B, 2, L] -- must be
# given [B, L, 2]. Passing [B, 2, L] raises inside the encoder, and an
# early version did exactly that: swallowed by the guard below, so the
# whole layer silently did nothing.
#
# Two seconds, encoded ONCE and cached. Encoding a full 15s shot instead
# cost a VAE pass big enough to OOM mid-render on a 16GB card, where the
# failure again degraded silently to no conditioning at all.
enc = audio_vae.encode(torch.zeros((1, sr * _SILENT_SECONDS, 2)))
if enc is None or enc.dim() != 4 or enc.shape[1] != 32:
return None
if enc.shape[-1] <= 2 * _SILENT_EDGE + 1:
return None
block = enc[..., _SILENT_EDGE:-_SILENT_EDGE].detach().to("cpu").clone()
_SILENT_UNIT["lat"] = block
n = block.shape[-1]
if n < 1:
return None
# Forward, reversed, forward... Each join repeats a frame, so the seam that
# plain tiling leaves is gone while the interior variation is the encoder's.
pieces, have, i = [], 0, 0
while have < want_t:
piece = block if i % 2 == 0 else torch.flip(block, dims=[-1])
pieces.append(piece)
have += n
i += 1
out = torch.cat(pieces, dim=-1)[..., :want_t].clone()
if out.shape[-1] != want_t:
return None
return out
except Exception:
return None # never fail a render for a nicety
_POSTURE = re.compile(
r"\b(?:lying|laying|lies|lays|kneel(?:s|ing)?|knelt|sit(?:s|ting)?|sat|"
r"crouch(?:es|ing|ed)?|curled|sprawled|slumped|face[- ]?down|face[- ]?up|"
r"on (?:her|his|their) (?:side|back|front|knees|stomach|belly))\b", re.I)
def posture_note(scene, has_first_frame):
"""Warn when shot 1's opening pose is left to the text alone.
Shot 1 is the only shot with no keyframe -- there is no previous frame to
continue from -- so its opening pose comes from the text and from nothing else.
A posture sentence sitting at the end of a long sheet is the least-weighted
thing the model reads, and text cannot outrank a picture anyway. This does not
reorder anything: the node sends what you wrote, in the order you wrote it."""
if has_first_frame or not (scene or "").strip():
return ""
sents = [s for s in re.split(r"(?<=[.!?])\s+", scene.strip()) if s.strip()]
where = [i for i, s in enumerate(sents) if _POSTURE.search(s)]
if not where:
return ""
return (f"shot 1 has no keyframe, so its opening pose comes from the text alone -- "
f"and the sentence describing the pose is {where[0] + 1} of {len(sents)}. "
f"first_frame pins it, but it pins the WHOLE opening frame, so it has to be "
f"a composed frame of the shot you want: a head-and-shoulders picture wired "
f"there makes the first frame a head-and-shoulders picture. An identity "
f"portrait belongs on ref_image_1 instead")
def reference_note(n_refs, aug, has_first_frame):
"""What a near-clean reference actually asks the model to do.
ONE aug covers every visual conditioning row. At H3's default of 0.999 a
reference is handed over essentially noise-free, and a noise-free image is an
invitation to REPRODUCE it -- its framing and background along with its subject.
That is a matter of DEGREE, not a format error, and this is the dial for it: the
symptom is a shot that opens on the reference and moves off it, and the answer is
to lower the aug until it informs the face without being copied.
Shot 1 is where it shows most, because it has no keyframe pinning its opening
frame -- the reference is the only picture it has, so there is nothing competing
with the invitation to reproduce."""
if not n_refs or aug is None:
return ""
if float(aug) >= KEYFRAME_SAFE_AUG:
note = (f"{n_refs} reference image(s) at ref_noise_aug {float(aug):.3f}, which is "
f"near-clean -- that asks the model to REPRODUCE them, framing and "
f"background included, in the opening frames. Lower it to say "
f"approximate: try 0.95, then 0.90. Below 0.99 the handoff stops being "
f"a keyframe and rides as an extra reference, so continuity weakens as "
f"identity strengthens")
else:
note = (f"{n_refs} reference image(s) at ref_noise_aug {float(aug):.3f} -- "
f"softened, so they inform the face rather than being copied. Below "
f"0.99 one aug would also soften the keyframe, so the handoff rides as "
f"an extra reference instead of anchoring: weaker continuity, nothing "
f"pretending to anchor while carrying noise")
if not has_first_frame:
note += (". Shot 1 has no keyframe, so the reference is its only picture and "
"nothing competes with reproducing it -- that shot is where a "
"near-clean reference shows up as the opening frame")
return note
def frame_detail(img):
"""(detail, contrast) for one frame in 0..1, HWC.
Detail is mean absolute neighbour difference -- a cheap stand-in for how much
fine structure survives. Contrast is the luminance spread. Neither is an
absolute measure of anything; what matters is the TREND across shots.
Every shot boundary decodes a latent to pixels, takes the last frame and
re-encodes it as the next shot's keyframe. That round trip is lossy, and the
frame it runs on is the model's own output, so shot 11 is sampled from a
picture that has been through ten decode/encode cycles. Softening that
compounds is invisible shot to shot and obvious end to end -- so measure it."""
x = img.float()
if x.dim() == 3 and x.shape[-1] >= 3:
x = x[..., :3].mean(dim=-1)
elif x.dim() == 3:
x = x[..., 0]
if x.dim() != 2 or x.shape[0] < 2 or x.shape[1] < 2:
return 0.0, 0.0
gx = (x[:, 1:] - x[:, :-1]).abs().mean()
gy = (x[1:, :] - x[:-1, :]).abs().mean()
return float((gx + gy) * 0.5), float(x.std())
def detail_report(per_shot):
"""One line saying whether the chain is softening, and by how much.
per_shot is [(detail, contrast), ...] measured on each shot's last frame."""
vals = [d for d, _ in per_shot if d > 0]
if len(vals) < 2:
return ""
first, last = vals[0], vals[-1]
drop = (first - last) / first * 100.0 if first else 0.0
trend = " ".join(f"{d:.4f}" for d, _ in per_shot)
line = f"detail per shot (last frame): {trend}"
if drop >= 10.0:
line += (f" -- DOWN {drop:.0f}% from shot 1 to shot {len(vals)}. Each boundary "
f"decodes a shot, takes its LAST frame and re-encodes it as the next "
f"shot's keyframe, so the loss of one round trip is carried into the "
f"next and compounds. Break the chain to stop it accumulating: "
f"restart_after_removal starts a shot from the text instead of the "
f"previous frame, at the cost of a visible cut there")
elif drop <= -10.0:
line += f" -- UP {-drop:.0f}%, so the chain is not softening"
else:
line += f" -- flat within {abs(drop):.0f}%"
return line
def _keyframe_latent(vae, hand_img):
"""The keyframe latent for this shot: an ENCODE of the previous shot's last frame.
This was briefly an optimisation -- pass the previous shot's own latent straight
through and skip a VAE round trip per boundary. It was wrong, and it degraded
every shot after the first.
A keyframe is ONE pixel frame, and H3's grid puts that at 5f -> TWO latent
frames. Slicing [:, :, -1:] off a finished shot hands over one. Worse, the video
VAE is causal: the last latent of a 72-frame sequence encodes its temporal
context, not a standalone opening frame, so even at the right count it does not
mean what a keyframe means. The spatial-size guard could not see either problem.
The round trip is real but it is one lossy step on a correctly formed anchor,
which beats a cheap malformed one."""
return vae.encode(hand_img)
def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
"""(tokenizer items, DiT blocks) for a list of reference IMAGE tensors.
The tokenizer labels each one `<Picture N>:` itself, in the order given here --
so the roster the prompt refers to is decided by input order, not by anything
written in the prompt."""
items, blocks = [], []
for img in images:
if img is None:
continue
h, w = int(img.shape[1]), int(img.shape[2])
tw, th = ref_image_canvas(w, h, gen_w, gen_h, mode)
resized = _resize(img[:1], tw, th, "disabled")
items.append({"type": "image", "data": resized})
blocks.append({"kind": "image", "latent_h": th // 16, "latent_w": tw // 16,
"latent": vae.encode(resized)})
return items, blocks
def _sample_on_sigmas(model, seed, cfg, sampler_name, positive, negative, latent, sigmas):
"""common_ksampler, driven by an EXTERNAL sigma schedule.
common_ksampler derives its sigmas from (sampler_name, scheduler, steps, denoise)
and takes no schedule argument, so a schedule computed anywhere else cannot
reach it. Under PDD that is fatal rather than merely inconvenient: the heads
accept only their nine trained boundaries, and re-deriving the grid from
widgets means hitting it by coincidence and losing it again the moment a step
count changes.
Mirrors nodes.common_ksampler's noise / mask / callback handling exactly -- the
only substitution is comfy.sample.sample_custom for comfy.sample.sample."""
latent_image = latent["samples"]
latent_image = comfy.sample.fix_empty_latent_channels(
model, latent_image,
latent.get("downscale_ratio_spacial", None),
latent.get("downscale_ratio_temporal", None))
noise = comfy.sample.prepare_noise(latent_image, seed, latent.get("batch_index"))
# `steps` here only sizes the progress bar -- the schedule is `sigmas`, whose
# step count is one less than its length (the trailing 0.0 is an endpoint).
callback = latent_preview.prepare_callback(model, max(len(sigmas) - 1, 1))
samples = comfy.sample.sample_custom(
model, noise, cfg, comfy.samplers.sampler_object(sampler_name), sigmas,
positive, negative, latent_image,
noise_mask=latent.get("noise_mask"), callback=callback,
disable_pbar=not comfy.utils.PROGRESS_BAR_ENABLED, seed=seed)
out = latent.copy()
out.pop("downscale_ratio_spacial", None)
out.pop("downscale_ratio_temporal", None)
out["samples"] = samples
return out
def _find_h3_sampling_node():
"""Locate the H3 sigma-shift node under ANY registered name. It was renamed
to 'ModelSamplingMiniMaxH3' in a later patch (kijai PR #15243); older 0.30.x
builds register it under a different id, so exact-key lookup misses it. Try
the known names, then fuzzy-scan all node mappings for the H3 model-sampling
node. Returns (class, key) or (None, None)."""
maps = getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}
for key in ("ModelSamplingMiniMaxH3", "ModelSamplingMinimaxH3", "ModelSamplingMinimax", "ModelSamplingH3"):
if key in maps:
return maps[key], key
for k, v in maps.items():
kl = k.lower()
if "sampl" in kl and (("minimax" in kl and "h3" in kl) or ("h3" in kl and "shift" in kl)):
return v, k
for k, v in maps.items():
kl = k.lower()
if ("minimax" in kl or "h3" in kl) and ("shift" in kl or "sampling" in kl):
return v, k
return None, None
def _direct_model_sampling(model, shift_video, shift_audio):
"""Fallback that sets the shift on the model's own model_sampling object
without any node -- version-tolerant and V3-proof, since it uses model-level
APIs (get_model_object / set_parameters / add_object_patch) rather than
calling a node. Copies the sampling object so the base model isn't mutated,
and applies audio_shift only if the installed set_parameters accepts it."""
import inspect, copy
m = model.clone()
# deepcopy, not copy: model_sampling is an nn.Module, and a SHALLOW copy shares
# its `_buffers` dict with the original. set_parameters() re-registers `sigmas`
# into that shared dict, so a shallow copy silently rewrites the BASE model's
# sigma table -- the very thing this copy exists to prevent. Our own run reads
# the patched object either way, but ComfyUI caches the model across queue
# runs, so the damage outlives this execution and reaches anything else holding
# that model. The buffer is ~1000 floats; the deepcopy is free.
ms = copy.deepcopy(m.get_model_object("model_sampling"))
sig = inspect.signature(ms.set_parameters)
kwargs = {}
if "shift" in sig.parameters:
kwargs["shift"] = float(shift_video)
if "audio_shift" in sig.parameters:
# NOTE: on ComfyUI 0.31 the audio latent is carried on the video schedule
# scaled by audio_scale = shift_video / shift_audio (12/3 = 4.0), applied in
# process_latent_in and undone in process_latent_out. Forcing that ratio to
# 1.0 (audio_shift == shift_video) as a "legacy 0.30" emulation produces
# SILENT output -- the model needs the scaling -- so it is not offered.
kwargs["audio_shift"] = float(shift_audio)
if not kwargs:
raise RuntimeError("set_parameters takes no shift")
ms.set_parameters(**kwargs)
m.add_object_patch("model_sampling", ms)
return m
def last_audio_sigma(steps, shift_audio, scheduler="simple", shift_video=None):
"""How much audio noise is still left going into the FINAL sampling step.
The audio branch runs on its own shifted timeline: time_shift_sigma inverts the
video shift and re-applies the audio one, so what reaches the last step depends
on the STEP COUNT and shift_audio -- and not at all on shift_video, which is the
dial everybody reaches for.
The base grid's last position before zero is 1/steps, so
sigma_audio(last) = shift_audio / (steps + shift_audio - 1)
At the 8 steps this node defaults to, shift_audio 3.0 leaves 0.30. At the 4 a
distilled LoRA wants, the same 3.0 leaves 0.50 -- half of the audio denoising
crammed into one step, and an audio branch resolving half its noise in a single
jump is one that invents whatever is easiest. Reported as babble starting at
step 3 of 4, which is that step.
"""
try:
n = max(1, int(steps))
a = float(shift_audio)
except (TypeError, ValueError):
return 0.0
# THE SCHEDULER DECIDES THIS, and the closed form agrees with exactly one of them.
#
# comfy/ldm/minimax/model.py:569 derives the audio sigma from the VIDEO sigma --
# sigma_a = time_shift_sigma(sigma_v, shift_v, shift_a) -- so what reaches the
# last step is whatever ladder the SCHEDULER produced, re-shifted. The formula
# below reproduces that only for `simple`. Measured, 5 steps, shift_audio 3.0:
#
# simple 0.4286 formula agrees
# beta 0.2981 formula is 44% high
# kl_optimal 0.0030 formula is 143x high
#
# The note this feeds fires above 0.40 and told the reader "only the step count
# and shift_audio matter". On kl_optimal that warned about babble the scheduler
# had already removed, and sent them to lower shift_audio -- a dial that cannot
# reach 0.003 at any legal value -- when one dropdown does it.
v = float(shift_video) if shift_video else _WIDGET_RANGE["shift_video"][0]
try:
import comfy.samplers as _cs
import comfy.model_sampling as _cms
_calc = getattr(_cs, "calculate_sigmas", None)
if _calc is not None:
_ms = _cms.ModelSamplingDiscreteFlow()
_ms.set_parameters(shift=v)
_sig = [float(x) for x in _calc(_ms, str(scheduler), n)]
_last = next((x for x in reversed(_sig) if x > 0.0), 0.0)
# invert to the base grid at shift_video, re-apply shift_audio
_base = _last / (v + _last * (1.0 - v))
return a * _base / (1.0 + (a - 1.0) * _base)
except Exception:
# No real ComfyUI (tests stub it), or a scheduler this install lacks. The
# closed form is exact for `simple`, which is the shipped default.
pass
return a / (n + a - 1.0) if (n + a - 1.0) > 0 else 0.0
def scheduler_that_finishes_audio(steps, shift_audio, shift_video=None,
current="simple", target=0.10):
"""The shipped scheduler that leaves the LEAST audio noise on the last step.
Returns (name, sigma) when a different one would get under `target` and beat
what is selected, else None. Named rather than silently switched: the schedule
shape changes the picture too, and that is the reader's call to make."""
try:
import comfy.samplers as _cs
names = list(getattr(_cs.KSampler, "SCHEDULERS", []) or [])
except Exception:
return None
now = last_audio_sigma(steps, shift_audio, current, shift_video)
best, best_s = None, now
for nm in names:
if nm == current:
continue
try:
sg = last_audio_sigma(steps, shift_audio, nm, shift_video)
except Exception:
continue
if sg > 0.0 and sg < best_s:
best, best_s = nm, sg
return (best, best_s) if (best is not None and best_s <= target) else None
# What shift_audio 3.0 leaves on the last step at the 8 this node defaults to.
DEFAULT_LAST_AUDIO_SIGMA = 0.30
def shift_audio_for(steps, target=None):
"""The shift_audio that reproduces a chosen last-step sigma at THIS step count.
Inverting sigma = a / (steps + a - 1):
a = sigma * (steps - 1) / (1 - sigma)
The DIRECTION matters more than the arithmetic. sigma rises monotonically with
shift_audio -- d/da = (steps - 1) / (steps + a - 1)**2, positive for every step
count above one -- so fewer steps need a SMALLER shift_audio, not a larger one.
The note this feeds scaled the other way: 3.0 * 8 / steps, which at the 4 steps
a distilled LoRA wants advised 6.0 and took the last step from 0.50 to 0.67.
That is the babble dial turned the wrong way, printed on the one report that
only fires when somebody is already hearing babble. Nothing caught it because
the tests covered last_audio_sigma, which was right, and not the advice.
Clamped to the widget's own range so the number printed is one that can be
typed in; where the floor binds, the caller reports the sigma it really gives
rather than the one that was asked for.
"""
s = DEFAULT_LAST_AUDIO_SIGMA if target is None else float(target)
try:
n = max(1, int(steps))
except (TypeError, ValueError):
return 0.0
if not 0.0 < s < 1.0:
return 0.0
lo, hi = _WIDGET_RANGE["shift_audio"][1], _WIDGET_RANGE["shift_audio"][2]
return min(max(s * (n - 1) / (1.0 - s), lo), hi)
def apply_h3_model_sampling(model, shift_video, shift_audio):
"""Apply H3's dual video/audio flow schedule from INSIDE the node so a missing
upstream patch can't silently gibberish the audio.
On ComfyUI 0.31+ the H3 nodes are V3-schema and don't live in the legacy
NODE_CLASS_MAPPINGS the old way -- AND the model already defaults to the correct
FLOW_AV schedule (12/3) at load. So the reliable path here is a DIRECT model-
level patch (works regardless of node API); the node call is only a secondary.
Order: direct model_sampling patch -> node under any name (V1/V3) -> give up with
an informative, non-alarming note. Shifts aren't hardcoded (12/3 base, ~8 video
for low-step MXFP8, ~4-6 audio for turbo)."""
try:
return _direct_model_sampling(model, shift_video, shift_audio), \
f"model_sampling video {shift_video:g}/audio {shift_audio:g} (direct)"
except Exception:
pass
cls, key = _find_h3_sampling_node()
if cls is not None:
try:
return _call_node(cls, model, shift_video, shift_audio), \
f"model_sampling video {shift_video:g}/audio {shift_audio:g} (via {key})"
except Exception:
pass
return model, (f"model_sampling not explicitly set (video {shift_video:g}/audio {shift_audio:g}); "
"on ComfyUI 0.30+ the model already defaults to the correct schedule, so this is "
"usually harmless -- only set shift_video/audio explicitly if you're on a low-step "
"MXFP8/turbo profile and the audio sounds wrong")
def sampling_oom_help(w, h, frames, fps, megapixels=0.0):
"""What to change, in this shot's own numbers, after a SAMPLING OOM.
Tiling is a decode setting and cannot help here, so the generic "try tiling"
advice is worse than useless -- it costs another full sampling pass before
failing the same way. Give the two levers that do change sampling cost, each
priced from the shot that just failed."""
now = shot_latent_cells(w, h, frames, fps)
secs = frames / float(fps or 24)
out = [f"This is a SAMPLING out-of-memory, not a decode one, so tiled decode "
f"cannot help it. The shot is {w}x{h} x {frames}f (~{secs:.1f}s) = "
f"{now:,} latent cells, and sampling cost scales linearly with that."]
opts = []
for cut in (10.0, 7.0):
if cut < secs - 0.4:
f2 = align_frame_count(int(round(cut * (fps or 24))))
opts.append(f"shot_seconds {cut:g} ({f2}f) is "
f"{100 - shot_latent_cells(w, h, f2, fps) * 100 // now}% smaller")
if megapixels:
for mp in (0.5, 0.35):
if mp < megapixels - 0.02:
w2, h2 = scale_to_megapixels(w, h, mp)
opts.append(f"megapixels {mp:g} ({w2}x{h2}) is "
f"{100 - shot_latent_cells(w2, h2, frames, fps) * 100 // now}% smaller")
if opts:
out.append("Options: " + "; ".join(opts) + ".")
out.append("Shot length is the stronger lever on a chain, because every shot pays it. "
"H3's own cap is 362 frames and this shot is at or near it.")
return " ".join(out)
# --- removals ----------------------------------------------------------------
# The one place the node edits your text, and it only ever DELETES.
#
# The scene paragraph is stamped on every shot, so a garment described there is
# still being described after a beat takes it off -- and a description of a worn
# garment beats a sentence saying it came off. The old node inferred removals from
# prose, which meant guessing, and the guessing is most of what made it
# unpredictable. This does not guess. You say what came off:
#
# Dan cuts off her jacket and throws it away.
# remove: jacket
#
# From that shot onward, any part of the scene naming "jacket" is dropped. The
# directive line itself never reaches the model.
_REMOVE_LINE = re.compile(r"^[ \t]*(?:remove|removed|off)[ \t]*:[ \t]*(.+?)[ \t]*$",
re.I | re.M)
# Field labels the OLD version of this node printed at the bottom of every shot it
# built. Paste one of those old scripts back in as a prompt and the labels now go
# to the model verbatim -- and a line reading "overall_soundscape: room tone" is
# read as text to put ON THE PICTURE. They are never scene description, so they are
# dropped, and info says so.
# A whole line that is nothing but one of those labels. Only the exact field names
# the old node emitted -- a bare "music:" could be someone's own scene note.
_LEGACY_FIELD = re.compile(
r"^[ \t]*(?:overall_soundscape|non_diegetic_music)[ \t]*:.*$", re.I | re.M)
# ...and the shot tag it put at the FRONT of a line that also carries real text, so
# only the tag comes off.
_LEGACY_PREFIX = re.compile(r"^[ \t]*\[(?:Generation|Shot)[ \t]*\d+\][ \t]*", re.I | re.M)
# Words that ask for letterforms in the frame. H3 renders text when the prompt
# names text, and at cfg 1 there is no negative prompt to take it back -- so this
# warns rather than edits: only you know whether "a neon sign" is set dressing you
# want or a watermark you do not.
_TEXT_CUE = re.compile(
r"\b(?:subtitle[sd]?|caption(?:s|ed)?|closed[- ]caption\w*|watermark(?:ed|s)?|"
r"logo|logos|credits|title card|end card|lower third|chyron|"
r"timestamp|time stamp|date stamp|timecode|"
r"text overlay|on-?screen text|banner|karaoke)\b", re.I)
def strip_legacy_fields(text):
"""(text, how many field-label lines were dropped)."""
text = text or ""
n = len(_LEGACY_FIELD.findall(text)) + len(_LEGACY_PREFIX.findall(text))
if not n:
return text, 0
out = _LEGACY_PREFIX.sub("", _LEGACY_FIELD.sub("", text))
# The field lines leave blank lines behind, and a blank line is a beat boundary
# here -- collapsing them keeps the shot count the author intended.
out = re.sub(r"[ \t]*\n[ \t]*\n[ \t]*\n+", "\n\n", out)
return out.strip(), n
_ADD_LINE = re.compile(r"^[ \t]*(?:add|wear|wearing)[ \t]*:[ \t]*(.+?)[ \t]*$", re.I | re.M)
# Prose that reads as taking something off. NOT used to remove anything -- inferring
# removals from prose is what made the old node unpredictable. It is used only to
# notice that a beat looks like a removal while the scene still describes the
# garment, and to say so, because that combination is a garment that comes back.
# Verbs that mean REMOVAL only with a particle. On their own, "cuts the rope",
# "takes her hand", "pulls her closer" and "throws the bag on the floor" are
# ordinary actions -- and reading one as a removal deletes that garment's entry
# from the scene, after which it is still worn but UNDESCRIBED. An undescribed
# garment is one the model invents, and what it invents is plain and pale. That
# is how a black shiny latex crop top comes back white.
#
# The particle's POSITION settles the ambiguous case. Straight after the verb it
# is a removal ("pulls down her shorts"); trailing after the object, only "off"
# and "away" are -- "takes her coat off" removes it, "pulls her crop top down"
# only adjusts it, and adjusting a garment must not cost it its description.
# One definition, in the engine. See engine._STRIP_VERB.
_STRIP_VERB = engine._STRIP_VERB
# The verbs above that stay a removal when the particle TRAILS the object -- "kicks
# her boots off". The rest are removals only with the particle straight after them:
# "steps out of her leggings" is one, "steps back" while a light goes off later in
# the sentence is not, and the trailing form would read that as a removal.
_TRAILING_VERB = (r"take[sn]?|took|taking|pull(?:s|ed|ing)?|peel(?:s|ed|ing)?|"
r"strip(?:s|ped|ping)?|cut(?:s|ting)?|rip(?:s|ped|ping)?|tear[s]?|"
r"tore|slip(?:s|ped)?|shrug(?:s|ged)?|yank(?:s|ed)?|tug(?:s|ged)?|"
r"toss(?:es|ed)?|throw[s]?|threw|kick(?:s|ed|ing)?|"
r"slide[s]?|slid|wriggle[sd]?|wiggle[sd]?")
# ...and verbs that are a removal on their own, needing no particle.
# One definition, in the engine. See engine._UNDO_VERB.
_UNDO_VERB = engine._UNDO_VERB
_REMOVAL_PROSE = re.compile(
r"\b(?:" + _UNDO_VERB + r")\b"
# "down" is NOT here. Pulling a garment down leaves it ON, around the thighs or
# the hips -- it is displaced, not removed. Counted as a removal it was scrubbed
# out of the scene, so every later shot stopped describing something that was
# still in the picture, and an undescribed garment is one the model re-invents.
# Reported as the shorts changing appearance in the next beat. The shot was also
# told they come off and are "dropped out of frame", which is not what the beat
# asked for at all. Displacement is handled below and keeps the garment described.
r"|\b(?:" + _STRIP_VERB + r")\s+(?:off|away|out\s+of)\b"
r"|\b(?:" + _TRAILING_VERB + r")\b(?=[^.;!?]{0,40}?\b(?:off|away)\b)"
# Over the head is off. The only way a garment goes over a head is coming off
# or going on, and the strip verbs are one-directional. A LOOKAHEAD, because
# the garment sits between the verb and the particle -- "lifts her top over her
# head" -- and the object span is read forward from the end of the match.
r"|\b(?:" + _STRIP_VERB + r")\b"
r"(?=[^.;!?]{0,40}?\bover\s+(?:her|his|their|the)\s+head\b)",
re.I)
_HAS_VERB = re.compile(
r"\b(?:is|are|was|were|be|being|been|has|have|had|wears?|wearing|dressed|"
r"walks?|walked|stands?|stood|sits?|sat|lies?|lying|holds?|holding|"
r"cuts?|pulls?|takes?|steps?|turns?|looks?|comes?|goes)\b", re.I)
# WHOSE HANDS take a garment off. A removal clause with no agent describes the
# garment removing itself -- "the belt comes off during this shot and is away by the
# last frame" is true of a belt that drops to the floor on its own, and that is what
# it rendered. Reported after a beat where she ASKS somebody to unlock it.
#
# The beat names the person; the clause was just not carrying it. Only where the beat
# is unambiguous about who acts, which is why asking is read as the OTHER person's
# hands: "she asks Dan to take it off" is Dan's doing, not hers.
_ASKS = re.compile(r"\b(?:asks?|asked|begs?|begged|tells?|told|wants?|wanted|"
r"pleads?|pleaded|has|have|had|gets?|got)\b", re.I)
def _clause_about(beat, item=""):
"""The sentence/clause of `beat` that names `item`; the whole beat if it does not.
An ask governs the garment it is ASKING about, not every garment in the beat.
"Kate takes off her coat ... and asks him to get the scarf off" has one removal
by her hands and one by his, and reading the ask against the whole beat gave
both to him."""
if not beat or not item:
return beat or ""
head = str(item).split()[-1]
for part in re.split(r"(?<=[.;!?])\s+", str(beat)):
if re.search(r"\b" + re.escape(head) + r"\b", part, re.I):
return part
return beat
def removal_agent(beat, cast, wearer=None, item=""):
"""Who takes the garment off in this beat. '' when the beat does not say.
A beat with one person in it is that person undressing. With two, the one who is
NOT the wearer is doing it when the wearer asks -- and when nobody asks, whoever
the beat names first is acting, the same reading restrained_by_beat uses."""
people = [n for n in (cast or []) if n]
if not people:
return ""
if len(people) == 1:
return people[0]
b = beat or ""
others = [n for n in people if n != wearer]
# "She asks Dan to take it off" -- the request is hers, the hands are his. Only
# when the ask governs THIS garment: a beat that takes a coat off and then asks
# about a scarf had the ask applied to both, so her own coat came off by his
# hands. Scoped to the clause the garment is named in, and when the garment is
# not named there the beat's own first-named actor is used instead.
if wearer and others and _ASKS.search(_clause_about(b, item)):
return others[0]
# First-named acts -- but in the GARMENT'S OWN clause, not the whole beat.
# "Sam unties the scarf. Kate takes off her jumper." names Sam first overall,
# so her jumper came off by his hands. The clause is what says who acts on what.
scope = _clause_about(b, item)
first, at = "", len(scope) + 1
for n in people:
m = re.search(r"\b" + re.escape(n) + r"\b", scope, re.I)
if m and m.start() < at:
first, at = n, m.start()
if first:
return first
# Nobody is named in that clause: the wearer is undressing themselves.
return wearer or people[0]
def beat_stages_removal(beat, item, agent=""):
"""Does the BEAT already say this garment comes off, by this agent's hands?
The clause exists to guarantee the removal FINISHES inside the shot -- the last
frame is the next shot's keyframe, and a cut mid-removal hands on a garment
still half worn. That guarantee is needed whether or not the beat stages it.
But when the beat already says "McKenna takes off her shorts and steps out of
them", the full clause repeats the whole action -- who, what, and that it comes
off -- and the shot carries the same removal twice. Two statements of one action
is an invitation to render it twice.
True when the beat names the garment's head noun near a removal verb, and either
names the agent or the beat has no other actor. The caller then says only the
part the beat does NOT cover: that it is finished by the last frame.
"""
b = str(beat or "")
head = str(item or "").split()[-1] if item else ""
if not b or not head:
return False
if not re.search(r"\b" + re.escape(head) + r"\b", b, re.I):
return False
# A removal verb in the same sentence as the garment.
for part in re.split(r"(?<=[.;!?])\s+", b):
if not re.search(r"\b" + re.escape(head) + r"\b", part, re.I):
continue
if not _REMOVAL_PROSE.search(part):
continue
# ...and not merely ASKED for: a request is not the act. See _in_a_request.
m = _REMOVAL_PROSE.search(part)
if m and _in_a_request(part, m.start()):
continue
if not agent:
return True
return bool(re.search(r"\b" + re.escape(agent) + r"\b", part, re.I)
# "she takes off her shorts" -- a pronoun for the only actor.
or re.search(r"\b(?:she|he|they)\b", part, re.I))
return False
def scene_tag_for(head, scene):
"""The <Picture N> tag on the sheet entry whose head noun is `head`. "" if none.
The tag lives INSIDE the wardrobe entry -- "chastity belt <Picture 2>" -- so
scrubbing the entry when the garment comes off takes the picture with it. That
is right for the description and wrong for the reference: the shot that takes a
thing off is the shot it is handled in and most needs to look like itself, and
without the tag it carries no image at all. Reported as the belt not matching
its reference on the shot that removes it."""
head = (head or "").strip().lower()
if not head or not scene:
return ""
for line in str(scene).split("\n"):
for item in re.split(r"[,;.]", line.split(":", 1)[-1]):
m = re.search(r"<\s*picture\s+\d+\s*>", item, re.I)
if not m:
continue
bare = re.sub(r"<\s*picture\s+\d+\s*>", " ", item, flags=re.I)
bare = re.sub(r"\s+", " ", bare).strip()
if bare and bare.split()[-1].lower() == head:
return m.group(0)
return ""
def off_by_last_frame(items, agent="", scene="", beat=""):
"""State that a removal FINISHES inside this shot. Empty when nothing came off.
Scrubbing the scene stops a garment being described. It does not tell the model
to complete the removal, and the last frame is what the next shot inherits as
its keyframe -- so a cut still in progress hands on a garment still half worn,
and the next beat has moved on and never contradicts the picture. The garment
stays. That is a garment "coming back" even though the text was right.
Said ONCE, in the removing shot, and never again. A later shot that says "no
longer wearing the coat" names the coat, and to a video model a mention is a
presence cue -- that phrasing put garments back on in the previous version of
this node. Afterwards the item is simply absent from the text."""
items = [i.strip() for i in (items or []) if i and i.strip()]
if not items:
return ""
# The SHEET's words for it, not the head noun the reader keyed it under. The
# tokens are identity keys -- matched by head noun everywhere that scrubs and
# compares -- but this sentence is PROSE the model reads, and "the shorts" beside
# a sheet saying "blue jeans shorts" is two garments described, not one. The pair
# that came back was the bare one, drawn however the model liked.
# ...with the picture the sheet gave it. The entry is scrubbed on the removing
# shot, so this is the only place left that can claim the image -- and a shot
# carrying a reference whose tag it never names reads the picture as ANOTHER
# subject, which is a duplicate rather than a belt.
named = []
for i in items:
nm = scene_name_for(i, scene) or i
tag = scene_tag_for(i, scene)
named.append(f"{nm} {tag}" if tag else nm)
what = " and ".join(f"the {i}" for i in named)
plural = len(items) > 1 or bool(_PLURAL_ITEM.search(named[-1]))
verb, are = ("come", "are") if plural else ("comes", "is")
# Named hands where the beat gives them. Without an agent this says a garment
# comes off by itself, and a belt with nobody touching it drops to the floor.
# The beat already staged it: say only the part it does NOT cover -- that the
# removal FINISHES in this shot. Restating who and what is the same action
# written twice in one prompt, which is what rendered it twice.
if beat and all(beat_stages_removal(beat, i, agent) for i in items):
# The AGENT is what the beat already gave; the ACTION is not. An earlier
# version of this cut both and returned only "the shorts are away by the
# last frame", which asserts an end state and never says the removal
# happens -- and the whole reason this clause exists is that scrubbing the
# scene does not tell the model to complete one. Garments stopped coming
# off. Say it agentlessly: the beat supplies the hands, this supplies the
# completion.
return (f" {what[0].upper()}{what[1:]} {verb} off during this shot and "
f"{are} away by the last frame -- fully removed and clear of "
f"the body.")
if agent:
sentence = (f"{agent} takes {what} off during this shot, with {agent}'s own "
f"hands, and {what} {are} away by the last frame -- fully removed "
f"and clear of the body.")
else:
sentence = (f"{what} {verb} off during this shot and {are} away by the last "
f"frame, fully removed and clear of the body, dropped out of "
f"frame.")
# BOUND the action. Saying what comes off does not say where to STOP, and an
# action with time left over runs on to whatever is next: a hand that finishes
# one garment starts on the next one, or on the body under it. Said as what
# STAYS -- at
# cfg 1 there is no negative prompt, and a negation in the positive names the
# thing it forbids. It also names no garment, so it summons none.
# About what is WORN, not about the body. "Everything else on the body stays
# exactly as it is for the whole shot" reads as an instruction to hold still.
bound = "Everything else worn stays exactly as it is, untouched and still fastened."
return " " + sentence[0].upper() + sentence[1:] + " " + bound
# Garments that are grammatically plural, so the sentence above agrees with them.
_PLURAL_ITEM = re.compile(r"\b(?:s|shorts|trousers|pants|jeans|boots|shoes|gloves|"
r"tights|leggings|briefs|knickers|cuffs)$", re.I)
# PUTTING SOMETHING BACK ON. The mirror of a removal, and it had none of the same
# machinery. A removal is scrubbed from the staging shot AND given a clause saying
# it FINISHES there -- both ends, because the shot's keyframe shows the garment on
# and the text has to carry it off. An `add:` had only the scrub's opposite: the
# phrase went into the same shot's scene block as a plain worn item.
#
# So the shot inherited a last frame with the garment OFF and was told, statically,
# that it is ON. There is no change described, only a disagreement, and the model
# resolves it in the opening frames: whatever is on the body turns into the garment.
# Reported as one thing instantly becoming another, a beat before the beat that
# puts it on -- which is exactly what the opening frames of that shot are.
_PUTS_ON = re.compile(
r"\b(?:put(?:s|ting)?|pull(?:s|ing)?|slip(?:s|ping)?|tug(?:s|ging)?|"
r"draw(?:s|ing)?|get(?:s|ting)?|climb(?:s|ing)?|step(?:s|ping)?)\b"
r"[^.;!?]{0,40}?\b(?:back\s+on|back\s+into|on|into)\b", re.I)
# ...and the ones that need no preposition.
_DRESSES = re.compile(r"\b(?:dress(?:es|ing)?|redress(?:es|ing)?|"
r"button(?:s|ing)?(?:\s+up)?|zip(?:s|ping)?\s+up|"
r"fasten(?:s|ing)?|laces?\s+up|puts?\s+back\s+on)\b", re.I)
def beat_stages_wearing(beat, item):
"""Does the BEAT say this garment goes ON during this shot?
Only then is the both-ends clause right. `add:` has a second, older job -- it
reveals a layer that was under something all along ("add: her white shirt
underneath", after the jacket is cut off) -- and that garment was already worn.
Telling the shot it goes on during these frames would stage a dressing that
never happens, which is the same defect pointing the other way."""
b = str(beat or "")
if not b.strip():
return False
head = str(item or "").strip().lower()
if not head:
return False
# The item has to be NAMED near the wearing verb, or a beat that puts a coat on
# would also claim the boots an `add:` mentioned in the same breath.
for pat in (_PUTS_ON, _DRESSES):
for m in pat.finditer(b):
window = b[max(0, m.start() - 60):min(len(b), m.end() + 60)]
if re.search(r"\b" + re.escape(head.split()[-1]) + r"\b", window, re.I):
return True
return False
def wearing_clause(phrases):
"""Give putting something on BOTH ENDS: off as the shot opens, on by the last.
The same shape direction_anchor uses for a door and removal_clause uses for a
garment coming off. Phrased as where the garment IS at each end rather than as
what it is not, because at cfg 1 there is no negative prompt and naming an
unwanted state in the positive asks for it."""
items = [str(p or "").strip().rstrip(".") for p in (phrases or []) if str(p or "").strip()]
if not items:
return ""
what = " and ".join(items)
plural = len(items) > 1 or bool(_PLURAL_ITEM.search(items[-1]))
are = "are" if plural else "is"
return (f" {what[0].upper()}{what[1:]} {are} off the body as the shot opens and "
f"fully on by the last frame, put on during this shot.")
# --- restraints ---------------------------------------------------------------
# The one continuity fact the node asserts on its own, because it is the one that
# cannot be recovered: a cuff that renders open is not a detail that drifts, it is
# the scene stopping making sense. Once hardware is on, it stays on.
#
# ONE sentence, impersonal, positive. The previous version had a per-limb effect
# table, pose tracking and a hardware clause, and between them the beat became 4% of
# the prompt. This is the fact and nothing else.
# What a `remove:` has to name to switch the hold off again.
RESTRAINT_HOLD_KEY = ("handcuffs cuffs chains rope ropes tape gag collar restraints "
"shackles clamp clamps clip clips")
# Every one of these constrains the HARDWARE, never the body. An earlier wording said
# the restraint held "the same way from the first frame to the last" and the chain let
# the body reach "only as far as the metal allows before it stops" -- read plainly,
# that is an instruction to hold still, and stacked together the holds came to 64% of
# a shot whose beat was 11%. The performance died under its own continuity guards.
# Say what the metal does; leave the body to the beat.
# Staying closed is not the same as staying itself. Every hold above constrains
# the fastening; none of them says the thing is still made of what it was made
# of. A strip of tape, decoded and re-encoded once a shot, has nothing in the
# text holding it to being tape, and it drifts to the nearest commoner object.
# One short sentence, because these holds are already the longest thing a
# restrained shot carries.
# The picture side of a shot with nobody speaking. Positively phrased, because at
# cfg 1 no negative is evaluated: "nobody speaks" asks the model to render an absence
# and a closed mouth is a thing it can actually draw.
#
# This is the WEAK half and is known to be. _silent_audio_latent already records that
# a lips-closed sentence loses against an audio stream that has decided somebody is
# talking -- conditioning the branch is what settles it. So this rides along, and the
# switch also extends the silencing to the shots that were keeping the branch open.
#
# TWO THINGS ca75672 PAID FOR, both of which this has to keep:
#
# It goes AFTER the action, never in front of it. As the opening tokens it was face
# anatomy in the first thing the model reads, and a distilled LoRA settles composition
# in its first step or two -- that rendered a face at the start of shots.
#
# It is only ever said where there is a mouth to describe. On a scenery beat with
# nobody in it, a sentence about mouths describes a person who is not there, and the
# only way to satisfy it is to put a face in an empty frame. The AUDIO half has no
# such limit -- an empty room still babbles -- so the two are separate conditions and
# are gated separately below.
MOUTH_HOLD = " Mouths in the shot stay closed, jaws still."
_PERSON_WORD = re.compile(
r"\b(?:he|she|they|him|her|hers|them|his|their|theirs|himself|herself|themselves|"
r"nobody|somebody|anyone|everyone|man|woman|men|women|boy|girl|person|people|"
r"figure|guard|driver|doctor|nurse|officer)\b", re.I)
def beat_puts_somebody_on_screen(beat, sheet=""):
"""Does the BEAT itself put a person in the shot?
Deliberately not "is a person described in this shot's text": the character
guard carries the previous shot's cast forward so a wordless beat does not empty
the frame, and falls back to the sole sheet entry when there is no previous. So
a scenery beat has a person described beside it before anybody has walked in,
and reading that as "somebody is here" is what put a face in an empty yard."""
b = beat or ""
if _PERSON_WORD.search(b):
return True
return any(n and re.search(r"\b" + re.escape(n) + r"\b", b, re.I)
for n, _ in sheet_lines(sheet))
FORM_HOLD = ", the same object in the same material."
# THE SHOT WHERE THE HARDWARE GOES ON IS NOT A SHOT WHERE IT IS ALREADY ON.
#
# Reported: she was meant to be caught and then restrained, and came out restrained
# and then bolting for the door. The applying shot was being handed the standing hold
# -- "fastened exactly as it was put on, and still fastened at the last frame" -- and
# read at frame 1 that says the cuffs are already closed. So they close first and the
# struggle happens around them, in whatever order is left.
#
# Same fault as a door told it is shut without being told when, and the same fix:
# name both ends. This replaces the standing hold on that one shot; from the next
# shot the latch takes over and the hold is correct, because by then it IS on.
RESTRAINT_GOING_ON = (" The hardware goes on during this shot: it is open and off the "
"body at the first frame, and closed on it by the last.")
# WHERE THE LIMBS FINISH, on the shot that stages the fastening.
#
# The clause above says what the HARDWARE does across the shot and says nothing
# about the body, and the anchor was deliberately withheld here on the grounds
# that the author's own words are right beside it. They are -- but they describe
# the ACT, and the next shot does not inherit the act. It inherits the last
# frame. So a shot could close the cuffs with the arms wherever they happened to
# be, and the shot after it opened on a picture of somebody with their arms at
# their sides while the text insisted the wrists were behind the back. Text loses
# to an inherited picture, every time.
#
# Reported as the handcuffs breaking in the next beat. Nothing broke: the frame
# the next shot started from never had them behind her back.
RESTRAINT_ENDS_AT = " By the last frame the {part} are {where}, and stay there."
# The rigid half of CHAIN_HOLD, on its own. Steel is steel while it is being locked
# on, so the applying shot keeps this even though it must not be told the thing is
# already fastened -- dropping it there let the chain go soft for exactly the shot
# that introduces it, which is where a model's idea of the object gets set.
CHAIN_RIGID_TAIL = " Its links keep their size and the run between them stays taut."
# Applying it, as opposed to describing it already worn. The tense is what separates
# them: "Dan cuffs her" stages the act, "her wrists cuffed" and "is handcuffed to the
# rail" describe a state that already holds. Getting that backwards would put "free at
# the first frame" on a woman who has been in cuffs for five shots.
# Nearly every one of these is a noun as well as a verb, and the noun is what a beat
# about restraints is full of: "pulls against the cuffs", "the chains hang", "her
# straps". Read as verbs those turn an ordinary struggling shot into an applying one,
# and it is then told the hardware is off at the first frame -- the exact inversion
# this is here to prevent, on a woman who has been in cuffs for five shots.
#
# A determiner in front is what marks the noun. You do not "the cuffs" anybody.
_A_DETERMINER = (r"(?<!\bthe\s)(?<!\bher\s)(?<!\bhis\s)(?<!\ba\s)(?<!\bmy\s)"
r"(?<!\bits\s)(?<!\btheir\s)(?<!\byour\s)(?<!\bthose\s)"
r"(?<!\bthese\s)(?<!\bsome\s)(?<!\bboth\s)")
_APPLY_NOW = re.compile(
_A_DETERMINER +
r"\b(?:cuffs|handcuffs|chains|ties|binds|locks|straps|tapes|gags|shackles|"
r"fastens|secures|padlocks|buckles|clamps|clips|snaps|trusses|lashes|wraps|"
r"cinches|tightens)\b", re.I)
_APPLY_PHRASE = re.compile(
r"\b(?:put|puts|putting|pull|pulls|pulling|force|forces|forcing|get|gets|"
r"getting|work|works|snap|snaps)\s+(?:[\w,']+\s+){0,4}?"
r"(?:on|onto|around|behind|together|shut|closed)\b", re.I)
# WHAT the hardware is, in the author's own words.
#
# Reported: the handcuffs disappeared while she still looked restrained. The holds say
# "every restraint stays whole and closed" and never name the thing, so a shot after
# the one that applied them is told a restraint EXISTS without being told what it is.
# The model renders the consequence -- hands held, restrained posture -- and no object,
# because no object was described.
#
# It only became visible after the sheet stopped listing the item: the sheet was what
# had been naming it in every shot. Taking it off the sheet is right, since the sheet
# put the cuffs in the shots before they went on; naming it here is what that costs.
#
# Ordered LONGEST FIRST inside each start position, so "duct tape gag" is latched
# whole. Matching the bare "gag" out of it made every later shot say "the gag is
# still on her" -- and a gag with no material named is a gag the model draws however
# it likes, which is a strip of tape turning into something else. The material IS the
# object here, the same way the form hold has to say what a thing is made of.
_TAPE = r"(?:duct|gaffer|packing|masking|electrical|parcel)"
_HARDWARE_NOUN = re.compile(
r"\b(?:(steel|metal|leather|nylon|plastic|padded|heavy|thin|black|chrome|"
r"ball|ring|bit|rubber|canvas|webbing)\s+)?"
r"(" + _TAPE + r"\s+tape\s+gags?|tape\s+gags?|" + _TAPE + r"\s+tape|tapes|tape|"
r"handcuffs|cuffs|manacles|shackles|leg\s+irons|chains|chain|ropes|rope|cords|"
r"cord|straps|strap|collars|collar|gags|gag|blindfolds|blindfold|"
r"spreader\s+bars|spreader\s+bar|zip\s+ties|zip\s+tie|cable\s+ties|cable\s+tie)\b",
re.I)
def hardware_named(text):
"""The hardware this text names, as written. '' when it names none."""
# The MOST SPECIFIC thing named anywhere in the beat, not the first one. "gags her
# with duct tape" names the verb before the material, and taking the leftmost gave
# "gags" -- so every later shot said "the gags are still on her" and the tape, the
# part that decides what it looks like, was never mentioned again.
best = ""
for m in _HARDWARE_NOUN.finditer(text or ""):
phrase = re.sub(r"\s+", " ", " ".join(g for g in m.groups() if g)).strip()
if len(phrase) > len(best):
best = phrase
if not best:
return ""
item = best.lower()
# "tapes her mouth shut" is the verb, and the thing it leaves behind is tape.
# Only reached on a shot already read as restrained, so an ordinary "tapes the
# box shut" never arrives here.
return "tape" if item == "tapes" else item
def hardware_all_named(text):
"""EVERY piece of hardware this text names, longest phrase per match, in order.
hardware_named returns one item -- the most specific -- and the caller appended
that single string to the worn list. So a beat that puts on two things at once,
which is the ordinary way to write it:
The guard handcuffs Ana's wrists behind her back and locks a steel collar
around her neck, chained to the wall.
recorded the collar and lost the handcuffs. From the next shot on, the cuffs
were not named in the prompt at all -- not "stays fastened", not mentioned --
and hardware nobody mentions is hardware the model stops drawing. Reported as
her breaking out of the handcuffs, which is the model rendering exactly what it
was told: a woman with a collar and free hands.
The across-shots case was already fixed -- worn_item used to be overwritten by
the next shot's item -- and the same bug within a single beat was left.
"""
out = []
for m in _HARDWARE_NOUN.finditer(text or ""):
phrase = re.sub(r"\s+", " ", " ".join(g for g in m.groups() if g)).strip().lower()
if phrase == "tapes":
phrase = "tape"
if not phrase:
continue
# A longer phrase naming the same thing replaces the shorter one: "collar"
# then "steel collar" is one item, described better the second time.
dupe = next((i for i, p in enumerate(out)
if p in phrase or phrase in p), None)
if dupe is None:
out.append(phrase)
elif len(phrase) > len(out[dupe]):
out[dupe] = phrase
return out
_UNDO_NOW = re.compile(
r"\b(?:unlocks?|unlocked|unlocking|uncuffs?|uncuffed|unbinds?|unbound|"
r"unties?|untied|untying|unbuckles?|unbuckled|unstraps?|unstrapped|"
r"unclips?|unclipped|unfastens?|unfastened|unshackles?|unshackled|"
r"ungags?|ungagged|releases?|released|frees?|freed|cuts?\s+(?:off|away|free)|"
r"slips?\s+off|takes?\s+off|pulls?\s+off|lifts?\s+(?:off|away))\b"
r"[^.;!?]{0,40}?"
r"\b(?:cuffs?|handcuffs?|chains?|ropes?|cords?|ties|straps?|tape|gags?|"
r"collars?|shackles?|clamps?|clips?|restraints?|belt|them|it)\b", re.I)
# ...and the object-first form: "the cuffs come off", "the rope is untied".
_UNDO_PHRASE = re.compile(
r"\b(?:cuffs?|handcuffs?|chains?|ropes?|cords?|ties|straps?|tape|gags?|"
r"collars?|shackles?|clamps?|clips?|restraints?)\b\s+"
r"(?:[\w,']+\s+){0,3}?"
r"\b(?:come|comes|came|drop|drops|dropped|fall|falls|fell)\s+"
r"(?:off|away|to\s+the\s+floor|to\s+the\s+ground)\b"
r"|\b(?:is|are|was|were|gets?|got)\s+"
r"(?:unlocked|untied|unbound|removed|taken\s+off|cut\s+(?:off|away|free))\b",
re.I)
def restraint_words(line):
"""The restraint HARDWARE named in one sheet entry, as its own head nouns.
Used to take hardware out of the sheet when a beat unlocks it: the hold can be
cleared, but while the entry still lists the cuffs the next shot reads them back
out of the scene text and latches the hold again."""
out = []
for item in re.split(r"[,;.]", str(line or "")):
item = _LEADING_TAG.sub("", re.sub(r"\s+", " ", item)).strip()
if not item:
continue
head = item.split()[-1].lower().strip("-")
if head and _RESTRAINT_WORD.match(head) and head not in out:
out.append(head)
return out
def restraint_coming_off(beat):
"""Does this beat stage hardware being TAKEN OFF, rather than merely mentioned?
The hold latches, and it was cleared only by an explicit `remove:` naming the
hardware -- deliberately, because a beat that does not mention cuffs is not a
beat that removes them. But auto_remove never puts hardware in `toks` (restraint
words are filtered out of infer_removals on purpose), so a script that unlocks
the cuffs IN ITS PROSE and writes no remove: line never cleared the latch: the
beat said they were unlocked and dropped to the floor, and every shot after went
on insisting they stay closed and fastened. Reported as the hold still firing
several shots after the hardware came off.
Narrow, like the apply patterns it mirrors: an UNDOING verb with the hardware or
a pronoun as its object. "She looks at the cuffs" or "the key is on the table"
must not clear a restraint that is still on.
"""
b = beat or ""
return bool(_UNDO_NOW.search(b) or _UNDO_PHRASE.search(b))
def restraint_going_on(beat):
"""Does this beat stage hardware being APPLIED, rather than already worn?"""
b = beat or ""
return bool(_APPLY_NOW.search(b) or _APPLY_PHRASE.search(b))
# COMPRESSED, 2026-09-05. This said "whole and closed", "fastened exactly as it was
# put on" and "still fastened at the last frame" -- three ways of saying closed --
# and then the material clause on top. 32 words. Measured on a real scene the
# guards had reached 65% of the shot against a 12% beat, which is the number this
# node was rebuilt to escape and the number RESTRAINT_HOLD's own comment warns
# about. Every guarantee is still here; each is stated once.
RESTRAINT_HOLD = (" Every restraint stays closed and fastened as it was put on") + FORM_HOLD
def restraint_wearers(sheet):
"""The people whose own sheet entry describes hardware.
Read from the entries rather than the beat, because the entry is what says who is
WEARING it -- a beat can mention a chain without anyone being in it."""
return [n for n, ln in sheet_lines(sheet) if n and restraint_present(ln)]
# A ceiling on continuity text, in words, relative to the beat it is standing next to.
#
# This node was rebuilt once because the guards had buried the action: the author's
# beat was under 4% of a 434-word prompt. It happened again by the ordinary route --
# a clause per bug report, each one justified on its own, none of them counting the
# others. Measured on a real scene the guards were 65% against a 12% beat, and the
# symptom is not subtle: the shot stops doing what the beat says. Somebody does not
# sit in the chair they were told to sit in.
#
# So the clauses are ranked and the low-priority end is dropped when there is no room,
# rather than every clause being emitted because each was a good idea in isolation.
# The floor exists so a very short beat still gets its single most important guard.
# Set to catch RUNAWAY, not to trim routinely. Measured against the same scene before
# this session's clause work, a shot carried 71 words of prompt; merging the three
# hardware clauses into one and shortening the gaze and mouth lines brought the worst
# shot from 124 words back to 59, which is already under that baseline. A tight budget
# on top of that was dropping guards that exist because of real reports -- the mouth
# holds, the revealed layer, the limb anchor -- and trading one set of bugs for
# another. The ceiling is here so the next clause added without counting the others
# cannot quietly rebuild the pile; it is not the thing doing the work.
# TIGHTENING THIS WAS TRIED, MEASURED, AND REJECTED. Recorded here so it is not
# proposed again from the balance report alone -- the report says the guards
# outweigh the beat, which is true, and reads like slack, which it is not.
#
# Swept against the suites, which are the record of what was actually reported:
#
# 90/5 (this) worst shot 116 words beat 14% 0 suite failures
# 80/5 2 -- the fall/landing guard, the
# bound-fall wording
# 75/4 3 -- ...and the forced position
# 65/4 3
# 60/4 6 -- ...and the rigid-metal hold
# 55/3 worst shot 87 words beat 16% 8 -- ...and both ends of applying
#
# Every clause the budget reaches is answering a report. There is no fat: two
# points of beat share cost the fall guard, the restraint holds and the posture
# hold, which is trading one set of bugs for another. With the sound clause ranked
# last (see _guards) a tighter floor drops THAT first instead, and on a shot whose
# audio branch is open the sound clause is the text half of the babble defence --
# the failure reported more often than any other here, and one no test asserts,
# so the suites would have gone green on it.
#
# So the floor stays a runaway catcher and is not the thing doing the work. What
# changed is that the sound clause now SPENDS from it, so the next clause added
# without counting the others cannot rebuild the pile the way sound quietly did.
GUARD_FLOOR_WORDS = 90
GUARD_WORDS_PER_BEAT_WORD = 5
def fit_guards(clauses, beat_words):
"""(kept text, dropped names) for continuity clauses, ranked, within a budget.
`clauses` is [(priority, name, text)] with 1 the most important. Order in the
OUTPUT follows the list as given, not the priority -- the ranking decides what
survives, not where it sits in the sentence."""
budget = max(GUARD_FLOOR_WORDS, int(beat_words) * GUARD_WORDS_PER_BEAT_WORD)
spent, keep = 0, set()
for _, name, text in sorted(clauses, key=lambda c: c[0]):
if not text:
continue
cost = len(text.split())
if spent + cost > budget and spent > 0:
continue
spent += cost
keep.add(name)
kept = "".join(t for _, n, t in clauses if n in keep and t)
dropped = [n for _, n, t in clauses if t and n not in keep]
return kept, dropped
def restrained_by_beat(beat, cast):
"""Who this beat puts in the hardware. The agent is not the one wearing it.
`restrained` was a film-level latch: once anything was on anybody, every later
shot got the hold. So a shot describing only the man who applied it was told
there were cuffs holding wrists behind a back -- with nobody in the text those
wrists could belong to. The model has to draw the person the sentence describes,
so it invents one. That is the duplicate.
One person in the shot is the one wearing it. Two or more and the first named is
the one doing it, which is how these beats are written: "Dan walks in and cuffs
her wrists"."""
people = [n for n in (cast or []) if n]
if len(people) <= 1:
return set(people)
# SPOKEN NAMES ARE NOT STAGED ONES, here for the same reason as in
# sheet_for_beat: "Dan says: 'McKenna, put the cuffs on'" names McKenna in
# dialogue only, and taking that as her being in the shot describes hardware
# on somebody the text never put in the room -- which is exactly how a second
# figure gets invented to own it.
b = _outside_speech(beat or "")
# The agent is whoever is named nearest BEFORE the applying verb, not whoever is
# named first. "Mara runs for the door. Dan catches her and cuffs her wrists"
# opens on the person being cuffed, and reading the first name as the agent put
# the hardware on the wrong one -- which then silenced the hold in every shot she
# was in, because the node thought she was not wearing anything.
verb = None
for pat in (_APPLY_NOW, _APPLY_PHRASE):
for m in pat.finditer(b):
verb = m.start() if verb is None else min(verb, m.start())
if verb is None:
return set(people)
agent, at = None, -1
for n in people:
for m in re.finditer(r"\b" + re.escape(n) + r"\b", b, re.I):
if at < m.start() < verb:
agent, at = n, m.start()
# No name in front of it -- "she is cuffed to the rail" -- so nothing here says
# who is doing it. Everybody stays a candidate rather than nobody: a hold that
# fires when it need not is a wasted sentence, one that fails to fire is hardware
# that stops being described.
return {n for n in people if n != agent} if agent else set(people)
# WHICH PART THE ANCHOR HOLDS. The clause used to say "holding the wrists" whatever
# the hardware was, so a steel collar chained to a wall came out as wrists held at
# the wall -- which describes a different restraint entirely, and leaves the neck
# free in the one shot whose point is that it is not. A model given wrists at the
# wall and a collar on the neck has two restraints to draw and reason to drop one.
_HELD_PART = (
(r"\b(?:collars?|leash(?:es)?|leads?|chokers?|neck\s*(?:chain|iron)s?)\b", "neck"),
(r"\b(?:leg\s*irons?|ankle\s*(?:cuffs?|chains?|straps?)|hobbles?|"
r"shackles?)\b", "ankles"),
(r"\b(?:harness(?:es)?|body\s*belts?)\b", "body"),
(r"\b(?:waist\s*(?:chain|belt)s?)\b", "waist"),
)
def held_part(items):
"""The body part an anchored restraint holds, read from the hardware itself."""
text = " ".join(items or [])
for pat, part in _HELD_PART:
if re.search(pat, text, re.I):
return part
return "wrists" # cuffs, rope and tape, which is the common case
# THE POSE A LIMB POSITION MAKES, as a body rather than as a relation. Buried in
# the hardware sentence as "holding the wrists behind the back" it was reported as
# the wrists rendering in front on the next beat: the fact was there, in every
# shot, and it was a subordinate clause in the middle of thirty words about the
# metal. A pose is drawn from arms and shoulders.
# SHORT. At 28 words this outbid FALL_HOLD and the budget dropped the fall guard
# -- which exists because a fall grew a third leg to brace a landing nothing in
# the text was taking. Trading one reported bug for another is not a fix. Arms and
# wrists make the pose renderable; elbows, shoulders and chest were decoration.
_POSE_OF_POSITION = {
"behind the back": ("Both arms are behind the body, wrists together at the "
"small of the back"),
"above the head": ("Both arms are raised, wrists together above the head, "
"the body stretched long"),
"in front of the body": ("Both arms are in front of the body, wrists "
"together at the waist"),
"out to the sides": ("Both arms are held out level with the shoulders, one "
"hand to each side"),
"at the waist": "Both arms are at the sides, wrists together at the waist",
}
# A BODY LYING DOWN NEEDS SOMETHING UNDER IT, and if the text does not say what,
# the model picks -- and what it picks for somebody on their side is the arm it has
# seen under every other body on its side: propped on the elbow, forearm out front.
# That is a hand in front of the body, which is the one place these wrists cannot be.
#
# Reported as her arm supporting her while the cuffs were meant to be holding her
# hands behind her back. The pose clause was already on that shot saying both arms
# are behind -- being told where the arms ARE does not settle what is BEARING THE
# WEIGHT, and between an arm it can see a use for and a sentence about wrists, the
# picture went with the arm.
#
# So name the contact. Positively, like everything else here: at cfg 1 nothing is
# negated, and "no arm under her" is the word "arm" next to the word "under". The
# shoulder and hip are what a bound body on its side actually rests on, and a
# shoulder taking the weight is an elbow with nothing to do.
#
# Only for wrists BEHIND THE BACK. Hands in front or above the head can prop a body
# up and it is not wrong that they do, so a clause forbidding it there would be
# taking away a shape the author may have wanted.
POSE_LYING_WEIGHT = "The shoulder and the hip take the weight of the body"
def pose_clause(position, lying=False):
"""One sentence describing the BODY a limb position makes. "" when unknown.
`lying` adds what is under it -- see POSE_LYING_WEIGHT."""
key = str(position or "").strip().lower()
said = _POSE_OF_POSITION.get(key, "")
if not said:
return ""
if lying and key == "behind the back":
said = f"{said}. {POSE_LYING_WEIGHT}"
return f" {said}."
def restraint_sentence(item, wearers, described, anchor="", rigid=False, posed=False,
part=""):
"""ONE sentence for the hardware: what it is, that it is closed, and where it holds.
These used to be three, written at three different times for three different bug
reports, and each of them names the same object again:
Every restraint stays closed and fastened as it was put on, ... (29 w)
The cuffs are still on her, in plain sight where they were put. (13 w)
The fastened wrists stay behind the back, where they were locked. (11 w)
53 words about one pair of handcuffs, beside a nine-word beat. Measured on a real
scene the guards had reached 65% of the shot against a 12% beat -- the number this
node was rebuilt to escape, arrived at again by adding a clause per report with no
budget on the total. Merged, the same facts cost 25.
Every guarantee survives: the thing is named so it gets drawn, it is closed, it is
the same object in the same material, and it is where it was fastened."""
# More than one piece of hardware reads as a list, and a list is plural however
# its last word ends: "The cuffs, duct tape stays closed" was what a comma-joined
# subject produced before this.
items = [i.strip() for i in (item or "").split(",") if i.strip()]
if len(items) > 1:
item = ", ".join(items[:-1]) + " and " + items[-1]
plural = True
else:
plural = bool(item) and item.endswith("s") and not item.endswith("ss")
who = ""
if wearers and len(described) >= 2:
who = (wearers[0] if len(wearers) == 1
else ", ".join(wearers[:-1]) + " and " + wearers[-1])
if item:
subject = f"The {item} on {who}" if who else f"The {item}"
verb = "stay" if plural else "stays"
else:
subject = f"Every restraint on {who}" if who else "Every restraint"
verb = "stays"
it, was = ("they", "were") if plural else ("it", "was")
# Rope is TIED. It is not closed and it is not fastened, and saying so of a cord
# describes a mechanism that is not there -- the same class of error as telling a
# strip of tape it sits in the mouth. Hardware closes; soft goods hold.
# ALL of it, not any of it. Cuffs and tape together are still cuffs, and steel
# that is only "tied and holding" is steel nobody has said is closed.
_soft_word = re.compile(r"\b(?:rope|ropes|cord|cords|twine|string|strap|straps|"
r"tape|scarf|belt|stocking|stockings|zip\s*ties?|"
r"cable\s*ties?|laces?)\b", re.I)
soft = bool(items) and all(_soft_word.search(i) for i in items)
shut = "tied and holding as" if soft else "closed and fastened as"
out = f" {subject} {verb} {shut} {it} {was} put on"
if anchor:
# `part` is passed in because the item NAME is dropped from this sentence
# whenever the beat already says it -- and with the name went the only clue
# to which part is held, so a collar the beat had just named came back
# holding the wrists. The latch still knows what is on; ask it, not the
# sentence being written.
#
# TWO RESTRAINTS, TWO ANCHORS. limb_anchor merges a limb POSITION with a
# fixed POINT into one string, and with cuffs behind the back and a collar
# chained to a wall that came out as "holding the neck behind the back, at
# the wall" -- a neck behind a back, which is not a thing, in the sentence
# whose whole job is to say plainly what is holding what. The position
# always belongs to the wrists; the point belongs to whatever is chained.
_m = re.match(r"^(.*?),?\s*(at the .+)$", anchor)
_pos, _point = (_m.group(1).strip(), _m.group(2)) if _m else (anchor, "")
_part = part or held_part(items)
# The limb POSITION leaves this sentence and gets one of its own, in
# pose_clause -- buried here it was the least prominent thing in thirty
# words about the metal, and it was reported as the wrists rendering in
# front. What stays is the anchor POINT, which is about the hardware and
# belongs with it.
# The PART and the POINT both stay: a collar holds the neck and the chain
# holds it to the wall, and dropping either leaves a shot that does not
# say what is attached to what. Naming the ITEM again here was worse than
# both -- "The steel collar stays closed and fastened, the steel collar
# fast at the wall" -- so the part carries it.
if _point:
out += f", holding the {_part} fast {_point}"
elif not _pos:
out += f", holding the {_part}"
if posed:
out += ("; the metal is already drawn to its full length, so the position it "
"fixes is the position that keeps, and the body strains against it "
"while the fastenings hold")
elif rigid:
out += (f", {'their' if plural else 'its'} links keeping their size and the run "
f"between them taut")
out += f", the same object in the same material."
if who:
out += " Everyone else in the shot has on exactly what their own entry lists."
return out
def own_body(clause, who, described):
"""Say WHOSE body a bare-skin clause is about, when more than one is described.
"Everything worn comes off during this shot" and "The legs are bare from the
hip down" name nobody. With one person in the shot that is unambiguous; with
two it is an instruction about whoever is on screen, and the second character
undresses alongside the first. Reported as one character mimicking the other's
actions -- and it is the same defect own_hold was written for, in the clause
next door.
Positively phrased, like own_hold: naming whose body it is excludes everyone
else, where "nobody else undresses" asks the model to render an absence. The
other people are pinned to their own entries in one short sentence rather than
named individually, which costs a second mention of each."""
if not clause or not who or len(described or []) < 2:
return clause
names = [n for n in (who if isinstance(who, (list, tuple)) else [who]) if n]
if not names:
return clause
subject = names[0] if len(names) == 1 else \
", ".join(names[:-1]) + " and " + names[-1]
body = clause.strip()
# "The legs are bare" -> "McKenna's legs are bare". "Everything worn comes off"
# -> "Everything McKenna is wearing comes off".
body = re.sub(r"^The\s+", f"{subject}'s ", body)
body = re.sub(r"^Everything worn\b", f"Everything {subject} is wearing", body)
return (" " + body
+ f" Everyone else in the shot keeps on exactly what their own entry "
f"lists.")
def own_hold(hold, wearers, described):
"""Attribute a hold to whoever actually wears the hardware.
The holds say "every restraint stays fastened" and name nobody, which was fine
while a shot meant one person. Put a second person in the frame and it becomes an
instruction about whoever is on screen: the belt locked onto one character turned
up on the other, over their clothes, because the sentence never said whose it was.
Only when the shot describes more than one person -- with one there is no
ambiguity, and the extra words are shot budget spent on nothing. Positively
phrased: saying who wears it is what excludes everyone else, where "nobody else
is wearing one" asks the model to render an absence."""
if not hold or not wearers or len(described) < 2:
return hold
def _and(names):
return names[0] if len(names) == 1 else \
", ".join(names[:-1]) + " and " + names[-1]
who = _and(wearers)
# ONE naming, not two. This used to add "The hardware is X's, worn on the body it
# was locked to" on top of rewriting the clause to "Every restraint on X" -- which
# says the same thing twice and costs a second mention of X in the shot.
#
# Reported as a second girl appearing at the moment of cuffing. A described person
# is a person the model draws; that is the whole basis of character_guard, and it
# does not stop applying because the describing sentence is a continuity guard.
# The dropped sentence also put a bare "the body" into the text, unattached to
# anybody, in the one shot where a second figure was turning up.
#
# What is KEPT is the half that does work the rewrite cannot: excluding everyone
# else. That is what stopped one character's hardware appearing on another.
tail = " Everyone else in the shot has on exactly what their own entry lists."
return hold.replace("Every restraint", f"Every restraint on {who}", 1).rstrip() + tail
# Hardware that means restraint on its own.
_RESTRAINT_PLAIN = re.compile(
r"\b(?:handcuff(?:s|ed)?|cuffed|shackle[sd]?|manacle[sd]?|hogtied|hog-?tied|"
r"hogcuffed|hog-?cuffed|gag(?:ged|s)?|blindfold(?:ed|s)?|zip[- ]ties?|"
r"cable[- ]ties?|restrain(?:t|ts|ed)|bound|bindings?|straitjacket|"
r"collared|leashed|tethered|manacled|fettered|chained\s+up|"
# PARTICIPLES are unambiguous and are not in the noun list, so they cannot
# satisfy both halves of the MAYBE rule by themselves. "Ana is collared and
# chained to the wall" matched nothing at all before this: "collared" is not
# "collars?", so the noun half failed and the whole latch stayed down.
#
# A COLLAR MADE OF HARDWARE. Bare "collar" is genuinely ambiguous -- a shirt
# has one -- so it needs a body part beside it, and "a collar at her throat"
# supplies that. But a sheet entry reading "green dress, steel collar" has
# neither a body part nor a binding verb anywhere in the line, so it was not
# a restraint at all: no hold ever fired for it, and hardware nobody holds is
# hardware the model drops. Reported as the collar missing from her neck.
#
# The material settles it. A shirt's collar is stiff or starched; it is not
# steel and it does not lock, so "white shirt with a stiff collar" still
# reads as clothing -- which is the distinction worth keeping.
r"(?:steel|iron|metal|chrome|brass|leather|padded|locked|lockable|heavy|"
r"thick|studded|spiked|posture|shock|bondage|slave)\s+collars?|"
r"collars?\s+(?:and|with)\s+(?:a\s+)?(?:lock|padlock|leash|lead|chain|ring)|"
r"spreader bar)\b", re.I)
# Hardware that is only a restraint in context -- a chain-link fence, a rope on a
# boat and a leather belt are none of the node's business.
# A clamp belongs here rather than in the list above: clamped to a bench it is a
# tool, clamped to a body it is hardware, and only the context tells them apart.
_RESTRAINT_MAYBE = re.compile(
r"\b(?:chains?|ropes?|cords?|cuffs?|straps?|collars?|tapes?|taped|taping|"
r"belts?|harness|hobble|clamps?|clips?)\b", re.I)
# VERB forms only. An earlier version listed "chain" and "cuff" here as well as in
# the noun list, so a chain-link fence matched both halves and armed the rule.
_BINDING_VERB = re.compile(
r"\b(?:cuffed|chained|tied|tying|bound|binds?|binding|locked|locks|"
r"strapped|taped|taping|gagged|shackled|fastened|fastens|secured|secures|"
r"padlocked|trussed|lashed|wrapped|clamped|clamping|clipped|clipping|"
r"pinned|attached|affixed)\b", re.I)
# NOTE the bare "clamps" and "clips" are deliberately absent above while "clamp" and
# "clip" are in the noun list. A word in BOTH lists satisfies both halves of the rule
# by itself, which is how "clamps the board to the workbench" armed the restraint
# hold -- the same way a chain-link fence did before "chain" was taken out of the
# verbs. Same reason "tapes" is a noun here and only "taped"/"taping" are verbs.
# _BODY_PART used to be defined twice at module level, here and again further down.
# Both readers sit below the second one, so the second has always been the one in
# force and this was dead -- but it read as the live definition from up here, and the
# restraint check below was written against this narrower vocabulary. Removed rather
# than merged: merging would change which shots read as restrained, and that is a
# behaviour change wearing a tidy-up's clothes.
# A turn shows a surface the shot has never shown. The keyframe pins the FRONT, so
# once the body rotates the model is filling in from its prior -- and its prior for
# an undescribed body is a CLOTHED one. That is a removed garment coming back, often
# stacked in the wrong order because nothing said which layer was where, and hardware
# on the far side being re-invented as it rotates into view.
#
# One sentence, only on shots that turn, and only once there is state worth holding.
# It names no garment and no person, so it summons neither.
TURN_HOLD = (" What is on the body now is all that is on it, front, side and behind, and "
"whatever is fastened stays fastened and closed as the view comes round.")
_TURN_CUE = re.compile(
r"\b(?:turn(?:s|ed|ing)?|rotat(?:es?|ed|ing)|spin(?:s|ning)?|swivel(?:s|led)?|"
r"roll(?:s|ed|ing)?\s+(?:over|onto)|faces?\s+away|face[sd]?\s+the\s+other|"
r"over\s+(?:her|his|their)\s+shoulder|from\s+behind|back\s+to\s+the\s+camera|"
r"shows?\s+(?:her|his|their)\s+back|other\s+side)\b", re.I)
# Being MOVED does the same damage as turning, for the same reason: the keyframe
# pinned one pose seen from one side, and lifting, dragging or rolling someone puts
# the body somewhere that frame never showed. The verb needs a PERSON as its object
# -- "lifts her onto the table" moves her, "lifts the crate" does not, and
# "positions her legs" moves a limb, not the body.
_MOVE_VERB = re.compile(
r"\b(?:lifts?|lifted|carr(?:ies|ied)|drags?|dragged|hauls?|hauled|hoists?|hoisted|"
r"picks?\s+up|picked\s+up|sets?\s+down|set\s+down|lays?|laid|"
r"lowers?|lowered|rolls?|rolled|flips?|flipped|props?|propped|"
r"moves?|moved|repositions?|repositioned|pulls?|pulled|pushes|pushed|"
r"shoves?|shoved|throws?|threw|drops?|dropped|turns?|turned)\s+", re.I)
_PERSON_OBJ = r"(?:the\s+|a\s+)?(?:her|him|them"
def body_moved(text, names=()):
"""Is a PERSON being moved in this beat, rather than an object or a limb?"""
toks = [re.escape(n) for n in (names or []) if n]
obj = re.compile(_PERSON_OBJ + (("|" + "|".join(toks)) if toks else "") + r")\b"
# ...not a possessive, and not a LIMB: "positions her legs" moves
# the legs, not the body. An earlier guard rejected any following
# word ending in "s", which threw out "drags her across the floor".
r"(?!\s*[']s)"
r"(?!\s+(?:legs?|arms?|wrists?|ankles?|hands?|feet|foot|head|hair|"
r"hips?|shoulders?|knees?|elbows?|thighs?|face|chin)\b)"
# A moved BODY goes somewhere: the object is followed by a word
# of motion, or the clause simply ends. Without this, "pulls her
# shorts off" reads as moving her rather than the shorts.
r"(?=\s*(?:[.,;!?]|$)"
r"|\s+(?:onto|into|on|in|to|across|down|up|over|under|back|out|"
r"away|upright|off|against|toward|towards|through|round|around|"
r"beside|behind|clear)\b)", re.I)
return any(obj.match(text[m.end():]) for m in _MOVE_VERB.finditer(text or ""))
def turns_in(text, names=()):
"""Does this beat rotate a body, move one, or bring the view around it?"""
return bool(_TURN_CUE.search(text or "")) or body_moved(text, names)
# A falling body's reflex is to put its hands out. When the hands are fastened, the
# model has to resolve that conflict, and the cheapest resolution is to free them --
# which renders as the cuffs opening or the chain snapping mid-fall. Nothing in the
# restraint hold covers it, because the hold says the hardware is whole and says
# nothing about what the body does on the way down.
#
# So say what DOES take the landing. Positive, and it names no person: at cfg 1
# there is no negative prompt, and "does not catch itself" names catching.
FALL_HOLD = (" A bound body falls as one piece: the fastened limbs stay fastened and travel "
"with it, the arms staying in the hold, the shoulder, hip or side takes "
"the landing, and the legs fold together under the body.")
# The same shot without the hardware. A falling body is the frame where limbs are
# least determined -- fast motion, heavy occlusion, and a pose the model has to invent
# the middle of -- and the reported result is a third leg, grown to brace a landing
# nothing else was taking.
#
# Said as what the limbs DO, never as how many there are. Counting was tried in this
# node's first life and removed -- the old subject-counting sentence is one of the
# phrases test_verbatim still bans by name. A count is also a mention, and a mention
# is a presence cue: naming legs to ask for two of them is a way of asking for legs.
# Giving them a definite job is what stops the model inventing one.
FALL_HOLD_FREE = (" The body falls as one piece: the arms stay with it and the shoulder, "
"hip or side takes the landing, the legs folding together under it.")
_FALL_CUE = re.compile(
r"\b(?:falls?|fell|falling|drops?\s+to|dropped\s+to|collapse[sd]?|collapsing|"
r"topple[sd]?|topples|tips?\s+over|tipped\s+over|keels?\s+over|goes\s+down|"
r"went\s+down|slumps?|slumped|stumbles?|stumbled|overbalance[sd]?|"
r"loses?\s+(?:her|his|their)\s+balance|lost\s+(?:her|his|their)\s+balance|"
# ...and being put down by someone else: "pushes her over", "knocked him down".
#
# WHAT GOES DOWN HAS TO BE A PERSON. The object here used to be optional, so the
# verb and the direction could sit straight against each other -- and "pulls down
# her shorts" is a verb and a direction. Every undressing beat written that way
# was read as a body being put on the floor, and told what takes the landing and
# how the legs fold. She stands up to take her shorts off and the shot drops her.
#
# Two shapes, both requiring more than the bare pair: somebody named and then the
# direction, or a destination explicit enough to be nothing else ("pushed to the
# floor"), which is how the passive gets in without an object.
r"(?:push|knock|shove|pull|drag|throw|thr[eo]w)(?:es|s|ed|n)?\s+"
r"(?:(?:her|him|them|herself|himself|themselves|[A-Z][\w-]+)\s+"
r"(?:over|down|to\s+the\s+(?:floor|ground))|to\s+the\s+(?:floor|ground))|"
r"hits?\s+the\s+(?:floor|ground|deck))\b", re.I)
# What can go down WITHOUT being a body. A garment let go of falls, and so does
# anything else the beat is holding -- and the fall guard exists to tell a shot what
# takes the landing and how the legs fold, so aiming it at a belt puts the person
# on the floor instead. Reported exactly that way: he took the belt off, it dropped
# to the ground, and she fell with it.
_OBJECT_FALLER = re.compile(
r"\b(?:it|its|belt|belts|top|tops|shirt|shorts|jeans|trousers|skirt|dress|"
r"coat|jacket|jumper|sweater|scarf|tie|boot|boots|shoe|shoes|sock|socks|"
r"glove|gloves|hat|bag|towel|sheet|blanket|cuffs?|handcuffs?|chain|chains|"
r"rope|ropes|tape|gag|collar|key|keys|phone|glass|bottle|cup|plate|book|"
r"clothes|clothing|garment|garments|thing|things)\b", re.I)
# A person going down. A NAME, or a personal pronoun that is not "it".
_PERSON_FALLER = re.compile(
r"\b(?:she|he|they|her|him|them|herself|himself|themselves|"
r"[A-Z][\w-]{1,24})\b")
def falls_in(text):
"""Does a BODY go down in this beat? A dropped garment is not a fall.
The fall guard tells the shot what takes the landing and what the legs do, so a
match on something that is not a person aims all of that at the wrong subject
and the shot puts a body on the floor to satisfy it.
The subject is whatever sits between the start of the clause and the verb. An
object there -- "it drops to the ground", "the belt falls to the floor" -- is
the thing being let go of, not somebody going down."""
t = text or ""
for m in _FALL_CUE.finditer(t):
# Back to the start of this clause: a subject does not reach across a full
# stop, nor across a comma or conjunction joining two predicates.
head = t[:m.start()]
cut = max((c.end() for c in
re.finditer(r"[.;!?]\s+|,\s*|\s+(?:and|but|then|so)\s+", head)),
default=0)
subject = head[cut:]
if _OBJECT_FALLER.search(subject):
continue # a thing came down, not a person
if not subject.strip() or _PERSON_FALLER.search(subject):
return True
# Nothing recognisable as a subject: the passive and destination-only forms
# ("pushed to the floor") are already narrow enough to mean a body.
return True
return False
# Steel does not behave like rope. A model with no reason to think otherwise draws a
# chain as a soft cord: it sags, stretches to wherever a limb is going, and lets the
# body move as if nothing were fastened. The restraint hold says the hardware stays
# WHOLE; it says nothing about how it behaves while whole.
#
# Positive and impersonal, like the other holds -- at cfg 1 there is no negative
# prompt, so "does not stretch" only names stretching.
# REPLACES the restraint hold rather than joining it -- the two said "stays whole and
# closed" twice, and two clauses saying the same thing is twice the stasis for one
# guarantee.
CHAIN_HOLD = (" Every restraint stays closed and fastened as it was put on, its links "
"keeping their size and the run between them taut") + FORM_HOLD
# When hardware is what PUTS a body in a position, the length of that hardware is the
# whole reason the position holds. Saying the metal keeps its shape is not enough: a
# chain that keeps its shape can still be drawn as having slack, and slack is room to
# stand up out of a squat the chain was locked to enforce.
#
# It replaces the clause above rather than joining it, and it is careful to leave the
# body free to act: straining and pulling is exactly what should happen, and the last
# thing this should say is that anything holds still.
CHAIN_POSE_HOLD = (" Every restraint stays closed and fastened as it was put on; the metal "
"is already drawn to its full length, so the position it fixes is the "
"position that keeps, and the body strains against it while the "
"fastenings hold") + FORM_HOLD
# A position that hardware can be locked to enforce.
_FORCED_POSE = re.compile(
r"\b(?:squat(?:s|ting|ted)?|kneel(?:s|ing)?|knelt|crouch(?:es|ing|ed)?|"
r"hogtied|hog-?tied|hogcuffed|hog-?cuffed|trussed|"
r"bent\s+(?:over|double)|doubled\s+over|folded\s+(?:up|forward)|"
r"spread[- ]eagled?|curled\s+up|"
r"on\s+(?:her|his|their)\s+(?:knees|haunches))\b", re.I)
# WHERE the fastened limbs are held. Distinct from _FORCED_POSE, which is what the
# whole body is doing -- kneeling, hogtied, bent over. Cuffed wrists above the head is
# not a pose in that sense: the body can be standing, sitting or lying and the arms are
# still fixed at one point.
#
# Reported: cuffs above the head in one shot, somewhere else in the next. The restraint
# hold kept them shut and said nothing about where they were, so the only thing
# carrying the position was the picture -- and the picture is the previous shot's last
# frame, which a close shot crops the anchor point straight out of. Text is the only
# thing that survives a tight frame.
_LIMB_ANCHOR = (
(r"(?:above|over)\s+(?:her|his|their|the)\s+head|overhead|"
r"stretched\s+(?:up|upward)", "above the head"),
(r"behind\s+(?:her|his|their|the)\s+back", "behind the back"),
# THE SAME PLACE, WRITTEN THE WAYS PEOPLE WRITE IT. The line above needs the
# literal word "back" after the possessive, so every one of these recorded
# NOTHING -- and nothing here is not a smaller clause, it is pose_clause
# returning "" and the shot never being told where the wrists are at all.
# Reported as her hands simply not being bound together behind her.
#
# "cuffed behind her" -- the back is implied and not typed
# "at the small of her back" -- which is the phrase THIS NODE
# prints back in its own pose clause
# "hands behind back" -- no possessive, as stage directions
# are written
#
# "behind her" cannot be matched on its own: limb_anchor only runs on a shot
# already holding a restraint, and in one of those "Dan stands behind her" is an
# ordinary sentence that would anchor her wrists to his position. So each form
# below carries its own evidence -- a limb, or a fastening participle, within a
# few words of it.
(r"(?:hands?|wrists?|arms?)\s+(?:\w+\s+){0,3}?behind\s+(?:her|his|their)\b",
"behind the back"),
(r"(?:cuffed|handcuffed|bound|tied|shackled|manacled|strapped|secured|"
r"fastened|locked|pinned|clasped|held)\s+(?:\w+\s+){0,2}?"
r"behind\s+(?:her|his|their)\b", "behind the back"),
(r"at\s+the\s+small\s+of\s+(?:her|his|their|the)\s+back", "behind the back"),
(r"\b(?:hands?|wrists?|arms?)\s+behind\s+back\b", "behind the back"),
(r"in\s+front\s+of\s+(?:her|his|their)\s+(?:body|chest|waist)", "in front of the body"),
(r"(?:out\s+)?to\s+the\s+sides?|spread\s+wide", "out to the sides"),
(r"at\s+(?:her|his|their|the)\s+waist", "at the waist"),
)
# What they are fastened TO. Named separately because a shot can state one, the other,
# or both, and the clause reads correctly with whichever it has.
#
# THE VERB IS REQUIRED, and it was not. "to the <noun>" alone read any movement as a
# fastening: "he walks to the table" came back anchored at the table and "she is
# dragged to the bed" anchored at the bed. That was survivable only because the noun
# list was short enough to miss most sentences -- and adding the missing nouns below
# without this would have made "she sinks to the floor" a chain.
#
# VERB FORMS ONLY -- the same rule _BINDING_VERB already documents, and the first
# version of this broke it. Written as bare stems with an optional suffix, "chain",
# "rope", "clip", "lock" and "bolt" are all NOUNS as well, so the pattern found its
# own hardware and called it a fastening: "she drops the rope to the floor" anchored
# at the floor, "the clip fell to the floor" anchored at the floor, and with the
# restraint gate now leaning on this, each of them latched a restraint hold over
# hardware lying on the ground for the rest of the film.
_FASTEN_PART = (r"(?:chained|cuffed|handcuffed|shackled|manacled|locked|padlocked|"
r"fastened|secured|tethered|bound|tied|strapped|clipped|hooked|"
r"bolted|attached|anchored|leashed|roped|affixed|fixed|pinned|"
r"hitched|moored|lashed|chaining|cuffing|locking|fastening|"
r"securing|tethering|tying|strapping|clipping|hooking|bolting|"
r"attaching|anchoring|padlocking)")
# The -s forms are verbs or plural nouns depending on what sits in front of them.
# A determiner makes them nouns -- "the chains", "a clip", "those cuffs" -- and
# anything else makes them verbs: "the guard chains her collar", "...and clips the
# chain to a ring".
_FASTEN_S = (r"(?<!\bthe\s)(?<!\ba\s)(?<!\ban\s)(?<!\bthese\s)(?<!\bthose\s)"
r"(?<!\btwo\s)(?<!\bsome\s)(?<!\bmore\s)"
r"(?:chains|cuffs|handcuffs|shackles|manacles|locks|padlocks|fastens|"
r"secures|tethers|ties|straps|clips|hooks|bolts|attaches|anchors|"
r"leashes|ropes|pins)")
# WEAK verbs describe a chain's PATH rather than an act of fastening, and on their
# own they are ordinary motion -- "she runs to the wall" must not be an anchor. They
# count only with the hardware itself as the subject: "a chain runs from her collar
# to the wall" and "a short chain holds her collar to the wall" are both anchors and
# neither has a fastening verb anywhere in it.
_FASTEN_WEAK = (r"(?:chains?|ropes?|cords?|cables?|leash(?:es)?|leads?|straps?|"
r"tethers?|links?|lines?)\s+(?:\S+\s+){0,4}?"
r"(?:run|hold|lead|stretch|extend|go|reach|drop|hang)(?:s|es|ing)?")
# WHAT A CHAIN CAN BE FASTENED TO. The wall was not on this list, and that is the
# whole of the reported bug: "chains it to the wall" produced NO anchor, so the
# collar hold said the collar stays closed and nothing ever said she was tethered.
# A shot that then has her cross the room is a shot with a collar, no tether, and a
# beat saying she walks away -- and the cheapest way for the model to make that make
# sense is to take the collar off. Floor, ceiling and pillar were missing with it.
_ANCHOR_POINT = re.compile(
r"\b(?:" + _FASTEN_PART + r"|" + _FASTEN_S + r"|" + _FASTEN_WEAK + r")"
# One determiner list, in the engine, for the same reason the vocabularies
# moved there: this one and the engine's drifted apart and a phrasing either
# reader missed was a restraint the other half of the node did not know about.
r"\b(?:\s+\S+){0,5}?\s+to\s+" + engine.ANCHOR_DET +
r"((?:bed\s*frames?|bed\s*heads?|headboards?|bed\s*posts?|beds?|rails?|railings?|"
r"bars?|posts?|rings?|hooks?|pipes?|radiators?|chairs?|tables?|beams?|frames?|"
r"grates?|grilles?|fences?|walls?|floors?|grounds?|ceilings?|pillars?|columns?|"
r"stakes?|eye\s*bolts?|bolts?|brackets?|cages?|bunks?|benches?|ladders?|"
r"girders?|struts?|anchors?|loops?))\b", re.I)
def limb_anchor(text):
"""Where fastened limbs are being held, as a phrase. '' when the text says none."""
t = text or ""
where = next((phrase for pat, phrase in _LIMB_ANCHOR
if re.search(pat, t, re.I)), "")
m = _ANCHOR_POINT.search(t)
point = ("at the " + re.sub(r"\s+", " ", m.group(1).lower())) if m else ""
if where and point:
return f"{where}, {point}"
return where or point
# Framing tight enough to crop an anchor point out of shot. Worth naming because the
# next shot starts from THIS shot's last frame: whatever a close shot cuts off, the
# next shot inherits a picture without it, and only the text still knows.
_TIGHT_FRAME = re.compile(
r"\bclose[-\s]?up|\bclose\s+(?:shot|on)\b|\btight\s+(?:on|shot)\b|"
r"\bfills?\s+the\s+frame\b|\bmacro\b", re.I)
def tight_framing(text):
"""Does this beat call for a frame close enough to lose the anchor point?"""
return bool(_TIGHT_FRAME.search(text or ""))
# WHAT THE CLOSE FRAME IS CLOSE **ON**, and therefore what it can hold.
#
# Reported: camera types written in the anchor did not take. They were reaching
# the model verbatim -- the anchor is 11-13% of a shot's conditioning -- but the
# other 87% asserted denim shorts, wrists at the small of the back and the weight
# on shoulder and hip. A close-up on a face contains none of those. The camera was
# not being ignored, it was being outvoted by the node's own continuity prose.
#
# Only WARDROBE is scoped away. The limb and restraint sentences STAY on a tight
# shot, and deliberately: the framing crops the anchor point out of the frame the
# next shot inherits, so the text is the only thing left that knows where the limbs
# are fastened. Dropping that is the exact drift the tight-frame warning exists to
# report.
_FRAME_ON = re.compile(
r"\b(?:close[-\s]?up|close\s+shot|tight\s+shot|macro(?:\s+lens)?)\b[^.;]{0,24}?"
r"\bon\s+(?:her|his|their|its|the)\s+([\w][\w\- ]{1,20})"
r"|\b(?:close|tight)\s+on\s+(?:her|his|their|its|the)\s+([\w][\w\- ]{1,20})", re.I)
# Subject word -> the garment REGIONS that frame can still show. A face is read as
# head-and-shoulders, which is what a close-up on a face conventionally is, so a
# collar or neckline survives and the trousers do not.
_FRAME_HOLDS = (
(r"face|eyes?|mouth|lips|head|hair|jaw|cheeks?|ears?|nose|expression",
frozenset(("torso",))),
(r"hands?|fingers?|wrists?|palms?|knuckles?", frozenset(("hands",))),
(r"feet|foot|ankles?|toes?", frozenset(("feet",))),
(r"chest|breasts?|torso|shoulders?|stomach|belly|waist|back",
frozenset(("torso",))),
(r"legs?|thighs?|hips?|knees?|calves|calf", frozenset(("legs", "feet"))),
)
def frame_holds(text):
"""The garment regions a named close frame can still contain.
None when the text names no close frame, or names one without saying what it
is close ON -- a bare "close-up" gives no way to know what is in it, and
guessing would be the node cropping the author's wardrobe on a coin toss."""
m = _FRAME_ON.search(text or "")
if not m:
return None
subject = (m.group(1) or m.group(2) or "").strip().lower()
for pat, regions in _FRAME_HOLDS:
if re.search(r"\b(?:" + pat + r")\b", subject, re.I):
return regions
return None
def out_of_frame_garments(scene, holds):
"""Garments in `scene` whose region the frame cannot show.
A garment that cannot be placed at all is KEPT: an unplaceable item is one this
file does not recognise, and cropping what it does not understand is how a
wardrobe quietly loses things the author wrote."""
if not holds:
return []
out = []
for g in garments_in(scene or ""):
r = region_of(g)
if r and r not in holds:
out.append(g)
return out
# WHERE SOMEBODY IS LOOKING.
#
# Reported: "she is looking at the TV" rendered her looking off to the side, posing
# for the camera. The beat says it once and nothing else in the shot agrees with it,
# while a near-clean reference is asking for the portrait's pose -- and the portrait
# looks at the lens, because photographs of people do. info already warned that a
# referenced person can hold the portrait's gaze; nothing in the TEXT argued back.
#
# The model's own prior pulls the same way: a person in frame faces the camera unless
# something says otherwise. So the target gets said a second time, as a physical fact
# about the eyes and the head rather than as an activity.
_GAZE_PREP = r"(?:at|to|towards?|into|onto|over\s+at)"
_GAZE_TAIL = (r"(?=[.,;:!?]|\s+(?:and|as|while|when|who|which|that|with|for|from|in|on|"
r"before|after|until)\b|$)")
_GAZE_DET = r"(?:the|a|an|her|his|their|its|that|this)\s+"
_LOOK_AT = re.compile(
r"\b(?:look(?:s|ed|ing)?|star(?:e|es|ed|ing)|gaz(?:e|es|ed|ing)|"
r"glanc(?:e|es|ed|ing)|peer(?:s|ed|ing)?|squint(?:s|ed|ing)?)\s+"
r"(?:back\s+|down\s+|up\s+|over\s+|round\s+|around\s+|straight\s+|right\s+)?"
+ _GAZE_PREP + r"\s+" + _GAZE_DET + r"([\w][\w\- ]{0,24}?)" + _GAZE_TAIL, re.I)
# Verbs that carry their object without a preposition. "Watching the TV" is a gaze
# instruction as much as "looking at the TV" is.
_WATCH = re.compile(
r"\b(?:watch(?:es|ed|ing)?|stud(?:y|ies|ied|ying)|examin(?:e|es|ed|ing))\s+"
+ _GAZE_DET + r"([\w][\w\- ]{0,24}?)" + _GAZE_TAIL, re.I)
# Things that are not a place to look. "Looks at her" is a pronoun with no picture in
# it, and restating a pronoun as a target says nothing the beat did not.
_NOT_A_TARGET = frozenset(
"him her them it me us you himself herself themselves one other others "
"time moment thing things way".split())
def look_target(beat):
"""What this beat says somebody is looking at. '' when it names nothing."""
for pat in (_LOOK_AT, _WATCH):
m = pat.search(beat or "")
if not m:
continue
target = re.sub(r"\s+", " ", m.group(1)).strip(" -")
if not target or target.lower() in _NOT_A_TARGET:
continue
return target
return ""
# Going somewhere ends a look. Held across it, "the eyes are on the TV" follows
# somebody out of the room and into the next scene.
_MOVES_OFF = re.compile(
r"\b(?:walks?|walked|runs?|ran|steps?|stepped|moves?|moved|crosses|crossed|"
r"leaves?|left|exits?|exited|goes|went|heads?|headed|climbs?|climbed|"
r"follows?|followed)\b", re.I)
# The look VERBS on their own, with no target required. _LOOK_AT needs a nameable
# object, so "looks at her" reads as no look at all -- and the latch then held a
# television she had just turned away from.
_LOOK_VERB = re.compile(
r"\b(?:look(?:s|ed|ing)?|star(?:e|es|ed|ing)|gaz(?:e|es|ed|ing)|"
r"glanc(?:e|es|ed|ing)|peer(?:s|ed|ing)?|watch(?:es|ed|ing)?|"
r"stud(?:y|ies|ied|ying))\b", re.I)
def looks_somewhere(beat):
"""Does this beat stage a look at all, nameable target or not?
"Mara looks at her" names no target this node can restate -- but it does
move the look, and holding the previous target across it says her eyes are
on a television she has just turned away from."""
return bool(_LOOK_VERB.search(beat or ""))
def gaze_hold(target):
"""One sentence putting the eyes and the head on the thing the beat named.
Impersonal, like the hardware placement clause: naming the person again is one
more mention of a person, and that has its own cost. Says nothing about where the
camera is -- the shot may be looking straight down the line of sight -- only that
the head is turned to face what the eyes are on."""
if not target:
return ""
# SHORT. Nineteen words restating a nine-word beat is most of the shot spent
# agreeing with it, and the guards crowding out the action is what "the
# character did not do what I told it" looks like from the outside.
return f" The eyes and the head are turned to the {target}."
def forced_pose(text):
"""Does this text put a body into a position that hardware can enforce?"""
return bool(_FORCED_POSE.search(text or ""))
# Hardware that is rigid by nature. Only consulted once a restraint is established,
# so a chain-link fence in the scenery cannot arm it on its own.
# Named hardware only. "steel" was in this list, which meant any steel object earned
# the chain clause -- and that clause talks about LINKS and the RUN between fastenings,
# which is nonsense said of a steel clamp. A clamp is rigid, but it is not a chain: it
# gets the plain restraint hold, which is what "it stays on" needs anyway.
_RIGID_HARDWARE = re.compile(
r"\b(?:chain(?:s|ed|ing)?|padlock(?:s|ed|ing)?|shackle[sd]?|manacle[sd]?|"
r"handcuff(?:s|ed)?|cuffs?|cuffed|irons|spreader\s+bar|"
r"hogcuffed|hog-?cuffed)\b", re.I)
# Where each piece of hardware goes. Not a creative choice -- it is what the object
# IS. A collar without a neck is a band with no place to be, and a model handed a
# band-shaped object and no anatomy puts it where bands most often sit in its
# training data: on the head. That is the reported failure, and it happens whether
# the item is being fastened or merely held up and shown.
#
# (item pattern, the phrase that places it)
_TAPE_GAG = (r"(?:duct[\s-]*)?tape\s+gag|"
r"gag(?:s|ged|ging)?\s+\w{0,12}\s*with\s+"
r"(?:duct\s+|packing\s+|masking\s+)?tape|"
r"tape\s+(?:over|across)\s+(?:her|his|their|the)\s+mouth")
_TAPE_GAG_CLAUSE = "a strip of tape lies flat across the mouth"
_GAG_CLAUSE = "a gag sits in the mouth"
_HARDWARE_ANCHOR = (
(r"collar(?:s|ed)?", "a collar closes around the neck"),
(r"leash(?:es)?|lead\b", "a leash clips to the collar at the neck and hangs down from it"),
# Tape is a gag that lies flat against the face. Told "a gag sits in the
# mouth" it is given bulk it does not have, and bulk over the mouth,
# re-encoded shot after shot, settles into a mask.
(_TAPE_GAG, _TAPE_GAG_CLAUSE),
(r"gag(?:s|ged)?|ball\s*gag", _GAG_CLAUSE),
(r"blindfold(?:s|ed)?", "a blindfold covers the eyes"),
(r"handcuff(?:s|ed)?", "handcuffs close around the wrists"),
(r"shackle[sd]?|leg\s+irons", "shackles close around the ankles"),
(r"harness(?:es)?", "a harness sits on the torso"),
(r"spreader\s+bar", "a spreader bar holds the ankles apart"),
# No entry for a chastity belt, and the lookbehind below keeps the plain belt off
# it too, so it gets no placement clause at all. It is the item most likely to
# arrive with its own <Picture N>, and a written description of where the shield
# and the lock sit argues with the picture rather than adding to it. Where the
# reference shows the object, the object is already placed; describe it in your
# own words if you want it stated.
(r"(?<!chastity\s)belt(?:s|ed)?", "a belt closes around the waist and hips"),
)
# Anatomy that already places something, so the writer's own wording wins.
_BODY_PART = re.compile(
r"\b(?:neck|throat|wrist|wrists|ankle|ankles|mouth|lips|jaw|eyes|face|head|"
r"waist|hips?|chest|torso|stomach|belly|shoulders?|arms?|legs?|thighs?|"
r"knees?|feet|foot|hands?)\b", re.I)
def unanchored_hardware(text):
"""Phrases placing any hardware that is named with no body part beside it.
A window of 60 characters either side counts as 'beside'. If the text already
says where the thing goes, nothing is added -- what you wrote wins."""
out = []
t = text or ""
for pat, phrase in _HARDWARE_ANCHOR:
placed = False
found = False
for m in re.finditer(r"\b(?:" + pat + r")", t, re.I):
found = True
window = t[max(0, m.start() - 60):m.end() + 60]
if _BODY_PART.search(window):
placed = True
break
if found and not placed and phrase not in out:
out.append(phrase)
# A tape gag answers the gag entry as well, and the two clauses disagree
# about whether the thing has bulk. The flat one is the true one.
if _TAPE_GAG_CLAUSE in out and _GAG_CLAUSE in out:
out.remove(_GAG_CLAUSE)
return out
def anchor_clause(phrases):
"""One sentence saying where the named hardware sits."""
if not phrases:
return ""
return " Each piece of hardware sits where it belongs: " + "; ".join(phrases) + "."
# A state written down is a state the model can render by ARRIVING at it.
#
# Reported: "stand behind a van with its doors closed" put the doors open and the
# characters closing them. The text named a state and never said WHEN it was true,
# and a video model asked for a door renders the thing a door does. The state is
# the most interesting event in the sentence, so it gets performed.
#
# Fewer sampling steps make it worse rather than better. On a 4-step distill
# schedule the layout is committed almost immediately, so an opening frame that
# guessed wrong is never argued out of it by the later steps -- there are none.
# Saying the state is already true at the first frame costs one sentence and takes
# the event away.
#
# Scenery only, and only words that are not also something worn: no "boots", no
# "hood", no "bonnet". A character sheet lives in this same text.
_STATE_THING = (r"doors?|gates?|windows?|curtains?|blinds?|shutters?|"
r"hatch(?:es)?|tailgates?|lids?|drawers?")
_STATE_WORD = r"closed|shut|open|locked|unlocked|latched|bolted|drawn|ajar|sealed"
# Verbs that CHANGE one of those states. Several are also the state word itself --
# "closed" is both -- which the reader below has to tell apart.
_STATE_ACTS = (r"opens?|opened|opening|closes?|closed|closing|shuts?|shutting|"
r"slams?|slammed|slamming|slides?|slid|sliding|pulls?|pulled|pulling|"
r"pushes?|pushed|pushing|draws?|drew|drawing|locks?|locked|locking|"
r"unlocks?|unlocked|unlocking|lifts?|lifted|lifting|raises?|raised|"
r"lowers?|lowered|swings?|swung|yanks?|yanked|wrenches|wrenched")
# The gap takes apostrophes: "closed the van's doors" is a determiner phrase, and a
# gap of bare \w+ does not match one, so the whole act went unseen.
_STATE_ACT = re.compile(r"\b(" + _STATE_ACTS + r")\s+((?:[\w']+\s+){0,3}?)(" +
_STATE_THING + r")\b", re.I)
# What tells "closed the rear doors" from "closed rear doors": a determiner. The verb
# reading needs one -- you close THE doors, ITS doors, THE VAN'S doors -- and the
# adjective reading cannot have one, because the determiner belongs in front of the
# whole phrase ("a van with closed rear doors").
#
# Getting this wrong is not a missed guard, it is an inverted one. Read as a verb,
# "a van with closed rear doors" earned the anchor "the doors are open at the first
# frame and shut by the last" -- the node itself asking for the doors to start open
# and be closed on camera, which is the bug it was written to fix.
_STATE_DET = re.compile(r"\b(?:the|a|an|its|his|her|their|our|my|your|this|that|these|"
r"those|both|all|each|every|another|one|two|three|\w+'s)\b", re.I)
def _adjectival(verb, gap):
"""Is this state word describing the noun rather than acting on it?
Only words that are ALSO states can be adjectives: "opens" is a verb however it
is placed. Modifiers may sit between -- "closed rear doors", "shut cargo doors" --
so the test is the determiner, not the distance."""
return (bool(re.fullmatch(_STATE_WORD, verb, re.I))
and not _STATE_DET.search(gap or ""))
# "closed doors", and "closed rear doors" -- the state in front of its noun, with the
# modifiers a real sentence puts between them.
_STATE_ADJ = re.compile(r"\b(" + _STATE_WORD + r")\s+((?:[\w']+\s+){0,2}?)(" +
_STATE_THING + r")\b", re.I)
# "the doors are closed", "the doors closed", "the doors are still shut". The gap is
# copulas and nothing else, so a state word further off in the sentence -- belonging
# to some other object -- is not dragged onto this one.
_STATE_PRED = re.compile(r"\b(" + _STATE_THING + r")\s+" +
r"((?:(?:are|is|was|were|remains?|stay|stays|still|both|all)\s+){0,2})(" +
_STATE_WORD + r")\b", re.I)
# POSTURE. The one piece of continuity the scene-state reader never covered: it
# tracks scenery -- doors, windows, drawers -- and nothing about the body. A beat
# that sits somebody down establishes a pose the next shot is never told about, so
# the shot ends with them seated and the next one stands them back up. The keyframe
# does carry the pose as a picture, but the TEXT is what the model reconciles it
# against, and text that says nothing loses to a reference that says something.
#
# Deliberately coarse: four postures, no orientation, no limb detail. Naming more
# than the pose is how a continuity clause turns into an instruction to hold still.
# The posture vocabulary lives in the engine: there were two tables and they
# had diverged, with a crouch setting no posture at all here. This file keeps
# its own READER, because it answers a different question -- whose posture,
# clause by clause -- but off the same words.
_POSTURE_OF = engine._POSTURE_OF
_NOT_A_BODY = engine._NOT_A_BODY
# Words that follow a posture verb but are never its object: they are the
# direction the body goes, not the body.
_POSE_DIRECTIONS = frozenset(
"down up back onto into on in over out away flat still there here".split())
# ...and the pronouns that ARE an object.
_OBJECT_PRONOUN = frozenset(
"her him them herself himself themselves".split())
def posture_in(beat, cast):
"""{name: posture} this beat puts somebody into. {} when it stages none.
Attributed by CLAUSE, so "Kate sits down and Sam stays by the door" does not
seat them both. A clause with a posture verb and no name belongs to whoever the
beat names first, which is the same reading the removal agent uses."""
b = str(beat or "")
people = [n for n in (cast or []) if n]
if not b or not people:
return {}
out = {}
# A CLOSING QUOTE ends a sentence too. 'Dana says: "Lie down." McKenna lies
# down' has its full stop inside the quotes, so a plain full-stop split left it
# as one clause -- and the subject span for the real "lies" reached back past
# the quote and picked up Dana as well.
#
# But splitting there also cuts the quote in half, and a half-open quote is one
# _in_a_request cannot see. So the clause gives the SUBJECT and the whole beat
# gives the QUOTES: each part carries its offset, and the request test is asked
# about the position in `b`.
at0 = 0
for part in re.split(r"(?<=[.;!?])[\"'”’]?\s+", b):
base = b.find(part, at0)
base = at0 if base < 0 else base
at0 = base + len(part)
if _NOT_A_BODY.search(part):
continue
# Every posture verb in the clause, in order, with the span of text that
# precedes it. The SUBJECT is the names in that span: "Kate sits down and
# Sam stays by the door" seated them both when the whole sentence was
# searched, because Sam is in it -- but he is after the verb, doing
# something else. "Kate and Sam sit down" still seats both, because both
# names precede the one verb.
# A posture ASKED FOR is not one taken. 'Dana says: "lie down on the
# change table"' put McKenna down a beat early -- and both of them, since
# both names precede the verb. _in_a_request is the reader the removal
# side already uses: quoted speech, questions, and "tells her to sit"
# alike. Asking for a garment stopped removing it for the same reason.
# EVERY occurrence, not the first. Taking only the first meant that
# skipping a quoted "Lie down." threw away the real "McKenna lies down"
# that followed it, and the beat registered no posture at all.
hits = sorted(((m.start(), pose) for pose, rx in _POSTURE_OF
for m in rx.finditer(part)
if not _in_a_request(b, base + m.start())),
key=lambda h: h[0])
prev = 0
for at, pose in hits:
span = part[prev:at]
# TRANSITIVE first. "Dana lies McKenna down" puts MCKENNA down -- the
# person going into the pose is the object, and reading the subject
# latched the wrong person, so every later shot said Dana was still
# lying down while the beat had her up and working. Same subject/object
# confusion as crediting an addressee with a line.
#
# Tight on purpose: the name has to sit between the verb and a
# direction or preposition. "sits down and looks at Dana" has "down"
# straight after the verb and Dana is nobody's object.
tail = part[at:]
# The object has to be a NAME on the sheet or a personal pronoun.
# Matching [A-Z]\w+ under re.I matches any word at all, so "lying down
# on the table" parsed as verb "lying", object "down", direction "on"
# -- and the pose landed on whoever was not acting.
obj = re.match(r"\w+\s+(?:the\s+)?([\w'-]+)\s+"
r"(?:down|up|back|onto|into|on|in)\b", tail, re.I)
if obj and obj.group(1).lower() not in _POSE_DIRECTIONS:
word = obj.group(1)
named = [n for n in people
if n.lower() == word.lower()]
if named:
for n in named:
out[n] = pose
prev = at
continue
if word.lower() not in _OBJECT_PRONOUN:
obj = None # not a name on the sheet, not a pronoun
# A pronoun object: resolvable only when it points at exactly one
# person who is NOT the one acting.
actor = [n for n in people
if re.search(r"\b" + re.escape(n) + r"\b", span, re.I)]
others = [n for n in people if n not in actor]
if obj and len(others) == 1:
out[others[0]] = pose
prev = at
continue
here = [n for n in people
if re.search(r"\b" + re.escape(n) + r"\b", span, re.I)]
if not here:
# No name before the verb: the beat's first-named person is acting,
# and with one person in the shot there is nobody else it can be.
first = next((n for n in people
if re.search(r"\b" + re.escape(n) + r"\b", b, re.I)), None)
here = [first] if first else (people[:1] if len(people) == 1 else [])
for n in here:
out[n] = pose
prev = at
return out
# Actions that CONTRADICT a posture. A latched pose survives until another one is
# staged, and a beat can put somebody back on their feet without ever saying so:
# "Dana takes out a new nappy and places it on the change table" is not something
# anybody does lying down, but it names no posture, so "Dana is still lying down"
# went on being said in every later shot. Reported exactly that way.
#
# Split by how strong the contradiction is. TRAVEL is incompatible with every
# posture -- somebody walking across a room is not sitting, kneeling or lying.
# HANDLING at arm's length is only incompatible with LYING: it is perfectly
# possible to sit or kneel while picking something up.
_HANDLES = re.compile(
r"\b(?:takes?|took|taking|picks?|picked|picking|places?|placed|placing|"
r"puts?|putting|sets?|setting|lifts?|lifted|lifting|carries|carried|"
r"carrying|fetch(?:es|ed|ing)?|hands?|handed|handing|passes|passed|"
r"opens?|opened|opening|closes?|closed|closing|pours?|poured|pouring)\b",
re.I)
def _real_travel(span):
"""Does this span actually move somebody, or does it just LOOK like it?
"takes off her shirt" matched the travel list on "takes" -- the list has it
for "takes her to the car" -- so undressing cleared the posture latch and the
shot after was told nothing about how the body was left. Reported as a squat
not being held: she stood up on her own.
Only this one phrasal is excluded. "walks off" and "runs off" are locomotion
and the particle does not change that; it is the HANDLING verb that makes
"takes off" mean something else entirely."""
for m in _TRAVEL_VERB.finditer(span or ""):
if re.match(r"\s+off\b", (span or "")[m.end():m.end() + 6], re.I) \
and re.match(r"(?:takes?|took|taking)$", m.group(0), re.I):
continue
return True
return False
def posture_cleared(beat, poses):
"""{name} whose latched posture this beat contradicts without restating one.
The beat is the author's own words and outranks a hold: where it puts somebody
on their feet, the hold has to let go or it argues with the shot it is standing
next to."""
# WHAT SHE SAYS IS NOT WHAT SHE DOES. Read whole, this cleared the latch on
# `Kate says: "Someone is coming."` -- "coming" is a travel verb, inside the
# quoted line, about somebody else entirely. The pose was dropped and the next
# shot was told nothing about how the body was left, so she stood up on her
# own. Reported as a squat not being held.
b = _outside_speech(str(beat or ""))
out = set()
if not b:
return out
for name, pose in (poses or {}).items():
m = re.search(r"\b" + re.escape(name) + r"\b", b, re.I)
if not m:
continue
# What this beat has them doing, up to the end of the clause.
stop = re.search(r"[.;!?]", b[m.end():])
span = b[m.end():m.end() + (stop.start() if stop else len(b))]
if _real_travel(span):
out.add(name)
elif pose == "lying down" and _HANDLES.search(span):
out.add(name)
return out
def posture_hold(poses, described):
"""One short sentence keeping people in the pose an earlier beat put them in.
Only for people this shot DESCRIBES -- a pose belonging to somebody the text
does not mention is a pose for nobody, and the model draws the person that
sentence implies. Short on purpose: this is latched, so it lands in every shot
after the one that stages it, and a long clause repeated is the guard bloat
this node was rebuilt to escape."""
# STANDING is not held. It is the default pose -- a model draws a standing
# person unless told otherwise -- so the clause buys nothing and costs a naming
# of the person, and a described person is a person the model draws: naming
# somebody twice in one shot is what put a second copy of them in frame.
# Sitting, kneeling and lying down are the poses that need saying.
who = [(n, p) for n, p in (poses or {}).items()
if n in set(described or []) and p != "standing"]
if not who:
return ""
if len(who) == 1:
return f" {who[0][0]} is still {who[0][1]}."
said = "; ".join(f"{n} is still {p}" for n, p in who[:2])
return f" {said}."
def _state_key(thing):
"""One key for 'door' and 'doors', so a beat acting on either clears both."""
t = (thing or "").lower()
return t[:-2] if t.endswith("es") and t.startswith("hatch") else t.rstrip("s")
# Which way a verb runs. Reported: some distill LoRAs render an action BACKWARDS --
# the beat opens the doors and the shot closes them. A single staged action is
# direction-ambiguous to a model that has learned to treat a clip and its reverse as
# the same clip, which is what time-flip augmentation teaches. Naming the two ends
# settles it, and it is the same thing a removal already does: "off during this shot
# and away by the last frame".
#
# Only verbs that HAVE a direction. "pulls", "draws", "slides" and "swings" do not:
# drawing the curtains closes them and pulling a door can do either, and a wrong
# anchor is worse than none -- it asks for the reversal instead of merely allowing it.
_OPENS = re.compile(r"(?:opens?|opened|opening|unlocks?|unlocked|unlocking|"
r"lifts?|lifted|lifting|raises?|raised)\Z", re.I)
_SHUTS = re.compile(r"(?:closes?|closed|closing|shuts?|shutting|slams?|slammed|"
r"slamming|locks?|locked|locking|lowers?|lowered)\Z", re.I)
def state_changes(text):
"""[(thing, 'open'|'shut'|None)] for the scenery this text actually works.
A state word sitting straight in front of its noun is an adjective describing
the thing, not a verb acting on it: "the closed doors" says nothing happens.
The direction is None where the verb does not carry one."""
out, seen = [], set()
for m in _STATE_ACT.finditer(text or ""):
verb, gap, thing = m.group(1), m.group(2), m.group(3)
if _adjectival(verb, gap):
continue
key = _state_key(thing)
if key in seen:
continue
seen.add(key)
way = ("open" if _OPENS.match(verb) else
"shut" if _SHUTS.match(verb) else None)
out.append((thing.lower(), way))
return out
def state_acts(text):
"""Which of those things this text works, in either direction."""
return [_state_key(t) for t, _ in state_changes(text)]
# TRAVEL between places. A beat that walks somebody from one room to another is a
# staged change with two ends, exactly like a door opening -- and told only where
# it finishes, the shot renders the destination and cuts straight to it. Reported
# as a scene starting in the living room and instantly being in the bedroom, with
# the hallway between them missing.
#
# The same fix direction_anchor uses for doors: name BOTH ends, and the middle if
# the beat gives one.
# The vocabulary lives in the engine, because there were two of these and they
# disagreed -- a cell and a warehouse were rooms to one reader and nowhere to the
# other. The READERS stay separate: this file's search free text with no
# preposition, which is what a scene paragraph gives them, and that is why they
# carry the also-a-verb guard the engine's does not need.
_PLACE = engine.PLACES
# "door" WAS IN THAT LIST and a door is not a room -- it is a thing inside one. So
# "Ana looks at the door", the most ordinary beat there is, moved the whole shot:
# "This shot is in the door, not the room the scene text names." The camera was
# relocated into a door by a character glancing at it. "doorway" stays, because
# standing in one is a real place to be.
# _PLACE is an alternation with no edges of its own, so searching it RAW matches
# inside words: "shallow depth of field" contains "hall", and every camera anchor
# ever written for this node says shallow. That put the film in a hallway it never
# had -- stated on each shot, used as the origin of the first journey, and handed
# to room_tone, which gave a lens setting the acoustic of a cathedral. The readers
# that sit behind a preposition were always safe, because the \s+ before them is
# already a boundary; the two that search free text were not.
_PLACE_WORD = engine._PLACE_WORD
# Place words that are also ordinary verbs. free-text readers cannot tell which
# sense is meant, and "she steps out", "he lands badly", "they study the map" are
# all commoner than the rooms they collide with.
_PLACE_ALSO_A_VERB = engine.PLACE_ALSO_A_VERB
# Words in front of "room" that do NOT make it a particular room -- an article or
# a preposition leaves it as "wherever we already are".
_NOT_A_ROOM_MODIFIER = {"the", "a", "an", "this", "that", "her", "his", "their",
"its", "my", "our", "your", "in", "into", "inside", "of",
"from", "to", "at", "on", "and", "or", "same", "other"}
# A room is usually DESCRIBED, not just named: "the tiled bathroom", "the long
# hallway", "the second-floor landing". Every reader below wanted the article and
# the room word to be adjacent, so one adjective made the whole journey invisible
# -- no ends named, `here` never updated, and the room hold that depends on it
# never fired. Silently: nothing warns, because nothing was found to warn about.
# That is a scene resetting at a cut with no explanation anywhere.
#
# NON-GREEDY on purpose. "at the kitchen door" should still read as the kitchen,
# so the FIRST place word wins and the modifiers are only tried when it does not
# match. Prepositions and articles are excluded, so a match cannot cross into
# "the door OF THE bedroom", and a comma ends it, so it cannot cross a clause.
_MOD = (r"(?:(?!(?:of|the|an?|and|or|to|in|into|from|with|on|at|by|for|her|his|"
r"their|its|my|our|your)\b)[A-Za-z][A-Za-z-]*\s+){0,3}?")
# "to the bedroom", "into the kitchen" -- where it ENDS.
# A room can belong to somebody. "McKenna's bedroom" is the ordinary way to write
# whose room it is, and a determiner list of the/her/his/their/a did not match a
# possessive name -- so the destination of the journey was invisible.
# The determiner, possessive names included, lives in the engine -- two readers
# needed it and got narrower copies weeks apart. See engine.DET_POSS.
_DET_POSS = engine.DET_POSS
# ENTERING is how arrival is written, and it is a verb, not a preposition. The
# list here was prepositions only, so "Dana walks through the home and enters
# McKenna's bedroom" named no destination at all, travel_in returned ('','',''),
# the journey guard stayed silent and the walk rendered as a cut straight to the
# far end. That is the failure the guard exists to prevent, arriving through the
# one door it was not watching.
_GOES_TO = re.compile(r"\b(?:to|into|toward|towards|through\s+to|"
r"enters?|entered|entering|reaches|reached|arrives?\s+(?:at|in)|"
r"steps?\s+into|stepped\s+into)\s+"
+ _DET_POSS + r"\s+" + _MOD
+ r"(" + _PLACE + r")\b", re.I)
# "down the hallway", "along the corridor" -- what it passes THROUGH.
_GOES_VIA = re.compile(r"\b(?:down|along|across|through|up|via|past)\s+"
+ _DET_POSS + r"\s+" + _MOD
+ r"(" + _PLACE + r")\b", re.I)
# "from the living room", "out of the kitchen" -- where it STARTS.
_GOES_FROM = re.compile(r"\b(?:from|out\s+of|leaves?|leaving)\s+"
+ _DET_POSS + r"?\s*" + _MOD
+ r"(" + _PLACE + r")\b", re.I)
# A verb that actually MOVES somebody. "looks to the bedroom" is not travel.
_TRAVEL_VERB = re.compile(
r"\b(?:walk|walks|walked|walking|lead|leads|led|leading|take|takes|took|taking|"
r"go|goes|went|going|head|heads|headed|heading|move|moves|moved|moving|"
r"carry|carries|carried|carrying|follow|follows|followed|following|"
r"step|steps|stepped|stepping|climb|climbs|climbed|climbing|"
r"run|runs|ran|running|come|comes|came|coming|"
# _GOES_FROM already reads "leaves the bedroom" as the start of a journey, so
# the two lists disagreed with each other: one saw an origin, the other did not
# see a move, and the move was dropped. Entering and crossing are the same
# omission from the other end.
r"leave|leaves|left|leaving|enter|enters|entered|entering|"
r"cross|crosses|crossed|crossing|exit|exits|exited|exiting|"
r"return|returns|returned|returning)\b", re.I)
def travel_in(beat):
"""(from, via, to) for a beat that moves somebody between places.
All three may be "". Only when a MOVEMENT verb is present: "she looks to the
bedroom" names a place and goes nowhere."""
b = str(beat or "")
if not b or not _TRAVEL_VERB.search(b):
return ("", "", "")
def _one(rx):
m = rx.search(b)
return re.sub(r"\s+", " ", m.group(1)).strip().lower() if m else ""
to, via, frm = _one(_GOES_TO), _one(_GOES_VIA), _one(_GOES_FROM)
# A place cannot be two ends of the same journey.
if via and via == to:
via = ""
if frm and frm in (to, via):
frm = ""
return (frm, via, to)
def where_hold(here, scene):
"""Say which room the shot is in, once the film has left the one in the scene.
The scene paragraph is stamped into EVERY shot -- it has to be, or a removal
has nothing to scrub -- so a script that walks from the living room to the
bedroom goes on opening every later shot with "A living room." while the beat
has them on the bed. The shot then holds two places at once, and the picture
settles on whichever the model weighs more heavily, differently each time.
That is a scene that keeps changing and resetting.
The author's scene text is NOT edited. This states where the shot is now, and
only where that disagrees with what the scene says, so a script that never
moves is untouched and costs nothing."""
here = (here or "").strip().lower()
if not here:
return ""
txt = str(scene or "")
if not txt.strip():
return ""
# Nothing to correct if the scene already names this room.
if re.search(r"\b" + re.escape(here) + r"\b", txt, re.I):
return ""
# ...and nothing to correct unless the scene names a DIFFERENT place, since a
# scene that names no room is not disagreeing with anything.
if not _PLACE_WORD.search(txt):
return ""
# POSITIVELY PHRASED. This said "in the {here}, not the room the scene text
# names" -- and at cfg 1 there is no negative to carry the "not", so the
# clause pointed the model at the scene paragraph's room in the very shot
# that has to override it. The scene text is already in the prompt saying
# living room; this only has to assert the new one, harder, and let the
# stronger, later, more specific statement win on its own terms.
return (f" This shot takes place in the {here}: the walls, floor, light and "
f"furniture are the {here}'s throughout.")
def travel_anchor(frm, via, to, here=""):
"""Say where the shot starts, what it passes, and where it ends. "" if nowhere.
`here` is the room an earlier beat established, used when the beat names no
origin -- a journey with only a destination is what renders as a cut.
Short on purpose: this lands on travel beats, which already carry an action,
and the node's whole balance problem is continuity crowding the beat out."""
start = frm or here
if not to or start == to:
return ""
# POSITIVELY PHRASED, and it was not. Every version of this clause ended
# "-- one continuous move, not a cut", which puts the word CUT in the prompt
# of the one shot that must not cut. At cfg 1 H3 evaluates no negative, so a
# negation in the positive prompt is just the thing it names: this file says
# so in eleven other places -- "no leggings" would be read as leggings -- and
# then asked for a cut in the clause written to prevent one.
#
# Reported twice as an instant cut across a house, once after the destination
# reader was fixed and the clause was demonstrably in the prompt. Say what the
# shot DOES: the walk happens, on screen, in frame, the whole way.
walk = "the walk between them played out on screen, every step in frame."
if via:
return (f" The shot opens in the {start}, carries along the {via}, and "
f"arrives in the {to}, {walk}") if start else (
f" The shot carries along the {via} and arrives in the {to}, "
f"{walk}")
if not start:
return ""
return f" The shot opens in the {start} and arrives in the {to}, {walk}"
_IS_IN = re.compile(r"\b(?:in|inside|within|at)\s+(?:the|her|his|their|a)\s+"
+ _MOD + r"(" + _PLACE + r")\b", re.I)
def first_place(text):
"""The first place this text names at all. "" when it names none.
place_named wants "in the kitchen"; a scene paragraph is more often just "A
living room." with no preposition to hang on."""
for m in _PLACE_WORD.finditer(str(text or "")):
got = re.sub(r"\s+", " ", m.group(0)).strip().lower()
# WORDS THAT ARE ALSO VERBS ARE NOT PLACES HERE. "McKenna steps out of
# the far room" read as the flight of STEPS -- the same collision as
# "door", found the same way, in the same sentence. This reader searches
# free text with no preposition in front of it, so it cannot tell a noun
# from a verb; the ones that collide are simply not allowed to win here.
# place_named still reads them, because "at the top of the steps" has a
# preposition and is unambiguous.
if got in _PLACE_ALSO_A_VERB:
continue
# ...and a bare "room" names nowhere. "Ana walks into the room" says she
# goes inside, not which room, and taking it as a place produced "This
# shot is in the room, not the room the scene text names" -- a sentence
# that contradicts itself. QUALIFIED it is a real place, and the word in
# front is what qualifies it: the back room, the far room, the next room
# are each somewhere distinct from where we just were.
if got == "room":
before = re.search(r"(\w+)\s+$", str(text or "")[:m.start()])
word = before.group(1).lower() if before else ""
if word in _NOT_A_ROOM_MODIFIER or not word:
continue
return (word + " room")
return got
return ""
def place_named(text):
"""The place this text says somebody is IN, without travelling. "" if none."""
m = _IS_IN.search(str(text or ""))
return re.sub(r"\s+", " ", m.group(1)).strip().lower() if m else ""
def direction_anchor(changes):
"""Say which end of a staged change is which, for the ones that have a direction.
Two at most, and the caller trades these against the held states: a shot carrying
four continuity sentences is a shot that has stopped being about its beat."""
said = []
for thing, way in changes:
if not way:
continue
start, end = ("shut", "open") if way == "open" else ("open", "shut")
said.append(f"The {thing} {'are' if thing.endswith('s') else 'is'} {start} at "
f"the first frame and {end} by the last.")
if len(said) == 2:
break
return (" " + " ".join(said)) if said else ""
def stated_states(text):
"""(thing, state) for every scenery state this text asserts but does not stage."""
# A thing this same text WORKS is not a thing standing in a state: "slams the
# tailgate shut" reads as both, and the action is the true reading. Left to the
# caller this came back twice, once held and once anchored, disagreeing.
out, seen = [], set(state_acts(text))
for pat, order in ((_STATE_ADJ, "sn"), (_STATE_PRED, "ns")):
for m in pat.finditer(text or ""):
if order == "sn":
state, gap, thing = m.group(1), m.group(2), m.group(3)
# The same determiner test, from the other side: with one, this is
# somebody closing the doors, and the state is not standing at all.
if not _adjectival(state, gap):
continue
else:
state, thing = m.group(3), m.group(1)
key = _state_key(thing)
if key in seen:
continue
seen.add(key)
out.append((thing.lower(), state.lower()))
return out
# Getting OUT of a vehicle. A person leaving a van opens a door to do it, so a beat
# staging an exit and a state saying the doors are shut are two instructions that
# cannot both be followed. The beat wins -- it stages an action, and an action beats
# a state -- and the hold is left arguing with the script it is supposed to serve.
#
# The node must not touch the wording either way: those are the author's words, and
# "out of the van" may be exactly what they mean. So it says so instead. Three rounds
# of this went by as a silent bad render when one line of info would have placed it.
_EXIT_VEHICLE = re.compile(
r"\b(?:get|gets|got|climb(?:s|ed)?|step(?:s|ped)?|jump(?:s|ed)?|slid(?:e|es)|"
r"come|comes|came|walk(?:s|ed)?|hop(?:s|ped)?|pile)\s+(?:down\s+|back\s+)?out\s+"
r"of\s+(?:the\s+|a\s+|an\s+|his\s+|her\s+|their\s+|its\s+)?"
r"(?:back\s+of\s+(?:the\s+|a\s+)?)?(?:van|car|truck|cab|vehicle|lorry|bus)\b"
r"|\bexits?\s+(?:the\s+|a\s+)?(?:van|car|truck|cab|vehicle)\b"
r"|\bout\s+of\s+(?:the\s+|a\s+)?(?:van|car|truck|cab)\b", re.I)
def exits_vehicle(text):
"""Does this beat stage somebody getting out of a vehicle?"""
return bool(_EXIT_VEHICLE.search(text or ""))
_PICTURE_TAG = re.compile(r"<\s*picture[\s_\-]*(\d+)\s*>", re.I)
def renumber_reference_tags(text, wired):
"""Rewrite <Picture N> from INPUT number to position in the reference roster.
The roster is packed dense -- the wired images become picture 1, 2, 3 in the
order of their sockets -- but nobody writing a sheet knows that. They write the
number on the socket, which is what the README documents. Wire ref_image_1 and
ref_image_3 and the two conventions disagree: <Picture 3> names nothing in a
roster of two, so the tag was stripped and the image was dropped in silence.
`wired` is the socket numbers that actually have an image, in socket order. With
no gaps this is the identity mapping and nothing changes, which is why the fault
stayed hidden -- everybody fills the sockets from the top until they don't."""
seat = {slot: i + 1 for i, slot in enumerate(wired)}
if not text or all(k == v for k, v in seat.items()):
return text
return _PICTURE_TAG.sub(
lambda m: (f"<Picture {seat[int(m.group(1))]}>"
if int(m.group(1)) in seat else m.group(0)), text)
def unwired_reference_tags(text, wired):
"""Tag numbers naming a socket with no image on it. Sorted, no repeats."""
return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")}
- set(wired or ()))
def handoff_rides_as_ref(handoff, refs, ref_noise_aug):
"""Is the shot handoff about to be encoded as a subject reference?
Mirrors the demotion in build_conditioning. Read in the render loop as well,
because the text has to claim the picture and the text is written up there."""
return bool(handoff is not None and refs
and not (ref_noise_aug is None
or float(ref_noise_aug) >= KEYFRAME_SAFE_AUG))
def handoff_claim(n):
"""Name the demoted handoff as this shot's opening frame.
Below KEYFRAME_SAFE_AUG the handoff stops being a keyframe and is encoded as an
extra reference -- and it was going in unclaimed, on the reasoning that a first
frame is not a subject and needs no tag. It needs one HERE. In the reference
rows it is not a first frame any more, it is picture N of N, and the rule that
governs those is the node's oldest: a picture the prompt names is that subject,
and a picture it never names is ANOTHER subject.
So the last shot of a run carried a second person wearing the previous shot's
clothes and face -- reported as a duplicate at the end of the video, and only
ever below 0.99, which is why lowering the aug to strengthen identity was what
produced the twin."""
return (f" <Picture {n}> is the frame this shot opens on: the same place and the "
f"same people, one moment earlier, carried forward rather than joined by "
f"anybody new.")
def room_claim(n, present, joining):
"""Claim a handoff carried as a reference because somebody NEW is in the shot.
The keyframe used to be thrown away here, and throwing it away is what the
node's own note in build_conditioning warns about: with no handoff the VLM is
never shown where the shot left off and re-imagines the scenery -- same place,
new room. Reported as the scene not staying the same between shots.
A keyframe and a reference are different instruments. A keyframe IS frame one,
so a newcomer absent from it has to walk in from nowhere, which is the bug the
fresh start was for. A reference only supplies appearance, so the same picture
carries the room and the people already in it while the newcomer is simply
there at the first frame.
Claimed, and specifically. An unclaimed picture of somebody is another person
who looks like them, and the standing claim is worse than nothing here: it says
the shot is joined by nobody new, in the one case where it is."""
said = (f" <Picture {n}> is this room a moment earlier: the same walls, floor, "
f"furniture and light, from the same camera.")
if present:
said += (f" {' and '.join(present)} "
f"{'are the people' if len(present) > 1 else 'is the person'} there.")
if joining:
said += (f" {' and '.join(joining)} {'are' if len(joining) > 1 else 'is'} in "
f"this room too, already in place at the first frame.")
return said
def state_hold(pairs):
"""One sentence putting those states at the first frame instead of in the action.
Two at most. These sentences are continuity, and continuity that outgrows the
beat is what the beat stops being about."""
said = []
for thing, state in pairs[:2]:
plural = thing.endswith("s")
# BOUNDED, for the same reason a removal says "by the last frame": "stays
# closed" has no end on it, and a state with time left over is a state
# something can happen to before the shot is out.
said.append(f"The {thing} {'are' if plural else 'is'} already {state} at the "
f"first frame and {'stay' if plural else 'stays'} {state} for the "
f"whole shot.")
return (" " + " ".join(said)) if said else ""
def rigid_hardware(text):
"""Is the hardware here the kind that cannot flex?"""
return bool(_RIGID_HARDWARE.search(text or ""))
def restraint_present(text):
"""Is a restraint being applied or worn, in this text?
Plain hardware counts on its own. Ambiguous hardware needs a binding verb or a
body part alongside it, so a chain-link fence and a leather belt do not arm a
continuity rule about restraints."""
t = text or ""
if _RESTRAINT_PLAIN.search(t):
return True
# SAME CLAUSE. Both halves were searched across the whole text, however far
# apart: a sheet listing a belt and a beat saying "she sits with her legs
# crossed" satisfied both, so the belt became restraint hardware and the hold
# latched from there -- every later shot told to keep fastened something that
# was never a restraint. The qualifier has to be near the hardware to qualify it.
for part in re.split(r"(?<=[.;!?])\s+", t):
if _RESTRAINT_MAYBE.search(part) and (_BINDING_VERB.search(part)
or _BODY_PART.search(part)):
return True
# ...OR the hardware is fastened to something that does not move. That is
# what a restraint IS, and the verb list could not see it: _BINDING_VERB
# holds participles only -- "chained", "clipped", "bolted" is not even in
# it -- so "the guard chains her collar to the wall", "clips the chain to a
# ring in the wall" and "a short chain holds her collar to the wall" all
# came back with no restraint at all, and the anchor reader is only ever
# consulted once one is latched. Six of fourteen ordinary phrasings.
#
# _ANCHOR_POINT is safe to lean on here because it now demands a fastening
# verb of its own, so this cannot fire on somebody merely walking to a wall.
if _RESTRAINT_MAYBE.search(part) and _ANCHOR_POINT.search(part):
return True
return False
def names_any(text, tokens):
"""Does `text` name any of these items?"""
return any(re.search(r"\b" + re.escape(t) + r"\b", text or "", re.I)
for t in (tokens or []) if t)
# Where a removal verb's object ENDS. "pulls off her coat and drops it, showing the
# jumper" takes off the coat; the jumper is what becomes visible. The old version of
# this node matched garment words anywhere in the beat and took both off, which is
# the failure that made prose inference untrustworthy.
def person_tags(text, objects=None):
"""The <Picture N> tags that belong to a PERSON rather than to an object.
Decided by what stands immediately BEFORE the tag. A name -- capitalised, with or
without its colon -- means the picture is of that person: "Nora: <Picture 1>",
"Nora <Picture 1> in a grey coat". A lowercase noun means it is a picture OF the
thing it is standing next to: "a silver locket <Picture 2>".
That distinction is what lets an object's reference come off with the object. A
person's tag has to survive a removal that shares its fragment, or the shot loses
its identity reference; an object's tag has to go, or it keeps asserting the thing
that was just taken off."""
out = []
for m in _PICTURE_TAG.finditer(text or ""):
before = (text[:m.start()]).rstrip().rstrip(",").rstrip()
w = re.search(r"([\w'-]+)$", before)
# An object owns the tag only when a lowercase NOUN stands immediately before
# it -- "a silver locket <Picture 2>". Everything else is the person's: a
# name, a colon, an age ("Kate is 20, <Picture 1> blonde crop top"), or
# nothing at all. Erring this way on purpose, because losing a person's
# identity reference costs the shot its face, while an object tag left behind
# only keeps describing something already taken off.
if not before.endswith(":") and w:
head = w.group(1)[:1]
if head.isalpha() and head.islower():
continue # the picture belongs to the object
# ...and the same claim written the other way round. "<Picture 2> a chastity
# belt" puts the tag in FRONT, where there is nothing before it to read, so
# the rule above called it the person's and the tag survived the belt going
# under the jeans -- which kept sending the belt's picture into every covered
# shot, to be drawn on top of them.
#
# Only ever for a tag standing directly in front of the thing being REMOVED,
# which is the one case where the answer is not in doubt. A tag with nothing
# before it and nothing of ours after it stays the person's, as it was.
# Only when there is NOTHING in front of it. "Kate is 20, <Picture 1> blonde
# crop top" also puts a tag before a garment, and that one is hers -- the age
# standing in front is what says so. Reading ahead there would take her
# identity reference off with the top.
if objects and w is None and not before.endswith(":"):
ahead = (text[m.end():]).lstrip()
if any(re.match(r"(?:(?:a|an|the|her|his|their)\s+)?(?:[\w-]+\s+){0,2}"
+ re.escape(o) + r"\b", ahead, re.I) for o in objects if o):
continue
out.append(m.group(1))
return out
_OBJECT_END = re.compile(r"(?:,|;|\.|\bexposing\b|\brevealing\b|\bshowing\b|\bleaving\b|"
r"\bto\s+expose\b|\bto\s+reveal\b|\bthen\b|\buntil\b)", re.I)
# Words that sit in a removal's object span but are never the thing that comes off:
# grammar, the prepositions that place a garment, and the body it is placed on.
# "cuts the tight top away from her back" names ONE garment; the rest is syntax and
# anatomy. Without this, every word the beat happened to share with the scene was
# taken off -- "the tight and the her and the back come off during this shot".
_NOT_A_GARMENT = frozenset("""
the a an and or her his its their our your this that these those
off from over under onto into out down up away through across behind
front side left right rest way bit end edge
back neck chest waist hips hip wrist wrists ankle ankles arm arms hand hands
leg legs thigh thighs knee knees foot feet shoulder shoulders head face mouth
lips hair skin body torso stomach belly chin jaw eyes ear ears
floor ground wall room air
""".split())
# Where a scene's wardrobe entry ENDS. A garment word is the HEAD of its phrase --
# "black boots," "grey coat and", "wool scarf." -- while a modifier is followed by
# more of the phrase ("tight white crop top": tight, white and crop all fail this,
# top passes). Adjectives cannot be listed, so test position instead of vocabulary.
_ENTRY_END = re.compile(r"^\s*(?:[,;.!?]|$|(?:and|over|under|beneath|above|with|plus)\b)",
re.I)
# Hardware, not clothing. Inference never takes a restraint off: the standing rule is
# that once one goes on it stays on, and an explicit `remove:` is the only thing that
# clears it. A beat that cuts a rope must not silently unlock the cuffs as well.
_RESTRAINT_WORD = re.compile(
r"^(?:handcuffs?|cuffs?|shackles?|manacles?|chains?|ropes?|cords?|straps?|"
r"collars?|gags?|blindfolds?|restraints?|bindings?|tape|ties?|harness|"
r"straitjacket|spreader|hogtie|clamps?|clips?)$", re.I)
# A <Picture N> immediately after a word, so the entry-end test can look past an
# object's own reference to the comma that actually ends its entry.
_LEADING_TAG = re.compile(r"^\s*<\s*picture[\s_\-]*\d+\s*>", re.I)
def _is_entry_head(word, scene):
"""Is `word` the head of a wardrobe entry in the scene, rather than a modifier
inside one or a fragment of a hyphenated compound?"""
for m in re.finditer(r"\b" + re.escape(word) + r"\b", scene, re.I):
# "tight" inside "skin-tight" is half a word, not a garment.
if m.start() and scene[m.start() - 1] == "-":
continue
if m.end() < len(scene) and scene[m.end()] == "-":
continue
# An object's own reference sits between the noun and the comma that ends its
# entry -- "a silver locket <Picture 2>, green jacket" -- so the entry-end
# test has to look past it. Without this, a tagged object is never the head of
# anything, which means auto_remove can never take it off: it needed an
# explicit `remove:` line while an untagged one came off from the prose.
tail = _LEADING_TAG.sub("", scene[m.end():], count=1)
if _ENTRY_END.match(tail):
return True
return False
def _modifier_of_a_named_entry(word, span, scene):
"""Is `word` a MODIFIER of a longer garment the same span already names?
"Dan pulls off her jeans shorts" names one garment. But "jeans" is also the
head of Dan's own entry, so the reader matched it against his line and took
HIS jeans off as well -- in a beat that never mentions him. His trousers came
off automatically, and stayed off.
The span is what the beat says is coming off. If the word is immediately
followed there by another garment word, it is describing that one, not naming
a second: the phrase is "jeans shorts", and only "shorts" is the head.
"""
m = re.search(r"\b" + re.escape(word) + r"\b\s+([\w-]{3,})", span or "", re.I)
if not m:
return False
nxt = m.group(1).lower().strip("-")
# ...and only when that following word is itself a garment the scene lists,
# so "jeans and boots" -- two garments -- is not read as one.
return bool(nxt and nxt not in _NOT_A_GARMENT and _is_entry_head(nxt, scene))
# A garment MOVED rather than taken off: pulled down, pushed up, shoved aside, left
# hanging open. It is still on the body and still in the picture, so it has to go on
# being described -- but described as it now is, or the next shot puts it back the way
# the sheet says it was worn.
# "back up" first, so it is matched whole. Written as two words it is the commonest
# way anybody says a garment is being put right, and matching only "back" left the
# trailing "up" outside the pattern -- so the restore looked like a new displacement.
# DISPLACEMENT AND RESTORE LIVE IN THE ENGINE, with the garment vocabulary
# they both read. Split across two files this pair went wrong three ways in
# one day; together they cannot disagree about what a garment is called.
_DISPLACE = engine._DISPLACE
scene_name_for = engine.scene_name_for
displaced_garments = engine.displaced_garments
puts_it_back = engine.puts_it_back
restored_garments = engine.restored_garments
def displaced_hold(items):
"""Say where a moved garment now sits, so the next shot does not put it back.
Without this the garment is described by the sheet in the state it was WORN, and
the sheet is re-stamped into every shot -- so shorts pulled down are pulled back
up by the next beat, or come back looking like a different pair."""
if not items:
return ""
said = ", ".join(f"the {thing} {how}" for thing, how in items[:2])
return f" Still on the body and {said}, left exactly where the beat put them."
# A REQUEST is not the thing happening. "McKenna asks Dan to take the chastity belt
# off" contains a removal verb and a garment the scene says is worn, which is all
# infer_removals needs -- so asking for it stripped it, and the shot was then told the
# belt comes off and is away by the last frame. She asks, and it falls off.
#
# Worse where the answer is no: "she asks him to remove the belt. He shakes his head."
# took the belt off anyway, which is the script's meaning inverted.
#
# Only the verb inside the REQUEST is discounted. A beat that asks and is then obeyed
# in its own words -- "she asks him to unlock it, and he does" -- still has a removal
# in the second half, and that half is read normally.
_ASK_VERB = (r"asks?|asked|asking|begs?|begged|begging|pleads?|pleaded|pleading|"
r"wants?|wanted|wishes|wished|tells?|told|orders?|ordered|demands?|"
r"demanded|whispers?|whispered|says?|said|shouts?|shouted|screams?|"
r"screamed")
_REQUEST = re.compile(
# "asks him TO take it off"
r"\b(?:" + _ASK_VERB + r")\b[^.;!?]{0,60}?\bto\s+(?=[a-z])"
# "asks FOR the belt to come off"
r"|\b(?:asks?|asked|begs?|begged|pleads?|pleaded)\b[^.;!?]{0,40}?\bfor\b"
# "asks IF he will unlock it" / "asks WHETHER he can" -- an indirect question
# has no "to" at all, so the first branch never saw it.
r"|\b(?:asks?|asked|asking|wonders?|wondered)\b[^.;!?]{0,40}?\b(?:if|whether)\b",
re.I)
# SPEECH is a request too. "McKenna approaches Dan. \"Will you take the chastity belt
# off?\"" has no asking verb before the removal at all -- the words are quoted, and a
# line of dialogue asking for a thing is not the thing happening. Nor is an imperative:
# "\"Take the chastity belt off.\"" is her telling him to, not him doing it.
#
# Only what is INSIDE the quotes. A beat that quotes a request and then narrates the
# act -- "\"Take it off.\" He unlocks the belt." -- still has a removal outside them.
_QUOTED = re.compile(r"\"[^\"]*\"|“[^”]*”|<d>.*?</d>", re.S)
def _in_quotes(text, at):
"""Is position `at` inside a span of dialogue?"""
return any(m.start() <= at < m.end() for m in _QUOTED.finditer(text or ""))
# A question is a request whatever introduced it: "Will you take it off?" is asking,
# and so is "Can you", "Would you", "Could you". Judged by the question MARK, which
# is the one reliable mark of an interrogative in prose.
_QUESTION = re.compile(r"[^.;!?]*\?")
def _in_a_question(text, at):
"""Is the removal verb at `at` inside a sentence that ends in a question mark?"""
return any(m.start() <= at < m.end() for m in _QUESTION.finditer(text or ""))
def _in_a_request(text, at):
"""Is the removal verb at `at` inside a request rather than an action?"""
# Asked in someone's own words, or asked as a question: either way, not done.
if _in_quotes(text, at) or _in_a_question(text, at):
return True
start = max((m.end() for m in _REQUEST.finditer(text or "") if m.end() <= at),
default=None)
if start is None:
return False
# Only up to the end of that clause: a request in one sentence does not reach
# into the next, where the thing may actually be done. ", and he removes it"
# is a new clause too, so a comma before a conjunction ends the request as
# surely as a full stop does -- otherwise asking and then being obeyed inside
# one sentence reads as pure request and the removal is lost.
stop = re.search(r"[.;!?]|,\s*(?:and|then|so|but)\b", (text or "")[start:])
return at <= (start + stop.start() if stop else len(text or ""))
def infer_removals(beat, scene):
"""Garments this beat takes off, read from its own prose. [] when none.
Two conditions, both required, because a wrong removal is worse than a missed
one: the beat has to contain a REMOVAL verb, and the thing named has to be
something the SCENE already says is worn. A beat cannot take off what the
character was never described wearing.
Only the verb's own object counts -- the span from the verb to the next clause
boundary. That is what keeps "pulls off her coat, showing the jumper" to the
coat."""
if not beat or not scene:
return []
found = []
for m in _REMOVAL_PROSE.finditer(beat):
# Asked for is not done. See _in_a_request.
if _in_a_request(beat, m.start()):
continue
tail = beat[m.end():]
cut = _OBJECT_END.search(tail)
span = tail[:cut.start()] if cut else tail
# In the TRAILING form the object sits between the verb and the particle --
# "takes her jacket off" -- so the particle ends the object, and what comes
# after it is a new clause: in "takes her jacket off and drops it on the
# chair" the chair is furniture the beat mentions, not something worn.
#
# A verb before the particle means the particle is not ours. "kicks the
# chair and Mike walks off" ends in "off", but it is the walking that is off,
# and reading that as a removal deleted the chair from the scene.
#
# Neither test applies to a verb that already swallowed its particle
# ("pulls off her coat") or needs none ("unzips her jacket and pulls it
# off"), where the object follows the verb and the sentence runs on.
if not (re.fullmatch(_UNDO_VERB, m.group(0), re.I)
or re.search(r"\b(?:off|away|out\s+of|down)$", m.group(0), re.I)):
part = re.search(r"\b(?:off|away)\b", span, re.I)
if part:
if _HAS_VERB.search(span[:part.start()]):
continue
span = span[:part.start()]
for word in re.findall(r"\b[\w-]{3,}\b", span):
low = word.lower().strip("-")
if not low or low in found:
continue
# Grammar, prepositions and anatomy are not garments.
if low in _NOT_A_GARMENT:
continue
# Hardware is cleared by an explicit `remove:` and by nothing else.
if _RESTRAINT_WORD.match(low):
continue
# It has to be worn: the HEAD of something the scene lists, not a
# modifier inside it and not half of a hyphenated compound.
if not _is_entry_head(word, scene):
continue
# "her jeans shorts" is ONE garment. "jeans" there is a modifier, but it
# is also the head of Dan's own entry, so it matched his line and took
# HIS trousers off in a beat that never mentions him -- and they stayed
# off, because a removal is permanent.
if _modifier_of_a_named_entry(word, span, scene):
continue
# ...and not a person or a place.
if re.search(r"\b" + re.escape(word) + r"\b\s*(?:is|was|walks|stands|sits|=)",
scene, re.I):
continue
found.append(low)
# A garment the beat says is EXPOSED cannot also be one it takes off. "Pulls off
# her coat to show the jumper underneath" ran the removal verb's object span past
# "to show" and took the jumper with it -- so the one garment the beat exists to
# reveal was scrubbed from the wardrobe, and every shot after it described bare
# skin where the jumper was. Reported as a removal going straight past what the
# sheet said was underneath.
#
# The comma form (", showing the jumper") already ended the span correctly, which
# is why this only bit one phrasing of the two.
shown_off = exposed_by(beat, scene)
return [f for f in found if f not in shown_off]
# Clothing, for the one case that names no garment at all: "strips out of their
# clothes". A vocabulary is the wrong tool for reading a removal out of prose -- which
# is why infer_removals tests POSITION instead -- but here the beat says nothing about
# WHAT comes off, so the only place left to read it from is the wardrobe itself.
#
# Anything this misses stays described, and the note says which entries were cleared,
# so a gap is visible rather than silent.
# GARMENTS LIVE IN THE ENGINE. There were two vocabularies here and in
# engine.py and they disagreed -- this one had thong and no chastity belt,
# that one had chastity belt and matched the bare "belt" inside it. Both
# were fixed on the same day from opposite ends. One list now, and the two
# readers that need different answers are built on it rather than on each
# other: garment_words gives head words for tracking, garments_in keeps the
# adjectives for the text.
garments_in = engine.garment_words
# Which body region a garment covers -- read from the engine's own table so
# frame scoping and the bare/undress logic can never disagree about where a
# garment sits.
region_of = engine.region_of
# A beat that undresses somebody completely without naming one garment. Every other
# removal path needs the thing to be named; this is the case where the SCRIPT does not
# name it, so nothing came off and the scene went on listing the whole wardrobe --
# which is re-stamped into every later shot, so the clothes came back on.
#
# "naked eye" and "naked flame" are not people.
_NAKED_CUE = re.compile(
r"\bnaked\b(?!\s+(?:eye|flame))"
r"|\bnude\b|\bin\s+the\s+nude\b"
r"|\bundress(?:es|ed|ing)?\b"
r"|\bstrips?\s+(?:out\s+of|off|down|naked|bare)\b|\bstripp(?:ed|ing)\s+"
r"(?:out\s+of|off|down|naked|bare)\b"
r"|\btakes?\s+(?:everything|it\s+all|all\s+of\s+it|the\s+lot)\s+off\b"
# A GENERIC garment word as the object. "Sam takes off his clothes" is the
# commonest way anybody writes this, and it named no garment the sheet lists,
# so every other path had nothing to remove: his wardrobe stayed in the scene
# text and was re-stamped into every later shot, which is the clothes still
# being on. Her named garments came off; his generic ones never did.
r"|\b(?:takes?|took|taking|pulls?|pulled|peels?|peeled|sheds?|shed|"
r"removes?|removed|gets?|got|slips?|slipped)\b"
r"(?:\s+(?:off|out\s+of))?\s+(?:his|her|their|its|the|all\s+(?:his|her|their))?"
r"\s*(?:clothes|clothing|garments|things|kit|outfit|gear)\b"
r"(?:\s+off)?"
# A bare "strips" only when it takes NO object: "Sam strips." undresses him,
# "she strips the paint off the door" and "strips a length of tape" do not.
# The object is what tells them apart, so anything but a clause end is out.
r"|\bstrips?\b(?=\s*[.,;!?]|\s*$)"
r"|\bstripp(?:ed|ing)\b(?=\s*[.,;!?]|\s*$)"
r"|\bwearing\s+nothing\b|\bwith\s+no\s+clothes\b|\bbare\s+skin\b", re.I)
def strips_who(beat, cast):
"""Who this beat undresses. [] when it cannot tell.
strips_bare only answers WHETHER somebody ends up with no clothes on. The
wardrobe was then read off the whole shot sheet, so in a shot describing two
people BOTH were stripped -- one character undressing made the other undress
too. Reported as the second character mimicking the first.
The subject is the name before the cue, the same reading posture_in uses. With
one person in the shot there is nobody else it can be."""
people = [n for n in (cast or []) if n]
b = str(beat or "")
if not people or not b:
return []
if len(people) == 1:
return people[:1]
m = _NAKED_CUE.search(b)
if not m:
return []
# The SUBJECT is the span between the last clause boundary and the cue, not the
# nearest name: "McKenna and Dan undress" is a compound subject and both are
# stripped, while "McKenna watches as Dan undresses" is Dan alone.
before = b[:m.start()]
cut = max((c.end() for c in
re.finditer(r"[.;!?]\s+|,\s*|\s+(?:as|while|and then|then|but)\s+",
before)), default=0)
span = before[cut:]
here = [n for n in people
if re.search(r"\b" + re.escape(n) + r"\b", span, re.I)]
if here:
return here
# No name before it: the beat's own first-named person is acting.
first = next((n for n in people
if re.search(r"\b" + re.escape(n) + r"\b", b, re.I)), None)
return [first] if first else []
def strips_bare(text):
"""Does this beat say somebody ends up with no clothes on?"""
return bool(_NAKED_CUE.search(text or ""))
# Said once, in place of listing every garment separately. Restraints are named
# because they do NOT come off here, and a sentence about everything coming off would
# otherwise be read as including them.
BARE_HOLD = (" Everything worn comes off during this shot and is away by the last "
"frame, leaving bare skin from the shoulders down; whatever is fastened "
"to the body stays fastened exactly as it was.")
def missing_removals(beat, scene, already):
"""Garment words the SCENE still describes, in a beat whose prose takes
something off and which carries no `remove:` line for them.
Reports; never acts."""
if not scene or not _REMOVAL_PROSE.search(beat or ""):
return []
hits = []
for word in re.findall(r"\b[\w-]{4,}\b", beat or ""):
low = word.lower().strip("-")
if not low or low in already or low in hits or low in _NOT_A_GARMENT:
continue
# Same discipline as the inference: the head of an entry, not a modifier
# inside one. Reporting "back" and "her" as unremoved garments is noise
# that buries the one line that matters.
if _is_entry_head(word, scene):
hits.append(low)
# Words that are in the scene because they are the PERSON or the place, not
# something worn. A name or a room is not a garment.
return [h for h in hits if not re.search(
r"\b" + re.escape(h) + r"\b\s*(?:is|was|walks|stands|sits)", scene, re.I)]
def extract_directives(beat):
"""(beat text with directive lines taken out, [removed tokens], [added phrases]).
`add:` is the other half of `remove:`, and it exists because of a specific
failure: a scene that lists every layer at once -- coat, jumper, shirt --
tells the model the character is wearing all of them simultaneously, with
nothing saying which is hidden. The keyframe pins the first frame, so early
frames look right; by the last frame only the text is governing, and the under
layer starts showing through the top one.
So describe what is VISIBLE, and add a layer when it becomes visible:
Dan cuts off her jacket and throws it away.
remove: jacket
add: her white shirt underneath
The added phrase is appended to the scene from that shot onward, in your words,
unchanged."""
removed, added = [], []
def take_removed(m):
removed.extend(t.strip() for t in m.group(1).split(",") if t.strip())
return ""
def take_added(m):
phrase = m.group(1).strip()
if phrase:
added.append(phrase)
return ""
body = _ADD_LINE.sub(take_added, _REMOVE_LINE.sub(take_removed, beat or ""))
return re.sub(r"\n{2,}", "\n", body).strip(), removed, added
def extract_removals(beat):
"""Back-compatible shim: (body, removed tokens)."""
body, removed, _ = extract_directives(beat)
return body, removed
def hide_item(text, items):
"""Take the named items out of a sheet line, keeping everything else.
SURGICAL, unlike scrub_removed, which drops the whole comma-separated
fragment -- that is right for a garment that has come off and wrong here: it
took "green dress, steel collar" down to nothing and the person's line with
it, leaving shots with nobody described in them.
This removes the item and the adjectives attached to it, and stops. A
fragment that held only that item disappears; a fragment holding anything
else keeps the rest. A fragment carrying the person's LABEL ("McKenna: she,
22") never disappears, whatever else is in it."""
if not text or not items:
return text
out_lines = []
for line in str(text).split("\n"):
frags, kept = line.split(","), []
for n, frag in enumerate(frags):
new = frag
for item in items:
if not str(item).strip():
continue
# The item, plus any adjectives sitting directly in front of it.
new = re.sub(r"(?:\b\w+[\w-]*\s+){0,3}?\b"
+ re.escape(str(item).strip()) + r"\b",
"", new, flags=re.I)
# THE PRINT ON A COVERED GARMENT GOES WITH THE GARMENT.
#
# Reported: a thong under shorts, lettering on the thong, and the
# lettering rendered ON THE SHORTS. The removal above takes the item
# plus up to three words IN FRONT of it and nothing behind, so
# "denim shorts, a black thong with \"PRINCESS\" across the front."
# became
# "denim shorts, with \"PRINCESS\" across the front."
# -- the garment deleted out from under its own modifier, which then
# sits in the list right after the shorts. A described print is a drawn
# print and it is drawn on whatever garment is still there to carry it.
# It survived the emptiness test below because that only strips
# articles: the leftovers read as '"PRINCESS"acrossthefront'.
#
# So a fragment this removal EMPTIED OF GARMENTS goes whole. Narrowly:
# only when something was actually removed from it, only when no
# garment word is left -- "a thong and denim shorts" keeps the shorts,
# which is the case hide_item exists to protect -- and never when the
# fragment carries the person's LABEL, which would take their name out
# of the sheet with it.
if (new != frag and ":" not in frag
and not garments_in(new) and re.search(r"\w", new)):
continue
# An article left standing alone ("a", "the") is not a garment,
# so the fragment goes. A fragment carrying the person's LABEL
# never reaches this test empty -- the removal takes the item and
# leaves the name -- which is why there is no separate guard for
# it. One was written; a disable-check showed it never fired, and
# a guard that looks protective and is not is worse than none.
if not re.sub(r"\b(?:a|an|the|and|with|in)\b|[\s,.;]", "", new):
continue
kept.append(new)
joined = ",".join(kept)
# Tidy the seams the removal leaves: doubled commas and spaces.
joined = re.sub(r"\s*,\s*,+", ",", joined)
joined = re.sub(r"\s{2,}", " ", joined).strip()
joined = re.sub(r",\s*([.;]|$)", r"\1", joined)
# The seams a removal leaves at the LABEL. "Ana: chastity belt, jeans"
# becomes "Ana: , jeans" and "Ana: a chastity belt" becomes "Ana: ."
# Both are malformed, and a sheet entry the reader cannot parse is
# worse than one item missing from it.
joined = re.sub(r":\s*,\s*", ": ", joined)
joined = re.sub(r":\s*(?=[.;]|$)", "", joined)
# The removed fragment may have carried the line's full stop away
# with it. terminate_lines expects one, and without it the next
# sheet line welds onto this one -- a name fused to the end of an
# attribute list reads as one more item in it.
if (line.rstrip().endswith((".", "!", "?")) and joined
and not joined.endswith((".", "!", "?"))):
joined += "."
out_lines.append(joined)
return "\n".join(out_lines)
def scrub_removed(text, tokens):
"""Drop the parts of `text` that name a removed item.
Comma-separated fragments first, because that is how a scene lists what someone
is wearing ("blonde, 20, grey jacket, black boots"). A sentence that is left
with no words at all is dropped whole, so "She wears a red coat." disappears
rather than becoming a stub."""
if not text or not tokens:
return text
live = [t for t in tokens if t]
pats = [re.compile(r"\b" + re.escape(t) + r"\b", re.I) for t in live]
# A scene lists what someone wears as comma-separated NOUN PHRASES ("blonde,
# pale blue cotton shirt, heavy black waxed canvas jacket"). For those, the
# whole entry goes: trimming a fixed number of modifiers off the front left
# orphans like "heavy black waxed" sitting in the list, and an orphan
# description is read as some garment -- which is a garment coming back.
#
# A fragment with a VERB in it is prose, not a list entry, and there the entry
# is only part of the sentence, so it gets the surgical treatment below.
kept = []
for sent in re.split(r"(?<=[.!?])\s+", text):
# Ownership is decided on the WHOLE SENTENCE, then applied per fragment.
#
# person_tags reads what stands immediately before a tag, and splitting on
# commas throws that away: " <Picture 2> a chastity belt" has nothing in front
# of it once detached, so the tag fell back to "the person's" and survived the
# belt being scrubbed -- an orphaned tag, which still fetches the picture. The
# sentence has "blue jeans" in front of it and answers correctly.
#
# This also settles "Kate is 20, <Picture 1> blonde crop top" the same way and
# without special-casing: in the full sentence the age stands before the tag,
# so it is hers and stays.
_person_tags = set(person_tags(sent))
frags = sent.split(",")
out_frags = []
for frag in frags:
if any(p.search(frag) for p in pats) and not _HAS_VERB.search(frag):
# Restraint hardware is not clothing. An entry describing it goes
# only when a token NAMES it: dropping "wrists handcuffed behind
# her back" whole because a removal named "back" takes the cuffs
# out of the prompt entirely, and hardware absent from the text
# renders absent. Keep the fragment; the surgical pass below still
# trims the token's own words out of it.
if restraint_present(frag) and not any(_RESTRAINT_WORD.match(t)
for t in live):
out_frags.append(frag)
continue
# One entry can carry two garments joined by "and" -- "a grey coat
# and black boots". Dropping it whole takes the innocent one with
# it, and an undescribed garment is one the model re-invents. So
# drop only the side that names the removed item.
sides = re.split(r"\s+\band\b\s+", frag, flags=re.I)
gone = [s for s in sides if any(p.search(s) for p in pats)]
keep = [s for s in sides if s not in gone] if len(sides) > 1 else []
# A PERSON's tag must not leave with a garment that happened to share
# its fragment -- losing it costs that shot its identity reference.
# An OBJECT's tag is the opposite case: "a silver locket <Picture 2>"
# is a picture OF the locket, so when the locket comes off the tag has
# to come off with it. Left behind it kept asserting the thing that
# was just removed, and a tag pointing at a picture nothing in the
# text accounts for is also how a spare subject gets drawn.
#
# The person's tag is the one in the fragment carrying their LABEL --
# "Nora: <Picture 1>" -- because that is where a sheet entry puts it.
# Any other tag belongs to whatever it is standing next to.
# `live` is what is being removed, so a tag standing in front of
# one of those belongs to it and goes with it.
# A leading tag is the person's or the object's depending on the
# ENTRY, which a comma fragment cannot see. "Kate is 20,
# <Picture 1> blonde crop top" and "<Picture 2> a chastity belt"
# are the same shape once split. What tells them apart is whether
# the person is ALREADY tagged at their label: if she is, a later
# tag cannot be hers as well.
tags = [n for s in (gone or [frag]) for n in picture_tags(s)
if str(n) in _person_tags]
piece = " and ".join(k for k in keep if k.strip())
if tags:
piece = ((piece + " ") if piece.strip() else "") + \
" ".join(f"<Picture {n}>" for n in tags)
if piece.strip():
out_frags.append(piece)
continue # the rest of the entry goes
out_frags.append(frag)
rebuilt = ",".join(out_frags)
# A sentence's full stop lives on its LAST fragment. Dropping that fragment
# -- which is exactly what removing the last-listed garment does -- takes the
# full stop with it and runs the sentence into the next one: "blue eyes
# Wrists cuffed behind back." Put the terminator back.
end = re.search(r"([.!?])\s*$", sent)
if end and rebuilt.strip() and not re.search(r"[.!?]\s*$", rebuilt):
rebuilt = rebuilt.rstrip().rstrip(",;") + end.group(1)
kept.append(rebuilt)
out = " ".join(k for k in kept if k.strip())
for t in live:
# The item and the words that belong to it -- an article and up to two
# modifiers -- and nothing else. Deleting the whole comma fragment took
# neighbours with it: removing "jacket" from "a grey jacket over a white
# shirt" deleted the shirt too, and an undescribed garment is one the model
# re-invents, which looks like the clothing changing by itself.
#
# AND THE OBJECT'S OWN TAG WITH IT. "a chastity belt <Picture 2>" is a picture
# OF the belt: take the words and leave the tag, and the shot carries a
# reference with nothing in the text accounting for it. The comma-list path
# above already knew this; this path did not, so any object written into a
# fragment with a verb -- "wearing a chastity belt <Picture 2>" -- was scrubbed
# to "wearing <Picture 2>". Reported as the object looking different when it
# came back into view: the shots where it was covered still sent its picture,
# unclaimed, and whatever those shots made of it is what the next shot
# inherited as a keyframe.
#
# Only a tag STANDING ON the removed words. A person's tag sits after their
# label -- "Mara: <Picture 1>" -- never after a garment, so it cannot be taken
# by this: losing it would cost that shot its identity reference.
#
# A LEADING tag counts too. "<Picture 2> a chastity belt" is the same claim
# written the other way round, and taking only the trailing form left the tag
# standing when the belt went under the jeans -- so the image was still sent
# on every covered shot and drawn on top of them. The words stopping is not
# the same as the picture stopping.
#
# No comma may sit between: "Mara: <Picture 1>, blue jeans" has the person's
# tag in front of a garment, and consuming across the comma would take her
# identity reference with the jeans.
out = re.sub(r"(?:<\s*picture[\s_\-]*\d+\s*>\s*)?"
r"\b(?:(?:a|an|the|her|his|their)\s+)?(?:[\w-]+\s+){0,2}"
+ re.escape(t) + r"\b(?:\s*<\s*picture[\s_\-]*\d+\s*>)?",
"", out, flags=re.I)
# Tidy what the deletion left behind, without touching anything it did not.
# Twice: removing a stranded verb can strand the conjunction in front of it
# ("Kate is 20 and wears a grey jacket" -> "... and wears" -> "... and").
for _ in range(2):
out = re.sub(r"\s{2,}", " ", out)
# "wearing and black boots" / "wears over a white shirt"
out = re.sub(r"\b(wearing|wears|in|dressed)\s+(?:and|over|under|with)\s+",
r"\1 ", out, flags=re.I)
# a clothing verb with nothing left to govern
out = re.sub(r"\s*\b(?:wearing|wears|dressed in)\s*(?=[.,;]|$)", "", out, flags=re.I)
# a connector left hanging before punctuation or the end
out = re.sub(r"\s+(?:and|over|under|with)\s*(?=[.,;]|$)", "", out, flags=re.I)
out = re.sub(r",\s*(?=,)", "", out)
out = re.sub(r"\s*,\s*(?=[.!?])", "", out)
out = re.sub(r"\s+([.,;!?])", r"\1", out)
# A dropped entry can leave its comma flush against the next one. Not
# before a digit, so a thousands separator survives ("1,500").
out = re.sub(r",(?=[^\s,\d])", ", ", out)
# A dropped entry can leave the "and" that joined it to the next one
# stranded at the front of the survivor: "30, and a long coat".
out = re.sub(r"(,\s*)(?:and|or)\s+", lambda m: m.group(1), out, flags=re.I)
out = re.sub(r"\s{2,}", " ", out)
# Drop a sentence the deletion emptied, and one it reduced to a bare subject
# ("She wears a red coat." -> "She.") -- which describes nobody and is one more
# mention of a person, which is its own problem.
kept = []
for sent in re.split(r"(?<=[.!?])\s+", out):
s = sent.strip()
if not re.search(r"[A-Za-z0-9]", s):
continue
# ...including one left with only a copula: "She is wearing a belt." can
# come down to "She is.", which is the same empty mention with a verb on
# the end. The removal took everything the sentence was about.
if re.fullmatch(r"(?:he|she|they|it|[A-Z][\w-]*)"
r"(?:\s+(?:is|are|was|were|has|have|had))?\s*[.!?]?",
s, re.I):
continue
kept.append(s if s[-1] in ".!?" else s + ".")
return " ".join(kept).strip()
# --- upscaling ---------------------------------------------------------------
# How many frames go through one resize call. The whole chain used to go in one,
# which is what made this the largest allocation in the node -- see below.
RESIZE_CHUNK = 32
def _stream_chunks(total):
"""A collector that writes upscaled chunks into ONE destination as they land.
Both chunk loops in _upscale_frames used `out.append(...)` then
`frames = torch.cat(out, dim=0)`. That is the shape the finished-chain join was
rebuilt to stop, at a LARGER size: the list holds the whole upscaled chain and
the cat allocates a second one, both live at the cat, and `out` is a local that
is never cleared -- so it survives the cat, survives the trailing resize, and is
still bound at the return. Meanwhile the CALLER's pre-upscale chain cannot be
dropped either, because `part = frames[s:s+batch]` is a view into it.
At 2580 frames of 1056x608 that is 9.26GB per copy per doubling: 37GB x2 at 2x,
and 148GB x2 with the RealESRGAN_x4plus that is sitting in models/upscale_models.
Preallocating from the first chunk and copying into it removes exactly one of
those two, and drops the list at the same time.
The destination is sized from the FIRST chunk, so the model's scale factor does
not have to be known in advance, and the frame count is the caller's own -- an
upscaler changes width and height, never the number of frames."""
state = {"dst": None, "at": 0}
def put(piece):
if state["dst"] is None:
state["dst"] = torch.empty((int(total),) + tuple(piece.shape[1:]),
dtype=piece.dtype, device=piece.device)
k = int(piece.shape[0])
end = min(state["at"] + k, state["dst"].shape[0])
if end > state["at"]:
state["dst"][state["at"]:end].copy_(piece[:end - state["at"]])
state["at"] = end
def done():
d, at = state["dst"], state["at"]
if d is None:
return None
return d if at == d.shape[0] else d[:at]
return put, done
def _resize_short_edge(frames, target, method="lanczos", chunk=0):
"""Resize a [B,H,W,C] frame batch so its short edge == target (keeping aspect,
snapped to /32). Plain high-quality resize -- enlarges, doesn't add detail.
IN CHUNKS, BECAUSE LANCZOS IS FOUR FULL-LENGTH COPIES. The whole chain went
into one common_upscale call, and comfy.utils.lanczos is three successive list
comprehensions over every frame at once:
images = [Image.fromarray(...) for image in samples] # N at source size
images = [image.resize(...) for image in images] # N at target size
images = [torch.from_numpy(np.array(im).astype(np.float32)/255.) ...]
result = torch.stack(images)
return result.to(samples.device, samples.dtype)
A comprehension builds the new list completely before rebinding the name, so at
each rebind BOTH are live; then torch.stack allocates a full copy while its list
still exists, and .to() allocates the result while the stack still exists. Note
the astype(np.float32): the input is fp16 but the two largest transients are at
DOUBLE its width. At 2580 frames to a 1080 short edge that peaked around 147GB
to produce a 29GB result, and it fires on a DOWNSCALE too.
Chunked, the peak is the result plus one chunk's worth of that machinery. It is
bit-identical: PIL resizes each frame independently, so per-chunk and per-chain
give the same pixels. The early return for an already-correct size is kept, so
the common no-op case still allocates nothing."""
b, h, w, c = frames.shape
if min(h, w) == target:
return frames
if h <= w:
nh = target; nw = max(32, int(round(target * w / h / 32) * 32))
else:
nw = target; nh = max(32, int(round(target * h / w / 32) * 32))
step = max(1, int(chunk) or RESIZE_CHUNK)
out = torch.empty((b, nh, nw, c), dtype=frames.dtype, device=frames.device)
for i in range(0, b, step):
part = comfy.utils.common_upscale(
frames[i:i + step].movedim(-1, 1), nw, nh, method, "disabled")
out[i:i + step].copy_(part.movedim(1, -1))
del part
return out
def _upscale_frames(frames, mode, model_name, target_short_edge, batch=4):
"""Optional post-pass upscale of the finished frames (on CPU).
mode 'model' : run a ComfyUI upscale model (Real-ESRGAN/UltraSharp class)
via the registered loader+apply nodes, chunked with cleanup
so 2000+ frames don't OOM; then fit to target short edge.
mode 'rtx' : NVIDIA RTX Video Super Resolution (Tensor Cores; fastest,
best quality for video -- needs Nvidia_RTX_Nodes_ComfyUI).
mode 'lanczos' : plain high-quality resize to the target short edge.
Any failure falls back to lanczos (or the raw frames), so it never breaks a
render. Returns (frames, note). NOTE: this SHARPENS/ENLARGES; it does not
reconstruct video detail the way a second-model (LTX 2.3) pass does."""
if mode == "off" or frames is None or getattr(frames, "shape", [0])[0] == 0:
return frames, ""
note = ""
if mode == "rtx":
# NVIDIA RTX Video Super Resolution (Comfy-Org/Nvidia_RTX_Nodes_ComfyUI).
# Runs on RTX Tensor Cores -- far faster than ESRGAN-class models and
# generally cleaner on video, though like them it enhances/enlarges rather
# than reconstructing detail (an LTX 2.3 re-generation does that).
try:
rtx = (_find_node(["rtx", "video", "super"]) or _find_node(["rtxvideosuperresolution"])
or _find_node(["rtx", "upscale"]))
if rtx is None:
raise RuntimeError("RTX node not installed (Nvidia_RTX_Nodes_ComfyUI)")
scale = 2
if target_short_edge and int(target_short_edge) > 0:
cur = min(frames.shape[1], frames.shape[2])
if cur > 0:
scale = max(1, min(4, int(round(int(target_short_edge) / cur))))
_put, _done = _stream_chunks(frames.shape[0])
n = frames.shape[0]
step = max(1, int(batch))
for st in range(0, n, step):
part = frames[st:st + step]
res = None
for kw in ({"image": part, "scale": scale}, {"images": part, "scale": scale},
{"image": part, "scale_factor": scale}, {"image": part}):
try:
res = _invoke_node(rtx, **kw); break
except TypeError:
continue
if res is None:
raise RuntimeError("RTX node signature not recognized")
_put(res.detach().to("cpu"))
del res, part
_deep_cleanup()
frames = _done()
note = f"RTX Video Super Resolution x{scale}"
if target_short_edge and int(target_short_edge) > 0:
frames = _resize_short_edge(frames, int(target_short_edge))
note += f"; fit to {int(target_short_edge)}px short edge"
return frames, note
except Exception as e:
mode = "model"
note = f"RTX upscale unavailable ({e}); fell back to model/lanczos"
if mode == "model" and model_name and model_name != "none":
try:
loader = _find_node(["upscale", "model", "load"]) or _find_node(["loadupscalemodel"])
applier = _find_node(["imageupscale", "model"]) or _find_node(["upscaleimageusingmodel"])
if loader is None or applier is None:
raise RuntimeError("upscale-model nodes not found")
up_model = _invoke_node(loader, model_name=model_name)
_put, _done = _stream_chunks(frames.shape[0])
n = frames.shape[0]
for s in range(0, n, max(1, int(batch))):
part = frames[s:s + max(1, int(batch))]
res = _invoke_node(applier, upscale_model=up_model, image=part)
_put(res.detach().to("cpu"))
del res, part
_deep_cleanup()
frames = _done()
note = f"upscaled with {model_name}"
except Exception as e:
mode = "lanczos"
note = f"model upscale unavailable ({e}); used lanczos"
if target_short_edge and int(target_short_edge) > 0:
try:
frames = _resize_short_edge(frames, int(target_short_edge))
note = (note + "; " if note else "") + f"fit to {int(target_short_edge)}px short edge"
except Exception as e:
note = (note + "; " if note else "") + f"resize failed ({e})"
elif mode == "lanczos" and not note:
note = "lanczos selected but no target set -> unchanged"
return frames, note
def _upscale_model_list():
"""Filenames in models/upscale_models, plus 'none'. Read fresh at INPUT_TYPES
time so newly-added models show up on a graph reload."""
try:
import folder_paths
return ["none"] + list(folder_paths.get_filename_list("upscale_models"))
except Exception:
return ["none"]
def upscale_video_latent(video, model_name, scale):
"""(upscaled_video_latent, note). Never raises -- a failure returns the input.
Spatial only: the temporal length comes back unchanged, which is what lets this
sit between sampling and decode without touching the audio half or the frame
count the rest of the chain has already committed to."""
if not model_name or model_name == "off" or float(scale) <= 1.0:
return video, ""
cls = latent_upscaler_node()
if cls is None:
return video, ("latent_upscale is set but the 'Minimax H3 Latent Upscaler' node pack is "
"not installed, so the shots were rendered at their sampled size. Install "
"Comfyui_Minimax_h3_latent_Upscaler, or set latent_upscale to 'off'")
try:
before = tuple(video.shape)
# Its UpscaleMode is a str-Enum, so the literal VALUE compares equal without
# importing the pack. Read the enum off the class when it is reachable, and
# fall back to the literal -- hardcoding a foreign string is the fragile part
# of this integration, so it is not the only path.
mode_val = "scale by multiplier"
try:
mode_val = sys.modules[cls.__module__].UpscaleMode.SCALE_BY
except Exception:
pass
out = _invoke_node(cls, latent={"samples": video},
model_name=model_name,
mode={"mode": mode_val, "scale": float(scale)},
align=32, device="cuda", precision="fp16")
up = out["samples"] if isinstance(out, dict) else out
if up is None or up.dim() != video.dim() or up.shape[2] != video.shape[2]:
# A temporal change would desync the audio half and the frame count.
return video, ("the latent upscaler returned an unexpected shape, so the shot was "
"left at its sampled size")
return up.to(video.dtype), (f"latent upscale {model_name} x{float(scale):g}: "
f"{before[-2]}x{before[-1]} -> {up.shape[-2]}x{up.shape[-1]} "
f"latent cells per frame, sampled small and decoded large")
except Exception as e:
return video, (f"latent upscale failed ({type(e).__name__}), so the shots were rendered "
f"at their sampled size")
def _latent_upscale_model_list():
"""H3 latent-upscaler weights in models/latent_upscale_models, plus 'off'.
Filtered to H3 builds: that folder also holds LTX spatial/temporal upscalers,
and offering one here would let it be picked for a model it cannot take -- the
first conv is [512, 24, 3, 3, 3] and 24 is H3's latents_dim specifically.
Listed whether or not the node pack that RUNS them is installed. The widget has
to exist unconditionally or a saved workflow would lose its widget positions the
moment the pack was uninstalled; being unable to run is handled at render time."""
try:
import folder_paths
d = os.path.join(folder_paths.models_dir, "latent_upscale_models")
names = [f for f in sorted(os.listdir(d))
if f.lower().endswith((".pth", ".safetensors"))
and ("minimax" in f.lower() or "h3" in f.lower())]
except Exception:
names = []
return ["off"] + names
def _find_node(substrings):
"""Find a registered node whose key contains all of `substrings` (lowercased)."""
maps = getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}
for k, v in maps.items():
kl = k.lower()
if all(s in kl for s in substrings):
return v
return None
def _invoke_node(cls, **kwargs):
"""Call a registered ComfyUI node (V1 FUNCTION or V3 execute) with kwargs and
return its first output. Used to reuse ComfyUI's own upscale-model loader/apply
so we don't reimplement spandrel loading or tiled scaling."""
inst = cls()
fn = None
if getattr(cls, "FUNCTION", None) and hasattr(inst, cls.FUNCTION):
fn = getattr(inst, cls.FUNCTION)
else:
for cand in ("execute", "upscale", "load_model", "load"):
if hasattr(inst, cand):
fn = getattr(inst, cand); break
if fn is None:
raise RuntimeError("no callable entrypoint")
out = fn(**kwargs)
out = getattr(out, "result", out)
return out[0] if isinstance(out, (tuple, list)) else out
def latent_upscaler_node():
return _find_node(["minimaxh3latentupscaler", "3d"]) or _find_node(["minimaxh3latentupscaler"])
# --- one shot's conditioning ------------------------------------------------
def build_conditioning(clip, vae, audio_vae, prompt, width, height, length,
handoff=None, refs=None,
ref_noise_aug=0.999, silent=False, ref_image_size="match",
handoff_as_ref=False):
"""Text + references + keyframe for a single shot.
THE ONE RULE from H3's layout: a shot's conditioning rows are packed in the
order the tokenizer is given them, and tokenize_with_weights is either/or --
passing minimax_ref_items makes it ignore `images` outright. So a reference and
a keyframe cannot be handed over separately; whatever the encoder is to see goes
in one list, numbered by position.
So one roster, and it has to be readable under ONE format. A shot with a keyframe
is fl2va -- the keyframe is <Picture 1> -- and reference images are dropped for
that shot, because on fl2va slot 2 means the LAST frame rather than a second
subject. See the comments below.
"""
latent, fc = _empty_av_latent(width, height, length, H3_FPS)
refs = [r for r in (refs or []) if r is not None]
hand_img = None
if handoff is not None:
hand_img = _resize(handoff[:1], width, height, "disabled")
# REFERENCES AND THE KEYFRAME RIDE TOGETHER. This is the arrangement the node
# had before I broke it, and the reason is in ComfyUI's own layout:
#
# model_base.py:2183-2191 cond_video_latents = keyframe latents THEN ref latents
# model.py PackedLayout emits keyframe "cond" segments THEN ref "ref_img" ones
#
# The two orders agree, so both channels coexist. A shot takes its references AND
# a real keyframe: the keyframe ANCHORS the first frame, which is what continuity
# needs, while a reference only supplies identity. They are not alternatives.
#
# I had read "<Picture 1>" as MEANING the first frame on fl2va, and rearranged the
# roster around that. It does not. Which image is the first frame is decided by
# resolved_frame_index in minimax_keyframes, not by a label's number -- the labels
# are only how the images are shown to the VLM, and what they have to line up with
# is the <Picture N> tags in the prompt.
#
# So references come FIRST and keep slots 1..N, which is what a sheet line's
# `Name: <Picture 1>, ...` points at, and the handoff is appended AFTER them where
# it disturbs no numbering. It has to be in the list at all because
# tokenize_with_weights is either/or: passing minimax_ref_items makes it ignore
# `images` outright, so leaving the handoff out means the VLM is never shown where
# the shot left off and re-imagines the scenery -- same place, new room.
keyframe_ok = ref_noise_aug is None or float(ref_noise_aug) >= KEYFRAME_SAFE_AUG
# One aug covers every visual condition row, references AND the keyframe. Below
# KEYFRAME_SAFE_AUG the keyframe latent would be noised and labelled at the wrong
# timestep, so the handoff stops being an anchor and rides as an extra reference
# instead: weaker continuity, but nothing pretending to anchor while carrying noise.
# ...or because the caller asked for it. A shot that introduces somebody already
# in position wants the room this picture carries and NOT the first frame it
# would force, and that is a demotion the aug knows nothing about.
carry_as_ref = bool(hand_img is not None
and (handoff_as_ref or (refs and not keyframe_ok)))
enc_refs = refs + ([hand_img] if carry_as_ref else [])
items, blocks = ([], [])
if enc_refs:
items, blocks = _build_ref_images(vae, enc_refs, width, height, ref_image_size)
if hand_img is not None and not carry_as_ref:
items = items + [{"type": "image", "data": hand_img}]
if items:
tokens = clip.tokenize(prompt, minimax_ref_items=items)
else:
tokens = clip.tokenize(prompt)
cond = clip.encode_from_tokens_scheduled(tokens)
vals = {}
if blocks:
vals["minimax_refs"] = blocks
# How CLEAN the references are shown. One aug covers every conditioning
# latent, keyframe included -- which is why softening references below
# KEYFRAME_SAFE_AUG would soften the anchor too.
if ref_noise_aug is not None:
vals["minimax_visual_cond_noise_aug"] = float(ref_noise_aug)
kfs = []
if hand_img is not None and not carry_as_ref:
kfs.append({"resolved_frame_index": 0,
"latent": _keyframe_latent(vae, hand_img)})
# Silence on the audio branch for a shot with no scripted line. H3 is joint:
# an unconditioned audio stream invents a voice and the picture lip-syncs to it,
# and no sentence in the prompt outvotes a stream that has already decided
# someone is talking. PackedLayout emits a video segment only when a keyframe
# carries a `latent`, so an audio-only keyframe is legal and costs no frame.
if silent:
_SILENCE_STATUS["asked"] += 1
if audio_vae is None:
_SILENCE_STATUS["why"] = "no audio VAE is wired to the node"
else:
sil = _silent_audio_latent(audio_vae, fc, H3_FPS)
if sil is None:
_SILENCE_STATUS["why"] = ("the audio VAE would not encode a silent "
"second -- the wrong VAE is on the "
"audio_vae input")
else:
kfs.append({"resolved_frame_index": 0, "audio_latent": sil})
_SILENCE_STATUS["applied"] += 1
if kfs:
vals["minimax_keyframes"] = kfs
if vals:
cond = node_helpers.conditioning_set_values(cond, vals)
return cond, latent, fc, carry_as_ref
def sample_shot(model, cond, negative, latent, seed, steps, cfg, sampler_name,
scheduler, sigmas=None):
"""One sampling pass. denoise is fixed at 1.0: partial denoise desyncs the
joint audio/video schedule."""
if sigmas is not None and len(sigmas):
return _sample_on_sigmas(model, seed, cfg, sampler_name, cond, negative,
latent, sigmas)
(out,) = nodes.common_ksampler(model, seed, steps, cfg, sampler_name, scheduler,
cond, negative, latent, denoise=1.0)
return out
_HERE = os.path.dirname(os.path.abspath(__file__))
# (default, min, max, cast) for every numeric widget, so a value that cannot be used
# as a number can be replaced by the one the widget was built with.
_WIDGET_RANGE = {
"megapixels": (1.0, 0.0, 2.0, float),
"shot_seconds": (10.0, 1.0, 15.0, float),
"steps": (8, 1, 100, int),
"cfg": (1.0, 1.0, 20.0, float),
"shift_video": (12.0, 1.0, 20.0, float),
"shift_audio": (3.0, 1.0, 20.0, float),
"ref_noise_aug": (0.999, 0.5, 1.0, float),
"latent_upscale_scale": (2.0, 1.0, 4.0, float),
"upscale_target_short_edge": (0, 0, 4096, int),
"upscale_batch": (4, 1, 64, int),
"pace": (1.0, 0.25, 2.0, float),
"ambient_level": (0.25, 0.0, 1.0, float),
"foley_level": (0.35, 0.0, 1.0, float),
}
def misaligned_widgets(values, options):
"""[(widget, value, what it should have been)] for choices that are not choices.
sane_widgets repairs a NUMBER that arrives as NaN, and that is the visible symptom
of a positional shift. It cannot see the cause, and it cannot help the widgets
whose values are WORDS: a shift puts a scheduler's name into sampler_name and a
seed into scheduler, and those pass straight through into the render.
A combo holding a value that is not one of its own options is not a preference
this node can honour. It is proof the list is out of step -- values are restored
by POSITION, so converting one widget to an input, or adding or removing one,
slides every value after it into the wrong slot."""
bad = []
for name, choices in (options or {}).items():
if name not in values:
continue
got = values[name]
if got not in choices:
bad.append((name, got, choices))
return bad
def combo_options(spec):
"""{widget: [options]} for every choice widget the node declares."""
out = {}
for section in ("required", "optional"):
for name, decl in (spec or {}).get(section, {}).items():
if decl and isinstance(decl[0], list):
out[name] = list(decl[0])
return out
def alignment_error(bad):
"""The message for a workflow whose widget values have slid out of position."""
if not bad:
return ""
shown = "; ".join(f"{n} = {v!r}, which is not one of {c[:3]}"
+ ("..." if len(c) > 3 else "") for n, v, c in bad[:3])
return (
"H3 Long Videos: this node's saved widget values are out of position. "
+ shown + ".\n\n"
"Widget values are restored by POSITION, with no names stored, so converting "
"a widget to an input -- or adding or removing one -- slides every value after "
"it into the wrong slot. A scheduler's name lands in sampler_name, a seed in "
"scheduler, and a number with nowhere to go reads as NaN.\n\n"
"To fix it: right-click the node and choose 'Fix node (recreate)', or convert "
"any widget you turned into an input back to a widget. Then set the values you "
"want and save the workflow again. Nothing is wrong with the model or the "
"prompt, and rendering with these values would use settings you did not pick.")
def sane_widgets(values):
"""(repaired values, notes) for the numeric widgets.
Saved workflows restore widget values BY POSITION, with no names stored. Remove or
reorder a widget and every later value shifts up one, so a boolean can land in a
FLOAT slot -- which is where a widget reading NaN comes from, and a NaN pace makes
NaN shot lengths and a render that never starts.
A value that will not become a finite number falls back to the widget's built-in
default; one that is merely out of range is clamped. Reported either way, because
silently substituting a number the user did not choose is how a wrong render looks
like a broken node."""
out, notes, unusable = dict(values), [], []
for name, (default, lo, hi, cast) in _WIDGET_RANGE.items():
if name not in out:
continue
raw = out[name]
try:
if isinstance(raw, bool):
raise TypeError("a boolean is not a setting for this widget")
num = float(raw)
if num != num or num in (float("inf"), float("-inf")):
raise ValueError("not a finite number")
except (TypeError, ValueError):
out[name] = default
unusable.append(f"{name} was {raw!r}, now {default}")
continue
clamped = min(max(num, lo), hi)
if clamped != num:
notes.append(f"{name} was {num:g}, outside {lo:g}..{hi:g}, so it was clamped "
f"to {clamped:g}")
out[name] = cast(clamped)
# ONE note for all of them. This used to emit a paragraph per widget, and a
# workflow whose values have slid produces several at once -- the same
# explanation three or four times, at the top of every run, which buries the
# notes that are about the film. Said once, with the list.
if unusable:
notes.insert(0, "widget values that were not usable numbers, replaced with "
"their defaults: " + "; ".join(unusable)
+ ". Values are restored BY POSITION with no names stored, so this "
"means the node's widget list and the saved one disagree -- "
"usually because a widget was converted to an input, or the node "
"gained one. It repairs itself for THIS run only: the graph still "
"holds the bad values, so it comes back every restart until the "
"node is fixed. Right-click the node and choose 'Fix node "
"(recreate)', set your values, and save the workflow")
return out, notes
class H3LongVideos:
"""One prompt -> a chain of MiniMax-H3 shots, joined into one video."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"vae": ("VAE",),
"audio_vae": ("VAE",),
"prompt": ("STRING", {"multiline": True, "forceInput": True,
"tooltip": "Paragraph 1 is the SCENE, prepended to every shot verbatim. "
"Every paragraph after it is one beat = one shot.\n\n"
"Nothing is rewritten. What you type is what the shot is told, "
"plus the scene line. Put a quoted \"line of dialogue\" in a beat "
"and that shot keeps its audio; beats without one are silenced."}),
"resolution": (list(NATIVE_RES), {"default": "16:9",
"tooltip": "Aspect ratio. megapixels sets the size."}),
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05,
"tooltip": "1.0 = 1024x1024 worth of pixels, H3's native budget. Lower is "
"faster and leaner; 0 keeps the preset's own dimensions. Cost "
"scales with latent cells and attention is quadratic in them."}),
"shot_seconds": ("FLOAT", {"default": 10.0, "min": 1.0, "max": 15.0, "step": 0.5,
"tooltip": "Length of EVERY shot. Uniform on purpose: noise is drawn to the "
"latent's shape, so shots of different lengths get unrelated noise "
"from the same seed and the grain resets at every cut. Snapped to "
"H3's 17k+5 frame grid."}),
"steps": ("INT", {"default": 8, "min": 1, "max": 100,
"tooltip": "6-8 with a turbo/distill LoRA; 20+ without one."}),
"cfg": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 20.0, "step": 0.1,
"tooltip": "H3 is CFG-free. At 1.0 the negative prompt is never evaluated -- "
"which is why nothing here is phrased as a negation."}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}),
# control_after_generate DECLARED, not left implicit. The frontend adds
# that control by itself for any INT named "seed", so it existed in the
# panel while the backend knew nothing about it -- the UI's widget list
# was one longer than this one, and widget values are restored BY
# POSITION. Declaring it is what ComfyUI's own KSampler does
# (nodes.py:1602), and it makes the two lists agree on where every
# later value belongs.
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff,
"control_after_generate": True,
"tooltip": "One seed for the whole chain. Every shot is the same length, so "
"they share a noise field."}),
},
"optional": {
"first_frame": ("IMAGE", {"tooltip":
"Pins the opening frame of shot 1 -- the only shot with no previous frame to "
"continue from.\n\n"
"It pins the WHOLE frame, so give it a composed frame of the shot you want: "
"subject, pose, framing, background. A head-and-shoulders portrait wired here "
"makes shot 1 a head-and-shoulders portrait. An identity portrait belongs on "
"ref_image_1, which says who the person is without dictating the frame."}),
"ref_image_1": ("IMAGE", {"tooltip":
"Identity reference, applied to every shot unless the prompt places it with a "
"<Picture 1> tag. Kept on every shot on purpose: it is the only fixed anchor a "
"long chain has, and without it shot 11 is drift piled on drift."}),
"ref_image_2": ("IMAGE",),
"ref_image_3": ("IMAGE",),
"ref_image_4": ("IMAGE",),
"negative": ("CONDITIONING", {"tooltip":
"Ignored at cfg 1.0, which is where H3 runs. Wired for completeness."}),
"sigmas": ("SIGMAS", {"tooltip":
"An external schedule (PDD Acc's Apply node). Drives the sampler directly; "
"steps and scheduler are then only for the progress bar."}),
"shift_video": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 20.0, "step": 0.1}),
"shift_audio": ("FLOAT", {"default": 3.0, "min": 1.0, "max": 20.0, "step": 0.1,
"tooltip": "Keep video:audio near 4:1. H3 carries the audio latent on the "
"video schedule scaled by that ratio; flattening it breaks audio."}),
"apply_model_sampling": ("BOOLEAN", {"default": True,
"tooltip": "Patch the dual video/audio schedule inside the node. Turn off only "
"if you patch it upstream yourself."}),
"silence_nonspeech": ("BOOLEAN", {"default": True,
"tooltip": "Anchor the audio branch to real silence on any shot with no quoted "
"line. H3 is joint -- an unconditioned audio stream invents a voice "
"and the picture lip-syncs to it. This conditions the stream itself "
"rather than asking the prompt to stop it."}),
"trim_seam": ("BOOLEAN", {"default": True,
"tooltip": "Drop the first frame of every shot after the first: it is the "
"model's own reproduction of the keyframe, so it is a duplicate."}),
"ref_noise_aug": ("FLOAT", {"default": 0.999, "min": 0.5, "max": 1.0, "step": 0.005,
"tooltip": "How CLEAN a reference is shown. 0.999 (H3's default) hands over a "
"noise-free image, which invites the model to REPRODUCE it -- "
"including its background and pose -- in the opening frames. Lower "
"says approximate: try 0.95, then 0.90. One aug covers every "
"conditioning latent, so below 0.99 the keyframe rides as a "
"reference instead of an anchor."}),
"tiled_decode": ("BOOLEAN", {"default": True,
"tooltip": "Decode in tiles. The whole-clip decode is the single largest "
"allocation in a run and the usual point a big checkpoint spills."}),
"cleanup_between_shots": ("BOOLEAN", {"default": True,
"tooltip": "Move each finished shot to system RAM and purge VRAM between "
"shots, so a long chain does not accumulate on the card."}),
"latent_upscale": (_latent_upscale_model_list(), {"default": "off",
"tooltip": "Upscale each shot in LATENT space, between sampling and decode, "
"so the shot is SAMPLED small and only DECODED large. That is the "
"cheap one: cost scales with latent cells and attention is "
"quadratic in them, so sampling 512x512 and upscaling 2x is far "
"less work than sampling 1024x1024.\n\n"
"Model and nodes by LBH-123-AI; needs the separate Minimax H3 "
"Latent Upscaler pack and its weights in "
"models/latent_upscale_models. Without the pack this does nothing "
"and info says so. Spatial only, so frame count and audio are "
"untouched, and tiled decode is forced while it is on."}),
"latent_upscale_scale": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 4.0,
"step": 0.05,
"tooltip": "Latent upscale factor on both axes. 2.0 doubles each side. "
"1.0 disables it as surely as 'off'."}),
"upscale": (["off", "rtx", "model", "lanczos"], {"default": "off",
"tooltip": "Post-pass on the FINISHED frames, after the latent pass and after "
"the shots are joined. 'rtx' = NVIDIA RTX Video Super Resolution "
"(needs the Nvidia_RTX_Nodes_ComfyUI pack, falls back if absent); "
"'model' = an upscale model from upscale_models; 'lanczos' = a "
"plain resize. These ENLARGE; for real detail reconstruction from a "
"low-res render use a separate pass."}),
# Explicit, not left to fall back to the list's first entry: the list
# is built from what is installed, so leaving it implicit makes the
# default depend on the machine.
"upscale_model": (_upscale_model_list(), {"default": "none",
"tooltip": "Which model, when upscale = model. From models/upscale_models."}),
"upscale_target_short_edge": ("INT", {"default": 0, "min": 0, "max": 4096,
"step": 32,
"tooltip": "Fit the result's short edge to this many pixels. 0 keeps the "
"model's own factor."}),
"upscale_batch": ("INT", {"default": 4, "min": 1, "max": 64,
"tooltip": "Frames per chunk for the model upscale. Lower = less VRAM, "
"slower."}),
"shot_length": (["from the beat", "fixed"], {"default": "from the beat",
"tooltip": "How long each shot is.\n\n"
"'from the beat' sizes every shot from what its own line "
"stages, capped by shot_seconds and floored at one action's "
"worth. A beat with one action stops getting a shot with room "
"for two -- which is what makes an action carry on past its "
"end, repeating itself on whatever is nearest once it has "
"run out of what it was given.\n\n"
"'fixed' gives every shot shot_seconds. Uniform lengths mean "
"uniform latent SHAPES, and noise is drawn to the shape -- so "
"one seed gives the whole chain one noise field and surface "
"detail does not reset at each cut. That consistency is what "
"you trade away for pacing.\n\n"
"The estimate leans short on purpose: a shot that ends before "
"its action does hands a mid-motion frame to the next shot, "
"which the chain continues from. A shot that outlasts its "
"action has to invent the rest."}),
"auto_remove": ("BOOLEAN", {"default": True,
"tooltip": "Read removals out of the beat itself, so a garment comes "
"off without a 'remove:' line.\n\n"
"Two conditions, both required, because a wrong removal is "
"worse than a missed one: the beat has to contain a removal "
"verb, and the thing named has to be the HEAD of something "
"the SCENE already lists as worn -- not a modifier inside an "
"entry, not a body part, and never restraint hardware. Only "
"the verb's own object counts, the span up to the next clause "
"boundary, so 'pulls off her coat, showing the jumper' takes "
"off the coat and leaves the jumper.\n\n"
"info reports every removal it reads, by shot. An explicit "
"'remove:' line still works and is added to whatever is "
"inferred."}),
"restart_after_removal": ("BOOLEAN", {"default": True,
"tooltip": "After a shot with a 'remove:', start the NEXT shot fresh "
"instead of continuing from that shot's last frame.\n\n"
"Every shot is anchored to the previous shot's last frame. If "
"the model does not finish taking the garment off inside its "
"own shot, that frame still shows it -- and a keyframe is a "
"PICTURE, which outvotes any sentence. Inherit it once and every "
"later shot inherits it too, with no wording able to undo it. "
"This breaks that inheritance at the one boundary where the "
"state changes.\n\n"
"The cost is a visible cut there, and that shot re-deriving its "
"pose and framing from the text. Turn it off if your removals do "
"complete on screen and you would rather keep the continuity."}),
"hold_restraints": ("BOOLEAN", {"default": True,
"tooltip": "Once a restraint is put on, keep it whole. From the shot "
"that applies it onward, every shot carries one sentence: "
"every restraint stays whole and closed, fastened exactly as "
"it was put on. Cleared by a 'remove:' naming the hardware.\n\n"
"This is the ONE continuity fact the node asserts by itself, "
"because it is the one that cannot be recovered -- a cuff "
"that renders open is not a detail that drifted, it is the "
"scene ceasing to make sense. Everything else is yours to "
"write."}),
"plan_only": ("BOOLEAN", {"default": False,
"tooltip": "Report the shot split, lengths and warnings without rendering."}),
# Appended LAST on purpose. Saved workflows restore widget values by
# POSITION, with no names stored, so inserting a widget anywhere above
# this shifts every later value in every workflow already saved.
"anchor": ("STRING", {"multiline": True, "default": "",
"tooltip": "Framing that belongs to the whole film -- look, camera, "
"lighting, location. Carried at the FRONT of every shot.\n\n"
"FILLING THIS IN MAKES EVERY PARAGRAPH OF THE PROMPT A "
"BEAT. The anchor is then the scene, so the prompt is "
"pure action and nothing is taken out of it to serve as "
"scene text.\n\n"
"Leave it empty and the first paragraph of the prompt is "
"the scene instead, as before. Use one or the other: with "
"both, put ALL the framing here, because the prompt's "
"first paragraph will be rendered as a shot."}),
"character_memory": ("STRING", {"multiline": True, "default": "",
"tooltip": "Who is in the film and what they are wearing, re-stamped "
"into EVERY shot.\n\n"
"Write it as a sheet, one person per line:\n"
" Maya: 27, silver hair, grey shorts, red jacket\n"
" Jon: 34, navy overalls\n\n"
"This is what makes clothing hold across a chain. A "
"garment described in one beat is described in ONE shot; "
"every later shot then says nothing about it, and what "
"the model is not told, it invents -- which is a garment "
"changing colour, or coming back after it came off.\n\n"
"It is also what a removal scrubs. `remove:` and the "
"automatic inference take the item out of this sheet from "
"that shot onward, so the text stops describing what the "
"beat took off.\n\n"
"A `Name: ...` paragraph in the prompt itself is folded in "
"here automatically -- a sheet is not a beat, and spending "
"a shot rendering a description is the visible symptom."}),
"character_guard": ("BOOLEAN", {"default": True,
"tooltip": "Describe only the people a beat actually involves.\n\n"
"The sheet has to be in every shot for clothing to hold. "
"But describing EVERYONE in every shot puts everyone in "
"every shot: a beat about one person renders two, because "
"the text standing beside it says the other one is there, "
"and a described person is a person the model draws.\n\n"
"A beat naming nobody keeps whoever the last one kept, so "
"'She lies still.' does not empty the frame. Off, every "
"sheet line goes into every shot. info names who each shot "
"kept."}),
"pace": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 2.0, "step": 0.05,
"tooltip": "Scales how much screen time each beat is given, when "
"shot_length is 'from the beat'.\n\n"
"A shot longer than its action does not get filled with "
"MORE action -- the model performs the same action more "
"slowly to reach the end of the shot. That is what "
"slow-looking footage is. Below 1.0 shortens every shot "
"and the motion in it quickens; above 1.0 lengthens and "
"slows.\n\n"
"Try 0.75 if the movement drags. Shots are still floored "
"at one action's worth and capped by shot_seconds, and "
"'fixed' ignores this entirely. info reports the seconds "
"each staged action ends up with."}),
"auto_sound": ("BOOLEAN", {"default": True,
"tooltip": "Give each shot the sound its own action implies.\n\n"
"H3 is joint, so the same prose conditions the audio "
"branch -- and a beat that says what happens has already "
"said what it sounds like. Walking gets footsteps, a "
"chain gets links dragging, scissors get blades through "
"fabric, a lock gets a lock closing.\n\n"
"Read from the BEAT only, never the scene: a chain "
"standing in the scene does not rattle in a shot where "
"nobody moves. Three sounds at most, so the shot gets a "
"cue rather than an inventory.\n\n"
"The ambient bed and the room tone FOLLOW the "
"characters. Both are read from the scene, and a film "
"that walks into a tiled bathroom was going on being "
"told it sounds like the carpeted room it left -- the "
"picture in one room and the audio in another, in the "
"same conditioning. They are re-read at a move, but "
"only where the new room has a sound of its own.\n\n"
"NOTHING HERE CAN OPEN A SILENT SHOT. Ambience on "
"every shot was tried and does not work: the bed was "
"allowed to open the audio branch, and an open branch "
"on a joint model fills itself. At 4-8 steps the last "
"audio step clears 50%-30% of its denoising in one "
"jump, and what it invents there is a voice -- so "
"every wordless shot got ambience and a babbling mouth "
"with it. Ambience everywhere and silence cannot both "
"hold: the silence latent IS the audio, and there is "
"no room in it for a room tone. Score a silent shot by "
"writing the sound into that beat, or lay an ambient "
"track under the finished video outside the model.\n\n"
"A beat that already describes its own sound is left "
"alone -- what you wrote wins. A shot given sound is also "
"not silenced, since it is now asking for audio. info "
"lists which shots got one."}),
# APPENDED, like every widget before it. Saved workflows restore these
# positionally with no names stored, so inserting one shifts every
# value after it into the wrong control.
"hold_scene_state": ("BOOLEAN", {"default": True,
"tooltip": "Put a described state at the first frame instead of "
"leaving it to be performed.\n\n"
"'A van with its doors closed' names a state and never "
"says when it is true. A video model asked for a door "
"renders what a door does, so the shot opens on the doors "
"open and the characters close them -- the state arrives "
"as the action, because that is the most interesting "
"event in the sentence.\n\n"
"Doors, gates, windows, curtains, blinds, shutters, "
"hatches, tailgates, lids and drawers. Two at most per "
"shot.\n\n"
"A beat that WORKS the thing is not held -- 'Mara opens the "
"doors' is asking for exactly that motion. It is given the "
"two ENDS of the change instead: shut at the first frame, "
"open by the last. Some distill LoRAs render an action "
"backwards, and a beat naming one state names neither end, "
"so the reverse answers it just as well. Verbs that go "
"either way -- pulls, draws, slides, swings -- get no "
"anchor, since a wrong one asks for the reversal rather "
"than allowing it.\n\n"
"Once a beat has changed a state, no later shot is told "
"the old one, even though the scene paragraph still says "
"it. Two sentences per shot at most, the two kinds sharing "
"that budget. info lists which shots got which."}),
# APPENDED. Saved workflows restore widgets by position.
"mouths_shut_when_no_line": ("BOOLEAN", {"default": True,
"tooltip": "Keep mouths closed on shots where nobody speaks.\n\n"
"H3 is joint: the face follows the audio branch. A shot "
"with no line but a sound YOU wrote -- 'a low hum off the "
"strip light' -- kept its branch open, and an open branch "
"invents a voice the face lip-syncs to. Nobody is speaking "
"and the mouth moves anyway.\n\n"
"On, such a shot is conditioned on silence like any other "
"wordless shot, and every wordless shot is also told the "
"mouths are closed. Conditioning is what actually settles "
"it; the sentence alone loses to a stream that has already "
"decided somebody is talking.\n\n"
"THE COST: that shot gives up the sound you wrote for it. "
"info names those shots, so turn this off if you would "
"rather keep the ambience and risk the mouth.\n\n"
"EFFORT IS EXEMPT. Straining, thrashing, a body under load "
"is vocal and its mouth should be open, so those shots keep "
"their audio and are never told to close."}),
# APPENDED. Saved workflows restore widgets by position.
"hold_gaze": ("BOOLEAN", {"default": True,
"tooltip": "Put the eyes where the beat says they are looking.\n\n"
"'She is looking at the TV' says it once, and two things "
"pull the other way: the model's prior is that a person in "
"frame faces the camera, and a near-clean reference is "
"asking for the portrait's pose -- which looks at the lens, "
"because photographs of people do. The result is somebody "
"posing for the camera instead of watching what you "
"named.\n\n"
"On, a beat naming a thing to look at gets one more "
"sentence saying the eyes are on it and the head is turned "
"to face it. Stated as a physical fact rather than an "
"activity, and impersonally -- naming the person again is "
"one more mention of a person, which has its own cost.\n\n"
"Reads 'looks at', 'stares at', 'glances at', 'peers into', "
"'watching', 'studies'. It says nothing about where the "
"camera is, so a shot looking straight down the line of "
"sight is unaffected. Looking at a PERSON is left alone: "
"restating a pronoun says nothing the beat did not."}),
# APPENDED, like every widget before it. Saved workflows restore
# widget values by POSITION with no names stored.
"ambient_audio": ("AUDIO", {"tooltip":
"OPTIONAL OVERRIDE. Leave it empty and the bed is BUILT from the "
"scene -- the node has already read what the room sounds like, "
"and room tone is physically shaped noise, so it can be made "
"rather than fetched. No file needed and no second model pass.\n\n"
"Wire a recording here only when you want that recording: a real "
"location, or the events a synthesiser cannot make. Building "
"produces TONE -- air, rumble, plant, a mains hum, water, a "
"clock -- so a scene whose ambience is birdsong or a room full "
"of cutlery gets the room those things are in, not the things. "
"info says when that has happened.\n\n"
"Either way this is a MIX, not conditioning: it plays under what "
"the model generated, at the level you set. That is the "
"difference that makes it work -- ambience has nothing to "
"lip-sync to and asks nothing of the model, so it cannot put a "
"voice in a wordless shot.\n\n"
"Ambience derived in the prompt CANNOT do this. To score a "
"silent shot from text, the audio branch has to be left open, "
"and an open branch on a joint model fills itself -- at 4-8 "
"steps the last audio step resolves 50%-30% of its denoising in "
"one jump, and what it invents there is a voice. Wordless shots "
"keep their silent conditioning and get this bed on top instead, "
"which is what makes them sound like a room rather than a mute.\n\n"
"Looped with a crossfade to the length of the video, resampled if "
"it does not match, and downmixed or spread to match the "
"channels. Anything shorter than the film is fine."}),
"ambient_level": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0,
"step": 0.01,
"tooltip": "How loud the bed sits under everything, and the "
"switch that turns it on: above 0 a bed is built "
"from the scene even with nothing wired to "
"ambient_audio. 0 turns it off entirely.\n\n"
"A built bed is normalised to a fixed RMS first, so "
"this means the same thing in every room -- the "
"default 0.25 lands near -34 dBFS, present and well "
"under a spoken line. 0.15-0.3 is a bed you notice "
"only when it stops.\n\n"
"If the sum would clip, the whole mix is scaled down "
"rather than clipped, because clipping distorts the "
"line, which is the part worth keeping."}),
# APPENDED. Saved workflows restore widget values by position.
"foley_level": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0,
"step": 0.01,
"tooltip": "Build the sound an action makes, on shots that have "
"no line.\n\n"
"auto_sound already reads those sounds out of the beat "
"-- cuffs, a chain, a zip, footsteps -- but only as "
"TEXT in the prompt, and text can never open a shot's "
"audio branch, because an open branch on a joint model "
"invents a voice. So a wordless shot staging cuffs "
"going on was pinned to silence and the cue was "
"dropped: the one shot whose point is a sound made "
"none, and the only fix was writing the sound into the "
"beat by hand.\n\n"
"This builds it and mixes it into THAT SHOT'S span "
"instead. It asks nothing of the model, so it cannot "
"babble. Shots that already have a line, or a sound "
"you wrote yourself, are left alone -- their branch is "
"open and making that sound from the same prose, and "
"building over it would double every footfall.\n\n"
"A shot staging EFFORT is the exception and does get "
"built sound, even though its branch is open. It "
"opened to make a VOICE, and a voice is not a bed "
"frame or a chain -- so what is built there is the "
"non-vocal half the model will not make. Lower this "
"if anything doubles.\n\n"
"0.35 puts it about 37 dB below full scale: well clear "
"on a silenced shot, which sits near -65, and about 23 "
"dB under a spoken one. Raise it towards 0.6-0.7 if "
"you want it audible under a voice.\n\n"
"It is synthesis, not a recording: a click, a rattle, "
"a rustle, in the right place. Nothing vocal is ever "
"built. 0 turns it off; needs auto_sound on."}),
},
}
RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "INT", "INT", "INT", "FLOAT")
RETURN_NAMES = ("images", "audio", "info", "script", "frames_per_shot", "total_frames",
"shots", "video_seconds")
FUNCTION = "run"
CATEGORY = "sampling/minimax"
DESCRIPTION = ("Chain MiniMax-H3 shots into one continuous video with synchronised audio. "
"One paragraph per shot; the first paragraph is the scene. Your text is "
"passed through verbatim.")
def run(self, model, clip, vae, audio_vae, prompt, resolution, megapixels, shot_seconds,
steps, cfg, sampler_name, scheduler, seed,
first_frame=None, ref_image_1=None, ref_image_2=None, ref_image_3=None,
ref_image_4=None, negative=None, sigmas=None,
shift_video=12.0, shift_audio=3.0, apply_model_sampling=True,
silence_nonspeech=True, trim_seam=True, ref_noise_aug=0.999,
tiled_decode=True, cleanup_between_shots=True, plan_only=False,
latent_upscale="off", latent_upscale_scale=2.0,
upscale="off", upscale_model="none", upscale_target_short_edge=0,
upscale_batch=4, shot_length="from the beat", hold_restraints=True,
restart_after_removal=True, auto_remove=True, anchor="", character_memory="",
character_guard=True, pace=1.0, auto_sound=True, hold_scene_state=True,
mouths_shut_when_no_line=True, hold_gaze=True,
ambient_audio=None, ambient_level=0.25, foley_level=0.35,
**_removed):
# **_removed: a workflow saved with the old `save_defaults` widget still sends
# it. Swallowed rather than raising, so an existing workflow keeps loading.
notes = []
# BEFORE the numbers are repaired, because the numbers are the symptom and
# this is the cause. A combo holding something that is not one of its own
# options cannot be honoured, and rendering anyway would use settings nobody
# chose -- a scheduler's name in sampler_name, a seed in scheduler.
_bad = misaligned_widgets(
dict(resolution=resolution, sampler_name=sampler_name, scheduler=scheduler,
shot_length=shot_length, upscale=upscale, latent_upscale=latent_upscale,
upscale_model=upscale_model),
combo_options(self.INPUT_TYPES()))
if _bad:
raise RuntimeError(alignment_error(_bad))
# A widget value that arrives as NaN -- which is
# what a positional shift in a saved workflow produces -- would otherwise flow
# into the frame arithmetic and come out as a shot length of nan.
_fixed, _fixnotes = sane_widgets(dict(
megapixels=megapixels, shot_seconds=shot_seconds, steps=steps, cfg=cfg,
shift_video=shift_video, shift_audio=shift_audio,
ref_noise_aug=ref_noise_aug, latent_upscale_scale=latent_upscale_scale,
upscale_target_short_edge=upscale_target_short_edge,
upscale_batch=upscale_batch, pace=pace))
megapixels, shot_seconds = _fixed["megapixels"], _fixed["shot_seconds"]
steps, cfg = _fixed["steps"], _fixed["cfg"]
shift_video, shift_audio = _fixed["shift_video"], _fixed["shift_audio"]
ref_noise_aug = _fixed["ref_noise_aug"]
latent_upscale_scale = _fixed["latent_upscale_scale"]
upscale_target_short_edge = _fixed["upscale_target_short_edge"]
upscale_batch, pace = _fixed["upscale_batch"], _fixed["pace"]
notes.extend(_fixnotes)
# <Picture N> means ref_image_N, the socket. Everything downstream works on
# the packed roster instead, so translate once, here, before anything has
# read a tag. With the sockets filled from the top this changes nothing.
_wired = [n for n, r in enumerate((ref_image_1, ref_image_2, ref_image_3,
ref_image_4), 1) if r is not None]
_missing = unwired_reference_tags(f"{prompt}\n{character_memory}", _wired)
if _wired and list(_wired) != list(range(1, len(_wired) + 1)):
notes.append(
f"reference sockets {', '.join('ref_image_' + str(n) for n in _wired)} "
f"are wired with a gap, so <Picture N> has been read as the SOCKET "
f"number and renumbered onto the packed roster "
f"({', '.join(f'{n}->{i}' for i, n in enumerate(_wired, 1))}). Without "
f"this a tag naming a socket past the end of the roster matched nothing, "
f"and its image was dropped in silence")
prompt = renumber_reference_tags(prompt, _wired)
character_memory = renumber_reference_tags(character_memory, _wired)
if _missing:
notes.append(
f"<Picture {'>, <Picture '.join(str(n) for n in _missing)}> "
f"{'names a socket' if len(_missing) == 1 else 'name sockets'} with no "
f"image on it: nothing is wired to "
f"{', '.join('ref_image_' + str(n) for n in _missing)}. The tag is "
f"dropped from the text, because a tag pointing at no picture is a "
f"person the model is told to look up and cannot find. Wire the image, "
f"or take the tag out")
swap = flush_for_model_change(model)
if swap:
notes.append(swap)
check_vae_wiring(vae, audio_vae)
prompt, n_legacy = strip_legacy_fields(prompt)
if n_legacy:
notes.append(f"dropped {n_legacy} field-label line(s) left over from an older "
f"version of this node (overall_soundscape:, [Generation N] and the "
f"like) -- your text now goes to the model verbatim, and a label like "
f"that is read as text to put ON the picture")
if (anchor or "").strip():
# The anchor IS the scene, so nothing has to be taken out of the prompt to
# be one, and every paragraph is a beat. Otherwise the first ACTION becomes
# the scene: prepended to every shot, repeated to the end of the film, and
# never given a shot of its own. A removal written in it can never stick
# either, because the scene restates the garment on every later shot.
scene, beats = "", paragraphs(prompt)
else:
scene, beats = split_beats(prompt)
# A character sheet is not a beat. Pulled out of the beat list and folded into
# the scene, so it is re-stamped into EVERY shot -- which is what makes a
# removal stick and what stops a later shot describing no clothing at all.
beats, sheet = pull_character_sheets(beats)
# The sheet is kept APART from the rest of the scene: it is the part that
# varies per shot, because only the people a beat involves should be
# described in it. Everything else is stamped on every shot unchanged.
sheet, _dupes = merge_sheets((character_memory or "").strip(), sheet)
if _dupes:
notes.append(
f"{', '.join(_dupes)} described more than once -- character_memory and a "
f"'Name:' paragraph in the prompt are the same channel by two routes, and "
f"using both put the person in every shot twice. A model told about one "
f"person twice renders two of them. Kept the character_memory entry and "
f"dropped the duplicate")
static = build_scene(anchor, scene, "", "")
scene = build_scene(anchor, scene, "", sheet) # the whole of it, for inference
if sheet:
notes.append(f"folded {sheet.count(chr(10)) + 1} character-sheet line(s) into "
f"the scene instead of spending a shot on them -- a sheet "
f"describes people, it does not stage anything, and it has to "
f"be in EVERY shot for a removal to have something to scrub")
# Somebody the beats stage and the sheet never describes. Nothing in the shot
# says who they are, so the model invents them -- and a beat whose only person
# is undescribed falls back to the previous beat's cast, which describes
# someone who is not in the shot and says nothing about the one who is.
for _who, _in in unknown_people([extract_directives(b)[0] for b in beats],
sheet).items():
notes.append(
f"shot(s) {', '.join(str(n) for n in _in)} name {_who}, who has no entry "
f"in the character sheet. {_who} is IN those shots and nothing describes "
f"them -- no age, no clothes, no face -- so the model invents them, "
f"differently each time. Where that is the ONLY person a beat names, the "
f"shot falls back to the previous beat's people, and then it describes "
f"someone who is not in it and nobody who is. If {_who} is already on the "
f"sheet under another name, use one name throughout; otherwise add "
f"'{_who}: ...' to character_memory")
# Account for every paragraph, so a beat that quietly went somewhere else is
# visible. Two ways one disappears: it reads as a character sheet and is folded
# into the scene, or it was never a separate paragraph to begin with.
_given = len(paragraphs(prompt))
_sheets = len(sheet_lines(sheet)) if sheet else 0
notes.append(f"{_given} paragraph(s) in the prompt: {len(beats)} rendered as "
f"shots" + (f", {_sheets} folded in as character sheet(s)"
if _sheets else "")
+ ("" if (anchor or "").strip() else ", 1 kept as the scene"))
# Paragraphs are separated by a BLANK line. Lines joined by a single newline
# are ONE beat, so three actions written on three lines become one shot with
# three actions in it, and two of them look like they were absorbed.
_multi = [i for i, b in enumerate(beats, 1) if "\n" in b]
if _multi:
notes.append(
f"shot(s) {', '.join(str(i) for i in _multi)} carry more than one line. "
f"Paragraphs are separated by a BLANK line, so lines with only a single "
f"newline between them are one beat and share one shot. If those were "
f"meant to be separate shots, put an empty line between them")
if not beats:
raise RuntimeError("H3 Long Videos: no beat to render. Every paragraph after "
"the first is one shot; a character sheet ('Name: ...') "
"is folded into the scene and does not count as one.")
w, h = scale_to_megapixels(*parse_resolution(resolution), megapixels)
ceiling = align_frame_count(int(round(float(shot_seconds) * H3_FPS)))
# 'remove:' lines take their item out of the SCENE from that shot onward, so
# the scene stops describing a garment a beat has taken off. It applies to
# the removing shot too: the keyframe already shows the garment on at the
# start, and a description saying it is still worn is what puts it back.
shots, speech, gone, shown = [], [], [], []
shot_events = [] # per shot: the sounds its action makes
sounded = [] # beats that ask for a sound of their own
# Of those, the ones open ONLY because the beat stages effort. The branch
# is open on both, but for opposite reasons, and built sound has to tell
# them apart -- see the foley mix.
voiced_only = []
inferred_sound = [] # shots given one derived from their action
restrained = posed = rigid_latched = False
# Has any BEAT stated a posture yet? The scene fallback for the weight
# clause stops the moment one does. Film-level on purpose: it is the
# degraded path, taken when the per-person latch cannot fill because the
# script names no cast, and in that state there is nobody to attribute a
# posture to either. It errs towards saying nothing, which is the old
# behaviour.
beat_said_posture = False
restrained_who = set() # who is actually in the hardware
anchored = "" # where fastened limbs are held
worn_item = "" # the hardware, in the author's words
worn_items = [] # ...each piece of it, in order
displaced = {} # garment -> how it was moved
moved_shots = [] # shots reminded of it
revealed_shots = [] # shots that uncover a layer
unattributed = [] # shots whose line names no speaker
mouth_named = [] # shots with a line, holding the OTHER mouths
language_shots = [] # shots told which language the line is in
_spoken_words = {} # shot -> words actually inside the quotes
_breath_shots = [] # shots whose only sound was a breath
_langs_used = [] # ...and which languages those turned out to be
# THE WHOLE SCRIPT'S language, as the per-shot fallback. A single short
# line -- "Si." -- carries no evidence on its own, and reading it alone
# would call it English inside a Spanish script.
# ...and where the vote abstains on the whole script, the author's own
# statement anywhere in it settles the fallback rather than English:
# a script with ONE line in it, carrying one function word, is a script
# whose language nothing could vote for.
_script_voted = engine.language_of(engine.spoken_text(prompt or ""),
fallback="")
_script_lang = (_script_voted or engine.language_named(prompt or "")
or "English")
told_shots = [] # shots whose line orders somebody about
dialogue_marked = [] # shots whose quotes became <d>...</d>
poses = {} # name -> the posture a beat put them in
# Seeded from the SCENE, so the first journey has somewhere to start
# from. Without it "walks him down the hallway to the bedroom" had a
# destination and no origin, and a journey stated as a destination
# alone is the one that renders as a cut.
#
# READ FROM THE WHOLE SCENE, ANCHOR INCLUDED, and that was checked rather
# than assumed. Excluding the anchor looks right -- an anchor is the camera,
# and a lens line was being read for a location -- but the anchor is also
# DOCUMENTED to carry the location, and with one set there is no scene
# paragraph for the room to live in instead. Excluded, "A carpeted living
# room. Shot on 35mm" lost the origin of its first journey, which is the
# destination-with-no-origin case that renders as a cut: the bug this seed
# exists to fix, reintroduced through the widget meant to prevent it.
#
# The lens was never the anchor's fault. _PLACE matched INSIDE "shallow",
# and the word boundary in _PLACE_WORD is the whole of the fix.
here = place_named(scene) or first_place(scene)
# The film's ambient bed, read from the anchor and the scene rather
# than typed into every beat. See scene_ambient.
ambient_bed = scene_ambient(anchor, scene) if auto_sound else ""
ambient_shots = [] # shots given the bed
posture_shots = [] # shots told to keep a standing posture
travel_shots = [] # shots that move between places
where_shots = [] # shots in a room the scene does not name
acoustic_shots = [] # ...and the ones whose sound followed them there
paced_shots = [] # shots told to spread their action
staging_shots = set() # shots that MOVE a garment on screen
bared_shots = [] # ...and shots that uncover skin
bare_held = [] # ...and shots told a region is STILL bare
crowded = [] # (shot, clauses dropped for room)
absent_hold = [] # shots where the wearer is not on screen
exposed_by_beat = [] # (shot, garments the beat names while covered)
named_shots = [] # shots reminded the thing is still there
anchored_shots = [] # shots reminded of it
gaze_shots = [] # shots told where the look goes
looking_at = "" # the target, held until it changes
fall_shots = [] # shots told what takes the landing
device_shots = [] # shots whose line belongs to a machine
applied_shots = [] # shots that put the hardware on
early_hardware = [] # ...where the sheet already claimed it
tight_shots = [] # ...where the framing also crops it
cropped_wardrobe = [] # garments a named close frame stopped describing
# FILM-WIDE FRAMING LIVES IN THE ANCHOR, which is where the tooltip sends
# it: "Framing that belongs to the whole film -- look, camera, lighting,
# location." tight_framing was only ever handed the BEAT, so a film shot
# entirely in close-up -- declared once, in the documented place -- read as
# no close framing at all, and the warning below never fired for anybody who
# put their camera where they were told to. It fired only for people who
# wrote "close-up" into a beat, which the tooltip does not ask them to do.
# Computed once: the anchor is the same on every shot by definition.
_anchor_tight = tight_framing(anchor)
# Scenery whose state a beat has CHANGED. After that the node stops asserting
# the state it was written with, because it is no longer the state: a van
# opened in shot 2 must not be told it is shut in shot 3, and the scene
# paragraph goes into every shot still saying "doors closed".
state_acted = set()
stated_shots = [] # shots given a state put at the first frame
turned_shots = [] # shots given both ends of a staged change
mouth_shut = [] # shots told every mouth is closed
muted_sound = [] # shots whose written sound was given up for it
stripped_shots = set() # 0-based shots that took something off
restarted = [] # shots started fresh after a removal
restored = [] # garments an add: put back on
wearing_shots = [] # shots that put one back on, given both ends
# Names, so "lifts Kate onto the table" reads as moving a person rather than
# an object. A sheet LABELS them, which beats scanning prose for capitals --
# that way "Medium shadows" is not a member of the cast, and a name with an
# inner capital (McKenna) is not missed.
cast = re.findall(r"^\s*([A-Z][\w'-]{1,24})\s*:", sheet or "", re.M)
if not cast:
cast = re.findall(r"\b[A-Z][a-z]{2,}\b", scene or "")
# Which garment is under which, read from the script's own "takes A off to
# expose B". A sheet lists every layer at once, and a layer the model is told
# about is a layer it draws -- through the one on top of it.
# What the script states wins over what the categories imply: a beat saying
# "takes the shorts off to expose the belt" is the author telling us directly,
# and it may pair things the lists opposite know nothing about.
# LAYERING IS OPTIONAL. On, a covered garment is left out of the shot
# text until the thing over it comes off, because a described thing is a
# drawn thing and it would be drawn over its cover. Off, nothing is ever
# held back from the character memory -- which is what somebody wants who
# has attached a <Picture N> to the item and expects to see it.
# PER PERSON. Read off the whole sheet at once, layering has no idea whose
# garments it is pairing: a sheet with Dana in jeans and McKenna in a skirt
# and a chastity belt produced {chastity belt: skirt} with no owner on it,
# and the under-clause was then written into a shot describing only Dana.
# The belt does not go on Dana. A described garment is a drawn garment, and
# it is drawn on whoever is in the frame.
#
# Each sheet line is one person, so the layers are read line by line and
# the owner is kept. Anything the SCENE paragraph implies has no owner and
# is left unattributed, which is right: it belongs to the set, not a body.
deferred_shots = [] # (shot, items whose picture waits this shot)
covers, cover_owner = {}, {}
for _who, _line in sheet_lines(sheet):
for _u, _o in implied_layers(_line).items():
covers[_u] = _o
if _who:
cover_owner[_u] = _who
for _u, _o in implied_layers(static or "").items():
covers.setdefault(_u, _o)
covers.update(infer_layers([extract_directives(b)[0] for b in beats], scene))
if covers:
notes.append("read as layers -- underwear goes under whatever the sheet "
"also puts over it, and anything the script itself pairs by "
"taking one off to expose the other: "
+ "; ".join(f"{u} under {o}" for u, o in covers.items())
+ " -- each is left out of the scene text until the thing "
"over it comes off, so it is not described as visible "
"while it is covered")
_pose = posture_note(scene, first_frame is not None)
if _pose:
notes.append(_pose)
_ref = reference_note(len([r for r in (ref_image_1, ref_image_2, ref_image_3,
ref_image_4) if r is not None]),
ref_noise_aug, first_frame is not None)
if _ref:
notes.append(_ref)
# The acoustic of the space, read once: it is the same room in every shot.
# The opening beat is the fallback: with `anchor` set there is no scene
# paragraph, and an anchor describes the camera rather than the room.
_opening = extract_directives(beats[0])[0] if beats else ""
_room = room_tone(scene, _opening) if auto_sound else ""
_room_src = "the scene" if room_tone(scene) else "the opening beat"
# The same two readings, kept for the MIX and not gated on auto_sound.
# auto_sound governs what goes in the PROMPT, which is a conditioning-side
# question -- the mixed bed conditions nothing, so turning the prompt-side
# inference off should not also silence the room.
_mix_bed = scene_ambient(anchor, scene)
_mix_room = room_tone(scene, _opening)
if _room:
notes.append(f"room tone read from {_room_src}: {_room}. It goes under the "
f"shots whose audio branch is already open -- ones with a line, "
f"or with a sound you described yourself -- so those are not "
f"conditioned on digital silence, and nothing real is that "
f"quiet. It can never OPEN a branch: a shot with no line and no "
f"sound of your own stays pinned to silence and carries no room "
f"tone either, because the clause would describe an acoustic the "
f"conditioning says is not there. That is what stops the mouth "
f"moving. H3 is joint, so a free branch fills itself with a "
f"voice and the face lip-syncs to the babble, and no wording "
f"suppresses that -- only the silent keyframe does, and it pins "
f"the whole shot rather than just its opening")
active = [] # the people the previous beat involved
_seen_before = set() # everyone a shot has described so far
_returns = [] # (shot, names back after a shot away)
_placed_shots = {} # 0-based shot -> who it introduces in position
shot_cast = [] # the names each shot describes
guard_words = beat_words = total_words = sound_words = 0
# THE PROMPT ENGINE. One state, read beat by beat, rendered once per shot.
# It replaces the continuity guards that used to be derived independently
# -- hold, anchors, posture, where, removal, wearing, moved -- each of
# which searched the beat for its own thing and appended its own sentence
# with no way to see the others. That is what emitted "holding the neck
# behind the back", dropped the handcuffs from a beat that applied two
# things, and moved the camera into a door. See engine.py.
_state = engine.SceneState(place=engine.place_in(scene or ""))
# Which beat first puts each thing on, read before anything renders. The
# sheet cannot say when; the script can, and where it does it wins.
_staged_at = engine.staged_applications(
[extract_directives(b)[0] for b in beats])
# What the SHEET names, so the two can be told apart: hardware this node
# held back out of the sheet is a conflict it created, and hardware the
# sheet never mentioned is not.
_sheet_hw = {c for c, _p, _w, _a in engine.hardware_spans(sheet or "")}
for b in beats:
body, toks, adds = extract_directives(b)
# Quoted speech becomes H3'S OWN dialogue marker before anything else
# reads it. <d> and </d> are special tokens the model was trained with,
# and they say "this is spoken" where quotation marks say nothing at
# all -- a quoted imperative is just an imperative sentence in the
# prompt, and the model performed it. Every word is kept in order; only
# the quotation marks are exchanged. Reported below.
_marked = mark_dialogue(body)
if _marked != body:
dialogue_marked.append(len(shots) + 1)
body = _marked
# THE ENGINE READS FIRST, before anything downstream asks it what is
# true. It was reading further down at one point, after the hardware
# latch had already consulted it, so every shot was answered with the
# PREVIOUS shot's state -- and shot 1 with an empty one.
# The sheet first: what it already says is true before any beat runs.
# ...except anything the SCRIPT stages later. A sheet says what
# somebody has and never says when, so "McKenna: she, 27, green
# dress, handcuffs" beside a script that cuffs her in beat 3 put the
# cuffs on her from shot 1 -- reported as a handcuff on her arm
# before she is handcuffed.
_later_for_state = {c for c, at in _staged_at.items()
if at > len(shots) + 1}
for _n, _line in sheet_lines(sheet):
if _n:
_state.declare(_n, _line, staged_later=_later_for_state)
_ch = _state.read(body, cast=[n for n, _ in sheet_lines(sheet) if n],
shot=len(shots) + 1)
# Who this beat involves, decided BEFORE the removals: a beat that
# undresses somebody names no garment, so the wardrobe to clear is read
# off their sheet entries -- and only theirs. Undressing one person must
# not take the other one's clothes off.
# Bound whether or not the guard runs: the previous shot's cast is read
# further down, to keep saying what is bare about somebody the keyframe
# still carries, and that has nothing to do with the guard being on.
_was = list(active)
if character_guard:
shot_sheet, active = sheet_for_beat(sheet, body, active)
if len(sheet_lines(sheet)) > len(sheet_lines(shot_sheet)):
notes.append(f"shot {len(shots) + 1} describes only "
f"{', '.join(active) or 'the scene'} -- the rest of the "
f"sheet is held back, because a person the text "
f"describes is a person the model draws")
# Somebody back after a shot away. The keyframe is the PREVIOUS shot's
# last frame, so a person who was not in that shot is not in the
# picture this one starts from -- their appearance is carried by the
# sheet text and nothing else, and text drifts where a picture does
# not. This is what "walks out of frame and comes back looking
# different" is.
for _grp, _who_all in unresolved_pronouns(sheet, body, _was):
notes.append(
f"shot {len(shots) + 1} says '{_grp}' and "
f"{' and '.join(_who_all)} all answer to it, so the guard could "
f"not tell which -- and it describes NEITHER rather than both, "
f"because naming somebody the beat did not is how an extra "
f"character walks into a shot. Write the name instead of the "
f"pronoun in that beat and it resolves")
# First appearance, with the beat saying where they ARE rather than
# staging them arriving. See the handoff decision in the render loop.
_new = [n for n in active if n not in _seen_before]
if _new and not arrives_in(body) and shots:
_placed_shots[len(shots)] = list(_new)
notes.append(
f"shot {len(shots) + 1} introduces {', '.join(_new)} in "
f"position rather than arriving, so the previous shot's last "
f"frame stops being this shot's FIRST frame -- that frame does "
f"not have them in it, and a keyframe is a picture, so they "
f"would have to appear out of nothing and travel to the spot "
f"the beat describes. The frame is still carried, as a "
f"reference, so the room comes with it. Write the entrance -- "
f"'walks in', 'steps through' -- if you would rather they "
f"arrive on screen and keep the frame as the anchor")
_back = [n for n in active if n not in _was and n in _seen_before]
if _back:
_returns.append((len(shots) + 1, list(_back)))
_seen_before.update(active)
else:
shot_sheet = sheet
# Read the removal out of the beat itself. Explicit 'remove:' lines still
# win and are added to whatever is inferred.
if auto_remove:
inferred = [t for t in infer_removals(body, scene)
if t not in toks and t not in gone]
# HARDWARE the beat itself unlocks. infer_removals filters restraint
# words out on purpose -- a cuff must not come off because a beat
# mentions it -- so a script that unlocks the cuffs in its prose and
# writes no remove: line left them in the sheet for ever. Clearing
# the hold was not enough: the sheet still listed them, so the next
# shot re-detected the restraint from the scene text and latched it
# again, over hardware the beat had put on the floor.
if hold_restraints and restraint_coming_off(body):
# The WHOLE sheet, not this shot's. A shot that describes only
# the person doing the unlocking has no entry for the person
# wearing it, so nothing was found to remove and the next shot
# read the hardware straight back out of her sheet.
for _n, _ln in sheet_lines(sheet if sheet_lines(sheet) else scene):
for _hw in restraint_words(_ln):
# Only hardware THIS BEAT names, or one it refers to by
# pronoun when the wearer has just one piece. "Sam cuts
# the rope free" must not unlock her handcuffs.
_named = re.search(r"\b" + re.escape(_hw) + r"\b",
body or "", re.I)
_pron = (len(restraint_words(_ln)) == 1
and re.search(r"\b(?:them|it)\b", body or "", re.I))
if (_named or _pron) and _hw not in toks and _hw not in gone:
inferred.append(_hw)
if inferred:
toks = list(toks) + inferred
notes.append(f"shot {len(shots) + 1}: read '{', '.join(inferred)}' as "
f"coming off, from the beat's own wording")
# "...strip out of their clothes, becoming naked" names nothing, so every
# other path had nothing to take off and the scene went on listing the
# whole wardrobe -- in every later shot, which is how the clothes came
# back on. Here the garments are read off the sheet instead of the beat.
bare = auto_remove and strips_bare(body)
if bare:
# ...off THEIR OWN entry. Read off the whole shot sheet, a shot
# describing two people stripped both wardrobes, so one character
# undressing undressed the other as well.
_strippers = strips_who(body, active if character_guard and active
else [n for n, _ in sheet_lines(shot_sheet) if n])
_their_sheet = "\n".join(
ln for n, ln in sheet_lines(shot_sheet) if n in set(_strippers)
) or shot_sheet
stripped = [g for g in garments_in(_their_sheet)
if g not in toks and g not in gone]
if stripped:
toks = list(toks) + stripped
notes.append(
f"shot {len(shots) + 1} reads as undressing "
f"{', '.join(active) if character_guard and active else 'the cast'}"
f" completely, and the beat names no garment -- so the wardrobe was "
f"read off the character sheet and all of it taken off: "
f"{', '.join(stripped)}. Anything worn that is not in that list is "
f"still described as on; name it in a 'remove:' line if so")
elif not gone:
notes.append(
f"shot {len(shots) + 1} reads as undressing completely, but no "
f"garment was recognised in the character sheet, so nothing was "
f"taken off and every later shot still describes the clothes. Add "
f"a 'remove:' line naming them")
# A beat's own words go to the model verbatim. Naming a garment that came
# off in an EARLIER beat puts it back -- the scene is clean, the removal
# was honoured, and then the beat itself asks for it. The removing beat
# names it legitimately, so only later ones are reported.
revived = [t for t in gone if names_any(body, [t])]
if revived:
notes.append(
f"shot {len(shots) + 1} names {', '.join(revived)} in its own text, and "
f"that came off earlier. Beats are sent to the model word for word, so "
f"naming it puts it back on -- the scene no longer mentions it, but this "
f"beat does. Reword the beat if it should stay off")
if toks:
stripped_shots.add(len(shots))
gone.extend(t for t in toks if t not in gone)
# An added layer is subject to removal too: once the shirt comes off,
# the phrase that introduced it goes with it, or the scene keeps
# describing a garment that is no longer there. Retired HERE, at the
# moment of removal, so it retires the phrases that exist NOW -- an
# add written later is putting the thing back on and must survive.
_retired = [a for a in shown if names_any(a, toks)]
if _retired:
shown = [a for a in shown if a not in _retired]
notes.append(f"shot {len(shots) + 1} takes off something an earlier "
f"'add:' had put on, so that line retires with it: "
+ "; ".join(_retired))
# Reported with the SHEET's words, not the head-noun keys. The
# reader checks this line to see what the shot was told, and a
# bare "shorts" here for a sheet saying "blue jeans shorts" reads
# as the node having lost the description -- which is exactly the
# bug it had, so the report has to be able to show it is gone.
notes.append(f"removed from the scene from shot {len(shots) + 1} on: "
+ ", ".join(scene_name_for(t, scene) or t for t in toks))
maybe = missing_removals(body, scene, gone) if not auto_remove else []
if maybe:
notes.append(f"shot {len(shots) + 1} reads as taking something off, but the "
f"scene still describes {', '.join(maybe)} and there is no "
f"'remove:' line for it -- so every shot keeps saying it is worn. "
f"Add 'remove: {maybe[0]}' to that beat")
_wearing = "" # the both-ends clause for a garment going on
_staged_add = [] # ...and the phrases it covers, held out of
# this shot's static wardrobe
if adds:
shown.extend(a for a in adds if a not in shown)
# An `add:` that names something previously removed is putting it
# back ON. `gone` only ever grew, so the layering could never
# re-cover what it uncovered: shorts taken off and then added back
# left the thong described for the rest of the film.
# NOT removed from `gone`. The scene stays scrubbed, or the sheet
# describes the thing again alongside the add: line that put it
# back -- two mentions, and with a tagged object two copies of its
# <Picture N>, which is the duplicate-reference hazard.
#
# Layering is told separately: for covering purposes the garment is
# back on, so what is under it is hidden again.
_back = [g for g in gone
if any(names_any(a, [g]) for a in adds)
and g not in restored]
if _back:
restored.extend(_back)
notes.append(
f"shot {len(shots) + 1} puts " + ", ".join(_back)
+ " back on, so anything it covers is hidden again from "
"here. A garment coming back has to un-cover as well as "
"re-cover, or the layer under it stays described for the "
"rest of the run")
# ...and it goes on DURING this shot, which nothing said. The
# phrase went straight into the scene block as a worn item, so a
# shot inheriting a last frame without the garment was told flatly
# that it has it. That is a disagreement rather than a change, and
# the model settles it in the opening frames by turning whatever
# is on the body into the garment.
#
# Only where the BEAT stages the dressing. An `add:` revealing a
# layer that was underneath all along describes something already
# worn, and staging it would invent a dressing that never happens.
_worn_now = [a for a in adds
if any(beat_stages_wearing(body, g) for g in _back)
and any(names_any(a, [g]) for g in _back)]
if _worn_now:
_wearing = wearing_clause(_worn_now)
_staged_add = list(_worn_now)
wearing_shots.append(len(shots) + 1)
notes.append(f"added to the scene from shot {len(shots) + 1} on: "
+ "; ".join(adds))
# The scrub applies to the removing shot too -- but only because that
# shot's KEYFRAME already shows the garment on at the start, so the text
# saying it is worn would put it back at the end.
#
# A shot with no keyframe has no such picture. Scrubbing there deletes the
# only statement that the garment was ever on, and the shot then says: it
# is not worn, take it off, and the thing under it is already showing.
# The model renders that contradiction as a garment half present -- open,
# or partly cut -- with the layer beneath it on display.
#
# ...and a keyframe that is not ANCHORING is no such picture either.
# Below KEYFRAME_SAFE_AUG the handoff stops being a keyframe and rides
# as an extra reference: it says who somebody is, not what the opening
# frame holds. Scrubbing on that assumption took the belt out of the
# text of the very shot that removes it, so the shot said it is not
# worn AND to take it off, and it was gone a beat early with nothing
# anchoring it on. Reported exactly that way.
i_shot = len(shots)
_anchoring = (ref_noise_aug is None
or float(ref_noise_aug) >= KEYFRAME_SAFE_AUG)
has_keyframe = ((i_shot > 0 or first_frame is not None)
and _anchoring
and not (restart_after_removal
and (i_shot - 1) in stripped_shots))
visible = gone if has_keyframe else [g for g in gone if g not in toks]
# Whether the chain actually broke was invisible. restart_after_removal
# costs a visible cut, so it should be possible to confirm it happened
# without reading the code -- and to see it did NOT when it should have.
if (i_shot > 0 and restart_after_removal
and (i_shot - 1) in stripped_shots):
restarted.append(i_shot + 1)
if toks and not has_keyframe:
_why = ("its opening frame is not anchored -- ref_noise_aug "
f"{float(ref_noise_aug):g} is below {KEYFRAME_SAFE_AUG:g}, so "
"the handoff rides as a reference rather than holding the "
"first frame" if not _anchoring else
"it has no keyframe")
notes.append(f"shot {i_shot + 1} takes something off and {_why}, "
f"so {', '.join(toks)} stays described as worn HERE -- the "
f"text is the only thing saying it was on to start with. It "
f"is scrubbed from the next shot on")
# A garment still underneath something stays out of the text: described,
# it gets drawn, and it is drawn through whatever is over it.
# A displaced outer garment is still WORN, so `gone` never hears about
# it -- but it is no longer covering what is under it. Without this a
# beat pulling the shorts down to show the thong described the thong
# in that shot only, and the layering hid it again in the next.
# THIS BEAT'S displacements, read here rather than 500 lines further
# down where the latch is updated. The layering consumed
# before the beat had been added to it, so the shot that LIFTS the
# skirt still saw it covering, and the belt came out from under it one
# shot late. The latch below is unchanged; this only looks ahead.
_moved_now = {g for g, _h in displaced_garments(body, shot_sheet or sheet)}
# ...minus anything this beat puts BACK. Without it the shot that
# lets the skirt fall still counted the skirt as moved, so what
# was under it stayed uncovered for one shot too many -- the
# mirror of the off-by-one that made it uncover one shot late.
_back_now = set(restored_garments(body, shot_sheet or sheet))
if puts_it_back(body) and len(displaced) == 1:
_back_now |= set(displaced)
_heads_back = {str(g).lower().split()[-1] for g in _back_now}
_moved_now = {g for g in _moved_now
if str(g).lower().split()[-1] not in _heads_back}
covered = hidden_layers(covers,
[g for g in visible if g not in restored],
(set(displaced) | _moved_now)
- {g for g in (set(displaced) | _moved_now)
if str(g).lower().split()[-1]
in _heads_back})
# A BEAT that names a covered garment. Beats are passed through word for
# word and never scrubbed -- that is the node's oldest promise -- so the
# layering can take the belt out of the sheet and the beat can put it
# straight back. The words win, the thing is drawn over what is on top of
# it, and from there the keyframe carries it into every later shot, which
# is why it looks permanent rather than like one bad shot.
#
# Not edited, ever. Reported, because from the outside it is
# indistinguishable from the layering being broken.
_said = [g for g in covered
if re.search(r"\b" + re.escape(g) + r"\b", body or "", re.I)]
if _said:
exposed_by_beat.append((len(shots) + 1, _said))
# The shot that UNCOVERS one says so. Reported: the shorts come off and
# the render goes straight to bare skin, past the underwear the sheet
# named. The removal clause is emphatic and specific -- off the body,
# dropped out of frame -- while the layer beneath is one entry in an
# attribute list, and against a model whose prior for trousers coming
# off is nudity, a list entry does not compete. Only on the shot that
# takes the cover off; after that it is simply worn.
# ...and not when the under-layer is coming off in the same breath. A full
# strip takes the cover AND what was under it, and "the panties underneath
# are what shows there now" would put back the one garment the beat was
# most explicit about removing.
_revealed = reveal_clause([u for u in revealed_by(covers, toks)
if u not in visible and not names_any(u, toks)])
if _revealed:
revealed_shots.append(len(shots) + 1)
# ...and when the sheet names NOTHING underneath, say the region is bare.
# Otherwise the shot says a garment is gone and leaves the space it left
# unspecified, which is where the model's own prior fills in -- legwear
# the prompt never asked for, carried on by the keyframe from there.
# Never both: reveal_clause speaks when something is under, this when
# nothing is.
# ...and not beside BARE_HOLD, which already says everything comes off.
# Both firing said it twice and attributed it twice.
_bare = ("" if (_revealed or bare)
else bare_clause(toks, covers, shot_sheet))
# ...and on EVERY shot after it, from state, for as long as the
# region has nothing on it. Said only on the uncovering beat, the
# region went unspecified from the next shot on -- and the model
# fills an unspecified region from its own prior. Reported as a bra
# coming back on a topless character whose sheet never had one.
#
# Only for people this shot describes: a region belonging to nobody
# in the frame is the sentence that draws the body to own it.
if not _bare and not bare and not _revealed:
# The same people _described names further down; that is computed
# after this clause, so the expression is repeated rather than
# moved -- moving it ahead of the sheet work it depends on is how
# a shot ends up guarding the previous shot's cast.
_who_here = (active if character_guard else
[n for n, _ in sheet_lines(shot_sheet) if n])
# ...and ALSO for anybody the keyframe still carries. A beat that
# names only the other person -- "Sam watches from the doorway" --
# left her out of the shot's cast, so nothing said what was on her
# chest for that one beat, and the model filled it in. Reported as
# a bra popping into ONE beat: this is the beat.
#
# The previous shot's cast, because that is the frame this shot
# opens on. It is one continuity sentence, not a sheet entry --
# no face, no wardrobe, nothing that would stage a person who is
# not there. She is already in the picture; the words only have to
# stop contradicting it.
# NOT gated on the shot starting fresh, though the frame is the
# reason this exists. That gate was written and reverted: the shot
# that uncovers a region is a removal shot, restart_after_removal
# makes the NEXT one fresh, and that next one is exactly the shot
# this clause is for -- so the gate disabled the fix in every real
# case while the reported bug stayed.
#
# The residual risk is real and stated: on a fresh shot nothing
# pictorial carries her, so naming her is one sentence about
# somebody the beat did not stage. It is one clause, not a sheet
# entry, and the alternative is the region the model fills in by
# itself. If a duplicate of the UNDRESSED character ever shows up,
# this is the first thing to look at.
_carried_on = [n for n in (_was or []) if n not in (_who_here or [])]
_rows = []
for _n in list(_who_here or []) + _carried_on:
_q = _state.people.get(_n)
if _q and _q.bare:
# WHAT IS ACTUALLY ON, from the state -- not the sheet.
# The sheet still lists the shirt, because the character
# memory is never edited, so passing it here suppressed
# every region the sheet ever mentioned and the clause
# could only ever speak about feet.
_rows.append((_n, list(_q.bare), ", ".join(_q.worn)))
_name_it = (len(_rows) > 1 or len(_who_here or []) > 1
or any(_n in _carried_on for _n, _r, _o in _rows))
_bare = "".join(
bare_hold(_rg, covers, _on, whose=(_n if _name_it else ""))
for _n, _rg, _on in _rows)
if _bare:
bare_held.append(len(shots) + 1)
if _bare:
bared_shots.append(len(shots) + 1)
# Terminated, or the last sheet line welds onto the beat -- "grey coat
# Maya lies still" -- and a name fused to the end of an attribute list is
# read as one more item in it.
# THE CHARACTER MEMORY IS NOT EDITED. This briefly scrubbed hardware
# out of the sheet before the beat that stages it, to stop a cuff
# appearing on a wrist before the cuffing. It was the wrong lever and
# it was told so: "Stop removing items from the character memory!"
#
# It was also worse than it looked. scrub_removed drops the whole
# comma-separated entry, so "green dress, steel collar" lost the line
# -- and with the line gone the person went with it, leaving shots
# with nobody described in them at all.
#
# The sheet is the author's. Where it disagrees with the script the
# node says so in the report and holds ITS OWN clause back, which is
# the half that was actually asserting a lie. Only removals the
# author staged still scrub, which is what that mechanism is for.
# COVERED IS NOT REMOVED. `covered` used to go in here beside
# `visible`, so a garment read as under something came out of the
# sheet entirely and took its <Picture N> with it. Reported three
# times as items disappearing out of the character memory. Only
# removals the AUTHOR staged scrub now; being underneath is said, in
# under_clause, not enacted by deletion.
# UNDERWEAR IS PLACED, NOT DELETED. Everything else that is covered
# still waits: a locket under a coat cannot be seen, nothing is lost
# by holding it until the coat comes off, and its picture would ask
# the model to draw a thing that is not visible.
# ...and only for people this shot actually describes. A garment
# whose owner is not in the frame is a garment drawn on whoever is.
# NOT `_described` -- that is assigned further down the loop, so
# reading it here would answer with the PREVIOUS shot's cast. Same
# expression, evaluated where it is needed.
_here = set(active if character_guard
else [n for n, _ in sheet_lines(shot_sheet) if n])
_worn_under = [u for u in covered
if is_undergarment(u)
and (cover_owner.get(u) in _here
or u not in cover_owner)]
_hidden = [u for u in covered if u not in _worn_under]
shot_scene = scrub_removed(
"\n".join(terminate_lines(p) for p in (static, shot_sheet) if p.strip()),
visible + _hidden)
# A NAMED CLOSE FRAME STOPS DESCRIBING WHAT IT CANNOT HOLD.
#
# Applied HERE, last, on the finished text: the layer, removal and
# restore logic has already run and its bookkeeping is untouched, so
# this only changes what is SAID on this shot and can never be mistaken
# by anything downstream for a garment coming off. The next shot builds
# from the sheet again, so nothing accumulates.
_holds = frame_holds(anchor) or frame_holds(body)
_cropped = out_of_frame_garments(shot_scene, _holds)
if _cropped:
shot_scene = hide_item(shot_scene, _cropped)
for _c in _cropped:
if _c not in cropped_wardrobe:
cropped_wardrobe.append(_c)
# THE REFERENCE STAYS. It was taken off for one commit, on the
# reasoning that a near-clean reference reproduces its picture and so
# draws the belt over the jeans -- which is true as far as it goes,
# but I changed the occlusion clause in the SAME commit and so never
# tested the combination that matters: the picture present AND the
# cover described as a whole opaque surface. The version that poked
# through had the picture with the weak clause.
#
# An author who attaches a <Picture N> to an item wants that item to
# look like that picture, and dropping the tag drops the reference
# entirely -- there is no weaker setting for one image, only
# ref_noise_aug for all of them. So it stays, and the cover carries
# the weight. See under_clause.
# The words stay in every shot; the PICTURE waits for the cover
# to come off. See defer_tag_for -- a reference reproduces its
# image and draws the thing, whatever the text says is over it.
_deferred = list(_worn_under)
# THE WORDS WAIT WITH THE PICTURE. At cfg 1 there is no negative
# prompt, so naming a thing draws it -- and with the picture
# already withheld and the occlusion clause no longer naming the
# belt, the sheet's own mention was the last one standing and it
# was enough on its own. Text cannot take itself back; every
# wording added to suppress it made it worse.
#
# Held back, not deleted, and the difference is what made this
# feel like deletion the first time: lifting a skirt was not read
# as a displacement, so the cover never came off and the item
# never returned. That is fixed, the restore verbs are in, and
# the report below names the item and the shots. hide_item is
# surgical where scrub_removed is not: it takes the phrase and
# leaves the entry, so a person's line cannot go with it.
shot_scene = defer_tag_for(shot_scene, _worn_under)
shot_scene = hide_item(shot_scene, _worn_under)
if _deferred and len(shot_scene) >= 0:
deferred_shots.append((len(shots) + 1, list(_worn_under)))
_under = under_clause(
[(u, covers.get(u, ""),
cover_owner.get(u, "") if len(_here) > 1 else "")
for u in _worn_under])
# A READING COPY, never emitted. The sheet is sent to the model exactly
# as written; this is only what the node consults when deciding whether
# to assert hardware is FASTENED, and it leaves out anything the script
# stages later. Without it the sheet's own mention latched the standing
# hold from shot 1 -- "the handcuffs stay closed and fastened as they
# were put on", two shots before anybody put them on -- which is the
# assertion that had to stop, as against the author's description,
# which did not.
_sheet_says_early = [c for c, at in _staged_at.items()
if c in _sheet_hw and at > len(shots) + 1]
_scene_for_state = (scrub_removed(shot_scene, _sheet_says_early)
if _sheet_says_early else shot_scene)
# "ALREADY ON" MEANS BEFORE THIS SHOT. The applying test asks whether
# the hardware was on before the beat that puts it on, so the item
# being staged HERE has to be out of the answer as well -- otherwise
# the sheet's own mention vetoes the both-ends clause on exactly the
# shot that stages the fastening, and it gets the standing hold: a lie
# about its first frame.
_sheet_says_now_or_later = [c for c, at in _staged_at.items()
if c in _sheet_hw and at >= len(shots) + 1]
_scene_before_now = (
scrub_removed(shot_scene, _sheet_says_now_or_later)
if _sheet_says_now_or_later else shot_scene)
# Retirement is handled at the moment of removal, above, so this is just
# what is currently on. Filtering here against the whole history of `gone`
# meant an add could never put anything BACK: the token stays in `gone`
# for the rest of the film, so "add: her locket is back on" was suppressed
# by the removal that took it off in the first place.
# A garment going ON in THIS shot is described by the wearing clause,
# which gives it both ends. Listing it here as well would say it is
# already worn while the clause says it is being put on -- the same
# shot holding the garment in two states, which is the disagreement
# that made it appear at the first frame. It joins the static wardrobe
# from the NEXT shot on, exactly as a removal scrubs from its own.
live = [a for a in shown if a not in _staged_add]
if live:
tail = ". ".join(a.rstrip(".") for a in live) + "."
tail = tail[0].upper() + tail[1:]
shot_scene = f"{shot_scene} {tail}".strip() if shot_scene else tail
# The removal has to FINISH inside this shot, because its last frame is
# the next shot's keyframe. Stated only here; naming the garment again
# later would put it back.
#
# A full strip says it once rather than reciting the wardrobe: listing
# eight garments coming off is eight more mentions of clothing in a shot
# whose point is that there is none.
# WHOSE HANDS. Without an agent the clause says a garment comes off by
# itself, and a belt nobody is touching drops to the floor -- reported on
# a beat where she ASKS to have it taken off, which the clause turned into
# it removing itself. The wearer is read from the sheet where the item is
# listed, so "she asks Dan" gives the hands to Dan and not to her.
# The sheet, or the SCENE when the sheet is empty. A sheet paragraph
# that was folded into the scene never reaches pull_character_sheets --
# it only ever sees the beat -- so `sheet` is "" for the whole run and
# shot_sheet with it. Both the wearer and the cast then came back empty
# and EVERY removal clause went out agentless: the beat says she takes
# the shorts off, the clause says they come off with no hands named, and
# with a second person in the shot the model gives that second removal to
# him. The action happens twice, once by each of them.
_who_sheet = shot_sheet if sheet_lines(shot_sheet) else scene
_wearer = next((n for n, ln in sheet_lines(_who_sheet)
if n and names_any(ln, toks)), None)
# WHOSE body is bare. Unattributed in a two-person shot this reads as
# an instruction about everyone on screen, and the second character
# undresses alongside the first. Done HERE because _wearer is what
# answers it, and `active` rather than `_described` because that is
# assigned further down the loop -- reading it here would get the
# PREVIOUS shot's cast.
_bare = own_body(_bare, _wearer or (active[:1] if active else []),
active if character_guard else
[n for n, _ in sheet_lines(_who_sheet) if n])
# `active`, not `_described`: that is assigned further down the loop, so
# reading it here gets the PREVIOUS shot's cast -- which on this shot meant
# Dan was not in it, the "asks" rule never applied, and the clause gave the
# hands back to the person doing the asking.
_cast_here = (active if (character_guard and active) else
[n for n, _ in sheet_lines(_who_sheet) if n])
# PER GARMENT. One agent for the whole beat meant a beat that takes a
# coat off and then asks about a scarf gave BOTH to the other person --
# her own coat came off by his hands. Each garment is attributed on its
# own clause, and garments sharing an agent are said in one sentence.
_by_agent = {}
for _t in (toks if not bare else []):
_w = next((n for n, ln in sheet_lines(_who_sheet)
if n and names_any(ln, [_t])), _wearer)
_a = removal_agent(body, _cast_here, _w, _t)
_by_agent.setdefault(_a, []).append(_t)
tail = (own_body(BARE_HOLD, _wearer or (active[:1] if active else []),
active if character_guard else
[n for n, _ in sheet_lines(_who_sheet) if n])
if (bare and toks)
else "".join(off_by_last_frame(_items, _a, scene, body)
for _a, _items in _by_agent.items()))
# Once hardware is on, it stays on. Latched, not re-detected: a beat that
# does not mention the cuffs does not mean they came off, and a cuff that
# renders open is not a detail that drifts -- it is the scene ceasing to
# make sense. Cleared only by a `remove:` that names the hardware.
_was_restrained = restrained
if hold_restraints:
if (names_any(RESTRAINT_HOLD_KEY, toks)
or any(restraint_present(t) for t in toks)
# ...or the BEAT itself says the hardware comes off. Without
# this the latch could only ever be cleared by a remove:
# line, and a script that unlocks the cuffs in its own prose
# kept being told they stay fastened -- for the rest of the
# film, over hardware lying on the floor.
# ...and only when this beat's undoing actually took a
# piece of hardware out of the sheet. "Sam cuts the rope
# free" reads as an undoing, but she wears handcuffs, and
# clearing on the verb alone unlocked them.
or (restraint_coming_off(body)
and any(_RESTRAINT_WORD.match(str(t)) for t in toks))):
restrained = posed = rigid_latched = False
anchored = ""
worn_item = ""
worn_items = []
restrained_who = set()
elif restraint_present(body) or restraint_present(_scene_for_state):
restrained = True
# At the moment hardware GOES ON -- every time, not only the
# first. Latching once meant a second person cuffed in a later
# beat never joined the set, so their hardware was applied and
# then never described again for the rest of the film.
#
# Still not re-read on shots that merely MENTION restraints:
# that was the original fault, where the man alone checking the
# cuffs was marked as wearing them.
if not _was_restrained or restraint_going_on(body):
_new = restrained_by_beat(body, active)
restrained_who |= (_new if _new else set(active))
# The shot where the hardware GOES ON. Newly restrained -- so it was not on
# before -- and the beat stages the act rather than describing it worn. On
# that one shot the standing hold is a lie about the first frame, and a
# first frame that already has the cuffs closed leaves the struggle to
# happen in whatever order is left over. That is being caught after being
# restrained instead of before.
#
# "Already on" has to include what the SCENE says, not only the latch.
# On shot 1 the latch is empty by definition, so a sheet reading "wrists
# cuffed behind back" would otherwise let a beat that locks a SECOND item
# on declare the first one off at the first frame.
# Every item, not just the newest. worn_item was a single string, so
# "cuffs her wrists" then "gags her with duct tape" overwrote the
# cuffs -- and from that shot on the cuffs were never named again,
# which is hardware that stops being drawn.
_named_item = hardware_named(body) if restrained else ""
# EVERY item this beat names, not just the most specific one. One beat
# that cuffs the wrists AND locks on a collar used to record whichever
# phrase was longer and drop the other for the rest of the film.
for _hw in (hardware_all_named(body) if restrained else []):
# Substring-aware, because the beats name the same thing differently
# from shot to shot: "handcuffs" in shot 1 and "the cuffs" in shot 4
# is ONE pair of handcuffs, and an exact-match check listed both --
# "The handcuffs, steel collar, chain and cuffs stay closed", which
# reads as four things and invites the model to draw a spare set.
_same = next((k for k, p in enumerate(worn_items)
if p in _hw or _hw in p), None)
if _same is None:
worn_items.append(_hw)
elif len(_hw) > len(worn_items[_same]):
worn_items[_same] = _hw
# THE ENGINE IS THE AUTHORITY ON WHAT IS ON WHOM, and this is the
# only place the answer comes from now. The old derivation ran here
# too, in parallel, and a disable-check showed the engine was not
# load-bearing at all: pull it out and nothing changed, because both
# paths were computing the same thing and the old one won by being
# first. A second implementation nothing depends on is not a port.
#
# So the old accumulation is gone. What is on somebody is what the
# state says is on them -- read once per beat, every item recorded
# rather than the longest, each modifier bound to its own item.
_eng_hw = [r for p in _state.people.values()
for r in p.hardware.values()]
worn_items = []
for _r in _eng_hw:
_same = next((k for k, p_ in enumerate(worn_items)
if p_ in _r.item or _r.item in p_), None)
if _same is None:
worn_items.append(_r.item)
elif len(_r.item) > len(worn_items[_same]):
worn_items[_same] = _r.item
worn_item = ", ".join(worn_items)
# THE SCRIPT DECIDES THE MOMENT, and the sheet check must not veto it.
# Blocking on restraint_present(shot_scene) is right when the sheet
# says somebody is ALREADY restrained and the beat merely mentions
# it. It is wrong once the sheet's own hardware has been held back
# until this beat: the sheet then names the cuffs in exactly the shot
# that applies them, which suppressed the both-ends clause and left
# the applying shot with a standing hold -- a lie about its first
# frame, and the cuffing happening in whatever order was left over.
# ...and ONLY for hardware the sheet itself named and this held back
# until now. A sheet that says "wrists cuffed behind back" beside a
# beat that locks a CHAIN on is a different situation: the cuffs are
# genuinely already on, the sheet check is doing its job, and
# overriding it there cost the cuffs their standing hold. The veto is
# lifted only where this node created the conflict.
_stages_now = any(at == len(shots) + 1 and canon in _sheet_hw
for canon, at in _staged_at.items())
_applying = bool(restrained and not _was_restrained
and not restraint_present(_scene_before_now)
and restraint_going_on(body))
# The sheet claiming hardware the beat is only now putting on. The sheet
# goes into EVERY shot, so it is on her in the shots before it happens,
# and this shot is told it is already fastened rather than going on.
# Not the node's to resolve -- the sheet is the author's standing
# description and the beat is the author's action -- but it is exactly
# the shape that renders as being restrained first and caught after.
# Not gated on the latch: the sheet has already made her restrained
# from shot 1, which is the whole problem being reported. Recorded
# once -- it is one authoring decision, not one per shot.
if (not early_hardware and restraint_going_on(body)
and restraint_present(_scene_for_state)):
early_hardware.append(len(shots) + 1)
# Rigidity latches like the hardware itself. Steel locked on in shot 1 is
# still steel in shot 5, and a beat that does not happen to say "chain"
# does not mean the chain became rope -- but tested per shot, that is
# exactly what happened: the shot naming it got the rigid clause and every
# shot after it fell back to the soft one. Which is where the slack came
# back from.
if restrained and rigid_hardware(f"{body} {shot_scene}"):
rigid_latched = True
# And a position that hardware was locked to enforce latches too: the chain
# that put a body in a squat is still that length three shots later, so the
# squat is still the position.
if rigid_latched and forced_pose(f"{body} {shot_scene}"):
posed = True
# WHERE the fastened limbs are held latches the same way, and for the
# same reason the pose does. Cuffs above the head are above the head
# three shots later: nothing let go of them. The restraint hold keeps
# them SHUT and says nothing about position, so the only thing carrying
# it was the picture -- and a close shot crops the anchor point straight
# out of frame, which is the reported failure exactly.
# Where the beat says somebody is looking, said once more as a fact
# about the eyes and the head. One mention in the beat loses to a
# near-clean reference asking for the portrait's pose, and the
# portrait looks at the lens because photographs of people do.
# LATCHED, like every other state here. A look was stated once and then
# dropped, so somebody watching a screen across four shots was told
# where their eyes were in the first one only -- and the portrait pull
# that made this necessary does not stop after one shot.
#
# Cleared by a beat that moves the look somewhere else, or one that
# moves the person: walking away ends it, and holding a stale target
# across that would be worse than saying nothing.
_look_now = look_target(body) if hold_gaze else ""
if _look_now:
looking_at = _look_now
elif (looks_somewhere(body) or arrives_in(body) or falls_in(body)
or turns_in(body, cast) or _MOVES_OFF.search(body or "")):
looking_at = ""
_gaze = gaze_hold(looking_at) if (hold_gaze and looking_at) else ""
if _gaze:
gaze_shots.append(len(shots) + 1)
# POSTURE, latched the way the gaze is. A beat that sits somebody down
# ends its shot with them seated; the next beat says nothing about it,
# so the shot was free to stand them back up -- reported as the end of
# one beat and the start of the next not matching. The keyframe does
# carry the pose as a picture, but the text is what the model
# reconciles it against, and text saying nothing loses to a reference
# saying something.
#
# Said only on the shots AFTER the one that stages it: the staging beat
# has the author's own words and does not need a sentence arguing
# beside them. Cleared by whatever the new beat stages instead.
# WHERE the shot goes. A beat that walks somebody from one room to
# another is a staged change with two ends -- told only where it
# finishes, the shot renders the destination and cuts straight to it,
# with the hallway between them missing. Named both ends, the way a
# door's direction is.
# A short action in a long shot is performed at once and then carried
# on to fill the rest. Give it the whole shot to happen in.
# This beat's own length. plan_lengths sizes each beat independently,
# so asking it for one gives the same answer the whole run will --
# and `lens` itself is not computed until after this loop.
_have = plan_lengths([body], ceiling,
shot_length == "from the beat", pace)[0][0] / H3_FPS
_pace = pace_clause(beat_seconds(body), _have)
if _pace:
paced_shots.append(len(shots) + 1)
_frm, _via, _to = travel_in(body)
_travel = travel_anchor(_frm, _via, _to, here)
if _travel:
travel_shots.append(len(shots) + 1)
# The room the next beat starts from: where this one ended, or where it
# simply says everyone is.
here = _to or _frm or place_named(body) or here
# ...and say so on later shots, because the scene paragraph still
# names the room they started in and is stamped into every shot.
_where = where_hold(here, scene) if not _travel else ""
if _where:
where_shots.append(len(shots) + 1)
# ...and the ACOUSTIC follows them. Both were read ONCE, before the
# loop, out of the scene -- so a film that walks into a tiled bathroom
# went on being told it sounds like the carpeted living room it left.
# H3 is joint, so that is the picture told one room and the audio told
# another, inside the same conditioning: the contradiction the room
# hold was written to end, arriving through the other branch.
#
# Only where the room has actually changed, and only when the new room
# has a sound of its own -- otherwise the film's own bed stands, since
# one bed across a chain is part of what makes it one film. A travel
# shot keeps the origin's acoustic, because that is where it begins.
_room_now = (room_tone(here) or _room) if (auto_sound and _where) else _room
_bed_now = ((scene_ambient(here) or ambient_bed)
if (auto_sound and _where) else ambient_bed)
if _where and auto_sound and (_room_now != _room or _bed_now != ambient_bed):
acoustic_shots.append((len(shots) + 1, here))
_pose_now = posture_in(body, active if character_guard and active
else [n for n, _ in sheet_lines(_who_sheet) if n])
# ...and let go of any the beat contradicts. A pose that survives an
# action nobody performs in it is a hold arguing with its own shot.
for _gone_pose in posture_cleared(body, poses):
poses.pop(_gone_pose, None)
_posture = ("" if not hold_scene_state
else posture_hold({n: p for n, p in poses.items()
if n not in _pose_now},
# `active`, not `_described`: that is
# assigned further down this loop, so
# reading it here gets the PREVIOUS
# shot's cast.
active if character_guard else
[n for n, _ in sheet_lines(_who_sheet) if n]))
if _posture:
posture_shots.append(len(shots) + 1)
poses.update(_pose_now)
_anchor_now = limb_anchor(body) if restrained else ""
if _anchor_now:
anchored = _anchor_now
# Said only on the shots AFTER the one that staged it. The staging shot
# has the author's own words for this and does not need a second
# sentence arguing beside them.
# Where the limbs are held is inside the restraint sentence now. What is
# still worth reporting is that it is being held, and where the framing
# is tight enough to crop the anchor out of the picture the chain hands
# on -- so those key off the latch rather than off a clause.
_holding = bool(restrained and anchored and not _anchor_now)
if _holding:
anchored_shots.append(len(shots) + 1)
if _holding and (_anchor_tight or tight_framing(body)):
tight_shots.append(len(shots) + 1)
# A turn shows a surface the keyframe never pinned, and the model fills
# it from a clothed prior. Only on shots that turn, and only once there
# is something to hold -- a removal already made, or hardware on.
turn = TURN_HOLD if (turns_in(body, cast)
and (gone or shown or restrained)) else ""
# Going down with the hands fastened: say what takes the landing, or the
# model frees the hands to break the fall and the hardware gives way.
#
# A FREE body needs the landing named too, for a different reason. Reported:
# a third leg on the shot where she fell, grown to brace a landing nothing
# in the text was taking. A fall is the frame where limbs are least
# determined -- fast motion, heavy occlusion, and a middle the model has to
# invent -- so leaving it to work out what catches the body is leaving it
# free to add something that can.
_falls = falls_in(body)
fall = (FALL_HOLD if (restrained and _falls)
else FALL_HOLD_FREE if _falls else "")
if fall:
fall_shots.append(len(shots) + 1)
# Steel is not rope. Without being told, the model draws a chain slack --
# sagging, stretching to wherever a limb is going, allowing movement the
# hardware does not allow. Only where such hardware is actually named.
rigid = restrained and rigid_latched
# Where the hardware is holding a POSITION, its length is the reason the
# position holds -- and a chain drawn with slack is room to stand out of it.
chain = (CHAIN_POSE_HOLD if (rigid and posed)
else CHAIN_HOLD if rigid else "")
# Hardware named with nowhere to sit. A collar with no neck beside it is a
# band with no place to be, and it ends up on the head. Only where this
# beat itself raises the item, and only when the text has not already put
# it somewhere -- what you wrote wins.
anchors = anchor_clause(unanchored_hardware(body))
if anchors:
notes.append(f"shot {i_shot + 1} names hardware with no body part beside "
f"it, so the shot says where it sits: "
f"{anchors.split(': ', 1)[1].rstrip('.')}")
line = f"{shot_scene} {body}".strip() if shot_scene else body
# A state the text asserts but does not stage. Read from the whole line,
# because the van usually stands in the scene paragraph rather than in
# the beat -- and suppressed for anything this beat is actually working,
# since a shot that opens the doors is a shot about the doors opening.
_pairs, _moves = [], []
if hold_scene_state:
_moves = state_changes(body)
_acting = [_state_key(t) for t, _ in _moves]
_pairs = [(t, s) for t, s in stated_states(line)
if _state_key(t) not in state_acted and _state_key(t) not in _acting]
# Which end of the action is which. Some distill LoRAs render a staged
# change backwards, and a beat that names one state names neither end.
_turn = direction_anchor(_moves)
# The two share a budget. Holding a state and anchoring a change are both
# continuity, and four such sentences is a shot about its own continuity.
_state_clause = state_hold(_pairs[:max(0, 2 - _turn.count("first frame"))]) + _turn
if _pairs:
stated_shots.append(len(shots) + 1)
if _turn:
turned_shots.append(len(shots) + 1)
# The beat and the hold asking for opposite things. Reported three times
# running as "the doors keep opening", and every time the node text was
# by then correct -- it was the beat staging an exit the doors have to
# open for. Say it; do not touch the wording.
if _pairs and exits_vehicle(body) and any(
_state_key(t) in ("door",) for t, _ in _pairs):
notes.append(
f"shot {len(shots) + 1} says somebody gets OUT of a vehicle and also "
f"says the doors are closed. Those are opposite instructions and the "
f"beat wins: a person leaving a van opens a door to do it, so the "
f"doors open however firmly the text says they are shut. If they are "
f"meant to be shut the whole shot, the people cannot be leaving the "
f"vehicle in it -- write them already out and standing ('Mara and Dom "
f"stand behind the van, its rear doors closed'), or put the exit in "
f"its own earlier shot. Your wording is never edited, so this is "
f"yours to resolve.")
# Latch what this beat changed, so no later shot re-asserts the old state.
state_acted.update(_state_key(t) for t, _ in _moves)
# The chain clause SUBSUMES the restraint hold -- it says "whole and closed"
# itself. Emitting both said it twice, which is twice the stasis for one
# guarantee.
# On the shot that PUTS the hardware on, both ends instead of the standing
# hold: the chain clause is about a chain that is already taut, and the
# restraint hold asserts a first frame that has not happened yet.
# ...and where the limbs finish, so the NEXT shot's keyframe has them
# in the right place. See RESTRAINT_ENDS_AT.
_ends_at = ""
if _applying and _anchor_now:
_pos = _anchor_now.split(", at the")[0].strip()
if _pos and not _pos.startswith("at the "):
_ends_at = RESTRAINT_ENDS_AT.format(
part=engine.held_part_of(worn_items) or "wrists",
where=_pos)
# The limb pose, said as a body, on every shot the position holds --
# the applying shot included, where it says where they FINISH.
# ...and whether anybody holding that position is off their feet, which
# is what decides if the weight needs naming. Read off the poses this
# shot is carrying, not off the beat: the beat that lays her down is
# rarely the shot the propped arm shows up in.
_lying_now = any(_p == "lying down" for _p in poses.values())
# ...and the posture may only be written once in the scene too, which is
# the same asymmetry the anchor below had: a scene reading "McKenna lies
# in the back" put nobody in a posture, because posture_in reads the
# beat. So a restrained body the script never lays down ON SCREEN was
# never known to be off its feet, and the weight clause -- the whole
# point of which is bodies that are -- could not fire for it.
#
# ONLY for somebody the shot holds in hardware, and only while NOTHING
# is latched for them. A beat that stands her up latches standing and
# this stops: the scene paragraph still says she lies in the back, and
# believing it over the beat would hold her down for the rest of the
# film. The author's beat outranks the author's scene, always.
#
# The posture HOLD is deliberately not given this. That sentence exists
# to carry a pose the scene text does not, and the scene is stamped into
# every shot verbatim -- "McKenna is still lying down" beside a scene
# that just said she is lying in the back is the node repeating the
# author back to the author. What was missing was the physics, not the
# restatement.
# restrained_who can be EMPTY while restrained is True -- it is filled
# from the beat that applies the hardware, and a script whose restraint
# is only ever stated in the scene never has such a beat. So "nothing
# latched for the people in the hardware" has to degrade to "nothing
# latched at all" rather than refusing to answer, or this misses exactly
# the scripts it was written for.
if engine.posture_in(body):
beat_said_posture = True
if not _lying_now and restrained and not beat_said_posture:
_watch = restrained_who or set()
_free = (not any(n in poses for n in _watch)) if _watch else (not poses)
if _free and engine.posture_in(_scene_for_state) == "lying down":
_lying_now = True
# WHERE THE WRISTS ARE MAY ONLY EVER BE SAID ONCE, IN THE SCENE.
#
# restrained is set by `restraint_present(body) or
# restraint_present(_scene_for_state)` -- the beat OR the scene. The
# anchor was read from the beat alone. So the ordinary way of writing
# this -- "McKenna: ..., handcuffed behind her back" on the sheet, or a
# scene paragraph saying it once, and beats that never repeat it --
# marked her restrained and left the position empty for the whole film.
#
# pose_clause looks its argument up in a dict, so empty is not a shorter
# sentence, it is NO sentence: never told the wrists are together, never
# told the arms are behind the body, and never told what takes the weight
# when she lies down, because that clause reads this same anchor. Where
# the text says nothing the model puts the hands where the picture wants
# them, which is under her, propping her up.
#
# Reported twice, and neither the weight clause nor the wider anchor
# vocabulary could reach it: both fixed readers that were never being
# shown the text the position was written in.
#
# The BEAT still wins where it says one -- a beat that moves the wrists
# is the author changing them -- and the latch still wins over the
# scene, so this is only the fallback for a position that was stated
# once and never repeated. _anchor_now itself is left alone: it is what
# _holding keys off to tell a staging shot from the ones after it.
_pose_pos = (_anchor_now or anchored
or (limb_anchor(_scene_for_state) if restrained else ""))
_pose = pose_clause(_pose_pos.split(", at the")[0].strip(),
lying=_lying_now)
hold = (RESTRAINT_GOING_ON + (CHAIN_RIGID_TAIL if rigid else "") + _ends_at
if _applying
else chain if chain else (RESTRAINT_HOLD if restrained else ""))
if _applying:
applied_shots.append(len(shots) + 1)
# Name the thing on shots that do not. The hold says a restraint stays
# fastened and never says WHAT, so a shot after the applying one is told
# a restraint exists with no object to draw -- which renders as the
# behaviour without the hardware. Skipped where the text already names
# it, and where nothing has been seen to name.
# A garment MOVED rather than removed. It stays in the scene text, so
# the sheet keeps describing it the way it was WORN -- and the sheet is
# re-stamped into every shot, which pulls it back up. Latch the state the
# beat left it in and restate that instead.
_staged_here = displaced_garments(body, shot_scene)
if _staged_here:
# The shot that STAGES a displacement -- the garment is being moved
# on screen in it. Recorded because the render loop must not capture
# a subject reference from it: moved_shots starts the shot AFTER.
staging_shots.add(len(shots) + 1)
for _g, _how in _staged_here:
_was = displaced.get(_g, "")
# Put back up again is a restore, not a new displacement.
if _was == "pulled down" and _how in ("pulled up", "pulled back"):
displaced.pop(_g, None)
else:
displaced[_g] = _how
# A real removal takes the garment out of the scene, so there is nothing
# left to describe as displaced.
for _g in [g for g in displaced if names_any(g, toks)]:
displaced.pop(_g, None)
# "pulls them back up" names nothing, and a pronoun cannot be matched
# against the wardrobe -- but with one garment displaced there is only
# one thing it can mean, and leaving it displaced is the error that shows.
# ...and a restore that NAMES the garment clears that one, however
# many are displaced. "Lets the skirt fall" is not a pronoun and
# does not need the one-garment guess.
for _g in restored_garments(body, shot_scene):
_head = str(_g).lower().split()[-1]
for _k in [k for k in displaced
if str(k).lower().split()[-1] == _head]:
displaced.pop(_k, None)
if len(displaced) == 1 and puts_it_back(body):
displaced.clear()
# The shot that STAGES the displacement already says so in the beat, and
# saying it again is telling it twice. Matched on the HEAD NOUN: the key
# is the sheet's full name ("blue denim shorts") while the beat says
# "her shorts", so comparing whole names stopped recognising the beat
# that was staging it and the staging shot got the guard as well.
_body_low = (body or "").lower()
_moved = displaced_hold([(g, h) for g, h in displaced.items()
if not re.search(r"\b" + re.escape(g.split()[-1])
+ r"\b", _body_low)])
if _moved:
moved_shots.append(len(shots) + 1)
# ...and say WHOSE. Unattributed, "every restraint stays fastened" is an
# instruction about whoever is on screen, so hardware locked onto one
# character turned up on the other, over their clothes. Read from the sheet
# entries, which are what say who is wearing it.
_wearers = [n for n in restraint_wearers(shot_sheet)
if not character_guard or n in active]
_described = (active if character_guard else
[n for n, _ in sheet_lines(shot_sheet) if n])
# ONE sentence for the hardware. The hold, the name of the thing and
# where it holds were three separate clauses written for three separate
# reports, each naming the same object again -- 53 words about one pair
# of cuffs beside a nine-word beat. Merged they cost 25 and every
# guarantee survives.
#
# The applying shot keeps its own wording: it is the one shot where the
# hardware is NOT already closed, and that is the whole point of it.
# ONLY where somebody wearing it is in this shot. Otherwise the hold
# describes cuffs on wrists belonging to nobody the text mentions,
# and the model draws the person that sentence implies.
_wearer_here = (not restrained_who
or not character_guard
or bool(restrained_who & set(_described or [])))
if not _wearer_here:
# Nobody in this shot is wearing it. The hold would describe cuffs
# on wrists belonging to nobody the text mentions, and the model
# draws the person that sentence implies -- which is the duplicate.
# It latches, so the shot they come back in has it again.
hold = ""
absent_hold.append(len(shots) + 1)
elif not _applying and restrained:
hold = restraint_sentence(
worn_item if not _named_item else "",
# Not on the shot that STAGES the anchor: the author's own
# words are right there, and a second sentence saying it back
# is the redundancy this merge exists to remove.
_wearers, _described, anchor=("" if _anchor_now else anchored),
rigid=bool(rigid), posed=bool(posed),
part=held_part(worn_items or ([worn_item] if worn_item else [])))
if worn_item and not _named_item:
named_shots.append(len(shots) + 1)
else:
hold = own_hold(hold, _wearers, _described)
# What you wrote wins: a beat that already describes its own sound is left
# alone, and only one that describes none gets the sound its action implies.
# ONLY WHAT THE AUTHOR WROTE OPENS THE AUDIO BRANCH.
#
# H3 is joint: the mouth follows the audio. Leave that branch free on a
# shot with no line and it fills itself with a voice, and the face
# lip-syncs to the babble. Text cannot stop it -- "the only sounds are
# footsteps" was tried and the mouth still moved -- because the only thing
# that actually settles the branch is CONDITIONING it, and the silent
# keyframe pins the whole shot, not just its opening.
#
# So nothing this node infers may unsilence a shot. A quoted line is a
# request for audio; a sound the AUTHOR described is a request for audio;
# footsteps this file worked out from "walks in" is not, and neither is
# room tone. That is the whole rule, and it is the only one that holds --
# every version that let an inference open the branch babbled.
_speaks = has_speech(body)
_own = sound_described(body)
# A breath before a line no longer holds the branch open. Recorded so
# the trade is visible: a breath that will not be heard is a change to
# what was written, and finding that out from the render is worse than
# reading it here.
if not _own and not _speaks and _BREATH_PREP.search(body):
_breath_shots.append(len(shots) + 1)
# A beat staging EFFORT or vocal reaction is asking for a voice, and that
# is read from the author's own verbs -- "thrashes", "writhes", "moans" --
# so it belongs with a quoted line and a written sound, not with the things
# this file infers. Silencing it says the person makes no sound, and a
# person making no sound is rendered still: it is the flat, unreacting
# face, and it is why a body under effort came out mute.
_voiced = exertion_in(body)
# A shot where nobody speaks but the author wrote a SOUND kept its branch
# open, and an open branch invents a voice the face lip-syncs to. That is
# the hole: "a low hum off the strip light" is nobody talking, and it was
# enough to leave the mouth free for the whole shot.
#
# Effort is different and stays out of this. Straining, thrashing, a body
# under load -- those are vocal, the mouth SHOULD be open, and silencing
# them was a bug once already: a person making no sound renders as a flat,
# unreacting face.
# THE BED. A shot with no line and no written sound was pinned to real
# silence -- not "no speech" but no footsteps, no room tone, nothing,
# which is what makes a scene sound staged. Read from the anchor and
# the scene, the ambience no longer has to be typed into every beat.
#
# This DOES open the audio branch, which derived sound was never
# allowed to do before. The rule it replaces was written when nothing
# held the mouth on such a shot; the mouths-shut guard now lands on
# exactly these shots, so the picture half is covered. It is still a
# trade -- an open branch can put a voice in the gap -- and it is off
# with auto_sound.
# EVERY shot, including ones silence would otherwise close. Chosen
# deliberately on 2026-09-06, with the trade stated: this is the
# mechanism that babbled before and was reported twice, and nothing
# this file infers was allowed to open a branch because of it.
#
# What has changed since is the picture half -- the mouths-shut guard
# now lands on exactly these shots, and the language clause keeps a
# spoken shot in one language. Neither can outvote an audio stream
# that has decided somebody is talking, so if babble comes back on
# wordless shots, THIS is the first thing to turn off: auto_sound.
_bed = _bed_now if auto_sound and _bed_now else ""
if _bed:
ambient_shots.append(len(shots) + 1)
_mute_written = bool(mouths_shut_when_no_line and _own and not _speaks
and not _voiced)
# The bed no longer defeats this. It is the one thing this file infers
# that was allowed to open a branch, and opening a branch is what puts a
# voice in a wordless shot.
_will_silence = bool(silence_nonspeech and not _speaks and not _voiced
and (not _own or _mute_written))
if _mute_written and _will_silence:
muted_sound.append(len(shots) + 1)
# The picture side -- and ONLY where the shot actually describes somebody.
# A mouth sentence on a scenery beat describes a person who is not there,
# and the one way to satisfy it is to draw a face in an empty frame. That
# is ca75672's bug and it must not come back.
# Read from the BEAT, not from the carried cast. The guard keeps the
# previous shot's people in the text so a wordless beat does not empty the
# frame, and it falls back to the sole sheet entry when there is no
# previous -- so "Rain on the corrugated roof", before anybody has walked
# in, still has a person described beside it. Taking that as "somebody is
# here" puts a mouth sentence on an empty yard, which is the whole of
# ca75672. If the beat itself does not put a person in the shot, say
# nothing about mouths and let the audio half do the work.
_has_people = beat_puts_somebody_on_screen(body, sheet)
# A line that belongs to a MACHINE is not this shot's people speaking.
# Reported as somebody mouthing what was on the television: the quote
# made it a speaking shot, which opened the branch and turned the mouth
# guard off, so the only face in frame was handed the line. The branch
# still opens -- the set is meant to be heard -- but the mouths close and
# the voice is given back to the thing it came out of.
_device_line = (mouths_shut_when_no_line
and speech_is_a_devices(body, sheet))
_mouth = MOUTH_HOLD if (mouths_shut_when_no_line and _has_people
and (not _speaks or _device_line)
and not _voiced) else ""
# One of two people speaking still leaves the OTHER one's mouth free. The
# shot is a speaking shot, so the guard stood down for everybody in it --
# and the listener is exactly who the invented lip-sync lands on. Name the
# speaker and close the rest, which needs the speaker to be identifiable:
# an unattributed line could belong to either of them.
# WHICH hold this shot got. Both end up in _mouth, and reporting them
# together said a shot with a line had "no scripted line" -- the reader
# then cannot tell a silenced shot from one where the speaker is named,
# which are opposite situations.
_mouth_from_silence = bool(_mouth)
if (not _mouth and mouths_shut_when_no_line and _speaks and not _voiced
and not _device_line):
_talkers = speakers_in(body, shot_sheet)
_silent = [n for n in (_described or []) if n not in _talkers]
if _talkers and _silent:
_mouth = MOUTH_HOLD_OTHERS.format(
who=_talkers[0] if len(_talkers) == 1
else ", ".join(_talkers[:-1]) + " and " + _talkers[-1])
elif not _talkers and len(_described or []) > 1:
# A line with no name on it, and more than one person who could
# be saying it. Whose mouth to hold is unknowable, but how many
# voices there are is not -- and leaving it unsaid is what let
# the listener talk too.
_mouth = ONE_VOICE
unattributed.append(len(shots) + 1)
if _mouth:
(mouth_shut if _mouth_from_silence
else mouth_named).append(len(shots) + 1)
# A shot with a line is told what language it is in. Every shot with a
# line, not only the ones with a listener to hold: a single speaker can
# deliver the line in whatever language the model picks.
# ...in the language THIS shot's line is written in. Read from the
# line itself, falling back to the language the script as a whole is
# in, so one short line ("Si.") in a Spanish script is not called
# English on a technicality.
# ...and THIS beat's own stage direction outranks the script-wide
# fallback, so one German line inside an English script is not told it
# is English -- which is what a fallback alone does to it.
_shot_lang = engine.language_of(engine.spoken_text(body),
fallback=_script_lang,
named=engine.language_named(body))
_lang = (LANGUAGE_HOLD.format(lang=_shot_lang)
if (_speaks and not _voiced) else "")
if _lang and _shot_lang not in _langs_used:
_langs_used.append(_shot_lang)
# A quoted ORDER is still in the shot's words, and a model renders what
# the words describe. Give the listener something to be doing, so the
# instruction is not the only thing in the frame about their body.
_told = told_hold(told_to_act(
body, speakers_in(body, _who_sheet),
_described if character_guard else
[n for n, _ in sheet_lines(_who_sheet) if n])) if _speaks else ""
if _told:
told_shots.append(len(shots) + 1)
if _lang:
language_shots.append(len(shots) + 1)
# How much of this shot the line actually fills. A short line in a long
# shot leaves the audio branch with time and nothing to put in it, and
# what it puts there is more speech -- the line again. Counted here
# where the beat is; judged against the shot length further down.
_said_words = len(engine.spoken_text(body).split())
if _said_words:
_spoken_words[len(shots) + 1] = _said_words
_device = device_voice_clause(body) if (_device_line and _has_people) else ""
if _device:
device_shots.append(len(shots) + 1)
# The held scenery goes in, so the shot is not asked to keep the doors
# shut and to sound like a door swinging in the same breath.
heard = ([] if (not auto_sound or _own)
else sounds_for(body, held=[_state_key(t) for t, _ in _pairs]))
# ...AND THE AUTHOR'S OWN VOCAL GOES BACK IN, because the sentence below
# is EXCLUSIVE. The zeroing above is right in intent -- nothing this node
# infers may claim to be the sound of a shot the author already scored --
# but it drops the author's word along with the inferences, and what is
# appended next is the ambient bed. On a shot kept open by _voiced the
# result was an exclusive claim naming only the bed:
#
# "She screams." -> "The only sound is an engine idling."
# "She sobs quietly." -> "The only sound is an engine idling."
# "...starts whimpering" -> "The only sound is an engine idling."
#
# Reproduced on all three. That is the node telling the model the scream
# is not happening, on precisely the shots whose branch is open and which
# therefore must fill themselves with something.
#
# Only the six vocals, matched literally in the beat -- the author's own
# words, not an inference -- so "nothing inferred may unsilence a shot"
# still holds. A beat whose written sound is NOT a vocal is muted
# outright by _mute_written and reaches no clause at all, which is a
# different path and reports itself.
if _own:
heard = [v for v in named_vocals_in(body) if v not in heard] + heard
if _will_silence:
# The audio is pinned to silence for this shot's whole length, so a
# sentence saying what it sounds like would describe an acoustic the
# conditioning says is not there.
heard = []
elif _bed:
heard = heard + [_bed] + ([_room_now] if _room_now else [])
elif auto_sound and _room_now:
heard = heard + [_room_now]
if heard:
inferred_sound.append(len(shots) + 1)
# The branch is free on this shot, so SOMETHING fills it. Naming the sound
# as the only thing heard leaves nothing for a voice to be -- it is not
# the guard, the silence is, but it is what shapes a branch that is
# legitimately open. Positively phrased: "the only sound is X" says what
# IS there, where "nobody speaks" asks the model to render an absence.
_sound = sound_clause(heard, only=not _speaks)
# RANKED, and cut to fit. Each of these was a good idea on its own and
# none of them counted the others; together they had reached 65% of the
# shot against a 12% beat, which is the state this node was rebuilt to
# escape. What the beat itself stages ranks above what merely persists.
# THE ENGINE DECIDES THE FACTS; THESE SENTENCES SAY THEM.
#
# The rewrite kept the half that was wrong and kept the half that was
# right. What was wrong was the DERIVATION: sixty readers each
# searching the beat alone, so nothing could notice that "neck" and
# "behind the back" contradicted, or that a beat naming two items had
# recorded one. That is now engine.SceneState -- one state, read once
# per beat, and the source of truth for what is on whom, where it
# holds, what it is anchored to and which room this is.
#
# What was RIGHT was the prose. Every clause below is worded the way
# it is because a specific render came back wrong: "both ends" exists
# because a garment came off a beat early, "dropped out of frame"
# because it reappeared, "the same object in the same material"
# because tape drifted into the nearest commoner object. Throwing that
# away would have cost more than the derivations ever did, so the
# builders stay and the engine feeds them.
_guards = [
(1, "removal", tail), # the beat's own action, completing
(1, "wearing", _wearing), # ...and its mirror, a garment going on
(2, "revealed", _revealed), # what shows where it was
(2, "under", _under), # ...and what is underneath, still on
(2, "bare", _bare), # ...or that nothing does
(3, "hold", hold), # hardware coming open is not a drift
(4, "fall", fall), # a body going down needs a landing
(4, "travel", _travel), # a journey needs both its ends
(4, "where", _where), # ...and later shots need the new room
(5, "pace", _pace), # ...and a short action needs the whole shot
(5, "device", _device), # a voice that is not hers
(6, "moved", _moved), # a garment left where it was put
(7, "anchors", anchors), # hardware with nowhere to sit
(10, "state", _state_clause),
(9, "posture", _posture), # where the last beat left the body
# ...and the pose the hardware holds them in, as a BODY. Ranked
# beside posture because that is what it is: an arm position, not
# a fact about metal. See pose_clause.
(3, "pose", _pose),
(11, "gaze", _gaze),
(12, "mouth", _mouth),
(12, "language", _lang), # ...and in which language
(6, "told", _told), # a listener given an order to ignore
(13, "turn", turn),
# LAST in the list and LAST in the ranking, both on purpose.
#
# This was appended after fit_guards and so was the one piece of
# node-written text no budget could reach -- unranked, uncuttable,
# and measured as the largest single contributor: 97 words of 420
# across a six-shot script, double the budgeted guard on shot 1.
# Counting it APART for the balance report is right, because it asks
# for something to HAPPEN rather than to stay as it is. Exempting it
# from the CAP was a different thing, and not intended.
#
# Rank 14 was measured, not assumed. Ranked high it wins its words
# from the continuity holds, and the suites caught exactly that: at
# the SAME budget, ranking it 2 cost the fall/landing guard. So it
# goes last -- above nothing, cut before anything that traces to a
# report. At the shipped floor the budget never binds, so nothing
# about a current render changes; what changes is that sound can no
# longer grow the pile without the cap noticing.
#
# Last in the LIST keeps it at the end of the sentence where it
# already sat: fit_guards orders its output by list position, not by
# priority. Being last to survive is not the same as being last to
# read, and only the ranking was in question.
(14, "sound", _sound),
]
_kept, _dropped = fit_guards(_guards, len(body.split()))
if _dropped:
crowded.append((len(shots) + 1, _dropped))
shot_text = (line + _kept).strip()
# Sound direction is not a continuity guard -- it asks for something to
# HAPPEN rather than for something to stay as it is -- so it is counted
# apart, or the balance report blames the wrong text for crowding the beat.
_sound_kept = "" if "sound" in _dropped else _sound
sound_words += len(_sound_kept.split())
guard_words += (len(shot_text.split()) - len(_sound_kept.split())
- len(f"{shot_scene} {body}".split()))
beat_words += len(body.split())
total_words += len(shot_text.split())
shots.append(shot_text)
shot_cast.append(list(active) if character_guard else [])
speech.append(_speaks)
# The event sounds this beat implies, kept per shot so they can be
# BUILT and mixed into that shot's span later. `heard` is not it:
# that one has the bed and the room tone folded in and is emptied
# on a silenced shot, which is precisely the shot this is for.
shot_events.append(list(sounds_for(body, held=[_state_key(t)
for t, _ in _pairs]))
if auto_sound else [])
# What the AUTHOR wrote, and nothing this file worked out. See above --
# effort counts, because the verb staging it is theirs.
#
# `_bed` USED TO BE IN HERE, against the comment above it. The ambient
# bed is inferred, and putting it in this list left the audio branch
# open on every shot that got one -- which is every wordless shot, which
# is what the bed was for. An open branch on a joint model fills itself,
# and at 4-8 steps the final audio step clears 50%-30% of the denoising
# in one jump, so what it fills with is a voice. Ambience everywhere and
# silence are mutually exclusive by construction: the silence latent IS
# the audio, and there is no room in it for a room tone.
sounded.append(_own or _voiced)
voiced_only.append(bool(_voiced and not _own))
# What share of a shot is the node talking rather than the script. Continuity
# clauses all say some version of "this stays as it is", and enough of them
# drown the one sentence describing what HAPPENS -- which renders as a shot
# where nothing does. The previous node reached 96%; this is here so the creep
# is visible before it gets there again.
# Somebody back after a shot away, with nothing pictorial carrying them.
if _returns:
_lines = "; ".join(f"shot {n}: {', '.join(w)}" for n, w in _returns)
_tagged_back = {w for _, ws in _returns for w in ws
if re.search(r"^\s*" + re.escape(w) + r"\s*:.*<\s*picture",
sheet or "", re.I | re.M)}
_bare = sorted({w for _, ws in _returns for w in ws} - _tagged_back)
notes.append(
f"back after a shot away -- {_lines}. Each shot starts from the PREVIOUS "
f"shot's last frame, so somebody who was not in that shot is not in the "
f"picture this one begins from: their appearance comes from the sheet "
f"text and nothing else, and text drifts where a picture does not. That "
f"is a character walking out of frame and coming back looking different"
+ (f". {', '.join(_bare)} " + ("has" if len(_bare) == 1 else "have")
+ " no <Picture N> tag, so there is no picture of them anywhere in the "
"run -- tag a reference to them and it is carried into every shot "
"they are named in, this one included"
if _bare else
". All of them carry a reference tag, which is what pins them here"))
if total_words:
notes.append(
f"prompt balance: the beat is {100 * beat_words / total_words:.0f}% of "
f"what each shot is told, continuity clauses "
f"{100 * guard_words / total_words:.0f}%, sound "
f"{100 * sound_words / total_words:.0f}%, scene and sheet the rest"
+ (" -- the guards are outweighing the action, which reads as a shot "
"where nothing happens. Fewer restraints named, or a beat with more "
"in it, shifts the balance back"
if guard_words > beat_words * 3 else ""))
refs_all = [r for r in (ref_image_1, ref_image_2, ref_image_3, ref_image_4)
if r is not None]
# A reference nothing tags rides EVERY shot -- including the ones where a
# garment it may depict is covered. Layering can hide the words; it cannot
# hide a picture, and the picture wins. Reported as a chastity belt drawn on
# top of the jeans while the text had correctly stopped mentioning it.
#
# The node cannot know what an untagged image shows, so it cannot withhold it
# on its own. Tagging is what puts it under the layering's control, and that
# is the one thing that fixes this.
if refs_all and covers and not _PICTURE_TAG.search(f"{scene}\n" + "\n".join(beats)):
notes.append(
f"{len(refs_all)} reference image(s) and not one <Picture N> tag anywhere, "
f"while the wardrobe has layers in it ("
+ "; ".join(f"{u} under {o}" for u, o in list(covers.items())[:3])
+ "). An untagged reference goes into EVERY shot, so a picture of "
"something that is currently underneath something else is still sent "
"on the shots where it is covered -- and the text having stopped "
"describing it does not stop the model drawing it. That is an under "
"layer rendered on top. Tag the image onto the thing it shows -- "
"'a chastity belt <Picture 2>' -- and it is sent only where that "
"thing is actually visible")
lens, len_note = plan_lengths(beats, ceiling, shot_length == "from the beat", pace)
# How much of a SPEAKING shot the line does not cover. The branch is free for
# the whole shot, so whatever the line does not fill is unconditioned audio in
# a shot the model knows somebody is talking in -- which is where invented
# speech after the line comes from. Reported per shot, because the fix is the
# author's: a longer line, or a shorter shot.
_tail = []
for _i, _b in enumerate(beats):
if _i >= len(lens) or not has_speech(_b):
continue
_words = (sum(len(q.split()) for q in _QUOTED.findall(_b))
+ sum(len(q.split()) for q in _DIALOGUE_TAG.findall(_b)))
_say = _words / WORDS_PER_SEC
_shot = lens[_i] / H3_FPS
if _shot - _say >= 3.0:
_tail.append((_i + 1, _words, _say, _shot))
if _tail:
notes.append(
"dialogue headroom -- "
+ "; ".join(f"shot {n}: {w} word(s), about {s:.1f}s of a {t:.1f}s shot"
for n, w, s, t in _tail)
+ ". The audio branch is open for the whole shot, so the seconds the "
"line does not fill are unconditioned in a shot the model already "
"knows has a voice in it -- that is where speech carries on after the "
"line, or turns into babble. Give the beat a longer line, or a "
"shorter shot: shot_length 'from the beat' sizes to the line, while "
"'fixed' gives every shot shot_seconds whatever the line needs")
# Seconds of shot per staged action -- the number that decides whether the
# motion looks brisk or stretched. A shot longer than its action is filled by
# performing the action more slowly, not by inventing more of it.
_clauses = sum(max(1, len([p for p in _CLAUSE_SPLIT.split(b)
if p and len(p.split()) >= 2])) for b in beats)
if _clauses and lens:
_per = sum(lens) / H3_FPS / _clauses
notes.append(
f"pacing: {_per:.1f}s of shot per staged action across {len(beats)} "
f"beat(s), at pace {float(pace):.2f}"
+ (" -- a staged action is usually 2 to 3 seconds on screen, and a shot "
"longer than its action is filled by performing it more slowly. Lower "
"pace for brisker movement" if _per > 3.5 else ""))
if len(set(lens)) == 1:
notes.append(f"{len(shots)} shot(s) x {lens[0]}f (~{lens[0] / H3_FPS:.1f}s) "
f"at {w}x{h} = ~{sum(lens) / H3_FPS:.1f}s total")
else:
notes.append(f"{len(shots)} shot(s) at {w}x{h}, sized per beat: "
+ ", ".join(f"{n}f/{n / H3_FPS:.1f}s" for n in lens)
+ f" = ~{sum(lens) / H3_FPS:.1f}s total")
if len_note:
notes.append(len_note)
if stated_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in stated_shots)} describe scenery in a "
f"state -- doors closed, curtains drawn -- so the shot is told that state "
f"is already true at the first frame. A state written down and not placed "
f"in time is a state the model can render by arriving at it, which is a "
f"van whose doors open so somebody can close them. A beat that works the "
f"thing itself is left alone, and once a beat has changed a state no "
f"later shot is told the old one. Off with hold_scene_state.")
if paced_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in paced_shots)} stage less than "
f"their length, so each is told its action runs across the whole "
f"shot. A shot told WHAT happens and nothing about WHEN performs it "
f"at once, and the cheapest way to fill the seconds left is to carry "
f"on -- the same movement repeated on whatever is nearest. It names "
f"when, never how fast: 'slowly' is a style instruction and this is "
f"not one. Give the beat more to do, or shorten the shot, and it "
f"stops being needed")
if where_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in where_shots)} are in a room the "
f"scene text does not name, so each is told which one. The scene "
f"paragraph is stamped into EVERY shot -- it has to be, or a removal "
f"has nothing to scrub -- so a script that walks from one room to "
f"another goes on opening every later shot with the room it started "
f"in, while the beat has them somewhere else. The shot then holds two "
f"places at once and settles on whichever the model weighs more, "
f"differently each time. Your scene text is not edited: move the "
f"location into the beats, or keep the scene general, and this stops "
f"being needed")
if wearing_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in wearing_shots)} put a garment back "
f"ON, so each is told both ends: off the body as the shot opens, fully "
f"on by the last frame. An 'add:' used to go straight into the scene "
f"block as a worn item, which told a shot inheriting a last frame "
f"WITHOUT the garment that it flatly has it -- a disagreement rather "
f"than a change, and the model settles those in the opening frames by "
f"turning whatever is on the body into the garment. That reads as one "
f"thing instantly becoming another, a beat before the beat that puts it "
f"on, which is what those opening frames are. The garment joins the "
f"static wardrobe from the NEXT shot, the way a removal scrubs from its "
f"own. An 'add:' that merely reveals a layer already underneath is left "
f"alone: nothing is being put on there")
if crowded:
# This was collected and never reported. The budget rarely binds, so the
# one time it did there was nothing in info saying a guard had been cut
# -- the shot simply stopped holding something, with no way to tell that
# from the guard having failed to fire. A dropped clause is exactly the
# case worth reading, being the one place the node knowingly stops
# answering a bug it knows about.
notes.append(
"guard clauses dropped for room -- "
+ "; ".join(f"shot {n}: {', '.join(d)}" for n, d in crowded)
+ f". Each shot's continuity text is capped at "
f"{GUARD_WORDS_PER_BEAT_WORD} words per word of beat, floored at "
f"{GUARD_FLOOR_WORDS}, and the lowest-ranked clauses give way "
f"first. The cap is set to catch a runaway rather than to trim "
f"routinely, so this firing at all means one shot is carrying far "
f"more continuity than its beat -- usually a one-line beat in a "
f"scene holding a lot of state. Giving that beat more to do buys "
f"back the room, and is better than raising the cap: every clause "
f"below the line is answering something")
if acoustic_shots:
notes.append(
"the sound followed them into the new room on "
+ "; ".join(f"shot {n}: {r}" for n, r in acoustic_shots)
+ ". The ambient bed and the room tone were read once, before the "
"first shot, out of the scene -- so a film that walked into a "
"tiled bathroom went on being told it sounds like the carpeted "
"room it left. H3 is joint, so that is the picture told one room "
"and the audio told another inside the same conditioning, which is "
"the contradiction the room hold exists to end, arriving through "
"the other branch. Only where the room actually changed and only "
"where the new room has a sound of its own: otherwise the film's "
"own bed stands, because one bed across a chain is part of what "
"makes it one film. Off with auto_sound")
if travel_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in travel_shots)} move between "
f"places, so the shot is told where it BEGINS as well as where it "
f"ends. A journey given only its destination is a journey the model "
f"can satisfy by starting there -- the living room becomes the "
f"bedroom at the first frame and the hallway between them is never "
f"seen. Named both ends, it has to travel. The starting place is "
f"read from the beat, or from wherever the last one left everybody")
if posture_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in posture_shots)} are told to keep "
f"the posture an earlier beat put somebody in -- seated, kneeling, "
f"lying down. The scene-state reader tracks scenery and nothing about "
f"the body, so a shot that ended with somebody seated was followed by "
f"one free to stand them up: the keyframe carries the pose as a "
f"picture, but the text is what the model reconciles it against, and "
f"text that says nothing loses to a reference that says something. "
f"Standing is never held -- it is the default pose, so the clause "
f"would cost a naming of the person and buy nothing. Off with "
f"hold_scene_state")
if unattributed:
notes.append(
f"shot(s) {', '.join(str(n) for n in unattributed)} carry a line that "
f"names no speaker, and more than one person is in them -- so which "
f"mouth to hold is unknowable and the shot is told only that there is "
f"ONE voice. H3 is joint, so an unheld mouth beside an open audio "
f"branch is where a second voice comes from, and that voice is the "
f"babble. Attribute the line -- 'Nora says: \"...\"' -- and the "
f"listener's mouth is held shut by name instead")
if revealed_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in revealed_shots)} take off a "
f"garment that was covering another, so the shot is told what shows "
f"there now. The removal clause is emphatic and specific -- off the "
f"body, dropped out of frame -- while the layer underneath is one "
f"entry in an attribute list, and against a prior that says trousers "
f"coming off means bare skin, a list entry does not compete. Said only "
f"on the shot that uncovers it; after that it is simply worn")
if bared_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in bared_shots)} take off a garment "
f"with nothing named underneath it, so the shot is told that region is "
f"BARE. Left unsaid, the space a garment leaves is unspecified, and an "
f"unspecified region is filled by the model's own prior -- for legs "
f"that prior is legwear, so leggings or tights appear that the prompt "
f"never asked for, and the keyframe carries them into every later shot. "
f"It names a body part and never a garment: at cfg 1 there is no "
f"negative prompt, so naming the unwanted thing would summon it. Name "
f"an under-layer in the sheet and this gives way to that instead")
if restarted:
notes.append(
f"shot(s) {', '.join(str(n) for n in restarted)} start FRESH rather "
f"than from the previous shot's last frame, because the shot before "
f"took something off -- that is restart_after_removal, and it is what "
f"stops a garment being inherited back through the keyframe. It costs "
f"a visible cut at each of those points. Turn it off to keep the "
f"chain unbroken and accept the risk")
if exposed_by_beat:
notes.append(
"a beat NAMES something the wardrobe says is covered: "
+ "; ".join(f"shot {n}: {', '.join(g)}" for n, g in exposed_by_beat)
+ ". Your beats are passed through word for word and are never "
"scrubbed, so the layering can take it out of the sheet and the "
"beat puts it straight back -- and a described thing is a drawn "
"thing, drawn over whatever is on top of it. Worse, the next shot "
"starts from this one's last frame, so once it is rendered on top "
"it is carried forward and looks permanent. Take the name out of "
"the beat while it is underneath, or take the outer garment off "
"first. Nothing here edits your wording")
if absent_hold:
notes.append(
f"shot(s) {', '.join(str(n) for n in absent_hold)} describe nobody who "
f"is wearing the hardware, so the restraint hold is left out of them. It "
f"says cuffs are closed on wrists, and in a shot where the person wearing "
f"them is not described those wrists belong to nobody the text mentions -- "
f"so the model draws the person the sentence implies, which is a duplicate "
f"nobody asked for. The hold latches, so the shot they come back in has it "
f"again")
if moved_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in moved_shots)} carry a garment "
f"MOVED rather than taken off -- pulled down, pushed up, shoved aside. "
f"It is still on the body, so it stays in the scene and is described "
f"where the beat left it. Counted as a removal it would be scrubbed "
f"instead, and every later shot would describe nothing where something "
f"still is -- which is the garment coming back looking like a "
f"different one. Putting it back ('pulls them back up') releases it, "
f"and a real removal or a `remove:` empties it for good")
if named_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in named_shots)} name the hardware "
f"itself, because their own text does not. The holds say a restraint "
f"stays whole and closed and never say WHAT it is, so a shot after the "
f"one that applied it is told a restraint exists with no object to "
f"draw -- and what renders is the consequence without the hardware: "
f"held hands and a restrained posture, bare wrists. Taken from your own "
f"wording at the shot that put it on, and released by a `remove:` "
f"naming it")
if early_hardware:
notes.append(
f"shot(s) {', '.join(str(n) for n in early_hardware)} stage hardware "
f"going ON, but the character sheet already lists it as worn. The sheet "
f"goes into every shot, so it DESCRIBES the hardware in the shots "
f"BEFORE this happens, and a described item is a drawn item. What the "
f"node will not do is assert it: no shot before this one is told the "
f"restraint is fastened, and this one is told both ends rather than "
f"the standing hold. Take the hardware off the sheet entry and let the "
f"beat put it on, or drop the beat if she wears it throughout. Your "
f"wording is never edited, so this one is yours")
if deferred_shots:
_items = sorted({i for _n, its in deferred_shots for i in its})
notes.append(
f"{', '.join(_items)} WAITS on shot(s) "
f"{', '.join(str(n) for n, _ in deferred_shots)}, where it is under "
f"something else -- BOTH the words and the picture. It is deferred, "
f"never removed: your character memory is not edited, and the item "
f"comes back in full on the shot that lifts, moves or removes what "
f"covers it. A "
f"reference is an instruction to REPRODUCE an image, so handing the "
f"model a picture of a thing that is under a skirt draws it through "
f"the skirt -- measured twice, including with the cover described as "
f"whole and opaque. Reference strength is ref_noise_aug and it is one "
f"number for every image, so this one cannot be weakened without "
f"weakening the face")
if applied_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in applied_shots)} put the hardware "
f"ON, so they are told both ends -- open and off at the first frame, "
f"closed on the body by the last -- instead of the standing hold. The "
f"standing hold says the restraint is fastened as it was put on and "
f"still fastened at the last frame, which read at frame 1 means it is "
f"already closed. A first frame that already has the cuffs on leaves "
f"the catching and the struggling to happen in whatever order is left, "
f"which is being restrained and THEN caught. From the next shot the "
f"standing hold is correct again, because by then it is on")
if device_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in device_shots)} have a spoken "
f"line that belongs to a machine, not to anybody in the room. H3 is "
f"joint and the audio branch has no idea a voice came out of a set, so "
f"a quote made the shot a speaking one and the only face in frame was "
f"handed the line. The branch still opens -- the set is meant to be "
f"heard -- but the mouths are held closed and the voice is given back "
f"to the thing it came out of. A line anybody in the room might have "
f"stays theirs: an unattributed quote is a person talking")
if fall_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in fall_shots)} put a body down, so "
f"the shot is told what takes the landing and what the legs do. A fall "
f"is the frame where limbs are least determined -- fast motion, heavy "
f"occlusion, and a middle the model has to invent -- and leaving it to "
f"work out what catches the body leaves it free to add something that "
f"can, which is where a spare limb comes from. Said as what the limbs "
f"DO, never as how many there are: a count is also a mention, and "
f"naming legs to ask for two is a way of asking for legs")
if gaze_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in gaze_shots)} name something to "
f"look at, so the eyes and the head are put on it in so many words. "
f"The beat says it once and two things pull the other way: a person in "
f"frame faces the camera unless something says otherwise, and a "
f"near-clean reference asks for the portrait's pose -- which looks at "
f"the lens, because photographs of people do. Nothing is said about "
f"where the camera is. Off with hold_gaze")
if anchored_shots:
notes.append(
f"fastened limbs held in place on shot(s) {', '.join(str(n) for n in anchored_shots)}"
f" -- the shot that staged it said where, and every shot after it is "
f"told the same, because the restraint hold keeps the hardware SHUT and "
f"says nothing about where it is. Position was being carried by the "
f"picture alone, and the picture is the previous shot's last frame. "
f"Cleared by a `remove:` naming the hardware, like the hold itself")
if cropped_wardrobe:
notes.append(
"the anchor names a close frame and says what it is close ON, so the "
"wardrobe that frame cannot hold stopped being described: "
+ ", ".join(cropped_wardrobe)
+ ". The camera was always reaching the model -- it is a tenth of a "
"shot's text -- and the rest of the shot was asserting clothes the "
"frame has no room for, which is a wider frame said at length. "
"ONLY WARDROBE GOES. Where the limbs are held and what is fastened "
"to them are still said, because a close frame crops the anchor "
"point out of the picture the NEXT shot inherits and the text is "
"then the only thing that knows. Write the frame without naming a "
"subject -- \"close-up\" and no more -- and nothing is cropped, "
"because there is no way to know what it is close on")
if tight_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in tight_shots)} frame tight enough to "
f"crop the anchor point out. That matters past this shot: the next one "
f"starts from THIS one's last frame, so whatever the close framing cut "
f"off is missing from the picture the next shot inherits, and the text is "
f"the only thing that still knows where the limbs are fastened. It is "
f"being said. If the position still drifts, give the beat a wider frame "
f"so the anchor is in the picture the chain hands on")
if ambient_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in ambient_shots)} were given an "
f"ambient bed read from the anchor and the scene -- \"{ambient_bed}\". "
f"It goes under shots whose audio branch is ALREADY open: ones with a "
f"line, or with a sound you wrote yourself. It can never open one. "
f"AMBIENCE ON EVERY SHOT WAS TRIED AND DOES NOT WORK: the bed was "
f"allowed to open a branch, which is the one thing nothing inferred "
f"here may do, and an open branch on a joint model fills itself. At "
f"4-8 steps the final audio step clears 50%-30% of its denoising in "
f"one jump, and what a branch resolving that much at once invents is "
f"a voice -- so every wordless shot got ambience and a babbling mouth "
f"with it. Ambience everywhere and silence are mutually exclusive by "
f"construction: the silence latent IS the audio, and there is no room "
f"in it for a room tone. To score a silent shot, write the sound into "
f"that beat -- that is you asking for audio on purpose -- or lay an "
f"ambient track under the finished video outside the model, where it "
f"costs nothing and cannot speak")
if dialogue_marked:
notes.append(
f"shot(s) {', '.join(str(n) for n in dialogue_marked)} had their "
f"quoted speech wrapped in H3's own dialogue marker, <d>...</d>. "
f"Those are special tokens the model was trained with, and they say "
f"a span is SPOKEN; quotation marks say nothing at all, so a quoted "
f"instruction reached the model as an imperative sentence and was "
f"performed -- often a beat before anybody said it. Every word you "
f"wrote is kept in order; only the quotation marks are exchanged. "
f"Mark them yourself and this leaves them alone")
if told_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in told_shots)} carry a line that "
f"ORDERS somebody to do something, so the listener is given "
f"something to be doing while it is said. The node does not stage "
f"what a quoted line asks for -- the readers refuse speech -- but "
f"the words are still in the shot, because beats go to the model "
f"verbatim, and a video model does not tell a quoted instruction "
f"from a stage direction: it renders what the words describe, and "
f"the action lands a beat early. The words cannot be removed "
f"without breaking the one promise this node makes about your text. "
f"If it still happens, put the order in narration instead -- 'Dana "
f"tells her to lie down' -- and keep the quoted line for something "
f"that is not an instruction")
if language_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in language_shots)} carry a line, "
f"so each is told which language it is spoken in -- "
f"{', '.join(_langs_used) or SPOKEN_LANGUAGE}, read from the line "
f"itself rather than fixed. H3 is joint and multilingual: the prose "
f"conditions the audio branch, and a branch told a line is spoken "
f"but never told in WHAT will pick a language -- fluent delivery in "
f"one nobody asked for sounds like babble to anybody expecting the "
f"one they wrote. Said positively, because at cfg 1 there is no "
f"negative prompt and naming the unwanted language would ask for it. "
f"Write the dialogue in the language you want spoken; a line too "
f"short to tell falls back to the rest of the script, then to "
f"{SPOKEN_LANGUAGE}")
# A LINE THAT DOES NOT FILL ITS SHOT. H3 is joint: the audio branch runs
# for the whole shot, and a short line in a long one leaves it with time
# and nothing to say. What it does with that time is say the line again.
# Reported as dialogue duplication.
#
# A REPORT, not a clause. "the line said once" was tried as prompt text
# and made it worse -- more speech words on a shot is more reason for the
# branch to make speech -- so this says it to YOU instead, where the fix
# is to shorten the shot or write more line.
_roomy = []
for _n, _w in sorted(_spoken_words.items()):
_sec = (lens[_n - 1] / H3_FPS) if _n - 1 < len(lens) else 0.0
_need = _w / 2.5 + 0.5 # ~150 words a minute, plus a breath
if _sec > 0 and _sec > _need * 2:
_roomy.append((_n, _w, _sec, _need))
if _roomy:
notes.append(
"shot(s) " + ", ".join(
f"{n} ({w} word{'s' if w != 1 else ''} of line, about "
f"{need:.1f}s, in a {sec:.1f}s shot)"
for n, w, sec, need in _roomy)
+ " leave more than half their length with no line in it. The audio "
"branch runs for the whole shot and fills what is left, and what "
"it fills it with is the line again -- that is where doubled "
"dialogue comes from. Shorten those shots (shot_length 'from the "
"beat', or a lower shot_seconds), or give the beat more to say. "
"Room tone is already laid under them, which is what makes the "
"silence survivable at all")
# WHAT A LINE CANNOT BE READ ALOUD FROM. Digits, times and abbreviations
# have no single pronunciation -- "7:30" is "seven thirty" and also "seven
# three zero", "Dr." is "doctor" and also "dee arr", "1985" is a year and
# also four digits -- so the model picks, and picking wrong is what
# mispronunciation sounds like. Written out, there is nothing to pick.
#
# Reported, never rewritten: the one promise this node makes about your
# text is that it goes to the model as you wrote it.
if _breath_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in _breath_shots)} stage a "
f"breath and nothing else audible, so they are conditioned on "
f"silence and the breath is NOT heard. A single indrawn breath "
f"is half a second; holding the audio branch open for a whole "
f"shot to render it leaves the rest of that shot open, and an "
f"open branch on a joint model fills itself with a voice -- "
f"which is the babble that arrives just before somebody speaks. "
f"To hear it, put the breath in the same beat as the line, or "
f"give the shot a sound that lasts: breathing hard, a chain, "
f"footsteps")
_hard = []
_said_all = engine.spoken_text(prompt or "")
for _m in _HARD_TO_SAY.finditer(_said_all):
_t = _m.group(0).strip()
if _t and _t not in _hard:
_hard.append(_t)
if _hard:
notes.append(
f"the dialogue contains {len(_hard)} thing(s) with no single way "
f"to say them out loud: {', '.join(_hard[:10])}. A joint model "
f"reads the line as text and chooses a pronunciation -- \"7:30\" is "
f"\"seven thirty\" and equally \"seven three zero\", \"Dr.\" is "
f"\"doctor\" and equally \"dee arr\" -- and the choice is where "
f"mispronounced dialogue comes from. Spell them the way they should "
f"be SPOKEN and there is nothing left to choose. They are NOT "
f"rewritten: your words go to the model as you wrote them")
_odd = non_latin_in(prompt) + non_latin_in(character_memory or "") \
+ non_latin_in(anchor or "")
_odd = list(dict.fromkeys(_odd))
if _odd:
notes.append(
f"the prompt contains {len(_odd)} character(s) that are not Latin "
f"text: {' '.join(_odd[:12])}. A multilingual model reads those as a "
f"strong signal about which language to speak, and one pasted glyph "
f"is easy to miss by eye. They are NOT removed -- the node passes "
f"your words through -- so retype them if the delivery is coming out "
f"in a language you did not ask for"
# ...and when the script IS in that language, this is not a warning
# at all. Reporting a Cyrillic script as a stray glyph would be the
# node telling somebody their own dialogue looks like a mistake.
+ (f". Your dialogue reads as {_script_lang}, though, so these are "
f"most likely meant to be here -- the lines are told they are "
f"spoken in {_script_lang}"
if _script_lang != SPOKEN_LANGUAGE else ""))
if mouth_named:
notes.append(
f"shot(s) {', '.join(str(n) for n in mouth_named)} have a line, so "
f"the shot is told who is speaking and every other mouth in it is "
f"held closed. One of two people speaking still leaves the OTHER "
f"one's mouth free, and the listener is exactly who invented "
f"lip-sync lands on")
if mouth_shut:
notes.append(
f"mouths held closed on shot(s) {', '.join(str(n) for n in mouth_shut)} -- "
f"no scripted line and no effort staged in them. H3 is joint, so the face "
f"follows the audio branch: the sentence is the picture half and the "
f"silent conditioning is the half that actually settles it, since a "
f"lips-closed line loses to a stream that has decided somebody is "
f"talking. Shots staging effort are left out on purpose -- straining is "
f"vocal and that mouth should be open. Off with mouths_shut_when_no_line")
if muted_sound:
notes.append(
f"shot(s) {', '.join(str(n) for n in muted_sound)} gave up the sound you "
f"wrote for them so the mouths could be held shut. Those shots have no "
f"line, and a sound alone was enough to leave the audio branch open -- "
f"which is where the invented voice and the lip-sync came from. This is "
f"the trade and it is the only one available: the ambience cannot be kept "
f"while the branch is conditioned to silence. Turn off "
f"mouths_shut_when_no_line to keep the sound and accept the mouth")
if turned_shots:
notes.append(
f"shot(s) {', '.join(str(n) for n in turned_shots)} stage a change with a "
f"direction -- something opened or shut -- so the shot is told both ends: "
f"what is true at the first frame and what is true by the last. Some "
f"distill LoRAs render an action backwards, and a beat naming one state "
f"names neither end, so the reverse reads as an equally good answer. Verbs "
f"that genuinely go either way -- pulls, draws, slides, swings -- get no "
f"anchor, because a wrong one asks for the reversal instead of allowing "
f"it. Reversal is likeliest in shot 1, which has no previous last frame "
f"pinning where it starts; first_frame pins it. Off with hold_scene_state.")
if inferred_sound:
notes.append(
f"shot(s) {', '.join(str(n) for n in inferred_sound)} were given the "
f"sound their own action implies -- H3 is joint, so the same prose "
f"conditions the audio branch, and a beat that says what happens has "
f"said what it sounds like. Read from the beat, never the scene, so a "
f"chain standing in the scene does not rattle where nobody moves. A beat "
f"that describes its own sound is left alone. This is TEXT ONLY and can "
f"never unsilence a shot: it is added to shots whose audio branch is "
f"already open, meaning ones with a line or with a sound you wrote "
f"yourself. A shot with neither stays pinned to silence and gets no "
f"sound sentence, because the mouth follows the audio and an inference "
f"is not a good enough reason to let it move")
# WHICH shots, not how many. "2 shot(s) have an open branch" told a reader
# that two of eleven can babble and gave them no way to find out which two
# -- and the whole point of the note is that the beat's own sound wording is
# what opened it, which cannot be acted on without knowing the beat.
_open_br = [i + 1 for i, (s_, snd) in enumerate(zip(speech, sounded))
if not s_ and snd]
_pinned = [i + 1 for i, (s_, snd) in enumerate(zip(speech, sounded))
if not s_ and not snd]
n_silent, n_kept = len(_pinned), len(_open_br)
if silence_nonspeech and n_kept:
notes.append(
f"shot(s) {', '.join(str(n) for n in _open_br)} have no line but either "
f"describe a sound IN THE BEAT or "
f"stage EFFORT, so their audio is left free to make it -- writing the "
f"sound, or the verb that produces one, is asking for audio on purpose. "
f"Those are the only shots without a line "
f"where the branch is open, and an open branch on a joint model can "
f"still put a voice in the gap. If one of them babbles, that beat's own "
f"sound wording is what opened it")
if silence_nonspeech and n_silent:
notes.append(
f"shot(s) {', '.join(str(n) for n in _pinned)} have no quoted line and no "
f"sound described, so they "
f"are conditioned on real silence -- which is not 'no speech', it is 'no "
f"sound at all': no footsteps, no room tone, nothing. H3 is joint, so the "
f"way to score a scene is to DESCRIBE it in the prose: 'boots on concrete, "
f"a chain dragging, a low hum off the strip light'. Write it into a beat "
f"for that shot, or into the anchor to carry it through the film. Do not "
f"use a label like 'sound:' -- a labelled line is read as text to draw")
# The note that used to sit here warned that a beat staging effort was
# being silenced, which read as a flat, unreacting face. It cannot happen
# any more: effort opens the audio branch, because the verb staging it is
# the author's. See _voiced in the shot loop.
if first_frame is None:
notes.append("no first_frame: shot 1 has nothing pinning its opening frame, so its "
"starting pose and framing come from the text and any reference")
# Text in the frame. H3 draws letterforms when the prompt names them, and at
# cfg 1 there is no negative prompt to take them back -- adding "no watermark"
# to the positive only names it again, which is how a mention becomes a
# presence cue. So: point at the words, and leave the decision to the author.
# H3 has a caption channel of its own. A prompt carrying those tokens is
# ASKING for text on the picture.
if any(_CAPTION_TOKEN.search(s) for s in shots):
notes.append("the prompt contains H3's caption/lyrics tokens "
"(<|caption_start|> and friends) -- those request text ON the "
"picture. Remove them unless you want subtitles burned in")
# Quoted dialogue with no <d> marker. H3 distinguishes speech, captions and
# lyrics with explicit tokens; unmarked quoted text is not identified as any
# of them, and a model with a caption channel may render it rather than say
# it. Worth trying if subtitles are appearing under spoken lines.
# Only what the marker did NOT catch: a quote with no terminal punctuation
# and no speech cue in front of it, which is a scare quote far more often
# than a line. The note used to tell the reader to wrap their dialogue by
# hand; the node does that now, so this is what is left over.
n_bare = sum(1 for b in beats
if _QUOTED.search(b) and not _DIALOGUE_TAG.search(b)
and mark_dialogue(b) == b)
if n_bare:
notes.append(f"{n_bare} beat(s) carry quotes that were NOT read as speech: "
f"no full stop, question mark or exclamation inside them, and "
f"no speech verb in front. A quote like that is usually "
f"emphasis or a title, so it was left exactly as written. If "
f"one of them IS a line, end it with punctuation or mark it "
f"yourself with <d>...</d> and it will be spoken rather than "
f"drawn")
cued = sorted({m.group(0).lower() for s in shots for m in _TEXT_CUE.finditer(s)})
if cued:
notes.append(f"the prompt names on-screen text ({', '.join(cued)}) -- H3 draws "
f"letterforms when asked, and at cfg 1 no negative prompt can take "
f"them back. Remove the words if you do not want the text")
# Each beat against ITS OWN shot length; thin_beats numbers from 1, so the
# shot number is restored here.
thin = [t.replace("shot 1:", f"shot {i + 1}:")
for i, b in enumerate(beats)
for t in thin_beats([b], lens[i] / H3_FPS)]
if thin:
notes.append(
"THIN BEATS -- the shot outlasts what the beat gives it to do, and the "
"cheapest way for the model to fill the rest is to CARRY ON with the "
"action, repeating it on whatever is nearest: "
+ "; ".join(thin)
+ ". Give the beat a second action -- what happens after it -- or lower "
"shot_seconds")
if float(cfg) != 1.0:
notes.append(f"cfg is {float(cfg):g}; H3 is CFG-free and expects 1.0")
# Resolve the <Picture N> tags before `script` is written, so what you read is
# what the model is given. Which roster they resolve against depends entirely
# on the format -- see build_conditioning.
# Tags PLACE the references. With none written anywhere, placing by tag would
# place them nowhere -- a connected reference that silently does nothing at
# all. The old node fell back rather than no-op, and so does this.
# Judged on what was WRITTEN, not on what survives scrubbing.
#
# A tag on a covered garment is removed from every shot that hides it --
# correctly, since the tag has to leave with the thing it names. But if
# that was the only tag in the sheet, the check below then saw no tags
# anywhere and fell back to "untagged references ride EVERY shot", which
# sent the picture straight back into the shots that had just hidden it.
# Reported as a chastity belt drawn over the shorts by somebody whose only
# reference was the belt.
#
# The author tagged something. That the layering consumed it later is not
# a reason to start placing pictures everywhere.
_written = "\n".join([scene or ""] + list(beats))
_tagged = bool(picture_tags(_written)
or any(picture_tags(s) for s in shots))
_tagged_names = {n for n, ln in sheet_lines(sheet) if n and picture_tags(ln)}
if refs_all and not _tagged:
notes.append(
f"{len(refs_all)} reference image(s) connected and no <Picture N> tag "
f"anywhere, so they go on EVERY shot -- placing by tag would place them "
f"nowhere. To aim them, write the tag on the person they depict: 'Nora: "
f"<Picture 1>, 34, she, ...'. Each then travels with that person into "
f"the shots she is in, and only those")
shot_refs_all = []
for _i, _s in enumerate(shots):
# The tag is the BINDING between a picture and the subject the prompt
# describes, and it stays IN the text -- comfy_extras/nodes_minimax_h3.py:
# "the prompt refers to them as <Picture i>", "Use the same tags when
# prompting". Renumbered per shot, because the encoder numbers by the
# order it receives images and a shot carrying only slot 2 receives that
# image as <Picture 1>.
if not _tagged:
shot_refs_all.append(list(refs_all))
continue
_s, _r, _missing = resolve_tags(_s, refs_all)
shots[_i] = _s
shot_refs_all.append(_r)
for _n in _missing:
_msg = f"<Picture {_n}> names a slot with no image connected"
if _msg not in notes:
notes.append(_msg)
# ONE FACE, TWO PEOPLE. A shot that carries a picture for somebody AND
# describes somebody else who has none gives the model a photographed face
# and two faces to draw. A reference is the strongest identity signal in the
# prompt -- far stronger than "38, dark hair" -- so the one that exists gets
# used twice, and the second character arrives as a copy of the first.
#
# Reported as two of the same woman in a scene written for two people. The
# node cannot stop it: it is the model resolving a shot that has more
# subjects than pictures, and there is no sentence that outranks a photo.
# What it can do is say which shots are in that state, and say it in terms
# of the fix -- a second reference, tagged onto the other person.
_twinned = []
if refs_all and _tagged_names:
for _i, _s in enumerate(shots):
if not picture_tags(_s):
continue
_cast_here = shot_cast[_i] if _i < len(shot_cast) else []
_cast_here = [n for n in _cast_here if n] or [
n for n, _ in sheet_lines(sheet) if n]
_bare = [n for n in _cast_here if n not in _tagged_names]
if _bare and any(n in _tagged_names for n in _cast_here):
_twinned.append((_i + 1, _bare))
if _twinned:
_who = sorted({n for _, ns in _twinned for n in ns})
notes.append(
f"shot(s) {', '.join(str(n) for n, _ in _twinned)} carry a reference "
f"picture for one person and also describe "
f"{', '.join(_who)}, who {'has' if len(_who) == 1 else 'have'} no "
f"<Picture N> of their own. That "
f"is one photographed face and two people to draw, and a reference is "
f"the strongest identity signal in the prompt -- much stronger than a "
f"line of description -- so the face that exists tends to be used "
f"twice and the second character arrives as a copy of the first. Wire "
f"a picture of {', '.join(_who)} to a free ref_image slot and tag it "
f"on their sheet "
f"{'entries' if len(_who) > 1 else 'entry'} -- "
f"'{_who[0]}: <Picture 2>, ...' -- so every shot with both of them "
f"carries both faces. No wording fixes this: nothing in the text "
f"outranks a photograph")
if refs_all:
_named = sum(1 for s in shots if picture_tags(s))
notes.append(
f"{len(refs_all)} reference image(s) supply IDENTITY, and they go WHERE "
f"TAGGED: every shot whose text names <Picture N> carries the image on "
f"ref_image_N, which is what holds a face across beats instead of "
f"letting it drift down the "
f"keyframe chain. Put the tag on the person -- 'Nora: <Picture 1>, 34, "
f"she, ...' -- and it travels with her. {_named} shot(s) claim one here. "
f"References ride alongside the keyframe rather than instead of it: the "
f"keyframe anchors the first frame, a reference only says who somebody "
f"is, and ComfyUI packs both (keyframe rows then ref rows, in the same "
f"order model_base builds the latents). References keep slots 1..N so the "
f"tag points at the right image; the handoff is appended after them and "
f"disturbs no numbering. Expect the NUMBER in script to differ from the "
f"one you wrote: it is the picture's place in THAT shot's reference "
f"list, not a name for the image, so a shot carrying one reference "
f"always says <Picture 1> whichever socket it came from. The image is "
f"still that person's -- what would be wrong is a shot carrying two "
f"references and naming only one, since a picture the text never names "
f"is read as another subject")
if _named > 1 and float(ref_noise_aug) >= KEYFRAME_SAFE_AUG:
notes.append(
f"a reference on {_named} shots at ref_noise_aug "
f"{float(ref_noise_aug):g} is the trade this makes. Near-clean, a "
f"reference asks the model to reproduce the PICTURE -- pose and "
f"framing, not only the face -- and on a shot that is not introducing "
f"the character that competes with the staging the beat describes: "
f"the referenced person can hold the portrait's gaze while anyone "
f"without a reference is placed relative to that composition and then "
f"travels to where the text put them. It is the price of the face "
f"holding. A hybrid fl2va/ref2va checkpoint is trained for reference "
f"conditioning and does not make this trade; on a plain fl2va one, "
f"lowering ref_noise_aug is the dial")
if _named < len(shots):
notes.append(
f"{len(shots) - _named} shot(s) name no <Picture N> at all, so they "
f"carry no reference. Claim it on the person it depicts -- 'Nora: "
f"<Picture 1>, 34, she, ...' -- and it travels with her into the shots "
f"she is in, and only those. A picture the prompt never refers to is "
f"read as ANOTHER subject")
# What each shot was SENT. Built here so plan_only has it, and corrected in
# the render loop for the one sentence that is added down there.
#
# It used to be built here and never touched again, while the recovered-face
# claim was written onto the loop's own copy of the prompt -- so the model got
# "Dom: <Picture 1>, he, 41" and this said "Dom: he, 41". The output documented
# as the exact per-shot text was wrong about the one shot most likely to be
# under investigation, and it is the output the reader is told to check when a
# shot renders somebody they did not ask for.
# CAN the silence conditioning actually be built? Every failure inside
# _silent_audio_latent returns None on purpose so a render never dies for a
# nicety -- which means a wrong VAE on the audio_vae input costs nothing at
# load time and silently unpins every line-free shot, and the first anybody
# knows of it is a shot with no dialogue that babbles.
#
# Probed HERE, before the plan is returned, because finding out should not
# cost a full render. The unit is cached, so a real render pays nothing for
# this and the answer is the same one the render would get.
# The audio branch's own last step. Reported whenever it is steep, because
# shift_video is the dial people reach for and it does not touch this.
_last_a = last_audio_sigma(steps, shift_audio, scheduler, shift_video)
# A SCHEDULER CAN END THIS OUTRIGHT, and this note used to deny it.
_alt_sched = scheduler_that_finishes_audio(steps, shift_audio, shift_video,
scheduler)
# Never advise RAISING it: the target is a ceiling on the last step, not a
# setting to move towards from below.
_fix_a = min(shift_audio_for(steps), float(shift_audio or 0.0) or 1.0)
if _last_a > 0.4:
notes.append(
f"the audio branch still has sigma {_last_a:.2f} to clear on its FINAL "
f"step at {int(steps)} steps with shift_audio {float(shift_audio):g} -- "
f"about {_last_a * 100:.0f}% of its denoising in one jump, and a branch "
f"resolving that much at once invents whatever is easiest, which is a "
f"voice. It is the step where babble appears. shift_VIDEO does not "
f"change this: time_shift_sigma inverts the video shift and re-applies "
f"the audio one. "
+ (f"The SCHEDULER is the biggest dial here and '{scheduler}' is not "
f"using it: '{_alt_sched[0]}' at these same {int(steps)} steps and "
f"the same shift_audio leaves {_alt_sched[1]:.3f} instead of "
f"{_last_a:.2f}, because it spends steps in the low-sigma tail "
f"where the fine detail of speech is resolved. Try that first. "
if _alt_sched else "")
+ f"Otherwise LOWER shift_audio or raise steps -- sigma rises with "
f"shift_audio, so raising it makes this worse. shift_audio "
f"{_fix_a:.2f} at {int(steps)} steps leaves "
f"{last_audio_sigma(steps, _fix_a, scheduler, shift_video):.2f}, "
f"against the {DEFAULT_LAST_AUDIO_SIGMA:.2f} the default 3.0 leaves "
f"at 8 steps on 'simple'.")
# Probed whenever silencing is ON, not only when a shot is silent today:
# an ambient bed can cover every shot, and the answer still matters for
# the moment one is not covered -- and for knowing the wiring is sound.
if silence_nonspeech:
if audio_vae is None:
notes.append(
"SILENCE CANNOT BE APPLIED: no audio VAE is wired to the node's "
"audio_vae input, so every shot listed above as conditioned on "
"real silence has an audio branch that is NOT pinned. H3 is "
"joint, so an unconditioned branch invents a voice and the "
"picture lip-syncs to it -- a shot babbling with nothing "
"scripted to say")
elif _silent_audio_latent(audio_vae, lens[0], H3_FPS) is None:
notes.append(
"SILENCE CANNOT BE APPLIED: the VAE on the audio_vae input would "
"not encode a silent second, so every shot listed above as "
"conditioned on real silence has an audio branch that is NOT "
"pinned -- and an unconditioned branch on a joint model invents "
"a voice the picture then lip-syncs to. That input wants the "
"MiniMax H3 AUDIO vae (minimax_h3_audio_vae.safetensors) in its "
"own VAELoader. Every VAE carries an audio_sample_rate "
"attribute, so a video VAE wired here passes every check "
"until the encode itself fails -- which is caught and "
"turned into no conditioning at all")
else:
notes.append(
f"silence can be applied: the audio VAE encodes silence, so the "
f"{n_silent} shot(s) above are pinned to it rather than merely "
f"told to be quiet")
sent_text = list(shots)
script = "\n---\n".join(f"[Shot {i}] {s}" for i, s in enumerate(shots, 1))
info = " | ".join(notes)
if plan_only:
empty = torch.zeros((1, h, w, 3))
return (empty, {"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100},
"PLAN ONLY -- nothing rendered. " + info, script,
lens[0], 0, len(shots), 0.0)
if apply_model_sampling:
model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio)
notes.append(ms_note)
if negative is None:
negative = clip.encode_from_tokens_scheduled(clip.tokenize(""))
handoff = first_frame
# Where the time actually goes. Sampling and decode trade off against each
# other -- latent_upscale buys cheaper sampling and pays for it at decode,
# and which side wins depends on `steps`. Reported so the trade is a
# measurement rather than an argument.
t_sample = t_decode = 0.0
_aug_warned = False
fresh = []
t_start = time.perf_counter()
vid_out, aud_out, sr = [], [], 44100
# THE FINISHED CHAIN, ALLOCATED ONCE, BEFORE THE FIRST SHOT LANDS IN IT.
# vid_out survives only as the overflow path -- see the shot write below.
_dst, _at = None, 0
av_fix = 0 # samples of A/V drift corrected across the chain
_captured = {} # name -> a frame from the last shot they were in
_captured_from = {} # name -> which shot that frame came from
_recovered = [] # (shot, name, source shot) actually pinned
_handoff_claimed = [] # shots whose demoted handoff was named in the text
_carried = [] # (shot, who was there, who joins) room carried on
shot_detail = [] # (detail, contrast) per shot, on its last frame
_SILENCE_STATUS.update(asked=0, applied=0, why="")
_deep_cleanup()
for i, shot_prompt in enumerate(shots):
silent = bool(silence_nonspeech and not speech[i] and not sounded[i])
# A shot that follows a removal starts FRESH. Every shot is anchored to
# the previous one's last frame, so if the model did not finish taking
# the garment off inside its own shot, that frame still shows it -- and a
# keyframe is a PICTURE, which outvotes any sentence. Inherit it once and
# every later shot inherits it too, with no wording able to undo it.
# Breaking the chain at the one boundary where the state changes costs a
# cut exactly where a cut belongs.
shot_handoff = handoff
_handoff_ref = False
if restart_after_removal and (i - 1) in stripped_shots:
shot_handoff = None
fresh.append(i + 1)
# ...and so does a shot that INTRODUCES somebody already in position.
#
# Same reasoning, same evidence. The keyframe is the previous shot's last
# frame, and a character appearing for the first time is not in it. The
# beat says where they are; the picture says they are nowhere. The picture
# wins, so the model starts from a frame without them and has to put them
# there during the shot -- which renders as the person arriving out of
# nothing and then travelling to the spot the beat described.
#
# Only when the beat does NOT stage an entrance. "Dan walks in through the
# side door" is a person who SHOULD arrive, and continuing from the frame
# before is exactly right there. "Dan is already sitting on the crate" is
# a person who should be there at the first frame, and there is no frame to
# inherit that has him in it.
# The frame is still the right picture of the ROOM, though, and throwing
# it away is what build_conditioning's own note warns about: with no
# handoff the VLM is never shown where the shot left off and re-imagines
# the scenery -- same place, new room. So it is DEMOTED rather than
# dropped. As a reference it carries the walls, the light and the people
# already there without being frame one, so the newcomer is simply in
# place instead of walking in from nowhere.
#
# Only when everybody in that frame is named in this shot. The picture
# contains whoever was on screen when it was taken, and one the prompt
# cannot account for is the node's oldest bug: a picture nobody claims is
# another person. When it cannot be claimed, the old fresh start stands.
elif i in _placed_shots:
_was_here = [n for n in (shot_cast[i - 1] if i - 1 < len(shot_cast)
else []) if n]
_here_now = shot_cast[i] if i < len(shot_cast) else []
# ...and NOT when somebody in that frame already has a portrait of
# their own in this shot. Their identity is carried by that
# picture; the carried frame would be a SECOND picture of the same
# person, and two pictures of one person is how a duplicate of her
# gets drawn. Reported as a duplicate Mistress: her sheet portrait
# went in as <Picture 1> and this frame as <Picture 2>, both of
# her. The recovered-frame path below skips tagged people for the
# same reason and this was written without that skip.
#
# The room is lost on those shots, back to the fresh start it was
# before. A re-imagined set is a smaller bug than a second person.
if _was_here and all(n in _here_now for n in _was_here) \
and not any(n in _tagged_names for n in _was_here):
_handoff_ref = True
_carried.append((i + 1, list(_was_here),
list(_placed_shots[i])))
else:
shot_handoff = None
fresh.append(i + 1)
# SOMEBODY BACK AFTER A SHOT AWAY, with no picture of them anywhere.
#
# This shot starts from the previous shot's last frame, and they were not
# in that shot -- so nothing pictorial carries their appearance and the
# sheet text is on its own. A frame from the last shot they WERE in fixes
# that, and the node has one: it rendered it.
#
# Narrow on purpose. Only when this shot describes that person ALONE,
# because the recovered frame contains whoever else was on screen when it
# was taken, and an unexplained person in a reference is how a second one
# gets drawn. A multi-character return is reported and left alone.
#
# Skipped for anyone with a <Picture N> tag: their own reference already
# travels into every shot they are named in, and a second picture of the
# same person is just a second picture.
_extra = []
_cast = shot_cast[i] if i < len(shot_cast) else []
if len(_cast) == 1 and _cast[0] not in _tagged_names:
_who = _cast[0]
if any(n == i + 1 and _who in ws for n, ws in _returns) \
and _captured.get(_who) is not None:
_extra = [_captured[_who]]
_recovered.append((i + 1, _who, _captured_from.get(_who, 0)))
# CLAIM IT IN THE PROSE. A picture the prompt refers to is that
# subject; one it never mentions is ANOTHER subject. Sent
# unclaimed, a recovered frame of somebody is read as a second
# person who looks exactly like them -- same face, same clothes --
# standing beside the one the beat asked for.
#
# Its number is its place in the roster: the shot's own references
# first, this after them. The handoff follows and stays unclaimed,
# which is H3's own first-frame shape.
_n = len(shot_refs_all[i]) + 1
_tag = f"<Picture {_n}>"
if f"{_who}:" in shot_prompt:
shot_prompt = shot_prompt.replace(
f"{_who}:", f"{_who}: {_tag},", 1)
else:
shot_prompt = f"{shot_prompt} {_who} is the person in {_tag}."
# The handoff, when it is demoted to a reference, is a picture like any
# other and has to be claimed or it reads as a second person. Decided
# here rather than inside build_conditioning because the claim is text,
# and the text is assembled up here.
_shot_refs = list(shot_refs_all[i]) + _extra
if _handoff_ref:
# Carried for the ROOM, with somebody new in the shot -- so the
# standing claim is exactly wrong here ("joined by anybody new") and
# this one names the room, who was in it, and who is also here.
_was, _join = next(((w, j) for s, w, j in _carried if s == i + 1),
([], []))
shot_prompt = shot_prompt + room_claim(len(_shot_refs) + 1, _was, _join)
_handoff_claimed.append(i + 1)
elif handoff_rides_as_ref(shot_handoff, _shot_refs, ref_noise_aug):
shot_prompt = shot_prompt + handoff_claim(len(_shot_refs) + 1)
_handoff_claimed.append(i + 1)
# Whatever this shot ends up being, that is what `script` reports.
sent_text[i] = shot_prompt
cond, latent, fc, demoted = build_conditioning(
clip, vae, audio_vae, shot_prompt, w, h, lens[i],
handoff=shot_handoff, refs=list(shot_refs_all[i]) + _extra,
ref_noise_aug=ref_noise_aug, silent=silent,
handoff_as_ref=_handoff_ref)
if demoted and not _aug_warned:
_aug_warned = True
notes.append(
f"ref_noise_aug is {float(ref_noise_aug):g}, below {KEYFRAME_SAFE_AUG:g} -- "
f"one aug covers references AND the keyframe, so at this value the "
f"anchor would be noised and mis-timestepped, and every shot after the "
f"first degrades while sampling. The handoff is riding as an extra "
f"reference instead: continuity is weaker but nothing is corrupted. "
f"Raise it to {KEYFRAME_SAFE_AUG:g}+ for a real keyframe")
_evict_all_but(model, latent)
try:
_t0 = time.perf_counter()
out = sample_shot(model, cond, negative, latent, seed, steps, cfg,
sampler_name, scheduler, sigmas)
t_sample += time.perf_counter() - _t0
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if not _is_oom(e):
raise
raise RuntimeError(
f"H3 Long Videos: shot {i + 1} of {len(shots)} ran out of VRAM while "
f"sampling. " + sampling_oom_help(w, h, fc, H3_FPS, megapixels)) from e
# The video latent, for the latent upscale below. NOT used as the next
# shot's keyframe -- see _keyframe_latent for why that failed.
try:
parts = out["samples"].unbind() if hasattr(out["samples"], "unbind") else None
except Exception:
parts = None
# LATENT upscale, between sampling and decode: the shot is SAMPLED small
# and only DECODED large, which is where the saving is -- cost scales with
# latent cells and attention is quadratic in them. Note the handoff latent
# was taken ABOVE, before this: the chain must inherit the sampled latent,
# not the upscaler's reinterpretation of it, or that guess compounds.
shot_tiled = tiled_decode
pre_up = None # the SAMPLED video latent, when upscaling ran
if latent_upscale and latent_upscale != "off" and parts and len(parts) == 2:
vid_up, up_note = upscale_video_latent(parts[0], latent_upscale,
latent_upscale_scale)
if vid_up is not parts[0]:
pre_up = parts[0]
out["samples"] = comfy.nested_tensor.NestedTensor((vid_up, parts[1]))
shot_tiled = True # a 2x latent is ~4x the decode memory
if up_note and up_note not in notes:
notes.append(up_note)
_t0 = time.perf_counter()
# The DiT goes so the decode fits; the two VAEs stay, because both are
# used in the next two lines and evicting them only buys a reload.
imgs = _decode_video(vae, out, shot_tiled, free_first=model,
keep=(vae, audio_vae))
wav = _decode_audio(audio_vae, out)
t_decode += time.perf_counter() - _t0
sr = wav["sample_rate"]
del out
# The chain must not inherit the UPSCALER's reinterpretation. The shot's
# own frames stay upscaled, but the handoff comes from the sampled latent
# -- otherwise every boundary hands on an upscaled-then-downscaled frame,
# and eleven shots of that compounds into colour cast and mush.
hand_src = imgs
if pre_up is not None:
try:
n = min(int(pre_up.shape[2]), HANDOFF_LATENT_TAIL)
tail = _decode_video(vae, {"samples": pre_up[:, :, -n:].contiguous()},
True)
if tail is not None and tail.shape[0] > 0:
hand_src = tail
except Exception:
pass # fall back to the upscaled frames
# Clamp before it becomes a keyframe. A decode can land slightly outside
# 0..1, and feeding that back in to be re-encoded every boundary is a
# drift that accumulates rather than cancels.
handoff = hand_src[-1:].detach().clamp(0.0, 1.0).to("cpu", copy=True)
# Keep a frame for the shot they come back on -- but ONLY from a shot that
# was theirs alone.
#
# A frame is a picture of everyone who was in it. Captured from a shot with
# two people and sent later as a reference, it brings the other one back
# into a shot that does not call for them. That is the second character
# turning up uninvited, and it was this code: the destination was guarded
# (the return shot has to describe one person) and the SOURCE was not.
#
# The MIDDLE frame, not the last: somebody walking out during the shot is
# gone by the last frame -- which is the whole failure -- and somebody
# walking in is missing from the first.
#
# ...and not from a shot whose WARDROBE is unusual. A captured frame is
# sent later as a subject reference, and a reference outranks the sheet:
# it is a picture of what the person looks like. Captured where a garment
# was displaced, removed, or newly uncovered, it is a picture of them
# dressed differently from the sheet -- and the shot that receives it
# renders the garment the way the PICTURE has it, which is a garment the
# prompt never described. Reported as clothing invented several shots in,
# because that is exactly when a recovery first fires.
# Read from the per-shot records, NOT from the text loop's own variables:
# that loop finished long before this one started, so its `toks` and
# `displaced` hold the last shot's values for every shot down here, and
# `_bare` has since been reused for something else entirely.
#
# moved_shots holds the shots that CARRY the displacement guard, which
# starts the shot AFTER the one that stages it -- and the staging shot is
# the worst one to capture from, since the garment is being moved on
# screen in it. Its own beat is what says so.
_n = i + 1
_wardrobe_normal = not (i in stripped_shots
or _n in moved_shots
or _n in revealed_shots
or _n in bared_shots
or _n in staging_shots)
try:
if (hand_src.shape[0] and shot_cast and i < len(shot_cast)
and len(shot_cast[i]) == 1 and _wardrobe_normal):
_mid = hand_src.shape[0] // 2
_keep = hand_src[_mid:_mid + 1].detach().clamp(0.0, 1.0).to(
"cpu", copy=True)
for _who in shot_cast[i]:
_captured[_who] = _keep
_captured_from[_who] = i + 1
except Exception:
pass # a recovered frame is a nicety, not the render
del hand_src
if trim_seam and i > 0:
imgs = imgs[1:]
wav["waveform"] = wav["waveform"][..., max(0, round(sr / H3_FPS)):]
# Make the sound exactly as long as the picture it belongs to.
#
# The audio latent count is round(frames / 24 * 40), which lands exactly
# only when the frame count divides by 3 -- so most of H3's 17k+5 grid
# leaves a shot's audio 8.3 ms longer or shorter than its video. On its own
# that is inaudible. Concatenated it is not: with shots of equal length the
# error carries the same sign every time and adds up, and eleven 73-frame
# shots finish 92 ms out, which is plainly visible on a mouth.
#
# Correcting per shot rather than once at the end keeps every cut aligned
# too, instead of only the final duration.
want = int(round(imgs.shape[0] * sr / H3_FPS))
have = int(wav["waveform"].shape[-1])
if have > want:
wav["waveform"] = wav["waveform"][..., :want]
elif have < want:
shape = list(wav["waveform"].shape)
shape[-1] = want - have
wav["waveform"] = torch.cat(
[wav["waveform"], torch.zeros(shape, dtype=wav["waveform"].dtype,
device=wav["waveform"].device)], dim=-1)
av_fix += have - want
# Measured on the frame that becomes the next shot's keyframe, because
# that is the one whose losses are inherited.
try:
if imgs is not None and imgs.shape[0]:
shot_detail.append(frame_detail(imgs[-1]))
except Exception:
pass
# HALF PRECISION IN RAM. The finished shots are the largest thing this node
# holds, and they compete with the weights for system memory -- ComfyUI
# offloads models to RAM rather than discarding them, so a shot boundary is
# a PCIe copy only while that RAM is there. Once the frames crowd the
# weights out, the "reload" becomes a disk read, and on a chain that is
# once per shot per model.
#
# A 107s chain at 1056x608 is ~2580 frames, 18.5GB as float32 and 9.3GB as
# float16, against ~39GB of weights on a 64GB machine. That 9GB is the
# difference between the weights staying resident and not.
#
# Free, not a trade: fp16 carries ~3 decimal digits over 0..1, and the
# output is 8-bit. Converted back at the join, so nothing downstream sees
# a different dtype.
# STRAIGHT INTO THE FINISHED CHAIN, not into a list to be joined later.
#
# The per-shot list existed because the total length was not known until
# the loop ended -- and it IS known: plan_lengths fixed `lens` before the
# first shot sampled, every entry is on H3's 17k+5 grid, and trim_seam
# only ever REMOVES a frame, so sum(lens) is a hard upper bound. With the
# destination allocated up front each shot is written where it belongs
# and the join has nothing left to do.
#
# That deletes the last double-hold in the node. Even after the join was
# rewritten to drain the list, both were still fully live at the moment
# it started: 9.26GB of destination beside 9.26GB of pieces, 18.51GB of
# chain on top of 44.64GB of staged weights, which is where the render
# was being killed. Now the chain is one copy from first shot to return.
#
# It also drops the copy=True. That was duplicating a whole shot (1.30GB)
# purely to detach it from the decode buffer; copy_ into the destination
# detaches it just the same, and converts device and dtype on the way, so
# one copy does what two did.
#
# ON AN fp32 INSTALL THIS TRADES SUSTAINED FOR PEAK, deliberately. The
# list was fp16 while the render ran and widened only at the join, so a
# 107s chain sat at 9.26GB and spiked to 27.77GB; the destination is the
# OUTPUT dtype throughout, so it sits at 18.51GB and never spikes. Peak
# is what the OOM killer reads, and on an --fp16-intermediates install --
# where the output dtype is fp16 anyway -- both numbers improve.
#
# The overflow branch is not reachable on the real VAE, which decodes a
# shot to exactly the length it was planned at. It exists because "not
# reachable" is a claim about somebody else's code, and a wrong frame
# count should cost a slower path, not a crash.
_k = int(imgs.shape[0])
if _dst is None and _k:
_dst = torch.empty(
(max(_k, int(sum(lens))),) + tuple(imgs.shape[1:]),
dtype=_image_out_dtype(),
device=(torch.device("cpu") if cleanup_between_shots
else imgs.device))
if _dst is not None and _at + _k <= _dst.shape[0]:
_dst[_at:_at + _k].copy_(imgs)
_at += _k
else:
vid_out.append(imgs.to("cpu", torch.float16, copy=True)
if cleanup_between_shots else imgs)
aud_out.append(wav["waveform"].to("cpu", copy=True) if cleanup_between_shots
else wav["waveform"])
del imgs, wav
if cleanup_between_shots:
_deep_cleanup()
if _handoff_claimed:
notes.append(
f"ref_noise_aug is below {KEYFRAME_SAFE_AUG:g}, so on shot(s) "
f"{', '.join(str(n) for n in _handoff_claimed)} the handoff is encoded as "
f"a reference rather than a keyframe, and the text now NAMES it as the "
f"frame the shot opens on. Unnamed it was a picture of the previous shot "
f"-- the same people, a moment earlier -- sitting in the reference rows "
f"with nothing claiming it, and a picture the prompt never names is read "
f"as another subject. That is a duplicate of whoever was on screen, "
f"appearing on the later shots because those are the ones with both a "
f"handoff and a reference. Raising ref_noise_aug to {KEYFRAME_SAFE_AUG:g} "
f"or above keeps the handoff a keyframe and the question does not arise")
if _recovered:
notes.append(
"recovered a face for "
+ "; ".join(f"{who} on shot {n}, from shot {src}"
for n, who, src in _recovered)
+ ". They were back after a shot away with no picture of them anywhere "
"-- the keyframe is the previous shot's last frame and they were not "
"in it -- so a frame from the middle of the last shot that was THEIRS "
"ALONE was sent as a reference. The middle, because somebody walking "
"out is gone by the last frame and somebody walking in is missing from "
"the first. Both ends have to be solo: a frame is a picture of "
"everyone in it, so one taken from a shared shot would carry the other "
"person into a shot that does not call for them. A character never on "
"screen alone gets nothing, which beats importing somebody. Skipped "
"for anyone with a <Picture N> tag of their own. The frame is "
"CLAIMED on their sheet entry for that shot -- a picture the "
"prompt never refers to is read as another subject, so an "
"unclaimed one would arrive as a second person with the same "
"face and the same clothes. `script` is written before the render, so it does not show that tag")
# JOIN WITHOUT HOLDING THE CHAIN TWICE. torch.cat allocates the whole chain
# a second time and .float() a third -- at fp32, so double again -- while the
# per-shot fp16 pieces the loop spent a copy each to make are still sitting in
# vid_out. On the 107s chain costed above that peaks at 9.3 + 9.3 + 18.5 =
# 37GB, and vid_out was never dropped afterwards, so 27.8GB stayed held for
# the rest of the run. The fp16 saving above was being spent here twice over.
#
# Allocate the fp32 output once and fill it shot by shot, releasing each piece
# as it lands: the peak is the output plus whatever is left of vid_out, and
# the pieces are gone by the end. Same tensor, same dtype, same device, same
# contract downstream. Measured on an 8-shot chain: 20.47GB peak -> 11.11GB,
# and at the 2580-frame size costed above, 37.9GB -> 20.6GB. That is 17GB off
# the peak (three copies became one) and 9.3GB no longer held afterwards.
# device= matters: with cleanup_between_shots off the pieces are still on the
# GPU and cat/float would have returned a GPU tensor, so this must too.
# THERE IS NO JOIN LEFT. Every shot was written into _dst as it was decoded,
# so the chain is already assembled and this is a view onto it -- zero new
# bytes at the moment that used to be the peak of the whole render.
#
# _at is short of the capacity by exactly one frame per seam that trim_seam
# removed, so the slice keeps a few frames of slack allocated rather than
# copying the chain to reclaim them: shots-1 frames against a copy of the
# whole thing is not a trade worth making.
if vid_out:
# OVERFLOW ONLY -- a VAE that decoded a shot longer than it was planned
# at. Assemble both halves the old way, which costs the extra copy this
# rewrite exists to remove, on a path the real VAE never takes.
_extra = sum(int(_t.shape[0]) for _t in vid_out)
_ref = _dst if _dst is not None else vid_out[0]
video = torch.empty((_at + _extra,) + tuple(_ref.shape[1:]),
dtype=_image_out_dtype(), device=_ref.device)
if _dst is not None and _at:
video[:_at].copy_(_dst[:_at])
_dst = None
_w = _at
while vid_out:
_piece = vid_out.pop(0)
_k2 = int(_piece.shape[0])
video[_w:_w + _k2].copy_(_piece)
_w += _k2
del _piece
elif _dst is not None:
video = _dst if _at == _dst.shape[0] else _dst[:_at]
else:
video = torch.cat(vid_out, dim=0) # empty: fail exactly as before
# PIXEL upscale, once, on the finished chain. After the latent pass and after
# the join, so a model-based upscaler sees whole frames and the seam is not
# upscaled twice.
if upscale and upscale != "off":
video, up_note = _upscale_frames(video, upscale, upscale_model,
upscale_target_short_edge, upscale_batch)
if up_note:
notes.append(up_note)
# AUDIO IS FLOAT32 WHATEVER THE FRAMES ARE, and this is the one place the two
# branches must not follow the same rule. --fp16-intermediates is a good trade
# on pixels and a bad one on a waveform, because what each is quantised to at
# the end is not the same:
#
# images 0..1, out at 8 bits : fp16 step 2.4e-04 against 3.9e-03 -- 16x finer
# than the output can show. Invisible.
# audio -1..1, out at 16 bits: fp16 step 2.4e-04 against 3.1e-05 -- 8x
# COARSER than the format. ~12 effective bits.
#
# And it buys nothing: the frames are 9.3GB of the chain and the whole
# soundtrack is 0.018GB, so holding it at full width costs 18MB of the 58.9GB
# that made this render fit. The bed is mixed onto this AFTER the join and the
# levelling runs over the joined track, so a narrow accumulator is not merely
# stored coarse, it is added up coarse.
audio = torch.cat(aud_out, dim=-1)
if audio.dtype != torch.float32:
audio = audio.float()
# ...and the ambient bed goes on last, over the joined soundtrack rather than
# per shot, so the loop runs continuously through the cuts instead of
# restarting at each one. A bed that resets every shot is a bed you can hear.
# THE BED IS BUILT, not fetched, unless something is wired to ambient_audio.
# The node has already read what the room sounds like off the scene -- that
# is what auto_sound puts in the prompt -- so the same phrase can be turned
# into the sound itself. No file, no second model pass, and shaped noise is
# the one source of ambience that physically cannot produce a voice.
_bed_in, _built = ambient_audio, ""
if _bed_in is None and float(ambient_level or 0.0) > 0.0:
_phrase = " ".join(p for p in (_mix_bed, _mix_room) if p)
_synth = synth_ambient(_phrase, int(audio.shape[-1]), int(sr),
seed=seed, channels=int(audio.shape[1]))
_fell_back = False
if _synth is None:
# A shaped bed that will not build falls back to a plain one rather
# than to nothing. Wiring a file is NOT the remedy: the built bed is
# the feature and a file is only ever an override, so the floor has
# to be here.
_synth, _fell_back = plain_bed(int(audio.shape[-1]), int(sr), seed,
int(audio.shape[1])), True
if _synth is None:
# SAID, not swallowed. Both builders are defensive so a render never
# dies for a bed, and that would otherwise turn a failure into an
# output with no ambience and nothing anywhere saying why -- the
# exact hole _SILENCE_STATUS exists to close on the other branch.
notes.append(
f"AMBIENT LEVEL IS {float(ambient_level):.2f} BUT NO BED WENT ON. "
f"Both the shaped bed and the plain fallback failed to build, "
f"which should not be possible on a soundtrack this node just "
f"produced -- please report it")
else:
_bed_in = {"waveform": _synth.unsqueeze(0), "sample_rate": int(sr)}
_built = (f"built from the scene, not a file: \"{_phrase}\". "
if _phrase else "built as a neutral room tone. ")
if _fell_back:
_built += ("The SHAPED bed would not build, so this is the plain "
"fallback -- a rumble rather than the acoustic the "
"scene describes. Worth reporting: it should not "
"happen. ")
# Said plainly rather than left to disappoint: this shapes TONE.
if any(w in _phrase for w in _BED_EVENTFUL):
_built += ("That description names EVENTS, and this builds tone "
"-- so what went under is the room those things are "
"in, not the things themselves. Wire a recording to "
"ambient_audio if you want the events. ")
# FOLEY, into each shot's own span. Only shots pinned to SILENCE: an open
# branch is already making its own sound from the same prose, and building
# over that would double every footfall. These are the shots that had
# nothing -- a wordless beat staging cuffs going on, silent because opening
# its branch is what babbles.
_foley_on = []
if auto_sound and float(foley_level or 0.0) > 0.0 and shot_events:
_at = 0
for _i, _w in enumerate(aud_out):
_len = int(_w.shape[-1])
_lo, _hi, _at = _at, _at + _len, _at + _len
if _i >= len(shot_events) or _i >= len(speech):
continue
_pinned = bool(silence_nonspeech and not speech[_i]
and not (sounded[_i] if _i < len(sounded) else False))
# ...OR open only because the beat stages EFFORT. The skip above
# exists so built sound does not double what an open branch is
# already making out of the same prose. That is true when the
# AUTHOR wrote the sound, and false for effort: THAT branch opened
# to make a voice, and a voice is not a bed frame, a chain or a
# cuff. Every recipe here is non-vocal by construction, so on such
# a shot the vocal phrase simply has no recipe and drops out on its
# own -- what is left is exactly the half the model will not make.
#
# Missing this undid the recipes in the same commit that added
# them: the nine effort verbs became _voiced, _voiced unpins the
# shot, and an unpinned shot skips the mix. So "a bed frame
# working" and "restraints pulling taut" were read from the beat,
# written into the prompt, and then never built -- on precisely the
# beats they exist for. Reported as hearing nothing.
#
# Gated on silence_nonspeech with everything else. Turning that off
# says "pin nothing, let the model sound every shot" -- and then
# there is no shot the model cannot make, which is the entire reason
# anything is built here. Without this the effort shots kept their
# built layer while the model was also sounding them from the same
# prose, which is the doubling this whole gate exists to avoid.
_voice_open = bool(silence_nonspeech and _i < len(voiced_only)
and voiced_only[_i])
if not (_pinned or _voice_open) or _len < 64:
continue
_made = []
for _ph in shot_events[_i]:
_fx = foley_for(_ph, _len, int(sr), seed=int(seed) + _i)
if _fx is None:
continue
audio[..., _lo:_hi] = (audio[..., _lo:_hi]
+ _fx.to(audio.dtype).unsqueeze(0)
* float(foley_level))
_made.append(_ph)
if _made:
_foley_on.append((_i + 1, _made, _voice_open))
if _foley_on:
_eff = [n for n, _, v in _foley_on if v]
notes.append(
"sound built into the shot itself on "
+ "; ".join(f"shot {n}: {', '.join(m)}" for n, m, _v in _foley_on)
+ (f". Shot(s) {', '.join(str(n) for n in _eff)} stage effort, so "
f"their branch IS open and the model is making the voice -- what "
f"is built there is only the non-vocal half it will not make, the "
f"frame and the metal. Lower foley_level if it doubles anything. "
if _eff else ". ")
+ "The rest have no line, so their audio branch is pinned to "
"silence and the model cannot make these -- auto_sound puts them in "
"the prompt, and prompt text can never open a branch, so the cue was "
"being dropped on exactly the shots whose point is a sound. Built and "
"mixed instead, which asks nothing of the model and so cannot babble. "
"It is synthesis, not a recording: it reads as a click, a rattle, a "
"rustle, in the right place. Nothing vocal is ever built. "
"foley_level sets how loud, 0 turns it off")
audio, _bed_note = mix_ambient(audio, sr, _bed_in, ambient_level)
if _bed_note:
notes.append(_built + _bed_note if _built else _bed_note)
total = video.shape[0]
# The finished chain is the largest thing this node holds, and it competes with
# the MODELS for system RAM: ComfyUI offloads weights to RAM rather than
# discarding them, so a shot boundary is a PCIe copy while that RAM is there
# and a disk read once the frames have crowded the weights out.
if cleanup_between_shots and total:
# MEASURED off the tensor, not assumed. This said "* 4" for float32 while
# the chain was float16 during the render and, since the join started
# asking ComfyUI what dtype it wants, may be float16 when it is returned
# too -- so a fixed width here is a number that is wrong on one install
# or the other. element_size() is right on both.
_bytes = video.element_size()
_held = total * int(w) * int(h) * 3 * _bytes / GB
_dt = "float16" if _bytes == 2 else "float32"
if _held >= 2.0:
notes.append(
f"the finished chain is {_held:.1f}GB in system RAM ({total} frames at "
f"{w}x{h}, {_dt}). It "
f"shares that RAM with the models, which ComfyUI offloads to it "
f"rather than discarding: while they fit, a shot boundary is a PCIe "
f"copy; once the frames crowd them out it becomes a disk read, once "
f"per model per shot. If the machine is thrashing, the levers are "
f"fewer frames per run (lower shot_seconds, or split a long script "
f"and join the parts outside the node), a lower megapixels, or a "
f"smaller diffusion quant -- every GB of weights is a GB not "
f"available to hold the render")
if fresh:
notes.append(
f"shot(s) {', '.join(str(n) for n in fresh)} start fresh, because the shot "
f"before each took something off -- continuing from a frame that may still "
f"show the garment is how it comes back, and a picture outvotes the text. "
f"That costs a cut there. Turn restart_after_removal off to keep the "
f"continuity instead")
if _carried:
notes.append(
"; ".join(
f"shot {s} carries the previous frame as a REFERENCE rather than "
f"as its first frame, so the room, the light and "
f"{' and '.join(w)} come with it while "
f"{' and '.join(j)} {'are' if len(j) > 1 else 'is'} already in "
f"place instead of walking in"
for s, w, j in _carried)
+ " -- a keyframe is frame one and a reference is not, which is what "
"lets a shot introduce somebody without re-imagining the room")
wall = time.perf_counter() - t_start
n = max(1, len(shots))
other = max(0.0, wall - t_sample - t_decode)
notes.append(
f"rendered {total} frames (~{total / H3_FPS:.1f}s) in {wall:.0f}s -- "
f"sampling {t_sample:.0f}s ({100 * t_sample / wall:.0f}%), "
f"decode {t_decode:.0f}s ({100 * t_decode / wall:.0f}%), "
f"other {other:.0f}s ({100 * other / wall:.0f}%); "
f"per shot {t_sample / n:.1f}s + {t_decode / n:.1f}s")
if av_fix:
per_shot = abs(av_fix) / sr * 1000 / max(1, len(shots))
notes.append(
f"audio realigned to the picture by ~{abs(av_fix) / sr * 1000:.0f} ms "
f"across {len(shots)} shot(s), {per_shot:.1f} ms each. H3's audio latent "
f"runs at {AUDIO_LATENT_FPS}/s against {H3_FPS} fps video, so a shot's "
f"sound lands exactly only when its frame count divides by 3 -- otherwise "
f"it is up to 8.3 ms out, with the same sign every time when the shots "
f"are the same length, which is how a chain drifts out of sync"
+ (". That is far more than the 8.3 ms the grid accounts for, so the "
"audio VAE is not returning the length its latent implies -- check "
"that the audio VAE is H3's own converted one"
if per_shot > 50 else ""))
_detail = detail_report(shot_detail)
if _detail:
notes.append(_detail)
if t_decode > t_sample:
notes.append("decode is costing more than sampling here -- latent_upscale "
"trades cheaper sampling for a 4x more expensive decode, so it "
"is the wrong way round at this step count. megapixels is the "
"lever that lowers both")
script = "\n---\n".join(f"[Shot {i}] {s}" for i, s in enumerate(sent_text, 1))
# Whether the silence conditioning ACTUALLY went on. Reported from the
# result, not from the flag: every failure inside _silent_audio_latent
# returns None on purpose so a render never dies for a nicety, but that
# meant a shot with a wide-open audio branch was described as "conditioned
# on real silence" -- and a shot with no scripted line babbled with nothing
# in the report saying why. This is the one note that has to come after the
# loop, because before it there is no result to report.
if silence_nonspeech and _SILENCE_STATUS["asked"]:
_missed = _SILENCE_STATUS["asked"] - _SILENCE_STATUS["applied"]
if _missed > 0:
notes.append(
f"SILENCE WAS ASKED FOR ON {_SILENCE_STATUS['asked']} shot(s) AND "
f"WENT ON {_SILENCE_STATUS['applied']}: {_missed} shot(s) have no "
f"line and an audio branch that is NOT pinned, because "
f"{_SILENCE_STATUS['why'] or 'the silent latent could not be built'}"
f". H3 is joint, so an unconditioned branch invents a voice and the "
f"picture lip-syncs to it -- a shot babbling with nothing scripted "
f"to say. The lips-closed sentence is still in the prompt and still "
f"loses to the stream")
else:
notes.append(
f"silence went on all {_SILENCE_STATUS['applied']} shot(s) that "
f"asked for it -- their audio branch is pinned to encoded silence, "
f"not merely told to be quiet")
return (video, {"waveform": audio, "sample_rate": sr}, " | ".join(notes), script,
lens[0], total, len(shots), round(total / H3_FPS, 2))
NODE_CLASS_MAPPINGS = {"H3LongVideos": H3LongVideos}
NODE_DISPLAY_NAME_MAPPINGS = {"H3LongVideos": "H3 Long Videos"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]