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.
|