File size: 57,231 Bytes
e727aeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a01834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e727aeb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddcc824
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07b19b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
# Hand Tie Clips -- engineering log

The dated record behind [`CLAUDE.md`](../CLAUDE.md), which is the architecture
brief. Everything here is **history**: what was built on a given day, what it
measured, and what it got wrong. Later sections correct earlier ones -- read to
the end of a thread before trusting the top of it.

Numbering starts at 8 because sections 1-7 became the brief.

## 8. References are files, not wires (2026-08-28)

The node had **16 sockets** and twelve of them were user media: `ref_image_1..9`,
`reference_video`, `voice`, `start_image`. They occupied roughly 340px down the
left of the node before the editor started, each needing its own `Load Image`.
They are gone. The node now has **five**: `model`, `clip`, `vae`, `audio_vae`,
`continuity_state`.

A reference names a **file** under `<ComfyUI input>/h3_refs`. `media.py` owns
both halves of that:

- **`resolve(name)` is the only thing that turns a name into a path**, and it is
  used by the upload route and the loaders alike. Basename only, `normpath`,
  then a prefix check against the reference directory, then an extension
  whitelist. Verified against absolute paths, `../` traversal, and traversal
  carrying a legal extension β€” all refused.
- **The loaders return exactly what the sockets delivered** β€” float `[N,H,W,3]`
  in 0..1 β€” so `_ref_frames`' resize, `_collect_ref_images`' dense-pack and
  `store.tensor_digest`'s cache keying are all unchanged. That is why this was a
  small diff rather than a rewrite.

**Pixels never enter a widget.** Only the basename is stored. PromptMasterLD
measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
failed to save the workflow at all; previews are `/view?...&subfolder=h3_refs`
URLs, which cost nothing to rebuild and survive a reload. `/view` also brings
Range support, which a `<video>` needs to seek β€” so no thumbnail route was
needed.

**`IS_CHANGED` is new and load-bearing.** ComfyUI caches a node's output on its
inputs, and a filename is a stable input even when the bytes behind it change:
overwrite `face.png` and the previous render would be served. It hashes
`name:mtime` for every referenced file. Deliberately **not** `float("nan")` β€”
that is the blunt version PromptMasterLD's studio node uses, and it would force
a full re-render of an expensive node on every queue.

**Widget order is part of the saved-workflow format.** `widgets_values` is a
**positional array** -- ComfyUI restores `value[i]` into `widget[i]` and never
looks at the name. The three `*_file` widgets were first added at the *top* of
`optional`, which shifted `hop_script`..`tone_compensate` by +3; every workflow
saved before that change then loaded `audio_pin_frames`' integer into `ref_plan`
and the editor died with `(text || "").trim is not a function` on load. That
throw was the lucky part -- `hop_script`, `shot_plan` and `tone_compensate` were
being silently misassigned too, and only the type mismatch made any of it
visible.

**New widgets go at the bottom of `optional`, always.** Old workflows are then
*short* rather than *misaligned*, and the new widget takes its default. There is
an append-only marker comment in `INPUT_TYPES` saying so. Note `seed` costs
**two** array slots, not one: the frontend appends `control_after_generate`
right after it.

Both plan parsers now coerce (`String(text ?? "")`) and reject non-object JSON
instead of dereferencing it, so a future misalignment shows an empty JSON tab
rather than aborting the whole workflow load.

**The hop cache needed no change at all.** `chain_salt` already digests the
actual tensors, so different pixels behind the same filename still move the key.

**Legacy plans cannot be migrated automatically.** An old `ref_image_N` held a
tensor from a `Load Image`; there is no filename to recover. `refs.py` keeps the
authored slot as `legacy_slot`, `parse_ref_plan` does **not** raise on it, and
both the rail row and `check()` say *"was wired to ref_image_3 β€” pick its
picture"*. Failing the parse would have stopped the editor opening the very plan
the author needs to repair.

## 9. Presentation (2026-08-28)

**The node collapsed on first click and stayed collapsed.** Two causes, both in
`installHeightGuard` (`js/editor/widget_utils.js`), both ported from
`PromptMasterLD/js/claude_prompt.js:6285-6370`:

- `computeLayoutSize().minWidth` reported a **constant**. The layout pass
  re-reads it on every recompute β€” selecting the node is enough β€” and
  faithfully re-declares the node at its stated minimum. It now tracks
  `node.size[0]`. Safe against feedback: `max()` against a constant is a
  fixpoint, not an accumulator, and width never feeds height.
- A stale `widget.width` shadows `node.width` forever after load, because
  ComfyUI's DOM-widget position updater reads `(widget.width ?? node.width)`. A
  live getter with a dropped setter makes the stale value unrepresentable.

Two more fixes alongside: `node.computeSize()[0]` is floored at the panel width
(with no widget declaring one, LiteGraph falls back to `NODE_WIDTH * 1.5 = 210`
and every resize command is free to crush the panel), and `domWidget.computeSize`
now answers a **width-passing** caller with the minimum height and a
**no-argument** caller with the live height. Reporting the live height to both
pins the resize-drag floor to the current height, so the node could only ever
grow.

**`chrome()`'s memo key was stale by construction.** It keyed on
`inputs|outputs|widgets.length`, none of which change when a widget is *hidden*
β€” so every panel height computed after `applyVisibility` used a chrome
measurement taken before it. The hidden count is now part of the key.

**The reference rail was permanently crushed, and its rescue was dead code.**
The 7-track grid needs ~536px and the node offers ~510px at `NODE_WIDTH 560`.
There was a `@container (max-width: 460px)` block written to relieve it β€” but
**nothing in the codebase declared `container-type`**, so the query had no
containment context and never matched. `.h3e-section` now declares
`container-type: inline-size`, and there are two breakpoints.

**The palette committed to one look.** It used to derive surfaces from the host
theme via `color-mix()` while hardcoding every accent β€” the intent was
light-theme safety, but `--h3-bg` mixed toward `#111827` and `--h3-sunken`
toward `#000`, so a light theme got dark blue-grey islands anyway. It is now the
PromptMasterLD `.ldp-root` system: `#0a0a0a`, one hairline `#2a2a2a`, zero
radius, one accent `#e8ff47`. **Changing `--h3-accent` moves the whole panel.**
Emphasis **inverts** (accent fill, `--h3-on-accent` ink) rather than tinting,
because an acid accent at 30% behind unchanged text is olive mud.

A cascade trap worth remembering: the override block was first inserted *before*
the reference-rail section, so `.h3e-chip-on`, `.h3e-inactive .h3e-ord` and
`.h3e-subj-badge` all kept winning on source order. Overrides live at the end of
the sheet now. Without a browser, a token audit script is the only thing that
catches this class of bug.

**The panel did not fill the node, and the mirror was why.** `installHeightGuard`
kept an independent `_h`, updated through an `onResize` hook, so that the
arrange pass could not feed its own growth. Measured during a drag: `onResize`
and `setSize` each fired 57 times while `_h` sat at 876 and `node.size[1]`
climbed past 1400. `measuring` was not stuck, which left one gate --
`Array.isArray(size)`. **This frontend's `node.size` is not a plain Array**, so
every write was skipped and `_h` held the install-time height forever. A 1911px
node had a 742px panel.

The repair was to delete the mirror, not fix the hook. `_h` existed only to
break the arrange loop, and

    panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK)   // SLACK 8

breaks it outright: the pass wants `panelTop + panelHeight + 4`, and `panelTop`
is chrome minus the node's bottom padding, so a panel of exactly `size - chrome`
asks for up to 4px more than the node has on every frame -- the ~130px/frame
runaway the header comment records. With slack the inequality holds, the loop
settles, and the node's height simply *is* the panel's height. Nothing to keep
in sync, no hook to get wrong. `sync()` now only ever grows a too-short node;
the height is the user's to choose.

**RUN is pinned and always open.** `.h3e-root` used to be the scroll container
with all four sections inside it, which put RUN below the script -- out of view
on any workflow with more than two shots, and it is the section touched on every
queue. The root is now a flex column holding `.h3e-scroll` (the authoring
sections) and RUN outside it.

Two flex details, both of which cost a round trip to learn:

- **`.h3e-scroll` needs `min-height: 0`.** A flex item defaults to
  `min-height: auto` and refuses to shrink below its content, which pushes RUN
  off the bottom of the node instead of scrolling.
- **`.h3e-run` must be `flex: 0 0 auto`, never `0 1 auto`.** Flex divides a
  deficit in proportion to each item's content height. The scroller's content is
  far taller, so a shrinkable RUN loses most of the contest and clips its lower
  groups. The scroller absorbs all the shrinking; its `min-height: 160px` is the
  floor that stops RUN owning the panel and RUN's `max-height: 55%` is the
  ceiling.

Always-open removed the only moment RUN re-read its widgets, so it now resyncs
on `api`'s `promptQueued` -- the client-side event that fires after
`control_after_generate` has bumped the seed, and the same one ComfyUI's own
change tracker uses. The listener is dropped in `node.onRemoved`.

**Not done:** PromptMasterLD's `--fsc` UI-scale multiplier, which makes every
dimension `calc(Npx * var(--fsc))` and puts a zoom slider on the panel. It is
mechanical churn across every rule in the sheet and was not worth doing blind.

## 10. Ported for public use (2026-08-29)

The prompting craft was written down and the pack was made installable by a
stranger. Four things, in the order they mattered.

**The shipped example workflows were broken.** All four in `workflows/` predated
the 2026-08-28 socket removal: twelve dead media inputs each, three `LoadImage`
nodes, and 21 widget values against a 28-widget node. Loading one is a new
user's *first* action, so this outranked any amount of documentation. They are
quarantined in `_disabled_custom_nodes/h3_legacy_workflows/` (the pack is not
under its own version control -- deleting would have been unrecoverable) and
replaced by two built from the verified `H3_Stress_6x7` structure:

- **`HandTieClips_Starter.json`** -- two hops, **no references at all**, runs the
  moment the loaders are pointed at files. The empty register is deliberate: an
  `@tag` whose picture is missing is a *hard* error in `resolve_tags`, so a
  starter that shipped with tags would fail on first queue for everyone.
- **`HandTieClips_Showcase.json`** -- the six-hop continuity test, with the three
  reference filenames generalised.

Both use **core ComfyUI plus this pack only**. The dev workflow reaches KJNodes
(`ModelPreviewOverrideKJ`, `MiniMaxLowVRAMAttention`) and PlagueKind
(`LTX_lora_loader`, `H3SLAAttention`, `H3AdaLNLoRAFix`); an example that fails
to load because of a pack the reader never asked for teaches nothing. The
speed stack is documented, not shipped.

**A declared-but-inactive `@tag` reported the wrong cause.** `check()` already
warned correctly that a picture was missing, but the run then died on
`resolve_tags` with *"unknown reference '@kitchen'"* -- pointing at the beat's
spelling, the one thing that was right. `resolve_tags` now takes `declared`
(every tag in the register, active this hop or not) and separates the two
failures. The old two-argument behaviour is unchanged when `declared` is
omitted.

**`PROMPTING.md` and `prompt_pack/`.** The craft rules were spread through
README prose; they are now a standalone guide, and a copy-paste system prompt
that gets a language model to emit valid plans. Two files under `prompt_pack/`
are **generated, never hand-written**:

- `tools/gen_schema.py` builds `SCHEMA.json` from `directives.VOCAB`,
  `refs.RETENTION` and the duration table, and **asserts** against
  `plan._SHOT_KEYS`, `refs.REF_FIELDS` and `refs.SUBJECT_FIELDS`. Add a camera
  move and the schema follows; add a shot field and the generator fails loudly
  rather than emitting a stale schema. `--check` is the CI form.
- `tools/gen_example.py` builds `EXAMPLE_6_HOP.md` from the showcase workflow,
  including its hop/reference table, so the worked example and the shipped
  workflow cannot disagree.

**Template patterns in the editor.** `js/editor/templates.js` plus a
**Templates** button in the SCRIPT header. They **append**, never replace --
replacing would be the one destructive control on the node, and stacking is how
a chain is actually built. `freeId()` mints the lowest unused `sN` because `id`
is the hop cache's pointer and two shots sharing one would make `locked` reuse
the wrong render.

No template contains an `@tag`, for the same reason the starter workflow has no
references. `tools/check_templates.py` extracts the patterns *out of the JS*
and runs them through `plan.parse_plan` and `plan.check_coherence`, and lints
every beat for negation -- a template that produced a plan the node rejects
would be worse than no templates, because a first-time author would blame their
own writing. It caught two: a beat reading "They stop at the window" (naming a
cessation, which is law 2) and a line of dialogue containing "did not".

That second one is worth recording as an open question: **whether the additive
prompt bites inside quoted dialogue was never tested.** The templates avoid it,
and `PROMPTING.md` says plainly that this is untested rather than inventing a
rule.

## 11. What chain_00057 taught (2026-08-29)

The six-hop showcase was rendered for the first time. Three failures, and the
one that mattered was a code gap the documentation had already claimed was
fixed.

**`locked` and `context` reached hop 1 only.** The register's whole promise is
that a subject's continuity text carries identity across a hop where the
photograph is absent. It did not. `subject_prose` is called under `if i == 0`,
and `_identity_lock` returns `""` when no subject-bearing ref is active -- so on
the showcase, hops 2, 3, 5 and 6 carried **no identity text of any kind**, and
hop 5, scheduled with no references at all, lost the character entirely.
Identity was riding on the pinned frames alone, which is the exact failure the
register exists to prevent.

`refs.continuity_line()` now emits that text on every hop 2+, and
`_assemble_next` injects it between the lock and the live-frame citation. The
reason it was suppressed in the first place is real and is preserved: naming
*pictures* on a pin-only hop sent the encoder back to the plates (chain_00034 --
commercial kitchen, grey shirt, no apron). So the new line carries **no
`<Picture N>` and no `<Subject N>`**. It is a description of what stays the
same, not a citation of anything. `<Subject N>` is excluded specifically because
there is no `subject_definitions` block on a continuation hop to bind it to.

Note this changes the assembled block on hop 2+ of every chain with a register,
so it invalidates the hop cache. That is correct, not a regression.

**Dialogue propagated through five hops.** Shot 1 ended on its spoken line with
`tail: ongoing`. The audio pin carries the previous hop's tail, so the last
second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
the closing instruction; the model satisfied it with the action it could hear.
Nothing in hops 2-6 gave the audio anywhere else to go.

This is the guide's own law 3, broken by the plan written to demonstrate it. The
fix is authorial, not code: land the line **mid-hop** and leave a non-verbal
action running into the seam (the knife on the board), and give every
dialogue-free hop a narrowband sound of its own. Both `PROMPTING.md` and the
authoring prompt now carry this as a named rule, and the shipped templates were
rewritten to obey it.

**`join: continuous` across a location change morphed one room into the other.**
Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
continuous join asks for one unbroken take between two different rooms, which
is not a thing. `match_cut` is what a walk through a doorway is. The beat also
read "steps into @kitchen" -- the container phrasing the README warns produces a
composite of the photograph -- and now names the counter *in* the kitchen.

Hop 5's beat is unchanged in the revised plan **on purpose**: it is the
measurement, and changing it would forfeit the comparison.

The revised plan is in the shipped showcase and in
`user/default/workflows/H3_Stress_6x7_v2.json`; the original that produced
chain_00057 is left alone so the A/B survives.


## 12. Renamed to Hand Tie Clips (2026-08-29)

The pack was `ComfyUI-H3-Ref-Chain`. It is now `ComfyUI-Hand-Tie-Clips`, and the
four registered ids moved with it:

| was | is |
|---|---|
| `H3RefChain` | `HandTieClips` |
| `H3ContinuityState` | `HTCContinuityState` |
| `H3ChainPreview` | `HTCChainPreview` |
| `H3ToneCompensate` | `HTCToneCompensate` |

Display names are unchanged (`H3 Ref2VA Chain`, `H3 Chain Preview`, ...) because
the pack only drives MiniMax H3 checkpoints and a name that hides that costs
somebody an afternoon. The menu category is `Hand Tie Clips`. `TAG` -- and so the
console prefix -- is `[HandTieClips]`.

**The old ids are still registered, as `DEPRECATED` subclasses.** A type id is
what every saved `.json` carries, and an unregistered one is a red missing-node
box, not a warning. A plain alias in `NODE_CLASS_MAPPINGS` would have worked but
listed each node twice in search: ComfyUI falls back to the mapping key when
`NODE_DISPLAY_NAME_MAPPINGS` has no entry for it. Subclassing and setting
`DEPRECATED = True` gets both -- `server.py:783` publishes `deprecated: True`,
and the frontend's `Comfy.Node.ShowDeprecated` (off by default) keeps it out of
search while leaving it fully functional in workflows that name it.

**The JS had to learn both ids or the aliases would have been worse than
useless.** `js/h3_ref_chain_ui.js` and `js/h3_chain_preview.js` each compared
`nodeData.name` against a single string; a legacy node would have loaded with no
editor at all, which looks exactly like the rename having broken the pack. Both
now test membership of a `Set`. `js/h3_chain_preview.js` needs it twice: once
for its own type, once for `CHAIN_TYPES`, which is how a preview walks back up
`images` to find the chain feeding it.

**What deliberately kept the `h3` naming**, and must not be "finished" later:
module filenames (`h3_ref_chain.py`), the `h3e-` CSS class prefix and `--h3-`
tokens (515 occurrences -- one missed class silently breaks styling), the
`/h3_ref_chain/*` routes, the `h3_refchain_preview` event name, and
`input/h3_refs`. Renaming that folder would orphan every reference photo already
on disk.

The two shipped workflows moved to `HandTieClips_Starter.json` /
`HandTieClips_Showcase.json` and were rewritten onto the new ids, with their
`SaveVideo` prefix now `video/HANDTIECLIPS/chain`. The three under
`user/default/workflows/` were left on the legacy ids on purpose: re-running
`H3_Stress_6x7_v2.json` is then a live test of the alias path, and their existing
renders stay together under `output/video/H3REFCHAIN/`.


## 13. The on-canvas board (2026-08-29)

`workflows/HandTieClips_Starter.json` carries six `MarkdownNote` cards to the
left of the loaders, wrapped in a group titled READ ME. The text lives in
`tools/notes.py` and is written into the workflow by `tools/build_notes.py`.

Why on the canvas: the craft was in `PROMPTING.md`, `prompt_pack/` and the
Templates panel, and all three require leaving the graph. The rules that decide
whether a first render works are needed while beats are being written, which is
on the canvas. The cards are a **condensation**, not a copy -- `PROMPTING.md`
stays the authority and every card says so.

Mechanics worth not rediscovering:

- `MarkdownNote` is a core virtual node. The frontend renders it with `marked`
  at `gfm: true` and sanitises with DOMPurify, so headings, GFM tables, bold and
  code fences all work. It needs frontend >= ~1.16.
- Cards are identified by `properties.htc_card`, which is what makes
  `build_notes.py` idempotent -- it drops marked nodes before writing, so
  re-running replaces the board instead of stacking a second copy.
- `extra.ds` is **restored** on load, not fitted. Without setting it, a board at
  negative x sits off-screen and is never found. Screen is
  `(world + offset) * scale`, so the offset is what brings it into view.
- Group serialisation is `{id?, title, bounding:[x,y,w,h], color?, font_size?,
  locked?}` -- confirmed against the frontend's own zod schema, not guessed.

**`widgets_values_named`, found while doing this.** Both shipped workflows
carried a stale copy: `chains: 3`, `duration: 10 s`,
`control_after_generate: randomize`, and a legacy `ref_plan` naming pictures that
do not ship -- 25 entries against a 28-widget node. It came from the builder
deep-copying nodes out of the dev workflow and overwriting only
`widgets_values`. Dormant while `Comfy.Workflow.NamedValuesRestore` stays off
(experimental, default false), but anyone who turned that on would have loaded a
Starter that randomizes its seed and dies on a missing reference.
`build_notes.py` strips it from both, and `tools/check_workflows.py` now fails if
it ever comes back.

`tools/check_workflows.py` is the promoted version of the validator that guarded
the shipped workflows against socket/widget drift. It also checks the board:
every card marked and non-empty, exactly one group, the group enclosing every
card, and no card reaching past x=0 onto the loaders.


## 14. The turbo stack ships in the examples (2026-08-29)

Both shipped workflows now carry the dev graph's full MODEL wire:

    UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
               -> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips

and **CLIP reaches the chain from the LoRA loader, not the encoder** -- that is
what makes the text half of every LoRA land, and it is the wire most likely to
get quietly "fixed" back to the encoder by someone tidying the graph.

This reverses the earlier "core ComfyUI and this pack only" rule for the
examples, at the user's direction: `steps` is 7, which only works with a turbo
LoRA, so an example without the LoRA stack is not a graph anyone can run at the
settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
KJNodes supplies Low VRAM Attention and the preview override.

`tools/build_speed_stack.py` inserts and rewires the five nodes idempotently
(marker `properties.htc_speed`), rebuilding the plain loader -> chain shape
first so it is reentrant from either state. `tools/check_workflows.py` walks
**both** wires link by link -- a patch node that is present but bypassed round
the side looks right on the canvas and does nothing.

`H3SLAAttention` widget values are written out in full including
`reference_protection`, which post-dates the dev workflow's saved values. That
workflow has 10 entries against an 11-widget node, which is the positional rule
working as intended: the new widget was appended last, so old values still line
up and the missing one falls back to its default. The trailing `""` on the LoRA
loader and the preview override is carried over verbatim for the same reason --
a value past the last widget is ignored, and dropping one that turns out to
belong to a widget would shift every value after it.

`head_chunks` ships at 4 (the node's own default, safer on unknown VRAM) rather
than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
loader names, and `taeh3.safetensors` for the preview override's `tiny_vae`.

## 15. What a shipped diagnosis looked like (2026-08-29)

The editor UI vanished after the folder rename -- raw widget boxes, no panel.
Not a code fault. ComfyUI had been restarted while the pack was still
`ComfyUI-H3-Ref-Chain`, so the running process held
`python_module: custom_nodes.ComfyUI-H3-Ref-Chain` and served its web assets
from a path the move had deleted. `/object_info` had all eight node types;
`/extensions` listed none of the pack's JS and a direct fetch 404'd.

Worth knowing for next time: `/object_info` carries `python_module`, which is
the fastest way to find out **which copy of a pack a running server actually
loaded**, and `/extensions` plus a direct fetch of one script separates "the JS
is broken" from "the JS is not being served at all".

## 16. What the Rain Kitchen renders taught (2026-08-29)

Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
three findings are now in `PROMPTING.md`, the troubleshooting table, the
authoring prompt and the on-canvas board.

**Identity drift is permanent, and the old advice was backwards.** This file and
`PROMPTING.md` used to say a face plate riding a later hop *beats the pin* and
that `shots` should therefore be kept tight. chain_00059 falsified it: hop 4
carried a face plate photographed in a *different kitchen* through a walking
medium shot and held cleanly, while hop 5 β€” scheduled with no references at
all β€” came back a different person, and hop 6 never recovered even though a
place plate rode it and restored the room instantly. `locked` holds a face that
is still right; only a plate rebuilds one that is gone. **Face refs go on every
hop.** The old advice survives for *place* plates only.

**A beat must survive an over-delivered hop.** A hop routinely does more than it
was asked. Shot 3 asked for "a first slow step along the counter" and delivered
the whole walk; shot 4 was then handed an instruction its own live frame had
already satisfied, and the only way to obey was to reset the scene β€” a hard
cut 1.5 s *into* the hop, not at the seam, because `_assemble_next` holds the
incoming frames for a short beat first. Give one hop the whole movement, and
write the next beat true from either ending ("she *reaches* the window", "she
takes up the bowl *again*").

**A noun with no adjective drifts.** Three hops said only "the bowl"; it came
back stainless steel. Naming it "the white bowl" everywhere *and* stating it in
`context` as a property (never a location β€” "stays in her hands" fights the
beat the moment she puts it down) held it for six hops. The re-run then proved
the rule by accident: `context` read "the apron stays tied over the **grey**
t-shirt", and the t-shirt held all six hops while the apron, one clause away
with no colour, had turned denim blue by hop 6.

### The code change this bought

`refs.resolve_tags` now takes `subject_names` and the node passes it on
continuation hops only. `<Subject N>` is bound by `subject_definitions:`, which
is hop-1 material β€” so on hop 4 the ordinal dangled, exactly the same defect
as the undescribed bowl. From hop 2 a person tag resolves to the subject's
`name` instead ("The cook walks down the hallway"), which binds to the
`continuity_line` sentence every continuation hop already carries. No name means
the old `<Subject N>` fallback, so nothing regresses.

`tools/check_prompts.py` mirrors the new call, and gained the banned-word check
that previously ran only against `templates.js`. That gap is how the shipped
Showcase carried "**None** of the kitchen is visible" (law 1 β€” it *adds* a
kitchen at cfg 1.0) and "She **stops** at the window" (law 2) through six
versions. Both are fixed, and the Showcase's face ref now rides all six hops.

## 17. The pre-beta audit (2026-08-29)

Read for what a stranger hits, not for what we already check. Six findings; the
first two would each have cost a tester a run.

**The docs promised a stop the code never did.** `refs.check()` is *"warn, never
raise"*, and its caller only printed. A ref naming a file that is not in
`h3_refs` had its slot skipped and the chain rendered on β€” all six hops, with
the reference silently inactive, which is precisely the uncontrolled output the
register exists to prevent. Four shipped surfaces said otherwise, including the
Showcase's own note: *"the run stops and names the reference it could not find.
Nothing guesses."*

Fixed in the code rather than the docs, because there is no reading under which
rendering without a named-but-absent picture is what the author meant. New
`refs.missing_files()` returns `(tag, file)` for every ref whose named file did
not load; `h3_ref_chain.py` raises on it, after printing the register table so
the error arrives with its context. **The distinction that matters:** a ref with
*no* `file` stays a warning β€” the Starter ships that way on purpose so it runs
before any pictures exist. Only a *named* file that is absent is fatal.

**The on-canvas board never got Β§16's corrections.** `tools/notes.py` still
carried the row chain_00059 falsified β€” *"a plate riding a hop it does not
belong on, beating the pin β†’ tighten `shots`"* β€” and was missing all three rows
`PROMPTING.md` gained. The board is what a beginner actually reads, so it was
shipping the inverse of the rule. Lesson: `PROMPTING.md` is the authority, but
`tools/notes.py` is a **second copy** of the same craft, and a correction is not
finished until both move. `build_notes.py` regenerates the Starter's cards *and*
the Showcase's `Note` from `notes.SHOWCASE_NOTE`, so one rebuild covers both.

The rest were packaging, not craft: the MIT `LICENSE` named no copyright holder;
`pyproject.toml` lacks both fields a Registry publish needs (`PublisherId` and a
`[project.urls] Repository`), now commented in place; the `README` had **no
install section at all**; `ComfyUI-H3-Motion-Context` was absent from Needs
despite the intro calling it the primary guidance path, so testers land on the
`MiniMaxH3AddGuide` fallback without knowing they changed code paths; and the
example workflows name quantised checkpoints by filename with no note that they
are one valid set among many, which reads as a broken graph rather than a
missing file.

Clean on the same pass, worth not re-checking: no personal data anywhere, no
hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
syntax, no third-party dependencies, Motion-Context absence handled with fork
detection, `WEB_DIRECTORY` present, `h3_refs` auto-created.

## 18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)

Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The `ref_plan` came
back structurally perfect -- both subjects named, face plate on all eight hops,
places scheduled off the hops they do not belong on, zero register warnings. The
`shot_plan` carried four defects, and tracing each one back to
`prompt_pack/AUTHORING_PROMPT.md` found more than four gaps.

**The prompt taught one of the bugs.** Rule 9 illustrated a beat as
``"stands at the counter in `@kitchen`"`` -- backticks and all, because it was
the one place a tag appeared inside a beat. The model copied the formatting, and
literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
prose.

**Beat length had no guidance at all.** The only budget in the file was for
dialogue, so a no-dialogue plan had nothing to size against and every beat came
back at 22-28 words regardless of a 15 s hop. There is now a word table -- and
an honest note on it: the shipped plans run 37-39 words at *both* 5 s and 7 s,
so beat length is near-constant across the only two hop lengths ever rendered.
The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned
extrapolation, not a measurement, and the table says so.

**Two rules were simply missing.** Nothing told the model to state a visual style
-- for a "2D anime, Ufotable" concept neither returned block contained the word
anime, so the text asked for photoreal while the pictures asked for anime. And
the over-delivery rule from section 16 had never been carried into the prompt at
all, only into `PROMPTING.md` and the board.

**Two statements were wrong.** The prompt said "at most 9 pictures on any one
hop"; `parse_ref_plan` counts `len(refs)` over the **whole plan**. The code's own
error message said "on one hop" too, and has been corrected to match what it
checks. And the `file` bullet still told the model to invent placeholder
filenames without noting that, as of 0.3.1, a named file absent from `h3_refs`
**stops the run**.

Law 2 is the interesting non-fix. It is stated plainly, with the exact example
"The cook stops talking", and the model wrote "stops and looks up" anyway. A
principle is not something a 27B reliably applies to its own output; the literal
word list `check_prompts.py` enforces now appears in the prompt, because a word
list is checkable and a principle is not.

## 19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)

Three sources landed together: a rendered 8 x 15 s anime chain (`chain_00003`,
2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
written to trip specific rules. Shipped as **0.3.3**.

### The node was fighting every stylised plan

`directives.py` prepended `ESTABLISH = "Live-action, natural light, one
continuous take."` to hop 1 **unconditionally**. At cfg 1.0 with no negative
branch that is additive, and it landed *ahead* of the style declaration rule 12
requires. A stop-motion puppet plan compiled to "Live-action, natural light,
one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
painted wood", and the two fought.

It also explains the anime chain's opening: hop 1 rendered as bright
naturalistic daylight (**mean luma 72**) against a night plan *and* a night
place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That
had been read as drift; it was the node.

Never surfaced because both shipped workflows are live-action.

Fixed twice over, because either alone leaves a hole: an `establish` widget
(appended **last** in `optional`, per the positional-widget rule in section 8),
and `directives.declares_own_medium()` / `establish_for()`, which drop the
default when shot 1's opening names a medium. A model-authored plan never
touches a widget, which is why the automatic arm is the one that matters.

### Luminance drifts, and it only goes one way

Mean luma per hop across the eight: **72, 46, 35, 17, 11, 11, 19, 14**. Setting
the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the
point of the film, plays at the bottom of it.

This is the colour law one level up. `locked` holds a face; nothing holds an
exposure. Each hop inherits the last frame and darkens it slightly and the
error compounds. **Restate the light as a positive property in every beat** --
naming a light *source* ("pale moonlight") does not set a level, and at cfg 1.0
it only adds a moon.

### The seams were never the problem

7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is
13.6, inside the range of ordinary in-shot motion, and two seams score below the
film's own mean. Identity held 114 seconds on face plates riding every hop.

The one hard cut in the film is **inside** hop 4, 3.25 s in, at 7.1 sd -- more
than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
the two of them square off". Hop 4 was handed a live frame of a man among trees
and a beat asserting he stood on open stone, held the forest for 3.25 s, then
reset the scene. The plan was clean under `check_coherence` and the banned-word
scan. Hence `plan.check_place_handoff()`.

### What the two models did

Near-identical answers from Qwen and Gemma on the same prompt -- same beats to
the word in 5 of 6 shots, same defects, same invented justification. The prompt
is prescriptive enough to collapse two models onto one answer; a shared blind
spot is then invisible from output alone.

**The word table was inert.** Both models, asked for six 15 s hops, returned
beats averaging **54 words** against a 70-100 band -- every beat under the floor
-- and the same models on much shorter hops returned 40-48. Beat length is
near-invariant to hop length in model output, now measured in a third setting.
A reference table does not move a model with a prior on paragraph length; the
rule is now an instruction to count, with a worked 74-word example.

**The banned-word list beat the principle, and made things worse.** Zero literal
banned words in all four plans -- and "The storm's roar begins to fade...
raindrops strike the glass with decreasing force" and "The storm has passed".
The list taught token avoidance and handed them a box to tick. It is now framed
as crude examples of an idea, with the test stated as: is this happening, or has
it finished happening?

**Both abandoned the second location.** Each plated the opening place, moved the
story elsewhere, gave the new place no plate, and justified it with a rule that
does not exist ("to avoid conflicting with the frame pin of the new space");
Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left
the lamp room -- four of six hops -- on beat text alone.

### `check_place_handoff` has two arms, and both were narrowed by real plans

1. **Handoff.** Shot N names a place tag shot N-1 never mentions, and shot N's
   own beat does not carry the journey. The arrival vocabulary had to widen: the
   Showcase's shot 6 ("walks back along the hallway and through the doorway to
   the counter in @kitchen") is correct and was being flagged.
2. **Abandonment**, not gaps. Warning on any unplated hop also flagged the
   Showcase, which deliberately walks her down an unplated hallway on 4-5 and
   returns the kitchen plate on 6. The rule that survives contact: warn only
   when the plates stop and **never resume**, so the film ends somewhere no
   picture describes.

Both shipped workflows are clean under the final version; the anime plan raises
exactly one warning, on shot 4.

### Audio, for the record

No background music and no speech, confirmed on the spectrogram: broadband
transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
of seven joins, and the two exceptions are drops the beats themselves ask for.
The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
while hop 8 averages -42 dBFS. There is no audio equivalent of
`HTCToneCompensate`. Left open.
## 20. Seven features built blind (2026-08-30)

Built in one pass with no browser and no GPU render available -- the user was
away and explicitly asked for the work anyway. Everything below was verified by
offline execution only. **Section 21 is what happened when it was finally
opened in ComfyUI**: two of the seven shipped broken in ways no offline test
could have caught, and the rest measured out. Read section 19 first for the
measurements that motivated most of it, and 21 for what survived contact.

### What shipped

| # | thing | where |
|---|---|---|
| 1 | `tone_compensate=anchor` + `tone_anchor` strength | `tone.py`, wired in `h3_ref_chain.py` |
| 2 | `dry_run` -- compile every prompt, render nothing | `h3_ref_chain.py` |
| 3 | `contact_sheet` -- a fourth IMAGE output | new `sheet.py` |
| 4 | `render_through` -- stop after hop N | `h3_ref_chain.py` |
| 5 | `quality=draft` -- 0.3 MP, 6 steps | `h3_ref_chain.py` |
| 6 | **H3 Seam Report** node | new `seam.py` |
| 7 | over-delivery lint | `plan.py` |

Five new widgets, appended LAST (29 -> 34 values). One new output, appended
LAST (3 -> 4). Both rules are in section 9; both were obeyed.

### The anchor, and why it is not just another tone mode

frame_shift/gain_bias/lut are **seam-local**: they cancel the denoiser's tone
bias on the overlap, which makes each join exact. They cannot see the exposure
falloff *inside* a hop, and that is what compounds -- hop N darkens across its
own frames, hands the darker tail to hop N+1, and every individual seam stays
perfect while the film dims. Section 19 measured 46 -> 11 across hops 2-6.

Worth stating plainly because it is counter-intuitive: **a synthetic 8-hop
chain showed frame_shift making the total slide WORSE** (66/255 vs 35/255 with
correction off). That is correct behaviour, not a bug. The denoiser's per-hop
bias happened to lift; cancelling it removed a lift that had been partly
offsetting the falloff. Seam correction fixes seams. It was never a level
control and should not be read as one.

`anchor` = frame_shift + a second stage pulling each hop's mean back toward
**hop 1's**. Two properties make it safe to stack:

- the pull **ramps from zero** over `ANCHOR_RAMP` (48f) frames, so frame 0 of
  a hop is returned untouched and the seam stays exactly as frame_shift left
  it. Without the ramp a per-hop constant offset re-introduces precisely the
  step frame_shift just removed -- this is the whole design, and the trap
  anyone re-implementing it will fall into;
- it is **capped** (`ANCHOR_MAX_SHIFT`, 0.06) and scaled by `tone_anchor`
  (0.35), so a slide is corrected across several hops instead of one hop
  snapping back.

The correction needs no carry variable between hops: because it is applied
before `prev_imgs` is taken, the next hop's seam correction matches the
already-corrected tail and the offset propagates on its own.

On the synthetic chain: slide 66 -> 18/255, worst seam step 2.22 -> 2.13/255.
The seam did not regress, which is the property that mattered.

Intent is indistinguishable from drift from the inside, hence the per-shot
`tone` field: `"free"` skips one hop's pull, `"rebase"` moves the anchor onto
that hop. A deliberate walk into a cellar needs `rebase` or the chain spends
the rest of the film brightening it back.

### dry_run: what it must not touch

The value is that it costs seconds, so every expensive thing is guarded:
`MiniMaxH3SigmaShift`, `KSamplerSelect`, `BasicScheduler`, `_model_fingerprint`
(it hashes patched weights), the hop store, and -- the big one -- the master
preallocation. `master_imgs` for 8 x 15 s at 1280x736 is 2742 float frames,
about **31 GB**. A dry run that allocated it would be worse than useless.

Hop 2+ needs *a* `prev_imgs` to compute `<Picture N>` ordinals. Content is
irrelevant to the compiled text, so a `[overlap, 8, 8, 3]` zero tensor stands
in and the text is byte-identical to a real run's.

The smoke test (`tmp/t_dry.py`) replaces all five sampler entry points with
objects that raise on **any** attribute access, so "did not touch the sampler"
is asserted rather than assumed. It caught one real bug: the dry block
referenced `pin_mech_pred` before its assignment, ~40 lines later. Which pin a
hop gets is decided at render time from whether a sampler latent exists, so a
dry run genuinely cannot know it -- the sheet reports the `pin_to_qwen`
*setting* instead. Reporting AddGuide for every hop would have been a lie.

### The over-delivery lint

The one defect class every other check structurally misses: both shots are
individually well-formed, the directives are individually legal, and only the
JOIN between them is wrong. `tail=settle|hold` promises rest; a following beat
that opens "She continues...", "Walking to...", "Mid-sentence..." asks the model
to carry on what the hop before was told to stop.

Narrow on purpose. Trailing spaces in `_MID_ACTION` are load-bearing ("keeps "
not "keepsake", "still " not "stillness"), and `_MID_ACTION_LEAD` is only
checked at position 0, which is what stops "Morning light..." and "Nothing
moves..." from firing. Verified against those exact traps, and both shipped
plans stay quiet.

It will miss a beat that opens mid-action without saying so. That is accepted:
a false positive that blocked a render would be worse than the defect.

### Notes for whoever picks this up

- `sheet.py` and `seam.py` catch every exception and return a placeholder
  image. A picture must never lose a finished chain. Do not "clean up" those
  handlers. The placeholder was 1x1 until section 21 -- see there for why an
  inert-looking image is not inert.
- The contact sheet stores frames through `sheet.small()` (168px tall). Two
  full frames per hop across eight hops is 180 MB held for the whole render for
  no reason.
- The sheet shows `imgs[overlap_n]` for hops 2+, not `imgs[0]`: the first
  `overlap` frames are trimmed at the join, so `imgs[0]` is a frame the master
  never contains.
- `tools/check_workflows.py` derives the expected widget list from the live
  `INPUT_TYPES`, so it needed no edit for the five new widgets -- only
  `SaveImage` added to `CORE`, for the Starter's new contact-sheet node.
  (Section 21 added `PreviewAny`, `PreviewImage` and `HTCSeamReport` to those
  allowlists when the seam report was wired into the Starter.)
- The Starter now ships `contact_sheet=on` with a `SaveImage` wired; the
  Showcase ships it off. Starter is the teaching graph, so the feature is on
  the canvas where it will be found.

### Unverified, in priority order

All five items that stood here were closed on 2026-08-30. See section 21.

## 21. What the first ComfyUI session measured (2026-08-30)

Section 20's seven features, opened in a browser and run on a GPU for the first
time. Five measured out. **Two were broken, and neither could have been caught
by any offline test that existed** -- both failures lived in the gap between
"the Python is correct" and "the graph runs".

### Bug 1: 0.4.0 never touched `js/`

`git show --stat` on the 0.4.0 commit lists twenty files and not one under
`js/`. The five new widgets were declared in `INPUT_TYPES` and never added to
`GROUPS` in `js/editor/run_panel.js`, so the run panel did not draw them.

It did not *look* broken, which is the interesting part. The panel hides only
the widgets it successfully drew -- a deliberate design so an undrawable dial
never vanishes from the node -- so all five fell through to native dials and
worked fine. `tools/check_workflows.py` passed throughout, because it derives
from `INPUT_TYPES` and `GROUPS` is display-only.

**The lesson: a Python-side widget list and a JS-side widget list are two
sources of truth, and nothing checks that they agree.** Adding a widget means
editing both. There is still no checker for this.

### Bug 2: a 1x1 image is not inert

A dry run returned `sheet.placeholder()` on `images` -- a 1x1 black frame. The
Starter wires `images` into `CreateVideo` -> `SaveVideo`. libx264 in yuv420p
subsamples chroma by 2 and **cannot open a codec context on an odd dimension**,
so every dry run died in `avcodec_open2` before writing a frame, with a
traceback naming ComfyUI's video node and nothing of ours.

Reproduced in isolation: 1x1 throws, 2x2 encodes. `placeholder()` now takes a
width and height, floors at 2x2, and rounds odd dimensions down to even; the
dry run passes the geometry the plan resolved to, so a dry run yields one black
frame at the real resolution and downstream video nodes are happy.

`tools/check_features.py` had asserted `shape == (1, 1, 1, 3)` -- the offline
suite was *pinning the bug in place*. It now asserts an `encodable()` contract
instead. **A test that encodes an exact wrong value is worse than no test.**

### `tone_compensate=anchor`, measured on a real chain

Three hops, 8 s, 0.3 MP, 6 steps, one seed, one cache. Because the hop store
writes *before* the tone block runs, flipping tone modes re-grades cached
renders in ~14 s instead of 164 s -- so the whole sweep is nearly free. Drift is
hop 3 mean minus hop 1 mean; seams are the step across the join.

| `tone_anchor` | drift | seam @192 | seam @362 | flicker |
|---|---|---|---|---|
| off | 13.5/255 | +0.9 | +2.1 | 0.4675 |
| 0.15 | 7.4 (-45%) | -1.3 | -1.0 | 0.4566 |
| 0.35 | 5.1 (-62%) | -1.9 | -1.6 | 0.4618 |
| 0.60 | 2.9 (-78%) | -2.6 | -1.8 | 0.4661 |

Seam figures are `seam.measure(window=6)` -- the shipped node's own method, not
an ad-hoc frame difference -- so the docs and the instrument a user runs agree.

Drift removal is even: 45 / 62 / 78% of the uncorrected slide, ~16pp per step.
**The seam is not monotonic.** `0.15` pulls it tighter than the uncorrected
chain (2.1 -> 1.3) before it grows again, so there is a shallow optimum below
the default rather than a straight trade. From 0.15 up it costs ~0.6/255 per
step. Note also that the seams flip sign: uncorrected they are positive, and
every corrected run overshoots slightly negative. Hop 1 is byte-identical across
all four, as the design requires. **The shipped 0.35 default stays** -- it
halves the drift while every seam still reads marginal or better.

The propagation claim in section 20 -- that no carry variable is needed --
is visible in the logs: hop 3's `frame_shift` grew with anchor strength
(`r+0.0185` -> `r+0.0217` -> `r+0.0256`) because hop 3 measures against hop 2's
already-corrected tail. It self-propagates, exactly as designed.

**Caveat that limits this measurement**: the test scene walks the subject toward
a bright window, so some of that 13.4/255 is light a real camera would also
produce. Anchor cannot distinguish motivated light from drift -- that is what
the per-shot `tone` field is for. A `camera=hold` scene is the run that would
justify moving off 0.35.

### The seam report node, validated

Never executed before this session. Wired into the Starter and checked against
an independent decode of the mp4: agreement to **Β±0.07/255** across six seam
readings under two different hop geometries. Its arithmetic is right.

A better result came out of the cross-check. On a 5-hop master the eight largest
frame-to-frame luma jumps were at f289, f368, f409, f412, f463, f464, f482,
f497 -- **not one of them a seam**. Every join is quieter than ordinary scene
motion. That is the pack's central claim, measured.

**Trap, and it cost a wrong diagnosis.** 5 hops x 124f and 3 hops x 192f both
total 532 frames at overlap 22. The node derives hop length from
`frames`, `hops` and `overlap`, so a wrong `hops` yields a plausible length and
four confidently wrong seam positions. There is no way for it to know better
from `images` alone -- but the chain's `info` output carries the real geometry,
so an optional `info` input that cross-checks would turn this class of mistake
into an error message. Worth doing.

### `quality=draft` is close to a no-op here

Draft's two levers are resolution -> 0.3 MP and steps -> 6. In the turbo regime
this pack targets, a "final" run is *already* 0.3 MP at 6-8 steps, so the first
lever does nothing and the second saves one step:

- draft, 6 steps: **42.1 s/hop**
- final, 7 steps: **43.3 / 45.2 / 50.2 / 44.1 s/hop**

About 7% apart. Draft only earns its place if final is genuinely heavier --
1.0 MP at 14 steps. Kept, because that configuration exists, but it is not the
fast-preview button it sounds like. `dry_run` is the fast-preview button.

### The rest

- **Contact sheet**: correct and genuinely useful on real renders -- first/last
  thumbnails per hop, directives, beat, tone line, seed, `cached`, pin
  mechanism. Legible at 1:1; **not** legible in a node preview, where a 1280px
  sheet scales to ~0.25 and 15px body text renders at ~4px. It is a
  click-to-enlarge document. Fonts left alone deliberately.
- **`render_through`**: truncates correctly (`rendering hops 1-1 of 2`), and
  re-extending works -- a 3-hop chain extended to 5 loaded hops 1-3 from cache
  and started rendering at hop 4.
- **Over-delivery lint**: exercised offline; never fired in ComfyUI because both
  shipped plans and the test plan stay clean. Unproven against a real positive.

## 22. Two models, one prompt, the same two mistakes (2026-08-30)

A 3-hop / 10 s brief was written to load six traps into thirty seconds, and
handed to two local models in LM Studio with `prompt_pack/SYSTEM_PROMPT.md` in
the system box, temperature 0.4, and nothing else. `EXAMPLE_6_HOP.md` was
deliberately withheld -- it argues for six hops and would have contaminated a
3-hop test. Grading ran every reply through the real parsers rather than by eye.

| | qwen | gemma 26b-a4b |
|---|---|---|
| FAIL | 4 | 2 |
| after the shared prompt bug | 2 | 0 |

**Both models made the same tag mistake, which makes it the prompt's.** Each
wrote `@kitchen` in the beat -- correctly, that is rule 10's own example -- and
then invented `"tag": "kitchen_plate"` for the register. The string `_plate`
appears nowhere in the prompt; they arrived at the same convention
independently. The cause was in the file: rule 10's only concrete place tag
lives in a *beat*, the register example held one ref (`hero_face`, a person),
and the two were never shown together. The invariant *was* stated, in a field
bullet 150 lines later -- and models copy examples, not bullets. `PROMPTING.md`,
the human guide, has had a three-ref example including `kitchen` all along; the
machine prompt was trimmed and lost it.

**The silent one.** qwen wrote `"name": "@cook_face"` into `subjects`. That
parses, resolves, renders, and is wrong: `name` is what `resolve_tags`
substitutes for a subject's tag from hop 2 on, so the tag resolved to itself and
a literal at-sign reached the encoder on two of three hops. Nothing caught it.
`check_prompts.py` would have, but only for the two shipped workflows.

**Where the models actually differed** is invisible to any parser. Both lifted
rule 2's worked answer verbatim (*"water runs in slow threads down the window
glass"*). But hop 3 moves to a hallway, and qwen also copied rule 4's example
*object* -- putting "a single click from the refrigerator" in a corridor, the
kitchen appliance following her out of the room. gemma copied rule 4's *method*
and wrote "the low hum of a hallway light". Recitation versus transfer, and only
one of them survives a change of location.

**Both** also left `tail` off hops 1-2, describing the arrival at rest in prose
instead of directing it -- so the over-delivery lint, which only arms after a
`settle`, still has not fired on a real positive.

Fixed here: the register example carries a place tag on both sides of the round
trip and says the two spellings are one string; `refs.py` rejects an `@tag` in
`name`, `locked` or `context`. Not fixed, because it is a brief-writing lesson
rather than a bug: ending a chain in a location no plate describes earns the
place-handoff warning, and that was the brief's fault, not either model's.

## 23. The hop cache stops shelling out (2026-08-30)

The Comfy registry flagged all three published versions. The reason is not in
the web UI and `status_detail` on the node is empty; it is behind
`https://api.comfy.org/versions?nodeId=<id>&include_status_reason=true`, which
returns the actual findings:

    scanner      yara_scan
    issue_type   python_command_injection_risk
    file_path    store.py   lines 131 and 215
    description  "Detects all os.system and subprocess usage"
    severity     info
    recommendation  null
    admin_tags   any-code-execute

Two findings, both the `subprocess.Popen` calls that ran `ffmpeg` for the FFV1
hop cache. The rule does no taint analysis, so a static argument list built from
`shutil.which` and run with `shell=False` matches exactly as hard as a shell
injection would. Its 95% confidence is confidence that the call *is* a
subprocess call, not that it is exploitable.

Appealing looked like the wrong move. `plaguekind-nodes` -- 22.5k downloads --
has 1.3.8 through 1.4.0 flagged with the same two findings and 1.4.1 onward
`Active` with `status_reason` = **"Passed automated checks"**, the string the
scanner writes when it finds nothing. That is a code change, not an admin
override.

**But the registry is the weakest reason to have done this.** `_ffmpeg()` raised
if no ffmpeg binary was on PATH, and ComfyUI never requires one -- so the
feature that makes a tone A/B cost 14 s instead of 164 s hard-failed for a large
share of users, on the pack's fastest path, at the exact moment a CivitAI post
would send new people at it.

PyAV is a hard dependency of ComfyUI itself (SaveVideo and CreateVideo are built
on it) and its ffv1 encoder lists `rgb48le` among 61 pixel formats, so the
format did not have to change: ffv1 / rgb48le / level 3 / coder 1 / context 1,
in matroska. Verified before writing any of it, and again through the real
`HopStore`:

- PyAV encode -> PyAV decode: **bit exact**, including 0, 65535 and midpoints
- **ffmpeg encode -> PyAV decode: bit exact** -- existing caches on disk still
  read, which is the part that protects users
- file sizes within 44 bytes of each other (145,780 vs 145,824)
- a frame-count mismatch still raises rather than returning a short clip

The decode path also got slightly better on the way: it decodes into one
preallocated `(n, h, w, 3)` array instead of building a list of frames and
stacking, so there is no second full-size copy. Five documents claimed ffmpeg
was required -- CLAUDE.md, PROMPTING.md, README twice, and the on-canvas card in
the Starter workflow via `tools/notes.py`. All corrected. README's existing
"No dependencies to install" line, which already listed `av`, is now true rather
than nearly true.