Sandpies Claude Opus 5 commited on
Commit
7c2113f
·
0 Parent(s):

Hand Tie Clips 0.3.3

Browse files

ComfyUI node pack for MiniMax H3 Reference-to-Video. Chains several
generations into one continuous clip with held identity across hops.

First commit of an existing pack. Until now every edit was guarded by a
.bak sibling -- 89 of them accumulated -- which is what this replaces.
Those files stay on disk and are gitignored rather than deleted.

Contents: the four nodes, the on-node editor, the shot_plan / ref_plan
authoring format, the prompt pack for getting a local model to write
plans, two example workflows, and CLAUDE.md -- the engineering log of
what the model actually does, which is where most of the authoring rules
come from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MGjcAV8bDy93qJfmLi9kw

.gitattributes ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Every source file in this pack is written LF. Without this, a clone on
2
+ # Windows checks out CRLF and the next commit shows every line of every file
3
+ # as changed -- which would bury real diffs in a pack whose whole value is
4
+ # being able to see what changed between renders.
5
+ * text=auto eol=lf
6
+
7
+ # Keep binary-ish assets out of the text pipeline.
8
+ *.png binary
9
+ *.jpg binary
10
+ *.mp4 binary
11
+ *.safetensors binary
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+
4
+ # Every source in this pack has .bak siblings -- the reflex that stands in for
5
+ # version control here. They are working files, not releases.
6
+ *.bak
7
+ *.bak*
CLAUDE.md ADDED
@@ -0,0 +1,1020 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `ComfyUI-Hand-Tie-Clips` chains multiple MiniMax H3 Reference-to-Video generates into one longer clip. Each hop after the first is guided by the previous hop's last overlap frames + audio via stock `MiniMaxH3AddGuide` (requires a ComfyUI build with `MiniMaxH3AddGuide`, ComfyUI PR #15439). It is explicitly **not** the H3-Multishot/airlock pack; do not merge that pack's syntax in here.
8
+
9
+ Two nodes ship: `HandTieClips` (**H3 Ref2VA Chain**, the whole pipeline) and `HTCContinuityState` (legacy continuity text, superseded — see below).
10
+
11
+ It lives inside a full ComfyUI checkout (`D:\ComfyUI\custom_nodes\ComfyUI-Hand-Tie-Clips`) and imports directly from ComfyUI internals (`comfy.model_management`, `comfy.samplers`, `comfy.utils`, `comfy_extras.nodes_minimax_h3`, `comfy_extras.nodes_custom_sampler`, `comfy_extras.nodes_audio`, `nodes`, `folder_paths`). It only runs as a loaded custom node inside that ComfyUI instance — there is no standalone entrypoint, package manager, or test harness.
12
+
13
+ ## The target sampling regime
14
+
15
+ **Turbo LoRA at 4-8 steps.** That is what the node is built for and what the shipped workflow uses (8 steps, `minimax_h3_ref2v_turbo_4step`). High-step runs (14+) are diagnostic instruments to check whether an effect exists at all; **any change must be re-validated at 8 steps before it counts as working**, because at low step counts seed structure dominates and can swamp conditioning differences. Never report a 14-step result as validation on its own.
16
+
17
+ ## Development workflow
18
+
19
+ No build step, linter config, or test suite (`pyproject.toml` declares zero dependencies beyond ComfyUI). The dev loop is:
20
+
21
+ - **Python changes**: restart ComfyUI to reload the node.
22
+ - **JS changes** (anything under `js/`): hard-refresh the ComfyUI browser tab.
23
+ - Validate by loading `workflows/HandTieClips_Starter.json` and queuing a run. Two signals:
24
+ - the `[HandTieClips]`-prefixed console lines (hop count, shot plan table, reference register table, model-patch log, cache hits, overlap drop, final frame/duration summary);
25
+ - the node's third output, **`info`**, which carries the fully assembled per-hop prompts. Wire it to a Preview Text node to read exactly what each hop sent to the text encoder. The editor shows what you wrote; `info` shows what the compiler made of it, which is not the same thing.
26
+
27
+ ## Architecture
28
+
29
+ Python modules feed four nodes, plus a DOM editor in `js/`. `h3_ref_chain.py` holds `HandTieClips.run()` and the prompt-assembly helpers; `plan.py`, `directives.py`, `refs.py`, `store.py` and `tone.py` are parsers, compilers and estimators with no ComfyUI imports in their math, which is what makes them testable outside a running server. `routes.py` serves the editor its vocabulary. `preview_node.py` and `tone.py` each register a standalone side node.
30
+
31
+ ### Two independent reference channels — do not conflate them
32
+
33
+ The core invariant, and the thing most likely to be broken by a careless change:
34
+
35
+ 1. **References** (files in `<ComfyUI input>/h3_refs`, named by each ref's `file`) → bound into the tokenizer as `<Picture 1..N>` *and* into the DiT as `minimax_refs`. Identity/place stills. On `hop_script=next`, they ride **hop 1** unless a ref's `shots` lists later hops. They are never the join pin. Riding a face/outfit plate of a different room on hop 2+ opens a new Ref2VA generate (chain_00030..00034: commercial-kitchen face+outfit beat the residential pin; hop 2 dropped the apron).
36
+ 2. **The AddGuide pin** (`MiniMaxH3AddGuide.execute`, once per hop after 0) → DiT `minimax_keyframes` at t=0 only. **The text encoder never sees this pin.** The master throws the overlapped frames away after decode.
37
+
38
+ `pin_to_qwen` (`_attach_pin_to_qwen`) optionally makes the *text encoder* aware of the incoming state, since channel 2 is otherwise invisible to it: `last frame` is **`<Picture 1>`** of the previous hop's last frame (identity stills that still ride shift to Picture 2+), `pin clip` appends the overlap frames as an extra `<Video>` (~2 fps, no audio), `both` does both, `off` neither. Additive to channel 1's slots (max 9 images / 3 videos). **`last frame` is the default and stays that way.** Appending the pin after the stills made it Picture 4 against three stills (chain_00034).
39
+
40
+ **DiT pin (hop 2+):** keep the previous hop’s **sampler AV latent** and call `MiniMaxH3MotionContext` (already installed) with `context_length` matching overlap (22) and `audio_context_length=24`. That is the Multishot `context_pin` path: no pixel VAE round trip, audio end-aligned on this clip’s timeline. If the node is missing, fall back to `MiniMaxH3AddGuide` on decoded frames and log it. A cache hit no longer forces that fallback: the sampler latent is stored beside the frames and restored on a hit (see the hop store below). Hop 1 `start_image` still uses AddGuide.
41
+
42
+ **5 s is not a join-validation canvas.** Multishot: 124 f drops the airlock. `00028` 1→2 joined at 8 s. Verify after this pass is 2 × **8 s**. `join=continuous` at 5 s logs a note. A different seed on the 5 s pin-only graph did join (not a strict pass) — 777777 was join-hostile; `seed_per_shot` stays ON. Seed is not a substitute for the latent pin or an 8 s airlock budget.
43
+
44
+ ### 1. Shot plan (`plan.py`)
45
+
46
+ A plan is one JSON string in the `shot_plan` widget: an ordered list of shots, one per hop. **The shot count IS the hop count**, which removes the whole class of "3 blocks but chains=4" mismatches — when `shot_plan` is non-empty it is authoritative, `hop_script` is forced to `next`, and `chains` is ignored with a log line (`h3_ref_chain.py`, `run()`; search `shot_plan present -> hop_script=next`).
47
+
48
+ Shot fields (all optional except `beat`): `beat`, `directives`, `prose`, `seed`, `steps`, `duration`, `locked`, `id`. There is no shot-level `refs` field -- a reference activates itself through its own `shots` list in `ref_plan`, and the shot-level one was removed 2026-08-27 (parsed and printed, read by nothing, and silently dropped by the editor). Per-shot `seed`/`steps`/`duration` really are per-shot — sigmas are built per distinct step count behind `sigma_cache` and durations are validated up front so a bad value fails before any sampling.
49
+
50
+ **`refs` on a shot is parsed and printed but not consumed.** Ref activation comes only from each ref's own `shots` field in `refs.py`. The editor deliberately does not expose it, so nothing implies it works. Either wire it up or drop the field.
51
+
52
+ `prompt` is the legacy path, read only when `shot_plan` is empty (`_parse_shots`/`_expand_shots` + `hop_script=verbatim|next`).
53
+
54
+ ### 2. Directive compiler (`directives.py`) — the differentiator
55
+
56
+ `join` / `camera` / `framing` / `pace` / `tail` compile to vetted prose from `VOCAB`, so improving a phrase improves every existing plan at once. `join` is skipped on hop 0, which has nothing to join to; `tail` is the only axis with a default (`ongoing`).
57
+
58
+ **Phrasing rules — read before editing VOCAB:**
59
+
60
+ 1. **AFFIRMATIVE ONLY.** Sampling runs through `BasicGuider` at cfg 1.0 with no negative branch, so every concept named is additive and cannot be subtracted. "no cut" puts `cut` in front of the encoder. This is not a style preference — it is the load-bearing finding the whole rework was built on.
61
+ 2. **NO PRONOUNS.** The beat text owns the subject; plural "they" has been observed rendering two people from one reference.
62
+ 3. **NO ENUMERATED DETAIL.** Naming a prop or garment to control it adds it. Directives cover camera, join, framing, pace, tail only.
63
+ 4. **ONE SENTENCE PER ENTRY.** These get concatenated; long entries crowd the beat.
64
+
65
+ `check_coherence` warns (never raises) when `join=continuous` is asked for alongside a framing change with a held camera — that combination implies a cut, so the model picks one.
66
+
67
+ ### 3. Reference register (`refs.py`)
68
+
69
+ Fixes two real bugs:
70
+
71
+ - **Ordinal instability.** Core assigns `<Picture N>` 1-based in list order (`comfy/text_encoders/minimax.py:148-202`) and refs are dense-packed, so unplugging a `Load Image` silently renumbers every later one, and any prompt naming a literal ordinal then points at the wrong picture. Here each ref carries a stable `@tag` and a **filename**; ordinals are **derived per hop** and `resolve_tags` rewrites the prose.
72
+ - **Subject collapse.** Declaring every picture as a photo of `<Subject 1>` makes the model render the average of two different people. `subject` numbers group pictures per person; `retention` (`fully_preserved` / `partially_copy` / `reference`) generates the carry-over prose.
73
+
74
+ `run()` keeps a raw `slot -> tensor` map *before* `_collect_ref_images` dense-packs it, because a `@tag` is pinned to one picture. Since 2026-08-28 `slot` is **derived from the ref's position in the rail**, not authored — it is an ordering index, not a socket number. The image dict and the cited ordinals are built from the same `hop_active` list, so they cannot drift apart.
75
+
76
+ **Only subject-bearing refs are identities.** `_identity_lock` takes `identity_ordinals` and `n_subjects` from the register; without them it falls back to "every wired still is an identity", which is right when nothing better is known but wrong the moment a register is wired — it told the encoder a photograph of a kitchen had a face and hairstyle to match exactly, on every hop. `n_subjects` also drives number agreement: two photographs of one person is one identity, and plural phrasing has been observed rendering two people from one reference.
77
+
78
+ **`HTCContinuityState` was meant to be retired by this and has not been.** It is still registered, still wireable, still parsed (`_parse_state` / `_state_header`). It cannot express two people at all (one `character_id`, one `characters` key) and names people by an arbitrary id while `<Picture N>` is positional, with nothing joining the two — precisely the subject-collapse setup. Prefer `ref_plan`; the `setting_*` half is the only part `ref_plan` does not cover. `run()` warns when both define identity text, but only when a character field actually holds content — the node emits a `characters` entry for its `character_id` even when every field is blank, so the key's presence means nothing.
79
+
80
+ ### 4. Hop store (`store.py`)
81
+
82
+ Lossless FFV1 (`rgb48le`) video + a float32 `.npy` waveform + a `.latent.pt` sidecar per hop under ComfyUI's temp dir, enabled by `cache_hops`, LRU-evicted above `cache_budget_gb`. **Needs `ffmpeg` on PATH** or it raises.
83
+
84
+ **The latent sidecar is what makes the cache useful past hop 1** (added 2026-08-27). It stores this hop's sampler output so a hit can seed the *next* hop's Motion-Context pin. Without it, a hit left `prev_sampled` empty, the next hop predicted the AddGuide fallback, and because the mechanism is in the per-hop key that key no longer matched what was on disk -- so **nothing past hop 1 could ever hit, and the hop after a hit was joined by the weaker mechanism.** `cache_hops=on` was measurably worse than off. Verified in-browser: hop 1 hit, hop 2 logged `previous hop has no sampler latent (cache hit); AddGuide pixel pin`, hops 2-3 re-rendered.
85
+
86
+ The sidecar is optional in both directions: `has()` ignores it, so entries written before this change still hit (returning `latent=None` and the old fallback), and a latent that will not serialise is logged and skipped rather than failing the hop. It is `torch.save`/`torch.load(weights_only=False)` because `samples` is a `comfy.nested_tensor.NestedTensor` -- a plain Python class holding a tensor list, which `weights_only=True` refuses. Cost is ~2.9% of the entry (measured: 8.4 MB against a ~285 MB video at 0.3 MP / 243f). **Verified end to end 2026-08-27:** fresh render 186.9 s writing three sidecars, then a `cache_budget_gb` nudge re-queue at 17.97 s with all three hops logging `loaded from cache`, zero DiT loads, zero SLA passes, and the same `drift -80 ms` as the fresh run. Invalidation confirmed in the same sitting: LoRA strength 0.800 -> 1.000 produced **zero** cache hits and re-rendered every hop, so `_model_fingerprint` does see a strength change.
87
+
88
+ **The key chains**: each hop's key mixes in the previous hop's key plus a `chain_salt` of everything constant across the run — canvas, sampler, scheduler, shifts, `pin_to_qwen`, tensor digests of every wired ref / voice / reference video / start image, **and `_model_fingerprint`**, because a hop rendered under different LoRAs or a different attention path is not the same hop. The *pin mechanism* is keyed per hop rather than chain-wide (Motion-Context vs the AddGuide fallback produce different frames, and which one runs depends on whether the previous hop was a cache hit). So editing shot 1 correctly invalidates 2..N. That is correct behaviour and must be surfaced in any UI, or it reads as a bug.
89
+
90
+ **Anti-ratchet levers were dead until 2026-08-27.** `pin_renorm` and `pin_noise` both ran through `_condition_pin_latent`, which called `.std()` on `latent["samples"]` — a `comfy.nested_tensor.NestedTensor`, which has no `.std()`. Every hop logged `pin conditioning skipped (AttributeError(...))` and both widgets did nothing; the line looked like routine noise next to the other per-hop output. `NestedTensor` is a trap to write against: it *does* have `.float()`, `.cpu()` and `.shape`, but `.shape` returns `tensors[0].shape` — the video component's, silently speaking for both — so the noise draw would have been sized to the video and broadcast onto the audio.
91
+
92
+ Now conditioned **per component** via `_latent_parts` / `_rebuild_latent_samples`, with `anchor_std` a list of one sigma per stream. Per-stream is also the correct semantics, not just the working one: in a synthetic hop-3 case with video inflated 1.60x and audio 2.51x, the per-stream corrections were 0.6365 and 0.3988 — one global scale would have left the audio ~60% hot. Verified offline against the real class; not yet exercised in a render, because both levers ship off.
93
+
94
+ **Why 16-bit:** a cached hop's last frame becomes the next hop's Qwen pin and its AddGuide guide, so an 8-bit round trip would make a resumed chain diverge from an uninterrupted one — the cache would change the output, defeating the point.
95
+
96
+ A shot's `locked` flag pins it to its last render regardless of hash, resolved through `set_pointer`/`get_pointer` keyed on the shot `id` (the content key has moved by definition, so only the pointer can find it).
97
+
98
+ The master is **preallocated** — `total_frames` is known up front — and slice-written, rather than grown with `torch.cat`, which allocates a fresh full-size tensor every hop while `prev_imgs` and `imgs` are also live. Only the overlap tail stays resident between hops.
99
+
100
+ ### 5. The MODEL wire stays drawn
101
+
102
+ The speed stack is **four separate nodes in the graph**, not a widget on this one:
103
+
104
+ UNETLoader → LTX_lora_loader → H3AdaLNLoRAFix → MiniMaxLowVRAMAttention → H3SLAAttention → HandTieClips
105
+
106
+ CLIP comes off `LTX_lora_loader`'s second output, not straight from the encoder, or the text-side keys H3 LoRAs carry (`condition_proj`, `token_refiner`) are silently discarded.
107
+
108
+ A `model_patches` widget that folded all four into this node existed briefly and was **reverted by decision**: it made the graph shorter and the node harder to read, and legibility is the point. Do not re-collapse them. If it ever comes back it belongs behind the editor's advanced section, not as a bare JSON widget.
109
+
110
+ Note the wire order differs from the pre-rework graph, which ran SLA *before* low-VRAM. SLA's own documentation puts it last; that ordering was researched and is deliberate.
111
+
112
+ **`_model_fingerprint` exists because of this revert.** With the patches drawn upstream, this node cannot see their settings, so a hop cached under one LoRA stack would be served under another — silently wrong frames. The fingerprint hashes `sorted(model.patches.keys())`, the per-key `strength_patch` / `strength_model` scalars, and the scalar half of `model_options["transformer_options"]`, and that goes into `chain_salt`.
113
+
114
+ - Patch **values** are tensors and are deliberately not hashed.
115
+ - `model.patches_uuid` is *not* usable: `add_patches` assigns a fresh `uuid4()` on every call, so it would bust the cache every run.
116
+ - **Known collision:** two different LoRAs touching an identical key set at identical strengths fingerprint the same. Rare; the alternative is a full state-dict walk per run.
117
+
118
+ ### Prompt assembly
119
+
120
+ `hop_script=next` (forced under a shot plan): hop 1's block is the only full prompt; later hops get the new beat plus a live-frame citation. Register `subject_prose` stays off hop 2+ (it is a `subject_definitions` block). Unscheduled stills stay off hop 2+; `@tag` of a person still resolves to `<Subject N>`. `_assemble_next` composes `state_header` ahead of the beat when `HTCContinuityState` is wired.
121
+
122
+ **Official H3 tags and prompt methods still work here** (user 2026-08-26). This pack is a hop compiler and a join pin, not a new prompt language. Do not invent beat-keywords or pack-specific interpretation (`Silence.` as a spell, “still talking”, “says one short line”, folklore about leftover duration). Use the MiniMax H3 Ref2VA contract the encoder already knows:
123
+
124
+ - six fields in order: `subject_definitions` / `summary` / `retention_analysis` / `detailed_description` / `overall_soundscape` / `non_diegetic_music`
125
+ - labels `<Subject N>`, `<Picture N>`, `<Video N>`, `<Audio N>`
126
+ - speakers `(S1)` and dialogue `<d>[English] …</d>` (exact words; `<cutoff>` if the line overruns the hop; `<scenetrans>` if a line crosses a join)
127
+ - `[Shot N]` / `At MM:SS.mmm` inside `detailed_description`
128
+ - `overall_soundscape` = ambience + physical + non-verbal only; `N/A` only when the hop is requested silent throughout; do not put dialogue there
129
+ - `non_diegetic_music: N/A` when there is no score
130
+
131
+ `_is_full_h3_prompt` already detects `subject_definitions:` / `integrated_multimodal_description:` and routes through `_continue_prompt`. A shot-card beat may *be* that official block. Short prose on a card is a convenience path, not a dialect. When a hop is a full H3 prompt, do not wrap it in invented closer sentences that fight the official fields.
132
+
133
+ Hop 2+ carries **no identity header at all**. `_assemble_next` has no `identity_header` parameter (removed 2026-08-27 along with the dead `_extract_identity_header`/`_section_block` pair). Register `subject_prose` is a `subject_definitions` block, and putting one on hop 2+ made it a second Ref2VA generate -- `chain_00033_`.
134
+
135
+ ### 6. The editor (`js/editor/*`, `routes.py`)
136
+
137
+ The node is driven by a DOM panel, not by hand-written JSON. Five files:
138
+
139
+ | file | job |
140
+ |---|---|
141
+ | `js/h3_ref_chain_ui.js` | extension entry, the sample preview, mounts the panel |
142
+ | `js/editor/widget_utils.js` | widget hiding, the height guard, vocab fetch, DOM helpers |
143
+ | `js/editor/plan_editor.js` | Simple/Shots toggle, shot cards, JSON escape hatch |
144
+ | `js/editor/ref_rail.js` | reference register rows and subject blocks |
145
+ | `js/editor/run_panel.js` | the shot-independent dials, grouped (added 2026-08-27) |
146
+
147
+ **`shot_plan` and `ref_plan` remain the only source of truth.** The panel reads them and writes straight back, so a workflow authored in the editor and one typed by hand are the same file. Never add a parallel store.
148
+
149
+ **The run panel is the same rendering-layer contract, applied to the native widgets.** Every control writes to `widget.value` and then fires `widget.callback` — that callback is not optional decoration, it is how `control_after_generate` stays bound to `seed`. Four groups (`output`, `sampling`, `join & pin`, `cache`) in `GROUPS`; a name the build does not define is skipped, so the list can carry a widget that only exists on a newer Python side. Four things worth knowing before editing it:
150
+
151
+ - **Ownership decides hiding, not the group list.** `ownedNames()` reports the widgets the panel actually *drew*, and `applyVisibility` hides exactly those. A dial whose type the panel cannot render therefore stays visible as a native widget instead of vanishing off the node. `sync()` must run **before** `setWidgetVisibility`, or a stale build hides something the panel stopped drawing.
152
+ - **`chains` and `hop_script` are dropped in Shots mode**, with a note saying why. `run()` ignores `chains` and forces `hop_script=next` the moment a shot plan is present, so drawing them would be two controls that do nothing. The digest reads the hop count from the plan for the same reason.
153
+ - **Never read `w.type` or `w.options` directly — use `widgetType(w)` / `widgetOptions(w)`.** `hideWidget` overwrites `w.type` with `"hidden"` and swaps `w.options` for a flagged copy, stashing the originals in `w._h3Saved`. The run panel hid the twenty dials it owned on its first build; when a shot plan loaded and flipped the mode to Shots, the suppression key changed, `build()` re-ran, and every widget now reported type `"hidden"` — so `fieldFor` rejected all of them and the panel emptied itself. It only reproduces on a workflow that *arrives* in Shots mode, since a fresh node builds once and never rebuilds. An empty build also clears `builtFor`, so `sync()` retries instead of caching the failure for the life of the node.
154
+ - **Help text is `widget.options.tooltip`**, i.e. the `tooltip` from `INPUT_TYPES`. Do not retype those sentences in JS — same rule as the vocab route, same reason.
155
+
156
+ The summary line deliberately omits the seed: `control_after_generate` rewrites it after every queue without telling the panel, so a seed shown there would be wrong more often than right. That staleness is also why the panel re-reads every field when it is opened.
157
+
158
+ Two recipes here are load-bearing and both are ported from `PromptMasterLD/js/claude_prompt.js`:
159
+
160
+ - **Four-flag widget hiding.** Classic LiteGraph only needed `computeSize = [0,-4]`. Vue Nodes 2.0 filters on `options.hidden | hideInPanel | canvasOnly`; without those flags every hidden dial reappears as a raw form. Invisible in packs with three widgets, unmissable at 21. Multiline STRING widgets are real DOM textareas, so their element has to be hidden too or it floats over the panel.
161
+ - **The `_h` fixpoint height guard.** `_arrangeWidgets` runs every frame and grows the node when `panelTop + panelH + 4 > size[1]`; reporting a height derived from `node.size[1]` makes that true forever (~130 px of growth per frame). Report from an independent stored `_h`, updated in `onResize`. `chromeCompute`'s `_measuring` flag measures the frontend's own `computeSize` rather than re-deriving it.
162
+
163
+ **Dropdown options come from `routes.py`, never from a copy in JS.** `GET /h3_ref_chain/vocab` serves `directives.VOCAB` *with its prose*, so hovering an option shows the exact sentence it will put in the prompt. A second copy in JavaScript would defeat the reason `directives.py` exists. The route is read-only — no writes, no filesystem; the reference-upload route is a separate unbuilt thing.
164
+
165
+ Simple mode clears `shot_plan` (stashing it in `node.properties.h3_plan_backup` first) so `run()` cannot silently prefer a stale plan over the visible prompt.
166
+
167
+ ### 7. Tone compensation (`tone.py`)
168
+
169
+ The H3 denoiser applies a tone bias to each generated segment, so the master steps in brightness at every seam. The estimator is ported from [`rkfg/ComfyUI-MiniMaxH3-ToneCompensate`](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT, as is this pack). Three modes: `frame_shift` (per-frame per-channel additive), `gain_bias` (global affine), `lut` (tone curve). `frame_shift` is the one that suits our case, because the target's first frames are the model's *regeneration* of the source's last frames — same content, not a pixel-wise transform.
170
+
171
+ **A downstream node cannot do this job, and that is the whole reason `tone_compensate` is a widget on the chain node.** The estimate needs both copies of the overlap: hop N's tail and hop N+1's regeneration of it. The join drops the second (`master_imgs[write_pos:...] = imgs[overlap_n:]`). By the time images leave `run()`, only one copy survives, and a node there would be comparing frames ~0.9 s apart in scene time — measuring content change as much as tone. `HTCToneCompensate` ships anyway for hand-built chains and for A/B work; it is not the fix for this node.
172
+
173
+ **The call site is the design.** It sits in `run()` where the render and cache-hit paths converge, and its position relative to three neighbours is deliberate:
174
+
175
+ - **After `hop_store.put`** — the cache holds *raw* hops, so the mode stays out of the hop key. Switching modes costs nothing instead of invalidating ~285 MB per entry. Correct on the way out, hit or miss.
176
+ - **Before the master write** — the delivered video is corrected.
177
+ - **Before `prev_imgs = imgs[-tail_n:].clone()`** — so hop N+1 is measured against hop N's *corrected* tail, which is what makes each hop's shift cumulative and lands the whole chain on hop 1's tone. **This does not stop the generator drifting, and an earlier version of this note wrongly claimed it did.** Measured 2026-08-28: with tone on and all three hops rendering fresh, hop 3 was generated from a corrected `prev_imgs` and still came out +3.48/255 above raw hop 2 — so it needed a `2d` correction, not `d`. `prev_sampled`, the Motion-Context latent, dominates the conditioning and is never touched by a pixel fix. Correcting here is still right (it is free, and it keeps `prev_imgs` consistent with the master), but the benefit is a clean cumulative repaint, not a cure for the drift at source.
178
+
179
+ **Two consequences, both deliberate:**
180
+
181
+ - **Enabling any mode clamps the master to 0..1**, including hop 1, which is otherwise unclamped VAE output. Correcting hops 2+ and not hop 1 would make the master inconsistent with itself.
182
+ - **The latent path is untouched.** Motion-Context forwards `prev_sampled`, a latent; pixel correction never reaches it. `_condition_pin_latent` matches per-stream **std**, not mean — and a brightness shift is a shift in the *mean*. So nothing currently anchors latent mean. Known gap; do not build a latent-mean anchor speculatively.
183
+
184
+ **Measured, 2026-08-28 — the real number, off the hop cache** (`chain_00052`, 3 hops x 243f, overlap 22, 7 steps res_multistep, Motion-Context pin on both joins). `tools/tone_probe.py` pairs hop N's last 22 frames with hop N+1's first 22 -- the model's regeneration of the same content, which survives only in the cache:
185
+
186
+ | pair | r | g | b | luma |
187
+ |---|---|---|---|---|
188
+ | hop 1 -> 2 | +0.00995 | +0.00973 | +0.00914 | **+2.45/255** |
189
+ | hop 2 -> 3 | +0.01003 | +0.01129 | +0.01085 | **+2.73/255** |
190
+ | cumulative | +0.01998 | +0.02103 | +0.01999 | **+5.18/255** |
191
+
192
+ Three facts follow, and they are the justification for the feature. It is **achromatic** -- r/g/b move together within ~0.001, so it is a luma bias, not a colour cast. It **accumulates linearly** -- ~2.5/255 per hop with nothing pulling it back, so a 5-hop chain lands near +10/255. And it is **brighter**, the opposite sign to the upstream README's "runs darker"; likely because we pin with a Motion-Context latent and never take the decode->encode round trip his workflow does.
193
+
194
+ **Verified end to end, 2026-08-28** (seed `700637295460319`, `chain_00053` = `frame_shift`, `chain_00054` = `off`, both served from the same raw cache so content is identical):
195
+
196
+ - **The mode is not in the hop key.** The `off` run hit all three keys written by the `frame_shift` run (`c442d296`, `4854c20f`, `2e4c1e14`) — 17.5 s against 171 s, zero re-renders. Switching modes costs nothing, which is the whole reason the call site sits after `hop_store.put`.
197
+ - **Chain drift 5.57/255 -> 0.29/255**, a 95% reduction, and it stops accumulating. Applied shifts measured in the delivered video were `0`, `-1.71/255`, `-5.26/255`, matching the logged notes exactly. Segment 1 came out byte-identical, so hop 1's clamp was a no-op on this render.
198
+ - **Do not use "seam -> 0" as the success metric.** It is wrong and it will make you overcorrect. Seam steps went `+1.40 -> -0.16` and `+1.41 -> -2.06`; the second is not an overshoot, it is the scene's own darkening across the cut, which correction should leave alone. The arithmetic closes: uncorrected seam minus true tone bias predicts `-0.31` and `-2.28` against measured `-0.16` and `-2.06`. Judge on cumulative drift, via `tone_probe`.
199
+ - **Hop N needs a correction of N-1 times the per-hop bias** (hop 2 got `d`, hop 3 got `2d`), because `prev_sampled` — the Motion-Context latent the next hop is actually generated from — is never corrected. This is a pixel fix for a latent drift, so the required shift grows linearly: ~5/255 by hop 3, ~10/255 by hop 5, ~23/255 by hop 10. Since a positive drift is *subtracted*, the far-end failure is **crushed blacks** clipping at 0. Fine over the 3-5 hops this pack recommends; not a fix for long chains, and the first hard cost attached to the latent-mean gap below.
200
+ - **The per-frame drift is not flat across the overlap.** It ramps in over ~6 frames, plateaus, then dips sharply at frame 17 in *both* joins — likely a VAE temporal chunk boundary. `frame_shift` uses `drift[-1]`, which sits in that dip and under-corrects by ~0.38/255; the mean of the last 4 overlap frames would be marginally better and is the obvious tuning knob if the residual ever matters. Upstream's reason for `drift[-1]` (an exact internal join) does not apply here, because the master drops the overlap.
201
+
202
+ **All three modes measured, 2026-08-28** (seed `700637295460319`, four masters over identical cached pixels: `chain_00054` off, `00053` frame_shift, `00055` gain_bias, `00056` lut):
203
+
204
+ | mode | worst residual drift | dark-clipped px, seg 3 | notes |
205
+ |---|---|---|---|
206
+ | off | — | 0.32% | |
207
+ | `frame_shift` | +0.38/255 | **0.96%** | `rx-0.0076`, then `-0.0207` |
208
+ | `gain_bias` | **0.20/255** | 0.56% | `rx0.9854-0.0035`, then `rx0.9674-0.0071` |
209
+ | `lut` | 0.27/255 | **0.07%** | mean `0.4372->0.4284`, then `0.4520->0.4291` |
210
+
211
+ **On drift removal there is nothing to choose between them** -- all three land inside 0.4/255, below this measurement's noise. Pick on failure mode instead, and `frame_shift` wins on one argument: **every `gain_bias` slope came out below 1 and moving further away (0.985 -> 0.967), which is attenuation bias, not tone compression.** Fitting `s = A*g + C` where `g = s + delta + noise` yields `A = var(s)/(var(s)+var(noise)) < 1`, driven purely by content mismatch between the source and its regeneration. The drift is a *pure level shift* (r/g/b within 0.001), so any slope != 1 is fitting artifact, and it lands in the output as a contrast reduction that deepens along the chain. `lut` has the same defect with 64 free parameters per channel instead of one -- its dark clipping coming out *below* the uncorrected reference means it is reshaping the tone curve, not correcting level. `frame_shift` cannot make that class of error; it can only shift.
212
+
213
+ The cost is `frame_shift`'s alone: **dark clipping tripled, 0.32% -> 0.96%**, because subtracting a flat 5.26/255 pushes near-black pixels through zero. That is the crushed-blacks endgame already visible at hop 3. A gain-only mode anchored at black (`out = g * mean(src)/mean(tgt)`, no bias term) would fix it without introducing a fitted slope, and is the obvious next mode if long chains ever need one.
214
+
215
+ **Seam measurements understate it by about a third, so never use them to decide.** `tools/seam_probe.py` on the same master reads `+1.56/255` and `+1.83/255` (sum `+3.39/255`) against the cache's `+5.18/255`: the frames either side of a cut are ~0.9 s apart in scene time and the content change partly cancels the drift. Earlier estimates of `≈2/255` from `chain_00047`/`00050`/`00051` were this same floor, mistaken for the value. Seam numbers are still the right *after* instrument -- `tone_probe` reads the cache, which stores raw pre-correction hops by design -- but only as an A/B between two masters from the same seed and cache, where the contamination is identical in both and cancels. **Note `temp/` is wiped on ComfyUI start, so probe before restarting.**
216
+
217
+ Offline results (`frame_shift`, synthetic): recovers a planted additive shift to <1e-5; `gain_bias` recovers 0.92/+0.04 as 1.0870/−0.0435 exactly. A simulated 4-hop chain drifts +1.95/255 uncorrected and −0.13/255 corrected, with each hop's fitted shift staying at the planted per-hop bias rather than accumulating. That validates the mechanism, not the real-world magnitude.
218
+
219
+ ### Progress preview (Python ↔ JS)
220
+
221
+ `_push_preview` sends a `h3_refchain_preview` websocket event (via `PromptServer.instance.send_sync`) with hop status and an optional base64 JPEG of the latest frame. `js/h3_ref_chain_ui.js` listens, resolves the target node (`findNodeByQualifiedId`, subgraph-aware), and updates a DOM widget it mounts on the node. If you change the preview payload shape in Python, update the JS listener in the same change.
222
+
223
+ **Status is a short label only.** First stress run ended by passing the full `info` dump (summary + every hop prompt) as `status`, so the preview strip became the prompt. Final push is `done · Nf · Ts`. Assembled prompts stay on the `info` output (Preview Text). The JS strip also ellipsizes anything over 80 chars so this cannot happen again from a long status.
224
+
225
+ **Next (user 2026-08-26, during first stress run):** the in-node preview should become a KJNodes `Model Preview Override`–shaped panel — large “waiting for sample…” frame, idle/status strip, optional a/Δ and step-time strip — not a 260 px strip at the bottom of the chain node. **OK to split it off** as its own node / sidebar on the MODEL (or IMAGE) wire **before** CreateVideo / SaveVideo. Do not fold more chrome onto `HandTieClips`. Reference implementation: `ComfyUI-KJNodes/nodes/preview_override_node.py` + `web/js/preview_override/preview_override.js`. Not built yet; current `mountPreview` stays until this lands.
226
+
227
+ ### First 8-step stress run (user 2026-08-26)
228
+
229
+ 4 hops × 8 s, seed 777777, turbo. Shipped plan: s1 `says one short line` / s2 `still talking` + `join=continuous` `pan_follow` `keep` / s3 drink + `continuous` `push_in` `close` / s4 `match_cut` `pull_back` `wide`.
230
+
231
+ - **1→2 continuous + keep: clean.** The join directive is doing work; this is the success case.
232
+ - **2→3 continuous + push_in + close: small jump.** `check_coherence` does **not** warn here (it only flags continuous + framing change + *held* camera). The jump is still expected at 8 steps: VOCAB concatenates “carry straight on from the pinned frames” with “A close shot, head and shoulders filling the frame,” so `framing: close` asserts the destination as the opening state while AddGuide is still the medium pin from s2. Next compiler pass should compose camera-move + framing as a *landing*, not as the opening, when `join=continuous`.
233
+ - **3→4 match_cut: ok.** Control cut is distinguishable from 1→2. Do not treat all three seams as identical.
234
+ - **Speech audio outlived the mouth.** s1–s2 ask for talking; s3–s4 do not (drink / lower mug). Picture followed the later beats (mouth busy, then quiet); soundtrack kept the line going for roughly the second half of the master. This is a pack gap, not a seam bug:
235
+ 1. AddGuide pins the previous hop’s overlap **audio** at t=0 (`nodes_minimax_h3.py` crops it to remaining duration — the pin is ~0.9 s of *speech*, which seeds the rest of the hop).
236
+ 2. cfg 1.0 cannot subtract speech. Omitting “talking” from s3/s4 is a no-op.
237
+ 3. Shot-plan compilation emits no `overall_soundscape`. There is no speech/soundtrack axis on `AXES`.
238
+ 4. `tail: ongoing` plus `_assemble_next`’s closer (“that action is still underway as the clip ends”) keep whatever the pin started, including a line.
239
+ 5. s1’s “says one short line” names speech without giving the words, inside an 8 s hop. H3 invents a line and pads the leftover duration with more speech.
240
+ 6. Vague leftover time is what H3 fills. User correction (2026-08-26): **every hop in a chain must carry either enough actual dialogue or a specific mention of silence.** Omitting both is gibberish, especially on hops 2+. “Says one short line” / “still talking” is vague and under-fills an 8 s hop. “No speech” / “no dialogue” is the negation form and also gibberish. The quiet word is **silence**, named in the beat. Spoken hops put the real words in the beat, enough to occupy the duration.
241
+ - **End-of-run preview became the prompt dump.** `_push_preview(unique_id, info, …)` stuffed `info` into the status strip. Fixed to a short `done · Nf · Ts`; dump stays on the `info` output.
242
+ - **Hop-1 compile order.** ~~`tail` sits before the beat~~ **Fixed.** `compile_shot` splits `lead` (join/camera/framing/pace) from `tail` and appends `tail` last, on hop 1 and hop 2+ alike (`directives.py`, `compile_shot`). Verified 2026-08-27. Two residuals survive: ESTABLISH still prepends “Live-action, natural light, one continuous take.” on hop 1 even when the beat already opens “Live-action…”, and a hop-1 beat that *is* a full six-field H3 block returns early and drops every directive including `tail`.
243
+
244
+ **Contract (confirmed 2026-08-26, chain_00030..00032):** a complete six-field H3 / Ref2VA prompt is one generate. `[Shot 1]` is the opening of *that* generate. Hop 1 may be official. Hop 2+ must be a continuation beat only — pin-open + new action + sound. Wrapping hop 2 as another full official block (join splice, pin-open first, `keyframe completion`, “first frame of [Shot 1]”) still hard-cut at f124 onto the outfit still. `compile_shot` / `_assemble_next` flatten an official hop 2+ block to action + `overall_soundscape` + `non_diegetic_music` and drop `subject_definitions` / `summary` / `retention_analysis` / leading `[Shot 1]`.
245
+
246
+ **Join pass (user 2026-08-27, chain_00037_.mp4).** Smooth chain. Console: `2 hop(s), 192f (8.0s) @ 960x544`, `8 steps res_multistep/beta`, hop 2 `Qwen last frame -> <Picture 1>`, `0 identity stills`, `Motion-Context pin: previous hop latent (22f picture, 24f audio, trim 22)`, master **362 f / 15.1 s**. Register: all three stills `shots 1`. That is the working join recipe at 0.5 MP / 8 step: latent pin + pin-only hop 2 + 8 s airlock budget + hop-2 paragraph (no official fields). Native mask (Phase 2) is not needed for this seam.
247
+
248
+ **Next tests** (pack, not kitchen beats):
249
+
250
+ 1. **Cache fingerprint.** LoRA strength 1.0 → 0.9, re-queue. Both hops re-render. (Before the latent sidecar, a hop-1 hit made hop 2 log `AddGuide pixel pin`; that line now means the entry predates the sidecar or its latent failed to load.)
251
+ **Note ComfyUI's own node cache sits in front of this one:** re-queueing with *nothing* changed skips `run()` entirely (no `[HandTieClips]` lines at all, ~9 s), so it tests nothing. Nudge `cache_budget_gb` to force re-execution -- it is in neither `chain_salt` nor the hop key, so every key stays byte-identical.
252
+ 2. **Chained re-roll.** Revert strength. Change one word in hop 2 only. Hop 1 cache hit, hop 2 renders.
253
+ 3. Confirm the same join at **1.0 MP** (`1280×736`) before calling it shipped. Keep `seed_per_shot`. 777777 was join-hostile on the old 5 s pin-only graph; do not treat that seed as the only one.
254
+
255
+ **chain_00030_.mp4 / chain_00031_.mp4 / the keyframe-completion re-queue.** Hard cut at hop 2, f124, onto the outfit still’s commercial kitchen. Drink beat ran; pin did not. Official hop 2 cannot join at 8 steps.
256
+
257
+ **chain_00033_.mp4.** Short hop-2 drink beat, flatten did not fire (card was already short). Still hard-cut. Two findings: (1) `_assemble_next` still prepended register `subject_prose` (`subject_definitions:` + `retention_analysis:`), so hop 2 was a Ref2VA generate again. (2) Console: `8 steps euler/simple`, not `res_multistep/beta`. `tail: settle` also led the compiled beat, so hop 2 opened on “eases to a rest.” Header stripped; tail moved after the beat.
258
+
259
+ **chain_00034_.mp4.** Header-stripped hop 2, `8 steps res_multistep/beta`. Still hard-cut; apron gone on hop 2. Console: `Qwen last frame -> <Picture 4>`, `3 identity stills`. Face still (`h3_stress_hero_face.jpg`) is the same commercial kitchen as the outfit plate, grey shirt, **no apron in the crop**. Outfit still has the apron. Hop 2 followed the face plate (place + wardrobe), not the pin. Stills without `shots` stay off hop 2+ continue; live frame is Picture 1.
260
+
261
+ **H3 soundtrack (official methods, user 2026-08-26):** do not invent a pack dialect for quiet vs speech. Dialogue belongs in `detailed_description` as `(S1)` + `<d>[English] …</d>` with the actual words. Ambience/physical/non-verbal belong in `overall_soundscape`. Requested silence throughout a hop is `overall_soundscape: N/A` (the official complete-silence token), not the English word “Silence” stuffed into a beat. “No speech” / “no dialogue” is still negation and still gibberish. `_assemble_next` currently does not emit these fields; when the beat already contains them, leave them alone.
262
+
263
+ **Iteration canvas (user 2026-08-26):** further tests run at **0.5 MP** (`960×544` landscape) for speed. 8 steps, seed 777777, 4×8 s, overlap 0.9 s stay. Seam times do not move (still master frames 192 / 362 / 532). Resolution is in `chain_salt`, so the 1.0 MP cache will not hit. A 0.5 MP pass validates join / speech / cache behaviour, not 1.0 MP texture. Confirm anything that ships at 1.0 MP (`1280×736`) on that canvas before calling it done.
264
+
265
+ **chain_00028_.mp4 (0.5 MP, 960×544, 702 f / 29.25 s, 8 step).** Same stress plan. Frame-walked.
266
+
267
+ - 1→2 at f192: continuous. Then **an inside-hop cut at f220–228 (~9.2–9.5 s, ~1.3 s after the join)** — over-shoulder + mug → frontal talking head, mug leaves frame. Same class as the old 1.8 s inside-hop cut. Shot 2’s `still talking` beat beat `pan_follow` + the walk.
268
+ - 2→3 at f362: join itself is continuous (window, mug out). Close-up lands later inside hop 3 (~f432–456) on the drink, not at the seam.
269
+ - 3→4 at f532: still the drink CU. `match_cut` + `wide` does **not** cut at the seam; pull-back to wide is ~f576–624. Control cut is late / soft.
270
+ - Soundtrack is speech-level for almost the whole clip (integrated ~−11 LUFS). Brief dip at the 1→2 join; no quiet second half. Mouth can drink (f456–504) while the track keeps talking.
271
+ - Mug survives hops 1, 3, 4; missing during the hop-2 talking-head. Identity holds at 0.5 MP (cross necklace from locked text rendered).
272
+ - A/V: audio 29.131 s vs video 29.250 s (~119 ms short, ~40 ms × 3 hops).
273
+
274
+ **chain_00029_.mp4 (0.5 MP, explicit beats + silence).** Same 702 f / 29.25 s.
275
+
276
+ - Soundtrack: wall-to-wall speech is gone. Hop 1 line-burst ~4.4–7.3 s (mouth open at f120). **Random line at 15.41–16.71 s** (user-confirmed): 0.33 s after the 2→3 seam, 1.3 s of speech inside hop 3’s *kept* audio — the 0.9 s pin was already trimmed, so this is hop 3 inventing a line, not hop-2 leak. Hop 3 beat names Silence once, then “a swallow”, then `_assemble_next` still *ends* on “that action is still underway as the clip ends.” One silence mention in the middle of the prompt does not occupy an 8 s hop. Later hops also have a late spike (~23 s).
277
+ - Mug path: chest height on the walk; lifts to the mouth only on hop 3; no reach to the glass. Opening frame still has the mug **on the counter** because the kitchen still itself shows it there (`h3_stress_kitchen.jpg`) — the photograph is additive, not only the desc.
278
+ - 1→2 at f192: continuous, mug at chest. Camera swings toward the face ~f216 (mug stays). **Not** the 00028 talking-head cut.
279
+ - Place break inside hop 2, f240→f270 (~10.0–11.25 s): residential window kitchen → the **outfit still’s commercial kitchen** (stainless, SANITIZER buckets, range). `h3_stress_hero_outfit.jpg` is that room. Kitchen ref is `retention: reference` (weak); outfit is a subject-bearing still of a *different set*. Walking off the pin lets the outfit photograph’s room take over. Hops 3–4 stay there. 2→3 join is continuous *in the wrong room*. 3→4 is again a delayed pull-back, not a match_cut at f532.
280
+
281
+ ## Writing beats (verified against live renders, 2026-08-27)
282
+
283
+ The first end-to-end runs of v1.1 produced three failures that all trace to the
284
+ same root: **sampling runs at cfg 1.0 with no negative branch, so the prompt is
285
+ purely additive.** Nothing can be subtracted by naming it. Each was diagnosed
286
+ from the assembled prompt and fixed by rewriting a beat, not by changing code.
287
+
288
+ **1. A reference is only used where the action line points.**
289
+ `retention_analysis` described `<Picture 3>` correctly ("the layout, surfaces,
290
+ and light carry over as the setting") and the model still took its background
291
+ from `<Picture 2>`, a full-length person still that carries its own room. The
292
+ register is a static header; the beat is what drives the frame. Writing the tag
293
+ into the action line fixed it:
294
+
295
+ > The cook stands at the counter in **@kitchen**, looks up from the chopping board...
296
+
297
+ Phrase the tag as a depicted place, not a container. `in @kitchen` alone once
298
+ produced a literal composite -- the figure pasted onto the reference photo with
299
+ the counter cutting through the body.
300
+
301
+ **2. Naming the thing you want to end adds it.**
302
+ `The cook stops talking, ...` kept her talking. "stops talking" and "talking"
303
+ condition on nearly the same thing. Describe the wanted state as a pose plus a
304
+ sound: `with her lips closed` gives the video branch something renderable, and
305
+ naming room tone ("the hum of the refrigerator") gives the *audio* branch a
306
+ target that is not a voice. H3 always generates audio for the full hop -- silence
307
+ has to be described as a sound, never as an absence. This is the same finding as
308
+ the "No speech / no dialogue is negation" bullet below, reached independently.
309
+
310
+ **4. `tail` now reaches hop 2+ (fixed 2026-08-27).**
311
+ `_assemble_next`'s closer used to end unconditionally on *"that action is still
312
+ underway as the clip ends"*, and being the last sentence it overrode the shot's
313
+ own `tail` directive -- `settle` and `hold` were unreachable on every hop after
314
+ the first. On the final hop nothing absorbs that instruction, so the model
315
+ invented late action to satisfy it: a line of dialogue in the last second of a
316
+ 3-hop chain, and the ~23 s spike noted further down this file. The closer now
317
+ selects its terminal clause from `tail`, and every variant still ends on the
318
+ clip's terminal *state* rather than on the photographs, preserving the ordering
319
+ rule documented at the `return`. An absent or unrecognised `tail` keeps the old
320
+ "still underway" wording.
321
+
322
+ **5. `overall_soundscape` is hop-1 only.**
323
+ `_assemble_next` strips `^(overall_soundscape|non_diegetic_music):` on hop 2+,
324
+ leaving the value inline as prose, because official field names there made the
325
+ model start a new Ref2VA generate (`chain_00030..00034`). So ambience on
326
+ continuation hops is prose-only and is tuned by word choice. Choose narrowband,
327
+ specific sources: "faint street noise through the window" is broadband and
328
+ renders as a 5-second hiss; "the low hum of the refrigerator" does not.
329
+
330
+ **3. A state change belongs at the END of the previous hop.**
331
+ Affirmative phrasing alone did not stop the dialogue. Hop 2 was configured
332
+ `tail=ongoing` with the beat `...turns toward the window, still talking`, and
333
+ `_assemble_next` opens every hop 2+ with *"The clip opens already in progress
334
+ from the pinned frames... holds for a short beat... and only then the next
335
+ action begins."* With `audio_pin_frames=24`, the pin hop 3 inherits is literally
336
+ mid-sentence. No wording in hop 3 can undo a boundary defined as mid-speech.
337
+
338
+ The fix was in **shot 2**, not shot 3:
339
+
340
+ > The cook sets the knife down, turns toward the window, and **finishes her last word** as she looks out.
341
+
342
+ Generalises: to be silent/still/elsewhere in hop N, arrive at that state before
343
+ hop N-1 ends. Hop N opens by holding what it was handed.
344
+
345
+ ## Known constraints (don't "fix" without reading this)
346
+ - **The join pin is a latent slice, not a VAE round trip** (corrected 2026-08-27). Hop 2+ passes the previous hop's sampler AV latent to `MiniMaxH3MotionContext` as `context_latent`, which never decodes and end-aligns the audio window. Core's `MiniMaxH3AddGuide` does take `IMAGE`, not `LATENT` (`nodes_minimax_h3.py:177`), so it remains the *fallback* -- used when Motion-Context is absent, the overlap has no matching `context_length`, or the cached entry has no latent sidecar (pre-2026-08-27 entries only; the latent is stored now). Do not "simplify" back to AddGuide-only: it is what `chain_00037_`/`chain_00038_` were pinned against.
347
+ - Overlap is fixed per-run to 22 f / 5 f / 39 f. A longer overlap does not fix continuity and can instead pin whatever content is in that longer tail — "increase overlap" is not a general fix for join artifacts.
348
+ - Each join hard-cuts video but crossfades audio ~40 ms, accumulating ~40 ms/hop of A/V desync. Known and deliberately deferred.
349
+ - `ref_image_size="max"` (2048 short-edge) is slower per step than `"match"` — an explicit tradeoff, not a default to silently upgrade.
350
+ - Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
351
+ - Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
352
+
353
+ ## 8. References are files, not wires (2026-08-28)
354
+
355
+ The node had **16 sockets** and twelve of them were user media: `ref_image_1..9`,
356
+ `reference_video`, `voice`, `start_image`. They occupied roughly 340px down the
357
+ left of the node before the editor started, each needing its own `Load Image`.
358
+ They are gone. The node now has **five**: `model`, `clip`, `vae`, `audio_vae`,
359
+ `continuity_state`.
360
+
361
+ A reference names a **file** under `<ComfyUI input>/h3_refs`. `media.py` owns
362
+ both halves of that:
363
+
364
+ - **`resolve(name)` is the only thing that turns a name into a path**, and it is
365
+ used by the upload route and the loaders alike. Basename only, `normpath`,
366
+ then a prefix check against the reference directory, then an extension
367
+ whitelist. Verified against absolute paths, `../` traversal, and traversal
368
+ carrying a legal extension — all refused.
369
+ - **The loaders return exactly what the sockets delivered** — float `[N,H,W,3]`
370
+ in 0..1 — so `_ref_frames`' resize, `_collect_ref_images`' dense-pack and
371
+ `store.tensor_digest`'s cache keying are all unchanged. That is why this was a
372
+ small diff rather than a rewrite.
373
+
374
+ **Pixels never enter a widget.** Only the basename is stored. PromptMasterLD
375
+ measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
376
+ failed to save the workflow at all; previews are `/view?...&subfolder=h3_refs`
377
+ URLs, which cost nothing to rebuild and survive a reload. `/view` also brings
378
+ Range support, which a `<video>` needs to seek — so no thumbnail route was
379
+ needed.
380
+
381
+ **`IS_CHANGED` is new and load-bearing.** ComfyUI caches a node's output on its
382
+ inputs, and a filename is a stable input even when the bytes behind it change:
383
+ overwrite `face.png` and the previous render would be served. It hashes
384
+ `name:mtime` for every referenced file. Deliberately **not** `float("nan")` —
385
+ that is the blunt version PromptMasterLD's studio node uses, and it would force
386
+ a full re-render of an expensive node on every queue.
387
+
388
+ **Widget order is part of the saved-workflow format.** `widgets_values` is a
389
+ **positional array** -- ComfyUI restores `value[i]` into `widget[i]` and never
390
+ looks at the name. The three `*_file` widgets were first added at the *top* of
391
+ `optional`, which shifted `hop_script`..`tone_compensate` by +3; every workflow
392
+ saved before that change then loaded `audio_pin_frames`' integer into `ref_plan`
393
+ and the editor died with `(text || "").trim is not a function` on load. That
394
+ throw was the lucky part -- `hop_script`, `shot_plan` and `tone_compensate` were
395
+ being silently misassigned too, and only the type mismatch made any of it
396
+ visible.
397
+
398
+ **New widgets go at the bottom of `optional`, always.** Old workflows are then
399
+ *short* rather than *misaligned*, and the new widget takes its default. There is
400
+ an append-only marker comment in `INPUT_TYPES` saying so. Note `seed` costs
401
+ **two** array slots, not one: the frontend appends `control_after_generate`
402
+ right after it.
403
+
404
+ Both plan parsers now coerce (`String(text ?? "")`) and reject non-object JSON
405
+ instead of dereferencing it, so a future misalignment shows an empty JSON tab
406
+ rather than aborting the whole workflow load.
407
+
408
+ **The hop cache needed no change at all.** `chain_salt` already digests the
409
+ actual tensors, so different pixels behind the same filename still move the key.
410
+
411
+ **Legacy plans cannot be migrated automatically.** An old `ref_image_N` held a
412
+ tensor from a `Load Image`; there is no filename to recover. `refs.py` keeps the
413
+ authored slot as `legacy_slot`, `parse_ref_plan` does **not** raise on it, and
414
+ both the rail row and `check()` say *"was wired to ref_image_3 — pick its
415
+ picture"*. Failing the parse would have stopped the editor opening the very plan
416
+ the author needs to repair.
417
+
418
+ ## 9. Presentation (2026-08-28)
419
+
420
+ **The node collapsed on first click and stayed collapsed.** Two causes, both in
421
+ `installHeightGuard` (`js/editor/widget_utils.js`), both ported from
422
+ `PromptMasterLD/js/claude_prompt.js:6285-6370`:
423
+
424
+ - `computeLayoutSize().minWidth` reported a **constant**. The layout pass
425
+ re-reads it on every recompute — selecting the node is enough — and
426
+ faithfully re-declares the node at its stated minimum. It now tracks
427
+ `node.size[0]`. Safe against feedback: `max()` against a constant is a
428
+ fixpoint, not an accumulator, and width never feeds height.
429
+ - A stale `widget.width` shadows `node.width` forever after load, because
430
+ ComfyUI's DOM-widget position updater reads `(widget.width ?? node.width)`. A
431
+ live getter with a dropped setter makes the stale value unrepresentable.
432
+
433
+ Two more fixes alongside: `node.computeSize()[0]` is floored at the panel width
434
+ (with no widget declaring one, LiteGraph falls back to `NODE_WIDTH * 1.5 = 210`
435
+ and every resize command is free to crush the panel), and `domWidget.computeSize`
436
+ now answers a **width-passing** caller with the minimum height and a
437
+ **no-argument** caller with the live height. Reporting the live height to both
438
+ pins the resize-drag floor to the current height, so the node could only ever
439
+ grow.
440
+
441
+ **`chrome()`'s memo key was stale by construction.** It keyed on
442
+ `inputs|outputs|widgets.length`, none of which change when a widget is *hidden*
443
+ — so every panel height computed after `applyVisibility` used a chrome
444
+ measurement taken before it. The hidden count is now part of the key.
445
+
446
+ **The reference rail was permanently crushed, and its rescue was dead code.**
447
+ The 7-track grid needs ~536px and the node offers ~510px at `NODE_WIDTH 560`.
448
+ There was a `@container (max-width: 460px)` block written to relieve it — but
449
+ **nothing in the codebase declared `container-type`**, so the query had no
450
+ containment context and never matched. `.h3e-section` now declares
451
+ `container-type: inline-size`, and there are two breakpoints.
452
+
453
+ **The palette committed to one look.** It used to derive surfaces from the host
454
+ theme via `color-mix()` while hardcoding every accent — the intent was
455
+ light-theme safety, but `--h3-bg` mixed toward `#111827` and `--h3-sunken`
456
+ toward `#000`, so a light theme got dark blue-grey islands anyway. It is now the
457
+ PromptMasterLD `.ldp-root` system: `#0a0a0a`, one hairline `#2a2a2a`, zero
458
+ radius, one accent `#e8ff47`. **Changing `--h3-accent` moves the whole panel.**
459
+ Emphasis **inverts** (accent fill, `--h3-on-accent` ink) rather than tinting,
460
+ because an acid accent at 30% behind unchanged text is olive mud.
461
+
462
+ A cascade trap worth remembering: the override block was first inserted *before*
463
+ the reference-rail section, so `.h3e-chip-on`, `.h3e-inactive .h3e-ord` and
464
+ `.h3e-subj-badge` all kept winning on source order. Overrides live at the end of
465
+ the sheet now. Without a browser, a token audit script is the only thing that
466
+ catches this class of bug.
467
+
468
+ **The panel did not fill the node, and the mirror was why.** `installHeightGuard`
469
+ kept an independent `_h`, updated through an `onResize` hook, so that the
470
+ arrange pass could not feed its own growth. Measured during a drag: `onResize`
471
+ and `setSize` each fired 57 times while `_h` sat at 876 and `node.size[1]`
472
+ climbed past 1400. `measuring` was not stuck, which left one gate --
473
+ `Array.isArray(size)`. **This frontend's `node.size` is not a plain Array**, so
474
+ every write was skipped and `_h` held the install-time height forever. A 1911px
475
+ node had a 742px panel.
476
+
477
+ The repair was to delete the mirror, not fix the hook. `_h` existed only to
478
+ break the arrange loop, and
479
+
480
+ panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK) // SLACK 8
481
+
482
+ breaks it outright: the pass wants `panelTop + panelHeight + 4`, and `panelTop`
483
+ is chrome minus the node's bottom padding, so a panel of exactly `size - chrome`
484
+ asks for up to 4px more than the node has on every frame -- the ~130px/frame
485
+ runaway the header comment records. With slack the inequality holds, the loop
486
+ settles, and the node's height simply *is* the panel's height. Nothing to keep
487
+ in sync, no hook to get wrong. `sync()` now only ever grows a too-short node;
488
+ the height is the user's to choose.
489
+
490
+ **RUN is pinned and always open.** `.h3e-root` used to be the scroll container
491
+ with all four sections inside it, which put RUN below the script -- out of view
492
+ on any workflow with more than two shots, and it is the section touched on every
493
+ queue. The root is now a flex column holding `.h3e-scroll` (the authoring
494
+ sections) and RUN outside it.
495
+
496
+ Two flex details, both of which cost a round trip to learn:
497
+
498
+ - **`.h3e-scroll` needs `min-height: 0`.** A flex item defaults to
499
+ `min-height: auto` and refuses to shrink below its content, which pushes RUN
500
+ off the bottom of the node instead of scrolling.
501
+ - **`.h3e-run` must be `flex: 0 0 auto`, never `0 1 auto`.** Flex divides a
502
+ deficit in proportion to each item's content height. The scroller's content is
503
+ far taller, so a shrinkable RUN loses most of the contest and clips its lower
504
+ groups. The scroller absorbs all the shrinking; its `min-height: 160px` is the
505
+ floor that stops RUN owning the panel and RUN's `max-height: 55%` is the
506
+ ceiling.
507
+
508
+ Always-open removed the only moment RUN re-read its widgets, so it now resyncs
509
+ on `api`'s `promptQueued` -- the client-side event that fires after
510
+ `control_after_generate` has bumped the seed, and the same one ComfyUI's own
511
+ change tracker uses. The listener is dropped in `node.onRemoved`.
512
+
513
+ **Not done:** PromptMasterLD's `--fsc` UI-scale multiplier, which makes every
514
+ dimension `calc(Npx * var(--fsc))` and puts a zoom slider on the panel. It is
515
+ mechanical churn across every rule in the sheet and was not worth doing blind.
516
+
517
+ ## 10. Ported for public use (2026-08-29)
518
+
519
+ The prompting craft was written down and the pack was made installable by a
520
+ stranger. Four things, in the order they mattered.
521
+
522
+ **The shipped example workflows were broken.** All four in `workflows/` predated
523
+ the 2026-08-28 socket removal: twelve dead media inputs each, three `LoadImage`
524
+ nodes, and 21 widget values against a 28-widget node. Loading one is a new
525
+ user's *first* action, so this outranked any amount of documentation. They are
526
+ quarantined in `_disabled_custom_nodes/h3_legacy_workflows/` (the pack is not
527
+ under its own version control -- deleting would have been unrecoverable) and
528
+ replaced by two built from the verified `H3_Stress_6x7` structure:
529
+
530
+ - **`HandTieClips_Starter.json`** -- two hops, **no references at all**, runs the
531
+ moment the loaders are pointed at files. The empty register is deliberate: an
532
+ `@tag` whose picture is missing is a *hard* error in `resolve_tags`, so a
533
+ starter that shipped with tags would fail on first queue for everyone.
534
+ - **`HandTieClips_Showcase.json`** -- the six-hop continuity test, with the three
535
+ reference filenames generalised.
536
+
537
+ Both use **core ComfyUI plus this pack only**. The dev workflow reaches KJNodes
538
+ (`ModelPreviewOverrideKJ`, `MiniMaxLowVRAMAttention`) and PlagueKind
539
+ (`LTX_lora_loader`, `H3SLAAttention`, `H3AdaLNLoRAFix`); an example that fails
540
+ to load because of a pack the reader never asked for teaches nothing. The
541
+ speed stack is documented, not shipped.
542
+
543
+ **A declared-but-inactive `@tag` reported the wrong cause.** `check()` already
544
+ warned correctly that a picture was missing, but the run then died on
545
+ `resolve_tags` with *"unknown reference '@kitchen'"* -- pointing at the beat's
546
+ spelling, the one thing that was right. `resolve_tags` now takes `declared`
547
+ (every tag in the register, active this hop or not) and separates the two
548
+ failures. The old two-argument behaviour is unchanged when `declared` is
549
+ omitted.
550
+
551
+ **`PROMPTING.md` and `prompt_pack/`.** The craft rules were spread through
552
+ README prose; they are now a standalone guide, and a copy-paste system prompt
553
+ that gets a language model to emit valid plans. Two files under `prompt_pack/`
554
+ are **generated, never hand-written**:
555
+
556
+ - `tools/gen_schema.py` builds `SCHEMA.json` from `directives.VOCAB`,
557
+ `refs.RETENTION` and the duration table, and **asserts** against
558
+ `plan._SHOT_KEYS`, `refs.REF_FIELDS` and `refs.SUBJECT_FIELDS`. Add a camera
559
+ move and the schema follows; add a shot field and the generator fails loudly
560
+ rather than emitting a stale schema. `--check` is the CI form.
561
+ - `tools/gen_example.py` builds `EXAMPLE_6_HOP.md` from the showcase workflow,
562
+ including its hop/reference table, so the worked example and the shipped
563
+ workflow cannot disagree.
564
+
565
+ **Template patterns in the editor.** `js/editor/templates.js` plus a
566
+ **Templates** button in the SCRIPT header. They **append**, never replace --
567
+ replacing would be the one destructive control on the node, and stacking is how
568
+ a chain is actually built. `freeId()` mints the lowest unused `sN` because `id`
569
+ is the hop cache's pointer and two shots sharing one would make `locked` reuse
570
+ the wrong render.
571
+
572
+ No template contains an `@tag`, for the same reason the starter workflow has no
573
+ references. `tools/check_templates.py` extracts the patterns *out of the JS*
574
+ and runs them through `plan.parse_plan` and `plan.check_coherence`, and lints
575
+ every beat for negation -- a template that produced a plan the node rejects
576
+ would be worse than no templates, because a first-time author would blame their
577
+ own writing. It caught two: a beat reading "They stop at the window" (naming a
578
+ cessation, which is law 2) and a line of dialogue containing "did not".
579
+
580
+ That second one is worth recording as an open question: **whether the additive
581
+ prompt bites inside quoted dialogue was never tested.** The templates avoid it,
582
+ and `PROMPTING.md` says plainly that this is untested rather than inventing a
583
+ rule.
584
+
585
+ ## 11. What chain_00057 taught (2026-08-29)
586
+
587
+ The six-hop showcase was rendered for the first time. Three failures, and the
588
+ one that mattered was a code gap the documentation had already claimed was
589
+ fixed.
590
+
591
+ **`locked` and `context` reached hop 1 only.** The register's whole promise is
592
+ that a subject's continuity text carries identity across a hop where the
593
+ photograph is absent. It did not. `subject_prose` is called under `if i == 0`,
594
+ and `_identity_lock` returns `""` when no subject-bearing ref is active -- so on
595
+ the showcase, hops 2, 3, 5 and 6 carried **no identity text of any kind**, and
596
+ hop 5, scheduled with no references at all, lost the character entirely.
597
+ Identity was riding on the pinned frames alone, which is the exact failure the
598
+ register exists to prevent.
599
+
600
+ `refs.continuity_line()` now emits that text on every hop 2+, and
601
+ `_assemble_next` injects it between the lock and the live-frame citation. The
602
+ reason it was suppressed in the first place is real and is preserved: naming
603
+ *pictures* on a pin-only hop sent the encoder back to the plates (chain_00034 --
604
+ commercial kitchen, grey shirt, no apron). So the new line carries **no
605
+ `<Picture N>` and no `<Subject N>`**. It is a description of what stays the
606
+ same, not a citation of anything. `<Subject N>` is excluded specifically because
607
+ there is no `subject_definitions` block on a continuation hop to bind it to.
608
+
609
+ Note this changes the assembled block on hop 2+ of every chain with a register,
610
+ so it invalidates the hop cache. That is correct, not a regression.
611
+
612
+ **Dialogue propagated through five hops.** Shot 1 ended on its spoken line with
613
+ `tail: ongoing`. The audio pin carries the previous hop's tail, so the last
614
+ second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
615
+ the closing instruction; the model satisfied it with the action it could hear.
616
+ Nothing in hops 2-6 gave the audio anywhere else to go.
617
+
618
+ This is the guide's own law 3, broken by the plan written to demonstrate it. The
619
+ fix is authorial, not code: land the line **mid-hop** and leave a non-verbal
620
+ action running into the seam (the knife on the board), and give every
621
+ dialogue-free hop a narrowband sound of its own. Both `PROMPTING.md` and the
622
+ authoring prompt now carry this as a named rule, and the shipped templates were
623
+ rewritten to obey it.
624
+
625
+ **`join: continuous` across a location change morphed one room into the other.**
626
+ Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
627
+ continuous join asks for one unbroken take between two different rooms, which
628
+ is not a thing. `match_cut` is what a walk through a doorway is. The beat also
629
+ read "steps into @kitchen" -- the container phrasing the README warns produces a
630
+ composite of the photograph -- and now names the counter *in* the kitchen.
631
+
632
+ Hop 5's beat is unchanged in the revised plan **on purpose**: it is the
633
+ measurement, and changing it would forfeit the comparison.
634
+
635
+ The revised plan is in the shipped showcase and in
636
+ `user/default/workflows/H3_Stress_6x7_v2.json`; the original that produced
637
+ chain_00057 is left alone so the A/B survives.
638
+
639
+
640
+ ## 12. Renamed to Hand Tie Clips (2026-08-29)
641
+
642
+ The pack was `ComfyUI-H3-Ref-Chain`. It is now `ComfyUI-Hand-Tie-Clips`, and the
643
+ four registered ids moved with it:
644
+
645
+ | was | is |
646
+ |---|---|
647
+ | `H3RefChain` | `HandTieClips` |
648
+ | `H3ContinuityState` | `HTCContinuityState` |
649
+ | `H3ChainPreview` | `HTCChainPreview` |
650
+ | `H3ToneCompensate` | `HTCToneCompensate` |
651
+
652
+ Display names are unchanged (`H3 Ref2VA Chain`, `H3 Chain Preview`, ...) because
653
+ the pack only drives MiniMax H3 checkpoints and a name that hides that costs
654
+ somebody an afternoon. The menu category is `Hand Tie Clips`. `TAG` -- and so the
655
+ console prefix -- is `[HandTieClips]`.
656
+
657
+ **The old ids are still registered, as `DEPRECATED` subclasses.** A type id is
658
+ what every saved `.json` carries, and an unregistered one is a red missing-node
659
+ box, not a warning. A plain alias in `NODE_CLASS_MAPPINGS` would have worked but
660
+ listed each node twice in search: ComfyUI falls back to the mapping key when
661
+ `NODE_DISPLAY_NAME_MAPPINGS` has no entry for it. Subclassing and setting
662
+ `DEPRECATED = True` gets both -- `server.py:783` publishes `deprecated: True`,
663
+ and the frontend's `Comfy.Node.ShowDeprecated` (off by default) keeps it out of
664
+ search while leaving it fully functional in workflows that name it.
665
+
666
+ **The JS had to learn both ids or the aliases would have been worse than
667
+ useless.** `js/h3_ref_chain_ui.js` and `js/h3_chain_preview.js` each compared
668
+ `nodeData.name` against a single string; a legacy node would have loaded with no
669
+ editor at all, which looks exactly like the rename having broken the pack. Both
670
+ now test membership of a `Set`. `js/h3_chain_preview.js` needs it twice: once
671
+ for its own type, once for `CHAIN_TYPES`, which is how a preview walks back up
672
+ `images` to find the chain feeding it.
673
+
674
+ **What deliberately kept the `h3` naming**, and must not be "finished" later:
675
+ module filenames (`h3_ref_chain.py`), the `h3e-` CSS class prefix and `--h3-`
676
+ tokens (515 occurrences -- one missed class silently breaks styling), the
677
+ `/h3_ref_chain/*` routes, the `h3_refchain_preview` event name, and
678
+ `input/h3_refs`. Renaming that folder would orphan every reference photo already
679
+ on disk.
680
+
681
+ The two shipped workflows moved to `HandTieClips_Starter.json` /
682
+ `HandTieClips_Showcase.json` and were rewritten onto the new ids, with their
683
+ `SaveVideo` prefix now `video/HANDTIECLIPS/chain`. The three under
684
+ `user/default/workflows/` were left on the legacy ids on purpose: re-running
685
+ `H3_Stress_6x7_v2.json` is then a live test of the alias path, and their existing
686
+ renders stay together under `output/video/H3REFCHAIN/`.
687
+
688
+
689
+ ## 13. The on-canvas board (2026-08-29)
690
+
691
+ `workflows/HandTieClips_Starter.json` carries six `MarkdownNote` cards to the
692
+ left of the loaders, wrapped in a group titled READ ME. The text lives in
693
+ `tools/notes.py` and is written into the workflow by `tools/build_notes.py`.
694
+
695
+ Why on the canvas: the craft was in `PROMPTING.md`, `prompt_pack/` and the
696
+ Templates panel, and all three require leaving the graph. The rules that decide
697
+ whether a first render works are needed while beats are being written, which is
698
+ on the canvas. The cards are a **condensation**, not a copy -- `PROMPTING.md`
699
+ stays the authority and every card says so.
700
+
701
+ Mechanics worth not rediscovering:
702
+
703
+ - `MarkdownNote` is a core virtual node. The frontend renders it with `marked`
704
+ at `gfm: true` and sanitises with DOMPurify, so headings, GFM tables, bold and
705
+ code fences all work. It needs frontend >= ~1.16.
706
+ - Cards are identified by `properties.htc_card`, which is what makes
707
+ `build_notes.py` idempotent -- it drops marked nodes before writing, so
708
+ re-running replaces the board instead of stacking a second copy.
709
+ - `extra.ds` is **restored** on load, not fitted. Without setting it, a board at
710
+ negative x sits off-screen and is never found. Screen is
711
+ `(world + offset) * scale`, so the offset is what brings it into view.
712
+ - Group serialisation is `{id?, title, bounding:[x,y,w,h], color?, font_size?,
713
+ locked?}` -- confirmed against the frontend's own zod schema, not guessed.
714
+
715
+ **`widgets_values_named`, found while doing this.** Both shipped workflows
716
+ carried a stale copy: `chains: 3`, `duration: 10 s`,
717
+ `control_after_generate: randomize`, and a legacy `ref_plan` naming pictures that
718
+ do not ship -- 25 entries against a 28-widget node. It came from the builder
719
+ deep-copying nodes out of the dev workflow and overwriting only
720
+ `widgets_values`. Dormant while `Comfy.Workflow.NamedValuesRestore` stays off
721
+ (experimental, default false), but anyone who turned that on would have loaded a
722
+ Starter that randomizes its seed and dies on a missing reference.
723
+ `build_notes.py` strips it from both, and `tools/check_workflows.py` now fails if
724
+ it ever comes back.
725
+
726
+ `tools/check_workflows.py` is the promoted version of the validator that guarded
727
+ the shipped workflows against socket/widget drift. It also checks the board:
728
+ every card marked and non-empty, exactly one group, the group enclosing every
729
+ card, and no card reaching past x=0 onto the loaders.
730
+
731
+
732
+ ## 14. The turbo stack ships in the examples (2026-08-29)
733
+
734
+ Both shipped workflows now carry the dev graph's full MODEL wire:
735
+
736
+ UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
737
+ -> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips
738
+
739
+ and **CLIP reaches the chain from the LoRA loader, not the encoder** -- that is
740
+ what makes the text half of every LoRA land, and it is the wire most likely to
741
+ get quietly "fixed" back to the encoder by someone tidying the graph.
742
+
743
+ This reverses the earlier "core ComfyUI and this pack only" rule for the
744
+ examples, at the user's direction: `steps` is 7, which only works with a turbo
745
+ LoRA, so an example without the LoRA stack is not a graph anyone can run at the
746
+ settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
747
+ KJNodes supplies Low VRAM Attention and the preview override.
748
+
749
+ `tools/build_speed_stack.py` inserts and rewires the five nodes idempotently
750
+ (marker `properties.htc_speed`), rebuilding the plain loader -> chain shape
751
+ first so it is reentrant from either state. `tools/check_workflows.py` walks
752
+ **both** wires link by link -- a patch node that is present but bypassed round
753
+ the side looks right on the canvas and does nothing.
754
+
755
+ `H3SLAAttention` widget values are written out in full including
756
+ `reference_protection`, which post-dates the dev workflow's saved values. That
757
+ workflow has 10 entries against an 11-widget node, which is the positional rule
758
+ working as intended: the new widget was appended last, so old values still line
759
+ up and the missing one falls back to its default. The trailing `""` on the LoRA
760
+ loader and the preview override is carried over verbatim for the same reason --
761
+ a value past the last widget is ignored, and dropping one that turns out to
762
+ belong to a widget would shift every value after it.
763
+
764
+ `head_chunks` ships at 4 (the node's own default, safer on unknown VRAM) rather
765
+ than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
766
+ loader names, and `taeh3.safetensors` for the preview override's `tiny_vae`.
767
+
768
+ ## 15. What a shipped diagnosis looked like (2026-08-29)
769
+
770
+ The editor UI vanished after the folder rename -- raw widget boxes, no panel.
771
+ Not a code fault. ComfyUI had been restarted while the pack was still
772
+ `ComfyUI-H3-Ref-Chain`, so the running process held
773
+ `python_module: custom_nodes.ComfyUI-H3-Ref-Chain` and served its web assets
774
+ from a path the move had deleted. `/object_info` had all eight node types;
775
+ `/extensions` listed none of the pack's JS and a direct fetch 404'd.
776
+
777
+ Worth knowing for next time: `/object_info` carries `python_module`, which is
778
+ the fastest way to find out **which copy of a pack a running server actually
779
+ loaded**, and `/extensions` plus a direct fetch of one script separates "the JS
780
+ is broken" from "the JS is not being served at all".
781
+
782
+ ## 16. What the Rain Kitchen renders taught (2026-08-29)
783
+
784
+ Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
785
+ three findings are now in `PROMPTING.md`, the troubleshooting table, the
786
+ authoring prompt and the on-canvas board.
787
+
788
+ **Identity drift is permanent, and the old advice was backwards.** This file and
789
+ `PROMPTING.md` used to say a face plate riding a later hop *beats the pin* and
790
+ that `shots` should therefore be kept tight. chain_00059 falsified it: hop 4
791
+ carried a face plate photographed in a *different kitchen* through a walking
792
+ medium shot and held cleanly, while hop 5 — scheduled with no references at
793
+ all — came back a different person, and hop 6 never recovered even though a
794
+ place plate rode it and restored the room instantly. `locked` holds a face that
795
+ is still right; only a plate rebuilds one that is gone. **Face refs go on every
796
+ hop.** The old advice survives for *place* plates only.
797
+
798
+ **A beat must survive an over-delivered hop.** A hop routinely does more than it
799
+ was asked. Shot 3 asked for "a first slow step along the counter" and delivered
800
+ the whole walk; shot 4 was then handed an instruction its own live frame had
801
+ already satisfied, and the only way to obey was to reset the scene — a hard
802
+ cut 1.5 s *into* the hop, not at the seam, because `_assemble_next` holds the
803
+ incoming frames for a short beat first. Give one hop the whole movement, and
804
+ write the next beat true from either ending ("she *reaches* the window", "she
805
+ takes up the bowl *again*").
806
+
807
+ **A noun with no adjective drifts.** Three hops said only "the bowl"; it came
808
+ back stainless steel. Naming it "the white bowl" everywhere *and* stating it in
809
+ `context` as a property (never a location — "stays in her hands" fights the
810
+ beat the moment she puts it down) held it for six hops. The re-run then proved
811
+ the rule by accident: `context` read "the apron stays tied over the **grey**
812
+ t-shirt", and the t-shirt held all six hops while the apron, one clause away
813
+ with no colour, had turned denim blue by hop 6.
814
+
815
+ ### The code change this bought
816
+
817
+ `refs.resolve_tags` now takes `subject_names` and the node passes it on
818
+ continuation hops only. `<Subject N>` is bound by `subject_definitions:`, which
819
+ is hop-1 material — so on hop 4 the ordinal dangled, exactly the same defect
820
+ as the undescribed bowl. From hop 2 a person tag resolves to the subject's
821
+ `name` instead ("The cook walks down the hallway"), which binds to the
822
+ `continuity_line` sentence every continuation hop already carries. No name means
823
+ the old `<Subject N>` fallback, so nothing regresses.
824
+
825
+ `tools/check_prompts.py` mirrors the new call, and gained the banned-word check
826
+ that previously ran only against `templates.js`. That gap is how the shipped
827
+ Showcase carried "**None** of the kitchen is visible" (law 1 — it *adds* a
828
+ kitchen at cfg 1.0) and "She **stops** at the window" (law 2) through six
829
+ versions. Both are fixed, and the Showcase's face ref now rides all six hops.
830
+
831
+ ## 17. The pre-beta audit (2026-08-29)
832
+
833
+ Read for what a stranger hits, not for what we already check. Six findings; the
834
+ first two would each have cost a tester a run.
835
+
836
+ **The docs promised a stop the code never did.** `refs.check()` is *"warn, never
837
+ raise"*, and its caller only printed. A ref naming a file that is not in
838
+ `h3_refs` had its slot skipped and the chain rendered on — all six hops, with
839
+ the reference silently inactive, which is precisely the uncontrolled output the
840
+ register exists to prevent. Four shipped surfaces said otherwise, including the
841
+ Showcase's own note: *"the run stops and names the reference it could not find.
842
+ Nothing guesses."*
843
+
844
+ Fixed in the code rather than the docs, because there is no reading under which
845
+ rendering without a named-but-absent picture is what the author meant. New
846
+ `refs.missing_files()` returns `(tag, file)` for every ref whose named file did
847
+ not load; `h3_ref_chain.py` raises on it, after printing the register table so
848
+ the error arrives with its context. **The distinction that matters:** a ref with
849
+ *no* `file` stays a warning — the Starter ships that way on purpose so it runs
850
+ before any pictures exist. Only a *named* file that is absent is fatal.
851
+
852
+ **The on-canvas board never got §16's corrections.** `tools/notes.py` still
853
+ carried the row chain_00059 falsified — *"a plate riding a hop it does not
854
+ belong on, beating the pin → tighten `shots`"* — and was missing all three rows
855
+ `PROMPTING.md` gained. The board is what a beginner actually reads, so it was
856
+ shipping the inverse of the rule. Lesson: `PROMPTING.md` is the authority, but
857
+ `tools/notes.py` is a **second copy** of the same craft, and a correction is not
858
+ finished until both move. `build_notes.py` regenerates the Starter's cards *and*
859
+ the Showcase's `Note` from `notes.SHOWCASE_NOTE`, so one rebuild covers both.
860
+
861
+ The rest were packaging, not craft: the MIT `LICENSE` named no copyright holder;
862
+ `pyproject.toml` lacks both fields a Registry publish needs (`PublisherId` and a
863
+ `[project.urls] Repository`), now commented in place; the `README` had **no
864
+ install section at all**; `ComfyUI-H3-Motion-Context` was absent from Needs
865
+ despite the intro calling it the primary guidance path, so testers land on the
866
+ `MiniMaxH3AddGuide` fallback without knowing they changed code paths; and the
867
+ example workflows name quantised checkpoints by filename with no note that they
868
+ are one valid set among many, which reads as a broken graph rather than a
869
+ missing file.
870
+
871
+ Clean on the same pass, worth not re-checking: no personal data anywhere, no
872
+ hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
873
+ syntax, no third-party dependencies, Motion-Context absence handled with fork
874
+ detection, `WEB_DIRECTORY` present, `h3_refs` auto-created.
875
+
876
+ ## 18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)
877
+
878
+ Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The `ref_plan` came
879
+ back structurally perfect -- both subjects named, face plate on all eight hops,
880
+ places scheduled off the hops they do not belong on, zero register warnings. The
881
+ `shot_plan` carried four defects, and tracing each one back to
882
+ `prompt_pack/AUTHORING_PROMPT.md` found more than four gaps.
883
+
884
+ **The prompt taught one of the bugs.** Rule 9 illustrated a beat as
885
+ ``"stands at the counter in `@kitchen`"`` -- backticks and all, because it was
886
+ the one place a tag appeared inside a beat. The model copied the formatting, and
887
+ literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
888
+ prose.
889
+
890
+ **Beat length had no guidance at all.** The only budget in the file was for
891
+ dialogue, so a no-dialogue plan had nothing to size against and every beat came
892
+ back at 22-28 words regardless of a 15 s hop. There is now a word table -- and
893
+ an honest note on it: the shipped plans run 37-39 words at *both* 5 s and 7 s,
894
+ so beat length is near-constant across the only two hop lengths ever rendered.
895
+ The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned
896
+ extrapolation, not a measurement, and the table says so.
897
+
898
+ **Two rules were simply missing.** Nothing told the model to state a visual style
899
+ -- for a "2D anime, Ufotable" concept neither returned block contained the word
900
+ anime, so the text asked for photoreal while the pictures asked for anime. And
901
+ the over-delivery rule from section 16 had never been carried into the prompt at
902
+ all, only into `PROMPTING.md` and the board.
903
+
904
+ **Two statements were wrong.** The prompt said "at most 9 pictures on any one
905
+ hop"; `parse_ref_plan` counts `len(refs)` over the **whole plan**. The code's own
906
+ error message said "on one hop" too, and has been corrected to match what it
907
+ checks. And the `file` bullet still told the model to invent placeholder
908
+ filenames without noting that, as of 0.3.1, a named file absent from `h3_refs`
909
+ **stops the run**.
910
+
911
+ Law 2 is the interesting non-fix. It is stated plainly, with the exact example
912
+ "The cook stops talking", and the model wrote "stops and looks up" anyway. A
913
+ principle is not something a 27B reliably applies to its own output; the literal
914
+ word list `check_prompts.py` enforces now appears in the prompt, because a word
915
+ list is checkable and a principle is not.
916
+
917
+ ## 19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)
918
+
919
+ Three sources landed together: a rendered 8 x 15 s anime chain (`chain_00003`,
920
+ 2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
921
+ written to trip specific rules. Shipped as **0.3.3**.
922
+
923
+ ### The node was fighting every stylised plan
924
+
925
+ `directives.py` prepended `ESTABLISH = "Live-action, natural light, one
926
+ continuous take."` to hop 1 **unconditionally**. At cfg 1.0 with no negative
927
+ branch that is additive, and it landed *ahead* of the style declaration rule 12
928
+ requires. A stop-motion puppet plan compiled to "Live-action, natural light,
929
+ one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
930
+ painted wood", and the two fought.
931
+
932
+ It also explains the anime chain's opening: hop 1 rendered as bright
933
+ naturalistic daylight (**mean luma 72**) against a night plan *and* a night
934
+ place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That
935
+ had been read as drift; it was the node.
936
+
937
+ Never surfaced because both shipped workflows are live-action.
938
+
939
+ Fixed twice over, because either alone leaves a hole: an `establish` widget
940
+ (appended **last** in `optional`, per the positional-widget rule in section 8),
941
+ and `directives.declares_own_medium()` / `establish_for()`, which drop the
942
+ default when shot 1's opening names a medium. A model-authored plan never
943
+ touches a widget, which is why the automatic arm is the one that matters.
944
+
945
+ ### Luminance drifts, and it only goes one way
946
+
947
+ Mean luma per hop across the eight: **72, 46, 35, 17, 11, 11, 19, 14**. Setting
948
+ the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the
949
+ point of the film, plays at the bottom of it.
950
+
951
+ This is the colour law one level up. `locked` holds a face; nothing holds an
952
+ exposure. Each hop inherits the last frame and darkens it slightly and the
953
+ error compounds. **Restate the light as a positive property in every beat** --
954
+ naming a light *source* ("pale moonlight") does not set a level, and at cfg 1.0
955
+ it only adds a moon.
956
+
957
+ ### The seams were never the problem
958
+
959
+ 7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is
960
+ 13.6, inside the range of ordinary in-shot motion, and two seams score below the
961
+ film's own mean. Identity held 114 seconds on face plates riding every hop.
962
+
963
+ The one hard cut in the film is **inside** hop 4, 3.25 s in, at 7.1 sd -- more
964
+ than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
965
+ open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
966
+ the two of them square off". Hop 4 was handed a live frame of a man among trees
967
+ and a beat asserting he stood on open stone, held the forest for 3.25 s, then
968
+ reset the scene. The plan was clean under `check_coherence` and the banned-word
969
+ scan. Hence `plan.check_place_handoff()`.
970
+
971
+ ### What the two models did
972
+
973
+ Near-identical answers from Qwen and Gemma on the same prompt -- same beats to
974
+ the word in 5 of 6 shots, same defects, same invented justification. The prompt
975
+ is prescriptive enough to collapse two models onto one answer; a shared blind
976
+ spot is then invisible from output alone.
977
+
978
+ **The word table was inert.** Both models, asked for six 15 s hops, returned
979
+ beats averaging **54 words** against a 70-100 band -- every beat under the floor
980
+ -- and the same models on much shorter hops returned 40-48. Beat length is
981
+ near-invariant to hop length in model output, now measured in a third setting.
982
+ A reference table does not move a model with a prior on paragraph length; the
983
+ rule is now an instruction to count, with a worked 74-word example.
984
+
985
+ **The banned-word list beat the principle, and made things worse.** Zero literal
986
+ banned words in all four plans -- and "The storm's roar begins to fade...
987
+ raindrops strike the glass with decreasing force" and "The storm has passed".
988
+ The list taught token avoidance and handed them a box to tick. It is now framed
989
+ as crude examples of an idea, with the test stated as: is this happening, or has
990
+ it finished happening?
991
+
992
+ **Both abandoned the second location.** Each plated the opening place, moved the
993
+ story elsewhere, gave the new place no plate, and justified it with a rule that
994
+ does not exist ("to avoid conflicting with the frame pin of the new space");
995
+ Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left
996
+ the lamp room -- four of six hops -- on beat text alone.
997
+
998
+ ### `check_place_handoff` has two arms, and both were narrowed by real plans
999
+
1000
+ 1. **Handoff.** Shot N names a place tag shot N-1 never mentions, and shot N's
1001
+ own beat does not carry the journey. The arrival vocabulary had to widen: the
1002
+ Showcase's shot 6 ("walks back along the hallway and through the doorway to
1003
+ the counter in @kitchen") is correct and was being flagged.
1004
+ 2. **Abandonment**, not gaps. Warning on any unplated hop also flagged the
1005
+ Showcase, which deliberately walks her down an unplated hallway on 4-5 and
1006
+ returns the kitchen plate on 6. The rule that survives contact: warn only
1007
+ when the plates stop and **never resume**, so the film ends somewhere no
1008
+ picture describes.
1009
+
1010
+ Both shipped workflows are clean under the final version; the anime plan raises
1011
+ exactly one warning, on shot 4.
1012
+
1013
+ ### Audio, for the record
1014
+
1015
+ No background music and no speech, confirmed on the spectrogram: broadband
1016
+ transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
1017
+ of seven joins, and the two exceptions are drops the beats themselves ask for.
1018
+ The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
1019
+ while hop 8 averages -42 dBFS. There is no audio equivalent of
1020
+ `HTCToneCompensate`. Left open.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandpies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
PROMPTING.md ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Writing for Hand Tie Clips
2
+
3
+ Everything in this file was confirmed against renders, not reasoned from the
4
+ code. Where a rule exists because of something the model actually did, that is
5
+ said out loud — the reasons are more useful than the rules.
6
+
7
+ If you only read one section, read [The three laws](#the-three-laws).
8
+
9
+ ---
10
+
11
+ ## What you are actually writing
12
+
13
+ Two JSON documents, both living in widgets on the node, both editable as cards
14
+ in the panel or as raw JSON under the **JSON** tab.
15
+
16
+ | | |
17
+ |---|---|
18
+ | **`shot_plan`** | An ordered list of shots. **One shot per hop** — the shot count *is* the hop count. |
19
+ | **`ref_plan`** | The reference register: which pictures exist, what each one is for, who they are photographs *of*, and which hops each belongs on. |
20
+
21
+ The node writes a great deal that you do not: the identity lock, the citation of
22
+ the live frame carried over from the previous hop, the join sentence, and the
23
+ per-hop `<Picture N>` numbering. Your job is the **beat** — what happens this
24
+ hop — plus the register that tells the node who is in it.
25
+
26
+ ---
27
+
28
+ ## The three laws
29
+
30
+ ### 1. The prompt is additive
31
+
32
+ Sampling runs at **cfg 1.0 with no negative branch**. There is nothing to push
33
+ against. Every concept you name is added to the conditioning and nothing can be
34
+ removed by mentioning it.
35
+
36
+ ```
37
+ BAD No cut, no camera shake, don't change the lighting.
38
+ GOOD One unbroken take. The camera holds its position. The light stays as it is.
39
+ ```
40
+
41
+ The bad version puts `cut`, `camera shake` and `change the lighting` in front of
42
+ the encoder, which is precisely the opposite of the intent. Write what the shot
43
+ *is* doing. This is also why the directive vocabulary is phrased affirmatively
44
+ throughout, and why an unset axis emits nothing at all rather than asserting a
45
+ default — an unset axis costs zero tokens.
46
+
47
+ **Whether this bites inside quoted dialogue has not been tested.** A line like
48
+ *'I did not think you would come'* is speech, not a stage direction, and the
49
+ model may well treat it as such — but nothing here was confirmed against a
50
+ render, so the shipped templates avoid negation in dialogue rather than assume
51
+ either answer. Your own lines are your call.
52
+
53
+ ### 2. Never name the thing you want to end
54
+
55
+ ```
56
+ BAD The cook stops talking and stands still.
57
+ GOOD The cook leans back against the counter with her lips closed, and lets her
58
+ eyes move slowly across the room. The kitchen is quiet apart from the low
59
+ hum of the refrigerator.
60
+ ```
61
+
62
+ `stops talking` keeps her talking. Describe the state you want as **a pose plus
63
+ a sound**. This follows directly from law 1 but it is the single most common
64
+ mistake, so it gets its own rule.
65
+
66
+ **Silence has to be written as a sound.** H3 generates audio for the whole hop
67
+ whether or not you asked for any. Written as an absence, you get speech — the
68
+ model fills the track with the most likely thing. Written as room tone, a
69
+ refrigerator, a distant car, you get room tone.
70
+
71
+ ### 3. Put state changes at the end of the *previous* shot
72
+
73
+ Every hop after the first opens by holding the frames it was handed, and the
74
+ audio pin carries the tail of the previous hop across the join. If shot 2 ends
75
+ mid-sentence, **no wording in shot 3 will make shot 3 start quiet.**
76
+
77
+ ```
78
+ shot 2: The cook sets the knife down, turns toward the window, and finishes her
79
+ last word as she looks out.
80
+ shot 3: The cook leans back against the counter with her lips closed...
81
+ ```
82
+
83
+ To be silent, still, or somewhere else in a shot, **arrive there before the
84
+ previous shot ends.**
85
+
86
+ ### The other half of law 3: a beat must survive an over-delivered hop
87
+
88
+ Law 3 says put a state change at the end of the *previous* shot. Its mirror is
89
+ just as load-bearing: **never write a beat that assumes where the previous hop
90
+ stopped.** A hop routinely does more than you asked. Give it a walk that was
91
+ meant to be a first step and it will finish the whole walk — seven steps of
92
+ turbo sampling has no reason to stop halfway.
93
+
94
+ If the next beat then instructs an action its own live frame has already carried
95
+ out, there is exactly one way for the model to satisfy it: reset the scene and
96
+ do it again. That reads as a hard cut about a second and a half *into* the hop,
97
+ not at the seam, because `_assemble_next` holds the incoming frames for a short
98
+ beat first.
99
+
100
+ Observed on a six-hop chain. Shot 3 asked for *"takes a first slow step along
101
+ the counter"* and delivered the entire walk to the window in a tight close.
102
+ Shot 4 was then handed *"carries the bowl the length of the counter to the
103
+ window and sets it down on the sill"* — already true. It cut at 21.8 s.
104
+
105
+ Two fixes, and use both:
106
+
107
+ - **Give one hop the whole movement.** Splitting a walk across two hops is what
108
+ creates the impossible second instruction.
109
+ - **Write the next beat so it is true from either ending.** *"She reaches the
110
+ window and sets the bowl down"* is satisfiable whether she arrived last hop or
111
+ is still arriving. *"She takes up the bowl again"* asserts no location for the
112
+ bowl, so a hop that never saw it set down still has something legal to do.
113
+
114
+ The rewritten chain rendered 39 s with no cut detectable at a 0.08 scene
115
+ threshold.
116
+
117
+ ### Dialogue propagates, and `tail: ongoing` compounds it
118
+
119
+ Observed on a six-hop chain: shot 1 ended on its spoken line, and the character
120
+ **went on talking through all five remaining hops**, none of which had any
121
+ dialogue written.
122
+
123
+ Two things caused it together. The audio pin carries the previous hop's tail, so
124
+ the last second of hop 1 — speech — became the opening of hop 2. And
125
+ `tail: ongoing` closes the prompt with *action is still underway*, which the
126
+ model satisfied with the action it could hear.
127
+
128
+ The fix is not to write "she is silent" in the later shots. It is to **land the
129
+ line early in the hop and leave a non-verbal action running into the seam**:
130
+
131
+ ```
132
+ BAD ...looks up from the chopping board and says, 'You are early.'
133
+ tail: ongoing
134
+
135
+ GOOD ...looks up from the chopping board and says, 'You are early. I have
136
+ barely started.' She turns back to the board and goes on slicing, the
137
+ knife tapping steadily against the wood.
138
+ tail: ongoing
139
+ ```
140
+
141
+ The knife is what is still underway when the frames stop, so that is what the
142
+ pin carries. Then **give every dialogue-free hop a sound bed of its own** —
143
+ footsteps, a refrigerator, rain on glass — or the audio has nowhere to go but
144
+ back to speech.
145
+
146
+ ---
147
+
148
+ ## Templates
149
+
150
+ The **Templates** button in the SCRIPT header appends ready-made patterns —
151
+ *Dialogue, held*, *Cross the room*, *Leave for an unseen space*, *Return to the
152
+ room*, *Quiet close*. They append rather than replace, so a chain is built by
153
+ stacking them.
154
+
155
+ Each one exists to demonstrate a rule on this page in a form you can edit rather
156
+ than read, and each carries a note about how to schedule references around it.
157
+ None of them contains an `@tag`: a template that named a reference you had not
158
+ created would turn one click into a run-time error.
159
+
160
+ ## Writing a beat
161
+
162
+ **Shot 1 is the whole opening.** Establish the person, the place, the light, what
163
+ they are doing. **Every later shot is only the new beat** — do not re-describe
164
+ the face, the clothes, or the room. The photographs, the register and the pin
165
+ already carry all three, and re-asserting them competes with the pin rather than
166
+ reinforcing it.
167
+
168
+ ```
169
+ shot 1: @hero_face stands at the counter in @kitchen, an apron over a grey
170
+ t-shirt. She looks up from the chopping board and says one short line,
171
+ then turns back to the board and goes on slicing, the knife tapping
172
+ against the wood.
173
+ shot 2: She sets the knife down and walks the length of the counter to the
174
+ window, looking out at the street. Her steps are soft on the tiles and
175
+ the refrigerator hums behind her.
176
+ ```
177
+
178
+ Shot 2 says nothing about her face, her apron or the kitchen. It does not need
179
+ to. It *does* name a sound, because shot 2 has no dialogue and the audio needs
180
+ somewhere to go that is not the previous hop's speech.
181
+
182
+ ### Point the action line at the reference you want used
183
+
184
+ Describing a reference in the register is **not enough**. The beat is what drives
185
+ the frame. Write the tag into the action:
186
+
187
+ ```
188
+ The cook stands at the counter in @kitchen, looks up from the chopping board,
189
+ and speaks one short line to someone off-frame.
190
+ ```
191
+
192
+ `@kitchen` resolves to whatever `<Picture N>` that reference happens to be on
193
+ this hop, so adding, removing or rescheduling references never breaks your text.
194
+
195
+ A **person** tag behaves differently on each side of the first hop, and the node
196
+ handles the difference for you. On hop 1 `@hero_face` becomes `<Subject 1>`,
197
+ bound by the `subject_definitions:` block that only hop 1 carries. From hop 2 on
198
+ that block is gone, so an ordinal would dangle — and the node substitutes the
199
+ subject's `name` instead:
200
+
201
+ ```
202
+ hop 1: <Subject 1> stands at the counter in <Picture 2>...
203
+ hop 4: The cook walks down a narrow hallway...
204
+ ```
205
+
206
+ "The cook" binds to the identity sentence `continuity_line` puts on every
207
+ continuation hop. **So give every subject a `name`** — without one the node
208
+ falls back to `<Subject N>`, which points at nothing on that hop. Capitalisation
209
+ is handled at sentence starts.
210
+
211
+ Phrase a place as **a place that is depicted**, not as a container to be placed
212
+ inside — "in @kitchen", not "inside the image of @kitchen". The latter can
213
+ produce a literal composite of the photograph.
214
+
215
+ ### Dialogue
216
+
217
+ Write the line, in quotes, inside the beat:
218
+
219
+ ```
220
+ @hero_face looks up from the chopping board and says, "You are early. I have
221
+ barely started." She turns back to the board and goes on slicing.
222
+ ```
223
+
224
+ Land the line in the **middle** of the hop, never at its end — see
225
+ [Dialogue propagates](#dialogue-propagates-and-tail-ongoing-compounds-it).
226
+
227
+ Keep it to roughly one line per 5–7 seconds of hop. A long speech in a short hop
228
+ gets truncated mid-word, and that truncation is then pinned into the next hop's
229
+ audio.
230
+
231
+ If you are pasting a plan into the **JSON** tab rather than typing into the
232
+ cards, single quotes around dialogue survive the round trip more reliably than
233
+ escaped double quotes, and read identically to the encoder.
234
+
235
+ ### Ambience: narrowband and specific
236
+
237
+ After shot 1 the official soundscape fields are stripped and ambience is plain
238
+ prose, so the exact words carry all the weight.
239
+
240
+ | | |
241
+ |---|---|
242
+ | `faint street noise through the window` | broadband — renders as a five-second hiss |
243
+ | `the low hum of the refrigerator` | narrowband — renders as a refrigerator |
244
+ | `a single click from the refrigerator, then stillness` | a discrete event — the fallback when a continuous bed still misbehaves |
245
+
246
+ ---
247
+
248
+ ## Directives
249
+
250
+ Five axes, all optional. Set on a shot as `"directives": {...}`.
251
+
252
+ | axis | values | notes |
253
+ |---|---|---|
254
+ | `join` | `continuous`, `match_cut`, `hard_cut` | **Ignored on shot 1**, which has nothing to join to. |
255
+ | `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` | |
256
+ | `framing` | `keep`, `wide`, `medium`, `close` | |
257
+ | `pace` | `slow`, `steady`, `brisk` | |
258
+ | `tail` | `ongoing`, `settle`, `hold` | Defaults to `ongoing`. The only axis with a default. |
259
+
260
+ They compile in that order, `join` first, because `join` describes how this hop
261
+ meets the previous one.
262
+
263
+ ### `tail` is not decoration — set it on your last shot
264
+
265
+ This axis exists because of an observed failure. A beat whose action completes
266
+ before the frames run out leaves the model with nothing to render, and it settles
267
+ onto its strongest remaining conditioning — **the identity photograph**. The last
268
+ few seconds of a chain would cut to the reference still.
269
+
270
+ `ongoing` is the default for exactly that reason: it gives the tail somewhere to
271
+ go that is not the reference. But on your **final** shot, `ongoing` tells the
272
+ model action is still underway at the final frame, and it will invent something
273
+ to satisfy that — a stray gesture, or a stray line of dialogue in the closing
274
+ second. Use `settle` or `hold` there.
275
+
276
+ ### Two combinations the node will warn about
277
+
278
+ **`join: continuous` + a framing change + `camera: hold`.** A framing change asks
279
+ the audience to be somewhere new. With the camera still, the only way to get
280
+ there is a cut — so the join and the framing are asking for opposite things and
281
+ the model picks one. Earn the framing on the move (`push_in`, `pull_back`,
282
+ `pan_follow`) or set `framing: keep`.
283
+
284
+ **`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way
285
+ from the destination. Physically contradictory at any join value.
286
+
287
+ Both are warnings, not errors — they are legitimate things to want. They just
288
+ rarely read the way you meant.
289
+
290
+ > When `join: continuous` and the camera *is* moving, the framing sentence
291
+ > compiles as a **landing** ("The move settles into a close shot…") rather than
292
+ > as the shot's opening state, so it does not fight the pin that still holds the
293
+ > previous framing. You do not have to do anything to get this; it is worth
294
+ > knowing it happens.
295
+
296
+ ---
297
+
298
+ ## The reference register
299
+
300
+ ```json
301
+ {
302
+ "refs": [
303
+ {"tag": "hero_face", "file": "cook_face.jpg", "subject": 1,
304
+ "retention": "fully_preserved", "shots": [1, 4],
305
+ "desc": "head-and-shoulders, even light"},
306
+ {"tag": "hero_outfit", "file": "cook_apron.jpg", "subject": 1,
307
+ "retention": "partially_copy", "shots": [1]},
308
+ {"tag": "kitchen", "file": "kitchen_wide.jpg",
309
+ "retention": "reference", "shots": [1, 2, 3, 6]}
310
+ ],
311
+ "subjects": {
312
+ "1": {
313
+ "name": "the cook",
314
+ "locked": "the same face, the same short dark hair, the same silver stud earrings",
315
+ "context": "the apron stays tied over the grey t-shirt"
316
+ }
317
+ }
318
+ }
319
+ ```
320
+
321
+ ### Fields
322
+
323
+ | ref field | |
324
+ |---|---|
325
+ | `tag` | **Required.** Short, stable, what you write in beats as `@tag`. Must be unique. |
326
+ | `file` | A basename in `ComfyUI/input/h3_refs`. Set for you when you drop a picture on the rail. Never a path. |
327
+ | `subject` | Integer ≥ 1. Groups pictures **of the same person**. |
328
+ | `retention` | `fully_preserved`, `partially_copy`, `reference`. Defaults to `fully_preserved` when `subject` is set, `reference` otherwise. |
329
+ | `desc` | Free text about the picture. |
330
+ | `shots` | 1-based hop numbers this picture rides on. See below — this is the important one. |
331
+
332
+ | retention | what carries over |
333
+ |---|---|
334
+ | `fully_preserved` | face, bone structure and hairstyle, exactly |
335
+ | `partially_copy` | the garment and its cut, moving naturally with the body |
336
+ | `reference` | the layout, surfaces and light, i.e. a place |
337
+
338
+ | subject field | |
339
+ |---|---|
340
+ | `name` | What to call them in prose. |
341
+ | `locked` | What must not change. **This is what survives when the photograph is off the hop.** |
342
+ | `context` | Situational state that should persist — wardrobe, what they are holding. |
343
+
344
+ At most **9 pictures on any one hop**.
345
+
346
+ ### `subject` is not optional decoration
347
+
348
+ Declaring two photographs of two *different* people as the same subject makes
349
+ the model render the **average of two faces**. Group photos per person, always.
350
+
351
+ Conversely, a subject block that no ref claims is an error, not a warning: it
352
+ would ride every hop describing someone who never appears, and at cfg 1.0 that
353
+ is purely additive noise.
354
+
355
+ Give every subject a `locked`. Pictures alone put a face in front of the encoder;
356
+ `locked` is what carries the identity across a seam where the picture is absent.
357
+
358
+ > This was **not true before 2026-08-29**. `locked` and `context` reached hop 1
359
+ > only, and a hop scheduled with no references carried no identity text at all
360
+ > — which is exactly how a six-hop test lost its character on the one hop that
361
+ > had no pictures. They now ride every hop, phrased with no `<Picture N>` or
362
+ > `<Subject N>` citation so they cannot send the encoder back to the plates.
363
+
364
+ ### Name a colour, or it drifts
365
+
366
+ `locked` and `context` ride every hop, but they only protect what they actually
367
+ describe. A noun with no adjective is exactly as unanchored as a `<Subject N>`
368
+ with no antecedent — each hop is an independent encode, and *"the bowl"* on
369
+ hop 4 gives that encode nothing to bind to.
370
+
371
+ Two renders of the same chain settled it. In the first, three hops said only
372
+ *"the bowl"*; it came back stainless steel on hop 4 and a different ceramic one
373
+ on hop 6. Adding the colour to every mention **and** stating it as a property in
374
+ `context` — *"the bowl she is using is white porcelain"* — held one white bowl
375
+ through all six hops of the re-run.
376
+
377
+ The second render then made the rule sharper than a designed test would have.
378
+ This `context` line ran on every hop:
379
+
380
+ > the apron stays tied over the **grey** t-shirt
381
+
382
+ The t-shirt has its colour named and is grey in all six hops. The apron is one
383
+ clause away in the same sentence, has no colour, and had turned denim blue by
384
+ hop 6. One sentence, one variable.
385
+
386
+ So:
387
+
388
+ - Put persistent properties in `context` as **properties, never locations**.
389
+ *"the bowl is white porcelain"* is true wherever she puts it; *"the bowl stays
390
+ in her hands"* becomes additive text fighting the beat the moment she sets it
391
+ down.
392
+ - Repeat the adjective in the beats too. *"the white bowl"*, every time.
393
+ - This applies to anything the register does not photograph on that hop. A ref
394
+ with `shots: [1]` stops defending its garment at hop 2; from there the only
395
+ thing holding it is the words.
396
+
397
+ ### `shots` is how you schedule references — and the default will surprise you
398
+
399
+ > **On a continuation chain, a reference with no `shots` list rides hop 1 only.**
400
+
401
+ That is deliberate for **place** plates: a room still riding a hop set
402
+ somewhere else beats the pin, because the model holds a crisp well-lit picture
403
+ of one room and a noisy carried-over frame of another, and it follows the still.
404
+ List every hop a picture should appear on, explicitly.
405
+
406
+ > **For a face plate, schedule it on every hop.** This page used to warn that a
407
+ > face plate riding a later hop beats the pin the same way. A six-hop render
408
+ > contradicted it flatly: hop 4 carried the face plate through a walking medium
409
+ > shot in a space the plate had never seen — it was photographed in a different
410
+ > kitchen — and held cleanly, while hop 5, scheduled with no refs at all, came
411
+ > back a **different person**, and hop 6 never recovered.
412
+ >
413
+ > Identity drift is permanent. The room came back the instant a place plate rode
414
+ > hop 6; nothing brought the face back, because no face plate rode anything.
415
+ > `locked` holds a face that is already right. Only a plate rebuilds one that is
416
+ > gone, so do not let it go. Putting the face on `[1,2,3,4,5,6]` fixed it and
417
+ > cost nothing visible.
418
+
419
+ The useful consequence: you can take references *away* deliberately. A hop with
420
+ no room reference is a hop the model must invent a room for, which is exactly
421
+ what you want when the character walks somewhere the register has never seen.
422
+
423
+ ---
424
+
425
+ ## A worked six-hop plan
426
+
427
+ This is `workflows/HandTieClips_Showcase.json`, and it is built to be hard.
428
+
429
+ | hop | beat | refs active | what it tests |
430
+ |---|---|---|---|
431
+ | 1 | kitchen, dialogue | face, outfit, kitchen | establishment |
432
+ | 2 | walks to the window | kitchen | lateral movement, held identity |
433
+ | 3 | exits through the doorway | kitchen | leaving the referenced space |
434
+ | 4 | hallway | face | **an unseen space** — no room reference exists for it |
435
+ | 5 | dialogue at the far window | **none** | **identity, wardrobe and voice on the pin + `locked` alone** |
436
+ | 6 | returns to the kitchen | kitchen | re-entry after three hops away, on a `match_cut` |
437
+
438
+ Hop 6 joins on `match_cut`, not `continuous`. Asking for one unbroken take
439
+ between two different rooms is asking the impossible, and the first run of this
440
+ plan got what it deserved: the hallway morphed into the kitchen mid-turn. A walk
441
+ through a doorway *is* a cut on a matched movement.
442
+
443
+ Hop 5 is the point of the exercise. If the cook is the same person, in the same
444
+ apron, with the same voice, **with no picture in front of the encoder at all**,
445
+ the register is doing its job. Hop 6 then asks whether the room survived three
446
+ hops of absence.
447
+
448
+ The face plate returns on hop 4 and not on 5 on purpose: hop 4 is where the
449
+ character enters a space the register cannot describe, which is where identity
450
+ drift is most likely to start, and re-asserting the face there is cheaper than
451
+ recovering from drift on 5 and 6.
452
+
453
+ ---
454
+
455
+ ## Duration
456
+
457
+ The label on the widget and the frames the model actually renders agree to a
458
+ tenth of a second, because every offered value satisfies the model's own frame
459
+ alignment (`n % 17 == 5` at 24 fps).
460
+
461
+ | label | frames |
462
+ |---|---|
463
+ | `5 s` | 124 |
464
+ | `7 s` | 175 |
465
+ | `8 s` | 192 |
466
+ | `10 s` | 243 |
467
+ | `15 s` | 362 |
468
+
469
+ A shot may override the chain with `"duration": "7 s"`, using the same labels.
470
+
471
+ ---
472
+
473
+ ## When it goes wrong
474
+
475
+ | symptom | cause | fix |
476
+ |---|---|---|
477
+ | The clip cuts to the reference photo in its last seconds | The beat finished before the frames did, and the model settled onto its strongest remaining conditioning | Set `tail`, and give the beat enough to do |
478
+ | A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |
479
+ | She keeps talking after you asked for quiet | You named the ending (`stops talking`) — law 2 | Pose plus a sound |
480
+ | Dialogue continues into hops that have none written | The hop before ended on speech, and the audio pin carried it | Land the line early; leave a non-verbal action running into the seam; give every quiet hop its own sound bed |
481
+ | A character walks between two rooms and one morphs into the other | `join: continuous` across a real location change | `match_cut` |
482
+ | Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |
483
+ | Ambience is a five-second hiss | Broadband wording | Narrowband, or a single discrete event |
484
+ | Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
485
+ | The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on every hop |
486
+ | A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs something its own live frame already did. The only way to obey is to reset the scene | Give one hop the whole movement; write the next beat so it is true from either ending |
487
+ | An animated or stylised plan renders photoreal, and hop 1 most of all | The node prepends an establishing line to hop 1 and its default asserts **live action**, so "Live-action, natural light" was added ahead of your style. Fixed in 0.3.3: the default is dropped when shot 1 names a medium | Name the medium in shot 1's **first sentence**, or clear the `establish` widget |
488
+ | The film gets steadily darker hop after hop | Luminance drifts like colour, in one direction, and nothing pushes back. Measured at 72 -> 11 mean luma across six hops of a night chain | Restate the light as a positive property in every beat: "the clearing reads bright under the full moon, pale stone, open detail in the shadows" |
489
+ | A location introduced mid-plan drifts | It has no place plate. Tightening a plate to its own shots does not mean the next location goes without one | Add a plate for it, riding the hop it arrives and every hop after. `check_place_handoff` warns when the plan ends with no plate riding |
490
+ | A prop or garment changes material or colour | It is named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |
491
+ | A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |
492
+ | The run stops naming a reference | That row's picture is not in `h3_refs`. A named file that is not on disk is fatal -- rendering the chain without it is never what was meant | Drop the file on the row in the REFERENCES rail, or clear its picture to run without it |
493
+ | A reference has no effect on some hop | Its `shots` list leaves that hop out. This is silent, not an error -- it is a legitimate schedule | List every hop the picture should ride |
494
+ | A pasted plan is rejected as invalid JSON | Usually escaped double quotes mangled in transit | Use single quotes around dialogue |
495
+
496
+ ---
497
+
498
+ ## Having a model write the plan
499
+
500
+ The whole vocabulary, schema and rule set fits in one prompt. `prompt_pack/`
501
+ holds a copy-paste system prompt that turns a plain description of a scene into
502
+ a `shot_plan` and `ref_plan` this node accepts:
503
+
504
+ - **[`prompt_pack/AUTHORING_PROMPT.md`](prompt_pack/AUTHORING_PROMPT.md)** — paste
505
+ into any chat model, describe your scene, paste the two JSON blocks back into
506
+ the node.
507
+ - **[`prompt_pack/SCHEMA.json`](prompt_pack/SCHEMA.json)** — the machine-readable
508
+ version, for anyone wiring this into their own tooling.
509
+
510
+ The node still validates everything, so a model that gets it wrong is caught
511
+ rather than obeyed.
README.md ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hand Tie Clips
2
+
3
+ One node. Write a shot plan, drop in your reference stills, queue.
4
+
5
+ > **Renamed 2026-08-29.** This pack was `ComfyUI-H3-Ref-Chain`. The nodes now
6
+ > live under the **Hand Tie Clips** category and register as `HandTieClips`,
7
+ > `HTCChainPreview`, `HTCToneCompensate` and `HTCContinuityState`. The old ids
8
+ > are still registered as deprecated aliases, so **every workflow saved before
9
+ > the rename keeps loading** — they are just hidden from node search. Nothing
10
+ > needs migrating.
11
+
12
+ **Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
13
+
14
+ Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
15
+
16
+ This is not the seamless-chain pack. No airlock script, no Motion-Context, no interior patch.
17
+
18
+ ## What v1.0 changed
19
+
20
+ The prompt is no longer a wall of `---`-delimited text, and the MODEL wire is no longer four extra nodes.
21
+
22
+ | | |
23
+ |---|---|
24
+ | **`shot_plan`** | The script, as JSON. One shot per hop — **the shot count is the hop count**, so `chains` can no longer disagree with it. |
25
+ | **`directives`** | `join` / `camera` / `framing` / `pace` / `tail` per shot, compiled to vetted prose. Improving a phrase improves every plan you have ever written. |
26
+ | **`ref_plan`** | Stable `@tags` for reference stills, grouped into subjects, each naming a picture file. Removing one can no longer silently renumber `<Picture N>`. |
27
+ | **`cache_hops`** | Lossless per-hop cache. Re-roll one shot, resume after a crash, and hold roughly one hop in RAM instead of the whole clip. |
28
+ | **the editor** | Cards on the node, not JSON in a textarea. Hover any directive to read the exact sentence it puts in the prompt, or hit **Templates** for a ready-made pattern. |
29
+
30
+ Simple mode keeps the old one-prompt workflow; the speed stack (LoRAs, AdaLN fix, low-VRAM, SLA) stays as four ordinary nodes on the MODEL wire, where you can see it.
31
+
32
+ ## Install
33
+
34
+ Unzip the folder into `ComfyUI/custom_nodes/`, so that it lands as:
35
+
36
+ ```
37
+ ComfyUI/custom_nodes/ComfyUI-Hand-Tie-Clips/
38
+ __init__.py
39
+ h3_ref_chain.py
40
+ js/
41
+ workflows/
42
+ ```
43
+
44
+ One folder level too deep (`custom_nodes/ComfyUI-Hand-Tie-Clips/ComfyUI-Hand-Tie-Clips/`) is the usual mistake, and the pack simply will not appear.
45
+
46
+ No dependencies to install. Everything it imports -- `torch`, `numpy`, `PIL`, `av`, `aiohttp` -- already ships with ComfyUI, which is why `dependencies` in `pyproject.toml` is empty.
47
+
48
+ Then:
49
+
50
+ 1. **Restart ComfyUI.** Python changed.
51
+ 2. **Hard-refresh the browser** (Ctrl+Shift+R / Cmd+Shift+R). The editor is served from `/extensions/ComfyUI-Hand-Tie-Clips/`, and a stale cache is the single most common reason the node mounts with no UI on a fresh install.
52
+
53
+ It is installed correctly when all three are true:
54
+
55
+ - the startup log carries a line beginning `[HandTieClips]`
56
+ - the browser console says `[HandTieClips] editor ui v1.5.0 loaded`
57
+ - node search shows a **Hand Tie Clips** category with four nodes, each listed once
58
+
59
+ Workflows saved before the 2026-08-29 rename keep loading: the old ids are still registered as deprecated aliases. Nothing needs migrating.
60
+
61
+ The two example graphs are in `workflows/` inside the pack folder -- open them with the **Workflow > Open** menu, or copy them into `ComfyUI/user/default/workflows/` to have them in the sidebar. Start with `HandTieClips_Starter.json`; it ships with no references on purpose, so it runs before you have supplied any pictures.
62
+
63
+ ## Needs
64
+
65
+ - ComfyUI new enough to include **Add Guide for MiniMax H3** (`MiniMaxH3AddGuide`)
66
+ - A **ref2va** (or hybrid ref2va) checkpoint — fl2va has no reference rows
67
+ - Video VAE + audio VAE + MiniMax text encoder
68
+ - `ffmpeg` on PATH, if you turn `cache_hops` on
69
+ - **Optional but recommended:** [ComfyUI-H3-Motion-Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context). Hops after the first are guided by the previous hop's sampler AV latent through it. Without it the node falls back to stock `MiniMaxH3AddGuide`, which still works and still chains -- it is a different join, so a seam you are comparing against someone else's render may not be the same code path. The node detects it at runtime and prints which one it took.
70
+
71
+ The two shipped workflows wire the **turbo stack** this node is actually run with, because an example without it is not the graph anyone uses:
72
+
73
+ ```
74
+ UNETLoader -> LoRA Loader Stack -> H3 AdaLN LoRA Fix -> MiniMax H3 Low VRAM
75
+ -> H3 SLA Attention -> Model Preview Override -> Hand Tie Clips
76
+ ```
77
+
78
+ | pack | nodes |
79
+ |---|---|
80
+ | [ComfyUI-PlagueKind-Nodes](https://github.com/PlagueKind/ComfyUI-PlagueKind-Nodes) | LoRA Loader Stack, H3 AdaLN LoRA Fix, H3 SLA Attention |
81
+ | [ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes) | MiniMax H3 Low VRAM Attention (experimental), Model Preview Override |
82
+
83
+ On disk as well: the **turbo LoRA** named in the loader stack, and `taeh3.safetensors` for the live preview (or set `tiny_vae` to `none`).
84
+
85
+ Both shipped workflows are saved pointing at the exact files they were rendered with. These are quantised builds and are **not** the only ones that work -- any ref2va or hybrid ref2va set will do. Repoint the loaders at what you have; a loader showing an empty or red filename is naming a file you do not have, not a broken workflow.
86
+
87
+ | loader | file the example names |
88
+ |---|---|
89
+ | `UNETLoader` | `minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors` |
90
+ | `CLIPLoader` | `qwen3vl_32b_minimax_h3_int8_convrot.safetensors` |
91
+ | `VAELoader` (video) | `minimax_h3_video_vae_int8_convrot.safetensors` |
92
+ | `VAELoader` (audio) | `minimax_h3_audio_vae_fp32.safetensors` |
93
+ | LoRA Loader Stack | `minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors` |
94
+ | `tiny_vae` | `taeh3.safetensors` (or `none`) |
95
+
96
+ **CLIP reaches the node from the LoRA loader, not from the encoder** — that is what makes the text half of every LoRA land. Do not rewire it back.
97
+
98
+ None of it is required by the node itself. Missing a pack, its nodes load as red boxes: delete them, wire the loader straight into `model` and the encoder into `clip`, and raise `steps` from 7 to around 20.
99
+
100
+ ## Use
101
+
102
+ 1. Restart ComfyUI and load **`workflows/HandTieClips_Starter.json`** — two hops, no references, runs as soon as the loaders are pointed at your files. It carries a six-card **READ ME** board on its own canvas, to the left of the loaders: the three laws, the directive table, the reference rules and the failure table, where you need them rather than in another file. `workflows/HandTieClips_Showcase.json` is the six-hop version and needs three pictures of your own. Both carry the turbo stack listed above.
103
+ 2. Point the loaders at your ref2va DiT, encoder, and both VAEs
104
+ 3. Add a row per identity still in the **REFERENCES** rail, then **drop a picture onto its thumbnail** (or click to browse, or pick one already uploaded). Give it a `@tag` and group photos of the same person under one subject number. There are no `Load Image` nodes to wire — files land in `ComfyUI/input/h3_refs`.
105
+ 4. In **SHOTS**, write one card per hop. Shot 1 is the whole opening; every later card is only the new beat.
106
+ 5. Optional: a `voice_file`, a `start_image_file`, a look `reference_video_file` — all picked in the **MEDIA** strip, all files under `input/h3_refs`
107
+ 6. Queue, and wire the **`info`** output to a Preview Text node — it prints the fully assembled prompt for every hop
108
+
109
+ Keep later-hop beats on **what happens next**. Do not re-describe the face; the photos, the register and the pin already carry it.
110
+
111
+ Two modes:
112
+
113
+ - **Simple** — one prompt box and a hop count, the way it always worked. Later hops advance the same action rather than replaying the opening.
114
+ - **Shots** — one card per hop with directives. Switching from Simple carries your prompt over and splits it on `---`.
115
+
116
+ Only one of them is on screen at a time, so there is never a text box quietly doing nothing.
117
+
118
+ Under the script sits **RUN**, collapsed, holding everything that is not per-shot: output size and length, sampling, the join and pin controls, and the hop cache. Its title line summarises the run — `1.0 MP 16:9 · 10s ×3 · 14 steps res_multistep · cache off` — so you can read the setup without opening it. In Shots mode `chains` and `hop_script` are not offered there, because the shot list already decides both.
119
+
120
+ **`tone_compensate`** lives in that panel's *join & pin* group. The H3 denoiser biases each hop's tone, so a chain gets steadily brighter; this measures the bias on the overlap each hop regenerated and undoes it, correcting each hop against the previous **corrected** one so the whole chain lands on hop 1's tone. `frame_shift` is the mode to reach for: all three modes remove the drift equally well (within 0.4/255 of each other), but `gain_bias` and `lut` pair pixels between a frame and its *regeneration*, which fits a slope that is not really there and flattens contrast a little more with every hop. `frame_shift` uses frame averages only, so it can shift but never distort. **Measured on a 3-hop render: chain drift 5.6/255 without it, 0.3/255 with it.** Worth turning on for anything past two hops. It ships off because enabling it also clamps the master to 0..1, and because the correction grows with hop count — by hop 10 it is subtracting ~23/255 and will start crushing blacks. Switching modes never invalidates the hop cache, so it is free to A/B. Do **not** judge it by whether the seams flatten to zero: real scene brightness changes across a cut should survive, and they do.
121
+
122
+ ## Shot plan
123
+
124
+ The cards write this; you rarely see it. It is under **JSON** on the node if you want to copy a plan between workflows.
125
+
126
+ ```json
127
+ {
128
+ "shots": [
129
+ {
130
+ "beat": "The cook stands at the counter, looks up, and speaks one short line.",
131
+ "directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
132
+ },
133
+ {
134
+ "beat": "The cook sets the knife down and turns toward the window, still talking.",
135
+ "directives": {"join": "continuous", "camera": "push_in", "framing": "close"}
136
+ }
137
+ ]
138
+ }
139
+ ```
140
+
141
+ Shot 1 is the whole opening. Every later shot is **only the new beat** — the node supplies the identity lock, the live-frame citation and the join itself.
142
+
143
+ Fields, all optional except `beat`:
144
+
145
+ | | |
146
+ |---|---|
147
+ | `beat` | What happens this hop. |
148
+ | `directives` | The five axes below. |
149
+ | `prose` | Free text appended verbatim, for anything the vocabulary lacks. |
150
+ | `seed`, `steps`, `duration` | Per-shot overrides. `duration` takes the same labels as the widget (`"8 s"`). |
151
+ | `locked` | Reuse this shot's cached render even when its inputs changed. Needs `cache_hops=on`. |
152
+ | `id` | Stable name, used as the cache pointer. Generated if absent. |
153
+
154
+ ### Directives
155
+
156
+ | axis | options |
157
+ |---|---|
158
+ | `join` | `continuous`, `match_cut`, `hard_cut` — ignored on shot 1, which has nothing to join to |
159
+ | `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
160
+ | `framing` | `keep`, `wide`, `medium`, `close` |
161
+ | `pace` | `slow`, `steady`, `brisk` |
162
+ | `tail` | `ongoing` (default), `settle`, `hold` |
163
+
164
+ An unset axis emits nothing rather than asserting a default, so it costs no tokens.
165
+
166
+ **Everything is phrased affirmatively, on purpose.** Sampling runs at cfg 1.0 with no negative branch, so every concept named is additive — "no cut" puts the word `cut` in front of the encoder. If you add prose of your own, say what the shot *is* doing.
167
+
168
+ `join=continuous` together with a framing change and a held camera will warn: with the camera still, the only way to reach a new framing is a cut, so the two are asking for opposite things. Use `push_in` / `pull_back` / `pan_follow` to earn the framing on the move, or `framing: keep`.
169
+
170
+ A camera move that points the opposite way from the framing (`push_in` with `wide`, `pull_back` with `close`) warns too -- those are physically contradictory whatever the join.
171
+
172
+ When `join=continuous` and the camera *is* moving, the framing sentence is compiled as a **landing** ("The move settles into a close shot...") rather than as the shot's opening state, so it does not fight the pin that still holds the previous framing.
173
+
174
+ ## Writing beats
175
+
176
+ > The full authoring guide, including how to have a model write plans for you, is **[PROMPTING.md](PROMPTING.md)**. What follows is the short version.
177
+
178
+ Sampling runs at cfg 1.0 with no negative branch, so **the prompt is additive:
179
+ anything you name is added, and nothing can be removed by mentioning it.** Three
180
+ rules follow, each confirmed against renders rather than reasoned from the code.
181
+
182
+ **Point the action line at the reference you want used.** Describing a reference
183
+ in the register is not enough -- the beat is what drives the frame. Write the tag
184
+ into the action:
185
+
186
+ ```
187
+ The cook stands at the counter in @kitchen, looks up from the chopping board,
188
+ and speaks one short line to someone off-frame.
189
+ ```
190
+
191
+ `@kitchen` resolves to the right `<Picture N>` on every hop it is active, so
192
+ rewiring references never breaks the text. Phrase it as a place that is depicted,
193
+ not as a container to be placed inside -- otherwise you can get a literal
194
+ composite of the photograph.
195
+
196
+ **Never name the thing you want to end.** `The cook stops talking` keeps her
197
+ talking. Describe the state you want as a pose plus a sound:
198
+
199
+ ```
200
+ The cook leans back against the counter with her lips closed, and lets her eyes
201
+ move slowly across the room. The kitchen is quiet apart from the hum of the
202
+ refrigerator and faint street noise through the window.
203
+ ```
204
+
205
+ H3 generates audio for the whole hop no matter what, so silence must be written
206
+ as a sound -- room tone, a fridge, traffic. Written as an absence, you get speech.
207
+
208
+ **Put a state change at the end of the previous shot.** Every hop after the first
209
+ opens by holding the frames it was handed, and the audio pin carries the tail of
210
+ the previous hop across the join. If shot 2 ends mid-sentence, no wording in shot
211
+ 3 will make it start quiet. Finish the line where it actually finishes:
212
+
213
+ ```
214
+ shot 2: The cook sets the knife down, turns toward the window, and finishes her
215
+ last word as she looks out.
216
+ shot 3: The cook leans back against the counter with her lips closed...
217
+ ```
218
+
219
+ To be silent, still, or somewhere else in a shot, arrive there before the
220
+ previous shot ends.
221
+
222
+ **Pick ambience that is narrowband and specific.** After the first shot, official
223
+ soundscape fields are stripped and ambience is plain prose, so the exact words
224
+ matter. "faint street noise through the window" is broadband and renders as a
225
+ five-second hiss; "the low hum of the refrigerator" does not. If a continuous bed
226
+ still misbehaves, name a single discrete event instead -- "a single click from the
227
+ refrigerator, then stillness".
228
+
229
+ **Set `tail` on your last shot.** `settle` and `hold` change the final sentence of
230
+ the prompt, which governs the terminal state of the clip. Left on `ongoing`, the
231
+ model is told action is still underway at the final frame and will invent
232
+ something to satisfy it -- on a last shot that means a stray gesture or a stray
233
+ line of dialogue in the closing second.
234
+
235
+ ## Reference register
236
+
237
+ ```json
238
+ {
239
+ "refs": [
240
+ {"tag": "hero_face", "file": "cook_face.jpg", "subject": 1, "retention": "fully_preserved"},
241
+ {"tag": "hero_outfit", "file": "cook_apron.jpg", "subject": 1, "retention": "partially_copy"},
242
+ {"tag": "kitchen", "file": "kitchen_wide.jpg", "retention": "reference"}
243
+ ],
244
+ "subjects": {
245
+ "1": {"name": "the cook", "locked": "the same face, the same short dark hair"}
246
+ }
247
+ }
248
+ ```
249
+
250
+ `file` is a picture in `ComfyUI/input/h3_refs`, set by the rail. `tag` is what you write in your beats — `@hero_face` — and the node resolves it to the right `<Picture N>` **per hop**, so pulling a still out of the middle no longer breaks every later reference.
251
+
252
+ `subject` groups pictures per person. This matters: declaring every picture as a photo of `<Subject 1>` makes the model render the *average* of two different people.
253
+
254
+ `retention` says how much of a picture carries over — `fully_preserved` (face and bone structure exactly), `partially_copy` (the garment and its cut), `reference` (layout, surfaces and light, i.e. a place). Refs with a subject default to `fully_preserved`; everything else defaults to `reference`.
255
+
256
+ Add `"shots": [1, 2]` to a ref to keep it out of the hops it does not belong in. On a continuation chain (`hop_script=next` / a shot plan), omitting `shots` means **hop 1 only** — right for a place plate, which beats the pin if it rides a hop set somewhere else. **Put face plates on every hop:** a hop with no face reference comes back a different person and no later hop recovers.
257
+
258
+ ## Hop cache
259
+
260
+ `cache_hops=on` writes every rendered hop to ComfyUI's temp dir as lossless FFV1 video plus a float32 `.npy` waveform, evicting least-recently-used above `cache_budget_gb`. The key is chained and includes the model fingerprint and, per hop, which pin mechanism was used -- a hop pinned by the AddGuide fallback is not the same render as one pinned by Motion-Context.
261
+
262
+ The key **chains** — each hop's key includes the previous hop's — because hops are causally dependent. So:
263
+
264
+ - edit shot 3 and re-queue → shots 1 and 2 load from cache, only 3 renders;
265
+ - edit shot 1 → all three re-render, which is correct, not a bug;
266
+ - change resolution, sampler, a reference still, a LoRA, or an attention setting → the whole chain re-renders.
267
+
268
+ That last one is worth knowing about. The node cannot read the settings on your LoRA and attention nodes, so instead it fingerprints what they *did* to the model — which weight keys were patched, at what strengths, and the attention overrides. Change a LoRA strength and the cache correctly invalidates. Two different LoRAs touching exactly the same keys at exactly the same strengths would look identical to it; that is the one gap.
269
+
270
+ Set `locked: true` on a shot to pin it to its last render regardless. Needs `ffmpeg` on PATH.
271
+
272
+ ## The MODEL wire
273
+
274
+ Four ordinary nodes, in this order:
275
+
276
+ UNETLoader → LoRA stack (mode=minimax) → H3 AdaLN Fix → MiniMax Low VRAM Attention → H3 SLA Attention → H3 Ref2VA Chain
277
+
278
+ **Take CLIP from the LoRA loader's second output**, not straight from the encoder, or the text-side keys H3 LoRAs carry are silently dropped.
279
+
280
+ They briefly lived inside this node as a `model_patches` JSON widget. That made the graph shorter and the node harder to read, so it was reverted — these belong where you can see them.
281
+
282
+ ## Pinning the previous hop
283
+
284
+ Three widgets tune the pin, all defaulting to their pre-existing behaviour:
285
+
286
+ | widget | default | what it does |
287
+ |---|---|---|
288
+ | `audio_pin_frames` | `24` | Audio context handed to the pin, in frames. 24 is one second on the model's 40 Hz grid. Longer audio context costs conditioning rows but **no delivered frames**, so it is the cheap lever on speech that breaks across a join — try `96` (4 s) for continuous dialogue. |
289
+ | `pin_renorm` | `off` | Rescales each pinned latent so its spread matches the one hop 2 established. The pin's own sigma climbs hop over hop and that inflated pin conditions the next one, so texture ratchets along a long chain. A scalar rescale moves no structure, so it cannot blur detail. Video and audio are corrected separately — their sigmas drift by different amounts. Worth turning on for 3+ hops. |
290
+ | `pin_noise` | `0.0` | Mixes seeded noise into the pin — the other half of the same fix. Small values only; measured gains reverse above `0.10`, which is why the range stops there. |
291
+
292
+ The DiT pin is the previous hop’s **sampler latent** through Motion-Context when present (no decode/re-encode; audio window ends at the join). AddGuide on decoded frames is the fallback. `pin_to_qwen` still shows the incoming state to the text encoder:
293
+
294
+ | | |
295
+ |---|---|
296
+ | `off` | Pin stays DiT-only. |
297
+ | `last frame` (default) | Previous hop's last frame is `<Picture 1>`. Identity stills that still ride this hop shift to Picture 2+. |
298
+ | `pin clip` | Overlap frames become an extra `<Video>` at ~2 fps. No soundtrack on it, so `voice` stays `<Audio 1>`. |
299
+ | `both` | Last frame + pin clip. |
300
+
301
+ `@tags` in beats resolve per hop, so stills shifting to Picture 2+ does not break prose. Literal `<Picture N>` in hop 2+ beats would.
302
+
303
+ ## Defaults
304
+
305
+ | | |
306
+ |---|---|
307
+ | resolution | 1.0 MP (1280×736 landscape) |
308
+ | duration | 10 s (243 frames) |
309
+ | overlap | 0.9 s (22 frames) |
310
+ | steps | 8, with a 4-step turbo LoRA — the regime this node targets |
311
+ | sampler / scheduler | `res_multistep` / `beta` |
312
+ | seed per hop | on |
313
+ | sigma shift | 12 / 3 |
314
+ | cache budget | 20 GB |
315
+
316
+ Three shots at 10 s with a 0.9 s overlap is about 28 s of master after the overlap is dropped.
317
+
318
+ ## Limits
319
+
320
+ - **A workflow saved before 2026-08-28 loses its reference pictures.** The old `ref_image_N` sockets carried tensors, so there is no filename to recover. The rail names each affected ref and asks you to pick its picture; nothing else about the plan is lost.
321
+ - There is no shot-level `refs` field. Activation lives on the reference: give a ref a `shots` list, or use the per-hop chips on its row in the rail.
322
+ - The reference `desc` and subject `locked` text go to the encoder verbatim, every hop. At cfg 1.0 there is no negative branch, so a detail that is not in the photograph is **asked for**, not ignored. Describe what you actually wired.
323
+ - `HTCContinuityState` is **setting only**. Characters live in the reference register; the node's `characters_*` fields were removed because filling in both injected identity text twice.
324
+ - Each join hard-cuts video but crossfades audio ~40 ms, so A/V drifts ~40 ms per hop.
325
+ - Texture still ratchets on long chains. Stay around 3–5 hops until that is handled.
326
+ - A 22-frame pin clip is ~2 Qwen frames at 2 fps. It is a live-state hint, not a full previous-clip watch.
327
+ - A longer overlap does not fix continuity — it can pin whatever content happens to be in that longer tail.
328
+
329
+ ## Nodes
330
+
331
+ **H3 Ref2VA Chain** — `images`, `audio`, `info` out. Wire `CreateVideo` + `SaveVideo` as in the example workflow, and `info` to a Preview Text node.
332
+
333
+ **H3 Chain Preview** — a passthrough panel for the IMAGE (and optionally AUDIO) wire, placed **between the chain and `CreateVideo`**. Images and audio come out unchanged, so adding or removing it changes no pixels. It shows the live sample, the seam (the previous hop's last frame beside this hop's first), a chain-wide progress bar, cache hit / seed / steps per hop, **which pin mechanism each hop actually used** — a latent Motion-Context pin or the AddGuide pixel fallback — and end-of-run A/V drift. Drag the grip to resize the stats panel; double-click it to reset.
334
+
335
+ **H3 Tone Compensate** — `images` out. Corrects a generated segment's tone against the previous one, estimated on the overlap they share. **For hand-built chains only.** It cannot fix `H3 Ref2VA Chain`'s output: that node joins its hops internally and drops each hop's first `overlap` frames at the seam, so the regenerated copies this needs are already gone by the time images leave it. Use the chain node's `tone_compensate` widget instead. Estimator ported from [rkfg/ComfyUI-MiniMaxH3-ToneCompensate](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT).
336
+
337
+ **H3 Continuity State** — `continuity_state` (STRING) out. **Setting only**: `setting_locked` / `setting_context` / `setting_mutable`. Characters belong in `ref_plan`.
__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand Tie Clips — native MiniMax H3 Ref2VA continuation for ComfyUI."""
2
+ from .h3_ref_chain import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
3
+ from .preview_node import (
4
+ NODE_CLASS_MAPPINGS as _PREVIEW_NODES,
5
+ NODE_DISPLAY_NAME_MAPPINGS as _PREVIEW_NAMES,
6
+ )
7
+ from .tone import (
8
+ NODE_CLASS_MAPPINGS as _TONE_NODES,
9
+ NODE_DISPLAY_NAME_MAPPINGS as _TONE_NAMES,
10
+ )
11
+
12
+ NODE_CLASS_MAPPINGS.update(_PREVIEW_NODES)
13
+ NODE_DISPLAY_NAME_MAPPINGS.update(_PREVIEW_NAMES)
14
+ NODE_CLASS_MAPPINGS.update(_TONE_NODES)
15
+ NODE_DISPLAY_NAME_MAPPINGS.update(_TONE_NAMES)
16
+
17
+ # The editor's dropdowns are served from directives.VOCAB rather than copied
18
+ # into JS. A failure here costs the dropdowns their tooltips, not the node, so
19
+ # it must never stop the pack from loading.
20
+ try:
21
+ from . import routes as _routes
22
+ _routes.register()
23
+ except Exception as _exc: # noqa: BLE001
24
+ print(f"[HandTieClips] vocab route not registered: {_exc!r}", flush=True)
25
+
26
+ WEB_DIRECTORY = "./js"
27
+
28
+ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
directives.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Directive vocabulary for H3 Ref Chain shot plans.
2
+
3
+ WHY THIS MODULE EXISTS
4
+ ----------------------
5
+ MiniMax H3 output is overwhelmingly carried by the prompt text, not by the
6
+ conditioning channels. Hand-writing continuity prose per shot does not scale and
7
+ drifts: every chain re-invents its own phrasing, so nothing learned on one chain
8
+ carries to the next. Structured directives compile to vetted prose instead, and
9
+ because every string lives in VOCAB below, improving the phrasing improves every
10
+ existing shot plan at once.
11
+
12
+ PHRASING RULES -- read before editing VOCAB
13
+ -------------------------------------------
14
+ 1. AFFIRMATIVE ONLY. Sampling runs through BasicGuider at cfg 1.0 with no
15
+ negative branch, so every concept named is additive and cannot be subtracted.
16
+ "no cut" puts `cut` in front of the encoder. Say what the shot IS doing.
17
+ 2. NO PRONOUNS. The beat text owns the subject. A pronoun here can disagree with
18
+ it -- plural "they" has been observed rendering two people from one reference.
19
+ 3. NO ENUMERATED DETAIL. Naming a prop or garment to control it adds it. Keep
20
+ directives about camera, join, framing, and pace only.
21
+ 4. ONE SENTENCE PER ENTRY. These get concatenated; long entries crowd the beat.
22
+ """
23
+
24
+ import re
25
+
26
+ # axis -> option -> prose. An option of "" (unset) emits nothing, deliberately:
27
+ # an unset axis should cost no tokens rather than assert a default.
28
+ VOCAB = {
29
+ "join": {
30
+ "continuous": "The camera and the action carry straight on from the pinned "
31
+ "frames in one unbroken take.",
32
+ "match_cut": "The shot changes on a matched movement, the new framing "
33
+ "picking up the same gesture already underway.",
34
+ "hard_cut": "A clean cut opens a new setup, the same place a moment later.",
35
+ },
36
+ "camera": {
37
+ "hold": "The camera holds its position throughout.",
38
+ "pan_follow": "The camera pans smoothly to follow the movement, holding it in frame.",
39
+ "push_in": "The camera pushes slowly in.",
40
+ "pull_back": "The camera draws slowly back, opening the frame.",
41
+ "orbit": "The camera arcs slowly around the action.",
42
+ "handheld": "The camera carries a light handheld float.",
43
+ },
44
+ "framing": {
45
+ "keep": "The framing stays as it is.",
46
+ "wide": "A wide shot with the full room in view.",
47
+ "medium": "A medium shot from the waist up.",
48
+ "close": "A close shot, head and shoulders filling the frame.",
49
+ },
50
+ "pace": {
51
+ "slow": "Everything moves at an unhurried pace.",
52
+ "steady": "The action moves at a steady, even pace.",
53
+ "brisk": "The action moves briskly.",
54
+ },
55
+ # How the clip ENDS. This axis exists because a beat whose action
56
+ # completes before the frames run out leaves the model with nothing to
57
+ # render, and it settles onto its strongest remaining conditioning --
58
+ # the identity photograph. Observed as the last ~3s of a chain cutting
59
+ # to the reference still. `ongoing` is the default for exactly that
60
+ # reason: it gives the tail somewhere to go that is not the ref.
61
+ "tail": {
62
+ "ongoing": "The action is still underway as the clip ends.",
63
+ "settle": "The movement eases to a rest and stays there.",
64
+ "hold": "The final position holds steady through the last moments.",
65
+ },
66
+ }
67
+
68
+ # B2 (2026-08-27). When `join=continuous` and the camera is moving, the framing
69
+ # sentence has to describe where the move ENDS, not what the shot opens on.
70
+ # Asserting the destination as the opening state while the pin still holds the
71
+ # previous hop's framing is what put the jump at the 2->3 seam of chain_00028:
72
+ # the compile read "...carry straight on from the pinned frames. The camera
73
+ # pushes slowly in. A close shot, head and shoulders filling the frame."
74
+ #
75
+ # Affirmative, one sentence, no pronouns -- same rules as the rest of VOCAB.
76
+ # Set FRAMING_AS_LANDING = False to restore the pre-2026-08-27 phrasing; that
77
+ # is the A/B against chain_00038.
78
+ FRAMING_AS_LANDING = True
79
+ _FRAMING_LANDING = {
80
+ "wide": "The move settles into a wide shot with the full room in view.",
81
+ "medium": "The move settles into a medium shot from the waist up.",
82
+ "close": "The move settles into a close shot, head and shoulders filling the frame.",
83
+ }
84
+ # "hold" is not a move, and "keep" needs no landing -- it already says the
85
+ # framing does not change.
86
+ _CAMERA_MOVES = ("pan_follow", "push_in", "pull_back", "orbit", "handheld")
87
+
88
+ # Order matters: this is the order the compiled sentences appear in the prompt.
89
+ # `join` leads because it describes how this hop meets the previous one.
90
+ AXES = ("join", "camera", "framing", "pace", "tail")
91
+
92
+ # Applied when a shot names no value for the axis. Only `tail` carries one:
93
+ # every other axis is legitimately "unspecified", but an unspecified tail is
94
+ # what lets the clip settle onto the reference image.
95
+ DEFAULTS = {"tail": "ongoing"}
96
+
97
+ # Opening line for hop 1, which has no previous hop to join to.
98
+ #
99
+ # 2026-08-29. This is the DEFAULT, not a constant. It used to be prepended to
100
+ # every hop-1 prompt unconditionally, which is correct for the live-action
101
+ # plans the pack shipped with and actively wrong for anything else: at cfg 1.0
102
+ # with no negative branch "Live-action" and "natural light" are ADDED, ahead of
103
+ # whatever style the beat declares. A stop-motion puppet plan compiled to
104
+ #
105
+ # Live-action, natural light, one continuous take.
106
+ # ...
107
+ # Hand-drawn stop-motion puppet animation in felt and painted wood ...
108
+ #
109
+ # and the two fought. It is also the reason hop 1 of the 8x15 s anime chain
110
+ # rendered as bright naturalistic daylight (mean luma 72) against a night plan
111
+ # and a night place plate, then dropped to 46 on hop 2 the moment this line
112
+ # stopped riding.
113
+ #
114
+ # Two fixes, because either alone leaves a hole. The node exposes an
115
+ # `establish` widget so a human can set or clear it; and `establish_for` below
116
+ # drops it automatically when the beat names a medium of its own, because a
117
+ # model-authored plan never touches a widget.
118
+ ESTABLISH = "Live-action, natural light, one continuous take."
119
+
120
+ # Media that contradict ESTABLISH's "Live-action". Matched against the opening
121
+ # of shot 1 only -- a beat that says "a photograph of a painting" later on is
122
+ # describing a prop, not declaring its own medium.
123
+ _STYLE_WORDS = (
124
+ "anime", "animation", "animated", "cartoon", "cel ", "cel-",
125
+ "stop-motion", "stop motion", "claymation", "clay-mation", "puppet",
126
+ "watercolour", "watercolor", "gouache", "oil-painted", "oil painted",
127
+ "hand-drawn", "hand drawn", "illustrated", "illustration", "storyboard",
128
+ "pixel art", "cgi", "3d render", "3d-render", "rendered in", "rotoscope",
129
+ "comic", "manga", "woodcut", "linocut", "papercraft", "paper-craft",
130
+ )
131
+ # How far into the beat to look. Rule 12 asks for the style in the first
132
+ # sentence; 220 characters covers a generous one without reaching the action.
133
+ _STYLE_WINDOW = 220
134
+
135
+
136
+ def declares_own_medium(beat):
137
+ """True when shot 1 opens by naming a medium that is not live action.
138
+
139
+ Deliberately narrow. It reads only the opening of the beat, and it defers
140
+ to an explicit "live-action" there, so a plan that means live action keeps
141
+ the establishing line even when it also mentions, say, an animated sign in
142
+ the background.
143
+ """
144
+ head = (beat or "")[:_STYLE_WINDOW].lower()
145
+ if "live-action" in head or "live action" in head:
146
+ return False
147
+ return any(w in head for w in _STYLE_WORDS)
148
+
149
+
150
+ def establish_for(beat, establish=None):
151
+ """The establishing line to prepend to hop 1, or "" for none.
152
+
153
+ `establish=None` means "the node did not say", which is every caller that
154
+ predates the widget -- those keep the historical default.
155
+ """
156
+ line = ESTABLISH if establish is None else str(establish or "").strip()
157
+ if line and declares_own_medium(beat):
158
+ return ""
159
+ return line
160
+
161
+
162
+ def validate(axis, value, where=""):
163
+ """Raise ValueError naming the offending shot and the valid options."""
164
+ v = str(value or "").strip()
165
+ if not v:
166
+ return ""
167
+ if v not in VOCAB[axis]:
168
+ valid = ", ".join(sorted(VOCAB[axis]))
169
+ raise ValueError(
170
+ f"HandTieClips: {where}directive {axis}='{v}' is not valid. Use one of: {valid}"
171
+ )
172
+ return v
173
+
174
+
175
+ def directive_prose(directives, hop_index, axes=None):
176
+ """Compile a shot's directives into prose. Hop 1 has nothing to join to."""
177
+ d = directives or {}
178
+ out = []
179
+ for axis in (axes or AXES):
180
+ if axis == "join" and hop_index == 0:
181
+ continue
182
+ value = str(d.get(axis) or "").strip() or DEFAULTS.get(axis, "")
183
+ line = VOCAB.get(axis, {}).get(value)
184
+ if (axis == "framing" and FRAMING_AS_LANDING and hop_index > 0
185
+ and str(d.get("join") or "").strip() == "continuous"
186
+ and str(d.get("camera") or "").strip() in _CAMERA_MOVES
187
+ and value in _FRAMING_LANDING):
188
+ line = _FRAMING_LANDING[value]
189
+ if line:
190
+ out.append(line)
191
+ return " ".join(out)
192
+
193
+
194
+ _OFFICIAL_FIELDS = (
195
+ "subject_definitions:",
196
+ "summary:",
197
+ "retention_analysis:",
198
+ "detailed_description:",
199
+ "integrated_multimodal_description:",
200
+ "overall_soundscape:",
201
+ "non_diegetic_music:",
202
+ )
203
+
204
+ def append_section_line(text, name, line):
205
+ """Append a line to the end of an official field. No-op when absent.
206
+
207
+ Carries a hop-1 `tail` directive into a complete six-field block: `tail`
208
+ describes how the clip *ends*, so it belongs at the end of
209
+ detailed_description rather than wrapped around the outside, where it
210
+ would fight the official fields.
211
+ """
212
+ t = text or ""
213
+ line = str(line or "").strip()
214
+ if not line or line in t:
215
+ return t
216
+ marker = name if name.endswith(":") else name + ":"
217
+ start = t.find(marker)
218
+ if start < 0:
219
+ marker = "integrated_multimodal_description:"
220
+ start = t.find(marker)
221
+ if start < 0:
222
+ return t
223
+ end = len(t)
224
+ for other in _OFFICIAL_FIELDS:
225
+ if other == marker:
226
+ continue
227
+ i = t.find(other, start + len(marker))
228
+ if i != -1:
229
+ end = min(end, i)
230
+ return t[:end].rstrip() + "\n" + line + "\n\n" + t[end:].lstrip()
231
+
232
+
233
+ def is_full_h3_prompt(text):
234
+ t = text or ""
235
+ return "subject_definitions:" in t or "integrated_multimodal_description:" in t
236
+
237
+
238
+ def field_body(text, name):
239
+ """Inner text of one official field, or ''."""
240
+ t = text or ""
241
+ marker = name if str(name).endswith(":") else str(name) + ":"
242
+ start = t.find(marker)
243
+ if start < 0:
244
+ return ""
245
+ end = len(t)
246
+ for other in _OFFICIAL_FIELDS:
247
+ if other == marker:
248
+ continue
249
+ i = t.find(other, start + len(marker))
250
+ if i != -1:
251
+ end = min(end, i)
252
+ return t[start + len(marker):end].strip()
253
+
254
+
255
+ def flatten_official_continue(body):
256
+ """Hop 2+ cannot be a complete Ref2VA generate (chain_00030..00032).
257
+
258
+ Keep the action + soundscape. Drop subject_definitions / summary /
259
+ retention_analysis and a leading [Shot 1] — those start a new scene.
260
+ """
261
+ if not is_full_h3_prompt(body):
262
+ return body
263
+ dd = (field_body(body, "detailed_description")
264
+ or field_body(body, "integrated_multimodal_description")
265
+ or "")
266
+ dd = re.sub(r"^\[Shot 1\]\s*", "", dd.strip())
267
+ for prefix in (
268
+ "The clip opens already in progress from the pinned frames, "
269
+ "continuing the scene they leave off. ",
270
+ "The clip opens already in progress from the pinned frames. ",
271
+ "The clip opens on the action already in progress from the pinned frames. ",
272
+ ):
273
+ if dd.startswith(prefix):
274
+ dd = dd[len(prefix):].lstrip()
275
+ break
276
+ parts = []
277
+ if dd:
278
+ parts.append(dd)
279
+ sound = field_body(body, "overall_soundscape")
280
+ if sound:
281
+ parts.append("overall_soundscape:\n" + sound)
282
+ music = field_body(body, "non_diegetic_music")
283
+ if music:
284
+ parts.append("non_diegetic_music:\n" + music)
285
+ return "\n\n".join(parts)
286
+
287
+
288
+ def compile_shot(shot, hop_index, establish=None):
289
+ """Build the body text for one hop.
290
+
291
+ Returns the *beat* portion only. For hop 2+ the caller wraps this with the
292
+ identity lock and live-frame citation via _assemble_next, so this must not
293
+ duplicate either of those.
294
+
295
+ Hop 1 may be a complete official H3 block and is returned intact. Hop 2+
296
+ of a complete block is flattened to a continuation beat — a second full
297
+ Ref2VA generate is a new scene (chain_00030..00032).
298
+ """
299
+ beat = str((shot or {}).get("beat") or "").strip()
300
+ prose = str((shot or {}).get("prose") or "").strip()
301
+ body = "\n\n".join(p for p in (beat, prose) if p)
302
+ dirs = (shot or {}).get("directives")
303
+ if is_full_h3_prompt(body):
304
+ if hop_index == 0:
305
+ # A complete six-field block is one generate and is returned
306
+ # intact -- lead directives would fight its own field text. `tail`
307
+ # is the exception: one affirmative sentence about how the clip
308
+ # ends, which is what stops the last seconds settling onto a
309
+ # reference still, so it is appended inside detailed_description
310
+ # rather than dropped. The rest are reported, because silently
311
+ # ignoring dropdowns the editor still renders is how this went
312
+ # unnoticed.
313
+ body = append_section_line(
314
+ body, "detailed_description",
315
+ directive_prose(dirs, hop_index, axes=("tail",)))
316
+ ignored = sorted(
317
+ a for a in ("join", "camera", "framing", "pace")
318
+ if str((dirs or {}).get(a) or "").strip())
319
+ if ignored:
320
+ print("[HandTieClips] shot 1 is a complete H3 block; its own fields "
321
+ "win, so these directives are not applied: " + ", ".join(ignored),
322
+ flush=True)
323
+ return body
324
+ beat = flatten_official_continue(body)
325
+ prose = ""
326
+ lead = directive_prose(dirs, hop_index, axes=("join", "camera", "framing", "pace"))
327
+ tail = directive_prose(dirs, hop_index, axes=("tail",))
328
+
329
+ parts = []
330
+ if hop_index == 0:
331
+ # `beat` is shot 1's own text, so a plan that declares its own medium
332
+ # suppresses the live-action default rather than fighting it.
333
+ opening = establish_for(beat, establish)
334
+ if opening:
335
+ parts.append(opening)
336
+ if lead:
337
+ parts.append(lead)
338
+ if beat:
339
+ parts.append(beat)
340
+ if prose:
341
+ parts.append(prose)
342
+ if tail:
343
+ parts.append(tail)
344
+ return "\n\n".join(parts)
h3_ref_chain.py ADDED
@@ -0,0 +1,1943 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native MiniMax H3 Ref2VA chain: several generates joined into one clip.
2
+
3
+ Hop 1 is a full Ref2VA generate and may carry an official six-field H3 prompt.
4
+ Hop 2+ is a *continuation*, not a second generate: a continuation beat with the
5
+ previous hop pinned in front of it.
6
+
7
+ The pin is the whole point. Hop 2+ slices the previous hop's sampler AV latent
8
+ through MiniMaxH3MotionContext, which keeps the join in the latent domain and
9
+ end-aligns the audio window. MiniMaxH3AddGuide on decoded pixels is the
10
+ fallback -- taken when Motion-Context is missing, or when the previous hop came
11
+ from the cache and so has no sampler latent to slice.
12
+
13
+ Authoring is `shot_plan` + `ref_plan`, both JSON strings, both edited by the DOM
14
+ panel in js/ and both the single source of truth. The legacy `prompt` widget
15
+ (optional --- / JSON blocks) still runs for `hop_script=verbatim`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import queue
22
+ import threading
23
+ import io as pyio
24
+ import json
25
+ import math
26
+ import os
27
+ import re
28
+
29
+ import torch
30
+
31
+ try:
32
+ from server import PromptServer
33
+ except Exception:
34
+ PromptServer = None
35
+
36
+ import comfy.model_management as mm
37
+ import comfy.samplers
38
+ import comfy.utils
39
+ from comfy_extras.nodes_audio import vae_decode_audio
40
+ from comfy_extras.nodes_custom_sampler import (
41
+ BasicGuider,
42
+ BasicScheduler,
43
+ KSamplerSelect,
44
+ RandomNoise,
45
+ SamplerCustomAdvanced,
46
+ )
47
+ try:
48
+ from comfy_extras.nodes_minimax_h3 import (
49
+ MiniMaxH3AddGuide,
50
+ MiniMaxH3ReferenceToVideo,
51
+ MiniMaxH3SigmaShift,
52
+ align_frame_count,
53
+ )
54
+ except ImportError as _exc: # pragma: no cover - depends on the host build
55
+ # The one failure a first-time installer is actually likely to hit. Left
56
+ # bare it surfaces as "cannot import name 'MiniMaxH3AddGuide'", which says
57
+ # nothing about what to do. The pack cannot work without these, so it still
58
+ # refuses to load -- it just says why.
59
+ raise ImportError(
60
+ "Hand Tie Clips needs MiniMax H3 support in ComfyUI itself "
61
+ "(comfy_extras/nodes_minimax_h3.py, ComfyUI PR #15439). Update ComfyUI "
62
+ "to a build that ships it, then restart. Original error: %s" % _exc
63
+ ) from _exc
64
+ from nodes import VAEDecode
65
+
66
+ from . import directives as _d
67
+ from . import plan as _plan
68
+ from . import refs as _refs
69
+ from . import store as _store
70
+ from . import media as _media
71
+ from . import tone as _tone
72
+ # One definition, in refs.py -- routes.py publishes that copy to the editor, so
73
+ # a second constant here meant the node's slot count and the number the UI was
74
+ # told could drift apart.
75
+ from .refs import MAX_REF_IMAGES
76
+
77
+ FPS = 24
78
+ TAG = "HandTieClips"
79
+
80
+ # H3 canvas: multiples of 32, short-edge ~768, area cap 768*1344.
81
+ CANVAS = {
82
+ "0.2 MP": {
83
+ "16:9 landscape": (608, 352),
84
+ "9:16 portrait": (352, 608),
85
+ "1:1 square": (448, 448),
86
+ },
87
+ "0.3 MP": {
88
+ "16:9 landscape": (736, 416),
89
+ "9:16 portrait": (416, 736),
90
+ "1:1 square": (544, 544),
91
+ },
92
+ "0.5 MP": {
93
+ "16:9 landscape": (960, 544),
94
+ "9:16 portrait": (544, 960),
95
+ "1:1 square": (704, 704),
96
+ },
97
+ "0.7 MP": {
98
+ "16:9 landscape": (1120, 640),
99
+ "9:16 portrait": (640, 1120),
100
+ "1:1 square": (832, 832),
101
+ },
102
+ "1.0 MP": {
103
+ "16:9 landscape": (1280, 736),
104
+ "9:16 portrait": (736, 1280),
105
+ "1:1 square": (992, 992),
106
+ },
107
+ }
108
+ DURATION_FRAMES = {
109
+ # Every value satisfies align_frame_count (n % 17 == 5) at FPS 24, so the
110
+ # label and the frames the model actually renders agree to a tenth.
111
+ "5 s": 124,
112
+ "7 s": 175,
113
+ "8 s": 192,
114
+ "10 s": 243,
115
+ "15 s": 362,
116
+ }
117
+ OVERLAP_FRAMES = {
118
+ "0.9 s": 22,
119
+ "0.2 s": 5,
120
+ "1.6 s": 39,
121
+ }
122
+ # MiniMaxH3MotionContext.apply takes `context_length` as a *string* combo and
123
+ # accepts only these values. Derived from OVERLAP_FRAMES so the two cannot
124
+ # drift: add an overlap without a matching context_length and the pin would
125
+ # silently clamp to 22 while the master trims the real value -- a misaligned
126
+ # seam with no error. _pin_continue logs and falls back instead.
127
+ MC_CONTEXT_LENGTHS = frozenset(str(v) for v in OVERLAP_FRAMES.values())
128
+
129
+
130
+ def _canvas(resolution, aspect):
131
+ try:
132
+ return CANVAS[str(resolution)][str(aspect)]
133
+ except KeyError:
134
+ return (1280, 736)
135
+
136
+
137
+ def _duration_frames(duration):
138
+ return int(DURATION_FRAMES.get(str(duration), 243))
139
+
140
+
141
+ def _overlap_frames(overlap):
142
+ key = str(overlap)
143
+ if key in OVERLAP_FRAMES:
144
+ return OVERLAP_FRAMES[key]
145
+ return int(overlap)
146
+
147
+ # Prompt phrasing rule: AFFIRMATIVE ONLY.
148
+ # Sampling runs through BasicGuider at cfg 1.0 with no negative branch, so every
149
+ # concept named in the prompt is additive and cannot be subtracted -- "Do not
150
+ # restart the scene" puts `restart` in front of the encoder. State what the shot
151
+ # IS doing, never what it must not do. Keep this rule when editing below.
152
+ CONTINUE_PREFIX = (
153
+ "The clip opens on the action already in progress from the pinned frames. "
154
+ "The same people continue from where the pinned frames leave off, in the same "
155
+ "wardrobe, the same room, and the same lighting. "
156
+ "After a brief hold, the action carries forward from its current point.\n\n"
157
+ )
158
+
159
+ ADVANCE_BEAT = (
160
+ "The action already in progress carries forward from its current point."
161
+ )
162
+
163
+ MAX_REF_VIDEOS = 3
164
+
165
+
166
+ def _result(out):
167
+ if hasattr(out, "args"):
168
+ return out.args
169
+ if isinstance(out, (tuple, list)):
170
+ return tuple(out)
171
+ return (out,)
172
+
173
+
174
+ def _model_fingerprint(model):
175
+ """Identify the incoming MODEL by what has been patched onto it.
176
+
177
+ The hop cache has to notice when a hop was rendered under a different LoRA
178
+ stack or a different attention path, or it will happily serve frames that
179
+ do not belong to the current graph -- silently wrong output, which is worse
180
+ than no cache at all. When the patch nodes were a widget on this node the
181
+ parsed plan went into the key directly; with them drawn upstream the only
182
+ thing available is the ModelPatcher itself.
183
+
184
+ Cheap and content-derived: the set of weight keys any LoRA touched plus the
185
+ per-key strength scalars, and the scalar half of `transformer_options`,
186
+ which is where the SLA and low-VRAM attention overrides land. Patch *values*
187
+ are tensors and are deliberately not hashed.
188
+
189
+ `patches_uuid` is not usable here: `ModelPatcher.add_patches` assigns a
190
+ fresh `uuid4()` on every call, so it would change every run and bust the
191
+ cache even when nothing about the graph moved.
192
+
193
+ Known collision: two different LoRAs touching an identical key set at
194
+ identical strengths fingerprint the same. Rare, and the alternative costs a
195
+ full state-dict walk per run.
196
+ """
197
+ h = hashlib.sha256()
198
+ patches = getattr(model, "patches", None) or {}
199
+ for key in sorted(patches):
200
+ h.update(str(key).encode())
201
+ for entry in patches[key]:
202
+ # (strength_patch, weights, strength_model, offset, function)
203
+ try:
204
+ h.update(f"{float(entry[0]):.6g}".encode())
205
+ if len(entry) > 2 and isinstance(entry[2], (int, float)):
206
+ h.update(f"{float(entry[2]):.6g}".encode())
207
+ except (TypeError, ValueError, IndexError):
208
+ h.update(b"?")
209
+
210
+ opts = getattr(model, "model_options", None) or {}
211
+ transformer = opts.get("transformer_options") or {}
212
+
213
+ def _closure_scalars(fn):
214
+ """The scalar settings a callable closed over.
215
+
216
+ H3-SLA-Attention installs its config by closure --
217
+ `_make_override(state, float(sparsity_ratio), blkq, blkk,
218
+ int(min_seq_len), bool(protect_audio))` -- so a callable rendered as
219
+ `type(fn).__name__` hashes to the bare string "function" and SLA's
220
+ settings vanish from the key. Changing sparsity 0.90 -> 0.50 then left
221
+ the fingerprint unmoved and the cache served hops rendered under a
222
+ different attention path.
223
+
224
+ Scalars only, deliberately: the first cell is a mutable `state` dict the
225
+ sampler counts into during the run, and hashing that would change the
226
+ fingerprint on every queue and never hit the cache at all.
227
+ """
228
+ parts = [getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "")]
229
+ for cell in (getattr(fn, "__closure__", None) or ()):
230
+ try:
231
+ v = cell.cell_contents
232
+ except ValueError: # empty cell, e.g. a recursive closure
233
+ parts.append("?")
234
+ continue
235
+ parts.append(repr(v)
236
+ if isinstance(v, (str, int, float, bool)) or v is None
237
+ else type(v).__name__)
238
+ return "fn(" + ",".join(parts) + ")"
239
+
240
+ def _scalars(obj, depth=0):
241
+ """Only names and scalars -- tensors and mutable state are not stable."""
242
+ if depth > 3:
243
+ return "..."
244
+ if isinstance(obj, dict):
245
+ # sorted(obj, key=str), not sorted(map(str, obj)): stringifying the
246
+ # keys first drops every non-str key from the hash, because the
247
+ # `k in obj` guard then fails against the real key.
248
+ return "{" + ",".join(
249
+ f"{k}:{_scalars(obj[k], depth + 1)}" for k in sorted(obj, key=str)
250
+ ) + "}"
251
+ if isinstance(obj, (list, tuple)):
252
+ return "[" + ",".join(_scalars(v, depth + 1) for v in obj) + "]"
253
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
254
+ return repr(obj)
255
+ if callable(obj):
256
+ return _closure_scalars(obj)
257
+ return type(obj).__name__
258
+
259
+ h.update(_scalars(transformer).encode())
260
+ return h.hexdigest()[:16]
261
+
262
+
263
+ def _parse_shots(text):
264
+ text = (text or "").strip()
265
+ if not text:
266
+ raise ValueError(f"{TAG}: prompt is empty")
267
+ if text.startswith("{") or text.startswith("["):
268
+ try:
269
+ data = json.loads(text)
270
+ except json.JSONDecodeError as e:
271
+ raise ValueError(f"{TAG}: prompt looks like JSON but does not parse ({e})") from e
272
+ if isinstance(data, dict):
273
+ shots = [str(p).strip() for p in data.get("prompts", []) if str(p).strip()]
274
+ elif isinstance(data, list):
275
+ shots = [str(p).strip() for p in data if str(p).strip()]
276
+ else:
277
+ shots = []
278
+ if shots:
279
+ return shots
280
+ parts = [b.strip() for b in re.split(r"(?m)^---\s*$", text) if b.strip()]
281
+ return parts or [text]
282
+
283
+
284
+ def _parse_state(text):
285
+ """continuity_state input: blank -> no-op, else a JSON object (from HTCContinuityState)."""
286
+ text = (text or "").strip()
287
+ if not text:
288
+ return {}
289
+ try:
290
+ data = json.loads(text)
291
+ except json.JSONDecodeError as e:
292
+ raise ValueError(f"{TAG}: continuity_state looks like JSON but does not parse ({e})") from e
293
+ if not isinstance(data, dict):
294
+ raise ValueError(f"{TAG}: continuity_state must be a JSON object")
295
+ return data
296
+
297
+
298
+ def _continue_prompt(block):
299
+ """Wrap a verbatim-mode block as a continuation.
300
+
301
+ Never rewrite the official summary task types here. This used to replace
302
+ `[keyframe completion]` with `[video continuation + reference generation]`,
303
+ which is what made hop 2 of chain_00030/00031 a new stills generate instead
304
+ of a first-frame continue. Combine types with ` + `; never drop one that is
305
+ already present.
306
+ """
307
+ text = (block or "").strip()
308
+ if _d.is_full_h3_prompt(text):
309
+ text = _d.flatten_official_continue(text)
310
+ return CONTINUE_PREFIX + (text or ADVANCE_BEAT)
311
+
312
+
313
+ def _expand_shots(blocks, chains, hop_script="verbatim"):
314
+ original = list(blocks)
315
+ if len(blocks) > chains:
316
+ print(f"[{TAG}] dropping {len(blocks) - chains} extra --- block(s)", flush=True)
317
+ blocks = blocks[:chains]
318
+ unique = len(original) if len(original) <= chains else chains
319
+ while len(blocks) < chains:
320
+ if hop_script == "next" and unique == 1:
321
+ blocks.append("")
322
+ else:
323
+ blocks.append(blocks[-1])
324
+ if hop_script == "next":
325
+ return blocks, unique
326
+ out = []
327
+ for i, block in enumerate(blocks):
328
+ wrap = i > 0 and (unique == 1 or i >= unique)
329
+ out.append(_continue_prompt(block) if wrap else block)
330
+ return out, unique
331
+
332
+
333
+ def _state_entry_text(entry, hop_index):
334
+ """locked (verbatim) + context (current-state) + this hop's mutable beat."""
335
+ if not isinstance(entry, dict):
336
+ return ""
337
+ locked = str(entry.get("locked") or "").strip()
338
+ context = str(entry.get("context") or "").strip()
339
+ mutable = entry.get("mutable") or []
340
+ if isinstance(mutable, str):
341
+ mutable = _parse_shots(mutable) if mutable.strip() else []
342
+ mutable = [str(b).strip() for b in mutable if str(b).strip()]
343
+ beat = ""
344
+ if mutable:
345
+ idx = hop_index if hop_index < len(mutable) else len(mutable) - 1
346
+ beat = mutable[idx]
347
+ return "\n".join(p for p in (locked, context, beat) if p)
348
+
349
+
350
+ def _state_header(state, hop_index):
351
+ """Compose the continuity_state block for this hop (locked/context every hop, mutable indexed)."""
352
+ if not state:
353
+ return ""
354
+ sections = []
355
+ setting_text = _state_entry_text(state.get("setting") or {}, hop_index)
356
+ if setting_text:
357
+ sections.append("setting:\n" + setting_text)
358
+ for char_id, entry in (state.get("characters") or {}).items():
359
+ char_text = _state_entry_text(entry, hop_index)
360
+ if char_text:
361
+ sections.append(f"character {char_id}:\n" + char_text)
362
+ return "\n\n".join(sections)
363
+
364
+
365
+ def _identity_lock(n_stills, live_picture, identity_ordinals=None, n_subjects=None):
366
+ """Name the pictures that are people, and only those.
367
+
368
+ `identity_ordinals` comes from the reference register, which is the only
369
+ thing that knows a picture is a face rather than a room. Without it this
370
+ falls back to "every wired still is an identity", which is what it always
371
+ did and is right when nothing better is known -- but with a register wired
372
+ that fallback tells the encoder a photograph of a kitchen has a face and a
373
+ hairstyle to match exactly. At cfg 1.0 there is no negative branch, so that
374
+ is additive noise on every hop.
375
+
376
+ `n_subjects` drives number agreement, not the picture count: two
377
+ photographs of one person is still one identity.
378
+ """
379
+ ords = (list(identity_ordinals) if identity_ordinals is not None
380
+ else list(range(1, int(n_stills) + 1)))
381
+ if not ords:
382
+ # A register with no subject-bearing refs -- setting plates only. There
383
+ # is no identity to lock, and _live_cite still cites the live frame.
384
+ return ""
385
+ pics = ", ".join(f"<Picture {i}>" for i in ords)
386
+ count = int(n_subjects) if n_subjects is not None else len(ords)
387
+ # Qwen3-VL is a language encoder, so number agreement is not cosmetic:
388
+ # "<Picture 1> are the only identities" is what a single wired ref produced.
389
+ if count == 1:
390
+ line = (
391
+ f"{pics} is the only identity. That face, bone structure, and hairstyle "
392
+ "match the photograph exactly."
393
+ ) if len(ords) == 1 else (
394
+ f"{pics} are the same one person, and the only identity. That face, bone "
395
+ "structure, and hairstyle match those photographs exactly."
396
+ )
397
+ else:
398
+ line = (
399
+ f"{pics} are the only identities. Each face, bone structure, and hairstyle "
400
+ "matches its photograph exactly."
401
+ )
402
+ if live_picture and live_picture not in ords:
403
+ who = ("that same person as they stand" if count == 1
404
+ else "those same people as they stand")
405
+ line += f" <Picture {live_picture}> shows {who} right now, mid-action."
406
+ return line
407
+
408
+
409
+ def _live_cite(live_picture, live_video):
410
+ bits = []
411
+ if live_picture:
412
+ bits.append(
413
+ f"<Picture {live_picture}> is the live frame at the start of this clip, "
414
+ "already in progress from the pinned tail."
415
+ )
416
+ if live_video:
417
+ bits.append(
418
+ f"<Video {live_video}> is the pinned tail of the previous clip and the first "
419
+ "moments of this generate. The motion continues at the speed it already has."
420
+ )
421
+ return " ".join(bits)
422
+
423
+
424
+ def _assemble_next(beat, live_picture=None, live_video=None,
425
+ n_stills=0, state_header="",
426
+ identity_ordinals=None, n_subjects=None, tail=None,
427
+ continuity=""):
428
+ """Hop 2+ in `next` mode: user text is only the new beat."""
429
+ text = (beat or "").strip() or ADVANCE_BEAT
430
+ cite = _live_cite(live_picture, live_video)
431
+ lock = _identity_lock(n_stills, live_picture,
432
+ identity_ordinals=identity_ordinals,
433
+ n_subjects=n_subjects)
434
+ solo = (n_subjects == 1) or (n_subjects is None and identity_ordinals is not None
435
+ and len(identity_ordinals) == 1)
436
+ whoever = "The same person holds" if solo else "The same people hold"
437
+ if _d.is_full_h3_prompt(text):
438
+ print(
439
+ f"[{TAG}] hop 2+ full H3 block flattened to a continuation beat "
440
+ "(a complete Ref2VA prompt on hop 2+ starts a new scene)",
441
+ flush=True,
442
+ )
443
+ text = _d.flatten_official_continue(text) or ADVANCE_BEAT
444
+ # No identity header on hop 2+ at all: register subject_prose is a
445
+ # subject_definitions block, and putting one here made hop 2 a second
446
+ # Ref2VA generate (chain_00033, the short drink beat). The call site passes
447
+ # nothing, and there is no parameter to pass.
448
+ header = str(state_header or "").strip()
449
+ top = header + "\n\n" if header else ""
450
+ # Order: who the pictures are, then what stays the same, then which
451
+ # frame is live. `continuity` carries no ordinals, so it is safe on a
452
+ # pin-only hop where `lock` is deliberately empty.
453
+ inject = " ".join(p for p in (lock, str(continuity or "").strip(), cite) if p)
454
+ inject = (inject + "\n\n") if inject else "\n"
455
+ # Pin-only hops have no identity photographs. Naming them (chain_00034)
456
+ # sent the encoder back to the face/outfit stills — commercial kitchen,
457
+ # grey shirt, no apron.
458
+ # The closer used to end on "still underway" unconditionally, which made it
459
+ # the last sentence of the prompt and so overrode the shot's own `tail`
460
+ # directive -- `settle` and `hold` were unreachable on hop 2+. On the final
461
+ # hop nothing absorbs that instruction and the model invents late action to
462
+ # satisfy it (observed: a line of dialogue in the last second of a 3-hop
463
+ # chain, and the ~23 s spike noted in CLAUDE.md). Each variant below still
464
+ # ends on the clip's terminal *state*, never on the photographs, so the
465
+ # ordering rule documented at the return is preserved.
466
+ terminal = {
467
+ "settle": "and that action eases to a rest and stays there through the "
468
+ "final frames.",
469
+ "hold": "and the final position holds steady through the last moments.",
470
+ }.get(str(tail or "").strip(), "and that action is still underway as the clip ends.")
471
+
472
+ if lock:
473
+ hold = (
474
+ f"{whoever} their current pose, room, lighting, and camera side, "
475
+ "and the shot continues from exactly there. "
476
+ )
477
+ closer = (
478
+ "Faces and hair follow the identity photographs. Clothing follows "
479
+ "whatever is already on them in the live frame. After a brief hold "
480
+ "on the incoming action, the shot advances through what the "
481
+ f"next-beat describes, {terminal}"
482
+ )
483
+ else:
484
+ hold = (
485
+ "The incoming frame holds the current pose, room, lighting, and "
486
+ "camera side, and the shot continues from exactly there. "
487
+ )
488
+ closer = (
489
+ "Wardrobe, room, and lighting stay as they are in the live frame. "
490
+ "After a brief hold on the incoming action, the shot advances "
491
+ f"through what the next-beat describes, {terminal}"
492
+ )
493
+ # Official field names on hop 2+ start a new Ref2VA generate
494
+ # (chain_00030..00034). One paragraph: airlock, then the beat.
495
+ text = re.sub(
496
+ r"(?m)^(overall_soundscape|non_diegetic_music):\s*", "", text).strip()
497
+ return (
498
+ f"{top}"
499
+ "The clip opens already in progress from the pinned frames. "
500
+ "The incoming arrangement holds for a short beat -- breath, a weight "
501
+ "shift, an eyeline -- and only then the next action begins. "
502
+ f"{hold}"
503
+ f"{inject}"
504
+ "What happens next:\n"
505
+ f"{text}\n\n"
506
+ # Ordering rule: the prompt must END on ongoing motion, never on the
507
+ # photographs. The final sentence governs the terminal state of the clip,
508
+ # and when the beat action finishes before the frames run out the model
509
+ # renders whatever the prompt last pointed it at. Ending on "follow the
510
+ # identity photographs" made the tail settle onto the reference image --
511
+ # observed as the last ~3s of a 3-hop chain cutting to the ref still.
512
+ # Hops 1..N-1 hide this because the pin consumes their tail; the final
513
+ # hop has no successor, so its drift is what you see.
514
+ f"{closer}"
515
+ )
516
+
517
+
518
+ def _attach_pin_to_qwen(pin_mode, hop_images, hop_videos, last_frame, pin_clip):
519
+ """AddGuide is invisible to Qwen. Optionally put the incoming state in ref slots.
520
+
521
+ The live frame is <Picture 1>. Identity stills shift up. Appending it after
522
+ the stills (chain_00034) made the pin Picture 4 against a commercial-kitchen
523
+ face and outfit as Pictures 1–2; hop 2 hard-cut and dropped the apron.
524
+ """
525
+ images = dict(hop_images or {})
526
+ videos = dict(hop_videos or {})
527
+ live_p = live_v = None
528
+ if pin_mode in ("last frame", "both") and last_frame is not None:
529
+ used = len(images)
530
+ if used >= MAX_REF_IMAGES:
531
+ print(f"[{TAG}] pin_to_qwen last frame skipped: already {MAX_REF_IMAGES} stills",
532
+ flush=True)
533
+ else:
534
+ # Insertion order IS Picture order (core walks .values()).
535
+ # Put the live frame in the dict first or it becomes the last picture.
536
+ ordered = {"ref_image_1": last_frame[:1].contiguous()}
537
+ for key, tensor in images.items():
538
+ n = int(str(key).rsplit("_", 1)[-1])
539
+ ordered[f"ref_image_{n + 1}"] = tensor
540
+ images = ordered
541
+ live_p = 1
542
+ extra = f", {used} still(s) -> Picture 2+" if used else ""
543
+ print(f"[{TAG}] Qwen last frame -> <Picture 1>{extra}", flush=True)
544
+ if pin_mode in ("pin clip", "both") and pin_clip is not None:
545
+ if pin_clip.shape[0] < 5:
546
+ print(f"[{TAG}] pin clip too short for a video ref "
547
+ f"({int(pin_clip.shape[0])}f)", flush=True)
548
+ elif len(videos) >= MAX_REF_VIDEOS:
549
+ print(f"[{TAG}] pin_to_qwen pin clip skipped: already {MAX_REF_VIDEOS} videos",
550
+ flush=True)
551
+ else:
552
+ live_v = len(videos) + 1
553
+ videos[f"ref_video_{live_v}"] = pin_clip.contiguous()
554
+ print(
555
+ f"[{TAG}] Qwen pin clip -> <Video {live_v}> "
556
+ f"({int(pin_clip.shape[0])}f, no soundtrack — voice stays <Audio 1>)",
557
+ flush=True,
558
+ )
559
+ return images, live_p, videos, live_v
560
+
561
+
562
+ def _latent_cpu(lat):
563
+ """Keep the previous hop's sampler output off GPU between hops."""
564
+ if not isinstance(lat, dict) or "samples" not in lat:
565
+ return lat
566
+ out = dict(lat)
567
+ samples = lat["samples"]
568
+ try:
569
+ out["samples"] = samples.cpu()
570
+ except Exception as e:
571
+ # Falling back to the GPU tensor is correct -- the pin still works --
572
+ # but it is also a per-hop VRAM leak, so it must not be silent.
573
+ print(f"[{TAG}] could not move the hop latent to CPU ({e!r}); "
574
+ f"keeping it on device", flush=True)
575
+ out["samples"] = samples
576
+ return out
577
+
578
+
579
+ def _motion_context_cls():
580
+ """Upstream MiniMaxH3MotionContext, skipping forks with a different apply()."""
581
+ try:
582
+ import inspect
583
+ import nodes as nodes_mod
584
+ except Exception:
585
+ return None
586
+ cls = getattr(nodes_mod, "NODE_CLASS_MAPPINGS", {}).get("MiniMaxH3MotionContext")
587
+
588
+ def compatible(c):
589
+ try:
590
+ params = inspect.signature(c.apply).parameters
591
+ need = [k for k, v in params.items()
592
+ if v.default is inspect.Parameter.empty and k != "self"]
593
+ return "context_frames" not in need
594
+ except Exception:
595
+ return False
596
+
597
+ if cls is not None and compatible(cls):
598
+ return cls
599
+ import sys
600
+ for mod in list(sys.modules.values()):
601
+ cand = getattr(mod, "MiniMaxH3MotionContext", None)
602
+ if cand is not None and compatible(cand):
603
+ if cls is not None:
604
+ print(
605
+ f"[{TAG}] MiniMaxH3MotionContext registry entry is a fork; "
606
+ f"using upstream class from {getattr(mod, '__name__', '?')}",
607
+ flush=True,
608
+ )
609
+ return cand
610
+ return None
611
+
612
+
613
+ def _latent_parts(x):
614
+ """The component tensors inside a sampler `samples`, as a flat list.
615
+
616
+ H3 hands back a `comfy.nested_tensor.NestedTensor` -- the video and audio
617
+ latents in one object -- and it is not a Tensor. It has no `.std()`, and
618
+ the attributes it *does* expose are traps: `.shape` returns
619
+ `tensors[0].shape`, i.e. the video component's shape while silently
620
+ speaking for both. Reading the components is the only honest way to touch
621
+ the numbers. Returns None for anything unrecognised, which callers treat as
622
+ "leave this latent alone".
623
+ """
624
+ if isinstance(x, torch.Tensor):
625
+ return [x]
626
+ if getattr(x, "is_nested", False) and hasattr(x, "unbind"):
627
+ parts = list(x.unbind())
628
+ if parts and all(isinstance(t, torch.Tensor) for t in parts):
629
+ return parts
630
+ return None
631
+
632
+
633
+ def _rebuild_latent_samples(x, parts):
634
+ """Put conditioned components back into the container they came from."""
635
+ if isinstance(x, torch.Tensor):
636
+ return parts[0]
637
+ return type(x)(parts)
638
+
639
+
640
+ def _condition_pin_latent(lat, anchor_std, renorm=False, noise=0.0, seed=0):
641
+ """Anti-ratchet preprocessing for the latent handed to Motion-Context.
642
+
643
+ MiniMaxH3MotionContext.apply() takes `context_latent` as-is and exposes no
644
+ hook, so both levers have to be applied to the latent before it goes in.
645
+
646
+ `renorm` rescales this pin so its standard deviation matches hop 2's. The
647
+ pin's own sigma climbs hop over hop, and because that inflated pin is what
648
+ conditions the next hop the growth compounds upstream of anything a master
649
+ pass can reach. A scalar rescale moves no structure, so it cannot blur
650
+ detail. `noise` mixes in a seeded perturbation, which attacks the same
651
+ ratchet from the other side; measured gains reverse above 0.10, hence the
652
+ widget cap.
653
+
654
+ **Per component, not per latent** (fixed 2026-08-27). Video and audio are
655
+ two tensors in one NestedTensor and their sigmas drift independently, so
656
+ each carries its own anchor. Before this, `.std()` raised on the nested
657
+ object and both levers were dead -- the failure announced itself once per
658
+ hop as `pin conditioning skipped` and was easy to read as routine noise.
659
+ A single global scale would also have been wrong on its own terms, letting
660
+ the much larger video component dictate the audio's correction.
661
+
662
+ Returns `(latent, new_anchor_std)` where the anchor is a list, one sigma
663
+ per component -- hop 2 establishes what hops 3+ are matched against. Both
664
+ levers default off, in which case the latent is returned untouched.
665
+ """
666
+ if not isinstance(lat, dict) or "samples" not in lat:
667
+ return lat, anchor_std
668
+ x = lat["samples"]
669
+ parts = _latent_parts(x)
670
+ if parts is None:
671
+ print(f"[{TAG}] pin conditioning skipped: unrecognised latent "
672
+ f"({type(x).__name__})", flush=True)
673
+ return lat, anchor_std
674
+ try:
675
+ cur = [float(t.float().std()) for t in parts]
676
+ except Exception as e: # noqa: BLE001
677
+ print(f"[{TAG}] pin conditioning skipped ({e!r})", flush=True)
678
+ return lat, anchor_std
679
+ if not all(c == c and c for c in cur): # zero or NaN in any stream
680
+ return lat, anchor_std
681
+ if anchor_std is None:
682
+ anchor_std = cur
683
+ if len(anchor_std) != len(cur):
684
+ # Stream count changed mid-chain. Nothing sensible to match against.
685
+ print(f"[{TAG}] pin conditioning skipped: latent has {len(cur)} "
686
+ f"component(s), anchor has {len(anchor_std)}", flush=True)
687
+ return lat, anchor_std
688
+ if not renorm and noise <= 0.0:
689
+ return lat, anchor_std
690
+ out_parts, notes = [], []
691
+ for idx, (t, c, a) in enumerate(zip(parts, cur, anchor_std)):
692
+ o = t
693
+ if renorm:
694
+ scale = a / c
695
+ o = o * scale
696
+ notes.append(f"renorm[{idx}] x{scale:.4f} (sigma {c:.4f} -> {a:.4f})")
697
+ if noise > 0.0:
698
+ # Per component: `.shape` on the nested object reports only the
699
+ # first component's shape, so one draw for the whole latent would
700
+ # size its noise to the video and broadcast that onto the audio.
701
+ g = torch.Generator(device="cpu").manual_seed((int(seed) + idx) & 0x7FFFFFFF)
702
+ n = torch.randn(o.shape, generator=g, dtype=torch.float32)
703
+ o = o + n.to(dtype=o.dtype, device=o.device) * (float(noise) * a)
704
+ notes.append(f"noise[{idx}] {float(noise):.3f}")
705
+ out_parts.append(o)
706
+ if notes:
707
+ print(f"[{TAG}] pin conditioning: " + ", ".join(notes), flush=True)
708
+ new = dict(lat)
709
+ new["samples"] = _rebuild_latent_samples(x, out_parts)
710
+ return new, anchor_std
711
+
712
+
713
+ def _pin_mech_for(hop_index, overlap_n, prev_sampled):
714
+ """Which mechanism `_pin_continue` will pick, without doing the work.
715
+
716
+ The hop cache key has to be built *before* the pin runs, and the two
717
+ mechanisms produce different frames, so the key needs the mechanism up
718
+ front. Every condition here mirrors _pin_continue; the one thing it cannot
719
+ predict is Motion-Context raising at call time, which the caller catches by
720
+ comparing this against the mechanism actually used and declining to cache
721
+ that hop.
722
+ """
723
+ if hop_index == 0:
724
+ return "none"
725
+ if _motion_context_cls() is None:
726
+ return "addguide_pixels"
727
+ if str(overlap_n) not in MC_CONTEXT_LENGTHS:
728
+ return "addguide_pixels"
729
+ if prev_sampled is None:
730
+ return "addguide_pixels"
731
+ return "motion_context"
732
+
733
+
734
+ def _pin_continue(cond, latent, vae, audio_vae, overlap_n,
735
+ prev_sampled, prev_imgs, prev_audio, audio_ctx=24):
736
+ """Hop 2+ motion pin. Latent Motion-Context when possible; AddGuide otherwise.
737
+
738
+ AddGuide re-encodes decoded pixels and anchors audio forwards from frame 0
739
+ (cover-band soundtrack). Motion-Context slices the previous sampler AV
740
+ latent and end-aligns the audio window on this clip's timeline.
741
+
742
+ Returns `(conditioning, mech)` where mech is one of "motion_context",
743
+ "addguide_pixels" or "none". The caller puts mech in the *per-hop* cache
744
+ key: the two mechanisms produce different frames, so a hop rendered under
745
+ the AddGuide fallback must not be served later to a run where the latent
746
+ pin was available.
747
+ """
748
+ ctx_label = str(overlap_n)
749
+ mc = _motion_context_cls()
750
+ if mc is not None and ctx_label not in MC_CONTEXT_LENGTHS:
751
+ print(
752
+ f"[{TAG}] overlap {overlap_n}f has no Motion-Context context_length "
753
+ f"(accepts {sorted(MC_CONTEXT_LENGTHS, key=int)}); AddGuide pixel pin",
754
+ flush=True,
755
+ )
756
+ mc = None
757
+ if mc is not None and prev_sampled is not None:
758
+ try:
759
+ a_ctx = int(audio_ctx)
760
+ cond, trim = mc().apply(
761
+ conditioning=cond, vae=vae, latent=latent,
762
+ context_length=ctx_label, audio_context_length=a_ctx,
763
+ context_latent=prev_sampled,
764
+ )
765
+ print(
766
+ f"[{TAG}] Motion-Context pin: previous hop latent "
767
+ f"({ctx_label}f picture, {a_ctx}f audio, trim {trim})",
768
+ flush=True,
769
+ )
770
+ return cond, "motion_context"
771
+ except Exception as e:
772
+ # This downgrade changes the join, so it is logged with the real
773
+ # exception rather than swallowed -- repr(), because a bare
774
+ # TypeError from a renamed upstream kwarg stringifies to nothing
775
+ # useful.
776
+ print(
777
+ f"[{TAG}] Motion-Context pin failed ({e!r}); AddGuide pixel pin",
778
+ flush=True,
779
+ )
780
+ if mc is None:
781
+ print(
782
+ f"[{TAG}] Motion-Context not available; AddGuide pixel pin "
783
+ f"({overlap_n}f). Install ComfyUI-H3-Motion-Context for a latent join.",
784
+ flush=True,
785
+ )
786
+ elif prev_sampled is None:
787
+ print(
788
+ f"[{TAG}] previous hop has no sampler latent (cache hit); "
789
+ f"AddGuide pixel pin ({overlap_n}f)",
790
+ flush=True,
791
+ )
792
+ pin_image = prev_imgs[-overlap_n:] if prev_imgs is not None else None
793
+ pin_audio = _tail_audio(prev_audio, overlap_n) if prev_audio is not None else None
794
+ if pin_image is None and pin_audio is None:
795
+ return cond, "none"
796
+ return _result(MiniMaxH3AddGuide.execute(
797
+ cond, latent, 0,
798
+ vae=vae if pin_image is not None else None,
799
+ audio_vae=audio_vae if pin_audio is not None else None,
800
+ image=pin_image,
801
+ audio=pin_audio,
802
+ ))[0], "addguide_pixels"
803
+
804
+
805
+ def _collect_ref_images(slot_images):
806
+ """Dense-pack the wired slots into <Picture N> order.
807
+
808
+ Takes the slot -> tensor map the caller already built, rather than reading
809
+ the nine node inputs a second time. Gathering them twice -- once here, once
810
+ for `slot_images` -- is how a slot goes missing from one of the two and
811
+ silently renumbers every later <Picture N>, which is the exact failure
812
+ refs.py exists to prevent.
813
+ """
814
+ frames = [slot_images[s] for s in sorted(slot_images)]
815
+ if not frames:
816
+ return None
817
+ bits = []
818
+ for i, im in enumerate(frames):
819
+ bits.append(f"<Picture {i + 1}> {int(im.shape[2])}x{int(im.shape[1])}")
820
+ print(f"[{TAG}] {len(frames)} reference image(s) -> " + ", ".join(bits), flush=True)
821
+ if len(frames) < 3:
822
+ print(f"[{TAG}] warning: fewer than 3 stills. A reference with no "
823
+ "picture chosen does not count.", flush=True)
824
+ return {f"ref_image_{i + 1}": frames[i] for i in range(len(frames))}
825
+
826
+
827
+ def _audio_samples(frames, sr):
828
+ return max(1, int(round(frames / float(FPS) * int(sr))))
829
+
830
+
831
+ def _tail_audio(audio, frames):
832
+ wav = audio["waveform"]
833
+ sr = int(audio["sample_rate"])
834
+ n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
835
+ return {"waveform": wav[..., -n:].contiguous(), "sample_rate": sr}
836
+
837
+
838
+ def _trim_audio_head(audio, frames):
839
+ wav = audio["waveform"]
840
+ sr = int(audio["sample_rate"])
841
+ n = min(_audio_samples(frames, sr), int(wav.shape[-1]))
842
+ return {"waveform": wav[..., n:].contiguous(), "sample_rate": sr}, n
843
+
844
+
845
+ def _xfade_audio(left, right, sr, ms=40):
846
+ n = max(1, int(sr * ms / 1000.0))
847
+ k = min(n, int(left.shape[-1]), int(right.shape[-1]))
848
+ if k < 8:
849
+ return torch.cat([left, right], dim=-1)
850
+ t = torch.linspace(0, 1, k, dtype=left.dtype, device=left.device)
851
+ fade_out = torch.cos(t * math.pi / 2)
852
+ fade_in = torch.sin(t * math.pi / 2)
853
+ while fade_out.ndim < left.ndim:
854
+ fade_out = fade_out.unsqueeze(0)
855
+ fade_in = fade_in.unsqueeze(0)
856
+ seam = left[..., -k:] * fade_out + right[..., :k] * fade_in
857
+ return torch.cat([left[..., :-k], seam, right[..., k:]], dim=-1)
858
+
859
+
860
+ def _frame_to_jpeg_b64(frame, max_side=512, quality=80):
861
+ from PIL import Image
862
+ arr = (frame.detach().float().cpu().numpy() * 255.0).clip(0, 255).astype("uint8")
863
+ if arr.ndim == 3 and arr.shape[-1] > 3:
864
+ arr = arr[..., :3]
865
+ img = Image.fromarray(arr)
866
+ w, h = img.size
867
+ scale = min(1.0, float(max_side) / float(max(w, h)))
868
+ if scale < 1.0:
869
+ img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
870
+ buf = pyio.BytesIO()
871
+ img.save(buf, format="JPEG", quality=int(quality))
872
+ return base64.b64encode(buf.getvalue()).decode("ascii"), img.size
873
+
874
+
875
+ class _PreviewEncoder:
876
+ """Encode preview JPEGs off the sampling thread.
877
+
878
+ Encoding inline cost the sampler a PIL resize plus a JPEG write at every
879
+ push. The queue is bounded and *drops* when full: a preview frame is worth
880
+ nothing if delivering it slows the render that produced it.
881
+ """
882
+
883
+ def __init__(self, depth=2):
884
+ self._q = queue.Queue(maxsize=depth)
885
+ self._t = None
886
+
887
+ def _run(self):
888
+ while True:
889
+ job = self._q.get()
890
+ if job is None:
891
+ return
892
+ payload, frames = job
893
+ try:
894
+ for key, frame in frames.items():
895
+ b64, (w, h) = _frame_to_jpeg_b64(frame)
896
+ payload[key] = b64
897
+ if key == "image":
898
+ payload["w"], payload["h"] = w, h
899
+ except Exception as e:
900
+ print(f"[{TAG}] preview encode skipped: {e!r}", flush=True)
901
+ try:
902
+ PromptServer.instance.send_sync(
903
+ "h3_refchain_preview", payload, PromptServer.instance.client_id)
904
+ except Exception as e:
905
+ print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
906
+
907
+ def submit(self, payload, frames):
908
+ if self._t is None:
909
+ self._t = threading.Thread(target=self._run, name="h3rc-preview",
910
+ daemon=True)
911
+ self._t.start()
912
+ try:
913
+ self._q.put_nowait((payload, frames))
914
+ except queue.Full:
915
+ pass # deliberate: never block the sampler for a preview
916
+
917
+
918
+ _PREVIEW = _PreviewEncoder()
919
+
920
+
921
+ def _push_preview(unique_id, status, frame=None, hop=0, total=0,
922
+ pin_mech=None, frac=None, seam_frame=None, meta=None):
923
+ """Send one preview update.
924
+
925
+ `status` stays a SHORT label. The full per-hop prompt dump belongs on the
926
+ `info` output -- passing it here once turned the status strip into the
927
+ prompt. Everything structured goes in its own field instead, which is what
928
+ a panel can actually lay out.
929
+ """
930
+ if not unique_id or PromptServer is None:
931
+ return
932
+ payload = {
933
+ "node_id": unique_id,
934
+ "status": status,
935
+ "hop": int(hop),
936
+ "total": int(total),
937
+ }
938
+ if pin_mech:
939
+ payload["pin_mech"] = str(pin_mech)
940
+ if frac is not None:
941
+ payload["frac"] = max(0.0, min(1.0, float(frac)))
942
+ if meta:
943
+ payload.update(meta)
944
+ frames = {}
945
+ if frame is not None:
946
+ frames["image"] = frame
947
+ if seam_frame is not None:
948
+ frames["seam_image"] = seam_frame
949
+ _PREVIEW.submit(payload, frames)
950
+
951
+
952
+ def _offload_text_encoder(clip, model):
953
+ te_dev = getattr(clip.patcher, "load_device", None)
954
+ dit_dev = getattr(model, "load_device", None)
955
+ if te_dev is not None and dit_dev is not None and str(te_dev) != str(dit_dev):
956
+ return
957
+ try:
958
+ clip.patcher.model.to(mm.text_encoder_offload_device())
959
+ except Exception as e:
960
+ print(f"[{TAG}] TE offload skipped: {e}", flush=True)
961
+ return
962
+ try:
963
+ dev = mm.get_torch_device()
964
+ mm.free_memory(mm.get_total_memory(dev) * 0.9, dev)
965
+ mm.soft_empty_cache()
966
+ free = mm.get_free_memory(dev) / (1024 ** 3)
967
+ print(f"[{TAG}] TE evicted; {free:.1f} GB free for the DiT", flush=True)
968
+ except Exception as e:
969
+ print(f"[{TAG}] VRAM purge skipped: {e}", flush=True)
970
+
971
+
972
+ def _decode_av(video_vae, audio_vae, latent):
973
+ imgs = VAEDecode().decode(video_vae, latent)[0]
974
+ audio = vae_decode_audio(audio_vae, latent)
975
+ return imgs, audio
976
+
977
+
978
+ class HandTieClips:
979
+ """Refs + shot plan + N hops, assembled into one clip.
980
+
981
+ Each hop after the first pins the previous hop's sampler latent through
982
+ Motion-Context, falling back to an AddGuide pixel pin when that is not
983
+ available. See the module docstring.
984
+ """
985
+
986
+ @classmethod
987
+ def INPUT_TYPES(cls):
988
+ return {
989
+ "required": {
990
+ "model": ("MODEL",),
991
+ "clip": ("CLIP",),
992
+ "vae": ("VAE",),
993
+ "audio_vae": ("VAE",),
994
+ "prompt": ("STRING", {
995
+ "multiline": True,
996
+ "dynamicPrompts": False,
997
+ "default": (
998
+ "Live-action, natural indoor light. The person looks exactly "
999
+ "as in the reference photographs.\n\n"
1000
+ "They sit at a table, look up, and speak one short line. "
1001
+ "Then they settle, watching the room."
1002
+ ),
1003
+ "tooltip": (
1004
+ "Hop 1 prompt. Separate hops with --- on its own line, "
1005
+ "or JSON {\"prompts\": [...]}. hop_script=verbatim: one block "
1006
+ "+ chains>1 wraps later hops. hop_script=next: later --- blocks "
1007
+ "are only 'what happens next'."
1008
+ ),
1009
+ }),
1010
+ "chains": (["1", "2", "3", "4", "5", "6", "7", "8"], {
1011
+ "default": "3",
1012
+ "tooltip": "How many generates to run and join. 3 at 10 s is about 28 s of master after the overlap trim.",
1013
+ }),
1014
+ "resolution": (["0.2 MP", "0.3 MP", "0.5 MP", "0.7 MP", "1.0 MP"], {
1015
+ "default": "1.0 MP",
1016
+ "tooltip": "Output area. 1.0 MP landscape is 1280x736 (the verified H3 size). Snapped to H3's 32 px grid.",
1017
+ }),
1018
+ "aspect": (["16:9 landscape", "9:16 portrait", "1:1 square"], {
1019
+ "default": "16:9 landscape",
1020
+ "tooltip": "Frame shape. Combined with resolution to set width and height.",
1021
+ }),
1022
+ "duration": (["5 s", "7 s", "8 s", "10 s", "15 s"], {
1023
+ "default": "10 s",
1024
+ "tooltip": "Length of each hop at 24 fps (H3 17k+5 grid: 124 / 192 / 243 / 362 frames). 5 s (124f) drops the airlock on a continuous join; use 8 s or 15 s to validate a seam.",
1025
+ }),
1026
+ "overlap": (["0.9 s", "0.2 s", "1.6 s"], {
1027
+ "default": "0.9 s",
1028
+ "tooltip": "Pinned clip from the previous hop at frame 0. 0.9 s (22 frames) is the native continuation length.",
1029
+ }),
1030
+ "seed": ("INT", {
1031
+ "default": 0, "min": 0, "max": 0xffffffffffffffff,
1032
+ "control_after_generate": True,
1033
+ }),
1034
+ "seed_per_shot": ("BOOLEAN", {
1035
+ "default": True,
1036
+ "label_on": "vary per hop",
1037
+ "label_off": "same seed every hop",
1038
+ }),
1039
+ "steps": ("INT", {"default": 14, "min": 1, "max": 50}),
1040
+ "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}),
1041
+ "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta"}),
1042
+ "shift_video": ("FLOAT", {"default": 12.0, "min": 0.01, "max": 100.0, "step": 0.01}),
1043
+ "shift_audio": ("FLOAT", {"default": 3.0, "min": 0.01, "max": 100.0, "step": 0.01}),
1044
+ "ref_image_size": (["match", "max"], {
1045
+ "tooltip": "match = faster. max = 2048 short-edge identity, slower every step.",
1046
+ }),
1047
+ },
1048
+ "optional": {
1049
+ # The nine ref_image_N sockets, plus reference_video, voice
1050
+ # and start_image, used to live at the TOP of this block --
1051
+ # twelve of a sixteen-socket column that occupied a third of
1052
+ # the node before the editor started. They are files now, and
1053
+ # their replacements are appended at the BOTTOM of `optional`
1054
+ # instead. See the note beside them: widget ORDER is part of
1055
+ # the saved-workflow format.
1056
+ "hop_script": (["verbatim", "next"], {
1057
+ "default": "verbatim",
1058
+ "tooltip": (
1059
+ "verbatim: your text is the hop prompt. next: hop 1 is the first "
1060
+ "block; every later block is only the new beat. One block + next: "
1061
+ "hops 2+ advance without replaying the opening."
1062
+ ),
1063
+ }),
1064
+ "pin_to_qwen": (["off", "last frame", "pin clip", "both"], {
1065
+ "default": "last frame",
1066
+ "tooltip": (
1067
+ "AddGuide is invisible to the text encoder. last frame = "
1068
+ "<Picture 1> of the previous hop's last frame (identity stills "
1069
+ "shift to Picture 2+). pin clip = overlap as extra <Video>. "
1070
+ "Voice stays <Audio 1>."
1071
+ ),
1072
+ }),
1073
+ "continuity_state": ("STRING", {
1074
+ "multiline": True,
1075
+ "default": "",
1076
+ "forceInput": True,
1077
+ "tooltip": (
1078
+ "Optional JSON continuity state from HTCContinuityState (or a String "
1079
+ "Primitive node for hand-typed JSON). hop_script=next only: locked + "
1080
+ "context text rides every hop 2+, mutable beats are indexed per hop. "
1081
+ "Unwired = no effect."
1082
+ ),
1083
+ }),
1084
+ "shot_plan": ("STRING", {
1085
+ "multiline": True,
1086
+ "default": "",
1087
+ "tooltip": (
1088
+ "Shot plan JSON: {\"shots\":[{\"beat\":\"...\","
1089
+ "\"directives\":{\"join\":\"continuous\"}}, ...]}. "
1090
+ "The shot count is the hop count, so `chains` is ignored. "
1091
+ "Directives compile to vetted continuity prose; `prose` per shot "
1092
+ "is appended verbatim. Blank = use the `prompt` widget instead."
1093
+ ),
1094
+ }),
1095
+ "ref_plan": ("STRING", {
1096
+ "multiline": True,
1097
+ "default": "",
1098
+ "tooltip": (
1099
+ "Reference register JSON: {'refs':[{'tag':'hero_face',"
1100
+ "'file':'face.png','subject':1,'retention':'fully_preserved'}]}. "
1101
+ "'file' is a picture in the reference folder, chosen in the panel. "
1102
+ "Beats refer to refs by @tag, resolved to the correct "
1103
+ "<Picture N> per hop, so removing or scheduling off a ref "
1104
+ "never renumbers the others. Refs sharing a 'subject' are "
1105
+ "the same person; different numbers stay different people. "
1106
+ "Blank = today's positional behaviour."
1107
+ ),
1108
+ }),
1109
+ "cache_hops": (["off", "on"], {
1110
+ "default": "off",
1111
+ "tooltip": (
1112
+ "Store each hop losslessly on disk, keyed by a chained "
1113
+ "content hash. Unchanged hops load instead of re-rendering, "
1114
+ "so editing only the last shot re-renders only that shot, "
1115
+ "and an interrupted chain resumes. Editing an early shot "
1116
+ "correctly invalidates every hop after it."
1117
+ ),
1118
+ }),
1119
+ "cache_budget_gb": ("FLOAT", {
1120
+ "default": 20.0, "min": 1.0, "max": 500.0, "step": 1.0,
1121
+ "tooltip": "Least-recently-used hops are evicted above this size.",
1122
+ }),
1123
+ "audio_pin_frames": ("INT", {
1124
+ "default": 24, "min": 0, "max": 240, "step": 24,
1125
+ "tooltip": (
1126
+ "Audio context handed to the Motion-Context pin, in frames. "
1127
+ "24 is one second and lands on the model's 40 Hz audio grid; "
1128
+ "multiples of 24 keep whole seconds. Longer audio context "
1129
+ "costs conditioning rows but NO delivered frames, so it is "
1130
+ "the cheap lever on speech that breaks across a join -- try "
1131
+ "96 (4 s) for continuous dialogue. 0 follows the picture "
1132
+ "overlap. Video pin length is not adjustable here: it "
1133
+ "follows the overlap widget."
1134
+ ),
1135
+ }),
1136
+ "pin_renorm": (["off", "on"], {
1137
+ "default": "off",
1138
+ "tooltip": (
1139
+ "Rescale each pinned latent so its spread matches hop 1's. "
1140
+ "The pin's own sigma climbs every hop and that inflated pin "
1141
+ "conditions the next one, so texture ratchets up along a "
1142
+ "long chain. A scalar rescale moves no structure, so it "
1143
+ "cannot blur detail. Off reproduces chain_00038 exactly; "
1144
+ "turn it on for chains of 3+ hops."
1145
+ ),
1146
+ }),
1147
+ "pin_noise": ("FLOAT", {
1148
+ "default": 0.0, "min": 0.0, "max": 0.10, "step": 0.005,
1149
+ "tooltip": (
1150
+ "Mix seeded noise into the pinned latent before it "
1151
+ "conditions the next hop -- the other half of the texture "
1152
+ "ratchet fix. Small values only: measured gains fall off "
1153
+ "and reverse above 0.10, which is why the range stops "
1154
+ "there. 0.0 reproduces chain_00038 exactly; 0.05 is the "
1155
+ "documented starting point."
1156
+ ),
1157
+ }),
1158
+ "tone_compensate": (_tone.MODES, {
1159
+ "default": "off",
1160
+ "tooltip": (
1161
+ "Undo the denoiser's tone bias on each hop, measured on the "
1162
+ "overlap that hop regenerated. The estimate needs both copies "
1163
+ "of the overlap, which only exist inside this node -- a "
1164
+ "downstream node cannot do this. frame_shift is the mode that "
1165
+ "suits regenerated content; gain_bias is more robust; lut "
1166
+ "overfits. Enabling any mode also clamps the master to 0..1."
1167
+ ),
1168
+ }),
1169
+ # ---------------------------------------------------------
1170
+ # APPEND-ONLY ZONE. `widgets_values` in a saved workflow is a
1171
+ # POSITIONAL array -- ComfyUI restores value[i] into widget[i]
1172
+ # and never checks the name. A widget inserted anywhere but the
1173
+ # end renumbers everything after it, and every previously saved
1174
+ # workflow silently loads its values into the wrong widgets.
1175
+ #
1176
+ # These three were first added at the top of `optional`, which
1177
+ # shifted hop_script..tone_compensate by +3. `ref_plan` then
1178
+ # received audio_pin_frames' integer and the editor threw
1179
+ # `(text || "").trim is not a function` on load -- the only
1180
+ # visible symptom of a much wider silent corruption.
1181
+ #
1182
+ # Add new widgets HERE, at the bottom. Old workflows are then
1183
+ # short rather than misaligned, and the new widget takes its
1184
+ # default.
1185
+ # ---------------------------------------------------------
1186
+ "start_image_file": ("STRING", {
1187
+ "default": "",
1188
+ "tooltip": "First-frame pin for hop 1 only. Set in the panel.",
1189
+ }),
1190
+ "reference_video_file": ("STRING", {
1191
+ "default": "",
1192
+ "tooltip": "Motion/look plate. Not the previous hop. Set in the panel.",
1193
+ }),
1194
+ "voice_file": ("STRING", {
1195
+ "default": "",
1196
+ "tooltip": "Voice or timbre reference, riding every hop as <Audio 1>. Set in the panel.",
1197
+ }),
1198
+ # Appended here on 2026-08-29, per the note above: LAST, so
1199
+ # every saved workflow keeps its widget alignment and simply
1200
+ # takes the default.
1201
+ "establish": ("STRING", {
1202
+ # Single line on purpose. The editor hides only the widgets
1203
+ # it explicitly owns, so this one renders as a native widget
1204
+ # on the node body -- and one sentence in a one-line box is
1205
+ # discoverable where a multiline textarea would be a slab.
1206
+ "default": _d.ESTABLISH,
1207
+ "tooltip": (
1208
+ "Opening line prepended to hop 1 only, before the beat. "
1209
+ "The default asserts live action; clear it, or replace it "
1210
+ "with your own medium, for anything else. Dropped "
1211
+ "automatically when shot 1 already names a medium."
1212
+ ),
1213
+ }),
1214
+ },
1215
+ "hidden": {
1216
+ "unique_id": "UNIQUE_ID",
1217
+ },
1218
+ }
1219
+
1220
+ RETURN_TYPES = ("IMAGE", "AUDIO", "STRING")
1221
+ RETURN_NAMES = ("images", "audio", "info")
1222
+ FUNCTION = "run"
1223
+ CATEGORY = "Hand Tie Clips"
1224
+ DESCRIPTION = (
1225
+ "MiniMax H3 Ref2VA chain. Hop 1 is a full generate; every later hop is a "
1226
+ "continuation pinned to the previous hop's sampler latent via "
1227
+ "Motion-Context, falling back to an AddGuide pixel pin when that is "
1228
+ "unavailable. Author with shot_plan + ref_plan; hop_script=next treats "
1229
+ "later blocks as what-happens-next; pin_to_qwen shows the incoming frame "
1230
+ "to the text encoder; continuity_state (from HTCContinuityState) carries "
1231
+ "locked/context setting text forward."
1232
+ )
1233
+
1234
+ @classmethod
1235
+ def IS_CHANGED(cls, ref_plan="", start_image_file="",
1236
+ reference_video_file="", voice_file="", **_):
1237
+ """Re-run when a reference file changes underneath its name.
1238
+
1239
+ Every picture now arrives as a basename, and a basename is a stable
1240
+ input: overwrite `face.png` with a different face and ComfyUI would
1241
+ happily serve the previous render. Hashing path+mtime is the fix.
1242
+
1243
+ Deliberately NOT `float("nan")` -- that is the blunt version of this and
1244
+ would force a full re-render of an expensive node on every queue.
1245
+ """
1246
+ names = [start_image_file, reference_video_file, voice_file]
1247
+ try:
1248
+ for r in (_refs.parse_ref_plan(ref_plan).get("refs") or []):
1249
+ if r.get("file"):
1250
+ names.append(r["file"])
1251
+ except Exception:
1252
+ # A malformed plan is run()'s error to report, with a message that
1253
+ # names the field. Raising here would surface as a cache failure.
1254
+ pass
1255
+ return _media.stamp(names)
1256
+
1257
+ def run(self, model, clip, vae, audio_vae, prompt,
1258
+ chains, resolution,
1259
+ aspect, duration, overlap, seed, seed_per_shot, steps,
1260
+ sampler_name, scheduler, shift_video, shift_audio, ref_image_size,
1261
+ start_image_file="", reference_video_file="", voice_file="",
1262
+ hop_script="verbatim", pin_to_qwen="last frame", continuity_state="",
1263
+ shot_plan="", ref_plan="", cache_hops="off", cache_budget_gb=20.0,
1264
+ audio_pin_frames=24, pin_renorm="off", pin_noise=0.0,
1265
+ tone_compensate="off", establish=None,
1266
+ unique_id=None):
1267
+ width, height = _canvas(resolution, aspect)
1268
+ length = align_frame_count(_duration_frames(duration))
1269
+ overlap_n = _overlap_frames(overlap)
1270
+
1271
+ # A shot plan is authoritative when present: its shot count is the hop
1272
+ # count, and every shot after the first is a beat, so `next` semantics
1273
+ # are the only correct reading of it.
1274
+ shots = _plan.parse_plan(shot_plan)
1275
+ if shots:
1276
+ if str(hop_script) != "next":
1277
+ print(f"[{TAG}] shot_plan present -> hop_script=next", flush=True)
1278
+ hop_script = "next"
1279
+ if int(chains) != len(shots):
1280
+ print(f"[{TAG}] shot_plan has {len(shots)} shot(s); "
1281
+ f"chains={chains} ignored", flush=True)
1282
+ # The place-handoff check needs to know which tags are places,
1283
+ # which only the ref plan says. Parsed defensively here: the
1284
+ # authoritative parse (and its error) is still the one below, so a
1285
+ # malformed ref_plan fails in the same place it always did.
1286
+ try:
1287
+ _rp_for_check = _refs.parse_ref_plan(ref_plan)
1288
+ except Exception:
1289
+ _rp_for_check = None
1290
+ blocks = _plan.compile_blocks(shots, establish, _rp_for_check)
1291
+ unique = len(shots)
1292
+ print(f"[{TAG}] shot plan:\n" + _plan.describe(shots), flush=True)
1293
+ for i, sh in enumerate(shots):
1294
+ if i > 0 and _d.is_full_h3_prompt((sh or {}).get("beat")):
1295
+ print(
1296
+ f"[{TAG}] hop {i + 1}: full H3 prompt flattened to a "
1297
+ "continuation beat (a complete Ref2VA block on hop 2+ "
1298
+ "starts a new scene)",
1299
+ flush=True,
1300
+ )
1301
+ else:
1302
+ shots = [None] * int(chains)
1303
+ blocks, unique = _expand_shots(
1304
+ _parse_shots(prompt), int(chains), hop_script=str(hop_script))
1305
+ n = len(blocks)
1306
+
1307
+ # Per-shot duration overrides, validated up front so a bad value fails
1308
+ # before any sampling happens rather than three hops in.
1309
+ lengths = []
1310
+ for i, sh in enumerate(shots):
1311
+ dur = (sh or {}).get("duration") if sh else None
1312
+ if dur and str(dur) not in DURATION_FRAMES:
1313
+ raise ValueError(
1314
+ f"{TAG}: shot {i + 1}: duration '{dur}' is not valid. "
1315
+ f"Use one of: {', '.join(DURATION_FRAMES)}"
1316
+ )
1317
+ lengths.append(align_frame_count(_duration_frames(dur or duration)))
1318
+ if str(hop_script) == "next":
1319
+ for i, sh in enumerate(shots):
1320
+ if i == 0:
1321
+ continue
1322
+ join = ((sh or {}).get("directives") or {}).get("join")
1323
+ dur = ((sh or {}).get("duration") if sh else None) or duration
1324
+ if join == "continuous" and str(dur) == "5 s":
1325
+ print(
1326
+ f"[{TAG}] note: shot {i + 1} is join=continuous at 5 s "
1327
+ "(124f). That budget drops the airlock; 8 s / 15 s is "
1328
+ "the join-validation canvas. A lucky seed can still "
1329
+ "join at 5 s.",
1330
+ flush=True,
1331
+ )
1332
+ for i, ln in enumerate(lengths):
1333
+ if overlap_n >= ln:
1334
+ raise ValueError(
1335
+ f"{TAG}: shot {i + 1}: overlap {overlap} ({overlap_n} frames) must "
1336
+ f"be smaller than duration ({ln} frames)"
1337
+ )
1338
+ state = _parse_state(continuity_state)
1339
+ # The three singles first, so a name that does not resolve is reported
1340
+ # before anything expensive starts. Each returns exactly what the socket
1341
+ # it replaced delivered, so everything downstream is unchanged.
1342
+ start_image = _media.load_image(start_image_file) if start_image_file else None
1343
+ reference_video = (_media.load_video(reference_video_file)
1344
+ if reference_video_file else None)
1345
+ voice = _media.load_audio(voice_file) if voice_file else None
1346
+ for _name, _got in (("start_image", start_image_file and start_image is None),
1347
+ ("reference_video", reference_video_file and reference_video is None),
1348
+ ("voice", voice_file and voice is None)):
1349
+ if _got:
1350
+ print(f"[{TAG}] note: {_name} file could not be read; continuing "
1351
+ f"without it", flush=True)
1352
+
1353
+ ref_plan_obj = _refs.parse_ref_plan(ref_plan)
1354
+ ref_plan_refs = ref_plan_obj["refs"]
1355
+ ref_subjects = ref_plan_obj["subjects"]
1356
+ # Derived slot -> tensor. The register still needs to know which picture
1357
+ # a @tag is pinned to; the slot is now the ref's position in the rail
1358
+ # rather than a socket number, and _collect_ref_images below dense-packs
1359
+ # and throws it away.
1360
+ slot_images = {}
1361
+ for _r in ref_plan_refs:
1362
+ if not _r["file"]:
1363
+ continue
1364
+ _im = _media.load_image(_r["file"])
1365
+ if _im is not None and _im.shape[0] > 0:
1366
+ slot_images[_r["slot"]] = _im[:1]
1367
+ if ref_plan_refs:
1368
+ print(f"[{TAG}] reference register:", flush=True)
1369
+ print(_refs.describe(ref_plan_obj), flush=True)
1370
+ # A named picture that is not on disk stops the queue. Warning and
1371
+ # continuing renders the whole chain with that reference silently
1372
+ # inactive, which is the failure the register exists to prevent --
1373
+ # and the docs have always promised a stop here.
1374
+ _absent = _refs.missing_files(ref_plan_obj, set(slot_images))
1375
+ if _absent:
1376
+ raise ValueError(
1377
+ f"[{TAG}] reference picture not found in "
1378
+ f"ComfyUI/input/h3_refs: "
1379
+ + "; ".join(f"@{_t} names '{_f}'" for _t, _f in _absent)
1380
+ + ". Drop the file onto that row in the REFERENCES rail, or "
1381
+ "clear the row's picture to render without it.")
1382
+ for _w in _refs.check(ref_plan_obj, set(slot_images)):
1383
+ print(f"[{TAG}] note: {_w}", flush=True)
1384
+ # ref_plan subjects and a `characters` block in continuity_state
1385
+ # both feed the prompt header, so filling in both injects identity
1386
+ # prose twice. HTCContinuityState no longer emits characters, so this
1387
+ # can only come from hand-authored JSON -- still worth warning about
1388
+ # rather than raising, since the setting half stays useful.
1389
+ _chars = sorted(
1390
+ cid for cid, c in (state.get("characters") or {}).items()
1391
+ if (c or {}).get("locked") or (c or {}).get("context")
1392
+ or (c or {}).get("mutable")
1393
+ )
1394
+ if ref_subjects and _chars:
1395
+ print(f"[{TAG}] note: ref_plan defines subject(s) "
1396
+ f"{sorted(ref_subjects)} and continuity_state also carries "
1397
+ f"character(s) {_chars}. Both inject identity text -- drop "
1398
+ f"the characters block and keep setting only.", flush=True)
1399
+ ref_images = _collect_ref_images(slot_images)
1400
+ base_videos = None if reference_video is None else {"ref_video_1": reference_video}
1401
+ ref_audios = None if voice is None else {"ref_audio_1": voice}
1402
+
1403
+ # Fingerprint the model as it arrives -- after whatever LoRA and
1404
+ # attention nodes are drawn upstream, before this node touches it.
1405
+ model_fp = _model_fingerprint(model)
1406
+
1407
+ model = _result(MiniMaxH3SigmaShift.execute(model, float(shift_video), float(shift_audio)))[0]
1408
+ sampler = _result(KSamplerSelect.execute(sampler_name))[0]
1409
+ base_sigmas = _result(BasicScheduler.execute(model, scheduler, int(steps), 1.0))[0]
1410
+ sigma_cache = {int(steps): base_sigmas}
1411
+
1412
+ print(
1413
+ f"[{TAG}] {n} hop(s), {length}f ({length / FPS:.1f}s) @ {width}x{height} "
1414
+ f"({resolution}, {aspect}), overlap {overlap_n}f, "
1415
+ f"hop_script={hop_script}, pin_to_qwen={pin_to_qwen}, "
1416
+ f"{unique} authored block(s), {steps} steps {sampler_name}/{scheduler}",
1417
+ flush=True,
1418
+ )
1419
+
1420
+ # Preallocate the master instead of growing it with torch.cat. cat
1421
+ # allocates a fresh full-size tensor every hop, so at hop N the old and
1422
+ # new masters are briefly live *together* alongside prev_imgs and imgs.
1423
+ # Total length is known up front, so one allocation plus slice-writes
1424
+ # removes that doubling.
1425
+ total_frames = sum(lengths) - overlap_n * (n - 1)
1426
+ master_imgs = torch.empty(
1427
+ (total_frames, int(height), int(width), 3), dtype=torch.float32)
1428
+ write_pos = 0
1429
+ master_wav = None
1430
+ sr = None
1431
+ prev_imgs = None
1432
+ prev_audio = None
1433
+ prev_sampled = None
1434
+ pbar = comfy.utils.ProgressBar(n)
1435
+
1436
+ hop_store = None
1437
+ pin_renorm_on = str(pin_renorm) == "on"
1438
+ pin_noise_v = max(0.0, min(0.10, float(pin_noise)))
1439
+ audio_ctx = int(audio_pin_frames) if int(audio_pin_frames) > 0 else int(overlap_n)
1440
+ pin_anchor_std = None # hop 2's pin sets the sigma hops 3+ match
1441
+ if pin_renorm_on or pin_noise_v > 0.0:
1442
+ print(f"[{TAG}] pin conditioning enabled: "
1443
+ f"renorm={'on' if pin_renorm_on else 'off'} "
1444
+ f"noise={pin_noise_v:.3f}", flush=True)
1445
+ tone_mode = str(tone_compensate)
1446
+ tone_on = tone_mode != "off" and tone_mode in _tone.MODES
1447
+ if tone_on:
1448
+ print(f"[{TAG}] tone compensation: {tone_mode} "
1449
+ f"(overlap {overlap_n}f)", flush=True)
1450
+ if str(cache_hops) == "on":
1451
+ import folder_paths
1452
+ hop_store = _store.HopStore(
1453
+ os.path.join(folder_paths.get_temp_directory(), "h3_ref_chain_hops"),
1454
+ budget_gb=float(cache_budget_gb), fps=FPS)
1455
+ print(f"[{TAG}] hop cache: {hop_store.root} "
1456
+ f"(budget {float(cache_budget_gb):.0f} GB)", flush=True)
1457
+ # Everything constant across the chain, mixed into every hop key so a
1458
+ # resolution or sampler change invalidates the whole cache.
1459
+ chain_salt = {
1460
+ "w": int(width), "h": int(height), "overlap": overlap_n,
1461
+ "sampler": str(sampler_name), "scheduler": str(scheduler),
1462
+ "shift_v": float(shift_video), "shift_a": float(shift_audio),
1463
+ "ref_size": str(ref_image_size), "pin": str(pin_to_qwen),
1464
+ # No "pin_mech" here: the mechanism is decided per hop at runtime
1465
+ # in _pin_continue (Motion-Context when a sampler latent exists,
1466
+ # AddGuide pixels otherwise), so it belongs in the per-hop key
1467
+ # below, not in the chain-wide salt.
1468
+ "refs": {s: _store.tensor_digest(t)
1469
+ for s, t in sorted(slot_images.items())},
1470
+ "voice": _store.audio_digest(voice),
1471
+ "refvid": _store.tensor_digest(reference_video),
1472
+ "start": _store.tensor_digest(start_image),
1473
+ # A cached hop rendered under different LoRAs or a different
1474
+ # attention path is not the same hop, so what has been patched onto
1475
+ # the incoming model is part of the key. See _model_fingerprint.
1476
+ "model": model_fp,
1477
+ }
1478
+ prev_key = None
1479
+ hop_keys = []
1480
+
1481
+ assembled = []
1482
+ for i, block in enumerate(blocks):
1483
+ mm.throw_exception_if_processing_interrupted()
1484
+ shot = shots[i] or {}
1485
+ hop_length = lengths[i]
1486
+ print(f"[{TAG}] hop {i + 1}/{n}...", flush=True)
1487
+ _push_preview(unique_id, f"hop {i + 1}/{n} sampling…", hop=i + 1, total=n,
1488
+ frac=(write_pos / float(total_frames)) if total_frames else None)
1489
+
1490
+ # With a register, this hop's refs are only the ones active on it,
1491
+ # re-packed in slot order. The image dict and the ordinals the prompt
1492
+ # cites are built from the same list, so they cannot drift apart.
1493
+ hop_active = []
1494
+ hop_subject_prose = ""
1495
+ if ref_plan_refs:
1496
+ hop_active = _refs.active_refs(ref_plan_refs, i, set(slot_images))
1497
+ # Continuation: omit shots[] = hop 1 only. Face/outfit plates
1498
+ # of a different room (chain_00034) beat the pin as Pictures 1–3
1499
+ # and hop 2 opened a new Ref2VA generate — commercial kitchen,
1500
+ # apron gone. List hop numbers on a ref to ride later hops.
1501
+ if i > 0 and str(hop_script) == "next":
1502
+ dropped = [r["tag"] for r in hop_active if r.get("shots") is None]
1503
+ hop_active = [r for r in hop_active if r.get("shots") is not None]
1504
+ if dropped:
1505
+ print(
1506
+ f"[{TAG}] hop {i + 1}: unscheduled stills stay off "
1507
+ f"this continue ({', '.join('@' + t for t in dropped)}); "
1508
+ f"pin carries wardrobe and room",
1509
+ flush=True,
1510
+ )
1511
+ base_images = {
1512
+ f"ref_image_{k + 1}": slot_images[r["slot"]]
1513
+ for k, r in enumerate(hop_active)
1514
+ } or None
1515
+ else:
1516
+ base_images = ref_images
1517
+ if i > 0 and str(hop_script) == "next":
1518
+ print(
1519
+ f"[{TAG}] hop {i + 1}: identity stills stay off this "
1520
+ "continue (no shots[] schedule); pin carries wardrobe "
1521
+ "and room",
1522
+ flush=True,
1523
+ )
1524
+ base_images = None
1525
+
1526
+ hop_images = base_images
1527
+ hop_videos = base_videos
1528
+ live_p = live_v = None
1529
+ still_shift = 0
1530
+ if i > 0:
1531
+ hop_images, live_p, hop_videos, live_v = _attach_pin_to_qwen(
1532
+ str(pin_to_qwen), base_images, base_videos,
1533
+ prev_imgs[-1:], prev_imgs[-overlap_n:],
1534
+ )
1535
+ if live_p == 1:
1536
+ still_shift = 1
1537
+ if not hop_images:
1538
+ hop_images = None
1539
+ if not hop_videos:
1540
+ hop_videos = None
1541
+
1542
+ # One ordinal map for this hop. The prompt's <Picture N> citations
1543
+ # and the identity lock's ordinals have to agree, and computing the
1544
+ # same shift twice is exactly how they drift apart. `p`, not `n` --
1545
+ # `n` is the hop count in the enclosing scope.
1546
+ hop_ords = _refs.ordinals(hop_active)
1547
+ if still_shift:
1548
+ hop_ords = {t: p + still_shift for t, p in hop_ords.items()}
1549
+ if ref_plan_refs:
1550
+ # Plan-wide subjects so @hero_face still resolves when that
1551
+ # photograph is off this hop.
1552
+ # Names, not ordinals, from hop 2 on. `subject_definitions:`
1553
+ # is hop-1 material, so a `<Subject N>` reaching hop 4 has no
1554
+ # antecedent in its own encode -- the dangling-token defect
1555
+ # that turned an undescribed "the bowl" into a steel one.
1556
+ # `continuity_line` rides every continuation hop and is what
1557
+ # the name binds to.
1558
+ block = _refs.resolve_tags(
1559
+ block, hop_ords, _refs.subjects(ref_plan_refs),
1560
+ where=f"shot {i + 1}",
1561
+ declared={r["tag"] for r in ref_plan_refs},
1562
+ subject_names=({k: (v or {}).get("name")
1563
+ for k, v in (ref_subjects or {}).items()}
1564
+ if i > 0 else None))
1565
+ if i == 0:
1566
+ # Hop 1 only: subject_prose derives its own ordinals with no
1567
+ # still_shift, correct only because hop 1 has no pin.
1568
+ # Computing it on hop 2+ invites an off-by-one against the
1569
+ # live frame.
1570
+ hop_subject_prose = _refs.subject_prose(hop_active, ref_subjects)
1571
+ if i == 0 and hop_subject_prose and not _d.is_full_h3_prompt(block):
1572
+ block = hop_subject_prose + "\n\n" + block
1573
+ if str(hop_script) == "next" and i > 0:
1574
+ n_stills = len(base_images or {})
1575
+ hop_state_header = _state_header(state, i)
1576
+ # With a register wired, only the subject-bearing refs are
1577
+ # identities; a setting or prop plate must not be declared one.
1578
+ id_ords = None
1579
+ n_subj = None
1580
+ if hop_active:
1581
+ id_ords = [hop_ords[r["tag"]] for r in hop_active
1582
+ if r["subject"] is not None]
1583
+ # Counted over this hop, not the whole plan. Counting
1584
+ # plan-wide while listing only this hop's ordinals is how a
1585
+ # single scheduled still produced "<Picture 2> are the only
1586
+ # identities" -- the plural that has rendered two people
1587
+ # from one reference.
1588
+ n_subj = len({r["subject"] for r in hop_active
1589
+ if r["subject"] is not None}) or None
1590
+ elif ref_plan_refs:
1591
+ # Pin-only hop: no identity to lock. _identity_lock returns
1592
+ # "" on an empty ordinal list whatever the count says.
1593
+ id_ords = []
1594
+ # Plan-wide, not per-hop: a character in the chain is in
1595
+ # the chain whether or not their photograph rides this hop.
1596
+ # This is the text that has to survive hop 5 of the showcase,
1597
+ # which schedules no references at all.
1598
+ hop_continuity = _refs.continuity_line(
1599
+ ref_subjects,
1600
+ {r["subject"] for r in ref_plan_refs
1601
+ if r["subject"] is not None}) if ref_plan_refs else ""
1602
+ block = _assemble_next(
1603
+ block,
1604
+ live_picture=live_p,
1605
+ live_video=live_v,
1606
+ n_stills=n_stills,
1607
+ state_header=hop_state_header,
1608
+ identity_ordinals=id_ords,
1609
+ n_subjects=n_subj,
1610
+ tail=(shot.get("directives") or {}).get("tail"),
1611
+ continuity=hop_continuity,
1612
+ )
1613
+ print(f"[{TAG}] hop {i + 1} next-beat assembled "
1614
+ f"(Picture {live_p}, Video {live_v}, "
1615
+ f"{n_stills} identity stills, "
1616
+ f"state_header {len(hop_state_header)} chars, "
1617
+ f"continuity {len(hop_continuity)} chars)", flush=True)
1618
+
1619
+ assembled.append((i + 1, block))
1620
+
1621
+ # Key this hop. prev_key makes the key chained, so editing shot 1
1622
+ # invalidates every hop after it -- correct, and the reason the UI
1623
+ # must show staleness before queuing or it reads as a bug.
1624
+ hop_key = None
1625
+ cached = None
1626
+ pin_mech_pred = _pin_mech_for(i, overlap_n, prev_sampled)
1627
+ pin_mech_used = pin_mech_pred
1628
+ if hop_store is not None:
1629
+ hop_key = _store.hop_key(prev_key, {
1630
+ "chain": chain_salt,
1631
+ "block": block,
1632
+ "len": hop_length,
1633
+ "steps": int(shot.get("steps") or steps),
1634
+ "seed": (int(shot["seed"]) if shot.get("seed") is not None
1635
+ else ((int(seed) + i) if seed_per_shot else int(seed))),
1636
+ "tags": [r["tag"] for r in hop_active],
1637
+ # Per hop, not chain-wide: hop 2 after a hop-1 cache hit
1638
+ # has no sampler latent and falls back to AddGuide, which
1639
+ # is a different render of the same inputs.
1640
+ "pin_mech": pin_mech_pred,
1641
+ "pin_cond": (pin_renorm_on, round(pin_noise_v, 4), audio_ctx),
1642
+ })
1643
+ # A locked shot reuses its last render even though its inputs
1644
+ # changed -- that is the point of locking. The content key would
1645
+ # have moved, so the pointer is what finds it.
1646
+ shot_name = str(shot.get("id") or f"shot{i + 1}")
1647
+ if shot.get("locked"):
1648
+ pinned = hop_store.get_pointer(shot_name)
1649
+ if pinned:
1650
+ if pinned != hop_key:
1651
+ print(f"[{TAG}] hop {i + 1} is locked: reusing its "
1652
+ f"earlier render (inputs changed)", flush=True)
1653
+ hop_key = pinned
1654
+ else:
1655
+ print(f"[{TAG}] hop {i + 1} is locked but has no cached "
1656
+ f"render yet; rendering it once", flush=True)
1657
+ hop_keys.append(hop_key)
1658
+ cached = hop_store.get(hop_key)
1659
+
1660
+ this_sampled = None
1661
+ if cached is not None:
1662
+ imgs, wav, sr, cached_latent = cached
1663
+ audio = {"waveform": wav, "sample_rate": sr}
1664
+ # Carry the stored sampler latent forward exactly as a render
1665
+ # would. Without this the next hop sees no latent, predicts the
1666
+ # AddGuide fallback, and its key stops matching what is on disk
1667
+ # -- so nothing past hop 1 could ever hit, and the hop after a
1668
+ # hit was joined by the weaker mechanism.
1669
+ this_sampled = cached_latent
1670
+ print(f"[{TAG}] hop {i + 1}: loaded from cache "
1671
+ f"({int(imgs.shape[0])}f, key {hop_key[:8]}"
1672
+ f"{'' if cached_latent is not None else ', no latent'})",
1673
+ flush=True)
1674
+ else:
1675
+ packed = MiniMaxH3ReferenceToVideo.execute(
1676
+ clip, vae, audio_vae, block, int(width), int(height), hop_length,
1677
+ ref_image_size=ref_image_size,
1678
+ ref_images=hop_images,
1679
+ ref_videos=hop_videos,
1680
+ ref_audios=ref_audios,
1681
+ )
1682
+ cond, latent = _result(packed)[0], _result(packed)[1]
1683
+
1684
+ if i == 0 and start_image is not None:
1685
+ cond = _result(MiniMaxH3AddGuide.execute(
1686
+ cond, latent, 0, vae=vae, audio_vae=None,
1687
+ image=start_image[:1], audio=None,
1688
+ ))[0]
1689
+ elif i > 0:
1690
+ pin_latent = prev_sampled
1691
+ if pin_latent is not None:
1692
+ pin_latent, pin_anchor_std = _condition_pin_latent(
1693
+ pin_latent, pin_anchor_std,
1694
+ renorm=pin_renorm_on, noise=pin_noise_v,
1695
+ seed=(int(seed) + i))
1696
+ cond, pin_mech_used = _pin_continue(
1697
+ cond, latent, vae, audio_vae, overlap_n,
1698
+ pin_latent, prev_imgs, prev_audio,
1699
+ audio_ctx=audio_ctx,
1700
+ )
1701
+
1702
+ _offload_text_encoder(clip, model)
1703
+
1704
+ guider = _result(BasicGuider.execute(model, cond))[0]
1705
+ if shot.get("seed") is not None:
1706
+ shot_seed = int(shot["seed"])
1707
+ else:
1708
+ shot_seed = (int(seed) + i) if seed_per_shot else int(seed)
1709
+ hop_steps = int(shot.get("steps") or steps)
1710
+ if hop_steps not in sigma_cache:
1711
+ sigma_cache[hop_steps] = _result(
1712
+ BasicScheduler.execute(model, scheduler, hop_steps, 1.0))[0]
1713
+ hop_sigmas = sigma_cache[hop_steps]
1714
+ if hop_steps != int(steps) or shot.get("seed") is not None:
1715
+ print(f"[{TAG}] hop {i + 1} override: seed={shot_seed} "
1716
+ f"steps={hop_steps}", flush=True)
1717
+ noise = _result(RandomNoise.execute(shot_seed))[0]
1718
+ sampled = _result(SamplerCustomAdvanced.execute(
1719
+ noise, guider, sampler, hop_sigmas, latent
1720
+ ))[0]
1721
+
1722
+ imgs, audio = _decode_av(vae, audio_vae, sampled)
1723
+ imgs = imgs.contiguous().cpu()
1724
+ wav = audio["waveform"].contiguous().cpu()
1725
+ sr = int(audio["sample_rate"])
1726
+ audio = {"waveform": wav, "sample_rate": sr}
1727
+ this_sampled = _latent_cpu(sampled)
1728
+
1729
+ del sampled, latent, cond, guider, noise
1730
+ mm.soft_empty_cache()
1731
+
1732
+ if (hop_store is not None and hop_key is not None
1733
+ and pin_mech_used != pin_mech_pred):
1734
+ print(f"[{TAG}] hop {i + 1} pinned by {pin_mech_used} but its "
1735
+ f"cache key says {pin_mech_pred}; not caching this hop",
1736
+ flush=True)
1737
+ elif hop_store is not None and hop_key is not None:
1738
+ hop_store.put(hop_key, imgs, wav, sr,
1739
+ {"hop": i + 1, "of": n, "block": block[:400],
1740
+ "pin_mech": pin_mech_used},
1741
+ latent=this_sampled)
1742
+ hop_store.set_pointer(
1743
+ str(shot.get("id") or f"shot{i + 1}"), hop_key)
1744
+
1745
+ # Tone compensation, at the one point both the render and the
1746
+ # cache-hit paths have converged.
1747
+ #
1748
+ # It sits AFTER hop_store.put on purpose, so the cache holds raw
1749
+ # hops and the mode stays out of the hop key -- switching modes
1750
+ # then costs nothing instead of invalidating ~285 MB an entry.
1751
+ # It sits BEFORE the master write and before `prev_imgs` is taken,
1752
+ # which is the half that matters: `prev_imgs` is what feeds the next
1753
+ # hop's Qwen <Picture 1> pin and the AddGuide guide image, so
1754
+ # correcting here is what stops the drift compounding rather than
1755
+ # merely repainting the master. Each hop is measured against the
1756
+ # previous hop's ALREADY CORRECTED tail, so the whole chain lands on
1757
+ # hop 1's tone.
1758
+ tone_note = ""
1759
+ if tone_on:
1760
+ if i > 0 and prev_imgs is not None:
1761
+ imgs, tone_note = _tone.compensate(
1762
+ prev_imgs, imgs, tone_mode, overlap_n)
1763
+ else:
1764
+ # Hop 1 has nothing to match against, but the corrected hops
1765
+ # come back clamped and an unclamped hop 1 beside them would
1766
+ # make the master inconsistent with itself.
1767
+ imgs = imgs.clamp(0.0, 1.0)
1768
+ if tone_note:
1769
+ print(f"[{TAG}] hop {i + 1} tone: {tone_note}", flush=True)
1770
+
1771
+ if i == 0:
1772
+ master_imgs[0:imgs.shape[0]] = imgs
1773
+ write_pos = int(imgs.shape[0])
1774
+ master_wav = wav
1775
+ else:
1776
+ if imgs.shape[0] <= overlap_n:
1777
+ raise ValueError(
1778
+ f"{TAG}: hop {i + 1} decoded {int(imgs.shape[0])} frames; "
1779
+ f"need more than overlap {overlap_n}"
1780
+ )
1781
+ keep_n = int(imgs.shape[0]) - overlap_n
1782
+ if write_pos + keep_n > total_frames:
1783
+ raise ValueError(
1784
+ f"{TAG}: hop {i + 1} overruns the preallocated master "
1785
+ f"({write_pos + keep_n} > {total_frames}). A hop decoded a "
1786
+ f"different length than planned.")
1787
+ master_imgs[write_pos:write_pos + keep_n] = imgs[overlap_n:]
1788
+ write_pos += keep_n
1789
+ trimmed, dropped = _trim_audio_head(audio, overlap_n)
1790
+ master_wav = _xfade_audio(master_wav, trimmed["waveform"], sr)
1791
+ print(
1792
+ f"[{TAG}] hop {i + 1}: dropped {overlap_n} frames / {dropped} audio samples",
1793
+ flush=True,
1794
+ )
1795
+ del trimmed
1796
+
1797
+ # The join, as two pictures: the previous hop's last delivered frame
1798
+ # and this hop's first. Sent together so the panel can show the
1799
+ # actual seam rather than one frame per hop.
1800
+ seam_frame = imgs[0] if i > 0 else None
1801
+ # Overlap tail for Qwen; full sampler latent for Motion-Context.
1802
+ tail_n = overlap_n if overlap_n else 1
1803
+ prev_imgs = imgs[-tail_n:].clone()
1804
+ prev_audio = {"waveform": _tail_audio(audio, overlap_n)["waveform"].clone(),
1805
+ "sample_rate": sr}
1806
+ prev_sampled = this_sampled
1807
+ prev_key = hop_key
1808
+ _push_preview(
1809
+ unique_id, f"hop {i + 1}/{n} done",
1810
+ frame=prev_imgs[-1], hop=i + 1, total=n,
1811
+ pin_mech=(pin_mech_used if i > 0 else None),
1812
+ frac=(write_pos / float(total_frames) if total_frames else None),
1813
+ seam_frame=seam_frame,
1814
+ meta={"cached": cached is not None,
1815
+ "key": (hop_key[:8] if hop_key else None),
1816
+ "frames": int(write_pos), "of_frames": int(total_frames),
1817
+ "seed": int(shot_seed) if cached is None else None,
1818
+ "steps": int(hop_steps) if cached is None else None,
1819
+ "tone": tone_note or None})
1820
+ del imgs, wav, audio
1821
+ pbar.update(1)
1822
+
1823
+ if write_pos != total_frames:
1824
+ print(f"[{TAG}] note: wrote {write_pos} of {total_frames} planned "
1825
+ f"frames; trimming", flush=True)
1826
+ master_imgs = master_imgs[:write_pos]
1827
+ if hop_store is not None:
1828
+ hop_store.sweep(keep=hop_keys)
1829
+ # lengths[0], not `length`: when every shot overrides duration to the
1830
+ # same value the set is still size 1, but `length` is the chain default.
1831
+ span = str(lengths[0]) if len(set(lengths)) == 1 else "/".join(str(v) for v in lengths)
1832
+ info = (
1833
+ f"{n} hops x {span}f overlap {overlap_n} -> "
1834
+ f"{int(master_imgs.shape[0])} frames ({master_imgs.shape[0] / FPS:.1f}s) "
1835
+ f"{int(master_imgs.shape[2])}x{int(master_imgs.shape[1])}"
1836
+ )
1837
+ print(f"[{TAG}] {info}", flush=True)
1838
+ # The assembled prompts being inspectable is the whole point of the
1839
+ # directive layer -- wire `info` to a Preview Text node to read exactly
1840
+ # what each hop sent to the text encoder.
1841
+ _sep = '\n\n'
1842
+ info = info + _sep + _sep.join('===== hop %d prompt =====\n%s' % (k, t) for k, t in assembled)
1843
+ # `info` is the third return (Preview Text). Never send it as the
1844
+ # preview status — that strip is ~22 px and will render the dump.
1845
+ _v_secs = float(master_imgs.shape[0]) / FPS
1846
+ _a_secs = float(master_wav.shape[-1]) / float(sr) if sr else 0.0
1847
+ _push_preview(
1848
+ unique_id,
1849
+ f"done · {int(master_imgs.shape[0])}f · {_v_secs:.1f}s",
1850
+ frame=master_imgs[-1], hop=n, total=n, frac=1.0,
1851
+ meta={"video_s": round(_v_secs, 3), "audio_s": round(_a_secs, 3),
1852
+ "drift_ms": round((_a_secs - _v_secs) * 1000.0, 1),
1853
+ "hops": int(n), "frames": int(master_imgs.shape[0]),
1854
+ "done": True})
1855
+ master_audio = {"waveform": master_wav, "sample_rate": sr}
1856
+ return (master_imgs, master_audio, info)
1857
+
1858
+
1859
+ class HTCContinuityState:
1860
+ """Author locked/context/mutable *setting* text once; HandTieClips consumes it per hop.
1861
+
1862
+ locked and context ride every hop 2+ unchanged. mutable is --- delimited like the
1863
+ prompt field: one beat per hop, padded by repeating the last block if there are
1864
+ fewer blocks than hops.
1865
+
1866
+ Setting only. Characters live in `ref_plan`'s reference register, which is
1867
+ the only thing that knows a photograph is a face rather than a room. This
1868
+ node used to carry `characters_*` as well, so filling in both it and the
1869
+ register injected identity prose twice into every hop 2+ -- run() warned
1870
+ about that collision rather than preventing it. With the character half
1871
+ gone the collision is structurally impossible.
1872
+ """
1873
+
1874
+ @classmethod
1875
+ def INPUT_TYPES(cls):
1876
+ return {
1877
+ "required": {},
1878
+ "optional": {
1879
+ "setting_locked": ("STRING", {
1880
+ "multiline": True, "default": "",
1881
+ "tooltip": "Verbatim setting text (location, lighting). Injected unchanged into every hop 2+.",
1882
+ }),
1883
+ "setting_context": ("STRING", {
1884
+ "multiline": True, "default": "",
1885
+ "tooltip": "Current-state setting text, less rigid than locked. Injected every hop 2+.",
1886
+ }),
1887
+ "setting_mutable": ("STRING", {
1888
+ "multiline": True, "default": "",
1889
+ "tooltip": "Per-hop setting beat text, --- delimited like characters_mutable.",
1890
+ }),
1891
+ },
1892
+ }
1893
+
1894
+ RETURN_TYPES = ("STRING",)
1895
+ RETURN_NAMES = ("continuity_state",)
1896
+ FUNCTION = "run"
1897
+ CATEGORY = "Hand Tie Clips"
1898
+ DESCRIPTION = (
1899
+ "Builds a JSON continuity-state blob (locked/context/mutable) for the "
1900
+ "*setting* only, feeding HandTieClips's continuity_state input. "
1901
+ "hop_script=next only. Characters belong in ref_plan's register."
1902
+ )
1903
+
1904
+ def run(self, setting_locked="", setting_context="", setting_mutable=""):
1905
+ state = {
1906
+ "setting": {
1907
+ "locked": setting_locked.strip(),
1908
+ "context": setting_context.strip(),
1909
+ "mutable": _parse_shots(setting_mutable) if setting_mutable.strip() else [],
1910
+ },
1911
+ }
1912
+ return (json.dumps(state),)
1913
+
1914
+
1915
+
1916
+
1917
+ # -- pre-rename ids ----------------------------------------------------------
1918
+ # A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
1919
+ # lists the node a second time in search: ComfyUI falls back to the mapping key
1920
+ # when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
1921
+ # DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
1922
+ # frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
1923
+ # while leaving it fully functional in workflows that name it.
1924
+
1925
+
1926
+ class _LegacyH3RefChain(HandTieClips):
1927
+ DEPRECATED = True
1928
+
1929
+
1930
+ class _LegacyH3ContinuityState(HTCContinuityState):
1931
+ DEPRECATED = True
1932
+
1933
+
1934
+ NODE_CLASS_MAPPINGS = {
1935
+ "HandTieClips": HandTieClips,
1936
+ "HTCContinuityState": HTCContinuityState,
1937
+ "H3RefChain": _LegacyH3RefChain,
1938
+ "H3ContinuityState": _LegacyH3ContinuityState,
1939
+ }
1940
+ NODE_DISPLAY_NAME_MAPPINGS = {
1941
+ "HandTieClips": "H3 Ref2VA Chain",
1942
+ "HTCContinuityState": "H3 Continuity State",
1943
+ }
js/editor/media_picker.js ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Files in, without an IMAGE socket.
3
+ *
4
+ * A reference used to be a wire: nine `ref_image_N` sockets, nine `Load Image`
5
+ * nodes, and a sixteen-socket column eating a third of the node. A reference is
6
+ * now a *filename* under `<ComfyUI input>/h3_refs`, and this is the control that
7
+ * puts one there.
8
+ *
9
+ * Two rules, both learned by PromptMasterLD the expensive way:
10
+ *
11
+ * 1. **Pixels never enter a widget.** Only the basename is stored. Nine
12
+ * base64 thumbnails measured 1.68 MB of widget value and ComfyUI then
13
+ * failed to save the workflow at all. Previews are URLs, which cost
14
+ * nothing to rebuild and survive a reload.
15
+ * 2. **Reads go through ComfyUI's own `/view`.** It already serves the input
16
+ * directory with a guessed MIME type and Range support, which a `<video>`
17
+ * needs in order to seek. A second thumbnail route would buy nothing.
18
+ *
19
+ * The upload is multipart rather than JSON+base64 so a batch of stills is not
20
+ * inflated by a third and held in memory twice on the way through the browser.
21
+ */
22
+
23
+ import { el, button } from "./widget_utils.js";
24
+
25
+ const UPLOAD_URL = "/h3_ref_chain/upload";
26
+ const FILES_URL = "/h3_ref_chain/files";
27
+ const SUBDIR = "h3_refs";
28
+
29
+ /** Accept attributes, by what a control is for. */
30
+ export const ACCEPT = {
31
+ image: "image/*",
32
+ video: "video/*",
33
+ audio: "audio/*",
34
+ };
35
+
36
+ /* -- the file list, cached and shared ------------------------------------ */
37
+
38
+ let _files = null;
39
+ let _pending = null;
40
+ const _listeners = new Set();
41
+
42
+ /** Everything currently in the reference folder. Cached; refresh() invalidates. */
43
+ export function files() {
44
+ if (_files) return Promise.resolve(_files);
45
+ if (!_pending) {
46
+ _pending = fetch(FILES_URL)
47
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
48
+ .then((j) => {
49
+ _files = Array.isArray(j.files) ? j.files : [];
50
+ return _files;
51
+ })
52
+ .catch((err) => {
53
+ console.error("[HandTieClips] reference list unavailable:", err);
54
+ _files = [];
55
+ return _files;
56
+ })
57
+ .finally(() => { _pending = null; });
58
+ }
59
+ return _pending;
60
+ }
61
+
62
+ /** Re-read the folder and tell every mounted picker. */
63
+ export function refresh() {
64
+ _files = null;
65
+ return files().then((list) => {
66
+ for (const fn of _listeners) {
67
+ try {
68
+ fn(list);
69
+ } catch (err) {
70
+ console.error("[HandTieClips] picker refresh failed:", err);
71
+ }
72
+ }
73
+ return list;
74
+ });
75
+ }
76
+
77
+ export function onFilesChanged(fn) {
78
+ _listeners.add(fn);
79
+ return () => _listeners.delete(fn);
80
+ }
81
+
82
+ /**
83
+ * The URL a preview is drawn from.
84
+ *
85
+ * Cache-busted because a filename can be reused: upload `face.png` twice and
86
+ * the second one collision-suffixes, but an *overwritten* file keeps its name
87
+ * and the browser would happily show the old bytes.
88
+ */
89
+ export function viewUrl(name) {
90
+ const q = `filename=${encodeURIComponent(name)}`
91
+ + `&subfolder=${encodeURIComponent(SUBDIR)}&type=input&t=${Date.now()}`;
92
+ return `/view?${q}`;
93
+ }
94
+
95
+ /** Push files at the upload route. -> [{name, kind, width, height}] */
96
+ export async function upload(fileList) {
97
+ const list = [...(fileList || [])];
98
+ if (!list.length) return [];
99
+ const fd = new FormData();
100
+ for (const f of list) fd.append("files", f, f.name);
101
+ const r = await fetch(UPLOAD_URL, { method: "POST", body: fd });
102
+ const j = await r.json().catch(() => ({}));
103
+ if (!r.ok || !j.ok) throw new Error(j.error || `upload failed (HTTP ${r.status})`);
104
+ for (const note of j.skipped || []) console.warn("[HandTieClips] upload skipped:", note);
105
+ await refresh();
106
+ return j.files || [];
107
+ }
108
+
109
+ /* -- one picker ---------------------------------------------------------- */
110
+
111
+ /**
112
+ * A thumbnail that takes a drop, plus a list of what is already on disk.
113
+ *
114
+ * `get()` returns the current basename, `set(name)` stores a new one. The
115
+ * control owns no state of its own -- same rendering-layer contract as the rest
116
+ * of the editor, so undo and the JSON tab keep working.
117
+ */
118
+ export function createPicker({ kind = "image", get, set, onChange, title } = {}) {
119
+ const root = el("div", "h3e-pick");
120
+
121
+ const thumb = el("div", "h3e-thumb");
122
+ thumb.title = title || "Drop a file here, or click to browse.";
123
+ root.appendChild(thumb);
124
+
125
+ const input = el("input");
126
+ input.type = "file";
127
+ input.accept = ACCEPT[kind] || "";
128
+ input.style.display = "none";
129
+ root.appendChild(input);
130
+
131
+ const picker = el("select", "h3e-select h3e-pickfile");
132
+ root.appendChild(picker);
133
+
134
+ let busy = false;
135
+
136
+ function paint() {
137
+ const name = String(get() || "");
138
+ thumb.textContent = "";
139
+ thumb.classList.toggle("h3e-has", Boolean(name));
140
+ if (busy) {
141
+ thumb.appendChild(el("span", "h3e-thumb-hint", "uploading…"));
142
+ } else if (!name) {
143
+ thumb.appendChild(el("span", "h3e-thumb-hint", "drop\nor click"));
144
+ } else if (kind === "image") {
145
+ const img = el("img", "h3e-thumb-img");
146
+ img.src = viewUrl(name);
147
+ img.alt = name;
148
+ // A file deleted from the folder behind our back must not leave a
149
+ // broken-image glyph with no explanation.
150
+ img.addEventListener("error", () => {
151
+ thumb.textContent = "";
152
+ thumb.appendChild(el("span", "h3e-thumb-hint h3e-thumb-bad", "missing"));
153
+ });
154
+ thumb.appendChild(img);
155
+ } else {
156
+ thumb.appendChild(el("span", "h3e-thumb-hint",
157
+ kind === "video" ? "▶ clip" : "♪ audio"));
158
+ }
159
+ if (name) {
160
+ thumb.title = `${name}\nClick to replace, or drop a new file.`;
161
+ const x = button("×", "Clear this picture", (e) => {
162
+ e.stopPropagation();
163
+ set("");
164
+ paint();
165
+ onChange?.();
166
+ }, "h3e-btn h3e-thumb-x");
167
+ thumb.appendChild(x);
168
+ } else {
169
+ thumb.title = title || "Drop a file here, or click to browse.";
170
+ }
171
+ }
172
+
173
+ function paintList(list) {
174
+ const name = String(get() || "");
175
+ picker.textContent = "";
176
+ const blank = el("option", null, "— none —");
177
+ blank.value = "";
178
+ picker.appendChild(blank);
179
+ let found = false;
180
+ for (const f of list || []) {
181
+ if (f.kind !== kind) continue;
182
+ const o = el("option", null, f.name);
183
+ o.value = f.name;
184
+ if (f.name === name) found = true;
185
+ picker.appendChild(o);
186
+ }
187
+ // A name that is no longer on disk still has to be selectable, or
188
+ // switching away from it would look like the value was never set.
189
+ if (name && !found) {
190
+ const o = el("option", null, `${name} (missing)`);
191
+ o.value = name;
192
+ picker.appendChild(o);
193
+ }
194
+ picker.value = name;
195
+ }
196
+
197
+ async function take(fileList) {
198
+ const list = [...(fileList || [])];
199
+ if (!list.length) return;
200
+ busy = true;
201
+ paint();
202
+ try {
203
+ const got = await upload(list.slice(0, 1));
204
+ if (got.length) {
205
+ set(got[0].name);
206
+ onChange?.();
207
+ }
208
+ } catch (err) {
209
+ console.error("[HandTieClips] upload failed:", err);
210
+ alert(`Upload failed: ${err.message || err}`);
211
+ } finally {
212
+ busy = false;
213
+ paint();
214
+ }
215
+ }
216
+
217
+ thumb.addEventListener("click", (e) => {
218
+ if (e.target.closest(".h3e-thumb-x")) return;
219
+ input.click();
220
+ });
221
+ input.addEventListener("change", () => {
222
+ take(input.files);
223
+ input.value = ""; // so re-picking the same file still fires
224
+ });
225
+ thumb.addEventListener("dragover", (e) => {
226
+ // Only light up for an actual file drag. Without this check, dragging a
227
+ // node across the canvas highlights every picker it passes over.
228
+ if (!e.dataTransfer?.types?.includes("Files")) return;
229
+ e.preventDefault();
230
+ e.stopPropagation();
231
+ thumb.classList.add("h3e-hot");
232
+ });
233
+ thumb.addEventListener("dragleave", () => thumb.classList.remove("h3e-hot"));
234
+ thumb.addEventListener("drop", (e) => {
235
+ if (!e.dataTransfer?.files?.length) return;
236
+ e.preventDefault();
237
+ e.stopPropagation();
238
+ thumb.classList.remove("h3e-hot");
239
+ take(e.dataTransfer.files);
240
+ });
241
+ picker.addEventListener("change", () => {
242
+ set(picker.value);
243
+ paint();
244
+ onChange?.();
245
+ });
246
+
247
+ const stop = onFilesChanged(paintList);
248
+ files().then(paintList);
249
+ paint();
250
+
251
+ return {
252
+ root,
253
+ render() {
254
+ paint();
255
+ files().then(paintList);
256
+ },
257
+ destroy: stop,
258
+ };
259
+ }
js/editor/media_strip.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The three single media inputs: first frame, reference clip, voice.
3
+ *
4
+ * These were `start_image`, `reference_video` and `voice` IMAGE/AUDIO sockets.
5
+ * Together with the nine `ref_image_N` slots they were twelve of a sixteen
6
+ * socket column that occupied a third of the node before the editor started.
7
+ * They are filenames now, in `start_image_file` / `reference_video_file` /
8
+ * `voice_file`, and this strip is what sets them.
9
+ *
10
+ * Same rendering-layer contract as the rest of the editor: the widget is the
11
+ * store, this only draws it and writes back through `widget.callback`.
12
+ */
13
+
14
+ import { el, widgetByName } from "./widget_utils.js";
15
+ import { createPicker } from "./media_picker.js";
16
+
17
+ /* Widget name -> what it is, in the order they are drawn. */
18
+ const SLOTS = [
19
+ ["start_image_file", "image", "first frame",
20
+ "Pins hop 1's opening frame. Ignored on later hops -- they are pinned by the join."],
21
+ ["reference_video_file", "video", "reference clip",
22
+ "A motion or look plate the whole chain reads. NOT the previous hop; the join handles that."],
23
+ ["voice_file", "audio", "voice",
24
+ "Voice or timbre reference. Rides every hop as <Audio 1>."],
25
+ ];
26
+
27
+ /** The widget names this strip owns, so the caller hides exactly those. */
28
+ export const MEDIA_WIDGETS = SLOTS.map(([name]) => name);
29
+
30
+ function commit(node, w, value) {
31
+ w.value = value;
32
+ try {
33
+ // Same reason as the run panel: setting `.value` alone leaves anything
34
+ // bound to this widget unaware that it changed.
35
+ w.callback?.(value, node.graph?.canvas, node, undefined, undefined);
36
+ } catch (err) {
37
+ console.error(`[HandTieClips] widget callback for ${w.name} failed:`, err);
38
+ }
39
+ node.graph?.setDirtyCanvas?.(true, true);
40
+ }
41
+
42
+ export function createMediaStrip(node, { onChange } = {}) {
43
+ const root = el("div", "h3e-section h3e-media");
44
+
45
+ const head = el("div", "h3e-head");
46
+ head.appendChild(el("span", "h3e-title", "MEDIA"));
47
+ const count = el("span", "h3e-count");
48
+ head.appendChild(count);
49
+ root.appendChild(head);
50
+
51
+ const grid = el("div", "h3e-media-grid");
52
+ root.appendChild(grid);
53
+
54
+ const pickers = [];
55
+ let missing = 0;
56
+
57
+ for (const [name, kind, label, tip] of SLOTS) {
58
+ const w = widgetByName(node, name);
59
+ if (!w) {
60
+ // A widget this build does not define is skipped, not warned about,
61
+ // so the strip survives an older Python side. Same rule as the run
62
+ // panel's group list.
63
+ missing += 1;
64
+ continue;
65
+ }
66
+ const cell = el("label", "h3e-media-cell");
67
+ cell.appendChild(el("span", "h3e-media-label", label));
68
+ const pick = createPicker({
69
+ kind,
70
+ get: () => String(w.value || ""),
71
+ set: (v) => commit(node, w, v),
72
+ onChange,
73
+ title: tip,
74
+ });
75
+ cell.title = tip;
76
+ cell.appendChild(pick.root);
77
+ grid.appendChild(cell);
78
+ pickers.push(pick);
79
+ }
80
+
81
+ if (missing === SLOTS.length) {
82
+ grid.appendChild(el("div", "h3e-note h3e-note-error",
83
+ "No media widgets found — restart ComfyUI so the Python side matches."));
84
+ }
85
+
86
+ function render() {
87
+ let set = 0;
88
+ for (const [name] of SLOTS) {
89
+ if (String(widgetByName(node, name)?.value || "")) set += 1;
90
+ }
91
+ count.textContent = set ? `${set} set` : "none set";
92
+ for (const p of pickers) p.render();
93
+ }
94
+
95
+ render();
96
+
97
+ return { root, render, ownedNames: () => MEDIA_WIDGETS.slice() };
98
+ }
js/editor/plan_editor.js ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The shot plan editor.
3
+ *
4
+ * `shot_plan` (a hidden STRING widget) stays the ONLY source of truth. Every
5
+ * control here reads from it and writes straight back, so a workflow authored
6
+ * with the editor and one typed by hand are the same file, and anything the
7
+ * editor cannot express is still reachable through the JSON tab.
8
+ *
9
+ * Structure follows ComfyUI-MiniMaxH3-Contex-Loop's h3_chain_plan_editor.js,
10
+ * which solved this shape already. What is ours: the directive axes come from
11
+ * the server (see routes.py) rather than a second copy, the coherence rule is
12
+ * mirrored from plan.check_coherence, and the timing summary uses the real
13
+ * overlap arithmetic instead of a nominal per-shot duration.
14
+ */
15
+
16
+ import {
17
+ el, button, select, vocab, widgetByName, refreshVocab, onVocabRefresh,
18
+ } from "./widget_utils.js";
19
+ import { createTemplatePanel } from "./templates.js";
20
+
21
+ const MODE_PROP = "h3_editor_mode"; // "simple" | "shots"
22
+ const FOLD_PROP = "h3_plan_folded"; // shot id -> collapsed
23
+ const BACKUP_PROP = "h3_plan_backup"; // shot_plan stashed while in Simple
24
+
25
+ /* -- plan model ---------------------------------------------------------- */
26
+
27
+ export function parsePlan(text) {
28
+ // A widget value is not guaranteed to be a string. ComfyUI restores
29
+ // `widgets_values` POSITIONALLY, so a node whose widget list changed can
30
+ // hand this an int or a bool from a neighbouring widget. Coerce rather
31
+ // than throw: a wrong-typed plan should show the JSON tab, not abort the
32
+ // whole workflow load.
33
+ const t = String(text ?? "").trim();
34
+ if (!t) return [];
35
+ let data;
36
+ try {
37
+ data = JSON.parse(t);
38
+ } catch (_) {
39
+ return null; // signals "unparseable"; the JSON tab takes over
40
+ }
41
+ // Valid JSON that is not a plan (a bare `null`, a stray int from a
42
+ // misaligned widget) would otherwise be dereferenced below.
43
+ if (data === null || typeof data !== "object") return null;
44
+ const shots = Array.isArray(data) ? data : (data.shots || []);
45
+ if (!Array.isArray(shots)) return null;
46
+ return shots.map((s, i) => ({
47
+ id: String(s.id || `s${i + 1}`),
48
+ beat: String(s.beat || ""),
49
+ directives: Object.assign({}, s.directives || {}),
50
+ prose: String(s.prose || ""),
51
+ seed: s.seed == null ? null : Number(s.seed),
52
+ steps: s.steps == null ? null : Number(s.steps),
53
+ duration: s.duration || null,
54
+ locked: Boolean(s.locked),
55
+ }));
56
+ }
57
+
58
+ export function planToJson(shots) {
59
+ if (!shots || !shots.length) return "";
60
+ const out = shots.map((s) => {
61
+ const o = { id: s.id, beat: s.beat };
62
+ const dirs = {};
63
+ for (const [k, v] of Object.entries(s.directives || {})) if (v) dirs[k] = v;
64
+ if (Object.keys(dirs).length) o.directives = dirs;
65
+ if (s.prose) o.prose = s.prose;
66
+ if (s.seed != null) o.seed = s.seed;
67
+ if (s.steps != null) o.steps = s.steps;
68
+ if (s.duration) o.duration = s.duration;
69
+ if (s.locked) o.locked = true;
70
+ return o;
71
+ });
72
+ return JSON.stringify({ shots: out }, null, 2);
73
+ }
74
+
75
+ /**
76
+ * Mirrors plan.check_coherence. A framing change asks the audience to be
77
+ * somewhere new; with a held camera the only way to get there is a cut, so
78
+ * `continuous` and the framing change want opposite things.
79
+ */
80
+ function coherenceWarning(shot, index) {
81
+ if (index === 0) return null;
82
+ const d = shot.directives || {};
83
+ const framing = d.framing || "";
84
+ const camera = d.camera || "";
85
+ if (d.join === "continuous" && framing && framing !== "keep" && (!camera || camera === "hold")) {
86
+ return `join=continuous with framing=${framing} and a held camera implies a cut. `
87
+ + "Use push_in / pull_back / pan_follow to reach that framing on the move, or framing=keep.";
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Mirrors the direction check in plan.check_coherence: push_in narrows the
94
+ * frame and pull_back opens it, so naming the opposite destination is a
95
+ * physical contradiction at any join value.
96
+ */
97
+ function directionWarning(shot) {
98
+ const d = shot.directives || {};
99
+ const pair = `${d.camera || ""}|${d.framing || ""}`;
100
+ if (pair === "push_in|wide" || pair === "pull_back|close") {
101
+ return `camera=${d.camera} moves the opposite way from framing=${d.framing}. `
102
+ + "Pick the framing the move actually lands on.";
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /* -- editor -------------------------------------------------------------- */
108
+
109
+ export function createPlanEditor(node, { onChange }) {
110
+ const planWidget = widgetByName(node, "shot_plan");
111
+ const promptWidget = widgetByName(node, "prompt");
112
+ const chainsWidget = widgetByName(node, "chains");
113
+ const durationWidget = widgetByName(node, "duration");
114
+ const overlapWidget = widgetByName(node, "overlap");
115
+
116
+ let V = null;
117
+ const initial = parsePlan(planWidget?.value);
118
+ let broken = initial === null;
119
+ let shots = initial || [];
120
+
121
+ const root = el("div", "h3e-section");
122
+
123
+ /* mode toggle */
124
+ const head = el("div", "h3e-head");
125
+ head.appendChild(el("span", "h3e-title", "SCRIPT"));
126
+ const summary = el("span", "h3e-count");
127
+ head.appendChild(summary);
128
+ head.appendChild(el("span", "h3e-spacer"));
129
+
130
+ const modeWrap = el("div", "h3e-modes");
131
+ const simpleBtn = button("Simple", "One prompt, repeated across N hops", () => setMode("simple"), "h3e-mode");
132
+ const shotsBtn = button("Shots", "One card per hop, with directives", () => setMode("shots"), "h3e-mode");
133
+ modeWrap.appendChild(simpleBtn);
134
+ modeWrap.appendChild(shotsBtn);
135
+ head.appendChild(modeWrap);
136
+ const blankShot = (i) => ({
137
+ id: `s${i + 1}`, beat: "", directives: {}, prose: "",
138
+ seed: null, steps: null, duration: null, locked: false,
139
+ });
140
+ const addBtn = button("+ shot", "Append a shot", () => {
141
+ shots.push(blankShot(shots.length));
142
+ commit();
143
+ });
144
+ head.appendChild(addBtn);
145
+
146
+ /* Templates append patterns rather than replacing the script. Replacing
147
+ * would be the one destructive button on the node, and appending is what
148
+ * you actually want -- a chain is built by stacking these. */
149
+ const templates = createTemplatePanel({
150
+ onPick: (picked) => {
151
+ if (mode() !== "shots") setMode("shots");
152
+ for (const sh of picked) shots.push({ ...sh, id: freeId() });
153
+ templates.hide();
154
+ tplBtn.classList.remove("h3e-on");
155
+ commit();
156
+ },
157
+ });
158
+ const tplBtn = button("Templates", "Insert a ready-made shot pattern", () => {
159
+ tplBtn.classList.toggle("h3e-on", templates.toggle());
160
+ });
161
+ head.appendChild(tplBtn);
162
+ root.appendChild(head);
163
+ root.appendChild(templates.root);
164
+
165
+ /** The lowest `sN` no shot is using. Appending a template onto a plan whose
166
+ * ids are already s1..s3 must not mint a second s1: `id` is the hop
167
+ * cache's pointer, and two shots sharing one would make `locked` reuse the
168
+ * wrong render. */
169
+ function freeId() {
170
+ const used = new Set(shots.map((sh) => sh.id).filter(Boolean));
171
+ for (let n = 1; ; n += 1) {
172
+ const id = `s${n}`;
173
+ if (!used.has(id)) return id;
174
+ }
175
+ }
176
+
177
+ const simpleNote = el("div", "h3e-empty",
178
+ "Simple mode: the prompt widget below drives the run, repeated across `chains` hops "
179
+ + "(set under RUN). Switch to Shots to write one beat per hop with camera and "
180
+ + "join directives.");
181
+ root.appendChild(simpleNote);
182
+
183
+ const list = el("div", "h3e-shots");
184
+ root.appendChild(list);
185
+
186
+ /* JSON escape hatch */
187
+ const jsonWrap = el("details", "h3e-json-wrap");
188
+ const jsonSum = el("summary", null, "JSON");
189
+ jsonSum.title = "The literal shot_plan value. Editing here updates the cards.";
190
+ jsonWrap.appendChild(jsonSum);
191
+ const jsonArea = el("textarea", "h3e-json");
192
+ jsonArea.spellcheck = false;
193
+ jsonWrap.appendChild(jsonArea);
194
+ const jsonErr = el("div", "h3e-note");
195
+ jsonErr.style.display = "none";
196
+ jsonWrap.appendChild(jsonErr);
197
+ jsonArea.addEventListener("input", () => {
198
+ const parsed = parsePlan(jsonArea.value);
199
+ if (parsed === null) {
200
+ jsonErr.textContent = "Not valid JSON — the cards are showing the last good version.";
201
+ jsonErr.style.display = "";
202
+ return;
203
+ }
204
+ jsonErr.style.display = "none";
205
+ shots = parsed;
206
+ writeWidget();
207
+ renderCards();
208
+ onChange?.();
209
+ });
210
+ root.appendChild(jsonWrap);
211
+
212
+ /* The vocabulary is fetched once per browser session. When it fails the
213
+ * editor used to render with no directive controls at all and no
214
+ * explanation -- `_failed` was set and never read. Surface it, with a
215
+ * retry, and re-render every mounted editor when the retry succeeds. */
216
+ const offline = el("div", "h3e-offline");
217
+ offline.style.display = "none";
218
+ root.insertBefore(offline, root.firstChild);
219
+
220
+ function loadVocab() {
221
+ return vocab().then((v) => {
222
+ V = v;
223
+ offline.textContent = "";
224
+ if (v?._failed) {
225
+ offline.style.display = "";
226
+ const note = el("div", "h3e-note h3e-note-error",
227
+ "Directive vocabulary unavailable"
228
+ + (v._error ? ` (${v._error})` : "")
229
+ + " — dropdowns are empty until it loads. Beats and JSON still work.");
230
+ offline.appendChild(note);
231
+ offline.appendChild(button("Retry", "Re-fetch /h3_ref_chain/vocab",
232
+ () => { refreshVocab(); loadVocab(); }));
233
+ } else {
234
+ offline.style.display = "none";
235
+ }
236
+ renderCards();
237
+ });
238
+ }
239
+ loadVocab();
240
+ onVocabRefresh(() => { loadVocab(); });
241
+
242
+ /* -- state plumbing -- */
243
+
244
+ /* Collapsed cards persist in node.properties so the state survives both a
245
+ * re-render and a workflow reload. This is presentation state only -- it
246
+ * never affects what run() sees, so it is not a second source of truth for
247
+ * the plan itself. */
248
+ function foldState() {
249
+ const v = node.properties?.[FOLD_PROP];
250
+ return (v && typeof v === "object") ? v : {};
251
+ }
252
+ function setFoldState(next) {
253
+ if (!node.properties) node.properties = {};
254
+ node.properties[FOLD_PROP] = next;
255
+ }
256
+
257
+ function mode() {
258
+ const stored = node.properties?.[MODE_PROP];
259
+ if (stored === "simple" || stored === "shots") return stored;
260
+ // No stored preference: a saved plan means Shots, otherwise Simple.
261
+ return shots.length ? "shots" : "simple";
262
+ }
263
+
264
+ function setMode(next) {
265
+ node.properties ??= {};
266
+ if (next === "simple" && shots.length) {
267
+ // Simple mode has to clear shot_plan, or run() would ignore the
268
+ // prompt and use a stale plan. Stash it first -- silently losing an
269
+ // authored script to a toggle would be indefensible.
270
+ node.properties[BACKUP_PROP] = planToJson(shots);
271
+ }
272
+ if (next === "shots" && !shots.length && node.properties[BACKUP_PROP]) {
273
+ const restored = parsePlan(node.properties[BACKUP_PROP]);
274
+ if (restored && restored.length) {
275
+ shots = restored;
276
+ node.properties[MODE_PROP] = next;
277
+ commit();
278
+ return;
279
+ }
280
+ }
281
+ if (next === "shots" && !shots.length) {
282
+ // Offer the migration plan.py already implements rather than
283
+ // writing a second one here: one block + chains -> N shots.
284
+ const text = String(promptWidget?.value || "").trim();
285
+ const n = Math.max(1, parseInt(chainsWidget?.value, 10) || 1);
286
+ if (text) {
287
+ const blocks = text.split(/^\s*---\s*$/m).map((b) => b.trim()).filter(Boolean);
288
+ const use = blocks.length > 1 ? blocks.slice(0, n) : [text];
289
+ while (use.length < n) use.push("");
290
+ shots = use.map((b, i) => ({
291
+ id: `s${i + 1}`, beat: b, directives: {}, prose: "",
292
+ seed: null, steps: null, duration: null, locked: false,
293
+ }));
294
+ } else {
295
+ shots = [{ id: "s1", beat: "", directives: {}, prose: "",
296
+ seed: null, steps: null, duration: null, locked: false }];
297
+ }
298
+ }
299
+ node.properties[MODE_PROP] = next;
300
+ commit();
301
+ }
302
+
303
+ function writeWidget() {
304
+ if (!planWidget) return;
305
+ // In Simple mode the plan must be empty, or run() would ignore `prompt`
306
+ // and silently use a stale plan — exactly the confusion this replaces.
307
+ planWidget.value = mode() === "shots" ? planToJson(shots) : "";
308
+ if (document.activeElement !== jsonArea) jsonArea.value = planWidget.value;
309
+ }
310
+
311
+ function commit() {
312
+ writeWidget();
313
+ renderCards();
314
+ onChange?.();
315
+ }
316
+
317
+ /* -- timing -- */
318
+
319
+ function timing() {
320
+ const durs = V?.durations || {};
321
+ const overs = V?.overlaps || {};
322
+ const fps = V?.fps || 24;
323
+ const baseDur = durationWidget?.value;
324
+ const ov = overs[overlapWidget?.value] ?? 0;
325
+ const lengths = shots.map((s) => durs[s.duration || baseDur] ?? durs[baseDur] ?? 0);
326
+ const total = lengths.reduce((a, b) => a + b, 0) - ov * Math.max(0, shots.length - 1);
327
+ return { lengths, ov, fps, total: Math.max(0, total) };
328
+ }
329
+
330
+ function clock(sec) {
331
+ if (!Number.isFinite(sec) || sec <= 0) return "—";
332
+ return `${sec.toFixed(1)}s`;
333
+ }
334
+
335
+ /* -- rendering -- */
336
+
337
+ function renderCard(shot, index) {
338
+ const card = el("div", "h3e-card");
339
+ card.dataset.index = String(index);
340
+
341
+ const bar = el("div", "h3e-card-head");
342
+ const drag = el("span", "h3e-drag", "⠿");
343
+ drag.title = "Drag to reorder";
344
+ drag.draggable = true;
345
+ drag.addEventListener("dragstart", (e) => {
346
+ e.dataTransfer.setData("text/plain", String(index));
347
+ e.dataTransfer.effectAllowed = "move";
348
+ });
349
+ bar.appendChild(drag);
350
+
351
+ // Collapse state is keyed by shot id, not index, so it survives a
352
+ // reorder as well as a re-render.
353
+ const key = shot.id || `s${index + 1}`;
354
+ const folded = Boolean(foldState()[key]);
355
+ if (folded) card.classList.add("h3e-collapsed");
356
+ const fold = button("", folded ? "Expand this shot" : "Collapse this shot", () => {
357
+ const st = foldState();
358
+ st[key] = !st[key];
359
+ setFoldState(st);
360
+ card.classList.toggle("h3e-collapsed");
361
+ fold.title = card.classList.contains("h3e-collapsed")
362
+ ? "Expand this shot" : "Collapse this shot";
363
+ }, "h3e-btn h3e-fold");
364
+ fold.appendChild(el("span", "h3e-fold-mark", "▾"));
365
+ bar.appendChild(fold);
366
+
367
+ bar.appendChild(el("span", "h3e-num", String(index + 1)));
368
+
369
+ const t = timing();
370
+ const frames = t.lengths[index] || 0;
371
+ const delivered = index === 0 ? frames : Math.max(0, frames - t.ov);
372
+ const badge = el("span", "h3e-timing", `${clock(delivered / t.fps)}`);
373
+ badge.title = index === 0
374
+ ? `${frames} frames.`
375
+ : `${frames} frames, ${t.ov} dropped into the previous hop's tail.`;
376
+ bar.appendChild(badge);
377
+
378
+ // Collapsed cards still need to say which shot they are.
379
+ const peekText = (shot.beat || "").replace(/\s+/g, " ").trim();
380
+ const peek = el("span", "h3e-peek", peekText.length > 70
381
+ ? peekText.slice(0, 67) + "…" : (peekText || "(continues)"));
382
+ peek.title = peekText;
383
+ bar.appendChild(peek);
384
+
385
+ if (shot.locked) {
386
+ const lock = el("span", "h3e-lock", "locked");
387
+ lock.title = "Reuses this shot's cached render even when its inputs changed. Needs cache_hops=on.";
388
+ bar.appendChild(lock);
389
+ }
390
+ bar.appendChild(button("×", "Delete this shot", () => {
391
+ shots.splice(index, 1);
392
+ commit();
393
+ }, "h3e-btn h3e-x"));
394
+ card.appendChild(bar);
395
+
396
+ card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("h3e-drop"); });
397
+ card.addEventListener("dragleave", () => card.classList.remove("h3e-drop"));
398
+ card.addEventListener("drop", (e) => {
399
+ e.preventDefault();
400
+ card.classList.remove("h3e-drop");
401
+ const from = parseInt(e.dataTransfer.getData("text/plain"), 10);
402
+ if (!Number.isFinite(from) || from === index) return;
403
+ const [moved] = shots.splice(from, 1);
404
+ shots.splice(index, 0, moved);
405
+ commit();
406
+ });
407
+
408
+ const body = el("div", "h3e-card-body");
409
+ const beat = el("textarea", "h3e-beat");
410
+ beat.value = shot.beat || "";
411
+ beat.rows = 3;
412
+ beat.placeholder = index === 0
413
+ ? "The whole opening: who, where, what they are doing."
414
+ : "Only what is NEW this hop. The identity lock and the join are added for you.";
415
+ beat.title = index === 0
416
+ ? "Shot 1 is the full establishing prompt."
417
+ : "Shots after the first are the new beat only — do not re-describe the face or replay the scene.";
418
+ // Write on every keystroke, not on "change". A textarea only fires
419
+ // "change" on blur, so saving the workflow with the caret still in the
420
+ // box silently discarded everything typed since the last blur.
421
+ beat.addEventListener("input", () => { shot.beat = beat.value; writeWidget(); });
422
+ beat.addEventListener("change", () => { writeWidget(); onChange?.(); });
423
+ body.appendChild(beat);
424
+
425
+ // directive row
426
+ const dirs = el("div", "h3e-dirs");
427
+ for (const axis of (V?.axes || [])) {
428
+ // join has nothing to attach to on hop 1 — directive_prose skips it.
429
+ if (axis === "join" && index === 0) continue;
430
+ const opts = Object.keys(V.vocab[axis] || {});
431
+ const wrap = el("label", "h3e-dir");
432
+ wrap.appendChild(el("span", "h3e-dir-label", axis));
433
+ const def = V.defaults?.[axis];
434
+ const sel = select(opts, shot.directives[axis] || "", (v) => {
435
+ shot.directives[axis] = v;
436
+ commit();
437
+ }, {
438
+ blankLabel: def ? `${def.replace(/_/g, " ")} (default)` : "—",
439
+ titles: V.vocab[axis] || {},
440
+ });
441
+ sel.title = `Hover an option to read the exact sentence it puts in the prompt.`;
442
+ wrap.appendChild(sel);
443
+ dirs.appendChild(wrap);
444
+ }
445
+ body.appendChild(dirs);
446
+
447
+ for (const [text, cls] of [[coherenceWarning(shot, index), "h3e-note h3e-note-hint"],
448
+ [directionWarning(shot), "h3e-note"]]) {
449
+ if (text) body.appendChild(el("div", cls, text));
450
+ }
451
+ if (directionWarning(shot)) card.classList.add("h3e-invalid");
452
+
453
+ // advanced
454
+ const adv = el("details", "h3e-adv");
455
+ adv.appendChild(el("summary", null, "advanced"));
456
+ const grid = el("div", "h3e-grid");
457
+
458
+ const num = (label, key, placeholder, hint) => {
459
+ const l = el("label", "h3e-field");
460
+ l.appendChild(el("span", null, label));
461
+ const i = el("input", "h3e-input");
462
+ i.type = "number";
463
+ i.value = shot[key] == null ? "" : String(shot[key]);
464
+ i.placeholder = placeholder;
465
+ i.title = hint;
466
+ i.addEventListener("input", () => {
467
+ shot[key] = i.value === "" ? null : Number(i.value);
468
+ writeWidget();
469
+ });
470
+ i.addEventListener("change", () => {
471
+ shot[key] = i.value === "" ? null : Number(i.value);
472
+ commit();
473
+ });
474
+ l.appendChild(i);
475
+ return l;
476
+ };
477
+ grid.appendChild(num("seed", "seed", "chain seed", "Override the seed for this hop only."));
478
+ grid.appendChild(num("steps", "steps", "chain steps", "Override the step count for this hop only."));
479
+
480
+ const durL = el("label", "h3e-field");
481
+ durL.appendChild(el("span", null, "duration"));
482
+ const durSel = select(Object.keys(V?.durations || {}), shot.duration || "", (v) => {
483
+ shot.duration = v || null;
484
+ commit();
485
+ }, { blankLabel: `${durationWidget?.value || "chain"} (default)` });
486
+ durSel.title = "Override this hop's length. Must be longer than the overlap.";
487
+ durL.appendChild(durSel);
488
+ grid.appendChild(durL);
489
+
490
+ const lockL = el("label", "h3e-field h3e-check");
491
+ const cb = el("input");
492
+ cb.type = "checkbox";
493
+ cb.checked = Boolean(shot.locked);
494
+ cb.title = "Pin this hop to its cached render regardless of what changed. Needs cache_hops=on.";
495
+ cb.addEventListener("change", () => { shot.locked = cb.checked; commit(); });
496
+ lockL.appendChild(cb);
497
+ lockL.appendChild(el("span", null, "locked"));
498
+ grid.appendChild(lockL);
499
+
500
+ const idL = el("label", "h3e-field");
501
+ idL.appendChild(el("span", null, "id"));
502
+ const idI = el("input", "h3e-input");
503
+ idI.type = "text";
504
+ idI.value = shot.id || "";
505
+ idI.title = "Stable name, used as the cache pointer for `locked`.";
506
+ idI.addEventListener("input", () => { shot.id = idI.value.trim() || `s${index + 1}`; writeWidget(); });
507
+ idI.addEventListener("change", () => { shot.id = idI.value.trim() || `s${index + 1}`; commit(); });
508
+ idL.appendChild(idI);
509
+ grid.appendChild(idL);
510
+ adv.appendChild(grid);
511
+
512
+ const prose = el("textarea", "h3e-beat");
513
+ prose.rows = 2;
514
+ prose.value = shot.prose || "";
515
+ prose.placeholder = "prose appended verbatim (for anything the directives lack)";
516
+ prose.title = "Added to the end of this hop's body, unmodified. Affirmative phrasing only — "
517
+ + "sampling runs at cfg 1.0, so anything named is added, never subtracted.";
518
+ prose.addEventListener("input", () => { shot.prose = prose.value; writeWidget(); });
519
+ prose.addEventListener("change", () => { writeWidget(); onChange?.(); });
520
+ adv.appendChild(prose);
521
+ body.appendChild(adv);
522
+ card.appendChild(body);
523
+
524
+ return card;
525
+ }
526
+
527
+ function renderCards() {
528
+ const m = mode();
529
+ simpleBtn.classList.toggle("h3e-on", m === "simple");
530
+ shotsBtn.classList.toggle("h3e-on", m === "shots");
531
+ simpleNote.style.display = m === "simple" ? "" : "none";
532
+ list.style.display = m === "simple" ? "none" : "";
533
+ jsonWrap.style.display = m === "simple" ? "none" : "";
534
+ addBtn.style.display = m === "simple" ? "none" : "";
535
+ tplBtn.style.display = m === "simple" ? "none" : "";
536
+ if (m === "simple") {
537
+ templates.hide();
538
+ tplBtn.classList.remove("h3e-on");
539
+ }
540
+
541
+ if (m === "simple") {
542
+ summary.textContent = `${chainsWidget?.value || 1} hop(s)`;
543
+ return;
544
+ }
545
+
546
+ list.textContent = "";
547
+ if (broken) {
548
+ const note = el("div", "h3e-note h3e-note-error",
549
+ "shot_plan is not valid JSON. Fix it in the JSON section below and the cards will come back.");
550
+ list.appendChild(note);
551
+ return;
552
+ }
553
+ if (!shots.length) {
554
+ const empty = el("div", "h3e-empty",
555
+ "No shots yet. Shot 1 is the whole opening; every shot after it is only what happens next.");
556
+ empty.appendChild(button("+ Add shot 1", "Add the opening shot", () => {
557
+ shots.push(blankShot(shots.length));
558
+ commit();
559
+ }, "h3e-btn h3e-empty-cta"));
560
+ list.appendChild(empty);
561
+ }
562
+ shots.forEach((s, i) => list.appendChild(renderCard(s, i)));
563
+
564
+ const t = timing();
565
+ summary.textContent = shots.length
566
+ ? `${shots.length} shots · ${clock(t.total / t.fps)} master`
567
+ : "empty";
568
+ }
569
+
570
+ /** Re-read the widget after an external change (workflow load, undo). */
571
+ function reload() {
572
+ const parsed = parsePlan(planWidget?.value);
573
+ broken = parsed === null;
574
+ if (!broken) shots = parsed;
575
+ if (document.activeElement !== jsonArea) jsonArea.value = planWidget?.value || "";
576
+ renderCards();
577
+ }
578
+
579
+ return { root, render: renderCards, reload, mode };
580
+ }
js/editor/ref_rail.js ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The reference rail: what each wired still is, and what it is for.
3
+ *
4
+ * Mirrors `refs.py`. Two things it surfaces that the console only told you
5
+ * about after you queued:
6
+ *
7
+ * - a ref whose picture is missing or never chosen (refs.check)
8
+ * - duplicate tags (parse_ref_plan raises on these, so a typo used to cost
9
+ * you a failed run instead of a red row)
10
+ * - a ref carried over from when references were wired to ref_image_N
11
+ * sockets, which cannot be migrated automatically: the socket held a
12
+ * tensor and there is no filename to recover, so the row says which
13
+ * slot it used to be and asks for the picture.
14
+ *
15
+ * The ordinal badge is the point of the whole module. Core assigns
16
+ * `<Picture N>` by position, so pulling a Load Image out renumbers everything
17
+ * after it; here the ordinal is derived and shown live, and prose refers to the
18
+ * stable @tag instead.
19
+ */
20
+
21
+ import { el, button, select, vocab, onVocabRefresh } from "./widget_utils.js";
22
+ import { createPicker } from "./media_picker.js";
23
+
24
+ const MAX_SLOTS = 9;
25
+
26
+ function blankRef(existing) {
27
+ let n = existing.length + 1;
28
+ const tags = new Set(existing.map((r) => r.tag));
29
+ while (tags.has(`ref_${n}`)) n += 1;
30
+ // No slot: the ordinal is the row's position now, and the picture is
31
+ // chosen in the row itself.
32
+ return { tag: `ref_${n}`, file: "", subject: null, retention: "", desc: "" };
33
+ }
34
+
35
+ export function parseRefPlan(text) {
36
+ // A widget value is not guaranteed to be a string. ComfyUI restores
37
+ // `widgets_values` POSITIONALLY, so a node whose widget list changed can
38
+ // hand this an int or a bool from a neighbouring widget. Coerce rather
39
+ // than throw: a wrong-typed plan should show the JSON tab, not abort the
40
+ // whole workflow load.
41
+ const t = String(text ?? "").trim();
42
+ if (!t) return { refs: [], subjects: {} };
43
+ let data;
44
+ try {
45
+ data = JSON.parse(t);
46
+ } catch (_) {
47
+ return null; // caller keeps the raw text and shows the JSON tab
48
+ }
49
+ // Same guard as plan_editor: valid JSON that is not a plan.
50
+ if (data === null || typeof data !== "object") return null;
51
+ const refs = Array.isArray(data) ? data : (data.refs || []);
52
+ const subjects = (Array.isArray(data) ? {} : (data.subjects || {})) || {};
53
+ return {
54
+ refs: refs.map((r, i) => ({
55
+ tag: String(r.tag || "").replace(/^@/, ""),
56
+ file: String(r.file || ""),
57
+ // A plan authored against the sockets carries a slot and no file.
58
+ // Kept only so the row can say what it used to be plugged into.
59
+ legacy_slot: (!r.file && r.slot != null) ? Number(r.slot) : null,
60
+ // Derived, never authored -- mirrors refs.py.
61
+ slot: i + 1,
62
+ subject: r.subject == null ? null : Number(r.subject),
63
+ retention: r.retention || "",
64
+ desc: r.desc || "",
65
+ shots: Array.isArray(r.shots) ? r.shots.slice() : null,
66
+ })),
67
+ subjects,
68
+ };
69
+ }
70
+
71
+ export function refPlanToJson(plan) {
72
+ if (!plan.refs.length && !Object.keys(plan.subjects).length) return "";
73
+ const refs = plan.refs.map((r) => {
74
+ // `slot` is deliberately not written back: it is derived from row
75
+ // order on both sides, and persisting it would resurrect the socket
76
+ // number a legacy plan is trying to leave behind.
77
+ const out = { tag: r.tag };
78
+ if (r.file) out.file = r.file;
79
+ else if (r.legacy_slot) out.slot = r.legacy_slot;
80
+ if (r.subject != null) out.subject = r.subject;
81
+ if (r.retention) out.retention = r.retention;
82
+ if (r.desc) out.desc = r.desc;
83
+ if (r.shots && r.shots.length) out.shots = r.shots;
84
+ return out;
85
+ });
86
+ const body = { refs };
87
+ if (Object.keys(plan.subjects).length) body.subjects = plan.subjects;
88
+ return JSON.stringify(body, null, 2);
89
+ }
90
+
91
+ /** Rows that actually name a picture. Only these take a <Picture N> ordinal. */
92
+ function wiredSlots(plan) {
93
+ const out = new Set();
94
+ (plan.refs || []).forEach((r, i) => {
95
+ if (r.file) out.add(i + 1);
96
+ });
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Per-hop ordinals, exactly as `refs.active_refs` + `refs.ordinals` compute
102
+ * them: slot order, restricted to slots with an image.
103
+ */
104
+ function ordinalsFor(plan, wired) {
105
+ const active = plan.refs
106
+ .filter((r) => wired.has(r.slot))
107
+ .sort((a, b) => a.slot - b.slot);
108
+ const ords = new Map();
109
+ active.forEach((r, i) => ords.set(r.tag, i + 1));
110
+ return ords;
111
+ }
112
+
113
+ function validate(plan, wired) {
114
+ const problems = new Map(); // tag -> message
115
+ const seenTag = new Map();
116
+ const seenSlot = new Map();
117
+ for (const r of plan.refs) {
118
+ if (!r.tag) {
119
+ problems.set(r, "a tag is required — prose refers to it as @tag");
120
+ continue;
121
+ }
122
+ if (seenTag.has(r.tag)) {
123
+ problems.set(r, `@${r.tag} is already used — tags must be unique`);
124
+ }
125
+ seenTag.set(r.tag, r);
126
+ if (seenSlot.has(r.slot)) {
127
+ problems.set(r, `slot ${r.slot} is already taken by @${seenSlot.get(r.slot).tag}`);
128
+ }
129
+ seenSlot.set(r.slot, r);
130
+ if (!wired.has(r.slot)) {
131
+ problems.set(r, r.legacy_slot
132
+ ? `was wired to ref_image_${r.legacy_slot}, which no longer exists `
133
+ + `— pick its picture to bring this ref back`
134
+ : "no picture chosen — this ref is inactive");
135
+ }
136
+ }
137
+ // A subject block nothing points at is continuity prose for someone who
138
+ // never appears; parse_ref_plan rejects it outright.
139
+ const used = new Set(plan.refs.filter((r) => r.subject != null).map((r) => String(r.subject)));
140
+ const orphans = Object.keys(plan.subjects).filter((k) => !used.has(String(k)));
141
+ return { problems, orphans };
142
+ }
143
+
144
+ export function createRefRail(node, { getPlan, setPlan, onChange, hopCount,
145
+ getRaw, isBad }) {
146
+ const root = el("div", "h3e-section");
147
+ const head = el("div", "h3e-head");
148
+ const title = el("span", "h3e-title", "REFERENCES");
149
+ const count = el("span", "h3e-count");
150
+ head.appendChild(title);
151
+ head.appendChild(count);
152
+ head.appendChild(el("span", "h3e-spacer"));
153
+ head.appendChild(button("+ ref", "Add a reference row", () => {
154
+ const plan = getPlan();
155
+ plan.refs.push(blankRef(plan.refs));
156
+ setPlan(plan);
157
+ render();
158
+ onChange?.();
159
+ }));
160
+ root.appendChild(head);
161
+
162
+ const list = el("div", "h3e-refs");
163
+ root.appendChild(list);
164
+
165
+ /* JSON escape hatch, mirroring the one in the SCRIPT section.
166
+ *
167
+ * Without it the rail was the ONLY editor for `ref_plan`, which made a
168
+ * model-authored register unpastable: prompt_pack hands the author two
169
+ * JSON documents and only `shot_plan` had a box to paste into.
170
+ *
171
+ * It also closes a quieter bug. `parseRefPlan` returns null on text it
172
+ * cannot read, and the caller swapped in an empty plan -- so the next rail
173
+ * edit wrote "" over the very text the author was trying to repair. The
174
+ * paste was destroyed by the act of fixing it. Now the raw value stays in
175
+ * the widget until something parses, and this box is where it shows.
176
+ */
177
+ const jsonWrap = el("details", "h3e-json-wrap");
178
+ const jsonSum = el("summary", null, "JSON");
179
+ jsonSum.title = "The literal ref_plan value. Editing here updates the rows.";
180
+ jsonWrap.appendChild(jsonSum);
181
+ const jsonArea = el("textarea", "h3e-json");
182
+ jsonArea.spellcheck = false;
183
+ jsonArea.placeholder = '{"refs": [{"tag": "hero_face", "file": "face.jpg", '
184
+ + '"subject": 1, "retention": "fully_preserved", "shots": [1, 2]}], '
185
+ + '"subjects": {"1": {"name": "the cook", "locked": "..."}}}';
186
+ jsonWrap.appendChild(jsonArea);
187
+ const jsonErr = el("div", "h3e-note");
188
+ jsonErr.style.display = "none";
189
+ jsonWrap.appendChild(jsonErr);
190
+ jsonArea.addEventListener("input", () => {
191
+ const parsed = parseRefPlan(jsonArea.value);
192
+ if (parsed === null) {
193
+ jsonErr.textContent =
194
+ "Not valid JSON -- the rows are showing the last good version.";
195
+ jsonErr.classList.add("h3e-note-error");
196
+ jsonErr.style.display = "";
197
+ return;
198
+ }
199
+ jsonErr.style.display = "none";
200
+ jsonErr.classList.remove("h3e-note-error");
201
+ setPlan(parsed);
202
+ onChange?.();
203
+ render();
204
+ });
205
+ root.appendChild(jsonWrap);
206
+
207
+ let V = null;
208
+ vocab().then((v) => { V = v; render(); });
209
+ // Re-render when a retry in the plan editor reloads the vocabulary.
210
+ onVocabRefresh(() => vocab().then((v) => { V = v; render(); }));
211
+
212
+ function commit() {
213
+ setPlan(getPlan());
214
+ onChange?.();
215
+ render();
216
+ }
217
+
218
+ function renderSubject(num) {
219
+ const plan = getPlan();
220
+ const s = plan.subjects[num] || (plan.subjects[num] = { name: "", locked: "", context: "" });
221
+ const box = el("div", "h3e-subject");
222
+ box.appendChild(el("span", "h3e-subj-badge", `Subject ${num}`));
223
+
224
+ const mk = (key, placeholder, hint) => {
225
+ const i = el("input", "h3e-input");
226
+ i.type = "text";
227
+ i.value = s[key] || "";
228
+ i.placeholder = placeholder;
229
+ i.title = hint;
230
+ i.addEventListener("input", () => { s[key] = i.value; setPlan(plan); onChange?.(); });
231
+ return i;
232
+ };
233
+ box.appendChild(mk("name", "what to call them",
234
+ "Used as “<Subject N> is <name>, the person in <Picture …>”. A role works better than a proper name."));
235
+ box.appendChild(mk("locked", "traits that must not drift",
236
+ "Injected verbatim on every hop. Only put things that are actually in the photograph here — at cfg 1.0 every word is additive."));
237
+ box.appendChild(mk("context", "current standing state",
238
+ "Same as locked but less rigid: what they are wearing right now, current mood."));
239
+ return box;
240
+ }
241
+
242
+ function render() {
243
+ list.textContent = "";
244
+ // Never while focused: it would reformat mid-keystroke and steal the caret.
245
+ if (document.activeElement !== jsonArea) jsonArea.value = getRaw?.() ?? "";
246
+ const plan = getPlan();
247
+ if (!plan) return;
248
+ if (isBad?.()) {
249
+ jsonWrap.open = true;
250
+ list.appendChild(el("div", "h3e-note h3e-note-error",
251
+ "ref_plan is not valid JSON. Fix it in the JSON section below "
252
+ + "and the rows will come back."));
253
+ }
254
+ const wired = wiredSlots(plan);
255
+ const ords = ordinalsFor(plan, wired);
256
+ const { problems, orphans } = validate(plan, wired);
257
+ count.textContent = `${plan.refs.length} · ${wired.size} with a picture`;
258
+
259
+ if (!plan.refs.length) {
260
+ const empty = el("div", "h3e-empty",
261
+ "No references. Add one per picture, then write @tag in your beats.");
262
+ list.appendChild(empty);
263
+ }
264
+
265
+ const bySubject = new Map();
266
+ for (const r of plan.refs) {
267
+ const row = el("div", "h3e-ref");
268
+ const bad = problems.get(r);
269
+ if (bad) row.classList.add("h3e-bad");
270
+ if (!wired.has(r.slot)) row.classList.add("h3e-inactive");
271
+
272
+ const ord = ords.get(r.tag);
273
+ const badge = el("span", "h3e-ord", ord ? String(ord) : "–");
274
+ badge.title = ord
275
+ ? `Resolves to <Picture ${ord}> on every hop this ref is active.`
276
+ : "No picture, so it takes no ordinal this run.";
277
+ row.appendChild(badge);
278
+
279
+ const tag = el("input", "h3e-input h3e-tag");
280
+ tag.type = "text";
281
+ tag.value = "@" + r.tag;
282
+ tag.title = "Stable name. Write this in a beat and it resolves to the right picture per hop.";
283
+ // Push to the widget per keystroke; commit() re-renders and would
284
+ // steal the caret, so only the blur handler may call it.
285
+ tag.addEventListener("input", () => {
286
+ r.tag = tag.value.trim().replace(/^@/, "").replace(/[^A-Za-z0-9_]/g, "_");
287
+ setPlan(plan);
288
+ });
289
+ tag.addEventListener("change", commit);
290
+ row.appendChild(tag);
291
+
292
+ // Was a `slot N` dropdown naming one of nine IMAGE sockets. The
293
+ // picture itself lives here now: drop a file on it, click to
294
+ // browse, or choose one already in the reference folder.
295
+ const pick = createPicker({
296
+ kind: "image",
297
+ get: () => r.file || "",
298
+ set: (v) => {
299
+ r.file = v;
300
+ // Choosing a picture is what retires a legacy row, so the
301
+ // "was wired to slot N" note has to go with it.
302
+ if (v) r.legacy_slot = null;
303
+ },
304
+ onChange: () => { commit(); render(); },
305
+ title: "The picture this reference stands for. Drop a file, or click to browse.",
306
+ });
307
+ pick.root.classList.add("h3e-slot");
308
+ row.appendChild(pick.root);
309
+
310
+ const subj = el("select", "h3e-select h3e-subj");
311
+ const none = el("option", null, "setting/prop");
312
+ none.value = "";
313
+ subj.appendChild(none);
314
+ for (let i = 1; i <= 4; i += 1) {
315
+ const o = el("option", null, `Subject ${i}`);
316
+ o.value = String(i);
317
+ subj.appendChild(o);
318
+ }
319
+ subj.value = r.subject == null ? "" : String(r.subject);
320
+ subj.title = "Group pictures of the SAME person under one subject number. "
321
+ + "Two different people sharing a number makes the model render the average of their faces.";
322
+ subj.addEventListener("change", () => {
323
+ r.subject = subj.value === "" ? null : Number(subj.value);
324
+ if (!r.retention) r.retention = r.subject ? "fully_preserved" : "reference";
325
+ commit();
326
+ });
327
+ row.appendChild(subj);
328
+
329
+ const retOpts = Object.keys(V?.retention || {});
330
+ const ret = select(retOpts, r.retention, (v) => { r.retention = v; commit(); }, {
331
+ blankLabel: r.subject ? "fully preserved" : "reference",
332
+ titles: V?.retention || {},
333
+ });
334
+ ret.classList.add("h3e-ret");
335
+ ret.title = "How much of this picture carries over. Hover an option for the exact sentence.";
336
+ row.appendChild(ret);
337
+
338
+ const desc = el("input", "h3e-input h3e-desc");
339
+ desc.type = "text";
340
+ desc.value = r.desc || "";
341
+ desc.placeholder = "describe this photo";
342
+ desc.title = "Goes into retention_analysis verbatim. Describe the picture you actually wired — "
343
+ + "at cfg 1.0 a detail that is not there is asked for, not ignored.";
344
+ desc.addEventListener("input", () => { r.desc = desc.value; setPlan(plan); });
345
+ desc.addEventListener("change", commit);
346
+ row.appendChild(desc);
347
+
348
+ row.appendChild(button("×", "Remove this reference", () => {
349
+ const p = getPlan();
350
+ p.refs.splice(p.refs.indexOf(r), 1);
351
+ setPlan(p);
352
+ onChange?.();
353
+ render();
354
+ }, "h3e-btn h3e-x"));
355
+
356
+ /* Which hops this photograph rides. An empty schedule means hop 1
357
+ * only on a `next` chain -- that is the pin-only recipe
358
+ * chain_00037/00038 were rendered with, and until now it could
359
+ * only be set by hand in the JSON. */
360
+ const hops = Math.max(1, Number(hopCount?.() || 1));
361
+ const chips = el("div", "h3e-chips");
362
+ chips.appendChild(el("span", "h3e-chips-label", "rides hops"));
363
+ for (let h = 1; h <= hops; h += 1) {
364
+ const on = Array.isArray(r.shots) && r.shots.includes(h);
365
+ const chip = el("span", "h3e-chip" + (on ? " h3e-chip-on" : ""), String(h));
366
+ chip.title = on
367
+ ? `This photograph is sent to the encoder on hop ${h}. Click to remove it.`
368
+ : `Click to also send this photograph on hop ${h}. `
369
+ + "Leave every hop off to let the motion pin carry wardrobe and room.";
370
+ chip.addEventListener("click", () => {
371
+ const cur = Array.isArray(r.shots) ? r.shots.slice() : [];
372
+ const at = cur.indexOf(h);
373
+ if (at >= 0) cur.splice(at, 1); else cur.push(h);
374
+ cur.sort((a, b) => a - b);
375
+ r.shots = cur.length ? cur : null;
376
+ commit();
377
+ render();
378
+ });
379
+ chips.appendChild(chip);
380
+ }
381
+ if (!Array.isArray(r.shots) || !r.shots.length) {
382
+ const note = el("span", "h3e-chips-label", "— unscheduled: hop 1 only");
383
+ note.title = "With no hops selected this still stays off hop 2+ of a `next` chain, "
384
+ + "so the motion pin carries continuity instead of the photograph.";
385
+ chips.appendChild(note);
386
+ }
387
+ row.appendChild(chips);
388
+
389
+ list.appendChild(row);
390
+ if (bad) {
391
+ const note = el("div", "h3e-note h3e-note-error", bad);
392
+ list.appendChild(note);
393
+ }
394
+ if (r.subject != null) bySubject.set(r.subject, true);
395
+ }
396
+
397
+ for (const num of [...bySubject.keys()].sort((a, b) => a - b)) {
398
+ list.appendChild(renderSubject(num));
399
+ }
400
+ for (const num of orphans) {
401
+ const warn = el("div", "h3e-note",
402
+ `Subject ${num} has continuity text but no ref claims it. Give a ref that subject number, or clear the text — the run will refuse otherwise.`);
403
+ list.appendChild(warn);
404
+ }
405
+ }
406
+
407
+ return { root, render };
408
+ }
js/editor/run_panel.js ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The run panel: every dial that is not per-shot, grouped and labelled.
3
+ *
4
+ * The shot cards made the *script* legible and left the other twenty-odd
5
+ * widgets stacked underneath as a wall of raw controls, in definition order,
6
+ * with no grouping -- so the node read as "a polished editor, then a heap".
7
+ * The dials with the least obvious semantics (`pin_renorm`, `pin_noise`,
8
+ * `cache_budget_gb`) were indistinguishable from the ones nobody touches.
9
+ *
10
+ * Same contract as the shot editor: this is a RENDERING LAYER over the native
11
+ * widgets, never a second source of truth. Every control writes straight back
12
+ * to `widget.value` and fires the widget's own callback, so the workflow JSON
13
+ * is byte-identical to one produced by the stock UI, and anything this panel
14
+ * cannot express is still reachable by dropping it from the group list.
15
+ *
16
+ * Help text is read from `widget.options.tooltip`, i.e. the `tooltip` written in
17
+ * INPUT_TYPES. Retyping those sentences here would be a second copy that goes
18
+ * stale, for exactly the reason routes.py exists.
19
+ */
20
+
21
+ import { el, widgetByName, widgetType, widgetOptions } from "./widget_utils.js";
22
+
23
+ const OPEN_PROP = "h3_run_open";
24
+
25
+ /**
26
+ * Groups, in draw order; names are native widget names.
27
+ *
28
+ * A name this build does not define is skipped rather than warned about, so the
29
+ * list can carry a widget that only exists on a newer Python side. The flip
30
+ * side is that a *renamed* widget silently drops out of the panel and reappears
31
+ * as a raw dial below it -- the safe direction to fail, and visible the moment
32
+ * you look at the node.
33
+ */
34
+ const GROUPS = [
35
+ ["output", ["resolution", "aspect", "duration", "overlap", "chains"]],
36
+ ["sampling", ["steps", "sampler_name", "scheduler", "seed",
37
+ "control_after_generate", "seed_per_shot",
38
+ "shift_video", "shift_audio"]],
39
+ ["join & pin", ["hop_script", "pin_to_qwen", "ref_image_size",
40
+ "audio_pin_frames", "pin_renorm", "pin_noise",
41
+ "tone_compensate"]],
42
+ ["cache", ["cache_hops", "cache_budget_gb"]],
43
+ ];
44
+
45
+ /**
46
+ * Why a dial is not drawn in the current mode.
47
+ *
48
+ * `run()` ignores `chains` and forces `hop_script=next` the moment a shot plan
49
+ * is present (h3_ref_chain.py:1187-1192, which prints both overrides). Drawing
50
+ * them anyway would offer two controls that do nothing, which is worse than not
51
+ * offering them -- so the panel drops them in Shots mode and says why.
52
+ */
53
+ /**
54
+ * Dials that need two grid cells.
55
+ *
56
+ * A seed is up to 19 digits and a truncated one cannot be read back to
57
+ * reproduce a render, which is the only reason to look at it.
58
+ */
59
+ const WIDE = new Set(["seed"]);
60
+
61
+ const SUPPRESSED_WHY = {
62
+ chains: "the shot list sets the hop count",
63
+ hop_script: "a shot plan forces it to `next`",
64
+ };
65
+
66
+ /* -- one field per native widget ----------------------------------------- */
67
+
68
+ /**
69
+ * Push a value back through the widget.
70
+ *
71
+ * `callback` is how a ComfyUI widget tells the rest of the app it changed --
72
+ * `control_after_generate` binds to the seed through it, and setting `.value`
73
+ * alone would leave that link dead.
74
+ */
75
+ function commitWidget(node, w, value, onChange) {
76
+ w.value = value;
77
+ try {
78
+ w.callback?.(value, node.graph?.canvas, node, undefined, undefined);
79
+ } catch (err) {
80
+ console.error(`[HandTieClips] widget callback for ${w.name} failed:`, err);
81
+ }
82
+ node.graph?.setDirtyCanvas?.(true, true);
83
+ onChange?.();
84
+ }
85
+
86
+ function labelFor(w) {
87
+ return String(w.name || "").replace(/_/g, " ");
88
+ }
89
+
90
+ function comboField(node, w, onChange) {
91
+ const values = widgetOptions(w).values;
92
+ const opts = typeof values === "function" ? values(w, node) : values;
93
+ if (!Array.isArray(opts)) return null;
94
+ const s = el("select", "h3e-select");
95
+ for (const opt of opts) {
96
+ const o = el("option", null, String(opt));
97
+ o.value = String(opt);
98
+ s.appendChild(o);
99
+ }
100
+ s.value = String(w.value ?? "");
101
+ s.addEventListener("change", () => commitWidget(node, w, s.value, onChange));
102
+ return { input: s, read: () => { s.value = String(w.value ?? ""); } };
103
+ }
104
+
105
+ function toggleField(node, w, onChange) {
106
+ const wrap = el("span", "h3e-run-toggle");
107
+ const cb = el("input");
108
+ cb.type = "checkbox";
109
+ cb.checked = Boolean(w.value);
110
+ const text = el("span", "h3e-run-togglabel");
111
+ const paint = () => {
112
+ // A BOOLEAN with label_on/label_off says what each state MEANS
113
+ // ("vary per hop" / "same seed every hop"); a bare on/off throws that away.
114
+ const o = widgetOptions(w);
115
+ text.textContent = cb.checked
116
+ ? (o.label_on || "on")
117
+ : (o.label_off || "off");
118
+ };
119
+ paint();
120
+ cb.addEventListener("change", () => {
121
+ paint();
122
+ commitWidget(node, w, cb.checked, onChange);
123
+ });
124
+ wrap.appendChild(cb);
125
+ wrap.appendChild(text);
126
+ return { input: wrap, read: () => { cb.checked = Boolean(w.value); paint(); } };
127
+ }
128
+
129
+ function numberField(node, w, onChange) {
130
+ const i = el("input", "h3e-input");
131
+ i.type = "number";
132
+ const o = widgetOptions(w);
133
+ if (o.min != null) i.min = String(o.min);
134
+ if (o.max != null) i.max = String(o.max);
135
+ // LiteGraph's `options.step` is ten times the author's step -- a legacy of
136
+ // the canvas drag increment. `step2` carries the real one when the frontend
137
+ // is new enough; divide as the fallback rather than showing a 10x arrow.
138
+ const step = o.step2 ?? (o.step != null ? o.step / 10 : null);
139
+ if (step != null) i.step = String(step);
140
+ // Whether to round on commit. Testing the step alone is not enough: if a
141
+ // frontend ever reports the true step in `step`, the /10 fallback turns an
142
+ // INT's step into 0.1 and `steps` would happily accept 7.5. Requiring the
143
+ // bounds and the current value to be integral too keeps pin_noise (max
144
+ // 0.10) and the shifts (min 0.01) out of it. `cache_budget_gb` is integral
145
+ // on every count and so gets rounded -- a whole-GB budget, which is what
146
+ // its 1.0 step already asks for.
147
+ const isInt = [o.min, o.max, w.value, step]
148
+ .every((v) => v == null || Number.isInteger(Number(v)));
149
+ i.value = String(w.value ?? "");
150
+ const push = () => {
151
+ if (i.value === "") return; // mid-edit; wait for change
152
+ let v = Number(i.value);
153
+ if (!Number.isFinite(v)) return;
154
+ if (o.min != null) v = Math.max(o.min, v);
155
+ if (o.max != null) v = Math.min(o.max, v);
156
+ if (isInt) v = Math.round(v);
157
+ commitWidget(node, w, v, onChange);
158
+ };
159
+ // Commit on input as well as change: the shot editor learned the hard way
160
+ // that a field committing only on blur loses whatever was typed when the
161
+ // workflow is saved with the caret still in the box.
162
+ i.addEventListener("input", push);
163
+ i.addEventListener("change", () => {
164
+ push();
165
+ i.value = String(w.value ?? ""); // show the clamped value back
166
+ });
167
+ return { input: i, read: () => { i.value = String(w.value ?? ""); } };
168
+ }
169
+
170
+ function textField(node, w, onChange) {
171
+ const i = el("input", "h3e-input");
172
+ i.type = "text";
173
+ i.value = String(w.value ?? "");
174
+ i.addEventListener("input", () => commitWidget(node, w, i.value, onChange));
175
+ return { input: i, read: () => { i.value = String(w.value ?? ""); } };
176
+ }
177
+
178
+ /** null when this widget's type is not one the panel can draw. */
179
+ function fieldFor(node, w, onChange) {
180
+ const t = widgetType(w);
181
+ if (t === "combo") return comboField(node, w, onChange);
182
+ if (t === "toggle" || t === "boolean") return toggleField(node, w, onChange);
183
+ if (t === "number" || t === "int" || t === "float") return numberField(node, w, onChange);
184
+ if (t === "string" || t === "text") {
185
+ // Multiline STRINGs are documents, not dials -- they belong to the shot
186
+ // editor or to their own widget, never squeezed into a 104px grid cell.
187
+ if (widgetOptions(w).multiline) return null;
188
+ return textField(node, w, onChange);
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /* -- panel ---------------------------------------------------------------- */
194
+
195
+ export function createRunPanel(node, { onChange, suppressed, hopCount } = {}) {
196
+ const root = el("details", "h3e-section h3e-run");
197
+ const sum = el("summary", "h3e-run-sum");
198
+ sum.appendChild(el("span", "h3e-title", "RUN"));
199
+ const digest = el("span", "h3e-count h3e-run-digest");
200
+ sum.appendChild(digest);
201
+ root.appendChild(sum);
202
+
203
+ const body = el("div", "h3e-run-body");
204
+ root.appendChild(body);
205
+
206
+ /** Widgets this panel actually drew, so the caller hides exactly those. */
207
+ const owned = [];
208
+ const readers = [];
209
+ let builtFor = null; // the suppression set the current DOM was built for
210
+
211
+ function suppressedSet() {
212
+ try {
213
+ return new Set(suppressed?.() || []);
214
+ } catch (err) {
215
+ console.error("[HandTieClips] run panel suppression check failed:", err);
216
+ return new Set();
217
+ }
218
+ }
219
+
220
+ function build() {
221
+ body.textContent = "";
222
+ owned.length = 0;
223
+ readers.length = 0;
224
+ // Widgets found by name but whose type fieldFor could not draw. A
225
+ // frontend that renames widget types would empty this panel silently,
226
+ // and "silent" is exactly what made the last mount bug hard to find.
227
+ const rejected = [];
228
+
229
+ const skip = suppressedSet();
230
+ builtFor = [...skip].sort().join(",");
231
+
232
+ for (const [title, names] of GROUPS) {
233
+ const grid = el("div", "h3e-grid");
234
+ let n = 0;
235
+ for (const name of names) {
236
+ if (skip.has(name)) continue;
237
+ const w = widgetByName(node, name);
238
+ if (!w) continue;
239
+ const f = fieldFor(node, w, () => { paintDigest(); onChange?.(); });
240
+ if (!f) { // stays a native dial below
241
+ rejected.push(`${name}:${widgetType(w) || "?"}`);
242
+ continue;
243
+ }
244
+ const l = el("label",
245
+ WIDE.has(name) ? "h3e-field h3e-run-wide" : "h3e-field");
246
+ l.appendChild(el("span", null, labelFor(w)));
247
+ l.appendChild(f.input);
248
+ // The tooltip is the widget's own, straight from INPUT_TYPES.
249
+ const tip = widgetOptions(w).tooltip;
250
+ if (tip) l.title = String(tip);
251
+ grid.appendChild(l);
252
+ owned.push(name);
253
+ readers.push(f.read);
254
+ n += 1;
255
+ }
256
+ if (!n) continue; // nothing found: draw nothing
257
+ const group = el("div", "h3e-run-group");
258
+ group.appendChild(el("div", "h3e-run-label", title));
259
+ group.appendChild(grid);
260
+ body.appendChild(group);
261
+ }
262
+
263
+ const why = [...skip]
264
+ .filter((name) => SUPPRESSED_WHY[name])
265
+ .map((name) => `${name} — ${SUPPRESSED_WHY[name]}`);
266
+ if (why.length) {
267
+ body.appendChild(el("div", "h3e-note h3e-note-hint",
268
+ `Not shown here: ${why.join("; ")}.`));
269
+ }
270
+
271
+ if (!owned.length) {
272
+ // Two very different failures, and the message has to say which:
273
+ // nothing found by name means a stale Python side; found but not
274
+ // drawable means this frontend reports widget types fieldFor does
275
+ // not know, which is a bug here rather than a version mismatch.
276
+ if (rejected.length) {
277
+ console.warn("[HandTieClips] run panel cannot draw these widget "
278
+ + "types:", rejected.join(", "));
279
+ }
280
+ body.appendChild(el("div", "h3e-note h3e-note-error",
281
+ rejected.length
282
+ ? `No drawable run widgets: this frontend reports types the `
283
+ + `panel does not handle (${rejected.slice(0, 4).join(", ")}`
284
+ + `${rejected.length > 4 ? ", …" : ""}). Native dials left `
285
+ + `visible below; see the console.`
286
+ : "No run widgets found — the Python side is probably a "
287
+ + "different version. The native dials are left visible below."));
288
+ } else if (rejected.length) {
289
+ console.warn("[HandTieClips] run panel left these as native dials:",
290
+ rejected.join(", "));
291
+ }
292
+ // An empty build must not stick: builtFor is what stops sync() from
293
+ // rebuilding, so caching a failure would make it permanent for the
294
+ // life of the node.
295
+ if (!owned.length) builtFor = null;
296
+ }
297
+
298
+ /* The one-line summary, readable with the panel shut. Deliberately no seed:
299
+ * `control_after_generate` rewrites it after every queue without telling
300
+ * us, so a seed shown here would be wrong more often than right. */
301
+ function paintDigest() {
302
+ const v = (n) => widgetByName(node, n)?.value;
303
+ const bits = [];
304
+ const res = v("resolution");
305
+ const asp = String(v("aspect") || "").split(" ")[0];
306
+ if (res) bits.push(asp ? `${res} ${asp}` : String(res));
307
+ const dur = v("duration");
308
+ // The hop count, not `chains`: with a shot plan loaded the two disagree
309
+ // and the plan is the one run() obeys.
310
+ const ch = hopCount?.() || v("chains");
311
+ if (dur && ch) bits.push(`${String(dur).replace(/\s+/g, "")} ×${ch}`);
312
+ const steps = v("steps");
313
+ if (steps) bits.push(`${steps} steps ${v("sampler_name") || ""}`.trim());
314
+ const cache = v("cache_hops");
315
+ if (cache) bits.push(`cache ${cache}`);
316
+ digest.textContent = bits.join(" · ");
317
+ }
318
+
319
+ /** Re-read every field from its widget. */
320
+ function render() {
321
+ for (const read of readers) {
322
+ try {
323
+ read();
324
+ } catch (err) {
325
+ console.error("[HandTieClips] run field refresh failed:", err);
326
+ }
327
+ }
328
+ paintDigest();
329
+ }
330
+
331
+ /**
332
+ * Rebuild only when the suppression set actually changed.
333
+ *
334
+ * Called from the same place that recomputes widget visibility, and it has
335
+ * to run BEFORE that: `ownedNames()` is what decides which native dials get
336
+ * hidden, so a stale build would hide a dial the panel no longer draws.
337
+ */
338
+ function sync() {
339
+ const key = [...suppressedSet()].sort().join(",");
340
+ if (key !== builtFor) build();
341
+ render();
342
+ }
343
+
344
+ build();
345
+ render();
346
+
347
+ // Always open. Collapsing made sense while RUN was the last child of the
348
+ // scroller and cost a screenful; pinned to the bottom it is the one
349
+ // section you touch on every queue, so a toggle only ever hid it.
350
+ // `<details>` is kept for the summary bar's markup, forced open and with
351
+ // its disclosure suppressed -- see the CSS.
352
+ root.open = true;
353
+ root.addEventListener("toggle", () => { root.open = true; });
354
+ // A click on the summary would otherwise close it before that fires.
355
+ sum.addEventListener("click", (e) => e.preventDefault());
356
+ // Values change behind the panel's back -- `control_after_generate` bumps
357
+ // the seed on every queue, and undo rewrites widgets wholesale -- so what
358
+ // used to be re-read on open is now re-read whenever the node is drawn
359
+ // through sync(). node.properties[OPEN_PROP] is left alone: old workflows
360
+ // carry it and it costs nothing.
361
+
362
+ return {
363
+ root,
364
+ render,
365
+ sync,
366
+ /** Names the panel drew; the caller hides exactly these. */
367
+ ownedNames: () => owned.slice(),
368
+ };
369
+ }
js/editor/templates.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Skeleton shot patterns, appended into the card list.
2
+ *
3
+ * These are not "example scenes" -- each one exists to demonstrate a rule from
4
+ * PROMPTING.md that a first-time author reliably gets wrong, in a form they can
5
+ * edit rather than read:
6
+ *
7
+ * - an ending written as a pose plus a sound, never as "stops talking";
8
+ * - a state change landing at the END of the shot before the one that needs
9
+ * it, because every hop opens holding the frames it was handed;
10
+ * - `tail` set to settle/hold wherever a clip actually finishes;
11
+ * - a framing change earned on a camera move, so `join: continuous` and the
12
+ * framing are not asking for opposite things;
13
+ * - a spoken line landing MID-hop with a non-verbal action running into the
14
+ * seam, and a named sound on every hop without dialogue, because the audio
15
+ * pin otherwise carries speech down the whole chain (chain_00057);
16
+ * - `match_cut` on a hop that changes location, because a continuous join
17
+ * across two rooms morphs one into the other (chain_00057).
18
+ *
19
+ * No beat carries an `@tag`. A template that referred to a reference the user
20
+ * has not created would turn a helpful click into a hard error at run time.
21
+ * The `note` on each pattern is where reference scheduling is explained
22
+ * instead.
23
+ */
24
+
25
+ import { el, button } from "./widget_utils.js";
26
+
27
+ function shot(beat, directives) {
28
+ return {
29
+ id: "", beat, directives,
30
+ prose: "", seed: null, steps: null, duration: null, locked: false,
31
+ };
32
+ }
33
+
34
+ export const TEMPLATES = [
35
+ {
36
+ name: "Dialogue, held",
37
+ hint: "Someone speaks, then finishes. Two hops.",
38
+ note: "The line lands mid-hop and a non-verbal action runs into the "
39
+ + "seam, so the audio pin does not carry speech into the next hop.",
40
+ shots: [
41
+ shot("A person stands in the room, looks up at someone off-frame "
42
+ + "and says, 'You are earlier than I expected.' They set both "
43
+ + "hands on the counter, the refrigerator humming behind them.",
44
+ { camera: "hold", framing: "medium", pace: "steady", tail: "ongoing" }),
45
+ shot("Their weight shifts onto one hip and their eyes move slowly "
46
+ + "across the room. The refrigerator hums and a car passes "
47
+ + "outside.",
48
+ { join: "continuous", camera: "hold", framing: "keep", pace: "slow", tail: "settle" }),
49
+ ],
50
+ },
51
+ {
52
+ name: "Cross the room",
53
+ hint: "Movement across the space, camera following. Two hops.",
54
+ note: "The framing change is earned on the move -- a framing change "
55
+ + "with a held camera and a continuous join implies a cut.",
56
+ shots: [
57
+ shot("They set down what they are holding and walk the length of "
58
+ + "the room to the window, looking out at the street. Their "
59
+ + "steps are soft on the floor.",
60
+ { join: "continuous", camera: "pan_follow", framing: "medium", pace: "steady", tail: "ongoing" }),
61
+ shot("They arrive at the window and rest one hand on the frame, "
62
+ + "their weight settling onto one hip. Traffic passes faintly "
63
+ + "on the other side of the glass.",
64
+ { join: "continuous", camera: "push_in", framing: "close", pace: "slow", tail: "settle" }),
65
+ ],
66
+ },
67
+ {
68
+ name: "Leave for an unseen space",
69
+ hint: "Exit the referenced room into somewhere with no picture. Two hops.",
70
+ note: "Take the room reference OFF these hops in the register. A plate "
71
+ + "of the room riding them drags the character back into it.",
72
+ shots: [
73
+ shot("They turn from the window, cross the room and push through "
74
+ + "the doorway into the corridor beyond, the room falling "
75
+ + "away behind them, their footsteps carrying on the floor.",
76
+ { join: "continuous", camera: "pan_follow", framing: "wide", pace: "brisk", tail: "ongoing" }),
77
+ shot("They walk down a narrow corridor hung with coats, one hand "
78
+ + "trailing along the wall, their footsteps muffled on the "
79
+ + "runner.",
80
+ { join: "continuous", camera: "handheld", framing: "medium", pace: "steady", tail: "ongoing" }),
81
+ ],
82
+ },
83
+ {
84
+ name: "Return to the room",
85
+ hint: "Come back to an established space. One hop.",
86
+ note: "Put the room reference back on this hop, and join on "
87
+ + "match_cut -- a continuous join across a real location "
88
+ + "change morphs one room into the other.",
89
+ shots: [
90
+ shot("They walk back through the doorway to where they started, "
91
+ + "picking up what they set down. The room is quiet apart "
92
+ + "from the hum of the refrigerator.",
93
+ { join: "match_cut", camera: "pull_back", framing: "wide", pace: "steady", tail: "hold" }),
94
+ ],
95
+ },
96
+ {
97
+ name: "Quiet close",
98
+ hint: "End a chain without a stray gesture. One hop.",
99
+ note: "A discrete sound rather than a continuous bed, and `tail: hold` "
100
+ + "so the model is not told action is still underway at the last "
101
+ + "frame.",
102
+ shots: [
103
+ shot("They lean back with their lips closed and let their eyes "
104
+ + "move slowly across the room. A single click from the "
105
+ + "refrigerator, then stillness.",
106
+ { join: "continuous", camera: "hold", framing: "keep", pace: "slow", tail: "hold" }),
107
+ ],
108
+ },
109
+ ];
110
+
111
+ /* A panel of one row per pattern. Hidden until the SCRIPT header's button asks
112
+ * for it, so it costs nothing on a node the author already knows their way
113
+ * around. `onPick` receives a fresh deep copy -- the caller renumbers ids, and
114
+ * handing out the module-level object would let one insertion mutate the
115
+ * template for the rest of the session. */
116
+ export function createTemplatePanel({ onPick }) {
117
+ const root = el("div", "h3e-tpl");
118
+ root.style.display = "none";
119
+
120
+ root.appendChild(el("div", "h3e-note",
121
+ "Appended to the end of your script. Every beat is a starting point to "
122
+ + "rewrite, not a finished shot."));
123
+
124
+ for (const t of TEMPLATES) {
125
+ const row = el("div", "h3e-tpl-row");
126
+ const text = el("div", "h3e-tpl-text");
127
+ text.appendChild(el("div", "h3e-tpl-name", t.name));
128
+ text.appendChild(el("div", "h3e-tpl-hint", t.hint));
129
+ text.appendChild(el("div", "h3e-tpl-note", t.note));
130
+ row.appendChild(text);
131
+ row.appendChild(button(
132
+ `+ ${t.shots.length}`,
133
+ `Append ${t.shots.length} shot${t.shots.length > 1 ? "s" : ""}: ${t.note}`,
134
+ () => onPick(JSON.parse(JSON.stringify(t.shots))),
135
+ ));
136
+ root.appendChild(row);
137
+ }
138
+
139
+ return {
140
+ root,
141
+ toggle() {
142
+ root.style.display = root.style.display === "none" ? "" : "none";
143
+ return root.style.display !== "none";
144
+ },
145
+ hide() { root.style.display = "none"; },
146
+ };
147
+ }
js/editor/widget_utils.js ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Foundations for the H3 Ref Chain node editor.
3
+ *
4
+ * Two things here are load-bearing and neither is obvious, so both are
5
+ * documented at the point of use rather than in a README nobody opens:
6
+ *
7
+ * hideWidget() - the four-flag recipe. Classic LiteGraph only needed
8
+ * computeSize = [0, -4]; Vue Nodes 2.0 filters on
9
+ * options.hidden | hideInPanel | canvasOnly, and without
10
+ * those flags every hidden dial reappears as a raw form.
11
+ * Invisible in packs with three widgets, unmissable at 21.
12
+ *
13
+ * installHeightGuard() - the _h fixpoint. LiteGraph's _arrangeWidgets runs
14
+ * every frame; report a height derived from node.size[1] and
15
+ * the node grows forever.
16
+ *
17
+ * Both are ported from PromptMasterLD/js/claude_prompt.js, which paid for them.
18
+ */
19
+
20
+ import { app } from "../../../scripts/app.js";
21
+
22
+ const VOCAB_URL = "/h3_ref_chain/vocab";
23
+
24
+ /* -- vocabulary ---------------------------------------------------------- */
25
+
26
+ let _vocabPromise = null;
27
+ const _vocabListeners = new Set();
28
+
29
+ /**
30
+ * The directive vocabulary, fetched once per browser session.
31
+ *
32
+ * Deliberately NOT bundled into this file: `directives.py` is the single source
33
+ * of truth so that improving one sentence improves every plan already written.
34
+ * A copy here would go stale on the first edit.
35
+ */
36
+ /** Drop the cached vocabulary so the next vocab() call refetches. */
37
+ export function refreshVocab() {
38
+ _vocabPromise = null;
39
+ for (const fn of _vocabListeners) {
40
+ try { fn(); } catch (err) { console.error("[HandTieClips] vocab listener failed:", err); }
41
+ }
42
+ }
43
+
44
+ /** Register a mounted editor to be re-rendered when the vocabulary reloads. */
45
+ export function onVocabRefresh(fn) {
46
+ _vocabListeners.add(fn);
47
+ return () => _vocabListeners.delete(fn);
48
+ }
49
+
50
+ export function vocab() {
51
+ if (!_vocabPromise) {
52
+ _vocabPromise = fetch(VOCAB_URL)
53
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
54
+ .catch((err) => {
55
+ console.error("[HandTieClips] vocabulary unavailable:", err);
56
+ // Degrade to free-text directives rather than an empty editor.
57
+ // `_failed` is read by the editors, which show a retry strip --
58
+ // it used to be set here and never looked at again, so a failed
59
+ // fetch just produced a mysteriously featureless panel.
60
+ return { axes: [], vocab: {}, defaults: {}, retention: {},
61
+ durations: {}, overlaps: {}, max_ref_images: 9, fps: 24,
62
+ _failed: true, _error: String(err && err.message || err) };
63
+ });
64
+ }
65
+ return _vocabPromise;
66
+ }
67
+
68
+ /* -- widget visibility --------------------------------------------------- */
69
+
70
+ /**
71
+ * Hide a native widget without losing its value.
72
+ *
73
+ * The widget still serializes -- that is the entire point. The DOM editor is a
74
+ * rendering layer over `shot_plan` / `ref_plan`, and those widgets remain the
75
+ * only source of truth, so the workflow JSON is byte-identical in shape to one
76
+ * authored by hand.
77
+ */
78
+ /* `widget.inputEl` is a deprecated alias on frontend 1.49.6: merely reading it
79
+ * logs a deprecation warning to the console. Prefer `element`, and only reach
80
+ * for the alias when it is absent, i.e. on an older frontend. */
81
+ function widgetElements(w) {
82
+ if (w.element) return [w.element];
83
+ return w.inputEl ? [w.inputEl] : [];
84
+ }
85
+
86
+ /**
87
+ * The widget's type as the FRONTEND defined it, seeing past our own hiding.
88
+ *
89
+ * `hideWidget` overwrites `w.type` with "hidden" and stashes the real one in
90
+ * `_h3Saved`. Anything that renders a widget therefore has to ask for the type
91
+ * this way, or it reads back the hiding rather than the widget -- which is
92
+ * exactly how the run panel emptied itself: it hid the dials it owned on the
93
+ * first build, then found nothing but "hidden" on the rebuild.
94
+ */
95
+ export function widgetType(w) {
96
+ const t = (w && w._h3Hidden) ? w._h3Saved?.type : w?.type;
97
+ return String(t ?? "").toLowerCase();
98
+ }
99
+
100
+ /** Ditto for options: `hideWidget` replaces the object with a flagged copy. */
101
+ export function widgetOptions(w) {
102
+ const o = (w && w._h3Hidden) ? w._h3Saved?.options : w?.options;
103
+ return o || w?.options || {};
104
+ }
105
+
106
+ export function hideWidget(w) {
107
+ if (!w || w._h3Hidden) return;
108
+ w._h3Hidden = true;
109
+ w._h3Saved = {
110
+ type: w.type,
111
+ computeSize: w.computeSize,
112
+ computeLayoutSize: w.computeLayoutSize,
113
+ draw: w.draw,
114
+ options: w.options,
115
+ };
116
+
117
+ w.type = "hidden";
118
+ w.hidden = true;
119
+ w.options = Object.assign({}, w.options || {}, {
120
+ hidden: true,
121
+ hideInPanel: true,
122
+ // canvasOnly also drops it from the Vue widget list (shouldRenderAsVue).
123
+ canvasOnly: true,
124
+ // Never flip serialization off: the value IS the saved document.
125
+ serialize: w.options?.serialize !== false,
126
+ });
127
+ w.computeSize = () => [0, -4];
128
+ if (typeof w.computeLayoutSize === "function") {
129
+ w.computeLayoutSize = () => ({ minHeight: 0, maxHeight: 0, minWidth: 0 });
130
+ }
131
+ w.draw = () => {};
132
+
133
+ // A multiline STRING widget is a real DOM textarea. Collapsing only the
134
+ // LiteGraph geometry leaves it floating at its old coordinates, on top of
135
+ // the editor.
136
+ for (const el of widgetElements(w)) {
137
+ if (!el?.style) continue;
138
+ el.style.setProperty("display", "none", "important");
139
+ el.setAttribute?.("aria-hidden", "true");
140
+ }
141
+ }
142
+
143
+ /** Undo hideWidget. Used when the Simple/Shots toggle flips. */
144
+ export function showWidget(w) {
145
+ if (!w?._h3Hidden) return;
146
+ const saved = w._h3Saved || {};
147
+ w.type = saved.type;
148
+ w.hidden = false;
149
+ w.options = saved.options || {};
150
+ if (saved.computeSize) w.computeSize = saved.computeSize;
151
+ else delete w.computeSize;
152
+ if (saved.computeLayoutSize) w.computeLayoutSize = saved.computeLayoutSize;
153
+ if (saved.draw) w.draw = saved.draw;
154
+ else delete w.draw;
155
+ for (const el of widgetElements(w)) {
156
+ if (!el?.style) continue;
157
+ el.style.removeProperty("display");
158
+ el.removeAttribute?.("aria-hidden");
159
+ }
160
+ w._h3Hidden = false;
161
+ delete w._h3Saved;
162
+ }
163
+
164
+ export function widgetByName(node, name) {
165
+ return node.widgets?.find((w) => w.name === name) || null;
166
+ }
167
+
168
+ /** Hide every widget in `names`, show the rest of `names`' complement set. */
169
+ export function setWidgetVisibility(node, hiddenNames) {
170
+ const hide = new Set(hiddenNames);
171
+ for (const w of node.widgets || []) {
172
+ if (hide.has(w.name)) hideWidget(w);
173
+ else showWidget(w);
174
+ }
175
+ }
176
+
177
+ /* -- height guard -------------------------------------------------------- */
178
+
179
+ /**
180
+ * Stop the node growing a little every frame.
181
+ *
182
+ * LiteGraph's _arrangeWidgets does, once per frame:
183
+ *
184
+ * l = widgetsTop; for (w of widgets) l += w.computeSize()[1] + 4;
185
+ * if (l > node.size[1]) node.setSize([node.size[0], l]);
186
+ *
187
+ * so a panel that reports `node.size[1]` makes l = top + size[1] + 4 > size[1]
188
+ * on EVERY frame. Observed at ~130px of growth per frame -- the workflow
189
+ * scrolls away from you while you watch it.
190
+ *
191
+ * Reporting `size[1] - chrome() - SLACK` makes l <= size[1] on every frame, so
192
+ * the arrange pass never asks to grow and the loop settles at the node's own
193
+ * height. SLACK is what buys the inequality: panelTop is chrome minus the
194
+ * node's bottom padding, so a panel of exactly `size - chrome` still asks for
195
+ * up to 4px more than the node has -- every frame, forever.
196
+ *
197
+ * This replaced an independent stored `_h`, updated through an onResize hook.
198
+ * The hook fired (57 times in one measured drag) but its `Array.isArray(size)`
199
+ * gate rejected every call -- this frontend's `node.size` is not a plain Array
200
+ * -- so `_h` kept the height the node had at install time and the panel stayed
201
+ * 742px inside a 1911px node. Reading the node directly has no such gate to get
202
+ * wrong.
203
+ *
204
+ * The second floor is the resize drag, which clamps up to
205
+ * LGraphNode.computeSize()[1]. Rather than re-derive that formula (and have it
206
+ * rot the next time the frontend changes), measure it: with `_measuring` set,
207
+ * panelHeight() reports 0, so computeSize() returns pure chrome. The flag also
208
+ * breaks the recursion, since computeSize() calls back into us.
209
+ */
210
+ export function installHeightGuard(node, domWidget, { minHeight = 420, minWidth = 520 } = {}) {
211
+ if (node._h3HeightGuard) return node._h3HeightGuard;
212
+
213
+ let measuring = false;
214
+ let chromeVal = null;
215
+ let chromeKey = "";
216
+
217
+ const chrome = () => {
218
+ // The hidden count is part of the key because hiding a widget changes
219
+ // what computeSize() measures while leaving widgets.length alone --
220
+ // so without it the cached chrome height goes stale the moment
221
+ // applyVisibility runs, and every panel height after that is wrong.
222
+ const hidden = (node.widgets || []).reduce((n, w) => n + (w._h3Hidden ? 1 : 0), 0);
223
+ const key = `${node.inputs?.length}|${node.outputs?.length}|${node.widgets?.length}|${hidden}`;
224
+ if (key === chromeKey && chromeVal != null) return chromeVal;
225
+ measuring = true;
226
+ try {
227
+ chromeVal = +node.computeSize()[1] || 0;
228
+ chromeKey = key;
229
+ } catch (_) {
230
+ chromeVal = 0;
231
+ } finally {
232
+ measuring = false;
233
+ }
234
+ return chromeVal;
235
+ };
236
+
237
+ // The margin that keeps the arrange pass from asking for one more pixel
238
+ // than the node has. See the note above. Costs an invisible strip at the
239
+ // bottom of the panel; 4 would do, 8 leaves room for the frontend to
240
+ // change its mind about padding.
241
+ const SLACK = 8;
242
+ const nodeHeight = () => Math.max(minHeight, +(node.size?.[1]) || minHeight);
243
+ const nodeWidth = () => Math.max(minWidth, +(node.size?.[0]) || minWidth);
244
+
245
+ const guard = {
246
+ get minHeight() { return minHeight; },
247
+ /** The node's own height is the authority; nothing mirrors it. */
248
+ get _h() { return nodeHeight(); },
249
+ panelHeight() {
250
+ if (measuring) return 0;
251
+ return Math.max(minHeight, nodeHeight() - chrome() - SLACK);
252
+ },
253
+ /** Grow the node if it is too short to hold a minimum panel. Never
254
+ * shrinks it -- the node's height is the user's to choose. */
255
+ sync() {
256
+ const need = chrome() + minHeight + SLACK;
257
+ if (nodeHeight() < need - 1) node.setSize([nodeWidth(), need]);
258
+ node.graph?.setDirtyCanvas?.(true, true);
259
+ },
260
+ /** Ask for a taller panel, e.g. after adding a card. */
261
+ grow(px) {
262
+ node.setSize([nodeWidth(), Math.max(minHeight, nodeHeight() + px)]);
263
+ node.graph?.setDirtyCanvas?.(true, true);
264
+ },
265
+ };
266
+ node._h3HeightGuard = guard;
267
+
268
+ if (domWidget) {
269
+ // The two callers want different answers and, helpfully, pass
270
+ // different arguments -- which is the only way to tell them apart:
271
+ //
272
+ // LGraphNode.computeSize() calls computeSize(size[0]), WITH a width.
273
+ // Its result is used purely as a FLOOR (the resize-drag clamp,
274
+ // expandToFitContent). Reporting the live height there pins the
275
+ // floor to the current height, so the node can only ever grow --
276
+ // drag it smaller and nothing happens. Report the MINIMUM.
277
+ //
278
+ // _arrangeWidgets() calls computeSize(), no argument.
279
+ // The real layout pass. Report the live height.
280
+ domWidget.computeSize = (width) => [
281
+ Math.max(minWidth, +(node.size?.[0]) || minWidth),
282
+ measuring ? 0 : (width === undefined ? guard.panelHeight() : minHeight),
283
+ ];
284
+ if (typeof domWidget.computeLayoutSize === "function") {
285
+ // TRACK, DO NOT FREEZE. Reporting a constant `minWidth` here is
286
+ // what collapses the node: the layout pass re-reads it on every
287
+ // recompute -- selecting the node is enough -- and faithfully
288
+ // re-declares the node at its stated minimum, so a wide node
289
+ // becomes a 560px node on the first click and stays there.
290
+ // Safe against feedback: max() against a constant is a fixpoint,
291
+ // not an accumulator like the height chain, and width never feeds
292
+ // height.
293
+ domWidget.computeLayoutSize = () => ({
294
+ minHeight: measuring ? 0 : guard.panelHeight(),
295
+ maxHeight: measuring ? 0 : guard.panelHeight(),
296
+ minWidth: Math.max(minWidth, +(node.size?.[0]) || minWidth),
297
+ });
298
+ }
299
+ // The other half of the collapse. ComfyUI's DOM-widget position
300
+ // updater computes `size = [(widget.width ?? node.width) - margin*2, ...]`.
301
+ // Once anything writes a stale number onto widget.width, node.width is
302
+ // never consulted again -- which is why fixing only the layout size is
303
+ // not enough. A live getter makes the stale value unrepresentable:
304
+ // width always IS the node's width, and writes are dropped.
305
+ try {
306
+ Object.defineProperty(domWidget, "width", {
307
+ get: () => Math.max(minWidth, +(node.size?.[0]) || minWidth),
308
+ set: () => {},
309
+ configurable: true,
310
+ });
311
+ } catch (err) {
312
+ console.warn("[HandTieClips] width getter failed:", err);
313
+ }
314
+ }
315
+
316
+ // With no widget declaring a width, LiteGraph falls back to
317
+ // NODE_WIDTH * 1.5 = 210, and every resize command is then free to crush
318
+ // the panel. Floor it.
319
+ const prevComputeSize = node.computeSize;
320
+ if (typeof prevComputeSize === "function") {
321
+ node.computeSize = function (...args) {
322
+ const r = prevComputeSize.apply(this, args) || [0, 0];
323
+ if (r[0] < minWidth) r[0] = minWidth;
324
+ return r;
325
+ };
326
+ }
327
+
328
+ // No onResize hook any more. There is nothing to mirror: panelHeight()
329
+ // reads node.size[1] at the moment it is asked, so a resize is reflected
330
+ // by the very next arrange pass with nothing to keep in step.
331
+
332
+ return guard;
333
+ }
334
+
335
+ /* -- tiny DOM helpers ---------------------------------------------------- */
336
+
337
+ export function el(tag, className, text) {
338
+ const node = document.createElement(tag);
339
+ if (className) node.className = className;
340
+ if (text != null) node.textContent = text;
341
+ return node;
342
+ }
343
+
344
+ export function button(label, title, onClick, className = "h3e-btn") {
345
+ const b = el("button", className, label);
346
+ b.type = "button";
347
+ if (title) b.title = title;
348
+ b.addEventListener("click", (e) => {
349
+ e.preventDefault();
350
+ e.stopPropagation();
351
+ onClick(e);
352
+ });
353
+ return b;
354
+ }
355
+
356
+ export function select(options, value, onChange, { blankLabel = "—", titles = {} } = {}) {
357
+ const s = el("select", "h3e-select");
358
+ const blank = el("option", null, blankLabel);
359
+ blank.value = "";
360
+ s.appendChild(blank);
361
+ for (const opt of options) {
362
+ const o = el("option", null, opt.replace(/_/g, " "));
363
+ o.value = opt;
364
+ if (titles[opt]) o.title = titles[opt];
365
+ s.appendChild(o);
366
+ }
367
+ s.value = value || "";
368
+ s.addEventListener("change", () => onChange(s.value));
369
+ return s;
370
+ }
371
+
372
+ /**
373
+ * Keep canvas gestures out of the panel.
374
+ *
375
+ * Without this a click inside a textarea also starts a node drag, and a scroll
376
+ * inside the shot list zooms the graph instead.
377
+ */
378
+ export function isolateEvents(root) {
379
+ for (const name of ["pointerdown", "pointerup", "mousedown", "mouseup",
380
+ "click", "dblclick", "contextmenu"]) {
381
+ root.addEventListener(name, (e) => e.stopPropagation());
382
+ }
383
+ root.addEventListener("keydown", (e) => e.stopPropagation());
384
+
385
+ // Wheel is NOT simply swallowed. Stopping it outright meant the graph could
386
+ // not be zoomed while the pointer was anywhere over the panel -- and the
387
+ // panel is most of the node. Scroll the nearest scrollable ancestor if
388
+ // there is somewhere left to scroll; otherwise hand the gesture to the
389
+ // canvas so zoom keeps working. Same approach as KJNodes' wheel passthrough.
390
+ root.addEventListener("wheel", (e) => {
391
+ let n = e.target;
392
+ while (n && n !== root.parentNode) {
393
+ if (n.scrollHeight > n.clientHeight + 1) {
394
+ const style = getComputedStyle(n).overflowY;
395
+ if (style === "auto" || style === "scroll") {
396
+ const atTop = n.scrollTop <= 0;
397
+ const atEnd = n.scrollTop + n.clientHeight >= n.scrollHeight - 1;
398
+ if (!((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atEnd))) {
399
+ e.stopPropagation();
400
+ return;
401
+ }
402
+ }
403
+ }
404
+ n = n.parentNode;
405
+ }
406
+ const canvasEl = document.querySelector("#graph-canvas")
407
+ || app?.canvas?.canvas;
408
+ if (!canvasEl) return;
409
+ e.preventDefault();
410
+ e.stopPropagation();
411
+ canvasEl.dispatchEvent(new WheelEvent(e.type, e));
412
+ }, { passive: false });
413
+ }
js/h3_chain_preview.js ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * H3 Chain Preview -- the chain's progress panel, on its own node.
3
+ *
4
+ * Structure follows ComfyUI-KJNodes' preview_override panel (image area that
5
+ * absorbs slack, a fixed-height stats panel, a drag grip between them). The
6
+ * grip itself follows the sibling MiniMaxH3-Contex-Loop pack's review node
7
+ * instead: it uses pointer capture and reads the UNSCALED offsetHeight, because
8
+ * getBoundingClientRect() includes ComfyUI's canvas zoom and every release then
9
+ * compounds a smaller screen-space height into the saved value.
10
+ *
11
+ * Deliberately NOT overriding computeSize/computeLayoutSize the way the editor
12
+ * panel does. Frontend 1.49.6 distributes a node's spare vertical space across
13
+ * its widgets by growing each from minHeight toward maxHeight; pinning both to
14
+ * the same value opts the widget out of that entirely, which is why the editor
15
+ * panel does not respond to node resize. Here we let the frontend size it and
16
+ * do the internal layout in CSS.
17
+ *
18
+ * There is no JS runtime on the machine this was written on, so this file is
19
+ * static-checked only.
20
+ */
21
+
22
+ import { app } from "../../scripts/app.js";
23
+ import { api } from "../../scripts/api.js";
24
+
25
+ /* Both ids. The pack registers the pre-rename id as a deprecated subclass so
26
+ * workflows saved before 2026-08-29 still load. If this check knew only the
27
+ * new id those nodes would come up with NO editor at all, which looks exactly
28
+ * like the rename having broken the pack. */
29
+ const NODE_TYPES = new Set(["HTCChainPreview", "H3ChainPreview"]);
30
+ const CHAIN_TYPES = new Set(["HandTieClips", "H3RefChain"]);
31
+ const STYLE_ID = "h3rc-style";
32
+ const PANEL_PROP = "h3rcPanelH";
33
+ const _cssUrl = new URL("./h3_ref_chain.css", import.meta.url).href;
34
+
35
+ function ensureStyles() {
36
+ if (document.getElementById(STYLE_ID)) return;
37
+ const link = document.createElement("link");
38
+ link.id = STYLE_ID;
39
+ link.rel = "stylesheet";
40
+ link.href = _cssUrl;
41
+ document.head.appendChild(link);
42
+ }
43
+
44
+ function el(tag, className, text) {
45
+ const n = document.createElement(tag);
46
+ if (className) n.className = className;
47
+ if (text != null) n.textContent = text;
48
+ return n;
49
+ }
50
+
51
+ /** Mirrors getNodeByExecutionId (not exported); subgraph-aware. */
52
+ function findNodeByQualifiedId(rootGraph, qid) {
53
+ if (!rootGraph || qid == null) return null;
54
+ const parts = String(qid).split(":");
55
+ let graph = rootGraph;
56
+ for (let i = 0; i < parts.length - 1; i++) {
57
+ const parentId = parseInt(parts[i], 10);
58
+ if (!Number.isFinite(parentId)) return null;
59
+ const parentNode = graph?.getNodeById?.(parentId);
60
+ if (!parentNode?.subgraph) return null;
61
+ graph = parentNode.subgraph;
62
+ }
63
+ const leafId = parseInt(parts[parts.length - 1], 10);
64
+ if (!Number.isFinite(leafId)) return null;
65
+ return graph?.getNodeById?.(leafId) || null;
66
+ }
67
+
68
+ /**
69
+ * Walk back from this node's `images` input to the chain node feeding it.
70
+ *
71
+ * Live progress events carry the *chain's* node id, so a graph with two chains
72
+ * needs to know which one belongs to this panel. Returns null when the input is
73
+ * unwired or the upstream is something else, in which case the panel falls back
74
+ * to accepting any chain's events -- correct for the common single-chain graph,
75
+ * and labelled as such so it cannot silently mislead.
76
+ */
77
+ function upstreamChainId(node) {
78
+ let hops = 0;
79
+ let current = node;
80
+ let slotName = "images";
81
+ while (current && hops < 12) {
82
+ const input = (current.inputs || []).find((s) => s.name === slotName);
83
+ if (!input || input.link == null) return null;
84
+ const link = current.graph?.links?.[input.link];
85
+ if (!link) return null;
86
+ const src = current.graph.getNodeById?.(link.origin_id);
87
+ if (!src) return null;
88
+ if (CHAIN_TYPES.has(src.type)) return String(src.id);
89
+ // Pass through anything that forwards images (reroutes, other previews).
90
+ const forwarded = (src.inputs || []).find((s) => s.type === "IMAGE");
91
+ if (!forwarded) return null;
92
+ current = src;
93
+ slotName = forwarded.name;
94
+ hops += 1;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function clock(seconds) {
100
+ if (!Number.isFinite(seconds)) return "–";
101
+ const m = Math.floor(seconds / 60);
102
+ const s = seconds - m * 60;
103
+ return m ? `${m}:${s.toFixed(1).padStart(4, "0")}` : `${s.toFixed(1)}s`;
104
+ }
105
+
106
+ function mountPanel(node) {
107
+ ensureStyles();
108
+
109
+ const root = el("div", "h3rc-preview");
110
+
111
+ const frame = el("div", "h3rc-frame");
112
+ const imgA = el("img", "h3rc-img");
113
+ const imgB = el("img", "h3rc-img h3rc-seam-b");
114
+ const placeholder = el("div", "h3rc-placeholder", "waiting for sample…");
115
+ const seamLine = el("div", "h3rc-seam-line");
116
+ const tagA = el("div", "h3rc-seam-tag h3rc-tag-a", "prev");
117
+ const tagB = el("div", "h3rc-seam-tag h3rc-tag-b", "this hop");
118
+ frame.append(imgA, imgB, seamLine, tagA, tagB, placeholder);
119
+ root.appendChild(frame);
120
+
121
+ const bar = el("div", "h3rc-bar");
122
+ const fill = el("div", "h3rc-bar-fill");
123
+ bar.appendChild(fill);
124
+ root.appendChild(bar);
125
+
126
+ const grip = el("div", "h3rc-grip");
127
+ grip.setAttribute("role", "separator");
128
+ grip.setAttribute("aria-orientation", "horizontal");
129
+ grip.title = "Drag to resize · double-click to reset";
130
+ root.appendChild(grip);
131
+
132
+ const panel = el("div", "h3rc-panel");
133
+ const line = el("div", "h3rc-line");
134
+ line.appendChild(el("span", "h3rc-status-label", "status"));
135
+ const statusValue = el("span", "h3rc-status-value", "idle");
136
+ line.appendChild(statusValue);
137
+ const mech = el("span", "h3rc-mech");
138
+ mech.style.display = "none";
139
+ line.appendChild(mech);
140
+ panel.appendChild(line);
141
+ const meta = el("div", "h3rc-meta");
142
+ panel.appendChild(meta);
143
+ root.appendChild(panel);
144
+
145
+ /* -- resize grip ----------------------------------------------------- */
146
+
147
+ const DEFAULT_PANEL_H = 62;
148
+ const applyPanelH = (px) => { panel.style.height = `${Math.max(40, px)}px`; };
149
+ if (typeof node.properties?.[PANEL_PROP] === "number") {
150
+ applyPanelH(node.properties[PANEL_PROP]);
151
+ }
152
+
153
+ let dragFrom = null;
154
+ grip.addEventListener("pointerdown", (e) => {
155
+ if (e.button !== 0) return;
156
+ e.preventDefault();
157
+ e.stopPropagation();
158
+ grip.setPointerCapture(e.pointerId);
159
+ dragFrom = { y: e.clientY, h: panel.offsetHeight };
160
+ });
161
+ grip.addEventListener("pointermove", (e) => {
162
+ if (!dragFrom) return;
163
+ // Divide by canvas zoom: clientY is screen space, offsetHeight is not.
164
+ const scale = app.canvas?.ds?.scale || 1;
165
+ const dy = (e.clientY - dragFrom.y) / scale;
166
+ const max = Math.max(40, root.clientHeight - 90);
167
+ applyPanelH(Math.min(max, dragFrom.h - dy));
168
+ });
169
+ const endDrag = (e) => {
170
+ if (!dragFrom) return;
171
+ dragFrom = null;
172
+ try { grip.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
173
+ // offsetHeight, not getBoundingClientRect().height -- the latter is
174
+ // zoom-scaled and would shrink the saved height on every release.
175
+ if (!node.properties) node.properties = {};
176
+ node.properties[PANEL_PROP] = panel.offsetHeight;
177
+ node.graph?.change?.();
178
+ };
179
+ grip.addEventListener("pointerup", endDrag);
180
+ grip.addEventListener("pointercancel", endDrag);
181
+ grip.addEventListener("dblclick", (e) => {
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ applyPanelH(DEFAULT_PANEL_H);
185
+ if (!node.properties) node.properties = {};
186
+ node.properties[PANEL_PROP] = DEFAULT_PANEL_H;
187
+ });
188
+
189
+ // Keep canvas gestures out of the panel, but let the wheel through so the
190
+ // graph still zooms with the pointer over the preview.
191
+ for (const name of ["pointerdown", "mousedown", "click", "dblclick", "contextmenu"]) {
192
+ root.addEventListener(name, (e) => e.stopPropagation());
193
+ }
194
+
195
+ /* -- state ------------------------------------------------------------ */
196
+
197
+ const setImage = (target, b64) => {
198
+ target.src = `data:image/jpeg;base64,${b64}`;
199
+ target.style.display = "block";
200
+ placeholder.style.display = "none";
201
+ };
202
+
203
+ const api_ = {
204
+ set(data) {
205
+ if (data?.status != null) {
206
+ const s = String(data.status).replace(/\s+/g, " ").trim();
207
+ // Hard clip: this strip is one line, and passing the prompt
208
+ // dump through it once already turned the panel into the prompt.
209
+ statusValue.textContent = s.length > 80 ? `${s.slice(0, 77)}…` : s;
210
+ statusValue.title = s;
211
+ }
212
+ if (typeof data?.image === "string") setImage(imgA, data.image);
213
+ if (typeof data?.seam_image === "string") {
214
+ setImage(imgB, data.seam_image);
215
+ frame.classList.add("h3rc-showing-seam");
216
+ } else if (data?.image) {
217
+ imgB.style.display = "none";
218
+ frame.classList.remove("h3rc-showing-seam");
219
+ }
220
+
221
+ if (typeof data?.frac === "number") {
222
+ fill.style.width = `${Math.round(data.frac * 100)}%`;
223
+ fill.classList.toggle("h3rc-done", data.frac >= 1);
224
+ } else if (data?.hop && data?.total) {
225
+ fill.style.width = `${Math.round((data.hop / data.total) * 100)}%`;
226
+ }
227
+
228
+ if (data?.pin_mech) {
229
+ mech.style.display = "";
230
+ mech.className = `h3rc-mech h3rc-mech-${data.pin_mech}`;
231
+ mech.textContent = data.pin_mech === "motion_context"
232
+ ? "latent pin" : "pixel pin";
233
+ mech.title = data.pin_mech === "motion_context"
234
+ ? "Motion-Context pinned the previous hop's sampler latent — the working join."
235
+ : "AddGuide pinned decoded pixels. This is the fallback: Motion-Context was "
236
+ + "unavailable, the overlap is not one of its context lengths, or the previous "
237
+ + "hop was cached by a build older than the latent sidecar.";
238
+ }
239
+
240
+ const bits = [];
241
+ if (data?.hop && data?.total) bits.push(["hop", `${data.hop}/${data.total}`]);
242
+ if (data?.frames != null && data?.of_frames) bits.push(["frames", `${data.frames}/${data.of_frames}`]);
243
+ else if (data?.frames != null) bits.push(["frames", String(data.frames)]);
244
+ if (data?.cached) bits.push(["cache", `hit ${data.key || ""}`.trim()]);
245
+ if (data?.seed != null) bits.push(["seed", String(data.seed)]);
246
+ if (data?.steps != null) bits.push(["steps", String(data.steps)]);
247
+ if (data?.tone) bits.push(["tone", String(data.tone)]);
248
+ if (data?.video_s != null) bits.push(["video", clock(data.video_s)]);
249
+ if (data?.audio_s != null) bits.push(["audio", clock(data.audio_s)]);
250
+ if (data?.drift_ms != null) {
251
+ bits.push(["A/V drift", `${data.drift_ms > 0 ? "+" : ""}${data.drift_ms} ms`]);
252
+ }
253
+ if (data?.width && data?.height) bits.push(["size", `${data.width}x${data.height}`]);
254
+ if (bits.length) {
255
+ meta.textContent = "";
256
+ for (const [k, v] of bits) {
257
+ const cell = el("span", null, `${k} `);
258
+ cell.appendChild(el("b", null, v));
259
+ meta.appendChild(cell);
260
+ }
261
+ }
262
+ },
263
+ reset() {
264
+ imgA.style.display = "none";
265
+ imgB.style.display = "none";
266
+ frame.classList.remove("h3rc-showing-seam");
267
+ placeholder.style.display = "";
268
+ placeholder.textContent = "waiting for sample…";
269
+ statusValue.textContent = "queued";
270
+ statusValue.title = "";
271
+ mech.style.display = "none";
272
+ meta.textContent = "";
273
+ fill.style.width = "0";
274
+ fill.classList.remove("h3rc-done");
275
+ },
276
+ error(msg) {
277
+ placeholder.style.display = "";
278
+ placeholder.textContent = msg;
279
+ },
280
+ };
281
+
282
+ node._h3ChainPreview = api_;
283
+ const widget = node.addDOMWidget("preview", "h3rc_chain_preview", root, {
284
+ serialize: false,
285
+ hideOnZoom: false,
286
+ getMinHeight: () => 180,
287
+ getMaxHeight: () => 4000,
288
+ });
289
+ return widget;
290
+ }
291
+
292
+ app.registerExtension({
293
+ name: "HandTieClips.ChainPreview",
294
+
295
+ async beforeRegisterNodeDef(nodeType, nodeData) {
296
+ if (!NODE_TYPES.has(nodeData?.name)) return;
297
+
298
+ const onCreated = nodeType.prototype.onNodeCreated;
299
+ nodeType.prototype.onNodeCreated = function () {
300
+ const r = onCreated?.apply(this, arguments);
301
+ mountPanel(this);
302
+ this.setSize([
303
+ Math.max(this.size?.[0] ?? 360, 360),
304
+ Math.max(this.size?.[1] ?? 420, 420),
305
+ ]);
306
+ return r;
307
+ };
308
+
309
+ const onRemoved = nodeType.prototype.onRemoved;
310
+ nodeType.prototype.onRemoved = function () {
311
+ delete this._h3ChainPreview;
312
+ return onRemoved?.apply(this, arguments);
313
+ };
314
+ },
315
+ });
316
+
317
+ /* Live events from the chain node carry the CHAIN's node id, so each panel takes
318
+ * the ones from the chain feeding it. With no resolvable upstream a lone panel
319
+ * still shows the only chain running rather than sitting blank. */
320
+ function previewNodes(graph, out) {
321
+ for (const n of graph?._nodes || []) {
322
+ if (NODE_TYPES.has(n.type) && n._h3ChainPreview) out.push(n);
323
+ if (n.subgraph) previewNodes(n.subgraph, out);
324
+ }
325
+ return out;
326
+ }
327
+
328
+ api.addEventListener("h3_refchain_preview", (e) => {
329
+ const data = e.detail;
330
+ if (!data) return;
331
+ const panels = previewNodes(app.graph, []);
332
+ if (!panels.length) return;
333
+ const chainId = data.node_id == null ? null : String(data.node_id);
334
+ const matched = panels.filter((n) => upstreamChainId(n) === chainId);
335
+ const targets = matched.length ? matched
336
+ : (panels.length === 1 && upstreamChainId(panels[0]) === null ? panels : []);
337
+ for (const n of targets) n._h3ChainPreview.set(data);
338
+ });
339
+
340
+ /* The preview node's own execution: final frame count, size and A/V drift. */
341
+ api.addEventListener("h3_chain_preview", (e) => {
342
+ const data = e.detail;
343
+ if (!data || data.node_id == null) return;
344
+ const node = findNodeByQualifiedId(app.graph, data.node_id);
345
+ node?._h3ChainPreview?.set(data);
346
+ });
347
+
348
+ /* A new run starts blank rather than showing the previous run's last frame --
349
+ * the old panel defined reset() and never called it. */
350
+ api.addEventListener("execution_start", () => {
351
+ for (const n of previewNodes(app.graph, [])) n._h3ChainPreview.reset();
352
+ });
js/h3_ref_chain.css ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* H3 Ref Chain -- node panel styling.
2
+ *
3
+ * External stylesheet rather than a template literal in JS: the old inline
4
+ * sheet hardcoded every colour as a hex, so a light ComfyUI theme rendered the
5
+ * panel as dark-grey islands on a light node. Everything below derives from
6
+ * ComfyUI's own custom properties through color-mix(), which is the idiom the
7
+ * sibling MiniMaxH3-Contex-Loop pack already uses.
8
+ *
9
+ * Class prefixes: .h3e- editor, .h3rc- preview.
10
+ */
11
+
12
+ .h3e-root,
13
+ .h3rc-preview {
14
+ /* Black, one hairline, no radius -- the PromptMasterLD `.ldp-root` system.
15
+ *
16
+ * This used to derive every surface from the host theme with color-mix().
17
+ * The intent was light-theme safety; in practice --h3-bg mixed toward
18
+ * #111827 and --h3-sunken toward #000, so a light theme got dark blue-grey
19
+ * islands anyway while the accents stayed literal hex. Committing to one
20
+ * look is more honest than half-following two. */
21
+ --h3-bg: #0a0a0a;
22
+ --h3-panel: #111111;
23
+ --h3-sunken: #0d0d0d;
24
+ --h3-border: #2a2a2a;
25
+ --h3-border-soft: #1c1c1c;
26
+
27
+ --h3-text: #e8e8e8;
28
+ --h3-muted: #9a9a9a;
29
+ --h3-faint: #6a6a6a;
30
+
31
+ /* One accent. Change this line and the whole panel follows. */
32
+ --h3-accent: #e8ff47;
33
+ --h3-accent-soft: color-mix(in srgb, var(--h3-accent) 10%, transparent);
34
+ --h3-accent-wash: color-mix(in srgb, var(--h3-accent) 4%, transparent);
35
+ /* Text that sits ON the accent. Never white -- acid yellow needs ink. */
36
+ --h3-on-accent: #101010;
37
+ --h3-warn: #ffb020;
38
+ --h3-error: #ff3b30;
39
+ --h3-ok: #5fbf7a;
40
+
41
+ /* One spacing scale. */
42
+ --h3-gap: 8px;
43
+ --h3-gap-sm: 5px;
44
+ /* Square. Kept as tokens rather than deleted so a rounded variant is one
45
+ * edit, and so no rule has to change to get there. */
46
+ --h3-radius: 0px;
47
+ --h3-radius-sm: 0px;
48
+ --h3-row-h: 28px;
49
+
50
+ /* A type scale, which this sheet did not have. Hierarchy was carried by
51
+ * four literal sizes plus alpha, with nothing bold anywhere, which is why
52
+ * it read flat. */
53
+ --h3-fs-xs: 9px;
54
+ --h3-fs-sm: 10px;
55
+ --h3-fs: 11px;
56
+ --h3-fs-lg: 12px;
57
+ --h3-weight-label: 700;
58
+ /* One label idiom. Seven different letter-spacings were doing this job. */
59
+ --h3-track: .14em;
60
+
61
+ box-sizing: border-box;
62
+ color: var(--h3-text);
63
+ font: var(--h3-fs-lg)/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
64
+ }
65
+
66
+ .h3e-root *,
67
+ .h3e-root *::before,
68
+ .h3e-root *::after,
69
+ .h3rc-preview *,
70
+ .h3rc-preview *::before,
71
+ .h3rc-preview *::after { box-sizing: border-box; }
72
+
73
+ /* ---------------------------------------------------------------- editor -- */
74
+
75
+ .h3e-root {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: var(--h3-gap);
79
+ width: 100%;
80
+ height: 100%;
81
+ min-height: 0;
82
+ padding: 2px;
83
+ /* The root no longer scrolls -- .h3e-scroll does. See the note at the end
84
+ * of this sheet. */
85
+ overflow: hidden;
86
+ }
87
+
88
+ /* The containment context the rail's @container queries need. Without this the
89
+ * queries below never match -- see the note above them. */
90
+ .h3e-section { container-type: inline-size; }
91
+
92
+ .h3e-section {
93
+ background: var(--h3-bg);
94
+ border: 1px solid var(--h3-border);
95
+ border-radius: var(--h3-radius);
96
+ padding: var(--h3-gap);
97
+ }
98
+
99
+ /* Sticky header so "+ shot" stays reachable in a long plan. */
100
+ .h3e-head {
101
+ position: sticky;
102
+ top: calc(var(--h3-gap) * -1);
103
+ z-index: 4;
104
+ display: flex;
105
+ align-items: center;
106
+ gap: var(--h3-gap-sm);
107
+ margin: 0 0 var(--h3-gap);
108
+ padding: 4px 0;
109
+ background: var(--h3-bg);
110
+ }
111
+
112
+ .h3e-title {
113
+ font-size: var(--h3-fs-sm);
114
+ font-weight: var(--h3-weight-label);
115
+ letter-spacing: var(--h3-track);
116
+ text-transform: uppercase;
117
+ color: var(--h3-text);
118
+ }
119
+ .h3e-count { font-size: var(--h3-fs-sm); color: var(--h3-faint); font-variant-numeric: tabular-nums; }
120
+ .h3e-spacer { flex: 1; }
121
+
122
+ /* -- controls ------------------------------------------------------------- */
123
+
124
+ .h3e-root button,
125
+ .h3e-root input,
126
+ .h3e-root select,
127
+ .h3e-root textarea {
128
+ font: inherit;
129
+ color: var(--h3-text);
130
+ background: var(--h3-sunken);
131
+ border: 1px solid var(--h3-border);
132
+ border-radius: var(--h3-radius-sm);
133
+ padding: 5px 7px;
134
+ min-width: 0;
135
+ transition: border-color .13s ease, background-color .13s ease, opacity .13s ease;
136
+ }
137
+
138
+ .h3e-root textarea { resize: vertical; line-height: 1.45; }
139
+
140
+ .h3e-root button { cursor: pointer; white-space: nowrap; background: var(--h3-panel); }
141
+ .h3e-root button:hover { border-color: var(--h3-accent); }
142
+ .h3e-root button:active { background: color-mix(in srgb, var(--h3-accent) 22%, var(--h3-panel)); }
143
+ .h3e-root button:disabled { cursor: not-allowed; opacity: .4; }
144
+
145
+ .h3e-root input:hover,
146
+ .h3e-root select:hover,
147
+ .h3e-root textarea:hover { border-color: color-mix(in srgb, var(--h3-accent) 55%, var(--h3-border)); }
148
+
149
+ /* Keyboard focus was completely invisible before -- ~8 controls per card. */
150
+ .h3e-root :focus-visible {
151
+ outline: 2px solid var(--h3-accent);
152
+ outline-offset: 1px;
153
+ border-color: var(--h3-accent);
154
+ }
155
+
156
+ .h3e-btn { padding: 5px 9px; }
157
+ .h3e-x {
158
+ padding: 4px 8px;
159
+ color: var(--h3-error);
160
+ background: transparent;
161
+ border-color: var(--h3-border-soft);
162
+ }
163
+ .h3e-x:hover { border-color: var(--h3-error); background: color-mix(in srgb, var(--h3-error) 14%, transparent); }
164
+
165
+ /* -- Simple / Shots toggle ------------------------------------------------ */
166
+
167
+ .h3e-modes { display: flex; border: 1px solid var(--h3-border); border-radius: var(--h3-radius-sm); overflow: hidden; }
168
+ .h3e-root .h3e-mode {
169
+ border: 0;
170
+ border-radius: 0;
171
+ background: var(--h3-sunken);
172
+ color: var(--h3-muted);
173
+ padding: 5px 12px;
174
+ font-size: 11px;
175
+ }
176
+ .h3e-root .h3e-mode:hover { background: color-mix(in srgb, var(--h3-accent) 12%, var(--h3-sunken)); color: var(--h3-text); }
177
+ .h3e-root .h3e-mode[aria-pressed="true"],
178
+ .h3e-root .h3e-mode.h3e-on {
179
+ background: color-mix(in srgb, var(--h3-accent) 34%, var(--h3-sunken));
180
+ color: var(--h3-text);
181
+ }
182
+
183
+ .h3e-empty {
184
+ color: var(--h3-muted);
185
+ padding: 14px 10px;
186
+ line-height: 1.6;
187
+ text-align: center;
188
+ border: 1px dashed var(--h3-border-soft);
189
+ border-radius: var(--h3-radius-sm);
190
+ }
191
+ .h3e-empty-cta { display: block; margin: 8px auto 0; }
192
+
193
+ /* -- shot cards ----------------------------------------------------------- */
194
+
195
+ .h3e-card {
196
+ --h3-card-accent: var(--h3-accent);
197
+ background:
198
+ linear-gradient(90deg, color-mix(in srgb, var(--h3-card-accent) 7%, transparent), transparent 140px),
199
+ var(--h3-panel);
200
+ border: 1px solid color-mix(in srgb, var(--h3-card-accent) 46%, var(--h3-border));
201
+ border-left: 4px solid var(--h3-card-accent);
202
+ border-radius: var(--h3-radius);
203
+ padding: var(--h3-gap-sm) var(--h3-gap);
204
+ margin-bottom: var(--h3-gap-sm);
205
+ transition: border-color .13s ease, box-shadow .13s ease;
206
+ }
207
+ .h3e-card:hover { border-color: color-mix(in srgb, var(--h3-card-accent) 70%, var(--h3-border)); }
208
+ .h3e-card.h3e-drop,
209
+ .h3e-card.h3e-drag-over { outline: 2px solid var(--h3-accent); outline-offset: 1px; }
210
+ .h3e-card.h3e-dragging { opacity: .45; }
211
+ .h3e-card.h3e-invalid { border-left-color: var(--h3-error); }
212
+
213
+ .h3e-card-head {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: var(--h3-gap-sm);
217
+ min-height: var(--h3-row-h);
218
+ }
219
+
220
+ /* A one-character glyph is not a drag target. */
221
+ .h3e-drag {
222
+ cursor: grab;
223
+ user-select: none;
224
+ padding: 4px 5px;
225
+ border-radius: 4px;
226
+ color: var(--h3-faint);
227
+ line-height: 1;
228
+ }
229
+ .h3e-drag:hover { color: var(--h3-text); background: var(--h3-accent-soft); }
230
+ .h3e-drag:active { cursor: grabbing; }
231
+
232
+ /* Collapse/expand affordance. */
233
+ .h3e-root .h3e-fold {
234
+ padding: 3px 6px;
235
+ background: transparent;
236
+ border-color: transparent;
237
+ color: var(--h3-muted);
238
+ font-size: 10px;
239
+ line-height: 1;
240
+ }
241
+ .h3e-root .h3e-fold:hover { color: var(--h3-text); background: var(--h3-accent-soft); border-color: transparent; }
242
+ .h3e-card.h3e-collapsed .h3e-card-body { display: none; }
243
+ .h3e-card.h3e-collapsed { padding-bottom: var(--h3-gap-sm); }
244
+ .h3e-fold-mark { display: inline-block; transition: transform .15s ease; }
245
+ .h3e-card.h3e-collapsed .h3e-fold-mark { transform: rotate(-90deg); }
246
+
247
+ .h3e-num,
248
+ .h3e-ord {
249
+ background: color-mix(in srgb, var(--h3-accent) 28%, var(--h3-sunken));
250
+ color: var(--h3-text);
251
+ border-radius: 4px;
252
+ padding: 2px 8px;
253
+ font-size: 10px;
254
+ font-variant-numeric: tabular-nums;
255
+ white-space: nowrap;
256
+ }
257
+ .h3e-timing { font-size: 10px; color: var(--h3-muted); font-variant-numeric: tabular-nums; }
258
+ .h3e-lock { font-size: 10px; color: var(--h3-warn); }
259
+ .h3e-peek {
260
+ flex: 1;
261
+ min-width: 0;
262
+ overflow: hidden;
263
+ text-overflow: ellipsis;
264
+ white-space: nowrap;
265
+ font-size: 10px;
266
+ color: var(--h3-faint);
267
+ }
268
+
269
+ .h3e-beat { width: 100%; background: var(--h3-sunken); }
270
+
271
+ /* -- directive row: grid, not fixed-width flex ---------------------------- */
272
+
273
+ .h3e-dirs {
274
+ display: grid;
275
+ grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
276
+ gap: var(--h3-gap-sm);
277
+ margin-top: var(--h3-gap-sm);
278
+ }
279
+ .h3e-dir { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
280
+ .h3e-dir-label,
281
+ .h3e-field > span:first-child {
282
+ font-size: var(--h3-fs-xs);
283
+ font-weight: var(--h3-weight-label);
284
+ letter-spacing: var(--h3-track);
285
+ text-transform: uppercase;
286
+ color: var(--h3-muted);
287
+ }
288
+ .h3e-select, .h3e-input { width: 100%; }
289
+
290
+ /* -- notes: hint / warning / error are three different things ------------- */
291
+
292
+ .h3e-note {
293
+ border-radius: var(--h3-radius-sm);
294
+ padding: 6px 8px;
295
+ margin-top: var(--h3-gap-sm);
296
+ line-height: 1.5;
297
+ border: 1px solid;
298
+ color: var(--h3-warn);
299
+ background: color-mix(in srgb, var(--h3-warn) 12%, transparent);
300
+ border-color: color-mix(in srgb, var(--h3-warn) 40%, transparent);
301
+ }
302
+ .h3e-note.h3e-note-error {
303
+ color: var(--h3-error);
304
+ background: color-mix(in srgb, var(--h3-error) 13%, transparent);
305
+ border-color: color-mix(in srgb, var(--h3-error) 45%, transparent);
306
+ }
307
+ .h3e-note.h3e-note-hint {
308
+ color: var(--h3-muted);
309
+ background: color-mix(in srgb, var(--h3-accent) 8%, transparent);
310
+ border-color: var(--h3-border-soft);
311
+ }
312
+
313
+ .h3e-adv { margin-top: var(--h3-gap-sm); }
314
+ .h3e-adv > summary,
315
+ .h3e-json-wrap > summary {
316
+ cursor: pointer;
317
+ color: var(--h3-muted);
318
+ font-size: 10px;
319
+ letter-spacing: .08em;
320
+ text-transform: uppercase;
321
+ padding: 3px 2px;
322
+ border-radius: 4px;
323
+ }
324
+ .h3e-adv > summary:hover,
325
+ .h3e-json-wrap > summary:hover { color: var(--h3-text); background: var(--h3-accent-soft); }
326
+
327
+ .h3e-grid {
328
+ display: grid;
329
+ grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
330
+ gap: var(--h3-gap-sm);
331
+ margin: var(--h3-gap-sm) 0;
332
+ }
333
+ .h3e-field { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
334
+ .h3e-field .h3e-input, .h3e-field .h3e-select { width: 100%; }
335
+ .h3e-check { flex-direction: row; align-items: center; gap: 6px; }
336
+ .h3e-check input { width: auto; }
337
+
338
+ /* -- run panel ------------------------------------------------------------ */
339
+
340
+ /* A <details> wearing .h3e-section, so it keeps the panel chrome whether it is
341
+ * open or shut. Collapsed by default: these dials are set once and then left,
342
+ * unlike the script above them, and the summary line carries enough of the
343
+ * state that opening it is a choice rather than a check. */
344
+ .h3e-run { padding: var(--h3-gap); }
345
+ .h3e-run > summary {
346
+ cursor: pointer;
347
+ display: flex;
348
+ align-items: baseline;
349
+ gap: var(--h3-gap-sm);
350
+ padding: 2px;
351
+ border-radius: 4px;
352
+ list-style: none; /* the default triangle sits badly on a row */
353
+ min-width: 0;
354
+ }
355
+ .h3e-run > summary::-webkit-details-marker { display: none; }
356
+ .h3e-run > summary::before {
357
+ content: "▸";
358
+ color: var(--h3-faint);
359
+ font-size: 9px;
360
+ transition: transform .12s ease;
361
+ }
362
+ .h3e-run[open] > summary::before { transform: rotate(90deg); }
363
+ .h3e-run > summary:hover { background: var(--h3-accent-soft); }
364
+ .h3e-run > summary:hover .h3e-title { color: var(--h3-text); }
365
+
366
+ /* The digest is one line and must stay one line: it is read at a glance with
367
+ * the panel shut, and wrapping it would push the script off screen. */
368
+ .h3e-run-digest {
369
+ flex: 1;
370
+ min-width: 0;
371
+ overflow: hidden;
372
+ text-overflow: ellipsis;
373
+ white-space: nowrap;
374
+ text-align: right;
375
+ }
376
+
377
+ .h3e-run-body { margin-top: var(--h3-gap-sm); }
378
+ .h3e-run-group + .h3e-run-group {
379
+ margin-top: var(--h3-gap-sm);
380
+ padding-top: var(--h3-gap-sm);
381
+ border-top: 1px solid var(--h3-border-soft);
382
+ }
383
+ .h3e-run-label {
384
+ font-size: var(--h3-fs-xs);
385
+ font-weight: var(--h3-weight-label);
386
+ letter-spacing: var(--h3-track);
387
+ text-transform: uppercase;
388
+ color: var(--h3-muted);
389
+ }
390
+ /* auto-FILL, not auto-fit: auto-fit collapses the empty tracks, so the two
391
+ * dials in CACHE stretched to half the panel each while the four in OUTPUT sat
392
+ * at a quarter. Keeping the empty tracks lines every group up on one grid. */
393
+ .h3e-run-group .h3e-grid {
394
+ margin-top: 4px;
395
+ grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
396
+ }
397
+ .h3e-run .h3e-run-wide { grid-column: span 2; }
398
+ /* Digits line up column-wise, so a changed seed is visible at a glance. */
399
+ .h3e-run input[type="number"] { font-variant-numeric: tabular-nums; }
400
+
401
+ /* A checkbox plus the widget's own label_on/label_off, so the state reads as
402
+ * "vary per hop" rather than as a bare tick. */
403
+ .h3e-run-toggle { display: flex; align-items: center; gap: 6px; min-width: 0; }
404
+ .h3e-root .h3e-run-toggle input { width: auto; flex: none; }
405
+ .h3e-run-togglabel {
406
+ overflow: hidden;
407
+ text-overflow: ellipsis;
408
+ white-space: nowrap;
409
+ color: var(--h3-muted);
410
+ }
411
+
412
+
413
+ /* -- reference rail ------------------------------------------------------- */
414
+
415
+ /* Was fixed pixel widths in a non-wrapping flex row summing to ~406px, so the
416
+ * description got crushed and the row overflowed a narrow node. */
417
+ .h3e-ref {
418
+ display: grid;
419
+ grid-template-columns: auto minmax(88px, 1.1fr) minmax(74px, .8fr) minmax(74px, .8fr) minmax(96px, 1fr) minmax(120px, 1.6fr) auto;
420
+ align-items: center;
421
+ gap: var(--h3-gap-sm);
422
+ padding: 4px;
423
+ margin-bottom: 4px;
424
+ min-height: var(--h3-row-h);
425
+ border: 1px solid transparent;
426
+ border-radius: var(--h3-radius-sm);
427
+ transition: background-color .13s ease, border-color .13s ease;
428
+ }
429
+ .h3e-ref:hover { background: var(--h3-panel); border-color: var(--h3-border-soft); }
430
+ .h3e-ref.h3e-inactive { opacity: .55; }
431
+ .h3e-ref.h3e-bad { border-color: color-mix(in srgb, var(--h3-error) 55%, transparent); }
432
+ .h3e-ref.h3e-bad .h3e-tag { border-color: var(--h3-error); }
433
+ .h3e-inactive .h3e-ord { background: var(--h3-sunken); color: var(--h3-faint); }
434
+ .h3e-tag, .h3e-slot, .h3e-subj, .h3e-ret, .h3e-desc { width: 100%; }
435
+
436
+ /* The rail's 7-track grid needs ~536px of content width and the node only ever
437
+ * offers ~510px at NODE_WIDTH 560, so it is crushed by default. These queries
438
+ * are the relief -- and until `container-type` was declared on .h3e-section
439
+ * (below) they matched nothing at all, because a @container query without a
440
+ * containment context is dead code. That is why the rail looked mangled at
441
+ * every node width. */
442
+ @container (max-width: 560px) {
443
+ /* Drop the description to its own line first: it is the widest track and
444
+ * the least useful one to read mid-row. */
445
+ .h3e-ref {
446
+ grid-template-columns: auto minmax(88px, 1.2fr) minmax(74px, .9fr) minmax(74px, .9fr) minmax(96px, 1fr) auto;
447
+ }
448
+ .h3e-ref .h3e-desc { grid-column: 2 / -1; }
449
+ }
450
+ @container (max-width: 430px) {
451
+ /* Then stack: tag on the top row with the ordinal and the kill, the three
452
+ * selects sharing the next, description below that. */
453
+ .h3e-ref { grid-template-columns: auto 1fr auto; }
454
+ .h3e-ref .h3e-slot,
455
+ .h3e-ref .h3e-subj { grid-column: 2 / 3; }
456
+ .h3e-ref .h3e-ret,
457
+ .h3e-ref .h3e-desc { grid-column: 1 / -1; }
458
+ }
459
+
460
+ /* Per-hop schedule chips. */
461
+ .h3e-chips { display: flex; flex-wrap: wrap; gap: 3px; grid-column: 1 / -1; padding: 2px 0 0 26px; }
462
+ .h3e-chip {
463
+ font-size: 9px;
464
+ line-height: 1;
465
+ padding: 3px 6px;
466
+ border-radius: 999px;
467
+ border: 1px solid var(--h3-border-soft);
468
+ background: var(--h3-sunken);
469
+ color: var(--h3-faint);
470
+ cursor: pointer;
471
+ user-select: none;
472
+ transition: background-color .12s ease, color .12s ease, border-color .12s ease;
473
+ }
474
+ .h3e-chip:hover { border-color: var(--h3-accent); color: var(--h3-text); }
475
+ .h3e-chip.h3e-chip-on {
476
+ background: color-mix(in srgb, var(--h3-accent) 30%, var(--h3-sunken));
477
+ color: var(--h3-text);
478
+ border-color: color-mix(in srgb, var(--h3-accent) 55%, transparent);
479
+ }
480
+ .h3e-chips-label { font-size: 9px; color: var(--h3-faint); align-self: center; margin-right: 2px; letter-spacing: .06em; }
481
+
482
+ .h3e-subject {
483
+ display: flex;
484
+ align-items: center;
485
+ gap: var(--h3-gap-sm);
486
+ margin: 4px 0 var(--h3-gap-sm) 26px;
487
+ }
488
+ .h3e-subject .h3e-input { flex: 1; }
489
+ .h3e-subj-badge {
490
+ /* Was a hardcoded #a98bd8 that appeared nowhere else in the sheet and read
491
+ * as an unexplained colour code beside the ordinal badge. A subject is a
492
+ * different KIND of thing, not a different priority: outline, not fill. */
493
+ background: transparent;
494
+ border: 1px solid var(--h3-accent);
495
+ color: var(--h3-accent);
496
+ border-radius: 4px;
497
+ padding: 2px 8px;
498
+ font-size: 10px;
499
+ white-space: nowrap;
500
+ }
501
+
502
+ .h3e-json {
503
+ width: 100%;
504
+ min-height: 120px;
505
+ background: var(--h3-sunken);
506
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
507
+ font-size: 10px;
508
+ margin-top: 4px;
509
+ }
510
+
511
+ /* -- vocabulary unavailable ----------------------------------------------- */
512
+
513
+ .h3e-offline {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: var(--h3-gap-sm);
517
+ margin-bottom: var(--h3-gap-sm);
518
+ }
519
+ .h3e-offline .h3e-btn { margin-left: auto; }
520
+
521
+ /* --------------------------------------------------------------- preview -- */
522
+
523
+ .h3rc-preview {
524
+ display: flex;
525
+ flex-direction: column;
526
+ width: 100%;
527
+ height: 100%;
528
+ min-height: 0;
529
+ background: var(--h3-bg);
530
+ border: 1px solid var(--h3-border);
531
+ border-radius: var(--h3-radius);
532
+ overflow: hidden;
533
+ }
534
+
535
+ .h3rc-frame { position: relative; flex: 1 1 auto; min-height: 60px; background: var(--h3-sunken); }
536
+ .h3rc-img {
537
+ position: absolute; inset: 0;
538
+ width: 100%; height: 100%;
539
+ object-fit: contain;
540
+ display: none;
541
+ }
542
+ .h3rc-img.h3rc-seam-b { clip-path: inset(0 0 0 50%); }
543
+
544
+ .h3rc-placeholder {
545
+ position: absolute; inset: 0;
546
+ display: flex; align-items: center; justify-content: center;
547
+ color: var(--h3-faint);
548
+ font-size: 12px;
549
+ letter-spacing: .02em;
550
+ user-select: none;
551
+ text-align: center;
552
+ padding: 0 12px;
553
+ }
554
+
555
+ /* Seam divider, only while two frames are shown. */
556
+ .h3rc-seam-line {
557
+ position: absolute; top: 0; bottom: 0; left: 50%;
558
+ width: 1px;
559
+ background: color-mix(in srgb, var(--h3-accent) 70%, transparent);
560
+ display: none;
561
+ }
562
+ .h3rc-seam-tag {
563
+ position: absolute; bottom: 6px;
564
+ font-size: 9px; letter-spacing: .08em; text-transform: uppercase;
565
+ color: var(--h3-text);
566
+ background: color-mix(in srgb, #000 55%, transparent);
567
+ padding: 2px 6px; border-radius: 3px;
568
+ display: none;
569
+ }
570
+ .h3rc-seam-tag.h3rc-tag-a { left: 6px; }
571
+ .h3rc-seam-tag.h3rc-tag-b { right: 6px; }
572
+ .h3rc-showing-seam .h3rc-seam-line,
573
+ .h3rc-showing-seam .h3rc-seam-tag { display: block; }
574
+
575
+ /* Progress across the whole chain. */
576
+ .h3rc-bar { flex: 0 0 3px; background: var(--h3-sunken); overflow: hidden; }
577
+ .h3rc-bar-fill {
578
+ height: 100%;
579
+ width: 0;
580
+ background: var(--h3-accent);
581
+ transition: width .25s ease;
582
+ }
583
+ .h3rc-bar-fill.h3rc-done { background: var(--h3-ok); }
584
+
585
+ .h3rc-panel {
586
+ flex: 0 0 auto;
587
+ display: flex;
588
+ flex-direction: column;
589
+ gap: 3px;
590
+ padding: 6px 9px;
591
+ background: var(--h3-panel);
592
+ border-top: 1px solid var(--h3-border-soft);
593
+ }
594
+ .h3rc-line { display: flex; align-items: center; gap: var(--h3-gap-sm); font-size: 11px; min-width: 0; }
595
+ .h3rc-status-label { color: var(--h3-muted); flex: 0 0 auto; }
596
+ .h3rc-status-value {
597
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
598
+ min-width: 0; flex: 1;
599
+ color: var(--h3-text);
600
+ font-variant-numeric: tabular-nums;
601
+ }
602
+ .h3rc-meta {
603
+ display: flex; flex-wrap: wrap; gap: 4px 10px;
604
+ font-size: 10px; color: var(--h3-muted);
605
+ font-variant-numeric: tabular-nums;
606
+ }
607
+ .h3rc-meta b { font-weight: 600; color: var(--h3-text); }
608
+ .h3rc-mech { padding: 1px 6px; border-radius: 999px; font-size: 9px; letter-spacing: .04em; }
609
+ .h3rc-mech-motion_context { background: color-mix(in srgb, var(--h3-ok) 26%, transparent); color: var(--h3-text); }
610
+ .h3rc-mech-addguide_pixels { background: color-mix(in srgb, var(--h3-warn) 30%, transparent); color: var(--h3-text); }
611
+
612
+ /* Resize grip -- pointer capture, unscaled offsetHeight. */
613
+ .h3rc-grip {
614
+ flex: 0 0 11px;
615
+ position: relative;
616
+ cursor: ns-resize;
617
+ background: var(--h3-panel);
618
+ border-top: 1px solid var(--h3-border-soft);
619
+ }
620
+ .h3rc-grip::after {
621
+ content: "";
622
+ position: absolute;
623
+ left: calc(50% - 18px);
624
+ top: 4px;
625
+ width: 36px; height: 2px;
626
+ border-radius: 2px;
627
+ background: var(--h3-faint);
628
+ }
629
+ .h3rc-grip:hover::after { background: var(--h3-accent); }
630
+
631
+
632
+ /* -- accent states, pickers, media strip ----------------------------------
633
+ *
634
+ * The old palette was a soft blue, so "active" could be a 28-34% tint sitting
635
+ * behind unchanged text. An acid accent cannot do that -- the result is olive
636
+ * mud -- so emphasis inverts instead: accent fill, ink text. Same rule
637
+ * PromptMasterLD uses, and the reason --h3-on-accent exists.
638
+ */
639
+ .h3e-root .h3e-mode.h3e-on {
640
+ background: var(--h3-accent);
641
+ color: var(--h3-on-accent);
642
+ font-weight: 700;
643
+ }
644
+ .h3e-num,
645
+ .h3e-ord {
646
+ background: var(--h3-accent);
647
+ color: var(--h3-on-accent);
648
+ font-weight: 700;
649
+ }
650
+ .h3e-inactive .h3e-ord {
651
+ background: var(--h3-sunken);
652
+ color: var(--h3-faint);
653
+ }
654
+ .h3e-chip.h3e-chip-on {
655
+ background: var(--h3-accent);
656
+ color: var(--h3-on-accent);
657
+ border-color: var(--h3-accent);
658
+ font-weight: 700;
659
+ }
660
+
661
+ /* Grouping by a left rule and a wash rather than an outlined box. */
662
+ .h3e-section {
663
+ border: none;
664
+ border-left: 2px solid var(--h3-border);
665
+ background: var(--h3-bg);
666
+ }
667
+ .h3e-card { border-left-width: 2px; }
668
+ .h3e-run-group {
669
+ border-left: 2px solid var(--h3-accent);
670
+ background: var(--h3-accent-wash);
671
+ padding-left: var(--h3-gap-sm);
672
+ }
673
+
674
+ /* -- media pickers --------------------------------------------------------
675
+ *
676
+ * Empty is dashed, filled is solid, so the border alone says whether a slot
677
+ * holds something -- readable at a glance across nine of them. `contain` and
678
+ * never `cover`: the whole job of this thumbnail is letting you identify the
679
+ * picture, and a cropped face is worse than a letterboxed one.
680
+ */
681
+ .h3e-pick { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
682
+ .h3e-thumb {
683
+ position: relative;
684
+ width: 100%;
685
+ height: 46px;
686
+ border: 1px dashed var(--h3-border);
687
+ background: var(--h3-sunken);
688
+ display: flex;
689
+ align-items: center;
690
+ justify-content: center;
691
+ cursor: pointer;
692
+ overflow: hidden;
693
+ transition: border-color .12s ease, background-color .12s ease;
694
+ }
695
+ .h3e-thumb:hover { border-color: var(--h3-accent); }
696
+ .h3e-thumb.h3e-has { border-style: solid; }
697
+ .h3e-thumb.h3e-hot {
698
+ border-style: solid;
699
+ border-color: var(--h3-accent);
700
+ background: color-mix(in srgb, var(--h3-accent) 10%, var(--h3-sunken));
701
+ }
702
+ .h3e-thumb-img {
703
+ position: absolute;
704
+ inset: 0;
705
+ width: 100%;
706
+ height: 100%;
707
+ object-fit: contain;
708
+ pointer-events: none;
709
+ }
710
+ .h3e-thumb-hint {
711
+ font-size: var(--h3-fs-xs);
712
+ color: var(--h3-faint);
713
+ text-align: center;
714
+ line-height: 1.15;
715
+ white-space: pre-line;
716
+ pointer-events: none;
717
+ }
718
+ .h3e-thumb-bad { color: var(--h3-error); }
719
+ /* Reveal on hover, and a destructive hover is a tinted ground rather than a
720
+ * red fill -- a clear button that shouts is one people misread. */
721
+ .h3e-root .h3e-thumb-x {
722
+ position: absolute;
723
+ top: 2px;
724
+ right: 2px;
725
+ z-index: 5;
726
+ width: 16px;
727
+ height: 16px;
728
+ padding: 0;
729
+ line-height: 1;
730
+ display: none;
731
+ align-items: center;
732
+ justify-content: center;
733
+ background: color-mix(in srgb, #000 78%, transparent);
734
+ border: 1px solid var(--h3-border);
735
+ color: var(--h3-muted);
736
+ }
737
+ .h3e-thumb:hover .h3e-thumb-x { display: flex; }
738
+ .h3e-root .h3e-thumb-x:hover {
739
+ color: var(--h3-error);
740
+ border-color: var(--h3-error);
741
+ background: color-mix(in srgb, var(--h3-error) 18%, #000);
742
+ }
743
+ .h3e-root .h3e-pickfile {
744
+ font-size: var(--h3-fs-xs);
745
+ padding: 2px 4px;
746
+ }
747
+
748
+ /* -- media strip ---------------------------------------------------------- */
749
+ .h3e-media-grid {
750
+ display: grid;
751
+ grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
752
+ gap: var(--h3-gap-sm);
753
+ margin-top: 4px;
754
+ }
755
+ .h3e-media-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
756
+ .h3e-media-label {
757
+ font-size: var(--h3-fs-xs);
758
+ font-weight: var(--h3-weight-label);
759
+ letter-spacing: var(--h3-track);
760
+ text-transform: uppercase;
761
+ color: var(--h3-muted);
762
+ }
763
+
764
+
765
+ /* -- pinned RUN ----------------------------------------------------------
766
+ *
767
+ * `.h3e-root` used to be the scroll container with all four sections as
768
+ * children, which put RUN below the script -- out of view on any workflow
769
+ * with more than a couple of shots, and it is the section you open on every
770
+ * queue. Split in two: the authoring sections scroll, RUN is pinned.
771
+ *
772
+ * `min-height: 0` on the scroller is what makes this work at all. A flex item
773
+ * defaults to `min-height: auto`, so the scroller would refuse to shrink below
774
+ * its content and push RUN off the bottom of the node instead of scrolling.
775
+ */
776
+ .h3e-scroll {
777
+ flex: 1 1 auto;
778
+ /* Never let RUN crush the authoring sections to nothing. Below this the
779
+ * scroller stops shrinking and RUN scrolls internally instead. */
780
+ min-height: 160px;
781
+ overflow-y: auto;
782
+ scrollbar-gutter: stable;
783
+ display: flex;
784
+ flex-direction: column;
785
+ gap: var(--h3-gap);
786
+ }
787
+ .h3e-root > .h3e-run {
788
+ /* `0 0 auto`. It must NOT shrink: flex divides a deficit in proportion to
789
+ * each item's content height, and the scroller's content is far taller, so
790
+ * a shrinkable RUN loses most of the space and clips its lower groups. The
791
+ * scroller absorbs all the shrinking; its min-height is the floor that
792
+ * stops RUN taking the whole panel, and max-height below is the ceiling. */
793
+ flex: 0 0 auto;
794
+ max-height: 55%;
795
+ display: flex;
796
+ flex-direction: column;
797
+ min-height: 0;
798
+ }
799
+ /* Always open, so the disclosure triangle would be a control that does
800
+ * nothing. The summary stays as the header and digest bar. */
801
+ .h3e-root > .h3e-run > .h3e-run-sum {
802
+ cursor: default;
803
+ list-style: none;
804
+ }
805
+ .h3e-root > .h3e-run > .h3e-run-sum::-webkit-details-marker { display: none; }
806
+ .h3e-root > .h3e-run > .h3e-run-sum::marker { content: ""; }
807
+ .h3e-root > .h3e-run > .h3e-run-body {
808
+ overflow-y: auto;
809
+ min-height: 0;
810
+ }
811
+ /* Pinned means it reads as chrome, not as another card in the stack. */
812
+ .h3e-root > .h3e-run > .h3e-run-sum {
813
+ position: sticky;
814
+ top: 0;
815
+ z-index: 2;
816
+ background: var(--h3-bg);
817
+ }
818
+
819
+ /* -- template panel -------------------------------------------------------
820
+ * Sits between the SCRIPT header and the card list, hidden until asked for.
821
+ * Each row is text plus one button, so the whole thing survives the narrow
822
+ * container breakpoints without its own media query. */
823
+ .h3e-tpl {
824
+ display: flex;
825
+ flex-direction: column;
826
+ gap: 1px;
827
+ margin-bottom: var(--h3-gap-sm);
828
+ border: 1px solid var(--h3-border-soft);
829
+ background: var(--h3-sunken);
830
+ }
831
+
832
+ .h3e-tpl > .h3e-note {
833
+ margin: 0;
834
+ border: 0;
835
+ border-bottom: 1px solid var(--h3-border-soft);
836
+ color: var(--h3-faint);
837
+ }
838
+
839
+ .h3e-tpl-row {
840
+ display: flex;
841
+ align-items: flex-start;
842
+ gap: var(--h3-gap);
843
+ padding: 7px 8px;
844
+ }
845
+
846
+ .h3e-tpl-row + .h3e-tpl-row { border-top: 1px solid var(--h3-border-soft); }
847
+ .h3e-tpl-row:hover { background: var(--h3-accent-wash); }
848
+
849
+ /* min-width: 0 or the text column refuses to wrap and pushes the button off
850
+ * the panel -- a flex item will not shrink below its content by default. */
851
+ .h3e-tpl-text { flex: 1 1 auto; min-width: 0; }
852
+
853
+ .h3e-tpl-name {
854
+ color: var(--h3-text);
855
+ font-size: var(--h3-fs);
856
+ font-weight: var(--h3-weight-label);
857
+ }
858
+
859
+ .h3e-tpl-hint {
860
+ color: var(--h3-muted);
861
+ font-size: var(--h3-fs-sm);
862
+ margin-top: 1px;
863
+ }
864
+
865
+ .h3e-tpl-note {
866
+ color: var(--h3-faint);
867
+ font-size: var(--h3-fs-sm);
868
+ line-height: 1.5;
869
+ margin-top: 3px;
870
+ }
871
+
872
+ .h3e-tpl-row > .h3e-btn { flex: 0 0 auto; }
js/h3_ref_chain_ui.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../scripts/app.js";
2
+ import { api } from "../../scripts/api.js";
3
+ import {
4
+ el, isolateEvents, installHeightGuard, setWidgetVisibility, widgetByName,
5
+ } from "./editor/widget_utils.js";
6
+ import { createPlanEditor } from "./editor/plan_editor.js";
7
+ import { createRefRail, parseRefPlan, refPlanToJson } from "./editor/ref_rail.js";
8
+ import { createRunPanel } from "./editor/run_panel.js";
9
+ import { createMediaStrip, MEDIA_WIDGETS } from "./editor/media_strip.js";
10
+
11
+ const VERSION = "v1.5.0";
12
+ /* Both ids. The pack registers the pre-rename id as a deprecated subclass so
13
+ * workflows saved before 2026-08-29 still load. If this check knew only the
14
+ * new id those nodes would come up with NO editor at all, which looks exactly
15
+ * like the rename having broken the pack. */
16
+ const NODE_TYPES = new Set(["HandTieClips", "H3RefChain"]);
17
+ const NODE_WIDTH = 560;
18
+ const EDITOR_MIN_H = 460;
19
+ const STYLE_ID = "h3rc-style";
20
+
21
+ /* Widgets the editor owns. In Simple mode the plan is hidden instead.
22
+ *
23
+ * The run panel's share is NOT listed here: it reports the widgets it actually
24
+ * managed to draw, and only those are hidden. A dial the panel could not render
25
+ * -- a type it does not know, or one renamed on the Python side -- therefore
26
+ * stays visible as a native widget instead of vanishing from the node. */
27
+ // MEDIA_WIDGETS are the three filename STRINGs that replaced the
28
+ // start_image / reference_video / voice sockets. They are always hidden:
29
+ // the media strip owns them in both modes, and shown raw they are three
30
+ // text boxes inviting you to type a path that would not resolve.
31
+ const SHOTS_HIDDEN = ["prompt", "chains", "hop_script", "shot_plan", "ref_plan"]
32
+ .concat(MEDIA_WIDGETS);
33
+ const SIMPLE_HIDDEN = ["shot_plan", "ref_plan"].concat(MEDIA_WIDGETS);
34
+
35
+ const _cssUrl = new URL("./h3_ref_chain.css", import.meta.url).href;
36
+
37
+ /* External stylesheet, not a template literal. The old inline sheet hardcoded
38
+ * every colour, so a light ComfyUI theme rendered the panel as dark islands on
39
+ * a light node; the CSS file derives everything from the host theme's own
40
+ * custom properties. */
41
+ function ensureStyles() {
42
+ if (document.getElementById(STYLE_ID)) return;
43
+ const link = document.createElement("link");
44
+ link.id = STYLE_ID;
45
+ link.rel = "stylesheet";
46
+ link.href = _cssUrl;
47
+ document.head.appendChild(link);
48
+ }
49
+
50
+ /* -- editor -------------------------------------------------------------- */
51
+
52
+ function mountEditor(node) {
53
+ if (node._h3Editor) return;
54
+ if (typeof node.addDOMWidget !== "function") {
55
+ console.warn("[HandTieClips] no addDOMWidget on this frontend; editor not mounted");
56
+ return;
57
+ }
58
+ const planWidget = widgetByName(node, "shot_plan");
59
+ const refWidget = widgetByName(node, "ref_plan");
60
+ if (!planWidget || !refWidget) {
61
+ // Stale node definition: the Python side was not reloaded. Silent here
62
+ // is what made this hard to diagnose the first time.
63
+ console.warn(
64
+ "[HandTieClips] shot_plan/ref_plan widget missing -- restart ComfyUI. "
65
+ + "Widgets present:", (node.widgets || []).map((w) => w.name),
66
+ );
67
+ return;
68
+ }
69
+ ensureStyles();
70
+
71
+ const root = el("div", "h3e-root");
72
+ isolateEvents(root);
73
+
74
+ // A ref_plan that does not parse must NOT be silently replaced by an empty
75
+ // one: the rail would then write "" over it on the next edit, destroying a
76
+ // paste the author was in the middle of repairing. Keep the raw text in the
77
+ // widget, flag it, and let the rail's JSON section show and fix it.
78
+ let refBad = false;
79
+ const readRefPlan = () => {
80
+ const parsed = parseRefPlan(refWidget.value);
81
+ refBad = parsed === null;
82
+ return parsed || { refs: [], subjects: {} };
83
+ };
84
+ let refPlan = readRefPlan();
85
+
86
+ // How many hops this run will actually do. The shot plan is authoritative
87
+ // when present -- run() ignores `chains` the moment one is loaded -- so both
88
+ // the rail's schedule chips and the run panel's summary read it from here
89
+ // rather than from the widget that no longer decides.
90
+ const hopCount = () => {
91
+ const plan = widgetByName(node, "shot_plan")?.value;
92
+ try {
93
+ const parsed = JSON.parse((plan || "").trim() || "null");
94
+ const arr = Array.isArray(parsed) ? parsed : parsed?.shots;
95
+ if (Array.isArray(arr) && arr.length) return arr.length;
96
+ } catch (_) { /* unparseable plan: fall back to chains */ }
97
+ return Number(widgetByName(node, "chains")?.value) || 1;
98
+ };
99
+
100
+ const rail = createRefRail(node, {
101
+ getPlan: () => refPlan,
102
+ setPlan: (p) => { refPlan = p; refBad = false; refWidget.value = refPlanToJson(p); },
103
+ getRaw: () => refWidget.value,
104
+ isBad: () => refBad,
105
+ onChange: () => node.graph?.setDirtyCanvas?.(true, true),
106
+ hopCount,
107
+ });
108
+
109
+ const editor = createPlanEditor(node, {
110
+ onChange: () => {
111
+ applyVisibility();
112
+ node.graph?.setDirtyCanvas?.(true, true);
113
+ },
114
+ });
115
+
116
+ const runPanel = createRunPanel(node, {
117
+ onChange: () => node.graph?.setDirtyCanvas?.(true, true),
118
+ hopCount,
119
+ // In Shots mode these two are decided by the plan, not by the user:
120
+ // run() ignores `chains` and forces `hop_script=next`. They are already
121
+ // in SHOTS_HIDDEN, so dropping them here leaves them hidden rather than
122
+ // offering a control that does nothing.
123
+ suppressed: () => (editor.mode() === "shots" ? ["chains", "hop_script"] : []),
124
+ });
125
+
126
+ const mediaStrip = createMediaStrip(node, {
127
+ onChange: () => node.graph?.setDirtyCanvas?.(true, true),
128
+ });
129
+
130
+ // Authoring sections scroll; RUN does not. RUN is the one section touched
131
+ // on every queue, and it used to be the last child of the scroller -- so
132
+ // it sat below however many shot cards the script had and you had to
133
+ // scroll the panel just to reach the summary bar. It is pinned to the
134
+ // bottom of the panel now, collapsed to its ~28px summary until opened.
135
+ const scroll = el("div", "h3e-scroll");
136
+ scroll.appendChild(rail.root);
137
+ scroll.appendChild(mediaStrip.root);
138
+ scroll.appendChild(editor.root);
139
+ root.appendChild(scroll);
140
+ root.appendChild(runPanel.root);
141
+
142
+ const domWidget = node.addDOMWidget("h3_editor", "h3_editor", root, {
143
+ serialize: false,
144
+ hideOnZoom: false,
145
+ getMinHeight: () => EDITOR_MIN_H,
146
+ });
147
+ domWidget.serialize = false;
148
+
149
+ installHeightGuard(node, domWidget, { minHeight: EDITOR_MIN_H, minWidth: NODE_WIDTH });
150
+
151
+ function applyVisibility() {
152
+ // The rail always owns ref_plan; the plan widget's visibility follows
153
+ // the mode, so Simple mode never leaves an inert second text area up.
154
+ // sync() first: ownedNames() decides what gets hidden, so rebuilding
155
+ // after the hide would hide a dial the panel had stopped drawing.
156
+ runPanel.sync();
157
+ const base = editor.mode() === "shots" ? SHOTS_HIDDEN : SIMPLE_HIDDEN;
158
+ setWidgetVisibility(node, base.concat(runPanel.ownedNames()));
159
+ }
160
+
161
+ node._h3Editor = {
162
+ refresh() {
163
+ refPlan = readRefPlan();
164
+ editor.reload();
165
+ rail.render();
166
+ mediaStrip.render();
167
+ applyVisibility(); // syncs the run panel on the way through
168
+ },
169
+ rail,
170
+ editor,
171
+ runPanel,
172
+ };
173
+
174
+ applyVisibility();
175
+ rail.render();
176
+ mediaStrip.render();
177
+ editor.render();
178
+
179
+ // `control_after_generate` bumps the seed widget as the prompt is queued,
180
+ // and undo rewrites widgets wholesale -- both behind this panel's back.
181
+ // RUN used to re-read on open, which is no longer a moment that exists.
182
+ // `promptQueued` fires client-side after the bump has been applied.
183
+ const onQueued = () => {
184
+ try {
185
+ runPanel.sync();
186
+ } catch (err) {
187
+ console.warn("[HandTieClips] run panel resync failed:", err);
188
+ }
189
+ };
190
+ api.addEventListener("promptQueued", onQueued);
191
+ // Removing the node must drop the listener, or every add/remove cycle
192
+ // leaves another closure holding this node alive.
193
+ const prevRemoved = node.onRemoved;
194
+ node.onRemoved = function (...args) {
195
+ api.removeEventListener("promptQueued", onQueued);
196
+ return prevRemoved?.apply(this, args);
197
+ };
198
+
199
+ console.log(`[HandTieClips] ${VERSION} editor mounted on node ${node.id}`);
200
+ }
201
+
202
+ console.log(`[HandTieClips] editor ui ${VERSION} loaded`);
203
+
204
+ app.registerExtension({
205
+ name: "HandTieClips.ui",
206
+ async beforeRegisterNodeDef(nodeType, nodeData) {
207
+ if (!NODE_TYPES.has(nodeData.name)) return;
208
+
209
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
210
+ nodeType.prototype.onNodeCreated = function () {
211
+ const r = onNodeCreated?.apply(this, arguments);
212
+ mountEditor(this);
213
+ this.setSize([
214
+ Math.max(this.size[0], NODE_WIDTH),
215
+ Math.max(this.size[1], 640),
216
+ ]);
217
+ return r;
218
+ };
219
+
220
+ const onConfigure = nodeType.prototype.onConfigure;
221
+ nodeType.prototype.onConfigure = function () {
222
+ const r = onConfigure?.apply(this, arguments);
223
+ mountEditor(this);
224
+ // Widget values arrive with the workflow, after the mount, so the
225
+ // panel has to re-read them or a loaded graph shows an empty editor.
226
+ this._h3Editor?.refresh();
227
+ return r;
228
+ };
229
+
230
+ // Wiring or unwiring a ref_image_N input changes the ordinals and the
231
+ // wired/unwired dots, so the rail has to be told.
232
+ const onConnectionsChange = nodeType.prototype.onConnectionsChange;
233
+ nodeType.prototype.onConnectionsChange = function () {
234
+ const r = onConnectionsChange?.apply(this, arguments);
235
+ this._h3Editor?.rail?.render();
236
+ return r;
237
+ };
238
+ },
239
+ });
media.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Files on disk -> tensors, plus the path safety that makes that survivable.
2
+
3
+ The node used to take its pictures down nine `ref_image_N` IMAGE sockets, which
4
+ meant nine `Load Image` nodes and a 16-socket column occupying a third of the
5
+ node before the editor even started. References now live as *files* in
6
+ `<ComfyUI input>/h3_refs`, and a reference carries a filename rather than a
7
+ wire.
8
+
9
+ Two rules this module exists to enforce:
10
+
11
+ **Nothing from the browser is trusted as a path.** `resolve` normalises, then
12
+ prefix-checks against the reference directory, then checks the extension. A
13
+ POST route that writes files is only as safe as the function deciding where the
14
+ bytes land, so that function lives here, is nine lines, and is used by both the
15
+ route and the loaders.
16
+
17
+ **Pixels never travel as base64.** Only the basename is stored in a widget.
18
+ PromptMasterLD measured 1.68 MB of widget value for nine images and ComfyUI
19
+ then failed to save the workflow at all; the filename is the whole payload.
20
+
21
+ The loaders deliberately return exactly what a `Load Image` would -- float
22
+ `[N,H,W,3]` in 0..1 -- so everything downstream of the old sockets is unchanged,
23
+ including `_ref_frames`' resize and `store.tensor_digest`'s cache keying.
24
+
25
+ No hard ComfyUI import: `input_dir()` falls back to a path relative to this
26
+ file, so the loaders and the safety check are testable without a running server
27
+ (same rule as `plan.py`, `refs.py` and `tone.py`).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import os
32
+
33
+ TAG = "HandTieClips"
34
+
35
+ # One flat folder under ComfyUI's input dir. Flat on purpose: `/view` takes a
36
+ # basename plus one subfolder, and a tree would need a second lookup for no
37
+ # benefit at the scale a chain uses (single digits of references).
38
+ REFS_SUBDIR = "h3_refs"
39
+
40
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
41
+ VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"}
42
+ AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac"}
43
+ ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS | AUDIO_EXTS
44
+
45
+ # Anything past this is re-encoded on upload. A 12 MP phone photo is a
46
+ # reference, not an asset: it costs decode RAM on every load and is about to be
47
+ # resized to the canvas anyway.
48
+ MAX_SIDE = 2048
49
+ MAX_PIXELS = 1_500_000
50
+ JPEG_QUALITY = 88
51
+
52
+
53
+ def kind_of(name):
54
+ """'image' | 'video' | 'audio' | None, from the extension alone."""
55
+ ext = os.path.splitext(str(name or ""))[1].lower()
56
+ if ext in IMAGE_EXTS:
57
+ return "image"
58
+ if ext in VIDEO_EXTS:
59
+ return "video"
60
+ if ext in AUDIO_EXTS:
61
+ return "audio"
62
+ return None
63
+
64
+
65
+ def input_dir():
66
+ """ComfyUI's input directory, or a sibling `input/` when it is absent."""
67
+ try:
68
+ import folder_paths # noqa: PLC0415
69
+ return folder_paths.get_input_directory()
70
+ except Exception:
71
+ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
72
+ return os.path.join(os.path.dirname(here), "input")
73
+
74
+
75
+ def refs_dir(create=False):
76
+ d = os.path.join(input_dir(), REFS_SUBDIR)
77
+ if create:
78
+ os.makedirs(d, exist_ok=True)
79
+ return d
80
+
81
+
82
+ def resolve(name, kinds=None):
83
+ """Absolute path for a reference basename, or None.
84
+
85
+ Refuses anything that escapes the reference directory, carries an unknown
86
+ extension, or does not exist. `name` is treated as a bare filename -- any
87
+ directory component is a caller error, not a feature, so `..` cannot even be
88
+ expressed as a traversal.
89
+
90
+ `kinds` optionally restricts to {'image','video','audio'}.
91
+ """
92
+ raw = str(name or "").strip()
93
+ if not raw:
94
+ return None
95
+ kind = kind_of(raw)
96
+ if kind is None:
97
+ return None
98
+ if kinds and kind not in kinds:
99
+ return None
100
+ base = os.path.normpath(refs_dir())
101
+ full = os.path.normpath(os.path.join(base, raw.replace("/", os.sep)))
102
+ # The prefix check is the load-bearing line. normpath has already collapsed
103
+ # any `..`, so a path that still starts with the reference directory cannot
104
+ # be pointing outside it.
105
+ if full != base and not full.startswith(base + os.sep):
106
+ return None
107
+ return full if os.path.isfile(full) else None
108
+
109
+
110
+ def listing(kinds=None):
111
+ """Reference filenames on disk, newest first, for the editor's dropdown."""
112
+ d = refs_dir()
113
+ if not os.path.isdir(d):
114
+ return []
115
+ rows = []
116
+ for name in os.listdir(d):
117
+ kind = kind_of(name)
118
+ if kind is None or (kinds and kind not in kinds):
119
+ continue
120
+ path = os.path.join(d, name)
121
+ if not os.path.isfile(path):
122
+ continue
123
+ try:
124
+ rows.append((os.path.getmtime(path), name, kind))
125
+ except OSError:
126
+ continue
127
+ rows.sort(reverse=True)
128
+ return [{"name": n, "kind": k} for _m, n, k in rows]
129
+
130
+
131
+ def stamp(names):
132
+ """`name:mtime` for each file, for IS_CHANGED.
133
+
134
+ ComfyUI caches a node's output on its inputs, and a filename is a stable
135
+ input even when the bytes behind it change. Without this, replacing a
136
+ reference in place would serve the previous render.
137
+ """
138
+ bits = []
139
+ for name in names:
140
+ if not name:
141
+ continue
142
+ path = resolve(name)
143
+ try:
144
+ bits.append(f"{name}:{os.path.getmtime(path)}" if path else f"{name}:missing")
145
+ except OSError:
146
+ bits.append(f"{name}:missing")
147
+ return "|".join(bits)
148
+
149
+
150
+ # -- loaders ---------------------------------------------------------------
151
+
152
+ def _to_tensor(pil):
153
+ import numpy as np # noqa: PLC0415
154
+ import torch # noqa: PLC0415
155
+
156
+ arr = np.asarray(pil.convert("RGB"), dtype=np.float32) / 255.0
157
+ return torch.from_numpy(arr).unsqueeze(0)
158
+
159
+
160
+ def load_image(name):
161
+ """One still as an IMAGE tensor `[1,H,W,3]`, or None.
162
+
163
+ Returns what a `Load Image` returns, so `_ref_frames` resizes it and
164
+ `tensor_digest` keys it exactly as before.
165
+ """
166
+ path = resolve(name, kinds={"image"})
167
+ if path is None:
168
+ return None
169
+ try:
170
+ from PIL import Image, ImageOps # noqa: PLC0415
171
+ with Image.open(path) as im:
172
+ # EXIF orientation: a phone portrait otherwise loads on its side,
173
+ # and the model would be handed a rotated face.
174
+ return _to_tensor(ImageOps.exif_transpose(im))
175
+ except Exception as exc:
176
+ print(f"[{TAG}] could not read reference {name!r}: {exc!r}", flush=True)
177
+ return None
178
+
179
+
180
+ def load_video(name, max_frames=None):
181
+ """A clip as an IMAGE batch `[N,H,W,3]`, or None."""
182
+ path = resolve(name, kinds={"video"})
183
+ if path is None:
184
+ return None
185
+ try:
186
+ import av # noqa: PLC0415
187
+ import numpy as np # noqa: PLC0415
188
+ import torch # noqa: PLC0415
189
+
190
+ frames = []
191
+ with av.open(path) as container:
192
+ for frame in container.decode(video=0):
193
+ frames.append(frame.to_ndarray(format="rgb24"))
194
+ if max_frames and len(frames) >= int(max_frames):
195
+ break
196
+ if not frames:
197
+ return None
198
+ arr = np.stack(frames).astype(np.float32) / 255.0
199
+ return torch.from_numpy(arr)
200
+ except Exception as exc:
201
+ print(f"[{TAG}] could not read video {name!r}: {exc!r}", flush=True)
202
+ return None
203
+
204
+
205
+ def load_audio(name):
206
+ """A take as ComfyUI's AUDIO dict, or None.
207
+
208
+ Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
209
+ in the graph expects.
210
+ """
211
+ path = resolve(name, kinds={"audio"})
212
+ if path is None:
213
+ return None
214
+ try:
215
+ import torch # noqa: PLC0415
216
+ import torchaudio # noqa: PLC0415
217
+
218
+ wav, sr = torchaudio.load(path)
219
+ if wav.dim() == 1:
220
+ wav = wav.unsqueeze(0)
221
+ return {"waveform": wav.unsqueeze(0).float(), "sample_rate": int(sr)}
222
+ except Exception as exc:
223
+ print(f"[{TAG}] could not read audio {name!r}: {exc!r}", flush=True)
224
+ return None
225
+
226
+
227
+ # -- writing (used by the upload route) ------------------------------------
228
+
229
+ def unique_path(directory, filename):
230
+ """A free path in `directory`, suffixing `_1`, `_2`... on collision."""
231
+ name = os.path.basename(str(filename or "").strip()) or "upload"
232
+ stem, ext = os.path.splitext(name)
233
+ path, i = os.path.join(directory, name), 1
234
+ while os.path.exists(path):
235
+ path = os.path.join(directory, f"{stem}_{i}{ext}")
236
+ i += 1
237
+ return path
238
+
239
+
240
+ def shrink_image(path):
241
+ """Re-encode an oversized still in place. -> (final_path, w, h).
242
+
243
+ A 12 MP drop costs decode RAM on every single load and is about to be
244
+ resized to a 0.3 MP canvas regardless. Anything within budget is left
245
+ untouched, so a PNG the author cared about stays a PNG.
246
+ """
247
+ try:
248
+ from PIL import Image, ImageOps # noqa: PLC0415
249
+ with Image.open(path) as im:
250
+ im = ImageOps.exif_transpose(im)
251
+ w, h = im.size
252
+ if w * h <= MAX_PIXELS and max(w, h) <= MAX_SIDE:
253
+ return path, w, h
254
+ scale = min(MAX_SIDE / max(w, h), (MAX_PIXELS / float(w * h)) ** 0.5, 1.0)
255
+ new = (max(1, int(w * scale)), max(1, int(h * scale)))
256
+ im = im.convert("RGB").resize(new, Image.Resampling.LANCZOS)
257
+ out = os.path.splitext(path)[0] + ".jpg"
258
+ out = out if out == path else unique_path(os.path.dirname(path),
259
+ os.path.basename(out))
260
+ im.save(out, format="JPEG", quality=JPEG_QUALITY, optimize=False)
261
+ if out != path:
262
+ try:
263
+ os.remove(path)
264
+ except OSError:
265
+ pass
266
+ return out, new[0], new[1]
267
+ except Exception as exc:
268
+ print(f"[{TAG}] could not shrink {os.path.basename(path)}: {exc!r}",
269
+ flush=True)
270
+ return path, 0, 0
plan.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shot-plan schema for H3 Ref Chain.
2
+
3
+ A plan is the authored script for one chain: an ordered list of shots, one per
4
+ hop. It is carried as a single JSON string so the whole plan lives in one widget
5
+ and serializes with the workflow -- the DOM editor renders cards from this and
6
+ writes back to the same string, so the JSON stays the source of truth whether it
7
+ was typed by hand or clicked together.
8
+
9
+ The shot count IS the hop count. There is no separate `chains` number to keep in
10
+ sync, which removes a whole class of "3 blocks but chains=4" mismatches.
11
+
12
+ Shot fields (all optional except `beat`):
13
+ beat what happens this hop
14
+ directives {join, camera, framing, pace, tail} -- see directives.VOCAB
15
+ prose free text appended verbatim, for anything the vocabulary lacks
16
+ seed int override, else the chain seed
17
+ steps int override
18
+ duration str override, e.g. "8 s"
19
+ locked bool -- reuse this hop's cached render (hop store, step 4)
20
+ id stable identifier, generated if absent
21
+ """
22
+
23
+ import json
24
+
25
+ from . import directives as _d
26
+
27
+ TAG = "HandTieClips"
28
+
29
+ # No "refs" here. A shot never activated a reference: activation is the ref's
30
+ # own `shots` list in refs.py. The field was parsed, normalised and printed but
31
+ # read by nothing -- and the editor neither loads nor writes it, so a
32
+ # hand-authored shot_plan lost it the first time anyone touched a card. Leaving
33
+ # it out means _norm_shot's unknown-field error names it and points at `shots`.
34
+ _SHOT_KEYS = {"id", "beat", "directives", "prose",
35
+ "seed", "steps", "duration", "locked"}
36
+
37
+
38
+ def _norm_shot(raw, i):
39
+ where = f"shot {i + 1}: "
40
+ if isinstance(raw, str):
41
+ raw = {"beat": raw}
42
+ if not isinstance(raw, dict):
43
+ raise ValueError(f"{TAG}: {where}each shot must be an object or a string")
44
+
45
+ unknown = set(raw) - _SHOT_KEYS
46
+ if unknown:
47
+ raise ValueError(
48
+ f"{TAG}: {where}unknown field(s) {sorted(unknown)}. "
49
+ f"Valid: {sorted(_SHOT_KEYS)}"
50
+ )
51
+
52
+ d_raw = raw.get("directives") or {}
53
+ if not isinstance(d_raw, dict):
54
+ raise ValueError(f"{TAG}: {where}directives must be an object")
55
+ bad = set(d_raw) - set(_d.AXES)
56
+ if bad:
57
+ raise ValueError(
58
+ f"{TAG}: {where}unknown directive axis/axes {sorted(bad)}. "
59
+ f"Valid: {list(_d.AXES)}"
60
+ )
61
+ dirs = {ax: _d.validate(ax, d_raw.get(ax), where=where) for ax in _d.AXES}
62
+ dirs = {k: v for k, v in dirs.items() if v}
63
+
64
+ def _int(name):
65
+ v = raw.get(name)
66
+ if v in (None, ""):
67
+ return None
68
+ try:
69
+ return int(v)
70
+ except (TypeError, ValueError):
71
+ raise ValueError(f"{TAG}: {where}{name} must be a whole number, got {v!r}")
72
+
73
+ return {
74
+ "id": str(raw.get("id") or f"s{i + 1}"),
75
+ "beat": str(raw.get("beat") or "").strip(),
76
+ "directives": dirs,
77
+ "prose": str(raw.get("prose") or "").strip(),
78
+ "seed": _int("seed"),
79
+ "steps": _int("steps"),
80
+ "duration": (str(raw["duration"]).strip() or None) if raw.get("duration") else None,
81
+ "locked": bool(raw.get("locked")),
82
+ }
83
+
84
+
85
+ def parse_plan(text):
86
+ """Parse a shot-plan JSON string. Blank -> [] (caller falls back to `prompt`)."""
87
+ text = (text or "").strip()
88
+ if not text:
89
+ return []
90
+ try:
91
+ data = json.loads(text)
92
+ except json.JSONDecodeError as e:
93
+ raise ValueError(f"{TAG}: shot_plan does not parse as JSON ({e})") from e
94
+
95
+ if isinstance(data, dict):
96
+ shots = data.get("shots")
97
+ if shots is None:
98
+ raise ValueError(f'{TAG}: shot_plan object needs a "shots" array')
99
+ elif isinstance(data, list):
100
+ shots = data
101
+ else:
102
+ raise ValueError(f'{TAG}: shot_plan must be an object with "shots", or an array')
103
+
104
+ if not isinstance(shots, list) or not shots:
105
+ raise ValueError(f"{TAG}: shot_plan has no shots")
106
+
107
+ out = [_norm_shot(s, i) for i, s in enumerate(shots)]
108
+ if not any(s["beat"] or s["prose"] for s in out):
109
+ raise ValueError(f"{TAG}: shot_plan has no beat text in any shot")
110
+ return out
111
+
112
+
113
+ def check_coherence(shots):
114
+ """Warn (never raise) about directive combinations that fight each other.
115
+
116
+ A framing change asks the audience to be somewhere new. With a moving camera
117
+ the move earns it; with a held camera the only way to get there is a cut, so
118
+ `join=continuous` and the framing change are asking for opposite things and
119
+ the model will pick one. Warn rather than raise -- it is a legitimate thing
120
+ to want, it just rarely reads as continuous.
121
+
122
+ Also warns when the camera move and the framing point opposite ways
123
+ (push_in + wide, pull_back + close), which is a contradiction regardless of
124
+ how the hop joins.
125
+ """
126
+ warnings = []
127
+ # push_in narrows the frame, pull_back opens it. Asking for one and naming
128
+ # the opposite destination is a physical contradiction at any join value,
129
+ # and nothing warned about it before.
130
+ _opposed = {("push_in", "wide"), ("pull_back", "close")}
131
+ for i, s in enumerate(shots):
132
+ d = s.get("directives") or {}
133
+ framing = d.get("framing", "")
134
+ camera = d.get("camera", "")
135
+ # Hop 1 has no previous hop, and directive_prose does not emit `join`
136
+ # there, so a join warning on shot 1 points at a sentence that is never
137
+ # compiled. The JS mirror already skips index 0.
138
+ if (i > 0 and d.get("join") == "continuous"
139
+ and framing not in ("", "keep")
140
+ and camera in ("", "hold")):
141
+ warnings.append(
142
+ f"shot {i + 1}: join=continuous with framing={framing} and a held "
143
+ f"camera implies a cut. Use camera=push_in/pull_back/pan_follow to "
144
+ f"reach that framing on the move, or framing=keep."
145
+ )
146
+ if (camera, framing) in _opposed:
147
+ warnings.append(
148
+ f"shot {i + 1}: camera={camera} moves the opposite way from "
149
+ f"framing={framing}. Pick the framing the move actually lands on."
150
+ )
151
+ return warnings
152
+
153
+
154
+ def check_place_handoff(shots, ref_plan=None):
155
+ """Warn when a hop changes location without the previous beat arriving there.
156
+
157
+ This is the failure that put the one visible cut in the 8x15 s anime chain:
158
+ shot 3 ended "ahead the trunks begin to thin toward open ground" and shot 4
159
+ opened "Across the flat moonlit stone of @arena_clearing the two of them
160
+ square off". Hop 4 was handed a live frame of a man among trees and a beat
161
+ asserting he was already standing on open stone. It held the forest for
162
+ 3.25 s and then reset the scene -- a hard cut 78 frames into the hop, the
163
+ single largest frame-to-frame jump in 114 seconds of film.
164
+
165
+ Nothing warned. `check_coherence` sees only directives, and the plan was
166
+ clean by every other check the pack has.
167
+
168
+ The rule the warning encodes: a beat must be true from ANY plausible ending
169
+ of the hop before it. When shot N names a place tag that shot N-1 never
170
+ mentions, shot N-1 has to do the arriving, or the model has to cut.
171
+
172
+ Two shapes are accepted as an arrival, because both work in practice:
173
+ shot N-1 naming the new place tag itself, or shot N's own beat carrying the
174
+ journey ("reaches the top of the stairs and pushes open the door"), which is
175
+ satisfiable from a live frame that is still on the stairs.
176
+
177
+ `ref_plan` is the parsed dict from refs.parse_ref_plan. Without it there is
178
+ no way to tell a place tag from a face tag, so the check no-ops.
179
+ """
180
+ warnings = []
181
+ if not ref_plan or len(shots) < 2:
182
+ return warnings
183
+ # A ref with no subject is a place plate. Faces ride people, not rooms.
184
+ places = {r["tag"] for r in (ref_plan.get("refs") or []) if not r.get("subject")}
185
+ if not places:
186
+ return warnings
187
+
188
+ # Phrases that mean the beat itself carries the journey, so it is
189
+ # satisfiable from a live frame still in the old location. The pack's own
190
+ # Showcase is the case to keep clean: shot 5 leaves her at a window in the
191
+ # hallway and shot 6 reads "She walks back along the hallway and through
192
+ # the doorway to the counter in @kitchen" -- the travelling is right there
193
+ # in the beat, and warning about it would be noise.
194
+ #
195
+ # Kept as travel language rather than a list of rooms: what makes a beat
196
+ # safe is that it starts where the last hop ended and moves, not which
197
+ # place it moves to.
198
+ _ARRIVES = (
199
+ "reach", "arriv", "enter", "emerg",
200
+ "step into", "steps into", "step through", "steps through",
201
+ "walk into", "walks into", "walk back", "walks back", "walking back",
202
+ "walk through", "walks through", "go back", "goes back",
203
+ "return", "returns", "returning", "head back", "heads back",
204
+ "head toward", "heads toward", "back along", "back through",
205
+ "push open", "pushes open", "through the door", "through the doorway",
206
+ "through the gate", "through the entrance", "across into",
207
+ "come out", "comes out", "break out", "breaks out",
208
+ "cross into", "crosses into", "climb", "climbs", "descend", "descends",
209
+ "makes her way", "makes his way", "makes their way",
210
+ )
211
+
212
+ for i in range(1, len(shots)):
213
+ prev = (shots[i - 1].get("beat") or "").lower()
214
+ here = shots[i].get("beat") or ""
215
+ low = here.lower()
216
+ new_places = [t for t in places
217
+ if ("@" + t).lower() in low and ("@" + t).lower() not in prev]
218
+ for tag in sorted(new_places):
219
+ if any(v in low for v in _ARRIVES):
220
+ continue
221
+ warnings.append(
222
+ f"shot {i + 1}: @{tag} is a new place and shot {i} never goes there. "
223
+ f"The hop opens on a live frame of the old location, so the only way "
224
+ f"to obey is a cut. End shot {i} with the arrival, or have shot "
225
+ f"{i + 1} do the travelling."
226
+ )
227
+
228
+ # The same defect seen from the other side. Both LM Studio models plated
229
+ # the opening location, moved the story somewhere else, and gave the new
230
+ # place no plate at all -- then justified it with a rule that does not
231
+ # exist ("to avoid conflicting with the frame pin of the new space"). In
232
+ # the lighthouse plan that left the lamp room, the main setting of four of
233
+ # six hops, carried by beat text alone.
234
+ #
235
+ # The test is ABANDONMENT, not gaps. A plan is free to leave a transitional
236
+ # space unplated and come back: the pack's own Showcase plates the kitchen
237
+ # on shots 1-3, walks her down an unplated hallway for 4-5, and returns the
238
+ # plate on 6. What is always wrong is plating the opening and then letting
239
+ # the plan END with no plate riding, because that is the destination -- the
240
+ # place the film spends its last hops in -- with nothing holding it.
241
+ #
242
+ # A plan with no place plates at all is a legitimate shape and says nothing
243
+ # here: the Starter ships that way so it runs before any pictures exist.
244
+ covered = set()
245
+ for r in (ref_plan.get("refs") or []):
246
+ if r.get("subject"):
247
+ continue
248
+ covered |= set(r.get("shots") or range(1, len(shots) + 1))
249
+ n = len(shots)
250
+ if covered and n not in covered:
251
+ first_bare = n
252
+ while first_bare - 1 >= 1 and (first_bare - 1) not in covered:
253
+ first_bare -= 1
254
+ span = (f"shot {first_bare}" if first_bare == n
255
+ else f"shots {first_bare}-{n}")
256
+ warnings.append(
257
+ f"{span}: the plan's place plates stop riding and never resume, so "
258
+ f"the film ends somewhere no picture describes. A location "
259
+ f"introduced part way through needs its own plate on the hop it "
260
+ f"arrives and every hop after -- tightening a plate to its own "
261
+ f"shots does not mean the next location goes without one."
262
+ )
263
+ return warnings
264
+
265
+
266
+ def compile_blocks(shots, establish=None, ref_plan=None):
267
+ """Compile a plan into one body string per hop, ready for the chain loop."""
268
+ for w in check_coherence(shots):
269
+ print(f"[{TAG}] note: {w}", flush=True)
270
+ for w in check_place_handoff(shots, ref_plan):
271
+ print(f"[{TAG}] note: {w}", flush=True)
272
+ return [_d.compile_shot(s, i, establish) for i, s in enumerate(shots)]
273
+
274
+
275
+ def describe(shots):
276
+ """One-line-per-shot summary for the console, so the plan is auditable."""
277
+ rows = []
278
+ for i, s in enumerate(shots):
279
+ bits = [f"{k}={v}" for k, v in s["directives"].items()]
280
+ extra = []
281
+ if s["seed"] is not None:
282
+ extra.append(f"seed={s['seed']}")
283
+ if s["steps"] is not None:
284
+ extra.append(f"steps={s['steps']}")
285
+ if s["duration"]:
286
+ extra.append(f"duration={s['duration']}")
287
+ if s["locked"]:
288
+ extra.append("locked")
289
+ beat = (s["beat"] or "").replace(chr(10), " ")
290
+ if len(beat) > 60:
291
+ beat = beat[:57] + "..."
292
+ rows.append(
293
+ f" shot {i + 1} [{s['id']}] {' '.join(bits + extra) or '(no directives)'}"
294
+ + chr(10) + f" beat: {beat or '(continues)'}"
295
+ )
296
+ return chr(10).join(rows)
preview_node.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """H3 Chain Preview -- the chain's progress panel, as its own node.
2
+
3
+ Split off `HandTieClips` deliberately. That node already carries a 21-widget shot
4
+ editor and a reference rail; folding a large media panel onto it as well makes
5
+ an unreadable node, and the panel wants to be somewhere else in the graph
6
+ anyway -- on the IMAGE wire, right before CreateVideo, where the thing being
7
+ previewed actually flows.
8
+
9
+ It is a passthrough: images and audio go straight out again, unchanged, so
10
+ dropping it into an existing chain costs nothing and removing it changes no
11
+ pixels. What it adds is a place for the live `h3_refchain_preview` events to
12
+ land, and an end-of-run report of the two numbers the chain never surfaced --
13
+ which pin mechanism each hop actually used, and how far the audio has drifted
14
+ from the video.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ try:
19
+ from server import PromptServer
20
+ except Exception: # noqa: BLE001 -- headless / API-only runs have no server
21
+ PromptServer = None
22
+
23
+ TAG = "HTCChainPreview"
24
+ FPS = 24
25
+
26
+
27
+ def _send(payload):
28
+ if PromptServer is None:
29
+ return
30
+ try:
31
+ PromptServer.instance.send_sync(
32
+ "h3_chain_preview", payload, PromptServer.instance.client_id)
33
+ except Exception as e: # noqa: BLE001
34
+ print(f"[{TAG}] preview send skipped: {e!r}", flush=True)
35
+
36
+
37
+ class HTCChainPreview:
38
+ """Pass images and audio through, and report the join.
39
+
40
+ Wire between HandTieClips and CreateVideo.
41
+ """
42
+
43
+ @classmethod
44
+ def INPUT_TYPES(cls):
45
+ return {
46
+ "required": {
47
+ "images": ("IMAGE", {"tooltip": "From H3 Ref2VA Chain. Passed straight through."}),
48
+ },
49
+ "optional": {
50
+ "audio": ("AUDIO", {
51
+ "tooltip": (
52
+ "The chain's audio. Wire it and the panel reports A/V "
53
+ "drift, which accumulates roughly 40 ms per hop from the "
54
+ "audio crossfade at each join."
55
+ ),
56
+ }),
57
+ "info": ("STRING", {
58
+ "forceInput": True,
59
+ "tooltip": "The chain's `info` output. Shown in the panel's detail view, never in the status strip.",
60
+ }),
61
+ },
62
+ "hidden": {"unique_id": "UNIQUE_ID"},
63
+ }
64
+
65
+ RETURN_TYPES = ("IMAGE", "AUDIO")
66
+ RETURN_NAMES = ("images", "audio")
67
+ FUNCTION = "run"
68
+ CATEGORY = "Hand Tie Clips"
69
+ OUTPUT_NODE = False
70
+ DESCRIPTION = (
71
+ "Preview panel for an H3 Ref2VA chain. Sits on the IMAGE wire before "
72
+ "CreateVideo and passes images and audio through untouched. Shows the "
73
+ "live sample, per-hop progress, which pin mechanism each hop used "
74
+ "(Motion-Context vs the AddGuide fallback), and end-of-run A/V drift."
75
+ )
76
+
77
+ def run(self, images, audio=None, info="", unique_id=None):
78
+ frames = int(images.shape[0]) if images is not None else 0
79
+ video_s = frames / float(FPS)
80
+ payload = {
81
+ "node_id": unique_id,
82
+ "frames": frames,
83
+ "video_s": round(video_s, 3),
84
+ "width": int(images.shape[2]) if frames else 0,
85
+ "height": int(images.shape[1]) if frames else 0,
86
+ }
87
+
88
+ if isinstance(audio, dict) and audio.get("waveform") is not None:
89
+ wav = audio["waveform"]
90
+ sr = int(audio.get("sample_rate") or 0)
91
+ if sr:
92
+ audio_s = float(wav.shape[-1]) / float(sr)
93
+ drift_ms = (audio_s - video_s) * 1000.0
94
+ payload["audio_s"] = round(audio_s, 3)
95
+ payload["sample_rate"] = sr
96
+ payload["drift_ms"] = round(drift_ms, 1)
97
+ # Worth a console line too: it is cumulative across a chain and
98
+ # nothing else in the pack reports it.
99
+ print(f"[{TAG}] {frames}f / {video_s:.2f}s video, "
100
+ f"{audio_s:.2f}s audio, drift {drift_ms:+.0f} ms", flush=True)
101
+ if info:
102
+ payload["info"] = str(info)
103
+
104
+ _send(payload)
105
+ return (images, audio)
106
+
107
+
108
+
109
+
110
+ # -- pre-rename ids ----------------------------------------------------------
111
+ # A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
112
+ # lists the node a second time in search: ComfyUI falls back to the mapping key
113
+ # when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
114
+ # DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
115
+ # frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
116
+ # while leaving it fully functional in workflows that name it.
117
+
118
+
119
+ class _LegacyH3ChainPreview(HTCChainPreview):
120
+ DEPRECATED = True
121
+
122
+
123
+ NODE_CLASS_MAPPINGS = {
124
+ "HTCChainPreview": HTCChainPreview,
125
+ "H3ChainPreview": _LegacyH3ChainPreview,
126
+ }
127
+ NODE_DISPLAY_NAME_MAPPINGS = {"HTCChainPreview": "H3 Chain Preview"}
prompt_pack/AUTHORING_PROMPT.md ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Authoring prompt
2
+
3
+ Paste everything between the rules below into any chat model, then describe your
4
+ scene in plain language. It answers with two JSON blocks: paste the first into
5
+ the **JSON** box under **SCRIPT** (`shot_plan`) and the second into the **JSON**
6
+ box under **REFERENCES** (`ref_plan`). Each panel section has its own.
7
+
8
+ The node validates everything it is given, so a model that gets this wrong is
9
+ caught rather than obeyed. If a plan is rejected, paste the error back — every
10
+ message names the shot or the reference it came from.
11
+
12
+ ---
13
+
14
+ You are writing shot plans for **Hand Tie Clips**, a ComfyUI node that
15
+ renders a multi-hop video-with-audio chain. Each hop is one continuous clip;
16
+ consecutive hops are joined by pinning the previous hop's final frames and audio
17
+ tail into the next hop's conditioning.
18
+
19
+ You produce exactly two JSON documents: `shot_plan` and `ref_plan`.
20
+
21
+ ## How the renderer behaves
22
+
23
+ These are not style preferences. They are how this model fails.
24
+
25
+ 1. **Sampling runs at cfg 1.0 with no negative branch, so the prompt is purely
26
+ additive.** Every concept named is added to the conditioning. Nothing can be
27
+ removed by mentioning it — "no cut" puts the word *cut* in front of the
28
+ encoder. **Never write a negation.** Write what the shot *is* doing.
29
+
30
+ 2. **Never name the thing you want to end.** "The cook stops talking" keeps her
31
+ talking. Write the state you want as **a pose plus a sound**: "leans back
32
+ against the counter with her lips closed, and lets her eyes move slowly
33
+ across the room. The kitchen is quiet apart from the low hum of the
34
+ refrigerator."
35
+
36
+ **The ban is on the idea, not on a list of words.** Two different models,
37
+ given a scene that ended *"the storm finally stops"*, avoided every banned
38
+ word below and still wrote *"The storm's roar begins to fade... raindrops
39
+ strike the glass with decreasing force"* and *"The storm has passed."* Both
40
+ name the ending. Both keep the storm. Fading, passing, waning, subsiding,
41
+ dying down, easing off, receding, growing quiet and dropping away are the
42
+ same move wearing different words, and every one of them adds the thing it
43
+ describes.
44
+
45
+ Test every sentence with one question: **is this a thing that is happening,
46
+ or a thing that has finished happening?** Only the first survives at cfg 1.0.
47
+ To end a storm, write the world that is left: *"Water runs down the glass in
48
+ slow threads. The mechanism turns with a low hum and the sea moves against
49
+ the rocks below."*
50
+
51
+ These words are rejected outright, in any beat: **no, not, never, n't,
52
+ without, none of, stop, stops, silent, silence.** They are the crudest
53
+ examples of the idea, not the whole of it -- a beat clean of all ten can
54
+ still break this rule, and usually does.
55
+
56
+ 3. **Audio is always generated.** Silence written as an absence produces speech,
57
+ because the model fills the track with the most likely thing. Silence must be
58
+ written **as a sound** — room tone, a refrigerator, a distant car.
59
+
60
+ 4. **Ambience must be narrowband and specific.** "faint street noise through the
61
+ window" is broadband and renders as a five-second hiss. "the low hum of the
62
+ refrigerator" renders as a refrigerator. When in doubt, name a single
63
+ discrete event: "a single click from the refrigerator, then stillness".
64
+
65
+ 5. **A state change belongs at the END of the previous shot.** Every hop opens
66
+ holding the frames it was handed, and the audio pin carries the previous
67
+ hop's tail across the join. If shot 2 ends mid-sentence, nothing you write in
68
+ shot 3 will make shot 3 start quiet. To be silent, still, or elsewhere in a
69
+ shot, arrive there before the previous shot ends.
70
+
71
+ 6. **A hop that ends on dialogue keeps talking into the next hop.** The audio
72
+ pin carries the previous hop's tail, so speech at the end of hop N becomes
73
+ the opening of hop N+1 and propagates down the whole chain. Land each spoken
74
+ line in the MIDDLE of its hop and leave a non-verbal action running into the
75
+ seam — slicing, walking, a hand on a doorframe. Give every hop with no
76
+ dialogue its own narrowband sound bed (footsteps, a refrigerator, rain on
77
+ glass), or the audio has nowhere to go but back to speech.
78
+
79
+ 7. **A beat must be true from any plausible ending of the hop before it.** A
80
+ hop routinely over-delivers -- given a movement it finishes it, and then some.
81
+ If the next beat instructs something its own opening frames have already done,
82
+ the only way to obey is to reset the scene, which reads as a hard cut about a
83
+ second and a half *into* the hop rather than at the seam. Give one hop the
84
+ whole of one movement, and write the next beat so it holds whether the
85
+ previous hop stopped short or ran ahead.
86
+
87
+ 8. **A walk between two different rooms is `match_cut`, not `continuous`.**
88
+ Asking for one unbroken take across a real location change makes the model
89
+ morph one room into the other mid-movement.
90
+
91
+ 9. **Shot 1 establishes everything. Every later shot carries ONLY the new
92
+ beat.** Never re-describe the face, the clothes or the room after shot 1 —
93
+ the reference photographs, the register and the frame pin already carry all
94
+ three, and repeating them competes with the pin instead of reinforcing it.
95
+
96
+ 10. **Write `@tags` into the action line.** Describing a reference in the register
97
+ does not make the model use it; the beat is what drives the frame. Write
98
+ "stands at the counter in @kitchen", not merely a `@kitchen` entry in the
99
+ register. Phrase a place as a place that is depicted, not as a container to
100
+ be placed inside. **A beat is plain prose.** A tag written inside a beat
101
+ carries no backticks, asterisks or other markdown -- every character you
102
+ write reaches the encoder literally, so formatting becomes noise in the
103
+ conditioning.
104
+
105
+ 11. **Every object that persists gets an adjective.** A bare noun is
106
+ unanchored: each hop encodes "the bowl" from scratch and is free to make it
107
+ steel in one hop and porcelain in the next. Name a colour or a material the
108
+ first time and repeat it every time -- "the white porcelain bowl", "the dark
109
+ blue wrapped hilt". State it in the subject's `context` as a **property**,
110
+ never as a location.
111
+
112
+ 12. **Name the visual style in shot 1, once.** Nothing else states it. If the
113
+ user asks for 2D anime, film noir, stop motion, watercolour or any look other
114
+ than live action, shot 1's beat opens with it: "Hand-drawn 2D anime in the
115
+ style of ..., crisp inked linework over painted backgrounds." Reference
116
+ pictures pull the look one way and unstated text pulls it back toward
117
+ photoreal video, and the text wins often enough to matter. Later shots do not
118
+ repeat it -- it belongs in `setting.locked` on the H3 Continuity State node,
119
+ which rides every hop from 2 on.
120
+
121
+ The node prepends an establishing line of its own to hop 1, and its default
122
+ asserts live action. When shot 1 opens by naming a medium, that default is
123
+ dropped automatically, so a styled plan needs nothing else from you. Name the
124
+ medium in the **first sentence** of shot 1 for that to happen.
125
+
126
+ 13. **Set `tail` on the final shot** to `settle` or `hold`. Left at its default
127
+ `ongoing`, the model is told action is still underway at the last frame and
128
+ will invent something to satisfy that — a stray gesture, or a stray line of
129
+ dialogue in the closing second.
130
+
131
+ ## `shot_plan`
132
+
133
+ One shot per hop. **The number of shots is the number of hops.**
134
+
135
+ ```json
136
+ {
137
+ "shots": [
138
+ {
139
+ "id": "s1",
140
+ "beat": "...",
141
+ "directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
142
+ }
143
+ ]
144
+ }
145
+ ```
146
+
147
+ Valid shot fields, and no others: `id`, `beat`, `directives`, `prose`, `seed`,
148
+ `steps`, `duration`, `locked`. Only `beat` is required. An unknown field is a
149
+ hard error.
150
+
151
+ Directive axes, and no others. Every axis is optional; an unset axis emits
152
+ nothing at all, which costs no tokens.
153
+
154
+ | axis | values |
155
+ |---|---|
156
+ | `join` | `continuous`, `match_cut`, `hard_cut` — **omit on shot 1**, which has nothing to join to |
157
+ | `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
158
+ | `framing` | `keep`, `wide`, `medium`, `close` |
159
+ | `pace` | `slow`, `steady`, `brisk` |
160
+ | `tail` | `ongoing` (default), `settle`, `hold` |
161
+
162
+ Two combinations are contradictions and will be flagged:
163
+
164
+ - `join: continuous` + a framing change + `camera: hold`. With the camera still,
165
+ the only way to reach a new framing is a cut. Earn it on the move (`push_in`,
166
+ `pull_back`, `pan_follow`) or use `framing: keep`.
167
+ - `push_in` with `wide`, or `pull_back` with `close`. The move points the
168
+ opposite way from the destination.
169
+
170
+ `duration` may override the chain per shot, using exactly these labels:
171
+ `"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.
172
+
173
+ ### How long a beat has to be
174
+
175
+ A beat has to fill its whole hop. Written short, the model finishes the action
176
+ early and invents something for the seconds left over -- most often a cut to the
177
+ reference photograph in the closing moments. **Ask the user how long each hop is
178
+ if they have not said**, and size every beat to it.
179
+
180
+ **Count the words in each beat before you answer, and write the count down.**
181
+ This is the instruction most often ignored, and ignoring it is not a small
182
+ miss. Two different models asked for six 15 s hops both returned beats averaging
183
+ **54 words** -- every single beat under the floor -- and the same two models,
184
+ asked for much shorter hops, returned 40 to 48. Left to itself a model writes
185
+ about fifty words whatever the hop length. Fifty words is right for a 7 s hop
186
+ and is half of what 15 s needs. The table below is not a style note; it is the
187
+ one part of this document you have to do arithmetic for.
188
+
189
+ | hop | words in the beat |
190
+ |---|---|
191
+ | `5 s` | 30-45 |
192
+ | `7 s` | 30-55 |
193
+ | `8 s` | 35-60 |
194
+ | `10 s` | 45-75 |
195
+ | `15 s` | 70-100 |
196
+
197
+ A worked `15 s` beat, at 74 words, from a rendered eight-hop chain:
198
+
199
+ > They close the distance together and the blades meet at the centre of the clearing, white sparks bursting from the impact as the camera orbits around the lock. @warrior_face turns the heavier blade aside and cuts back across the body; @enemy_shadow catches it on the flat and steps in behind it. Steel rings sharp and resonant on every contact, armour plates grind against one another, and boots drag hard over stone between the exchanges.
200
+
201
+ Read how the length is spent, because padding to a word count fails differently
202
+ but just as badly. One continuous movement carries the whole hop -- they close,
203
+ the blades meet, one turns the other aside, the other steps in. The camera move
204
+ is named inside the action rather than after it. The last third is the sound bed
205
+ and nothing else: three specific noises, each tied to a thing on screen. There
206
+ is no second event and no scene change.
207
+
208
+ The `5 s` and `7 s` rows are the measured spread of the two plans that ship with
209
+ the node. Their thinnest beat is 28 words in a 7 s hop, carrying one simple
210
+ action; their fullest is 56. The `15 s` row now has one measurement behind it --
211
+ the chain the example above comes from ran 74 to 90 words a beat across eight
212
+ hops and every hop delivered its beat in full, with no hop idling into the
213
+ reference. **The `8 s` and `10 s` rows are still interpolated, not measured.**
214
+ If the closing seconds drift or cut to the reference photograph, the beat was
215
+ short.
216
+
217
+ Fill that length with *continuous* material -- one movement that takes the whole
218
+ hop, what the camera is doing while it happens, and the sound bed underneath --
219
+ rather than with more separate events. Four events crammed into one hop is the
220
+ over-delivery in rule 7, and it costs you the next hop's opening.
221
+
222
+ ### Dialogue
223
+
224
+ Put the spoken line inside the beat, in **single** quotes:
225
+
226
+ ```
227
+ @hero_face looks up from the chopping board and says, 'You are early. I have
228
+ barely started.'
229
+ ```
230
+
231
+ Single quotes survive copy-paste; escaped double quotes are the most common
232
+ cause of a rejected plan. Budget roughly one line of dialogue per 5–7 seconds of
233
+ hop — a long speech in a short hop is truncated mid-word, and that truncation is
234
+ then pinned into the next hop's audio.
235
+
236
+ ## `ref_plan`
237
+
238
+ ```json
239
+ {
240
+ "refs": [
241
+ {"tag": "hero_face", "file": "face.jpg", "subject": 1,
242
+ "retention": "fully_preserved", "shots": [1, 4],
243
+ "desc": "head-and-shoulders, even light"}
244
+ ],
245
+ "subjects": {
246
+ "1": {"name": "the cook",
247
+ "locked": "the same face, the same short dark hair",
248
+ "context": "the apron stays tied over the grey t-shirt"}
249
+ }
250
+ }
251
+ ```
252
+
253
+ Valid ref fields: `tag`, `file`, `subject`, `retention`, `desc`, `shots`. (There
254
+ is also `slot`, which is derived from list position — never author it.)
255
+
256
+ - **`tag`** is required, unique, `[A-Za-z0-9_]+`, and is what appears in beats
257
+ as `@tag`.
258
+ - **`file`** is a bare filename in `ComfyUI/input/h3_refs`, never a path. A
259
+ filename that is not in that folder **stops the run** -- it is an error, not a
260
+ warning, so a placeholder you invent must be one the user then actually
261
+ supplies. If the user has not told you their filenames, use clear placeholders
262
+ and list every one of them at the end of your answer, with what each picture
263
+ should show.
264
+ - **`subject`** is an integer ≥ 1 grouping pictures **of the same person**.
265
+ Declaring two different people under one subject number makes the model render
266
+ **the average of their faces**. One number per person, always.
267
+ - **`retention`** is one of:
268
+ - `fully_preserved` — face, bone structure and hairstyle carry over exactly
269
+ - `partially_copy` — the garment and its cut carry over
270
+ - `reference` — layout, surfaces and light carry over, i.e. a place
271
+
272
+ It defaults to `fully_preserved` when `subject` is set, `reference` otherwise.
273
+ - **`shots`** is the list of 1-based hops the picture rides on.
274
+
275
+ Every subject that appears in `subjects` must be claimed by at least one ref, or
276
+ the plan is rejected. Give every subject a **`locked`**: pictures put a face in
277
+ front of the encoder, but `locked` is what carries the identity across a hop
278
+ where the picture is absent. At most **9 references in the whole plan** -- the limit counts entries in
279
+ `refs`, not pictures per hop.
280
+
281
+ ### Scheduling references is the part that decides whether continuity holds
282
+
283
+ Write `@tag` for a person on any hop you like. Hop 1 resolves it to
284
+ `<Subject N>` against its definitions block; hop 2 onward resolves it to the
285
+ subject's `name`, which binds to the identity sentence every continuation hop
286
+ carries. **Every subject therefore needs a `name`.**
287
+
288
+ **A reference with no `shots` list rides hop 1 only.** That default is right
289
+ for a **place** plate: a room still riding a hop set somewhere else beats the
290
+ frame pin, because the model has a crisp picture of one room and a noisy
291
+ carried-over frame of another, and it follows the still. **List every hop a
292
+ picture belongs on, explicitly.**
293
+
294
+ **A face plate is the opposite. Put it on every hop.** Two six-hop renders
295
+ settled this. A face plate rode a hop set in a space it had never seen and held
296
+ cleanly; the hop scheduled with no references came back a different person, and
297
+ nothing after it recovered. Identity drift does not self-correct. `locked` holds
298
+ a face that is still right; only a plate rebuilds one that is gone.
299
+
300
+ - Put every **face** reference on every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
301
+ - Keep a **place** reference on every hop set in that place, and off every hop
302
+ that is not.
303
+ - An **outfit** plate can ride hop 1 only, but then `context` is the only thing
304
+ holding the wardrobe from hop 2 on, so name its colours there.
305
+ - Never let a place plate shot in one location ride a hop set somewhere else.
306
+ - **The hop that changes location belongs entirely to the new place.** Put the
307
+ departure at the end of the *previous* beat, so that no place plate ever rides
308
+ a hop which opens somewhere it was not photographed.
309
+ - **A location introduced part way through needs its own plate, on the hop it
310
+ arrives and every hop after.** Tightening a place plate to its own shots does
311
+ **not** mean the next location goes without one. This is the most common
312
+ mistake made on this rule: two models both plated the opening location, moved
313
+ the story to a second one, gave that second one no plate at all, and wrote a
314
+ confident justification for it. In one case that left the film's main setting
315
+ -- four of six hops -- held by beat text alone. Ask for the extra picture. If
316
+ the plan ends somewhere no picture describes, the plan is wrong.
317
+
318
+ ## What to produce
319
+
320
+ 1. A one-paragraph plan in prose: how many hops, what each covers, where the
321
+ references sit and why.
322
+ 2. ```json ``` block — the `shot_plan`.
323
+ 3. ```json ``` block — the `ref_plan`.
324
+ 4. A short list of any pictures the user still needs to supply, with the
325
+ filename you used for each and what the picture should show.
326
+
327
+ Before you answer, check every one of these:
328
+
329
+ - [ ] Shot count matches the hop count the user asked for.
330
+ - [ ] Every beat's word count has been counted, written down, and falls inside
331
+ the band for its hop length. A 15 s hop needs 70-100 words; fifty is
332
+ what comes out when this check is skipped.
333
+ - [ ] No shot contains a negation anywhere.
334
+ - [ ] No beat contains any of: no, not, never, n't, without, none of, stop,
335
+ stops, silent, silence.
336
+ - [ ] No beat describes a thing that has finished happening -- nothing fades,
337
+ passes, wanes, subsides, dies down, eases off, recedes or grows quiet.
338
+ Passing this check is not the same as passing the one above it.
339
+ - [ ] No beat contains backticks, asterisks or any other markdown. Beats are
340
+ plain prose; a tag inside a beat is written bare, as @tag.
341
+ - [ ] No shot names an action ending; endings are written as a pose plus a sound.
342
+ - [ ] Any quiet moment names a specific narrowband sound.
343
+ - [ ] No hop ends on a spoken line; every dialogue-free hop names a sound of its own.
344
+ - [ ] Any hop that changes location joins on `match_cut` or `hard_cut`, not `continuous`.
345
+ - [ ] Every state change lands at the end of the shot *before* the one that
346
+ needs it.
347
+ - [ ] Shot 1 establishes; no later shot re-describes face, clothes or room.
348
+ - [ ] Shot 1 names the visual style, if the user asked for anything other
349
+ than live action.
350
+ - [ ] Every persistent object and garment carries an adjective, in the beat
351
+ and again in `context`.
352
+ - [ ] Each beat holds true whether the hop before it stopped short or ran
353
+ ahead of its beat.
354
+ - [ ] Every `@tag` used in a beat exists in `ref_plan`, spelled identically.
355
+ - [ ] Every `@tag` used on hop N has that N in its ref's `shots` list.
356
+ - [ ] Every location the film visits has a place plate covering the hops set
357
+ there, including any location introduced after hop 1. The final hop is
358
+ covered by one.
359
+ - [ ] `join` is absent from shot 1.
360
+ - [ ] The final shot sets `tail` to `settle` or `hold`.
361
+ - [ ] Compare each shot's `framing` with the previous shot's: where it
362
+ changes on `join: continuous`, the camera is moving, not `hold`. And no
363
+ `push_in`+`wide`, no `pull_back`+`close`.
364
+ - [ ] Every subject in `subjects` is claimed by a ref, and has both a `name`
365
+ and a `locked`.
366
+ - [ ] `refs` holds at most 9 entries in total.
367
+ - [ ] Dialogue uses single quotes.
368
+ - [ ] Both blocks are valid JSON: no trailing commas, no comments, no smart
369
+ quotes, plain ASCII, and no field outside the lists above.
prompt_pack/EXAMPLE_6_HOP.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Worked example: six hops, three pictures
2
+
3
+ This is the plan inside `workflows/HandTieClips_Showcase.json`, reproduced here so
4
+ it can be shown to a model as an example of the shape and the reasoning. It is
5
+ generated from that workflow, so the two cannot drift apart.
6
+
7
+ The scene: a cook in a kitchen speaks a line, crosses the room, leaves through a
8
+ doorway into a hallway the register has no picture of, speaks again there, and
9
+ comes back.
10
+
11
+ ## What each hop is for
12
+
13
+ | hop | id | references active |
14
+ |---|---|---|
15
+ | 1 | s1 | @hero_face, @hero_outfit, @kitchen |
16
+ | 2 | s2 | @kitchen |
17
+ | 3 | s3 | @kitchen |
18
+ | 4 | s4 | @hero_face |
19
+ | 5 | s5 | **none** |
20
+ | 6 | s6 | @kitchen |
21
+
22
+ Three things in that table are the whole point:
23
+
24
+ - **Hop 3** carries the kitchen while she is leaving it, and hop 4 does not.
25
+ The hallway is a space no reference describes, so the model must invent it —
26
+ and a kitchen plate riding hop 4 would drag her back into the kitchen.
27
+ - **Hop 4** re-asserts the face. Entering an unseen space is where identity
28
+ drift starts, and re-asserting there is cheaper than recovering on 5 and 6.
29
+ - **Hop 5 has no references at all.** Identity, wardrobe and voice ride on the
30
+ frame pin plus `subjects.1.locked` and `.context` alone. If she is still the
31
+ same person in the same apron with the same voice, the register works.
32
+
33
+ Note also that the dialogue lands on hops 1 and 5 — the establishing shot and
34
+ the one with no pictures — and that both use single quotes.
35
+
36
+ ## shot_plan
37
+
38
+ ```json
39
+ {
40
+ "shots": [
41
+ {
42
+ "id": "s1",
43
+ "beat": "@hero_face stands at the counter in @kitchen, an apron over a grey t-shirt. She looks up from the chopping board and says, 'You are early. I have barely started.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.",
44
+ "directives": {
45
+ "camera": "hold",
46
+ "framing": "medium",
47
+ "pace": "steady",
48
+ "tail": "ongoing"
49
+ }
50
+ },
51
+ {
52
+ "id": "s2",
53
+ "beat": "She sets the knife down and walks the length of the counter to the window of @kitchen, looking out at the street. Her steps are soft on the tiles and the refrigerator hums behind her.",
54
+ "directives": {
55
+ "join": "continuous",
56
+ "camera": "pan_follow",
57
+ "framing": "medium",
58
+ "pace": "steady",
59
+ "tail": "ongoing"
60
+ }
61
+ },
62
+ {
63
+ "id": "s3",
64
+ "beat": "She turns from the window, crosses @kitchen and pushes through the doorway into the hallway beyond, the room falling away behind her. Her footsteps carry on the tiles.",
65
+ "directives": {
66
+ "join": "continuous",
67
+ "camera": "pan_follow",
68
+ "framing": "wide",
69
+ "pace": "brisk",
70
+ "tail": "ongoing"
71
+ }
72
+ },
73
+ {
74
+ "id": "s4",
75
+ "beat": "@hero_face walks down a narrow hallway hung with coats, one hand trailing along the wall, her footsteps muffled on the runner. None of the kitchen is visible.",
76
+ "directives": {
77
+ "join": "continuous",
78
+ "camera": "handheld",
79
+ "framing": "medium",
80
+ "pace": "steady",
81
+ "tail": "ongoing"
82
+ }
83
+ },
84
+ {
85
+ "id": "s5",
86
+ "beat": "She stops at the window at the end of the hall, half turns back over her shoulder and says, 'It is still raining. We will have to do it inside.' She looks back out at the glass with her lips closed, and the rain taps steadily on the pane.",
87
+ "directives": {
88
+ "join": "continuous",
89
+ "camera": "push_in",
90
+ "framing": "close",
91
+ "pace": "slow",
92
+ "tail": "settle"
93
+ }
94
+ },
95
+ {
96
+ "id": "s6",
97
+ "beat": "She walks back along the hallway and through the doorway to the counter in @kitchen, picking the knife up again. The refrigerator hums and the knife starts on the board.",
98
+ "directives": {
99
+ "join": "match_cut",
100
+ "camera": "pull_back",
101
+ "framing": "wide",
102
+ "pace": "steady",
103
+ "tail": "hold"
104
+ }
105
+ }
106
+ ]
107
+ }
108
+ ```
109
+
110
+ ## ref_plan
111
+
112
+ ```json
113
+ {
114
+ "refs": [
115
+ {
116
+ "tag": "hero_face",
117
+ "file": "ref_face.jpg",
118
+ "subject": 1,
119
+ "retention": "fully_preserved",
120
+ "shots": [
121
+ 1,
122
+ 4
123
+ ],
124
+ "desc": "head-and-shoulders photograph of the cook, even light"
125
+ },
126
+ {
127
+ "tag": "hero_outfit",
128
+ "file": "ref_outfit.jpg",
129
+ "subject": 1,
130
+ "retention": "partially_copy",
131
+ "shots": [
132
+ 1
133
+ ],
134
+ "desc": "full-length photograph of the same cook, apron over a grey t-shirt"
135
+ },
136
+ {
137
+ "tag": "kitchen",
138
+ "file": "ref_room.jpg",
139
+ "retention": "reference",
140
+ "shots": [
141
+ 1,
142
+ 2,
143
+ 3,
144
+ 6
145
+ ],
146
+ "desc": "the kitchen: counter, window, and the light coming through it"
147
+ }
148
+ ],
149
+ "subjects": {
150
+ "1": {
151
+ "name": "the cook",
152
+ "locked": "the same face, the same short dark hair, the same silver stud earrings",
153
+ "context": "the apron stays tied over the grey t-shirt"
154
+ }
155
+ }
156
+ }
157
+ ```
prompt_pack/README.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # prompt_pack
2
+
3
+ Everything needed to have a language model write plans for this node.
4
+
5
+ | file | what it is |
6
+ |---|---|
7
+ | **`AUTHORING_PROMPT.md`** | Paste into any chat model, describe your scene, paste the two JSON blocks it returns into the node. Start here. |
8
+ | **`EXAMPLE_6_HOP.md`** | A worked six-hop plan with the reasoning behind its reference schedule. Useful as a second message to the model when you want it to match a shape. |
9
+ | **`SYSTEM_PROMPT.md`** | The same thing with the human preamble stripped, for pasting into a *System Prompt* box. Select all, paste, done. |
10
+ | **`SCHEMA.json`** | JSON Schema for both documents, for anyone wiring this into their own tooling. Carries the rule set under `x-rules` and the duration/frame table under `x-duration-frames`. |
11
+
12
+ ## Using it in LM Studio (or any local chat app)
13
+
14
+ 1. Load a model and set its **context length to 16384 or more**. The system
15
+ prompt is ~4,700 tokens and the reply is another 1,000-2,000; a small window
16
+ truncates the rules and you get invented directive names.
17
+ 2. Open `SYSTEM_PROMPT.md`, select all, paste it into the **System Prompt** box
18
+ in LM Studio's right-hand sidebar. Nothing else goes in that box.
19
+ 3. Set **temperature 0.3-0.5**. Higher and the JSON starts growing trailing
20
+ commas and smart quotes.
21
+ 4. In the chat, describe your scene in plain language, and say **how many hops**
22
+ and **what pictures you have**:
23
+
24
+ > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,
25
+ > waits by a window, then comes back. I have a face photo, a photo of her
26
+ > apron, and a photo of the kitchen.
27
+
28
+ 5. It answers with a paragraph of reasoning and two ```json``` blocks. Each
29
+ section of the panel has its own **JSON** disclosure at the bottom:
30
+ - the first block goes in the **JSON** box under **SCRIPT** (`shot_plan`);
31
+ - the second goes in the **JSON** box under **REFERENCES** (`ref_plan`).
32
+
33
+ Both parse as you type. Bad JSON leaves the cards and rows showing the last
34
+ good version and says so, rather than throwing your paste away.
35
+ 6. Put your pictures in `ComfyUI/input/h3_refs` under the filenames the model
36
+ used, or drop them onto the reference thumbnails and fix the names.
37
+ 7. Queue. **If the node rejects the plan, paste the error straight back into the
38
+ chat** — every message names the shot or reference it came from, and one
39
+ round trip usually fixes it.
40
+
41
+ Want it to match a particular shape? Paste `EXAMPLE_6_HOP.md` as a second
42
+ message before describing your scene.
43
+
44
+ Small local models (7B-8B) hold the JSON schema fine but drift on the prose
45
+ rules — they will write negations. Read the plan before queueing; a beat that
46
+ says "she stops talking" costs you a render.
47
+
48
+ `SCHEMA.json`, `EXAMPLE_6_HOP.md` and `SYSTEM_PROMPT.md` are **generated**, not
49
+ written:
50
+
51
+ ```
52
+ python tools/gen_schema.py # regenerate the schema from the node
53
+ python tools/gen_schema.py --check # exit 1 if it is out of date
54
+ python tools/gen_example.py # regenerate the example from the workflow
55
+ sed -n '14,$p' prompt_pack/AUTHORING_PROMPT.md \n > prompt_pack/SYSTEM_PROMPT.md # re-strip the preamble
56
+ ```
57
+
58
+ Both read the installed node and the shipped workflow, so they cannot describe a
59
+ vocabulary or a plan that does not exist. Run `gen_schema.py` with ComfyUI's
60
+ interpreter — importing the node pulls in torch. Re-run both after changing
61
+ `directives.VOCAB`, `refs.RETENTION`, the shot fields, or the duration table;
62
+ `gen_schema.py` asserts against `plan._SHOT_KEYS`, `refs.REF_FIELDS` and
63
+ `refs.SUBJECT_FIELDS` and will fail loudly rather than emit a stale schema.
64
+
65
+ The node validates every plan it is handed, so a model that gets this wrong is
66
+ caught rather than obeyed. Errors name the shot or the reference they came from
67
+ — pasting one back to the model is usually enough to fix it.
68
+
69
+ The prose guide for humans is [`../PROMPTING.md`](../PROMPTING.md), and the
70
+ Starter workflow carries a condensed version of it as a card board on its own
71
+ canvas — including the LM Studio recipe above.
prompt_pack/SCHEMA.json ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "H3 Ref Chain plans",
4
+ "description": "Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` in the ref_plan widget. Generated by tools/gen_schema.py from the installed node -- do not edit by hand.",
5
+ "type": "object",
6
+ "properties": {
7
+ "shot_plan": {
8
+ "type": "object",
9
+ "required": [
10
+ "shots"
11
+ ],
12
+ "additionalProperties": false,
13
+ "properties": {
14
+ "shots": {
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "object",
19
+ "required": [
20
+ "beat"
21
+ ],
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "id": {
25
+ "type": "string",
26
+ "description": "Stable name. Generated if absent."
27
+ },
28
+ "beat": {
29
+ "type": "string",
30
+ "description": "What happens THIS hop. Shot 1 is the whole opening; every later shot is only the new beat."
31
+ },
32
+ "directives": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "join": {
37
+ "type": "string",
38
+ "enum": [
39
+ "continuous",
40
+ "hard_cut",
41
+ "match_cut"
42
+ ],
43
+ "description": "continuous: The camera and the action carry straight on from the pinned frames in one unbroken take. | match_cut: The shot changes on a matched movement, the new framing picking up the same gesture already underway. | hard_cut: A clean cut opens a new setup, the same place a moment later."
44
+ },
45
+ "camera": {
46
+ "type": "string",
47
+ "enum": [
48
+ "handheld",
49
+ "hold",
50
+ "orbit",
51
+ "pan_follow",
52
+ "pull_back",
53
+ "push_in"
54
+ ],
55
+ "description": "hold: The camera holds its position throughout. | pan_follow: The camera pans smoothly to follow the movement, holding it in frame. | push_in: The camera pushes slowly in. | pull_back: The camera draws slowly back, opening the frame. | orbit: The camera arcs slowly around the action. | handheld: The camera carries a light handheld float."
56
+ },
57
+ "framing": {
58
+ "type": "string",
59
+ "enum": [
60
+ "close",
61
+ "keep",
62
+ "medium",
63
+ "wide"
64
+ ],
65
+ "description": "keep: The framing stays as it is. | wide: A wide shot with the full room in view. | medium: A medium shot from the waist up. | close: A close shot, head and shoulders filling the frame."
66
+ },
67
+ "pace": {
68
+ "type": "string",
69
+ "enum": [
70
+ "brisk",
71
+ "slow",
72
+ "steady"
73
+ ],
74
+ "description": "slow: Everything moves at an unhurried pace. | steady: The action moves at a steady, even pace. | brisk: The action moves briskly."
75
+ },
76
+ "tail": {
77
+ "type": "string",
78
+ "enum": [
79
+ "hold",
80
+ "ongoing",
81
+ "settle"
82
+ ],
83
+ "description": "ongoing: The action is still underway as the clip ends. | settle: The movement eases to a rest and stays there. | hold: The final position holds steady through the last moments.",
84
+ "default": "ongoing"
85
+ }
86
+ },
87
+ "description": "Compiled in the order join, camera, framing, pace, tail. `join` is ignored on shot 1."
88
+ },
89
+ "prose": {
90
+ "type": "string",
91
+ "description": "Appended verbatim, for anything the vocabulary lacks. Phrase it affirmatively."
92
+ },
93
+ "seed": {
94
+ "type": "integer"
95
+ },
96
+ "steps": {
97
+ "type": "integer"
98
+ },
99
+ "duration": {
100
+ "type": "string",
101
+ "enum": [
102
+ "5 s",
103
+ "7 s",
104
+ "8 s",
105
+ "10 s",
106
+ "15 s"
107
+ ]
108
+ },
109
+ "locked": {
110
+ "type": "boolean",
111
+ "description": "Reuse this hop's cached render even when its inputs changed. Needs cache_hops=on."
112
+ }
113
+ }
114
+ },
115
+ "description": "One shot per hop. The shot count IS the hop count."
116
+ }
117
+ }
118
+ },
119
+ "ref_plan": {
120
+ "type": "object",
121
+ "required": [
122
+ "refs"
123
+ ],
124
+ "additionalProperties": false,
125
+ "properties": {
126
+ "refs": {
127
+ "type": "array",
128
+ "items": {
129
+ "type": "object",
130
+ "required": [
131
+ "tag"
132
+ ],
133
+ "additionalProperties": false,
134
+ "properties": {
135
+ "tag": {
136
+ "type": "string",
137
+ "pattern": "^[A-Za-z0-9_]+$",
138
+ "description": "Unique. Written into beats as @tag."
139
+ },
140
+ "file": {
141
+ "type": "string",
142
+ "description": "Basename under ComfyUI/input/h3_refs. Never a path."
143
+ },
144
+ "slot": {
145
+ "type": "integer",
146
+ "description": "Derived from list position. Do not author it; present only for legacy plans."
147
+ },
148
+ "subject": {
149
+ "type": "integer",
150
+ "minimum": 1,
151
+ "description": "Groups pictures OF THE SAME PERSON. Two different people under one number makes the model render the average of their faces."
152
+ },
153
+ "retention": {
154
+ "type": "string",
155
+ "enum": [
156
+ "fully_preserved",
157
+ "partially_copy",
158
+ "reference"
159
+ ],
160
+ "description": "fully_preserved: face, bone structure, and hairstyle carry over exactly | partially_copy: the garment and its cut carry over, moving naturally with the body | reference: the layout, surfaces, and light carry over as the setting"
161
+ },
162
+ "desc": {
163
+ "type": "string"
164
+ },
165
+ "shots": {
166
+ "type": "array",
167
+ "items": {
168
+ "type": "integer",
169
+ "minimum": 1
170
+ },
171
+ "minItems": 1,
172
+ "description": "1-based hops this picture rides on. OMITTING IT MEANS HOP 1 ONLY on a continuation chain -- a plate shot elsewhere riding a later hop beats the frame pin."
173
+ }
174
+ }
175
+ },
176
+ "maxItems": 9,
177
+ "description": "At most 9 pictures on any one hop."
178
+ },
179
+ "subjects": {
180
+ "type": "object",
181
+ "patternProperties": {
182
+ "^[0-9]+$": {
183
+ "type": "object",
184
+ "additionalProperties": false,
185
+ "properties": {
186
+ "name": {
187
+ "type": "string"
188
+ },
189
+ "locked": {
190
+ "type": "string",
191
+ "description": "What must not change. This is what survives on a hop where the photograph is absent."
192
+ },
193
+ "context": {
194
+ "type": "string",
195
+ "description": "Situational state that should persist -- wardrobe, what they are holding."
196
+ }
197
+ }
198
+ }
199
+ },
200
+ "additionalProperties": false,
201
+ "description": "Keyed by subject number, as a STRING. Every subject here must be claimed by at least one ref, or the plan is rejected."
202
+ }
203
+ }
204
+ }
205
+ },
206
+ "x-duration-frames": {
207
+ "5 s": 124,
208
+ "7 s": 175,
209
+ "8 s": 192,
210
+ "10 s": 243,
211
+ "15 s": 362
212
+ },
213
+ "x-rules": [
214
+ "cfg is 1.0 with no negative branch: the prompt is ADDITIVE. Anything named is added; nothing can be removed by mentioning it. Never write a negation.",
215
+ "Never name the thing you want to end. 'stops talking' keeps them talking. Write the state as a pose plus a sound.",
216
+ "H3 always generates audio. Silence must be written AS a sound -- room tone, a refrigerator, a single click.",
217
+ "Ambience must be narrowband. 'faint street noise' renders as hiss; 'the low hum of the refrigerator' renders as a refrigerator.",
218
+ "A state change belongs at the END of the previous shot. Every hop opens holding the frames it was handed.",
219
+ "Shot 1 establishes everything. Later shots carry ONLY the new beat -- never re-describe the face, the clothes or the room.",
220
+ "Write @tags into the action line. Describing a reference in the register does not make the model use it.",
221
+ "Set `tail` to settle or hold on the FINAL shot, or the model is told action is still underway at the last frame and invents something to satisfy it."
222
+ ]
223
+ }
prompt_pack/SYSTEM_PROMPT.md ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are writing shot plans for **Hand Tie Clips**, a ComfyUI node that
2
+ renders a multi-hop video-with-audio chain. Each hop is one continuous clip;
3
+ consecutive hops are joined by pinning the previous hop's final frames and audio
4
+ tail into the next hop's conditioning.
5
+
6
+ You produce exactly two JSON documents: `shot_plan` and `ref_plan`.
7
+
8
+ ## How the renderer behaves
9
+
10
+ These are not style preferences. They are how this model fails.
11
+
12
+ 1. **Sampling runs at cfg 1.0 with no negative branch, so the prompt is purely
13
+ additive.** Every concept named is added to the conditioning. Nothing can be
14
+ removed by mentioning it — "no cut" puts the word *cut* in front of the
15
+ encoder. **Never write a negation.** Write what the shot *is* doing.
16
+
17
+ 2. **Never name the thing you want to end.** "The cook stops talking" keeps her
18
+ talking. Write the state you want as **a pose plus a sound**: "leans back
19
+ against the counter with her lips closed, and lets her eyes move slowly
20
+ across the room. The kitchen is quiet apart from the low hum of the
21
+ refrigerator."
22
+
23
+ **The ban is on the idea, not on a list of words.** Two different models,
24
+ given a scene that ended *"the storm finally stops"*, avoided every banned
25
+ word below and still wrote *"The storm's roar begins to fade... raindrops
26
+ strike the glass with decreasing force"* and *"The storm has passed."* Both
27
+ name the ending. Both keep the storm. Fading, passing, waning, subsiding,
28
+ dying down, easing off, receding, growing quiet and dropping away are the
29
+ same move wearing different words, and every one of them adds the thing it
30
+ describes.
31
+
32
+ Test every sentence with one question: **is this a thing that is happening,
33
+ or a thing that has finished happening?** Only the first survives at cfg 1.0.
34
+ To end a storm, write the world that is left: *"Water runs down the glass in
35
+ slow threads. The mechanism turns with a low hum and the sea moves against
36
+ the rocks below."*
37
+
38
+ These words are rejected outright, in any beat: **no, not, never, n't,
39
+ without, none of, stop, stops, silent, silence.** They are the crudest
40
+ examples of the idea, not the whole of it -- a beat clean of all ten can
41
+ still break this rule, and usually does.
42
+
43
+ 3. **Audio is always generated.** Silence written as an absence produces speech,
44
+ because the model fills the track with the most likely thing. Silence must be
45
+ written **as a sound** — room tone, a refrigerator, a distant car.
46
+
47
+ 4. **Ambience must be narrowband and specific.** "faint street noise through the
48
+ window" is broadband and renders as a five-second hiss. "the low hum of the
49
+ refrigerator" renders as a refrigerator. When in doubt, name a single
50
+ discrete event: "a single click from the refrigerator, then stillness".
51
+
52
+ 5. **A state change belongs at the END of the previous shot.** Every hop opens
53
+ holding the frames it was handed, and the audio pin carries the previous
54
+ hop's tail across the join. If shot 2 ends mid-sentence, nothing you write in
55
+ shot 3 will make shot 3 start quiet. To be silent, still, or elsewhere in a
56
+ shot, arrive there before the previous shot ends.
57
+
58
+ 6. **A hop that ends on dialogue keeps talking into the next hop.** The audio
59
+ pin carries the previous hop's tail, so speech at the end of hop N becomes
60
+ the opening of hop N+1 and propagates down the whole chain. Land each spoken
61
+ line in the MIDDLE of its hop and leave a non-verbal action running into the
62
+ seam — slicing, walking, a hand on a doorframe. Give every hop with no
63
+ dialogue its own narrowband sound bed (footsteps, a refrigerator, rain on
64
+ glass), or the audio has nowhere to go but back to speech.
65
+
66
+ 7. **A beat must be true from any plausible ending of the hop before it.** A
67
+ hop routinely over-delivers -- given a movement it finishes it, and then some.
68
+ If the next beat instructs something its own opening frames have already done,
69
+ the only way to obey is to reset the scene, which reads as a hard cut about a
70
+ second and a half *into* the hop rather than at the seam. Give one hop the
71
+ whole of one movement, and write the next beat so it holds whether the
72
+ previous hop stopped short or ran ahead.
73
+
74
+ 8. **A walk between two different rooms is `match_cut`, not `continuous`.**
75
+ Asking for one unbroken take across a real location change makes the model
76
+ morph one room into the other mid-movement.
77
+
78
+ 9. **Shot 1 establishes everything. Every later shot carries ONLY the new
79
+ beat.** Never re-describe the face, the clothes or the room after shot 1 —
80
+ the reference photographs, the register and the frame pin already carry all
81
+ three, and repeating them competes with the pin instead of reinforcing it.
82
+
83
+ 10. **Write `@tags` into the action line.** Describing a reference in the register
84
+ does not make the model use it; the beat is what drives the frame. Write
85
+ "stands at the counter in @kitchen", not merely a `@kitchen` entry in the
86
+ register. Phrase a place as a place that is depicted, not as a container to
87
+ be placed inside. **A beat is plain prose.** A tag written inside a beat
88
+ carries no backticks, asterisks or other markdown -- every character you
89
+ write reaches the encoder literally, so formatting becomes noise in the
90
+ conditioning.
91
+
92
+ 11. **Every object that persists gets an adjective.** A bare noun is
93
+ unanchored: each hop encodes "the bowl" from scratch and is free to make it
94
+ steel in one hop and porcelain in the next. Name a colour or a material the
95
+ first time and repeat it every time -- "the white porcelain bowl", "the dark
96
+ blue wrapped hilt". State it in the subject's `context` as a **property**,
97
+ never as a location.
98
+
99
+ 12. **Name the visual style in shot 1, once.** Nothing else states it. If the
100
+ user asks for 2D anime, film noir, stop motion, watercolour or any look other
101
+ than live action, shot 1's beat opens with it: "Hand-drawn 2D anime in the
102
+ style of ..., crisp inked linework over painted backgrounds." Reference
103
+ pictures pull the look one way and unstated text pulls it back toward
104
+ photoreal video, and the text wins often enough to matter. Later shots do not
105
+ repeat it -- it belongs in `setting.locked` on the H3 Continuity State node,
106
+ which rides every hop from 2 on.
107
+
108
+ The node prepends an establishing line of its own to hop 1, and its default
109
+ asserts live action. When shot 1 opens by naming a medium, that default is
110
+ dropped automatically, so a styled plan needs nothing else from you. Name the
111
+ medium in the **first sentence** of shot 1 for that to happen.
112
+
113
+ 13. **Set `tail` on the final shot** to `settle` or `hold`. Left at its default
114
+ `ongoing`, the model is told action is still underway at the last frame and
115
+ will invent something to satisfy that — a stray gesture, or a stray line of
116
+ dialogue in the closing second.
117
+
118
+ ## `shot_plan`
119
+
120
+ One shot per hop. **The number of shots is the number of hops.**
121
+
122
+ ```json
123
+ {
124
+ "shots": [
125
+ {
126
+ "id": "s1",
127
+ "beat": "...",
128
+ "directives": {"camera": "hold", "framing": "medium", "pace": "steady", "tail": "ongoing"}
129
+ }
130
+ ]
131
+ }
132
+ ```
133
+
134
+ Valid shot fields, and no others: `id`, `beat`, `directives`, `prose`, `seed`,
135
+ `steps`, `duration`, `locked`. Only `beat` is required. An unknown field is a
136
+ hard error.
137
+
138
+ Directive axes, and no others. Every axis is optional; an unset axis emits
139
+ nothing at all, which costs no tokens.
140
+
141
+ | axis | values |
142
+ |---|---|
143
+ | `join` | `continuous`, `match_cut`, `hard_cut` — **omit on shot 1**, which has nothing to join to |
144
+ | `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
145
+ | `framing` | `keep`, `wide`, `medium`, `close` |
146
+ | `pace` | `slow`, `steady`, `brisk` |
147
+ | `tail` | `ongoing` (default), `settle`, `hold` |
148
+
149
+ Two combinations are contradictions and will be flagged:
150
+
151
+ - `join: continuous` + a framing change + `camera: hold`. With the camera still,
152
+ the only way to reach a new framing is a cut. Earn it on the move (`push_in`,
153
+ `pull_back`, `pan_follow`) or use `framing: keep`.
154
+ - `push_in` with `wide`, or `pull_back` with `close`. The move points the
155
+ opposite way from the destination.
156
+
157
+ `duration` may override the chain per shot, using exactly these labels:
158
+ `"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.
159
+
160
+ ### How long a beat has to be
161
+
162
+ A beat has to fill its whole hop. Written short, the model finishes the action
163
+ early and invents something for the seconds left over -- most often a cut to the
164
+ reference photograph in the closing moments. **Ask the user how long each hop is
165
+ if they have not said**, and size every beat to it.
166
+
167
+ **Count the words in each beat before you answer, and write the count down.**
168
+ This is the instruction most often ignored, and ignoring it is not a small
169
+ miss. Two different models asked for six 15 s hops both returned beats averaging
170
+ **54 words** -- every single beat under the floor -- and the same two models,
171
+ asked for much shorter hops, returned 40 to 48. Left to itself a model writes
172
+ about fifty words whatever the hop length. Fifty words is right for a 7 s hop
173
+ and is half of what 15 s needs. The table below is not a style note; it is the
174
+ one part of this document you have to do arithmetic for.
175
+
176
+ | hop | words in the beat |
177
+ |---|---|
178
+ | `5 s` | 30-45 |
179
+ | `7 s` | 30-55 |
180
+ | `8 s` | 35-60 |
181
+ | `10 s` | 45-75 |
182
+ | `15 s` | 70-100 |
183
+
184
+ A worked `15 s` beat, at 74 words, from a rendered eight-hop chain:
185
+
186
+ > They close the distance together and the blades meet at the centre of the clearing, white sparks bursting from the impact as the camera orbits around the lock. @warrior_face turns the heavier blade aside and cuts back across the body; @enemy_shadow catches it on the flat and steps in behind it. Steel rings sharp and resonant on every contact, armour plates grind against one another, and boots drag hard over stone between the exchanges.
187
+
188
+ Read how the length is spent, because padding to a word count fails differently
189
+ but just as badly. One continuous movement carries the whole hop -- they close,
190
+ the blades meet, one turns the other aside, the other steps in. The camera move
191
+ is named inside the action rather than after it. The last third is the sound bed
192
+ and nothing else: three specific noises, each tied to a thing on screen. There
193
+ is no second event and no scene change.
194
+
195
+ The `5 s` and `7 s` rows are the measured spread of the two plans that ship with
196
+ the node. Their thinnest beat is 28 words in a 7 s hop, carrying one simple
197
+ action; their fullest is 56. The `15 s` row now has one measurement behind it --
198
+ the chain the example above comes from ran 74 to 90 words a beat across eight
199
+ hops and every hop delivered its beat in full, with no hop idling into the
200
+ reference. **The `8 s` and `10 s` rows are still interpolated, not measured.**
201
+ If the closing seconds drift or cut to the reference photograph, the beat was
202
+ short.
203
+
204
+ Fill that length with *continuous* material -- one movement that takes the whole
205
+ hop, what the camera is doing while it happens, and the sound bed underneath --
206
+ rather than with more separate events. Four events crammed into one hop is the
207
+ over-delivery in rule 7, and it costs you the next hop's opening.
208
+
209
+ ### Dialogue
210
+
211
+ Put the spoken line inside the beat, in **single** quotes:
212
+
213
+ ```
214
+ @hero_face looks up from the chopping board and says, 'You are early. I have
215
+ barely started.'
216
+ ```
217
+
218
+ Single quotes survive copy-paste; escaped double quotes are the most common
219
+ cause of a rejected plan. Budget roughly one line of dialogue per 5–7 seconds of
220
+ hop — a long speech in a short hop is truncated mid-word, and that truncation is
221
+ then pinned into the next hop's audio.
222
+
223
+ ## `ref_plan`
224
+
225
+ ```json
226
+ {
227
+ "refs": [
228
+ {"tag": "hero_face", "file": "face.jpg", "subject": 1,
229
+ "retention": "fully_preserved", "shots": [1, 4],
230
+ "desc": "head-and-shoulders, even light"}
231
+ ],
232
+ "subjects": {
233
+ "1": {"name": "the cook",
234
+ "locked": "the same face, the same short dark hair",
235
+ "context": "the apron stays tied over the grey t-shirt"}
236
+ }
237
+ }
238
+ ```
239
+
240
+ Valid ref fields: `tag`, `file`, `subject`, `retention`, `desc`, `shots`. (There
241
+ is also `slot`, which is derived from list position — never author it.)
242
+
243
+ - **`tag`** is required, unique, `[A-Za-z0-9_]+`, and is what appears in beats
244
+ as `@tag`.
245
+ - **`file`** is a bare filename in `ComfyUI/input/h3_refs`, never a path. A
246
+ filename that is not in that folder **stops the run** -- it is an error, not a
247
+ warning, so a placeholder you invent must be one the user then actually
248
+ supplies. If the user has not told you their filenames, use clear placeholders
249
+ and list every one of them at the end of your answer, with what each picture
250
+ should show.
251
+ - **`subject`** is an integer ≥ 1 grouping pictures **of the same person**.
252
+ Declaring two different people under one subject number makes the model render
253
+ **the average of their faces**. One number per person, always.
254
+ - **`retention`** is one of:
255
+ - `fully_preserved` — face, bone structure and hairstyle carry over exactly
256
+ - `partially_copy` — the garment and its cut carry over
257
+ - `reference` — layout, surfaces and light carry over, i.e. a place
258
+
259
+ It defaults to `fully_preserved` when `subject` is set, `reference` otherwise.
260
+ - **`shots`** is the list of 1-based hops the picture rides on.
261
+
262
+ Every subject that appears in `subjects` must be claimed by at least one ref, or
263
+ the plan is rejected. Give every subject a **`locked`**: pictures put a face in
264
+ front of the encoder, but `locked` is what carries the identity across a hop
265
+ where the picture is absent. At most **9 references in the whole plan** -- the limit counts entries in
266
+ `refs`, not pictures per hop.
267
+
268
+ ### Scheduling references is the part that decides whether continuity holds
269
+
270
+ Write `@tag` for a person on any hop you like. Hop 1 resolves it to
271
+ `<Subject N>` against its definitions block; hop 2 onward resolves it to the
272
+ subject's `name`, which binds to the identity sentence every continuation hop
273
+ carries. **Every subject therefore needs a `name`.**
274
+
275
+ **A reference with no `shots` list rides hop 1 only.** That default is right
276
+ for a **place** plate: a room still riding a hop set somewhere else beats the
277
+ frame pin, because the model has a crisp picture of one room and a noisy
278
+ carried-over frame of another, and it follows the still. **List every hop a
279
+ picture belongs on, explicitly.**
280
+
281
+ **A face plate is the opposite. Put it on every hop.** Two six-hop renders
282
+ settled this. A face plate rode a hop set in a space it had never seen and held
283
+ cleanly; the hop scheduled with no references came back a different person, and
284
+ nothing after it recovered. Identity drift does not self-correct. `locked` holds
285
+ a face that is still right; only a plate rebuilds one that is gone.
286
+
287
+ - Put every **face** reference on every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
288
+ - Keep a **place** reference on every hop set in that place, and off every hop
289
+ that is not.
290
+ - An **outfit** plate can ride hop 1 only, but then `context` is the only thing
291
+ holding the wardrobe from hop 2 on, so name its colours there.
292
+ - Never let a place plate shot in one location ride a hop set somewhere else.
293
+ - **The hop that changes location belongs entirely to the new place.** Put the
294
+ departure at the end of the *previous* beat, so that no place plate ever rides
295
+ a hop which opens somewhere it was not photographed.
296
+ - **A location introduced part way through needs its own plate, on the hop it
297
+ arrives and every hop after.** Tightening a place plate to its own shots does
298
+ **not** mean the next location goes without one. This is the most common
299
+ mistake made on this rule: two models both plated the opening location, moved
300
+ the story to a second one, gave that second one no plate at all, and wrote a
301
+ confident justification for it. In one case that left the film's main setting
302
+ -- four of six hops -- held by beat text alone. Ask for the extra picture. If
303
+ the plan ends somewhere no picture describes, the plan is wrong.
304
+
305
+ ## What to produce
306
+
307
+ 1. A one-paragraph plan in prose: how many hops, what each covers, where the
308
+ references sit and why.
309
+ 2. ```json ``` block — the `shot_plan`.
310
+ 3. ```json ``` block — the `ref_plan`.
311
+ 4. A short list of any pictures the user still needs to supply, with the
312
+ filename you used for each and what the picture should show.
313
+
314
+ Before you answer, check every one of these:
315
+
316
+ - [ ] Shot count matches the hop count the user asked for.
317
+ - [ ] Every beat's word count has been counted, written down, and falls inside
318
+ the band for its hop length. A 15 s hop needs 70-100 words; fifty is
319
+ what comes out when this check is skipped.
320
+ - [ ] No shot contains a negation anywhere.
321
+ - [ ] No beat contains any of: no, not, never, n't, without, none of, stop,
322
+ stops, silent, silence.
323
+ - [ ] No beat describes a thing that has finished happening -- nothing fades,
324
+ passes, wanes, subsides, dies down, eases off, recedes or grows quiet.
325
+ Passing this check is not the same as passing the one above it.
326
+ - [ ] No beat contains backticks, asterisks or any other markdown. Beats are
327
+ plain prose; a tag inside a beat is written bare, as @tag.
328
+ - [ ] No shot names an action ending; endings are written as a pose plus a sound.
329
+ - [ ] Any quiet moment names a specific narrowband sound.
330
+ - [ ] No hop ends on a spoken line; every dialogue-free hop names a sound of its own.
331
+ - [ ] Any hop that changes location joins on `match_cut` or `hard_cut`, not `continuous`.
332
+ - [ ] Every state change lands at the end of the shot *before* the one that
333
+ needs it.
334
+ - [ ] Shot 1 establishes; no later shot re-describes face, clothes or room.
335
+ - [ ] Shot 1 names the visual style, if the user asked for anything other
336
+ than live action.
337
+ - [ ] Every persistent object and garment carries an adjective, in the beat
338
+ and again in `context`.
339
+ - [ ] Each beat holds true whether the hop before it stopped short or ran
340
+ ahead of its beat.
341
+ - [ ] Every `@tag` used in a beat exists in `ref_plan`, spelled identically.
342
+ - [ ] Every `@tag` used on hop N has that N in its ref's `shots` list.
343
+ - [ ] Every location the film visits has a place plate covering the hops set
344
+ there, including any location introduced after hop 1. The final hop is
345
+ covered by one.
346
+ - [ ] `join` is absent from shot 1.
347
+ - [ ] The final shot sets `tail` to `settle` or `hold`.
348
+ - [ ] Compare each shot's `framing` with the previous shot's: where it
349
+ changes on `join: continuous`, the camera is moving, not `hold`. And no
350
+ `push_in`+`wide`, no `pull_back`+`close`.
351
+ - [ ] Every subject in `subjects` is claimed by a ref, and has both a `name`
352
+ and a `locked`.
353
+ - [ ] `refs` holds at most 9 entries in total.
354
+ - [ ] Dialogue uses single quotes.
355
+ - [ ] Both blocks are valid JSON: no trailing commas, no comments, no smart
356
+ quotes, plain ASCII, and no field outside the lists above.
pyproject.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "comfyui-hand-tie-clips"
3
+ version = "0.3.3"
4
+ description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.10"
8
+ dependencies = []
9
+
10
+ [project.urls]
11
+ Repository = "https://github.com/dntpi/ComfyUI-Hand-Tie-Clips"
12
+
13
+ # `comfy node publish` needs PublisherId as well as the Repository above.
14
+ # PublisherId comes from a registry.comfy.org account and is the last field
15
+ # outstanding. Neither is needed to install the pack by hand or from a zip.
16
+ [tool.comfy]
17
+ PublisherId = ""
18
+ DisplayName = "Hand Tie Clips"
19
+ Icon = ""
refs.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference register: stable @tags, subject numbers, retention tiers.
2
+
3
+ Two bugs this exists to fix.
4
+
5
+ **Ordinal instability.** Core assigns `<Picture N>` 1-based in list order
6
+ (`comfy/text_encoders/minimax.py:148-202`), and `_collect_ref_images` packs the
7
+ wired slots densely. Wire slots 1/3/5 and they become `<Picture 1/2/3>`; unplug
8
+ one and every later reference silently renumbers. Any prompt text naming a
9
+ literal ordinal is then pointing at the wrong picture. Here a ref carries a
10
+ stable `@tag` and the ordinal is *derived per hop*, so prompt prose refers to
11
+ tags and the compiler resolves them.
12
+
13
+ **Subject collapse.** H3-Multishot found that declaring every picture as a photo
14
+ of `<Subject 1>` makes the model render the average of two different people.
15
+ Subject numbers group pictures per person and state distinctness explicitly.
16
+
17
+ Phrasing here follows the same rule as `directives.py`: AFFIRMATIVE ONLY.
18
+ Sampling runs at cfg 1.0 with no negative branch, so every concept named is
19
+ additive. State what each picture IS for.
20
+ """
21
+
22
+ from os.path import basename as _basename
23
+
24
+ TAG = "HandTieClips"
25
+
26
+ MAX_REF_IMAGES = 9
27
+
28
+ # What the model should do with a picture. Wording is deliberately about the
29
+ # *carry-over*, not about the picture, because the model is being told how much
30
+ # of it to reproduce.
31
+ RETENTION = {
32
+ "fully_preserved": "face, bone structure, and hairstyle carry over exactly",
33
+ "partially_copy": "the garment and its cut carry over, moving naturally with the body",
34
+ "reference": "the layout, surfaces, and light carry over as the setting",
35
+ }
36
+
37
+ # `file` is the reference: a basename under <ComfyUI input>/h3_refs. `slot` is
38
+ # still accepted, and still *emitted*, but it means something different now --
39
+ # it is derived from the ref's position in the list rather than naming a socket,
40
+ # and it exists only so the ordinal machinery below did not have to change.
41
+ # A plan from before the sockets were removed carries an authored `slot` and no
42
+ # `file`; that is detected, kept as `legacy_slot`, and reported by check().
43
+ REF_FIELDS = ("tag", "file", "slot", "subject", "retention", "desc", "shots")
44
+
45
+ # Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
46
+ # so it is keyed by the same subject number that owns the picture ordinals.
47
+ #
48
+ # The old node could not express a second character at all: `continuity_state`
49
+ # is a single forceInput STRING and the node emits exactly one `characters` key
50
+ # from one `character_id` widget, so two people were unrepresentable (its own
51
+ # docstring says so). Worse, its prose named people by an arbitrary string while
52
+ # `<Picture N>` was assigned positionally by slot order, with nothing joining
53
+ # the two -- so with two people at two refs each, `_identity_lock` emitted one
54
+ # undifferentiated pool of four "identities" and the model was free to average
55
+ # the faces. Keying continuity text by subject number closes that join.
56
+ SUBJECT_FIELDS = ("name", "locked", "context")
57
+
58
+
59
+ def _fail(msg):
60
+ raise ValueError(f"{TAG}: {msg}")
61
+
62
+
63
+ def _norm_subject(key, raw):
64
+ """Validate one subject entry. Errors name the subject and valid fields."""
65
+ try:
66
+ num = int(key)
67
+ except (TypeError, ValueError):
68
+ _fail(f"subject key {key!r} is not a number. Use the same subject numbers "
69
+ f"the refs use, e.g. \"1\".")
70
+ if num < 1:
71
+ _fail(f"subject {key}: subject numbers start at 1")
72
+ if not isinstance(raw, dict):
73
+ _fail(f"subject {num}: expected an object, got {type(raw).__name__}")
74
+ unknown = sorted(set(raw) - set(SUBJECT_FIELDS))
75
+ if unknown:
76
+ _fail(f"subject {num}: unknown field(s) {unknown}. "
77
+ f"Valid: {list(SUBJECT_FIELDS)}")
78
+ return num, {
79
+ "name": str(raw.get("name") or "").strip(),
80
+ "locked": str(raw.get("locked") or "").strip(),
81
+ "context": str(raw.get("context") or "").strip(),
82
+ }
83
+
84
+
85
+ def _norm_ref(raw, i):
86
+ """Validate one ref entry. Errors name the ref and the valid options."""
87
+ where = f"ref {i + 1}"
88
+ if not isinstance(raw, dict):
89
+ _fail(f"{where}: expected an object, got {type(raw).__name__}")
90
+
91
+ unknown = sorted(set(raw) - set(REF_FIELDS))
92
+ if unknown:
93
+ _fail(f"{where}: unknown field(s) {unknown}. Valid: {list(REF_FIELDS)}")
94
+
95
+ tag = str(raw.get("tag") or "").strip().lstrip("@")
96
+ if not tag:
97
+ _fail(f"{where}: 'tag' is required (a short stable name like 'hero_face')")
98
+
99
+ # A bare filename, never a path: media.resolve refuses anything with a
100
+ # directory component, so accepting one here would only produce a ref that
101
+ # silently never loads.
102
+ file = _basename(str(raw.get("file") or "").strip())
103
+
104
+ # Legacy: a plan authored against the ref_image_N sockets. Not an error --
105
+ # failing here would stop the editor opening the very plan the author needs
106
+ # to repair, so it is carried through and reported by check().
107
+ legacy_slot = None
108
+ if not file and raw.get("slot") is not None:
109
+ try:
110
+ legacy_slot = int(raw["slot"])
111
+ except (TypeError, ValueError):
112
+ legacy_slot = None
113
+
114
+ subject = raw.get("subject")
115
+ if subject is not None:
116
+ try:
117
+ subject = int(subject)
118
+ except (TypeError, ValueError):
119
+ _fail(f"{where} (@{tag}): subject {subject!r} is not a number")
120
+ if subject < 1:
121
+ _fail(f"{where} (@{tag}): subject must be 1 or greater")
122
+
123
+ retention = str(raw.get("retention") or "").strip()
124
+ if not retention:
125
+ # A ref tied to a person defaults to identity; anything else is setting.
126
+ retention = "fully_preserved" if subject else "reference"
127
+ if retention not in RETENTION:
128
+ _fail(f"{where} (@{tag}): retention '{retention}' is not valid. "
129
+ f"Use one of: {', '.join(sorted(RETENTION))}")
130
+
131
+ shots = raw.get("shots")
132
+ if shots is not None:
133
+ if not isinstance(shots, (list, tuple)):
134
+ _fail(f"{where} (@{tag}): 'shots' must be a list of 1-based shot "
135
+ f"numbers, e.g. [1, 2]")
136
+ clean = []
137
+ for s in shots:
138
+ try:
139
+ clean.append(int(s))
140
+ except (TypeError, ValueError):
141
+ _fail(f"{where} (@{tag}): shot number {s!r} is not a number")
142
+ if not clean:
143
+ _fail(f"{where} (@{tag}): 'shots' is empty. Omit it for every shot.")
144
+ shots = sorted(set(clean))
145
+
146
+ return {
147
+ "tag": tag,
148
+ "file": file,
149
+ "legacy_slot": legacy_slot,
150
+ # Filled in by parse_ref_plan from list position; see REF_FIELDS.
151
+ "slot": 0,
152
+ "subject": subject,
153
+ "retention": retention,
154
+ "desc": str(raw.get("desc") or "").strip(),
155
+ "shots": shots,
156
+ }
157
+
158
+
159
+ def parse_ref_plan(text):
160
+ """Parse the ref_plan widget.
161
+
162
+ Returns {"refs": [...], "subjects": {n: {...}}}. Blank -> empty plan, and
163
+ the caller falls back to raw slot order.
164
+ """
165
+ import json
166
+
167
+ t = (text or "").strip()
168
+ if not t:
169
+ return {"refs": [], "subjects": {}}
170
+ try:
171
+ data = json.loads(t)
172
+ except json.JSONDecodeError as e:
173
+ _fail(f"ref_plan is not valid JSON: {e}")
174
+
175
+ raw_subjects = {}
176
+ if isinstance(data, list):
177
+ raw_refs = data
178
+ elif isinstance(data, dict):
179
+ raw_refs = data.get("refs")
180
+ if raw_refs is None:
181
+ _fail("ref_plan object needs a 'refs' array")
182
+ raw_subjects = data.get("subjects") or {}
183
+ if not isinstance(raw_subjects, dict):
184
+ _fail("ref_plan 'subjects' must be an object keyed by subject number, "
185
+ "e.g. {\"1\": {\"name\": \"...\", \"locked\": \"...\"}}")
186
+ else:
187
+ _fail(f"ref_plan must be an object or array, got {type(data).__name__}")
188
+
189
+ if not isinstance(raw_refs, (list, tuple)):
190
+ _fail("ref_plan 'refs' must be an array")
191
+
192
+ refs = [_norm_ref(r, i) for i, r in enumerate(raw_refs)]
193
+ if len(refs) > MAX_REF_IMAGES:
194
+ _fail(f"ref_plan has {len(refs)} references; the encoder takes at most "
195
+ f"{MAX_REF_IMAGES} in one plan.")
196
+ # The ordinal is derived from list position, which is what the author sees
197
+ # in the rail. This keeps `active_refs`/`ordinals` unchanged while the
198
+ # authored identity moves from a socket number to a filename.
199
+ for i, r in enumerate(refs):
200
+ r["slot"] = i + 1
201
+ subjects = dict(_norm_subject(k, v) for k, v in raw_subjects.items())
202
+
203
+ # A subject block that no ref points at is continuity text for a person with
204
+ # no face wired -- it would ride every hop describing someone who never
205
+ # appears, which at cfg 1.0 is purely additive.
206
+ used = {r["subject"] for r in refs if r["subject"] is not None}
207
+ for num in sorted(set(subjects) - used):
208
+ _fail(f"subject {num} has continuity text but no ref claims subject "
209
+ f"{num}. Give one of the refs \"subject\": {num}, or remove the "
210
+ f"subject block.")
211
+
212
+ seen_tags = {}
213
+ for i, r in enumerate(refs):
214
+ if r["tag"] in seen_tags:
215
+ _fail(f"ref {i + 1}: tag '@{r['tag']}' is already used by ref "
216
+ f"{seen_tags[r['tag']] + 1}. Tags must be unique.")
217
+ seen_tags[r["tag"]] = i
218
+ return {"refs": refs, "subjects": subjects}
219
+
220
+
221
+ def active_refs(refs, hop_index, wired_slots):
222
+ """Refs live on this hop, in rail order, restricted to those whose file loaded.
223
+
224
+ `hop_index` is 0-based; `shots` in the plan is 1-based because that is what
225
+ the shot plan shows the author.
226
+ """
227
+ shot_no = hop_index + 1
228
+ out = []
229
+ for r in refs:
230
+ if r["slot"] not in wired_slots:
231
+ continue
232
+ if r["shots"] is not None and shot_no not in r["shots"]:
233
+ continue
234
+ out.append(r)
235
+ return sorted(out, key=lambda r: r["slot"])
236
+
237
+
238
+ def ordinals(active):
239
+ """tag -> `<Picture N>` ordinal for this hop.
240
+
241
+ This is the whole point: the ordinal is derived from what is active on this
242
+ hop, so prose written against @tags stays correct when refs are added,
243
+ removed, or scheduled off.
244
+ """
245
+ return {r["tag"]: i + 1 for i, r in enumerate(active)}
246
+
247
+
248
+ def resolve_tags(text, tag_map, subject_map=None, where="", declared=None,
249
+ subject_names=None):
250
+ """Replace `@tag` in prose with what this hop calls that reference.
251
+
252
+ A ref tied to a person resolves to `<Subject N>`, not `<Picture N>`: beat
253
+ prose describes someone *acting*, and a picture cannot act. The
254
+ `subject_definitions:` block binds the subject to its pictures, so the
255
+ identity still lands. Setting and prop refs have no subject and resolve to
256
+ their picture ordinal.
257
+
258
+ `subject_map` may include people whose photograph is off this hop
259
+ (continuation: pin-only). Those tags still resolve to `<Subject N>`.
260
+
261
+ An unknown tag is a hard error -- silently leaving `@hero_face` in the
262
+ prompt would put the literal word in front of the encoder, which at cfg 1.0
263
+ is additive noise.
264
+
265
+ `subject_names` maps subject number -> the name to use in prose, and is
266
+ passed on **continuation hops only**. There, `<Subject N>` is a dangling
267
+ token: `subject_definitions:` is hop-1 material, so on hop 4 the ordinal has
268
+ nothing in its own encode to bind to -- the same defect that turned an
269
+ undescribed "the bowl" into a stainless steel one. A name binds to the
270
+ identity sentence `continuity_line` puts on every continuation hop, so the
271
+ prose reads "The cook walks down the hallway" and every word is anchored.
272
+ Without a name for that subject it falls back to `<Subject N>`, which is no
273
+ worse than before.
274
+
275
+ `declared` is every tag in the register, active on this hop or not. A tag
276
+ that is in it has been spelled correctly and the fault lies elsewhere --
277
+ a missing picture, or a `shots` list that leaves this hop out. Reporting
278
+ that as "unknown reference" sent authors hunting through their beats for a
279
+ typo that was never there.
280
+ """
281
+ import re
282
+
283
+ t = str(text or "")
284
+ if "@" not in t:
285
+ return t
286
+ subject_map = subject_map or {}
287
+ declared = declared or set()
288
+
289
+ def sub(m):
290
+ tag = m.group(1)
291
+ # A person tag stays <Subject N> even when that photograph is off this
292
+ # hop (continuation: the pin carries wardrobe and room, the still does
293
+ # not). Requiring the tag in tag_map first made @hero_face a hard error
294
+ # the moment hop 2 dropped the face plate.
295
+ if tag in subject_map:
296
+ num = subject_map[tag]
297
+ name = str((subject_names or {}).get(num) or "").strip()
298
+ if name:
299
+ # Sentence-initial gets a capital: "@hero_face walks" became
300
+ # "the cook walks" mid-paragraph, which reads as a fragment.
301
+ head = t[:m.start()].rstrip()
302
+ if not head or head[-1] in '.!?:;':
303
+ return name[0].upper() + name[1:]
304
+ return name
305
+ return f"<Subject {num}>"
306
+ if tag in tag_map:
307
+ return f"<Picture {tag_map[tag]}>"
308
+ w = where + ": " if where else ""
309
+ if tag in declared:
310
+ _fail(f"{w}@{tag} is in the reference register but has no picture "
311
+ f"on this hop. Either its file is not in the reference "
312
+ f"folder, or its `shots` list leaves this hop out.")
313
+ known = ", ".join("@" + k for k in sorted(set(tag_map) | set(subject_map))) or "(none)"
314
+ _fail(f"{w}unknown reference '@{tag}'. Known: {known}")
315
+
316
+ return re.sub(r"@([A-Za-z0-9_]+)", sub, t)
317
+
318
+
319
+ def subjects(active):
320
+ """tag -> subject number, for refs that name a person."""
321
+ return {r["tag"]: r["subject"] for r in active if r["subject"] is not None}
322
+
323
+
324
+ def subject_prose(active, subjects=None):
325
+ """Build `subject_definitions:` and `retention_analysis:` for this hop.
326
+
327
+ Pictures are grouped by subject number so two people stay two people, and
328
+ each subject's continuity text is emitted *inside its own block*, directly
329
+ after the ordinals that subject owns. That adjacency is the whole fix: the
330
+ old split put identity prose in one place (keyed by an arbitrary string) and
331
+ picture ordinals in another (assigned positionally), so the encoder was
332
+ never told which face belonged to which description.
333
+
334
+ A ref with no subject is a setting or prop and appears only in retention.
335
+ """
336
+ if not active:
337
+ return ""
338
+
339
+ subjects = subjects or {}
340
+ ords = ordinals(active)
341
+ by_subject = {}
342
+ for r in active:
343
+ if r["subject"] is not None:
344
+ by_subject.setdefault(r["subject"], []).append(r)
345
+
346
+ parts = []
347
+ if by_subject:
348
+ lines = []
349
+ for subj in sorted(by_subject):
350
+ pics = ", ".join(f"<Picture {ords[r['tag']]}>" for r in by_subject[subj])
351
+ info = subjects.get(subj) or {}
352
+ name = info.get("name")
353
+ who = f"{name}, the person in {pics}" if name else f"the person in {pics}"
354
+ lines.append(f"<Subject {subj}> is {who}.")
355
+ for extra in (info.get("locked"), info.get("context")):
356
+ if extra:
357
+ lines.append(f"<Subject {subj}>: {extra}")
358
+ if len(by_subject) > 1:
359
+ names = ", ".join(f"<Subject {s}>" for s in sorted(by_subject))
360
+ lines.append(
361
+ f"{names} are separate people. Each one keeps the face from its "
362
+ f"own pictures throughout."
363
+ )
364
+ parts.append("subject_definitions:\n" + "\n".join(lines))
365
+
366
+ ret = []
367
+ for r in active:
368
+ pic = f"<Picture {ords[r['tag']]}>"
369
+ detail = RETENTION[r["retention"]]
370
+ desc = f" ({r['desc']})" if r["desc"] else ""
371
+ ret.append(f"{pic}{desc}: {detail}.")
372
+ parts.append("retention_analysis:\n" + "\n".join(ret))
373
+
374
+ return "\n\n".join(parts)
375
+
376
+
377
+ def continuity_line(subjects, subject_nums=None):
378
+ """Identity prose for a hop that cites no photographs.
379
+
380
+ `subject_prose` binds continuity text to picture ordinals, which makes it
381
+ hop-1 material: repeating a `<Subject N> is the person in <Picture M>`
382
+ block on a later hop makes that hop a second Ref2VA generate. But the text
383
+ itself -- `locked` and `context` -- is not about a photograph. It is what
384
+ must not change, and a hop with no reference scheduled is exactly where the
385
+ encoder has nothing else to go on.
386
+
387
+ So this emits the same text with **no ordinals of any kind**. No
388
+ `<Picture N>` (which sends the encoder back to the plates -- chain_00034)
389
+ and no `<Subject N>` (which would dangle, with no definitions block on this
390
+ hop to bind it). One person is "The same person"; several are named, and
391
+ fall back to "one person" only when a subject has no name.
392
+
393
+ Returns "" when nothing is worth saying, so callers can `if p` it away.
394
+ """
395
+ subjects = subjects or {}
396
+ nums = sorted(subject_nums if subject_nums is not None else subjects)
397
+ nums = [n for n in nums if (subjects.get(n) or {})]
398
+ if not nums:
399
+ return ""
400
+
401
+ solo = len(nums) == 1
402
+ lines = []
403
+ for num in nums:
404
+ info = subjects.get(num) or {}
405
+ bits = [t.strip() for t in (info.get("locked"), info.get("context"))
406
+ if t and t.strip()]
407
+ if not bits:
408
+ continue
409
+ # "The same person" reads as continuation; a bare name reads as an
410
+ # introduction, which on a continuation hop is a new scene. With more
411
+ # than one subject there is no way round naming them, and a `name`
412
+ # carries its own article in practice ("the cook") -- so it is used as
413
+ # written rather than glued behind "The same", which produced "The same
414
+ # the cook continues".
415
+ name = (info.get("name") or "").strip()
416
+ if solo or not name:
417
+ who = "The same person"
418
+ else:
419
+ who = name[0].upper() + name[1:]
420
+ body = "; ".join(b.rstrip(".") for b in bits)
421
+ lines.append(f"{who} continues, with {body}.")
422
+ return " ".join(lines)
423
+
424
+
425
+ def describe(plan):
426
+ """Console-auditable summary, printed once at chain start."""
427
+ refs = plan.get("refs") or []
428
+ subjects = plan.get("subjects") or {}
429
+ if not refs:
430
+ return "(no ref plan)"
431
+ out = []
432
+ for num in sorted(subjects):
433
+ info = subjects[num]
434
+ label = info.get("name") or "(unnamed)"
435
+ out.append(f" <Subject {num}> {label}")
436
+ for r in refs:
437
+ subj = f"subject {r['subject']}" if r["subject"] else "setting"
438
+ when = "all shots" if r["shots"] is None else "shots " + ",".join(
439
+ str(s) for s in r["shots"])
440
+ src = r["file"] or (f"(was slot {r['legacy_slot']}, no file)"
441
+ if r["legacy_slot"] else "(no file)")
442
+ out.append(f" @{r['tag']:<16} {src:<28} {subj:<10} "
443
+ f"{r['retention']:<16} {when}")
444
+ return "\n".join(out)
445
+
446
+
447
+ def missing_files(plan, wired_slots):
448
+ """Refs that name a picture which did not load. Fatal, unlike `check`.
449
+
450
+ A ref with no `file` at all is a legitimate half-built plan -- the Starter
451
+ ships that way on purpose, so it runs before you have supplied anything. A
452
+ ref that *names* `ref_face.jpg` when no such file is in `h3_refs` is a typo
453
+ or a missing asset, and there is no reading of it under which rendering six
454
+ hops without that picture is what the author wanted: it produces exactly the
455
+ uncontrolled output the register exists to prevent, and the only signal used
456
+ to be one console line under the sampler output.
457
+
458
+ Returns a list of `(tag, file)`. The caller raises; this stays free of disk
459
+ access, like `check`.
460
+ """
461
+ out = []
462
+ for r in plan.get("refs") or []:
463
+ if r["slot"] in wired_slots or not r["file"]:
464
+ continue
465
+ out.append((r["tag"], r["file"]))
466
+ return out
467
+
468
+
469
+ def check(plan, wired_slots):
470
+ """Warn (never raise) about refs whose picture is missing or never chosen.
471
+
472
+ `wired_slots` is the set of derived slots whose file actually loaded, so
473
+ the caller does the resolving and this stays free of disk access.
474
+ """
475
+ refs = plan.get("refs") or []
476
+ subjects = plan.get("subjects") or {}
477
+ warnings = []
478
+ # A person with pictures but no continuity text still renders, but nothing
479
+ # carries their state across the seam -- which is the failure this register
480
+ # exists to fix, so it is worth naming.
481
+ for num in sorted({r["subject"] for r in refs if r["subject"] is not None}):
482
+ if num not in subjects:
483
+ warnings.append(
484
+ f"subject {num} has pictures but no continuity text. Add "
485
+ f"subjects.{num}.locked to carry their identity across seams.")
486
+ for r in refs:
487
+ if r["slot"] in wired_slots:
488
+ continue
489
+ if r["legacy_slot"]:
490
+ warnings.append(
491
+ f"@{r['tag']} was wired to ref_image_{r['legacy_slot']}, which no "
492
+ f"longer exists. Pick its picture in the REFERENCES rail -- this "
493
+ f"ref is inactive until you do.")
494
+ elif not r["file"]:
495
+ warnings.append(
496
+ f"@{r['tag']} has no picture chosen. This ref is inactive.")
497
+ else:
498
+ warnings.append(
499
+ f"@{r['tag']} names '{r['file']}', which is not in the reference "
500
+ f"folder or could not be read. This ref is inactive.")
501
+ return warnings
routes.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One read-only endpoint: the vocabulary the editor draws its dropdowns from.
2
+
3
+ The alternative is a copy of `VOCAB` in JavaScript, and a copy is a second source
4
+ of truth that goes stale the first time a phrase is improved. `directives.py`
5
+ exists precisely so that improving a sentence improves every plan ever written;
6
+ duplicating those strings in the UI would undo that.
7
+
8
+ It also ships the *prose* each option compiles to, not just the option names, so
9
+ the editor can show what a directive actually puts in front of the encoder. That
10
+ is the whole answer to "I don't know how any of this works at a glance".
11
+
12
+ Since 2026-08-28 this module also carries the reference *files*: `POST
13
+ /h3_ref_chain/upload` streams dropped media into `<input>/h3_refs`, and `GET
14
+ /h3_ref_chain/files` lists what is there. Those replaced the nine `ref_image_N`
15
+ IMAGE sockets, which were most of a 16-socket column.
16
+
17
+ Everything that decides *where bytes land* lives in `media.py`, not here: one
18
+ prefix-checked resolver used by both the route and the loaders, so there is a
19
+ single place to be wrong about it. The route itself never joins a path.
20
+ """
21
+
22
+ import os as _os
23
+
24
+ from . import directives as _d
25
+ from . import media as _media
26
+ from . import refs as _refs
27
+
28
+ TAG = "HandTieClips"
29
+ ROUTE = "/h3_ref_chain/vocab"
30
+ UPLOAD_ROUTE = "/h3_ref_chain/upload"
31
+ FILES_ROUTE = "/h3_ref_chain/files"
32
+
33
+ # A batch of stills is a handful; this is a guard against a runaway multipart
34
+ # body, not a considered product limit.
35
+ MAX_UPLOAD_FILES = 32
36
+ MAX_UPLOAD_BYTES = 256 * 1024 * 1024
37
+
38
+
39
+ def _payload():
40
+ from .h3_ref_chain import DURATION_FRAMES, OVERLAP_FRAMES, CANVAS, FPS
41
+
42
+ return {
43
+ # axis -> option -> the sentence it compiles to. Order matters: AXES is
44
+ # the order directive_prose concatenates in, so the UI shows them in the
45
+ # order the encoder reads them.
46
+ "axes": list(_d.AXES),
47
+ "vocab": {axis: dict(opts) for axis, opts in _d.VOCAB.items()},
48
+ "defaults": dict(_d.DEFAULTS),
49
+ "establish": _d.ESTABLISH,
50
+ # join has nothing to attach to on the first hop; the editor hides it
51
+ # there to match directive_prose's hop_index == 0 skip.
52
+ "join_axis": "join",
53
+
54
+ "retention": dict(_refs.RETENTION),
55
+ "max_ref_images": _refs.MAX_REF_IMAGES,
56
+ "refs_subdir": _media.REFS_SUBDIR,
57
+ "image_exts": sorted(_media.IMAGE_EXTS),
58
+ "video_exts": sorted(_media.VIDEO_EXTS),
59
+ "audio_exts": sorted(_media.AUDIO_EXTS),
60
+ "ref_fields": list(_refs.REF_FIELDS),
61
+ "subject_fields": list(_refs.SUBJECT_FIELDS),
62
+
63
+ "durations": {k: v for k, v in DURATION_FRAMES.items()},
64
+ "overlaps": {k: v for k, v in OVERLAP_FRAMES.items()},
65
+ "canvas": {res: {asp: list(wh) for asp, wh in by_asp.items()}
66
+ for res, by_asp in CANVAS.items()},
67
+ "fps": FPS,
68
+ }
69
+
70
+
71
+ def register():
72
+ """Attach the route if a PromptServer exists. Never raises on import."""
73
+ try:
74
+ from aiohttp import web
75
+ from server import PromptServer
76
+ except ImportError:
77
+ return False
78
+ instance = getattr(PromptServer, "instance", None)
79
+ if instance is None or not hasattr(instance, "routes"):
80
+ return False
81
+
82
+ @instance.routes.get(ROUTE)
83
+ async def _vocab(_request):
84
+ try:
85
+ return web.json_response(_payload())
86
+ except Exception as exc: # a broken payload must not take the server down
87
+ print(f"[{TAG}] vocab route failed: {exc!r}", flush=True)
88
+ return web.json_response({"error": str(exc)}, status=500)
89
+
90
+ @instance.routes.get(FILES_ROUTE)
91
+ async def _files(request):
92
+ """What is already in the reference folder, for the editor's picker."""
93
+ try:
94
+ kinds = request.rel_url.query.get("kinds") or ""
95
+ want = {k.strip() for k in kinds.split(",") if k.strip()} or None
96
+ return web.json_response({"ok": True, "files": _media.listing(want)})
97
+ except Exception as exc:
98
+ print(f"[{TAG}] files route failed: {exc!r}", flush=True)
99
+ return web.json_response({"ok": False, "error": str(exc)}, status=500)
100
+
101
+ @instance.routes.post(UPLOAD_ROUTE)
102
+ async def _upload(request):
103
+ """Stream dropped media into the reference folder.
104
+
105
+ Multipart, not JSON+base64: a batch of stills should not be inflated by
106
+ a third and held in memory twice on the way through the browser. The
107
+ destination is fixed -- `media.refs_dir()` -- so a filename can only
108
+ ever name a file *inside* it, and `media.resolve` re-checks that on the
109
+ way back out.
110
+ """
111
+ if not (request.content_type or "").startswith("multipart/"):
112
+ return web.json_response(
113
+ {"ok": False, "error": "expected multipart/form-data"}, status=400)
114
+ try:
115
+ reader = await request.multipart()
116
+ except Exception as exc:
117
+ return web.json_response(
118
+ {"ok": False, "error": f"bad multipart: {exc}"}, status=400)
119
+
120
+ dest = _media.refs_dir(create=True)
121
+ saved, skipped, total = [], [], 0
122
+ try:
123
+ while True:
124
+ part = await reader.next()
125
+ if part is None:
126
+ break
127
+ if not getattr(part, "filename", None):
128
+ continue
129
+ if len(saved) >= MAX_UPLOAD_FILES:
130
+ skipped.append(f"{part.filename}: batch limit "
131
+ f"{MAX_UPLOAD_FILES} reached")
132
+ break
133
+ # Basename only, and the extension has to be one we can open.
134
+ # Rejecting here means nothing unreadable is ever written.
135
+ name = _os.path.basename(part.filename or "")
136
+ kind = _media.kind_of(name)
137
+ if kind is None:
138
+ skipped.append(f"{name}: not an image, video or audio file")
139
+ continue
140
+ path = _media.unique_path(dest, name)
141
+ size = 0
142
+ try:
143
+ with open(path, "wb") as fh:
144
+ while True:
145
+ chunk = await part.read_chunk()
146
+ if not chunk:
147
+ break
148
+ size += len(chunk)
149
+ total += len(chunk)
150
+ if total > MAX_UPLOAD_BYTES:
151
+ raise ValueError("upload too large")
152
+ fh.write(chunk)
153
+ except Exception:
154
+ # A partial file is worse than no file: it would list in
155
+ # the picker and fail to open.
156
+ try:
157
+ _os.remove(path)
158
+ except OSError:
159
+ pass
160
+ raise
161
+ w = h = 0
162
+ if kind == "image":
163
+ path, w, h = _media.shrink_image(path)
164
+ saved.append({"name": _os.path.basename(path), "kind": kind,
165
+ "width": w, "height": h, "bytes": size})
166
+ except Exception as exc:
167
+ print(f"[{TAG}] upload failed: {exc!r}", flush=True)
168
+ return web.json_response(
169
+ {"ok": False, "error": str(exc), "files": saved}, status=400)
170
+
171
+ if saved:
172
+ print(f"[{TAG}] uploaded {len(saved)} reference file(s) -> "
173
+ + ", ".join(f["name"] for f in saved), flush=True)
174
+ for note in skipped:
175
+ print(f"[{TAG}] upload skipped {note}", flush=True)
176
+ return web.json_response({"ok": True, "files": saved, "skipped": skipped})
177
+
178
+ return True
store.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Disk-backed hop store: resume, single-shot re-roll, and lower peak RAM.
2
+
3
+ Hops are causally dependent -- hop N is rendered from hop N-1's tail -- so the
4
+ cache key **chains**: each hop's key mixes in the previous hop's key. Editing
5
+ shot 1 therefore invalidates 2..N automatically, which is correct and must be
6
+ surfaced in the UI, because otherwise it reads as a bug.
7
+
8
+ **Why 16-bit.** A cached hop's last frame becomes the next hop's Qwen pin and
9
+ its AddGuide guide. Round-tripping float32 through 8-bit would make a resumed
10
+ chain diverge from an uninterrupted one -- the cache would change the output,
11
+ which defeats the point. FFV1 at `rgb48le` keeps ~16 bits per channel, which is
12
+ far below the VAE's own noise floor, so a resumed hop is indistinguishable from
13
+ a fresh one. The cost is roughly 2x the bytes of an 8-bit lossless encode, and
14
+ FFV1 still compresses it well.
15
+
16
+ **On the RAM claim.** The node's IMAGE output is the whole clip, so the final
17
+ tensor is unavoidably full size. What the store removes is the *double and
18
+ triple buffering* during the loop: today `master_imgs` grows by concatenation
19
+ (which allocates a new full-size tensor every hop) while `prev_imgs` and `imgs`
20
+ are also live. Streaming to disk keeps one hop plus the overlap tail resident
21
+ and concatenates once at the end.
22
+ """
23
+
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+ import time
30
+
31
+ import numpy as np
32
+ import torch
33
+
34
+ TAG = "HandTieClips"
35
+
36
+ VIDEO_EXT = ".mkv"
37
+ AUDIO_EXT = ".npy"
38
+ META_EXT = ".json"
39
+ LATENT_EXT = ".latent.pt"
40
+
41
+
42
+ def _ffmpeg():
43
+ exe = shutil.which("ffmpeg")
44
+ if not exe:
45
+ raise RuntimeError(
46
+ f"{TAG}: ffmpeg is not on PATH. The hop store needs it to write "
47
+ f"lossless FFV1. Install ffmpeg or set cache to off."
48
+ )
49
+ return exe
50
+
51
+
52
+ def tensor_digest(t):
53
+ """Cheap, order-sensitive digest of a tensor's actual bytes."""
54
+ if t is None:
55
+ return "none"
56
+ a = t.detach().cpu().contiguous().numpy()
57
+ h = hashlib.sha256()
58
+ h.update(str(a.shape).encode())
59
+ h.update(str(a.dtype).encode())
60
+ h.update(a.tobytes())
61
+ return h.hexdigest()[:16]
62
+
63
+
64
+ def audio_digest(a):
65
+ """Digest an AUDIO input -- ``{"waveform": Tensor, "sample_rate": int}``.
66
+
67
+ AUDIO is a dict, not a tensor, so `tensor_digest` cannot take it: `.detach`
68
+ on a dict raises AttributeError, which is what wiring `voice` with the hop
69
+ cache on used to do before hop 1 ever started. Sample rate is part of the
70
+ identity -- the same waveform at a different rate is different audio.
71
+ """
72
+ if a is None:
73
+ return "none"
74
+ if not isinstance(a, dict):
75
+ return tensor_digest(a)
76
+ h = hashlib.sha256()
77
+ h.update(tensor_digest(a.get("waveform")).encode())
78
+ h.update(str(a.get("sample_rate")).encode())
79
+ return h.hexdigest()[:16]
80
+
81
+
82
+ def hop_key(prev_key, payload):
83
+ """Chained content key. `payload` must contain everything that changes pixels.
84
+
85
+ Anything omitted here is something the cache will fail to notice, so err
86
+ toward including it.
87
+ """
88
+ h = hashlib.sha256()
89
+ h.update((prev_key or "root").encode())
90
+ h.update(json.dumps(payload, sort_keys=True, default=str).encode())
91
+ return h.hexdigest()[:24]
92
+
93
+
94
+ class HopStore:
95
+ def __init__(self, root, budget_gb=20.0, fps=24):
96
+ self.root = str(root)
97
+ self.budget = float(budget_gb) * (1024 ** 3)
98
+ self.fps = int(fps)
99
+ os.makedirs(self.root, exist_ok=True)
100
+
101
+ # -- paths ------------------------------------------------------------
102
+ def _p(self, key, ext):
103
+ return os.path.join(self.root, key + ext)
104
+
105
+ def has(self, key):
106
+ return all(os.path.exists(self._p(key, e))
107
+ for e in (VIDEO_EXT, AUDIO_EXT, META_EXT))
108
+
109
+ # -- write ------------------------------------------------------------
110
+ def put(self, key, imgs, wav, sr, meta=None, latent=None):
111
+ """imgs: float [N,H,W,3] in 0..1 on cpu. wav: float [.., C, S].
112
+
113
+ `latent` is this hop's sampler output and is optional; see the comment
114
+ at the write below for why storing it is what makes the cache useful
115
+ past hop 1.
116
+ """
117
+ n, hgt, wid = int(imgs.shape[0]), int(imgs.shape[1]), int(imgs.shape[2])
118
+ vid_tmp = self._p(key, VIDEO_EXT + ".part")
119
+ cmd = [
120
+ _ffmpeg(), "-y", "-v", "error",
121
+ "-f", "rawvideo", "-pix_fmt", "rgb48le",
122
+ "-s", f"{wid}x{hgt}", "-r", str(self.fps),
123
+ "-i", "-",
124
+ "-c:v", "ffv1", "-level", "3", "-coder", "1", "-context", "1",
125
+ "-pix_fmt", "rgb48le",
126
+ # The .part suffix defeats extension-based format detection, so the
127
+ # muxer is named explicitly. Writing to .part and renaming on success
128
+ # keeps a killed render from leaving a half-file that `has()` trusts.
129
+ "-f", "matroska", vid_tmp,
130
+ ]
131
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
132
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
133
+ try:
134
+ # Frame at a time: never materialise a second full-size copy.
135
+ for i in range(n):
136
+ f = (imgs[i].clamp(0, 1) * 65535.0).round().to(torch.int32)
137
+ proc.stdin.write(f.numpy().astype("<u2").tobytes())
138
+ proc.stdin.close()
139
+ except BrokenPipeError:
140
+ pass
141
+ err = proc.stderr.read().decode(errors="replace")
142
+ if proc.wait() != 0:
143
+ raise RuntimeError(f"{TAG}: FFV1 encode failed for hop {key}: {err.strip()}")
144
+ os.replace(vid_tmp, self._p(key, VIDEO_EXT))
145
+
146
+ np.save(self._p(key, AUDIO_EXT),
147
+ wav.detach().cpu().contiguous().numpy().astype(np.float32))
148
+
149
+ # The sampler latent, not just the decoded frames. Without it a cache
150
+ # hit leaves the caller's `prev_sampled` empty, so the *next* hop
151
+ # predicts the AddGuide pixel fallback instead of Motion-Context --
152
+ # which is part of its key, so that key stops matching what was stored.
153
+ # The cache could therefore never hit past hop 1, and the hop after a
154
+ # hit was joined by the inferior mechanism, making `cache_hops=on`
155
+ # actively worse than off. Optional and best-effort: a hop whose latent
156
+ # will not serialise is still worth caching for its frames, it just
157
+ # cannot seed a latent join.
158
+ if latent is not None:
159
+ tmp = self._p(key, LATENT_EXT + ".part")
160
+ try:
161
+ torch.save(latent, tmp)
162
+ os.replace(tmp, self._p(key, LATENT_EXT))
163
+ except Exception as e: # noqa: BLE001
164
+ print(f"[{TAG}] hop {key[:8]}: latent not cached ({e!r}); a hit "
165
+ f"on this hop will fall back to the pixel pin", flush=True)
166
+ try:
167
+ os.remove(tmp)
168
+ except OSError:
169
+ pass
170
+
171
+ info = dict(meta or {})
172
+ info.update({"frames": n, "height": hgt, "width": wid,
173
+ "sample_rate": int(sr), "fps": self.fps,
174
+ "written": time.time()})
175
+ with open(self._p(key, META_EXT), "w", encoding="utf-8") as fh:
176
+ json.dump(info, fh, indent=1)
177
+ return info
178
+
179
+ # -- read -------------------------------------------------------------
180
+ def _get_latent(self, key):
181
+ """The stored sampler latent, or None when this entry has none.
182
+
183
+ None is always safe: the caller falls back to the pixel pin, which is
184
+ what every cache hit did before latents were stored. Entries written by
185
+ an older build simply have no sidecar, so they keep working.
186
+ """
187
+ path = self._p(key, LATENT_EXT)
188
+ if not os.path.exists(path):
189
+ return None
190
+ try:
191
+ # weights_only=False: this is a latent *dict*, written by our own
192
+ # put() into ComfyUI's temp directory, not a downloaded checkpoint.
193
+ return torch.load(path, map_location="cpu", weights_only=False)
194
+ except Exception as e: # noqa: BLE001
195
+ print(f"[{TAG}] hop {key[:8]}: cached latent unreadable ({e!r}); "
196
+ f"falling back to the pixel pin", flush=True)
197
+ return None
198
+
199
+ def get(self, key):
200
+ """-> (imgs float32 [N,H,W,3], wav, sample_rate, latent|None) or None."""
201
+ if not self.has(key):
202
+ return None
203
+ with open(self._p(key, META_EXT), encoding="utf-8") as fh:
204
+ info = json.load(fh)
205
+ n, hgt, wid = int(info["frames"]), int(info["height"]), int(info["width"])
206
+ cmd = [
207
+ _ffmpeg(), "-v", "error", "-i", self._p(key, VIDEO_EXT),
208
+ "-f", "rawvideo", "-pix_fmt", "rgb48le", "-",
209
+ ]
210
+ want = n * hgt * wid * 3 * 2
211
+ # communicate(), not sequential reads: draining stdout to EOF while
212
+ # stderr is an unread pipe deadlocks the moment ffmpeg emits more than
213
+ # the pipe buffer on stderr -- which is exactly what a corrupt FFV1
214
+ # does, i.e. the one case where the error actually matters.
215
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
216
+ raw, err_raw = proc.communicate()
217
+ err = err_raw.decode(errors="replace")
218
+ if proc.returncode != 0:
219
+ raise RuntimeError(f"{TAG}: FFV1 decode failed for hop {key}: {err.strip()}")
220
+ if len(raw) != want:
221
+ raise RuntimeError(
222
+ f"{TAG}: cached hop {key} is {len(raw)} bytes, expected {want}. "
223
+ f"Delete the cache entry and re-render.")
224
+ arr = np.frombuffer(raw, dtype="<u2").reshape(n, hgt, wid, 3)
225
+ imgs = torch.from_numpy(arr.astype(np.float32) / 65535.0)
226
+ wav = torch.from_numpy(np.load(self._p(key, AUDIO_EXT)))
227
+ os.utime(self._p(key, VIDEO_EXT), None) # LRU touch
228
+ return imgs, wav, int(info["sample_rate"]), self._get_latent(key)
229
+
230
+ # -- shot locks -------------------------------------------------------
231
+ # A locked shot reuses whatever it last rendered even when its inputs
232
+ # change, so it needs a name that survives an edit -- the content key by
233
+ # definition does not. The pointer maps a stable shot name to the key that
234
+ # shot last produced.
235
+ def _ptr(self, name):
236
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(name))
237
+ return os.path.join(self.root, "ptr_" + safe + ".json")
238
+
239
+ def set_pointer(self, name, key):
240
+ with open(self._ptr(name), "w", encoding="utf-8") as fh:
241
+ json.dump({"key": key, "written": time.time()}, fh)
242
+
243
+ def get_pointer(self, name):
244
+ try:
245
+ with open(self._ptr(name), encoding="utf-8") as fh:
246
+ key = json.load(fh).get("key")
247
+ except (OSError, ValueError):
248
+ return None
249
+ return key if key and self.has(key) else None
250
+
251
+ # -- housekeeping -----------------------------------------------------
252
+ def entries(self):
253
+ out = []
254
+ for fn in os.listdir(self.root):
255
+ if not fn.endswith(VIDEO_EXT):
256
+ continue
257
+ key = fn[: -len(VIDEO_EXT)]
258
+ try:
259
+ st = os.stat(self._p(key, VIDEO_EXT))
260
+ except OSError:
261
+ continue
262
+ size = st.st_size
263
+ for e in (AUDIO_EXT, META_EXT, LATENT_EXT):
264
+ try:
265
+ size += os.path.getsize(self._p(key, e))
266
+ except OSError:
267
+ pass
268
+ out.append((key, size, st.st_mtime))
269
+ return out
270
+
271
+ def sweep(self, keep=()):
272
+ """Evict least-recently-used entries until under budget.
273
+
274
+ `keep` names hops this run still needs, so a budget smaller than one
275
+ chain cannot delete the hop that is about to be read back.
276
+ """
277
+ items = self.entries()
278
+ total = sum(s for _, s, _ in items)
279
+ if total <= self.budget:
280
+ return 0
281
+ keep = set(keep)
282
+ freed = 0
283
+ for key, size, _ in sorted(items, key=lambda r: r[2]):
284
+ if total - freed <= self.budget:
285
+ break
286
+ if key in keep:
287
+ continue
288
+ for e in (VIDEO_EXT, AUDIO_EXT, META_EXT, LATENT_EXT):
289
+ try:
290
+ os.remove(self._p(key, e))
291
+ except OSError:
292
+ pass
293
+ freed += size
294
+ if freed:
295
+ print(f"[{TAG}] cache: evicted {freed / 1024 ** 3:.2f} GB "
296
+ f"(budget {self.budget / 1024 ** 3:.1f} GB)", flush=True)
297
+ return freed
tone.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tone compensation for chained H3 segments.
2
+
3
+ The H3 denoiser applies a tone bias to each generated segment, which shows up as
4
+ a brightness step at the seam between hops. This module estimates that bias on
5
+ the overlap -- the segment's first frames against the source frames they
6
+ reconstruct -- and undoes it across the whole segment.
7
+
8
+ The estimator, and the reasoning behind the three modes, is ported from
9
+ `rkfg/ComfyUI-MiniMaxH3-ToneCompensate` (MIT). This pack is MIT too, so the code
10
+ travels; the credit does not travel by itself, hence this paragraph. Changes
11
+ from upstream: the overlap default is our 22 frames rather than his 48, `"off"`
12
+ is a real mode so callers need no branch, and the math is split from the node so
13
+ it can be tested without a running server (same reason `plan.py` and `refs.py`
14
+ have no ComfyUI imports).
15
+
16
+ The three modes, most to least specific:
17
+
18
+ - frame_shift: per-frame per-channel additive shift. The target's first frames
19
+ are the model's regeneration of the source (similar content, not a pixel-wise
20
+ transform), so the bias is best captured as a per-frame shift of the mean.
21
+ Matches the overlap exactly and applies the last overlap frame's shift to the
22
+ continuation (no seam).
23
+ - gain_bias: global per-channel affine s = A*g + C. Robust, extrapolates
24
+ cleanly; good when the drift is a roughly uniform shift/compression.
25
+ - lut: per-pixel piecewise-linear tone curve. Captures nonlinear drift;
26
+ flexible, but overfits when the target is regenerated content (pixels differ
27
+ from the source).
28
+
29
+ Alignment: source's last `overlap` frames are paired with target's first
30
+ `overlap` frames, so the whole previous segment can be passed as `source` and it
31
+ auto-crops to the tail.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import torch
36
+
37
+ TAG = "HTCTone"
38
+ MODES = ["off", "frame_shift", "gain_bias", "lut"]
39
+
40
+ _TABLE = 4096 # dense LUT resolution used when applying the lut mode
41
+
42
+ # Our native overlap: 0.9 s at 24 fps, the H3 continuation length. Upstream
43
+ # defaults to 48 (2 s) because that is what his workflow pins with.
44
+ DEFAULT_OVERLAP = 22
45
+
46
+
47
+ def _fit_affine(src, tgt):
48
+ """Regress source on generated per channel: s = A*g + C. Return (A, C) as [1,1,1,C]."""
49
+ c_out = src.shape[-1]
50
+ gain = torch.ones(1, 1, 1, c_out, dtype=torch.float32, device=src.device)
51
+ bias = torch.zeros(1, 1, 1, c_out, dtype=torch.float32, device=src.device)
52
+ for c in range(c_out):
53
+ s = src[..., c].reshape(-1).float()
54
+ g = tgt[..., c].reshape(-1).float()
55
+ gm = g.mean()
56
+ sm = s.mean()
57
+ dg = g - gm
58
+ den = (dg * dg).sum()
59
+ if den < 1e-12:
60
+ A, C = 1.0, float(sm - gm)
61
+ else:
62
+ A = float((dg * (s - sm)).sum() / den)
63
+ C = float(sm - A * gm)
64
+ if abs(A) < 1e-6:
65
+ A = 1.0
66
+ gain[0, 0, 0, c] = A
67
+ bias[0, 0, 0, c] = C
68
+ return gain, bias
69
+
70
+
71
+ def _monotone(ys):
72
+ """Make ys non-decreasing (guards against bin-mean inversions from noise)."""
73
+ v = ys.tolist()
74
+ best = v[0]
75
+ out = []
76
+ for y in v:
77
+ if y > best:
78
+ best = y
79
+ out.append(best)
80
+ return torch.tensor(out, dtype=torch.float32, device=ys.device)
81
+
82
+
83
+ def _lut_control(s, g, bins):
84
+ """Build per-channel control points from paired pixels.
85
+
86
+ For each generated-value bin that actually occurs, store (mean generated,
87
+ mean source). Using the means as x keeps boundary segments exact -- bin
88
+ centres would skew the outer slopes. Returns sorted (xs, ys).
89
+ """
90
+ dev = g.device
91
+ idx = torch.clamp(torch.floor(g * bins), 0, bins - 1).long()
92
+ sums_s = torch.zeros(bins, dtype=torch.float32, device=dev)
93
+ sums_g = torch.zeros(bins, dtype=torch.float32, device=dev)
94
+ counts = torch.zeros(bins, dtype=torch.float32, device=dev)
95
+ sums_s.index_add_(0, idx, s)
96
+ sums_g.index_add_(0, idx, g)
97
+ counts.index_add_(0, idx, torch.ones_like(idx, dtype=torch.float32))
98
+ nz = counts > 0
99
+ xs = (sums_g / counts)[nz]
100
+ ys = (sums_s / counts)[nz]
101
+ return xs, _monotone(ys)
102
+
103
+
104
+ def _linfit(x, y):
105
+ """Least-squares line y = slope*x + intercept. Return (slope, intercept)."""
106
+ xm, ym = x.mean(), y.mean()
107
+ dx = x - xm
108
+ den = (dx * dx).sum()
109
+ if den < 1e-12:
110
+ return 0.0, ym.item()
111
+ slope = float((dx * (y - ym)).sum() / den)
112
+ return slope, float(ym - slope * xm)
113
+
114
+
115
+ def _pwl(query, xs, ys):
116
+ """Piecewise-linear evaluation of the (xs, ys) control points at query.
117
+
118
+ The interior is interpolated; the ends extrapolate with a robust slope
119
+ (least-squares on the outermost K points) so a single noisy boundary bin
120
+ cannot skew the extrapolation.
121
+ """
122
+ n = xs.numel()
123
+ if n == 1:
124
+ return torch.full_like(query, ys.item())
125
+ k = min(5, n)
126
+ ls, lb = _linfit(xs[:k], ys[:k])
127
+ rs, rb = _linfit(xs[-k:], ys[-k:])
128
+ i = torch.clamp(torch.searchsorted(xs, query), 1, n - 1)
129
+ xl, xr = xs[i - 1], xs[i]
130
+ yl, yr = ys[i - 1], ys[i]
131
+ out = yl + (yr - yl) * (query - xl) / (xr - xl)
132
+ left, right = query < xs[0], query > xs[-1]
133
+ out = torch.where(left, ls * query + lb, out)
134
+ out = torch.where(right, rs * query + rb, out)
135
+ return out
136
+
137
+
138
+ def _apply_lut(x, xs, ys, table=_TABLE):
139
+ """Apply a per-channel control LUT to x (values in [0,1])."""
140
+ dense = _pwl(torch.linspace(0, 1, table, device=x.device), xs, ys)
141
+ idx = torch.clamp(torch.floor(x * table), 0, table - 1).long()
142
+ return dense[idx]
143
+
144
+
145
+ def compensate(source, target, mode, overlap=DEFAULT_OVERLAP, lut_bins=64):
146
+ """Correct `target`'s tone to match `source`. -> (images, note).
147
+
148
+ `source` is the previous segment (or just its tail); `target` is the whole
149
+ generated segment. Both float [N,H,W,3] in 0..1. Returns the corrected
150
+ target and a short human-readable note for the log, or (target, "") when
151
+ there is nothing to do.
152
+
153
+ `mode="off"` returns `target` untouched, so a caller can pass the widget
154
+ value straight through without branching on it. Anything unrecognised is
155
+ treated the same way rather than raising: a bad mode should not lose a
156
+ render that has already been sampled.
157
+ """
158
+ if mode is None or str(mode) == "off" or str(mode) not in MODES:
159
+ return target, ""
160
+ if source is None or target is None:
161
+ return target, ""
162
+
163
+ src = source.float()
164
+ tgt = target.float()
165
+ n = min(int(overlap), int(src.shape[0]), int(tgt.shape[0]))
166
+ if n <= 0:
167
+ return target, ""
168
+ fit_src = src[-n:]
169
+ fit_tgt = tgt[:n]
170
+
171
+ mode = str(mode)
172
+ if mode == "frame_shift":
173
+ # Per-frame per-channel drift (mean target - mean source), applied
174
+ # per-frame on the overlap and as the last overlap frame's value on the
175
+ # continuation -- which is what makes the seam itself exact.
176
+ drift = fit_tgt.mean(dim=(1, 2), keepdim=True) - fit_src.mean(dim=(1, 2), keepdim=True)
177
+ out = tgt.clone()
178
+ out[:n] = out[:n] - drift
179
+ out[n:] = out[n:] - drift[-1]
180
+ d = drift[-1].reshape(-1)
181
+ note = ("frame_shift " + " ".join(f"{c}{v:+.4f}" for c, v in zip("rgb", d.tolist())))
182
+ elif mode == "gain_bias":
183
+ gain, bias = _fit_affine(fit_src, fit_tgt)
184
+ out = gain * tgt + bias
185
+ g = gain.reshape(-1).tolist()
186
+ b = bias.reshape(-1).tolist()
187
+ note = ("gain_bias " + " ".join(f"{c}x{gv:.4f}{bv:+.4f}"
188
+ for c, gv, bv in zip("rgb", g, b)))
189
+ else: # lut
190
+ out = torch.empty_like(tgt)
191
+ for c in range(tgt.shape[-1]):
192
+ xs, ys = _lut_control(fit_src[..., c].reshape(-1),
193
+ fit_tgt[..., c].reshape(-1), int(lut_bins))
194
+ out[..., c] = _apply_lut(tgt[..., c], xs, ys)
195
+ before = float(tgt.mean())
196
+ after = float(out.mean())
197
+ note = f"lut mean {before:.4f} -> {after:.4f} ({int(lut_bins)} bins)"
198
+
199
+ out = out.clamp_(0.0, 1.0).to(target.dtype)
200
+ return out, note
201
+
202
+
203
+ class HTCToneCompensate:
204
+ """Undo the denoiser's tone bias on a generated H3 segment.
205
+
206
+ Wire it between two hand-chained H3 generations: `source` is the previous
207
+ segment, `target` the one to correct.
208
+
209
+ Note this cannot do the same job downstream of `HandTieClips`. That node joins
210
+ its hops internally and drops each hop's first `overlap` frames at the seam,
211
+ so the regenerated copies this estimator needs no longer exist by the time
212
+ images leave it. Use the chain node's own `tone_compensate` widget for that;
213
+ this node is for hand-built chains, and for A/B-ing the correction.
214
+ """
215
+
216
+ @classmethod
217
+ def INPUT_TYPES(cls):
218
+ return {
219
+ "required": {
220
+ "source": ("IMAGE", {"tooltip": "The previous segment. The whole thing is fine -- only its last `overlap` frames are read."}),
221
+ "target": ("IMAGE", {"tooltip": "The generated segment to correct."}),
222
+ "mode": (MODES, {
223
+ "default": "frame_shift",
224
+ "tooltip": (
225
+ "frame_shift: per-frame additive shift; best when the target is "
226
+ "regenerated content, which it is here. gain_bias: global affine, "
227
+ "robust. lut: tone curve, captures nonlinear drift but overfits."
228
+ ),
229
+ }),
230
+ "overlap": ("INT", {
231
+ "default": DEFAULT_OVERLAP, "min": 1, "max": 4096,
232
+ "tooltip": (
233
+ "Number of keyframe frames: last `overlap` of source vs first "
234
+ "`overlap` of target. Must equal the keyframe count used for "
235
+ "generation (22 = 0.9 s @ 24 fps), NOT the whole segment."
236
+ ),
237
+ }),
238
+ "lut_bins": ("INT", {
239
+ "default": 64, "min": 16, "max": 512,
240
+ "tooltip": "Aggregation bins for lut mode. Ignored otherwise.",
241
+ }),
242
+ }
243
+ }
244
+
245
+ RETURN_TYPES = ("IMAGE",)
246
+ RETURN_NAMES = ("images",)
247
+ FUNCTION = "run"
248
+ CATEGORY = "Hand Tie Clips"
249
+
250
+ def run(self, source, target, mode, overlap, lut_bins):
251
+ out, note = compensate(source, target, mode, overlap, lut_bins)
252
+ if note:
253
+ print(f"[{TAG}] {note}", flush=True)
254
+ return (out,)
255
+
256
+
257
+
258
+
259
+ # -- pre-rename ids ----------------------------------------------------------
260
+ # A plain alias in NODE_CLASS_MAPPINGS keeps old workflows loading, but it also
261
+ # lists the node a second time in search: ComfyUI falls back to the mapping key
262
+ # when NODE_DISPLAY_NAME_MAPPINGS has no entry. Subclassing and setting
263
+ # DEPRECATED gets both -- server.py publishes `deprecated: True`, and the
264
+ # frontend's `Comfy.Node.ShowDeprecated` (off by default) hides it from search
265
+ # while leaving it fully functional in workflows that name it.
266
+
267
+
268
+ class _LegacyH3ToneCompensate(HTCToneCompensate):
269
+ DEPRECATED = True
270
+
271
+
272
+ NODE_CLASS_MAPPINGS = {
273
+ "HTCToneCompensate": HTCToneCompensate,
274
+ "H3ToneCompensate": _LegacyH3ToneCompensate,
275
+ }
276
+ NODE_DISPLAY_NAME_MAPPINGS = {"HTCToneCompensate": "H3 Tone Compensate"}
tools/build_notes.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Write the instruction board into the Starter workflow, in place.
2
+
3
+ Idempotent: cards are identified by `properties.htc_card`, so re-running
4
+ replaces the board rather than stacking a second copy beside it. Operates on the
5
+ shipped `.json` itself rather than rebuilding from a private source workflow, so
6
+ anyone with the pack can run it.
7
+
8
+ It also strips `widgets_values_named` from every node. Both shipped workflows
9
+ carried a stale copy inherited from the dev workflow they were cloned out of --
10
+ `chains: 3`, `duration: 10 s`, `control_after_generate: randomize`, and a legacy
11
+ `ref_plan` naming pictures that do not ship, 25 entries against a 28-widget node.
12
+ It is dormant while `Comfy.Workflow.NamedValuesRestore` stays off (experimental,
13
+ default false), but anyone who turns that on would load a Starter that randomizes
14
+ its seed and dies on a missing reference. The frontend re-emits the block
15
+ correctly on the next save.
16
+ """
17
+ import io
18
+ import json
19
+ import os
20
+ import shutil
21
+ import sys
22
+
23
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ import notes # noqa: E402
27
+
28
+ WF = os.path.join(HERE, "workflows", "HandTieClips_Starter.json")
29
+ # The board is Starter-only, but the stale-named-values bug is in both shipped
30
+ # workflows, so the strip runs over both.
31
+ ALSO_STRIP = [os.path.join(HERE, "workflows", "HandTieClips_Showcase.json")]
32
+ MARKER = "htc_card"
33
+ FIRST_ID = 20
34
+
35
+
36
+ def strip_named(path):
37
+ """Drop `widgets_values_named`, and refresh the Showcase's plain Note.
38
+
39
+ The Showcase carries one `Note` rather than the card board; its text lives
40
+ in `notes.SHOWCASE_NOTE` so there is still a single source for it.
41
+ """
42
+ wf = json.load(io.open(path, encoding="utf-8"))
43
+ n = sum(1 for node in wf["nodes"]
44
+ if node.pop("widgets_values_named", None) is not None)
45
+ for node in wf["nodes"]:
46
+ if node["type"] == "Note" and node.get("widgets_values") != [notes.SHOWCASE_NOTE]:
47
+ node["widgets_values"] = [notes.SHOWCASE_NOTE]
48
+ # The dependency block pushed it past what 400x700 shows without
49
+ # scrolling.
50
+ node["size"] = [440, 900]
51
+ n += 1
52
+ if n:
53
+ bak = path + ".bak-notes"
54
+ if not os.path.exists(bak):
55
+ shutil.copyfile(path, bak)
56
+ io.open(path, "w", encoding="utf-8", newline="\n").write(
57
+ json.dumps(wf, indent=2) + "\n")
58
+ return n
59
+
60
+
61
+ def main():
62
+ wf = json.load(io.open(WF, encoding="utf-8"))
63
+
64
+ bak = WF + ".bak-notes"
65
+ if not os.path.exists(bak):
66
+ shutil.copyfile(WF, bak)
67
+
68
+ # Out with the old board, and with the single legacy `Note` whose text the
69
+ # cards now carry between them.
70
+ before = len(wf["nodes"])
71
+ wf["nodes"] = [n for n in wf["nodes"]
72
+ if MARKER not in (n.get("properties") or {})
73
+ and n["type"] != "Note"]
74
+ dropped = before - len(wf["nodes"])
75
+
76
+ order = max((n.get("order", 0) for n in wf["nodes"]), default=0)
77
+ for i, (key, title, pos, size, colour, text) in enumerate(notes.CARDS):
78
+ order += 1
79
+ wf["nodes"].append({
80
+ "id": FIRST_ID + i,
81
+ "type": "MarkdownNote",
82
+ "pos": list(pos),
83
+ "size": list(size),
84
+ "flags": {},
85
+ "order": order,
86
+ "mode": 0,
87
+ "inputs": [],
88
+ "outputs": [],
89
+ "title": title,
90
+ "properties": {MARKER: key},
91
+ "widgets_values": [text],
92
+ "color": colour[0],
93
+ "bgcolor": colour[1],
94
+ })
95
+
96
+ wf["groups"] = [dict(notes.GROUP)]
97
+ wf["extra"]["ds"] = dict(notes.DS)
98
+ wf["last_node_id"] = max(wf["last_node_id"], FIRST_ID + len(notes.CARDS) - 1)
99
+
100
+ stripped = 0
101
+ for n in wf["nodes"]:
102
+ if n.pop("widgets_values_named", None) is not None:
103
+ stripped += 1
104
+
105
+ io.open(WF, "w", encoding="utf-8", newline="\n").write(
106
+ json.dumps(wf, indent=2) + "\n")
107
+
108
+ print("%s\n dropped %d old note/card node(s), wrote %d card(s)"
109
+ % (os.path.basename(WF), dropped, len(notes.CARDS)))
110
+ print(" stripped widgets_values_named from %d node(s)" % stripped)
111
+ print(" %d nodes, %d bytes" % (len(wf["nodes"]), os.path.getsize(WF)))
112
+
113
+ for path in ALSO_STRIP:
114
+ print("%s\n stripped widgets_values_named from %d node(s)"
115
+ % (os.path.basename(path), strip_named(path)))
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
tools/build_speed_stack.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wire the turbo stack onto both shipped workflows, matching the dev graph.
2
+
3
+ UNETLoader -> LoRA Loader Stack -> H3 AdaLN LoRA Fix
4
+ -> MiniMax H3 Low VRAM Attention
5
+ -> H3 SLA Attention
6
+ -> Model Preview Override (KJ) -> Hand Tie Clips
7
+
8
+ and, off the same LoRA loader, **CLIP goes to the chain from the loader, not
9
+ from the encoder** -- that is what makes the text half of every LoRA land.
10
+
11
+ None of these five nodes belong to this pack. They are here because this is the
12
+ graph the node is actually run with: `steps` is 7, which only works with a turbo
13
+ LoRA, and the AdaLN fix exists because the LoRA needs it. Shipping the examples
14
+ without them ships a graph nobody uses. Every dependency is named on the START
15
+ HERE card, in the Showcase note and in the README.
16
+
17
+ Idempotent: inserted nodes carry `properties.htc_speed`, so a re-run rewires
18
+ rather than stacking a second copy. Widget values are POSITIONAL (CLAUDE.md) and
19
+ are copied from the proven dev workflow rather than reconstructed.
20
+ """
21
+ import io
22
+ import json
23
+ import os
24
+ import shutil
25
+
26
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ WORKFLOWS = ["HandTieClips_Starter.json", "HandTieClips_Showcase.json"]
28
+ MARKER = "htc_speed"
29
+
30
+ TURBO_LORA = "minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors"
31
+ LORA_STACK = json.dumps([{"on": True, "lora": TURBO_LORA,
32
+ "str": 1, "v": 1, "a": 1, "t": 1}],
33
+ separators=(",", ":"))
34
+
35
+ # id, type, title, pos, size, widgets
36
+ #
37
+ # Widget order is INPUT_TYPES order with sockets skipped. The trailing "" on the
38
+ # LoRA loader and the preview override is carried over from the dev workflow --
39
+ # a value past the last widget is ignored, and dropping one that turns out to
40
+ # belong to a widget would silently shift every value after it.
41
+ STACK = [
42
+ ("lora", "LTX_lora_loader", "LoRA Loader Stack (turbo)",
43
+ [520, -40], [420, 240], ["minimax", LORA_STACK, ""]),
44
+ ("adaln", "H3AdaLNLoRAFix", "H3 AdaLN LoRA Fix",
45
+ [520, 240], [340, 150], ["port"]),
46
+ ("lowvram", "MiniMaxLowVRAMAttention", "MiniMax H3 Low VRAM Attention",
47
+ [520, 430], [330, 58], [4]),
48
+ # sparsity_ratio, block_size, min_seq_len, dense_last_steps, protect_audio,
49
+ # enabled, dense_steps, dense_backend, disable_fp16_accum, stabilize_motion,
50
+ # reference_protection -- the last one post-dates the dev workflow's saved
51
+ # values, so it is written out explicitly here.
52
+ ("sla", "H3SLAAttention", "H3 SLA Attention",
53
+ [520, 530], [340, 322],
54
+ [0.9, "64", 8192, 0, True, True, "0", "comfy_kitchen", True, True, "Light"]),
55
+ ("preview", "ModelPreviewOverrideKJ", "Model Preview Override",
56
+ [520, 900], [360, 480], [512, 80, True, 100, 8, "taeh3.safetensors", ""]),
57
+ ]
58
+ FIRST_ID = 30
59
+
60
+ MODEL_PATH = ["UNETLoader"] + [t for _, t, _, _, _, _ in STACK] + ["HandTieClips"]
61
+
62
+
63
+ def main():
64
+ for fn in WORKFLOWS:
65
+ p = os.path.join(HERE, "workflows", fn)
66
+ bak = p + ".bak-speed"
67
+ if not os.path.exists(bak):
68
+ shutil.copyfile(p, bak)
69
+ wf = json.load(io.open(p, encoding="utf-8"))
70
+
71
+ # Drop any previous stack and every link that touched it.
72
+ old = {n["id"] for n in wf["nodes"]
73
+ if MARKER in (n.get("properties") or {})}
74
+ wf["nodes"] = [n for n in wf["nodes"] if n["id"] not in old]
75
+ wf["links"] = [l for l in wf["links"]
76
+ if l[1] not in old and l[3] not in old]
77
+
78
+ unet = next(n for n in wf["nodes"] if n["type"] == "UNETLoader")
79
+ clipl = next(n for n in wf["nodes"] if n["type"] == "CLIPLoader")
80
+ chain = next(n for n in wf["nodes"] if n["type"] == "HandTieClips")
81
+ lid = max([l[0] for l in wf["links"]] or [0])
82
+
83
+ # Removing the stack also removed the wires that fed it, so rebuild the
84
+ # plain loader -> chain shape first. That makes this reentrant from
85
+ # either state: a fresh workflow or one already wired.
86
+ wf["links"] = [l for l in wf["links"]
87
+ if not (l[3] == chain["id"] and l[4] in (0, 1))]
88
+ lid += 1
89
+ model_link = [lid, unet["id"], 0, chain["id"], 0, "MODEL"]
90
+ lid += 1
91
+ clip_link = [lid, clipl["id"], 0, chain["id"], 1, "CLIP"]
92
+ wf["links"] += [model_link, clip_link]
93
+
94
+ ids = {}
95
+ for i, (key, ntype, title, pos, size, widgets) in enumerate(STACK):
96
+ ids[key] = FIRST_ID + i
97
+ wf["nodes"].append({
98
+ "id": FIRST_ID + i, "type": ntype,
99
+ "pos": list(pos), "size": list(size),
100
+ "flags": {}, "order": 0, "mode": 0,
101
+ "inputs": [], "outputs": [],
102
+ "title": title,
103
+ "properties": {MARKER: key, "Node name for S&R": ntype},
104
+ "widgets_values": list(widgets),
105
+ })
106
+ byid = {n["id"]: n for n in wf["nodes"]}
107
+
108
+ def link(src, src_slot, dst, dst_slot, kind):
109
+ """Append one link and record it on both endpoints."""
110
+ nonlocal lid
111
+ lid += 1
112
+ wf["links"].append([lid, src, src_slot, dst, dst_slot, kind])
113
+ return lid
114
+
115
+ # MODEL, straight down the column.
116
+ model_link[3], model_link[4] = ids["lora"], 0
117
+ chain_model = model_link[0]
118
+ prev = ids["lora"]
119
+ for key in ("adaln", "lowvram", "sla", "preview"):
120
+ chain_model = link(prev, 0, ids[key], 0, "MODEL")
121
+ prev = ids[key]
122
+ chain_model = link(prev, 0, chain["id"], 0, "MODEL")
123
+
124
+ # CLIP through the LoRA loader, so the text half of every LoRA lands.
125
+ clip_link[3], clip_link[4] = ids["lora"], 1
126
+ chain_clip = link(ids["lora"], 1, chain["id"], 1, "CLIP")
127
+
128
+ # Sockets and slots, now that every link id exists.
129
+ byid[ids["lora"]]["inputs"] = [
130
+ {"name": "model", "type": "MODEL", "link": model_link[0]},
131
+ {"name": "clip", "type": "CLIP", "link": clip_link[0]},
132
+ ]
133
+ for key in ("adaln", "lowvram", "sla", "preview"):
134
+ byid[ids[key]]["inputs"] = [
135
+ {"name": "model", "type": "MODEL", "link": None}]
136
+ byid[ids["preview"]]["inputs"].append(
137
+ {"name": "vae", "type": "VAE", "link": None})
138
+
139
+ outs = {"lora": [("model", "MODEL"), ("clip", "CLIP")],
140
+ "adaln": [("MODEL", "MODEL")],
141
+ "lowvram": [("model", "MODEL")],
142
+ "sla": [("MODEL", "MODEL")],
143
+ "preview": [("MODEL", "MODEL")]}
144
+ for key, spec in outs.items():
145
+ byid[ids[key]]["outputs"] = [
146
+ {"name": nm, "type": ty, "links": []} for nm, ty in spec]
147
+
148
+ # Walk the finished link table back onto the endpoints.
149
+ for n in wf["nodes"]:
150
+ for o in n.get("outputs", []):
151
+ o["links"] = []
152
+ for l in wf["links"]:
153
+ src, sslot, dst, dslot = l[1], l[2], l[3], l[4]
154
+ byid[src]["outputs"][sslot]["links"].append(l[0])
155
+ byid[dst]["inputs"][dslot]["link"] = l[0]
156
+
157
+ wf["last_link_id"] = lid
158
+ wf["last_node_id"] = max(wf["last_node_id"], FIRST_ID + len(STACK) - 1)
159
+
160
+ io.open(p, "w", encoding="utf-8", newline="\n").write(
161
+ json.dumps(wf, indent=2) + "\n")
162
+ print("%s\n MODEL: %s\n CLIP : CLIPLoader -> LoRA Loader Stack -> "
163
+ "Hand Tie Clips (link %d)"
164
+ % (fn, " -> ".join(MODEL_PATH), chain_clip))
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
tools/check_prompts.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-ship sanity: walk both shipped workflows' plans through the real code.
2
+
3
+ Not a unit test -- it drives the same helpers `run()` drives, hop by hop, and
4
+ asserts the invariants that have actually broken before:
5
+
6
+ * every `@tag` resolves, and nothing literal survives into the prompt;
7
+ * every `<Picture N>` on a hop is either the live frame or a plate that is
8
+ genuinely scheduled onto that hop;
9
+ * identity text is present on every hop 2+ of a chain with a register (the
10
+ chain_00057 failure);
11
+ * shot 1 carries subject definitions when there is a register, and no later
12
+ shot is a full H3 block;
13
+ * the last shot closes on settle/hold.
14
+
15
+ It also REPORTS, without failing, any `<Subject N>` that reaches a continuation
16
+ hop. `subject_definitions` is hop-1 material and each hop is an independent text
17
+ encode, so such a token has no antecedent in its own conditioning.
18
+ """
19
+ import io
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import types
25
+
26
+ PACK = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ ROOT = os.path.dirname(os.path.dirname(PACK))
28
+ sys.path.insert(0, ROOT)
29
+ pkg = types.ModuleType("h3p")
30
+ pkg.__path__ = [PACK]
31
+ sys.modules["h3p"] = pkg
32
+
33
+ from h3p import refs as R # noqa: E402
34
+ from h3p import plan as PL # noqa: E402
35
+ from h3p import directives as D # noqa: E402
36
+ from h3p import h3_ref_chain as H3 # noqa: E402
37
+
38
+ FAIL = []
39
+ NOTE = []
40
+
41
+
42
+ def ck(label, ok, detail=""):
43
+ print(" %s %s%s" % ("ok " if ok else "FAIL", label,
44
+ (" " + detail) if detail else ""))
45
+ if not ok:
46
+ FAIL.append(label)
47
+
48
+
49
+ def widget_map(node):
50
+ names = [i["name"] for i in node["inputs"] if i.get("widget")]
51
+ names.insert(names.index("seed") + 1, "control_after_generate")
52
+ return dict(zip(names, node["widgets_values"]))
53
+
54
+
55
+ for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"):
56
+ p = os.path.join(PACK, "workflows", fn)
57
+ wf = json.load(io.open(p, encoding="utf-8"))
58
+ chain = next(n for n in wf["nodes"] if n["type"] == "HandTieClips")
59
+ wv = widget_map(chain)
60
+ print("\n%s" % fn)
61
+
62
+ plan = PL.parse_plan(wv["shot_plan"])
63
+ shots = plan["shots"] if isinstance(plan, dict) else plan
64
+ rp = R.parse_ref_plan(wv["ref_plan"])
65
+ refs, subs = rp["refs"], rp["subjects"]
66
+ wired = {r["slot"] for r in refs if r["file"]}
67
+ has_people = any(r["subject"] is not None for r in refs)
68
+
69
+ ck("shot count == chains", len(shots) == int(wv["chains"]),
70
+ "%d vs %s" % (len(shots), wv["chains"]))
71
+ warn = PL.check_coherence(shots)
72
+ ck("coherence clean", not warn, " | ".join(warn))
73
+ last_tail = (shots[-1].get("directives") or {}).get("tail")
74
+ ck("last shot closes", last_tail in ("settle", "hold"), repr(last_tail))
75
+
76
+ known = {r["tag"] for r in refs}
77
+ for i, shot in enumerate(shots):
78
+ beat = shot.get("beat") or ""
79
+ used = set(re.findall(r"@([A-Za-z0-9_]+)", beat))
80
+ active = R.active_refs(refs, i, wired)
81
+ ords = R.ordinals(active)
82
+ shift = 1 if i > 0 else 0 # live frame takes ordinal 1
83
+ hop_ords = {t: n + shift for t, n in ords.items()}
84
+ legal_pics = set(hop_ords.values()) | ({1} if i > 0 else set())
85
+
86
+ ck("hop %d every tag declared" % (i + 1), used <= known,
87
+ "unknown=%s" % sorted(used - known) if used - known else "")
88
+ try:
89
+ # Mirrors the node: names, not ordinals, from hop 2 on.
90
+ resolved = R.resolve_tags(
91
+ beat, hop_ords, R.subjects(refs), where="shot %d" % (i + 1),
92
+ declared=known,
93
+ subject_names=({k: (v or {}).get("name")
94
+ for k, v in (subs or {}).items()}
95
+ if i > 0 else None))
96
+ except Exception as exc: # noqa: BLE001
97
+ ck("hop %d tags resolve" % (i + 1), False, str(exc))
98
+ continue
99
+ ck("hop %d leaves no literal @tag" % (i + 1),
100
+ not re.search(r"@[A-Za-z0-9_]+", resolved))
101
+
102
+ # Same list tools/check_templates.py enforces. At cfg 1.0 with no
103
+ # negative branch every named concept is ADDED, so "None of the kitchen
104
+ # is visible" puts a kitchen in the encoder, and "she stops" is law 2.
105
+ hits = [w for w in (" no ", " not ", " never ", "n't", "without ",
106
+ "stops ", "stop ", "silent", "silence", "none of ")
107
+ if w in (" " + beat.lower() + " ")]
108
+ ck("hop %d names nothing it wants absent" % (i + 1), not hits,
109
+ str(hits))
110
+
111
+ if i == 0:
112
+ prose = R.subject_prose(active, subs)
113
+ block = (prose + "\n\n" + resolved) if prose else resolved
114
+ if refs:
115
+ ck("hop 1 defines its subjects",
116
+ ("subject_definitions:" in prose) if has_people else True,
117
+ "%d chars of prose" % len(prose))
118
+ else:
119
+ id_ords = None
120
+ n_subj = None
121
+ if active:
122
+ id_ords = [hop_ords[r["tag"]] for r in active
123
+ if r["subject"] is not None]
124
+ n_subj = len({r["subject"] for r in active
125
+ if r["subject"] is not None}) or None
126
+ elif refs:
127
+ id_ords = []
128
+ cont = R.continuity_line(
129
+ subs, {r["subject"] for r in refs
130
+ if r["subject"] is not None}) if refs else ""
131
+ block = H3._assemble_next(
132
+ resolved, live_picture=1, live_video=1,
133
+ n_stills=len(active), state_header="",
134
+ identity_ordinals=id_ords, n_subjects=n_subj,
135
+ tail=(shot.get("directives") or {}).get("tail"),
136
+ continuity=cont)
137
+
138
+ pics = {int(m) for m in re.findall(r"<Picture (\d+)>", block)}
139
+ ck("hop %d cites only scheduled plates" % (i + 1),
140
+ pics <= legal_pics,
141
+ "cited=%s legal=%s" % (sorted(pics), sorted(legal_pics)))
142
+ ck("hop %d is not a full H3 block" % (i + 1),
143
+ not D.is_full_h3_prompt(resolved))
144
+ if has_people:
145
+ ck("hop %d carries identity text" % (i + 1), bool(cont.strip()),
146
+ "%d chars" % len(cont))
147
+ dangling = sorted(set(re.findall(r"<Subject (\d+)>", block)))
148
+ if dangling:
149
+ NOTE.append("%s hop %d: <Subject %s> with no definitions block "
150
+ "on this hop" % (fn, i + 1, ">, <Subject ".join(dangling)))
151
+
152
+ print()
153
+ for n in NOTE:
154
+ print("NOTE " + n)
155
+ print()
156
+ if FAIL:
157
+ print("%d FAILURE(S):\n %s" % (len(FAIL), "\n ".join(FAIL)))
158
+ raise SystemExit(1)
159
+ print("SHIP CHECK: all clear (%d note%s)" % (len(NOTE), "" if len(NOTE) == 1 else "s"))
tools/check_templates.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Every shipped template must survive the real Python validators.
2
+
3
+ A template that produced a plan the node rejects would be worse than no
4
+ templates at all -- a first-time author would blame their own writing. This
5
+ extracts the patterns out of templates.js rather than restating them, so the
6
+ test cannot pass against a copy that has drifted.
7
+ """
8
+ import io
9
+ import json
10
+ import re
11
+ import sys
12
+ import types
13
+
14
+ import os
15
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
16
+ sys.path.insert(0, os.path.dirname(os.path.dirname(HERE)))
17
+ pkg = types.ModuleType("h3p")
18
+ pkg.__path__ = [HERE]
19
+ sys.modules["h3p"] = pkg
20
+
21
+ from h3p import plan as PL # noqa: E402
22
+ from h3p import directives as D # noqa: E402
23
+
24
+ SRC = io.open(os.path.join(HERE, "js", "editor", "templates.js"), encoding="utf-8").read()
25
+
26
+ # shot("...beat, possibly + concatenated...", { key: "value", ... })
27
+ # The required leading quote skips `function shot(beat, directives)`, which
28
+ # otherwise matched and swallowed the first pattern's metadata as a beat.
29
+ CALL = re.compile(r"\bshot\(\s*(\".+?)\s*,\s*(\{[^}]*\})\s*\)", re.S)
30
+ STR = re.compile(r'"((?:[^"\\]|\\.)*)"')
31
+ NAME = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*:")
32
+
33
+
34
+ def js_string(expr):
35
+ """Join a run of concatenated double-quoted literals."""
36
+ parts = STR.findall(expr)
37
+ if not parts:
38
+ raise SystemExit("could not read a beat from: " + expr[:60])
39
+ return "".join(p.replace('\\"', '"').replace("\\\\", "\\") for p in parts)
40
+
41
+
42
+ def js_object(expr):
43
+ return json.loads(NAME.sub(r'"\1":', expr))
44
+
45
+
46
+ def main():
47
+ names = re.findall(r'name:\s*"([^"]+)"', SRC)
48
+ calls = CALL.findall(SRC)
49
+ print("templates.js: %d patterns, %d shots\n" % (len(names), len(calls)))
50
+ if not names or not calls:
51
+ print("FAIL: extracted nothing -- the file's shape changed")
52
+ return 1
53
+
54
+ fails = []
55
+ all_shots = []
56
+ for beat_expr, dir_expr in calls:
57
+ beat = js_string(beat_expr)
58
+ dirs = js_object(dir_expr)
59
+ all_shots.append({"beat": beat, "directives": dirs})
60
+
61
+ for axis, val in dirs.items():
62
+ if axis not in D.AXES:
63
+ fails.append("unknown axis %r" % axis)
64
+ elif val not in D.VOCAB[axis]:
65
+ fails.append("%s=%r is not in VOCAB" % (axis, val))
66
+
67
+ # Law 1: the prompt is additive, so a negation is never correct.
68
+ for bad in (" no ", " not ", " never ", "n't", "without ", "stops ",
69
+ "stop ", "silent", "silence"):
70
+ if bad in beat.lower():
71
+ fails.append("beat contains %r: %s" % (bad, beat[:70]))
72
+
73
+ # Every pattern must parse as a plan on its own AND stacked together.
74
+ for label, shots in [("each pattern stacked", all_shots)]:
75
+ text = json.dumps({"shots": [
76
+ dict(s, id="s%d" % (i + 1)) for i, s in enumerate(shots)]})
77
+ try:
78
+ parsed = PL.parse_plan(text)
79
+ print(" ok %s parses (%d shots)" % (label, len(parsed)))
80
+ except Exception as e:
81
+ fails.append("%s: %s" % (label, e))
82
+ print(" FAIL %s: %s" % (label, e))
83
+ continue
84
+ warns = PL.check_coherence(parsed)
85
+ if warns:
86
+ print(" FAIL coherence warnings:")
87
+ for w in warns:
88
+ print(" " + w)
89
+ fails.extend(warns)
90
+ else:
91
+ print(" ok no coherence warnings across the whole stack")
92
+
93
+ # The last shot of any pattern that ends a chain must not be left `ongoing`.
94
+ closers = [s for s in all_shots
95
+ if (s["directives"].get("tail") in ("settle", "hold"))]
96
+ print(" ok %d of %d shots close with settle/hold"
97
+ % (len(closers), len(all_shots)))
98
+
99
+ print()
100
+ if fails:
101
+ print("%d FAILURE(S):" % len(fails))
102
+ for f in fails:
103
+ print(" -", f)
104
+ return 1
105
+ print("all template checks passed")
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
tools/check_workflows.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate the two shipped workflows against the live node, not against a guess.
2
+
3
+ Checks the things that actually break on someone else's machine: a node type
4
+ that is not registered, a widget count that disagrees with INPUT_TYPES, a link
5
+ that points at nothing, and a plan that does not survive the real parsers.
6
+ """
7
+ import io
8
+ import json
9
+ import os
10
+ import sys
11
+ import types
12
+
13
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
14
+ # ComfyUI root is two levels above the pack. Derived, not hardcoded, so this
15
+ # survives the folder being renamed or the checkout living somewhere else.
16
+ sys.path.insert(0, os.path.dirname(os.path.dirname(HERE)))
17
+ pkg = types.ModuleType("h3p")
18
+ pkg.__path__ = [HERE]
19
+ sys.modules["h3p"] = pkg
20
+
21
+ from h3p import refs as R # noqa: E402
22
+ from h3p import plan as PL # noqa: E402
23
+
24
+ # The widget list straight from INPUT_TYPES, so this cannot drift from the node.
25
+ from h3p import h3_ref_chain as H3 # noqa: E402
26
+
27
+ CORE = {"UNETLoader", "CLIPLoader", "VAELoader", "CreateVideo", "SaveVideo",
28
+ "Note", "MarkdownNote"}
29
+ OURS = {"HandTieClips", "HTCChainPreview"}
30
+ # Declared dependencies, not accidents: the turbo stack this node is actually
31
+ # run with. Anything outside these three sets is a pack the reader never asked
32
+ # for and must not appear in a shipped example.
33
+ DEPS = {"LTX_lora_loader": "ComfyUI-PlagueKind-Nodes",
34
+ "H3AdaLNLoRAFix": "ComfyUI-PlagueKind-Nodes",
35
+ "H3SLAAttention": "ComfyUI-PlagueKind-Nodes",
36
+ "MiniMaxLowVRAMAttention": "ComfyUI-KJNodes (experimental)",
37
+ "ModelPreviewOverrideKJ": "ComfyUI-KJNodes"}
38
+ # The MODEL wire, in order, from the loader to the chain.
39
+ MODEL_PATH = ["UNETLoader", "LTX_lora_loader", "H3AdaLNLoRAFix",
40
+ "MiniMaxLowVRAMAttention", "H3SLAAttention",
41
+ "ModelPreviewOverrideKJ", "HandTieClips"]
42
+ # CLIP must reach the chain THROUGH the LoRA loader, or the text half of every
43
+ # LoRA is silently dropped -- a wire that looks fine and costs you the LoRA.
44
+ CLIP_PATH = ["CLIPLoader", "LTX_lora_loader", "HandTieClips"]
45
+ FAIL = []
46
+
47
+
48
+ def ck(label, ok, detail=""):
49
+ print(" %s %s%s" % ("ok " if ok else "FAIL", label,
50
+ (" " + detail) if detail else ""))
51
+ if not ok:
52
+ FAIL.append(label)
53
+
54
+
55
+ SOCKET_TYPES = {"MODEL", "CLIP", "VAE", "IMAGE", "AUDIO", "LATENT",
56
+ "CONDITIONING", "VIDEO"}
57
+
58
+
59
+ def widget_names():
60
+ """The flat widget list, in the order widgets_values is indexed by.
61
+
62
+ A widget is any input whose type is a primitive or a combo AND which is not
63
+ marked `forceInput` -- `continuity_state` is a STRING but arrives on a wire.
64
+ `seed` occupies TWO entries because the frontend appends
65
+ control_after_generate directly after it.
66
+ """
67
+ it = H3.HandTieClips.INPUT_TYPES()
68
+ names = []
69
+ for section in ("required", "optional"):
70
+ for name, spec in (it.get(section) or {}).items():
71
+ t = spec[0]
72
+ cfg = spec[1] if len(spec) > 1 else {}
73
+ if isinstance(t, str) and t in SOCKET_TYPES:
74
+ continue
75
+ if cfg.get("forceInput"):
76
+ continue
77
+ names.append(name)
78
+ if name == "seed":
79
+ names.append("control_after_generate")
80
+ return names
81
+
82
+
83
+ def main():
84
+ expect = widget_names()
85
+ print("node declares %d widgets (control_after_generate included)\n"
86
+ % len(expect))
87
+
88
+ for fn in ("HandTieClips_Starter.json", "HandTieClips_Showcase.json"):
89
+ p = os.path.join(HERE, "workflows", fn)
90
+ print(fn)
91
+ wf = json.load(io.open(p, encoding="utf-8"))
92
+ nodes = wf["nodes"]
93
+ byid = {n["id"]: n for n in nodes}
94
+ types_used = {n["type"] for n in nodes}
95
+
96
+ allowed = CORE | OURS | set(DEPS)
97
+ ck("only core, this pack and declared deps", types_used <= allowed,
98
+ str(sorted(types_used - allowed)) if types_used - allowed
99
+ else "%d types" % len(types_used))
100
+ ck("no LoadImage", not any(n["type"] == "LoadImage" for n in nodes))
101
+
102
+ chain = next(n for n in nodes if n["type"] == "HandTieClips")
103
+
104
+ # The wires, walked link by link. A patch node that is present but
105
+ # bypassed round the side is the failure this catches -- it looks right
106
+ # on the canvas and does nothing.
107
+ def walk_wire(path, kind):
108
+ seen = [path[0]]
109
+ cur = next(n for n in nodes if n["type"] == path[0])
110
+ for _ in range(len(path)):
111
+ nxt = [l for l in wf["links"]
112
+ if l[1] == cur["id"] and l[5] == kind]
113
+ if not nxt:
114
+ break
115
+ cur = byid[nxt[0][3]]
116
+ seen.append(cur["type"])
117
+ return seen
118
+
119
+ m = walk_wire(MODEL_PATH, "MODEL")
120
+ ck("MODEL wire runs the whole turbo stack", m == MODEL_PATH,
121
+ " -> ".join(m))
122
+ c = walk_wire(CLIP_PATH, "CLIP")
123
+ ck("CLIP reaches the chain through the LoRA loader", c == CLIP_PATH,
124
+ " -> ".join(c))
125
+ ins = [i["name"] for i in chain["inputs"]]
126
+ dead = [i for i in ins
127
+ if i.startswith("ref_image_") and i != "ref_image_size"
128
+ or i in ("reference_video", "voice", "start_image")]
129
+ ck("no removed sockets", not dead, str(dead))
130
+ ck("widget count matches INPUT_TYPES",
131
+ len(chain["widgets_values"]) == len(expect),
132
+ "%d vs %d" % (len(chain["widgets_values"]), len(expect)))
133
+
134
+ wv = dict(zip(expect, chain["widgets_values"]))
135
+ ck("control_after_generate=fixed", wv["control_after_generate"] == "fixed")
136
+
137
+ # Links: every id referenced by a node must exist, and vice versa.
138
+ lids = {l[0] for l in wf["links"]}
139
+ used = set()
140
+ for n in nodes:
141
+ for i in n.get("inputs", []):
142
+ if i.get("link") is not None:
143
+ used.add(i["link"])
144
+ for o in n.get("outputs", []):
145
+ used.update(o.get("links") or [])
146
+ ck("link table consistent", lids == used,
147
+ "table=%s used=%s" % (sorted(lids), sorted(used)))
148
+ ck("every link endpoint exists",
149
+ all(l[1] in byid and l[3] in byid for l in wf["links"]))
150
+ ck("all four sockets fed",
151
+ all(chain["inputs"][k]["link"] is not None for k in range(4)))
152
+
153
+ # The plans, through the real parsers.
154
+ shots = PL.parse_plan(wv["shot_plan"])
155
+ n_shots = len(shots["shots"]) if isinstance(shots, dict) else len(shots)
156
+ ck("shot plan parses", n_shots == int(wv["chains"]),
157
+ "%d shots, chains=%s" % (n_shots, wv["chains"]))
158
+
159
+ rp = R.parse_ref_plan(wv["ref_plan"])
160
+ refs = rp["refs"]
161
+ ck("ref plan parses", True, "%d refs" % len(refs))
162
+
163
+ # Every @tag in every beat must be declared, or the run dies at hop N.
164
+ declared = {r["tag"] for r in refs}
165
+ import re
166
+ body = [s["beat"] for s in (shots["shots"] if isinstance(shots, dict)
167
+ else shots)]
168
+ tags = set()
169
+ for b in body:
170
+ tags |= set(re.findall(r"@([A-Za-z0-9_]+)", b or ""))
171
+ ck("every @tag is declared", tags <= declared,
172
+ "undeclared=%s" % sorted(tags - declared) if tags - declared
173
+ else "tags=%s" % sorted(tags))
174
+
175
+ # And every declared tag must be active on the hops that use it.
176
+ bad = []
177
+ for hop, b in enumerate(body):
178
+ want = set(re.findall(r"@([A-Za-z0-9_]+)", b or ""))
179
+ active = R.active_refs(refs, hop, {r["slot"] for r in refs
180
+ if r["file"]})
181
+ have = set(R.ordinals(active)) | set(R.subjects(refs))
182
+ miss = want - have
183
+ if miss:
184
+ bad.append("hop %d: %s" % (hop + 1, sorted(miss)))
185
+ ck("every tag is scheduled onto the hops that use it", not bad,
186
+ "; ".join(bad))
187
+
188
+ warn = R.check(rp, {r["slot"] for r in refs if r["file"]})
189
+ ck("no register warnings", not warn, " | ".join(warn))
190
+
191
+ # Stale named values. Both shipped workflows once carried a copy
192
+ # inherited from the dev workflow they were cloned out of, describing a
193
+ # 3x10 s chain with a randomizing seed and references that do not ship.
194
+ # It is dormant while Comfy.Workflow.NamedValuesRestore stays off, which
195
+ # is exactly why it sat there unnoticed.
196
+ named = [n["type"] for n in nodes if "widgets_values_named" in n]
197
+ ck("no stale widgets_values_named", not named, str(sorted(set(named))))
198
+
199
+ # The on-canvas board, where there is one.
200
+ cards = [n for n in nodes if n["type"] == "MarkdownNote"]
201
+ if cards:
202
+ ck("every card is marked and non-empty",
203
+ all((n.get("properties") or {}).get("htc_card")
204
+ and (n.get("widgets_values") or [""])[0].strip()
205
+ for n in cards),
206
+ "%d cards" % len(cards))
207
+ groups = wf.get("groups") or []
208
+ ck("a group wraps the board", len(groups) == 1,
209
+ "%d groups" % len(groups))
210
+ if groups:
211
+ gx, gy, gw, gh = groups[0]["bounding"]
212
+ outside = [n["title"] for n in cards
213
+ if not (gx <= n["pos"][0]
214
+ and gy <= n["pos"][1]
215
+ and n["pos"][0] + n["size"][0] <= gx + gw
216
+ and n["pos"][1] + n["size"][1] <= gy + gh)]
217
+ ck("the group encloses every card", not outside, str(outside))
218
+ # A card reaching past x=0 would sit on top of the loaders.
219
+ ck("the board stays left of the graph",
220
+ all(n["pos"][0] + n["size"][0] <= 0 for n in cards))
221
+
222
+ print(" %d nodes, %d links, %d KB\n"
223
+ % (len(nodes), len(wf["links"]), os.path.getsize(p) // 1024))
224
+
225
+ if FAIL:
226
+ print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
227
+ return 1
228
+ print("ALL PASS")
229
+ return 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ raise SystemExit(main())
tools/gen_example.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emit the prompt pack's worked example FROM the showcase workflow.
2
+
3
+ Typing the example out by hand would give the pack a third copy of the same
4
+ plan, free to drift from the one that is actually validated. This reads the
5
+ shipped workflow, so the example and the example workflow are the same bytes.
6
+ """
7
+ import io
8
+ import json
9
+ import os
10
+
11
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
12
+ WF = os.path.join(HERE, "workflows", "HandTieClips_Showcase.json")
13
+ OUT = os.path.join(HERE, "prompt_pack", "EXAMPLE_6_HOP.md")
14
+
15
+ wf = json.load(io.open(WF, encoding="utf-8"))
16
+ # Both ids: the pack registers the pre-rename `HandTieClips` as an alias, so a
17
+ # workflow saved before 2026-08-29 is still a legal input here.
18
+ CHAIN_TYPES = ("HandTieClips", "HandTieClips")
19
+ chain = next(n for n in wf["nodes"] if n["type"] in CHAIN_TYPES)
20
+ names = [i["name"] for i in chain["inputs"] if i.get("widget")]
21
+ names.insert(names.index("seed") + 1, "control_after_generate")
22
+ wv = dict(zip(names, chain["widgets_values"]))
23
+
24
+ shot = json.loads(wv["shot_plan"])
25
+ ref = json.loads(wv["ref_plan"])
26
+
27
+ # The schedule, derived rather than described, so the table cannot lie.
28
+ rows = []
29
+ for i, s in enumerate(shot["shots"], start=1):
30
+ active = [r["tag"] for r in ref["refs"]
31
+ if r.get("shots") is None or i in r["shots"]]
32
+ rows.append("| %d | %s | %s |" % (
33
+ i, s["id"], ", ".join("@" + t for t in active) or "**none**"))
34
+
35
+ body = """# Worked example: six hops, three pictures
36
+
37
+ This is the plan inside `workflows/HandTieClips_Showcase.json`, reproduced here so
38
+ it can be shown to a model as an example of the shape and the reasoning. It is
39
+ generated from that workflow, so the two cannot drift apart.
40
+
41
+ The scene: a cook in a kitchen speaks a line, crosses the room, leaves through a
42
+ doorway into a hallway the register has no picture of, speaks again there, and
43
+ comes back.
44
+
45
+ ## What each hop is for
46
+
47
+ | hop | id | references active |
48
+ |---|---|---|
49
+ %s
50
+
51
+ Three things in that table are the whole point:
52
+
53
+ - **Hop 3** carries the kitchen while she is leaving it, and hop 4 does not.
54
+ The hallway is a space no reference describes, so the model must invent it —
55
+ and a kitchen plate riding hop 4 would drag her back into the kitchen.
56
+ - **Hop 4** re-asserts the face. Entering an unseen space is where identity
57
+ drift starts, and re-asserting there is cheaper than recovering on 5 and 6.
58
+ - **Hop 5 has no references at all.** Identity, wardrobe and voice ride on the
59
+ frame pin plus `subjects.1.locked` and `.context` alone. If she is still the
60
+ same person in the same apron with the same voice, the register works.
61
+
62
+ Note also that the dialogue lands on hops 1 and 5 — the establishing shot and
63
+ the one with no pictures — and that both use single quotes.
64
+
65
+ ## shot_plan
66
+
67
+ ```json
68
+ %s
69
+ ```
70
+
71
+ ## ref_plan
72
+
73
+ ```json
74
+ %s
75
+ ```
76
+ """ % ("\n".join(rows),
77
+ json.dumps(shot, indent=2, ensure_ascii=False),
78
+ json.dumps(ref, indent=2, ensure_ascii=False))
79
+
80
+ io.open(OUT, "w", encoding="utf-8", newline="\n").write(body)
81
+ print("wrote %s (%d bytes)" % (OUT, len(body)))
82
+ non_ascii = [c for c in body if ord(c) > 127]
83
+ print("non-ASCII in output:", sorted(set(non_ascii)) or "none")
tools/gen_schema.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate `prompt_pack/SCHEMA.json` from the node's own constants.
2
+
3
+ A hand-written schema is a second source of truth that starts drifting the day
4
+ someone adds a camera move. This one reads `directives.VOCAB`, `refs.RETENTION`,
5
+ `plan._SHOT_KEYS` and the duration table directly, so it can only ever describe
6
+ the node that is actually installed.
7
+
8
+ python tools/gen_schema.py # write prompt_pack/SCHEMA.json
9
+ python tools/gen_schema.py --check # exit 1 if it is out of date (CI)
10
+
11
+ Run it with ComfyUI's interpreter -- importing the node pulls in torch.
12
+ """
13
+ import argparse
14
+ import io
15
+ import json
16
+ import os
17
+ import sys
18
+ import types
19
+
20
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
21
+ OUT = os.path.join(HERE, "prompt_pack", "SCHEMA.json")
22
+
23
+
24
+ def _load():
25
+ """Import the pack as a package without needing it to be installed."""
26
+ root = os.path.dirname(os.path.dirname(HERE)) # .../ComfyUI
27
+ if root not in sys.path:
28
+ sys.path.insert(0, root)
29
+ name = "_h3schema"
30
+ pkg = types.ModuleType(name)
31
+ pkg.__path__ = [HERE]
32
+ sys.modules[name] = pkg
33
+ mod = __import__(name + ".directives", fromlist=["directives"])
34
+ d = sys.modules[name + ".directives"]
35
+ r = __import__(name + ".refs", fromlist=["refs"])
36
+ r = sys.modules[name + ".refs"]
37
+ p = __import__(name + ".plan", fromlist=["plan"])
38
+ p = sys.modules[name + ".plan"]
39
+ return d, r, p, mod
40
+
41
+
42
+ def build():
43
+ d, r, p, _ = _load()
44
+
45
+ # The duration labels, read from the node so the two cannot disagree. The
46
+ # import is late and guarded because it pulls torch in.
47
+ try:
48
+ from _h3schema import h3_ref_chain as node # noqa: F401
49
+ durations = list(node.DURATION_FRAMES)
50
+ frames = dict(node.DURATION_FRAMES)
51
+ except Exception: # pragma: no cover
52
+ durations, frames = [], {}
53
+
54
+ directives = {
55
+ axis: {
56
+ "type": "string",
57
+ "enum": sorted(vals),
58
+ "description": " | ".join(f"{k}: {v}" for k, v in vals.items()),
59
+ }
60
+ for axis, vals in d.VOCAB.items()
61
+ }
62
+ for axis, spec in directives.items():
63
+ if axis in d.DEFAULTS:
64
+ spec["default"] = d.DEFAULTS[axis]
65
+
66
+ shot = {
67
+ "type": "object",
68
+ "required": ["beat"],
69
+ "additionalProperties": False,
70
+ "properties": {
71
+ "id": {"type": "string",
72
+ "description": "Stable name. Generated if absent."},
73
+ "beat": {"type": "string",
74
+ "description": "What happens THIS hop. Shot 1 is the whole "
75
+ "opening; every later shot is only the new "
76
+ "beat."},
77
+ "directives": {
78
+ "type": "object", "additionalProperties": False,
79
+ "properties": directives,
80
+ "description": "Compiled in the order " + ", ".join(d.AXES) +
81
+ ". `join` is ignored on shot 1.",
82
+ },
83
+ "prose": {"type": "string",
84
+ "description": "Appended verbatim, for anything the "
85
+ "vocabulary lacks. Phrase it affirmatively."},
86
+ "seed": {"type": "integer"},
87
+ "steps": {"type": "integer"},
88
+ "duration": {"type": "string", "enum": durations},
89
+ "locked": {"type": "boolean",
90
+ "description": "Reuse this hop's cached render even when "
91
+ "its inputs changed. Needs cache_hops=on."},
92
+ },
93
+ }
94
+ assert set(shot["properties"]) == set(p._SHOT_KEYS), (
95
+ "schema and plan._SHOT_KEYS disagree: "
96
+ f"{sorted(set(shot['properties']) ^ set(p._SHOT_KEYS))}")
97
+
98
+ ref = {
99
+ "type": "object",
100
+ "required": ["tag"],
101
+ "additionalProperties": False,
102
+ "properties": {
103
+ "tag": {"type": "string", "pattern": "^[A-Za-z0-9_]+$",
104
+ "description": "Unique. Written into beats as @tag."},
105
+ "file": {"type": "string",
106
+ "description": "Basename under ComfyUI/input/h3_refs. "
107
+ "Never a path."},
108
+ "slot": {"type": "integer",
109
+ "description": "Derived from list position. Do not author "
110
+ "it; present only for legacy plans."},
111
+ "subject": {"type": "integer", "minimum": 1,
112
+ "description": "Groups pictures OF THE SAME PERSON. Two "
113
+ "different people under one number makes "
114
+ "the model render the average of their "
115
+ "faces."},
116
+ "retention": {"type": "string", "enum": sorted(r.RETENTION),
117
+ "description": " | ".join(f"{k}: {v}" for k, v
118
+ in r.RETENTION.items())},
119
+ "desc": {"type": "string"},
120
+ "shots": {
121
+ "type": "array", "items": {"type": "integer", "minimum": 1},
122
+ "minItems": 1,
123
+ "description": "1-based hops this picture rides on. OMITTING IT "
124
+ "MEANS HOP 1 ONLY on a continuation chain -- a "
125
+ "plate shot elsewhere riding a later hop beats "
126
+ "the frame pin.",
127
+ },
128
+ },
129
+ }
130
+ assert set(ref["properties"]) == set(r.REF_FIELDS), (
131
+ "schema and refs.REF_FIELDS disagree: "
132
+ f"{sorted(set(ref['properties']) ^ set(r.REF_FIELDS))}")
133
+
134
+ subject = {
135
+ "type": "object", "additionalProperties": False,
136
+ "properties": {
137
+ "name": {"type": "string"},
138
+ "locked": {"type": "string",
139
+ "description": "What must not change. This is what "
140
+ "survives on a hop where the photograph "
141
+ "is absent."},
142
+ "context": {"type": "string",
143
+ "description": "Situational state that should persist "
144
+ "-- wardrobe, what they are holding."},
145
+ },
146
+ }
147
+ assert set(subject["properties"]) == set(r.SUBJECT_FIELDS)
148
+
149
+ return {
150
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
151
+ "title": "H3 Ref Chain plans",
152
+ "description":
153
+ "Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` "
154
+ "in the ref_plan widget. Generated by tools/gen_schema.py from the "
155
+ "installed node -- do not edit by hand.",
156
+ "type": "object",
157
+ "properties": {
158
+ "shot_plan": {
159
+ "type": "object",
160
+ "required": ["shots"],
161
+ "additionalProperties": False,
162
+ "properties": {
163
+ "shots": {"type": "array", "minItems": 1, "items": shot,
164
+ "description": "One shot per hop. The shot count "
165
+ "IS the hop count."},
166
+ },
167
+ },
168
+ "ref_plan": {
169
+ "type": "object",
170
+ "required": ["refs"],
171
+ "additionalProperties": False,
172
+ "properties": {
173
+ "refs": {"type": "array", "items": ref,
174
+ "maxItems": r.MAX_REF_IMAGES,
175
+ "description": f"At most {r.MAX_REF_IMAGES} "
176
+ f"pictures on any one hop."},
177
+ "subjects": {
178
+ "type": "object",
179
+ "patternProperties": {"^[0-9]+$": subject},
180
+ "additionalProperties": False,
181
+ "description": "Keyed by subject number, as a STRING. "
182
+ "Every subject here must be claimed by "
183
+ "at least one ref, or the plan is "
184
+ "rejected.",
185
+ },
186
+ },
187
+ },
188
+ },
189
+ "x-duration-frames": frames,
190
+ "x-rules": [
191
+ "cfg is 1.0 with no negative branch: the prompt is ADDITIVE. "
192
+ "Anything named is added; nothing can be removed by mentioning it. "
193
+ "Never write a negation.",
194
+ "Never name the thing you want to end. 'stops talking' keeps them "
195
+ "talking. Write the state as a pose plus a sound.",
196
+ "H3 always generates audio. Silence must be written AS a sound -- "
197
+ "room tone, a refrigerator, a single click.",
198
+ "Ambience must be narrowband. 'faint street noise' renders as hiss; "
199
+ "'the low hum of the refrigerator' renders as a refrigerator.",
200
+ "A state change belongs at the END of the previous shot. Every hop "
201
+ "opens holding the frames it was handed.",
202
+ "Shot 1 establishes everything. Later shots carry ONLY the new "
203
+ "beat -- never re-describe the face, the clothes or the room.",
204
+ "Write @tags into the action line. Describing a reference in the "
205
+ "register does not make the model use it.",
206
+ "Set `tail` to settle or hold on the FINAL shot, or the model is "
207
+ "told action is still underway at the last frame and invents "
208
+ "something to satisfy it.",
209
+ ],
210
+ }
211
+
212
+
213
+ def main():
214
+ ap = argparse.ArgumentParser()
215
+ ap.add_argument("--check", action="store_true",
216
+ help="exit 1 if the file on disk is out of date")
217
+ args = ap.parse_args()
218
+
219
+ text = json.dumps(build(), indent=2, ensure_ascii=False) + "\n"
220
+
221
+ if args.check:
222
+ try:
223
+ have = io.open(OUT, encoding="utf-8").read()
224
+ except OSError:
225
+ print("SCHEMA.json is missing")
226
+ return 1
227
+ if have != text:
228
+ print("SCHEMA.json is out of date; run tools/gen_schema.py")
229
+ return 1
230
+ print("SCHEMA.json is current")
231
+ return 0
232
+
233
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
234
+ io.open(OUT, "w", encoding="utf-8", newline="\n").write(text)
235
+ print("wrote %s (%d bytes)" % (OUT, len(text)))
236
+ return 0
237
+
238
+
239
+ if __name__ == "__main__":
240
+ raise SystemExit(main())
tools/notes.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The instruction board that ships on the Starter workflow's canvas.
2
+
3
+ One source for six `MarkdownNote` cards laid out to the left of the loaders.
4
+ `build_notes.py` writes them into `workflows/HandTieClips_Starter.json`.
5
+
6
+ Why on the canvas at all: the craft lives in `PROMPTING.md`, `prompt_pack/` and
7
+ the editor's Templates panel, and all three require leaving the graph. The rules
8
+ that decide whether a first render works are needed at the moment beats are being
9
+ written, which is on the canvas.
10
+
11
+ This is a condensation, not a copy. `PROMPTING.md` stays the long-form authority
12
+ and every card says so; what is here is the part needed to get a first render
13
+ right. Keep the cards short enough to read at a glance -- a wall of text on the
14
+ canvas is the same as no text on the canvas.
15
+
16
+ Layout: two columns of three at x=-960 and x=-480, 440 wide, so the board ends at
17
+ x=-40 and the loaders (which start at x=0) are untouched. Reading order is down
18
+ column A, then down column B.
19
+ """
20
+
21
+ YELLOW = ("#432", "#653") # what the existing Note uses
22
+ GREEN = ("#232", "#353") # ComfyUI's green -- card 1 only, so the entry
23
+ # point is unmistakable in a wall of yellow
24
+
25
+ GROUP = {
26
+ "id": 1,
27
+ "title": "READ ME -- writing for this node",
28
+ "bounding": [-990, -465, 980, 2560],
29
+ "color": "#3f789e",
30
+ "font_size": 24,
31
+ }
32
+
33
+ # `extra.ds` is restored on load rather than fitted, so without this the board
34
+ # sits off-screen to the left and is never found. Screen = (world + offset) *
35
+ # scale, so an offset of 500 puts world x=-500 at the left edge: column B, the
36
+ # loaders and the left of the chain node all in view at once.
37
+ DS = {"scale": 0.8264462809917354, "offset": [500, -281.1386834420914]}
38
+
39
+
40
+ START_HERE = """\
41
+ ## Start here
42
+
43
+ Two hops of 5 s, joined into one 10 s clip.
44
+
45
+ ### Requires
46
+
47
+ The MODEL wire is a turbo stack, not a bare loader:
48
+
49
+ ```
50
+ UNETLoader
51
+ -> LoRA Loader Stack (turbo LoRA)
52
+ -> H3 AdaLN LoRA Fix
53
+ -> MiniMax H3 Low VRAM Attention
54
+ -> H3 SLA Attention
55
+ -> Model Preview Override
56
+ -> this node
57
+ ```
58
+
59
+ | pack | nodes |
60
+ |---|---|
61
+ | **ComfyUI-PlagueKind-Nodes** | LoRA Loader Stack, AdaLN Fix, SLA Attention |
62
+ | **ComfyUI-KJNodes** | Low VRAM Attention (experimental), Model Preview Override |
63
+
64
+ Also on disk: the **turbo LoRA** named in the loader, and `taeh3.safetensors`
65
+ for the live preview (or set `tiny_vae` to `none`).
66
+
67
+ **CLIP goes to this node from the LoRA loader, not from the encoder.** That is
68
+ what makes the text half of every LoRA land. Do not rewire it back.
69
+
70
+ `steps` is **7**, which only works with the turbo LoRA. Missing a pack? Its
71
+ nodes load as red boxes -- delete them, wire the loader straight into `model`
72
+ and the encoder into `clip`, and raise `steps` to 20 or so.
73
+
74
+ 1. Point the four **loaders** at your H3 files.
75
+ 2. Queue.
76
+ 3. Read the **SHOTS** cards on the node.
77
+
78
+ **Shot 1 is the whole opening. Every later shot is only the new beat.** The node
79
+ writes the identity lock, the live-frame citation and the join itself -- so
80
+ re-describing the face, the clothes or the room after shot 1 competes with the
81
+ frame pin instead of reinforcing it.
82
+
83
+ This plan ships with **no references on purpose**, so it runs before you have
84
+ supplied any pictures.
85
+
86
+ ### Adding pictures
87
+
88
+ - Open **REFERENCES**, add a row, drop a photo on its thumbnail. Files land in
89
+ `ComfyUI/input/h3_refs`. There is no Load Image node to wire.
90
+ - Give the row a `@tag`, and group photos of the same person under one subject.
91
+ - Write the tag into the beat: `@hero_face stands at the counter in @kitchen`.
92
+
93
+ The long-form guide is `PROMPTING.md` in the pack folder.\
94
+ """
95
+
96
+
97
+ RULES = """\
98
+ ## The rules that decide whether it works
99
+
100
+ Not style preferences. This is how this model fails.
101
+
102
+ ### 1. The prompt is additive
103
+
104
+ Sampling runs at **cfg 1.0 with no negative branch**. Every concept you name is
105
+ added, and nothing can be removed by mentioning it -- `no cut` puts the word
106
+ *cut* in front of the encoder. **Never write a negation.**
107
+
108
+ ### 2. Never name the thing you want to end
109
+
110
+ "The cook stops talking" keeps her talking. Write the state you want as **a pose
111
+ plus a sound**:
112
+
113
+ > leans back against the counter with her lips closed, and lets her eyes move
114
+ > slowly across the room. The kitchen is quiet apart from the low hum of the
115
+ > refrigerator.
116
+
117
+ Audio is always generated. Silence written as an absence comes back as speech,
118
+ so **silence has to be written as a sound** -- room tone, a fridge, a single
119
+ click. Keep it narrowband: "faint street noise" renders as a five-second hiss.
120
+
121
+ **The ban is on the idea, not on a word list.** *Fades, passes, wanes, subsides,
122
+ dies down, eases off* all name an ending as surely as *stops* does, and all of
123
+ them add the thing they describe. Ask of each sentence: is this happening, or
124
+ has it finished happening?
125
+
126
+ ### 3. A state change belongs at the END of the previous shot
127
+
128
+ Every hop opens holding the frames it was handed, and the audio pin carries the
129
+ previous hop's tail across the join. Nothing you write in shot 3 can make shot 3
130
+ start quiet. **Arrive there before the previous shot ends.**
131
+
132
+ ### 4. A hop that ends on dialogue keeps talking
133
+
134
+ Speech at the end of hop N opens hop N+1 and propagates down the whole chain.
135
+ Land each line **mid-hop** and leave a non-verbal action running into the seam --
136
+ slicing, walking, a hand on a doorframe. Give every hop with no dialogue a sound
137
+ bed of its own.
138
+
139
+ ### 5. A walk between two rooms is `match_cut`
140
+
141
+ `join: continuous` across a real location change makes the model morph one room
142
+ into the other mid-movement.
143
+
144
+ ### 6. Set `tail` on your last shot
145
+
146
+ Left at `ongoing`, the model is told action is still underway at the final frame
147
+ and will invent something to satisfy it. Use `settle` or `hold`.\
148
+ """
149
+
150
+
151
+ REFERENCES = """\
152
+ ## References and @tags
153
+
154
+ A picture in the register does not make the model use it. **The beat is what
155
+ drives the frame** -- write the tag into the action line.
156
+
157
+ Phrase a place as a place that is *depicted*, not as a container to be placed
158
+ inside: "at the counter in `@kitchen`", not "steps into `@kitchen`".
159
+
160
+ ### Fields
161
+
162
+ | field | what it does |
163
+ |---|---|
164
+ | `tag` | what you write in beats as `@tag` |
165
+ | `file` | a bare filename in `input/h3_refs` |
166
+ | `subject` | groups pictures **of the same person** |
167
+ | `retention` | `fully_preserved` / `partially_copy` / `reference` |
168
+ | `shots` | the 1-based hops this picture rides |
169
+
170
+ **One subject number per person.** Two different people under one number makes
171
+ the model render the average of their faces.
172
+
173
+ ### `shots` is the part that decides continuity
174
+
175
+ **A reference with no `shots` list rides hop 1 only.** Right for a **place**
176
+ plate: a room still riding a hop set somewhere else beats the frame pin, and the
177
+ model follows the still. **List every hop a picture belongs on.**
178
+
179
+ **A face plate is the opposite — put it on every hop.** A hop scheduled with no
180
+ face reference came back a different person, and no later hop recovered.
181
+ Identity drift does not self-correct.
182
+
183
+ - Every **face** ref gets every hop: `"shots": [1, 2, 3, 4, 5, 6]`.
184
+ - Keep a **place** ref on the hops set in that place, and off the rest.
185
+
186
+ ### `locked` and `context`
187
+
188
+ Per-subject prose that rides **every hop**, with no picture citation, so it
189
+ carries identity across a hop where the photograph is absent. Give every subject
190
+ a `locked`, and a `name` -- from hop 2 on, `@tag` for a person resolves to that
191
+ name.
192
+
193
+ **Name a colour or it drifts.** A noun with no adjective is unanchored: each hop
194
+ is an independent encode, so "the bowl" on hop 4 came back stainless steel. Put
195
+ properties in `context` -- "the bowl is white porcelain" -- never locations, and
196
+ repeat the adjective in every beat.\
197
+ """
198
+
199
+
200
+ DIRECTIVES = """\
201
+ ## Directives
202
+
203
+ Five axes, all optional, set per shot. An unset axis emits nothing at all and
204
+ costs no tokens.
205
+
206
+ | axis | values |
207
+ |---|---|
208
+ | `join` | `continuous`, `match_cut`, `hard_cut` -- **omit on shot 1** |
209
+ | `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |
210
+ | `framing` | `keep`, `wide`, `medium`, `close` |
211
+ | `pace` | `slow`, `steady`, `brisk` |
212
+ | `tail` | `ongoing` (default), `settle`, `hold` |
213
+
214
+ They compile in that order -- `join` first, because it describes how this hop
215
+ meets the previous one.
216
+
217
+ ### Two combinations the node warns about
218
+
219
+ **`join: continuous` + a framing change + `camera: hold`.** A framing change asks
220
+ the audience to be somewhere new; with the camera still, the only way there is a
221
+ cut. Earn it on the move (`push_in`, `pull_back`, `pan_follow`) or use
222
+ `framing: keep`.
223
+
224
+ **`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way
225
+ from the destination.
226
+
227
+ Both are warnings, not errors. They are legitimate things to want -- they just
228
+ rarely read the way you meant.
229
+
230
+ ### Duration
231
+
232
+ Per-shot `duration` overrides the chain, using exactly these labels:
233
+ `"5 s"`, `"7 s"`, `"8 s"`, `"10 s"`, `"15 s"`.\
234
+ """
235
+
236
+
237
+ TROUBLE = """\
238
+ ## When it goes wrong
239
+
240
+ | symptom | cause | fix |
241
+ |---|---|---|
242
+ | The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |
243
+ | A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |
244
+ | She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |
245
+ | Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |
246
+ | A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |
247
+ | Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |
248
+ | Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |
249
+ | Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
250
+ | The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |
251
+ | A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |
252
+ | The film gets darker every hop | Luminance drifts one way and nothing pushes back | Restate the light as a positive property in every beat |
253
+ | A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |
254
+ | A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |
255
+ | A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |
256
+ | A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |
257
+ | The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |
258
+ | A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |
259
+ | A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |
260
+
261
+ Every error message names the shot or the reference it came from. Nothing
262
+ guesses.\
263
+ """
264
+
265
+
266
+ AUTHOR = """\
267
+ ## Let a model write your plan
268
+
269
+ `prompt_pack/` in the pack folder turns any chat model into a plan writer. In
270
+ LM Studio, or anything with a system-prompt box:
271
+
272
+ 1. Load a model with **context 8192 or more**. The prompt is ~2,600 tokens and
273
+ the reply another 1,000-2,000; a 4k window truncates the rules and you get
274
+ invented directive names.
275
+ 2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.
276
+ Nothing else goes in that box.
277
+ 3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart
278
+ quotes.
279
+ 4. Describe the scene, and say how many hops and what pictures you have:
280
+
281
+ > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,
282
+ > waits by a window, then comes back. I have a face photo, a photo of her
283
+ > apron, and a photo of the kitchen.
284
+
285
+ 5. Each panel section has its own **JSON** disclosure at the bottom. The first
286
+ ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second
287
+ in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good
288
+ version on screen and says so, rather than discarding your paste.
289
+ 6. **If the node rejects it, paste the error straight back into the chat.** One
290
+ round trip usually fixes it.
291
+
292
+ Want it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.
293
+
294
+ Small models (7B-8B) hold the JSON schema but drift on the prose rules -- they
295
+ write negations. Skim the beats before queueing.\
296
+ """
297
+
298
+
299
+ SHOWCASE_NOTE = """\
300
+ Hand Tie Clips -- SHOWCASE (6 hops x 7 s = 39.2 s)
301
+
302
+ A continuity stress test, and the honest demonstration of what the reference
303
+ register buys you.
304
+
305
+ REQUIRES a turbo stack on the MODEL wire, which is how this node is actually
306
+ run here:
307
+
308
+ UNETLoader -> LoRA Loader Stack (turbo LoRA) -> H3 AdaLN LoRA Fix
309
+ -> MiniMax H3 Low VRAM Attention -> H3 SLA Attention
310
+ -> Model Preview Override -> this node
311
+
312
+ ComfyUI-PlagueKind-Nodes -> LoRA Loader Stack, AdaLN Fix, SLA Attention
313
+ ComfyUI-KJNodes -> Low VRAM Attention (experimental),
314
+ Model Preview Override
315
+
316
+ On disk as well: the turbo LoRA named in the loader, and taeh3.safetensors for
317
+ the live preview (or set tiny_vae to none).
318
+
319
+ CLIP reaches this node FROM THE LORA LOADER, not from the encoder. That is what
320
+ makes the text half of every LoRA land. Do not rewire it back.
321
+
322
+ `steps` is 7, which only works with the turbo LoRA. Missing a pack? Its nodes
323
+ load as red boxes -- delete them, wire the loader straight into `model` and the
324
+ encoder into `clip`, and raise `steps` to 20 or so.
325
+
326
+ BEFORE YOU RUN IT, supply three pictures. Open REFERENCES on the node and drop
327
+ your own onto each thumbnail, or put files in ComfyUI/input/h3_refs named:
328
+
329
+ ref_face.jpg head-and-shoulders, even light -> @hero_face
330
+ ref_outfit.jpg full length, same person -> @hero_outfit
331
+ ref_room.jpg the room, wide -> @kitchen
332
+
333
+ Without them the run stops and names the reference it could not find. Nothing
334
+ guesses.
335
+
336
+ What each hop is testing:
337
+
338
+ 1 kitchen, dialogue all three references active
339
+ 2 lateral move to the window kitchen only
340
+ 3 exits through the doorway kitchen only
341
+ 4 hallway - UNSEEN space face re-asserted; no room reference exists
342
+ 5 dialogue, ZERO references identity, wardrobe and voice ride on the frame
343
+ pin plus subjects.1.locked/context alone
344
+ 6 returns to the kitchen on a MATCH CUT, not a continuous join -- one
345
+ unbroken take across two rooms makes the model
346
+ morph one into the other mid-movement
347
+
348
+ Hop 5 is the point of the whole thing. If the cook is still the same person in
349
+ the same apron with the same voice, with no picture in front of the encoder,
350
+ the register is doing its job.
351
+
352
+ `shots` on each reference is what schedules this. On a continuation chain,
353
+ omitting `shots` means HOP 1 ONLY. Right for a place plate, wrong for a face:
354
+ put every face reference on every hop, or the identity drifts and stays
355
+ drifted. List every hop a still should appear on.
356
+
357
+ Settings that are deliberate, not defaults:
358
+ control_after_generate = fixed or no two runs are comparable
359
+ tone_compensate = frame_shift counters the drift that accumulates over six
360
+ hops. Set it to `off` and re-queue for a
361
+ same-seed A/B: tone mode is not in the hop
362
+ key, so every hop replays from cache in
363
+ seconds instead of re-rendering.
364
+ cache_hops = on edit shot 4 and only shots 4-6 re-render
365
+ """
366
+
367
+
368
+ CARDS = [
369
+ # key, title, pos, size, colour
370
+ ("start", "START HERE", [-960, -400], [440, 940], GREEN, START_HERE),
371
+ ("rules", "The rules", [-960, 600], [440, 760], YELLOW, RULES),
372
+ ("refs", "References and @tags", [-960, 1420], [440, 640], YELLOW, REFERENCES),
373
+ ("directives", "Directives", [-480, -400], [440, 640], YELLOW, DIRECTIVES),
374
+ ("trouble", "When it goes wrong", [-480, 280], [440, 760], YELLOW, TROUBLE),
375
+ ("author", "Let a model write it", [-480, 1080], [440, 620], YELLOW, AUTHOR),
376
+ ]
tools/seam_probe.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Measure the brightness step at each seam of a finished master.
2
+
3
+ This is the *after* instrument. `tone_probe.py` reads the hop cache, which
4
+ stores raw pre-correction hops, so it always reports the same raw drift whether
5
+ or not compensation is on -- by design (the mode stays out of the hop key).
6
+ The correction is only visible in the delivered video, which is what this reads.
7
+
8
+ A single file's seam step is contaminated by real content change across the cut:
9
+ the frames either side are ~0.9 s apart in scene time. Pass two masters rendered
10
+ from the same seed and cache and that contamination is identical in both, so the
11
+ DIFFERENCE between them is a clean read on what the correction did.
12
+
13
+ D:\ComfyUI\venv\Scripts\python.exe tools\seam_probe.py --hops 3 before.mp4 after.mp4
14
+
15
+ Seam positions are derived, not guessed: hop 1 contributes its whole length and
16
+ every later hop contributes length - overlap, so
17
+ hop_len = (total_frames + (hops - 1) * overlap) / hops
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import os
23
+
24
+ import av
25
+ import numpy as np
26
+
27
+
28
+ def frame_means(path):
29
+ """Per-frame mean RGB. -> [N,3] float64."""
30
+ out = []
31
+ with av.open(path) as container:
32
+ for frame in container.decode(video=0):
33
+ out.append(frame.to_ndarray(format="rgb24").mean(axis=(0, 1)))
34
+ return np.asarray(out, dtype=np.float64) / 255.0
35
+
36
+
37
+ def seams(total, hops, overlap):
38
+ """Frame index of the first frame belonging to each later hop."""
39
+ if hops < 2:
40
+ return []
41
+ hop_len = (total + (hops - 1) * overlap) / hops
42
+ return [int(round(hop_len + k * (hop_len - overlap))) for k in range(hops - 1)]
43
+
44
+
45
+ def report(path, hops, overlap, window):
46
+ means = frame_means(path)
47
+ n = len(means)
48
+ cuts = seams(n, hops, overlap)
49
+ print(f"{os.path.basename(path)}: {n}f, seams at {cuts}")
50
+ steps = []
51
+ for k, c in enumerate(cuts, 1):
52
+ a = means[max(0, c - window):c]
53
+ b = means[c:c + window]
54
+ if not len(a) or not len(b):
55
+ print(f" seam {k} @ {c}: too close to an edge, skipped")
56
+ continue
57
+ step = b.mean(axis=0) - a.mean(axis=0)
58
+ luma = float(step.mean())
59
+ steps.append(luma)
60
+ print(f" seam {k} @ {c}: "
61
+ + " ".join(f"{ch}{v:+.5f}" for ch, v in zip("rgb", step))
62
+ + f" luma {luma:+.5f} ({luma * 255:+.2f}/255)")
63
+ if steps:
64
+ tot = sum(steps)
65
+ print(f" sum of steps: {tot:+.5f} ({tot * 255:+.2f}/255) "
66
+ f"mean |step| {np.mean(np.abs(steps)) * 255:.2f}/255")
67
+ return steps
68
+
69
+
70
+ def main():
71
+ ap = argparse.ArgumentParser(description=__doc__)
72
+ ap.add_argument("files", nargs="+", help="master mp4(s), oldest first")
73
+ ap.add_argument("--hops", type=int, required=True)
74
+ ap.add_argument("--overlap", type=int, default=22)
75
+ ap.add_argument("--window", type=int, default=6,
76
+ help="Frames averaged either side of the cut.")
77
+ args = ap.parse_args()
78
+
79
+ all_steps = []
80
+ for p in args.files:
81
+ if not os.path.isfile(p):
82
+ print(f"{p}: not found")
83
+ return 1
84
+ all_steps.append(report(p, args.hops, args.overlap, args.window))
85
+ print()
86
+
87
+ if len(all_steps) == 2 and all_steps[0] and all_steps[1]:
88
+ print("A/B (same seed + cache, so content change cancels):")
89
+ for k, (a, b) in enumerate(zip(*all_steps), 1):
90
+ print(f" seam {k}: {a * 255:+.2f}/255 -> {b * 255:+.2f}/255 "
91
+ f"({(abs(b) - abs(a)) * 255:+.2f}/255 magnitude)")
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())
tools/tone_probe.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measure the real per-hop tone drift, from the hop cache.
2
+
3
+ Why the cache and not the rendered master: the estimate has to pair hop N's last
4
+ `overlap` frames with hop N+1's *first* `overlap` frames, which are the model's
5
+ regeneration of the same content. The join drops that second copy
6
+ (`h3_ref_chain.py`, `master_imgs[...] = imgs[overlap_n:]`), so it does not exist
7
+ in the output video. It does exist in the hop cache, which stores each hop's
8
+ full pre-trim frames as lossless 16-bit FFV1 -- which makes the cache the only
9
+ honest instrument for this measurement.
10
+
11
+ Comparing frames either side of the seam in a finished master is NOT the same
12
+ measurement: those frames are ~0.9 s apart in scene time, so the number includes
13
+ whatever the scene did in between.
14
+
15
+ Usage, from the pack directory:
16
+
17
+ D:\\ComfyUI\\venv\\Scripts\\python.exe tools\\tone_probe.py [--overlap 22] [--root PATH]
18
+
19
+ ComfyUI wipes `temp/` on startup, so this only sees a cache written by the
20
+ currently running session: render with `cache_hops=on` and probe before you
21
+ restart.
22
+
23
+ Read-only apart from one side effect worth knowing: `HopStore.get()` touches
24
+ each entry's mtime for LRU, so probing marks every hop as recently used.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ import types
33
+
34
+ # Derived from this file's position (pack/tools/ -> custom_nodes/pack ->
35
+ # ComfyUI root), so the probe works on a checkout that is not this machine's.
36
+ # Matches the root h3_ref_chain.py builds from folder_paths.get_temp_directory().
37
+ _COMFY_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
38
+ os.path.dirname(os.path.abspath(__file__)))))
39
+ DEFAULT_ROOT = os.path.join(_COMFY_ROOT, "temp", "h3_ref_chain_hops")
40
+
41
+
42
+ def _load_store():
43
+ """Import the pack's own store.py without installing the pack.
44
+
45
+ The directory has dashes, so it is not a legal module name; register a
46
+ synthetic package pointing at it. Same trick the offline tests use.
47
+ """
48
+ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
+ pkg = types.ModuleType("h3pack")
50
+ pkg.__path__ = [here]
51
+ sys.modules["h3pack"] = pkg
52
+ from h3pack import store # noqa: PLC0415
53
+ return store
54
+
55
+
56
+ def _hops(store, root):
57
+ """Cached hops as [(hop_index, key)], ordered, skipping unreadable meta."""
58
+ out = []
59
+ for key, _size, _mtime in store.HopStore(root).entries():
60
+ meta_path = os.path.join(root, key + store.META_EXT)
61
+ try:
62
+ with open(meta_path, encoding="utf-8") as fh:
63
+ meta = json.load(fh)
64
+ except (OSError, ValueError) as e:
65
+ print(f" ! {key[:8]}: unreadable meta ({e!r}), skipped")
66
+ continue
67
+ hop = meta.get("hop")
68
+ if hop is None:
69
+ print(f" ! {key[:8]}: meta has no hop index, skipped")
70
+ continue
71
+ out.append((int(hop), key))
72
+ return sorted(out)
73
+
74
+
75
+ def main():
76
+ ap = argparse.ArgumentParser(description=__doc__)
77
+ ap.add_argument("--root", default=DEFAULT_ROOT)
78
+ ap.add_argument("--overlap", type=int, default=22,
79
+ help="Frames pinned per join. Must match the render.")
80
+ args = ap.parse_args()
81
+
82
+ if not os.path.isdir(args.root):
83
+ print(f"No hop cache at {args.root}.\n"
84
+ f"Render with cache_hops=on and probe before restarting ComfyUI "
85
+ f"(temp/ is wiped on start).")
86
+ return 1
87
+
88
+ store = _load_store()
89
+ import torch # noqa: PLC0415 -- after store, so the venv check fails first
90
+
91
+ hops = _hops(store, args.root)
92
+ if len(hops) < 2:
93
+ print(f"Found {len(hops)} cached hop(s) in {args.root}; need at least 2.")
94
+ return 1
95
+
96
+ print(f"{len(hops)} hops in {args.root}, overlap {args.overlap}f\n")
97
+ st = store.HopStore(args.root)
98
+ ov = int(args.overlap)
99
+
100
+ prev = None
101
+ cumulative = torch.zeros(3)
102
+ for hop, key in hops:
103
+ got = st.get(key)
104
+ if got is None:
105
+ print(f"hop {hop}: entry {key[:8]} incomplete, skipped")
106
+ continue
107
+ imgs = got[0]
108
+ if prev is None:
109
+ print(f"hop {hop} ({key[:8]}): {imgs.shape[0]}f "
110
+ f"mean {float(imgs.mean()):.4f} [reference]")
111
+ prev = imgs
112
+ continue
113
+
114
+ n = min(ov, prev.shape[0], imgs.shape[0])
115
+ src = prev[-n:] # what hop N ended on
116
+ tgt = imgs[:n] # hop N+1's regeneration of it
117
+ # Per channel over the matched overlap: this is exactly what
118
+ # tone.compensate's frame_shift mode fits.
119
+ drift = tgt.mean(dim=(0, 1, 2)) - src.mean(dim=(0, 1, 2))
120
+ cumulative = cumulative + drift
121
+ luma = float(drift.mean())
122
+ print(f"hop {hop} ({key[:8]}): {imgs.shape[0]}f "
123
+ f"mean {float(imgs.mean()):.4f}")
124
+ print(f" drift vs hop {hop - 1} over {n}f: "
125
+ + " ".join(f"{c}{v:+.5f}" for c, v in zip("rgb", drift.tolist()))
126
+ + f" luma {luma:+.5f} ({luma * 255:+.2f}/255)")
127
+ print(f" cumulative from hop 1: "
128
+ + " ".join(f"{c}{v:+.5f}" for c, v in zip("rgb", cumulative.tolist()))
129
+ + f" luma {float(cumulative.mean()):+.5f} "
130
+ f"({float(cumulative.mean()) * 255:+.2f}/255)")
131
+ prev = imgs
132
+
133
+ total = float(cumulative.mean())
134
+ print(f"\nTotal drift across the chain: {total:+.5f} ({total * 255:+.2f}/255)")
135
+ print("Positive = later hops are brighter. A step below ~1/255 is not worth "
136
+ "correcting; the correction itself costs a clamp.")
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())
workflows/HandTieClips_Showcase.json ADDED
@@ -0,0 +1,1213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "h3-ref2va-chain",
3
+ "revision": 1,
4
+ "last_node_id": 34,
5
+ "last_link_id": 18,
6
+ "nodes": [
7
+ {
8
+ "id": 2,
9
+ "type": "UNETLoader",
10
+ "pos": [
11
+ 0,
12
+ 340
13
+ ],
14
+ "size": [
15
+ 420,
16
+ 82
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [
22
+ {
23
+ "localized_name": "unet_name",
24
+ "name": "unet_name",
25
+ "type": "COMBO",
26
+ "widget": {
27
+ "name": "unet_name"
28
+ },
29
+ "link": null
30
+ },
31
+ {
32
+ "localized_name": "weight_dtype",
33
+ "name": "weight_dtype",
34
+ "type": "COMBO",
35
+ "widget": {
36
+ "name": "weight_dtype"
37
+ },
38
+ "link": null
39
+ }
40
+ ],
41
+ "outputs": [
42
+ {
43
+ "localized_name": "MODEL",
44
+ "name": "MODEL",
45
+ "type": "MODEL",
46
+ "slot_index": 0,
47
+ "links": [
48
+ 11
49
+ ]
50
+ }
51
+ ],
52
+ "title": "DiT (ref2va)",
53
+ "properties": {
54
+ "cnr_id": "comfy-core",
55
+ "ver": "0.34.0",
56
+ "Node name for S&R": "UNETLoader"
57
+ },
58
+ "widgets_values": [
59
+ "minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors",
60
+ "default"
61
+ ]
62
+ },
63
+ {
64
+ "id": 3,
65
+ "type": "CLIPLoader",
66
+ "pos": [
67
+ 0,
68
+ 460
69
+ ],
70
+ "size": [
71
+ 420,
72
+ 106
73
+ ],
74
+ "flags": {},
75
+ "order": 1,
76
+ "mode": 0,
77
+ "inputs": [
78
+ {
79
+ "localized_name": "clip_name",
80
+ "name": "clip_name",
81
+ "type": "COMBO",
82
+ "widget": {
83
+ "name": "clip_name"
84
+ },
85
+ "link": null
86
+ },
87
+ {
88
+ "localized_name": "type",
89
+ "name": "type",
90
+ "type": "COMBO",
91
+ "widget": {
92
+ "name": "type"
93
+ },
94
+ "link": null
95
+ },
96
+ {
97
+ "localized_name": "device",
98
+ "name": "device",
99
+ "shape": 7,
100
+ "type": "COMBO",
101
+ "widget": {
102
+ "name": "device"
103
+ },
104
+ "link": null
105
+ }
106
+ ],
107
+ "outputs": [
108
+ {
109
+ "localized_name": "CLIP",
110
+ "name": "CLIP",
111
+ "type": "CLIP",
112
+ "slot_index": 0,
113
+ "links": [
114
+ 12
115
+ ]
116
+ }
117
+ ],
118
+ "title": "Text encoder",
119
+ "properties": {
120
+ "cnr_id": "comfy-core",
121
+ "ver": "0.34.0",
122
+ "Node name for S&R": "CLIPLoader"
123
+ },
124
+ "widgets_values": [
125
+ "qwen3vl_32b_minimax_h3_int8_convrot.safetensors",
126
+ "minimax",
127
+ "default"
128
+ ]
129
+ },
130
+ {
131
+ "id": 4,
132
+ "type": "VAELoader",
133
+ "pos": [
134
+ 0,
135
+ 600
136
+ ],
137
+ "size": [
138
+ 420,
139
+ 82
140
+ ],
141
+ "flags": {},
142
+ "order": 2,
143
+ "mode": 0,
144
+ "inputs": [
145
+ {
146
+ "localized_name": "vae_name",
147
+ "name": "vae_name",
148
+ "type": "COMBO",
149
+ "widget": {
150
+ "name": "vae_name"
151
+ },
152
+ "link": null
153
+ }
154
+ ],
155
+ "outputs": [
156
+ {
157
+ "localized_name": "VAE",
158
+ "name": "VAE",
159
+ "type": "VAE",
160
+ "slot_index": 0,
161
+ "links": [
162
+ 3
163
+ ]
164
+ }
165
+ ],
166
+ "title": "Video VAE",
167
+ "properties": {
168
+ "cnr_id": "comfy-core",
169
+ "ver": "0.34.0",
170
+ "Node name for S&R": "VAELoader"
171
+ },
172
+ "widgets_values": [
173
+ "minimax_h3_video_vae_int8_convrot.safetensors"
174
+ ]
175
+ },
176
+ {
177
+ "id": 5,
178
+ "type": "VAELoader",
179
+ "pos": [
180
+ 0,
181
+ 720
182
+ ],
183
+ "size": [
184
+ 420,
185
+ 82
186
+ ],
187
+ "flags": {},
188
+ "order": 3,
189
+ "mode": 0,
190
+ "inputs": [
191
+ {
192
+ "localized_name": "vae_name",
193
+ "name": "vae_name",
194
+ "type": "COMBO",
195
+ "widget": {
196
+ "name": "vae_name"
197
+ },
198
+ "link": null
199
+ }
200
+ ],
201
+ "outputs": [
202
+ {
203
+ "localized_name": "VAE",
204
+ "name": "VAE",
205
+ "type": "VAE",
206
+ "slot_index": 0,
207
+ "links": [
208
+ 4
209
+ ]
210
+ }
211
+ ],
212
+ "title": "Audio VAE",
213
+ "properties": {
214
+ "cnr_id": "comfy-core",
215
+ "ver": "0.34.0",
216
+ "Node name for S&R": "VAELoader"
217
+ },
218
+ "widgets_values": [
219
+ "minimax_h3_audio_vae_fp32.safetensors"
220
+ ]
221
+ },
222
+ {
223
+ "id": 7,
224
+ "type": "HandTieClips",
225
+ "pos": [
226
+ 994,
227
+ 427
228
+ ],
229
+ "size": [
230
+ 968.3162169696975,
231
+ 1426.2006199999998
232
+ ],
233
+ "flags": {},
234
+ "order": 4,
235
+ "mode": 0,
236
+ "inputs": [
237
+ {
238
+ "localized_name": "model",
239
+ "name": "model",
240
+ "type": "MODEL",
241
+ "link": 17
242
+ },
243
+ {
244
+ "localized_name": "clip",
245
+ "name": "clip",
246
+ "type": "CLIP",
247
+ "link": 18
248
+ },
249
+ {
250
+ "localized_name": "vae",
251
+ "name": "vae",
252
+ "type": "VAE",
253
+ "link": 3
254
+ },
255
+ {
256
+ "localized_name": "audio_vae",
257
+ "name": "audio_vae",
258
+ "type": "VAE",
259
+ "link": 4
260
+ },
261
+ {
262
+ "localized_name": "continuity_state",
263
+ "name": "continuity_state",
264
+ "shape": 7,
265
+ "type": "STRING",
266
+ "link": null
267
+ },
268
+ {
269
+ "localized_name": "prompt",
270
+ "name": "prompt",
271
+ "type": "STRING",
272
+ "widget": {
273
+ "name": "prompt"
274
+ },
275
+ "link": null
276
+ },
277
+ {
278
+ "localized_name": "chains",
279
+ "name": "chains",
280
+ "type": "COMBO",
281
+ "widget": {
282
+ "name": "chains"
283
+ },
284
+ "link": null
285
+ },
286
+ {
287
+ "localized_name": "resolution",
288
+ "name": "resolution",
289
+ "type": "COMBO",
290
+ "widget": {
291
+ "name": "resolution"
292
+ },
293
+ "link": null
294
+ },
295
+ {
296
+ "localized_name": "aspect",
297
+ "name": "aspect",
298
+ "type": "COMBO",
299
+ "widget": {
300
+ "name": "aspect"
301
+ },
302
+ "link": null
303
+ },
304
+ {
305
+ "localized_name": "duration",
306
+ "name": "duration",
307
+ "type": "COMBO",
308
+ "widget": {
309
+ "name": "duration"
310
+ },
311
+ "link": null
312
+ },
313
+ {
314
+ "localized_name": "overlap",
315
+ "name": "overlap",
316
+ "type": "COMBO",
317
+ "widget": {
318
+ "name": "overlap"
319
+ },
320
+ "link": null
321
+ },
322
+ {
323
+ "localized_name": "seed",
324
+ "name": "seed",
325
+ "type": "INT",
326
+ "widget": {
327
+ "name": "seed"
328
+ },
329
+ "link": null
330
+ },
331
+ {
332
+ "localized_name": "seed_per_shot",
333
+ "name": "seed_per_shot",
334
+ "type": "BOOLEAN",
335
+ "widget": {
336
+ "name": "seed_per_shot"
337
+ },
338
+ "link": null
339
+ },
340
+ {
341
+ "localized_name": "steps",
342
+ "name": "steps",
343
+ "type": "INT",
344
+ "widget": {
345
+ "name": "steps"
346
+ },
347
+ "link": null
348
+ },
349
+ {
350
+ "localized_name": "sampler_name",
351
+ "name": "sampler_name",
352
+ "type": "COMBO",
353
+ "widget": {
354
+ "name": "sampler_name"
355
+ },
356
+ "link": null
357
+ },
358
+ {
359
+ "localized_name": "scheduler",
360
+ "name": "scheduler",
361
+ "type": "COMBO",
362
+ "widget": {
363
+ "name": "scheduler"
364
+ },
365
+ "link": null
366
+ },
367
+ {
368
+ "localized_name": "shift_video",
369
+ "name": "shift_video",
370
+ "type": "FLOAT",
371
+ "widget": {
372
+ "name": "shift_video"
373
+ },
374
+ "link": null
375
+ },
376
+ {
377
+ "localized_name": "shift_audio",
378
+ "name": "shift_audio",
379
+ "type": "FLOAT",
380
+ "widget": {
381
+ "name": "shift_audio"
382
+ },
383
+ "link": null
384
+ },
385
+ {
386
+ "localized_name": "ref_image_size",
387
+ "name": "ref_image_size",
388
+ "type": "COMBO",
389
+ "widget": {
390
+ "name": "ref_image_size"
391
+ },
392
+ "link": null
393
+ },
394
+ {
395
+ "localized_name": "hop_script",
396
+ "name": "hop_script",
397
+ "shape": 7,
398
+ "type": "COMBO",
399
+ "widget": {
400
+ "name": "hop_script"
401
+ },
402
+ "link": null
403
+ },
404
+ {
405
+ "localized_name": "pin_to_qwen",
406
+ "name": "pin_to_qwen",
407
+ "shape": 7,
408
+ "type": "COMBO",
409
+ "widget": {
410
+ "name": "pin_to_qwen"
411
+ },
412
+ "link": null
413
+ },
414
+ {
415
+ "localized_name": "shot_plan",
416
+ "name": "shot_plan",
417
+ "shape": 7,
418
+ "type": "STRING",
419
+ "widget": {
420
+ "name": "shot_plan"
421
+ },
422
+ "link": null
423
+ },
424
+ {
425
+ "localized_name": "ref_plan",
426
+ "name": "ref_plan",
427
+ "shape": 7,
428
+ "type": "STRING",
429
+ "widget": {
430
+ "name": "ref_plan"
431
+ },
432
+ "link": null
433
+ },
434
+ {
435
+ "localized_name": "cache_hops",
436
+ "name": "cache_hops",
437
+ "shape": 7,
438
+ "type": "COMBO",
439
+ "widget": {
440
+ "name": "cache_hops"
441
+ },
442
+ "link": null
443
+ },
444
+ {
445
+ "localized_name": "cache_budget_gb",
446
+ "name": "cache_budget_gb",
447
+ "shape": 7,
448
+ "type": "FLOAT",
449
+ "widget": {
450
+ "name": "cache_budget_gb"
451
+ },
452
+ "link": null
453
+ },
454
+ {
455
+ "localized_name": "audio_pin_frames",
456
+ "name": "audio_pin_frames",
457
+ "shape": 7,
458
+ "type": "INT",
459
+ "widget": {
460
+ "name": "audio_pin_frames"
461
+ },
462
+ "link": null
463
+ },
464
+ {
465
+ "localized_name": "pin_renorm",
466
+ "name": "pin_renorm",
467
+ "shape": 7,
468
+ "type": "COMBO",
469
+ "widget": {
470
+ "name": "pin_renorm"
471
+ },
472
+ "link": null
473
+ },
474
+ {
475
+ "localized_name": "pin_noise",
476
+ "name": "pin_noise",
477
+ "shape": 7,
478
+ "type": "FLOAT",
479
+ "widget": {
480
+ "name": "pin_noise"
481
+ },
482
+ "link": null
483
+ },
484
+ {
485
+ "localized_name": "tone_compensate",
486
+ "name": "tone_compensate",
487
+ "shape": 7,
488
+ "type": "COMBO",
489
+ "widget": {
490
+ "name": "tone_compensate"
491
+ },
492
+ "link": null
493
+ },
494
+ {
495
+ "localized_name": "start_image_file",
496
+ "name": "start_image_file",
497
+ "shape": 7,
498
+ "type": "STRING",
499
+ "widget": {
500
+ "name": "start_image_file"
501
+ },
502
+ "link": null
503
+ },
504
+ {
505
+ "localized_name": "reference_video_file",
506
+ "name": "reference_video_file",
507
+ "shape": 7,
508
+ "type": "STRING",
509
+ "widget": {
510
+ "name": "reference_video_file"
511
+ },
512
+ "link": null
513
+ },
514
+ {
515
+ "localized_name": "voice_file",
516
+ "name": "voice_file",
517
+ "shape": 7,
518
+ "type": "STRING",
519
+ "widget": {
520
+ "name": "voice_file"
521
+ },
522
+ "link": null
523
+ }
524
+ ],
525
+ "outputs": [
526
+ {
527
+ "localized_name": "images",
528
+ "name": "images",
529
+ "type": "IMAGE",
530
+ "slot_index": 0,
531
+ "links": [
532
+ 5
533
+ ]
534
+ },
535
+ {
536
+ "localized_name": "audio",
537
+ "name": "audio",
538
+ "type": "AUDIO",
539
+ "slot_index": 1,
540
+ "links": [
541
+ 6
542
+ ]
543
+ },
544
+ {
545
+ "localized_name": "info",
546
+ "name": "info",
547
+ "type": "STRING",
548
+ "slot_index": 2,
549
+ "links": [
550
+ 7
551
+ ]
552
+ }
553
+ ],
554
+ "properties": {
555
+ "Node name for S&R": "H3RefChain",
556
+ "h3_plan_backup": "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"Live-action, natural indoor light. The cook stands at the counter in @kitchen, looks up from the chopping board, and speaks one short line to someone off-frame.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"The cook sets the knife down, turns toward the window, and finishes her last word as she looks out.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"The cook leans back against the counter with her lips closed, and lets her eyes move slowly across the room. The room is still, with only the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pull_back\",\n \"framing\": \"medium\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
557
+ "h3_editor_mode": "shots"
558
+ },
559
+ "widgets_values": [
560
+ "Live-action, natural indoor light. The person looks exactly as in the reference photographs.\n\nThey sit at a table, look up, and speak one short line. Then they settle, watching the room.",
561
+ "6",
562
+ "0.3 MP",
563
+ "16:9 landscape",
564
+ "7 s",
565
+ "0.9 s",
566
+ 427058607873049,
567
+ "fixed",
568
+ true,
569
+ 7,
570
+ "res_multistep",
571
+ "simple",
572
+ 12,
573
+ 3,
574
+ "match",
575
+ "next",
576
+ "last frame",
577
+ "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"@hero_face stands at the counter in @kitchen, a grey apron over a grey t-shirt. She looks up from the chopping board and says, 'You are early. I have barely started.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"She sets the knife down and walks the length of the counter to the window of @kitchen, looking out at the street. Her steps are soft on the tiles and the refrigerator hums behind her.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"She turns from the window, crosses @kitchen and pushes through the doorway into the hallway beyond, the room falling away behind her. Her footsteps carry on the tiles.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"wide\",\n \"pace\": \"brisk\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s4\",\n \"beat\": \"@hero_face walks down a narrow hallway hung with coats, one hand trailing along the wall, her footsteps muffled on the runner. The hallway walls stand close on either side of her and the coats brush past her shoulder.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"handheld\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s5\",\n \"beat\": \"She reaches the window at the end of the hall and rests one hand on the frame, then half turns back over her shoulder and says, 'It is still raining. We will have to do it inside.' She looks back out at the glass with her lips closed, and the rain taps steadily on the pane.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n },\n {\n \"id\": \"s6\",\n \"beat\": \"She walks back along the hallway and through the doorway to the counter in @kitchen, picking the knife up again. The refrigerator hums and the knife starts on the board.\",\n \"directives\": {\n \"join\": \"match_cut\",\n \"camera\": \"pull_back\",\n \"framing\": \"wide\",\n \"pace\": \"steady\",\n \"tail\": \"hold\"\n }\n }\n ]\n}",
578
+ "{\n \"refs\": [\n {\n \"tag\": \"hero_face\",\n \"file\": \"ref_face.jpg\",\n \"subject\": 1,\n \"retention\": \"fully_preserved\",\n \"shots\": [\n 1,\n 2,\n 3,\n 4,\n 5,\n 6\n ],\n \"desc\": \"head-and-shoulders photograph of the cook, even light\"\n },\n {\n \"tag\": \"hero_outfit\",\n \"file\": \"ref_outfit.jpg\",\n \"subject\": 1,\n \"retention\": \"partially_copy\",\n \"shots\": [\n 1\n ],\n \"desc\": \"full-length photograph of the same cook, grey apron over a grey t-shirt\"\n },\n {\n \"tag\": \"kitchen\",\n \"file\": \"ref_room.jpg\",\n \"retention\": \"reference\",\n \"shots\": [\n 1,\n 2,\n 3,\n 6\n ],\n \"desc\": \"the kitchen: counter, window, and the light coming through it\"\n }\n ],\n \"subjects\": {\n \"1\": {\n \"name\": \"the cook\",\n \"locked\": \"the same face, the same short dark hair, the same silver stud earrings\",\n \"context\": \"the grey apron stays tied over the grey t-shirt\"\n }\n }\n}",
579
+ "on",
580
+ 19,
581
+ 24,
582
+ "off",
583
+ 0,
584
+ "frame_shift",
585
+ "",
586
+ "",
587
+ "",
588
+ "Live-action, natural light, one continuous take."
589
+ ],
590
+ "title": "H3 Ref2VA Chain - showcase 6x7s"
591
+ },
592
+ {
593
+ "id": 16,
594
+ "type": "HTCChainPreview",
595
+ "pos": [
596
+ 2258.827272727274,
597
+ -90.16060606060621
598
+ ],
599
+ "size": [
600
+ 400,
601
+ 460
602
+ ],
603
+ "flags": {},
604
+ "order": 5,
605
+ "mode": 0,
606
+ "inputs": [
607
+ {
608
+ "localized_name": "images",
609
+ "name": "images",
610
+ "type": "IMAGE",
611
+ "link": 5
612
+ },
613
+ {
614
+ "localized_name": "audio",
615
+ "name": "audio",
616
+ "shape": 7,
617
+ "type": "AUDIO",
618
+ "link": 6
619
+ },
620
+ {
621
+ "localized_name": "info",
622
+ "name": "info",
623
+ "shape": 7,
624
+ "type": "STRING",
625
+ "link": 7
626
+ }
627
+ ],
628
+ "outputs": [
629
+ {
630
+ "localized_name": "images",
631
+ "name": "images",
632
+ "type": "IMAGE",
633
+ "slot_index": 0,
634
+ "links": [
635
+ 8
636
+ ]
637
+ },
638
+ {
639
+ "localized_name": "audio",
640
+ "name": "audio",
641
+ "type": "AUDIO",
642
+ "slot_index": 1,
643
+ "links": [
644
+ 9
645
+ ]
646
+ }
647
+ ],
648
+ "properties": {
649
+ "Node name for S&R": "H3ChainPreview",
650
+ "h3rcPanelH": 40
651
+ },
652
+ "widgets_values": [
653
+ ""
654
+ ]
655
+ },
656
+ {
657
+ "id": 8,
658
+ "type": "CreateVideo",
659
+ "pos": [
660
+ 2991.7272727272743,
661
+ 37.33939393939383
662
+ ],
663
+ "size": [
664
+ 300,
665
+ 130
666
+ ],
667
+ "flags": {},
668
+ "order": 6,
669
+ "mode": 0,
670
+ "inputs": [
671
+ {
672
+ "localized_name": "images",
673
+ "name": "images",
674
+ "type": "IMAGE",
675
+ "link": 8
676
+ },
677
+ {
678
+ "localized_name": "audio",
679
+ "name": "audio",
680
+ "shape": 7,
681
+ "type": "AUDIO",
682
+ "link": 9
683
+ },
684
+ {
685
+ "localized_name": "fps",
686
+ "name": "fps",
687
+ "type": "FLOAT",
688
+ "widget": {
689
+ "name": "fps"
690
+ },
691
+ "link": null
692
+ },
693
+ {
694
+ "localized_name": "bit_depth",
695
+ "name": "bit_depth",
696
+ "shape": 7,
697
+ "type": "COMBO",
698
+ "widget": {
699
+ "name": "bit_depth"
700
+ },
701
+ "link": null
702
+ },
703
+ {
704
+ "localized_name": "color_space",
705
+ "name": "color_space",
706
+ "shape": 7,
707
+ "type": "COMBO",
708
+ "widget": {
709
+ "name": "color_space"
710
+ },
711
+ "link": null
712
+ }
713
+ ],
714
+ "outputs": [
715
+ {
716
+ "localized_name": "VIDEO",
717
+ "name": "VIDEO",
718
+ "type": "VIDEO",
719
+ "slot_index": 0,
720
+ "links": [
721
+ 10
722
+ ]
723
+ }
724
+ ],
725
+ "properties": {
726
+ "cnr_id": "comfy-core",
727
+ "ver": "0.34.0",
728
+ "Node name for S&R": "CreateVideo"
729
+ },
730
+ "widgets_values": [
731
+ 24,
732
+ "auto",
733
+ "sRGB"
734
+ ]
735
+ },
736
+ {
737
+ "id": 9,
738
+ "type": "SaveVideo",
739
+ "pos": [
740
+ 3032.515151515151,
741
+ 714.542424242424
742
+ ],
743
+ "size": [
744
+ 340,
745
+ 106
746
+ ],
747
+ "flags": {},
748
+ "order": 7,
749
+ "mode": 0,
750
+ "inputs": [
751
+ {
752
+ "localized_name": "video",
753
+ "name": "video",
754
+ "type": "VIDEO",
755
+ "link": 10
756
+ },
757
+ {
758
+ "localized_name": "filename_prefix",
759
+ "name": "filename_prefix",
760
+ "type": "STRING",
761
+ "widget": {
762
+ "name": "filename_prefix"
763
+ },
764
+ "link": null
765
+ },
766
+ {
767
+ "localized_name": "format",
768
+ "name": "format",
769
+ "type": "COMFY_DYNAMICCOMBO_V3",
770
+ "widget": {
771
+ "name": "format"
772
+ },
773
+ "link": null
774
+ },
775
+ {
776
+ "localized_name": "format.codec",
777
+ "name": "format.codec",
778
+ "type": "COMFY_DYNAMICCOMBO_V3",
779
+ "widget": {
780
+ "name": "format.codec"
781
+ },
782
+ "link": null
783
+ },
784
+ {
785
+ "localized_name": "codec",
786
+ "name": "codec",
787
+ "shape": 7,
788
+ "type": "COMFY_DYNAMICCOMBO_V3",
789
+ "widget": {
790
+ "name": "codec"
791
+ },
792
+ "link": null
793
+ }
794
+ ],
795
+ "outputs": [
796
+ {
797
+ "localized_name": "video",
798
+ "name": "video",
799
+ "type": "VIDEO",
800
+ "links": []
801
+ }
802
+ ],
803
+ "title": "SAVE",
804
+ "properties": {
805
+ "cnr_id": "comfy-core",
806
+ "ver": "0.34.0",
807
+ "Node name for S&R": "SaveVideo"
808
+ },
809
+ "widgets_values": [
810
+ "video/HANDTIECLIPS/chain",
811
+ "auto",
812
+ "auto",
813
+ "auto"
814
+ ]
815
+ },
816
+ {
817
+ "id": 1,
818
+ "type": "Note",
819
+ "pos": [
820
+ -137.49999999999997,
821
+ -359.15000000000015
822
+ ],
823
+ "size": [
824
+ 440,
825
+ 900
826
+ ],
827
+ "flags": {},
828
+ "order": 8,
829
+ "mode": 0,
830
+ "inputs": [],
831
+ "outputs": [],
832
+ "title": "H3 Ref2VA Chain",
833
+ "properties": {},
834
+ "widgets_values": [
835
+ "Hand Tie Clips -- SHOWCASE (6 hops x 7 s = 39.2 s)\n\nA continuity stress test, and the honest demonstration of what the reference\nregister buys you.\n\nREQUIRES a turbo stack on the MODEL wire, which is how this node is actually\nrun here:\n\n UNETLoader -> LoRA Loader Stack (turbo LoRA) -> H3 AdaLN LoRA Fix\n -> MiniMax H3 Low VRAM Attention -> H3 SLA Attention\n -> Model Preview Override -> this node\n\n ComfyUI-PlagueKind-Nodes -> LoRA Loader Stack, AdaLN Fix, SLA Attention\n ComfyUI-KJNodes -> Low VRAM Attention (experimental),\n Model Preview Override\n\nOn disk as well: the turbo LoRA named in the loader, and taeh3.safetensors for\nthe live preview (or set tiny_vae to none).\n\nCLIP reaches this node FROM THE LORA LOADER, not from the encoder. That is what\nmakes the text half of every LoRA land. Do not rewire it back.\n\n`steps` is 7, which only works with the turbo LoRA. Missing a pack? Its nodes\nload as red boxes -- delete them, wire the loader straight into `model` and the\nencoder into `clip`, and raise `steps` to 20 or so.\n\nBEFORE YOU RUN IT, supply three pictures. Open REFERENCES on the node and drop\nyour own onto each thumbnail, or put files in ComfyUI/input/h3_refs named:\n\n ref_face.jpg head-and-shoulders, even light -> @hero_face\n ref_outfit.jpg full length, same person -> @hero_outfit\n ref_room.jpg the room, wide -> @kitchen\n\nWithout them the run stops and names the reference it could not find. Nothing\nguesses.\n\nWhat each hop is testing:\n\n 1 kitchen, dialogue all three references active\n 2 lateral move to the window kitchen only\n 3 exits through the doorway kitchen only\n 4 hallway - UNSEEN space face re-asserted; no room reference exists\n 5 dialogue, ZERO references identity, wardrobe and voice ride on the frame\n pin plus subjects.1.locked/context alone\n 6 returns to the kitchen on a MATCH CUT, not a continuous join -- one\n unbroken take across two rooms makes the model\n morph one into the other mid-movement\n\nHop 5 is the point of the whole thing. If the cook is still the same person in\nthe same apron with the same voice, with no picture in front of the encoder,\nthe register is doing its job.\n\n`shots` on each reference is what schedules this. On a continuation chain,\nomitting `shots` means HOP 1 ONLY. Right for a place plate, wrong for a face:\nput every face reference on every hop, or the identity drifts and stays\ndrifted. List every hop a still should appear on.\n\nSettings that are deliberate, not defaults:\n control_after_generate = fixed or no two runs are comparable\n tone_compensate = frame_shift counters the drift that accumulates over six\n hops. Set it to `off` and re-queue for a\n same-seed A/B: tone mode is not in the hop\n key, so every hop replays from cache in\n seconds instead of re-rendering.\n cache_hops = on edit shot 4 and only shots 4-6 re-render\n"
836
+ ],
837
+ "color": "#432",
838
+ "bgcolor": "#653"
839
+ },
840
+ {
841
+ "id": 30,
842
+ "type": "LTX_lora_loader",
843
+ "pos": [
844
+ 520,
845
+ -40
846
+ ],
847
+ "size": [
848
+ 420,
849
+ 240
850
+ ],
851
+ "flags": {},
852
+ "order": 0,
853
+ "mode": 0,
854
+ "inputs": [
855
+ {
856
+ "name": "model",
857
+ "type": "MODEL",
858
+ "link": 11
859
+ },
860
+ {
861
+ "name": "clip",
862
+ "type": "CLIP",
863
+ "link": 12
864
+ }
865
+ ],
866
+ "outputs": [
867
+ {
868
+ "name": "model",
869
+ "type": "MODEL",
870
+ "links": [
871
+ 13
872
+ ]
873
+ },
874
+ {
875
+ "name": "clip",
876
+ "type": "CLIP",
877
+ "links": [
878
+ 18
879
+ ]
880
+ }
881
+ ],
882
+ "title": "LoRA Loader Stack (turbo)",
883
+ "properties": {
884
+ "htc_speed": "lora",
885
+ "Node name for S&R": "LTX_lora_loader"
886
+ },
887
+ "widgets_values": [
888
+ "minimax",
889
+ "[{\"on\":true,\"lora\":\"minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors\",\"str\":1,\"v\":1,\"a\":1,\"t\":1}]",
890
+ ""
891
+ ]
892
+ },
893
+ {
894
+ "id": 31,
895
+ "type": "H3AdaLNLoRAFix",
896
+ "pos": [
897
+ 520,
898
+ 240
899
+ ],
900
+ "size": [
901
+ 340,
902
+ 150
903
+ ],
904
+ "flags": {},
905
+ "order": 0,
906
+ "mode": 0,
907
+ "inputs": [
908
+ {
909
+ "name": "model",
910
+ "type": "MODEL",
911
+ "link": 13
912
+ }
913
+ ],
914
+ "outputs": [
915
+ {
916
+ "name": "MODEL",
917
+ "type": "MODEL",
918
+ "links": [
919
+ 14
920
+ ]
921
+ }
922
+ ],
923
+ "title": "H3 AdaLN LoRA Fix",
924
+ "properties": {
925
+ "htc_speed": "adaln",
926
+ "Node name for S&R": "H3AdaLNLoRAFix"
927
+ },
928
+ "widgets_values": [
929
+ "port"
930
+ ]
931
+ },
932
+ {
933
+ "id": 32,
934
+ "type": "MiniMaxLowVRAMAttention",
935
+ "pos": [
936
+ 520,
937
+ 430
938
+ ],
939
+ "size": [
940
+ 330,
941
+ 58
942
+ ],
943
+ "flags": {},
944
+ "order": 0,
945
+ "mode": 0,
946
+ "inputs": [
947
+ {
948
+ "name": "model",
949
+ "type": "MODEL",
950
+ "link": 14
951
+ }
952
+ ],
953
+ "outputs": [
954
+ {
955
+ "name": "model",
956
+ "type": "MODEL",
957
+ "links": [
958
+ 15
959
+ ]
960
+ }
961
+ ],
962
+ "title": "MiniMax H3 Low VRAM Attention",
963
+ "properties": {
964
+ "htc_speed": "lowvram",
965
+ "Node name for S&R": "MiniMaxLowVRAMAttention"
966
+ },
967
+ "widgets_values": [
968
+ 4
969
+ ]
970
+ },
971
+ {
972
+ "id": 33,
973
+ "type": "H3SLAAttention",
974
+ "pos": [
975
+ 520,
976
+ 530
977
+ ],
978
+ "size": [
979
+ 340,
980
+ 322
981
+ ],
982
+ "flags": {},
983
+ "order": 0,
984
+ "mode": 0,
985
+ "inputs": [
986
+ {
987
+ "name": "model",
988
+ "type": "MODEL",
989
+ "link": 15
990
+ }
991
+ ],
992
+ "outputs": [
993
+ {
994
+ "name": "MODEL",
995
+ "type": "MODEL",
996
+ "links": [
997
+ 16
998
+ ]
999
+ }
1000
+ ],
1001
+ "title": "H3 SLA Attention",
1002
+ "properties": {
1003
+ "htc_speed": "sla",
1004
+ "Node name for S&R": "H3SLAAttention"
1005
+ },
1006
+ "widgets_values": [
1007
+ 0.9,
1008
+ "64",
1009
+ 8192,
1010
+ 0,
1011
+ true,
1012
+ true,
1013
+ "0",
1014
+ "comfy_kitchen",
1015
+ true,
1016
+ true,
1017
+ "Light"
1018
+ ]
1019
+ },
1020
+ {
1021
+ "id": 34,
1022
+ "type": "ModelPreviewOverrideKJ",
1023
+ "pos": [
1024
+ 520,
1025
+ 900
1026
+ ],
1027
+ "size": [
1028
+ 360,
1029
+ 480
1030
+ ],
1031
+ "flags": {},
1032
+ "order": 0,
1033
+ "mode": 0,
1034
+ "inputs": [
1035
+ {
1036
+ "name": "model",
1037
+ "type": "MODEL",
1038
+ "link": 16
1039
+ },
1040
+ {
1041
+ "name": "vae",
1042
+ "type": "VAE",
1043
+ "link": null
1044
+ }
1045
+ ],
1046
+ "outputs": [
1047
+ {
1048
+ "name": "MODEL",
1049
+ "type": "MODEL",
1050
+ "links": [
1051
+ 17
1052
+ ]
1053
+ }
1054
+ ],
1055
+ "title": "Model Preview Override",
1056
+ "properties": {
1057
+ "htc_speed": "preview",
1058
+ "Node name for S&R": "ModelPreviewOverrideKJ"
1059
+ },
1060
+ "widgets_values": [
1061
+ 512,
1062
+ 80,
1063
+ true,
1064
+ 100,
1065
+ 8,
1066
+ "taeh3.safetensors",
1067
+ ""
1068
+ ]
1069
+ }
1070
+ ],
1071
+ "links": [
1072
+ [
1073
+ 3,
1074
+ 4,
1075
+ 0,
1076
+ 7,
1077
+ 2,
1078
+ "VAE"
1079
+ ],
1080
+ [
1081
+ 4,
1082
+ 5,
1083
+ 0,
1084
+ 7,
1085
+ 3,
1086
+ "VAE"
1087
+ ],
1088
+ [
1089
+ 5,
1090
+ 7,
1091
+ 0,
1092
+ 16,
1093
+ 0,
1094
+ "IMAGE"
1095
+ ],
1096
+ [
1097
+ 6,
1098
+ 7,
1099
+ 1,
1100
+ 16,
1101
+ 1,
1102
+ "AUDIO"
1103
+ ],
1104
+ [
1105
+ 7,
1106
+ 7,
1107
+ 2,
1108
+ 16,
1109
+ 2,
1110
+ "STRING"
1111
+ ],
1112
+ [
1113
+ 8,
1114
+ 16,
1115
+ 0,
1116
+ 8,
1117
+ 0,
1118
+ "IMAGE"
1119
+ ],
1120
+ [
1121
+ 9,
1122
+ 16,
1123
+ 1,
1124
+ 8,
1125
+ 1,
1126
+ "AUDIO"
1127
+ ],
1128
+ [
1129
+ 10,
1130
+ 8,
1131
+ 0,
1132
+ 9,
1133
+ 0,
1134
+ "VIDEO"
1135
+ ],
1136
+ [
1137
+ 11,
1138
+ 2,
1139
+ 0,
1140
+ 30,
1141
+ 0,
1142
+ "MODEL"
1143
+ ],
1144
+ [
1145
+ 12,
1146
+ 3,
1147
+ 0,
1148
+ 30,
1149
+ 1,
1150
+ "CLIP"
1151
+ ],
1152
+ [
1153
+ 13,
1154
+ 30,
1155
+ 0,
1156
+ 31,
1157
+ 0,
1158
+ "MODEL"
1159
+ ],
1160
+ [
1161
+ 14,
1162
+ 31,
1163
+ 0,
1164
+ 32,
1165
+ 0,
1166
+ "MODEL"
1167
+ ],
1168
+ [
1169
+ 15,
1170
+ 32,
1171
+ 0,
1172
+ 33,
1173
+ 0,
1174
+ "MODEL"
1175
+ ],
1176
+ [
1177
+ 16,
1178
+ 33,
1179
+ 0,
1180
+ 34,
1181
+ 0,
1182
+ "MODEL"
1183
+ ],
1184
+ [
1185
+ 17,
1186
+ 34,
1187
+ 0,
1188
+ 7,
1189
+ 0,
1190
+ "MODEL"
1191
+ ],
1192
+ [
1193
+ 18,
1194
+ 30,
1195
+ 1,
1196
+ 7,
1197
+ 1,
1198
+ "CLIP"
1199
+ ]
1200
+ ],
1201
+ "groups": [],
1202
+ "config": {},
1203
+ "extra": {
1204
+ "ds": {
1205
+ "scale": 0.8264462809917354,
1206
+ "offset": [
1207
+ 76.11091610712583,
1208
+ -281.1386834420914
1209
+ ]
1210
+ }
1211
+ },
1212
+ "version": 0.4
1213
+ }
workflows/HandTieClips_Starter.json ADDED
@@ -0,0 +1,1358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "h3-ref2va-chain",
3
+ "revision": 1,
4
+ "last_node_id": 34,
5
+ "last_link_id": 18,
6
+ "nodes": [
7
+ {
8
+ "id": 2,
9
+ "type": "UNETLoader",
10
+ "pos": [
11
+ 0,
12
+ 340
13
+ ],
14
+ "size": [
15
+ 420,
16
+ 82
17
+ ],
18
+ "flags": {},
19
+ "order": 0,
20
+ "mode": 0,
21
+ "inputs": [
22
+ {
23
+ "localized_name": "unet_name",
24
+ "name": "unet_name",
25
+ "type": "COMBO",
26
+ "widget": {
27
+ "name": "unet_name"
28
+ },
29
+ "link": null
30
+ },
31
+ {
32
+ "localized_name": "weight_dtype",
33
+ "name": "weight_dtype",
34
+ "type": "COMBO",
35
+ "widget": {
36
+ "name": "weight_dtype"
37
+ },
38
+ "link": null
39
+ }
40
+ ],
41
+ "outputs": [
42
+ {
43
+ "localized_name": "MODEL",
44
+ "name": "MODEL",
45
+ "type": "MODEL",
46
+ "slot_index": 0,
47
+ "links": [
48
+ 11
49
+ ]
50
+ }
51
+ ],
52
+ "title": "DiT (ref2va)",
53
+ "properties": {
54
+ "cnr_id": "comfy-core",
55
+ "ver": "0.34.0",
56
+ "Node name for S&R": "UNETLoader"
57
+ },
58
+ "widgets_values": [
59
+ "minimax_h3_hybrid_fl2va_ref2va_b30-49-int8.safetensors",
60
+ "default"
61
+ ]
62
+ },
63
+ {
64
+ "id": 3,
65
+ "type": "CLIPLoader",
66
+ "pos": [
67
+ 0,
68
+ 460
69
+ ],
70
+ "size": [
71
+ 420,
72
+ 106
73
+ ],
74
+ "flags": {},
75
+ "order": 1,
76
+ "mode": 0,
77
+ "inputs": [
78
+ {
79
+ "localized_name": "clip_name",
80
+ "name": "clip_name",
81
+ "type": "COMBO",
82
+ "widget": {
83
+ "name": "clip_name"
84
+ },
85
+ "link": null
86
+ },
87
+ {
88
+ "localized_name": "type",
89
+ "name": "type",
90
+ "type": "COMBO",
91
+ "widget": {
92
+ "name": "type"
93
+ },
94
+ "link": null
95
+ },
96
+ {
97
+ "localized_name": "device",
98
+ "name": "device",
99
+ "shape": 7,
100
+ "type": "COMBO",
101
+ "widget": {
102
+ "name": "device"
103
+ },
104
+ "link": null
105
+ }
106
+ ],
107
+ "outputs": [
108
+ {
109
+ "localized_name": "CLIP",
110
+ "name": "CLIP",
111
+ "type": "CLIP",
112
+ "slot_index": 0,
113
+ "links": [
114
+ 12
115
+ ]
116
+ }
117
+ ],
118
+ "title": "Text encoder",
119
+ "properties": {
120
+ "cnr_id": "comfy-core",
121
+ "ver": "0.34.0",
122
+ "Node name for S&R": "CLIPLoader"
123
+ },
124
+ "widgets_values": [
125
+ "qwen3vl_32b_minimax_h3_int8_convrot.safetensors",
126
+ "minimax",
127
+ "default"
128
+ ]
129
+ },
130
+ {
131
+ "id": 4,
132
+ "type": "VAELoader",
133
+ "pos": [
134
+ 0,
135
+ 600
136
+ ],
137
+ "size": [
138
+ 420,
139
+ 82
140
+ ],
141
+ "flags": {},
142
+ "order": 2,
143
+ "mode": 0,
144
+ "inputs": [
145
+ {
146
+ "localized_name": "vae_name",
147
+ "name": "vae_name",
148
+ "type": "COMBO",
149
+ "widget": {
150
+ "name": "vae_name"
151
+ },
152
+ "link": null
153
+ }
154
+ ],
155
+ "outputs": [
156
+ {
157
+ "localized_name": "VAE",
158
+ "name": "VAE",
159
+ "type": "VAE",
160
+ "slot_index": 0,
161
+ "links": [
162
+ 3
163
+ ]
164
+ }
165
+ ],
166
+ "title": "Video VAE",
167
+ "properties": {
168
+ "cnr_id": "comfy-core",
169
+ "ver": "0.34.0",
170
+ "Node name for S&R": "VAELoader"
171
+ },
172
+ "widgets_values": [
173
+ "minimax_h3_video_vae_int8_convrot.safetensors"
174
+ ]
175
+ },
176
+ {
177
+ "id": 5,
178
+ "type": "VAELoader",
179
+ "pos": [
180
+ 0,
181
+ 720
182
+ ],
183
+ "size": [
184
+ 420,
185
+ 82
186
+ ],
187
+ "flags": {},
188
+ "order": 3,
189
+ "mode": 0,
190
+ "inputs": [
191
+ {
192
+ "localized_name": "vae_name",
193
+ "name": "vae_name",
194
+ "type": "COMBO",
195
+ "widget": {
196
+ "name": "vae_name"
197
+ },
198
+ "link": null
199
+ }
200
+ ],
201
+ "outputs": [
202
+ {
203
+ "localized_name": "VAE",
204
+ "name": "VAE",
205
+ "type": "VAE",
206
+ "slot_index": 0,
207
+ "links": [
208
+ 4
209
+ ]
210
+ }
211
+ ],
212
+ "title": "Audio VAE",
213
+ "properties": {
214
+ "cnr_id": "comfy-core",
215
+ "ver": "0.34.0",
216
+ "Node name for S&R": "VAELoader"
217
+ },
218
+ "widgets_values": [
219
+ "minimax_h3_audio_vae_fp32.safetensors"
220
+ ]
221
+ },
222
+ {
223
+ "id": 7,
224
+ "type": "HandTieClips",
225
+ "pos": [
226
+ 994,
227
+ 427
228
+ ],
229
+ "size": [
230
+ 968.3162169696975,
231
+ 1426.2006199999998
232
+ ],
233
+ "flags": {},
234
+ "order": 4,
235
+ "mode": 0,
236
+ "inputs": [
237
+ {
238
+ "localized_name": "model",
239
+ "name": "model",
240
+ "type": "MODEL",
241
+ "link": 17
242
+ },
243
+ {
244
+ "localized_name": "clip",
245
+ "name": "clip",
246
+ "type": "CLIP",
247
+ "link": 18
248
+ },
249
+ {
250
+ "localized_name": "vae",
251
+ "name": "vae",
252
+ "type": "VAE",
253
+ "link": 3
254
+ },
255
+ {
256
+ "localized_name": "audio_vae",
257
+ "name": "audio_vae",
258
+ "type": "VAE",
259
+ "link": 4
260
+ },
261
+ {
262
+ "localized_name": "continuity_state",
263
+ "name": "continuity_state",
264
+ "shape": 7,
265
+ "type": "STRING",
266
+ "link": null
267
+ },
268
+ {
269
+ "localized_name": "prompt",
270
+ "name": "prompt",
271
+ "type": "STRING",
272
+ "widget": {
273
+ "name": "prompt"
274
+ },
275
+ "link": null
276
+ },
277
+ {
278
+ "localized_name": "chains",
279
+ "name": "chains",
280
+ "type": "COMBO",
281
+ "widget": {
282
+ "name": "chains"
283
+ },
284
+ "link": null
285
+ },
286
+ {
287
+ "localized_name": "resolution",
288
+ "name": "resolution",
289
+ "type": "COMBO",
290
+ "widget": {
291
+ "name": "resolution"
292
+ },
293
+ "link": null
294
+ },
295
+ {
296
+ "localized_name": "aspect",
297
+ "name": "aspect",
298
+ "type": "COMBO",
299
+ "widget": {
300
+ "name": "aspect"
301
+ },
302
+ "link": null
303
+ },
304
+ {
305
+ "localized_name": "duration",
306
+ "name": "duration",
307
+ "type": "COMBO",
308
+ "widget": {
309
+ "name": "duration"
310
+ },
311
+ "link": null
312
+ },
313
+ {
314
+ "localized_name": "overlap",
315
+ "name": "overlap",
316
+ "type": "COMBO",
317
+ "widget": {
318
+ "name": "overlap"
319
+ },
320
+ "link": null
321
+ },
322
+ {
323
+ "localized_name": "seed",
324
+ "name": "seed",
325
+ "type": "INT",
326
+ "widget": {
327
+ "name": "seed"
328
+ },
329
+ "link": null
330
+ },
331
+ {
332
+ "localized_name": "seed_per_shot",
333
+ "name": "seed_per_shot",
334
+ "type": "BOOLEAN",
335
+ "widget": {
336
+ "name": "seed_per_shot"
337
+ },
338
+ "link": null
339
+ },
340
+ {
341
+ "localized_name": "steps",
342
+ "name": "steps",
343
+ "type": "INT",
344
+ "widget": {
345
+ "name": "steps"
346
+ },
347
+ "link": null
348
+ },
349
+ {
350
+ "localized_name": "sampler_name",
351
+ "name": "sampler_name",
352
+ "type": "COMBO",
353
+ "widget": {
354
+ "name": "sampler_name"
355
+ },
356
+ "link": null
357
+ },
358
+ {
359
+ "localized_name": "scheduler",
360
+ "name": "scheduler",
361
+ "type": "COMBO",
362
+ "widget": {
363
+ "name": "scheduler"
364
+ },
365
+ "link": null
366
+ },
367
+ {
368
+ "localized_name": "shift_video",
369
+ "name": "shift_video",
370
+ "type": "FLOAT",
371
+ "widget": {
372
+ "name": "shift_video"
373
+ },
374
+ "link": null
375
+ },
376
+ {
377
+ "localized_name": "shift_audio",
378
+ "name": "shift_audio",
379
+ "type": "FLOAT",
380
+ "widget": {
381
+ "name": "shift_audio"
382
+ },
383
+ "link": null
384
+ },
385
+ {
386
+ "localized_name": "ref_image_size",
387
+ "name": "ref_image_size",
388
+ "type": "COMBO",
389
+ "widget": {
390
+ "name": "ref_image_size"
391
+ },
392
+ "link": null
393
+ },
394
+ {
395
+ "localized_name": "hop_script",
396
+ "name": "hop_script",
397
+ "shape": 7,
398
+ "type": "COMBO",
399
+ "widget": {
400
+ "name": "hop_script"
401
+ },
402
+ "link": null
403
+ },
404
+ {
405
+ "localized_name": "pin_to_qwen",
406
+ "name": "pin_to_qwen",
407
+ "shape": 7,
408
+ "type": "COMBO",
409
+ "widget": {
410
+ "name": "pin_to_qwen"
411
+ },
412
+ "link": null
413
+ },
414
+ {
415
+ "localized_name": "shot_plan",
416
+ "name": "shot_plan",
417
+ "shape": 7,
418
+ "type": "STRING",
419
+ "widget": {
420
+ "name": "shot_plan"
421
+ },
422
+ "link": null
423
+ },
424
+ {
425
+ "localized_name": "ref_plan",
426
+ "name": "ref_plan",
427
+ "shape": 7,
428
+ "type": "STRING",
429
+ "widget": {
430
+ "name": "ref_plan"
431
+ },
432
+ "link": null
433
+ },
434
+ {
435
+ "localized_name": "cache_hops",
436
+ "name": "cache_hops",
437
+ "shape": 7,
438
+ "type": "COMBO",
439
+ "widget": {
440
+ "name": "cache_hops"
441
+ },
442
+ "link": null
443
+ },
444
+ {
445
+ "localized_name": "cache_budget_gb",
446
+ "name": "cache_budget_gb",
447
+ "shape": 7,
448
+ "type": "FLOAT",
449
+ "widget": {
450
+ "name": "cache_budget_gb"
451
+ },
452
+ "link": null
453
+ },
454
+ {
455
+ "localized_name": "audio_pin_frames",
456
+ "name": "audio_pin_frames",
457
+ "shape": 7,
458
+ "type": "INT",
459
+ "widget": {
460
+ "name": "audio_pin_frames"
461
+ },
462
+ "link": null
463
+ },
464
+ {
465
+ "localized_name": "pin_renorm",
466
+ "name": "pin_renorm",
467
+ "shape": 7,
468
+ "type": "COMBO",
469
+ "widget": {
470
+ "name": "pin_renorm"
471
+ },
472
+ "link": null
473
+ },
474
+ {
475
+ "localized_name": "pin_noise",
476
+ "name": "pin_noise",
477
+ "shape": 7,
478
+ "type": "FLOAT",
479
+ "widget": {
480
+ "name": "pin_noise"
481
+ },
482
+ "link": null
483
+ },
484
+ {
485
+ "localized_name": "tone_compensate",
486
+ "name": "tone_compensate",
487
+ "shape": 7,
488
+ "type": "COMBO",
489
+ "widget": {
490
+ "name": "tone_compensate"
491
+ },
492
+ "link": null
493
+ },
494
+ {
495
+ "localized_name": "start_image_file",
496
+ "name": "start_image_file",
497
+ "shape": 7,
498
+ "type": "STRING",
499
+ "widget": {
500
+ "name": "start_image_file"
501
+ },
502
+ "link": null
503
+ },
504
+ {
505
+ "localized_name": "reference_video_file",
506
+ "name": "reference_video_file",
507
+ "shape": 7,
508
+ "type": "STRING",
509
+ "widget": {
510
+ "name": "reference_video_file"
511
+ },
512
+ "link": null
513
+ },
514
+ {
515
+ "localized_name": "voice_file",
516
+ "name": "voice_file",
517
+ "shape": 7,
518
+ "type": "STRING",
519
+ "widget": {
520
+ "name": "voice_file"
521
+ },
522
+ "link": null
523
+ }
524
+ ],
525
+ "outputs": [
526
+ {
527
+ "localized_name": "images",
528
+ "name": "images",
529
+ "type": "IMAGE",
530
+ "slot_index": 0,
531
+ "links": [
532
+ 5
533
+ ]
534
+ },
535
+ {
536
+ "localized_name": "audio",
537
+ "name": "audio",
538
+ "type": "AUDIO",
539
+ "slot_index": 1,
540
+ "links": [
541
+ 6
542
+ ]
543
+ },
544
+ {
545
+ "localized_name": "info",
546
+ "name": "info",
547
+ "type": "STRING",
548
+ "slot_index": 2,
549
+ "links": [
550
+ 7
551
+ ]
552
+ }
553
+ ],
554
+ "properties": {
555
+ "Node name for S&R": "H3RefChain",
556
+ "h3_plan_backup": "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"Live-action, natural indoor light. The cook stands at the counter in @kitchen, looks up from the chopping board, and speaks one short line to someone off-frame.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"The cook sets the knife down, turns toward the window, and finishes her last word as she looks out.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"push_in\",\n \"framing\": \"close\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s3\",\n \"beat\": \"The cook leans back against the counter with her lips closed, and lets her eyes move slowly across the room. The room is still, with only the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pull_back\",\n \"framing\": \"medium\",\n \"pace\": \"slow\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
557
+ "h3_editor_mode": "shots"
558
+ },
559
+ "widgets_values": [
560
+ "Live-action, natural indoor light. The person looks exactly as in the reference photographs.\n\nA cook in an apron stands at a kitchen counter, slicing. They look up, speak one short line, then turn back to the board.",
561
+ "2",
562
+ "0.3 MP",
563
+ "16:9 landscape",
564
+ "5 s",
565
+ "0.9 s",
566
+ 427058607873049,
567
+ "fixed",
568
+ true,
569
+ 7,
570
+ "res_multistep",
571
+ "simple",
572
+ 12,
573
+ 3,
574
+ "match",
575
+ "next",
576
+ "last frame",
577
+ "{\n \"shots\": [\n {\n \"id\": \"s1\",\n \"beat\": \"A cook in an apron stands at a kitchen counter, looks up from the chopping board and says, 'You are earlier than I expected.' She turns back to the board and goes on slicing, the knife tapping steadily against the wood.\",\n \"directives\": {\n \"camera\": \"hold\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"ongoing\"\n }\n },\n {\n \"id\": \"s2\",\n \"beat\": \"She sets the knife down, turns toward the window and looks out at the street, her weight settling onto one hip. The kitchen is quiet apart from the low hum of the refrigerator.\",\n \"directives\": {\n \"join\": \"continuous\",\n \"camera\": \"pan_follow\",\n \"framing\": \"medium\",\n \"pace\": \"steady\",\n \"tail\": \"settle\"\n }\n }\n ]\n}",
578
+ "{\n \"refs\": [],\n \"subjects\": {}\n}",
579
+ "on",
580
+ 19,
581
+ 24,
582
+ "off",
583
+ 0,
584
+ "frame_shift",
585
+ "",
586
+ "",
587
+ "",
588
+ "Live-action, natural light, one continuous take."
589
+ ],
590
+ "title": "H3 Ref2VA Chain - starter"
591
+ },
592
+ {
593
+ "id": 16,
594
+ "type": "HTCChainPreview",
595
+ "pos": [
596
+ 2258.827272727274,
597
+ -90.16060606060621
598
+ ],
599
+ "size": [
600
+ 400,
601
+ 460
602
+ ],
603
+ "flags": {},
604
+ "order": 5,
605
+ "mode": 0,
606
+ "inputs": [
607
+ {
608
+ "localized_name": "images",
609
+ "name": "images",
610
+ "type": "IMAGE",
611
+ "link": 5
612
+ },
613
+ {
614
+ "localized_name": "audio",
615
+ "name": "audio",
616
+ "shape": 7,
617
+ "type": "AUDIO",
618
+ "link": 6
619
+ },
620
+ {
621
+ "localized_name": "info",
622
+ "name": "info",
623
+ "shape": 7,
624
+ "type": "STRING",
625
+ "link": 7
626
+ }
627
+ ],
628
+ "outputs": [
629
+ {
630
+ "localized_name": "images",
631
+ "name": "images",
632
+ "type": "IMAGE",
633
+ "slot_index": 0,
634
+ "links": [
635
+ 8
636
+ ]
637
+ },
638
+ {
639
+ "localized_name": "audio",
640
+ "name": "audio",
641
+ "type": "AUDIO",
642
+ "slot_index": 1,
643
+ "links": [
644
+ 9
645
+ ]
646
+ }
647
+ ],
648
+ "properties": {
649
+ "Node name for S&R": "H3ChainPreview",
650
+ "h3rcPanelH": 40
651
+ },
652
+ "widgets_values": [
653
+ ""
654
+ ]
655
+ },
656
+ {
657
+ "id": 8,
658
+ "type": "CreateVideo",
659
+ "pos": [
660
+ 2991.7272727272743,
661
+ 37.33939393939383
662
+ ],
663
+ "size": [
664
+ 300,
665
+ 130
666
+ ],
667
+ "flags": {},
668
+ "order": 6,
669
+ "mode": 0,
670
+ "inputs": [
671
+ {
672
+ "localized_name": "images",
673
+ "name": "images",
674
+ "type": "IMAGE",
675
+ "link": 8
676
+ },
677
+ {
678
+ "localized_name": "audio",
679
+ "name": "audio",
680
+ "shape": 7,
681
+ "type": "AUDIO",
682
+ "link": 9
683
+ },
684
+ {
685
+ "localized_name": "fps",
686
+ "name": "fps",
687
+ "type": "FLOAT",
688
+ "widget": {
689
+ "name": "fps"
690
+ },
691
+ "link": null
692
+ },
693
+ {
694
+ "localized_name": "bit_depth",
695
+ "name": "bit_depth",
696
+ "shape": 7,
697
+ "type": "COMBO",
698
+ "widget": {
699
+ "name": "bit_depth"
700
+ },
701
+ "link": null
702
+ },
703
+ {
704
+ "localized_name": "color_space",
705
+ "name": "color_space",
706
+ "shape": 7,
707
+ "type": "COMBO",
708
+ "widget": {
709
+ "name": "color_space"
710
+ },
711
+ "link": null
712
+ }
713
+ ],
714
+ "outputs": [
715
+ {
716
+ "localized_name": "VIDEO",
717
+ "name": "VIDEO",
718
+ "type": "VIDEO",
719
+ "slot_index": 0,
720
+ "links": [
721
+ 10
722
+ ]
723
+ }
724
+ ],
725
+ "properties": {
726
+ "cnr_id": "comfy-core",
727
+ "ver": "0.34.0",
728
+ "Node name for S&R": "CreateVideo"
729
+ },
730
+ "widgets_values": [
731
+ 24,
732
+ "auto",
733
+ "sRGB"
734
+ ]
735
+ },
736
+ {
737
+ "id": 9,
738
+ "type": "SaveVideo",
739
+ "pos": [
740
+ 3032.515151515151,
741
+ 714.542424242424
742
+ ],
743
+ "size": [
744
+ 340,
745
+ 106
746
+ ],
747
+ "flags": {},
748
+ "order": 7,
749
+ "mode": 0,
750
+ "inputs": [
751
+ {
752
+ "localized_name": "video",
753
+ "name": "video",
754
+ "type": "VIDEO",
755
+ "link": 10
756
+ },
757
+ {
758
+ "localized_name": "filename_prefix",
759
+ "name": "filename_prefix",
760
+ "type": "STRING",
761
+ "widget": {
762
+ "name": "filename_prefix"
763
+ },
764
+ "link": null
765
+ },
766
+ {
767
+ "localized_name": "format",
768
+ "name": "format",
769
+ "type": "COMFY_DYNAMICCOMBO_V3",
770
+ "widget": {
771
+ "name": "format"
772
+ },
773
+ "link": null
774
+ },
775
+ {
776
+ "localized_name": "format.codec",
777
+ "name": "format.codec",
778
+ "type": "COMFY_DYNAMICCOMBO_V3",
779
+ "widget": {
780
+ "name": "format.codec"
781
+ },
782
+ "link": null
783
+ },
784
+ {
785
+ "localized_name": "codec",
786
+ "name": "codec",
787
+ "shape": 7,
788
+ "type": "COMFY_DYNAMICCOMBO_V3",
789
+ "widget": {
790
+ "name": "codec"
791
+ },
792
+ "link": null
793
+ }
794
+ ],
795
+ "outputs": [
796
+ {
797
+ "localized_name": "video",
798
+ "name": "video",
799
+ "type": "VIDEO",
800
+ "links": []
801
+ }
802
+ ],
803
+ "title": "SAVE",
804
+ "properties": {
805
+ "cnr_id": "comfy-core",
806
+ "ver": "0.34.0",
807
+ "Node name for S&R": "SaveVideo"
808
+ },
809
+ "widgets_values": [
810
+ "video/HANDTIECLIPS/chain",
811
+ "auto",
812
+ "auto",
813
+ "auto"
814
+ ]
815
+ },
816
+ {
817
+ "id": 30,
818
+ "type": "LTX_lora_loader",
819
+ "pos": [
820
+ 520,
821
+ -40
822
+ ],
823
+ "size": [
824
+ 420,
825
+ 240
826
+ ],
827
+ "flags": {},
828
+ "order": 0,
829
+ "mode": 0,
830
+ "inputs": [
831
+ {
832
+ "name": "model",
833
+ "type": "MODEL",
834
+ "link": 11
835
+ },
836
+ {
837
+ "name": "clip",
838
+ "type": "CLIP",
839
+ "link": 12
840
+ }
841
+ ],
842
+ "outputs": [
843
+ {
844
+ "name": "model",
845
+ "type": "MODEL",
846
+ "links": [
847
+ 13
848
+ ]
849
+ },
850
+ {
851
+ "name": "clip",
852
+ "type": "CLIP",
853
+ "links": [
854
+ 18
855
+ ]
856
+ }
857
+ ],
858
+ "title": "LoRA Loader Stack (turbo)",
859
+ "properties": {
860
+ "htc_speed": "lora",
861
+ "Node name for S&R": "LTX_lora_loader"
862
+ },
863
+ "widgets_values": [
864
+ "minimax",
865
+ "[{\"on\":true,\"lora\":\"minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors\",\"str\":1,\"v\":1,\"a\":1,\"t\":1}]",
866
+ ""
867
+ ]
868
+ },
869
+ {
870
+ "id": 31,
871
+ "type": "H3AdaLNLoRAFix",
872
+ "pos": [
873
+ 520,
874
+ 240
875
+ ],
876
+ "size": [
877
+ 340,
878
+ 150
879
+ ],
880
+ "flags": {},
881
+ "order": 0,
882
+ "mode": 0,
883
+ "inputs": [
884
+ {
885
+ "name": "model",
886
+ "type": "MODEL",
887
+ "link": 13
888
+ }
889
+ ],
890
+ "outputs": [
891
+ {
892
+ "name": "MODEL",
893
+ "type": "MODEL",
894
+ "links": [
895
+ 14
896
+ ]
897
+ }
898
+ ],
899
+ "title": "H3 AdaLN LoRA Fix",
900
+ "properties": {
901
+ "htc_speed": "adaln",
902
+ "Node name for S&R": "H3AdaLNLoRAFix"
903
+ },
904
+ "widgets_values": [
905
+ "port"
906
+ ]
907
+ },
908
+ {
909
+ "id": 32,
910
+ "type": "MiniMaxLowVRAMAttention",
911
+ "pos": [
912
+ 520,
913
+ 430
914
+ ],
915
+ "size": [
916
+ 330,
917
+ 58
918
+ ],
919
+ "flags": {},
920
+ "order": 0,
921
+ "mode": 0,
922
+ "inputs": [
923
+ {
924
+ "name": "model",
925
+ "type": "MODEL",
926
+ "link": 14
927
+ }
928
+ ],
929
+ "outputs": [
930
+ {
931
+ "name": "model",
932
+ "type": "MODEL",
933
+ "links": [
934
+ 15
935
+ ]
936
+ }
937
+ ],
938
+ "title": "MiniMax H3 Low VRAM Attention",
939
+ "properties": {
940
+ "htc_speed": "lowvram",
941
+ "Node name for S&R": "MiniMaxLowVRAMAttention"
942
+ },
943
+ "widgets_values": [
944
+ 4
945
+ ]
946
+ },
947
+ {
948
+ "id": 33,
949
+ "type": "H3SLAAttention",
950
+ "pos": [
951
+ 520,
952
+ 530
953
+ ],
954
+ "size": [
955
+ 340,
956
+ 322
957
+ ],
958
+ "flags": {},
959
+ "order": 0,
960
+ "mode": 0,
961
+ "inputs": [
962
+ {
963
+ "name": "model",
964
+ "type": "MODEL",
965
+ "link": 15
966
+ }
967
+ ],
968
+ "outputs": [
969
+ {
970
+ "name": "MODEL",
971
+ "type": "MODEL",
972
+ "links": [
973
+ 16
974
+ ]
975
+ }
976
+ ],
977
+ "title": "H3 SLA Attention",
978
+ "properties": {
979
+ "htc_speed": "sla",
980
+ "Node name for S&R": "H3SLAAttention"
981
+ },
982
+ "widgets_values": [
983
+ 0.9,
984
+ "64",
985
+ 8192,
986
+ 0,
987
+ true,
988
+ true,
989
+ "0",
990
+ "comfy_kitchen",
991
+ true,
992
+ true,
993
+ "Light"
994
+ ]
995
+ },
996
+ {
997
+ "id": 34,
998
+ "type": "ModelPreviewOverrideKJ",
999
+ "pos": [
1000
+ 520,
1001
+ 900
1002
+ ],
1003
+ "size": [
1004
+ 360,
1005
+ 480
1006
+ ],
1007
+ "flags": {},
1008
+ "order": 0,
1009
+ "mode": 0,
1010
+ "inputs": [
1011
+ {
1012
+ "name": "model",
1013
+ "type": "MODEL",
1014
+ "link": 16
1015
+ },
1016
+ {
1017
+ "name": "vae",
1018
+ "type": "VAE",
1019
+ "link": null
1020
+ }
1021
+ ],
1022
+ "outputs": [
1023
+ {
1024
+ "name": "MODEL",
1025
+ "type": "MODEL",
1026
+ "links": [
1027
+ 17
1028
+ ]
1029
+ }
1030
+ ],
1031
+ "title": "Model Preview Override",
1032
+ "properties": {
1033
+ "htc_speed": "preview",
1034
+ "Node name for S&R": "ModelPreviewOverrideKJ"
1035
+ },
1036
+ "widgets_values": [
1037
+ 512,
1038
+ 80,
1039
+ true,
1040
+ 100,
1041
+ 8,
1042
+ "taeh3.safetensors",
1043
+ ""
1044
+ ]
1045
+ },
1046
+ {
1047
+ "id": 20,
1048
+ "type": "MarkdownNote",
1049
+ "pos": [
1050
+ -960,
1051
+ -400
1052
+ ],
1053
+ "size": [
1054
+ 440,
1055
+ 940
1056
+ ],
1057
+ "flags": {},
1058
+ "order": 8,
1059
+ "mode": 0,
1060
+ "inputs": [],
1061
+ "outputs": [],
1062
+ "title": "START HERE",
1063
+ "properties": {
1064
+ "htc_card": "start"
1065
+ },
1066
+ "widgets_values": [
1067
+ "## Start here\n\nTwo hops of 5 s, joined into one 10 s clip.\n\n### Requires\n\nThe MODEL wire is a turbo stack, not a bare loader:\n\n```\nUNETLoader\n -> LoRA Loader Stack (turbo LoRA)\n -> H3 AdaLN LoRA Fix\n -> MiniMax H3 Low VRAM Attention\n -> H3 SLA Attention\n -> Model Preview Override\n -> this node\n```\n\n| pack | nodes |\n|---|---|\n| **ComfyUI-PlagueKind-Nodes** | LoRA Loader Stack, AdaLN Fix, SLA Attention |\n| **ComfyUI-KJNodes** | Low VRAM Attention (experimental), Model Preview Override |\n\nAlso on disk: the **turbo LoRA** named in the loader, and `taeh3.safetensors`\nfor the live preview (or set `tiny_vae` to `none`).\n\n**CLIP goes to this node from the LoRA loader, not from the encoder.** That is\nwhat makes the text half of every LoRA land. Do not rewire it back.\n\n`steps` is **7**, which only works with the turbo LoRA. Missing a pack? Its\nnodes load as red boxes -- delete them, wire the loader straight into `model`\nand the encoder into `clip`, and raise `steps` to 20 or so.\n\n1. Point the four **loaders** at your H3 files.\n2. Queue.\n3. Read the **SHOTS** cards on the node.\n\n**Shot 1 is the whole opening. Every later shot is only the new beat.** The node\nwrites the identity lock, the live-frame citation and the join itself -- so\nre-describing the face, the clothes or the room after shot 1 competes with the\nframe pin instead of reinforcing it.\n\nThis plan ships with **no references on purpose**, so it runs before you have\nsupplied any pictures.\n\n### Adding pictures\n\n- Open **REFERENCES**, add a row, drop a photo on its thumbnail. Files land in\n `ComfyUI/input/h3_refs`. There is no Load Image node to wire.\n- Give the row a `@tag`, and group photos of the same person under one subject.\n- Write the tag into the beat: `@hero_face stands at the counter in @kitchen`.\n\nThe long-form guide is `PROMPTING.md` in the pack folder."
1068
+ ],
1069
+ "color": "#232",
1070
+ "bgcolor": "#353"
1071
+ },
1072
+ {
1073
+ "id": 21,
1074
+ "type": "MarkdownNote",
1075
+ "pos": [
1076
+ -960,
1077
+ 600
1078
+ ],
1079
+ "size": [
1080
+ 440,
1081
+ 760
1082
+ ],
1083
+ "flags": {},
1084
+ "order": 9,
1085
+ "mode": 0,
1086
+ "inputs": [],
1087
+ "outputs": [],
1088
+ "title": "The rules",
1089
+ "properties": {
1090
+ "htc_card": "rules"
1091
+ },
1092
+ "widgets_values": [
1093
+ "## The rules that decide whether it works\n\nNot style preferences. This is how this model fails.\n\n### 1. The prompt is additive\n\nSampling runs at **cfg 1.0 with no negative branch**. Every concept you name is\nadded, and nothing can be removed by mentioning it -- `no cut` puts the word\n*cut* in front of the encoder. **Never write a negation.**\n\n### 2. Never name the thing you want to end\n\n\"The cook stops talking\" keeps her talking. Write the state you want as **a pose\nplus a sound**:\n\n> leans back against the counter with her lips closed, and lets her eyes move\n> slowly across the room. The kitchen is quiet apart from the low hum of the\n> refrigerator.\n\nAudio is always generated. Silence written as an absence comes back as speech,\nso **silence has to be written as a sound** -- room tone, a fridge, a single\nclick. Keep it narrowband: \"faint street noise\" renders as a five-second hiss.\n\n**The ban is on the idea, not on a word list.** *Fades, passes, wanes, subsides,\ndies down, eases off* all name an ending as surely as *stops* does, and all of\nthem add the thing they describe. Ask of each sentence: is this happening, or\nhas it finished happening?\n\n### 3. A state change belongs at the END of the previous shot\n\nEvery hop opens holding the frames it was handed, and the audio pin carries the\nprevious hop's tail across the join. Nothing you write in shot 3 can make shot 3\nstart quiet. **Arrive there before the previous shot ends.**\n\n### 4. A hop that ends on dialogue keeps talking\n\nSpeech at the end of hop N opens hop N+1 and propagates down the whole chain.\nLand each line **mid-hop** and leave a non-verbal action running into the seam --\nslicing, walking, a hand on a doorframe. Give every hop with no dialogue a sound\nbed of its own.\n\n### 5. A walk between two rooms is `match_cut`\n\n`join: continuous` across a real location change makes the model morph one room\ninto the other mid-movement.\n\n### 6. Set `tail` on your last shot\n\nLeft at `ongoing`, the model is told action is still underway at the final frame\nand will invent something to satisfy it. Use `settle` or `hold`."
1094
+ ],
1095
+ "color": "#432",
1096
+ "bgcolor": "#653"
1097
+ },
1098
+ {
1099
+ "id": 22,
1100
+ "type": "MarkdownNote",
1101
+ "pos": [
1102
+ -960,
1103
+ 1420
1104
+ ],
1105
+ "size": [
1106
+ 440,
1107
+ 640
1108
+ ],
1109
+ "flags": {},
1110
+ "order": 10,
1111
+ "mode": 0,
1112
+ "inputs": [],
1113
+ "outputs": [],
1114
+ "title": "References and @tags",
1115
+ "properties": {
1116
+ "htc_card": "refs"
1117
+ },
1118
+ "widgets_values": [
1119
+ "## References and @tags\n\nA picture in the register does not make the model use it. **The beat is what\ndrives the frame** -- write the tag into the action line.\n\nPhrase a place as a place that is *depicted*, not as a container to be placed\ninside: \"at the counter in `@kitchen`\", not \"steps into `@kitchen`\".\n\n### Fields\n\n| field | what it does |\n|---|---|\n| `tag` | what you write in beats as `@tag` |\n| `file` | a bare filename in `input/h3_refs` |\n| `subject` | groups pictures **of the same person** |\n| `retention` | `fully_preserved` / `partially_copy` / `reference` |\n| `shots` | the 1-based hops this picture rides |\n\n**One subject number per person.** Two different people under one number makes\nthe model render the average of their faces.\n\n### `shots` is the part that decides continuity\n\n**A reference with no `shots` list rides hop 1 only.** Right for a **place**\nplate: a room still riding a hop set somewhere else beats the frame pin, and the\nmodel follows the still. **List every hop a picture belongs on.**\n\n**A face plate is the opposite \u2014 put it on every hop.** A hop scheduled with no\nface reference came back a different person, and no later hop recovered.\nIdentity drift does not self-correct.\n\n- Every **face** ref gets every hop: `\"shots\": [1, 2, 3, 4, 5, 6]`.\n- Keep a **place** ref on the hops set in that place, and off the rest.\n\n### `locked` and `context`\n\nPer-subject prose that rides **every hop**, with no picture citation, so it\ncarries identity across a hop where the photograph is absent. Give every subject\na `locked`, and a `name` -- from hop 2 on, `@tag` for a person resolves to that\nname.\n\n**Name a colour or it drifts.** A noun with no adjective is unanchored: each hop\nis an independent encode, so \"the bowl\" on hop 4 came back stainless steel. Put\nproperties in `context` -- \"the bowl is white porcelain\" -- never locations, and\nrepeat the adjective in every beat."
1120
+ ],
1121
+ "color": "#432",
1122
+ "bgcolor": "#653"
1123
+ },
1124
+ {
1125
+ "id": 23,
1126
+ "type": "MarkdownNote",
1127
+ "pos": [
1128
+ -480,
1129
+ -400
1130
+ ],
1131
+ "size": [
1132
+ 440,
1133
+ 640
1134
+ ],
1135
+ "flags": {},
1136
+ "order": 11,
1137
+ "mode": 0,
1138
+ "inputs": [],
1139
+ "outputs": [],
1140
+ "title": "Directives",
1141
+ "properties": {
1142
+ "htc_card": "directives"
1143
+ },
1144
+ "widgets_values": [
1145
+ "## Directives\n\nFive axes, all optional, set per shot. An unset axis emits nothing at all and\ncosts no tokens.\n\n| axis | values |\n|---|---|\n| `join` | `continuous`, `match_cut`, `hard_cut` -- **omit on shot 1** |\n| `camera` | `hold`, `pan_follow`, `push_in`, `pull_back`, `orbit`, `handheld` |\n| `framing` | `keep`, `wide`, `medium`, `close` |\n| `pace` | `slow`, `steady`, `brisk` |\n| `tail` | `ongoing` (default), `settle`, `hold` |\n\nThey compile in that order -- `join` first, because it describes how this hop\nmeets the previous one.\n\n### Two combinations the node warns about\n\n**`join: continuous` + a framing change + `camera: hold`.** A framing change asks\nthe audience to be somewhere new; with the camera still, the only way there is a\ncut. Earn it on the move (`push_in`, `pull_back`, `pan_follow`) or use\n`framing: keep`.\n\n**`push_in` + `wide`, or `pull_back` + `close`.** The move points the opposite way\nfrom the destination.\n\nBoth are warnings, not errors. They are legitimate things to want -- they just\nrarely read the way you meant.\n\n### Duration\n\nPer-shot `duration` overrides the chain, using exactly these labels:\n`\"5 s\"`, `\"7 s\"`, `\"8 s\"`, `\"10 s\"`, `\"15 s\"`."
1146
+ ],
1147
+ "color": "#432",
1148
+ "bgcolor": "#653"
1149
+ },
1150
+ {
1151
+ "id": 24,
1152
+ "type": "MarkdownNote",
1153
+ "pos": [
1154
+ -480,
1155
+ 280
1156
+ ],
1157
+ "size": [
1158
+ 440,
1159
+ 760
1160
+ ],
1161
+ "flags": {},
1162
+ "order": 12,
1163
+ "mode": 0,
1164
+ "inputs": [],
1165
+ "outputs": [],
1166
+ "title": "When it goes wrong",
1167
+ "properties": {
1168
+ "htc_card": "trouble"
1169
+ },
1170
+ "widgets_values": [
1171
+ "## When it goes wrong\n\n| symptom | cause | fix |\n|---|---|---|\n| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |\n| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |\n| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |\n| Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |\n| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |\n| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |\n| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |\n| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |\n| The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |\n| A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |\n| The film gets darker every hop | Luminance drifts one way and nothing pushes back | Restate the light as a positive property in every beat |\n| A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |\n| A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |\n| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |\n| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |\n| The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |\n| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |\n| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |\n\nEvery error message names the shot or the reference it came from. Nothing\nguesses."
1172
+ ],
1173
+ "color": "#432",
1174
+ "bgcolor": "#653"
1175
+ },
1176
+ {
1177
+ "id": 25,
1178
+ "type": "MarkdownNote",
1179
+ "pos": [
1180
+ -480,
1181
+ 1080
1182
+ ],
1183
+ "size": [
1184
+ 440,
1185
+ 620
1186
+ ],
1187
+ "flags": {},
1188
+ "order": 13,
1189
+ "mode": 0,
1190
+ "inputs": [],
1191
+ "outputs": [],
1192
+ "title": "Let a model write it",
1193
+ "properties": {
1194
+ "htc_card": "author"
1195
+ },
1196
+ "widgets_values": [
1197
+ "## Let a model write your plan\n\n`prompt_pack/` in the pack folder turns any chat model into a plan writer. In\nLM Studio, or anything with a system-prompt box:\n\n1. Load a model with **context 8192 or more**. The prompt is ~2,600 tokens and\n the reply another 1,000-2,000; a 4k window truncates the rules and you get\n invented directive names.\n2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.\n Nothing else goes in that box.\n3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart\n quotes.\n4. Describe the scene, and say how many hops and what pictures you have:\n\n > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,\n > waits by a window, then comes back. I have a face photo, a photo of her\n > apron, and a photo of the kitchen.\n\n5. Each panel section has its own **JSON** disclosure at the bottom. The first\n ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second\n in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good\n version on screen and says so, rather than discarding your paste.\n6. **If the node rejects it, paste the error straight back into the chat.** One\n round trip usually fixes it.\n\nWant it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.\n\nSmall models (7B-8B) hold the JSON schema but drift on the prose rules -- they\nwrite negations. Skim the beats before queueing."
1198
+ ],
1199
+ "color": "#432",
1200
+ "bgcolor": "#653"
1201
+ }
1202
+ ],
1203
+ "links": [
1204
+ [
1205
+ 3,
1206
+ 4,
1207
+ 0,
1208
+ 7,
1209
+ 2,
1210
+ "VAE"
1211
+ ],
1212
+ [
1213
+ 4,
1214
+ 5,
1215
+ 0,
1216
+ 7,
1217
+ 3,
1218
+ "VAE"
1219
+ ],
1220
+ [
1221
+ 5,
1222
+ 7,
1223
+ 0,
1224
+ 16,
1225
+ 0,
1226
+ "IMAGE"
1227
+ ],
1228
+ [
1229
+ 6,
1230
+ 7,
1231
+ 1,
1232
+ 16,
1233
+ 1,
1234
+ "AUDIO"
1235
+ ],
1236
+ [
1237
+ 7,
1238
+ 7,
1239
+ 2,
1240
+ 16,
1241
+ 2,
1242
+ "STRING"
1243
+ ],
1244
+ [
1245
+ 8,
1246
+ 16,
1247
+ 0,
1248
+ 8,
1249
+ 0,
1250
+ "IMAGE"
1251
+ ],
1252
+ [
1253
+ 9,
1254
+ 16,
1255
+ 1,
1256
+ 8,
1257
+ 1,
1258
+ "AUDIO"
1259
+ ],
1260
+ [
1261
+ 10,
1262
+ 8,
1263
+ 0,
1264
+ 9,
1265
+ 0,
1266
+ "VIDEO"
1267
+ ],
1268
+ [
1269
+ 11,
1270
+ 2,
1271
+ 0,
1272
+ 30,
1273
+ 0,
1274
+ "MODEL"
1275
+ ],
1276
+ [
1277
+ 12,
1278
+ 3,
1279
+ 0,
1280
+ 30,
1281
+ 1,
1282
+ "CLIP"
1283
+ ],
1284
+ [
1285
+ 13,
1286
+ 30,
1287
+ 0,
1288
+ 31,
1289
+ 0,
1290
+ "MODEL"
1291
+ ],
1292
+ [
1293
+ 14,
1294
+ 31,
1295
+ 0,
1296
+ 32,
1297
+ 0,
1298
+ "MODEL"
1299
+ ],
1300
+ [
1301
+ 15,
1302
+ 32,
1303
+ 0,
1304
+ 33,
1305
+ 0,
1306
+ "MODEL"
1307
+ ],
1308
+ [
1309
+ 16,
1310
+ 33,
1311
+ 0,
1312
+ 34,
1313
+ 0,
1314
+ "MODEL"
1315
+ ],
1316
+ [
1317
+ 17,
1318
+ 34,
1319
+ 0,
1320
+ 7,
1321
+ 0,
1322
+ "MODEL"
1323
+ ],
1324
+ [
1325
+ 18,
1326
+ 30,
1327
+ 1,
1328
+ 7,
1329
+ 1,
1330
+ "CLIP"
1331
+ ]
1332
+ ],
1333
+ "groups": [
1334
+ {
1335
+ "id": 1,
1336
+ "title": "READ ME -- writing for this node",
1337
+ "bounding": [
1338
+ -990,
1339
+ -465,
1340
+ 980,
1341
+ 2560
1342
+ ],
1343
+ "color": "#3f789e",
1344
+ "font_size": 24
1345
+ }
1346
+ ],
1347
+ "config": {},
1348
+ "extra": {
1349
+ "ds": {
1350
+ "scale": 0.8264462809917354,
1351
+ "offset": [
1352
+ 500,
1353
+ -281.1386834420914
1354
+ ]
1355
+ }
1356
+ },
1357
+ "version": 0.4
1358
+ }