From 72f9b07ed8498efe20548adeb530748705e8fbff Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Thu, 10 Sep 2026 09:46:37 +0000 Subject: [PATCH] Replace Long Videos with upstream sampler --- H3_LONGVIDEOS_UPSTREAM_LICENSE.txt | 53 + README.md | 18 +- dumas_h3_longvideos.py | 7945 +------------------ dumas_h3_longvideos_engine.py | 1723 +++++ dumas_h3_longvideos_upstream.py | 10930 +++++++++++++++++++++++++++ js/h3_longvideos/index.js | 376 +- tests/test_dumas_h3_longvideos.py | 1673 +--- 7 files changed, 12793 insertions(+), 9925 deletions(-) create mode 100644 H3_LONGVIDEOS_UPSTREAM_LICENSE.txt create mode 100644 dumas_h3_longvideos_engine.py create mode 100644 dumas_h3_longvideos_upstream.py diff --git a/H3_LONGVIDEOS_UPSTREAM_LICENSE.txt b/H3_LONGVIDEOS_UPSTREAM_LICENSE.txt new file mode 100644 index 0000000..bfc354f --- /dev/null +++ b/H3_LONGVIDEOS_UPSTREAM_LICENSE.txt @@ -0,0 +1,53 @@ +H3-LongVideos — Licence +Copyright (c) 2026 Smite79. All rights reserved. + +This licence applies to every version published on or after 2026-09-07. + +WHAT YOU MAY DO + + 1. Download and use this software, in unmodified or modified form, for your + own purposes, personal or commercial. Rendering with it, and whatever you + render with it, is yours and is not covered by this licence. + + 2. Modify your own copy. + + 3. Submit changes back to the original project. + +WHAT YOU MAY NOT DO WITHOUT WRITTEN PERMISSION + + 4. Redistribute this software, in whole or in part, modified or unmodified. + That includes publishing it to any repository, registry, model hub, node + manager, marketplace, or mirror; bundling it inside another package, + product, image, or installer; and hosting it as a service. + + 5. Remove, alter, or obscure the copyright notice above, this licence, or the + attribution in the source files — including where a permitted redistribution + has been agreed. + + 6. Represent this software, or a derivative of it, as your own work. + +ASKING + + Permission for anything under 4 is granted case by case and is usually given + for things like inclusion in a node manager. Ask via the project's GitHub + issues at https://github.com/Smite79/MiniMax-H3-LongVideos. + +EARLIER VERSIONS + + Versions published before 2026-09-07 were released under Apache License 2.0. + That grant is irrevocable for those versions: copies obtained under it stay + under it, and this licence does not and cannot withdraw it retroactively. It + governs this version and every version after it. + + Apache 2.0 also required attribution, so a copy of an earlier version + republished with the copyright notice stripped was already in breach of the + licence it was taken under. + +NO WARRANTY + + This software is provided "as is", without warranty of any kind, express or + implied, including but not limited to the warranties of merchantability, + fitness for a particular purpose, and non-infringement. In no event shall the + copyright holder be liable for any claim, damages, or other liability, whether + in an action of contract, tort, or otherwise, arising from, out of, or in + connection with the software or the use or other dealings in the software. diff --git a/README.md b/README.md index 39b9dc9..79e1515 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,12 @@ - Outputs: `plan`, `image1`..`image9`, `connected_images` - Reads back the nine optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`. -- `Dumas H3 Long Videos (FL2VA + REF2VA)` - - Inputs: H3 model stack, prompt socket, optional `first_frame`, optional `ref_1`..`ref_9`, plus the upstream long-video control surface for pacing, continuity, audio, overlays, and guards - - Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape` - - First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work. - - Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline. - - Full user guide: [`H3_LONG_VIDEOS_GUIDE.md`](./H3_LONG_VIDEOS_GUIDE.md) - - Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu. - - Prompt `` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_2` and `ref_7` connected. - - Character refs now contribute appearance and wardrobe context from the same structured object, while location refs contribute environment context from theirs. - - The default ref2v bias is now stronger: `ref_mode` defaults to `auto ref2v` so untagged prompts condition every shot instead of only shot 1, and `ref_noise_aug` defaults to `0.95` rather than the upstream-literal `0.999`. - - `Dumas H3 Latent Upscale Params` provides the optional pre-decode latent refinement stage for the long-video node. - - Per-shot directives now support `continuity:`, `ref_mode:`, `ref_noise_aug:`, `anchor_add:`, `soundscape:`, and `music:` in addition to the existing timing and wardrobe directives. +- `Dumas H3 Long Videos` + - Inputs/outputs: the current upstream `MiniMax-H3-Longvideos` sampler surface, exposed under the existing `DumasH3LongVideos` key for saved Dumas workflows. + - The local Dumas prompt-engineering fork has been removed from this node. Long Videos now wraps the upstream sampler/engine directly so it can track the source project again. + - Upstream compatibility keys `H3LongVideos`, `H3LongVideosFL2VA`, `H3LongVideosV1`, and `H3LongVideosREF2VA` are also registered to the same class. + - The old Dumas browser widget grouping script is disabled for this node because it targeted controls that no longer exist on the upstream sampler. + - Upstream license text is included in [`H3_LONGVIDEOS_UPSTREAM_LICENSE.txt`](./H3_LONGVIDEOS_UPSTREAM_LICENSE.txt). - `Dumas H3 Latent Upscale Params` - Inputs: `mode`, `model_name`, `method`, `width`, `height`, `device`, `precision`, `sampler_name`, `scheduler`, `steps`, `denoise`, `megapixels`, `tile_width`, `tile_height`, `overlap`, `fade_width`, `fade_height`, `overlap_mode`, `overlap_blend`, `tile_size_mode`, `grid_rows`, `grid_cols`, `spatial_w_overlap`, `spatial_h_overlap`, `min_tile_size`, `masked_area_noise`, `brightness_match`, `dynamic_fade`, `dynamic_fade_min`, `chunk_length`, `temporal_overlap`, `resize_conditioning`, `anchor_strength` diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index 5e03ca4..514f77b 100644 --- a/dumas_h3_longvideos.py +++ b/dumas_h3_longvideos.py @@ -1,7925 +1,32 @@ -""" -H3 Long Videos (one prompt + one length -> long video+audio) -============================================================= -One node covering both of H3's conditioning tasks: - - * FL2VA -- a first frame (or the previous shot's last frame) anchors the shot. - This is what drives the chain: each shot continues the one before it. - * REF2VA -- reference images condition the shot on what a character LOOKS like, - independent of any frame. - -Connect nothing to ref_* and it behaves exactly as the FL2VA node always -did. Connect a reference and `ref_mode` decides which shots use it. - -THE ONE RULE: a shot carries EITHER references or the last-frame handoff, never -both. They are two task conditionings competing for the same cond_video_latents -slot in comfy/model_base.py -- the refs branch overwrites what the keyframe branch -wrote, while the packed layout still reserves rows for both, so a shot given both -hands the DiT fewer latents than it has condition rows. `ref_mode` chooses: - - first shot -- refs establish the cast in shot 1, every later shot - uses the handoff. Continuity unbroken. - every shot -- strongest identity; no handoff, so beats meet as - CUTS rather than as one continuous take. - every shot + handoff ref -- refs every shot, plus the previous shot's last - frame as one more reference. Continuity returns as - a soft signal. - -You give it a prompt (first paragraph = the look/character kept across the whole -video; each later paragraph = a scene beat), a shot length, and a resolution from -the VRAM-appropriate list. It splits the beats into shots that fit H3's ceiling -and your VRAM, chains them, and returns the finished video + audio. - -Requirements: H3 is CFG-free (cfg 1) and needs no negative prompt -- the node -makes an empty one internally. The main pass keeps denoise fixed at 1.0: a -partial denoise desyncs the joint audio/video schedule. An optional latent -upscale stage can rebuild the conditioning at a target size, run a short -refinement pass with its own sampler controls, and keep the output video-only -before the final pixel-space upscale options. - -Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py, -ldm/minimax/model.py, text_encoders/minimax.py, sd.py). -""" - -import gc -from functools import lru_cache -import json -import logging -import math -import os -import re -import time -import torch - -import nodes -import comfy.utils -import comfy.samplers -import comfy.nested_tensor -import comfy.model_management as mm -import node_helpers - +# Dumas Long Videos wrapper. +# +# The Long Videos implementation is vendored from: +# https://huggingface.co/Smite79/MiniMax-H3-Longvideos +# +# Keep this wrapper small so the upstream sampler can be refreshed without +# touching the other Dumas helper/reference/prompt nodes. + try: - from . import dumas_h3_overlay as _overlay - from .dumas_h3_latent_upscale import upscale_latent_video as _upscale_latent_video - from . import dumas_image_nodes as _image_nodes -except ImportError: # loaded as a bare file (test_prompt_logic.py), not as a package - import importlib.util as _ilu - import os as _os - import sys as _sys - _spec = _ilu.spec_from_file_location( - "dumas_h3_overlay", - _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_h3_overlay.py"), - ) - _overlay = _ilu.module_from_spec(_spec) - _spec.loader.exec_module(_overlay) - _latent_spec = _ilu.spec_from_file_location( - "dumas_h3_latent_upscale", - _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_h3_latent_upscale.py"), - ) - _latent_upscale = _ilu.module_from_spec(_latent_spec) - _latent_spec.loader.exec_module(_latent_upscale) - _upscale_latent_video = _latent_upscale.upscale_latent_video - _image_nodes = _sys.modules.get("dumas_image_nodes") - if _image_nodes is None: - _img_spec = _ilu.spec_from_file_location( - "dumas_image_nodes", - _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_image_nodes.py"), - ) - _image_nodes = _ilu.module_from_spec(_img_spec) - _sys.modules["dumas_image_nodes"] = _image_nodes - _img_spec.loader.exec_module(_image_nodes) - -AUDIO_LATENT_FPS = 40 -GB = 1024 ** 3 -H3_MAX_FRAMES = 362 -# H3's temporal grid is FIXED at 24 fps -- comfy_extras/nodes_minimax_h3.py hard-codes -# FPS = 24, and the audio latent length is derived from frame_count / 24. The model -# emits 24 fps content no matter what any node asks for, so every seconds<->frames -# conversion here MUST use 24. Treating it as a variable is what made a requested -# 10s shot render 124 frames (~5.2s of real time) when the widget said 12. -H3_FPS = 24 -MIN_SHOT_FRAMES = 124 # internal VRAM floor (~5s @24fps) - - -# --- H3 geometry ----------------------------------------------------------- -def align_frame_count(n): - while n % 17 != 5: - n += 1 - return n - - -def align_frame_count_nearest(n): - """Nearest 17k+5 grid point, not the next one up. - - align_frame_count always rounds UP, which is right when honoring a length that - was ASKED for -- never give back less than requested. It is wrong for an - ESTIMATE: the grid steps 17 frames (~0.7s), so rounding up added up to 0.7s to - every content-sized shot and pushed a 9.5s estimate out to 10.1s. Pacing leans - short on purpose; rounding should not quietly lean the other way.""" - n = max(5, int(n)) - lo = n - ((n - 5) % 17) - hi = lo + 17 - return 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): - """`fps` is accepted for call-site compatibility but deliberately IGNORED: the - audio latent must line up with 24 fps video or the shot's sound is stretched - against its picture.""" - fc = align_frame_count(max(5, length)) - return fc, video_latent_t(fc), round(fc / H3_FPS * AUDIO_LATENT_FPS) - - -def res_down(w, h, factor=0.85, mult=32): - snap = lambda v: max(mult, round(v * factor / mult) * mult) - return snap(w), snap(h) - - -# --- native 768p canvas per ratio (H3-Base renders at 768 short edge) ------ -# H3-Base's native/trained resolution is 768 on the short edge; rendering below -# it softens the whole frame (faces worst). So resolution is ALWAYS kept native -# and never traded down for VRAM -- when the card is tight, SHOT LENGTH shrinks -# instead (see estimate_shot_frames). The 768*1344 area cap means very wide -# ratios (21:9) land just under 768 short edge natively. -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), -} -# 512-short-edge "fast" tier: ~4x fewer pixels than native, for the generate-low- -# then-upscale (LTX 2.3) workflow. Best for close/medium shots -- H3 distorts faces -# on WIDE shots at any resolution, so keep faces reasonably large in frame. -FAST_RES = { - "16:9": (896, 512), - "9:16": (512, 896), - "4:3": (704, 512), - "3:4": (512, 704), - "1:1": (512, 512), - "21:9": (1184, 512), -} -# 640-short-edge "balanced" tier: a middle ground between fast 512 and native 768. -MID_RES = { - "16:9": (1152, 640), - "9:16": (640, 1152), - "4:3": (864, 640), - "3:4": (640, 864), - "1:1": (640, 640), - "21:9": (1504, 640), -} -NATIVE_PIXELS = 1344 * 768 # ~1MP reference for the VRAM/length budget -# Shot-length budget fit (see estimate_shot_frames). Measured anchors on a 16GB -# card: at 1344x768 with the pruned NVFP4 DiT, 243f fits (~2.7GB spare) and 362f -# overflowed by ~4.3GB -> slope (362-243)/(7.0-2.7) ~= 27.7 frames per GB. The -# baseline absorbs the part of the latent that fits in already-counted space. -# Refit against BOTH measured points at once: 243f must be reachable at the 640p / -# 13.6GB case (~1.1GB scaled spare) and 362f must NOT be until ~7GB. That gives -# slope (362-243)/(7.0-1.12) ~= 20.2 f/GB with a 10.91GB baseline. The native -# NVFP4 case then lands at 260f -- above the 243f measured safe and below the 362f -# measured overflow, i.e. consistent with both rather than fitted to either. -FRAMES_PER_GB = 20.2 -# Fraction of free VRAM held back for transient activation peaks during sampling -# (the steady-state latent is not the high-water mark). Prevents the node from -# picking a length that fits on paper but spills into shared memory mid-shot. -SPIKE_RESERVE = 0.12 -FRAMES_BASELINE_GB = 10.91 - - -def resolution_options(): - """ASPECT RATIOS only. `megapixels` decides the size. - - Shape and size are independent, and the widgets now say so. This list used to - carry three short-edge tiers per ratio (native 768 / balanced 640 / fast 512), - which baked a SIZE into every label -- and once megapixels existed those labels - lied whenever it was on. Nothing is lost: the tiers were three points on the - megapixel axis (~0.98 / ~0.70 / ~0.44MP), and a continuous control reaches them - and everything between. MID_RES and FAST_RES are kept only as the reference - anchors documented on them. - - NATIVE_RES still supplies each ratio's exact shape, which is what makes 1.00MP - reproduce H3's own sizes: the ratio NAMES are approximations -- 1344x768 is - 1.750, i.e. 7:4, NOT 16:9 (1.778) -- so scaling runs from the real dimensions - rather than the nominal ratio.""" - return list(NATIVE_RES) - - -def parse_resolution(choice): - """The chosen ratio's reference dimensions, which `megapixels` then scales. - - Accepts a bare ratio ("16:9") and ALSO the old "16:9 - 1344x768 (native)" form, - so a workflow saved before this list was simplified still resolves to the right - shape instead of silently falling back to the first entry. Unrecognized input - gives 16:9.""" - text = (choice or "").strip() - if text in NATIVE_RES: - return NATIVE_RES[text] - m = re.search(r"(\d+)\s*x\s*(\d+)", text) # legacy label carried its size - if m: - return int(m.group(1)), int(m.group(2)) - for r in NATIVE_RES: # legacy label led with the ratio - if text.startswith(r): - return NATIVE_RES[r] - return NATIVE_RES["16:9"] - - -# --- sizing by pixel budget ------------------------------------------------- -# Cost and training-distribution match are functions of TOKEN COUNT -- -# (h/16)*(w/16)*frames -- which tracks total pixels, not the short edge. A -# short-edge target makes two aspect ratios look comparable when they are not: -# -# 1:1 768x768 short edge 768, reads native -> 0.56 MP, 43% under -# 21:9 1536x672 short edge 672, reads sub-native -> 0.98 MP, full budget -# -# Scaling from the PRESET's own dimensions (rather than from a nominal ratio) is -# what makes 1.00MP reproduce each preset's native size exactly. That distinction -# is real: 1344x768 is 1.750, i.e. 7:4 -- NOT 16:9, which is 1.778 -- and -# 1536x672 is 16:7, not 21:9. Computing from a nominal 16:9 lands on 1376x768 and -# never reproduces the native. -# -# This does NOT change the sigma schedule. H3's shift is a fixed 12.0 in its model -# config with no resolution-dependent term, unlike Flux/SD3 dynamic shifting. -MP_UNIT = 1024 * 1024 # 1 MP == 1024x1024, matching ComfyUI's own convention -RES_MULTIPLE = 32 # every shipped preset is a multiple of 32 - - -def scale_to_megapixels(w, h, mp, multiple=RES_MULTIPLE): - """Resize (w, h) to hit `mp` megapixels, keeping the aspect ratio. - - Constant-area square root, snapped to `multiple` on both axes -- which is what - H3's patchified latent grid needs. Snapping moves the real area off the request - slightly, so callers report the ACHIEVED value: a readout of what was asked for - hides what was produced. mp <= 0 means "leave the preset alone".""" - if not mp or mp <= 0 or w <= 0 or h <= 0: - return int(w), int(h) - multiple = max(1, int(multiple)) - scale = math.sqrt((float(mp) * MP_UNIT) / float(w * h)) - nw = max(multiple, int(round(w * scale / multiple)) * multiple) - nh = max(multiple, int(round(h * scale / multiple)) * multiple) - return nw, nh - - -# --- prompt parsing + auto time distribution ------------------------------- -def split_paragraphs(text, delimiter): - raw = text.replace("\r\n", "\n").strip() - if not raw: - return [] - raw = re.sub(r"(?m)^\s*" + re.escape(delimiter) + r"\s*$", "\n\n", raw) - return [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()] - - -# Widgets added after the node's original 36-widget layout. Kept LAST in -# INPUT_TYPES so a workflow saved before they existed still maps its stored values -# onto the right widgets (ComfyUI matches them by position, not by name). -# APPEND widget names to this tuple; never insert into the middle. Pure sockets -# that carry no widget value can stay grouped in INPUT_TYPES without being listed. -ADDED_WIDGETS = ( - "beat_split", - "watermark_text", "watermark_position", "watermark_size", "watermark_opacity", - "watermark_margin", "intro_text", "intro_position", "intro_seconds", - "intro_fade", "intro_size", "overlay_font", "overlay_stroke", - "ref_mode", "ref_image_size", "ref_noise_aug", "auto_props", "prevent_nudity", - "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard", - "motion_guard", "contact_guard", - "auto_soundscape", "allow_nonspeech_vocals", -) - -NL = "\n" -# Lines that CONFIGURE a beat rather than being one. They attach to the beat that -# follows them, so a line-split never turns "wardrobe: ..." into its own shot. -DIRECTIVE_KEYS = ("wardrobe", "seconds", "duration", "exit", "enter", - "overall_soundscape", "non_diegetic_music", "soundscape", "music", - "continuity", "ref_mode", "ref_noise_aug", "anchor_add") - -_REF_MODE_DIRECTIVE_MAP = { - "auto ref2v": "auto ref2v", - "where tagged": "where tagged", - "first shot": "first shot", - "every shot": "every shot", - "every shot + handoff ref": "every shot + handoff ref", -} - -_CONTINUITY_DIRECTIVE_MAP = { - "auto": "auto", - "default": "auto", - "soft carry": "soft carry", - "hard cut": "hard cut", - "keyframe carry": "keyframe carry", - "handoff ref": "handoff ref", -} - - -def is_directive_line(line): - return bool(re.match(r"\s*(" + "|".join(DIRECTIVE_KEYS) + r")\s*:", line or "", re.I)) - - -def expand_beats(paras, mode="auto"): - """Turn the prompt's beat PARAGRAPHS into the final beat list. Returns - (beats, note). + from .dumas_h3_longvideos_upstream import H3LongVideos +except ImportError: # pragma: no cover - direct module import in local tests + from dumas_h3_longvideos_upstream import H3LongVideos - Beats are separated by a BLANK line (or a '##' line). That is unambiguous, but - it is also easy to misread once a dedicated beat editor exists: inside the - beat editor, one textbox is one beat, so plain newlines inside that textbox - should stay inside the same beat rather than quietly creating new shots. - mode: - 'auto' -- preserve each paragraph as one beat. - 'each line' -- every content line is its own beat, with directive lines - attaching to the beat they configure. +NODE_CLASS_MAPPINGS = { + "DumasH3LongVideos": H3LongVideos, + "H3LongVideos": H3LongVideos, + "H3LongVideosFL2VA": H3LongVideos, + "H3LongVideosV1": H3LongVideos, + "H3LongVideosREF2VA": H3LongVideos, +} - Directive lines ('wardrobe:', 'seconds:', 'exit:' ...) are never beats of their - own: they attach to the next content line, or to the previous beat if they - trail the paragraph.""" - # Any unrecognized mode means AUTO, never "do nothing". Older workflows may - # still store removed values such as 'blank line'; those should preserve whole - # paragraphs instead of unexpectedly splitting every newline into a beat. - if mode not in ("auto", "each line"): - mode = "auto" - if mode == "auto": - return [p.strip() for p in (paras or []) if str(p or "").strip()], "" - out, split_from = [], 0 - for p in paras: - lines = [ln for ln in (p or "").splitlines() if ln.strip()] - content = [ln for ln in lines if not is_directive_line(ln)] - if len(content) <= 1: - out.append(p) - else: - split_from += 1 - pending = [] - for ln in lines: - if is_directive_line(ln): - # Hold it for the NEXT content line: a directive reads as a header - # for the beat it introduces ("wardrobe: -= jacket" / "she shrugs - # it off"). Only if nothing follows does it fall back to the beat - # above, handled after the loop. - pending.append(ln) - continue - out.append(NL.join(pending + [ln])) - pending = [] - if pending: # directives with no beat after them - if out: - out[-1] = out[-1] + NL + NL.join(pending) - else: - out.append(NL.join(pending)) - note = "" - if split_from: - note = (f"beat_split '{mode}' split {split_from} multi-line paragraph(s) into " - f"{len(out)} beats") - return out, note - - -def _garment_side(side): - """Is `side` one garment, for the purpose of splitting an 'A and B' entry? - - Qualifies when its head noun is a garment the zone tables know, or when it is - long enough to be a described item rather than a bare adjective. The zone - vocabulary is looked up at call time because it is defined further down.""" - words = side.split() - if not words: - return False - vocab = _ZONE_LOWER | _ZONE_UPPER | _ZONE_BOTH - return _item_head(side) in vocab or len(words) >= 2 - - -def _split_conjoined(item): - """Split 'a black jacket and blue jeans' into two tracked garments. - - A sheet entry joined by 'and' was kept as ONE item, and _item_name() truncates - at 'and' -- so "small white t-shirt and shiny white lace thong" became the item - "small white t-shirt" with head `t-shirt`, and the thong was not tracked at all. - "pulls down the thong" then matched nothing, the removal silently did not fire, - and the whole compound string -- thong included -- was re-stamped into the - character's parenthetical on every later shot. The prompt kept saying she was - wearing it, so the model kept putting it back. - - Only split when BOTH sides read as garments and at least one is a garment the - zone tables recognise. That last condition is what keeps colour pairs and - ordinary attributes intact: "black and white dress" has 'black' on the left, - which is neither a known garment nor two words, and "blonde hair and blue eyes" - has no garment on either side. Not splitting is always safe -- the left-hand - garment stays tracked through _item_name()'s truncation either way -- so this - is purely additive: it can only start tracking a garment that was invisible.""" - parts = [p.strip(" .;") for p in re.split(r"\s+and\s+", item or "", flags=re.I)] - parts = [p for p in parts if p] - if len(parts) < 2 or not all(_garment_side(p) for p in parts): - return [item] - vocab = _ZONE_LOWER | _ZONE_UPPER | _ZONE_BOTH - if not any(_item_head(p) in vocab for p in parts): - return [item] - return parts - - -def _split_items(s): - """Split a description into attribute items on commas AND sentence ends. - - A sheet written naturally ends clauses with a period -- "wearing a black t-shirt - and jeans. Mouth closed." -- and treating that as ONE item drags a whole sentence - into the inline parenthetical, which then reads as its own statement about a - person rather than an attribute of the pronoun. Splitting on '.' as well keeps - each item a short attribute. - - Garments joined by 'and' are then separated so each is independently removable; - see _split_conjoined().""" - out = [] - for i in re.split(r"[,.;]", s or ""): - i = i.strip(" .;") - if i: - out += _split_conjoined(i) - return out - - -def _norm_name(name): - """Normalize a person key: trim whitespace and a trailing ':' so 'Kristy:' - and 'Kristy' are the same person (the colon is natural to type because it's - how the sheet renders back).""" - return name.strip().rstrip(":").strip() - - -def _split_name(part): - """Split 'Name = items' or 'Name: items' into (name, items_str). The name is - bound by '=' or a leading 'Name:' (the token before ':' must have no comma, - so a plain clothing list like 'grey shorts, red jacket' stays unnamed). - Returns ('', part) when there's no name binder.""" - if "=" in part: - name, desc = part.split("=", 1) - return _norm_name(name), desc - if ":" in part: - head, tail = part.split(":", 1) - if "," not in head: # a name won't contain a comma - return head.strip(), tail - return "", part - - -def _entries(text): - """Split a wardrobe sheet into per-person entries. Accepts BOTH ';' and - NEWLINES as separators -- character_memory is a multiline box, so one person - per line is the natural way to write it, and silently mis-parsing that (folding - the next person into the previous one's item list) breaks name lookup and makes - removals fail to match. Also tolerates a leading '-' bullet per line.""" - parts = [] - for chunk in re.split(r"[;\n\r]+", text or ""): - chunk = chunk.strip().lstrip("-*\u2022 ").strip() - if chunk: - parts.append(chunk) - return parts - - -def parse_wardrobe(text): - """Parse an INITIAL wardrobe sheet into an ordered {name: [items]} dict, so - people are tracked independently and individual garments can be added or - removed later. Entries split on ';' OR newlines; each is 'Name = a, b' OR - 'Name: a, b' (colon works too). An entry with no name binder is the single - unnamed subject under '' (one-person, backward-compat).""" - out = {} - for part in _entries(text): - name, desc = _split_name(part) - out[name] = _split_items(desc) - return out - - -def apply_wardrobe_change(active, text): - """Apply a per-beat 'wardrobe:' directive so you DON'T restate the whole - outfit to change one thing. Entries split on ';'; each targets one person - (or the unnamed subject) with an operator: - Name = a, b replace that person's whole outfit - Name += c, d ADD items - Name -= jacket REMOVE items whose text contains any given token - The Name may be written with or without a trailing colon ('Maya' or 'Maya:' - both work). Bare forms (no Name) target the single unnamed subject: '= a,b', - '+= hat', '-= jacket'. Names not mentioned are left untouched. So dropping a - jacket is just 'wardrobe: Maya -= jacket' -- one token, nothing re-typed.""" - active = {k: list(v) for k, v in active.items()} - for part in _entries(text): - if "+=" in part: - name, val, op = (*part.split("+=", 1), "+") - elif "-=" in part: - name, val, op = (*part.split("-=", 1), "-") - elif "=" in part: - name, val, op = (*part.split("=", 1), "=") - else: - name, val = _split_name(part); op = "=" # bare or 'Name: items' -> replace - name = _norm_name(name) - items = _split_items(val) - cur = active.get(name, []) - if op == "=": - active[name] = items - elif op == "+": - active[name] = cur + [i for i in items if i.lower() not in (c.lower() for c in cur)] - elif op == "-": - toks = [t.lower() for t in items] - active[name] = [it for it in cur if not any(t in it.lower() for t in toks)] - return active - - -_PRO = {"she": "she", "her": "she", "hers": "she", - "he": "he", "him": "he", "his": "he", - "they": "they", "them": "they", "their": "they", "theirs": "they"} -_GENDER = {"woman": "she", "women": "she", "female": "she", "girl": "she", "lady": "she", - "man": "he", "male": "he", "boy": "he", "guy": "he", "gentleman": "he"} - - -def _pronoun_of(items): - """A person's pronoun, from an explicit token in their sheet ('she') or a - gender word in their description ('woman'). None if undeclared/undetectable.""" - for it in items: - if it.strip().lower() in _PRO: - return _PRO[it.strip().lower()] - for it in items: - for w in re.findall(r"[a-z]+", it.lower()): - if w in _GENDER: - return _GENDER[w] - return None - - -_PERSON_NOUNS = (r"woman|women|man|men|girl|boy|guy|lady|gentleman|person|people|" - r"female|male|figure|character|adult|teen|teenager") - - -def _deposition(desc, name=None): - """Turn a description into pure ATTRIBUTES, removing any subject-introducing - noun phrase. - - A description written naturally -- "a woman with silver hair", "a young woman", - "Kristy is a tall woman" -- renders inline as `She (a woman with silver hair)`. - That is TWO subject nouns in one clause ("She" and "a woman"), which - text-to-video reads as two people: character duplication, visible from the very - first shot and independent of resolution. Attributes alone -- "silver hair" -- - bind to the pronoun instead of competing with it. - - Strips: a leading article + optional adjectives + person noun (keeping any - following "with/in ..." attributes), a copula phrase ("Kristy is a tall - woman"), and a bare repeat of the character's own name.""" - d = (desc or "").strip() - if not d: - return d - if name: - d = re.sub(r"\b" + re.escape(name) + r"\b\s*(?:is|,)?\s*", "", d, flags=re.I) - # "a young woman with silver hair" -> "silver hair"; "a tall woman" -> "tall" - m = re.match(r"^\s*(?:an?|the)\s+((?:[\w\-]+\s+){0,3}?)(?:" + _PERSON_NOUNS + r")\b" - r"(?:\s+(?:with|in|wearing)\s+)?(.*)$", d, re.I) - if m: - adjectives, rest = m.group(1).strip(), m.group(2).strip() - d = (rest if rest else adjectives) or adjectives - # "wearing a black t-shirt" -> "black t-shirt": inside a parenthetical the verb - # reads as a separate predicate about a subject, not an attribute of the pronoun - d = re.sub(r"^\s*(?:wearing|dressed in|dressed|clad in|in)\s+", "", d, flags=re.I) - d = re.sub(r"^\s*(?:an?|the)\s+", "", d) - # a bare person noun left on its own carries no attribute -> drop it - if re.fullmatch(r"\s*(?:an?|the)?\s*(?:" + _PERSON_NOUNS + r")\s*", d, re.I): - return "" - return re.sub(r"\s{2,}", " ", d).strip(" ,") - - -# Mouth/lip state items in a character sheet ("mouth closed", "lips together"). -# Users add these to force mouths shut on action shots, which works -- but they are -# re-stamped into EVERY shot, so on a beat with real quoted dialogue the prompt -# tells the model to keep the mouth closed AND to speak. Dropped on speaking shots -# only; kept everywhere else so the forced-closed behaviour is preserved. - - -def _is_mouth_state(item): - it = (item or "").strip().lower() - if not it: - return False - return bool(re.search(r"\b(?:mouth|lips|jaw)\b", it) and - re.search(r"\b(?:closed|shut|together|still|sealed|not\s+talking|no\s+talking)\b", it)) - - -def _clean_items(items, name=None, drop_mouth_state=False): - """Drop bare pronoun tokens, de-position any noun-phrase descriptions so a - parenthetical never introduces a second subject, and -- on shots that contain - real quoted dialogue -- drop mouth-state items so the sheet does not order a - closed mouth in the same breath as a spoken line.""" - out = [] - for it in items: - if it.strip().lower() in _PRO: - continue - if drop_mouth_state and _is_mouth_state(it): - continue - d = _deposition(it, name) - if d: - out.append(d) - return out - - - -def _pron_map(active): - """{pronoun: [names]} for resolving a bare 'she'/'he' to a person.""" - out = {} - for n, items in active.items(): - if not n: - continue - p = _pronoun_of(items) - if p: - out.setdefault(p, []).append(n) - return out - - -def _resolve_subject(word, names, pron_map, single): - """Map a subject token (a name or a pronoun) to a tracked person, or None if - ambiguous. In a one-person scene any pronoun maps to that person.""" - wl = word.lower() - for n in names: - if n.lower() == wl: - return n - if wl in _PRO: - want = _PRO[wl] - cands = pron_map.get(want, []) - if len(cands) == 1: - return cands[0] - if cands: - return None # ambiguous: two people share this pronoun - # No candidate with this pronoun. Only fall back to the lone remaining - # person if their pronoun is UNDECLARED -- never map 'he' onto a declared - # 'she' (which happened once the 'he' character had left the scene). - if single and names and not any(names[0] in v for v in pron_map.values()): - return names[0] - return None - return None - - -# Words that start a DETAIL trailing off a garment rather than continuing its name: -# "red leather jacket WITH silver zippers", "boots WITH steel buckles". -# NOTE: "down" is deliberately absent -- it is a material ("a puffy down jacket"), -# not a position, and cutting there would leave "puffy" as the garment name. -_ITEM_DETAIL = re.compile( - r"\b(?:with|without|featuring|showing|bearing|in|on|over|under|across|that|which|" - r"and|plus|sporting|carrying|covered|around|about|at|through|along|behind|" - r"beneath|beside|near)\b") - - -def _item_name(item): - """The part of a wardrobe item that NAMES the garment, without its detail. - - "red leather jacket with a white circular chest patch" -> "red leather jacket". - Used both to match a removal and to refer to the garment in generated prose: - the detail belongs in the description that is stamped every shot, not in a - sentence whose only job is to say the thing came off.""" - il = (item or "").strip() - cut = _ITEM_DETAIL.search(il.lower()) - name = il[:cut.start()].strip(" ,") if cut and cut.start() > 0 else il - # Drop a leading determiner: sheets are written both ways ("a diaper", "diaper"), - # and the generated prose supplies its own article -- "the a diaper underneath". - name = re.sub(r"^(?:a|an|the|her|his|their|its|my|your)\s+", "", name, flags=re.I) - return name or il - - -def _item_head(item): - """The garment's own head noun, ignoring any detail trailing off it. - - Taking the LAST word was wrong the moment an item carried detail: the head of - "red leather jacket with silver zippers" came out as `zippers`, of "bomber - jacket with a white logo on the chest" as `chest`. "takes off her red jacket" - then matched nothing and the removal SILENTLY did not fire -- the garment stayed - on the sheet and got re-stamped into every later shot. Detailed wardrobe entries - are normal (logos, zippers, torn knees), so the head has to be read from the - part of the phrase that names the garment.""" - words = re.findall(r"[a-z\-]+", _item_name(item).lower()) - if not words: - words = re.findall(r"[a-z\-]+", (item or "").lower()) - return words[-1] if words else "" - - -def _item_mentioned(item, window): - """Does `window` refer to this wardrobe item? Matches the whole phrase, or the - item's head noun, tolerant of singular/plural ('boots' vs 'boot') -- the strict - exact-substring test missed 'takes off her boots' when the sheet said 'boots' - and vice versa. Ignores generic colour/size adjectives so 'red jacket' is still - matched by 'her jacket', and trailing detail so 'red jacket with silver zippers' - is still matched by 'her jacket'.""" - w = window.lower() - il = item.lower().strip() - if not il: - return False - if il in w: - return True - head = _item_head(il) - if not head: - return False - for form in {head, head.rstrip("s"), head + "s", head + "es"}: - if form and re.search(r"\b" + re.escape(form) + r"\b", w): - return True - return False - - -# Where a removal verb's OBJECT ends. Anything past one of these belongs to a -# different phrase -- what was revealed, where the garment was put, what happened -# next -- and must not be treated as something that also came off. -_OBJECT_STOP = re.compile( - r"[,.;:!?]" - r"|\b(?:revealing|reveals?|showing|shows?|exposing|exposes?|leaving|leaves?|wearing|" - r"wears?|underneath|beneath|under|over|on|onto|in|into|to|from|at|by|beside|near|" - r"next|behind|against|while|as|before|after|then|toward|towards)\b" - r"|\b(?:puts?|pulls?|slips?|throws?|zips?|buttons?|laces?)\s+(?:on|into)\b") - - -def _removal_object_spans(text, m): - """(forward, backward) -- the two places a removal verb's object can sit. - - FORWARD is the normal case ("takes off her red jacket"). It runs from the verb - to the first phrase boundary and INCLUDES the matched cue, because the put-away - patterns ("hangs her jacket on a hook") carry the garment inside the match. It - deliberately does not extend past a boundary: a fixed ~68-character window used - to sweep up any garment sitting near the removal, so "drops it on the bench next - to her boots" removed the boots and "over her black tank top" removed the top. - - BACKWARD covers the phrasings that put the garment FIRST -- "her jacket slips - off her shoulders", "her dress falls to the ground", "the jacket is off now". - It is tried only when the forward span turns up nothing, and it stops at the - previous clause boundary so it reaches the subject and no further.""" - tail = text[m.end():m.end() + 60] - cut = _OBJECT_STOP.search(tail) - forward = m.group(0) + (tail[:cut.start()] if cut else tail) - head = text[max(0, m.start() - 45):m.start()] - bcut = None - for b in re.finditer(r"[,.;:!?]|\b(?:and|then|while|as|before|after)\b", head): - bcut = b - backward = head[bcut.end():] if bcut else head - return forward, backward - - -# Items that STAY ON until something explicitly says otherwise. -# -# A garment coming off by itself is usually what the prose meant. A restraint is -# not: it is a plot state that the scene establishes and only the scene ends. Left -# to the ordinary removal detector these came off far too easily, and often by -# accident -- "steps out of her jacket and the chain falls away" removed the ankle -# chain as a side effect of a beat about a jacket, because the removal window -# reaches any tracked item near the cue. -# -# Deliberately narrow. Only nouns that are unambiguously a restraint: `chain`, -# `collar`, `strap` and `belt` are all left OUT, because they are jewellery, a -# shirt part, a dress part and a garment at least as often. A compound like -# "ankle chain" or "chain restraint" is caught by its qualifier instead. -_RESTRAINT_HEADS = { - "handcuff", "handcuffs", "cuff", "cuffs", "shackle", "shackles", - "manacle", "manacles", "fetter", "fetters", "iron", "irons", - "restraint", "restraints", "binding", "bindings", "bond", "bonds", - "gag", "blindfold", "hood", "muzzle", "harness", "leash", "hobble", - "strait-jacket", "straitjacket", "spreader", "zip-tie", "ziptie", -} -# ...and the qualifiers that turn an ambiguous noun into a restraint. Body parts -# and binding participles ONLY. -# -# Materials are deliberately absent. "leather" would make a leather belt a -# restraint, "steel" a steel watch strap. And a word may not appear in BOTH lists: -# `chain` and `rope` were in each at first, so they qualified themselves and a bare -# "gold chain" came out a restraint. -_RESTRAINT_QUALIFIER = re.compile( - r"\b(?:ankle|wrist|leg|arm|thumb|neck|waist|" - r"chained|shackled|cuffed|bound|tied|locked|padlocked|restraining)\b", re.I) -_RESTRAINT_NOUN = re.compile( - r"\b(?:chain|chains|rope|ropes|cord|cords|tie|ties|strap|straps|collar|collars|" - r"band|bands|belt|belts|cuff|cuffs)\b", re.I) -# Two shapes the head-noun/qualifier rules above both miss: -# * compounds whose HEAD noun is innocent -- "spreader bar" resolves to `bar`, -# which is furniture far more often than hardware, so only the word SPREADER -# identifies it; -# * a binding participle fastened straight onto a body part -- "bound wrists", -# "shackled ankles", "tied hands" -- where there is no equipment noun at all. -# Only the STRONG participles qualify here, never a bare body part: the qualifier -# list already carries ankle/wrist/etc. for the equipment route, and letting a -# body part qualify itself would make "waist tie" on a dress unremovable. -_RESTRAINT_SPREADER = re.compile(r"\bspreaders?\b", re.I) -_RESTRAINT_PARTICIPLE = re.compile( - r"\b(?:chained|shackled|cuffed|bound|tied|locked|padlocked)\b", re.I) -_BODY_PART_TOKEN = re.compile( - r"\b(?:ankles?|wrists?|legs?|arms?|thumbs?|knees?|elbows?|hands?|feet|foot|" - r"fingers?|neck|waist)\b", re.I) - - -def is_restraint(item): - """True when this wardrobe item is a physical restraint rather than clothing. - - Either an unambiguous restraint noun on its own ("handcuffs", "shackles"), an - ambiguous one carrying a restraint qualifier ("ankle chain", "leather wrist - straps"), a compound named by its modifier ("spreader bar" -- the head noun - `bar` says nothing), or a binding participle on a body part ("bound wrists"). - A bare "chain" or "collar" is NOT a restraint -- it is jewellery or a shirt - part far more often, and a false positive here means a garment that can never - be taken off.""" - name = _item_name(item or "").lower() - if not name: - return False - if _item_head(item) in _RESTRAINT_HEADS: - return True - if _RESTRAINT_NOUN.search(name) and _RESTRAINT_QUALIFIER.search(name): - # ...unless the phrase resolves to an actual BODY-COVERING GARMENT -- - # "waist tie dress", "tie-front blouse with a neck strap" -- where the - # equipment word is a feature of the clothing rather than hardware. - # Clothing wins here, or the garment can never come off; genuine - # restraints ("ankle chain", "wrist cuffs") cover no zone at all. - if not garment_zones(item): - return True - if _RESTRAINT_SPREADER.search(name): - return True - return bool(_RESTRAINT_PARTICIPLE.search(name) and _BODY_PART_TOKEN.search(name)) - - -# What a restraint DOES, once it is on. Keeping the item in the wardrobe list only -# says it exists; nothing there says the body cannot move freely, so H3 renders a -# cuffed character walking with their arms swinging. The restraint is present and -# doing nothing -- which reads as it having broken. -# -# Stated as a POSITIVE physical state, the same as the mouth state and the limb -# count. "cannot move her arms" is a negation and a weak cue; "her wrists stay -# together in front of her" describes a pose the model can actually render. -# -# Keyed by the body region the restraint binds, so two wrist restraints produce one -# clause rather than two competing ones. -_RESTRAINT_EFFECT = { - "wrists": "the wrists stay bound close together, the arms moving as one and never " - "swinging apart", - "ankles": "the ankles stay bound close together, steps short and shuffling, the legs " - "never striding apart", - "mouth": "the mouth stays covered and the jaw still", - "eyes": "the eyes stay covered, the head turning toward sound rather than sight", - "body": "the body stays held by the restraint, movement limited and tethered", -} -# Which region each restraint binds. Checked against the item's full name, so -# "ankle chain" and "leg irons" both reach `ankles`. -_RESTRAINT_REGION = ( - ("mouth", re.compile(r"\b(?:gag|gagged|muzzle|muzzled)\b", re.I)), - ("eyes", re.compile(r"\b(?:blindfold|blindfolded|hood|hooded)\b", re.I)), - ("ankles", re.compile(r"\b(?:ankle|ankles|leg|legs|hobble|feet|foot)\b", re.I)), - ("wrists", re.compile(r"\b(?:handcuff|handcuffs|cuff|cuffs|wrist|wrists|manacle|" - r"manacles|thumb|arm|arms)\b", re.I)), -) - -# HOW a wrist restraint holds changes what the body can do, and the default effect -# text only covers ONE of those ways. "the wrists stay bound close together, the -# arms moving as one" is true of wrist-to-wrist cuffs and false of everything else: -# a character chained to a headboard has arms held APART at fixed anchor points, -# hands cuffed behind the back have arms folded behind, cuffed above the head has -# arms raised. Telling the model two contradictory things about the same wrists -- -# scene says spread, guard says together -- is exactly how the cuffs end up -# rendered broken: neither sentence holds, and the hardware loses. -# -# A tether needs a FASTENING verb before it trusts a target noun ("walks to the -# table" must not read as chained to the table); resistance verbs qualify only with -# "against", which is how pushing on an anchor point reads. -_RESTRAINT_TETHER = re.compile( - r"\b(?:(?:cuff(?:ed|s)?|chain(?:ed|s)?|ti(?:ed|es)|lock(?:ed|s)?|shackle(?:d|s)?|" - r"fasten(?:ed|s)?|strap(?:ped|s)?|bolt(?:ed|s)?|secur(?:ed|es)|hitch(?:ed)?|" - r"bind(?:s|ing)?|bound)\b[^.]{0,40}?\b(?:to|around)\s+(?:the|a|an|her|his|their|its)?\s*" - r"|(?:strain\w*|pull\w*|tug\w*|yank\w*)\b[^.]{0,30}?\bagainst\s+the\s+)" - r"(bed|bedpost|headboard|wall|post|pole|frame|ring|hook|ceiling|rack|table|bench|" - r"chair|doorframe|beam|pipe|rail|floor|tree|cross|stainless steel bar)\b", re.I) -_RESTRAINT_POSE = ( - ("behind", re.compile(r"\bbehind\s+(?:her|his|their|the)?\s*back\b", re.I)), - ("overhead", - re.compile(r"\b(?:above|over)\s+(?:her|his|their|the)?\s*head\b|\boverhead\b", re.I)), - ("spread", - re.compile(r"\bspread[- ]?eagl\w*\b|\b(?:wrists|arms)\s+(?:held\s+)?spread\b", re.I)), -) - - -def _detect_restraint_usage(text): - """Read HOW a restraint is being used out of one block of prose. - - Returns {"tether": anchor noun or None, "pose": pose name or None}. A tether - outranks a pose (the current shot said the anchor explicitly); poses are read - in _RESTRAINT_POSE order so 'hands cuffed behind her back' wins over a stray - 'legs spread'. Nothing found -> both None.""" - t = text or "" - m = _RESTRAINT_TETHER.search(t) - if m: - return {"tether": m.group(1).lower(), "pose": None} - for pose, rx in _RESTRAINT_POSE: - if rx.search(t): - return {"tether": None, "pose": pose} - return {"tether": None, "pose": None} - - -def _restraint_effect_text(region, text, usage=None): - """The effect sentence for one bound region, adapted to HOW the restraint holds. - - `text` is the person's own wardrobe entries plus this shot's beat -- both are - places the user states the attachment ("wrist cuffs to the bed frame") or the - pose ("hands cuffed behind her back"). When this shot says nothing, `usage` - carries what an EARLIER shot established for this person -- restraint use - persists until the prompt changes it, exactly like the wardrobe it belongs - to. Without either, the default wording stays correct for plain - wrist-to-wrist cuffs.""" - base = _RESTRAINT_EFFECT[region] - if region != "wrists": - return base - found = _detect_restraint_usage(text) - stored = usage or {} - # What this shot SAYS replaces what an earlier shot established, wholesale -- - # mixing a live pose with a remembered tether would blend two setups into the - # kind of contradiction this clause exists to prevent. - if found["tether"]: - anchor, pose = found["tether"], None - elif found["pose"]: - anchor, pose = None, found["pose"] - else: - anchor, pose = stored.get("tether"), stored.get("pose") - if anchor: - return ("the cuffs stay locked closed around the wrists and fastened to the " - f"{anchor}, the chain between them taut, the arms held where they are " - "secured") - if pose == "behind": - return ("the wrists stay bound close together behind the back, the arms " - "held there and moving as one") - if pose == "overhead": - return ("the wrists stay bound close together above the head, the arms held " - "up and moving as one") - if pose == "spread": - return ("the wrists stay bound apart at two fixed points, the arms held wide " - "and never coming closer together") - return base - - -# The failure mode where the HARDWARE itself gives up: H3 renders an open cuff, a -# snapped link, or a strap turned to ribbon mid-struggle. Nothing in the per-region -# effects says the equipment keeps its state, so state it once, positively -- at cfg -# 1 a negation ("does not break") would only name the breaking. "Full tension" was -# tried here and read as strain: the model rendered maximum-pull struggle to match -# the words, which is the exact failure this sentence exists to stop. -_RESTRAINT_HARDWARE = (" Every restraint stays whole and closed, fastened exactly " - "as it was put on.") - - -def restraint_regions(items): - """Body regions currently bound, for everything this person is wearing. - - A restraint with no region of its own ("shackles", "fetters", "restraints") - falls back to `body`, which says movement is limited without claiming to know - which limb. Guessing a specific limb there would be worse than saying less.""" - out = [] - for it in items or []: - if not is_restraint(it): - continue - name = _item_name(it) - for region, rx in _RESTRAINT_REGION: - if rx.search(name): - if region not in out: - out.append(region) - break - else: - if "body" not in out: - out.append("body") - return out - - -# What a bared zone keeps doing while the body moves. The marker in the item list -# says the zone IS bare; this says it STAYS bare through a change of view, which is -# the moment the clothed prior gets its chance -- a turn presents a surface the model -# has no evidence for, and its default for an undescribed body is a dressed one. -# -# Names no garment. Saying which garment is off puts that garment in the prompt, and -# a mention is a presence cue -- the same reason removed items are scrubbed from the -# anchor rather than negated in it. -# IMPERSONAL -- no name, no pronoun, no subject at all. An earlier version said -# "She is uncovered there and stays that way", which is a SECOND reference to someone -# the shot has already introduced, and a second mention is what renders a second -# figure. That put it straight up against the subject-count guard. The anchor-prose -# garments are stated impersonally for the same reason: it summons nobody. -# -# The wording also has to avoid the MARKER phrases themselves ("bare below the waist", -# "bare chest", "bare breasts"). Those strings are what identifies a marker in an item -# list, and repeating one verbatim in the prose makes a test for "is the generic marker -# still being used" -- or anyone reading the prompt -- unable to tell prose from state. -_BARE_PERSIST = { - "lower": "Bared skin from the waist down stays bared as the body turns, the same " - "from the front, the side and behind.", - "upper": "Bared skin above the waist stays bared as the body turns, the same from " - "the front, the side and behind.", -} - - -def bare_persist_clause(bare_zones, active, body): - """State that an already-bared zone stays bare through a change of angle. - - Only for people actually in the shot, on the same presence gate as the restraint - and mouth states: describing an uncovered body in a shot that person is not in - would summon them into it. - - Gated upstream by whatever allowed the marker in the first place -- this reads - the markers that are already on the person, so prevent_nudity, exposed_terms and - a sheet declaration all keep exactly the authority they had.""" - zones = [] - for name, zs in (bare_zones or {}).items(): - if name and not person_in_shot(body, name, active): - continue - for z in zs or (): - if z in _BARE_PERSIST and z not in zones: - zones.append(z) - if not zones: - return "" - # One sentence per bared ZONE, not per person: two people bared the same way need - # the state said once. Saying it per person would reintroduce the extra subject - # references this is written to avoid. - return " " + " ".join(_BARE_PERSIST[z] for z in zones) - - -def _guard_fires(mode, cued): - """Shared off/auto/on gate for the per-shot guards. - - 'off' never speaks, 'on' always does, 'auto' only when the shot gave it - something to talk about -- a cue in the beat, or a list of objects it found. - Three guards had this same three-line shape written out separately.""" - if mode == "off": - return False - return mode == "on" or bool(cued) - - -def contact_clause(body, n_present, mode="auto"): - """Geometry for two bodies in contact: whose limbs, no interpenetration, fixed roles. - - Needs TWO people. One body cannot be misaligned against another, and stating a - two-body arrangement in a one-person shot would invite the second person in -- - the same presence-cue failure every other per-shot state is gated against. - - 'auto' fires on a contact cue in the beat. 'on' states it whenever two or more - people are in the shot, which is worth having if bodies drift apart or merge in - beats that do not name the contact explicitly.""" - if n_present < 2 or not _guard_fires(mode, _CONTACT_CUE.search(body or "")): - return "" - return CONTACT_STATE - - -def motion_clause(body, mode="auto"): - """State that a pose is reached by travelling to it. - - 'auto' speaks only on a beat that actually moves someone, which is where a snap - can happen -- a beat where nobody changes orientation has no path to describe and - would just be paying for a sentence. - - Impersonal, like the solidity and bare-persistence states: it names no one, so it - adds no second reference to anybody already in the shot.""" - if not _guard_fires(mode, _MOTION_CUE.search(body or "")): - return "" - return MOTION_STATE - - -def _restrained_present(active, body): - """True when anyone PRESENT in this shot is currently carrying a restraint. - - Holds the generic free-travel motion clause back from bound bodies: that - sentence tells a figure to turn through every position on the way, which a - cuffed body cannot obey -- and the model settles the contradiction by letting - the restraints fail so it can. The bound parts carry their own continuity - instead ('the arms moving as one'), which is all a restrained figure should - promise.""" - return any(restraint_regions(active.get(nm)) and person_in_shot(body, nm, active) - for nm in active) - - -def solid_things_in(text): - """Solid objects named in this text, in order of appearance. - - Deduplicated by head noun so "table" and "tables" are not said twice, but the - surface form is what comes back: "stairs" singularises to "stair", which is not - what anyone calls them, and inherently-plural nouns are common here (stairs, - steps, shelves, rocks).""" - out, seen = [], set() - for m in _SOLID_NOUNS.finditer(text or ""): - w = m.group(0).lower() - head = w[:-1] if w.endswith("s") and not w.endswith("ss") else w - if head not in seen: - seen.add(head) - out.append(w) - return out - - -def solidity_clause(body, persistent="", mode="auto"): - """Assert that the objects in this shot occupy space and stop bodies. - - 'auto' only speaks when the shot actually names something solid, so a beat with - nothing to collide with pays nothing. 'on' always states it. - - The named list matters more than the general sentence: the failure is a body - passing through one PARTICULAR thing the shot established, and naming it again - is what keeps it in the frame as an obstacle rather than as scenery.""" - if mode == "off": - return "" - # BEAT first, then the identity block. The list is trimmed to three, and the - # object that matters is the one this shot's action involves -- scanning the - # anchor first filled the quota with set dressing and cut the stairs the - # character is actually climbing. - found = solid_things_in(body) - for w in solid_things_in(persistent): - if w not in found: - found.append(w) - if not _guard_fires(mode, found): - return "" - out = SOLIDITY_STATE - if found: - # A bare list, so no verb has to agree with a noun that may be plural - # ("the stairs is solid"). Three at most -- past that it reads as an - # inventory and starts competing with the beat for attention. - out += (" Solid, and occupying real space here: " - + ", ".join("the " + n for n in found[:3]) + ".") - return out - - -def _restraint_about(sentence, name, active): - """Is this prose sentence about THIS bound person, rather than someone else? - - The beat is shared but the restraint wording is per person: 'Mara is cuffed - to the headboard. Jon watches.' must not fasten JON to anything -- and before - persistence that mistake washed out a shot later; now it would stick. A - sentence naming another tracked person is skipped unless it also names this - person or uses the pronoun their sheet declares. Unattributed sentences - ("the chain rattles") stay relevant to everyone.""" - named_here = re.search(r"\b" + re.escape(name) + r"\b", sentence, re.I) - others = [n for n in active if n and n != name - and re.search(r"\b" + re.escape(n) + r"\b", sentence, re.I)] - if not others: - return True - if named_here: - return True - pronouns = [i.lower() for i in (active.get(name) or []) - if i.lower() in ("she", "he", "they")] - return any(re.search(r"\b" + re.escape(p) + r"\b", sentence, re.I) - for p in pronouns) - - -def restraint_clause(active, body, lock_restraints=True, usage=None): - """State what each restrained person's body cannot do, for the people in shot. - - Only for someone actually referenced in the beat -- describing a bound body in - a shot that person is not in would summon them into it, which is the same - failure the mouth state and the limb count are both gated against. - - `usage` is an optional dict carried ACROSS shots by distribute_generations: - when a shot states how the restraints are used (a tether anchor or a pose) it - is remembered per person and reused on later shots that only say "she strains" - -- otherwise those shots would fall back to wording that CONTRADICTS the - established attachment, which is exactly how cuffs render broken. Entries are - pruned upstream in distribute_generations the moment the person's sheet no - longer carries a restraint (freed, uncuffed, item removed), so a re-bound - character starts fresh.""" - if not lock_restraints: - return "" - bits = [] - for name, items in (active or {}).items(): - regions = restraint_regions(items) - if not regions: - continue - if name and not person_in_shot(body, name, active): - continue - subj = _subject_term(name, active) if name else "the subject" - # `their` rather than a repeated name: naming someone twice in a shot is - # what renders them twice. The attachment/pose scan reads BOTH the person's - # own wardrobe entries and this shot's prose -- but only the prose that is - # ABOUT them (one person's tether must not fasten a second restrained - # character to the same anchor), and never quoted dialogue: speech - # describes a restraint, it does not attach one. - masked, _qspans = _mask_quotes(body or "") - sentences = [s for s in re.split(r"(?<=[.!?])\s+", masked) if s.strip()] - if name: - sentences = [s for s in sentences if _restraint_about(s, name, active)] - text = " ".join(items) + " " + " ".join(sentences) - found = _detect_restraint_usage(text) - if usage is not None and (found["tether"] or found["pose"]): - usage[name] = found - effects = "; ".join(_restraint_effect_text(r, text, - usage.get(name) if usage else None) - for r in regions) - bits.append(f"{subj} is physically restrained -- {effects}") - if not bits: - return "" - return (" " + ". ".join(b[0].upper() + b[1:] for b in bits) + "." - + _RESTRAINT_HARDWARE) - - -def auto_wardrobe_removals(active, body, lock_restraints=True): - """Infer clothing REMOVALS from a beat's own action text, so you don't have - to write a 'wardrobe:' line at all -- "she takes off her jacket" drops the - jacket by itself. - - SAFE BY DESIGN: a removal only fires on an item the character is ALREADY - wearing. Non-garment objects match nothing, so "the plane takes off down the - runway" removes nothing. The subject can be a NAME or a PRONOUN: with two - people, declare a pronoun per person ('Maya = she, ...; Jon = he, ...') and - 'she takes off her jacket' attributes to Maya. In a one-person scene any - pronoun maps to that person. If the subject is ambiguous (two same-pronoun - people, no name), the item is dropped from whoever wears it. Explicit - 'wardrobe: -=' always overrides.""" - if not body: - return active - # Quoted speech is an INSTRUCTION, not an action. 'Mom says: "take off your - # thong"' used to strip the garment in the shot where it is merely asked for, - # one shot before the character does it -- so the shot that performs the - # removal no longer knew the garment was on, and the removal never got its - # direction clause. Negation made it worse: "do not take off your jacket" - # removed the jacket. Detect removals from narration only; the beat that - # actually stages it is unquoted. - text = " " + _mask_quotes(body)[0].lower() + " " - active = {k: list(v) for k, v in active.items()} - names = [n for n in active if n] - pron_map = _pron_map(active) - single = len(names) == 1 - - remove_cue = re.compile( - # verb ... off / out of / aside / away (covers "takes off", "steps out of") - r"\b(?:takes?|took|taken|taking|pulls?|pulled|peels?|peeled|strips?|stripped|" - r"slips?|slipped|shrugs?|shrugged|tears?|tore|yanks?|yanked|casts?|kicks?|" - r"throws?|threw|tosses|tossed|hangs?|hung|drops?|dropped|sets?|set|puts?|put|" - # ...and the ones you get OUT OF rather than take off - r"steps?|stepped|stepping|wriggles?|wriggled|wiggles?|wiggled|squirms?|squirmed|" - r"struggles?|struggled|struggling|" - r"slides?|slid|sliding|climbs?|climbed|works?|worked|eases?|eased|shakes?|shook)\b" - r"[\w\s\']{0,20}?\b(?:off|out of|aside|away|down)\b" - # the garment itself is the subject: "her dress falls to the ground", - # "the jacket pools at her feet". Matched backward to the subject. - r"|\b(?:falls?|fell|falling|slides?|slid|slips?|slipped|drops?|dropped|" - r"pools?|pooled|tumbles?|tumbled|crumples?|crumpled)\s+" - r"(?:to|onto|on to|down|off|open|away|around|at)\b" - # "lets her jacket fall", "lets it drop" - r"|\blets?\b[\w\s\']{0,20}?\b(?:fall|falls|drop|drops|slide|slides|slip|slips)\b" - # standalone removal verbs - r"|\b(?:removes?|removed|removing|sheds?|shed|shedding|discards?|discarded|" - r"ditch(?:es|ed)?|doffs?|doffed|unbuttons?|unzips?|unzipped|unbuckles?|" - r"undoes|undid|undone|unlaces?|unlaced|unhooks?|unhooked|unfastens?|unfastened|" - r"unclasps?|unclasped|unsnaps?|unsnapped|unties?|untied|unwraps?|unwrapped|" - r"hangs? up|hung up)\b" - # " is off / are off" - r"|\bis off\b|\bare off\b" - # put-away phrasings: "hangs her jacket on a hook", "drapes it over a chair" - r"|\b(?:hangs?|hung|drapes?|draped|slings?|slung|drops?|dropped|tosses|tossed|" - r"throws?|threw|leaves?|left|sets?|set|lays?|laid|places?|placed)\b[\w\s\']{0,20}?" - r"\b(?:on|over|across)\s+(?:a|an|the)\b") - - subj_tokens = [re.escape(n.lower()) for n in names] + list(_PRO.keys()) - subj_re = re.compile(r"\b(" + "|".join(subj_tokens) + r")\b") if subj_tokens else None - - def nearest_subject(pos): - best, bp = None, -1 - if subj_re: - for mm in subj_re.finditer(text): - if 0 <= mm.start() < pos and mm.start() > bp: - person = _resolve_subject(mm.group(1), names, pron_map, single) - if person is not None: - bp, best = mm.start(), person - return best - - # Donning phrases must never trigger a removal ("pulls on a jacket", "puts on - # her boots", "slips into her coat") -- the wrong direction is far worse than a - # miss, since it would strip clothing the character just put ON. - don_re = re.compile(r"\b(?:pulls?|puts?|slips?|throws?|shrugs?|zips?|buttons?|laces?|" - r"pulled|put|slipped|threw|shrugged)\b\s+(?:on|into)\b") - don_spans = [(d.start(), d.end() + 45) for d in don_re.finditer(text)] - - for m in remove_cue.finditer(text): - if any(a <= m.start() <= b for a, b in don_spans): - continue - forward, backward = _removal_object_spans(text, m) - tgt = nearest_subject(m.start()) - # Try the verb's forward object first; only if that names nothing tracked do - # we look BACK to the subject, which is where "her jacket falls to the - # ground" and "her jacket slips off her shoulders" put the garment. - for window in (forward, backward): - if not window.strip(): - continue - hit = False - for name in ([tgt] if tgt else list(active.keys())): - for it in list(active.get(name, [])): - if it.strip().lower() in _PRO: - continue - # A restraint is a plot state, not a garment. Prose never takes - # one off; only an explicit 'wardrobe: Name -= handcuffs' does. - if lock_restraints and is_restraint(it): - continue - if _item_mentioned(it, window): - active[name] = [x for x in active[name] if x != it] - hit = True - if hit: - break - return active - - - - -def _scrub_removed(text, removed): - """Delete phrases for removed garments from PERSISTENT text (the anchor), so a - garment written into the anchor prose -- e.g. 'A woman in a red flight jacket' - -- can't re-apply itself on every shot after the character takes it off. Removes - the item phrase plus a leading connector ('in a', 'wearing a', 'with a') and - tidies the leftover punctuation. Case-insensitive; leaves everything else alone.""" - if not text or not removed: - return text - changed = False - for item in sorted(removed, key=len, reverse=True): - item = item.strip() - if not item: - continue - pat = (r"(?:,\s*)?\b(?:wearing|dressed in|in|with)?\s*(?:a|an|the|her|his|their)?\s*" - + re.escape(item) + r"\b") - new_text = re.sub(pat, "", text, flags=re.I) - if new_text != text: - changed = True - text = new_text - if not changed: - # Nothing was actually scrubbed from this text, so leave it EXACTLY as the - # user wrote it. The tidy-up below repairs punctuation left by a removal; - # running it unconditionally silently rewrote untouched prose (e.g. - # "hangar and airfield" -> "hangar, airfield") on every shot after any - # unrelated garment removal. - return text.strip() - text = re.sub(r"\s{2,}", " ", text) - text = re.sub(r"\s+([,.])", r"\1", text) - text = re.sub(r"(,\s*){2,}", ", ", text) - text = re.sub(r",\s*\.", ".", text) - # tidy connectors left dangling by a removed phrase: "silver hair and , in a - # hangar" / "silver hair and hangar" -> "silver hair, in a hangar" - text = re.sub(r"\s+and\s*,", ",", text) - text = re.sub(r"\s+and\s+(in|at|on|with|under|beside)\b", r", \1", text) - text = re.sub(r"\s+and\s+(?=[a-z]+\s*,)", ", ", text) - text = re.sub(r"\s+and\s*$", "", text) - text = re.sub(r"^\s*(?:and|,)\s+", "", text) # leading dangling connector - text = re.sub(r"^\s*and\b", "", text) - text = re.sub(r"\s{2,}", " ", text) - text = re.sub(r"(,\s*){2,}", ", ", text) - return text.strip(" ,") - - -def _strip_people_from_anchor(anchor_id, active): - """Remove tracked people from the ANCHOR prose. - - The anchor is stamped into every shot, so if it also introduces a character -- - by name ("Kristy stands by the plane") or by description ("a woman with silver - hair in a red jacket") -- that character is asserted TWICE per shot: once by the - anchor and once by the beat's own inline binding. Text-to-video reads two - introductions of one subject as two subjects, which is the character-duplication - users see. The anchor should carry scene and style only; who is present is - decided per beat. - - Removes (a) any tracked NAME plus its clause, and (b) a person-phrase whose - description overlaps a tracked person's items (gender word + shared descriptors). - Leaves everything else -- setting, lighting, lens, mood -- untouched.""" - if not anchor_id: - return anchor_id - txt = anchor_id - for name in [n for n in active if n]: - # drop a whole sentence that names this person, else just the name token - sentences = re.split(r'(?<=[.!?])\s+', txt) - kept = [c for c in sentences if not re.search(r"\b" + re.escape(name) + r"\b", c, re.I)] - if len(kept) != len(sentences): - txt = " ".join(kept) - else: - txt = re.sub(r"\b" + re.escape(name) + r"\b", "", txt, flags=re.I) - # description overlap: "a woman with silver hair in a red jacket" - for name, items in active.items(): - descs = [i for i in _clean_items(items, name) if len(i.split()) <= 4] - if not descs: - continue - pron = _pronoun_of(items) - nouns = {"she": r"(?:woman|women|girl|lady)", "he": r"(?:man|men|boy|guy)"}.get(pron, r"(?:person|figure)") - # match the person phrase up to a sentence end / conjunction, so trailing - # "with X in a Y" clauses go with it instead of leaving fragments behind - pat = re.compile(r"(?:,\s*)?\b(?:a|an|the)\s+(?:[\w\-]+\s+){0,3}" + nouns + - r"(?:(?!\.|\band\b).)*", re.I) - for mm_ in list(pat.finditer(txt)): - phrase = mm_.group(0) - if sum(1 for d in descs if d.lower() in phrase.lower()) >= 1: - txt = txt.replace(phrase, "") - # Generic, UNNAMED person references in the anchor ("the camera follows the - # subject", "moves toward the person", "tracks the figure") are stamped into - # every shot alongside the named cast, so the model renders an extra body that - # matches nobody -- the phantom third person. Camera-direction wording is the - # usual way these creep in. Rewrite them to refer to the framing, not a body. - txt = re.sub(r"\b(?:the|a|an)\s+(?:main\s+|central\s+)?" - r"(?:subject|person|figure|character|model|individual|protagonist)\b", - "the scene", txt, flags=re.I) - txt = re.sub(r"\b(?:the|a|an)\s+(?:subjects|people|figures|characters)\b", - "the scene", txt, flags=re.I) - txt = re.sub(r"\s{2,}", " ", txt) - txt = re.sub(r"\s+([,.])", r"\1", txt) - txt = re.sub(r"(,\s*){2,}", ", ", txt) - txt = re.sub(r"\.{2,}", ".", txt) # "Warm light.." -> "Warm light." - txt = re.sub(r"^[\s.,]+", "", txt) # leading ". " left by a removed clause - txt = re.sub(r"^\s*(?:and|,)\s+", "", txt) - txt = re.sub(r"\s+and\s*$", "", txt) - txt = re.sub(r"\s+\.", ".", txt) - return txt.strip(" ,.").strip() + ("." if txt.strip(" ,") else "") - - -# Pronoun by grammatical case: (subject, object, possessive). -_PRON_CASES = {"she": ("she", "her", "her"), - "he": ("he", "him", "his"), - "they": ("they", "them", "their")} -# A name right after one of these is an OBJECT ("walks over to Dan"), so it takes the -# object form. Anything else mid-sentence is treated as an object too, since that is -# where a bare name usually lands ("hands Dan a wrench", "asks Dan"). -_OBJECT_PREPS = ("to", "with", "at", "for", "from", "toward", "towards", "behind", - "beside", "near", "of", "on", "onto", "into", "over", "under", "past", - "by", "about", "around", "beneath", "against", "alongside", "opposite", - "between", "upon", "across", "after", "before", "beyond", "through") -# ...and after one of these (or a sentence end) it is a SUBJECT ("and Dan takes it"). -_SUBJECT_LEADS = ("and", "then", "but", "so", "as", "while", "when", "until", "because", - "if", "though", "although", "where", "who") - - -def _mask_quotes(text): - """Hide double-quoted spans behind placeholders so a rewrite cannot touch the - spoken words. Returns (masked_text, spans).""" - spans = [] - - def grab(m): - spans.append(m.group(0)) - return "\x00%d\x00" % (len(spans) - 1) - - return re.sub(r'["“][^"”]*["”]', grab, text), spans - - -def _unmask_quotes(text, spans): - return re.sub(r"\x00(\d+)\x00", lambda m: spans[int(m.group(1))], text) - - -def dedupe_person_mentions(body, active): - """Replace the SECOND and later mentions of a tracked person's name inside one - beat with the right pronoun. - - Naming one person twice in a shot is the single most reliable way to make - text-to-video render them twice -- "Kristy finds Dan ... she walks over to Dan" - puts two Dans in frame. Binding the description once (compose_persistent) fixes - the description, not the name itself, so the bare repeat still duplicates. - - Only fires where the result is unambiguous: - * the person's pronoun must be known (declared in their sheet, or a gender word - in their description) -- an undeclared person is left exactly as written; - * no OTHER person in the shot may share that pronoun, or 'he' could not be - traced back to the right one; - * words inside double quotes are never touched -- a name in a spoken line is - dialogue ("Kristy, over here"), not a second reference to stage. - The FIRST mention always survives, so the description still has a name to bind to - and the reader can still tell who the shot is about.""" - if not body: - return body - present = [n for n in active if n and active[n]] - if not present: - return body - by_pron = {} - for n in present: - p = _pronoun_of(active[n]) - if p: - by_pron.setdefault(p, []).append(n) - - masked, spans = _mask_quotes(body) - for n in present: - p = _pronoun_of(active[n]) - if not p or len(by_pron.get(p, [])) != 1: - continue # undeclared pronoun, or two people share it - subj, obj, poss = _PRON_CASES[p] - hits = list(re.finditer(r"\b" + re.escape(n) + r"(?:'s|’s)?\b", masked, re.I)) - if len(hits) < 2: - continue - for m in reversed(hits[1:]): # right to left, so earlier offsets stay valid - token = m.group(0) - raw_before = masked[:m.start()] - before = raw_before.rstrip() - prev = re.search(r"([A-Za-z']+)\s*$", before) - prev = prev.group(1).lower() if prev else "" - if token.endswith("s") and ("'" in token or "’" in token): - rep = poss - elif prev in _OBJECT_PREPS: - rep = obj - elif (not before) or before[-1] in ".!?;:" or prev in _SUBJECT_LEADS: - rep = subj - else: - rep = obj - # capitalize only at a real sentence/line start - if (not before) or before[-1] in ".!?" or raw_before.rstrip(" \t").endswith("\n"): - rep = rep.capitalize() - masked = masked[:m.start()] + rep + masked[m.end():] - return _unmask_quotes(masked, spans) - - -def compose_persistent(body, active, anchor_id, removed=None, departed=None, - count_subjects=False, speaking=False, front_load=False, - count_auto=False, silence_nonspeech=True): - """Assemble one shot's text WITHOUT duplicating subjects. - - Each present person's description is injected as a parenthetical at the FIRST - reference to them in the beat -- whether that reference is their NAME or a - resolvable PRONOUN ('she'/'he'). So 'she takes off her jacket' becomes 'she - (silver hair, grey shorts) takes off her jacket': described once, no name, no - duplicate subject. A person not referenced at all (by name or pronoun) is - omitted from that shot. The unnamed single subject is prepended as before. - - Pronoun tokens declared in a person's sheet ('Maya = she, ...') are used to - resolve 'she'/'he' but are stripped from the shown description. Keep the - anchor to scene/style with NO names.""" - count_prefix = "" # set when the count clause is front-loaded (LoRA runs) - listeners = set() # bound people keeping their mouths shut while another speaks - departed = set(departed or ()) - # A character who has LEFT the scene is never described again -- not even if a - # later pronoun could resolve to them. This is what stops an exited character - # being silently re-summoned into a later shot. - active = {k: v for k, v in active.items() if k not in departed} - # Collapse repeat NAME mentions to pronouns before anything is measured or bound: - # naming one person twice in a shot renders them twice, and the refs below must be - # computed against the text that will actually be emitted. - body = dedupe_person_mentions(body, active) - named = [n for n in active if n and active[n]] - unnamed = active.get("", []) - anchor_id = _scrub_removed(anchor_id, removed) - # Keep tracked people OUT of the always-on anchor: the beat binds them inline, - # so leaving them here too introduces each character twice per shot. - anchor_id = _strip_people_from_anchor(anchor_id, active) - # An UNNAMED sheet is emitted as a bare list in front of the beat, so it has to - # be closed off as its own sentence. Without the period it ran straight into the - # action -- "...red jacket, blue jeans, black boots Kristy walks around the - # garage" -- where "black boots Kristy" reads as one noun phrase. A NAMED sheet - # never had this problem: it binds as a parenthetical at the person's first - # mention instead of being prepended. - unnamed_txt = ", ".join(unnamed) if unnamed else "" - if unnamed_txt and unnamed_txt[-1] not in ".!?": - unnamed_txt += "." - prefix_bits = [x for x in (anchor_id, unnamed_txt) if x] - - if named: - names = list(named) - pron_map = _pron_map(active) - single = len(names) == 1 - low = body.lower() - - # first reference position for each present person (name first, else pronoun) - refs = {} - for n in names: - m = re.search(r"\b" + re.escape(n.lower()) + r"\b", low) - if m: - refs[n] = m.end() - for m in re.finditer(r"\b(she|he|they|her|him|them|his|their)\b", low): - person = _resolve_subject(m.group(1), names, pron_map, single) - if person and person not in refs: - refs[person] = m.end() - - # Nobody bound individually, but the beat addresses the cast in the plural: - # bind everyone with a roll-call in FRONT of the beat rather than rewriting - # the sentence. Prepending keeps the author's prose exactly as written -- - # 'they' can mean a subset, and expanding it in place would assert a cast - # list the author did not write. - # Who is LISTENING here? Only decidable when every attributed speaker is one - # of this shot's bound people and at least one bound person is not speaking. - # An unattributed quote ('she says' with two people on screen) names nobody, - # so nothing is constrained -- guessing would gag the wrong mouth. - listeners = set() - if speaking and silence_nonspeech and len(names) >= 2 and not unnamed: - speakers = _speakers_in(body, names) - if speakers: - bound_names = set(refs) or ( - {n for n in names} if _plural_cast_matches_present(body, len(names)) else set()) - if speakers & bound_names: - listeners = bound_names - speakers - - # The listener keeps their sheet's mouth-state items ("mouth closed", "lips - # together"): dropping them, as a speaking shot used to do for everyone, - # left the listening mouth with nothing holding it shut. - def _drop_mouth(n): - return speaking and n not in listeners - - roll_call = "" - if not refs and _plural_cast_matches_present(body, len(names)): - bits = [] - for n in names: - desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n))) - bits.append(f"{n} ({desc})" if desc else n) - roll_call = ((", ".join(bits[:-1]) + " and " + bits[-1]) - + (" are both in this shot." if len(bits) == 2 - else " are all in this shot.")) - refs = {n: 0 for n in names} # for the subject count below - - if refs: - # inject from rightmost position first so earlier indices stay valid - if not roll_call: - for n in sorted(refs, key=lambda k: refs[k], reverse=True): - desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n))) - if desc: - pos = refs[n] - body = body[:pos] + f" ({desc})" + body[pos:] - else: - prefix_bits.append(roll_call) - # An EXPLICIT SUBJECT COUNT is the strongest prompt-side defence against - # the model rendering a character twice. Duplication gets much more - # likely below the model's native resolution: fewer pixels per subject - # pushes the sample away from the training distribution and the figure - # gets tiled. Stating the count (and "no other people") gives the model - # a hard target instead of leaving the number implicit. - # - # 'auto' also fires whenever THIS shot binds two or more people, whatever - # the resolution: two figures in frame is where tiling and merging - # happen even at native size, and the clause is the cheapest thing that - # holds the count down. - if count_subjects or (count_auto and len(refs) >= 2): - n_people = len(refs) - word = {1: "one", 2: "two", 3: "three", 4: "four", - 5: "five", 6: "six"}.get(n_people, str(n_people)) - noun = "person" if n_people == 1 else "people" - clause = (f"Exactly {word} {noun} in this shot, no duplicates, " - f"no other people in frame, no extra bodies, " - f"no repeated figures, no crowd. ") - if front_load: - # A distilled LoRA settles composition in its first step or two, - # so the count must be the FIRST thing in the prompt -- ahead of - # scene and style -- not buried after the anchor. - count_prefix = clause - clause = "" - else: - count_prefix = "" - body = clause + body - # If NOBODY is referenced by name or pronoun, this is a scenery/cutaway beat - # ("the hangar doors roll open"). Emit no people at all: the old grouped - # 'Kristy: ... Jon: ...' prefix both re-introduced names (the duplication - # pattern) and forced absent characters into shots they don't belong in. - - if listeners: - # The listening side of a dialogue shot gets the same physical mouth state - # a silent shot gets -- stated per person, positively, because at cfg 1 - # nothing suppresses; it can only be told what to do instead. - quiet = [] - for n in sorted(listeners): - term = _subject_term(n, active) - quiet.append(f"{term[0].upper() + term[1:]} stays silent through the line, " - "mouth closed, lips together, jaw still.") - body = body.rstrip(". ") + "." if body.strip() else body - body = body + " " + " ".join(quiet) - - prefix = " ".join(prefix_bits) - out = (prefix + " " + body).strip() if prefix else body.strip() - return (count_prefix + out).strip() - - -def extract_wardrobe(body): - """Pull a 'wardrobe: ...' directive line out of a beat body. Returns - (clean_body, wardrobe_or_None). The directive is a whole line starting with - 'wardrobe:' (case-insensitive), placed INSIDE a beat (not as its own blank- - line-separated paragraph, which would become its own shot). It's removed - from the body so the literal 'wardrobe:' text isn't stamped as an action.""" - kept, wardrobe = [], None - for ln in body.split("\n"): - if re.match(r"\s*wardrobe\s*:", ln, re.I): - wardrobe = ln.split(":", 1)[1].strip() - else: - kept.append(ln) - return "\n".join(kept).strip(), wardrobe - - -extract_wardrobe = lru_cache(maxsize=2048)(extract_wardrobe) - - -# --- anchor hazards --------------------------------------------------------- -# The anchor is stamped into EVERY shot, so anything in it has to be true of every -# shot. Four kinds of thing are not, and each fails in its own way. -_ANCHOR_PERSON = re.compile( - r"\b(skin|pores?|complexion|freckles?|stubble|face|facial|eyes?|lips?|mouth|" - r"hairs?|figure|portrait|subject|person|people|man|woman|men|women|girl|boy|" - r"he|she|him|her|his|hers|they|them|their)\b", re.I) -_ANCHOR_APPARATUS = re.compile( - r"\b(camera|camcorder|lens|sensor|tripod|gimbal|steadicam|dolly|crane|drone|" - r"handheld|hand-held|iphone|phone|gopro|dslr|webcam|filming|filmed|crew|" - r"operator|documentary|selfie|pov|point of view|shot on|taken with)\b", re.I) -_ANCHOR_FRAMING = re.compile( - r"\b(medium shot|close-?ups?|wide shot|long shot|full shot|two shot|" - r"over the shoulder|low angle|high angle|aerial|overhead shot|establishing shot)\b", re.I) -# A reflective surface shows whoever is in frame, and H3 renders that reflection as -# a SECOND figure standing in the room -- the same duplicated-subject failure a -# sub-native render produces, but caused by the scene text itself. Because it lives -# in the anchor, the mirror is present and doubling people on EVERY shot. -_ANCHOR_MIRROR = re.compile( - r"\b(mirrors?|mirrored|reflections?|reflexions?|reflective|reflecting)\b", re.I) - - -# Scene words -> the ambience they imply. Read from the ANCHOR, because the -# soundscape is global: it is stamped on every shot, so it has to describe the -# PLACE rather than what happens in any one beat. -# -# NOT ONE ENTRY NAMES A HUMAN SOUND. No chatter, no murmur, no crowd, no -# announcements, no footsteps-of-people. This node spends most of its silence -# machinery stopping H3 inventing a voice, and an auto-generated soundscape that -# says "busy cafe" would hand it one on every shot. A bar gets glassware and room -# tone; a station gets hall reverb and machinery. -_SOUNDSCAPE_CUES = ( - # weather and time first -- they layer over whatever the place is - (r"\bthunder|thunderstorm|lightning\b", "heavy rain and distant thunder"), - (r"\brain(y|ing|fall)?\b", "steady rain"), - (r"\bsnow(y|ing)?|blizzard\b", "muffled snowfall and thin wind"), - (r"\bfog|mist(y)?\b", "still muffled air"), - (r"\b(?:wind|windy|windswept|gale)\b", "gusting wind"), - # interiors - (r"\bhangar|warehouse|factory|silo\b", "cavernous interior, long reverb, distant metal ticks"), - (r"\bworkshop|garage|shed\b", "close interior room tone, faint metal clinks"), - (r"\bkitchen\b", "quiet room tone, faint appliance hum"), - (r"\bbathroom|shower\b", "tiled room tone, faint water drip"), - (r"\bcorridor|hallway|stairwell\b", "narrow interior reverb"), - (r"\bchurch|cathedral|chapel\b", "large stone reverb"), - (r"\bcave|tunnel|mine\b", "deep echoing reverb, dripping water"), - (r"\bbar\b|\bpub\b|\bcafe|\bdiner|restaurant\b", "low room tone, faint glassware and cutlery"), - (r"\bstation|platform|terminal|airport\b", "large hall reverb, distant machinery"), - (r"\boffice\b", "quiet room tone, faint ventilation hum"), - (r"\bhospital|clinic|ward\b", "quiet corridor tone, faint equipment beeps"), - # exteriors - (r"\bbeach|shore|ocean|\bsea\b|coast\b", "waves breaking, sea wind, distant gulls"), - (r"\bharbour|harbor|dock|pier|marina\b", "water lapping, hull and rigging creak, gulls"), - (r"\bforest|woods|jungle\b", "wind in leaves, birdsong"), - (r"\briver|stream|creek|waterfall\b", "running water"), - (r"\bdesert|dunes\b", "dry wind and drifting grit"), - (r"\bmountain|cliff|ridge\b", "high open wind"), - (r"\bfield|meadow|farm|barn|pasture\b", "open wind, insects, distant birds"), - (r"\brooftop|roof\b", "open wind, distant city hum"), - (r"\balley|alleyway\b", "close urban reverb, distant traffic"), - (r"\bcity|street|urban|downtown|sidewalk|pavement\b", - "distant traffic hum, occasional horn"), - (r"\bhighway|motorway|freeway\b", "passing vehicles and tyre noise"), - (r"\bpark|garden\b", "wind in trees, distant birds"), - # things that make noise wherever they are - (r"\bcampfire|bonfire|fireplace|fire\b", "crackling fire"), - (r"\baircraft|airplane|propeller|\bplane\b", "propeller drone"), - (r"\btrain|railway|locomotive\b", "rail rumble"), - (r"\bboat|ship|yacht\b", "hull creak and water"), - (r"\bvan\b|\bcar\b|\btruck\b|engine\b", "engine idle and road noise"), - (r"\bgenerator|machinery|turbine\b", "low machine hum"), - (r"\bnight|midnight|nocturnal\b", "night air, faint insects"), -) -# Tried only when nothing above matched: a generic interior is better than silence, -# but naming it alongside a specific one ("kitchen ... and also a room") is noise. -_SOUNDSCAPE_FALLBACK = ( - (r"\b(?:bedroom|living room|apartment|house|home|room|indoors|interior)\b", - "quiet indoor room tone"), - (r"\b(?:outside|outdoors|exterior)\b", "open outdoor air, faint distant wind"), -) - -# Camera and style vocabulary, stripped before matching. "shallow depth of FIELD" -# is not a meadow, and an anchor is mostly camera language -- exactly the words a -# naive scan trips over. -_CAMERA_WORDS = re.compile( - r"\b(?:depth of field|field of view|shallow focus|deep focus|focal length|" - r"\d+\s*mm|f/\d+(?:\.\d+)?|bokeh|anamorphic|handheld|dolly|steadicam|" - r"colour grade|color grade|film grain|motion blur|golden hour)\b", re.I) - -_SOUNDSCAPE_MAX = 4 # more than this reads as a sound-effects list, not a bed - - -def derive_soundscape(anchor, beats=()): - """An ambient bed inferred from the scene, or "" when nothing matches. - - Reads the ANCHOR first, since that is the permanent scene and the soundscape is - global. Beats are read only as a fallback -- a location often appears in the - first beat rather than the anchor -- and never for one-off actions, which would - stamp a single shot's noise onto the whole chain. - - Capped, ordered, de-duplicated. Weather layers before place, because "rain" over - "a city street" is the useful order to read.""" - for source in (anchor or "", " ".join(beats or ())): - low = _CAMERA_WORDS.sub(" ", source.lower()) - seen, out = set(), [] - for rx, phrase in _SOUNDSCAPE_CUES: - if len(out) >= _SOUNDSCAPE_MAX: - break - if phrase not in seen and re.search(rx, low, re.I): - seen.add(phrase) - out.append(phrase) - if not out: - for rx, phrase in _SOUNDSCAPE_FALLBACK: - if re.search(rx, low, re.I): - out.append(phrase) - break - if out: - return ", ".join(out) # the anchor described the place; stop there - return "" - - -def anchor_warnings(anchor): - """Things in the anchor that will misfire because it repeats on every shot. - - Pure text, no model. Each of these has cost a real render: face words put a face - in an empty establishing frame, apparatus words render the equipment, framing - pins every shot to one size, and clothing here is immutable so a removal can - never stick.""" - a = (anchor or "").strip() - if not a: - return [] - out = [] - def found(rx): - return sorted({m.group(0).lower() for m in rx.finditer(a)}) - p = found(_ANCHOR_PERSON) - if p: - out.append(f"person/face words in the anchor ({', '.join(p)}) -- the anchor is stamped on " - f"EVERY shot, so these arrive in shots with nobody in them and can render a " - f"face in an empty frame; move them to character_memory, which is only emitted " - f"where that person appears") - q = found(_ANCHOR_APPARATUS) - if q: - out.append(f"camera/apparatus words in the anchor ({', '.join(q)}) -- naming the equipment " - f"can render the equipment, or someone holding it; describe the IMAGE instead " - f"('shallow depth of field' rather than '35mm lens', 'fine grain' rather than " - f"'sensor grain')") - f = found(_ANCHOR_FRAMING) - if f: - out.append(f"framing in the anchor ({', '.join(f)}) -- this pins every shot to that size; " - f"put framing in the beats so it can change shot to shot") - mr = found(_ANCHOR_MIRROR) - if mr: - out.append(f"mirror/reflection words in the anchor ({', '.join(mr)}) -- H3 renders a " - f"reflection as a SECOND figure standing in the room, so this duplicates " - f"whoever is on screen on EVERY shot; drop it from the anchor, or make each " - f"shot that shows one say what is in the mirror and why") - garments = sorted({w.lower() for w in re.findall(r"[A-Za-z][\w\-]*", a) - if garment_zones(w)}) - if garments: - out.append(f"clothing in the anchor ({', '.join(garments)}) -- the anchor is immutable, so " - f"it re-applies the garment on every shot and a removal cannot stick; put " - f"clothing in character_memory, the only channel that can change mid-chain") - return out - - -def anchor_contributes_nothing(anchor, char_memory=""): - """True when the paragraph about to be consumed as the identity anchor would add - NOTHING to any shot -- i.e. taking it as the anchor silently DELETES it. - - The anchor is stamped into every shot, so _strip_people_from_anchor removes any - sentence that names a tracked character (otherwise that character is introduced - twice per shot and the model renders them twice). A first paragraph that is - *itself* an action beat about a tracked person -- "Kristy walks around in a garage - looking for engine parts." -- is therefore stripped to nothing: the user loses that - shot AND the only scene text they wrote, with just a mild note to say so. - - Returns False whenever the paragraph carries something real: a 'wardrobe:' line (it - seeds the wardrobe channel), or prose that survives the strip. So a normal - identity/scene anchor is never touched, and with no character_memory nothing is - tracked, nothing is stripped, and this cannot fire.""" - anchor_id, anchor_wardrobe = extract_wardrobe((anchor or "").strip()) - if anchor_wardrobe: # seeds the wardrobe channel -> it matters - return False - if not anchor_id.strip(): - return False - active = parse_wardrobe((char_memory or "").strip()) - return not _strip_people_from_anchor(anchor_id, active).strip(" .,") - - -# A sentence that STAGES something: a name or pronoun subject followed by a verb -# ("Kristy walks", "She finds", "Dan answers"). An anchor is scene and style -- noun -# phrases and lists ("An open 4 bay car garage.", "natural lighting, flat lighting") -- -# and does not match. -_ACTION_SENT = re.compile(r"^\s*(?:He|She|They|[A-Z][a-z]+)" - r"(?:\s+and\s+(?:[A-Z][a-z]+|he|she|they))?" - r"\s+[a-z]+(?:s|ed|ing)\b") - - -def anchor_is_action_beat(anchor, later_paras=()): - """True when the paragraph about to be consumed as the anchor is plainly a BEAT. - - anchor_contributes_nothing() only catches this when the character is tracked in - character_memory -- with no sheet, nothing is tracked, nothing is stripped, and an - action paragraph sails through to become the anchor. That is the common case: a - prompt written as three beats, no character sheet, renders as two shots with the - first beat demoted to a header stamped on the other two. - - Fires only when EVERY sentence stages an action AND the subject recurs later, so a - mixed paragraph ("Kristy stands by the plane. A cinematic hangar, warm light.") - keeps its scene text and stays an anchor, and a style list never matches at all. A - pronoun subject is accepted on its own -- 'She walks in.' cannot be scene text.""" - body, wardrobe = extract_wardrobe((anchor or "").strip()) - if wardrobe: # seeds the wardrobe channel -> it matters - return False - body = body.strip() - if not body: - return False - sents = [s for s in re.split(r"(?<=[.!?])\s+", body) if s.strip()] - if not sents or not all(_ACTION_SENT.match(s) or has_speech(s) for s in sents): - return False - m = re.match(r"\s*([A-Za-z]+)", body) - if not m: - return False - subj = m.group(1).lower() - if subj in ("he", "she", "they"): - return True - # A NAME is only a beat subject if the prompt goes on using it. This is what keeps - # a one-word style lead ("Cinematic lighting, warm tones.") from reading as an - # action: 'cinematic' never comes back as a subject in the beats. - later = " ".join(later_paras or ()).lower() - return bool(re.search(r"\b" + re.escape(subj) + r"\b", later)) - - -# --- props: objects that must survive the shot boundary --------------------- -# Nouns that are never a prop worth carrying: parts of the frame, parts of a body, -# and abstractions. Binding these would produce "the same ground from the previous -# shot", which is noise at best. -_NOT_A_PROP = { - "ground", "air", "sky", "floor", "ceiling", "background", "foreground", "distance", - "camera", "frame", "shot", "scene", "screen", "view", "angle", "light", "lighting", - "shadow", "sun", "moment", "time", "day", "night", "morning", "evening", "way", - # anatomy -- a body part is never a prop to carry between shots. Without these, - # "reveals a nipple" was tracked as an object and a later mention got the full - # continuity treatment: "exactly one nipple in this shot, the same nipple from - # the previous shot, in the same place". - "head", "face", "eyes", "eye", "hand", "hands", "arm", "arms", "leg", "legs", - "body", "hair", "mouth", "jaw", "lips", "shoulder", "shoulders", "chest", "torso", - "waist", "hip", "hips", "knee", "knees", "foot", "feet", "ankle", "wrist", "neck", - "throat", "stomach", "belly", "navel", "skin", "thigh", "thighs", "breast", - "breasts", "nipple", "nipples", "genitals", "genitalia", "penis", "vagina", - "vulva", "groin", "crotch", "buttock", "buttocks", "backside", "bottom", - # frame-relative words and bare determiners/pronouns, which are never objects - "back", "front", "side", "top", "edge", "middle", "end", "left", "right", - "centre", "center", "the", "a", "an", "it", "this", "that", "these", "those", - "they", "them", "her", "his", "him", "its", "one", "other", "another", "something", -} -# Where a prop's NAME stops and its circumstance begins: "a van PARKED in the bay", -# "a barn WITH a red roof". Same idea as _ITEM_DETAIL for garments. -_PROP_TAIL = re.compile( - r"\b(?:parked|standing|sitting|leaning|lying|resting|waiting|stopped|covered|" - r"filled|loaded|painted|marked|in|on|at|by|near|beside|behind|under|over|with|" - r"and|that|which|down|across|toward|towards|from|" - # a VERB ends the noun phrase too: "a bare breast CATCHES the light" was being - # read four words deep and keyed on the trailing "the". - r"is|are|was|were|has|have|had|catches|catch|presses|press|hangs|hang|rests|" - r"sits|sit|stands|stand|lies|lie|falls|fall|moves|move|shows|show|reveals|reveal|" - r"appears|appear|becomes|become|looks|look|seems|seem|glints|glows|shines)\b") - - -def introduced_props(text): - """{noun: phrase} for objects this text introduces INDEFINITELY -- "a white van" - -> {"van": "white van"}. These are the things a later shot can refer back to.""" - out = {} - body = _PICTURE_TAG.sub(" ", text or "") - # Scan from each article WITHOUT consuming what follows it: a greedy match over - # "a van and a truck" would swallow the truck's own article and lose it. - # Case-insensitive, because beats routinely OPEN with the object -- "A white van - # sits outside." -- and a case-sensitive scan silently tracked nothing there, - # while bind_props/dedupe_prop_mentions/repeated_props below are all re.I. - for m in re.finditer(r"\b(?:a|an)\s+", body, re.I): - ahead = body[m.end():] - ahead = re.split(r"[,.;:!?]", ahead)[0] - words_ahead = re.findall(r"[A-Za-z][\w\-]*", ahead)[:4] - phrase = " ".join(words_ahead) - cut = _PROP_TAIL.search(phrase.lower()) - if cut and cut.start() > 0: - phrase = phrase[:cut.start()].strip() - elif cut: - continue # starts with a tail word -- not a name - words = phrase.split() - if not words: - continue - noun = words[-1].lower() - if noun in _NOT_A_PROP or len(noun) < 3: - continue - out.setdefault(noun, phrase.strip()) - return out - - -def bind_props(body, props): - """Rewrite the FIRST definite reference to each carried prop so it names the - object instead of assuming one. Returns (body, [nouns bound]). - - "the van" in a later shot has no antecedent -- each shot is its own generation, - and nothing in that prompt describes a van. The model invents one, which is how - a second van appears in the frame while the first is still there. Naming it and - asserting it is the SAME one is what binds the two shots together.""" - if not props or not body: - return body, [] - masked, spans = _mask_quotes(body) - bound = [] - for noun, phrase in props.items(): - pat = re.compile(r"\bthe\s+(" + re.escape(noun) + r")\b", re.I) - m = pat.search(masked) - if not m: - continue - masked = masked[:m.start()] + f"the same {phrase}" + masked[m.end():] - bound.append(noun) - return _unmask_quotes(masked, spans), bound - - -def dedupe_prop_mentions(body, nouns): - """Collapse repeat mentions of the SAME object within one beat. - - "drives a van ... gets out of the van ... walks to the back of the van" is three - vans in one prompt, and repetition is how a video model ends up rendering three. - The node already does exactly this for people -- naming someone twice in a beat - is the most reliable way to get two of them -- and an object is no different. - - Only fires when a single carried object is in play, so "it" cannot be ambiguous, - and never inside quoted speech.""" - if not body or len(nouns) != 1: - return body, 0 - noun = nouns[0] - masked, spans = _mask_quotes(body) - hits = list(re.finditer(r"\b(?:the|that|this)\s+" + re.escape(noun) + r"\b", masked, re.I)) - if len(hits) < 2: - return body, 0 - # keep the first definite mention; the rest become pronouns - for m in reversed(hits[1:]): - masked = masked[:m.start()] + "it" + masked[m.end():] - return _unmask_quotes(masked, spans), len(hits) - 1 - - -def repeated_props(body, own): - """Objects a beat introduces and then refers back to definitely, in the SAME - beat -- the case a cross-shot carry never sees.""" - out = [] - for noun in own: - if re.search(r"\b(?:the|that|this)\s+" + re.escape(noun) + r"\b", body or "", re.I): - out.append(noun) - return out - - -def prop_count_clause(nouns): - """Positive count for objects, matching the subject-count guard's shape.""" - if not nouns: - return "" - bits = [f"exactly one {n} in this shot" for n in nouns] - s = ", ".join(bits) - return " " + s[0].upper() + s[1:] + "." - - -def prop_continuity_clause(bound, props): - """One short sentence pinning a carried prop to the previous shot's object. - - Re-describing it is not enough on its own: "a white van" in shot 1 and "a white - van" in shot 2 are two white vans. The clause states identity with the previous - shot, and states the COUNT positively. - - It deliberately does NOT say "no second van". Naming the unwanted thing is how - "she is no longer wearing the red jacket" put the jacket back on: to a video - model a mention is a presence cue and a negation is weak, so "no second van" - puts a second van in the text. The subject-count guard already had this right - for people -- it leads with "Exactly one person" -- and props follow the same - shape.""" - if not bound: - return "" - bits = [] - for noun in bound: - phrase = props.get(noun, noun) - bits.append(f"exactly one {noun} in this shot, the same {phrase} from the previous shot, " - f"in the same place") - s = "; ".join(bits) - return " " + s[0].upper() + s[1:] + "." - - -# Cues that the quoted words are PRINTED in the scene rather than spoken aloud. -# Kept to things that are unambiguously written surfaces or reading/marking verbs. -_WRITTEN_CUE = re.compile( - r"\b(?:reads?|reading|marked|labell?ed|titled|captioned|headlined|written|" - r"printed|engraved|stamped|embroidered|scrawled|painted|spells?|spelled|" - r"signs?|posters?|banner|placard|plaque|sticker|label|headline|" - r"caption|graffiti|screen|display|monitor|billboard|tattoo|note|letter|" - r"envelope|book|page|menu|ticket|receipt|badge|nameplate)\b", re.I) - -# ...and the verbs that mean someone said it out loud. -_SPOKEN_CUE = re.compile( - r"\b(?:says?|said|saying|asks?|asked|asking|replies|replied|answers?|answered|" - r"shouts?|shouted|yells?|yelled|calls?|called|whispers?|whispered|murmurs?|" - r"murmured|mutters?|muttered|adds?|added|tells?|told|cries|cried|barks?|" - r"barked|snaps?|snapped|breathes?|offers?|insists?|repeats?|begins?|continues?|" - r"declares?|announces?|responds?|responded|urges?|warns?|pleads?|laughs?)\b", - re.I) - - -def has_speech(body): - """True only if a beat contains ACTUAL scripted speech -- double-quoted words - or an explicit ... tag. Bare speech VERBS ('calls out', 'tells', 'says' - with no quoted line) deliberately do NOT count: unscripted speech is exactly - what H3 fills with gibberish, so those beats get silenced too. If you want - someone to speak, quote the line: She says, "Ready for departure." - Apostrophes/single quotes never count (they'd false-fire on "she's"). - - WRITTEN text in quotes is not speech. A sign, a label, a headline -- 'reads the - sign marked "EXIT"' -- used to make the whole shot count as dialogue, so it got - neither the lips-closed clause nor the no-voice soundscape and the characters - stood there opening their mouths. Nothing in the beat was ever spoken.""" - if not body: - return False - if re.search(r".*?", body, re.S): - return True - for m in re.finditer(r'["\u201c\u201d].+?["\u201c\u201d]', body, re.S): - # Look at what introduces this quote, and let the NEAREST cue decide. - # "Mara reads the sign, then says 'we go left'" is speech: 'says' sits - # closer to the quote than 'reads' does. Comparing presence rather than - # position got that backwards. - lead = body[max(0, m.start() - 60):m.start()].lower() - written = [x.end() for x in _WRITTEN_CUE.finditer(lead)] - spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)] - if written and (not spoken or written[-1] > spoken[-1]): - continue # printed in the scene, nobody said it - return True - return False - - -has_speech = lru_cache(maxsize=2048)(has_speech) - - -def _spoken_quotes(body): - """Each double-quoted span that IS speech, skipping printed text -- the same - nearest-cue rule has_speech uses, but returning the spans so each one can be - attributed to whoever introduced it.""" - body = body or "" - out = [] - for m in re.finditer(r'["\u201c\u201d](.+?)["\u201c\u201d]', body, re.S): - lead = body[max(0, m.start() - 60):m.start()].lower() - written = [x.end() for x in _WRITTEN_CUE.finditer(lead)] - spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)] - if written and (not spoken or written[-1] > spoken[-1]): - continue - out.append(m) - return out - - -def _speakers_in(body, names): - """Which tracked names are ATTRIBUTED to a spoken line in this beat. - - Attribution needs the name ADJACENT to a speech verb -- 'Jon says:' before - the quote, or 'said Jon' just after the close. Presence anywhere in a window - is not enough: in '"Open it." Mara steps back.' Mara sits right after the - quote and would be credited with saying it, and in 'Mara steps back. Jon - says: "..."' she sits inside any naive look-back window too -- so the lead - is cut at the last sentence boundary and the name must sit within a few - words of the verb on either side.""" - found = set() - text = body or "" - for m in _spoken_quotes(text): - lead = re.split(r"[.!?\u2026\n;]", - text[max(0, m.start() - 60):m.start()])[-1].lower() - tail = re.split(r"[.!?\u2026\n;]", text[m.end():m.end() + 45])[0].lower() - for frag in (lead, tail): - cues = list(_SPOKEN_CUE.finditer(frag)) - if not cues: - continue - for n in names: - if not n: - continue - esc = re.escape(n.lower()) - for nm in re.finditer(rf"\b{esc}\b", frag): - for c in cues: - if nm.end() <= c.start() and c.start() - nm.end() <= 16: - found.add(n) # 'Jon says: ' - elif nm.start() >= c.end() and nm.start() - c.end() <= 4: - found.add(n) # 'said Jon' - return found - - -def _emphasis_quotes(body): - """Quoted spans that LOOK like emphasis rather than dialogue: a single word, - no terminal punctuation inside the quotes, and no speech verb introducing - them. She gave him a "look" is prose; He whispers "now" is a real line -- - the spoken cue keeps genuine single-word dialogue out of this list.""" - out = [] - for m in _spoken_quotes(body): - inner = m.group(1).strip() - words = inner.split() - if len(words) == 1 and not re.search(r"[.!?\u2026]\s*$", inner): - lead = (body or "")[max(0, m.start() - 60):m.start()].lower() - if not _SPOKEN_CUE.search(lead): - out.append(inner) - return out - - -# Silence is stated as a described PHYSICAL STATE, which H3 follows far better than -# an appended negation -- but NOT in the leading position it used to occupy. -# -# Opening every silent shot with "mouth closed, lips together, jaw still" put face -# anatomy in the first tokens the model reads, and a distilled LoRA fixes -# composition in its first step or two. The result was a face rendered at the start -# of shots -- including scenery shots with nobody in them at all, which is where it -# was unmistakable. The mouth state now follows the action instead of preceding it, -# and it is skipped entirely on a shot with no people, where "everyone is silent -# with their mouth closed" describes nobody and only invites a face. -# -# The audio half of the babble fix does not depend on this: the no-voice soundscape -# line and mute_nonspeech_audio both still apply. -# Extra limbs, duplicated hands, a third arm. There is exactly one lever for this: -# H3 is CFG-FREE at cfg 1, and comfy/samplers.py:610 sets uncond_ = None at that -# scale, so the negative prompt is NEVER EVALUATED. "extra limbs, deformed hands" -# in a negative does nothing at all on this model. -# -# So it has to be said POSITIVELY, in the same shape as the subject count clause -# that already stops duplicate people. Stating a number gives the model a target; -# negating one just puts the word in the prompt, and on this model a mention is a -# presence cue -- which is why this says what the body HAS and never what it lacks. -# -# Placed per-shot, never in the anchor. Anchor body words are what burned a face -# into the opening frames of every shot, found by bisection, and limb words there -# would carry the same risk on every shot including scenery. -# How many frames of grace a mouth gets to close before its frame is handed on. -# ~125ms at 24fps -- the tail of a syllable. Applied ONLY at a dialogue -> silence -# boundary, where the next shot's keyframe would otherwise be an open mouth -# mid-word, and where no amount of lips-closed text can outvote it. -MOUTH_SETTLE_FRAMES = 3 - -ANATOMY_STATE = (" Each person has one head, two arms, two hands with five fingers on each hand, " - "and two legs with two feet. Each arm joins the body at one shoulder and runs " - "shoulder to elbow to wrist to hand; each leg joins at one hip and runs hip to " - "knee to ankle to foot. The parts stack in order: head on the neck, neck on the " - "shoulders, arms hanging along the sides of the torso, legs under the hips. Every " - "limb moves only with the person it belongs to. Between the legs there is one groin.") - -# Two bodies in physical contact. Position-AGNOSTIC on purpose: a dictionary of named -# positions would be endless, and the model already knows more names than any list -# would hold. What it gets wrong is not the name, it is the geometry -- so what gets -# stated is the geometry, and these invariants hold for every arrangement: -# -# ownership limbs belong to the person they are attached to. Two overlapping -# bodies is exactly when an arm gets reassigned to the wrong torso, -# or grown a second time. -# separation they meet AT the skin. Bodies passing into each other is the -# solidity failure again, between two bodies rather than a body and -# a table, and the same positive phrasing is the only lever. -# stable roles whoever is above stays above. Positions morph mid-shot because -# nothing says the arrangement is fixed. -# support weight rests somewhere real, which is what stops a body floating in -# a pose that nothing is holding up. -_CONTACT_CUE = re.compile( - r"\b(?:straddl\w+|astride|mount\w+|on top of|underneath|beneath|" - r"embrac\w+|entwin\w+|intertwin\w+|wrapped around|arms around|legs around|" - r"holds?|holding|grips?|gripping|clutch\w+|press\w+ against|pinned|" - r"lies? on|lying on|lies? under|lying under|lies? beside|lying beside|" - r"kneels? (?:behind|before|between|in front of)|sits? on|sitting on|" - r"behind her|behind him|behind them|face to face|facing each other|" - r"against (?:her|him|them)|body to body|skin to skin|" - r"in (?:her|his|their) lap|carries|carrying|lifts?|lifting|" - r"leans? (?:on|against|over)|bent over|" - r"sex|intercourse|making love|coupling|position)\b", re.I) - -CONTACT_STATE = ( - " Two bodies in contact form ONE fixed arrangement. Each person keeps their own head, " - "two arms and two legs, each limb joined to the body it belongs to and moving with that " - "person. The bodies meet at the surface of the skin and rest against each other there, " - "each keeping its own solid volume. Whoever is above stays above, whoever is below stays " - "below, and whoever is behind stays behind, the same arrangement held for the whole shot " - "and read the same from every angle the camera takes. The weight rests on whatever is " - "supporting it, and both bodies stay in proportion to each other.") - -# Beats where a body changes orientation or position -- the moments a pose can be -# reached without the frames in between. A head that arrives at a new angle without -# passing through the intermediate ones is the "neck snap": not a wrong pose, a -# missing path between two right ones. -_MOTION_CUE = re.compile( - r"\b(?:turn|turns|turning|turned|spin|spins|spinning|pivot|pivots|" - r"look|looks|looking|looked|glance|glances|glancing|face|faces|facing|" - r"nod|nods|nodding|shake|shakes|shaking|tilt|tilts|tilting|" - r"lean|leans|leaning|bend|bends|bending|swing|swings|swinging|" - r"walk|walks|walking|run|runs|running|step|steps|stepping|" - r"rise|rises|rising|stand|stands|standing|sit|sits|sitting|" - r"reach|reaches|reaching|raise|raises|raising|lower|lowers|" - r"kneel|kneels|climb|climbs|climbing|follow|follows|following|" - r"approach|approaches|enter|enters|exit|exits|leave|leaves|" - # High-jerk motion -- struggling, pulling, twisting. These are exactly the - # beats where a limb arrives without its path or spasm-renders, and 'auto' - # used to stay silent through all of them because a struggle is not an - # orientation change. A restrained character's beats are almost entirely - # made of these, which is where cuffs visibly tear. - r"struggl\w*|pull\w*|yank\w*|jerk\w*|tug\w*|twist\w*|writh\w*|thrash\w*|" - r"flail\w*|squirm\w*|strain\w*|crawl\w*|danc\w*|push\w*|stagger\w*|" - r"stumbl\w*|sway\w*|trembl\w*|kick\w*|lung(e|es|ing))\b", re.I) - -# Positive throughout, for the same reason as the solidity state: the negative is -# never evaluated at cfg 1, and "the head does not snap round" names a head snapping -# round. What is missing in a snap is the PATH, so the path is what gets stated. -MOTION_STATE = (" Movement is continuous and carries its own weight: the head and body turn " - "through every position on the way, at one steady speed, the neck following " - "the shoulders and the shoulders following the hips, so each pose is reached " - "by travelling to it.") - - -# Things a body has to stop at. Deliberately excludes anything genuinely passable -- -# a curtain, a beaded screen, smoke -- because asserting those are solid would be -# wrong, and excludes vague ones ("edge", "side") that would fire on prose. -_SOLID_NOUNS = re.compile( - r"\b(?:wall|walls|door|doors|doorway|gate|fence|railing|rail|banister|" - r"table|tables|desk|desks|counter|countertop|bench|workbench|" - r"chair|chairs|stool|couch|sofa|armchair|bed|beds|bunk|" - # No "chest" or "trunk": both are furniture AND body parts, and in a node that - # tracks bared zones the body reading is the likelier one -- "the chest is solid - # and occupies real space" was calling a person's chest a piece of furniture. - # "wardrobe" stays: the garment sense is a collection, never a thing in frame. - r"crate|crates|box|boxes|barrel|cabinet|dresser|wardrobe|" - r"shelf|shelves|bookcase|pillar|column|post|beam|" - r"stair|stairs|staircase|step|steps|ladder|" - r"window|windowpane|pane|windshield|" - r"van|car|truck|bike|motorcycle|trailer|" - r"floor|ground|ceiling|" - r"rock|rocks|boulder|tree|trees|" - r"barrier|partition|roller door|shutter)\b", re.I) - -# Positive throughout. "Does not walk through the wall" names walking through a wall, -# and a mention is a presence cue -- the same reason a removed garment came back when -# the prompt said it was gone. Say what the bodies DO instead. -SOLIDITY_STATE = (" Solid things stay solid: a body stops where it meets a surface, feet rest on " - "the floor, hands press against what they touch, and anyone crossing the space " - "walks around the furniture rather than across it.") - -LIPS_CLOSED_STATE = (" Everyone in this shot is silent with their mouth closed and lips together, " - "jaw still, not talking.") -LIPS_CLOSED_TAIL = " No speech, no dialogue, no lip movement, no mouth movement." - -# The lips-closed clause constrains the PICTURE only. H3 generates audio from its -# own fields, and an ABSENT `overall_soundscape:` leaves that branch unconditioned -# -- which is exactly when it invents speech-like babble under a silent shot. So a -# silenced shot always gets a soundscape line, and it says no voices outright. -NO_VOICE_SOUNDSCAPE = ("ambient background sound and room tone only, no voices, no speech, " - "no talking, no whispering, no singing, no vocal sounds") -NO_VOICE_CLAUSE = ", no voices, no speech, no talking, no vocal sounds" -NO_VOICE_SPEECH_SOUNDSCAPE = ("ambient background sound and room tone only, no speech, no dialogue, " - "no talking, no singing, no whispering, no spoken words") -NO_VOICE_SPEECH_CLAUSE = ", no speech, no dialogue, no talking, no singing, no whispering, no spoken words" - - -# A beat that refers to the cast only in the PLURAL ("they face each other") used -# to bind nobody: _resolve_subject() maps a pronoun to ONE person, and 'they' with -# two people resolves to neither. The shot then silently described no one -- losing -# both characters' descriptions and, after a removal, their exposure markers, so a -# stripped character quietly went back to being unmarked. -# -# Bare 'them'/'their' are deliberately NOT here. They refer to objects at least as -# often as to people -- "she steps out of them" is a garment, "light floods through -# them" is a pair of doors -- and this fires only when nobody was bound by name or -# singular pronoun, which is exactly the scenery-beat case that must stay empty. -_PLURAL_CAST = re.compile( - r"\b(?:they|themselves|both|each other|one another|" - r"the two of them|the two characters|the three characters|" - r"the four characters|all of them|all three|all four)\b", re.I) - -_PLURAL_COUNT_PATTERNS = ( - (re.compile(r"\b(?:both|each other|the two of them|the two characters)\b", re.I), 2), - (re.compile(r"\b(?:the three characters|all three)\b", re.I), 3), - (re.compile(r"\b(?:the four characters|all four)\b", re.I), 4), - (re.compile(r"\ball of them\b", re.I), "all"), - # Bare "they"/"themselves"/"one another" is only safe when exactly two tracked - # people are active; with three or more it is ambiguous and should not summon - # the whole cast into the shot. - (re.compile(r"\b(?:they|themselves|one another)\b", re.I), "ambiguous"), -) - - -def _plural_cast_matches_present(body, present_count): - """Does this beat unambiguously refer to the whole currently-active cast? - - Plural wording used to pull EVERY tracked character from character_memory into a - shot whenever the beat said "they" or "both of them". That is only safe when - the count implied by the words matches the active cast exactly. Otherwise the - plural is ambiguous and must not be expanded into a full roll-call.""" - text = body or "" - present_count = max(0, int(present_count or 0)) - if present_count < 2: - return False - for rx, target in _PLURAL_COUNT_PATTERNS: - if not rx.search(text): - continue - if target == "all": - return True - if target == "ambiguous": - return present_count == 2 - return present_count == target - return False - - -def person_referenced(body, name, active): - """Is this person actually in the beat -- by name, or by a pronoun that resolves - to them? Used to keep a wardrobe statement out of a shot they aren't in: saying - "she is no longer wearing the jacket" in a shot about someone else SUMMONS her - into it, which is the duplication failure the whole builder exists to avoid.""" - low = (body or "").lower() - if name and re.search(r"\b" + re.escape(name.lower()) + r"\b", low): - return True - names = [n for n in active if n] - pron_map = _pron_map(active) - single = len(names) == 1 - for m in re.finditer(r"\b(she|he|they|her|him|them|his|their)\b", low): - if _resolve_subject(m.group(1), names, pron_map, single) == name: - return True - return False - - -def person_in_shot(body, name, active, departed=()): - """Is this person IN this shot -- by name, by a resolvable pronoun, or as part - of a cast addressed in the plural? - - The single presence test. person_referenced() alone is not it: it resolves a - pronoun to ONE person, so 'they' and 'both of them' answer False for everybody, - and any clause gated on it silently skips a beat that binds the whole cast. - - That exact bug has now been written twice -- once in the mouth-state gate - (a plural beat got no lips-closed clause, so those shots babbled) and again in - the restraint clause (a plural beat dropped the physical constraint, so the - restraints appeared to break). Both are gated on this function now, so a third - caller cannot rediscover it.""" - if person_referenced(body, name, active): - return True - present = [n for n in (active or {}) if n and n not in (departed or ())] - return _plural_cast_matches_present(body, len(present)) - - -def _subject_term(name, active): - """How to refer to a person in a generated clause: their declared PRONOUN when - it identifies them uniquely, otherwise their name. Pronoun-first is the rule the - whole builder follows -- a bare name is a fresh introduction, and introducing - someone twice in a shot is what makes the model render them twice.""" - pron = _pronoun_of(active.get(name, [])) - if pron: - holders = [n for n in active if n and _pronoun_of(active[n]) == pron] - if len(holders) == 1: - return pron - return name - - -# Which part of the body a garment covers. Only two zones matter here, and the -# question each answers is strictly "is this part of the body still COVERED?" -- a -# removal that empties a zone is the one that renders as nudity. -# -# That question, not "is this clothing?", decides what belongs in these sets. A -# garter belt, stockings, hold-ups, socks, gloves and a scarf are all clothing and -# all deliberately absent: they leave the zone bare, so counting them as cover -# would SUPPRESS the exposure warning exactly when it is needed. Anything not -# listed maps to no zone, which is the safe default -- it can never mask a warning, -# it can only fail to volunteer an under-layer. -_ZONE_LOWER = { - # trousers and their families - "pants", "trousers", "jeans", "denims", "slacks", "chinos", "khakis", "cargos", - "cords", "corduroys", "joggers", "sweatpants", "sweats", "trackpants", - "breeches", "jodhpurs", "capris", "culottes", "bloomers", "harems", - # skirts - "skirt", "miniskirt", "midiskirt", "maxiskirt", "kilt", "sarong", "lungi", "dhoti", - # shorts - "shorts", "boardshorts", "trunks", "speedos", "hotpants", - # legwear that DOES cover the pelvis - "leggings", "jeggings", "treggings", "tights", "pantyhose", "pantihose", - # underwear and lingerie bottoms - "briefs", "boxers", "boxershorts", "panties", "knickers", "underpants", - "underwear", "undies", "drawers", "thong", "g-string", "gstring", "tanga", - "boyshorts", "boyshort", "hipsters", "jockstrap", "loincloth", "bottoms", - # nappies - "diaper", "diapers", "nappy", "nappies", "pull-ups", "pullups", "pull-up", -} -_ZONE_UPPER = { - # shirts and tops - "shirt", "t-shirt", "tshirt", "tee", "top", "crop-top", "croptop", "tanktop", - "tank", "blouse", "jersey", "polo", "henley", "turtleneck", "flannel", "smock", - "tunic", "kurta", "halter", "bandeau", "tube-top", - # knitwear and outerwear - "sweater", "jumper", "hoodie", "sweatshirt", "pullover", "cardigan", "jacket", - "coat", "blazer", "vest", "waistcoat", "gilet", "anorak", "parka", "windbreaker", - "bomber", "peacoat", "trenchcoat", "raincoat", "poncho", "shawl", "cape", "cloak", - "thermal", "thermals", - # lingerie and underlayers for the torso - "bra", "brassiere", "bralette", "bustier", "corset", "basque", "camisole", "cami", - "singlet", "undershirt", "brasiere", -} -_ZONE_BOTH = { - # one-piece garments covering torso AND pelvis - "dress", "gown", "frock", "pinafore", "jumpsuit", "romper", "playsuit", "catsuit", - "unitard", "leotard", "bodysuit", "bodystocking", "onesie", "overalls", - "dungarees", "coveralls", "boilersuit", "snowsuit", "wetsuit", "drysuit", - "robe", "bathrobe", "housecoat", "dressinggown", "kimono", "kaftan", "caftan", - "abaya", "sari", "saree", "toga", - # sleepwear and lingerie one-pieces - "nightgown", "nightie", "nightdress", "negligee", "babydoll", "teddy", "slip", - "chemise", "pyjamas", "pajamas", "pjs", - # swimwear counted as a set - "swimsuit", "swimming-costume", "maillot", "bikini", "tankini", "monokini", - # a suit is jacket + trousers - "suit", "tracksuit", -} - - -def garment_zones(item): - """The body zones a garment covers: {'lower'}, {'upper'}, both, or empty. - - Empty means it is not body covering at all (hat, boots, a scar, hair colour), so - removing it can never expose anything.""" - head = _item_head(item) - for form in (head, head.rstrip("s"), head + "s"): - if form in _ZONE_BOTH: - return {"upper", "lower"} - if form in _ZONE_LOWER: - return {"lower"} - if form in _ZONE_UPPER: - return {"upper"} - return set() - - -# Once a zone has been stripped, its state has to be STATED in every later shot. -# Deleting the garment is only a silence, and a video model's default prior is a -# clothed person, so silence gets them dressed again a shot or two later -- the same -# reason "no longer wearing the red jacket" was not enough on its own. These read as -# a physical description, not as a negation, and they live in the wardrobe channel -# so they persist and clear exactly like a garment. -_BARE_MARK = {"lower": "bare below the waist", "upper": "bare chest"} -# The upper-zone default has to follow the person. "bare chest" on a woman is both -# odd phrasing and a weak cue -- it describes a male torso, and H3 renders roughly -# what the words describe. The lower default stays neutral: it is a position on the -# body, not an anatomy, and naming anatomy there is exactly what exposed_terms is -# for. A person with no declared pronoun keeps the neutral wording. -_BARE_MARK_BY_PRON = { - "she": {"lower": "bare below the waist", "upper": "bare breasts"}, - "he": {"lower": "bare below the waist", "upper": "bare chest"}, -} - - -# A character can START bare rather than becoming bare. Until this existed the -# exposure marker only fired on a REMOVAL, so someone naked from shot 1 was never -# marked and exposed_terms never applied to them. -# -# This has to be DECLARED, never inferred. Absence of clothing in a sheet means the -# author did not enumerate it -- "Jon = he, 35, bald" is an ordinary -# under-specified sheet, not a naked man -- and inferring nudity from a short sheet -# would put it in scenes nobody asked for. Only these explicit tokens count. -_DECLARED_BARE = { - "nude": {"lower", "upper"}, "naked": {"lower", "upper"}, - "fully nude": {"lower", "upper"}, "fully naked": {"lower", "upper"}, - "completely nude": {"lower", "upper"}, "completely naked": {"lower", "upper"}, - "undressed": {"lower", "upper"}, "unclothed": {"lower", "upper"}, - "bottomless": {"lower"}, "topless": {"upper"}, - "waist down nude": {"lower"}, "nude below the waist": {"lower"}, - "bare chested": {"upper"}, "barechested": {"upper"}, "bare-chested": {"upper"}, -} - - -def declared_bare_zones(items): - """Zones a person's sheet says are bare from the outset, and the tokens saying so. - - Returns (zones, tokens). The tokens are returned so the caller can drop them - from the description: they are replaced by the exposure marker, and leaving both - in would state the same fact twice in one parenthetical.""" - zones, tokens = set(), [] - for it in items or []: - key = _item_name(it).strip().lower() - z = _DECLARED_BARE.get(key) - if z is None: - z = _DECLARED_BARE.get(re.sub(r"[^a-z ]", "", (it or "").strip().lower())) - if z: - zones |= z - tokens.append(it) - return zones, tokens - - -def parse_exposed_terms(text): - """Per-person text for a stripped zone: {key: {zone: phrase}}. - - Written like the wardrobe sheet, so there is one syntax to learn: - - she = visible vulva, mvagina - he = visible penis, mpenis - Mara upper = bare breasts - - A key is a PRONOUN (applies to everyone declaring it) or a NAME (which wins - over the pronoun). Without a trailing 'upper' the entry describes the lower - zone. Empty means the generic wording is used.""" - out = {} - for line in (text or "").splitlines(): - line = line.strip() - if not line or "=" not in line: - continue - key, val = line.split("=", 1) - key, val = key.strip(), val.strip() - if not key or not val: - continue - zone = "lower" - parts = key.split() - if len(parts) > 1 and parts[-1].lower() in ("upper", "lower"): - zone = parts[-1].lower() - key = " ".join(parts[:-1]) - out.setdefault(_norm_name(key).lower(), {})[zone] = val - return out - - -def exposed_mark(zone, name, items, terms): - """The phrase to stamp for a stripped `zone` on this person. - - Name beats pronoun, pronoun beats the person's own default, which beats the - neutral one -- so one setup covers a whole cast, and a single character can - still be given their own wording.""" - pron = _pronoun_of(items or []) - if terms: - by_name = terms.get((name or "").strip().lower(), {}) - if zone in by_name: - return by_name[zone] - if pron: - by_pron = terms.get(pron.lower(), {}) - if zone in by_pron: - return by_pron[zone] - # Configuring only the lower zone is the common case ('she = vagina'), and the - # upper zone still has to be worded for the right body rather than defaulting - # to a male torso. - return _BARE_MARK_BY_PRON.get(pron or "", _BARE_MARK)[zone] - - -def bare_state_items(items, stripped_zones, marks=None): - """Markers to add / remove so a stripped zone keeps saying it is stripped. - - Returns (add, drop). A zone that something covers again -- because a garment was - put back on -- drops its marker, which is what "unless requested" means.""" - marks = marks or dict(_BARE_MARK) - # Every phrase this function could have stamped, so a marker is recognised for - # removal even if the configured wording changed between runs -- including the - # per-pronoun defaults, or a marker stamped as 'bare breasts' on one run could - # not be dropped on the next. - known = set(_BARE_MARK.values()) | set(marks.values()) - for d in _BARE_MARK_BY_PRON.values(): - known |= set(d.values()) - add, drop = [], [] - for zone in _BARE_MARK: - mark = marks.get(zone, _BARE_MARK[zone]) - present = mark in items - covered = bool(remaining_cover([i for i in items if i not in known], {zone})) - if zone in stripped_zones and not covered and not present: - add.append(mark) - elif (covered or zone not in stripped_zones) and present: - drop.append(mark) - return add, drop - - -def remaining_cover(items, zones): - """Items still worn that cover any of `zones` -- what is underneath.""" - return [i for i in items if garment_zones(i) & zones] - - -def _is_plural_garment(item): - """Garments that take a plural verb: overalls, jeans, boots, gloves, shorts. - A head noun ending in a DOUBLE s (dress, harness) is singular, which is what - separates them from a real plural. - - Read from the garment's own head, not the item's last word: "red jacket with - silver zippers" ends on a plural detail while the garment itself is singular, - which produced "the red jacket ... ARE off and she is not wearing THEM".""" - head = _item_head(item) - return bool(head) and head.endswith("s") and not head.endswith("ss") - - -def takes_off_clause(pairs, active=None): - """The DIRECTION of a removal, stated in the shot that performs it. - - A removal is the one wardrobe change with a failure mode of its own: the motion - is symmetric. The same frames played backwards are a person putting the garment - ON, and both readings satisfy "takes off her red jacket" equally well. The model - picks whichever the rest of the conditioning supports -- and when the shot's own - description still listed the garment as worn, backwards was the reading that - matched. The removal rendered in reverse and the jacket came back. - - So the end state is stated explicitly, and the reverse is ruled out by name. - Said ONCE, in the removal shot only: every later shot simply describes what the - person is wearing now, and never names the garment again -- to a video model a - mention is a presence cue, and a negation is a weak one, so "no longer wearing - the red jacket" in the NEXT shot was itself enough to put it back on.""" - active = active or {} - by = {} - for name, item in pairs: - item = (item or "").strip() - if item and item not in by.setdefault(name or "", []): - by[name or ""].append(item) - bits, still = [], [] - for name, items in by.items(): - # Refer to the garment by NAME, not by its full sheet entry. The detail - # (logo, zippers, torn knee) is already stamped in the description every - # shot; repeating it twice inside a sentence that only has to say the thing - # came off buries the instruction in 22 words of wardrobe. - what = " and ".join(_item_name(i) for i in items) - # "the navy overalls IS off" reads as a mistake to the encoder that has to - # parse this. Garments like overalls/jeans/boots are grammatically plural, - # as is any list of more than one. - plural = len(items) > 1 or any(_is_plural_garment(i) for i in items) - # `pron` is the OBJECT form ("takes them off"), `subj_pron` the SUBJECT form - # ("they are off") -- the impersonal branch needs the latter. - verb, pron, subj_pron = ("are", "them", "they") if plural else ("is", "it", "it") - subj = _subject_term(name, active) if name else "" - # Name the garment ONCE. It was named twice here and again in the beat prose, - # which made it the most-referenced thing in the shot -- and a garment - # referenced that often gets rendered as a prominent object, picked up and - # handled by whoever is nearby. Same rule as people: repeat the reference, - # get the thing repeated. Saying where it ENDS UP is what stops it lingering - # in someone's hands. - if subj: - bits.append(f"{subj} takes the {what} off during this shot; by the last frame " - f"{subj_pron} {verb} off, dropped away out of frame, and {subj.lower()} is " - f"no longer wearing {pron}") - else: - bits.append(f"the {what} {'come' if plural else 'comes'} off during this shot; by the " - f"last frame {subj_pron} {verb} off and dropped away out of frame") - # SAY WHAT IS STILL ON. The clause is five statements about clothing coming - # off; without this, nothing in it says the body is still covered, and the - # model completes the obvious continuation -- shorts worn UNDER trousers were - # listed once in a distant parenthetical and simply not rendered. Naming the - # under-layer here, in the same breath as the removal, is what keeps it on. - zones = set() - for i in items: - zones |= garment_zones(i) - under = remaining_cover(active.get(name, []), zones) if zones else [] - if under: - worn = " and ".join(_item_name(u) for u in under) - who = (_subject_term(name, active).lower() if name else "the character") - still.append(f"the {worn} underneath {'stay' if len(under) > 1 or _is_plural_garment(under[0]) else 'stays'} " - f"on and {who} is still wearing {'them' if len(under) > 1 or _is_plural_garment(under[0]) else 'it'}") - if not bits: - return "" - s = "; ".join(bits) - if still: - s += ". " + ("; ".join(still)).capitalize() - # The anti-reverse instruction is the point of the clause, so it is not left - # implicit in the end-state description. Worded without a pronoun so it needs no - # agreement with whatever came off. - return (s[0].upper() + s[1:] - + ". The motion runs one way only: the clothing comes off and is never put back on, " - "never re-worn, and the action never plays in reverse.") - - -# Words that can never be part of the garment phrase itself. -_GARMENT_LEAD = {"off", "out", "of", "aside", "away", "down", "up", "the", "a", "an", - "her", "his", "their", "its", "it", "them", "then"} -# Words that END a garment phrase: a conjunction, a new preposition, or a new -# article all start something that is no longer the garment. -_GARMENT_END = {"and", "or", "but", "then", "on", "onto", "over", "into", "in", "to", - "from", "at", "by", "with", "under", "beside", "as", "while", "before", - "after", "a", "an", "the", "she", "he", "they", "her", "his", "their"} -# A person noun is never part of a garment phrase -- scrubbing one deletes the -# CHARACTER from the anchor and leaves the clothing behind. -_PERSON_NOUN = {"woman", "women", "man", "men", "girl", "boy", "guy", "lady", "person", - "people", "figure", "child", "kid", "teen", "teenager", "male", "female"} - - -def removed_phrase_items(body, anchor_id): - """Garments named in a REMOVAL phrase in this beat that also appear in the - anchor prose. Covers the case where the item was never in the wardrobe channel - at all -- e.g. the anchor says 'a woman in a red flight jacket' and the beat - says 'she takes off her jacket'. Without this the anchor would keep re-applying - it forever. Returns the anchor phrases to scrub. - - The phrase is read to its HEAD NOUN, not to the first word after the verb. The - earlier version stopped at the first non-stop word, so "takes off her red - jacket" yielded 'red' -- and matching 'red' with its preceding words in the - anchor produced 'A woman in a red', which scrubbed the PERSON out of - 'A woman in a red jacket' and left 'jacket'. The garment survived, the - character vanished, and clothing removal looked completely broken.""" - if not body or not anchor_id: - return [] - verb = re.compile(r"\b(takes?|took|taking|pulls?|pulled|peels?|peeled|strips?|stripped|" - r"slips?|slipped|shrugs?|shrugged|removes?|removed|sheds?|shed|discards?|" - r"ditch(?:es|ed)?|doffs?|unbuttons?|unzips?)\b", re.I) - out = [] - for m in verb.finditer(body): - # Stop at punctuation: "shrugs off his overalls, a flight suit underneath" - # must not drag the second clause into the garment. - tail = re.split(r"[,.;:!?]", body[m.end():m.end() + 60])[0] - words = re.findall(r"[A-Za-z][A-Za-z\-]*", tail) - i = 0 - while i < len(words) and words[i].lower() in _GARMENT_LEAD: - i += 1 - phrase = [] - while i < len(words) and words[i].lower() not in _GARMENT_END and len(phrase) < 4: - phrase.append(words[i]) - i += 1 - if not phrase: - continue - head = phrase[-1] - if head.lower() in _PERSON_NOUN: # "takes off after the man" -- not clothing - continue - # Take the head noun with its adjectives out of the anchor, then trim any - # leading word that belongs to the SENTENCE rather than to the garment. - am = re.search(r"((?:[A-Za-z\-]+\s+){0,2}" + re.escape(head) + r")\b", anchor_id, re.I) - if not am: - continue - toks = am.group(1).split() - while len(toks) > 1 and toks[0].lower() in (_GARMENT_END | _PERSON_NOUN | _GARMENT_LEAD): - toks.pop(0) - if toks and toks[-1].lower() not in _PERSON_NOUN: - out.append(" ".join(toks)) - return out - - -def extract_directive(body, key): - """Pull a ': ...' line out of a beat body. Returns (clean_body, value|None).""" - kept, val = [], None - for ln in body.split("\n"): - if re.match(r"\s*" + key + r"\s*:", ln, re.I): - val = ln.split(":", 1)[1].strip() - else: - kept.append(ln) - return "\n".join(kept).strip(), val - - -extract_directive = lru_cache(maxsize=4096)(extract_directive) - - -def extract_directive_aliases(body, keys): - """Pull all alias lines for one logical directive, returning the last value found.""" - cleaned = str(body or "") - value = None - for key in keys: - cleaned, found = extract_directive(cleaned, key) - if found: - value = found - return cleaned, value - - -def _normalize_choice_directive(value, allowed_map): - lowered = str(value or "").strip().lower() - if not lowered: - return None - return allowed_map.get(lowered) - - -def beat_ref_mode_directive(beat): - _, value = extract_directive((beat or ""), "ref_mode") - return _normalize_choice_directive(value, _REF_MODE_DIRECTIVE_MAP) - - -beat_ref_mode_directive = lru_cache(maxsize=2048)(beat_ref_mode_directive) - - -def beat_ref_noise_aug_directive(beat): - _, value = extract_directive((beat or ""), "ref_noise_aug") - if not value: - return None - match = re.search(r"([0-9]*\.?[0-9]+)", value) - if not match: - return None - try: - parsed = float(match.group(1)) - except ValueError: - return None - return parsed if parsed >= 0 else None - - -beat_ref_noise_aug_directive = lru_cache(maxsize=2048)(beat_ref_noise_aug_directive) - - -def beat_continuity_directive(beat): - _, value = extract_directive((beat or ""), "continuity") - return _normalize_choice_directive(value, _CONTINUITY_DIRECTIVE_MAP) - - -beat_continuity_directive = lru_cache(maxsize=2048)(beat_continuity_directive) - - -def beat_override_summary(beat, shot_number): - items = [] - continuity = beat_continuity_directive(beat) - if continuity and continuity != "auto": - items.append(f"continuity {continuity}") - ref_mode = beat_ref_mode_directive(beat) - if ref_mode: - items.append(f"ref_mode {ref_mode}") - ref_noise_aug = beat_ref_noise_aug_directive(beat) - if ref_noise_aug is not None: - items.append(f"ref_noise_aug {ref_noise_aug:g}") - _, anchor_add = extract_directive((beat or ""), "anchor_add") - if anchor_add: - items.append("anchor_add") - _, soundscape = extract_directive_aliases((beat or ""), ("overall_soundscape", "soundscape")) - if soundscape: - items.append("soundscape") - _, music = extract_directive_aliases((beat or ""), ("non_diegetic_music", "music")) - if music: - items.append("music") - if not items: - return "" - return f"shot {shot_number}: " + ", ".join(items) - - -# "walks out OF THE BARN" is emerging INTO the scene, not leaving it -- and a false -# exit is the expensive error: the character is stripped from every later shot and -# only an explicit 'enter:' brings them back. So "out of " is never an -# exit unless the somewhere is the frame itself. -_EMERGENCE_TAIL = re.compile( - r"^\s+of\s+(?:the\s+|a\s+|an\s+|his\s+|her\s+|their\s+|its\s+)?" - r"(?!frame\b|shot\b|view\b|screen\b|scene\b|camera\b|sight\b|there\b|here\b)\w+", re.I) - - -def _is_emergence(text, m): - """True when this exit cue is really someone coming OUT OF a place into view. - - The cue match ends at "out"/"off", so what decides it is what FOLLOWS: "walks - out | of the barn" is emergence, "walks out | of frame" is departure, and a - bare "walks out" has nothing after it and stays an exit.""" - return bool(_EMERGENCE_TAIL.match(text[m.end():])) - - -# One list of departure phrasings for both readers of it: detect_exits(), which -# decides WHO left, and departed_phrase_people(), which scrubs their description -# out of the anchor. It was written out twice; the two copies had to agree or a -# character could be marked departed while the anchor kept describing them. -_EXIT_CUE = re.compile( - r"\b(?:leaves?|left|leaving|exits?|exited|departs?|departed|" - r"walks? (?:out|off|away)|walked (?:out|off|away)|steps? (?:out|off|away)|" - r"stepped (?:out|off|away)|drives? (?:off|away)|drove (?:off|away)|" - r"rides? (?:off|away)|runs? (?:out|off)|ran (?:out|off)|" - r"disappears?|vanishes?|is gone|are gone|out of frame|off screen|off-screen)\b", re.I) - - -def detect_exits(body, active, departed): - """Names of characters who LEAVE in this beat, so they don't reappear later. - Matches an exit phrase ('leaves', 'walks out', 'exits', 'drives off', 'steps - out of frame', 'is gone') attributed to the nearest preceding subject (name or - resolvable pronoun). Gated on tracked people, so 'the plane leaves' -- not a - tracked person -- departs nobody.""" - if not body: - return [] - text = " " + body.lower() + " " - names = [n for n in active if n and n not in departed] - if not names: - return [] - pron_map = _pron_map({k: v for k, v in active.items() if k not in departed}) - single = len(names) == 1 - - subj_tokens = [re.escape(n.lower()) for n in names] + list(_PRO.keys()) - subj_re = re.compile(r"\b(" + "|".join(subj_tokens) + r")\b") - - out = [] - for m in _EXIT_CUE.finditer(text): - if _is_emergence(text, m): - continue - best, bp = None, -1 - for sm in subj_re.finditer(text): - if 0 <= sm.start() < m.start() and sm.start() > bp: - person = _resolve_subject(sm.group(1), names, pron_map, single) - if person is not None: - bp, best = sm.start(), person - if best: - out.append(best) - return out - - -def departed_phrase_people(body, anchor_id): - """Anchor phrases for people who LEAVE in this beat but were never declared in - the character channel -- e.g. the anchor says 'a woman with silver hair and a - bald man in navy overalls' and the beat says 'he walks out'. Without this the - anchor keeps re-asserting them into every later shot. - - Resolves the departing subject from the pronoun/noun before the exit cue, then - finds the matching person-phrase in the anchor by gender word ('man'/'woman'/ - 'boy'/'girl'/etc.) and returns that whole phrase (with its trailing - prepositional clause, e.g. 'a bald man in navy overalls') for scrubbing. - Returns [] when nothing matches, so non-person exits ('the plane leaves') - remove nobody.""" - if not body or not anchor_id: - return [] - want = {"she": ("woman", "women", "girl", "lady", "female"), - "he": ("man", "men", "boy", "guy", "gentleman", "male")} - out = [] - for m in _EXIT_CUE.finditer(body): - head = body[:m.start()] - pm = None - for p in re.finditer(r"\b(she|he|her|him|his|the\s+\w+)\b", head, re.I): - pm = p.group(1).lower() - if not pm: - continue - key = _PRO.get(pm.split()[-1]) - nouns = want.get(key, ()) - if not nouns: - continue - for noun in nouns: - # the person phrase: optional article/adjectives + noun + an immediate - # clothing clause only ('a bald man in navy overalls'). The clause must - # not run past a comma, so a following scene phrase ('..., in a hangar') - # is left intact. - am = re.search(r"((?:a|an|the)\s+(?:[\w\-]+\s+){0,3}" + noun + - r"(?:\s+(?:in|with|wearing)\s+(?:a\s+|an\s+|the\s+)?" - r"(?:[\w\-]+\s+){0,2}[\w\-]+)?)(?=\s*(?:,|\.|$|\band\b))", - anchor_id, re.I) - if am: - out.append(am.group(1).strip().rstrip(",")) - break - return out - - -# Natural speech runs ~2.3-2.8 words/sec in film dialogue; 2.5 is a safe middle. -# Used only to WARN that a line looks too long for the shot it sits in. -WORDS_PER_SEC = 2.5 - - -# A spoken line needs a beat of air before and after it inside the same shot -- -# the mouth opens late and the last syllable must not land on the cut. -SPEECH_PAD_SEC = 1.0 -# ...and a two-hander needs a hand-off between turns. Two people trading three -# lines is not the same screen time as one person saying all three back to back: -# the camera/mouth has to switch subject between each. -TURN_GAP_SEC = 0.5 - - -# --- content-aware shot length --------------------------------------------- -# A beat's screen time is estimated from how many ACTIONS it stages, not from its -# word count. Word count measures how wordy you were; clause count measures how -# much has to happen. -# -# The estimate is deliberately biased SHORT, because the two errors are not -# symmetric. A shot that ends before the action finishes hands a mid-motion frame -# to the next shot, which is exactly what the handoff chain is built to continue. -# A shot that outlasts its action leaves the model seconds it was told nothing -# about, and the cheapest filler for a symmetric action (taking a jacket off, a -# door opening, sitting down) is to run it BACKWARDS -- which returns to the start -# state and makes the clip loopable. Too long is unrecoverable; too short is not. -BEAT_BASE_SEC = 2.0 # setup/settle time every shot needs regardless of content -SECONDS_PER_ACTION = 2.5 # screen time for one staged action clause -MIN_CONTENT_FRAMES = 73 # ~3.0s: the shortest shot that can hold one action -# Clause separators: a new coordinated verb phrase starts a new action. -_CLAUSE_SPLIT = (r"(?:[.!?;]+|,?\s+(?:and then|then|and|before|after|while|as|until)\s+" - r"|,\s+(?=[a-z]+ing\b))") - - -def action_clauses(beat): - """How many distinct staged actions a beat contains. - - "takes off her red jacket and drops it on the workbench" is two; "walks the - length of the garage, checking every bench, then stops at the far wall" is - three. Quoted speech is excluded -- that time is counted by dialogue_seconds.""" - body, _ = extract_wardrobe((beat or "").strip()) - body = re.sub(r'["“][^"”]*["”]', " ", body) - body = " ".join(ln for ln in body.splitlines() if not is_directive_line(ln)) - parts = [p.strip() for p in re.split(_CLAUSE_SPLIT, body) if p and p.strip()] - # A fragment of one word is a leftover ("it", "her"), not an action of its own. - return sum(1 for p in parts if len(p.split()) >= 2) - - -action_clauses = lru_cache(maxsize=2048)(action_clauses) - - -def estimate_beat_seconds(beat): - """Screen time this beat needs, from its own content. 0.0 when it has none. - - Action and dialogue OVERLAP rather than add -- people talk while they move -- - so the estimate is the larger of the two, not their sum.""" - n = action_clauses(beat) - action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0 - return max(action, dialogue_seconds(beat)) - - -estimate_beat_seconds = lru_cache(maxsize=2048)(estimate_beat_seconds) - - -@lru_cache(maxsize=2048) -def _dialogue_spans_cached(beat): - """Word count of each double-quoted span in a beat, in order. Length of the - returned list is the number of speaking TURNS -- the multi-character case.""" - body, _ = extract_wardrobe((beat or "").strip()) - return tuple(len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split()) - - -def dialogue_spans(beat): - return list(_dialogue_spans_cached(beat)) - - -def dialogue_words(beat): - """Words inside double quotes in a beat -- the only speech H3 actually renders.""" - return sum(_dialogue_spans_cached(beat)) - - -dialogue_words = lru_cache(maxsize=2048)(dialogue_words) - - -def dialogue_seconds(beat, pad=True): - """Screen time this beat's dialogue needs, 0.0 when the beat has none. - - Counts every turn, so a two-character exchange is sized from the WHOLE - exchange plus a gap between turns -- not from the longest single line. - `pad` controls only the head/tail air; turn gaps are always counted because - they are time the shot genuinely has to contain.""" - spans = _dialogue_spans_cached(beat) - if not spans: - return 0.0 - return (sum(spans) / WORDS_PER_SEC - + TURN_GAP_SEC * (len(spans) - 1) - + (SPEECH_PAD_SEC if pad else 0.0)) - - -dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds) - - -def beat_seconds_directive(beat): - """Explicit per-beat length: a 'seconds: 8' (or 'duration: 8') line in the beat. - Returns the float, or None when the beat doesn't set one.""" - for key in ("seconds", "duration"): - _, val = extract_directive((beat or ""), key) - if val: - m = re.search(r"([0-9]*\.?[0-9]+)", val) - if m: - try: - v = float(m.group(1)) - except ValueError: - continue - if v > 0: - return v - return None - - -beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive) - - -def plan_beat_frames(beats, fps, budget): - """Per-beat shot lengths in frames. Returns (lengths, notes). - - `budget` is the socket-defined ceiling used for the beat-shot maximum. - Priority per beat: - - 1. an explicit 'seconds: N' line in the beat -- always honored, down to - H3's real 5-frame minimum, because you stated a duration outright; - 2. its own content -- action clauses and quoted dialogue (see - estimate_beat_seconds), floored at MIN_CONTENT_FRAMES so a shot always - has room for one action; - 3. otherwise its own content estimate. - - Why estimate at all, when action prose has no *reliable* duration? Because the - alternative is not "no guess" -- it is "guess the maximum", which is what giving - every beat the ceiling does. A 3-second action in a 12-second shot leaves nine - seconds the model was told nothing about, and it fills them by repeating or - REVERSING the action. Leaning short costs an unfinished action that the next - shot continues from the handoff frame; leaning long costs a jacket that takes - itself off and puts itself back on.""" - beats = beats if beats else [""] - # MIN_SHOT_FRAMES is the floor when the node has to guess with no information - # at all. A stated or estimated length is not silently rewritten upward. - cap = max(5, int(budget)) - content_floor = align_frame_count(MIN_CONTENT_FRAMES) - out, notes = [], [] - fps = max(1, int(fps)) - for i, b in enumerate(beats, 1): - want, src, floor = beat_seconds_directive(b), "seconds:", 5 - snap = align_frame_count # a stated length is never rounded DOWN - if want is None: - want = estimate_beat_seconds(b) - src, floor, snap = "content", content_floor, align_frame_count_nearest - if want <= 0: # no signal -> the ceiling - out.append(cap) - continue - n = max(floor, snap(int(round(want * fps)))) - out.append(n) - if n != cap: - notes.append(f"shot {i}: {n}f (~{n / fps:.1f}s, from {src})") - return out, notes - - -def pacing_warnings(beats, lengths, fps): - """Beats whose content is far too thin for the length they were given. - - Pure arithmetic, no model involved: it cannot know that "walks across the - tarmac" is 2s or 12s, but it can see 12 words sitting in a 12-second shot and - say so BEFORE the render, instead of leaving you to discover it as an action - that repeats or plays backwards.""" - out = [] - fps = max(1, int(fps)) - for i, (b, n) in enumerate(zip(beats or [], lengths or []), 1): - if beat_seconds_directive(b): # you stated it; not the node's business - continue - need = estimate_beat_seconds(b) - have = n / fps - if need and have > need * 1.8 and have - need >= 3.0: - out.append(f"shot {i}: ~{need:.1f}s of content in a {have:.1f}s shot " - f"({action_clauses(b)} action(s), {dialogue_words(b)} spoken words)") - return out - - -def dialogue_fit_warnings(beats, seconds_per_shot): - """Flag beats whose quoted dialogue is unlikely to fit the shot length. - - The VRAM budget caps SHOTS (never resolution, since rendering below native - softens the frame). That is the right trade for picture quality, but it is blind - to dialogue: a line written for a 10s shot gets cut off mid-sentence in a 7s one. - Audio cannot span the handoff either -- each shot generates its own -- so a - truncated line is simply lost, not continued. - - seconds_per_shot takes a single value or a per-shot list (per-beat sizing). - Returns a list like ["shot 3: ~6.4s of dialogue in a 5.2s shot"] so the user can - shorten the line, or choose a lower resolution tier to buy the duration back.""" - out = [] - for i, b in enumerate(beats or [], 1): - if isinstance(seconds_per_shot, (list, tuple)): - if i > len(seconds_per_shot): - break - sec = seconds_per_shot[i - 1] - else: - sec = seconds_per_shot - need = dialogue_seconds(b, pad=False) - if not need: - continue - if need > sec * 0.92: # leave a little room to breathe - out.append(f"shot {i}: ~{need:.1f}s of dialogue in a {sec:.1f}s shot") - return out - - -def continuity_warnings(gens): - """Shots that describe NOBODY while people are still in the story. - - The chain hands each shot the previous one's last decoded frame. A scenery beat - describes no one, so the frame it produces has no one in it -- and the next - shot has to re-establish every character from an empty room. That is a cohesion - break, and it is invisible in the prompt: the text of both shots is individually - correct, which is why chains lose their people in the middle rather than - degrading steadily. - - Only flagged when people appear on BOTH sides. A scenery beat that opens or - closes a chain hands its frame to nobody, so it costs nothing.""" - if len(gens or []) < 3: - return [] - # A bound person shows up as an inline parenthetical of real description. - peopled = [] - for g in gens: - body = (g or "").split("\n")[0] - body = re.sub(r"^\[Generation \d+\]\s*", "", body) - body = re.sub(r"^Exactly [a-z]+ (?:person|people)[^.]*\.\s*", "", body) - peopled.append(bool(re.search(r"\([^)]{6,}\)", body))) - out = [] - for i in range(1, len(peopled) - 1): - if not peopled[i] and peopled[i - 1] and any(peopled[i + 1:]): - out.append( - f"shot {i + 1} describes nobody, between shots that do -- it hands shot " - f"{i + 2} a frame with no people in it, so every character has to be " - f"re-established from an empty room. Give it someone ('Dom watches from " - f"the doorway'), or move it to the start or end of the chain") - return out - - -def dialogue_filler_warnings(beats, seconds_per_shot): - """Dialogue shots with far more time than their line, which H3 fills with speech. - - dialogue_fit_warnings covers the opposite error -- a line too long for its shot, - which gets truncated. This is the one that produces BABBLE: a two-second line in - a ten-second shot leaves eight seconds of audio the model was told nothing - about, and the audio branch keeps talking to fill them. mute_nonspeech_audio - cannot help, because a shot with a scripted line is deliberately left audible. - - Same vacuum as an over-long action beat, one channel across.""" - out = [] - for i, b in enumerate(beats or [], 1): - sec = (seconds_per_shot[i - 1] if isinstance(seconds_per_shot, (list, tuple)) - else seconds_per_shot) if not isinstance(seconds_per_shot, (list, tuple)) \ - or i <= len(seconds_per_shot) else None - if sec is None: - break - spoken = dialogue_seconds(b, pad=False) - if not spoken: - continue - gap = sec - spoken - if gap >= 3.0 and sec > spoken * 2: - out.append(f"shot {i}: {spoken:.1f}s of dialogue in a {sec:.1f}s shot -- {gap:.1f}s of " - f"unscripted audio the model will fill with more speech") - return out - - -def _annotate_shot_refs(gen, ref_slots, shot_index): - normalized_slots = _normalized_ref_slots(ref_slots) - tagged = _slot_refs_for_text(gen, normalized_slots) - named = _named_refs_for_text(gen, normalized_slots, kinds=("character", "location")) - seen = set() - merged = [] - for slot_number, ref in tagged: - key = (slot_number, "tag") - if key in seen: - continue - seen.add(key) - merged.append((slot_number, ref, "tag")) - for slot_number, ref in named: - key = (slot_number, "name") - if key in seen or any(slot_number == existing[0] for existing in merged): - continue - seen.add(key) - merged.append((slot_number, ref, "name")) - if not merged: - return f"# shot {shot_index} refs: none", "References used: none" - labels = [] - info_labels = [] - for slot_number, ref, source in merged: - label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}" - labels.append(f"Picture {slot_number} {label} (by {source})") - info_labels.append(f" {label} (matched by {source})") - return ( - f"# shot {shot_index} refs: " + "; ".join(labels), - "References used: " + "; ".join(info_labels), - ) +NODE_DISPLAY_NAME_MAPPINGS = { + "DumasH3LongVideos": "Dumas H3 Long Videos", + "H3LongVideos": "H3 Long Videos", + "H3LongVideosFL2VA": "H3 Long Videos", + "H3LongVideosV1": "H3 Long Videos", + "H3LongVideosREF2VA": "H3 Long Videos", +} -def _annotate_shot_guard(shot_index, anatomy_shots, anatomy_mode): - anatomy_set = set(anatomy_shots or []) - if anatomy_mode == "off": - return "Anatomy guard: off" - if shot_index in anatomy_set: - return "Anatomy guard: injected into this prompt" - if anatomy_mode == "forced": - return "Anatomy guard: forced on, but nothing was injected" - return "Anatomy guard: not injected for this prompt" - - -def annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots): - blocks = [] - for shot_index, gen in enumerate(gens or [], 1): - _ref_report, ref_info = _annotate_shot_refs(gen, ref_slots, shot_index) - blocks.append( - "\n".join( - [ - f"Prompt {shot_index}", - gen, - "", - f"Beat {shot_index} info", - _annotate_shot_guard(shot_index, anatomy_shots, anatomy_mode), - ref_info, - ] - ) - ) - if blocks: - return "\n\n---\n\n".join(blocks) - if anatomy_mode == "off": - return "Beat info\nAnatomy guard: off\nReferences used: none" - return "Beat info\nAnatomy guard: not injected for this prompt\nReferences used: none" - - -def annotate_script_refs(gens, ref_slots): - """Per-shot reference routing summary for the script socket.""" - lines = [] - for shot_index, gen in enumerate(gens or [], 1): - shot_report, _shot_info = _annotate_shot_refs(gen, ref_slots, shot_index) - lines.append(shot_report) - return "\n".join(lines) - - -def speech_flags(beats): - """Per-beat: does it contain scripted (quoted) dialogue? Same rule the prompt - builder uses to decide silencing, exposed so the renderer can also MUTE the - audio of non-speech shots -- a deterministic fix when H3 vocalizes anyway.""" - out = [] - for b in (beats if beats else [""]): - body, _ = extract_wardrobe((b or "").strip()) - out.append(has_speech(body)) - return out - - -def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_wardrobe=True, - auto_silence_nonspeech=True, allow_nonspeech_vocals=False, count_subjects=False, front_load=False, - notes_out=None, auto_props=True, prevent_nudity=True, - exposed_terms="", strip_out=None, anatomy_guard=False, - anatomy_auto=False, - lock_restraints=True, solidity_guard="auto", - motion_guard="auto", contact_guard="auto", count_auto=False): - """One beat = one shot. Stamp the permanent identity into each beat. Total - video length is (number of shots) x (per-shot length), computed by the - caller -- never divided out of a total, so beat count always equals shot count. - - WARDROBE LIVES IN ONE MUTABLE, PER-PERSON CHANNEL so it can be changed or - removed, and so multiple people are tracked independently. The channel is - seeded from character_memory, or from a 'wardrobe:' line in the anchor; - whatever anchor prose REMAINS after pulling that line is permanent identity, - stamped every shot. Clothing must NEVER be baked into the permanent anchor - prose (the anchor is immutable and would re-assert a garment you tried to - remove) -- keep identity in the prose, all clothing in this channel. - - auto_wardrobe (default on): removals are inferred from each beat's own action - text, so "she takes off her jacket" drops the jacket with no directive. It's - gated on tracked items, so non-garment objects ("the plane takes off") never - fire. Additions/swaps still use an explicit 'wardrobe: += ...' line, which - also overrides the auto-detection. - - Multi-person syntax: 'wardrobe: Maya = grey shorts, red jacket; Jon = navy - overalls' (a colon works too). A per-beat 'wardrobe:' line updates only the - names it mentions; one unnamed subject works as before. - - The two audio sections are appended after the visual timeline, in H3's - documented field order: - * `overall_soundscape:` -- ambient/environmental sound (rain, room tone). - * `non_diegetic_music:` -- background score not part of the scene. - Both are global (stamped on every shot). Dialogue and diegetic sound belong - in the beat body / timeline, NOT in either of these.""" - beats = beats if beats else [""] - anchor_id, anchor_wardrobe = extract_wardrobe((anchor or "").strip()) - seed = (char_memory or "").strip() or (anchor_wardrobe or "") - active = parse_wardrobe(seed) # {name: [items]}, mutable, per-person - removed = [] # garments taken off -> also scrubbed from the anchor - departed = set() # characters who left the scene -> never reappear - props = {} # objects introduced so far -> their phrase - stripped = {} # person -> body zones stripped so far - # person -> how their restraints are used (tether anchor / pose), as stated by - # whichever shot said it. A restraint is part of the wardrobe channel, and its - # USE persists with the wardrobe: shot 5 saying "she strains" must keep shot - # 2's "cuffed to the headboard", not fall back to wording that contradicts it. - restraint_usage = {} - exposed = parse_exposed_terms(exposed_terms) - # Every person key that existed at any point, so an entry naming someone who is - # only introduced later by a 'wardrobe: Name = ...' directive is not called - # unmatched. Checked after the loop, once the full cast is known. - seen_names = {k for k in active if k} - blocks = [] - for gi, b in enumerate(beats, 1): - body, wardrobe_change = extract_wardrobe((b or "").strip()) - body, _ = extract_directive(body, "seconds") # shot length, not prose - body, _ = extract_directive(body, "duration") # ditto (alias) - body, exit_directive = extract_directive(body, "exit") # explicit 'exit: Jon' - body, enter_directive = extract_directive(body, "enter") # explicit 'enter: Jon' (undo) - body, shot_soundscape = extract_directive_aliases(body, ("overall_soundscape", "soundscape")) - body, shot_music = extract_directive_aliases(body, ("non_diegetic_music", "music")) - body, shot_anchor_add = extract_directive(body, "anchor_add") - body, _ = extract_directive(body, "continuity") - body, _ = extract_directive(body, "ref_mode") - body, _ = extract_directive(body, "ref_noise_aug") - if enter_directive: - for nm in _entries(enter_directive): - departed.discard(_norm_name(nm)) - # Naming a departed character again is intent to have them BACK. Without - # this they stayed departed, so the beat carried their bare NAME with no - # description while everyone else kept theirs -- and the described character - # absorbed the action. A PRONOUN still cannot re-summon anyone: "he waves" - # after someone left is ambiguous, a name is not. Use 'exit: Name' again to - # send them back out. - if departed: - named_here, _spans = _mask_quotes(body) - for nm in list(departed): - if nm and re.search(r"\b" + re.escape(nm) + r"\b", named_here, re.I): - departed.discard(nm) - body = body or "continue the action, same subject" - # Props introduced in an EARLIER beat: bind the first definite reference to - # them, so "the van" in shot 2 means the van from shot 1 instead of an - # invented one standing next to it. Garments are excluded -- they have their - # own channel, and "the same red jacket" would fight a removal. - worn_nouns = {_item_head(i) for v in active.values() for i in v} - own_props = {n: p for n, p in introduced_props(body).items() if n not in worn_nouns} - carried = {n: p for n, p in props.items() - if n not in worn_nouns and n not in own_props} - body, bound_props = bind_props(body, carried) if auto_props else (body, []) - # An object introduced AND referred back to inside one beat never reaches the - # cross-shot carry -- but that is the case that duplicates hardest, because - # the repetition is all in one prompt. Collapse the repeats the way repeated - # NAMES are collapsed, and state the count. - here_again = repeated_props(body, own_props) if auto_props else [] - if here_again: - body, _ = dedupe_prop_mentions(body, here_again) - off_now = [] # (person, garment) coming off in THIS shot - - def _drop(before, after): - """Record what `after` no longer has, for both removal paths.""" - for k, v in before.items(): - gone = [it for it in v if it not in after.get(k, [])] - removed.extend(gone) - off_now.extend((k, it) for it in gone) - return after - - if wardrobe_change is not None: - before = {k: list(v) for k, v in active.items()} - # explicit: takes effect THIS shot - active = _drop(before, apply_wardrobe_change(active, wardrobe_change)) - # Auto-removals are resolved BEFORE the shot is composed, so the garment is - # already out of the person's description in the very shot that takes it off. - # - # It used to be deferred to the next shot, on the reasoning that the shot - # SHOWING the removal should still show the garment. That produced a shot - # whose description says "wearing a red jacket" while its verb says "takes off - # her red jacket" -- and the cheapest way for the model to satisfy both is to - # run the motion the OTHER way, ending with the jacket on. The video played - # the removal in reverse. - # - # The start state does not need the description: for every shot after the - # first it is pinned by the handoff keyframe, which shows the garment still - # worn. So the keyframe carries the START state and the prompt carries the - # END state, and the direction between them is stated outright below. - if auto_wardrobe: - before = {k: list(v) for k, v in active.items()} - active = _drop(before, auto_wardrobe_removals(active, body, lock_restraints)) - # A garment that lives ONLY in the anchor prose (never in the wardrobe - # channel): the removal phrase names it, so scrub it from the anchor or - # the anchor re-applies it forever. - anchor_gone = removed_phrase_items(body, anchor_id) - # Same rule on the anchor side: a restraint named in the anchor prose is - # not scrubbed by a removal phrase either, or it would vanish from every - # later shot without anything having asked for it. - if lock_restraints: - anchor_gone = [it for it in anchor_gone if not is_restraint(it)] - removed += anchor_gone - # Voice the anchor-side removal only when the channel didn't already cover - # it, or the same jacket is announced twice. - if anchor_gone and not off_now: - off_now += [("", it) for it in anchor_gone] - # Restraint USE follows the wardrobe channel: the moment a sheet no longer - # carries the restraint, its recorded use is forgotten -- otherwise a freed - # and later RE-bound character would inherit an attachment nobody has - # restated. Pruned HERE rather than inside restraint_clause, because the - # clause only runs on shots with someone in them, while removals -- and - # therefore forgetting -- must happen whatever the shot shows. - for _nm in list(restraint_usage.keys()): - if not restraint_regions(active.get(_nm)): - del restraint_usage[_nm] - # Record which zones this person has been stripped in, then keep the state - # STATED in every later shot. Deleting the garment is only a silence, and a - # video model's default is a clothed person -- so silence puts the clothes - # back on a shot or two later. The marker clears by itself if a garment - # covering that zone is put back on, which is the "unless requested" half. - for nm, it in off_now: - z = garment_zones(it) - if z: - stripped.setdefault(nm, set()).update(z) - # A sheet can DECLARE a zone bare from the outset ('Jon = he, 35, nude'), - # which is the start-naked case: there is no removal to trigger on, so - # without this the marker never fires and exposed_terms never reaches them. - # The token itself is swapped out for the marker so the fact is stated once. - declared = {} - for nm in list(active): - zones, tokens = declared_bare_zones(active.get(nm, [])) - if zones: - declared[nm] = zones - stripped.setdefault(nm, set()).update(zones) - for tok in tokens: - active[nm].remove(tok) - bare_now = {} - for nm in list(active): - marks = {z: exposed_mark(z, nm, active.get(nm, []), exposed) - for z in ("lower", "upper")} - add, drop = bare_state_items(active.get(nm, []), stripped.get(nm, set()), marks) - # prevent_nudity gates the ASSERTION, not the removal. Deleting a garment - # only leaves the zone undescribed, and a video model's default prior is a - # clothed person -- so it dresses them again. It is this marker that makes - # the prompt SAY the body is bare, which is the thing that renders. The - # garment still comes off either way; without the marker the model simply - # covers what nobody described. `info` still reports the empty zone. - # - # Filling in exposed_terms IS the intent, so it overrides the guard for the - # people it names. Requiring both switches was a footgun: the terms sat - # there looking configured and did nothing. - # Declaring nudity in the sheet is as explicit as filling in - # exposed_terms, so it overrides the guard for that person the same way. - if prevent_nudity and not exposed and nm not in declared: - add = [] - stripped_here = any(n == nm for n, _ in off_now) - # The handoff reset is recorded INDEPENDENTLY of the nudity gate above: - # that gate decides whether the bare state gets STATED, but whether or - # not it is stated, this shot's last frame shows the removal in - # progress -- and letting the next shot continue from that frame is how - # the garment reappears. Recording lived inside the `add` loop, so with - # prevent_nudity at its default the reset silently never fired and - # every removal was followed by a stale frame. - if (stripped_here and stripped.get(nm) - and strip_out is not None and gi not in strip_out): - strip_out.append(gi) - for mark in add: - active[nm].append(mark) - # This shot newly bared a zone by REMOVING something. The NEXT shot - # must not continue from its last frame: that frame is the removal in - # progress, and a picture of the garment still being worn beats any - # sentence saying it is off. A zone that was declared bare from the - # start has no such frame -- nothing came off -- so it must NOT cost - # the next shot its handoff. - if stripped_here and strip_out is not None and gi not in strip_out: - strip_out.append(gi) - for mark in drop: - active[nm].remove(mark) - # Which zones are marked bare AFTER this shot's adds and drops. Read off - # the markers themselves, so every gate that governs them -- prevent_nudity, - # exposed_terms, a sheet declaration -- governs this too, with no second - # decision to keep in step. - zones_bare = [z for z, mk in marks.items() if mk in active[nm]] - if zones_bare: - bare_now[nm] = zones_bare - persistent = compose_persistent(body, active, anchor_id, removed, departed, count_subjects, - speaking=has_speech(body), front_load=front_load, - count_auto=count_auto, - silence_nonspeech=bool(auto_silence_nonspeech)) - if shot_anchor_add: - persistent = persistent.rstrip(". ") - persistent = f"{persistent}. {shot_anchor_add}".strip(". ") if persistent else shot_anchor_add - # State the DIRECTION of the change, in the shot that performs it. Only for - # people actually in this shot; an anchor-prose garment is stated - # impersonally, so it summons nobody. - speak_off = [(n, it) for n, it in off_now - if not n or person_referenced(body, n, active)] - off_clause = takes_off_clause(speak_off, active) - prop_clause = (prop_continuity_clause(bound_props, carried) - + prop_count_clause([n for n in here_again if n not in bound_props])) - # A removal that leaves a body zone with NOTHING on it is the one the node - # cannot write its way out of: there is no under-layer to name, so the model - # renders bare skin. Say so before the render rather than after it. - if notes_out is not None: - for nm, it in off_now: - zones = garment_zones(it) - if zones and not remaining_cover(active.get(nm, []), zones): - who = nm or "the character" - notes_out.append( - f"shot {gi}: removing the {_item_name(it)} leaves {who} with nothing on the " - f"{'/'.join(sorted(zones))} body -- H3 will render bare skin there. Add an " - f"under-layer to character_memory (e.g. 'grey shorts') if that is not intended") - if off_clause: - persistent = persistent.rstrip(". ") + ". " + off_clause - if prop_clause: - persistent = persistent.rstrip(". ") + "." + prop_clause - # Silence non-speech shots: a shot with no scripted dialogue gets an explicit - # lips-closed / no-speech clause, so H3 doesn't animate a mouth or fill it with - # gibberish before (or between) actual dialogue. Shots WITH quoted dialogue are - # left alone so the speech renders. - # Two different silences, and they are NOT the same condition: - # no_speech -> the AUDIO constraint. A shot with no scripted line must not - # be given an unconditioned audio branch, whether or not - # anyone is on screen; an empty room still babbles. - # mouth_state-> the PICTURE constraint. Only meaningful when someone is - # there to have a mouth. On a scenery beat it describes - # nobody and can only invite a face into an empty frame. - no_speech = bool(auto_silence_nonspeech and not has_speech(body)) - allow_vocals = bool(allow_nonspeech_vocals and not has_speech(body)) - # A quoted single word with no speech verb is usually EMPHASIS, not a line -- - # but it still flips the shot to "speaking": every mouth goes free and the - # audio stays unmuted, which is how a character ends up mouthing prompt - # fragments. Report it rather than guess; the fix is one edit either way. - if notes_out is not None and auto_silence_nonspeech and not no_speech: - for q in _emphasis_quotes(body): - notes_out.append( - f'shot {gi}: "{q}" reads as emphasis, not dialogue -- this shot keeps every ' - f'mouth free because of it. If nobody says it aloud, drop the quotes; if ' - f'someone does, attribute the line (she says, "...") so only their mouth moves') - # Must agree with compose_persistent()'s binding, including the PLURAL case. - # It did not: person_referenced() resolves a pronoun to one person, so - # 'they'/'both of them' answered False for everyone, and a beat that the - # roll-call had just described in full counted as having nobody in it. Those - # shots got no mouth constraint at all -- two people on screen, nothing - # saying their lips are closed -- which is exactly a shot that opens mouths - # at random. - present_names = [n for n in active if n and n not in departed] - people_here = (bool(active.get("")) - or any(person_in_shot(body, n, active, departed) - for n in present_names)) - # EVERY per-shot state below is gated on someone being in the shot, for one - # reason: describing a body in a frame that has none can only invite one in. - # That gate used to be written out six times, along with the same - # rstrip-and-append; the ORDER is the only thing that differed, so the order - # is data here and the mechanics happen once. - # - # The order is deliberate, and each entry says why it sits where it does. - # `ident` is the identity block BEFORE any of these were appended: the - # solidity scan reads it for set dressing, and passing the growing text would - # let one guard's wording become another guard's input. - if people_here: - ident = persistent - n_in_shot = max(sum(1 for n in present_names - if person_in_shot(body, n, active, departed)), - 1 if active.get("") else 0) - states = [ - # the mouth, before anything describes the body it is in - (no_speech and not allow_vocals, lambda: LIPS_CLOSED_STATE + LIPS_CLOSED_TAIL), - # the limb COUNT, before anything constrains those limbs. 'auto' - # also fires wherever TWO or more bodies share the frame: spare - # limbs are grown where bodies meet or move together, whatever - # the resolution. - (anatomy_guard or (anatomy_auto and n_in_shot >= 2), - lambda: ANATOMY_STATE), - # what the restraints DO -- limits on limbs already established - (lock_restraints, lambda: restraint_clause(active, body, lock_restraints, - usage=restraint_usage)), - # a bared zone stays bared once the body turns to a surface the shot - # has not shown; the model's default for undescribed skin is clothed - (True, lambda: bare_persist_clause(bare_now, active, body)), - # two bodies arranged, BEFORE being told to hold together while moving - (True, lambda: contact_clause(body, n_in_shot, contact_guard)), - # then how that body moves -- unless someone in the frame is bound: - # the free-travel sentence outvotes the binding, and H3 settles - # that fight by rendering the restraints giving up. - (True, lambda: ("" if _restrained_present(active, body) - else motion_clause(body, motion_guard))), - # and last, what it cannot move through - (True, lambda: solidity_clause(body, ident, solidity_guard)), - ] - for want, produce in states: - clause = produce() if want else "" - if clause: - persistent = persistent.rstrip(". ") + "." + clause - silent_shot = no_speech and not allow_vocals - block = f"[Generation {gi}] {persistent}".strip() - # A silenced shot ALWAYS gets a soundscape line. Leaving the field out is - # what let H3 improvise a voice track under a shot whose picture was already - # told to keep its mouth shut -- the babble the lips-closed clause cannot - # reach, because it only constrains the frames. - if shot_soundscape: - if silent_shot: - block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_CLAUSE}" - elif no_speech: - block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_SPEECH_CLAUSE}" - else: - block += f"\noverall_soundscape: {shot_soundscape}" - elif "soundscape:" not in block.lower(): - if gs: - if silent_shot: - block += f"\noverall_soundscape: {gs}{NO_VOICE_CLAUSE}" - elif no_speech: - block += f"\noverall_soundscape: {gs}{NO_VOICE_SPEECH_CLAUSE}" - else: - block += f"\noverall_soundscape: {gs}" - elif silent_shot: - block += f"\noverall_soundscape: {NO_VOICE_SOUNDSCAPE}" - elif no_speech: - block += f"\noverall_soundscape: {NO_VOICE_SPEECH_SOUNDSCAPE}" - # Music is OPT-IN: a blank field emits the spec's silence token N/A on every - # shot, so H3 doesn't improvise a score. (Soundscape is NOT forced to N/A -- - # per the spec it takes N/A only when total silence is explicitly wanted, so a - # blank soundscape still lets H3 provide ambient sound.) - if shot_music: - block += f"\nnon_diegetic_music: {shot_music}" - elif "non_diegetic_music:" not in block.lower(): - block += f"\nnon_diegetic_music: {music if music else 'N/A'}" - blocks.append(block.strip()) - # Exits stay DEFERRED, unlike removals: a character has to be visible in the - # shot that shows them leaving, and the frame they leave in is the shot's own - # subject -- there is no reverse-motion trap, because "walks out" ending with - # them present would contradict the beat itself, not just a description. - if exit_directive: - for nm in _entries(exit_directive): - departed.add(_norm_name(nm)) - departed.update(detect_exits(body, active, departed)) - # Plain-text case: a person described only in the anchor prose (never in the - # character channel) can't be "departed" by name -- scrub their phrase from - # the anchor instead, exactly as removed garments are scrubbed. - removed += departed_phrase_people(body, anchor_id) - # Anything this beat introduces indefinitely becomes referable later. - for _n, _ph in introduced_props(body).items(): - props.setdefault(_n, _ph) - seen_names |= {k for k in active if k} - # An exposed_terms key that matches neither a character nor a usable pronoun - # never fires, and it fails SILENTLY: the lookup falls through to the pronoun, - # then to the default wording, so a typo'd name looks configured and does - # nothing. Only the three canonical pronouns work as pronoun keys, because - # _pronoun_of() normalizes to those -- an object form like 'her' is dead - # config for the same reason and is worth the same warning. - if exposed and notes_out is not None: - known = {n.lower() for n in seen_names} | set(_PRO.values()) - for key in exposed: - if key in known: - continue - hint = "" - if key in _PRO: # 'her'/'him'/'his'/'them'... - hint = f" -- use '{_PRO[key]}' for the pronoun form" - elif seen_names: - hint = " -- known characters: " + ", ".join(sorted(seen_names)) - # Echo the key as the user typed it: parse_exposed_terms() lowercases, - # and quoting something back in different case than they wrote reads - # like a different entry. - m = re.search(r"^\s*(" + re.escape(key) + r")\b", exposed_terms or "", - re.I | re.M) - notes_out.append( - f"exposed_terms entry '{m.group(1) if m else key}' matches no character " - f"and no pronoun, so it never applies{hint}") - return blocks - - -# --- VRAM helpers ---------------------------------------------------------- -def vram_gb(device=None): - try: - dev = device or mm.get_torch_device() - total, free = mm.get_total_memory(dev) / GB, mm.get_free_memory(dev) / GB - if total > 0: - return round(total, 2), round(free, 2) - except Exception: - pass - try: - if torch.cuda.is_available(): - fb, tb = torch.cuda.mem_get_info() - return round(tb / GB, 2), round(fb / GB, 2) - except Exception: - pass - return 0.0, 0.0 - - -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 hands - ComfyUI its aggressive cache purge, then empties the CUDA allocator's cached - blocks and IPC handles. Cheap relative to sampling; called once per beat.""" - gc.collect() - try: - mm.soft_empty_cache(True) # aggressive (unload_all_models path) - except TypeError: - mm.soft_empty_cache() - try: - if torch.cuda.is_available(): - torch.cuda.empty_cache() - torch.cuda.ipc_collect() - except Exception: - pass - - -def dit_resident_gb(model): - """Loaded model weight size in GB, using ComfyUI's OWN accounting so the - figure matches how ComfyUI itself budgets VRAM and counts packed/quantized - dtypes correctly. The old raw numel*element_size sum over DiT params - over-counts NVFP4/FP8 (it reads unpacked shapes + scale tensors) -- that's - what produced the impossible 61.7GB reading. Falls back progressively.""" - # 1) ModelPatcher.model_size() -- authoritative, same call ComfyUI budgets with - try: - sz = model.model_size() - if sz and sz > 0: - return round(sz / GB, 2) - except Exception: - pass - # 2) model_management.module_size on the inner model - try: - inner = getattr(model, "model", None) - if inner is not None and hasattr(mm, "module_size"): - sz = mm.module_size(inner) - if sz and sz > 0: - return round(sz / GB, 2) - except Exception: - pass - # 3) last resort: raw param sum (over-counts quant, but non-zero) - dm = getattr(getattr(model, "model", None), "diffusion_model", None) - if dm is not None and hasattr(dm, "parameters"): - try: - return round(sum(p.numel() * p.element_size() for p in dm.parameters()) / GB, 2) - except Exception: - pass - return 0.0 - - -def estimate_shot_frames(total_gb, resident_gb, headroom_gb, pixels=None, free_gb=None): - """Largest grid-aligned shot length the card can attempt. - - Budgets from CARD CAPACITY minus measured weight size -- deliberately NOT from - instantaneous free VRAM. Free VRAM is read at one moment during graph execution, - and whatever is resident right then (the checkpoint, the text encoder, a LoRA's - adapters, another node's leftovers) makes it read far lower than the memory - actually available across the render. That produced a real failure: a 13.6GB - checkpoint at 640p floored to 124f/5s even though a forced 10s shot ran fine, - peaking at 15.2GB on a 15.9GB card and settling at 11.2GB. Capacity minus - weights is stable regardless of when the node happens to run. - - Model-agnostic: the only model-dependent input is resident_gb (ComfyUI's own - accounting), so NVFP4 / FP8 / INT8 / GGUF / BF16 all flow through the same - arithmetic -- a heavier checkpoint leaves less room and yields shorter shots. - The rest is the latent + activations, which scale with pixels x frames, so - `pixels` normalizes any resolution back to the native reference. - - Continuous fit, anchored to MEASURED points on a 16GB card: - 1344x768, ~11.7GB NVFP4 -> 243f fits; 362f overflowed by ~4.3GB - 640p, ~13.6GB HQ -> 10s (243f) fits, peak 15.2GB - free_gb is still accepted (callers pass it) but is used only as a sanity floor: - if the card is genuinely almost full right now, don't promise a long shot.""" - floor = align_frame_count(MIN_SHOT_FRAMES) - if total_gb <= 0: - return floor - avail = total_gb - resident_gb - headroom_gb - if resident_gb >= total_gb: - # STREAMING REGIME. model_size() reports the whole checkpoint, but a checkpoint - # larger than the card is never all resident: ComfyUI streams it, so the weight - # figure is NOT what occupies VRAM and cannot be subtracted from capacity. Doing - # that arithmetic anyway drove the budget deeply negative and floored every shot - # to 124f/~5s on a card that was demonstrably not running out -- a 44.3GB MXFP8 - # build on a 15.9GB card sampled 243f at 768x768 without exceeding VRAM. - # - # There is no meaningful "capacity minus weights" here, so budget from the LIVE - # free reading instead: it measures what is actually unoccupied right now, which - # in this regime is the only number that means anything. Without a reading there - # is nothing to go on, so fall back to the floor. - if not free_gb or free_gb <= 0: - return floor - avail = max(0.0, free_gb * (1.0 - SPIKE_RESERVE) - headroom_gb) - # avail <= 0 here means the weights FIT but the safety headroom eats what is left. - # That is not the same thing, and it used to floor every shot to 124f/~5s no matter - # what -- including at the fast 512 tier, where a frame costs a quarter as much. - # Two dialogue beats came out at ~5s each on a card that could hold far more. The - # baseline term below already represents the latent that fits in space the weight - # accounting has covered, so let the arithmetic run instead of bailing out. - if avail > 0 and pixels and pixels > 0: - # Lower res -> effectively more room. Only ever applied to a POSITIVE surplus: - # a deficit is weights that do not fit, which no resolution can shrink, and - # scaling it would perversely make lower resolutions look worse. - avail *= NATIVE_PIXELS / float(pixels) - frames = FRAMES_PER_GB * (avail + FRAMES_BASELINE_GB) - # Sanity floor from a LIVE reading: capacity-minus-weights is the right basis - # (see above), but if the card is genuinely almost empty right now -- another - # app holding VRAM, a model that failed to unload -- do not promise a long - # shot on paper. Only ever REDUCES the estimate; it can never raise it, so a - # momentarily low reading during model load can't floor the budget the way - # budgeting from free_gb directly used to. - if free_gb is not None and free_gb > 0: - live = FRAMES_PER_GB * ((free_gb * (1.0 - SPIKE_RESERVE)) + FRAMES_BASELINE_GB) - if pixels and pixels > 0: - live = FRAMES_PER_GB * (((free_gb * (1.0 - SPIKE_RESERVE)) - * (NATIVE_PIXELS / float(pixels))) + FRAMES_BASELINE_GB) - frames = min(frames, live) - frames = max(MIN_SHOT_FRAMES, min(H3_MAX_FRAMES, int(frames))) - return max(floor, align_frame_count(min(H3_MAX_FRAMES, frames))) - - -def resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb, headroom_gb, - pixels=None, free_gb=None): - """Returns (frames, note). - - Auto mode (shot_seconds <= 0): frames = the VRAM budget estimate (resolution- - scaled). Forced mode: the requested length is honored as requested. When VRAM - is unknown the request is also honored.""" - budget = estimate_shot_frames(total_gb, resident_gb, headroom_gb, pixels, free_gb) - if not (shot_seconds and float(shot_seconds) > 0): - return budget, "" - requested = align_frame_count(min(H3_MAX_FRAMES, max(5, round(float(shot_seconds) * fps)))) - if total_gb <= 0: - return requested, "" - if requested > budget: - return requested, (f"requested {requested}f (~{requested/max(1,fps):.1f}s) exceeds the " - f"~{budget}f VRAM budget -- honoring it; may spill to system RAM " - f"(slow) or OOM") - return requested, "" - - -def _is_oom(e): - return isinstance(e, torch.cuda.OutOfMemoryError) or "out of memory" in str(e).lower() - - -# --- conditioning + decode ------------------------------------------------- -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 _decode_video(vae, out_latent, tiled, free_first=None, tile_t=None, tile_xy=None): - """Decode the video latent. If `free_first` is the diffusion model, unload it - first: sampling is finished, and the ~5GB video VAE needs the room. Leaving the - DiT (plus resident bypass-LoRA adapters) on the card while the VAE loads is a - second ratchet -- ComfyUI would otherwise evict reactively, after spilling.""" - if free_first is not None: - try: - mm.free_memory(1e30, mm.get_torch_device(), keep_loaded=[]) - except Exception: - pass - latent = out_latent["samples"] - if latent.is_nested: - latent = latent.unbind()[0] - if 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 = {} - 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 - - -# One second of encoded silence, made once per process and tiled to any length. -# Module-level so it outlives the node instance, which ComfyUI recreates per run. -_SILENT_UNIT = {"lat": None} - - -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. - - Seeding the keyframe's audio channel with encoded silence anchors that stream - instead. comfy/ldm/minimax/audio_vae.py encode() takes stereo [B, 2, L] at - 32 kHz and returns [B, 32, 2, T] on the same 40 Hz grid temporal_shape() uses. - - Everything here is defensive. The shape is CHECKED against what the layout - expects rather than assumed, and any failure returns None so the shot falls - back to today's behaviour instead of breaking the render.""" - 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 - unit = _SILENT_UNIT.get("lat") - if unit 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 the - # first version did exactly that: swallowed by the guard below, so the - # whole layer silently did nothing. - # - # ONE SECOND, encoded ONCE. Silence is homogeneous, so the result tiles - # along time -- and 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, 2))) - if enc is None or enc.dim() != 4 or enc.shape[1] != 32 or enc.shape[-1] < 3: - return None - # ONE STEADY FRAME, from the MIDDLE. The encoder's zero-padding leaves - # heavy edge artifacts -- measured deviation 0.351 at the first and last - # frames against 0.002 in the interior, ~170x -- and tiling the whole - # second therefore stamped a spike every 40 latent frames, which at 40Hz - # is once per SECOND. That is a metronome in the audio conditioning of a - # joint audio-video model, and the picture lip-syncs to it. Repeating a - # single interior frame gives conditioning that is genuinely constant. - mid = enc.shape[-1] // 2 - _SILENT_UNIT["lat"] = enc[..., mid:mid + 1].detach().to("cpu").clone() - unit = _SILENT_UNIT["lat"] - if unit.shape[-1] != 1: - return None - return unit.repeat(1, 1, 1, want_t).clone() - except Exception: - return None # never fail a render for a nicety - - -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} - - -def _copy_sample_latent(out_latent): - """Detach a sampled latent to CPU without changing its layout.""" - raw = out_latent.get("samples") if isinstance(out_latent, dict) else None - if raw is None: - return None - try: - parts = raw.unbind() if hasattr(raw, "unbind") else None - return ([t.detach().to("cpu", copy=True) for t in parts] - if parts else raw.detach().to("cpu", copy=True)) - except Exception: - return None - - -def _retarget_conditioning_spatial(cond, latent_h, latent_w): - """Resize H3 keyframe latents in existing conditioning to a new latent grid.""" - latent_h = int(latent_h) - latent_w = int(latent_w) - if latent_h <= 0 or latent_w <= 0: - raise RuntimeError("conditioning target latent size must be positive") - out = [] - for item in cond: - try: - tensor, data = item - except Exception: - out.append(item) - continue - nd = dict(data) - keyframes = nd.get("minimax_keyframes") - if keyframes: - resized_keyframes = [] - for keyframe in keyframes: - nkf = dict(keyframe) - latent_value = nkf.get("latent") - if latent_value is not None and len(getattr(latent_value, "shape", ())) >= 5: - if latent_value.shape[3] != latent_h or latent_value.shape[4] != latent_w: - b, c, t, h, w = latent_value.shape - resized = torch.nn.functional.interpolate( - latent_value.to(torch.float32).reshape(b * t, c, h, w), - size=(latent_h, latent_w), - mode="bilinear", - align_corners=False, - ).reshape(b, c, t, latent_h, latent_w) - nkf["latent"] = resized.to(device=latent_value.device, dtype=latent_value.dtype) - resized_keyframes.append(nkf) - nd["minimax_keyframes"] = resized_keyframes - out.append([tensor, nd]) - return out - - -def _pad_to_h3_patch_size(tensor): - try: - import comfy.ldm.common_dit as common_dit - return common_dit.pad_to_patch_size(tensor, (1, 2, 2)) - except Exception: - return tensor - - -def _crop_conditioning_to_tile(cond, source_h, source_w, row, col, tile_h, tile_w): - """Crop H3 keyframe latents in existing conditioning for a spatial tile.""" - out = [] - for item in cond: - try: - tensor, data = item - except Exception: - out.append(item) - continue - nd = dict(data) - keyframes = nd.get("minimax_keyframes") - if keyframes: - cropped_keyframes = [] - for keyframe in keyframes: - nkf = dict(keyframe) - latent_value = nkf.get("latent") - if latent_value is not None and len(getattr(latent_value, "shape", ())) >= 5: - kh, kw = latent_value.shape[3], latent_value.shape[4] - if kh != source_h or kw != source_w: - b, c, t, h, w = latent_value.shape - latent_value = torch.nn.functional.interpolate( - latent_value.to(torch.float32).reshape(b * t, c, h, w), - size=(source_h, source_w), - mode="bilinear", - align_corners=False, - ).reshape(b, c, t, source_h, source_w).to( - device=latent_value.device, - dtype=latent_value.dtype, - ) - nkf["latent"] = _pad_to_h3_patch_size( - latent_value[:, :, :, row:row + tile_h, col:col + tile_w].contiguous() - ) - cropped_keyframes.append(nkf) - nd["minimax_keyframes"] = cropped_keyframes - out.append([tensor, nd]) - return out - - -def _latent_with_replaced_samples(template_latent, sampled_latent): - """Reuse the original latent payload, but swap in freshly sampled tensors.""" - if not isinstance(template_latent, dict): - return sampled_latent - out = dict(template_latent) - if isinstance(sampled_latent, dict): - for key, value in sampled_latent.items(): - if key != "samples" and key not in out: - out[key] = value - if "samples" in sampled_latent: - out["samples"] = sampled_latent["samples"] - return out - return sampled_latent - - -def _video_only_refined_latent(base_latent, refined_latent): - """Keep the refined video latent, but preserve the original audio latent.""" - base = base_latent.get("samples") if isinstance(base_latent, dict) else None - refined = refined_latent.get("samples") if isinstance(refined_latent, dict) else None - if base is None or refined is None: - return refined_latent - if not getattr(base, "is_nested", False) or not getattr(refined, "is_nested", False): - return refined_latent - try: - base_parts = base.unbind() - refined_parts = refined.unbind() - if len(base_parts) >= 2 and len(refined_parts) >= 1: - return {"samples": comfy.nested_tensor.NestedTensor((refined_parts[0], base_parts[-1]))} - except Exception: - return refined_latent - return refined_latent - - -def _latent_upscale_target_size(base_w, base_h, param): - width = int(param.get("width", 0) or 0) - height = int(param.get("height", 0) or 0) - if width > 0 and height > 0: - return width, height - megapixels = float(param.get("megapixels", 0.0) or 0.0) - if megapixels > 0: - return scale_to_megapixels(base_w, base_h, megapixels) - return int(base_w), int(base_h) - - -def _latent_upscale_mode(param): - if not isinstance(param, dict): - return "off" - mode = str(param.get("mode") or "").strip() - if mode: - return mode - if "model_name" in param and str(param.get("model_name") or "").strip() not in ("", "none"): - return "model" - if "method" in param: - return "interp" - return "off" - - -def _latent_spatial_grid(h, w, th, tw, ol_h, ol_w): - if th <= 0 or tw <= 0: - raise ValueError("tile dimensions must be positive") - if ol_h >= th or ol_w >= tw: - raise ValueError("overlap must be smaller than the tile size") - sh = th - ol_h - sw = tw - ol_w - nrows = 1 if h <= th else math.ceil((h - ol_h) / sh) - if (nrows - 1) * sh + th < h: - nrows += 1 - ncols = 1 if w <= tw else math.ceil((w - ol_w) / sw) - if (ncols - 1) * sw + tw < w: - ncols += 1 - rows = [i * sh for i in range(nrows)] - cols = [j * sw for j in range(ncols)] - trows = [min(th, h - r) for r in rows] - tcols = [min(tw, w - c) for c in cols] - return rows, cols, trows, tcols - - -def _latent_spatial_blend_weights(t, overlap_mode, overlap_blend="linear"): - if overlap_blend == "overwrite": - return torch.ones_like(t) if overlap_mode == "later" else torch.zeros_like(t) - if overlap_blend == "midpoint": - base = (t >= 0.5).to(t.dtype) - elif overlap_blend == "smoothstep": - base = t * t * (3.0 - 2.0 * t) - else: - base = t - if overlap_mode == "later": - return base - return 1.0 - base - - -def _param_value(mapping, key, default): - value = mapping.get(key, default) - return default if value is None else value - - -def _tag_oom_stage(exc, stage): - if _is_oom(exc): - exc._h3_stage = stage - return exc - - -def _grid_1d(size, tile, ol, min_tile): - if size <= tile: - return [0], [size], [0] - sh = tile - ol - n = math.ceil((size - ol) / sh) - if (n - 1) * sh + tile < size: - n += 1 - rows = [i * sh for i in range(n)] - trows = [min(tile, size - r) for r in rows] - if min_tile > 0 and n >= 2: - edge = size - rows[-1] - if edge < min_tile: - new_last = size - min_tile - if rows[-2] < new_last < rows[-2] + trows[-2]: - rows[-1] = new_last - trows[-1] = size - new_last - ovl = [0] * n - for i in range(1, n): - ovl[i] = max(0, rows[i - 1] + trows[i - 1] - rows[i]) - return rows, trows, ovl - - -def compute_spatial_grid(h, w, th, tw, ol_h, ol_w, min_th=0, min_tw=0): - if th <= 0 or tw <= 0: - raise ValueError("tile dimensions must be positive") - if ol_h >= th or ol_w >= tw: - raise ValueError("overlap must be smaller than the tile size") - if min_th < 0 or min_tw < 0: - raise ValueError("minimum tile size must be non-negative") - if min_th > th or min_tw > tw: - raise ValueError("minimum tile size must not exceed the tile size") - rows, trows, row_ovl = _grid_1d(h, th, ol_h, min_th) - cols, tcols, col_ovl = _grid_1d(w, tw, ol_w, min_tw) - return rows, cols, trows, tcols, row_ovl, col_ovl - - -def spatial_fade_mask(tile_h, tile_w, ol_h, ol_w, done_top, done_left, fade_h=0, fade_w=0): - mask = torch.ones(tile_h, tile_w, dtype=torch.float32) - if done_left and ol_w > 0: - if fade_w == 0: - mask[:, :ol_w] = 0.0 - else: - f = min(fade_w, ol_w) - frozen_w = ol_w - f - w = torch.linspace(0.0, 1.0, f) - mask[:, :frozen_w] = 0.0 - mask[:, frozen_w:ol_w] = torch.minimum(mask[:, frozen_w:ol_w], w[None, :]) - if done_top and ol_h > 0: - if fade_h == 0: - mask[:ol_h, :] = 0.0 - else: - f = min(fade_h, ol_h) - frozen_h = ol_h - f - w = torch.linspace(0.0, 1.0, f) - mask[:frozen_h, :] = 0.0 - mask[frozen_h:ol_h, :] = torch.minimum(mask[frozen_h:ol_h, :], w[:, None]) - return mask - - -def _fade_band(band, fade, axis): - n = band.shape[axis] - f = min(int(fade), n) - if f == 0: - band[:] = 0.0 - return - w = torch.linspace(0.0, 1.0, f, dtype=band.dtype, device=band.device) - frozen = n - f - if axis == 1: - w = w[None, :] - band[:, :frozen] = torch.minimum( - band[:, :frozen], torch.zeros(frozen, dtype=band.dtype, device=band.device) - ) - band[:, frozen:] = torch.minimum(band[:, frozen:], w) - else: - w = w[:, None] - band[:frozen, :] = torch.minimum( - band[:frozen, :], torch.zeros((frozen, 1), dtype=band.dtype, device=band.device) - ) - band[frozen:, :] = torch.minimum(band[frozen:, :], w) - - -def make_fade_mask(tile_h, tile_w, ol_h, ol_w, done_top, done_left, fade_h=0, fade_w=0): - mask = torch.ones(tile_h, tile_w, dtype=torch.float32) - if done_left and ol_w > 0: - _fade_band(mask[:, :ol_w], fade_w, 1) - if done_top and ol_h > 0: - _fade_band(mask[:ol_h, :], fade_h, 0) - return mask - - -def bright_match_tile(tile, ref, clamp=0.05): - d = (tile - ref).float().reshape(tile.shape[0], tile.shape[1], tile.shape[2], -1) - dc = d.median(dim=-1).values.clamp(-clamp, clamp) - return tile - dc.to(tile.dtype).view(tile.shape[0], tile.shape[1], tile.shape[2], 1, 1) - - -def _dynamic_fade_closure(sp, fw, fh, tr, tc, tr_s, tc_s, ovh, ovw, done_top, done_left, video_flat, mn=0.0): - schedule = sp.get("dynamic_fade", "off") - if schedule == "off": - return None - fmin_w = int(sp.get("dynamic_fade_min", 0)) // 16 - fmin_h = int(sp.get("dynamic_fade_min", 0)) // 16 - if fw <= fmin_w and fh <= fmin_h: - return None - fw_start, fh_start = max(fw, 0), max(fh, 0) - fmin_w, fmin_h = min(fmin_w, fw_start), min(fmin_h, fh_start) - done_top = done_top and ovh > 0 - done_left = done_left and ovw > 0 - s_tok = tr_s * tc_s - n_frames = video_flat // s_tok - - def fade_at(p): - if schedule == "widening": - return fmin_w + (fw_start - fmin_w) * p, fmin_h + (fh_start - fmin_h) * p - return fw_start - (fw_start - fmin_w) * p, fh_start - (fh_start - fmin_h) * p - - cache = {} - - def step_fn(sigma, denoise_mask, **kwargs): - sigmas = kwargs.get("extra_options", {}).get("sigmas") - n_sigmas = int(sigmas.numel()) if sigmas is not None else 0 - masks = cache.get(n_sigmas) - if masks is None: - step_count = max(n_sigmas - 1, 1) - masks = [] - for i in range(n_sigmas - 1): - p = i / (step_count - 1) if step_count > 1 else 0.0 - cw, ch = fade_at(p) - m = make_fade_mask(tr_s, tc_s, ovh, ovw, done_top, done_left, - fade_h=round(ch), fade_w=round(cw)) - m[tr:tr_s, :] = 0.0 - m[:, tc:tc_s] = 0.0 - if mn > 0: - m = m + mn * (1.0 - m) - masks.append(m) - cache[n_sigmas] = masks - idx = 0 - if sigmas is not None: - idx = int((sigmas > sigma + 1e-6).sum()) - m = masks[min(idx, len(masks) - 1)] - flat = denoise_mask.clone() - flat.reshape(denoise_mask.shape[0], -1)[:, :video_flat] = \ - m.reshape(1, -1).repeat(denoise_mask.shape[0], n_frames) - return flat - - return step_fn - - -def _nested_tensor_parts(samples): - if samples is None: - return () - parts = getattr(samples, "tensors", None) - if parts is not None: - return tuple(parts) - if hasattr(samples, "unbind"): - try: - return tuple(samples.unbind()) - except Exception: - return () - return () - - -def _coerce_bool_flag(value): - if isinstance(value, str): - text = value.strip().lower() - if text in ("", "0", "false", "no", "off", "none", "null"): - return False - if text in ("1", "true", "yes", "on"): - return True - return bool(value) - - -def _format_elapsed_seconds(seconds): - seconds = max(0.0, float(seconds or 0.0)) - if seconds >= 3600: - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = seconds % 60 - return f"{hours}h{minutes:02d}m{secs:04.1f}s" - if seconds >= 60: - minutes = int(seconds // 60) - secs = seconds % 60 - return f"{minutes}m{secs:04.1f}s" - return f"{seconds:0.1f}s" - - -def _format_timing_note(shot_timings): - if not shot_timings: - return "" - totals = { - "total": 0.0, - "retry_elapsed": 0.0, - "sample": 0.0, - "latent_upscale_sample": 0.0, - "decode_video": 0.0, - "decode_audio": 0.0, - "cleanup": 0.0, - "retries": 0, - } - slowest = None - for shot in shot_timings: - totals["total"] += float(shot.get("total", 0.0) or 0.0) - totals["retry_elapsed"] += float(shot.get("retry_elapsed", 0.0) or 0.0) - totals["sample"] += float(shot.get("sample", 0.0) or 0.0) - totals["latent_upscale_sample"] += float(shot.get("latent_upscale_sample", 0.0) or 0.0) - totals["decode_video"] += float(shot.get("decode_video", 0.0) or 0.0) - totals["decode_audio"] += float(shot.get("decode_audio", 0.0) or 0.0) - totals["cleanup"] += float(shot.get("cleanup", 0.0) or 0.0) - totals["retries"] += max(0, int(shot.get("attempts", 1) or 1) - 1) - if slowest is None or float(shot.get("total", 0.0) or 0.0) > float(slowest.get("total", 0.0) or 0.0): - slowest = shot - pieces = [ - f"timing: {len(shot_timings)} shot(s) total {_format_elapsed_seconds(totals['total'])}", - f"sample {_format_elapsed_seconds(totals['sample'])}", - f"decode video {_format_elapsed_seconds(totals['decode_video'])}", - f"decode audio {_format_elapsed_seconds(totals['decode_audio'])}", - f"cleanup {_format_elapsed_seconds(totals['cleanup'])}", - ] - if totals["retry_elapsed"]: - pieces.append(f"retry elapsed {_format_elapsed_seconds(totals['retry_elapsed'])}") - if totals["latent_upscale_sample"]: - pieces.append(f"latent upscale {_format_elapsed_seconds(totals['latent_upscale_sample'])}") - if totals["retries"]: - pieces.append(f"retries {totals['retries']}") - if slowest is not None: - pieces.append( - f"slowest shot {int(slowest.get('shot', 0) or 0)} {_format_elapsed_seconds(slowest.get('total', 0.0))}" - ) - return "; ".join(pieces) - - -# --- ref2va reference conditioning ---------------------------------------- -# H3's reference pipeline encodes a reference image at up to a 2048 short edge. -# Reference rows ride through EVERY sampling step, so this is also the setting -# that decides how much the references cost per step. -REF_IMAGE_SHORT_EDGE = 2048 -CANVAS_MULTIPLE = 32 - - -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 _build_ref_images(vae, images, gen_w, gen_h, mode="match"): - """(tokenizer items, DiT blocks) for a list of reference images. - - The tokenizer labels each one `:` 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 source in images: - img = _reference_image(source) - 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 _build_shot_conditioning(clip, vae, prompt, width, height, length, fps, handoff, - ref_images=None, ref_image_size="match", ref_noise_aug=None, - audio_vae=None, silent=False): - latent, fc = _empty_av_latent(width, height, length, fps) - refs = [r for r in (ref_images or []) if _reference_image(r) is not None] - if refs: - # ref2va: this shot is reference-conditioned rather than keyframe-conditioned, - # and run() decides which per shot. A tagged shot is handed the previous - # frame as an extra REFERENCE so a tag never reads as a cut. - # - # WHY it is either/or is now historical. On ComfyUI 0.30 the two channels - # could not ride together: model_base.py filled ONE `cond_video_latents` - # list and the refs branch OVERWROTE whatever the keyframe branch had put - # there, while PackedLayout still laid out rows for both -- so the row count - # and the latent count disagreed, landing as a shape error deep in the DiT - # or feeding keyframe rows a reference's latent. - # - # ComfyUI 0.31+ concatenates instead: - # payload["cond_video_latents"] = payload.get("cond_video_latents", []) + [...] - # and PackedLayout appends keyframe segments before ref segments, so the two - # orders agree and both channels coexist. A tagged shot therefore takes its - # references AND a real keyframe: the keyframe ANCHORS the first frame, - # which is what continuity needs, while a reference only supplies identity. - # Passing the handoff as a reference (the 0.30 workaround) asked the model to - # look like the previous frame rather than to start from it. - items, blocks = _build_ref_images(vae, refs, width, height, ref_image_size) - # The handoff has to reach the TEXT ENCODER as well as the DiT. minimax.py's - # tokenize_with_weights is EITHER/OR (:158-184): passing minimax_ref_items - # makes it ignore `images` entirely. So on this path the VLM saw only the - # identity references and never the previous frame -- it was told the location - # in words and given a latent anchor at frame 0, but nothing showed it where - # the shot left off, so it re-imagined the scenery. Same place, new scene: - # the keyframe alone anchors the first frame without describing it. - # - # The keyframe-only path has always shown it as `` via images=, so - # this is the same convention. Appended AFTER the references, leaving their - # numbers -- which the prompt's tags refer to -- untouched. - hand_img = None - if handoff is not None and keyframe_rides_with_refs(ref_noise_aug): - hand_img = _resize(handoff[:1], width, height, "disabled") - items = items + [{"type": "image", "data": hand_img}] - tokens = clip.tokenize(prompt, minimax_ref_items=items) - cond = clip.encode_from_tokens_scheduled(tokens) - vals = {} - if blocks: - vals["minimax_refs"] = blocks - # How CLEAN the reference is presented as. The DiT both blends the - # condition latent with noise at (1 - aug) and labels those rows with a - # timestep of max(t_video, aug) -- so the model default of 0.999 hands it - # a finished, noise-free image, which is an invitation to REPRODUCE it - # rather than to take an identity from it. Lower says "approximate". - # - # It is a payload-level value, so it reaches the keyframe rows too. That - # is why the handoff stays out of the ref channel: a keyframe softened to - # 0.90 would stop anchoring, and continuity is exactly what it is for. - if ref_noise_aug is not None: - vals["minimax_visual_cond_noise_aug"] = float(ref_noise_aug) - # Enforced here as well as in run(): one aug covers every cond latent, so a - # softened reference would soften the anchor. Refusing at the source means no - # caller can assemble that combination by accident. - kfs = [] - if hand_img is not None: - # Same tensor the tokenizer was given, encoded once: the VLM is shown the - # frame and the DiT is anchored to it, which is what a seamless cut needs. - kfs.append({"resolved_frame_index": 0, "latent": vae.encode(hand_img)}) - # A reference-conditioned shot needs the silence anchor just as much as a - # keyframe-conditioned one, and it used to get NOTHING: `silent` was only - # honoured on the keyframe-only path below, so wiring any ref_image made the - # whole mechanism dead code and non-dialogue shots babbled again. - # - # It has to hang on a KEYFRAME, not a ref block. model_base.py:2174 does put a - # ref's audio_latent into cond_audio_latents, but PackedLayout only emits - # ref_audio rows for blocks of kind audio/video/video_audio (model.py:379-399) - # -- an "image" ref gets none -- so the latent list and the row layout would - # disagree and land as a shape error inside the DiT. - kfs = _attach_silence(kfs, audio_vae, fc, fps, silent) - if kfs: - vals["minimax_keyframes"] = kfs - vals["minimax_frame_count"] = fc - if vals: - cond = node_helpers.conditioning_set_values(cond, vals) - return cond, latent - # (fall through to the keyframe-only path below) - images, keyframes = [], [] - if handoff is not None: - img = _resize(handoff[:1], width, height, "disabled") - images.append(img) - keyframes.append({"resolved_frame_index": 0, "image": img}) - tokens = clip.tokenize(prompt, images=images) - cond = clip.encode_from_tokens_scheduled(tokens) - for kf in keyframes: - kf["latent"] = vae.encode(kf.pop("image")) - # Outside the `if keyframes:` it used to sit inside. A shot with no handoff -- - # the FIRST shot of every chain -- has no keyframe, so the silence anchor was - # skipped there too, on exactly the shot that sets the tone for the rest. - keyframes = _attach_silence(keyframes, audio_vae, fc, fps, silent) - if keyframes: - cond = node_helpers.conditioning_set_values(cond, {"minimax_keyframes": keyframes, "minimax_frame_count": fc}) - return cond, latent - - -def _attach_silence(keyframes, audio_vae, fc, fps, silent): - """Anchor this shot's audio channel to real silence. Returns the keyframe list. - - H3 is JOINT -- the mouth follows the audio branch -- so an unconditioned audio - stream invents a voice and the picture lip-syncs to it. A "lips closed" sentence - in the prompt cannot outvote a stream that has already decided someone is - speaking; this conditions the stream itself. - - Hangs on a keyframe because that is the only carrier PackedLayout emits matching - `cond_audio` rows for (model.py:354-361). When the shot has no keyframe -- the - first shot of a chain, or a ref-conditioned shot -- an AUDIO-ONLY keyframe is - appended: model_base.py:2168 filters video latents on `latent` and audio on - `audio_latent` independently, and model.py:345 skips the video segment when - `latent` is absent, so a dict carrying only audio produces exactly one cond_audio - segment and no video rows. - """ - if not silent or audio_vae is None: - return keyframes - sil = _silent_audio_latent(audio_vae, fc, fps) - if sil is None: - return keyframes - if keyframes: - keyframes[0]["audio_latent"] = sil # one bed, on the first carrier only - else: - keyframes = [{"resolved_frame_index": 0, "audio_latent": sil}] - return keyframes - - -# Below this, softening the references would soften the handoff KEYFRAME with them. -# visual_cond_noise_aug is a single payload value applied to every cond video latent -# (ldm/minimax/model.py:502-510) and it labels both segments the same way (:584: -# "cond": max(t_v, vis_aug), "ref_img": max(t_v, vis_aug)). There is no per-channel -# control, so a lowered ref_noise_aug would blend the anchor frame with noise and -# push its timestep up -- destroying exactly the continuity the keyframe is for. -KEYFRAME_SAFE_AUG = 0.99 - - -def keyframe_rides_with_refs(ref_noise_aug): - """Can a tagged shot carry its handoff as a real KEYFRAME alongside references? - - Yes at the default aug, where the keyframe passes through essentially untouched. - No once the user has softened the references, because the same value would soften - the keyframe -- there the handoff falls back to riding as an extra reference, - which is weaker for continuity but leaves nothing to compromise.""" - return ref_noise_aug is None or float(ref_noise_aug) >= KEYFRAME_SAFE_AUG - - -# , , -- all the ways people write the tag. -_PICTURE_TAG = re.compile(r"<\s*picture[\s_\-]*(\d+)\s*>", re.I) - - -def picture_tags(text): - """The reference slots a shot's text asks for, in ascending order.""" - return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")}) - - -def _reference_slot(ref, slot_index=None): - return _image_nodes.normalize_reference(ref, picture_id=slot_index, allow_image_fallback=True) - - -def _reference_image(ref): - try: - if isinstance(ref, dict): - normalized = _image_nodes.normalize_reference(ref, allow_image_fallback=False) - else: - normalized = _reference_slot(ref) - except Exception: - return None - return normalized.get("image") - - -def _reference_text(value): - return " ".join(str(value or "").split()).strip() - - -def _reference_sentence(value): - text = _reference_text(value) - if text and text[-1] not in ".!?": - text += "." - return text - - -def _reference_positive_int(value): - text = _reference_text(value) - if not text: - return None - try: - parsed = int(text) - except (TypeError, ValueError): - return None - return parsed if parsed > 0 else None - - -def _reference_height_text(facts): - feet = _reference_text((facts or {}).get("height_feet")) - inches = _reference_text((facts or {}).get("height_inches")) - if feet and inches: - return f"{feet} foot {inches}" - if feet: - return f"{feet} foot" - if inches: - return f"{inches} inch" - return "" - - -def _reference_fact_sentence(ref, label): - facts = dict(ref.get("facts") or {}) - bits = [] - gender = _reference_text(facts.get("gender")) - age = _reference_positive_int(facts.get("age")) - nationality = _reference_text(facts.get("nationality")) - occupation = _reference_text(facts.get("occupation")) - accent = _reference_text(facts.get("accent")) - height = _reference_height_text(facts) - aliases = [_reference_text(alias) for alias in (ref.get("aliases") or []) if _reference_text(alias)] - if aliases: - bits.append(f"also known as {aliases[0]}") - if gender: - bits.append(gender) - if age is not None: - bits.append(f"{age} years old") - if nationality: - bits.append(nationality) - if occupation: - bits.append(f"works as {occupation}") - if height: - bits.append(f"{height} tall") - if accent: - bits.append(f"speaks with a {accent} accent") - if not bits: - return "" - return f"Character facts for {label}: " + ", ".join(bits) + "." - - -def _reference_name_keys(ref): - names = [] - for key in ("name", "id"): - value = _reference_text(ref.get(key)) - if value: - names.append(value) - for alias in ref.get("aliases") or []: - value = _reference_text(alias) - if value: - names.append(value) - seen = set() - out = [] - for name in names: - key = name.lower() - if key in seen: - continue - seen.add(key) - out.append(name) - return out - - -def _normalized_ref_slots(ref_slots): - """Normalize every connected ref once so downstream helpers can reuse them.""" - out = [] - for slot_number, raw in enumerate(ref_slots or [], 1): - if raw is None: - out.append(None) - elif isinstance(raw, dict) and "image" in raw and raw.get("image") is None: - out.append(_image_nodes.normalize_reference(raw, picture_id=slot_number, allow_image_fallback=False)) - else: - out.append(_reference_slot(raw, slot_number)) - return tuple(out) - - -def _slot_refs_for_text(text, ref_slots): - refs = [] - for slot_number in picture_tags(text): - if not (1 <= slot_number <= len(ref_slots or [])): - continue - raw = ref_slots[slot_number - 1] - ref = _reference_slot(raw, slot_number) - if _reference_image(ref) is None: - continue - refs.append((slot_number, ref)) - return refs - - -def _named_character_refs_for_text(text, ref_slots): - return _named_refs_for_text(text, ref_slots, kinds=("character",)) - - -def _named_refs_for_text(text, ref_slots, kinds=("character",)): - """Backward-compatible alias for older call sites. - - The script annotation path still expects the shorter helper name, while the - newer reference matcher keeps the more explicit character-specific helper. - """ - kinds = tuple(dict.fromkeys(kinds or ())) - if not kinds: - return [] - haystack = str(text or "") - kind_hits = {kind: [] for kind in kinds} - name_hits = {kind: {} for kind in kinds} - for slot_number, raw in enumerate(ref_slots or [], 1): - ref = _reference_slot(raw, slot_number) - kind = ref.get("kind") - if kind not in kind_hits or _reference_image(ref) is None: - continue - matched_names = [] - for name in _reference_name_keys(ref): - if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I): - matched_names.append(name.lower()) - name_hits[kind].setdefault(name.lower(), []).append((slot_number, ref)) - if matched_names: - kind_hits[kind].append((slot_number, ref, tuple(set(matched_names)))) - matched = [] - seen = set() - for kind in kinds: - for slot_number, ref, matched_names in kind_hits.get(kind, []): - if not any(len(name_hits[kind].get(name, [])) == 1 for name in matched_names): - continue - marker = id(ref) - if marker in seen: - continue - seen.add(marker) - matched.append((slot_number, ref)) - return matched - - -def _matched_reference_slots(text, ref_slots): - matched = [] - seen = set() - for slot_number, ref in _slot_refs_for_text(text, ref_slots) + _named_refs_for_text(text, ref_slots, kinds=("character", "location")): - if slot_number in seen: - continue - seen.add(slot_number) - matched.append((slot_number, ref)) - return matched - - -def _resolved_picture_number(ref, resolved_refs): - target_image = _reference_image(ref) - for picture_number, active_ref in enumerate(resolved_refs or [], 1): - if active_ref is ref: - return picture_number - active_image = _reference_image(active_ref) - if target_image is not None and active_image is target_image: - return picture_number - return None - - -def _resolved_ref_label(ref, slot_number, resolved_refs=None): - name = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}" - if not resolved_refs: - return name - picture_number = _resolved_picture_number(ref, resolved_refs) - if picture_number is not None: - return f" {name}" - return name - - -def _reference_context_for_text(text, ref_slots, include_character_wardrobe=True, resolved_refs=None): - parts = [] - active_slots = tuple(resolved_refs) if resolved_refs else ref_slots - for slot_number, ref in _matched_reference_slots(text, active_slots): - if resolved_refs and _resolved_picture_number(ref, resolved_refs) is None: - continue - label = _resolved_ref_label(ref, slot_number, resolved_refs) - description = _reference_sentence(ref.get("description")) - wardrobe = _reference_sentence(ref.get("wardrobe")) - general = _reference_sentence(ref.get("general")) - facts = _reference_fact_sentence(ref, label) - if ref.get("kind") == "location": - if description: - parts.append(f"Location context for {label}: {description}") - if general: - parts.append(f"Location notes for {label}: {general}") - continue - if facts: - parts.append(facts) - if description: - parts.append(f"Persistent appearance for {label}: {description}") - if include_character_wardrobe and wardrobe: - parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}") - if general: - parts.append(f"Character notes for {label}: {general}") - return " ".join(parts).strip() - - -def _inject_reference_context(block, context): - if not context: - return block - text = str(block or "").strip() - if not text: - return context - match = re.match(r"^(\[Generation \d+\]\s*)", text) - if not match: - return f"{context} {text}".strip() - return re.sub( - r"^(\[Generation \d+\]\s*)", - lambda m: m.group(1) + context + " ", - text, - count=1, - ) - - -def _reference_character_memory(ref_slots): - lines = [] - seen = set() - for slot_number, ref in enumerate(ref_slots or [], 1): - if ref is None: - continue - if ref.get("kind") != "character": - continue - wardrobe = _reference_text(ref.get("wardrobe")) - if not wardrobe: - continue - label = ( - _reference_text(ref.get("name")) - or (_reference_name_keys(ref)[0] if _reference_name_keys(ref) else "") - ) - line = f"{label} = {wardrobe}" if label else wardrobe - key = line.lower() - if key in seen: - continue - seen.add(key) - lines.append(line) - return "\n".join(lines) - - -def _connected_refs(ref_slots): - """Connected refs only, preserving slot order and skipping empty sockets.""" - return [ref for ref in (ref_slots or []) if _reference_image(ref) is not None] - - -def resolve_tagged_refs(text, ref_list): - """(rewritten text, images, dropped) for the tags in ONE shot. - - The tokenizer numbers references by their position in the list it is handed, so - a shot that uses only would receive that image labelled - and the text would point at nothing. The tags are therefore - RENUMBERED per shot to match what that shot actually carries: slot 2 alone - becomes , slots 2 and 4 become and . - - A tag naming a slot with no image connected refers to nothing at all, so it is - removed from the text rather than left to confuse the encoder, and reported.""" - wanted = picture_tags(text) - live = [n for n in wanted if 1 <= n <= len(ref_list or []) and _reference_image(ref_list[n - 1]) is not None] - dropped = [n for n in wanted if n not in live] - renumber = {old: new for new, old in enumerate(live, 1)} - - def sub(m): - n = int(m.group(1)) - return f"" if n in renumber else "" - - out = _PICTURE_TAG.sub(sub, text or "") - if dropped: # tidy the gap a removed tag leaves behind - out = re.sub(r"\s+([,.;:])", r"\1", out) - out = re.sub(r"(,\s*){2,}", ", ", out) - out = re.sub(r"\s{2,}", " ", out) - return out.strip(), [ref_list[n - 1] for n in live], dropped - - -def resolve_prompt_refs(text, ref_list, include_named=True): - """(rewritten text, refs, dropped) for the refs a shot actually carries. - - Explicit tags still decide which slot numbers the prompt points at, - but named character matches must ride into the real ref-image list too. Without - that split, a shot could inherit the facts/context for "Mara" and "Jon" while - only carrying a tagged location image, which reads exactly like the names were - understood but the faces were ignored.""" - rewritten, tagged_refs, dropped = resolve_tagged_refs(text, ref_list) - refs = list(tagged_refs) - seen = {id(ref) for ref in refs} - if include_named: - for _slot_number, ref in _named_refs_for_text(rewritten, ref_list, kinds=("character", "location")): - marker = id(ref) - if marker in seen: - continue - seen.add(marker) - refs.append(ref) - return rewritten, refs, dropped - - -def resolve_tag_driven_prompt_refs(text, ref_list): - """Tag-driven prompt refs for mixed tagged/untagged scripts. - - Once any shot in the script uses ``, untagged shots should keep the - handoff path instead of quietly becoming ref-conditioned just because they name - a character or location. Named refs still supplement explicitly tagged shots so - their semantic context and actual image list stay aligned. - """ - rewritten, tagged_refs, dropped = resolve_tagged_refs(text, ref_list) - if not tagged_refs: - return rewritten, [], dropped - refs = list(tagged_refs) - seen_slots = { - slot_number - for slot_number in picture_tags(text) - if 1 <= slot_number <= len(ref_list or []) and _reference_image(ref_list[slot_number - 1]) is not None - } - for slot_number, ref in _named_refs_for_text(rewritten, ref_list, kinds=("character", "location")): - if slot_number in seen_slots: - continue - seen_slots.add(slot_number) - refs.append(ref) - return rewritten, refs, dropped - - -def _resolve_beat_prompt_refs(text, ref_list, beat_text, connected_ref_count, ref_mode, - any_tags_anywhere, shot_index, handoff): - shot_mode = beat_ref_mode_directive(beat_text) or ref_mode - shot_tag_driven = bool(connected_ref_count) and shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere - if shot_tag_driven: - rewritten, refs, dropped = resolve_tag_driven_prompt_refs(text, ref_list) - return rewritten, refs, dropped, shot_mode, True - shot_mode_eff = ("every shot" if shot_mode == "auto ref2v" - else "first shot" if shot_mode == "where tagged" else shot_mode) - return text, shot_references(ref_list, shot_mode_eff, shot_index, handoff), [], shot_mode_eff, False - - -def resolve_shot_references(text, ref_list, ref_mode="auto ref2v", shot_index=0, handoff=None): - """Compatibility wrapper for older tests and helper code. - - The renderer now uses `resolve_prompt_refs` plus `shot_references` directly, - but some helper tests still check the combined resolution path.""" - if ref_mode == "where tagged": - rewritten, refs, dropped = resolve_prompt_refs(text, ref_list, include_named=False) - return rewritten, refs, dropped, bool(picture_tags(text)), ref_mode - if ref_mode == "auto ref2v": - rewritten, tagged_refs, dropped = resolve_tagged_refs(text, ref_list) - refs = list(tagged_refs) - seen = {id(ref) for ref in refs} - for _slot_number, ref in _named_character_refs_for_text(rewritten, ref_list): - marker = id(ref) - if marker in seen: - continue - seen.add(marker) - refs.append(ref) - return rewritten, refs, dropped, bool(picture_tags(text)), ref_mode - rewritten, _, dropped = resolve_tagged_refs(text, ref_list) - refs = shot_references(ref_list, ref_mode, shot_index, handoff) - return rewritten, refs, dropped, False, ref_mode - - -def shot_references(ref_list, ref_mode, shot_index, handoff): - """Pure: which reference images shot `shot_index` is conditioned on, or [] when - the shot should use the keyframe handoff instead. - - NOTE: on ComfyUI 0.31+ a shot CAN carry both channels, and run() adds the handoff - as a real keyframe alongside whatever this returns (unless ref_noise_aug has been - lowered, which would soften the anchor too). The descriptions below are what each - mode contributes to the REFERENCE channel; they no longer describe a shot's whole - conditioning, and 'no handoff at all' is no longer a consequence of picking one: - - 'auto ref2v' -- use explicit prompt tags when they exist; otherwise carry - the references on every shot. This is the ref2v-biased - default: identity first, no need to tag a single-subject - chain by hand just to stop refs collapsing to shot 1. - 'first shot' -- references establish the cast in shot 1; every later shot - uses the last-frame handoff. Continuity is unbroken and the - look propagates down the chain, but only through the frames. - 'every shot' -- every shot is ref-conditioned. Strongest identity, and no - handoff at all, so shots meet as CUTS rather than as one - continuous take. - 'every shot + handoff ref' - -- every shot is ref-conditioned AND the previous shot's last - frame is appended as one more reference. Continuity comes - back as a soft signal (the model is shown where the last - shot ended rather than told to start exactly there), and it - stays a single ref2va task, so nothing conflicts.""" - refs = _connected_refs(ref_list) - if not refs: - return [] - if ref_mode == "auto ref2v": - return list(refs) - if ref_mode == "first shot": - return list(refs) if shot_index == 0 else [] - if ref_mode == "every shot": - return list(refs) - if ref_mode == "every shot + handoff ref": - return list(refs) + ([handoff] if handoff is not None else []) - return list(refs) if shot_index == 0 else [] # unknown value -> safest - - -# --- text-encoder / DiT compatibility ------------------------------------- -# H3's DiT accepts text conditioning at exactly two widths (comfy/ldm/minimax/ -# model.py, preprocess_text_embeds): -# * text_dim -- raw encoder states, projected by condition_proj (5120 on -# stock H3: Qwen3-VL-32B truncated to 50 layers) -# * hidden_size-- states already refined to DiT width (5376), passed through -# Anything else dies deep inside ComfyUI as a bare "mat1 and mat2 shapes cannot -# be multiplied (156x6144 and 5120x5376)", which reads like a bug in this node. -# Check the width up front and name what is actually wrong. -_TE_HIDDEN = { - 5120: "Qwen3-VL-32B truncated to 50 layers -- the H3 text encoder", - 5376: "text embeds already refined to DiT width", - 4096: "Qwen3-VL-8B / T5-XXL -- not an H3 encoder", - 3584: "Qwen2.5-VL-7B -- not an H3 encoder", - 2560: "Qwen3-VL-4B -- not an H3 encoder", - 2048: "Qwen3-VL-2B / Qwen3-30B-A3B -- not an H3 encoder", -} - - -def _te_name(dim): - return _TE_HIDDEN.get(dim, "not a width any H3 encoder produces") - - -def text_encoder_mismatch_note(got, accepted): - """Pure: message for conditioning of width `got` fed to a DiT that accepts - the widths in `accepted`, or None if it fits / nothing is known. Torch-free - so the tests can drive it.""" - ok = sorted({int(a) for a in (accepted or ()) if a}) - if not got or not ok or int(got) in ok: - return None - got = int(got) - return ( - f"H3 Long Videos: the CLIP input does not match this diffusion model. Its " - f"conditioning is {got}-dim ({_te_name(got)}), but this H3 DiT only accepts " - + " or ".join(f"{a} ({_te_name(a)})" for a in ok) + ". Check, in this order: " - f"(1) the CLIPLoader feeding 'clip' is set to the MiniMax-H3 type -- the same " - f"file loaded under another type gives a different width; (2) the encoder file " - f"is the H3 one that shipped with your H3 checkpoint, not another Qwen3-VL; " - f"(3) no upstream node replaced the conditioning between the encoder and this " - f"node. Nothing was rendered." - ) - - -def _dit_text_widths(model): - """The text widths this DiT accepts: (condition_proj.in_features, - hidden_size). Reads module attributes, not weight.shape -- a quantized or - packed weight has a misleading shape and would fake a mismatch. Missing - values are dropped, so a model this can't introspect yields ().""" - m = getattr(model, "model", model) - dm = getattr(m, "diffusion_model", None) - proj = getattr(dm, "condition_proj", None) - out = [] - for n in (getattr(proj, "in_features", None), getattr(dm, "hidden_size", None)): - if isinstance(n, int) and n > 0: - out.append(n) - return tuple(out) - - -def _cond_embed_dim(cond): - """Width of an encoded conditioning's embedding tensor, or None.""" - try: - return int(cond[0][0].shape[-1]) - except Exception: - return None - - -# --- quantization kernels --------------------------------------------------- -# The kernels are NOT something this node installs or calls. comfy_kitchen (imported -# as `ck` in comfy/quant_ops.py) is a compiled package shipped with ComfyUI, and -# comfy/ops.py routes every quantized Linear through it -- ck.int8_linear() and -# friends -- whenever the loaded weights carry a quant_format. Sampling runs the -# model, so the node gets that path for free and cannot opt in or out of it. -# -# What the node CAN do is notice when the path is not there, because that failure -# is silent. If the CUDA backend is disabled (torch built against CUDA < 13 -# triggers ck.registry.disable("cuda")) or comfy_kitchen fails to import, ComfyUI -# logs one line at startup and then quietly runs a slower, lower-fidelity route. -# Nothing errors, and the first sign is soft output -- which is indistinguishable -# from a dozen other causes unless something says so. -# -# Every format the checkpoint might use, mapped to the comfy-kitchen capability -# that serves it. The names come from ck.list_backends()[...]["capabilities"]. -_QUANT_CAPABILITY = { - "int8_tensorwise": "int8_linear", - "int8_tensorwise+convrot": "int8_linear", - "convrot_w4a4": "convrot_w4a4_linear", - "asym_w4a8_int8": "w4a8_int8_linear", - "nvfp4": "scaled_mm_nvfp4", - "mxfp8": "scaled_mm_mxfp8", -} - - -def quant_format_of(model): - """The dominant quant format of the loaded DiT, or "" when it is unquantized. - - Same detection the inspector node uses: a module's `quant_format` tag, with - int8 split by whether its packed weight carries convrot.""" - try: - dm = getattr(getattr(model, "model", None), "diffusion_model", None) - if dm is None or not hasattr(dm, "modules"): - return "" - counts = {} - for m in dm.modules(): - fmt = getattr(m, "quant_format", None) - if fmt is None: - continue - if fmt == "int8_tensorwise": - params = getattr(getattr(m, "weight", None), "_params", None) - if getattr(params, "convrot", False): - fmt = "int8_tensorwise+convrot" - counts[fmt] = counts.get(fmt, 0) + 1 - return max(counts.items(), key=lambda kv: kv[1])[0] if counts else "" - except Exception: - return "" - - -def kernel_backend_note(model): - """Warn when the loaded checkpoint's quant format has no accelerated backend. - - Silent on an unquantized checkpoint (nothing to accelerate) and silent when a - capable backend is present, so it only speaks when something is actually - wrong.""" - fmt = quant_format_of(model) - if not fmt: - return "" - want = _QUANT_CAPABILITY.get(fmt) - try: - import comfy_kitchen as ck - except Exception as e: - return (f"the checkpoint is {fmt} but comfy_kitchen failed to import " - f"({type(e).__name__}) -- ComfyUI is running the slow dequantize " - f"fallback, which is lower fidelity as well as slower") - try: - backends = ck.list_backends() - except Exception: - return "" # cannot introspect; never block on that - live = [name for name, b in backends.items() - if b.get("available") and not b.get("disabled") - and (want is None or want in (b.get("capabilities") or ()))] - if live: - return "" - disabled = [f"{name} ({b.get('unavailable_reason') or 'disabled'})" - for name, b in backends.items() if not b.get("available") or b.get("disabled")] - return (f"the checkpoint is {fmt} but no comfy-kitchen backend offers " - f"'{want or fmt}' -- " + ("; ".join(disabled) if disabled else "none available") - + ". ComfyUI falls back to a dequantize path: slower, and lower fidelity. " - "Check the startup log for 'Found comfy_kitchen backend', and that torch " - "is built against CUDA 13+ (cu130), which is what keeps the CUDA backend " - "enabled") - - -# --- flow-shift vs step count ---------------------------------------------- -# The shift maps timesteps onto sigmas, and the 'simple' scheduler then samples -# that curve at evenly spaced INDICES. At a high shift the curve is steep at the -# low-sigma end, so the last interval swallows most of the run: -# -# 4 steps, shift 12 -> 2.7% / 5.0% / 12.3% / 80.0% -# 4 steps, shift 3 -> 10.0% / 15.0% / 25.0% / 50.0% -# 20 steps, shift 12 -> worst single step 38.7% -# -# 12 is the right default -- it is what H3's own model config declares, and at ~20 -# steps it is well balanced. It only misbehaves when a distill LoRA drops the step -# count under it, and then it does so invisibly: nothing errors, the picture just -# comes back soft and painterly because one enormous final jump cannot resolve fine -# detail. Cheap to detect, so detect it. -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 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) - - -def flow_step_shares(shift, steps, timesteps=1000): - """Fraction of total denoising each sampler step performs. - - Mirrors comfy.model_sampling.ModelSamplingDiscreteFlow (time_snr_shift) plus - comfy.samplers.simple_scheduler, which indexes the precomputed sigma table - linearly. Returns [] when the inputs cannot form a schedule.""" - steps = int(steps) - if steps < 2 or shift is None or float(shift) <= 0: - return [] - a = float(shift) - table = [a * t / (1.0 + (a - 1.0) * t) - for t in ((i + 1) / timesteps for i in range(timesteps))] - stride = len(table) / steps - sig = [table[-(1 + int(x * stride))] for x in range(steps)] + [0.0] - deltas = [sig[i] - sig[i + 1] for i in range(steps)] - total = sum(deltas) - if total <= 0: - return [] - return [d / total for d in deltas] - - -# On ComfyUI 0.31+ the two shifts are COUPLED, and that is new. ModelSamplingAV -# carries the audio latent on the VIDEO schedule scaled by -# -# audio_scale = shift_video / shift_audio (12 / 3 = 4.0 by default) -# -# and that ratio drives process_latent_in, process_latent_out, the minimax payload -# and the DiT forward (comfy/model_base.py 2141/2144/2181, ldm/minimax/model.py:530). -# Collapse it to 1.0 by setting the two shifts equal and the audio pipeline loses -# the scaling it is built around -- which comes back as babble or silence. -# -# Before 0.31 the audio velocity was scaled by a derivative instead, so the two -# shifts were effectively independent and lowering shift_video alone was harmless. -# It is not harmless now. -H3_AUDIO_SCALE = 4.0 # the model's own 12/3 - - -def audio_scale_note(shift_video, shift_audio): - """Warn when the video/audio shift RATIO has drifted from what H3 expects.""" - try: - sv, sa = float(shift_video), float(shift_audio) - except (TypeError, ValueError): - return "" - if sa <= 0 or sv <= 0: - return "" - ratio = sv / sa - if ratio >= 1.75: - return "" - return (f"shift_video {sv:g} / shift_audio {sa:g} gives audio_scale {ratio:.2f}, " - f"against the {H3_AUDIO_SCALE:g} this model is built around. The audio latent " - f"rides the VIDEO schedule scaled by that ratio, so flattening it toward 1.0 " - f"breaks the audio branch -- babble or silence. Keep the two shifts about " - f"{H3_AUDIO_SCALE:g}:1 apart: for shift_video {sv:g}, use shift_audio " - f"{max(0.25, sv / H3_AUDIO_SCALE):g}") - - -def schedule_balance_note(shift, steps, scheduler, worst_allowed=0.55): - """Warn when one sampler step carries most of the denoising. - - Only for the 'simple' scheduler, because that is the curve this reproduces -- - reporting these numbers for a scheduler that spaces sigmas differently would be - making them up. The suggestion is searched rather than guessed: the lowest - shift whose worst step falls under the threshold.""" - if str(scheduler) != "simple": - return "" - shares = flow_step_shares(shift, steps) - if not shares: - return "" - worst = max(shares) - if worst <= worst_allowed: - return "" - better = "" - for cand in (6.0, 5.0, 4.0, 3.0, 2.5, 2.0, 1.5, 1.0): - cand_shares = flow_step_shares(cand, steps) - if cand_shares and max(cand_shares) <= worst_allowed: - # shift_audio has to come down WITH it. The two are coupled on 0.31+: - # audio_scale = shift_video / shift_audio, and lowering only the video - # shift flattens that ratio toward 1.0, which breaks the audio branch. - # Suggesting a bare shift_video here is what produced babble. - better = (f"; shift_video {cand:g} spreads it to " - + "/".join(f"{s * 100:.0f}%" for s in cand_shares) - + f" -- lower shift_audio to {max(0.25, cand / H3_AUDIO_SCALE):g} " - f"at the same time, or the audio breaks") - break - return (f"shift_video {float(shift):g} at {int(steps)} steps puts " - + "/".join(f"{s * 100:.0f}%" for s in shares) - + f" of the denoising into each step -- one step doing {worst * 100:.0f}% " - f"cannot resolve fine detail, which renders as soft, painterly output" - + better) - - -def check_text_encoder(model, cond): - """Raise a readable RuntimeError when clip and model disagree. Silent when - either side can't be read -- never block a run on a failed introspection.""" - note = text_encoder_mismatch_note(_cond_embed_dim(cond), _dit_text_widths(model)) - if note: - raise RuntimeError(note) - - -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 - - -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 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 _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 _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 _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 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 _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 _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 _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 _resize_short_edge(frames, target, method="lanczos"): - """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.""" - 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)) - s = frames.movedim(-1, 1) - s = comfy.utils.common_upscale(s, nw, nh, method, "disabled") - return s.movedim(1, -1) - - -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)))) - out = [] - 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") - out.append(res.detach().to("cpu")) - del res, part - _deep_cleanup() - frames = torch.cat(out, dim=0) - 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) - out = [] - 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) - out.append(res.detach().to("cpu")) - del res, part - _deep_cleanup() - frames = torch.cat(out, dim=0) - 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 lora_active(model): - """True if a LoRA is applied to this model, by either mechanism. - - Stock LoraLoaderModelOnly folds deltas in as ModelPatcher weight *patches*; - bypass LoRAs (turbo packs) register *injections* / wrappers instead. Detecting - both matters because a distilled LoRA compresses ~20 steps into 4-8, so the - model commits to global composition -- including HOW MANY PEOPLE are in frame -- - within the first step or two and then reinforces that choice rather than - revising it. That is why turbo LoRAs duplicate subjects even when the prompt is - clean, and why the subject-count guard has to be forced on for them regardless - of resolution.""" - try: - if getattr(model, "patches", None): - return True - for attr in ("injections", "wrappers"): - d = getattr(model, attr, None) or {} - if any(len(v) for v in d.values()): - return True - except Exception: - pass - return False - - -def lora_overhead_gb(model): - """Extra VRAM a bypass-LoRA holds resident during sampling. - - A bypass LoRA (e.g. the MiniMax-H3 Turbo LoRA) does NOT fold into the weights: - it keeps every low-rank A/B pair live in bf16 and adds lora(x) in activation - space each forward. With ~208 adapters plus per-adapter activations that is a - real, measurable chunk the budget must not spend on frames -- otherwise the - node picks a shot length that fits the base model and then overflows once the - adapters and their activations land. Returns an estimate in GB (0 if none).""" - try: - injections = getattr(model, "injections", None) or {} - n_inj = sum(len(v) for v in injections.values()) - n_hooks = 0 - for v in injections.values(): - for inj in v: - n_hooks += len(getattr(inj, "hooks", ()) or ()) - wrappers = getattr(model, "wrappers", None) or {} - n_wrap = sum(len(w) for w in wrappers.values()) - if not (n_inj or n_hooks or n_wrap): - return 0.0 - # low-rank deltas are small individually; the cost that matters is the - # per-adapter activation working set during the forward pass. - return round(max(0.6, 0.004 * max(n_hooks, 1)), 2) - except Exception: - return 0.0 - - -# --- SLA (sparse-linear attention) pairing ---------------------------------- -# An SLA LoRA is a turbo LoRA fine-tuned WITH sparse attention in the loop, so the -# weights have already adapted to the approximation. That is the whole benefit: you -# get the sparse-attention speedup without the quality collapse. The two halves are -# a matched pair and only work together -- -# -# sparse attention ON + ordinary LoRA -> the model sees an attention map it was -# never trained on. Long-range coherence is what sparsity drops first, and in -# a video DiT that shows up as the SAME PERSON RENDERED TWICE. -# SLA LoRA + sparse attention OFF -> the weights are pre-compensating for -# sparsity that isn't there. You pay its quality cost and get no speedup. -# -# Neither half is inferable from the model object. Verified against the actual -# file: minimax_h3_fl2v_turbo_4step_v0.1_768p_sla_comfyui_bf16.safetensors carries -# NO SLA marker -- not in its 624 tensor names, not in its 9 metadata keys. It is -# byte-shape-identical to any un-resized rank-128 H3 turbo LoRA. The only place the -# word appears is the FILENAME, so that is what has to be read. -# -# Matches 'sla' as a DELIMITED token, so 'slack', 'translate', 'isla' and 'SLAYER' -# do not fire. A LoRA that ends in '_sla' for some unrelated reason would warn -# spuriously -- the cost is one wrong line in `info`, never a changed render. -_SLA_NAME = re.compile(r"(?:^|[^a-z])sla(?:[^a-z]|$)", re.I) - - -def sparse_attention_active(model): - """True when an attention-override patch (Sol-Attn and friends) is on `model`. - - ComfyUI carries these in model_options['transformer_options'], which our node - receives already patched because the patch node ran upstream of us.""" - try: - opts = getattr(model, "model_options", None) or {} - tro = opts.get("transformer_options", {}) or {} - return tro.get("optimized_attention_override") is not None - except Exception: - return False - - -def upstream_lora_names(graph, node_id, _seen=None): - """LoRA filenames on the MODEL chain feeding this node, nearest last. - - Walks the workflow graph backwards from our own `model` input. Reading the - filename is the only way to identify an SLA LoRA (see above), and the graph is - the only place the filename survives -- ComfyUI stashes a LoRA's safetensors - metadata on the patcher but never its name. - - Deliberately follows ONLY model-carrying inputs, so a LoRA wired into some - other branch of the workflow is not mistaken for one that is affecting us.""" - if graph is None or node_id is None: - return [] - _seen = set() if _seen is None else _seen - nid = str(node_id) - if nid in _seen: - return [] - _seen.add(nid) - try: - node = graph.get_node(nid) - except Exception: - return [] - names, inputs = [], (node.get("inputs") or {}) - for key, val in inputs.items(): - # a link is [upstream_node_id, output_slot]; anything else is a widget value - if isinstance(val, (list, tuple)) and len(val) == 2 and not isinstance(val[1], (list, dict)): - if "model" in str(key).lower(): - names += upstream_lora_names(graph, val[0], _seen) - else: - names += lora_names_in_widget(key, val) - return names - - -# Filenames a stacked loader uses for an EMPTY slot. -_LORA_EMPTY = {"", "none", "null", "no lora", "-", "undefined"} - - -def lora_names_in_widget(key, val, _depth=0): - """LoRA filenames inside one widget value, however the loader packs them. - - A one-LoRA-per-widget loader puts a bare filename under a key containing - "lora", and that was all this understood. Stacked loaders do not: DaSiWa packs - every LoRA into ONE json string under `stack_data`, and rgthree's Power Lora - Loader stores a dict per slot. Neither has "lora" in the key it is filed under, - so a chain carrying four LoRAs read as carrying none. - - Disabled slots and zero-strength entries are skipped: a LoRA that is switched - off is not affecting this render and should not be reported as if it were.""" - if _depth > 6: # cyclic or absurdly nested - return [] - if isinstance(val, str): - s = val.strip() - if s[:1] in "[{": # a packed json payload - try: - return lora_names_in_widget(key, json.loads(s), _depth + 1) - except (ValueError, TypeError): - return [] - if s.lower() in _LORA_EMPTY: - return [] - return [s] if "lora" in str(key).lower() else [] - if isinstance(val, dict): - for flag in ("on", "enabled", "enable", "active"): - if flag in val and not val[flag]: - return [] # slot switched off - for f in ("str", "strength", "strength_model", "model_strength", "weight"): - if f in val: - try: - if abs(float(val[f])) < 1e-6: - return [] # at 0 it changes nothing - except (TypeError, ValueError): - pass - break - out = [] - for k, v in val.items(): - out += lora_names_in_widget(k, v, _depth + 1) - return out - if isinstance(val, (list, tuple)): - out = [] - for v in val: - out += lora_names_in_widget(key, v, _depth + 1) - return out - return [] - - -def sla_pairing(model, graph, node_id): - """(sla_lora_name|None, sparse_on, note) -- how the two halves line up. - - `note` is the warning when they are mismatched, and it is worth a warning - rather than a silent default because both mismatches cost a full render.""" - sparse = sparse_attention_active(model) - loras = upstream_lora_names(graph, node_id) - sla = next((n for n in reversed(loras) if _SLA_NAME.search(os.path.basename(str(n)))), None) - note = "" - if sla and not sparse: - note = (f"SLA LoRA '{os.path.basename(str(sla))}' is loaded but NO sparse-attention patch " - f"is active -- it was fine-tuned WITH sparse attention, so on dense attention you " - f"pay its quality cost and get none of its speed. Add the Sol-Attn patch node " - f"between the loader and this node, or load the non-SLA turbo LoRA instead") - elif sparse and loras and not sla: - note = (f"sparse attention is ON but the LoRA on this chain " - f"({os.path.basename(str(loras[-1]))}) is not an SLA build -- the model was never " - f"trained against the approximated attention map, and the first thing sparsity " - f"drops is long-range coherence, which renders as the SAME PERSON TWICE. Load the " - f"'_sla_' turbo LoRA, or turn the sparse-attention patch off") - elif sparse and not loras: - note = ("sparse attention is ON with no LoRA on this chain -- base H3 was not trained " - "against an approximated attention map, so expect duplicated subjects. Pair it " - "with an SLA turbo LoRA") - return sla, sparse, note - - -# --- what a LoRA actually declares about itself ------------------------------ -# Verified against all six LoRAs in this install before any of it was written, -# because most of what a "LoRA scanner" sounds like it should do is not in the -# files. What IS there: -# -# base model metadata: 'base_model' (Comfy-Org turbo builds) or -# 'ss_base_model_version' (ai-toolkit builds, e.g. 'minimax_h3') -# strength metadata: 'training_scale' / 'baked_scale', both 1.0 -# step count FILENAME only ('..._turbo_4step_...') -- no metadata field -# training res FILENAME only ('..._768p_...'), plus 'ss_resolution' on kohya -# builds, which none of these are -# -# What is NOT there, checked file by file: -# -# trigger words no 'ss_tag_frequency' in any of them. The ai-toolkit LoRAs carry -# nine metadata keys and none is a caption or tag list, so a -# trigger like 'mpenis' cannot be discovered -- it stays a manual -# exposed_terms entry. -# sampler/cfg/ no field for these exists in any LoRA metadata standard, and -# scheduler none of these files has one. Reporting them would be invention. -# -# Everything below therefore either quotes metadata or says the filename said it. -# Nothing here overrides a widget: a render must stay reproducible from what the -# graph shows. -_LORA_STEPS = re.compile(r"(?:^|[^a-z0-9])(\d{1,2})[ _-]?step", re.I) - -_LORA_RES = re.compile(r"(?:^|[^a-z0-9])(\d{3,4})p(?:[^a-z0-9]|$)", re.I) - - -def lora_declared(model): - """The nearest LoRA's own metadata dict, as ComfyUI stashed it, or {}. - - comfy.sd.load_lora_for_models() calls set_attachments('lora_metadata', ...) on - the patcher, so this is the LoRA's real safetensors header -- not a guess. With - several stacked, the attachment holds the last one applied.""" - try: - get = getattr(model, "get_attachment", None) - return (get("lora_metadata") if get else None) or {} - except Exception: - return {} - - -def lora_hint_notes(model, graph, node_id, steps, short_edge): - """Warnings where a LoRA's declared training disagrees with this run. - - Each note says WHERE the number came from, because the two sources are not - equally trustworthy: metadata is what the trainer wrote, a filename is a naming - convention that anyone can break by renaming the file.""" - notes = [] - names = upstream_lora_names(graph, node_id) - if not names: - return notes - md = lora_declared(model) - - # --- base model: the one check that is pure metadata --- - # ComfyUI's set_attachments('lora_metadata', ...) is overwritten by each loader - # in turn, so on a stack this describes the LAST one applied only. Attributed to - # that file by name rather than to "your LoRA", so the note cannot be read as a - # claim about the others. - base = str(md.get("base_model") or md.get("ss_base_model_version") or "") - nearest = os.path.basename(str(names[-1])) - if base and "minimax" not in base.lower() and "h3" not in base.lower(): - notes.append(f"'{nearest}' declares base_model '{base}', which is not MiniMax-H3 -- " - f"it will apply as noise on an H3 DiT") - - # Filename-derived checks run over EVERY LoRA on the chain. Only the nearest - # one's metadata is reachable, but every one of their names is -- and on a stack - # the step-count LoRA is usually NOT the nearest, so checking one file silently - # skipped the very LoRA whose step count the sampler has to match. - for raw in names: - name = os.path.basename(str(raw)) - - # --- step count: filename convention only --- - m = _LORA_STEPS.search(name) - if m: - want = int(m.group(1)) - if want and int(steps) != want: - notes.append(f"'{name}' is named as a {want}-step LoRA but steps={int(steps)} " - f"(from the filename, not metadata)" + - ("; a distill LoRA run past its step count re-noises a composition it " - "already settled" if int(steps) > want else - "; under its step count the distill has not finished resolving")) - - # --- training resolution: filename convention only --- - m = _LORA_RES.search(name) - if m and short_edge: - want = int(m.group(1)) - if short_edge > want * 1.34: - notes.append(f"'{name}' is named for {want}p but the short edge here is " - f"{int(short_edge)}px (from the filename, not metadata); well above a " - f"LoRA's training resolution H3 tends to tile the figure") - elif short_edge * 1.34 < want: - notes.append(f"'{name}' is named for {want}p but the short edge here is " - f"{int(short_edge)}px (from the filename, not metadata); well below " - f"it the LoRA's detail work has nothing to land on") - - # --- stacking a distill/turbo LoRA with others --- - # The genuine conflict, and the one that reads as "a LoRA fight". A distill LoRA - # rewrites the sampling trajectory: it settles global composition in the first - # step or two of a 4-8 step schedule. A subject LoRA trained against BASE H3 at - # a normal step count contributes deltas calibrated for a schedule that no - # longer exists, and at full strength those land in exactly the steps the - # distill is using to fix composition. Order does not matter -- ComfyUI sums the - # patches -- so strength is the only lever. - distill = [os.path.basename(str(n)) for n in names - if _LORA_STEPS.search(os.path.basename(str(n))) - or re.search(r"turbo|distill", os.path.basename(str(n)), re.I)] - if distill and len(names) > 1: - others = [os.path.basename(str(n)) for n in names - if os.path.basename(str(n)) not in distill] - if others: - notes.append( - f"{len(names)} LoRAs on this chain, one of them a distill/turbo build " - f"('{distill[0]}'). It settles composition in its first step or two, and the " - f"deltas from {', '.join(f'{o!r}' for o in others[:3])} land in those same " - f"steps -- lower the SUBJECT LoRA strengths first (0.6-0.8), not the distill's. " - f"Stacking order does not matter; ComfyUI sums the patches") - return notes - - -# Fingerprint of the model used by the previous run, so a checkpoint swap can be -# detected between queue executions. Module-level: it must outlive the node -# instance, which ComfyUI recreates per execution. -_LAST_MODEL_FP = {"fp": None} - - -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 - - -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") - - -def quant_accel_note(model): - """Report the loaded DiT's quant format and whether THIS card can run it on - tensor cores natively -- so a silent fall back to emulated/upcast math shows up - in `info` instead of just looking like slow output. - - The node itself never sets dtypes, never autocasts and never rebuilds modules: - it delegates sampling to ComfyUI's common_ksampler, and its only model patch is - a schedule-object patch (add_object_patch on 'model_sampling'). So NVFP4/MXFP8 - tensor-core acceleration is entirely ComfyUI's dispatch on the quantized layers - -- which is what we want: nothing here can disturb it. This is a read-only - check.""" - try: - import comfy.model_management as _mm - dm = getattr(getattr(model, "model", None), "diffusion_model", None) - fmts = {} - if dm is not None and hasattr(dm, "modules"): - for mod in dm.modules(): - f = getattr(mod, "quant_format", None) - if f: - fmts[f] = fmts.get(f, 0) + 1 - if not fmts: - return "" - top = max(fmts.items(), key=lambda kv: kv[1])[0] - native = None - if "nvfp4" in top: - native = getattr(_mm, "supports_nvfp4_compute", lambda: None)() - elif "mxfp8" in top: - native = getattr(_mm, "supports_mxfp8_compute", lambda: None)() - if native is True: - return f"{top}: native tensor-core compute" - if native is False: - return (f"WARNING {top}: this card/torch cannot run it natively -- weights are being " - f"upcast, so you pay full-precision compute with none of the speedup") - return f"{top} weights" - except Exception: - return "" - - - - -def _evict_all_but(keep_model): - """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 only evicts reactively, i.e. AFTER - it has already spilled. With a bypass LoRA also holding 208 bf16 adapters - resident there is no room left, and every shot leaves the card fuller. - - Freeing them explicitly, right after conditioning is built and before - sampling, keeps only what the sampler actually needs on the GPU.""" - try: - keep = [] - for lm in list(getattr(mm, "current_loaded_models", [])): - try: - if lm.model is keep_model or getattr(lm, "model", None) is getattr(keep_model, "model", None): - keep.append(lm) - except Exception: - pass - mm.free_memory(1e30, mm.get_torch_device(), keep_loaded=keep) - except Exception: - try: - mm.soft_empty_cache(True) - except Exception: - pass - - -def _evict_for_latent_upscale(model): - """Clear the sampler model before loading the auxiliary latent upscaler.""" - try: - unload_clones = getattr(mm, "unload_model_and_clones", None) - if callable(unload_clones): - try: - unload_clones(model, unload_additional_models=False) - mm.soft_empty_cache() - return - except Exception: - pass - mm.unload_all_models() - except Exception: - pass - try: - mm.soft_empty_cache(True) - except Exception: - try: - mm.soft_empty_cache() - except Exception: - pass - - - - - - -class H3LongVideos: - CATEGORY = "Dumas/MiniMax" - FUNCTION = "run" - # fps is emitted as BOTH types on purpose: ComfyUI does not coerce between them, - # and the nodes that want a frame rate are split -- CreateVideo / SaveWEBM / - # VHS Video Combine take a FLOAT, while plenty of utility nodes take an INT. - # Wiring the wrong one is a red link, not a runtime error, so both are offered. - # LATENT is APPENDED, never inserted: ComfyUI stores a link by output SLOT - # INDEX, so adding at the end leaves every existing wire pointing at the same - # output. Inserting mid-list would silently re-target them. - # APPEND to these, never insert. A workflow stores an output link by SLOT INDEX, - # so a new type in the middle silently re-points every link after it. - RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "INT", "INT", "INT", "FLOAT", "FLOAT", "INT", - "LATENT", "STRING", "IMAGE", "AUDIO") - RETURN_NAMES = ("images", "audio", "info", "script", "frames_per_shot", "total_frames", - "shots", "video_seconds", "fps", "fps_int", - "latent", "soundscape", "beat_images", "beat_audio") - OUTPUT_IS_LIST = (False, False, False, False, False, False, False, False, False, False, - False, False, True, True) - - @classmethod - def IS_CHANGED(cls, plan_only=False, **kwargs): - """Force a re-run for the PLAN, leave a real render cacheable. - - Without an IS_CHANGED, ComfyUI keys this node's cache on its inputs alone, so - re-queueing with the same widgets returns the previous outputs untouched -- and - `info` is an output. That reads as "info doesn't update on each run", and it is - actively misleading here, because both the info AND the chosen shot length now - depend on LIVE FREE VRAM, which is not an input: the cached answer describes a - card state that may no longer exist. - - plan_only is near-instant, so it always recomputes -- a stale plan is worse than - no plan. A real render still respects the cache (returning NaN there would - re-sample for minutes every time the graph is queued); change the seed, or any - widget, to force one.""" - if plan_only: - return float("nan") # NaN != NaN -> never matches the cached signature - return False - - @classmethod - def INPUT_TYPES(cls): - schema = { - "required": { - "model": ("MODEL",), "clip": ("CLIP",), "vae": ("VAE",), - "audio_vae": ("VAE",), - "prompt": ("STRING", {"multiline": True, "forceInput": True, "default": - "A woman with short silver hair and a scar over her left eyebrow. Warm " - "late-afternoon light, cinematic, 2K.\n" - "wardrobe: weathered red flight jacket, grey cargo shorts, black boots\n\n" - "walks across the tarmac toward a small propeller plane.\n\n" - "climbs in and flips the switches; the propeller spins.\n\n" - "taxis down the grass runway, the tail lifting.\n\n" - "the plane leaves the ground; wide shot banking against the sky.", - "tooltip": "This IS the integrated_multimodal_description (the visual/action " - "timeline). First paragraph = PERMANENT IDENTITY kept across the whole " - "video (hair, face, build) -- put NO clothing in this prose, or it can't " - "be changed later. Put clothing on a 'wardrobe:' line (in the first " - "paragraph and/or the character_memory field); it's the only channel that " - "can be changed/removed mid-chain. Each later paragraph = one scene beat. " - "Put dialogue and 'lips closed' beats in the beat bodies."}), - "resolution": (resolution_options(), { - "tooltip": "ASPECT RATIO only -- `megapixels` decides the size. The two are " - "independent, so changing shape does not change cost. Each ratio uses " - "H3's own dimensions as its reference, which is why 1.00MP reproduces " - "the model's native sizes exactly (16:9 -> 1344x768, 21:9 -> 1536x672)."}), - # No off-switch any more: `resolution` is a bare ratio, so there are no - # preset dimensions to fall back to. The floor keeps every result legal. - "megapixels": ("FLOAT", {"default": 1.0, "min": 0.10, "max": 4.0, "step": 0.01, - "tooltip": "Pixel BUDGET, applied to the preset's aspect ratio. 1.00 = 1024x1024 " - "worth of pixels, the same convention as ComfyUI's Scale Image to Total " - "Pixels. START at 1.00: every NATIVE preset reproduces its own size there " - "(1344x768, 1536x672, ...), then step down -- 0.83 gives a 704 short edge, " - "0.65 gives 640 -- for speed, VRAM and longer shots. Cost and training fit " - "track TOTAL PIXELS, not the short edge: 1:1 768x768 reads as native by " - "short edge but is only 0.56MP, while 21:9 1536x672 reads as sub-native at " - "a full 0.98MP. Snapped to multiples of 32; `info` reports the size and MP " - "actually produced. Set 0 to use the preset's own dimensions instead."}), - # Base H3 (NVFP4/FP8, no distill LoRA) needs ~20 steps with res_multistep+simple. - # 6-8 steps only makes sense WITH a working 4-step distill/turbo LoRA or an MXFP8 - # checkpoint tuned for low steps -- at 6-8 on the bare base model the frame comes - # out soft/under-formed (faces worst). Default is the safe base value. - "steps": ("INT", {"default": 20, "min": 1, "max": 200, - "tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a " - "working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the " - "bare base model, low steps are the #1 cause of soft output."}), - "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1, - "tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are " - "deliberately testing unusual sampler behaviour; higher values do not act " - "like a normal SD/Flux CFG boost and are not the fix for weak identity."}), - "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep", - "tooltip": "Main-pass sampler. Base H3 is tuned around res_multistep; changing this is " - "a real behaviour change, not a cosmetic preference. Treat it as an advanced " - "override."}), - "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple", - "tooltip": "Main-pass scheduler. Base H3 is tuned around simple when paired with " - "res_multistep and the default model-sampling shifts."}), - "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True, - "tooltip": "Base seed for the whole chain. With vary_seed_per_shot OFF, every beat is " - "sampled from the same noise field for better continuity. With it ON, this " - "becomes the first seed and later beats use seed+1, seed+2, ..."}), - }, - "optional": { - "first_frame": ("IMAGE", {"tooltip": "Optional opening keyframe for shot 1. Leave empty to " - "start from pure text/reference conditioning. On later shots the node normally uses the " - "previous shot's last frame automatically; this socket is only the initial handoff."}), - # ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these - # exact slots, even when some intermediate sockets are left empty. - # A shot using only ref_7 is still tagged as in the - # prompt and renumbered only for the per-shot tokenizer payload. - # When a mode uses all connected refs, they keep socket order. - # The tokenizer labels the carried refs .. in - # the order they are handed that shot. - # Refer to socket tags in the prompt if you want a reference bound to - # a named character ("Kristy, , walks in"). - "ref_1": ("REFERENCE", {"tooltip": "Reference slot . Feed a Dumas Character " - "Reference or Dumas Location Reference here. The image is used for real H3 visual " - "conditioning; the structured metadata is also used for prompt context."}), - "ref_2": ("REFERENCE", {"tooltip": "Reference slot . Socket numbers matter: " - "tag this exact slot as when you want explicit placement in a beat."}), - "ref_3": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_4": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_5": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_6": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_7": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_8": ("REFERENCE", {"tooltip": "Reference slot ."}), - "ref_9": ("REFERENCE", {"tooltip": "Reference slot ."}), - "plan_only": ("BOOLEAN", {"default": False, - "tooltip": "Preview the shot split and timing WITHOUT sampling pixels. Uses this " - "node's current settings and returns the plan in `info` almost instantly. " - "Good for checking beat counts, shot ceilings, ref placement and clamps " - "before a long render."}), - "fps": ("INT", {"default": 24, "min": 1, "max": 60, - "tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its " - "audio latent are both defined against 24, so this node computes every " - "duration at 24 regardless of what you set here. Set your video-save node " - "to 24 as well, or the clip plays at the wrong speed."}), - "global_soundscape": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "AMBIENT/environmental sound only (rain, room tone, footsteps, engines). " - "Appended to every shot as overall_soundscape. NOT for dialogue -- speech " - "and lip timing live in the prompt beats. Leave blank for no ambient bed."}), - "non_diegetic_music": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "Background SCORE only -- genre, mood, instrumentation, tempo -- music that " - "is NOT part of the scene. Music is OPT-IN: leave this BLANK and the node " - "emits 'non_diegetic_music: N/A' on every shot so H3 adds no score (fixes " - "unwanted music). Fill it in to request a specific score. Not for music a " - "character plays/hears (that's diegetic; put it in the beat)."}), - "apply_model_sampling": ("BOOLEAN", {"default": True, - "tooltip": "Patch ModelSamplingMiniMaxH3 (the dual video/audio schedule) inside the " - "node so you don't have to wire it upstream. Without it, H3's audio comes " - "out as gibberish. Turn OFF only if you patch it yourself upstream."}), - "shift_video": ("FLOAT", {"default": 12.0, "min": 1.0, "max": 32.0, "step": 0.01, - "round": 0.01, - "tooltip": "Video flow shift. 12 = base H3 (correct default). A low-step MXFP8 " - "checkpoint wants ~8. Only used when apply_model_sampling is on."}), - "shift_audio": ("FLOAT", {"default": 3.0, "min": 0.25, "max": 16.0, "step": 0.01, - "round": 0.01, - "tooltip": "Audio flow shift. 3 = base H3. COUPLED to shift_video on ComfyUI " - "0.31+: the audio latent rides the video schedule scaled by " - "audio_scale = shift_video / shift_audio (12/3 = 4). Flattening that " - "ratio toward 1.0 breaks the audio branch -- babble or silence -- so " - "if you lower shift_video, lower this by the same factor. Only used " - "when apply_model_sampling is on."}), - "trim_seam": ("BOOLEAN", {"default": True, - "tooltip": "Drop the FIRST frame of every shot after the first.\n\n" - "That frame is the model's own reproduction of the handoff -- the " - "last frame of the previous shot, which it was anchored to. Keeping " - "it shows the same moment twice and reads as a stutter.\n\n" - "So at a working seam the last frame of one shot and the first of " - "the next are NOT identical: they are one frame of normal motion " - "apart, which is what continuous footage looks like. Turn this off " - "only to inspect how closely the anchor was reproduced."}), - "vary_seed_per_shot": ("BOOLEAN", {"default": False, - "tooltip": "Give each shot its own seed (seed+1, seed+2, ...) instead of one " - "seed for the whole chain.\n\n" - "OFF by default, because this node builds a CONTINUOUS TAKE. The " - "seed sets the noise field every shot is sampled from, and that " - "field is what fixes the stochastic detail -- grain, micro-texture, " - "the exact rendering of surfaces the prompt never names. Change it " - "between shots and all of that resets at the boundary, which reads " - "as a cut even when the keyframe anchors the frame and the location " - "is unchanged: the same room, rendered afresh.\n\n" - "Shots still differ with one seed -- each has its own beat text and " - "its own handoff keyframe. Turn this ON only when you WANT the " - "beats to look separately shot, or when repeated beats are coming " - "out too alike."}), - "handoff_offset": ("INT", {"default": 0, "min": 0, "max": 12, "step": 1, - "tooltip": "End each shot this many frames early and hand THAT frame to the next " - "shot instead of the literal last frame. Set 2-4 if chained shots open " - "with moving/talking mouths -- it avoids seeding the next shot with a " - "mid-word open-mouth pose. Trims the matching audio tail too. 0 = last frame."}), - "shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5, - "forceInput": True, - "tooltip": "GLOBAL per-shot maximum in seconds. H3 Shot Length is the intended " - "source because it snaps to H3's frame grid and reports the real duration.\n\n" - "Leave UNCONNECTED for auto: the node picks the largest shot that fits " - "the current size/VRAM budget. Connect a value to cap every beat at that " - "length. A beat's own `seconds:` directive can still ask for less. If the " - "requested duration exceeds your hardware, the node will keep it and let the " - "render fail instead of shrinking it. Total video length is the sum of the " - "beat shots, not simply beat-count x this value."}), - "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5, - "tooltip": "Safety margin RESERVED from free VRAM before the node budgets shot length. " - "Higher = shorter safer shots. Lower = longer shots but more risk of spill or " - "OOM during sampling/decode peaks. 1.5GB is the conservative default."}), - "allow_res_backoff": ("BOOLEAN", {"default": True, - "tooltip": "If a render does not fit, try stepping the resolution down instead of just " - "failing. Helps salvage long jobs automatically, but the later shots may come " - "back smaller and the latent output can no longer join cleanly across sizes."}), - # ON by default: the prompt-side clauses ASK H3 not to vocalize (and now - # condition the soundscape field too), but asking is not a guarantee -- - # babble under a silent shot was the one artifact that survived both. - # Muting is the only deterministic answer, so it is the default and the - # trade-off (that shot's ambience goes too) is stated in `info`. - "mute_nonspeech_audio": ("BOOLEAN", {"default": True, - "tooltip": "DETERMINISTIC gibberish fix: FULLY silence the audio of any shot that has no " - "scripted dialogue (no double-quoted line). Prompt-level silencing asks H3 " - "not to babble; this guarantees it. TRADE-OFF: it also removes that shot's " - "generated ambience/SFX, so lay a continuous ambient bed under the video in " - "post. Shots WITH quoted dialogue keep their audio untouched."}), - "mute_fade_ms": ("INT", {"default": 40, "min": 0, "max": 500, "step": 10, - "tooltip": "Fade applied to the AUDIBLE shots that border a silenced one, so audio " - "doesn't cut to digital silence with a click. The silenced shots keep NO " - "original audio at all -- fading the muted shot itself would leave this many " - "ms of the gibberish audible at each end of every muted shot."}), - "decode_tile_frames": ("INT", {"default": 0, "min": 0, "max": 128, "step": 1, - "tooltip": "Temporal tiling for the VAE decode (tile_t). 0 = ComfyUI default, which " - "expands the WHOLE clip at once -- the single largest allocation in a run, " - "and the usual point where a big checkpoint tips into shared memory. Try 8-16 " - "if you spill during decode rather than sampling. Lower = less peak VRAM, " - "slightly slower."}), - "decode_tile_size": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 32, - "tooltip": "Spatial tile size for the VAE decode (tile_x/tile_y). 0 = ComfyUI default. " - "Try 256 on a tight card at 1344x768."}), - "latent_upscale_param": ("DUMAS_H3_LATENT_UPSCALE_PARAM", { - "tooltip": "Output of 'Dumas H3 Latent Upscale Params'. When connected, the first-pass " - "latent is upscaled and run through a short refinement pass before decode, " - "using the sampler, scheduler, steps, denoise, megapixel target, and optional " - "spatial batching from that node. " - "Leave unconnected to skip latent upscaling entirely."}), - "upscale": (["off", "rtx", "model", "lanczos"], {"default": "off", - "tooltip": "Optional post-pass on the finished frames. 'rtx' = NVIDIA RTX Video Super " - "Resolution (Tensor Cores -- fastest and best for video; needs the " - "Nvidia_RTX_Nodes_ComfyUI pack, falls back automatically if absent). 'model' = " - "a Real-ESRGAN/UltraSharp upscale model from upscale_model. 'lanczos' = plain " - "resize. All of these ENHANCE/ENLARGE; for true detail reconstruction from a " - "low-res render, use a separate LTX 2.3 upscale pass."}), - "upscale_model": (_upscale_model_list(), { - "tooltip": "Upscale model from models/upscale_models (used when upscale = model)."}), - "upscale_target_short_edge": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 32, - "tooltip": "Fit the result's short edge to this many px (0 = keep the model's native " - "factor / no resize). E.g. generate 512 fast, set 768 to land at native size."}), - "upscale_batch": ("INT", {"default": 4, "min": 1, "max": 64, - "tooltip": "Frames per chunk for the model upscale (lower = less VRAM, slower)."}), - "watermark_text": ("STRING", {"default": "", - "tooltip": "Composited with PIL onto every finished frame -- NOT rendered by the " - "model and NOT added to the prompt. White glyphs on a transparent layer, " - "alpha-blended over the video, so only the letters land on the picture. " - "Applied AFTER any upscale, so the text is crisp at final resolution. " - "Leave empty for none."}), - "watermark_position": (["bottom-right", "bottom-left", "bottom-center", - "top-right", "top-left", "top-center", "center"], - {"default": "bottom-right"}), - "watermark_size": ("FLOAT", {"default": 4.0, "min": 0.5, "max": 40.0, "step": 0.5, - "tooltip": "Cap height as a percentage of the frame's SHORT edge, so the mark keeps " - "its apparent size across portrait and landscape presets alike."}), - "watermark_opacity": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.05, - "tooltip": "Multiplies the white text alpha. 1.0 = solid white; 0.75 reads as a " - "watermark without burying the picture under it."}), - "watermark_margin": ("FLOAT", {"default": 3.0, "min": 0.0, "max": 25.0, "step": 0.5, - "tooltip": "Inset from the frame edge, as a percentage of the SHORT edge."}), - "intro_text": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "Title composited over the OPENING frames -- white on transparent, so the " - "first shot plays underneath it rather than being replaced by a card. " - "Multi-line is centered as a block. Holds for intro_seconds, then fades " - "out over intro_fade. Also PIL, never the model."}), - "intro_position": (["center", "lower-third", "top-center", "bottom-center"], - {"default": "center"}), - "intro_seconds": ("FLOAT", {"default": 3.0, "min": 0.0, "max": 30.0, "step": 0.5, - "tooltip": "How long the title stays at full opacity before the fade starts."}), - "intro_fade": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 10.0, "step": 0.1, - "tooltip": "Linear fade-out length after the hold. 0 = hard cut."}), - "intro_size": ("FLOAT", {"default": 9.0, "min": 0.5, "max": 40.0, "step": 0.5, - "tooltip": "Title cap height as a percentage of the frame's SHORT edge."}), - "overlay_font": ("STRING", {"default": "arial.ttf", - "tooltip": "TrueType font for BOTH overlays: a bare name resolved against the system " - "font folder (arial.ttf, arialbd.ttf, segoeui.ttf) or a full path to a " - ".ttf/.otf file. Falls back to the first font that loads if this one fails."}), - "overlay_stroke": ("INT", {"default": 0, "min": 0, "max": 20, - "tooltip": "Black outline thickness in pixels around the white text. 0 keeps it pure " - "white as asked; 2-3 makes it survive a bright sky or a white wall."}), - "ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"], - {"default": "auto ref2v", - "tooltip": "Which shots the reference IMAGE inputs condition. 'auto ref2v' (default) is " - "the reference-to-video bias: if the prompt uses tags, those " - "tags decide which shot gets which ref; if there are NO tags anywhere, the " - "node conditions EVERY shot with the connected refs rather than collapsing " - "them to shot 1. That is the better default for single-subject ref2v and " - "for long chains where identity drift matters more than strict per-shot " - "routing. 'where tagged' keeps the old strict behavior, including the " - "first-shot fallback when no tags are found. Tags are renumbered per shot, " - "so alone still resolves. In 'auto ref2v', character and " - "location names in the beat can also pull their matching refs into the " - "real image-conditioning list; 'where tagged' does NOT do that. " - "'first shot' / 'every shot' / " - "'every shot + handoff ref' go purely by position. Ignored when no " - "ref_image is connected."}), - "ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005, - "tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own " - "default) hands it a finished, noise-free image -- which invites the " - "model to REPRODUCE the reference in the opening frames instead of just " - "taking an identity from it. Lower values blend the condition with " - "noise and label it as approximate, so it informs the face without " - "being copied. 0.95 is the ref2v-biased default here; 0.999 keeps the " - "upstream literal-reference behavior. Too low (below ~0.8) and the " - "reference stops holding identity at all. Applies ONLY to " - "ref-conditioned shots -- the last-frame handoff is never weakened, or " - "continuity would break."}), - "ref_image_size": (["match", "max"], {"default": "match", - "tooltip": "How large each reference is encoded. 'match' scales it down to the " - "generation's pixel area -- a reference then costs about one frame per " - "step. 'max' uses the reference pipeline's 2048 short edge for the best " - "identity fidelity, but reference rows are re-attended EVERY step of " - "EVERY ref-conditioned shot, so on a long chain it is several times " - "slower. Neither ever upscales a small reference."}), - "beat_split": (["auto", "each line"], {"default": "auto", - "tooltip": "How the prompt box becomes beats. Beats are separated by a BLANK line " - "(or a '##' line). auto (default): each paragraph stays one beat, so " - "plain newlines inside a beat remain part of that beat. 'each line': every " - "content line becomes its own beat, with directive lines (wardrobe:, " - "seconds:, exit:, etc.) attaching to the beat they configure. Older " - "removed values such as 'blank line' now behave like 'auto'."}), - "anchor_override": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "Set the persistent look explicitly instead of using the first paragraph. " - "When this is filled in, EVERY paragraph of the prompt box is a beat/shot -- " - "nothing is consumed as the identity anchor. Put the permanent identity here " - "(hair, face, build, age) and the clothing in character_memory."}), - "auto_soundscape": (["off", "fill if blank", "always"], {"default": "fill if blank", - "tooltip": "Build the ambient bed from the scene instead of typing one. Reads the " - "ANCHOR (the soundscape is global, so it must describe the PLACE, not " - "one beat's action), falling back to the beats when the anchor is pure " - "camera language. 'A disused aircraft hangar' -> cavernous interior, " - "long reverb, distant metal ticks. Weather layers first: rain, wind, " - "snow, fog. NO human sounds are ever generated -- no chatter, crowd or " - "announcements -- because an ambient bed that implies voices is how H3 " - "starts talking.\n\n" - "'fill if blank' derives one only when the global_soundscape input " - "is unconnected or empty, so connecting your own text is enough to " - "keep it. 'always' derives even when you HAVE connected one, " - "overriding it -- deliberate, for comparing your bed against a " - "derived one without unwiring. 'off' never derives.\n\n" - "Whichever fires, the bed actually used comes out on the " - "`soundscape` output, so you can read it and wire it back into the " - "input to pin it."}), - "lock_restraints": ("BOOLEAN", {"default": True, - "tooltip": "Physical restraints stay ON until something explicitly removes them. " - "Handcuffs, shackles, manacles, fetters, irons, gags, blindfolds, " - "harnesses, leashes, plus qualified forms like 'ankle chain' or " - "'leather wrist straps'. Without this they come off like any garment, " - "and often by ACCIDENT -- 'steps out of her jacket and the chain falls " - "away' removed the ankle chain as a side effect of a jacket beat. To " - "take one off, say so directly: 'wardrobe: Mara -= handcuffs'. Bare " - "'chain', 'collar', 'strap' and 'belt' are NOT treated as restraints; " - "they are jewellery, a shirt part, a dress part and a garment at least " - "as often."}), - "contact_guard": (["off", "auto", "on"], {"default": "auto", - "tooltip": "Keep two bodies in contact correctly aligned -- any position, " - "not a list of named ones.\n\n" - "Position-agnostic on purpose. The model already knows more " - "position names than a dictionary could hold; what it gets wrong " - "is the GEOMETRY, so the geometry is what gets stated, and these " - "hold for every arrangement:\n" - " - OWNERSHIP: each person keeps their own head, two arms and two " - "legs, each joined to the body it belongs to. Overlapping bodies " - "is exactly when an arm gets reassigned to the wrong torso.\n" - " - SEPARATION: they meet at the surface of the skin, each keeping " - "its own volume, rather than passing into one another.\n" - " - STABLE ROLES: whoever is above stays above, below stays below, " - "behind stays behind, for the whole shot and from every camera " - "angle. Positions morph mid-shot because nothing fixes them.\n" - " - SUPPORT: weight rests on whatever is holding it, and the two " - "bodies stay in proportion.\n\n" - "Needs TWO people in the shot -- one body cannot be misaligned " - "against another, and saying otherwise would invite a second person " - "in. 'auto' fires on a contact cue in the beat; 'on' states it " - "whenever two or more people are present.\n\n" - "Describe the arrangement in the beat itself in RELATIVE terms " - "(who is above, behind, facing whom) rather than by a position " - "name alone -- this guard holds a stated arrangement together, it " - "cannot infer one you did not state."}), - "motion_guard": (["off", "auto", "on"], {"default": "auto", - "tooltip": "Stop poses being reached without the frames in between -- the " - "head arriving at a new angle with no path to it (a 'neck snap'), " - "a body teleporting between two positions.\n\n" - "What is missing in a snap is the PATH, not the pose, so the path " - "is what gets stated: movement travels through every position on " - "the way, at one steady speed, the neck following the shoulders " - "and the shoulders following the hips. Positive, because at cfg 1 " - "H3 is CFG-free and the negative is never evaluated -- and 'the " - "head does not snap round' in the positive names a head snapping " - "round.\n\n" - "'auto' speaks only on a beat that actually moves someone (turns, " - "looks, walks, leans, reaches... and the high-jerk ones -- struggles, " - "pulls, twists, writhes -- where a limb most often arrives without its " - "path), since a beat where nobody changes orientation has no path to " - "describe. 'on' states it every shot. Names nobody, so it adds no " - "second reference to anyone already in frame.\n\n" - "A snap right after a cut is a different thing: that is the model " - "leaving the keyframe pose. handoff_offset helps there."}), - "solidity_guard": (["off", "auto", "on"], {"default": "auto", - "tooltip": "Keep bodies from passing through objects. States that the solid " - "things in the shot occupy space and that bodies stop at surfaces.\n\n" - "Stated POSITIVELY, and it has to be: H3 is CFG-free at cfg 1, so a " - "negative prompt is never evaluated, and 'does not walk through the " - "wall' in the positive names walking through a wall -- a mention is a " - "presence cue. It says what bodies DO instead: stop at the surface, " - "rest on the floor, press against what they touch, go around the " - "furniture.\n\n" - "'auto' speaks only when the shot actually names something solid " - "(walls, doors, tables, stairs, vehicles, crates, trees...), reading " - "BOTH the beat and the identity block, since the set is usually " - "described in the anchor. 'on' states it every shot. Only ever " - "applied to a shot with someone in it -- a body is needed before one " - "can pass through anything."}), - "anatomy_guard": (["off", "auto", "on"], {"default": "auto", - "tooltip": "State each person's limb COUNT positively, to stop spare arms, " - "duplicated hands and the third leg. H3 is CFG-free at cfg 1, so a " - "NEGATIVE prompt is never evaluated -- 'extra limbs' in a negative does " - "nothing. Naming a number gives the model a target instead; negating one " - "only puts the word in the prompt. Added per-shot and only where people " - "are actually present, never in the anchor (anchor body words are what " - "burn a face into every opening frame). 'auto' = on below 768 short edge " - "OR when a LoRA is applied, and also on ANY shot holding two or more " - "people -- spare limbs are grown where bodies meet and move together. " - "Costs ~90 tokens on shots with people."}), - "exposed_terms": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "What a stripped body zone is CALLED, per character, so it persists " - "automatically instead of being typed into every beat. Same syntax as " - "character_memory -- a PRONOUN covers everyone who declares it, a NAME " - "overrides it, and a trailing 'upper' targets the chest. " - "For example -- 'she = visible vulva, mvagina' / " - "'he = visible penis, mpenis' / 'Mara upper = bare breasts'. " - "Once a removal empties that zone the phrase is stamped into every " - "later shot that person is in, and clears by itself when something " - "covers the zone again. Put LoRA trigger words here too. Requires " - "prevent_nudity OFF; empty falls back to 'bare below the waist'."}), - "prevent_nudity": ("BOOLEAN", {"default": True, - "tooltip": "Never let the prompt ASSERT that a body is bare. A removal still " - "happens either way -- this gates the sentence, not the garment. " - "Deleting the last item covering a zone only leaves it undescribed, " - "and a video model's default is a clothed person, so it covers what " - "nobody described. With this OFF the node states the state outright " - "('bare below the waist') and keeps stating it until something covers " - "that zone again, which is what makes a strip actually stick. ON is " - "the safe default; turn it OFF only when nudity is intended. Either " - "way info reports which zone a removal left uncovered."}), - "auto_props": ("BOOLEAN", {"default": True, - "tooltip": "Carry OBJECTS across shots. Each shot is a separate generation, so " - "'the van' in shot 2 has no antecedent -- nothing in that prompt " - "describes a van, and the model invents one, which is how a second van " - "appears while the first is still in frame. With this on, an object " - "introduced indefinitely ('a white van') is bound on its first definite " - "reference in any later beat ('the van' -> 'the same white van') and a " - "short clause pins it to the previous shot: one van only, no second van. " - "Only the FIRST mention per shot is expanded, quoted dialogue is never " - "rewritten, worn garments are excluded (they have the wardrobe channel), " - "and frame/body nouns (the ground, the light, the hand) are never " - "carried."}), - "auto_wardrobe": ("BOOLEAN", {"default": True, - "tooltip": "Read clothing REMOVALS straight from your beat prose -- 'she takes off her " - "jacket' drops the jacket with no 'wardrobe:' line needed. Safe: only fires " - "on items the character is already wearing, so 'the plane takes off' does " - "nothing. Additions/swaps still use 'wardrobe: += ...' (which overrides). " - "Turn OFF to control wardrobe only via explicit 'wardrobe:' lines."}), - "subject_count_guard": (["auto", "on", "off"], {"default": "auto", - "tooltip": "Anti-duplication: prepend an explicit subject count to each shot " - "(\"Exactly two people in this shot, no duplicates, no other people in " - "frame\"). Character duplication gets much more likely BELOW the model's " - "native 768 short edge -- fewer pixels per subject pushes the sample out of " - "the training distribution and the figure gets tiled. A LoRA causes it too: a " - "distilled LoRA fixes composition (including how many people are in frame) " - "in its first step or two, so it duplicates even at native size -- there the " - "count is moved to the FRONT of the prompt so it binds before the scene. " - "'auto' = on when the short edge is under 768 OR a LoRA is applied, and also " - "on ANY shot holding two or more people -- multi-figure frames are where " - "duplication happens even at native size; " - "'on' always; 'off' never."}), - "auto_silence_nonspeech": ("BOOLEAN", {"default": True, - "tooltip": "Stop mouths moving / gibberish audio on shots with no dialogue. Any beat " - "with no scripted speech gets an explicit 'lips closed, no dialogue' clause, " - "so H3 doesn't animate or vocalize a mouth before real dialogue. Beats with " - "quoted dialogue (\"...\") are left alone. To make someone speak, put the " - "words in double quotes. Turn OFF to manage lip state yourself."}), - "allow_nonspeech_vocals": ("BOOLEAN", {"default": False, - "tooltip": "Allow non-speech vocal sounds (screams, sobs, gasps, moans) on " - "shots with no dialogue. When ON, the node skips the lips-closed " - "clause and softens the no-voice soundscape to ban only speech, " - "dialogue and singing -- not screams, sobs, or other distress " - "vocalizations. Audio is also left unmuted on those shots. Turn ON " - "when your scene contains distress sounds that H3 would otherwise " - "suppress. Keep auto_silence_nonspeech ON for shots that should be " - "truly silent."}), - "character_memory": ("STRING", {"multiline": True, "forceInput": True, "default": "", - "tooltip": "Optional dedicated wardrobe channel (same role as a 'wardrobe:' line in " - "the first paragraph -- use whichever you prefer; this field wins if both " - "are set). Re-stamped into every shot so clothing holds even when the " - "camera crops it out. IMPORTANT: this is the ONLY place clothing should " - "live -- keep it out of the anchor prose, or a removal won't stick because " - "the immutable anchor keeps re-adding it. To change/remove an item " - "mid-chain, put 'wardrobe: ' inside the beat where it " - "changes; omit the removed item from the new sheet and it stays gone. " - "WRITE ATTRIBUTES, NOT NOUN PHRASES: 'silver hair, 27, red jacket' -- NOT " - "'a woman with silver hair'. A noun phrase renders as 'She (a woman with...)', " - "i.e. two subjects in one clause, which causes character duplication. The node " - "strips them automatically, but writing attributes directly is cleaner. " - "ONE-TOKEN EDITS (no restating the outfit): 'wardrobe: -= jacket' removes " - "the jacket, 'wardrobe: += sunglasses' adds one. TWO+ PEOPLE: name them -- " - "'Maya = grey shorts, red jacket; Jon = navy overalls', then edit one at a " - "time: 'wardrobe: Maya -= jacket' leaves Jon untouched."}), - }, - # Read-only graph access, for SLA-LoRA detection: a LoRA's filename is - # the only thing that identifies an SLA build, and the graph is the only - # place it survives. Named 'graph'/'node_id' rather than the usual - # 'prompt' because this node already has a `prompt` widget -- ComfyUI - # passes hidden inputs by parameter name, so "prompt": "PROMPT" would - # overwrite the user's text with the workflow dict. Hidden inputs carry - # no widget, so they cannot shift saved widget positions. - "hidden": {"graph": "DYNPROMPT", "node_id": "UNIQUE_ID"}, - } - # ComfyUI restores a saved graph's widget values POSITIONALLY, from a flat - # widgets_values array. A widget inserted in the MIDDLE therefore shifts every - # value after it onto the wrong widget in every workflow saved before it - # existed -- silently, with no error. So widgets added after v1 are forced to - # the END here, leaving the original order byte-for-byte intact. - opt = schema["optional"] - for name in ADDED_WIDGETS: - if name in opt: - opt[name] = opt.pop(name) # re-insert at the end, value unchanged - return schema - - def _render(self, model, clip, vae, audio_vae, negative, prompt, w, h, ln, fps, tiled, sa, - handoff, decode_tile_frames=0, decode_tile_size=0, - refs=None, ref_image_size="match", ref_noise_aug=None, silent=False, - latent_upscale_param=None, timing_sink=None): - timing = {"sample": 0.0, "latent_upscale_sample": 0.0, "decode_video": 0.0, "decode_audio": 0.0, "cleanup": 0.0} - positive, latent = _build_shot_conditioning(clip, vae, prompt, w, h, ln, fps, handoff, - ref_images=refs, ref_image_size=ref_image_size, - ref_noise_aug=ref_noise_aug, - audio_vae=audio_vae, silent=silent) - seed, steps, cfg, sn, sch, denoise = sa - # Conditioning is built, so the text encoder and VAEs are dead weight for the - # whole sampling loop -- evict them and keep only the DiT on the card. - _evict_all_but(model) - try: - sample_start = time.perf_counter() - (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative, - latent, denoise=denoise) - timing["sample"] += time.perf_counter() - sample_start - except Exception as e: - # Mark WHERE this failed. `tiled` only affects the DECODE, so the caller's - # OOM retry cannot help an OOM raised here -- it just re-runs the whole - # sampling pass and fails the same way, which on a 362-frame shot is four - # more minutes for nothing. - raise _tag_oom_stage(e, "sampling") - refined_out = out - latent_upscale_param = latent_upscale_param or None - latent_upscale_mode = _latent_upscale_mode(latent_upscale_param) - if latent_upscale_mode != "off": - try: - latent_start = time.perf_counter() - out_samples = out["samples"] - parts = _nested_tensor_parts(out_samples) - if not getattr(out_samples, "is_nested", False) or len(parts) < 2: - raise RuntimeError("latent upscale expects a nested AV latent") - if latent_upscale_mode == "model" and str(latent_upscale_param.get("device", "cuda")) == "cuda": - _evict_for_latent_upscale(model) - upscaled_video, up_h, up_w = _upscale_latent_video(parts[0], latent_upscale_param) - full_audio = parts[1] - # Drop the first-pass sampling state before we start the refinement - # pass; otherwise the 12-step base latent and the upscale latent sit - # in memory together and can trigger a retry loop. - out["samples"] = comfy.nested_tensor.NestedTensor((upscaled_video, full_audio)) - del out_samples, parts - mm.soft_empty_cache() - target_w = int(up_w) * 16 - target_h = int(up_h) * 16 - if target_w <= 0 or target_h <= 0: - raise RuntimeError("latent upscale target size must be positive") - try: - upscale_cond = _retarget_conditioning_spatial(positive, int(up_h), int(up_w)) - upscale_latent = dict(latent) if isinstance(latent, dict) else {} - except Exception: - upscale_cond, upscale_latent = _build_shot_conditioning( - clip, vae, prompt, target_w, target_h, ln, fps, handoff, - ref_images=refs, ref_image_size=ref_image_size, - ref_noise_aug=ref_noise_aug, audio_vae=audio_vae, silent=silent) - upscale_latent["samples"] = comfy.nested_tensor.NestedTensor((upscaled_video, full_audio)) - del positive, latent - refine_steps = int(latent_upscale_param.get("steps", 2) or 2) - refine_sampler = latent_upscale_param.get("sampler_name", sn) - refine_scheduler = latent_upscale_param.get("scheduler", sch) - refine_denoise_value = latent_upscale_param.get("denoise", latent_upscale_param.get("refine_denoise", 0.2)) - refine_denoise = 0.2 if refine_denoise_value is None else float(refine_denoise_value) - tile_size_mode = str(latent_upscale_param.get("tile_size_mode", "specific_size")) - tile_w_px = int(latent_upscale_param.get("tile_width", 512) or 512) - tile_h_px = int(latent_upscale_param.get("tile_height", 512) or 512) - overlap_px = max(0, int(_param_value(latent_upscale_param, "overlap", 64))) - fade_w_px = max(0, int(_param_value(latent_upscale_param, "fade_width", 32))) - fade_h_px = max(0, int(_param_value(latent_upscale_param, "fade_height", 32))) - overlap_mode = str(latent_upscale_param.get("overlap_mode", "earlier")) - overlap_blend = str(latent_upscale_param.get("overlap_blend", "linear")) - grid_rows = max(1, int(_param_value(latent_upscale_param, "grid_rows", 2))) - grid_cols = max(1, int(_param_value(latent_upscale_param, "grid_cols", 2))) - spatial_w_overlap_px = max(0, int(_param_value(latent_upscale_param, "spatial_w_overlap", overlap_px))) - spatial_h_overlap_px = max(0, int(_param_value(latent_upscale_param, "spatial_h_overlap", overlap_px))) - min_tile_size_px = max(0, int(_param_value(latent_upscale_param, "min_tile_size", 256))) - masked_area_noise = float(_param_value(latent_upscale_param, "masked_area_noise", 0.0)) - brightness_match = bool(latent_upscale_param.get("brightness_match", False)) - dynamic_fade = str(latent_upscale_param.get("dynamic_fade", "off")) - dynamic_fade_min_px = max(0, int(_param_value(latent_upscale_param, "dynamic_fade_min", 32))) - if tile_size_mode == "rows_cols": - tile_w_px, spatial_w_overlap_px = _solve_equal_tiles(target_w, grid_cols, spatial_w_overlap_px, 16) - tile_h_px, spatial_h_overlap_px = _solve_equal_tiles(target_h, grid_rows, spatial_h_overlap_px, 16) - if tile_w_px < min_tile_size_px or tile_h_px < min_tile_size_px: - raise ValueError( - f"rows_cols mode: solved tile size is {tile_h_px}x{tile_w_px}px " - f"(grid {grid_rows}x{grid_cols} over {target_h}x{target_w}px), " - f"which is smaller than min_tile_size ({min_tile_size_px}px). " - f"Reduce grid_rows/grid_cols, or lower min_tile_size to at most " - f"{min(tile_w_px, tile_h_px)}px." - ) - fade_w_px = min(fade_w_px, spatial_w_overlap_px) - fade_h_px = min(fade_h_px, spatial_h_overlap_px) - else: - for name, value in ( - ("tile_width", tile_w_px), - ("tile_height", tile_h_px), - ("overlap", overlap_px), - ("fade_width", fade_w_px), - ("fade_height", fade_h_px), - ("min_tile_size", min_tile_size_px), - ): - if value % 32 != 0: - raise ValueError(f"'{name}' must be a multiple of 32 pixels; got {value}.") - if overlap_px >= tile_w_px: - raise ValueError("overlap must be smaller than tile_width") - if overlap_px >= tile_h_px: - raise ValueError("overlap must be smaller than tile_height") - if fade_w_px > spatial_w_overlap_px: - raise ValueError("fade_width must not exceed spatial_w_overlap") - if fade_h_px > spatial_h_overlap_px: - raise ValueError("fade_height must not exceed spatial_h_overlap") - if min_tile_size_px > tile_w_px or min_tile_size_px > tile_h_px: - raise ValueError("min_tile_size must not exceed the tile size") - tile_tw = max(1, tile_w_px // 16) - tile_th = max(1, tile_h_px // 16) - ol_tw = max(0, min(tile_tw - 1, spatial_w_overlap_px // 16)) - ol_th = max(0, min(tile_th - 1, spatial_h_overlap_px // 16)) - fw_tw = max(0, min(ol_tw, fade_w_px // 16)) - fw_th = max(0, min(ol_th, fade_h_px // 16)) - min_tile_tw = max(0, min_tile_size_px // 16) - rows, cols, trows, tcols, row_ovl, col_ovl = compute_spatial_grid( - int(up_h), int(up_w), tile_th, tile_tw, ol_th, ol_tw, min_tile_tw, min_tile_tw - ) - logging.info( - "H3 latent refine: %s spatial sampler tiles, target=%sx%s tile_mode=%s tile=%sx%s overlap=%sx%s", - len(rows) * len(cols), target_w, target_h, tile_size_mode, tile_w_px, tile_h_px, - spatial_w_overlap_px, spatial_h_overlap_px, - ) - if len(rows) == 1 and len(cols) == 1: - (refined_out,) = nodes.common_ksampler( - model, seed, refine_steps, cfg, refine_sampler, refine_scheduler, upscale_cond, negative, upscale_latent, - denoise=refine_denoise) - else: - refined_video = upscaled_video.clone() - for row_index, r0 in enumerate(rows): - tr = trows[row_index] - ovh = row_ovl[row_index] - for col_index, c0 in enumerate(cols): - tc = tcols[col_index] - ovw = col_ovl[col_index] - tile_cond = _crop_conditioning_to_tile( - upscale_cond, int(up_h), int(up_w), r0, c0, tr, tc - ) - tile_latent = dict(upscale_latent) if isinstance(upscale_latent, dict) else {} - tile_video = upscaled_video[:, :, :, r0:r0 + tr, c0:c0 + tc].contiguous() - tr_s = tr + (tr % 2) - tc_s = tc + (tc % 2) - tile = torch.zeros((1, tile_video.shape[1], tile_video.shape[2], tr_s, tc_s), - device=tile_video.device, dtype=tile_video.dtype) - tile[:, :, :, :tr, :tc] = tile_video - if col_index > 0 and ovw > 0: - tile[:, :, :, :tr, :ovw] = refined_video[:, :, :, r0:r0 + tr, c0:c0 + ovw] - if row_index > 0 and ovh > 0: - tile[:, :, :, :ovh, :tc] = refined_video[:, :, :, r0:r0 + ovh, c0:c0 + tc] - mask = make_fade_mask(tr_s, tc_s, ovh, ovw, row_index > 0, col_index > 0, - fade_h=fw_th, fade_w=fw_tw) - mask[tr:tr_s, :] = 0.0 - mask[:, tc:tc_s] = 0.0 - mv = (mask + masked_area_noise * (1.0 - mask))[None, None, None].to(tile.dtype) - ma = torch.zeros((1, 32, 2, full_audio.shape[-1]), device=full_audio.device, dtype=full_audio.dtype) - tile_latent["samples"] = comfy.nested_tensor.NestedTensor((tile, full_audio)) - tile_latent["noise_mask"] = comfy.nested_tensor.NestedTensor((mv, ma)) - dynamic = _dynamic_fade_closure( - latent_upscale_param, fw_tw, fw_th, tr, tc, tr_s, tc_s, ovh, ovw, - row_index > 0, col_index > 0, math.prod(tile.shape[1:]), mn=masked_area_noise - ) - if dynamic is not None: - model.set_model_denoise_mask_function(dynamic) - try: - tile_out, = nodes.common_ksampler( - model, seed, refine_steps, cfg, refine_sampler, refine_scheduler, tile_cond, negative, tile_latent, - denoise=refine_denoise) - finally: - if dynamic is not None: - model.model_options.pop("denoise_mask_function", None) - tile_out = _video_only_refined_latent( - {"samples": comfy.nested_tensor.NestedTensor((tile_video, full_audio))}, - tile_out) - tile_video_out = tile_out["samples"].tensors[0] - if brightness_match: - tile_video_out = bright_match_tile( - tile_video_out, - upscaled_video[:, :, :, r0:r0 + tr, c0:c0 + tc] - ) - region = refined_video[:, :, :, r0:r0 + tr, c0:c0 + tc] - base_region = region.clone() - region.copy_(tile_video_out) - if col_index > 0 and ol_tw > 0: - t = torch.linspace(0.0, 1.0, ol_tw, device=region.device, dtype=region.dtype) - w = _latent_spatial_blend_weights(t, overlap_mode, overlap_blend) - if fw_tw > 0: - w = w.clone() - w[:fw_tw] = 0.0 - region[:, :, :, :, :ol_tw] = ( - base_region[:, :, :, :, :ol_tw] * (1.0 - w[None, None, None, None, :]) + - tile_video_out[:, :, :, :, :ol_tw] * w[None, None, None, None, :] - ) - if row_index > 0 and ol_th > 0: - t = torch.linspace(0.0, 1.0, ol_th, device=region.device, dtype=region.dtype) - w = _latent_spatial_blend_weights(t, overlap_mode, overlap_blend) - if fw_th > 0: - w = w.clone() - w[:fw_th] = 0.0 - region[:, :, :, :ol_th, :] = ( - base_region[:, :, :, :ol_th, :] * (1.0 - w[None, None, None, :, None]) + - tile_video_out[:, :, :, :ol_th, :] * w[None, None, None, :, None] - ) - refined_out = {"samples": comfy.nested_tensor.NestedTensor((refined_video, full_audio))} - timing["latent_upscale_sample"] += time.perf_counter() - latent_start - refined_out = _video_only_refined_latent(upscale_latent, refined_out) - del upscale_latent, upscaled_video, full_audio - mm.soft_empty_cache() - except Exception as e: - raise _tag_oom_stage(e, "latent_upscale") - # Keep a CPU copy of the sampled latent BEFORE decoding, for the `latent` - # output. Latents are ~1000x smaller than the frames they decode to (a - # 1344x768 124f shot is ~1.5MB against ~1.5GB), so carrying one per shot for - # the whole chain is free. Detached and moved off the card immediately, for - # the same reason the decoded frames are. - decode_audio_start = time.perf_counter() - audio = _decode_audio(audio_vae, out) - timing["decode_audio"] += time.perf_counter() - decode_audio_start - decode_video_start = time.perf_counter() - shot_latent = _copy_sample_latent(refined_out) - video = _decode_video(vae, refined_out, tiled, free_first=model, - tile_t=decode_tile_frames, tile_xy=decode_tile_size) - timing["decode_video"] += time.perf_counter() - decode_video_start - cleanup_start = time.perf_counter() - del out - del refined_out - _deep_cleanup() - timing["cleanup"] += time.perf_counter() - cleanup_start - if timing_sink is not None: - timing["total"] = sum(timing.values()) - timing_sink.append(timing) - return video, audio, shot_latent - - def run(self, model, clip, vae, audio_vae, prompt, resolution, - steps, cfg, sampler_name, scheduler, seed, - megapixels=1.0, - first_frame=None, fps=24, plan_only=False, - global_soundscape="", non_diegetic_music="", apply_model_sampling=True, - shift_video=12.0, shift_audio=3.0, trim_seam=True, vary_seed_per_shot=False, - handoff_offset=0, vram_headroom_gb=1.5, allow_res_backoff=True, - decode_tile_frames=0, decode_tile_size=0, - anchor_override="", shot_seconds=0.0, - beat_split="auto", - character_memory="", auto_wardrobe=True, auto_props=True, prevent_nudity=True, - exposed_terms="", anatomy_guard="auto", lock_restraints=True, - solidity_guard="auto", motion_guard="auto", contact_guard="auto", - auto_soundscape="fill if blank", - auto_silence_nonspeech=True, allow_nonspeech_vocals=False, - subject_count_guard="auto", - upscale="off", upscale_model="none", - upscale_target_short_edge=0, upscale_batch=4, - mute_nonspeech_audio=True, mute_fade_ms=40, - watermark_text="", watermark_position="bottom-right", watermark_size=4.0, - watermark_opacity=0.75, watermark_margin=3.0, - intro_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6, - intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0, - ref_1=None, ref_2=None, ref_3=None, ref_4=None, - ref_5=None, ref_6=None, ref_7=None, ref_8=None, - ref_9=None, - ref_mode="auto ref2v", ref_image_size="match", ref_noise_aug=0.95, - latent_upscale_param=None, - graph=None, node_id=None): - - # FIRST: detect a checkpoint swap since the previous execution and hard-flush. - # A stale resident model from a different checkpoint would otherwise poison - # every VRAM measurement below (and leave old hooks/allocator blocks behind), - # so this must run before the schedule patch and before vram_gb(). - swap_note = flush_for_model_change(model) - - # Cheap wiring preflight: a video VAE on the audio_vae socket only blows up - # after a full shot has been sampled and decoded, so reject it up front. - check_vae_wiring(vae, audio_vae) - - # H3 renders 24 fps, always. Honor the widget only as a warning: a lower value - # used to silently shorten every shot (10s -> 124f -> 5.2s of real time). - fps_note = ("" if int(fps) == H3_FPS else - f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations " - f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too") - fps = H3_FPS - w, h = parse_resolution(resolution) - ref_slots = tuple(( - ref_1, ref_2, ref_3, ref_4, ref_5, - ref_6, ref_7, ref_8, ref_9, - )) - normalized_ref_slots = _normalized_ref_slots(ref_slots) - connected_refs = [ref for ref in normalized_ref_slots if _reference_image(ref) is not None] - connected_ref_count = len(connected_refs) - direct_ref_count = len(connected_refs) - explicit_character_memory = (character_memory or "").strip() - derived_character_memory = _reference_character_memory(ref_slots) - effective_character_memory = explicit_character_memory or derived_character_memory - # A pixel budget overrides the preset's SIZE while keeping its aspect ratio, - # so the dropdown chooses the shape and this chooses how big. Scaling from - # the preset's own dimensions is what makes 1.00MP reproduce each native - # size exactly -- the preset NAMES are approximations (1344x768 is 7:4, not - # 16:9), so computing from a nominal ratio would not. - # - # Reported as ACHIEVED, not requested: snapping to the 32-grid moves the - # real area, and echoing the asked-for figure would print a number the - # render never used. - mp_note = "" - if megapixels and float(megapixels) > 0: - nw, nh = scale_to_megapixels(w, h, float(megapixels)) - if (nw, nh) != (w, h): - mp_note = (f"megapixels {float(megapixels):.2f} -> {nw}x{nh} " - f"({nw * nh / MP_UNIT:.3f}MP actual; preset was {w}x{h} " - f"@ {w * h / MP_UNIT:.3f}MP)") - w, h = nw, nh - else: - mp_note = (f"megapixels {float(megapixels):.2f} -> {w}x{h} " - f"({w * h / MP_UNIT:.3f}MP), the preset's own size") - # H3 is CFG-free (cfg 1): the sampler skips the negative, but common_ksampler - # still needs a conditioning object, so build an empty one from the clip. - negative = clip.encode_from_tokens_scheduled(clip.tokenize("")) - # Cheapest possible preflight: this empty encode already went through the - # text encoder, so compare its width to the DiT's before anything expensive. - check_text_encoder(model, negative) - - # Patch the dual video/audio schedule onto the model here, so a missing - # upstream ModelSamplingMiniMaxH3 can't silently produce gibberish audio. - # Shifts come from the widgets (12/3 base default; MXFP8/turbo differ). - ms_note = "" - if apply_model_sampling: - model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio) - latent_upscale_note = "" - latent_upscale_mode = _latent_upscale_mode(latent_upscale_param) - if latent_upscale_mode != "off": - target_w, target_h = _latent_upscale_target_size(w, h, latent_upscale_param) - mode = latent_upscale_mode - detail = f" via {mode}" - if mode == "model": - detail += f"/{latent_upscale_param.get('model_name', 'none')}" - else: - detail += f"/{latent_upscale_param.get('method', 'bilinear')}" - denoise_value = latent_upscale_param.get("denoise", latent_upscale_param.get("refine_denoise", 0.2)) - denoise = 0.2 if denoise_value is None else float(denoise_value) - refine_sampler = latent_upscale_param.get("sampler_name", "euler_ancestral") - refine_scheduler = latent_upscale_param.get("scheduler", "simple") - batch_note = "" - tile_size_mode = str(latent_upscale_param.get("tile_size_mode", "specific_size")) - tile_w_px = int(latent_upscale_param.get("tile_width", 512) or 512) - tile_h_px = int(latent_upscale_param.get("tile_height", 512) or 512) - overlap_px = max(0, int(_param_value(latent_upscale_param, "overlap", 64))) - overlap_blend = str(latent_upscale_param.get("overlap_blend", "linear")) - grid_rows = max(1, int(_param_value(latent_upscale_param, "grid_rows", 2))) - grid_cols = max(1, int(_param_value(latent_upscale_param, "grid_cols", 2))) - if tile_size_mode == "rows_cols": - batch_note = f"; spatial batches {grid_rows}x{grid_cols} rows_cols over {target_w}x{target_h}px" - elif tile_w_px > 0 and tile_h_px > 0 and (tile_w_px < target_w or tile_h_px < target_h): - spatial_w_overlap_px = max(0, int(_param_value(latent_upscale_param, "spatial_w_overlap", overlap_px))) - spatial_h_overlap_px = max(0, int(_param_value(latent_upscale_param, "spatial_h_overlap", overlap_px))) - batch_note = ( - f"; spatial batches {tile_w_px}x{tile_h_px}px " - f"overlap {spatial_w_overlap_px}x{spatial_h_overlap_px}px {overlap_blend}" - ) - latent_upscale_note = ( - f" latent upscale: target {target_w}x{target_h}px{detail}; " - f"{int(latent_upscale_param.get('steps', 2) or 2)}-step refinement " - f"{refine_sampler}/{refine_scheduler} denoise {denoise:.2f}{batch_note}" - ) - - paras = split_paragraphs(prompt, "##") - if anchor_override.strip(): - anchor, beat_paras = anchor_override.strip(), paras - elif paras: - anchor, beat_paras = paras[0], paras[1:] - else: - anchor, beat_paras = "", [] - # A first paragraph that would be stripped to nothing is not an anchor -- it is an - # action beat about a tracked character, and consuming it deletes that shot - # outright (the sentence names the character, so it gets removed from the always-on - # anchor to avoid introducing them twice). Keep it as a BEAT and say so loudly, - # rather than losing a shot and the scene text along with it. - anchor_note = "" - if (not anchor_override.strip()) and paras and \ - (anchor_contributes_nothing(anchor, effective_character_memory) - or anchor_is_action_beat(anchor, paras[1:])): - preview = " ".join(anchor.split())[:60] - anchor, beat_paras = "", paras - anchor_note = ( - f'WARNING: paragraph 1 ("{preview}...") reads as an action beat about a tracked ' - f'character, not an identity anchor -- consuming it would have deleted that shot ' - f'entirely, so it was KEPT AS A BEAT. There is now no persistent scene text: put ' - f'the setting and style (with NO character names) in anchor_override.') - # The anchor repeats on every shot, so what is IN it matters more than its - # length. These have each cost a render: face words putting a face in an empty - # establishing frame, apparatus words rendering the equipment (or someone - # holding it), framing pinning every shot, clothing that no removal can strip. - anchor_hazards = anchor_warnings(anchor) - # Anchor extraction happens on PARAGRAPHS first, so a line-split can never - # eat into the identity block; only the beat paragraphs are expanded. - beats, split_note = expand_beats(beat_paras, beat_split) - # Build the ambient bed from the scene when asked. Done here, where the - # anchor and beats are both parsed, and BEFORE distribute_generations stamps - # the soundscape onto every shot. - sound_note = "" - if auto_soundscape != "off": - typed = global_soundscape.strip() - if auto_soundscape == "always" or not typed: - derived = derive_soundscape(anchor, beats) - if derived and derived != typed: - sound_note = (f"soundscape {'replaced with' if typed else 'built from'} the " - f"scene: '{derived}'" - + (f" (yours: '{typed}')" if typed else "") - + ". Set auto_soundscape to 'off' to keep your own") - global_soundscape = derived - beats_note = (f"{len(beats)} beat(s) -> {len(beats)} shot(s) from {len(paras)} paragraph(s)" - + ("" if anchor_override.strip() else - "; paragraph 1 was consumed as the identity anchor (fill anchor_override " - "to make EVERY paragraph a beat)") - + (f". {split_note}" if split_note else "")) - - total_gb, free_gb = vram_gb() - resident_gb = dit_resident_gb(model) - # Weights larger than the card means ComfyUI must stream them: NO shot - # length or resolution avoids spilling into shared/system memory, so say so - # plainly rather than letting it look like a tuning problem. - streaming = total_gb > 0 and resident_gb > 0 and resident_gb > total_gb - lora_gb = lora_overhead_gb(model) - eff_headroom = vram_headroom_gb + lora_gb - ln, ln_note = resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb, - eff_headroom, w * h, free_gb) - if lora_gb: - ln_note = ((ln_note + " ") if ln_note else "") + ( - f"reserved ~{lora_gb:.1f}GB for bypass-LoRA adapters (they stay resident in bf16 " - f"rather than folding into the weights)") - # Hitting the internal floor means the budget arithmetic gave up, and every shot - # comes out ~5s regardless of what the beats need. That looked like the node - # ignoring the prompt; say what actually ran out and what moves the number. - if ln <= align_frame_count(MIN_SHOT_FRAMES) and total_gb > 0 and not ( - shot_seconds and float(shot_seconds) > 0): - ln_note = ((ln_note + " ") if ln_note else "") + ( - f"SHOT LENGTH IS AT THE {ln}f (~{ln / fps:.1f}s) FLOOR -- every shot will be this " - f"long whatever the beat asks for. " - + (f"No live free-VRAM reading was available, so there was nothing to budget from " - f"(weights ~{resident_gb:.1f}GB stream and cannot be subtracted from the " - f"{total_gb:.1f}GB card)." - if streaming else - f"Weights ~{resident_gb:.1f}GB + headroom ~{eff_headroom:.1f}GB leave nothing of " - f"the {total_gb:.1f}GB card for the latent.") - + f" Free right now: ~{free_gb:.1f}GB. Lower vram_headroom_gb, drop to the " - f"balanced/fast resolution tier, or close other GPU apps") - accel_note = quant_accel_note(model) - if streaming: - ln_note = ((ln_note + " ") if ln_note else "") + ( - f"weights (~{resident_gb:.1f}GB) exceed VRAM (~{total_gb:.1f}GB), so they stream rather " - f"than sitting on the card -- that figure is NOT subtracted from the budget, which is " - f"built from the ~{free_gb:.1f}GB actually free instead") - tiled = total_gb > 0 and (total_gb - resident_gb) < 20 - - # Sub-native renders duplicate subjects far more often, so default the guard on - # there and leave native renders alone (the extra clause costs prompt budget). - lora_on = lora_active(model) - # An SLA LoRA and a sparse-attention patch are a matched pair; either one - # alone costs a full render to discover. Computed here so plan_only reports - # it too -- that is the point of catching it, before anything is sampled. - sla_name, sparse_on, sla_note = sla_pairing(model, graph, node_id) - # Same idea, wider: where a LoRA's own declared training disagrees with this - # run. Reports only -- never overrides a widget, so the render stays - # reproducible from what the graph shows. - hint_notes = lora_hint_notes(model, graph, node_id, steps, min(w, h)) - # The flow shift is right for base H3 at ~20 steps and wrong for a distill - # at 4-8, and it fails silently -- soft output, no error. Computed here so - # plan_only reports it before anything is sampled. - sched_note = (schedule_balance_note(shift_video, steps, scheduler) - if apply_model_sampling else "") - # The quantization kernels are ComfyUI's, not this node's -- but losing them - # is silent, and the symptom (soft output) looks like a dozen other causes. - kernel_note = kernel_backend_note(model) - audio_ratio_note = (audio_scale_note(shift_video, shift_audio) - if apply_model_sampling else "") - # One ordered list, emitted once per output. These used to be six separate - # `+ (f" X -- {note}." if note else "")` fragments repeated at BOTH the plan - # and the render site -- twelve lines that had to stay in sync by hand. They - # did not: `audio_note` collided with the mute-reporting variable of the same - # name, so the shift-ratio warning reached plan_only and never a real render, - # while the mute note was printed twice under the wrong label. - # 'auto' fires below native resolution AND whenever a LoRA is applied: a - # distilled LoRA fixes the subject count in its first step or two, so the - # count has to be stated even at native size. - anatomy_on = (anatomy_guard == "on" or - (anatomy_guard == "auto" and (min(w, h) < 768 or lora_on))) - # 'auto' additionally states the limb count on any multi-person shot, - # whatever the resolution -- the same reasoning as count_auto above. - anatomy_auto = (anatomy_guard == "auto") - count_subjects = (subject_count_guard == "on" or - (subject_count_guard == "auto" and (min(w, h) < 768 or lora_on))) - # `ln` is the CEILING (VRAM budget, or a forced shot_seconds). Each beat gets - # its own length UNDER that ceiling -- including when shot_seconds is forced, - # which now means "no shot longer than this" rather than "every shot exactly - # this". Forcing a length used to DISABLE per-beat sizing entirely, which is - # why a plan made with a forced length disagreed with the auto render: two - # different code paths for the same question. - lens, len_notes = plan_beat_frames(beats, fps, ln) - secs = [n / fps for n in lens] - if len_notes: - n_short = sum(1 for n in lens if n < ln) - ln_note = ((ln_note + " ") if ln_note else "") + ( - f"content pacing sized {n_short} of {len(lens)} shot(s) under the {ln}f " - f"(~{ln / fps:.1f}s) ceiling from their own content: " + "; ".join(len_notes)) - # With pacing OFF, every beat gets the ceiling whether it has anything to fill - # it with or not -- so say which beats are too thin for the length they got. - # This is the failure that reads as an action repeating or playing backwards. - pace_warnings = pacing_warnings(beats, lens, fps) - if pace_warnings: - ln_note = ((ln_note + " ") if ln_note else "") + ( - "THIN BEATS -- the model must invent the remaining time, which it fills by " - "repeating or REVERSING the action: " + "; ".join(pace_warnings) - + ". Add a second clause to the beat, or set 'seconds:' on it") - fit_warnings = dialogue_fit_warnings(beats, secs) - # The opposite error, and the one that babbles: far more shot than line. - filler_warnings = dialogue_filler_warnings(beats, secs) - if filler_warnings: - ln_note = ((ln_note + " ") if ln_note else "") + ( - "BABBLE RISK -- " + "; ".join(filler_warnings) - + ". Set 'seconds:' on the beat if you want a fixed longer take") - wardrobe_notes = [] - strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh - raw_gens = distribute_generations(anchor, beats, global_soundscape.strip(), - non_diegetic_music.strip(), effective_character_memory, - auto_wardrobe, auto_silence_nonspeech, allow_nonspeech_vocals, count_subjects, - lora_on, notes_out=wardrobe_notes, auto_props=auto_props, - prevent_nudity=prevent_nudity, - exposed_terms=exposed_terms, strip_out=strip_shots, - anatomy_guard=anatomy_on, - anatomy_auto=anatomy_auto, - lock_restraints=lock_restraints, - solidity_guard=solidity_guard, - motion_guard=motion_guard, - contact_guard=contact_guard, - count_auto=(subject_count_guard == "auto")) - any_tags_anywhere = any(picture_tags(g) for g in raw_gens) - enriched_gens = [] - for i, block in enumerate(raw_gens): - beat_text = beats[i] if i < len(beats) else "" - rewritten_block, shot_refs, _dropped, _shot_mode_eff, _shot_tag_driven = _resolve_beat_prompt_refs( - block, - ref_slots, - beat_text, - connected_ref_count, - ref_mode, - any_tags_anywhere, - i, - None, - ) - context = _reference_context_for_text( - rewritten_block, - ref_slots, - resolved_refs=shot_refs, - ) - if context: - rewritten_block = _inject_reference_context(rewritten_block, context) - enriched_gens.append(rewritten_block) - gens = enriched_gens - - # A scenery beat mid-chain hands the next shot a frame with no people in - # it. Both prompts are individually correct, so this is invisible without - # looking at the sequence -- which is why chains lose their cast in the - # middle rather than degrading steadily. - cohesion_notes = continuity_warnings(gens) - # A per-shot seed resets the noise field -- and with it the grain, the - # micro-texture and every surface detail the prompt never names -- at each - # boundary. That reads as a cut even when the keyframe anchors the frame and - # the location is unchanged, and nothing else reported it. - if vary_seed_per_shot and len(gens) > 1: - cohesion_notes.append( - f"vary_seed_per_shot is ON, so the {len(gens)} shots sample from seeds " - f"{seed}..{seed + len(gens) - 1} rather than one field. Stochastic detail " - f"resets at every boundary, which looks like a cut in a continuous take. " - f"Turn it off unless the beats are meant to look separately shot") - preflight = [("SLA", sla_note), - ("LORA HINTS", "; ".join(hint_notes)), - ("", mp_note), - ("SCHEDULE", sched_note), - ("KERNELS", kernel_note), - ("AUDIO", audio_ratio_note), - ("CONTINUITY", "; ".join(cohesion_notes)), - ("SOUND", sound_note)] - preflight_txt = "".join(f"{(lbl + ' -- ') if lbl else ''}{txt}. " - for lbl, txt in preflight if txt) - override_notes = [beat_override_summary(beat, index) for index, beat in enumerate(beats, 1)] - override_notes = [note for note in override_notes if note] - anatomy_shots = [ - shot_index + 1 - for shot_index, gen in enumerate(gens) - if ANATOMY_STATE.strip() in gen - ] - anatomy_mode = ("forced" if anatomy_guard == "on" else - "auto" if anatomy_guard == "auto" else - "off") - anatomy_note = "" - if anatomy_shots: - if anatomy_mode == "forced": - anatomy_note = (f" ANATOMY -- guard injected on shot(s) " - f"{','.join(str(n) for n in anatomy_shots)}") - elif anatomy_mode == "auto": - anatomy_note = (f" ANATOMY -- guard injected on shot(s) " - f"{','.join(str(n) for n in anatomy_shots)} " - f"(auto: multi-person beats and/or sub-native or LoRA-biased runs)") - - if plan_only: - # Preview the split using THIS node's own settings -- no render, near-instant. - shots = len(gens) - plan_lens = (lens + [ln] * shots)[:shots] - total = round(sum(plan_lens) / fps, 2) - uniform = len(set(plan_lens)) == 1 - shape = (f"{shots} shot(s) x {plan_lens[0]}f (~{plan_lens[0] / fps:g}s each)" if uniform - else f"{shots} shot(s), {sum(plan_lens)}f total: " - + ", ".join(f"{n}f/~{n / fps:.1f}s" for n in plan_lens)) - vram_str = f"{total_gb:.1f}GB total / {resident_gb:.1f}GB weights / {free_gb:.1f}GB free" if total_gb else "VRAM unknown" - # Same dialogue/audio accounting the render reports, so the plan says up - # front which shots will come back silent instead of surprising you after. - n_silent = sum(1 for f in speech_flags(beats) if not f) - plan_audio = "" - if n_silent: - if allow_nonspeech_vocals: - plan_audio = (f" {n_silent} of {shots} shot(s) have no quoted dialogue -> " - f"non-speech vocals allowed (speech still suppressed)") - else: - plan_audio = (f" {n_silent} of {shots} shot(s) have no quoted dialogue -> " - + ("AUDIO-MUTED (ambience goes too)" if mute_nonspeech_audio - else "prompt/soundscape silencing only")) - # Same reference accounting the render reports: which shots lose the - # handoff is a composition decision, so it belongs in the preview. - n_refs = len(connected_refs) - plan_ref = "" - if n_refs: - # Mirror the render's placement exactly: 'where tagged' reads the - # prompts and falls back to first shot when nothing is tagged -- - # reporting by ref_mode alone described shots the render never gave - # references to. - tagged_used = False - on = [] - effective_modes = [] - for shot_index, gen in enumerate(raw_gens): - shot_mode = beat_ref_mode_directive(beats[shot_index] if shot_index < len(beats) else "") or ref_mode - if shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere: - if resolve_tag_driven_prompt_refs(gen, ref_slots)[1]: - on.append(shot_index + 1) - tagged_used = True - else: - mode_eff = ("every shot" if shot_mode == "auto ref2v" - else "first shot" if shot_mode == "where tagged" else shot_mode) - effective_modes.append(mode_eff) - if shot_references(ref_slots, mode_eff, shot_index, 1 if shot_index else None): - on.append(shot_index + 1) - if tagged_used: - how = "placed by tags" - else: - distinct_modes = list(dict.fromkeys(effective_modes)) - mode_eff = distinct_modes[0] if len(distinct_modes) == 1 else "per-shot overrides" - global_tag_mode = ref_mode in ("where tagged", "auto ref2v") - how = (f"ref_mode '{mode_eff}'" - + (" -- no tags found anywhere" if global_tag_mode and not any_tags_anywhere else "")) - src = [] - if direct_ref_count: - src.append(f"{direct_ref_count} direct") - plan_ref = (f" ref2va: {n_refs} reference image(s) at '{ref_image_size}' on shot(s) " - f"{','.join(str(n) for n in on) or 'none'} ({how}) -> those shots keep " - f"the previous frame as their keyframe too, unless ref_noise_aug was lowered" - + (f" [source: {', '.join(src)}]" if src else "")) - plan = ((anchor_note + " ") if anchor_note else "") + \ - preflight_txt + \ - (("DIALOGUE MAY BE CUT OFF -- " + "; ".join(fit_warnings) + ". ") if fit_warnings else "") + \ - (f"PLAN (no render): {shape} = ~{total:g}s at {w}x{h}. " - f"{len(beats) or 1} beat(s). decode {'tiled' if tiled else 'full'}. {vram_str}." - + (f" {beats_note}." if beats_note else "") - + (" ANCHOR: " + "; ".join(anchor_hazards) + "." - if anchor_hazards else "") - + (f"{anatomy_note}." if anatomy_note else "") - + (f"{latent_upscale_note}." if latent_upscale_note else "") - + (f"{plan_audio}." if plan_audio else "") - + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "." - if wardrobe_notes else "") - + (" OVERRIDES -- " + "; ".join(override_notes) + "." - if override_notes else "") - + (f"{plan_ref}." if plan_ref else "") - + (f" {fps_note}." if fps_note else "") - + (f" {ln_note}." if ln_note else "")) - ph_img = torch.zeros((1, 64, 64, 3)) - ph_audio = {"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100} - # plan_only samples nothing, so there is no latent to hand out. Emit a - # correctly-SHAPED empty one rather than None: a downstream LATENT input - # would choke on None, and this keeps the preview wireable exactly like - # a real run. - return (ph_img, ph_audio, plan, annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots), max(plan_lens), - sum(plan_lens), shots, total, float(fps), int(fps), - _empty_av_latent(w, h, 5, fps)[0], global_soundscape, [], []) - - spk = speech_flags(beats) # which shots have real (quoted) dialogue - vram_trace = [] # free VRAM after each shot - muted_flags = [] # which shots were audio-silenced - hoff = max(0, int(handoff_offset)) - backoff, video_chunks, audio_chunks = [], [], [] - latent_chunks = [] # per-shot sampled latents, pre-decode - mouth_settled = [] # shots seeded from a settled (closed) mouth - handoff, sr = first_frame, None - ref_shots = [] # which shots ended up ref-conditioned - ref_missing = [] # tags naming an unconnected slot - ref_carried = [] # tagged shots that kept continuity as an extra ref - ref_keyframed = [] # tagged shots that kept it as a real keyframe - ref_mode_used = [] - continuity_used = [] - ref_aug_used = [] - shot_timings = [] - cleanup_between_shots = True - if cleanup_between_shots: - _deep_cleanup() # start the first (heaviest) shot with max free VRAM - - shot_lens = (lens + [ln] * len(gens))[:len(gens)] - for i, base_prompt in enumerate(raw_gens): - # denoise is fixed at 1.0 (partial denoise desyncs the joint AV schedule). - sa = (seed + i if vary_seed_per_shot else seed, steps, cfg, sampler_name, scheduler, 1.0) - ln_i = shot_lens[i] # this beat's own length (<= the VRAM ceiling) - # Which conditioning channels this shot carries is decided here; see - # _build_shot_conditioning for how they are packed. On ComfyUI 0.31+ a - # shot may carry BOTH references and a keyframe. - beat_text = beats[i] if i < len(beats) else "" - shot_continuity = beat_continuity_directive(beat_text) or "auto" - shot_ref_noise_aug = beat_ref_noise_aug_directive(beat_text) - shot_aug = ref_noise_aug if shot_ref_noise_aug is None else shot_ref_noise_aug - gen_prompt, shot_refs, dropped, shot_mode_eff, shot_tag_driven = _resolve_beat_prompt_refs( - base_prompt, - ref_slots, - beat_text, - connected_ref_count, - ref_mode, - any_tags_anywhere, - i, - handoff, - ) - carry_keyframe = False # tagged shot keeps its handoff as a keyframe - for n in dropped: - if n not in ref_missing: - ref_missing.append(n) - context = _reference_context_for_text( - gen_prompt, - ref_slots, - resolved_refs=shot_refs, - ) - if context: - gen_prompt = _inject_reference_context(gen_prompt, context) - # A shot that follows a strip starts FRESH. Continuing from a frame that - # still shows the garment is how it reappears -- the picture outvotes the - # text every time. Costs a cut exactly where the state changes, which is - # where a cut belongs anyway. - # No scripted line -> anchor this shot's audio branch to silence. - shot_silent = bool(auto_silence_nonspeech and not allow_nonspeech_vocals and i < len(spk) and not spk[i]) - after_strip = i in strip_shots # strip_shots is 1-based, i is 0-based - if handoff is not None and shot_refs and shot_tag_driven and keyframe_rides_with_refs(shot_aug): - carry_keyframe = True - elif handoff is not None and shot_refs and shot_tag_driven: - if handoff not in shot_refs: - shot_refs = shot_refs + [handoff] - if (i + 1) not in ref_carried: - ref_carried.append(i + 1) - elif handoff is not None and shot_refs and keyframe_rides_with_refs(shot_aug): - carry_keyframe = True - shot_refs = [r for r in shot_refs if r is not handoff] - elif handoff is not None and shot_refs and shot_mode_eff == "every shot + handoff ref": - if (i + 1) not in ref_carried: - ref_carried.append(i + 1) - - if after_strip: - shot_refs = [r for r in shot_refs if r is not handoff] - shot_handoff = None - carry_keyframe = False - continuity_label = "hard cut (post-strip)" - elif shot_continuity == "hard cut": - shot_refs = [r for r in shot_refs if r is not handoff] - shot_handoff = None - carry_keyframe = False - continuity_label = "hard cut" - elif shot_continuity == "keyframe carry": - shot_refs = [r for r in shot_refs if r is not handoff] - shot_handoff = handoff - carry_keyframe = handoff is not None - continuity_label = "keyframe carry" - elif shot_continuity == "handoff ref": - if handoff is not None and shot_refs: - if handoff not in shot_refs: - shot_refs = shot_refs + [handoff] - if (i + 1) not in ref_carried: - ref_carried.append(i + 1) - shot_handoff = None - else: - shot_handoff = handoff if handoff is not None else None - carry_keyframe = False - continuity_label = "handoff ref" - elif shot_continuity == "soft carry": - shot_refs = [r for r in shot_refs if r is not handoff] - shot_handoff = handoff if not shot_refs else None - carry_keyframe = False - continuity_label = "soft carry" - else: - shot_handoff = handoff if (carry_keyframe or not shot_refs) else None - continuity_label = ("keyframe carry" if carry_keyframe else - "handoff ref" if (handoff is not None and handoff in shot_refs) else - "soft carry" if shot_handoff is not None else "hard cut") - if carry_keyframe and (i + 1) not in ref_keyframed and handoff is not None: - ref_keyframed.append(i + 1) - ref_mode_used.append(shot_mode_eff) - continuity_used.append(continuity_label) - ref_aug_used.append(shot_aug) - if shot_refs: - ref_shots.append(i + 1) - shot_total_start = time.perf_counter() - shot_retry_elapsed = 0.0 - shot_attempts = 0 - shot_timing = [] - if i == 0: - while True: - shot_attempts += 1 - attempt_start = time.perf_counter() - try: - frames, audio, shot_latent = self._render( - model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, - tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size, - shot_refs, ref_image_size, shot_aug, shot_silent, - latent_upscale_param=latent_upscale_param, timing_sink=shot_timing) - break - except (torch.cuda.OutOfMemoryError, RuntimeError) as e: - shot_retry_elapsed += time.perf_counter() - attempt_start - if not _is_oom(e): - raise - if getattr(e, "_h3_stage", "") == "latent_upscale": - raise RuntimeError( - f"H3 Long Videos: shot {i + 1} of {len(gens)} ran out of VRAM " - f"during latent upscale. The latent-upscale stage is too large " - f"for this card at {w}x{h} with the current spatial settings." - ) from e - mm.soft_empty_cache(True) - if not tiled: - tiled = True; backoff.append("tiled decode") - elif allow_res_backoff and min(w, h) > 384: - nw, nh = res_down(w, h); backoff.append(f"res->{nw}x{nh}"); w, h = nw, nh - else: - raise RuntimeError("H3 Long Videos: not enough VRAM even at the smallest size. " - "Pick a smaller resolution, close other GPU apps, or use a smaller quant.") - else: - try: - shot_attempts += 1 - attempt_start = time.perf_counter() - frames, audio, shot_latent = self._render( - model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, - tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size, - shot_refs, ref_image_size, shot_aug, shot_silent, - latent_upscale_param=latent_upscale_param, timing_sink=shot_timing) - except (torch.cuda.OutOfMemoryError, RuntimeError) as e: - shot_retry_elapsed += time.perf_counter() - attempt_start - stage = getattr(e, "_h3_stage", "") - if _is_oom(e) and stage == "latent_upscale": - raise RuntimeError( - f"H3 Long Videos: shot {i + 1} of {len(gens)} ran out of VRAM " - f"during latent upscale. The latent-upscale stage is too large " - f"for this card at {w}x{h} with the current spatial settings." - ) from e - if _is_oom(e) and getattr(e, "_h3_stage", "") == "sampling": - # Retrying with tiles would re-run the whole sampling pass and - # fail identically. Fail now, and say what actually shrinks it. - raise RuntimeError( - f"H3 Long Videos: shot {i + 1} of {len(gens)} ran out of VRAM " - f"while sampling. " + sampling_oom_help(w, h, ln_i, fps, megapixels) - ) from e - if not _is_oom(e) or tiled: - raise - mm.soft_empty_cache(True); tiled = True; backoff.append(f"shot {i+1}: tiled") - shot_attempts += 1 - attempt_start = time.perf_counter() - frames, audio, shot_latent = self._render( - model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, - tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size, - shot_refs, ref_image_size, shot_aug, shot_silent, - latent_upscale_param=latent_upscale_param, timing_sink=shot_timing) - shot_retry_elapsed += time.perf_counter() - attempt_start - - shot_total = time.perf_counter() - shot_total_start - if shot_timing: - render_timing = dict(shot_timing[-1]) - else: - render_timing = {} - shot_timings.append({ - "shot": i + 1, - "total": shot_total, - "retry_elapsed": shot_retry_elapsed, - "attempts": shot_attempts, - **render_timing, - }) - - if shot_latent is not None: - latent_chunks.append(shot_latent) - sr = audio["sample_rate"]; wav = audio["waveform"] - - # End the shot `hoff` frames early so the frame handed to the NEXT shot - # isn't the literal last frame (which may catch an open, mid-word mouth - # and make the next shot start "talking"). Drop the matching audio tail - # so this shot's A/V stays aligned. Skipped if the shot is too short. - # ...but ONLY when there IS a next shot. On the final shot the trim hands its - # frames to nobody, so it just deletes the tail of the finished video -- on a - # single-shot run that is the whole point of handoff_offset applied to the one - # thing it cannot help (243f requested came back as 231 frames). - # A dialogue shot handing its last frame to a SILENT shot is the one - # boundary where this matters, and it is where the prompt cannot help. - # The next shot's lips-closed clause is a sentence; its keyframe is a - # PICTURE of an open mouth mid-word, and a picture outvotes a sentence -- - # the same thing that made removed garments come back. So the mouth gets - # a moment to close before the frame is taken, automatically, at exactly - # that transition. 3 frames is ~125ms at 24fps: the tail of a syllable. - # - # Only when the user has not set their own offset, and only speech -> - # silence. Silence -> silence needs nothing, and silence -> speech wants - # the literal last frame so the mouth is already in place. - shot_hoff = hoff - auto_settle = (not hoff and auto_silence_nonspeech and not allow_nonspeech_vocals - and i < len(gens) - 1 and i < len(spk) - 1 - and spk[i] and not spk[i + 1]) - if auto_settle: - shot_hoff = MOUTH_SETTLE_FRAMES - if (i + 1) not in mouth_settled: - mouth_settled.append(i + 1) - if shot_hoff and i < len(gens) - 1 and frames.shape[0] > shot_hoff + 1: - cut = round(shot_hoff * sr / fps) - frames = frames[:-shot_hoff] - if cut: - wav = wav[..., :max(0, wav.shape[-1] - cut)] - - # Keep only a CPU copy of the handoff keyframe (re-encoded next shot), - # and move this shot's decoded video+audio to CPU/RAM immediately so - # they DON'T pile up in VRAM across the chain -- the main long-run OOM. - if cleanup_between_shots: - handoff = frames[-1:].detach().contiguous().to("cpu", copy=True) - else: - handoff = frames[-1:].clone() - if trim_seam and i > 0: - frames = frames[1:]; wav = wav[..., max(0, round(sr / fps)):] - - # Deterministic gibberish fix: a non-dialogue shot is silenced COMPLETELY. - # - # The earlier version faded the first/last `mute_fade_ms` from full - # volume, which left ~20ms of the original audio audible at BOTH ends of - # every muted shot -- on a 10-shot chain that is 20 short bursts of the - # very gibberish the setting exists to remove. The fade belongs on the - # NEIGHBOURING audible shots instead (applied after the loop), not on the - # silent one, so nothing of the muted shot survives. - muted_this_shot = bool(mute_nonspeech_audio and not allow_nonspeech_vocals and i < len(spk) and not spk[i]) - if muted_this_shot: - wav = torch.zeros_like(wav) - muted_flags.append(muted_this_shot) - - if cleanup_between_shots: - # .contiguous() forces a real copy: after trim_seam / handoff_offset - # these are SLICES of the decoded GPU tensor, and a view keeps the - # whole parent allocation alive even after .to("cpu"). Without it the - # previous shot's full decode is pinned while the next shot samples, - # which is the VRAM ratchet across a long chain. - frames_out = frames.detach().contiguous().to("cpu", copy=True) - wav_out = wav.detach().contiguous().to("cpu", copy=True) - video_chunks.append(frames_out); audio_chunks.append(wav_out) - # drop every GPU reference from this shot, then purge VRAM + RAM - del frames, wav, audio, frames_out, wav_out - _deep_cleanup() - else: - video_chunks.append(frames); audio_chunks.append(wav) - mm.soft_empty_cache() - # trace free VRAM after each shot: a falling series means something is - # still accumulating; a flat one means the chain is stable. - vram_trace.append(round(vram_gb()[1], 2)) - - # Fade the EDGES OF AUDIBLE chunks that border a silenced one, so audio does - # not cut to digital silence with a click. The silenced shots stay fully - # silent; only the audible neighbours are ramped. - if mute_nonspeech_audio and any(muted_flags): - fade = max(0, int(sr * int(mute_fade_ms) / 1000)) if sr else 0 - for idx, chunk in enumerate(audio_chunks): - if idx >= len(muted_flags) or muted_flags[idx] or not fade: - continue - n_s = chunk.shape[-1] - if n_s <= 2 * fade: - continue - prev_muted = idx > 0 and muted_flags[idx - 1] - next_muted = idx + 1 < len(muted_flags) and muted_flags[idx + 1] - if prev_muted: - ramp = torch.linspace(0.0, 1.0, fade, device=chunk.device, dtype=chunk.dtype) - chunk[..., :fade] *= ramp - if next_muted: - ramp = torch.linspace(1.0, 0.0, fade, device=chunk.device, dtype=chunk.dtype) - chunk[..., n_s - fade:] *= ramp - - all_frames = torch.cat(video_chunks, dim=0) - all_audio = torch.cat(audio_chunks, dim=-1) - - # --- the `latent` output ------------------------------------------------ - # The sampled latents, joined on the temporal axis. This is NOT the latent - # form of `images`, and the difference is not cosmetic: - # - # * trim_seam and handoff_offset cut DECODED frames. H3 compresses time, - # so one pixel frame is not one latent step and those cuts have no exact - # latent equivalent -- the seam frames trim_seam removes are still here. - # * the overlap fade and any post-pass upscale are pixel-space too. - # - # So decoding this yourself gives a slightly longer video with the seams - # intact. On a SINGLE-shot run none of those apply and it is exact, which is - # the case that matters for testing a latent upscaler. - latent_note = "" - latent_out = {"samples": _empty_av_latent(w, h, 5, fps)[0]["samples"]} - if latent_chunks: - try: - if all(isinstance(c, list) and len(c) == 2 for c in latent_chunks): - vids = [c[0] for c in latent_chunks] - auds = [c[1] for c in latent_chunks] - # A mid-chain resolution backoff makes the shots un-concatenable. - if len({tuple(v.shape[1:2] + v.shape[3:]) for v in vids}) == 1: - latent_out = {"samples": comfy.nested_tensor.NestedTensor( - (torch.cat(vids, dim=2), torch.cat(auds, dim=-1)))} - if len(latent_chunks) > 1: - latent_note = (f" latent: {len(latent_chunks)} shot(s) joined on the " - f"time axis -- PRE-trim, so it holds the seam frames " - f"trim_seam drops from `images` and is longer by " - f"{len(latent_chunks) - 1} frame(s)") - else: - latent_note = " latent: single shot, exact match for `images`" - else: - latent_out = {"samples": comfy.nested_tensor.NestedTensor( - (vids[-1], auds[-1]))} - latent_note = (" latent: shots differ in size after a resolution backoff, " - "so only the LAST shot's latent is emitted") - except Exception as e: - latent_note = f" latent: could not be assembled ({type(e).__name__})" - - # Optional post-pass upscale of the finished frames (safe: any failure - # falls back to lanczos / raw frames and never breaks the render). - up_note = "" - if upscale != "off": - _deep_cleanup() - all_frames, up_note = _upscale_frames(all_frames, upscale, upscale_model, - upscale_target_short_edge, upscale_batch) - - # Text overlays LAST -- after the upscale, so glyphs are rasterized at the - # final pixel size instead of being interpolated up along with the picture. - all_frames, ov_note = _overlay.apply_overlays( - all_frames, fps, watermark_text, watermark_position, watermark_size, - watermark_opacity, watermark_margin, intro_text, intro_seconds, - intro_fade, intro_size, intro_position, overlay_font, overlay_stroke) - - script = annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots) - actual = all_frames.shape[0] / fps - uniform_len = len(set(shot_lens)) == 1 - shape_str = (f"{len(gens)} shot(s) x {shot_lens[0]}f (~{shot_lens[0] / fps:.1f}s each) " - f"= ~{sum(shot_lens) / fps:.1f}s" if uniform_len else - f"{len(gens)} shot(s), per-beat " - + ", ".join(f"{n}f/~{n / fps:.1f}s" for n in shot_lens) - + f" = ~{sum(shot_lens) / fps:.1f}s") - vram_str = f"{total_gb:.1f}GB total / {resident_gb:.1f}GB weights / {free_gb:.1f}GB free" if total_gb else "VRAM unknown" - # Say how many shots the trim actually touched: on a single-shot run it is none, - # which explains the frame count instead of leaving it looking like a shortfall. - hoff_str = (f" handoff -{hoff}f on {max(0, len(gens) - 1)} of {len(gens)} shot(s)" - f"{' (last shot keeps its tail)' if len(gens) else ''}." if hoff else "") - # Say what was done about babble on non-dialogue shots, and what it cost. Both - # states need reporting: muting is silent about the ambience it removes, and - # NOT muting is silent about the babble it may leave in. - n_silent = sum(1 for f in spk if not f) - n_muted = sum(1 for f in muted_flags if f) - if n_muted: - audio_note = (f" {n_muted} of {len(gens)} shot(s) have no quoted dialogue and were AUDIO-MUTED " - f"(mute_nonspeech_audio) -- that also removes their generated ambience, so lay an " - f"ambient bed under the video in post, or untick it to keep H3's own") - elif n_silent: - audio_note = (f" {n_silent} of {len(gens)} shot(s) have no quoted dialogue: silenced in the prompt " - f"and soundscape only. If any of them still vocalize, tick mute_nonspeech_audio " - f"for a guaranteed fix") - else: - audio_note = "" - # Which shots actually took the reference channel, and what they gave up for - # it. Silence here would leave "why did shot 2 cut instead of continuing?" - # unanswerable from the output alone. - if ref_missing: - ref_note_missing = (f" named in the prompt " - f"but no image is connected to that ref_image input -- the tag(s) were " - f"dropped from the text") - else: - ref_note_missing = "" - if connected_ref_count and ref_shots: - kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots] - distinct_ref_modes = list(dict.fromkeys(ref_mode_used)) - tagged_used = any(mode in ("where tagged", "auto ref2v") for mode in ref_mode_used) and any_tags_anywhere - ref_placement = ("placed by tags" if tagged_used else - f"ref_mode '{distinct_ref_modes[0]}'" if len(distinct_ref_modes) == 1 else - "mixed per-shot ref_mode") - ref_source = [] - if direct_ref_count: - ref_source.append(f"{direct_ref_count} direct") - aug_override_notes = [ - f"{index + 1}={value:.3f}" - for index, value in enumerate(ref_aug_used) - if ref_noise_aug is not None and float(value) != float(ref_noise_aug) - ] - ref_note = (f" ref2va: {connected_ref_count} reference image(s) at '{ref_image_size}' on shot(s) " - f"{','.join(str(n) for n in ref_shots)} " - f"({ref_placement})" - + (f", ref_noise_aug {ref_noise_aug:.3f}" if ref_noise_aug is not None - and float(ref_noise_aug) < 0.999 else "") - + (f"; shot-specific ref_noise_aug shot(s) {', '.join(aug_override_notes)}" - if aug_override_notes else "") - + (f"; source {' + '.join(ref_source)}" if ref_source else "") - + (f"; shot(s) {','.join(str(n) for n in kept)} keep the handoff" if kept - else "") - + (f"; shot(s) {','.join(str(n) for n in ref_keyframed)} carry the previous " - f"frame as a real KEYFRAME alongside their references, so they anchor " - f"rather than cut" if ref_keyframed else "") - + (f"; the previous frame rides along as an extra reference on shot(s) " - f"{','.join(str(n) for n in ref_carried)} -- weaker than a keyframe, but " - f"ref_noise_aug below {KEYFRAME_SAFE_AUG:g} would soften a keyframe too " - f"(one aug covers every cond latent)" if ref_carried else "") - + ("" if (ref_keyframed or ref_carried or kept) - else ", so every cut between beats is a CUT, not a continuous take") - + ref_note_missing) - elif connected_ref_count: - distinct_ref_modes = list(dict.fromkeys(ref_mode_used)) - mode_label = distinct_ref_modes[0] if len(distinct_ref_modes) == 1 else "mixed per-shot ref_mode" - ref_note = (f" ref2va: {connected_ref_count} reference image(s) connected but ref_mode " - f"'{mode_label}' applied them to no shot" - + (f" (source {direct_ref_count} direct)" if direct_ref_count else "")) - else: - ref_note = "" - timing_note = _format_timing_note(shot_timings) - info = ((anchor_note + " ") if anchor_note else "") + \ - (f"{shape_str} at {w}x{h}; {all_frames.shape[0]} frames (~{actual:.1f}s actual). " - f"decode {'tiled' if tiled else 'full'}. {vram_str}.{hoff_str}" - + (" DIALOGUE MAY BE CUT OFF -- " + "; ".join(fit_warnings) - + ". Shorten the line, or pick a lower resolution tier to keep the duration." - if fit_warnings else "") - + ((" subject-count guard ON (" - + ("sub-native resolution" if min(w, h) < 768 else "") - + ("; " if min(w, h) < 768 and lora_on else "") - + ("LoRA active -- count front-loaded so it binds before the scene" - if lora_on else "") - + ").") if count_subjects else "") - + ((" " + preflight_txt.strip()) if preflight_txt else "") - + (f" MOUTH -- shot(s) {','.join(str(n) for n in mouth_settled)} were seeded " - f"from a settled mouth ({MOUTH_SETTLE_FRAMES}f before the cut), because the " - f"shot before them ended on dialogue." if mouth_settled else "") - + (f"{anatomy_note}." if anatomy_note else "") - + (f"{latent_note}." if latent_note else "") - + (f"{latent_upscale_note}." if latent_upscale_note else "") - + (f" SLA LoRA '{os.path.basename(str(sla_name))}' paired with sparse attention." - if sla_name and sparse_on else "") - + (f" {beats_note}." if beats_note else "") - + (f"{audio_note}." if audio_note else "") - + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "." - if wardrobe_notes else "") - + (" OVERRIDES -- " + "; ".join(override_notes) + "." - if override_notes else "") - + (f"{ref_note}." if ref_note else "") - + (f" {timing_note}." if timing_note else "") - + (f" {fps_note}." if fps_note else "") - + (f" {swap_note}." if swap_note else "") - + (f" free VRAM/shot: {vram_trace}." if len(vram_trace) > 1 else "") - + (f" {accel_note}." if accel_note else "") - + (f" {ms_note}." if ms_note else "") - + (f" {ln_note}." if ln_note else "") - + (f" {up_note}." if up_note else "") - + (f" {ov_note}." if ov_note else "") - + (f" Adjusted: {'; '.join(backoff)}." if backoff else "")) - # frames_per_shot is a single INT for a now-variable series: report the LONGEST - # shot, which is what a downstream consumer must be able to hold. - # `global_soundscape` is the soundscape ACTUALLY used: the derivation above - # reassigns it, so this is the derived bed when auto_soundscape fired and - # your own text when it did not. Emitting it means you can read what was - # generated, and feed it straight back into the widget-input to pin it. - return (all_frames, {"waveform": all_audio, "sample_rate": sr}, info, script, - max(shot_lens), all_frames.shape[0], len(gens), round(actual, 2), - float(fps), int(fps), latent_out, global_soundscape, - video_chunks, [{"waveform": chunk, "sample_rate": sr} for chunk in audio_chunks]) - - -# The old FL2VA / REF2VA aliases were only alternate menu entries for the same class. -# Dumas workflows now use the canonical "DumasH3LongVideos" key, so expose a single -# node entry instead of triplicating the search results with duplicate aliases. -NODE_CLASS_MAPPINGS = { - "DumasH3LongVideos": H3LongVideos, -} -NODE_DISPLAY_NAME_MAPPINGS = { - "DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)", -} -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +__all__ = ["H3LongVideos", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/dumas_h3_longvideos_engine.py b/dumas_h3_longvideos_engine.py new file mode 100644 index 0000000..f00aa31 --- /dev/null +++ b/dumas_h3_longvideos_engine.py @@ -0,0 +1,1723 @@ +# 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. +"""The prompt engine: scene state, beat by beat. + +WHY THIS REPLACED WHAT WAS HERE BEFORE +-------------------------------------- +The old engine was about sixty independent readers, each searching the beat for +its own thing and each appending its own sentence to the shot. `limb_anchor` +found "behind the back", `held_part` found "neck", and neither could see the +other, so the shot went out saying "holding the neck behind the back" -- a neck +behind a back. `hardware_named` returned one item, so a beat that cuffed the +wrists and locked on a collar recorded the collar and the handcuffs were never +mentioned again; hardware nobody mentions is hardware the model stops drawing, +and that read as her breaking out of them. `_PLACE` contained "door", so "Ana +looks at the door" moved the camera into a door. + +Every one of those is the same failure: a clause derived on its own, with nothing +holding the facts together and nothing able to notice a contradiction. + +So the shape here is different. A beat is parsed ONCE into events. Events update +one explicit state. The state renders ONE paragraph. There is exactly one place +that knows what is on whom, one that knows where anybody is, and one that turns +that into English -- so a contradiction is a bug in a value you can print, not an +emergent property of sixty regexes that never met. + +WHAT THE STATE GUARANTEES + - hardware is remembered per person, with the part it holds, the position it + holds that part in, and what it is anchored to. All four together or not at + all, so they cannot disagree. + - every piece of hardware on a person is named in every shot until something + takes it off. Not the newest, not the most specific: all of it. + - a garment is on, off, or displaced, and the shot that changes it says both + ends of the change. + - a place is a room somebody can be in. A door is not a room. +""" + +import re + +# --------------------------------------------------------------------------- +# Vocabulary. One table per KIND of thing, and a word appears in exactly one of +# them. The old engine had "chain" as a noun in one list and a verb in another, +# which let a chain-link fence satisfy both halves of a rule by itself. +# --------------------------------------------------------------------------- + +# Hardware, and the part each kind holds. The part is a property of the ITEM -- +# this is the table whose absence produced "holding the neck behind the back". +HARDWARE = ( + (r"hand\s?cuffs?|handcuffed", "handcuffs", "wrists"), + (r"leg\s?irons?", "leg irons", "ankles"), + (r"ankle\s+(?:cuffs?|chains?|straps?)", "ankle cuffs", "ankles"), + (r"shackles?|shackled", "shackles", "ankles"), + (r"manacles?|manacled", "manacles", "wrists"), + (r"zip\s?ties?|cable\s?ties?", "zip ties", "wrists"), + (r"collars?|chokers?|collared", "collar", "neck"), + (r"leash(?:es)?|leashed", "leash", "neck"), + (r"gags?|gagged", "gag", "mouth"), + (r"blindfolds?|blindfolded", "blindfold", "eyes"), + (r"harness(?:es)?", "harness", "body"), + (r"spreader\s+bars?", "spreader bar", "ankles"), + (r"straitjackets?", "straitjacket", "arms"), + (r"ropes?|cords?|twine", "rope", "wrists"), + (r"straps?", "straps", "wrists"), + (r"chains?", "chain", "wrists"), + (r"cuffs?|cuffed", "cuffs", "wrists"), + (r"tape", "tape", "wrists"), +) +# Material and colour survive because they decide what the thing looks like: +# "steel collar" must not come back as "collar" two shots later. +# WHAT THE AUTHOR CALLED IT. This decides how much of the wording survives into +# the guard clauses, and the guard is what every shot after the first repeats -- +# so a word missing here is a word the model stops hearing. +# +# It was twenty-odd words, and "a mirrored steel collar" came back as "steel +# collar" while "a brushed nickel collar" came back as "collar". A bare "collar" +# repeated once a shot is a bare collar, and the prior for that is a black +# leather one -- which is exactly what was reported. +# +# Hyphenated compounds pass whole ("mirror-finish", "chrome-plated"), so an +# unusual finish survives without being listed. Bare participles are NOT +# accepted: "-ed" is a verb far more often than a modifier, and capturing one +# would put an action into the name of the thing. +_ADJ = (r"(?:[A-Za-z]+-[A-Za-z]+|" + # materials + r"steel|stainless|iron|metal|metallic|nickel|chrome|chromed|brass|" + r"bronze|copper|pewter|gunmetal|titanium|alumini?um|gold|golden|silver|" + r"platinum|leather|pleather|suede|velvet|satin|silk|lace|mesh|nylon|" + r"plastic|rubber|latex|silicone|neoprene|vinyl|pvc|canvas|denim|cotton|" + r"wool|woollen|linen|rope|wood|wooden|ceramic|glass|resin|" + # finishes + r"mirrored|mirror|polished|brushed|burnished|hammered|plated|anodi[sz]ed|" + r"matte|matt|gloss|glossy|shiny|dull|tempered|hardened|welded|riveted|" + r"studded|spiked|lined|padded|quilted|ribbed|textured|smooth|" + # colours + r"black|white|red|blue|green|grey|gray|brown|pink|purple|tan|cream|navy|" + r"crimson|scarlet|ivory|amber|olive|" + # size and build + r"heavy|light|thin|thick|wide|narrow|short|long|small|large|broad|slim|" + r"duct|packing|electrical|zip)") + +# Body parts, for the hardware whose part is NOT a property of the item. +# +# A collar is the neck and handcuffs are the wrists, and those never need +# looking up. A chain, a rope, straps and tape go wherever the beat puts them, +# and reading their part off the table gave "locks a chain around her ankles" +# as a chain on the WRISTS -- where it then collided with the cuffs already +# there, two things drawn in one place. That is chains interfering. +PARTS = ( + (r"wrists?", "wrists"), + (r"ankles?", "ankles"), + (r"necks?|throats?", "neck"), + (r"mouths?", "mouth"), + (r"eyes?", "eyes"), + (r"elbows?", "elbows"), + (r"knees?", "knees"), + (r"thighs?", "thighs"), + (r"waists?", "waist"), + (r"arms?", "arms"), + (r"legs?", "legs"), + (r"hands?", "hands"), + (r"feet|foot", "feet"), +) +PART_VARIES = frozenset({"chain", "rope", "straps", "tape"}) + +# Which region a garment leaves uncovered when it comes off. Only what can be +# placed with certainty; a garment that cannot be placed gets no clause, because +# a wrong region is worse than none. +# +# Lives here, with the other vocabularies, because the BARE state is state -- it +# outlives the beat that caused it, and the clause that says so has to be +# writable from any later shot. +REGION_OF = ( + (r"shorts|trousers|jeans|slacks|chinos|skirt|kilt|leggings|joggers|tights|" + r"pantyhose|jeggings|culottes|tracksuit\s+bottoms", "legs", + "The legs are bare from the hip down"), + (r"socks|stockings|hold-?ups|boots|shoes|trainers|sneakers|sandals|heels", + "feet", "The feet and ankles are bare"), + # THE CHEST IS THE POINT. This said "The arms and shoulders are bare" and + # stopped there, so a shirt coming off left the one region a bra occupies + # unspecified -- and an unspecified region is filled by the model's own + # prior. Reported as a bra coming back on somebody topless, on a character + # whose sheet never listed a bra: it was never restored, it was invented. + (r"top|shirt|blouse|t-?shirt|tee|jumper|sweater|sweatshirt|hoodie|cardigan|" + r"jacket|coat|tunic|bra|bralette|camisole|vest", "torso", + "The chest, shoulders and arms are bare skin"), + (r"gloves|mittens", "hands", "The hands are bare"), +) +# Being in that state rather than arriving at it. "Kate is topless" takes nothing +# off, so every removal path had nothing to remove and no shot ever said what was +# on her chest. "naked eye" and "naked flame" are not people. +NUDITY = ( + (r"topless|bare-?chested|bare-?breasted|shirtless|" + r"stripped\s+to\s+the\s+waist|strips\s+to\s+the\s+waist", ("torso",)), + (r"bottomless|bare\s+from\s+the\s+waist\s+down", ("legs",)), + (r"naked(?!\s+(?:eye|flame))|nude|in\s+the\s+nude|wearing\s+nothing|" + r"with\s+no\s+clothes|stark\s+naked", ("torso", "legs", "feet")), +) + +# Where a limb is held. These all describe the ARMS -- that is why a limb +# position may never be attached to a collar. +POSITIONS = ( + (r"behind\s+(?:her|his|their|the)\s+backs?", "behind the back"), + (r"(?:above|over)\s+(?:her|his|their|the)\s+heads?|overhead", "above the head"), + (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+waists?", "at the waist"), +) + +# Fixed things hardware can be anchored to. A thing you cannot pick up and walk +# away with. +ANCHORS = (r"walls?|floors?|grounds?|ceilings?|pillars?|columns?|posts?|rails?|" + r"railings?|bars?|rings?|hooks?|pipes?|radiators?|beams?|girders?|" + r"struts?|stakes?|eye\s?bolts?|brackets?|cages?|fences?|grates?|" + r"grilles?|bed\s?frames?|bed\s?posts?|headboards?|bedsteads?|beds?|" + r"bunks?|benches?|chairs?|tables?|desks?|ladders?|anchors?|loops?") + +# Verbs, as VERBS. Participles and -ing forms are unambiguous. The -s forms are +# also plural nouns, so they carry a lookbehind: "the guard chains her collar" is +# a verb and "the chains on the floor" is not. +_DET = (r"(?.*?|[\"“][^\"“”]{1,400}?[\"”]", + re.S) + + +def spoken_text(text): + """Only what people SAY, with the markers stripped. "" when nobody speaks.""" + said = [] + for m in _SPOKEN_SPAN.finditer(text or ""): + s = m.group(0) + s = s[3:-4] if s.startswith("") else s[1:-1] + if s.strip(): + said.append(s.strip()) + return " ".join(said) + + +# WHICH LANGUAGE A LINE IS IN, read off the line itself. +# +# The node used to name English and only English. That clause is not decoration +# -- H3 is joint and multilingual, and an audio branch told a line is spoken but +# never told in WHAT picks one, which is where "sounds like gibberish" came from +# -- but the language it named was hard-coded, so a script written in any other +# language was told its own line is spoken in English and the delivery fought the +# words. Naming nothing is not the way out of that. Naming what the author +# actually wrote is. +# +# A script settles it outright; a Latin alphabet is shared, so common words vote. +_BY_SCRIPT = ( + # Kana before Han: Japanese uses both, so Han alone is what makes it Chinese. + ("Japanese", r"[぀-ヿ]"), + ("Korean", r"[가-힯ᄀ-ᇿ]"), + ("Chinese", r"[一-鿿㐀-䶿]"), + ("Greek", r"[Ͱ-Ͽἀ-῿]"), + ("Hebrew", r"[֐-׿]"), + ("Arabic", r"[؀-ۿݐ-ݿ]"), + ("Hindi", r"[ऀ-ॿ]"), + ("Thai", r"[฀-๿]"), + # Letters Russian lacks, or words it spells differently -- Ukrainian written + # without і/ї/є still says "що" where Russian says "что". + ("Ukrainian", r"[ЄЇєіїґ]|\b(?:що|це|ти|але|дуже|треба|дякую|немає)\b"), + ("Russian", r"[Ѐ-ӿ]"), +) +_BY_SCRIPT_RX = tuple((n, re.compile(p)) for n, p in _BY_SCRIPT) +# Function words. Content words are what a translator changes; these are what +# stay, and a line or two of dialogue carries several. +_BY_WORDS = ( + ("English", "the and is are you that not it to of in for with but what have"), + ("Spanish", "el la los las que de y no se es por con para pero muy sí está"), + ("French", "le la les des que de et ne pas est vous je pour avec au ça"), + ("German", "der die das und nicht ist ich du sie wir mit für auf ein aber"), + ("Italian", "il lo la che di non è sono per con questo come più sei ma"), + ("Portuguese", "os as que de não é para com você isso mais está eu sou"), + ("Dutch", "de het een en niet is ik je dat van voor met maar hij zijn"), + ("Polish", "nie jest to się na że do co jak ale jestem tak mnie"), + ("Turkish", "bir bu ve için ne değil çok ben sen var yok ama beni"), + ("Swedish", "och att det är inte jag du en för med men han hon"), +) +_BY_WORDS_SET = tuple((n, frozenset(w.split())) for n, w in _BY_WORDS) + + +# THE AUTHOR SAYING WHICH LANGUAGE IT IS. +# +# The word vote needs two function words before it will name a language, and +# ordinary speech often carries one: +# +# "Oh, du siehst heute toll aus, Schatz!" -- du +# "OK, das reicht mir jetzt wirklich!" -- das +# +# Both are unmistakably German to a reader and both scored 1, so both fell back -- +# and in a script whose other lines are English, the fallback is English, so the +# German line was actively TOLD it is English. The line then fights its own +# delivery, which is the failure the whole language hold exists to prevent. +# +# But the author had already said which language it is, in the stage direction, +# where anybody writing this puts it: "says in German". spoken_text() strips +# everything outside the quotes before the vote ever sees it, so the one +# unambiguous statement in the beat was the one thing thrown away. +# +# REQUIRES A SPEECH FRAME, so a nationality is not a language: "in German" and +# "speaks German" match, "the German soldier" and "a German car" do not. Adjectives +# people actually write are allowed between ("in broken German"). +_LANG_NAMES = tuple(n for n, _ in _BY_SCRIPT) + tuple(n for n, _ in _BY_WORDS) +_LANG_ADJ = (r"(?:fluent|broken|perfect|rapid|halting|accented|flawless|bad|" + r"basic|simple|quiet|loud|slow|fast)\s+") +_NAMED_LANG = re.compile( + r"\b(?:in|into|speaks?|speaking|spoke|spoken|" + r"switch(?:es|ed|ing)?\s+to|repl(?:y|ies|ied)\s+in|answers?\s+in|" + r"says?\s+in|said\s+in|ask(?:s|ed)?\s+in)\s+" + r"(?:" + _LANG_ADJ + r")?" + r"(" + "|".join(_LANG_NAMES) + r")\b", re.I) + + +def language_named(text): + """The language the TEXT ITSELF says is being spoken, or ''. + + The author's own statement, read from the stage direction rather than voted + for out of the line. See _NAMED_LANG for why a speech frame is required.""" + m = _NAMED_LANG.search(str(text or "")) + if not m: + return "" + said = m.group(1).lower() + return next((n for n in _LANG_NAMES if n.lower() == said), "") + + +def language_of(text, fallback="English", named=""): + """The language `text` is written in, or `fallback` when it cannot tell. + + Conservative on purpose: naming the WRONG language is worse than naming the + one the author most likely wanted, so a Latin-alphabet guess has to win by a + clear margin before it displaces the fallback.""" + t = str(text or "") + if not t.strip(): + return fallback + for name, rx in _BY_SCRIPT_RX: + if rx.search(t): + return name + words = set(re.findall(r"[^\W\d_]+", t.lower(), re.UNICODE)) + if not words: + return fallback + scores = sorted(((len(words & ws), n) for n, ws in _BY_WORDS_SET), reverse=True) + best, runner = scores[0], scores[1] + # Two hits, and ahead of everything else. One shared word ("no" is Spanish and + # English both) is not a language. + if best[0] >= 2 and best[0] > runner[0]: + return best[1] + # The author said so, and the vote could not tell. A statement beats a guess + # that abstained -- but NOT a script: Cyrillic is not a matter of opinion, and + # that branch has already returned above. + if named: + return named + return fallback + + +def _outside_speech(text): + """The beat with everything anybody SAYS taken out. + + What a character says is not stage direction. The commonest thing to talk + about is something that is NOT in the room -- "McKenna where are you?" is how + absence gets written -- and reading a spoken name as a staged one put a full + description of the missing person into the shot, so the model drew her. + + Both markers, because both exist in the pipeline: once mark_dialogue has + run, plain quotes before it.""" + return _SPOKEN_SPAN.sub(" ", text or "") + + +# THREE modifiers, not two: "mirrored stainless steel collar" is three words and +# a noun, and the third was the first to be dropped. +_HW_ONE = _rx(r"\b(" + _ADJ + r"(?:\s+" + _ADJ + r"){0,2}\s+)?(" + + "|".join(p for p, _n, _pt in HARDWARE) + r")\b") +_PART_ONE = _rx(r"\b(" + "|".join(p for p, _n in PARTS) + r")\b") +# A NOUN carries a determiner, a number or an adjective; a VERB follows its +# subject. "Sam chains her collar to the ring" introduces nothing to draw -- it +# fastens the collar that is already named -- and reading that verb as an item +# put a chain on the wrists of somebody with nothing on their wrists. +# +# "and" is deliberately absent: "...to the ring and chains her ankles together" +# is a second verb, and letting a conjunction vouch for a noun brought the +# phantom straight back. +_NOUN_BEFORE = _rx(r"(?:\b(?:a|an|the|her|his|its|their|my|your|our|this|that|" + r"these|those|one|two|three|several|more|another|in|with|by|" + r"of|on|from)\b|[,;:(])\s*(?:" + _ADJ + r"\s+){0,3}$") +_POSITION = [(_rx(r"\b" + p + r"\b"), name) for p, name in POSITIONS] +# What can stand in front of an anchor. "one ring", "the other ring", "a second +# hook" are the same fixture as "the ring", and the six-word list read them as no +# anchor at all -- so a collar chained to ONE OF TWO rings was not a restraint at +# all, nothing latched, and every later shot forgot it. Two people chained to two +# rings lost both. +ANCHOR_DET = (r"(?:the|a|an|her|his|its|their|one|another|each|either|that|" + r"this|both)\s+(?:(?:other|second|third|first|far|near|nearest|" + r"opposite|left|right|upper|lower|top|bottom|same|nearby|steel|" + r"iron|metal|heavy|small|large|wooden|old|thick)\s+){0,2}") +_ANCHOR_AT = _rx(r"\bto\s+" + ANCHOR_DET + r"(" + ANCHORS + r")\b") +_APPLY = _rx(r"\b(?:" + APPLY_VERB + r")\b") +_RELEASE = _rx(r"\b(?:" + RELEASE_VERB + r")\b") +_PLACE_IN = _rx(r"\b(?:in|into|inside|through|down|along|across|to|onto|at)\s+" + r"(?:the|a|an|her|his|their)\s+(" + _ROOM_MOD + r"(?:" + PLACES + + r"))\b") +_PLACE_WORD = _rx(r"\b(?:" + PLACES + r")\b") +_GARMENT_ONE = _rx(r"\b(" + _ADJ + r"(?:\s+" + _ADJ + r"){0,2}\s+)?(" + + _GARMENT + r"s?)\b") +_TAKES_OFF = _rx(r"\b" + TAKES_OFF + r"\b") +_PUTS_ON = _rx(r"\b" + PUTS_ON + r"\b") +_DISPLACES = _rx(r"\b" + DISPLACES + r"\b") +_MOVES = _rx(r"\b(?:walks?|walked|walking|goes|go|went|going|runs?|ran|running|" + r"steps?|stepped|stepping|moves?|moved|moving|enters?|entered|" + r"leaves?|left|leaving|crosses|crossed|crossing|climbs?|climbed|" + r"heads?|headed|returns?|returned|arrives?|arrived)\b") + + +def hardware_spans(text): + """Every piece of hardware named, as (canonical, part, as-written, at). + + `at` is where it sits in the text, and it is not decoration: a beat that + cuffs the wrists BEHIND THE BACK and locks a collar CHAINED TO THE WALL has + two modifiers and two items, and attaching either modifier to both gives + handcuffs chained to a wall they were never near. Modifiers bind to the + nearest item, which needs positions to work out. + + ALL of it, too. The old reader returned only the longest single match, so a + beat that put on cuffs and a collar recorded one and lost the other for the + rest of the film.""" + text = text or "" + parts = part_spans(text) + raw, verb_ats = [], [] + for m in _HW_ONE.finditer(text): + adj, noun = (m.group(1) or "").strip(), m.group(2) + canon, part = next((n, pt) for p, n, pt in HARDWARE + if re.fullmatch(p, noun, re.I)) + # A VERB IS NOT AN ITEM. Only the words that are also verbs need asking, + # and only "chain" is one that the table would otherwise turn into a + # restraint on a part the beat never mentions. + if canon == "chain" and not noun.lower().endswith("ed") \ + and not _NOUN_BEFORE.search(text[:m.start()]): + verb_ats.append(m.start()) + continue + written = f"{adj} {noun}".strip().lower() + # A PARTICIPLE FINDS IT AND DOES NOT NAME IT. "is handcuffed" is how the + # passive voice writes hardware, but an item recorded as "handcuffed" + # renders as "The handcuffed stay closed and fastened". Quote the noun. + if noun.lower().endswith("ed"): + written = f"{adj} {canon}".strip().lower() + raw.append([canon, part, written, m.start()]) + # WHERE IT GOES, for the things that go anywhere. Bound after every item is + # known, so the part attaches to the nearest one and a beat naming two of + # them does not give both the same place. + _ats = [(c, at) for c, _pt, _w, at in raw] + _tether = [] + for row in raw: + if row[0] not in PART_VARIES: + continue + _pt = _nearest_part(parts, row[3], _ats) + if _pt: + row[1] = _pt + elif any(c != row[0] for c, _a in _ats) and _runs_to(text, row[3]): + # NO PART OF ITS OWN, beside something that has one, and joined by + # "to": it is that thing's TETHER, not a restraint holding a pair of + # wrists nobody mentioned. "clips a chain to her collar" was a chain + # on the wrists AND a collar on the neck -- two things to draw where + # the beat put one. + # + # "to" matters. "gags her with duct tape" names the gag's MATERIAL + # by the same shape, and folding that away lost the tape entirely. + _tether.append(row) + raw = [r for r in raw if r not in _tether] + # A VERB STILL FASTENS SOMETHING, and what it fastens is either an item the + # beat names or a part of the body. "...chains her collar to the ring and + # chains her ankles together" is both, in that order: the first verb belongs + # to the collar and introduces nothing, the second puts a chain on the + # ankles. Recorded one and lost the other, which is two restraints becoming + # one -- chains interfering. + for _vat in verb_ats: + _pt = _nearest_part(parts, _vat, _ats) + if _pt and not any(c == "chain" and pt == _pt for c, pt, _w, _a in raw): + raw.append(["chain", _pt, "chain", _vat]) + # Nothing named at all: somebody is chained somewhere and the beat never + # says where on them. The anchor is still real, so it holds the body rather + # than inventing a pair of wrists to hold. + if verb_ats and not raw and anchor_in(text): + raw.append(["chain", "body", "chain", verb_ats[0]]) + out, seen = [], {} + for canon, part, written, at in raw: + # Keyed by the PAIR. Two chains on two parts are two restraints -- a + # beat chaining a collar and the ankles recorded one and lost the other + # -- while "collar" then "steel collar" is one collar, same part, and + # keeps the fuller wording. + key = (canon, part) + if key in seen: + i = seen[key] + if len(written) > len(out[i][2]): + out[i] = (canon, part, written, out[i][3]) + continue + seen[key] = len(out) + out.append((canon, part, written, at)) + return out + + +_REGION_RX = tuple((_rx(r"\b(?:" + p + r")\b"), region, said) + for p, region, said in REGION_OF) +_NUDITY_RX = tuple((_rx(r"\b(?:" + p + r")\b"), regions) for p, regions in NUDITY) + + +def region_of(garment): + """The region a garment covers, or "" when it cannot be placed.""" + for rx, region, _said in _REGION_RX: + if rx.search(str(garment or "")): + return region + return "" + + +def nudity_in(text): + """The regions a beat says are bare BY DESCRIPTION, widest match first.""" + out = [] + for rx, regions in _NUDITY_RX: + if rx.search(text or ""): + for r in regions: + if r not in out: + out.append(r) + return out + + +def bare_sentence(region): + """How to say a region is bare, or "" for one with no wording.""" + return next((s for _rx, r, s in _REGION_RX if r == region), "") + + +def _bare_on(p, regions): + for r in ([regions] if isinstance(regions, str) else regions): + if r and r not in p.bare: + p.bare.append(r) + + +def _bare_off(p, regions): + for r in ([regions] if isinstance(regions, str) else regions): + if r in p.bare: + p.bare.remove(r) + + +def part_spans(text): + """Every body part named, as (name, at).""" + out = [] + for m in _PART_ONE.finditer(text or ""): + out.append((next(n for p, n in PARTS + if re.fullmatch(p, m.group(1), re.I)), m.start())) + return out + + +_RUNS_TO = _rx(r"^\s*\w*\s*(?:to|onto|from)\b") + + +def _runs_to(text, at): + """Does the item at `at` run TO something -- is it a tether? + + Read just past the word, so "a chain to her collar" and "a chain running to + the ring" both answer yes and "duct tape" answers no.""" + return bool(_RUNS_TO.search(text[at:][_first_gap(text[at:]):])) \ + or bool(anchor_in(text)) + + +def _first_gap(s): + """Index just past the first word of `s`.""" + m = re.search(r"\s", s) + return m.start() if m else len(s) + + +def _nearest_part(parts, at, ats): + """The part belonging to the item at `at`, or "". + + English puts it after: "a chain around her ankles", "chains her ankles + together". So the first part named AFTER this item wins, unless another + item is named in between -- that one owns it instead.""" + later = [(p, q) for p, q in parts if q > at] + for name, q in later: + if any(at < other < q for _c, other in ats): + break + return name + return "" + + +def hardware_in(text): + """Every piece of hardware named, as (canonical, part, as-written).""" + return [(c, p, w) for c, p, w, _at in hardware_spans(text)] + + +def position_spans(text): + """Every limb position named, as (name, at).""" + out = [] + for rx, name in _POSITION: + m = rx.search(text or "") + if m: + out.append((name, m.start())) + return sorted(out, key=lambda x: x[1]) + + +def position_in(text): + """Where the arms are held. '' when the text does not say.""" + got = position_spans(text) + return got[0][0] if got else "" + + +def anchor_spans(text): + """Every fixed thing hardware is fastened to, as (name, at). + + The VERB is required. "to the " on its own is movement -- "she sinks + to the floor", "he walks to the table" -- and reading those as fastenings + latched a restraint over furniture somebody merely walked towards.""" + t, out = text or "", [] + for m in _ANCHOR_AT.finditer(t): + # The fastening verb has to be in THIS clause, not somewhere earlier in + # the paragraph. + clause = re.split(r"[.;!?]", t[:m.start()])[-1] + if _APPLY.search(clause) or re.search( + r"\b(?:chains?|ropes?|cords?|cables?|leash(?:es)?|straps?|" + r"tethers?|links?|lines?)\s+(?:\S+\s+){0,4}?" + r"(?:runs?|holds?|leads?|stretch(?:es)?|extends?|goes|hangs?)\b", + clause, re.I): + out.append((re.sub(r"\s+", " ", m.group(1).lower()), m.start())) + return out + + +def anchor_in(text): + """What hardware is fastened to. '' when the text fastens nothing.""" + got = anchor_spans(text) + return got[0][0] if got else "" + + +def place_in(text): + """The room this text puts the shot in. '' when it names none. + + Behind a preposition, so a room has to be somewhere somebody IS. "Ana looks + at the door" names no room -- and a door is not on the list in any case.""" + m = _PLACE_IN.search(text or "") + if not m: + return "" + got = re.sub(r"\s+", " ", m.group(1).lower()).strip() + # A BARE "room" NAMES NOWHERE. "Ana walks into the room" says she goes + # inside, not which room -- and taking it as a place produced "The shot is + # in the room, not the room the scene text names", which contradicts itself + # in one sentence. Modified, it is a real place: "the far room", "the back + # room" and "the next room" all distinguish themselves from where we were. + return "" if got == "room" else got + + +def garments_in(text): + """Garments named, as written, in order.""" + out, seen = [], set() + for m in _GARMENT_ONE.finditer(text or ""): + adj, noun = (m.group(1) or "").strip(), m.group(2) + phrase = f"{adj} {noun}".strip().lower() + key = noun.lower().rstrip("s") + if key not in seen: + seen.add(key) + out.append(phrase) + return out + + + +# Hardware, not clothing. Taking clothes off does not unlock anything, so these +# are kept out of the garment answer -- the standing rule is that hardware is +# cleared by an explicit `remove:` and by nothing else. +_NOT_CLOTHING = 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) +_PHRASE_ONE = _rx(r"\b(?:" + GARMENT_PHRASES + r")\b") +_WORD_ONE = _rx(r"^(?:" + GARMENT_WORDS + r")s?$") + + +def garment_words(text): + """Every garment named, as HEAD WORDS, restraints excluded. + + What the wardrobe logic tracks and compares: ["jeans", "chastity belt"]. Its + sister garments_in keeps the adjectives, because a shot has to SAY "blue + jeans" while the tracking only has to know they are jeans. + + Multi-word entries are read first and their words removed, so the single-word + pass cannot also find "belt" inside "chastity belt" and record the item twice + under two names.""" + text = text or "" + out = [] + for m in _PHRASE_ONE.finditer(text): + phrase = re.sub(r"[\s-]+", " ", m.group(0)).strip().lower() + if phrase not in out: + out.append(phrase) + text = _PHRASE_ONE.sub(" ", text) + for word in re.findall(r"\b[\w-]{3,}\b", text): + low = word.lower().strip("-") + if low in out or _NOT_CLOTHING.match(low): + continue + if _WORD_ONE.match(low): + out.append(low) + return out + +def posture_in(text): + """The posture this beat puts a body in. '' when it does not. + + Reads the one table. sampler.posture_in answers a different question -- which + PERSON each posture belongs to, clause by clause -- and keeps its own reader + for that, but off the same vocabulary.""" + t = text or "" + hits = sorted((m.start(), name) for name, rx in _POSTURE_OF + for m in [rx.search(t)] if m) + return hits[0][1] if hits else "" + + + +# A determiner, INCLUDING A POSSESSIVE NAME. "Dana lifts up McKenna's skirt" and +# "enters McKenna's bedroom" both failed on a list of the/her/his/their/a/an, in +# two different readers, fixed weeks apart. One constant, so the next reader that +# needs it cannot get a narrower copy. +DET_POSS = r"(?:the|her|his|their|its|a|an|\w+['’]s)" +_DET_POSS = DET_POSS + +# --------------------------------------------------------------------------- +# WARDROBE: what moved, and what was put back. +# +# Moved here from sampler.py, whole, because the pair went wrong three separate +# ways while it was split across two files: a possessive name ("Dana lifts up +# McKenna's skirt") matched neither reader, the restore verbs covered only the +# direction nobody writes, and the layering read displacements a beat before the +# latch recorded them. They name the same garments, they have to agree with the +# same sheet, and they now sit beside the vocabulary they both read. +# --------------------------------------------------------------------------- + +# Shared with the removal readers still in sampler.py, which is why it is here +# rather than moved: this is the copy, and the sampler imports it. +_STRIP_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]?|tore|" + r"slip(?:s|ped)?|shrug(?:s|ged)?|yank(?:s|ed)?|tug(?:s|ged)?|" + r"toss(?:es|ed)?|throw[s]?|threw|" + # How clothes actually come off, in the words people write it in. + # Without these a beat took the garment off on screen while the scene + # kept saying it was worn -- and the scene is re-stamped into every + # later shot, so it came back on and stayed on. + r"kick(?:s|ed|ing)?|step(?:s|ped|ping)?|lift(?:s|ed|ing)?|" + r"slide[s]?|slid|wriggle[sd]?|wiggle[sd]?|work(?:s|ed)?") +# 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. +_UNDO_VERB = (r"remove[sd]?|removing|undress(?:es|ed)?|unzip(?:s|ped)?|" + r"unbutton(?:s|ed)?|unhook(?:s|ed)?|unclasp(?:s|ed)?|unfasten(?:s|ed)?|" + # Hardware comes off by being UNDONE, and these were missing: a beat + # saying "unlocks the belt" left it described as worn for the rest of + # the film, because nothing here read as a removal at all. + r"unlock(?:s|ed)?|unbuckle[sd]?|unclip(?:s|ped)?|unstrap(?:s|ped)?|" + r"unlace[sd]?|untie[sd]?|unties|unwrap(?:s|ped)?|" + r"undo(?:es)?|undid") + + +_DISPLACE_WAY = (r"back\s+up|back\s+down|down|up|aside|open|back|" + r"off\s+(?:one|her|his|their)\s+shoulders?") +_DISPLACE = re.compile( + r"\b(?:" + _STRIP_VERB + r"|push(?:es|ed|ing)?|shove[sd]?|roll(?:s|ed|ing)?|" + r"hitch(?:es|ed)?|hike[sd]?|open(?:s|ed)?|undo(?:es)?|unzip(?:s|ped)?|" + # LIFTING A SKIRT IS DISPLACING IT, and none of these were here. Asked + # for directly: "when the skirt has been lifted up to show the chastity + # belt, that's when it should be shown". Lifting was not read as moving + # anything, so the belt stayed covered through the shot that uncovers it. + r"lift(?:s|ed|ing)?|raise[sd]?|rais(?:es|ed|ing)|hoist(?:s|ed|ing)?|" + r"hold(?:s|ing)?|held|gather(?:s|ed|ing)?|bunch(?:es|ed|ing)?)\s+" + r"(?:(" + _DISPLACE_WAY + r")\s+)?" + r"(" + _DET_POSS + r"\s+)?([\w][\w\- ]{0,28}?)" + r"(?:\s+(" + _DISPLACE_WAY + r"))?" + r"(?=[.,;:!?]|\s+(?:and|to|so|while|as|then)\b|$)", re.I) + + +def scene_name_for(head, scene): + """The sheet's OWN full name for a garment, found by its head noun. "" if absent. + + A beat calls a thing whatever is convenient -- "the shorts" for what the sheet + dressed her in as "blue jeans shorts". Anything the node then says about it has + to use the SHEET's words: a shot carrying both names is a shot describing two + garments, and the model draws the bare one however it likes. That is a garment + invented out of the node's own text, which is the worst kind. + + The entry is read back from the sheet: its modifiers are the words before the + head noun in the same comma-separated item, and no further -- a name from the + entry before it would attach one garment's colour to another.""" + head = (head or "").strip().lower() + if not head or not scene: + return "" + best = "" + for line in str(scene).split("\n"): + # Only the wardrobe side of "Name: she, 22, blue jeans shorts". + line = line.split(":", 1)[-1] + for item in re.split(r"[,;.]", line): + # A tag is not part of the garment's NAME. "chastity belt + # " ends in "2", so the head-noun match failed and the belt + # fell back to the beat's bare word -- while an untagged garment in the + # same sheet expanded correctly. The tagged garment is exactly the one + # a reference is pinning, so it is the worst one to describe loosely. + item = re.sub(r"<\s*picture\s+\d+\s*>", " ", item, flags=re.I) + item = re.sub(r"\s+", " ", item).strip() + if not item or item.split()[-1].lower() != head: + continue + # Drop a leading article or possessive; they are not description. + item = re.sub(r"^(?:a|an|the|her|his|their|its)\s+", "", item, flags=re.I) + # The longest entry wins: a sheet that names it twice described it most + # fully once, and the fuller name is the one worth carrying. + if len(item) > len(best): + best = item + # The author's OWN capitalisation. Lowercasing turned "PVC" into "pvc" and + # "Shiny white crop top" into all-lowercase -- a different token sequence than + # was written, for a brand or material name that is capitalised for a reason. + # Only the matching above is case-insensitive; what comes back is what they typed. + return best + + +def displaced_garments(beat, scene): + """[(garment, how)] this beat MOVES without taking off. [] when none. + + Same two conditions infer_removals uses, and for the same reason: the beat has + to stage it, and the scene has to already say the thing is worn. A displacement + invented for something nobody is wearing describes a garment into existence.""" + if not beat or not scene: + return [] + out, seen = [], set() + low = scene.lower() + for m in _DISPLACE.finditer(beat): + way = (m.group(1) or m.group(4) or "").lower().strip() + thing = re.sub(r"\s+", " ", (m.group(3) or "")).strip().lower() + # SOME VERBS CARRY THEIR OWN DIRECTION. "lifts her skirt" says which way + # by saying lift, and the direction word this pattern wants is simply not + # written -- so the match was thrown away for having no `way`, and the one + # beat that uncovers the layer beneath did nothing. Read on the matched + # text rather than a new capture group, which would renumber the rest. + if not way and re.match(r"\s*(?:lift|rais|hoist|gather|bunch)", m.group(0), + re.I): + way = "up" + if not way or not thing or thing in seen: + continue + # The garment has to be one the scene already dresses them in, and the head + # noun is what matches: "her denim shorts" is the scene's "blue denim shorts". + head = thing.split()[-1] + if len(head) < 3 or head not in low: + continue + seen.add(thing) + # ...and it is the SCENE'S name that gets carried forward, not the beat's. + # A beat says "pulls the shorts back up" for what the sheet calls "blue + # jeans shorts", and the guard echoed the beat: the shot then carried a + # bare "the shorts" beside the sheet's full name, and a model handed two + # differently-named garments draws two different garments. The shorts came + # back in a different colour and cut -- invented, from the node's own text. + thing = scene_name_for(head, scene) or thing + # "back up" and "back down" say the direction in their second word. + way = re.sub(r"^back\s+", "", re.sub(r"\s+", " ", way)) + out.append((thing, "pulled " + way if way in ("down", "up", "aside", "back") + else way)) + return out + + +# Putting it right without naming it: "pulls them back up". A pronoun cannot be +# matched against the wardrobe, but if exactly one garment is displaced there is only +# one thing it can mean -- and leaving it displaced is the error that shows. +# PUTTING IT BACK. A displaced garment is still worn and the node keeps saying +# where the beat left it -- so the beat that puts it right has to be read, or the +# skirt stays lifted for the rest of the film and whatever was under it stays on +# show. Reported: "McKenna lets it fall" did nothing, because the only restores +# recognised were pull/tug/hitch/hike/yank/push with a pronoun and a direction. +# +# What actually gets written is mostly the opposite: a lifted skirt is LET FALL, +# DROPPED, LOWERED, SMOOTHED DOWN, STRAIGHTENED, FIXED or simply LET GO of, and +# none of those has a direction word in it at all. +_RESTORE_VERB = (r"(?:let(?:s|ting)?(?:\s+go\s+of)?|drop(?:s|ped|ping)?|" + r"lower(?:s|ed|ing)?|smooth(?:s|ed|ing)?|straighten(?:s|ed|ing)?|" + r"fix(?:es|ed|ing)?|rearrang(?:e|es|ed|ing)|" + r"replac(?:e|es|ed|ing)|put(?:s|ting)?|tidy|tidies|tidied|" + r"cover(?:s|ed|ing)?\s+(?:herself|himself|themselves|up)|" + r"pull|tug|hitch|hike|yank|push)") +# The old pronoun form, plus the new verbs, still with no garment named. +_PUT_BACK = re.compile( + r"\b(?:pull|tug|hitch|hike|yank|push)(?:s|ed|ing)?\s+" + r"(?:it|them|these|those)\s+(?:back\s+)?(?:up|down|closed|shut|together)\b" + r"|\b(?:pull|tug|hitch|hike|yank|push)(?:s|ed|ing)?\s+" + r"(?:it|them)\s+back\b" + r"|\b" + _RESTORE_VERB + r"(?:s|ed|ing)?\s+" + r"(?:it|them|these|those)\s+(?:fall|drop|go|back|down|straight)\b" + r"|\blet(?:s|ting)?\s+(?:it|them)\s+fall\b" + r"|\b(?:cover(?:s|ed|ing)?\s+(?:herself|himself|themselves)\s+(?:back\s+)?up)\b", + re.I) +# ...and the same act with the garment NAMED: "lets the skirt fall", "smooths her +# skirt down". The garment has to be one the sheet already dresses them in, which +# is the same condition displaced_garments uses. +_PUT_BACK_NAMED = re.compile( + r"\b" + _RESTORE_VERB + r"\s+" + + _DET_POSS + r"\s+([\w][\w\- ]{0,28}?)" + r"(?:\s+(?:fall|drop|down|back|straight|up|closed|shut|together))?" + r"(?=[.,;:!?]|\s+(?:and|to|so|while|as|then|over|again)\b|$)", re.I) + + +def puts_it_back(beat): + """Does this beat put a displaced garment right without naming it?""" + return bool(_PUT_BACK.search(beat or "")) + + +def restored_garments(beat, scene): + """[garment] this beat puts back, by name. [] when it names none. + + Same two conditions as displaced_garments: the beat has to stage it, and the + sheet has to already dress them in the thing. The sheet's own name is what + comes back, so a restore keyed on "her skirt" clears a displacement stored as + "long grey skirt".""" + if not beat or not scene: + return [] + out, low = [], scene.lower() + for m in _PUT_BACK_NAMED.finditer(beat): + thing = re.sub(r"\s+", " ", (m.group(1) or "")).strip().lower() + if not thing: + continue + head = thing.split()[-1] + if len(head) < 3 or head not in low: + continue + name = scene_name_for(head, scene) or thing + if name not in out: + out.append(name) + return out + + + + +# --------------------------------------------------------------------------- +# LAYERING: which garment goes under which. +# +# Moved here to sit beside the vocabulary it reads. It is the last piece of the +# wardrobe that was living apart from the list of what a garment IS, and that +# separation is what let "chastity belt" be underwear to one file and a bare +# "belt" to the other. +# +# The CLAUSES stay in sampler.py -- under_clause, reveal_clause, bare_clause. +# Knowing a belt is under a skirt belongs here; saying so in a sentence belongs +# where a shot is assembled. Same split the restraint work settled on. +# --------------------------------------------------------------------------- + +_UNDER_BY_REGION = { + # NO HARDWARE HERE. A chastity belt is a restraint, and a restraint left out of + # the text renders absent -- that is the bug the hardware latch exists for, and + # putting the belt in this list rebuilt it from the other side. Reported as the + # belt disappearing a few beats in, right after layering shipped. + # + # Cloth can be hidden and recovered from a description. Hardware cannot: a belt + # that stops being drawn does not come back looking slightly wrong, it is gone, + # and so is every beat that depended on it being there. + # Hyphens and the other names for it. "chastity-belt" and "chastity device" were + # not matched, so a sheet that spelled it either of those way showed it through + # the jeans while "chastity belt" was correctly hidden -- the fix looked done + # because the one spelling I tested worked. + "lower": (r"panties|knickers|thong|g-?string|briefs|boxers|boxer\s+shorts|" + r"underwear|undies|jockstrap|loincloth|" + r"chastity[\s-]*(?:belts?|devices?|cages?)"), + "upper": (r"bra|bralette|brassiere|camisole|undershirt|vest|corset|bustier"), +} +_OUTER_BY_REGION = { + # Tights and pantyhose DO cover a waistband; stockings and hold-ups do not -- + # they stop at the thigh. Listing them together hid a chastity belt under a + # pair of stockings, which covers nothing of it. + "lower": (r"shorts|trousers|jeans|slacks|chinos|skirt|kilt|leggings|joggers|" + r"tights|pantyhose|jeggings|culottes|" + r"tracksuit\s+bottoms|dungarees|overalls|dress|gown|robe"), + "upper": (r"top|shirt|blouse|t-?shirt|tee|jumper|sweater|sweatshirt|hoodie|" + r"cardigan|jacket|coat|dress|gown|robe|dungarees|overalls|tunic"), +} + + +def implied_layers(scene): + """{under: over} for underwear the scene lists beneath outer clothes it also lists. + + Only where BOTH are named: underwear with nothing over it is on show, and saying + it is hidden would be describing away something the author dressed them in.""" + covers = {} + # ONE PERSON AT A TIME. This read the whole scene as a single wardrobe, so one + # character's jeans covered another character's belt -- and which garment won + # depended on the ORDER the sheet lines happened to be written in. A sheet that + # put the man second hid her belt under his trousers. + # + # Split on lines so each entry is judged alone. Text that is not an entry -- the + # scene paragraph -- is still read as one block, since a location describing + # clothing is describing whoever is in it. + for line in (scene or "").split("\n"): + text = line.strip() + if not text: + continue + for region, unders in _UNDER_BY_REGION.items(): + over = None + # The HEAD noun, which in English is the last one: "blue jeans shorts" is + # a pair of shorts, not a pair of jeans. Taking the first match recorded + # the cover as "jeans" while a removal names it "shorts", so the two never + # lined up -- the belt was hidden correctly and then never uncovered, + # because the garment that came off was not the one it was held under. + for m in re.finditer(r"\b(?:" + _OUTER_BY_REGION[region] + r")\b", + text, re.I): + over = m.group(0).lower() + if not over: + continue + for m in re.finditer(r"\b(?:" + unders + r")\b", text, re.I): + under = re.sub(r"\s+", " ", m.group(0).lower()) + if under != over: + covers.setdefault(under, over) + return covers + + +def is_undergarment(item): + """Is this one of the things that is ALWAYS worn under clothes? + + Panties, knickers, thongs, briefs, boxers, underwear, bras, corsets, and + chastity belts, devices and cages -- the _UNDER_BY_REGION list, which is why + it is read off that list rather than a second copy that could drift from it. + + These are named and placed rather than deleted. A locket under a coat is a + different thing: it genuinely cannot be seen, nothing is lost by waiting for + the coat to come off, and it keeps the older behaviour.""" + t = str(item or "").lower() + return any(re.search(r"\b(?:" + pat + r")\b", t, re.I) + for pat in _UNDER_BY_REGION.values()) + + +def hidden_layers(covers, gone, moved=()): + """Garments still underneath something that is still covering them. + + `moved` is outer garments the beats have DISPLACED -- pulled down, pushed + aside. Those are still worn, so `gone` never learns about them, and the + layer beneath stayed hidden while the beat was busy showing it off: "pulls + her shorts down to show the thong" described the thong in that one shot, + from the author's own words, and hid it again in the next.""" + # Compared on the HEAD NOUN. `covers` holds the outer garment as implied_layers + # read it ("shorts") while a displacement is keyed by the sheet's full name + # ("denim shorts"), and an exact match between the two never fires -- the layer + # underneath stayed hidden on the very shot the beat pulled the cover off. + aside = {str(m).lower().split()[-1] for m in (moved or ()) if str(m).strip()} + return [u for u, o in (covers or {}).items() + if o not in gone and u not in gone + and str(o).lower().split()[-1] not in aside] + + + + +# --------------------------------------------------------------------------- +# POSTURE. Moved from sampler.py, which had the richer table -- "takes a +# seat", "gets to her feet", "goes down on her knees" -- and the engine had +# three the sampler lacked. Two tables, diverged, and the sampler's is the +# one that drives the guard, so a crouch set no posture at all. +# --------------------------------------------------------------------------- + +_POSTURE_OF = ( + ("sitting", re.compile(r"\b(?:sits?|sat|sitting|seats?\s+(?:her|him|them)self|" + r"is\s+seated|takes?\s+a\s+seat|perch(?:es|ed)?)\b", re.I)), + ("kneeling", re.compile(r"\b(?:kneels?|knelt|kneeling|" + r"(?:goes?|got|gets?)\s+down\s+on\s+(?:her|his|their)\s+knees)\b", + re.I)), + ("lying down", re.compile(r"\b(?:lies?|lay|lays?|laid|lying|laying|" + r"stretches?\s+out|sprawls?|sprawled)\b", re.I)), + ("standing", re.compile(r"\b(?:stands?|stood|standing|" + r"(?:gets?|got)\s+(?:up|to\s+(?:her|his|their)\s+feet)|" + r"rises?|rose|risen)\b", re.I)), +) +# A posture verb that is really about somewhere else: "the chair stands in the +# corner", "the case lies on the table". Those set nobody's pose. +_NOT_A_BODY = re.compile(r"\b(?:it|chair|table|box|case|bag|door|house|room|" + r"building|tree|bottle|glass|book|light|lamp)\s+\w{0,8}?\s*" + r"(?:stands?|lies?|sits?)\b", re.I) +# THE THREE THE ENGINE KNEW AND THIS DID NOT. Two tables, diverged, and this is +# the one that drives the posture guard -- so "Ana crouches" set no posture at +# all and the next shot was told nothing about how she was left. +_POSTURE_OF = _POSTURE_OF + ( + # SQUATTING IS NOT CROUCHING. Folded together, a script that said "squats" + # was held as "still crouching" -- a different shape of body, and not the + # word the author chose. The hold says back what was written. + ("squatting", _rx(r"\b(?:squats?|squatting|squatted)\b")), + ("crouching", _rx(r"\b(?:crouch(?:es|ing|ed)?)\b")), + ("bent over", _rx(r"\b(?:bends?\s+over|bent\s+over|leans?\s+over|" + r"leaned\s+over|doubles?\s+over)\b")), + ("curled up", _rx(r"\b(?:curled\s+up|curls?\s+up|foetal|fetal)\b")), + # ROLLING ONTO A SIDE IS STILL LYING DOWN. "McKenna rolls onto her side" set no + # posture at all -- the lying verbs are all lie/lay/sprawl and none of them is + # how you write a body that is ALREADY down changing which way it faces. So a + # beat that put her on her side left the hold saying nothing, and the shot after + # it was told nothing about how she was left. + # + # It also decides whether the weight gets named: the pose clause only says what + # is under a bound body when it knows the body is off its feet (see + # POSE_LYING_WEIGHT in sampler.py), and that reads this posture. + # + # The possessive is required. "the barrel rolls onto its side" is not a person, + # and _NOT_A_BODY does not cover roll. + ("lying down", _rx(r"\broll(?:s|ed|ing)?\s+(?:over\s+)?(?:on)?to\s+" + r"(?:her|his|their)\s+" + r"(?:side|back|front|stomach|belly)\b")), +) + + +# Words that are capitalised at the start of a sentence whatever they mean, so +# their capital says nothing about whether they are a name. "May I come in?" +# staged a character called Aunt May. Mid-sentence the capital is informative +# again, and these are accepted there. +_SENTENCE_START_ALSO = frozenset(""" +may will can must might shall should would could does did was were are is +let get go come take put look stop wait now then there here this that these +those one some all any no yes so but and or if when while after before both +say tell keep hold turn open close pull push move step down +""".split()) + + +def _alias_at(word, staged): + """Where a one-word stand-in for a longer name appears, or None. + + A capital at the start of a sentence is free, so a word that is ordinary + English there has to earn its match somewhere else in the beat.""" + fallback = None + for m in re.finditer(r"\b" + re.escape(word) + r"\b", staged): + opens = re.search(r"(?:^|[.!?;:]\s*|[\"'“]\s*)$", staged[:m.start()]) + if not opens: + return m.start() + if word.lower() not in _SENTENCE_START_ALSO and fallback is None: + fallback = m.start() + return fallback + + +def names_in(beat, cast): + """Names this beat STAGES, in the order the sentence puts them. + + Case-SENSITIVE, and that is not fussiness: prose capitalises a name, and + matching without case makes the word "will" find a character called Will and + "grace" find Grace. The sampler had that fixed and this file did not, which + is what two copies of one idea buys you. + + Speech-stripped, for the same reason it is everywhere else -- "McKenna, where + are you?" is how absence gets written, and reading it as presence put a whole + sheet entry into a shot the person is not in.""" + staged = _outside_speech(beat or "") + names = [str(n) for n in (cast or []) if n] + hits, found = [], set() + for n in names: + m = re.search(r"\b" + re.escape(n) + r"\b", staged) + if m: + hits.append((m.start(), n)) + found.add(n) + # A SHEET NAME IS OFTEN LONGER THAN WHAT THE BEATS CALL HER. "Mistress Vale" + # on the sheet and "the Mistress" in every beat matched nothing, so her line + # was in no shot at all and the model invented her from scratch each time. + # + # One word of the name, and only when that word is hers alone: with both + # "Mistress" and "Mistress Vale" on the sheet, "Mistress" belongs to the + # first and picking either would be a guess. Titles are short and shared, so + # a word under three letters never stands in. + for n in names: + if n in found or " " not in n: + continue + for w in n.split(): + if len(w) < 3 or not w[:1].isupper(): + continue + if any(w == o or w in o.split() for o in names if o != n): + continue + m = _alias_at(w, staged) + if m is not None: + hits.append((m, n)) + found.add(n) + break + return [n for _at, n in sorted(hits)] + + +# --------------------------------------------------------------------------- +# STATE. One object knows what is true, and everything a shot says is rendered +# from it -- so two clauses cannot contradict each other, because there is only +# one place a fact lives. +# --------------------------------------------------------------------------- + +class Restraint: + """One piece of hardware on one person. + + part, position and anchor travel TOGETHER, and that is the whole point. The + old engine read the part off the item list and the position off the beat, in + two functions that never met, and emitted "holding the neck behind the back". + A position describes where the ARMS are, so a collar cannot carry one -- the + constructor drops it rather than trusting the caller.""" + + __slots__ = ("item", "part", "position", "anchor", "applied_in", "rigid") + + def __init__(self, item, part, position="", anchor="", applied_in=0, + rigid=False): + self.item = item + self.part = part + self.position = position if part in ("wrists", "arms") else "" + self.anchor = anchor + self.applied_in = applied_in + # RIGID metal keeps its shape. Steel decoded and re-encoded once a shot + # has nothing in the text holding its links to a size, and it creeps -- + # a chain grows slack, cuffs turn into bracelets. Soft goods do not need + # this and must not be given it: rope is tied, not held rigid. + self.rigid = bool(rigid) or _is_rigid(item) + + def phrase(self): + """Where this one holds, as English.""" + bits = [f"the {self.part}"] + if self.position: + bits.append(self.position) + if self.anchor: + bits.append(f"fast to the {self.anchor}") + return " ".join(bits) + + def __repr__(self): + return (f"Restraint({self.item!r},{self.part!r}," + f"{self.position!r},{self.anchor!r})") + + +class Person: + __slots__ = ("name", "hardware", "worn", "removed", "displaced", + "posture", "place", "bare") + + def __init__(self, name): + self.name = name + self.hardware = {} # (canonical, part) -> Restraint + self.worn = [] # garments on the body, as written + self.removed = [] # garments taken off + self.displaced = [] # pulled aside but still on + self.posture = "" + self.place = "" + # Regions with nothing on them. A LATCH, not a one-shot fact: the beat + # that uncovered a region is the only shot that used to say so, and every + # shot after it left that region unspecified -- which the model fills + # from its own prior. Reported as a bra coming back on a topless + # character who never had one on the sheet. + self.bare = [] + + def restrained(self): + return bool(self.hardware) + + def kinds(self): + """What is on, by canonical name, in the order it went on. + + The key is a (name, part) pair so that a chain on the ankles and a chain + on the wrists can both exist. Almost nothing cares about the part, so it + asks here instead of unpacking the key.""" + return [c for c, _pt in self.hardware] + + def hw(self, canon, part=None): + """One restraint by name, or None. Give a part to pick between two.""" + for (c, pt), r in self.hardware.items(): + if c == canon and (part is None or pt == part): + return r + return None + + def __repr__(self): + return (f"Person({self.name!r},hw={list(self.hardware)}," + f"posture={self.posture!r})") + + +class SceneState: + """What is true right now, and what each shot is told because of it.""" + + def __init__(self, place=""): + self.place = place + # The room the SCENE paragraph names. A shot only needs telling where it + # is once the film has moved somewhere else. + self.opened_in = place + self.people = {} + self.shot = 0 + + def person(self, name): + if name not in self.people: + self.people[name] = Person(name) + return self.people[name] + + def declare(self, name, description, staged_later=()): + """Take what a character sheet already says as read. + + A sheet entry is a STATE, not an event: "Kate: she, 30, coat, handcuffs" + says the handcuffs are already on before any beat puts them there. The + engine read only beats at first, so a scene that opened with somebody + already restrained had no hardware in it at all until a beat happened to + mention some -- and a hold that never fires is hardware the model is free + to leave off. + + `staged_later` is what stops that becoming its own bug. A sheet says WHAT + somebody has and never says WHEN, so a sheet reading "McKenna: she, 27, + green dress, handcuffs" beside a script that cuffs her in beat 3 declared + them on from shot 1 -- and shot 1 went out saying "The handcuffs stay + closed and fastened AS THEY WERE PUT ON", two shots before anybody put + them on. Reported as a handcuff on her arm before she is handcuffed. + + Where the script stages the fastening, the script decides the moment. The + sheet still supplies the description; it just does not get to start the + clock. Declared, never applied: nothing here is a change, so no shot is + told anything goes on during it.""" + p = self.person(name) + for canon, part, written, _at in hardware_spans(description or ""): + if canon in staged_later: + continue + if (canon, part) not in p.hardware: + # applied_in = 0, so this never reads as "goes on during this + # shot" -- shots are numbered from 1. + p.hardware[(canon, part)] = Restraint(written or canon, part, + position_in(description or ""), + anchor_in(description or ""), 0) + # ...unless it has already come OFF. The sheet is re-read every shot and + # the character memory is never edited, so a garment removed in shot 2 + # was put straight back on the body by the sheet in shot 3 -- and the + # clause saying that region is bare then went silent, because something + # "still worn" covered it. What the script did outranks what the sheet + # lists; the sheet says what she has, not what is on her now. + _off = [_garment_key(x) for x in p.removed] + for g in garments_in(description or ""): + key = _garment_key(g) + if key not in [_garment_key(x) for x in p.worn] and key not in _off: + p.worn.append(g) + return p + + # -- reading a beat ---------------------------------------------------- + def read(self, beat, cast=(), shot=0): + """Update the state from one beat, and report what CHANGED. + + The change matters separately from the result: the shot that puts the + cuffs on has to say both ends of that, and every shot after it says only + the result.""" + self.shot = shot + beat = beat or "" + changed = {"applied": [], "released": [], "moved_to": "", "posture": {}, + "removed": [], "worn": [], "displaced": []} + + here = place_in(beat) + if here: + self.place = here + changed["moved_to"] = here + + # IN SENTENCE ORDER, not cast order. Ordering by the sheet put "Ana" + # before "Guard" in "The guard handcuffs Ana", so the cuffs went on the + # guard -- the agent wearing what he is applying, which is the invented + # second figure all over again. + # + # ...and read from the STAGED half only. A name inside a line of dialogue + # is being said, not staged: "Dan says: 'McKenna, put the cuffs on'" + # would otherwise hand McKenna hardware in a shot she is not in. + who = names_in(beat, cast) + subject = who[0] if who else next(iter(list(self.people) or list(cast) + or [""])) + + hw = hardware_in(beat) + applying = bool(hw) and bool(_APPLY.search(beat)) and not _RELEASE.search(beat) + releasing = bool(_RELEASE.search(beat)) + + if applying: + wearer = _wearer(beat, who, subject) + p = self.person(wearer) + # MODIFIERS BIND TO THE NEAREST ITEM. "handcuffs her wrists behind + # her back and locks a steel collar around her neck, chained to the + # wall" carries two modifiers and two items; giving both modifiers + # to both items produced handcuffs chained to a wall they were never + # near, and a collar held behind a back. + spans = hardware_spans(beat) + for canon, part, written, at in spans: + # KEYED BY THE PAIR. A chain on the ankles and a chain on the + # wrists are two restraints; keyed by name alone the second + # overwrote the first and one of them was never drawn again. + p.hardware[(canon, part)] = Restraint( + written or canon, part, + _nearest(position_spans(beat), at, spans), + _nearest(anchor_spans(beat), at, spans), shot) + changed["applied"].append((wearer, p.hardware[(canon, part)])) + # A chain named beside another item is that item's TETHER, and it + # is folded in by hardware_spans now, where the parts are known. + # Doing it here meant popping "chain" whenever one was named with an + # anchor -- which also popped a chain that had a part of its OWN, so + # "chains her collar to the ring and chains her ankles together" + # kept the collar and lost the ankles. + # Its anchor needs no transferring either: with the tether gone from + # the spans, the anchor binds to the nearest remaining item, which + # is the one it was always describing. + elif releasing: + # Whoever is actually wearing it. "The guard unlocks the handcuffs" + # names only the agent, and taking the subject there tried to + # release hardware from the man holding the key. + held = [n for n, q in self.people.items() if q.restrained()] + wearer = next((n for n in who if n in held), + held[0] if len(held) == 1 else subject) + p = self.person(wearer) + # Released by NAME, whatever part it is on: an unlocking beat says + # "unlocks the chain", not which of two chains, and matching the + # pair left one fastened forever. + _kinds = {c for c, _pt, _w in hw} + named = [k for k in list(p.hardware) if k[0] in _kinds] + if named: + for key in named: + changed["released"].append((wearer, p.hardware.pop(key))) + elif re.search(r"\b(?:them|it|her|him|everything|all\s+of\s+it)\b", + beat, re.I): + # "the guard releases her" names no item, so all of it comes off. + while p.hardware: + changed["released"].append((wearer, p.hardware.popitem()[1])) + + # Garments. The verb decides which way the change runs, and the item has + # to be named -- a bare "she undresses" says nothing about which garment, + # and guessing is how a garment came off a beat before the beat that + # took it off. + wearer_g = _wearer(beat, who, subject) if len(who) > 1 else subject + if wearer_g: + p = self.person(wearer_g) + for g in garments_in(beat): + key = _garment_key(g) + if _TAKES_OFF.search(beat): + if key not in [_garment_key(x) for x in p.removed]: + p.removed.append(g) + changed["removed"].append((wearer_g, g)) + p.worn = [x for x in p.worn if _garment_key(x) != key] + p.displaced = [x for x in p.displaced + if _garment_key(x) != key] + _bare_on(p, region_of(g)) + elif _PUTS_ON.search(beat): + if key not in [_garment_key(x) for x in p.worn]: + p.worn.append(g) + changed["worn"].append((wearer_g, g)) + p.removed = [x for x in p.removed if _garment_key(x) != key] + p.displaced = [x for x in p.displaced + if _garment_key(x) != key] + # Covered again: the latch has to release, or a character who + # dresses is told for the rest of the film that the region is + # bare, over the garment she just put on. + _bare_off(p, region_of(g)) + elif _DISPLACES.search(beat): + if key not in [_garment_key(x) for x in p.displaced]: + p.displaced.append(g) + changed["displaced"].append((wearer_g, g)) + + # BEING in the state, rather than arriving at it. No garment is named and + # nothing comes off, so every removal path had nothing to do and no shot + # ever said what was on the chest. + _nude = nudity_in(beat) + if _nude: + for n in (who or ([subject] if subject else [])): + q = self.person(n) + _bare_on(q, _nude) + # ...and it takes the garments OFF. Saying somebody is topless + # names no garment, so nothing was removed and the sheet's shirt + # stayed on the body -- which then suppressed the very clause + # that says the chest is bare, because something "still worn" + # covered the region. The state has to agree with itself. + for g in list(q.worn): + if region_of(g) in _nude: + q.worn.remove(g) + if _garment_key(g) not in [_garment_key(x) + for x in q.removed]: + q.removed.append(g) + changed["removed"].append((n, g)) + + pose = posture_in(beat) + if pose and subject: + self.person(subject).posture = pose + changed["posture"][subject] = pose + + return changed + + # -- writing the shot -------------------------------------------------- + def continuity(self, described=(), changed=None): + """ONE paragraph, rendered from state. Every fact said once. + + The order is fixed -- hardware, what it holds, posture -- so a reader of + the output can tell at a glance whether something is missing. The old + engine emitted clauses in whatever order its readers happened to fire, + which is why nobody noticed the handcuffs had stopped appearing.""" + changed = changed or {} + out = [] + names = [n for n in described if n in self.people] or list(self.people) + applied_now = {r.item for _w, r in changed.get("applied", [])} + + for name in names: + p = self.people.get(name) + if not p or not p.hardware: + continue + items = [r.item for r in p.hardware.values()] + subject = _join(items) + many = len(items) > 1 or _plural(items[0]) + if applied_now & set(items): + out.append( + f"The {subject} {'go' if many else 'goes'} on during this " + f"shot: open and off the body at the first frame, closed on " + f"it by the last.") + else: + # WHOSE. With more than one person described, a hold that does + # not say whose hardware it is describes cuffs on wrists + # belonging to nobody -- and the model draws a body to own them. + who = f" on {name}" if len(names) > 1 else "" + soft = all(not r.rigid for r in p.hardware.values()) + shut = "tied and holding" if soft else "closed and fastened" + out.append( + f"The {subject}{who} {'stay' if many else 'stays'} {shut} " + f"as {'they were' if many else 'it was'} put on, the same " + f"object in the same material.") + holds = [r.phrase() for r in p.hardware.values() if r.phrase()] + if holds: + out.append(f"{'They hold' if many else 'It holds'} {_join(holds)}.") + if any(r.rigid for r in p.hardware.values()): + out.append("The links keep their size and the run between them " + "stays taut.") + + # GARMENTS. The shot that changes one says BOTH ENDS of the change -- + # where it starts and where it finishes -- because a shot told only the + # result is free to open with the result already true, which is a + # garment coming off a beat before the beat that takes it off. + off_now = {g for _w, g in changed.get("removed", [])} + on_now = {g for _w, g in changed.get("worn", [])} + aside_now = {g for _w, g in changed.get("displaced", [])} + for name in names: + p = self.people.get(name) + if not p: + continue + for g in p.removed: + if g in off_now: + out.append(f"The {g} is on the body as the shot opens and " + f"fully off it by the last frame, taken off " + f"during this shot.") + else: + out.append(f"The {g} is off the body and stays where it " + f"was put.") + for g in p.worn: + if g in on_now: + out.append(f"The {g} is off the body as the shot opens and " + f"fully on by the last frame, put on during this " + f"shot.") + for g in p.displaced: + if g in aside_now: + out.append(f"The {g} is moved aside during this shot and " + f"stays on the body.") + else: + out.append(f"The {g} is still pulled aside, still on the body.") + + for name in names: + p = self.people.get(name) + if p and p.posture and name not in (changed.get("posture") or {}): + who = name if len(names) > 1 else "The body" + out.append(f"{who} is still {p.posture}.") + + # WHERE. A journey moves the film, and every shot after it is in the new + # room -- the scene paragraph still names the old one, and without this + # the walk down the corridor arrives back in the room it left. + if self.place and self.place != self.opened_in and not changed.get("moved_to"): + out.append(f"The shot is in the {self.place}, " + f"not the room the scene text names.") + + return " ".join(out) + + +def _garment_key(g): + """Two wordings of the same garment are one garment: "blue shorts" and + "shorts" must not both be tracked, or the shot lists a spare pair.""" + return re.sub(r"\s+", " ", (g or "").lower()).split()[-1].rstrip("s") + + +_RIGID = _rx(r"\b(?:handcuffs?|cuffs?|chains?|shackles?|manacles?|leg\s?irons?|" + r"spreader\s+bars?|steel|iron|metal|padlock|chrome|brass)\b") +_SOFT = _rx(r"\b(?:rope|ropes|cord|cords|twine|string|tape|scarf|stocking|" + r"stockings|zip\s?ties?|cable\s?ties?|laces?)\b") + + +def _is_rigid(item): + """Does this thing hold its shape? Soft goods never do, whatever else the + phrase says -- "steel" in a sentence about rope does not make rope steel.""" + t = item or "" + return bool(_RIGID.search(t)) and not _SOFT.search(t) + + +def _nearest(mods, at, spans): + """The modifier belonging to the item at `at`, or ''. + + A modifier belongs to the last item mentioned before it -- English puts the + qualifier after the thing it qualifies. So with items at 10 and 60 and a + modifier at 75, the modifier is the second item's; a modifier at 20 is the + first item's.""" + starts = sorted(s for _c, _p, _w, s in spans) + mine = "" + for name, where in mods: + owner = max((s for s in starts if s <= where), default=starts[0]) + if owner == at: + mine = mine or name + return mine + + +def _wearer(beat, who, fallback): + """Who the hardware goes ON. The agent is not the wearer. + + "The guard cuffs Ana" puts them on Ana; "Ana is cuffed by the guard" puts + them on Ana too, and naive sentence order gets the second one backwards. + Describing hardware on somebody the text never put it on gives the model + wrists belonging to nobody, which is how a second figure gets invented to + own them.""" + if len(who) < 2: + return who[0] if who else fallback + passive = re.search(r"\bby\s+(?:the\s+)?(\w+)", beat or "", re.I) + agent = None + if passive: + agent = next((n for n in who + if n.lower() == passive.group(1).lower()), None) + if agent is None: + agent = who[0] # active voice: the one doing it comes first + return next((n for n in who if n != agent), fallback) + + +def held_part_of(items): + """The body part these items hold, as a plural noun for a sentence. + + A limb position describes the ARMS, so this answers "wrists" for anything + that holds them and defers to the item otherwise -- a collar's position is + never a limb position, and the constructor already refuses to give it one.""" + text = " ".join(items or []) + for pat, _n, part in HARDWARE: + if re.search(r"\b(?:" + pat + r")\b", text, re.I) and part not in ("wrists",): + return part + return "wrists" + + +def staged_applications(beats): + """{canonical hardware -> the 1-based beat that first puts it on}. + + Read once, before anything renders, because a sheet cannot say WHEN. Where + the script stages a fastening, no earlier shot may be told that thing is + already fastened -- that is a cuff on a wrist two shots before the cuffing.""" + out = {} + for i, b in enumerate(beats or [], 1): + b = b or "" + if not _APPLY.search(b) or _RELEASE.search(b): + continue + for canon, _part, _w, at in hardware_spans(b): + # WHAT IS BEING FASTENED TO WHAT. "Dana clips a lead to the steel + # collar" puts a LEAD on; the collar is where it clips, and it has + # been round her neck all along. Counting it as the collar's own + # application dated the collar to that beat, and everything before + # it was then treated as before she had one. + if re.search(r"\bto\s+(?:the|a|an|her|his|their)\s*$", b[:at], re.I): + continue + out.setdefault(canon, i) + return out + + +def _plural(item): + return item.endswith("s") and not item.endswith("ss") + + +def _join(items): + items = [i for i in items if i] + if not items: + return "" + if len(items) == 1: + return items[0] + return ", ".join(items[:-1]) + " and " + items[-1] diff --git a/dumas_h3_longvideos_upstream.py b/dumas_h3_longvideos_upstream.py new file mode 100644 index 0000000..f904962 --- /dev/null +++ b/dumas_h3_longvideos_upstream.py @@ -0,0 +1,10930 @@ +# 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 +# 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 tag ANYWHERE in the entry, not just at its head. "chastity + # belt " 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 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 " 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 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: " a + # chastity belt" and "a chastity belt " 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 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 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 + 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 and +# 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"|(?= 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"[\"'”’]|", b) and re.search( + r"(?:[\"'”’]|)\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. and +# (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. and 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 .... 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 "" 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 "" + said + "" + # 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 "" + said + "" + # ...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 "" + said + "" + 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 ... 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 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 + ", 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 ": " label itself, numbering + by the order it receives the images -- so a shot that uses only + receives that image labelled , and text still saying points + at nothing. The tags are renumbered per shot to match what the shot actually + carries: slot 2 alone becomes ; slots 2 and 4 become and + . + + 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"" 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 `:` 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 tag on the sheet entry whose head noun is `head`. "" if none. + + The tag lives INSIDE the wardrobe entry -- "chastity belt " -- 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"(? 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 " 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"(? 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 , 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"(?", re.I) + + +def renumber_reference_tags(text, wired): + """Rewrite 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: 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"" + 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" 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" 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 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: ", + "Nora in a grey coat". A lowercase noun means it is a picture OF the + thing it is standing next to: "a silver locket ". + + 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 ". Everything else is the person's: a + # name, a colon, an age ("Kate is 20, 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. " 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, 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 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 , 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"\"[^\"]*\"|“[^”]*”|.*?", 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: " 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, 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 " + # 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: " -- 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, + # blonde crop top" and " 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"" 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 " 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 " -- was scrubbed + # to "wearing ". 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: " -- 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. " 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: , 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 -- 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 "" 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 tags in the prompt. + # + # So references come FIRST and keep slots 1..N, which is what a sheet line's + # `Name: , ...` 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 " + " 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) + # 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 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", " + 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 ... + 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 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. and 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 + # , 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 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 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 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 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 ' -- 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, .... " + 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 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 ... 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 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 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", 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 ", "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 . + 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" 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" 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]}: , ...' -- 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 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: , 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 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 at all, so they " + f"carry no reference. Claim it on the person it depicts -- 'Nora: " + f", 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: , 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 and this frame as , 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 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"" + 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 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"] diff --git a/js/h3_longvideos/index.js b/js/h3_longvideos/index.js index aea53aa..e33a1f7 100644 --- a/js/h3_longvideos/index.js +++ b/js/h3_longvideos/index.js @@ -1,375 +1,9 @@ import { app } from "/scripts/app.js"; -import { applyAdaptiveCanvasOnly } from "../shared/nodes2.mjs"; - -const COMFY_CLASS = "DumasH3LongVideos"; -const STATE_PROPERTY = "dumas_h3_longvideos_section_state"; -const DOM_WIDGET_NAME = "dumas_h3_longvideos_sections"; -const MIN_WIDTH = 520; -const MIN_HEIGHT = 280; - -const GROUPS = [ - { - id: "prompt", - label: "Prompt", - defaultCollapsed: false, - widgets: ["prompt", "resolution", "megapixels", "beat_split", "anchor_override", "shot_seconds", "plan_only", "fps"], - }, - { - id: "refs", - label: "Refs", - defaultCollapsed: true, - widgets: ["ref_mode", "ref_image_size", "ref_noise_aug", "character_memory", "trim_seam", "vary_seed_per_shot", "handoff_offset"], - }, - { - id: "sampling", - label: "Sampling", - defaultCollapsed: true, - widgets: [ - "steps", "cfg", "sampler_name", "scheduler", "seed", - "apply_model_sampling", "shift_video", "shift_audio", - "vram_headroom_gb", "allow_res_backoff", - "decode_tile_frames", "decode_tile_size", - ], - }, - { - id: "audio", - label: "Audio", - defaultCollapsed: true, - widgets: [ - "global_soundscape", "non_diegetic_music", "auto_soundscape", - "auto_silence_nonspeech", "allow_nonspeech_vocals", - "mute_nonspeech_audio", "mute_fade_ms", - ], - }, - { - id: "scene", - label: "Scene Logic", - defaultCollapsed: true, - widgets: [ - "auto_wardrobe", "auto_props", "prevent_nudity", "exposed_terms", - "anatomy_guard", "subject_count_guard", "lock_restraints", - "contact_guard", "motion_guard", "solidity_guard", - ], - }, - { - id: "finish", - label: "Upscale", - defaultCollapsed: true, - widgets: [ - "upscale", "upscale_model", "upscale_target_short_edge", "upscale_batch", - ], - }, - { - id: "overlay", - label: "Overlays", - defaultCollapsed: true, - widgets: [ - "watermark_text", "watermark_position", "watermark_size", "watermark_opacity", "watermark_margin", - "intro_text", "intro_position", "intro_seconds", "intro_fade", "intro_size", - "overlay_font", "overlay_stroke", - ], - }, -]; - -function injectCSS() { - if (document.getElementById("dumas-h3lv-sections-css")) return; - const style = document.createElement("style"); - style.id = "dumas-h3lv-sections-css"; - style.textContent = ` - .dh3lv-sections { - box-sizing: border-box; - width: 100%; - padding: 8px 10px 6px; - color: #e6e7eb; - font: 12px/1.35 "Segoe UI", sans-serif; - pointer-events: auto; - background: linear-gradient(180deg, rgba(33, 36, 42, 0.96), rgba(22, 24, 29, 0.96)); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - } - .dh3lv-sections-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; - } - .dh3lv-sections-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #9da5b1; - } - .dh3lv-sections-actions { - display: flex; - gap: 6px; - } - .dh3lv-sections-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .dh3lv-chip, - .dh3lv-action { - appearance: none; - border: 1px solid #464d59; - background: #262c35; - color: #d7dce3; - border-radius: 999px; - padding: 5px 9px; - cursor: pointer; - font: inherit; - line-height: 1.1; - } - .dh3lv-chip[data-open="true"] { - background: #d96f2b; - border-color: #f09358; - color: #fff7f0; - } - .dh3lv-chip:hover, - .dh3lv-action:hover { - filter: brightness(1.06); - } - .dh3lv-count { - opacity: 0.78; - margin-left: 4px; - font-size: 11px; - } - `; - document.head.appendChild(style); -} - -function defaultState() { - const state = {}; - for (const group of GROUPS) state[group.id] = !group.defaultCollapsed; - return state; -} - -function parseState(value) { - let parsed = value; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch (_error) { - parsed = null; - } - } - const base = defaultState(); - if (!parsed || typeof parsed !== "object") return base; - for (const group of GROUPS) { - if (typeof parsed[group.id] === "boolean") base[group.id] = parsed[group.id]; - } - return base; -} - -function readState(node) { - return parseState(node.properties?.[STATE_PROPERTY] || node._dh3lvSectionState || ""); -} - -function writeState(node, state) { - const normalized = parseState(state); - const serialized = JSON.stringify(normalized); - node._dh3lvSectionState = serialized; - node.properties = node.properties || {}; - node.properties[STATE_PROPERTY] = serialized; -} - -function findWidget(node, name) { - return (node.widgets || []).find((widget) => widget?.name === name) || null; -} - -function isInteractiveTarget(target) { - return !!target?.closest?.("button, input, textarea, select, label"); -} - -function stopCanvasEvent(event) { - if (isInteractiveTarget(event.target)) event.stopPropagation(); -} - -function stopCanvasKeyboard(event) { - if (isInteractiveTarget(event.target)) event.stopImmediatePropagation(); -} - -function setWidgetHidden(widget, hidden) { - if (!widget) return; - if (!widget._dh3lvOriginal) { - widget._dh3lvOriginal = { - type: widget.type, - computeSize: widget.computeSize, - hidden: widget.hidden, - }; - } - if (hidden) { - widget.type = "hidden"; - widget.hidden = true; - widget.computeSize = () => [0, -4]; - return; - } - widget.type = widget._dh3lvOriginal.type; - widget.hidden = !!widget._dh3lvOriginal.hidden; - widget.computeSize = widget._dh3lvOriginal.computeSize; -} - -function applyVisibility(node) { - const state = readState(node); - for (const group of GROUPS) { - for (const name of group.widgets) { - const widget = findWidget(node, name); - if (!widget || widget.name === DOM_WIDGET_NAME) continue; - setWidgetHidden(widget, !state[group.id]); - } - } -} - -function resizeNode(node) { - requestAnimationFrame(() => { - const size = node.computeSize?.(); - if (Array.isArray(size)) { - node.size[0] = Math.max(MIN_WIDTH, size[0] || 0, node.size?.[0] || 0); - node.size[1] = Math.max(MIN_HEIGHT, size[1] || 0); - } - node.setDirtyCanvas?.(true, true); - }); -} - -function renderToolbar(node) { - const ui = node._dh3lvUI; - if (!ui) return; - const state = readState(node); - ui.list.innerHTML = ""; - for (const group of GROUPS) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "dh3lv-chip"; - button.dataset.open = state[group.id] ? "true" : "false"; - button.textContent = state[group.id] ? `Hide ${group.label}` : `Show ${group.label}`; - - const count = document.createElement("span"); - count.className = "dh3lv-count"; - count.textContent = String(group.widgets.filter((name) => findWidget(node, name)).length); - button.appendChild(count); - - button.addEventListener("click", () => { - const next = readState(node); - next[group.id] = !next[group.id]; - writeState(node, next); - applyVisibility(node); - renderToolbar(node); - resizeNode(node); - }); - ui.list.appendChild(button); - } -} - -function setAll(node, open) { - const next = {}; - for (const group of GROUPS) next[group.id] = !!open; - writeState(node, next); - applyVisibility(node); - renderToolbar(node); - resizeNode(node); -} - -function setupNode(node) { - if (node._dh3lvUI) return; - injectCSS(); - writeState(node, readState(node)); - - const root = document.createElement("div"); - root.className = "dh3lv-sections"; - - const head = document.createElement("div"); - head.className = "dh3lv-sections-head"; - - const title = document.createElement("div"); - title.className = "dh3lv-sections-title"; - title.textContent = "Sections"; - - const actions = document.createElement("div"); - actions.className = "dh3lv-sections-actions"; - - const expandAll = document.createElement("button"); - expandAll.type = "button"; - expandAll.className = "dh3lv-action"; - expandAll.textContent = "Expand All"; - expandAll.addEventListener("click", () => setAll(node, true)); - - const collapseAll = document.createElement("button"); - collapseAll.type = "button"; - collapseAll.className = "dh3lv-action"; - collapseAll.textContent = "Collapse Extras"; - collapseAll.addEventListener("click", () => { - const next = defaultState(); - writeState(node, next); - applyVisibility(node); - renderToolbar(node); - resizeNode(node); - }); - - actions.append(expandAll, collapseAll); - head.append(title, actions); - - const list = document.createElement("div"); - list.className = "dh3lv-sections-list"; - root.append(head, list); - root.addEventListener("pointerdown", stopCanvasEvent); - root.addEventListener("mousedown", stopCanvasEvent); - root.addEventListener("click", stopCanvasEvent); - root.addEventListener("dblclick", stopCanvasEvent); - root.addEventListener("keydown", stopCanvasKeyboard, true); - - node._dh3lvUI = { root, list }; - const widget = node.addDOMWidget(DOM_WIDGET_NAME, "custom", root, { - getValue: () => null, - setValue: () => {}, - serialize: false, - getMinHeight: () => 52, - hideOnZoom: false, - }); - applyAdaptiveCanvasOnly(widget); - const widgets = node.widgets || []; - const index = widgets.indexOf(widget); - if (index > 0) { - widgets.splice(index, 1); - widgets.unshift(widget); - } - - applyVisibility(node); - renderToolbar(node); - resizeNode(node); -} +// The Dumas Long Videos node now wraps the upstream MiniMax-H3-Longvideos +// sampler directly. The old local frontend grouped Dumas-specific widgets that +// no longer exist on the upstream node, so this extension intentionally does +// nothing. app.registerExtension({ - name: "Dumas.H3LongVideosSections", - async beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== COMFY_CLASS) return; - - const originalOnNodeCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function onNodeCreated() { - const result = originalOnNodeCreated?.apply(this, arguments); - setupNode(this); - return result; - }; - - const originalConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function onConfigure() { - const result = originalConfigure?.apply(this, arguments); - setupNode(this); - writeState(this, readState(this)); - applyVisibility(this); - renderToolbar(this); - resizeNode(this); - return result; - }; - - const originalSerialize = nodeType.prototype.onSerialize; - nodeType.prototype.onSerialize = function onSerialize(o) { - writeState(this, readState(this)); - const result = originalSerialize?.apply(this, arguments); - if (o && this.properties?.[STATE_PROPERTY]) { - o.properties = o.properties || {}; - o.properties[STATE_PROPERTY] = this.properties[STATE_PROPERTY]; - } - return result; - }; - }, + name: "Dumas.H3LongVideos.UpstreamWrapper", }); diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py index b95d34f..53deab4 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -1,11 +1,10 @@ import importlib -import inspect import sys import types import unittest -class DumasH3LongVideosHelperTests(unittest.TestCase): +class DumasH3LongVideosUpstreamWrapperTests(unittest.TestCase): @classmethod def setUpClass(cls): cls._saved_modules = { @@ -15,38 +14,40 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): "nodes", "comfy", "comfy.utils", + "comfy.sample", "comfy.samplers", "comfy.nested_tensor", "comfy.model_management", + "latent_preview", "node_helpers", - "numpy", - "PIL", - "PIL.Image", "folder_paths", - "dumas_image_nodes", - "dumas_h3_latent_upscale", "dumas_h3_longvideos", + "dumas_h3_longvideos_upstream", ) } fake_torch = types.SimpleNamespace( cuda=types.SimpleNamespace(OutOfMemoryError=RuntimeError), float32="float32", + float16="float16", + bfloat16="bfloat16", + zeros=lambda *args, **kwargs: None, + empty=lambda *args, **kwargs: None, + cat=lambda *args, **kwargs: None, + stack=lambda *args, **kwargs: None, + tensor=lambda *args, **kwargs: None, + no_grad=lambda: _NullContext(), + inference_mode=lambda: _NullContext(), ) - fake_numpy = types.SimpleNamespace( - clip=lambda array, _low, _high: array, - uint8="uint8", + fake_nodes = types.SimpleNamespace( + NODE_CLASS_MAPPINGS={}, + common_ksampler=lambda *args, **kwargs: ({},), ) - fake_pil_image_module = types.SimpleNamespace(fromarray=lambda _array: None) - fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module) - fake_folder_paths = types.SimpleNamespace( - get_temp_directory=lambda: "/tmp", - get_output_directory=lambda: "/tmp", - get_save_image_path=lambda prefix, _out, _width, _height: ("/tmp", prefix, 1, "", prefix), - ) - fake_nodes = types.SimpleNamespace(common_ksampler=lambda *args, **kwargs: ({},)) fake_comfy_samplers = types.SimpleNamespace( - KSampler=types.SimpleNamespace(SAMPLERS=("res_multistep",), SCHEDULERS=("simple",)) + KSampler=types.SimpleNamespace( + SAMPLERS=("res_multistep", "euler"), + SCHEDULERS=("simple", "normal"), + ) ) fake_comfy_utils = types.SimpleNamespace(ProgressBar=lambda total: None) fake_mm = types.SimpleNamespace( @@ -60,25 +61,31 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): ) fake_comfy = types.SimpleNamespace( utils=fake_comfy_utils, + sample=types.SimpleNamespace(), samplers=fake_comfy_samplers, nested_tensor=types.SimpleNamespace(), model_management=fake_mm, ) sys.modules["torch"] = fake_torch - sys.modules["numpy"] = fake_numpy - sys.modules["PIL"] = fake_pil_module - sys.modules["PIL.Image"] = fake_pil_image_module - sys.modules["folder_paths"] = fake_folder_paths sys.modules["nodes"] = fake_nodes sys.modules["comfy"] = fake_comfy sys.modules["comfy.utils"] = fake_comfy_utils + sys.modules["comfy.sample"] = fake_comfy.sample sys.modules["comfy.samplers"] = fake_comfy_samplers sys.modules["comfy.nested_tensor"] = fake_comfy.nested_tensor sys.modules["comfy.model_management"] = fake_mm + sys.modules["latent_preview"] = types.SimpleNamespace() sys.modules["node_helpers"] = types.SimpleNamespace() + sys.modules["folder_paths"] = types.SimpleNamespace( + get_folder_paths=lambda name: [], + get_filename_list=lambda name: [], + get_full_path=lambda name, filename: None, + get_temp_directory=lambda: "/tmp", + get_output_directory=lambda: "/tmp", + models_dir="/tmp", + ) - cls.image_module = importlib.import_module("dumas_image_nodes") cls.module = importlib.import_module("dumas_h3_longvideos") @classmethod @@ -89,1611 +96,31 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): else: sys.modules[name] = module - def test_extract_wardrobe_is_cached(self): - fn = self.module.extract_wardrobe - fn.cache_clear() + def test_dumas_key_wraps_upstream_node(self): + mappings = self.module.NODE_CLASS_MAPPINGS - beat = "walks forward\nwardrobe: red jacket, grey shorts\nlooks back" - self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts")) - self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts")) - self.assertGreater(fn.cache_info().hits, 0) + self.assertIs(mappings["DumasH3LongVideos"], self.module.H3LongVideos) + self.assertIs(mappings["H3LongVideos"], self.module.H3LongVideos) + self.assertIs(mappings["H3LongVideosREF2VA"], self.module.H3LongVideos) - def test_dialogue_helpers_keep_existing_outputs_and_cache(self): - spans_cache = self.module._dialogue_spans_cached - sec_fn = self.module.dialogue_seconds - words_fn = self.module.dialogue_words + def test_upstream_schema_is_exposed_under_dumas_key(self): + node_cls = self.module.NODE_CLASS_MAPPINGS["DumasH3LongVideos"] + schema = node_cls.INPUT_TYPES() - spans_cache.cache_clear() - sec_fn.cache_clear() - words_fn.cache_clear() + self.assertIn("prompt", schema["required"]) + self.assertTrue(schema["required"]["prompt"][1]["forceInput"]) + self.assertIn("first_frame", schema["optional"]) + self.assertIn("ref_image_1", schema["optional"]) + self.assertIn("latent_upscale", schema["optional"]) + self.assertEqual(node_cls.RETURN_NAMES[0:4], ("images", "audio", "info", "script")) - beat = 'Mara says, "Open it now." Jon replies, "Do it."' - self.assertEqual(self.module.dialogue_spans(beat), [3, 2]) - self.assertEqual(words_fn(beat), 5) - self.assertAlmostEqual(sec_fn(beat), 3.5) - self.assertAlmostEqual(sec_fn(beat, pad=False), 2.5) - self.module.dialogue_spans(beat) - sec_fn(beat) - words_fn(beat) +class _NullContext: + def __enter__(self): + return None - self.assertGreater(spans_cache.cache_info().hits, 0) - self.assertGreater(sec_fn.cache_info().hits, 0) - self.assertGreater(words_fn.cache_info().hits, 0) - - def test_directive_and_estimate_helpers_are_cached(self): - directive_fn = self.module.beat_seconds_directive - estimate_fn = self.module.estimate_beat_seconds - action_fn = self.module.action_clauses - - directive_fn.cache_clear() - estimate_fn.cache_clear() - action_fn.cache_clear() - - beat = 'seconds: 7.5\nShe opens the hatch and climbs inside.' - self.assertEqual(directive_fn(beat), 7.5) - self.assertEqual(action_fn(beat), 2) - self.assertAlmostEqual(estimate_fn(beat), 7.0) - - directive_fn(beat) - action_fn(beat) - estimate_fn(beat) - - self.assertGreater(directive_fn.cache_info().hits, 0) - self.assertGreater(action_fn.cache_info().hits, 0) - self.assertGreater(estimate_fn.cache_info().hits, 0) - - def test_per_shot_directive_helpers_parse_new_controls(self): - beat = ( - "ref_mode: every shot + handoff ref\n" - "ref_noise_aug: 0.87\n" - "continuity: keyframe carry\n" - "The courier waits under the sign." - ) - - self.assertEqual( - self.module.beat_ref_mode_directive(beat), - "every shot + handoff ref", - ) - self.assertEqual(self.module.beat_ref_noise_aug_directive(beat), 0.87) - self.assertEqual( - self.module.beat_continuity_directive(beat), - "keyframe carry", - ) - - def test_expand_beats_auto_preserves_multiline_paragraph_as_one_beat(self): - beats, note = self.module.expand_beats( - ["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."], - "auto", - ) - - self.assertEqual( - beats, - ["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."], - ) - self.assertEqual(note, "") - - def test_expand_beats_legacy_blank_line_value_falls_back_to_auto(self): - beats, note = self.module.expand_beats( - ["Maya enters the room.\nShe sits at the table."], - "blank line", - ) - - self.assertEqual( - beats, - ["Maya enters the room.\nShe sits at the table."], - ) - self.assertEqual(note, "") - - def test_expand_beats_each_line_still_splits_multiline_paragraphs(self): - beats, note = self.module.expand_beats( - ["wardrobe: Maya = red jacket\nMaya enters the room.\nseconds: 8\nShe sits at the table."], - "each line", - ) - - self.assertEqual( - beats, - [ - "wardrobe: Maya = red jacket\nMaya enters the room.", - "seconds: 8\nShe sits at the table.", - ], - ) - self.assertIn("beat_split 'each line' split 1 multi-line paragraph(s) into 2 beats", note) - - def test_timing_summary_reports_retry_and_bucket_totals(self): - note = self.module._format_timing_note([ - { - "shot": 1, - "total": 12.4, - "retry_elapsed": 1.2, - "attempts": 2, - "sample": 8.0, - "latent_upscale_sample": 0.5, - "decode_video": 2.1, - "decode_audio": 0.4, - "cleanup": 0.2, - }, - { - "shot": 2, - "total": 7.6, - "retry_elapsed": 0.0, - "attempts": 1, - "sample": 6.5, - "decode_video": 0.5, - "decode_audio": 0.3, - "cleanup": 0.1, - }, - ]) - - self.assertIn("timing: 2 shot(s) total 20.0s", note) - self.assertIn("sample 14.5s", note) - self.assertIn("decode video 2.6s", note) - self.assertIn("decode audio 0.7s", note) - self.assertIn("cleanup 0.3s", note) - self.assertIn("retry elapsed 1.2s", note) - self.assertIn("latent upscale 0.5s", note) - self.assertIn("retries 1", note) - self.assertIn("slowest shot 1 12.4s", note) - - def test_latent_upscale_refines_video_but_preserves_audio(self): - class FakeTensor: - def __init__(self, name): - self.name = name - - def detach(self): - return self - - def to(self, *args, **kwargs): - return self - - class FakeNestedTensor: - def __init__(self, parts): - self._parts = tuple(parts) - self.is_nested = True - - def unbind(self): - return self._parts - - calls = [] - first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))} - second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))} - - original_common_ksampler = self.module.nodes.common_ksampler - original_build = self.module._build_shot_conditioning - original_evict = self.module._evict_all_but - original_decode_video = self.module._decode_video - original_decode_audio = self.module._decode_audio - original_cleanup = self.module._deep_cleanup - original_upscale = self.module._upscale_latent_video - original_copy_sample = self.module._copy_sample_latent - original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None) - try: - self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor - - def common_ksampler(*args, **kwargs): - calls.append((args, kwargs)) - return (first_out if len(calls) == 1 else second_out,) - - self.module.nodes.common_ksampler = common_ksampler - self.module._build_shot_conditioning = lambda *_args, **_kwargs: ( - "cond", - {"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))}, - ) - self.module._evict_all_but = lambda *_args, **_kwargs: None - self.module._upscale_latent_video = lambda video, param: (FakeTensor("upv"), 8, 16) - self.module._copy_sample_latent = lambda sampled: sampled["samples"].unbind() - self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent - self.module._decode_audio = lambda _vae, out_latent: out_latent - self.module._deep_cleanup = lambda: None - - result = self.module.H3LongVideos()._render( - model=object(), - clip=types.SimpleNamespace( - tokenize=lambda text, **kwargs: text, - encode_from_tokens_scheduled=lambda tokens: tokens, - ), - vae=object(), - audio_vae=object(), - negative="negative", - prompt="beat", - w=128, - h=64, - ln=24, - fps=24, - tiled=False, - sa=(123, 20, 1.0, "res_multistep", "simple", 1.0), - handoff=None, - latent_upscale_param={ - "mode": "model", - "model_name": "upscale.safetensors", - "device": "cpu", - "precision": "fp16", - "sampler_name": "euler_ancestral", - "scheduler": "simple", - "steps": 2, - "denoise": 0.4, - "megapixels": 1.5, - }, - ) - - self.assertEqual(len(calls), 2) - self.assertIsNot(calls[1][0][8], first_out) - self.assertEqual(calls[1][0][8]["samples"].unbind()[0].name, "upv") - self.assertEqual(calls[1][0][2], 2) - self.assertEqual(calls[1][0][4], "euler_ancestral") - self.assertEqual(calls[1][0][5], "simple") - self.assertAlmostEqual(calls[1][1]["denoise"], 0.4) - self.assertEqual(result[1], first_out) - self.assertEqual(result[2][0].name, "v2") - self.assertEqual(result[2][1].name, "a1") - self.assertEqual(result[0]["samples"].unbind()[0].name, "v2") - self.assertEqual(result[0]["samples"].unbind()[-1].name, "a1") - finally: - self.module.nodes.common_ksampler = original_common_ksampler - self.module._build_shot_conditioning = original_build - self.module._evict_all_but = original_evict - self.module._decode_video = original_decode_video - self.module._decode_audio = original_decode_audio - self.module._deep_cleanup = original_cleanup - self.module._upscale_latent_video = original_upscale - self.module._copy_sample_latent = original_copy_sample - if original_nested is None: - delattr(self.module.comfy.nested_tensor, "NestedTensor") - else: - self.module.comfy.nested_tensor.NestedTensor = original_nested - - def test_latent_upscale_decodes_audio_before_video_and_cleans_up(self): - class FakeTensor: - def __init__(self, name): - self.name = name - - def detach(self): - return self - - def to(self, *args, **kwargs): - return self - - class FakeNestedTensor: - def __init__(self, parts): - self._parts = tuple(parts) - self.is_nested = True - - def unbind(self): - return self._parts - - order = [] - build_calls = [] - first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))} - second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))} - - original_common_ksampler = self.module.nodes.common_ksampler - original_build = self.module._build_shot_conditioning - original_evict = self.module._evict_all_but - original_decode_video = self.module._decode_video - original_decode_audio = self.module._decode_audio - original_cleanup = self.module._deep_cleanup - original_upscale = self.module._upscale_latent_video - original_copy_sample = self.module._copy_sample_latent - original_unload = getattr(self.module.mm, "unload_model_and_clones", None) - original_unload_all = self.module.mm.unload_all_models - original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None) - try: - self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor - - def common_ksampler(*args, **kwargs): - order.append("latent_upscale_sample" if len(order) else "sample") - return (first_out if len([x for x in order if x.endswith("sample")]) == 1 else second_out,) - - def decode_audio(_vae, out_latent): - order.append("audio") - self.assertIs(out_latent, first_out) - return out_latent - - def decode_video(_vae, out_latent, *_args, **_kwargs): - order.append("video") - self.assertIsNot(out_latent, first_out) - self.assertIs(out_latent["samples"].unbind()[0], second_out["samples"].unbind()[0]) - return out_latent - - def cleanup(): - order.append("cleanup") - - def unload_model_and_clones(*_args, **_kwargs): - order.append("unload_h3_failed") - raise RuntimeError("model wrapper does not expose clone metadata") - - def unload_all_models(*_args, **_kwargs): - order.append("unload_all") - - def upscale_latent_video(video, param): - order.append("upscale") - return FakeTensor("upv"), 8, 16 - - def build_conditioning(*_args, **_kwargs): - build_calls.append(True) - return ( - [["cond", {}]], - {"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))}, - ) - - self.module.nodes.common_ksampler = common_ksampler - self.module._build_shot_conditioning = build_conditioning - self.module._evict_all_but = lambda *_args, **_kwargs: None - self.module.mm.unload_model_and_clones = unload_model_and_clones - self.module.mm.unload_all_models = unload_all_models - self.module._upscale_latent_video = upscale_latent_video - self.module._copy_sample_latent = lambda sampled: sampled["samples"].unbind() - self.module._decode_video = decode_video - self.module._decode_audio = decode_audio - self.module._deep_cleanup = cleanup - - self.module.H3LongVideos()._render( - model=object(), - clip=types.SimpleNamespace( - tokenize=lambda text, **kwargs: text, - encode_from_tokens_scheduled=lambda tokens: tokens, - ), - vae=object(), - audio_vae=object(), - negative="negative", - prompt="beat", - w=128, - h=64, - ln=24, - fps=24, - tiled=False, - sa=(123, 20, 1.0, "res_multistep", "simple", 1.0), - handoff=None, - latent_upscale_param={ - "mode": "model", - "model_name": "upscale.safetensors", - "device": "cuda", - "precision": "fp16", - "sampler_name": "euler_ancestral", - "scheduler": "simple", - "steps": 2, - "denoise": 0.4, - "megapixels": 1.5, - }, - ) - - self.assertEqual(order[0], "sample") - self.assertLess(order.index("unload_all"), order.index("upscale")) - self.assertLess(order.index("upscale"), order.index("latent_upscale_sample")) - self.assertLess(order.index("audio"), order.index("video")) - self.assertEqual(order[-1], "cleanup") - self.assertEqual(len(build_calls), 1) - finally: - self.module.nodes.common_ksampler = original_common_ksampler - self.module._build_shot_conditioning = original_build - self.module._evict_all_but = original_evict - self.module._decode_video = original_decode_video - self.module._decode_audio = original_decode_audio - self.module._deep_cleanup = original_cleanup - self.module._upscale_latent_video = original_upscale - self.module._copy_sample_latent = original_copy_sample - if original_unload is None: - delattr(self.module.mm, "unload_model_and_clones") - else: - self.module.mm.unload_model_and_clones = original_unload - self.module.mm.unload_all_models = original_unload_all - if original_nested is None: - delattr(self.module.comfy.nested_tensor, "NestedTensor") - else: - self.module.comfy.nested_tensor.NestedTensor = original_nested - - def test_latent_refine_tiles_do_not_rebuild_conditioning(self): - source = inspect.getsource(self.module.H3LongVideos._render) - tile_branch = source[source.index("for col_index, c0 in enumerate(cols):"):] - self.assertIn("_crop_conditioning_to_tile", tile_branch) - self.assertNotIn("_build_shot_conditioning(", tile_branch) - - def test_latent_upscale_off_skips_second_pass(self): - calls = [] - original_common_ksampler = self.module.nodes.common_ksampler - original_build = self.module._build_shot_conditioning - original_evict = self.module._evict_all_but - original_decode_video = self.module._decode_video - original_decode_audio = self.module._decode_audio - original_cleanup = self.module._deep_cleanup - original_upscale = self.module._upscale_latent_video - original_copy_sample = self.module._copy_sample_latent - try: - self.module.nodes.common_ksampler = lambda *args, **kwargs: (calls.append((args, kwargs)) or {"samples": "latent"},) - self.module._build_shot_conditioning = lambda *_args, **_kwargs: ("cond", {"samples": "base"}) - self.module._evict_all_but = lambda *_args, **_kwargs: None - self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent - self.module._decode_audio = lambda _vae, out_latent: out_latent - self.module._deep_cleanup = lambda: None - self.module._upscale_latent_video = lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("should not run")) - self.module._copy_sample_latent = lambda sampled: sampled - - self.module.H3LongVideos()._render( - model=object(), - clip=object(), - vae=object(), - audio_vae=object(), - negative="negative", - prompt="beat", - w=128, - h=64, - ln=24, - fps=24, - tiled=False, - sa=(123, 20, 1.0, "res_multistep", "simple", 1.0), - handoff=None, - latent_upscale_param={"mode": "off"}, - ) - - self.assertEqual(len(calls), 1) - finally: - self.module.nodes.common_ksampler = original_common_ksampler - self.module._build_shot_conditioning = original_build - self.module._evict_all_but = original_evict - self.module._decode_video = original_decode_video - self.module._decode_audio = original_decode_audio - self.module._deep_cleanup = original_cleanup - self.module._upscale_latent_video = original_upscale - self.module._copy_sample_latent = original_copy_sample - - def test_distribute_generations_canonicalizes_per_shot_audio_and_anchor_directives(self): - generations = self.module.distribute_generations( - "", - [ - "anchor_add: harsh sodium spill, wet asphalt reflections\n" - "soundscape: distant traffic hiss, loose sign rattle\n" - "music: low pulsing synth tension\n" - "continuity: hard cut\n" - "ref_mode: every shot\n" - "ref_noise_aug: 0.88\n" - "A courier waits under the streetlight." - ], - "global rain", - "global score", - ) - - block = generations[0] - self.assertIn("harsh sodium spill, wet asphalt reflections", block) - self.assertIn("overall_soundscape: distant traffic hiss, loose sign rattle", block) - self.assertIn("non_diegetic_music: low pulsing synth tension", block) - self.assertNotIn("\nsoundscape:", block) - self.assertNotIn("\nmusic:", block) - self.assertNotIn("\ncontinuity:", block) - self.assertNotIn("\nref_mode:", block) - self.assertNotIn("\nref_noise_aug:", block) - self.assertNotIn("\nanchor_add:", block) - - def test_has_speech_cache_respects_written_text_filter(self): - fn = self.module.has_speech - fn.cache_clear() - - written = 'She reads the sign marked "EXIT" and keeps walking.' - spoken = 'She says, "Exit now." and points to the door.' - self.assertFalse(fn(written)) - self.assertTrue(fn(spoken)) - fn(written) - fn(spoken) - self.assertGreaterEqual(fn.cache_info().hits, 2) - - def test_resolve_tagged_refs_preserves_sparse_socket_numbers(self): - refs = [ - None, - {"kind": "character", "image": "img2", "name": "Jon"}, - None, - None, - None, - None, - {"kind": "character", "image": "img7", "name": "Mara"}, - None, - {"kind": "location", "image": "img9", "name": "Watchtower"}, - ] - - text, references, dropped = self.module.resolve_tagged_refs( - "Mara turns toward Jon while watches.", - refs, - ) - - self.assertEqual( - text, - "Mara turns toward Jon while watches.", - ) - self.assertEqual( - [self.module._reference_image(ref) for ref in references], - ["img2", "img7", "img9"], - ) - self.assertEqual(dropped, []) - - def test_resolve_tagged_refs_drops_unconnected_sparse_slots(self): - refs = [ - None, - {"kind": "character", "image": "img2", "name": "Jon"}, - None, - None, - None, - None, - {"kind": "character", "image": "img7", "name": "Mara"}, - None, - None, - ] - - text, references, dropped = self.module.resolve_tagged_refs( - "Use , skip , keep .", - refs, - ) - - self.assertEqual(text, "Use , skip, keep .") - self.assertEqual( - [self.module._reference_image(ref) for ref in references], - ["img2", "img7"], - ) - self.assertEqual(dropped, [4]) - - def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "character", "image": "img2", "name": "Jon"}, - {"kind": "location", "image": "img3", "name": "Hangar"}, - ] - - text, references, dropped = self.module.resolve_prompt_refs( - "Mara and Jon argue inside .", - refs, - ) - - self.assertEqual(text, "Mara and Jon argue inside .") - self.assertEqual( - [self.module._reference_image(ref) for ref in references], - ["img3", "img1", "img2"], - ) - self.assertEqual(dropped, []) - - def test_resolve_prompt_refs_where_tagged_mode_stays_tag_only(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "location", "image": "img2", "name": "Hangar"}, - ] - - text, references, dropped = self.module.resolve_prompt_refs( - "Mara waits in the hangar near .", - refs, - include_named=False, - ) - - self.assertEqual(text, "Mara waits in the hangar near .") - self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2"]) - self.assertEqual(dropped, []) - - def test_resolve_tag_driven_prompt_refs_keeps_untagged_shot_on_handoff(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "location", "image": "img2", "name": "Hangar"}, - ] - - text, references, dropped = self.module.resolve_tag_driven_prompt_refs( - "Mara waits in the hangar.", - refs, - ) - - self.assertEqual(text, "Mara waits in the hangar.") - self.assertEqual(references, []) - self.assertEqual(dropped, []) - - def test_resolve_tag_driven_prompt_refs_keeps_named_refs_on_tagged_shot(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "location", "image": "img2", "name": "Hangar"}, - ] - - text, references, dropped = self.module.resolve_tag_driven_prompt_refs( - "Mara waits in the hangar near .", - refs, - ) - - self.assertEqual(text, "Mara waits in the hangar near .") - self.assertEqual( - [self.module._reference_image(ref) for ref in references], - ["img2", "img1"], - ) - self.assertEqual(dropped, []) - - def test_resolve_shot_references_uses_named_characters_without_picture_tags(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "character", "image": "img2", "name": "Jon"}, - {"kind": "location", "image": "img3", "name": "Hangar"}, - ] - - text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references( - "[Generation 1] Mara crosses the hangar.", - refs, - "auto ref2v", - 0, - None, - ) - - self.assertEqual(text, "[Generation 1] Mara crosses the hangar.") - self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"]) - self.assertEqual(dropped, []) - self.assertFalse(shot_tag_driven) - self.assertEqual(mode_eff, "auto ref2v") - - def test_resolve_prompt_refs_prioritizes_characters_before_locations(self): - refs = [ - {"kind": "location", "image": "img1", "name": "Hangar"}, - {"kind": "character", "image": "img2", "name": "Mara"}, - ] - - text, references, dropped = self.module.resolve_prompt_refs( - "[Generation 1] Mara waits in the hangar.", - refs, - ) - - self.assertEqual(text, "[Generation 1] Mara waits in the hangar.") - self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2", "img1"]) - self.assertEqual(dropped, []) - - def test_shot_references_uses_all_connected_sparse_slots(self): - refs = [ - None, - {"kind": "character", "image": "img2"}, - None, - {"kind": "character", "image": "img4"}, - None, - None, - {"kind": "location", "image": "img7"}, - None, - None, - ] - - for mode, shot_index in (("auto ref2v", 0), ("first shot", 0), ("every shot", 3)): - self.assertEqual( - [self.module._reference_image(ref) for ref in self.module.shot_references(refs, mode, shot_index, None)], - ["img2", "img4", "img7"], - ) - - def test_annotate_script_refs_handles_named_characters_and_locations(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "location", "image": "img2", "name": "Hangar"}, - ] - - report = self.module.annotate_script_refs( - ["Mara waits in the Hangar.", "Nobody else is here."], - refs, - ) - - self.assertIn("# shot 1 refs: Picture 1 Mara (by name); Picture 2 Hangar (by name)", report) - self.assertIn("# shot 2 refs: none", report) - - def test_annotate_script_debug_groups_each_prompt_with_its_beat_info(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara"}, - {"kind": "location", "image": "img2", "name": "Hangar"}, - ] - - report = self.module.annotate_script_debug( - ["[Generation 1] Mara waits in the Hangar.", "[Generation 2] Nobody else is here."], - [1], - "auto", - refs, - ) - - self.assertIn("Prompt 1\n[Generation 1] Mara waits in the Hangar.", report) - self.assertIn("Beat 1 info\nAnatomy guard: injected into this prompt", report) - self.assertIn("References used: Mara (matched by name); Hangar (matched by name)", report) - self.assertIn("Prompt 2\n[Generation 2] Nobody else is here.", report) - self.assertIn("Beat 2 info\nAnatomy guard: not injected for this prompt\nReferences used: none", report) - - def test_input_types_expose_nine_ref_slots(self): - optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] - - for index in range(1, 10): - self.assertIn(f"ref_{index}", optional) - - names = list(optional) - ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)] - self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9))) - - def test_input_types_do_not_expose_legacy_ref_image_aliases(self): - optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] - - for index in range(1, 10): - self.assertNotIn(f"ref_image_{index}", optional) - self.assertNotIn("per_beat_length", optional) - self.assertNotIn("cleanup_between_shots", optional) - self.assertNotIn("detail_pass", optional) - self.assertNotIn("detail_sampler_name", optional) - self.assertNotIn("detail_scheduler", optional) - self.assertNotIn("detail_steps", optional) - self.assertNotIn("detail_denoise", optional) - self.assertIn("latent_upscale_param", optional) - - def test_shot_seconds_tooltip_describes_ceiling_behavior(self): - optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] - tooltip = optional["shot_seconds"][1]["tooltip"] - - self.assertIn("GLOBAL per-shot maximum", tooltip) - self.assertIn("A beat's own `seconds:` directive can still ask for less", tooltip) - self.assertIn("let the render fail instead of shrinking it", tooltip) - - def test_resolve_shot_frames_honors_forced_request_over_budget(self): - original_estimate_shot_frames = self.module.estimate_shot_frames - try: - self.module.estimate_shot_frames = lambda *_args, **_kwargs: 73 - frames, note = self.module.resolve_shot_frames(10.0, 24, 16.0, 8.0, 1.5) - - self.assertEqual(frames, 243) - self.assertIn("honoring it", note) - finally: - self.module.estimate_shot_frames = original_estimate_shot_frames - - def test_run_defaults_match_declared_ref_widget_defaults(self): - node = self.module.H3LongVideos() - optional = node.INPUT_TYPES()["optional"] - params = inspect.signature(node.run).parameters - - self.assertEqual(params["ref_mode"].default, optional["ref_mode"][1]["default"]) - self.assertEqual(params["ref_image_size"].default, optional["ref_image_size"][1]["default"]) - self.assertEqual(params["ref_noise_aug"].default, optional["ref_noise_aug"][1]["default"]) - - def test_node_appends_per_beat_list_outputs_without_reordering_existing_slots(self): - self.assertEqual( - self.module.H3LongVideos.RETURN_NAMES[-2:], - ("beat_images", "beat_audio"), - ) - self.assertEqual( - self.module.H3LongVideos.OUTPUT_IS_LIST[-2:], - (True, True), - ) - - def test_reference_context_matches_character_names_and_location_tags(self): - refs = [ - { - "kind": "character", - "image": "img1", - "name": "Mara", - "aliases": ["Xtina"], - "description": "silver hair", - "wardrobe": "red jacket", - "general": "wears a long grey coat", - "facts": { - "gender": "female", - "age": "41", - "nationality": "English", - "occupation": "a detective", - "height_feet": "6", - "height_inches": "2", - "accent": "English", - }, - }, - {"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor"}, - ] - - context = self.module._reference_context_for_text( - "[Generation 1] Mara crosses the room toward .", - refs, - ) - - self.assertIn("Character facts for Mara:", context) - self.assertIn("also known as Xtina", context) - self.assertIn("female", context) - self.assertIn("41 years old", context) - self.assertIn("English", context) - self.assertIn("works as a detective", context) - self.assertIn("6 foot 2 tall", context) - self.assertIn("speaks with a English accent", context) - self.assertIn("Persistent appearance for Mara: silver hair.", context) - self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context) - self.assertIn("Character notes for Mara: wears a long grey coat.", context) - - def test_reference_context_matches_location_names_without_picture_tag(self): - refs = [ - {"kind": "location", "image": "img2", "name": "Hangar", "aliases": ["loading bay"], "description": "wet concrete floor", "general": "cold industrial lighting"}, - ] - - context = self.module._reference_context_for_text( - "[Generation 1] They argue in the hangar near the loading bay.", - refs, - ) - - self.assertIn("Location context for Hangar: wet concrete floor.", context) - self.assertIn("Location notes for Hangar: cold industrial lighting.", context) - - def test_reference_context_uses_resolved_picture_numbers_for_per_beat_refs(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Bill", "description": "very tall", "facts": {"age": "25"}}, - {"kind": "location", "image": "img2", "name": "Pub", "description": "warm wood bar"}, - ] - - rewritten, resolved_refs, dropped = self.module.resolve_prompt_refs( - "[Generation 1] Bill leans on .", - refs, - ) - context = self.module._reference_context_for_text( - rewritten, - refs, - resolved_refs=resolved_refs, - ) - - self.assertEqual(dropped, []) - self.assertIn("Character facts for Bill: 25 years old.", context) - self.assertIn("Persistent appearance for Bill: very tall.", context) - self.assertIn("Location context for Pub: warm wood bar.", context) - - def test_reference_context_skips_ambiguous_name_matches(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Alex", "description": "short dark hair"}, - {"kind": "character", "image": "img2", "name": "Alex", "description": "tall blond hair"}, - ] - - context = self.module._reference_context_for_text( - "[Generation 1] Alex enters the room.", - refs, - ) - - self.assertEqual(context, "") - - def test_run_uses_reference_slots_directly(self): - calls = {} - original_parse_resolution = self.module.parse_resolution - original_connected_refs = self.module._connected_refs - original_reference_character_memory = self.module._reference_character_memory - original_vram_gb = self.module.vram_gb - original_dit_resident_gb = self.module.dit_resident_gb - original_lora_overhead_gb = self.module.lora_overhead_gb - original_resolve_shot_frames = self.module.resolve_shot_frames - original_lora_active = self.module.lora_active - original_sla_pairing = self.module.sla_pairing - original_apply_h3_model_sampling = self.module.apply_h3_model_sampling - original_split_paragraphs = self.module.split_paragraphs - original_expand_beats = self.module.expand_beats - original_anchor_warnings = self.module.anchor_warnings - original_anchor_contributes_nothing = self.module.anchor_contributes_nothing - original_anchor_is_action_beat = self.module.anchor_is_action_beat - original_distribute_generations = self.module.distribute_generations - original_continuity_warnings = self.module.continuity_warnings - original_speech_flags = self.module.speech_flags - original_annotate_script_debug = self.module.annotate_script_debug - original_empty_av_latent = self.module._empty_av_latent - original_torch_zeros = getattr(self.module.torch, "zeros", None) - try: - self.module.torch.zeros = lambda shape: shape - self.module.parse_resolution = lambda _resolution: (640, 360) - self.module._connected_refs = lambda refs: [ref for ref in refs if ref is not None] - self.module._reference_character_memory = lambda refs: (calls.setdefault("refs", tuple(refs)), "")[1] - self.module.vram_gb = lambda: (0, 0) - self.module.dit_resident_gb = lambda _model: 0 - self.module.lora_overhead_gb = lambda _model: 0 - self.module.resolve_shot_frames = lambda *args, **kwargs: (53, "") - self.module.lora_active = lambda _model: False - self.module.sla_pairing = lambda *_args, **_kwargs: ("", False, "") - self.module.apply_h3_model_sampling = lambda model, *_args: (model, "") - self.module.split_paragraphs = lambda _prompt, _sep: ["Anchor.", "Beat."] - self.module.expand_beats = lambda beat_paras, _split: (list(beat_paras), "") - self.module.anchor_warnings = lambda _anchor: [] - self.module.anchor_contributes_nothing = lambda *_args, **_kwargs: False - self.module.anchor_is_action_beat = lambda *_args, **_kwargs: False - self.module.distribute_generations = lambda _anchor, beats, *_args, **_kwargs: list(beats) - self.module.continuity_warnings = lambda _gens: [] - self.module.speech_flags = lambda _beats: [] - self.module.annotate_script_debug = lambda *_args, **_kwargs: "script" - self.module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"}, 5) - - clip = types.SimpleNamespace( - tokenize=lambda text, **kwargs: text, - encode_from_tokens_scheduled=lambda tokens: tokens, - ) - - result = self.module.H3LongVideos().run( - model=object(), - clip=clip, - vae=object(), - audio_vae=object(), - prompt="Anchor only.", - resolution="16:9", - steps=6, - cfg=1, - sampler_name="res_multistep", - scheduler="simple", - seed=1, - plan_only=True, - ref_1={"image": "live-1"}, - ref_3={"image": "live-3"}, - latent_upscale_param={ - "mode": "interp", - "method": "bilinear", - "sampler_name": "euler_ancestral", - "scheduler": "simple", - "steps": 2, - "denoise": 0.2, - "megapixels": 1.5, - }, - ) - - self.assertEqual(calls["refs"][0]["image"], "live-1") - self.assertIsNone(calls["refs"][1]) - self.assertEqual(calls["refs"][2]["image"], "live-3") - self.assertEqual(result[2].count("ref2va: 2 reference image(s)"), 1) - self.assertIn("latent upscale:", result[2]) - self.assertIn("euler_ancestral/simple", result[2]) - finally: - self.module.parse_resolution = original_parse_resolution - self.module._connected_refs = original_connected_refs - self.module._reference_character_memory = original_reference_character_memory - self.module.vram_gb = original_vram_gb - self.module.dit_resident_gb = original_dit_resident_gb - self.module.lora_overhead_gb = original_lora_overhead_gb - self.module.resolve_shot_frames = original_resolve_shot_frames - self.module.lora_active = original_lora_active - self.module.sla_pairing = original_sla_pairing - self.module.apply_h3_model_sampling = original_apply_h3_model_sampling - self.module.split_paragraphs = original_split_paragraphs - self.module.expand_beats = original_expand_beats - self.module.anchor_warnings = original_anchor_warnings - self.module.anchor_contributes_nothing = original_anchor_contributes_nothing - self.module.anchor_is_action_beat = original_anchor_is_action_beat - self.module.distribute_generations = original_distribute_generations - self.module.continuity_warnings = original_continuity_warnings - self.module.speech_flags = original_speech_flags - self.module.annotate_script_debug = original_annotate_script_debug - self.module._empty_av_latent = original_empty_av_latent - if original_torch_zeros is None: - delattr(self.module.torch, "zeros") - else: - self.module.torch.zeros = original_torch_zeros - - def test_reference_context_matches_tagged_character_without_name_in_text(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"}, - ] - - context = self.module._reference_context_for_text( - "[Generation 1] walks into the room.", - refs, - ) - - self.assertIn("Persistent appearance for Mara: silver hair.", context) - self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context) - - def test_reference_context_can_skip_character_wardrobe_when_live_memory_is_explicit(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"}, - ] - - context = self.module._reference_context_for_text( - "[Generation 1] Mara walks into the room.", - refs, - include_character_wardrobe=False, - ) - - self.assertIn("Persistent appearance for Mara: silver hair.", context) - self.assertNotIn("Persistent wardrobe/style for Mara: red jacket.", context) - - def test_resolve_prompt_refs_adds_named_location_refs(self): - refs = [ - {"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor"}, - ] - - rewritten, matched, dropped = self.module.resolve_prompt_refs( - "[Generation 1] They wait in the hangar.", - refs, - ) - - self.assertEqual(rewritten, "[Generation 1] They wait in the hangar.") - self.assertEqual(dropped, []) - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0]["name"], "Hangar") - - def test_resolve_tagged_refs_drops_reference_without_image(self): - refs = [ - {"kind": "character", "image": None, "name": "Mara"}, - {"kind": "character", "image": "img2", "name": "Jon"}, - ] - - rewritten, matched, dropped = self.module.resolve_tagged_refs( - "[Generation 1] faces .", - refs, - ) - - self.assertEqual(rewritten, "[Generation 1] faces .") - self.assertEqual(dropped, [1]) - self.assertEqual(len(matched), 1) - self.assertEqual(matched[0]["name"], "Jon") - - def test_reference_context_injects_immediately_after_generation_label(self): - block = ( - "[Generation 1] Classic sitcom lighting and staging. " - "Duke walks into the room." - ) - context = "Character facts for Duke: female, 25 years old." - - result = self.module._inject_reference_context(block, context) - - self.assertEqual( - result, - "[Generation 1] Character facts for Duke: female, 25 years old. " - "Classic sitcom lighting and staging. " - "Duke walks into the room.", - ) - - def test_reference_character_memory_uses_character_wardrobe_only(self): - refs = [ - {"kind": "character", "image": "img1", "name": "Mara", "wardrobe": "red jacket, black boots"}, - {"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor", "wardrobe": "should be ignored"}, - ] - - self.assertEqual( - self.module._reference_character_memory(refs), - "Mara = red jacket, black boots", - ) - - def test_ref_mode_defaults_are_ref2v_biased(self): - optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] - - self.assertEqual(optional["ref_mode"][1]["default"], "auto ref2v") - self.assertEqual(optional["ref_noise_aug"][1]["default"], 0.95) - - def test_only_canonical_h3_long_videos_node_is_exposed(self): - self.assertEqual( - self.module.NODE_CLASS_MAPPINGS, - {"DumasH3LongVideos": self.module.H3LongVideos}, - ) - self.assertEqual( - self.module.NODE_DISPLAY_NAME_MAPPINGS, - {"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)"}, - ) - - def test_latent_upscale_params_node_is_exposed(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - required = latent.H3LatentUpscaleParams.INPUT_TYPES()["required"] - - self.assertEqual( - latent.NODE_CLASS_MAPPINGS, - {"DumasH3LatentUpscaleParams": latent.H3LatentUpscaleParams}, - ) - self.assertEqual( - latent.NODE_DISPLAY_NAME_MAPPINGS, - {"DumasH3LatentUpscaleParams": "Dumas H3 Latent Upscale Params"}, - ) - self.assertEqual(required["sampler_name"][1]["default"], "euler_ancestral") - self.assertEqual(required["scheduler"][1]["default"], "simple") - self.assertEqual(required["steps"][1]["default"], 2) - self.assertEqual(required["denoise"][1]["default"], 0.2) - self.assertEqual(required["megapixels"][1]["default"], 1.0) - self.assertEqual(required["tile_width"][1]["default"], 512) - self.assertEqual(required["tile_height"][1]["default"], 512) - self.assertEqual(required["overlap"][1]["default"], 64) - self.assertEqual(required["fade_width"][1]["default"], 32) - self.assertEqual(required["fade_height"][1]["default"], 32) - self.assertEqual(required["overlap_mode"][1]["default"], "earlier") - self.assertEqual(required["overlap_blend"][1]["default"], "linear") - self.assertEqual(required["tile_size_mode"][1]["default"], "specific_size") - self.assertEqual(required["grid_rows"][1]["default"], 2) - self.assertEqual(required["grid_cols"][1]["default"], 2) - self.assertEqual(required["spatial_w_overlap"][1]["default"], 128) - self.assertEqual(required["spatial_h_overlap"][1]["default"], 128) - self.assertEqual(required["min_tile_size"][1]["default"], 256) - self.assertEqual(required["masked_area_noise"][1]["default"], 0.0) - self.assertFalse(required["brightness_match"][1]["default"]) - self.assertEqual(required["dynamic_fade"][1]["default"], "off") - self.assertEqual(required["dynamic_fade_min"][1]["default"], 32) - self.assertEqual(required["chunk_length"][1]["default"], 85) - self.assertEqual(required["temporal_overlap"][1]["default"], 17) - self.assertFalse(required["resize_conditioning"][1]["default"]) - self.assertEqual(required["anchor_strength"][1]["default"], 0.999) - - def test_latent_upscale_mode_infers_legacy_model_payloads(self): - self.assertEqual(self.module._latent_upscale_mode({"model_name": "foo.safetensors"}), "model") - self.assertEqual(self.module._latent_upscale_mode({"method": "bilinear"}), "interp") - self.assertEqual(self.module._latent_upscale_mode({"mode": "model"}), "model") - self.assertEqual(self.module._latent_upscale_mode({}), "off") - - def test_tag_oom_stage_marks_oom_exceptions(self): - exc = RuntimeError("CUDA out of memory") - tagged = self.module._tag_oom_stage(exc, "latent_upscale") - self.assertIs(tagged, exc) - self.assertEqual(getattr(tagged, "_h3_stage", ""), "latent_upscale") - - def test_shrink_model_tile_param_reduces_tile_size(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - smaller = latent._shrink_model_tile_param({ - "tile_size_mode": "specific_size", - "tile_width": 512, - "tile_height": 512, - "overlap": 64, - "fade_width": 32, - "fade_height": 32, - }) - self.assertIsNotNone(smaller) - self.assertEqual(smaller["tile_size_mode"], "rows_cols") - self.assertEqual(smaller["grid_rows"], 4) - self.assertEqual(smaller["grid_cols"], 4) - self.assertEqual(smaller["spatial_w_overlap"], 0) - self.assertEqual(smaller["spatial_h_overlap"], 0) - self.assertEqual(smaller["fade_width"], 0) - self.assertEqual(smaller["fade_height"], 0) - self.assertEqual(smaller["min_tile_size"], 32) - - def test_shrink_model_tile_param_rows_cols_resets_overlap(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - smaller = latent._shrink_model_tile_param({ - "tile_size_mode": "rows_cols", - "grid_rows": 4, - "grid_cols": 4, - "spatial_w_overlap": 128, - "spatial_h_overlap": 128, - "fade_width": 64, - "fade_height": 64, - "min_tile_size": 256, - }) - self.assertIsNotNone(smaller) - self.assertEqual(smaller["grid_rows"], 8) - self.assertEqual(smaller["grid_cols"], 8) - self.assertEqual(smaller["spatial_w_overlap"], 0) - self.assertEqual(smaller["spatial_h_overlap"], 0) - self.assertEqual(smaller["fade_width"], 0) - self.assertEqual(smaller["fade_height"], 0) - self.assertEqual(smaller["min_tile_size"], 32) - - def test_shrink_model_tile_param_rows_cols_can_reach_thirty_two(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - smaller = latent._shrink_model_tile_param({ - "tile_size_mode": "rows_cols", - "grid_rows": 16, - "grid_cols": 16, - "spatial_w_overlap": 0, - "spatial_h_overlap": 0, - "fade_width": 0, - "fade_height": 0, - "min_tile_size": 32, - }) - self.assertIsNotNone(smaller) - self.assertEqual(smaller["grid_rows"], 32) - self.assertEqual(smaller["grid_cols"], 32) - - def test_temporal_segments_split_long_sequences(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - bounds = latent._temporal_segments(36, 85, 17) - self.assertGreater(len(bounds), 1) - self.assertEqual(bounds[0][0], 0) - self.assertEqual(bounds[-1][2], 36) - - def test_shrink_temporal_param_reduces_chunk_length(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - smaller = latent._shrink_temporal_param({ - "chunk_length": 85, - "temporal_overlap": 17, - }) - self.assertIsNotNone(smaller) - self.assertEqual(smaller["chunk_length"], 17) - self.assertEqual(smaller["temporal_overlap"], 0) - - def test_cuda_model_temporal_params_keep_splitting_saved_workflows(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - chunk_length, temporal_overlap = latent._effective_temporal_params({ - "mode": "model", - "device": "cuda", - "chunk_length": 85, - "temporal_overlap": 17, - }, frame_count=124) - self.assertEqual(chunk_length, 85) - self.assertEqual(temporal_overlap, 17) - - def test_cuda_model_temporal_params_keep_short_saved_workflows_until_oom(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - chunk_length, temporal_overlap = latent._effective_temporal_params({ - "mode": "model", - "device": "cuda", - "chunk_length": 85, - "temporal_overlap": 17, - }, frame_count=85) - self.assertEqual(chunk_length, 85) - self.assertEqual(temporal_overlap, 17) - - def test_cuda_model_oom_retries_temporal_before_spatial_fallback(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - - calls = [] - original_tiled = latent._upscale_video_model_tiled - original_shrink_model = latent._shrink_model_tile_param - try: - def tiled(_video, param): - calls.append(("tiled", param.get("chunk_length"), param.get("tile_size_mode"))) - raise RuntimeError("out of memory") - - latent._upscale_video_model_tiled = tiled - latent._shrink_model_tile_param = ( - lambda param: calls.append(("shrink_spatial", param.get("tile_size_mode"))) or None - ) - - with self.assertRaisesRegex(RuntimeError, "smaller temporal chunk"): - latent.upscale_video_model( - "video", - { - "mode": "model", - "device": "cuda", - "model_name": "upscale.safetensors", - "chunk_length": 85, - "temporal_overlap": 17, - }, - ) - - self.assertEqual(calls[0], ("tiled", 85, None)) - self.assertNotIn(("shrink_spatial", None), calls) - finally: - latent._upscale_video_model_tiled = original_tiled - latent._shrink_model_tile_param = original_shrink_model - - def test_interp_temporal_params_preserve_upstream_defaults(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - chunk_length, temporal_overlap = latent._effective_temporal_params({ - "mode": "interp", - "device": "cuda", - "chunk_length": 85, - "temporal_overlap": 17, - }) - self.assertEqual(chunk_length, 85) - self.assertEqual(temporal_overlap, 17) - - def test_unload_upscale_model_defers_while_held(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - - class FakeParam: - device = "cuda" - - class FakeModel: - def __init__(self): - self.moves = [] - - def parameters(self): - return iter((FakeParam(),)) - - def to(self, device): - self.moves.append(device) - return self - - cache_key = "upscale.safetensors::cuda::fp16" - original_cache_value = latent._MODEL_CACHE.get(cache_key) - original_hold_depth = latent._MODEL_HOLD_DEPTH - fake_model = FakeModel() - try: - latent._MODEL_CACHE[cache_key] = fake_model - latent._MODEL_HOLD_DEPTH = 0 - with latent._hold_upscale_model_loaded(): - latent.unload_upscale_model("upscale.safetensors", "cuda", "fp16") - self.assertEqual(fake_model.moves, []) - - latent.unload_upscale_model("upscale.safetensors", "cuda", "fp16") - self.assertEqual(fake_model.moves, ["cpu"]) - finally: - latent._MODEL_HOLD_DEPTH = original_hold_depth - if original_cache_value is None: - latent._MODEL_CACHE.pop(cache_key, None) - else: - latent._MODEL_CACHE[cache_key] = original_cache_value - - def test_model_upscale_releases_cached_model_after_pass(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - calls = [] - - original_temporal = latent._upscale_video_temporal_chunks - original_unload_now = latent._unload_upscale_model_now - original_cuda = latent.torch.cuda - original_device = getattr(latent.torch, "device", None) - try: - latent.torch.cuda = types.SimpleNamespace(is_available=lambda: True) - latent.torch.device = lambda value: value - - def temporal(video, param, upscaler): - calls.append(("temporal", latent._MODEL_HOLD_DEPTH)) - return "video", 8, 16 - - def unload_now(name, device, precision): - calls.append(("unload", name, device, precision, latent._MODEL_HOLD_DEPTH)) - - latent._upscale_video_temporal_chunks = temporal - latent._unload_upscale_model_now = unload_now - - result = latent.upscale_latent_video("source", { - "mode": "model", - "model_name": "upscale.safetensors", - "device": "cuda", - "precision": "fp16", - }) - - self.assertEqual(result, ("video", 8, 16)) - self.assertEqual(calls[0], ("temporal", 1)) - self.assertEqual(calls[1], ("unload", "upscale.safetensors", "cuda", "fp16", 1)) - self.assertEqual(latent._MODEL_HOLD_DEPTH, 0) - finally: - latent._upscale_video_temporal_chunks = original_temporal - latent._unload_upscale_model_now = original_unload_now - latent.torch.cuda = original_cuda - if original_device is None: - delattr(latent.torch, "device") - else: - latent.torch.device = original_device - - def test_model_upscale_oom_falls_back_to_interp(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - calls = [] - - original_temporal = latent._upscale_video_temporal_chunks - original_interp = latent.upscale_video_interp - original_unload_now = latent._unload_upscale_model_now - original_cuda = latent.torch.cuda - original_device = getattr(latent.torch, "device", None) - try: - latent.torch.cuda = types.SimpleNamespace( - is_available=lambda: True, - empty_cache=lambda: calls.append(("empty_cache",)), - ) - latent.torch.device = lambda value: value - - def temporal(_video, _param, _upscaler): - calls.append(("temporal", latent._MODEL_HOLD_DEPTH)) - raise RuntimeError("H3 latent upscale exhausted its GPU spatial fallbacks") - - def interp(video, param): - calls.append(("interp", video, param.get("mode"), param.get("method"))) - return "interp_video", 8, 16 - - def unload_now(name, device, precision): - calls.append(("unload", name, device, precision, latent._MODEL_HOLD_DEPTH)) - - latent._upscale_video_temporal_chunks = temporal - latent.upscale_video_interp = interp - latent._unload_upscale_model_now = unload_now - - result = latent.upscale_latent_video("source", { - "mode": "model", - "model_name": "upscale.safetensors", - "method": "bilinear", - "device": "cuda", - "precision": "fp16", - }) - - self.assertEqual(result, ("interp_video", 8, 16)) - self.assertEqual(calls[0], ("temporal", 1)) - self.assertEqual(calls[1], ("unload", "upscale.safetensors", "cuda", "fp16", 1)) - self.assertIn(("empty_cache",), calls) - self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear")) - self.assertEqual(latent._MODEL_HOLD_DEPTH, 0) - finally: - latent._upscale_video_temporal_chunks = original_temporal - latent.upscale_video_interp = original_interp - latent._unload_upscale_model_now = original_unload_now - latent.torch.cuda = original_cuda - if original_device is None: - delattr(latent.torch, "device") - else: - latent.torch.device = original_device - - def test_model_upscale_skips_learned_model_on_8gb_cuda(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - calls = [] - - original_temporal = latent._upscale_video_temporal_chunks - original_interp = latent.upscale_video_interp - original_cuda = latent.torch.cuda - try: - latent.torch.cuda = types.SimpleNamespace( - is_available=lambda: True, - mem_get_info=lambda: (1 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024), - empty_cache=lambda: calls.append(("empty_cache",)), - ) - - def temporal(_video, _param, _upscaler): - calls.append(("temporal",)) - raise AssertionError("learned model path should be skipped on 8GB CUDA") - - def interp(video, param): - calls.append(("interp", video, param.get("mode"), param.get("method"))) - return "interp_video", 8, 16 - - latent._upscale_video_temporal_chunks = temporal - latent.upscale_video_interp = interp - - result = latent.upscale_latent_video("source", { - "mode": "model", - "model_name": "upscale.safetensors", - "method": "bilinear", - "device": "cuda", - "precision": "fp16", - }) - - self.assertEqual(result, ("interp_video", 8, 16)) - self.assertNotIn(("temporal",), calls) - self.assertIn(("empty_cache",), calls) - self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear")) - finally: - latent._upscale_video_temporal_chunks = original_temporal - latent.upscale_video_interp = original_interp - latent.torch.cuda = original_cuda - - def test_upscale_video_model_raises_when_gpu_cannot_shrink(self): - latent = importlib.import_module("dumas_h3_latent_upscale") - - original_tiled = latent._upscale_video_model_tiled - original_shrink = latent._shrink_model_tile_param - try: - latent._shrink_model_tile_param = lambda _param: None - latent._upscale_video_model_tiled = lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("out of memory")) - - with self.assertRaisesRegex(RuntimeError, "H3 latent upscale exhausted its GPU spatial fallbacks"): - latent.upscale_video_model("video", {"device": "cuda", "precision": "fp16"}) - finally: - latent._upscale_video_model_tiled = original_tiled - latent._shrink_model_tile_param = original_shrink - - def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self): - active = self.module.parse_wardrobe( - "Maya = she, red jacket\n" - "Jon = he, navy overalls\n" - "Becca = she, green coat" - ) - - shot = self.module.compose_persistent( - "Both of them walk to the door.", - active, - "", - speaking=False, - ) - - self.assertEqual(shot, "Both of them walk to the door.") - - def test_compose_persistent_keeps_two_person_plural_binding(self): - active = self.module.parse_wardrobe( - "Maya = she, red jacket\n" - "Jon = he, navy overalls" - ) - - shot = self.module.compose_persistent( - "They walk to the door.", - active, - "", - speaking=False, - ) - - self.assertIn("Maya (red jacket)", shot) - self.assertIn("Jon (navy overalls)", shot) - self.assertIn("They walk to the door.", shot) - - def test_compose_persistent_all_three_characters_binds_full_cast(self): - active = self.module.parse_wardrobe( - "Maya = she, red jacket\n" - "Jon = he, navy overalls\n" - "Becca = she, green coat" - ) - - shot = self.module.compose_persistent( - "The three characters walk to the door.", - active, - "", - speaking=False, - ) - - self.assertIn("Maya (red jacket)", shot) - self.assertIn("Jon (navy overalls)", shot) - self.assertIn("Becca (green coat)", shot) - - def test_plan_only_returns_joined_generations_on_script_socket_with_anchor_override(self): - module = self.module - node = module.H3LongVideos() - - class _Clip: - def tokenize(self, text): - return text - - def encode_from_tokens_scheduled(self, tokens): - return tokens - - class _TorchStub: - @staticmethod - def zeros(shape): - return ("zeros", shape) - - original_torch = module.torch - original_vram_gb = module.vram_gb - original_dit_resident_gb = module.dit_resident_gb - original_lora_overhead_gb = module.lora_overhead_gb - original_check_vae_wiring = module.check_vae_wiring - original_check_text_encoder = module.check_text_encoder - original_apply_h3_model_sampling = module.apply_h3_model_sampling - original_sla_pairing = module.sla_pairing - original_lora_hint_notes = module.lora_hint_notes - original_schedule_balance_note = module.schedule_balance_note - original_kernel_backend_note = module.kernel_backend_note - original_audio_scale_note = module.audio_scale_note - original_quant_accel_note = module.quant_accel_note - original_lora_active = module.lora_active - original_resolve_shot_frames = module.resolve_shot_frames - original_plan_beat_frames = module.plan_beat_frames - original_dialogue_fit_warnings = module.dialogue_fit_warnings - original_dialogue_filler_warnings = module.dialogue_filler_warnings - original_distribute_generations = module.distribute_generations - original_continuity_warnings = module.continuity_warnings - original_empty_av_latent = module._empty_av_latent - try: - module.torch = _TorchStub() - module.vram_gb = lambda: (0.0, 0.0) - module.dit_resident_gb = lambda _model: 0.0 - module.lora_overhead_gb = lambda _model: 0.0 - module.check_vae_wiring = lambda *_args, **_kwargs: None - module.check_text_encoder = lambda *_args, **_kwargs: None - module.apply_h3_model_sampling = lambda model, *_args, **_kwargs: (model, "") - module.sla_pairing = lambda *_args, **_kwargs: ("", False, "") - module.lora_hint_notes = lambda *_args, **_kwargs: [] - module.schedule_balance_note = lambda *_args, **_kwargs: "" - module.kernel_backend_note = lambda *_args, **_kwargs: "" - module.audio_scale_note = lambda *_args, **_kwargs: "" - module.quant_accel_note = lambda *_args, **_kwargs: "" - module.lora_active = lambda _model: False - module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "") - module.plan_beat_frames = lambda beats, fps, budget: ([73] * len(beats), []) - module.dialogue_fit_warnings = lambda *_args, **_kwargs: [] - module.dialogue_filler_warnings = lambda *_args, **_kwargs: [] - module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [ - f"[Generation 1] {anchor}. {beats[0]}", - f"[Generation 2] {anchor}. {beats[1]}{module.ANATOMY_STATE}", - ] - module.continuity_warnings = lambda _gens: [] - module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"},) - - result = node.run( - model=object(), - clip=_Clip(), - vae=object(), - audio_vae=object(), - prompt="Francine stands alone.\n\nFrancine and Frankie walk together.", - resolution="16:9", - steps=20, - cfg=1.0, - sampler_name="res_multistep", - scheduler="simple", - seed=1, - anchor_override="editorial room, soft practical lighting", - character_memory="Francine = white top\nFrankie = black jacket", - plan_only=True, - ) - - self.assertIn("Prompt 1", result[3]) - self.assertIn("Beat 1 info\nAnatomy guard: not injected for this prompt\nReferences used: none", result[3]) - self.assertIn("Beat 2 info\nAnatomy guard: injected into this prompt\nReferences used: none", result[3]) - self.assertIn( - "[Generation 1] editorial room, soft practical lighting. Francine stands alone.", - result[3], - ) - self.assertIn( - "[Generation 2] editorial room, soft practical lighting. Francine and Frankie walk together.", - result[3], - ) - self.assertIn("2 beat(s)", result[2]) - self.assertIn("2 shot(s)", result[2]) - self.assertIn("ANATOMY -- guard injected on shot(s) 2", result[2]) - self.assertEqual(result[-2:], ([], [])) - finally: - module.torch = original_torch - module.vram_gb = original_vram_gb - module.dit_resident_gb = original_dit_resident_gb - module.lora_overhead_gb = original_lora_overhead_gb - module.check_vae_wiring = original_check_vae_wiring - module.check_text_encoder = original_check_text_encoder - module.apply_h3_model_sampling = original_apply_h3_model_sampling - module.sla_pairing = original_sla_pairing - module.lora_hint_notes = original_lora_hint_notes - module.schedule_balance_note = original_schedule_balance_note - module.kernel_backend_note = original_kernel_backend_note - module.audio_scale_note = original_audio_scale_note - module.quant_accel_note = original_quant_accel_note - module.lora_active = original_lora_active - module.resolve_shot_frames = original_resolve_shot_frames - module.plan_beat_frames = original_plan_beat_frames - module.dialogue_fit_warnings = original_dialogue_fit_warnings - module.dialogue_filler_warnings = original_dialogue_filler_warnings - module.distribute_generations = original_distribute_generations - module.continuity_warnings = original_continuity_warnings - module._empty_av_latent = original_empty_av_latent + def __exit__(self, *_exc): + return False if __name__ == "__main__":