10931 lines
622 KiB
Python
10931 lines
622 KiB
Python
# 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"]
|