Sandpies Claude Opus 5 commited on
Commit
e727aeb
·
1 Parent(s): 652a63f

0.4.1: fix what shipped broken, and record what the first real session measured

Browse files

0.4.0 was built with no browser and no GPU. Run in ComfyUI for the first time,
two of its seven features were broken -- both in the gap between "the Python is
correct" and "the graph runs", which is exactly where the offline suite cannot
see.

Fixed:

- The five new widgets were never added to GROUPS in js/editor/run_panel.js.
0.4.0 touched twenty files and not one under js/. They worked, but fell
through to raw dials on the node body because the run panel hides only what
it managed to draw. A Python widget list and a JS widget list are two sources
of truth and nothing checks they agree.

- sheet.placeholder() returned a 1x1 image. libx264 in yuv420p cannot open a
codec context on an odd dimension, so every dry run wired to SaveVideo -- what
the Starter ships -- died in avcodec_open2 naming ComfyUI's video node and
nothing of ours. placeholder() now takes a size, floors at 2x2 and rounds odd
dimensions down; a dry run returns one frame at the resolved geometry.
check_features.py had asserted the exact broken shape, pinning the bug in
place; it now asserts an encodable() contract.

Measured (docs/DEVLOG.md section 21): tone_anchor swept 0.15/0.35/0.60 against
off on one seed and one cache -- 0.35 removes 62% of drift for 1.4/255 of seam,
monotonic with no knee, so the default stays. The seam report node, never
executed before, agrees with an independent mp4 decode to +/-0.07/255. On a
5-hop master no seam appears among the eight largest frame-to-frame luma jumps.
quality=draft is ~7% faster than final in this pack's turbo regime and is a
fidelity lever, not a speed one.

Also:

- Starter ships the seam report wired: HTCSeamReport + PreviewAny + PreviewImage
off the images output. Allowlists in check_workflows.py updated to match.
- Board cards corrected where the session proved them wrong: draft's speed
claim, tone_anchor strength guidance, and the seam report's hops trap -- a
wrong hops does not error, it returns a plausible length and puts every seam
where no join exists.
- CLAUDE.md was 1147 lines and mostly a dated log on a public repo. The log
moves to docs/DEVLOG.md; CLAUDE.md is the architecture brief at 362 lines.
- README carries the measured drift table, the fourth output socket it had
never listed, and HuggingFace card metadata.

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

.gitignore CHANGED
@@ -5,3 +5,6 @@ __pycache__/
5
  # version control here. They are working files, not releases.
6
  *.bak
7
  *.bak*
 
 
 
 
5
  # version control here. They are working files, not releases.
6
  *.bak
7
  *.bak*
8
+
9
+ # Session handover doc -- working file, never ships
10
+ HANDOVER.md
CLAUDE.md CHANGED
@@ -350,798 +350,13 @@ hop N-1 ends. Hop N opens by holding what it was handed.
350
  - Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
351
  - Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
352
 
353
- ## 8. References are files, not wires (2026-08-28)
354
-
355
- The node had **16 sockets** and twelve of them were user media: `ref_image_1..9`,
356
- `reference_video`, `voice`, `start_image`. They occupied roughly 340px down the
357
- left of the node before the editor started, each needing its own `Load Image`.
358
- They are gone. The node now has **five**: `model`, `clip`, `vae`, `audio_vae`,
359
- `continuity_state`.
360
-
361
- A reference names a **file** under `<ComfyUI input>/h3_refs`. `media.py` owns
362
- both halves of that:
363
-
364
- - **`resolve(name)` is the only thing that turns a name into a path**, and it is
365
- used by the upload route and the loaders alike. Basename only, `normpath`,
366
- then a prefix check against the reference directory, then an extension
367
- whitelist. Verified against absolute paths, `../` traversal, and traversal
368
- carrying a legal extension — all refused.
369
- - **The loaders return exactly what the sockets delivered** — float `[N,H,W,3]`
370
- in 0..1 — so `_ref_frames`' resize, `_collect_ref_images`' dense-pack and
371
- `store.tensor_digest`'s cache keying are all unchanged. That is why this was a
372
- small diff rather than a rewrite.
373
-
374
- **Pixels never enter a widget.** Only the basename is stored. PromptMasterLD
375
- measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
376
- failed to save the workflow at all; previews are `/view?...&subfolder=h3_refs`
377
- URLs, which cost nothing to rebuild and survive a reload. `/view` also brings
378
- Range support, which a `<video>` needs to seek — so no thumbnail route was
379
- needed.
380
-
381
- **`IS_CHANGED` is new and load-bearing.** ComfyUI caches a node's output on its
382
- inputs, and a filename is a stable input even when the bytes behind it change:
383
- overwrite `face.png` and the previous render would be served. It hashes
384
- `name:mtime` for every referenced file. Deliberately **not** `float("nan")` —
385
- that is the blunt version PromptMasterLD's studio node uses, and it would force
386
- a full re-render of an expensive node on every queue.
387
-
388
- **Widget order is part of the saved-workflow format.** `widgets_values` is a
389
- **positional array** -- ComfyUI restores `value[i]` into `widget[i]` and never
390
- looks at the name. The three `*_file` widgets were first added at the *top* of
391
- `optional`, which shifted `hop_script`..`tone_compensate` by +3; every workflow
392
- saved before that change then loaded `audio_pin_frames`' integer into `ref_plan`
393
- and the editor died with `(text || "").trim is not a function` on load. That
394
- throw was the lucky part -- `hop_script`, `shot_plan` and `tone_compensate` were
395
- being silently misassigned too, and only the type mismatch made any of it
396
- visible.
397
-
398
- **New widgets go at the bottom of `optional`, always.** Old workflows are then
399
- *short* rather than *misaligned*, and the new widget takes its default. There is
400
- an append-only marker comment in `INPUT_TYPES` saying so. Note `seed` costs
401
- **two** array slots, not one: the frontend appends `control_after_generate`
402
- right after it.
403
-
404
- Both plan parsers now coerce (`String(text ?? "")`) and reject non-object JSON
405
- instead of dereferencing it, so a future misalignment shows an empty JSON tab
406
- rather than aborting the whole workflow load.
407
-
408
- **The hop cache needed no change at all.** `chain_salt` already digests the
409
- actual tensors, so different pixels behind the same filename still move the key.
410
-
411
- **Legacy plans cannot be migrated automatically.** An old `ref_image_N` held a
412
- tensor from a `Load Image`; there is no filename to recover. `refs.py` keeps the
413
- authored slot as `legacy_slot`, `parse_ref_plan` does **not** raise on it, and
414
- both the rail row and `check()` say *"was wired to ref_image_3 — pick its
415
- picture"*. Failing the parse would have stopped the editor opening the very plan
416
- the author needs to repair.
417
-
418
- ## 9. Presentation (2026-08-28)
419
-
420
- **The node collapsed on first click and stayed collapsed.** Two causes, both in
421
- `installHeightGuard` (`js/editor/widget_utils.js`), both ported from
422
- `PromptMasterLD/js/claude_prompt.js:6285-6370`:
423
-
424
- - `computeLayoutSize().minWidth` reported a **constant**. The layout pass
425
- re-reads it on every recompute — selecting the node is enough — and
426
- faithfully re-declares the node at its stated minimum. It now tracks
427
- `node.size[0]`. Safe against feedback: `max()` against a constant is a
428
- fixpoint, not an accumulator, and width never feeds height.
429
- - A stale `widget.width` shadows `node.width` forever after load, because
430
- ComfyUI's DOM-widget position updater reads `(widget.width ?? node.width)`. A
431
- live getter with a dropped setter makes the stale value unrepresentable.
432
-
433
- Two more fixes alongside: `node.computeSize()[0]` is floored at the panel width
434
- (with no widget declaring one, LiteGraph falls back to `NODE_WIDTH * 1.5 = 210`
435
- and every resize command is free to crush the panel), and `domWidget.computeSize`
436
- now answers a **width-passing** caller with the minimum height and a
437
- **no-argument** caller with the live height. Reporting the live height to both
438
- pins the resize-drag floor to the current height, so the node could only ever
439
- grow.
440
-
441
- **`chrome()`'s memo key was stale by construction.** It keyed on
442
- `inputs|outputs|widgets.length`, none of which change when a widget is *hidden*
443
- — so every panel height computed after `applyVisibility` used a chrome
444
- measurement taken before it. The hidden count is now part of the key.
445
-
446
- **The reference rail was permanently crushed, and its rescue was dead code.**
447
- The 7-track grid needs ~536px and the node offers ~510px at `NODE_WIDTH 560`.
448
- There was a `@container (max-width: 460px)` block written to relieve it — but
449
- **nothing in the codebase declared `container-type`**, so the query had no
450
- containment context and never matched. `.h3e-section` now declares
451
- `container-type: inline-size`, and there are two breakpoints.
452
-
453
- **The palette committed to one look.** It used to derive surfaces from the host
454
- theme via `color-mix()` while hardcoding every accent — the intent was
455
- light-theme safety, but `--h3-bg` mixed toward `#111827` and `--h3-sunken`
456
- toward `#000`, so a light theme got dark blue-grey islands anyway. It is now the
457
- PromptMasterLD `.ldp-root` system: `#0a0a0a`, one hairline `#2a2a2a`, zero
458
- radius, one accent `#e8ff47`. **Changing `--h3-accent` moves the whole panel.**
459
- Emphasis **inverts** (accent fill, `--h3-on-accent` ink) rather than tinting,
460
- because an acid accent at 30% behind unchanged text is olive mud.
461
-
462
- A cascade trap worth remembering: the override block was first inserted *before*
463
- the reference-rail section, so `.h3e-chip-on`, `.h3e-inactive .h3e-ord` and
464
- `.h3e-subj-badge` all kept winning on source order. Overrides live at the end of
465
- the sheet now. Without a browser, a token audit script is the only thing that
466
- catches this class of bug.
467
-
468
- **The panel did not fill the node, and the mirror was why.** `installHeightGuard`
469
- kept an independent `_h`, updated through an `onResize` hook, so that the
470
- arrange pass could not feed its own growth. Measured during a drag: `onResize`
471
- and `setSize` each fired 57 times while `_h` sat at 876 and `node.size[1]`
472
- climbed past 1400. `measuring` was not stuck, which left one gate --
473
- `Array.isArray(size)`. **This frontend's `node.size` is not a plain Array**, so
474
- every write was skipped and `_h` held the install-time height forever. A 1911px
475
- node had a 742px panel.
476
-
477
- The repair was to delete the mirror, not fix the hook. `_h` existed only to
478
- break the arrange loop, and
479
-
480
- panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK) // SLACK 8
481
-
482
- breaks it outright: the pass wants `panelTop + panelHeight + 4`, and `panelTop`
483
- is chrome minus the node's bottom padding, so a panel of exactly `size - chrome`
484
- asks for up to 4px more than the node has on every frame -- the ~130px/frame
485
- runaway the header comment records. With slack the inequality holds, the loop
486
- settles, and the node's height simply *is* the panel's height. Nothing to keep
487
- in sync, no hook to get wrong. `sync()` now only ever grows a too-short node;
488
- the height is the user's to choose.
489
-
490
- **RUN is pinned and always open.** `.h3e-root` used to be the scroll container
491
- with all four sections inside it, which put RUN below the script -- out of view
492
- on any workflow with more than two shots, and it is the section touched on every
493
- queue. The root is now a flex column holding `.h3e-scroll` (the authoring
494
- sections) and RUN outside it.
495
-
496
- Two flex details, both of which cost a round trip to learn:
497
-
498
- - **`.h3e-scroll` needs `min-height: 0`.** A flex item defaults to
499
- `min-height: auto` and refuses to shrink below its content, which pushes RUN
500
- off the bottom of the node instead of scrolling.
501
- - **`.h3e-run` must be `flex: 0 0 auto`, never `0 1 auto`.** Flex divides a
502
- deficit in proportion to each item's content height. The scroller's content is
503
- far taller, so a shrinkable RUN loses most of the contest and clips its lower
504
- groups. The scroller absorbs all the shrinking; its `min-height: 160px` is the
505
- floor that stops RUN owning the panel and RUN's `max-height: 55%` is the
506
- ceiling.
507
-
508
- Always-open removed the only moment RUN re-read its widgets, so it now resyncs
509
- on `api`'s `promptQueued` -- the client-side event that fires after
510
- `control_after_generate` has bumped the seed, and the same one ComfyUI's own
511
- change tracker uses. The listener is dropped in `node.onRemoved`.
512
-
513
- **Not done:** PromptMasterLD's `--fsc` UI-scale multiplier, which makes every
514
- dimension `calc(Npx * var(--fsc))` and puts a zoom slider on the panel. It is
515
- mechanical churn across every rule in the sheet and was not worth doing blind.
516
-
517
- ## 10. Ported for public use (2026-08-29)
518
-
519
- The prompting craft was written down and the pack was made installable by a
520
- stranger. Four things, in the order they mattered.
521
-
522
- **The shipped example workflows were broken.** All four in `workflows/` predated
523
- the 2026-08-28 socket removal: twelve dead media inputs each, three `LoadImage`
524
- nodes, and 21 widget values against a 28-widget node. Loading one is a new
525
- user's *first* action, so this outranked any amount of documentation. They are
526
- quarantined in `_disabled_custom_nodes/h3_legacy_workflows/` (the pack is not
527
- under its own version control -- deleting would have been unrecoverable) and
528
- replaced by two built from the verified `H3_Stress_6x7` structure:
529
-
530
- - **`HandTieClips_Starter.json`** -- two hops, **no references at all**, runs the
531
- moment the loaders are pointed at files. The empty register is deliberate: an
532
- `@tag` whose picture is missing is a *hard* error in `resolve_tags`, so a
533
- starter that shipped with tags would fail on first queue for everyone.
534
- - **`HandTieClips_Showcase.json`** -- the six-hop continuity test, with the three
535
- reference filenames generalised.
536
-
537
- Both use **core ComfyUI plus this pack only**. The dev workflow reaches KJNodes
538
- (`ModelPreviewOverrideKJ`, `MiniMaxLowVRAMAttention`) and PlagueKind
539
- (`LTX_lora_loader`, `H3SLAAttention`, `H3AdaLNLoRAFix`); an example that fails
540
- to load because of a pack the reader never asked for teaches nothing. The
541
- speed stack is documented, not shipped.
542
-
543
- **A declared-but-inactive `@tag` reported the wrong cause.** `check()` already
544
- warned correctly that a picture was missing, but the run then died on
545
- `resolve_tags` with *"unknown reference '@kitchen'"* -- pointing at the beat's
546
- spelling, the one thing that was right. `resolve_tags` now takes `declared`
547
- (every tag in the register, active this hop or not) and separates the two
548
- failures. The old two-argument behaviour is unchanged when `declared` is
549
- omitted.
550
-
551
- **`PROMPTING.md` and `prompt_pack/`.** The craft rules were spread through
552
- README prose; they are now a standalone guide, and a copy-paste system prompt
553
- that gets a language model to emit valid plans. Two files under `prompt_pack/`
554
- are **generated, never hand-written**:
555
-
556
- - `tools/gen_schema.py` builds `SCHEMA.json` from `directives.VOCAB`,
557
- `refs.RETENTION` and the duration table, and **asserts** against
558
- `plan._SHOT_KEYS`, `refs.REF_FIELDS` and `refs.SUBJECT_FIELDS`. Add a camera
559
- move and the schema follows; add a shot field and the generator fails loudly
560
- rather than emitting a stale schema. `--check` is the CI form.
561
- - `tools/gen_example.py` builds `EXAMPLE_6_HOP.md` from the showcase workflow,
562
- including its hop/reference table, so the worked example and the shipped
563
- workflow cannot disagree.
564
-
565
- **Template patterns in the editor.** `js/editor/templates.js` plus a
566
- **Templates** button in the SCRIPT header. They **append**, never replace --
567
- replacing would be the one destructive control on the node, and stacking is how
568
- a chain is actually built. `freeId()` mints the lowest unused `sN` because `id`
569
- is the hop cache's pointer and two shots sharing one would make `locked` reuse
570
- the wrong render.
571
-
572
- No template contains an `@tag`, for the same reason the starter workflow has no
573
- references. `tools/check_templates.py` extracts the patterns *out of the JS*
574
- and runs them through `plan.parse_plan` and `plan.check_coherence`, and lints
575
- every beat for negation -- a template that produced a plan the node rejects
576
- would be worse than no templates, because a first-time author would blame their
577
- own writing. It caught two: a beat reading "They stop at the window" (naming a
578
- cessation, which is law 2) and a line of dialogue containing "did not".
579
-
580
- That second one is worth recording as an open question: **whether the additive
581
- prompt bites inside quoted dialogue was never tested.** The templates avoid it,
582
- and `PROMPTING.md` says plainly that this is untested rather than inventing a
583
- rule.
584
-
585
- ## 11. What chain_00057 taught (2026-08-29)
586
-
587
- The six-hop showcase was rendered for the first time. Three failures, and the
588
- one that mattered was a code gap the documentation had already claimed was
589
- fixed.
590
-
591
- **`locked` and `context` reached hop 1 only.** The register's whole promise is
592
- that a subject's continuity text carries identity across a hop where the
593
- photograph is absent. It did not. `subject_prose` is called under `if i == 0`,
594
- and `_identity_lock` returns `""` when no subject-bearing ref is active -- so on
595
- the showcase, hops 2, 3, 5 and 6 carried **no identity text of any kind**, and
596
- hop 5, scheduled with no references at all, lost the character entirely.
597
- Identity was riding on the pinned frames alone, which is the exact failure the
598
- register exists to prevent.
599
-
600
- `refs.continuity_line()` now emits that text on every hop 2+, and
601
- `_assemble_next` injects it between the lock and the live-frame citation. The
602
- reason it was suppressed in the first place is real and is preserved: naming
603
- *pictures* on a pin-only hop sent the encoder back to the plates (chain_00034 --
604
- commercial kitchen, grey shirt, no apron). So the new line carries **no
605
- `<Picture N>` and no `<Subject N>`**. It is a description of what stays the
606
- same, not a citation of anything. `<Subject N>` is excluded specifically because
607
- there is no `subject_definitions` block on a continuation hop to bind it to.
608
-
609
- Note this changes the assembled block on hop 2+ of every chain with a register,
610
- so it invalidates the hop cache. That is correct, not a regression.
611
-
612
- **Dialogue propagated through five hops.** Shot 1 ended on its spoken line with
613
- `tail: ongoing`. The audio pin carries the previous hop's tail, so the last
614
- second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
615
- the closing instruction; the model satisfied it with the action it could hear.
616
- Nothing in hops 2-6 gave the audio anywhere else to go.
617
-
618
- This is the guide's own law 3, broken by the plan written to demonstrate it. The
619
- fix is authorial, not code: land the line **mid-hop** and leave a non-verbal
620
- action running into the seam (the knife on the board), and give every
621
- dialogue-free hop a narrowband sound of its own. Both `PROMPTING.md` and the
622
- authoring prompt now carry this as a named rule, and the shipped templates were
623
- rewritten to obey it.
624
-
625
- **`join: continuous` across a location change morphed one room into the other.**
626
- Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
627
- continuous join asks for one unbroken take between two different rooms, which
628
- is not a thing. `match_cut` is what a walk through a doorway is. The beat also
629
- read "steps into @kitchen" -- the container phrasing the README warns produces a
630
- composite of the photograph -- and now names the counter *in* the kitchen.
631
-
632
- Hop 5's beat is unchanged in the revised plan **on purpose**: it is the
633
- measurement, and changing it would forfeit the comparison.
634
-
635
- The revised plan is in the shipped showcase and in
636
- `user/default/workflows/H3_Stress_6x7_v2.json`; the original that produced
637
- chain_00057 is left alone so the A/B survives.
638
-
639
-
640
- ## 12. Renamed to Hand Tie Clips (2026-08-29)
641
-
642
- The pack was `ComfyUI-H3-Ref-Chain`. It is now `ComfyUI-Hand-Tie-Clips`, and the
643
- four registered ids moved with it:
644
-
645
- | was | is |
646
- |---|---|
647
- | `H3RefChain` | `HandTieClips` |
648
- | `H3ContinuityState` | `HTCContinuityState` |
649
- | `H3ChainPreview` | `HTCChainPreview` |
650
- | `H3ToneCompensate` | `HTCToneCompensate` |
651
-
652
- Display names are unchanged (`H3 Ref2VA Chain`, `H3 Chain Preview`, ...) because
653
- the pack only drives MiniMax H3 checkpoints and a name that hides that costs
654
- somebody an afternoon. The menu category is `Hand Tie Clips`. `TAG` -- and so the
655
- console prefix -- is `[HandTieClips]`.
656
-
657
- **The old ids are still registered, as `DEPRECATED` subclasses.** A type id is
658
- what every saved `.json` carries, and an unregistered one is a red missing-node
659
- box, not a warning. A plain alias in `NODE_CLASS_MAPPINGS` would have worked but
660
- listed each node twice in search: ComfyUI falls back to the mapping key when
661
- `NODE_DISPLAY_NAME_MAPPINGS` has no entry for it. Subclassing and setting
662
- `DEPRECATED = True` gets both -- `server.py:783` publishes `deprecated: True`,
663
- and the frontend's `Comfy.Node.ShowDeprecated` (off by default) keeps it out of
664
- search while leaving it fully functional in workflows that name it.
665
-
666
- **The JS had to learn both ids or the aliases would have been worse than
667
- useless.** `js/h3_ref_chain_ui.js` and `js/h3_chain_preview.js` each compared
668
- `nodeData.name` against a single string; a legacy node would have loaded with no
669
- editor at all, which looks exactly like the rename having broken the pack. Both
670
- now test membership of a `Set`. `js/h3_chain_preview.js` needs it twice: once
671
- for its own type, once for `CHAIN_TYPES`, which is how a preview walks back up
672
- `images` to find the chain feeding it.
673
-
674
- **What deliberately kept the `h3` naming**, and must not be "finished" later:
675
- module filenames (`h3_ref_chain.py`), the `h3e-` CSS class prefix and `--h3-`
676
- tokens (515 occurrences -- one missed class silently breaks styling), the
677
- `/h3_ref_chain/*` routes, the `h3_refchain_preview` event name, and
678
- `input/h3_refs`. Renaming that folder would orphan every reference photo already
679
- on disk.
680
-
681
- The two shipped workflows moved to `HandTieClips_Starter.json` /
682
- `HandTieClips_Showcase.json` and were rewritten onto the new ids, with their
683
- `SaveVideo` prefix now `video/HANDTIECLIPS/chain`. The three under
684
- `user/default/workflows/` were left on the legacy ids on purpose: re-running
685
- `H3_Stress_6x7_v2.json` is then a live test of the alias path, and their existing
686
- renders stay together under `output/video/H3REFCHAIN/`.
687
-
688
-
689
- ## 13. The on-canvas board (2026-08-29)
690
-
691
- `workflows/HandTieClips_Starter.json` carries six `MarkdownNote` cards to the
692
- left of the loaders, wrapped in a group titled READ ME. The text lives in
693
- `tools/notes.py` and is written into the workflow by `tools/build_notes.py`.
694
-
695
- Why on the canvas: the craft was in `PROMPTING.md`, `prompt_pack/` and the
696
- Templates panel, and all three require leaving the graph. The rules that decide
697
- whether a first render works are needed while beats are being written, which is
698
- on the canvas. The cards are a **condensation**, not a copy -- `PROMPTING.md`
699
- stays the authority and every card says so.
700
-
701
- Mechanics worth not rediscovering:
702
-
703
- - `MarkdownNote` is a core virtual node. The frontend renders it with `marked`
704
- at `gfm: true` and sanitises with DOMPurify, so headings, GFM tables, bold and
705
- code fences all work. It needs frontend >= ~1.16.
706
- - Cards are identified by `properties.htc_card`, which is what makes
707
- `build_notes.py` idempotent -- it drops marked nodes before writing, so
708
- re-running replaces the board instead of stacking a second copy.
709
- - `extra.ds` is **restored** on load, not fitted. Without setting it, a board at
710
- negative x sits off-screen and is never found. Screen is
711
- `(world + offset) * scale`, so the offset is what brings it into view.
712
- - Group serialisation is `{id?, title, bounding:[x,y,w,h], color?, font_size?,
713
- locked?}` -- confirmed against the frontend's own zod schema, not guessed.
714
-
715
- **`widgets_values_named`, found while doing this.** Both shipped workflows
716
- carried a stale copy: `chains: 3`, `duration: 10 s`,
717
- `control_after_generate: randomize`, and a legacy `ref_plan` naming pictures that
718
- do not ship -- 25 entries against a 28-widget node. It came from the builder
719
- deep-copying nodes out of the dev workflow and overwriting only
720
- `widgets_values`. Dormant while `Comfy.Workflow.NamedValuesRestore` stays off
721
- (experimental, default false), but anyone who turned that on would have loaded a
722
- Starter that randomizes its seed and dies on a missing reference.
723
- `build_notes.py` strips it from both, and `tools/check_workflows.py` now fails if
724
- it ever comes back.
725
-
726
- `tools/check_workflows.py` is the promoted version of the validator that guarded
727
- the shipped workflows against socket/widget drift. It also checks the board:
728
- every card marked and non-empty, exactly one group, the group enclosing every
729
- card, and no card reaching past x=0 onto the loaders.
730
-
731
-
732
- ## 14. The turbo stack ships in the examples (2026-08-29)
733
-
734
- Both shipped workflows now carry the dev graph's full MODEL wire:
735
-
736
- UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
737
- -> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips
738
-
739
- and **CLIP reaches the chain from the LoRA loader, not the encoder** -- that is
740
- what makes the text half of every LoRA land, and it is the wire most likely to
741
- get quietly "fixed" back to the encoder by someone tidying the graph.
742
-
743
- This reverses the earlier "core ComfyUI and this pack only" rule for the
744
- examples, at the user's direction: `steps` is 7, which only works with a turbo
745
- LoRA, so an example without the LoRA stack is not a graph anyone can run at the
746
- settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
747
- KJNodes supplies Low VRAM Attention and the preview override.
748
-
749
- `tools/build_speed_stack.py` inserts and rewires the five nodes idempotently
750
- (marker `properties.htc_speed`), rebuilding the plain loader -> chain shape
751
- first so it is reentrant from either state. `tools/check_workflows.py` walks
752
- **both** wires link by link -- a patch node that is present but bypassed round
753
- the side looks right on the canvas and does nothing.
754
-
755
- `H3SLAAttention` widget values are written out in full including
756
- `reference_protection`, which post-dates the dev workflow's saved values. That
757
- workflow has 10 entries against an 11-widget node, which is the positional rule
758
- working as intended: the new widget was appended last, so old values still line
759
- up and the missing one falls back to its default. The trailing `""` on the LoRA
760
- loader and the preview override is carried over verbatim for the same reason --
761
- a value past the last widget is ignored, and dropping one that turns out to
762
- belong to a widget would shift every value after it.
763
-
764
- `head_chunks` ships at 4 (the node's own default, safer on unknown VRAM) rather
765
- than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
766
- loader names, and `taeh3.safetensors` for the preview override's `tiny_vae`.
767
-
768
- ## 15. What a shipped diagnosis looked like (2026-08-29)
769
-
770
- The editor UI vanished after the folder rename -- raw widget boxes, no panel.
771
- Not a code fault. ComfyUI had been restarted while the pack was still
772
- `ComfyUI-H3-Ref-Chain`, so the running process held
773
- `python_module: custom_nodes.ComfyUI-H3-Ref-Chain` and served its web assets
774
- from a path the move had deleted. `/object_info` had all eight node types;
775
- `/extensions` listed none of the pack's JS and a direct fetch 404'd.
776
-
777
- Worth knowing for next time: `/object_info` carries `python_module`, which is
778
- the fastest way to find out **which copy of a pack a running server actually
779
- loaded**, and `/extensions` plus a direct fetch of one script separates "the JS
780
- is broken" from "the JS is not being served at all".
781
-
782
- ## 16. What the Rain Kitchen renders taught (2026-08-29)
783
-
784
- Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
785
- three findings are now in `PROMPTING.md`, the troubleshooting table, the
786
- authoring prompt and the on-canvas board.
787
-
788
- **Identity drift is permanent, and the old advice was backwards.** This file and
789
- `PROMPTING.md` used to say a face plate riding a later hop *beats the pin* and
790
- that `shots` should therefore be kept tight. chain_00059 falsified it: hop 4
791
- carried a face plate photographed in a *different kitchen* through a walking
792
- medium shot and held cleanly, while hop 5 — scheduled with no references at
793
- all — came back a different person, and hop 6 never recovered even though a
794
- place plate rode it and restored the room instantly. `locked` holds a face that
795
- is still right; only a plate rebuilds one that is gone. **Face refs go on every
796
- hop.** The old advice survives for *place* plates only.
797
-
798
- **A beat must survive an over-delivered hop.** A hop routinely does more than it
799
- was asked. Shot 3 asked for "a first slow step along the counter" and delivered
800
- the whole walk; shot 4 was then handed an instruction its own live frame had
801
- already satisfied, and the only way to obey was to reset the scene — a hard
802
- cut 1.5 s *into* the hop, not at the seam, because `_assemble_next` holds the
803
- incoming frames for a short beat first. Give one hop the whole movement, and
804
- write the next beat true from either ending ("she *reaches* the window", "she
805
- takes up the bowl *again*").
806
-
807
- **A noun with no adjective drifts.** Three hops said only "the bowl"; it came
808
- back stainless steel. Naming it "the white bowl" everywhere *and* stating it in
809
- `context` as a property (never a location — "stays in her hands" fights the
810
- beat the moment she puts it down) held it for six hops. The re-run then proved
811
- the rule by accident: `context` read "the apron stays tied over the **grey**
812
- t-shirt", and the t-shirt held all six hops while the apron, one clause away
813
- with no colour, had turned denim blue by hop 6.
814
-
815
- ### The code change this bought
816
-
817
- `refs.resolve_tags` now takes `subject_names` and the node passes it on
818
- continuation hops only. `<Subject N>` is bound by `subject_definitions:`, which
819
- is hop-1 material — so on hop 4 the ordinal dangled, exactly the same defect
820
- as the undescribed bowl. From hop 2 a person tag resolves to the subject's
821
- `name` instead ("The cook walks down the hallway"), which binds to the
822
- `continuity_line` sentence every continuation hop already carries. No name means
823
- the old `<Subject N>` fallback, so nothing regresses.
824
-
825
- `tools/check_prompts.py` mirrors the new call, and gained the banned-word check
826
- that previously ran only against `templates.js`. That gap is how the shipped
827
- Showcase carried "**None** of the kitchen is visible" (law 1 — it *adds* a
828
- kitchen at cfg 1.0) and "She **stops** at the window" (law 2) through six
829
- versions. Both are fixed, and the Showcase's face ref now rides all six hops.
830
-
831
- ## 17. The pre-beta audit (2026-08-29)
832
-
833
- Read for what a stranger hits, not for what we already check. Six findings; the
834
- first two would each have cost a tester a run.
835
-
836
- **The docs promised a stop the code never did.** `refs.check()` is *"warn, never
837
- raise"*, and its caller only printed. A ref naming a file that is not in
838
- `h3_refs` had its slot skipped and the chain rendered on — all six hops, with
839
- the reference silently inactive, which is precisely the uncontrolled output the
840
- register exists to prevent. Four shipped surfaces said otherwise, including the
841
- Showcase's own note: *"the run stops and names the reference it could not find.
842
- Nothing guesses."*
843
-
844
- Fixed in the code rather than the docs, because there is no reading under which
845
- rendering without a named-but-absent picture is what the author meant. New
846
- `refs.missing_files()` returns `(tag, file)` for every ref whose named file did
847
- not load; `h3_ref_chain.py` raises on it, after printing the register table so
848
- the error arrives with its context. **The distinction that matters:** a ref with
849
- *no* `file` stays a warning — the Starter ships that way on purpose so it runs
850
- before any pictures exist. Only a *named* file that is absent is fatal.
851
-
852
- **The on-canvas board never got §16's corrections.** `tools/notes.py` still
853
- carried the row chain_00059 falsified — *"a plate riding a hop it does not
854
- belong on, beating the pin → tighten `shots`"* — and was missing all three rows
855
- `PROMPTING.md` gained. The board is what a beginner actually reads, so it was
856
- shipping the inverse of the rule. Lesson: `PROMPTING.md` is the authority, but
857
- `tools/notes.py` is a **second copy** of the same craft, and a correction is not
858
- finished until both move. `build_notes.py` regenerates the Starter's cards *and*
859
- the Showcase's `Note` from `notes.SHOWCASE_NOTE`, so one rebuild covers both.
860
-
861
- The rest were packaging, not craft: the MIT `LICENSE` named no copyright holder;
862
- `pyproject.toml` lacks both fields a Registry publish needs (`PublisherId` and a
863
- `[project.urls] Repository`), now commented in place; the `README` had **no
864
- install section at all**; `ComfyUI-H3-Motion-Context` was absent from Needs
865
- despite the intro calling it the primary guidance path, so testers land on the
866
- `MiniMaxH3AddGuide` fallback without knowing they changed code paths; and the
867
- example workflows name quantised checkpoints by filename with no note that they
868
- are one valid set among many, which reads as a broken graph rather than a
869
- missing file.
870
-
871
- Clean on the same pass, worth not re-checking: no personal data anywhere, no
872
- hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
873
- syntax, no third-party dependencies, Motion-Context absence handled with fork
874
- detection, `WEB_DIRECTORY` present, `h3_refs` auto-created.
875
-
876
- ## 18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)
877
-
878
- Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The `ref_plan` came
879
- back structurally perfect -- both subjects named, face plate on all eight hops,
880
- places scheduled off the hops they do not belong on, zero register warnings. The
881
- `shot_plan` carried four defects, and tracing each one back to
882
- `prompt_pack/AUTHORING_PROMPT.md` found more than four gaps.
883
-
884
- **The prompt taught one of the bugs.** Rule 9 illustrated a beat as
885
- ``"stands at the counter in `@kitchen`"`` -- backticks and all, because it was
886
- the one place a tag appeared inside a beat. The model copied the formatting, and
887
- literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
888
- prose.
889
-
890
- **Beat length had no guidance at all.** The only budget in the file was for
891
- dialogue, so a no-dialogue plan had nothing to size against and every beat came
892
- back at 22-28 words regardless of a 15 s hop. There is now a word table -- and
893
- an honest note on it: the shipped plans run 37-39 words at *both* 5 s and 7 s,
894
- so beat length is near-constant across the only two hop lengths ever rendered.
895
- The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned
896
- extrapolation, not a measurement, and the table says so.
897
-
898
- **Two rules were simply missing.** Nothing told the model to state a visual style
899
- -- for a "2D anime, Ufotable" concept neither returned block contained the word
900
- anime, so the text asked for photoreal while the pictures asked for anime. And
901
- the over-delivery rule from section 16 had never been carried into the prompt at
902
- all, only into `PROMPTING.md` and the board.
903
-
904
- **Two statements were wrong.** The prompt said "at most 9 pictures on any one
905
- hop"; `parse_ref_plan` counts `len(refs)` over the **whole plan**. The code's own
906
- error message said "on one hop" too, and has been corrected to match what it
907
- checks. And the `file` bullet still told the model to invent placeholder
908
- filenames without noting that, as of 0.3.1, a named file absent from `h3_refs`
909
- **stops the run**.
910
-
911
- Law 2 is the interesting non-fix. It is stated plainly, with the exact example
912
- "The cook stops talking", and the model wrote "stops and looks up" anyway. A
913
- principle is not something a 27B reliably applies to its own output; the literal
914
- word list `check_prompts.py` enforces now appears in the prompt, because a word
915
- list is checkable and a principle is not.
916
-
917
- ## 19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)
918
-
919
- Three sources landed together: a rendered 8 x 15 s anime chain (`chain_00003`,
920
- 2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
921
- written to trip specific rules. Shipped as **0.3.3**.
922
-
923
- ### The node was fighting every stylised plan
924
-
925
- `directives.py` prepended `ESTABLISH = "Live-action, natural light, one
926
- continuous take."` to hop 1 **unconditionally**. At cfg 1.0 with no negative
927
- branch that is additive, and it landed *ahead* of the style declaration rule 12
928
- requires. A stop-motion puppet plan compiled to "Live-action, natural light,
929
- one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
930
- painted wood", and the two fought.
931
-
932
- It also explains the anime chain's opening: hop 1 rendered as bright
933
- naturalistic daylight (**mean luma 72**) against a night plan *and* a night
934
- place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That
935
- had been read as drift; it was the node.
936
-
937
- Never surfaced because both shipped workflows are live-action.
938
-
939
- Fixed twice over, because either alone leaves a hole: an `establish` widget
940
- (appended **last** in `optional`, per the positional-widget rule in section 8),
941
- and `directives.declares_own_medium()` / `establish_for()`, which drop the
942
- default when shot 1's opening names a medium. A model-authored plan never
943
- touches a widget, which is why the automatic arm is the one that matters.
944
-
945
- ### Luminance drifts, and it only goes one way
946
-
947
- Mean luma per hop across the eight: **72, 46, 35, 17, 11, 11, 19, 14**. Setting
948
- the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the
949
- point of the film, plays at the bottom of it.
950
-
951
- This is the colour law one level up. `locked` holds a face; nothing holds an
952
- exposure. Each hop inherits the last frame and darkens it slightly and the
953
- error compounds. **Restate the light as a positive property in every beat** --
954
- naming a light *source* ("pale moonlight") does not set a level, and at cfg 1.0
955
- it only adds a moon.
956
-
957
- ### The seams were never the problem
958
-
959
- 7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is
960
- 13.6, inside the range of ordinary in-shot motion, and two seams score below the
961
- film's own mean. Identity held 114 seconds on face plates riding every hop.
962
-
963
- The one hard cut in the film is **inside** hop 4, 3.25 s in, at 7.1 sd -- more
964
- than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
965
- open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
966
- the two of them square off". Hop 4 was handed a live frame of a man among trees
967
- and a beat asserting he stood on open stone, held the forest for 3.25 s, then
968
- reset the scene. The plan was clean under `check_coherence` and the banned-word
969
- scan. Hence `plan.check_place_handoff()`.
970
-
971
- ### What the two models did
972
-
973
- Near-identical answers from Qwen and Gemma on the same prompt -- same beats to
974
- the word in 5 of 6 shots, same defects, same invented justification. The prompt
975
- is prescriptive enough to collapse two models onto one answer; a shared blind
976
- spot is then invisible from output alone.
977
-
978
- **The word table was inert.** Both models, asked for six 15 s hops, returned
979
- beats averaging **54 words** against a 70-100 band -- every beat under the floor
980
- -- and the same models on much shorter hops returned 40-48. Beat length is
981
- near-invariant to hop length in model output, now measured in a third setting.
982
- A reference table does not move a model with a prior on paragraph length; the
983
- rule is now an instruction to count, with a worked 74-word example.
984
-
985
- **The banned-word list beat the principle, and made things worse.** Zero literal
986
- banned words in all four plans -- and "The storm's roar begins to fade...
987
- raindrops strike the glass with decreasing force" and "The storm has passed".
988
- The list taught token avoidance and handed them a box to tick. It is now framed
989
- as crude examples of an idea, with the test stated as: is this happening, or has
990
- it finished happening?
991
-
992
- **Both abandoned the second location.** Each plated the opening place, moved the
993
- story elsewhere, gave the new place no plate, and justified it with a rule that
994
- does not exist ("to avoid conflicting with the frame pin of the new space");
995
- Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left
996
- the lamp room -- four of six hops -- on beat text alone.
997
-
998
- ### `check_place_handoff` has two arms, and both were narrowed by real plans
999
-
1000
- 1. **Handoff.** Shot N names a place tag shot N-1 never mentions, and shot N's
1001
- own beat does not carry the journey. The arrival vocabulary had to widen: the
1002
- Showcase's shot 6 ("walks back along the hallway and through the doorway to
1003
- the counter in @kitchen") is correct and was being flagged.
1004
- 2. **Abandonment**, not gaps. Warning on any unplated hop also flagged the
1005
- Showcase, which deliberately walks her down an unplated hallway on 4-5 and
1006
- returns the kitchen plate on 6. The rule that survives contact: warn only
1007
- when the plates stop and **never resume**, so the film ends somewhere no
1008
- picture describes.
1009
-
1010
- Both shipped workflows are clean under the final version; the anime plan raises
1011
- exactly one warning, on shot 4.
1012
-
1013
- ### Audio, for the record
1014
-
1015
- No background music and no speech, confirmed on the spectrogram: broadband
1016
- transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
1017
- of seven joins, and the two exceptions are drops the beats themselves ask for.
1018
- The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
1019
- while hop 8 averages -42 dBFS. There is no audio equivalent of
1020
- `HTCToneCompensate`. Left open.
1021
- ## 20. Seven features built blind (2026-08-30)
1022
-
1023
- Built in one pass with no browser and no GPU render available -- the user was
1024
- away and explicitly asked for the work anyway. Everything below is verified by
1025
- offline execution; **nothing has been seen in ComfyUI and no chain has been
1026
- rendered with it.** Read section 19 first for the measurements that motivated
1027
- most of it.
1028
-
1029
- ### What shipped
1030
-
1031
- | # | thing | where |
1032
- |---|---|---|
1033
- | 1 | `tone_compensate=anchor` + `tone_anchor` strength | `tone.py`, wired in `h3_ref_chain.py` |
1034
- | 2 | `dry_run` -- compile every prompt, render nothing | `h3_ref_chain.py` |
1035
- | 3 | `contact_sheet` -- a fourth IMAGE output | new `sheet.py` |
1036
- | 4 | `render_through` -- stop after hop N | `h3_ref_chain.py` |
1037
- | 5 | `quality=draft` -- 0.3 MP, 6 steps | `h3_ref_chain.py` |
1038
- | 6 | **H3 Seam Report** node | new `seam.py` |
1039
- | 7 | over-delivery lint | `plan.py` |
1040
-
1041
- Five new widgets, appended LAST (29 -> 34 values). One new output, appended
1042
- LAST (3 -> 4). Both rules are in section 9; both were obeyed.
1043
-
1044
- ### The anchor, and why it is not just another tone mode
1045
-
1046
- frame_shift/gain_bias/lut are **seam-local**: they cancel the denoiser's tone
1047
- bias on the overlap, which makes each join exact. They cannot see the exposure
1048
- falloff *inside* a hop, and that is what compounds -- hop N darkens across its
1049
- own frames, hands the darker tail to hop N+1, and every individual seam stays
1050
- perfect while the film dims. Section 19 measured 46 -> 11 across hops 2-6.
1051
-
1052
- Worth stating plainly because it is counter-intuitive: **a synthetic 8-hop
1053
- chain showed frame_shift making the total slide WORSE** (66/255 vs 35/255 with
1054
- correction off). That is correct behaviour, not a bug. The denoiser's per-hop
1055
- bias happened to lift; cancelling it removed a lift that had been partly
1056
- offsetting the falloff. Seam correction fixes seams. It was never a level
1057
- control and should not be read as one.
1058
-
1059
- `anchor` = frame_shift + a second stage pulling each hop's mean back toward
1060
- **hop 1's**. Two properties make it safe to stack:
1061
-
1062
- - the pull **ramps from zero** over `ANCHOR_RAMP` (48f) frames, so frame 0 of
1063
- a hop is returned untouched and the seam stays exactly as frame_shift left
1064
- it. Without the ramp a per-hop constant offset re-introduces precisely the
1065
- step frame_shift just removed -- this is the whole design, and the trap
1066
- anyone re-implementing it will fall into;
1067
- - it is **capped** (`ANCHOR_MAX_SHIFT`, 0.06) and scaled by `tone_anchor`
1068
- (0.35), so a slide is corrected across several hops instead of one hop
1069
- snapping back.
1070
-
1071
- The correction needs no carry variable between hops: because it is applied
1072
- before `prev_imgs` is taken, the next hop's seam correction matches the
1073
- already-corrected tail and the offset propagates on its own.
1074
-
1075
- On the synthetic chain: slide 66 -> 18/255, worst seam step 2.22 -> 2.13/255.
1076
- The seam did not regress, which is the property that mattered.
1077
-
1078
- Intent is indistinguishable from drift from the inside, hence the per-shot
1079
- `tone` field: `"free"` skips one hop's pull, `"rebase"` moves the anchor onto
1080
- that hop. A deliberate walk into a cellar needs `rebase` or the chain spends
1081
- the rest of the film brightening it back.
1082
-
1083
- ### dry_run: what it must not touch
1084
-
1085
- The value is that it costs seconds, so every expensive thing is guarded:
1086
- `MiniMaxH3SigmaShift`, `KSamplerSelect`, `BasicScheduler`, `_model_fingerprint`
1087
- (it hashes patched weights), the hop store, and -- the big one -- the master
1088
- preallocation. `master_imgs` for 8 x 15 s at 1280x736 is 2742 float frames,
1089
- about **31 GB**. A dry run that allocated it would be worse than useless.
1090
-
1091
- Hop 2+ needs *a* `prev_imgs` to compute `<Picture N>` ordinals. Content is
1092
- irrelevant to the compiled text, so a `[overlap, 8, 8, 3]` zero tensor stands
1093
- in and the text is byte-identical to a real run's.
1094
-
1095
- The smoke test (`tmp/t_dry.py`) replaces all five sampler entry points with
1096
- objects that raise on **any** attribute access, so "did not touch the sampler"
1097
- is asserted rather than assumed. It caught one real bug: the dry block
1098
- referenced `pin_mech_pred` before its assignment, ~40 lines later. Which pin a
1099
- hop gets is decided at render time from whether a sampler latent exists, so a
1100
- dry run genuinely cannot know it -- the sheet reports the `pin_to_qwen`
1101
- *setting* instead. Reporting AddGuide for every hop would have been a lie.
1102
-
1103
- ### The over-delivery lint
1104
-
1105
- The one defect class every other check structurally misses: both shots are
1106
- individually well-formed, the directives are individually legal, and only the
1107
- JOIN between them is wrong. `tail=settle|hold` promises rest; a following beat
1108
- that opens "She continues...", "Walking to...", "Mid-sentence..." asks the model
1109
- to carry on what the hop before was told to stop.
1110
-
1111
- Narrow on purpose. Trailing spaces in `_MID_ACTION` are load-bearing ("keeps "
1112
- not "keepsake", "still " not "stillness"), and `_MID_ACTION_LEAD` is only
1113
- checked at position 0, which is what stops "Morning light..." and "Nothing
1114
- moves..." from firing. Verified against those exact traps, and both shipped
1115
- plans stay quiet.
1116
-
1117
- It will miss a beat that opens mid-action without saying so. That is accepted:
1118
- a false positive that blocked a render would be worse than the defect.
1119
-
1120
- ### Notes for whoever picks this up
1121
-
1122
- - `sheet.py` and `seam.py` catch every exception and return a 1x1 placeholder.
1123
- A picture must never lose a finished chain. Do not "clean up" those handlers.
1124
- - The contact sheet stores frames through `sheet.small()` (168px tall). Two
1125
- full frames per hop across eight hops is 180 MB held for the whole render for
1126
- no reason.
1127
- - The sheet shows `imgs[overlap_n]` for hops 2+, not `imgs[0]`: the first
1128
- `overlap` frames are trimmed at the join, so `imgs[0]` is a frame the master
1129
- never contains.
1130
- - `tools/check_workflows.py` derives the expected widget list from the live
1131
- `INPUT_TYPES`, so it needed no edit for the five new widgets -- only
1132
- `SaveImage` added to `CORE`, for the Starter's new contact-sheet node.
1133
- - The Starter now ships `contact_sheet=on` with a `SaveImage` wired; the
1134
- Showcase ships it off. Starter is the teaching graph, so the feature is on
1135
- the canvas where it will be found.
1136
-
1137
- ### Unverified, in priority order
1138
-
1139
- 1. Everything visual in ComfyUI: the five new widgets rendering in the RUN
1140
- panel, the fourth output socket, the Starter's new SaveImage.
1141
- 2. `tone_compensate=anchor` on a real chain. The synthetic test models drift as
1142
- a linear ramp; real drift is not linear and 0.35/0.06 are guesses that
1143
- looked right on synthetic data.
1144
- 3. Whether the contact sheet is legible at ComfyUI's default zoom.
1145
- 4. `render_through` re-extending: render 3, then 5, and confirm hops 1-3 hit
1146
- the cache rather than re-rendering.
1147
- 5. `quality=draft` actually being fast enough to be worth it.
 
350
  - Fewer than 3 wired reference stills triggers a warning log; unconnected `Load Image` nodes do not count as wired.
351
  - Soundtrack is official H3, not a pack dialect: `(S1)` + `<d>…</d>` for lines, `overall_soundscape` for ambience/physical (or `N/A` for requested silence). Do not invent beat-keywords. “No speech” / “no dialogue” remains negation/gibberish.
352
 
353
+ ## The engineering log
354
+
355
+ Sections 8 onward -- the dated record of what was built, measured, and got
356
+ wrong -- live in [`docs/DEVLOG.md`](docs/DEVLOG.md). They are history, not
357
+ instructions. This file is the brief; the log is why the brief says what it
358
+ says, and it is worth reading before changing any of it.
359
+
360
+ Most recent: **section 21**, the first ComfyUI session, where two of the seven
361
+ features shipped in 0.4.0 turned out to be broken in ways no offline test could
362
+ have caught.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Hand Tie Clips
2
 
3
  One node. Write a shot plan, drop in your reference stills, queue.
@@ -9,6 +22,15 @@ One node. Write a shot plan, drop in your reference stills, queue.
9
  > the rename keeps loading** — they are just hidden from node search. Nothing
10
  > needs migrating.
11
 
 
 
 
 
 
 
 
 
 
12
  **Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
13
 
14
  Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
@@ -313,9 +335,14 @@ renders the new hops. The plan is not truncated: shot 4 still knows it is shot
313
  4, keeps its own seed, and keys the same way it will in the full run.
314
 
315
  `quality=draft` forces 0.3 MP and 6 steps — enough to read blocking, camera and
316
- whether a join lands, fast enough to iterate on. Resolution and steps are both
317
- in the cache key, so a draft never overwrites the final it stands in for; the
318
- two simply cost two entries.
 
 
 
 
 
319
 
320
  ## Contact sheet
321
 
@@ -367,6 +394,25 @@ here on stops being fought for the rest of the film.
367
 
368
  Set `tone_anchor` to 0 to get plain frame_shift back.
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  ## The MODEL wire
371
 
372
  Four ordinary nodes, in this order:
@@ -429,12 +475,12 @@ Three shots at 10 s with a 0.9 s overlap is about 28 s of master after the overl
429
 
430
  ## Nodes
431
 
432
- **H3 Ref2VA Chain** — `images`, `audio`, `info` out. Wire `CreateVideo` + `SaveVideo` as in the example workflow, and `info` to a Preview Text node.
433
 
434
  **H3 Chain Preview** — a passthrough panel for the IMAGE (and optionally AUDIO) wire, placed **between the chain and `CreateVideo`**. Images and audio come out unchanged, so adding or removing it changes no pixels. It shows the live sample, the seam (the previous hop's last frame beside this hop's first), a chain-wide progress bar, cache hit / seed / steps per hop, **which pin mechanism each hop actually used** — a latent Motion-Context pin or the AddGuide pixel fallback — and end-of-run A/V drift. Drag the grip to resize the stats panel; double-click it to reset.
435
 
436
  **H3 Tone Compensate** — `images` out. Corrects a generated segment's tone against the previous one, estimated on the overlap they share. **For hand-built chains only.** It cannot fix `H3 Ref2VA Chain`'s output: that node joins its hops internally and drops each hop's first `overlap` frames at the seam, so the regenerated copies this needs are already gone by the time images leave it. Use the chain node's `tone_compensate` widget instead. Estimator ported from [rkfg/ComfyUI-MiniMaxH3-ToneCompensate](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT).
437
 
438
- **H3 Seam Report** — `report` (STRING) + `chart` (IMAGE). Wire the chain's `images` into it and it measures the brightness step at every join, says whether each is invisible / marginal / visible, and totals the chain's cumulative drift. A single reading includes whatever the scene did across the cut — the frames either side are ~0.9 s apart in scene time — so treat one number as an upper bound; to isolate the seam itself, render twice from the same seed and cache changing only `tone_compensate`, and compare.
439
 
440
  **H3 Continuity State** — `continuity_state` (STRING) out. **Setting only**: `setting_locked` / `setting_context` / `setting_mutable`. Characters belong in `ref_plan`.
 
1
+ ---
2
+ license: mit
3
+ tags:
4
+ - comfyui
5
+ - comfyui-nodes
6
+ - custom-nodes
7
+ - video
8
+ - video-generation
9
+ - text-to-video
10
+ - image-to-video
11
+ - minimax-h3
12
+ ---
13
+
14
  # Hand Tie Clips
15
 
16
  One node. Write a shot plan, drop in your reference stills, queue.
 
22
  > the rename keeps loading** — they are just hidden from node search. Nothing
23
  > needs migrating.
24
 
25
+ > **0.4.1 — 2026-08-30.** The 0.4.0 feature set was built without a browser or a
26
+ > GPU and verified offline only. It has now been run in ComfyUI, and two things
27
+ > were broken: the five new dials were never added to the run panel's widget list
28
+ > (they worked, but rendered as raw dials on the node body), and a dry run
29
+ > returned a 1×1 placeholder image that **libx264 cannot encode** — so every dry
30
+ > run wired to `SaveVideo`, which is what the Starter ships, died in
31
+ > `avcodec_open2`. Both fixed. The Starter now also ships the seam report wired.
32
+ > Measurements are in [`docs/DEVLOG.md`](docs/DEVLOG.md) section 21.
33
+
34
  **Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
35
 
36
  Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
 
335
  4, keeps its own seed, and keys the same way it will in the full run.
336
 
337
  `quality=draft` forces 0.3 MP and 6 steps — enough to read blocking, camera and
338
+ whether a join lands. Resolution and steps are both in the cache key, so a draft
339
+ never overwrites the final it stands in for; the two simply cost two entries.
340
+
341
+ Treat it as a **fidelity** lever rather than a speed one. Measured at ~42 s/hop
342
+ against ~45 s/hop at 7 steps: if you already render at 0.3 MP and 6–8 steps —
343
+ the regime this pack targets — draft saves almost nothing, and `dry_run` is the
344
+ fast button. Draft earns its place when your final is genuinely heavier, 1.0 MP
345
+ at 14 steps.
346
 
347
  ## Contact sheet
348
 
 
394
 
395
  Set `tone_anchor` to 0 to get plain frame_shift back.
396
 
397
+ **Measured** on three hops from one seed and one cache — the hop store writes
398
+ before the tone stage runs, so flipping the mode re-grades the same renders and
399
+ only the correction differs:
400
+
401
+ | `tone_anchor` | drift across the chain | worst seam |
402
+ |---|---|---|
403
+ | off | 13.4/255 | 1.9/255 |
404
+ | 0.15 | 7.4 | 1.0 |
405
+ | 0.35 | 5.1 | 1.6 |
406
+ | 0.60 | 2.9 | 2.1 |
407
+
408
+ Monotonic, with no knee: each step of strength removes roughly another 16% of
409
+ the drift and costs about 0.5/255 of seam step. Hop 1 is byte-identical in all
410
+ four. **0.35 is the default and stays.**
411
+
412
+ One caveat the numbers cannot capture: a scene that brightens *for a reason* —
413
+ walking toward a window — is indistinguishable from drift from the inside, and
414
+ anchor will flatten it. That is what the per-shot `tone` field is for.
415
+
416
  ## The MODEL wire
417
 
418
  Four ordinary nodes, in this order:
 
475
 
476
  ## Nodes
477
 
478
+ **H3 Ref2VA Chain** — `images`, `audio`, `info`, `contact_sheet` out. Wire `CreateVideo` + `SaveVideo` as in the example workflow, `info` to a Preview Text node, and `contact_sheet` to a Save Image.
479
 
480
  **H3 Chain Preview** — a passthrough panel for the IMAGE (and optionally AUDIO) wire, placed **between the chain and `CreateVideo`**. Images and audio come out unchanged, so adding or removing it changes no pixels. It shows the live sample, the seam (the previous hop's last frame beside this hop's first), a chain-wide progress bar, cache hit / seed / steps per hop, **which pin mechanism each hop actually used** — a latent Motion-Context pin or the AddGuide pixel fallback — and end-of-run A/V drift. Drag the grip to resize the stats panel; double-click it to reset.
481
 
482
  **H3 Tone Compensate** — `images` out. Corrects a generated segment's tone against the previous one, estimated on the overlap they share. **For hand-built chains only.** It cannot fix `H3 Ref2VA Chain`'s output: that node joins its hops internally and drops each hop's first `overlap` frames at the seam, so the regenerated copies this needs are already gone by the time images leave it. Use the chain node's `tone_compensate` widget instead. Estimator ported from [rkfg/ComfyUI-MiniMaxH3-ToneCompensate](https://github.com/rkfg/ComfyUI-MiniMaxH3-ToneCompensate) (MIT).
483
 
484
+ **H3 Seam Report** — `report` (STRING) + `chart` (IMAGE). **Ships wired on the Starter canvas.** Set `hops` to your shot count: it derives hop length from frames, hops and overlap, so a wrong `hops` does not error — it returns a plausible length and puts every seam where no join exists. Wire the chain's `images` into it and it measures the brightness step at every join, says whether each is invisible / marginal / visible, and totals the chain's cumulative drift. A single reading includes whatever the scene did across the cut — the frames either side are ~0.9 s apart in scene time — so treat one number as an upper bound; to isolate the seam itself, render twice from the same seed and cache changing only `tone_compensate`, and compare.
485
 
486
  **H3 Continuity State** — `continuity_state` (STRING) out. **Setting only**: `setting_locked` / `setting_context` / `setting_mutable`. Characters belong in `ref_plan`.
docs/DEVLOG.md ADDED
@@ -0,0 +1,915 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hand Tie Clips -- engineering log
2
+
3
+ The dated record behind [`CLAUDE.md`](../CLAUDE.md), which is the architecture
4
+ brief. Everything here is **history**: what was built on a given day, what it
5
+ measured, and what it got wrong. Later sections correct earlier ones -- read to
6
+ the end of a thread before trusting the top of it.
7
+
8
+ Numbering starts at 8 because sections 1-7 became the brief.
9
+
10
+ ## 8. References are files, not wires (2026-08-28)
11
+
12
+ The node had **16 sockets** and twelve of them were user media: `ref_image_1..9`,
13
+ `reference_video`, `voice`, `start_image`. They occupied roughly 340px down the
14
+ left of the node before the editor started, each needing its own `Load Image`.
15
+ They are gone. The node now has **five**: `model`, `clip`, `vae`, `audio_vae`,
16
+ `continuity_state`.
17
+
18
+ A reference names a **file** under `<ComfyUI input>/h3_refs`. `media.py` owns
19
+ both halves of that:
20
+
21
+ - **`resolve(name)` is the only thing that turns a name into a path**, and it is
22
+ used by the upload route and the loaders alike. Basename only, `normpath`,
23
+ then a prefix check against the reference directory, then an extension
24
+ whitelist. Verified against absolute paths, `../` traversal, and traversal
25
+ carrying a legal extension — all refused.
26
+ - **The loaders return exactly what the sockets delivered** — float `[N,H,W,3]`
27
+ in 0..1 — so `_ref_frames`' resize, `_collect_ref_images`' dense-pack and
28
+ `store.tensor_digest`'s cache keying are all unchanged. That is why this was a
29
+ small diff rather than a rewrite.
30
+
31
+ **Pixels never enter a widget.** Only the basename is stored. PromptMasterLD
32
+ measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
33
+ failed to save the workflow at all; previews are `/view?...&subfolder=h3_refs`
34
+ URLs, which cost nothing to rebuild and survive a reload. `/view` also brings
35
+ Range support, which a `<video>` needs to seek — so no thumbnail route was
36
+ needed.
37
+
38
+ **`IS_CHANGED` is new and load-bearing.** ComfyUI caches a node's output on its
39
+ inputs, and a filename is a stable input even when the bytes behind it change:
40
+ overwrite `face.png` and the previous render would be served. It hashes
41
+ `name:mtime` for every referenced file. Deliberately **not** `float("nan")` —
42
+ that is the blunt version PromptMasterLD's studio node uses, and it would force
43
+ a full re-render of an expensive node on every queue.
44
+
45
+ **Widget order is part of the saved-workflow format.** `widgets_values` is a
46
+ **positional array** -- ComfyUI restores `value[i]` into `widget[i]` and never
47
+ looks at the name. The three `*_file` widgets were first added at the *top* of
48
+ `optional`, which shifted `hop_script`..`tone_compensate` by +3; every workflow
49
+ saved before that change then loaded `audio_pin_frames`' integer into `ref_plan`
50
+ and the editor died with `(text || "").trim is not a function` on load. That
51
+ throw was the lucky part -- `hop_script`, `shot_plan` and `tone_compensate` were
52
+ being silently misassigned too, and only the type mismatch made any of it
53
+ visible.
54
+
55
+ **New widgets go at the bottom of `optional`, always.** Old workflows are then
56
+ *short* rather than *misaligned*, and the new widget takes its default. There is
57
+ an append-only marker comment in `INPUT_TYPES` saying so. Note `seed` costs
58
+ **two** array slots, not one: the frontend appends `control_after_generate`
59
+ right after it.
60
+
61
+ Both plan parsers now coerce (`String(text ?? "")`) and reject non-object JSON
62
+ instead of dereferencing it, so a future misalignment shows an empty JSON tab
63
+ rather than aborting the whole workflow load.
64
+
65
+ **The hop cache needed no change at all.** `chain_salt` already digests the
66
+ actual tensors, so different pixels behind the same filename still move the key.
67
+
68
+ **Legacy plans cannot be migrated automatically.** An old `ref_image_N` held a
69
+ tensor from a `Load Image`; there is no filename to recover. `refs.py` keeps the
70
+ authored slot as `legacy_slot`, `parse_ref_plan` does **not** raise on it, and
71
+ both the rail row and `check()` say *"was wired to ref_image_3 — pick its
72
+ picture"*. Failing the parse would have stopped the editor opening the very plan
73
+ the author needs to repair.
74
+
75
+ ## 9. Presentation (2026-08-28)
76
+
77
+ **The node collapsed on first click and stayed collapsed.** Two causes, both in
78
+ `installHeightGuard` (`js/editor/widget_utils.js`), both ported from
79
+ `PromptMasterLD/js/claude_prompt.js:6285-6370`:
80
+
81
+ - `computeLayoutSize().minWidth` reported a **constant**. The layout pass
82
+ re-reads it on every recompute — selecting the node is enough — and
83
+ faithfully re-declares the node at its stated minimum. It now tracks
84
+ `node.size[0]`. Safe against feedback: `max()` against a constant is a
85
+ fixpoint, not an accumulator, and width never feeds height.
86
+ - A stale `widget.width` shadows `node.width` forever after load, because
87
+ ComfyUI's DOM-widget position updater reads `(widget.width ?? node.width)`. A
88
+ live getter with a dropped setter makes the stale value unrepresentable.
89
+
90
+ Two more fixes alongside: `node.computeSize()[0]` is floored at the panel width
91
+ (with no widget declaring one, LiteGraph falls back to `NODE_WIDTH * 1.5 = 210`
92
+ and every resize command is free to crush the panel), and `domWidget.computeSize`
93
+ now answers a **width-passing** caller with the minimum height and a
94
+ **no-argument** caller with the live height. Reporting the live height to both
95
+ pins the resize-drag floor to the current height, so the node could only ever
96
+ grow.
97
+
98
+ **`chrome()`'s memo key was stale by construction.** It keyed on
99
+ `inputs|outputs|widgets.length`, none of which change when a widget is *hidden*
100
+ — so every panel height computed after `applyVisibility` used a chrome
101
+ measurement taken before it. The hidden count is now part of the key.
102
+
103
+ **The reference rail was permanently crushed, and its rescue was dead code.**
104
+ The 7-track grid needs ~536px and the node offers ~510px at `NODE_WIDTH 560`.
105
+ There was a `@container (max-width: 460px)` block written to relieve it — but
106
+ **nothing in the codebase declared `container-type`**, so the query had no
107
+ containment context and never matched. `.h3e-section` now declares
108
+ `container-type: inline-size`, and there are two breakpoints.
109
+
110
+ **The palette committed to one look.** It used to derive surfaces from the host
111
+ theme via `color-mix()` while hardcoding every accent — the intent was
112
+ light-theme safety, but `--h3-bg` mixed toward `#111827` and `--h3-sunken`
113
+ toward `#000`, so a light theme got dark blue-grey islands anyway. It is now the
114
+ PromptMasterLD `.ldp-root` system: `#0a0a0a`, one hairline `#2a2a2a`, zero
115
+ radius, one accent `#e8ff47`. **Changing `--h3-accent` moves the whole panel.**
116
+ Emphasis **inverts** (accent fill, `--h3-on-accent` ink) rather than tinting,
117
+ because an acid accent at 30% behind unchanged text is olive mud.
118
+
119
+ A cascade trap worth remembering: the override block was first inserted *before*
120
+ the reference-rail section, so `.h3e-chip-on`, `.h3e-inactive .h3e-ord` and
121
+ `.h3e-subj-badge` all kept winning on source order. Overrides live at the end of
122
+ the sheet now. Without a browser, a token audit script is the only thing that
123
+ catches this class of bug.
124
+
125
+ **The panel did not fill the node, and the mirror was why.** `installHeightGuard`
126
+ kept an independent `_h`, updated through an `onResize` hook, so that the
127
+ arrange pass could not feed its own growth. Measured during a drag: `onResize`
128
+ and `setSize` each fired 57 times while `_h` sat at 876 and `node.size[1]`
129
+ climbed past 1400. `measuring` was not stuck, which left one gate --
130
+ `Array.isArray(size)`. **This frontend's `node.size` is not a plain Array**, so
131
+ every write was skipped and `_h` held the install-time height forever. A 1911px
132
+ node had a 742px panel.
133
+
134
+ The repair was to delete the mirror, not fix the hook. `_h` existed only to
135
+ break the arrange loop, and
136
+
137
+ panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK) // SLACK 8
138
+
139
+ breaks it outright: the pass wants `panelTop + panelHeight + 4`, and `panelTop`
140
+ is chrome minus the node's bottom padding, so a panel of exactly `size - chrome`
141
+ asks for up to 4px more than the node has on every frame -- the ~130px/frame
142
+ runaway the header comment records. With slack the inequality holds, the loop
143
+ settles, and the node's height simply *is* the panel's height. Nothing to keep
144
+ in sync, no hook to get wrong. `sync()` now only ever grows a too-short node;
145
+ the height is the user's to choose.
146
+
147
+ **RUN is pinned and always open.** `.h3e-root` used to be the scroll container
148
+ with all four sections inside it, which put RUN below the script -- out of view
149
+ on any workflow with more than two shots, and it is the section touched on every
150
+ queue. The root is now a flex column holding `.h3e-scroll` (the authoring
151
+ sections) and RUN outside it.
152
+
153
+ Two flex details, both of which cost a round trip to learn:
154
+
155
+ - **`.h3e-scroll` needs `min-height: 0`.** A flex item defaults to
156
+ `min-height: auto` and refuses to shrink below its content, which pushes RUN
157
+ off the bottom of the node instead of scrolling.
158
+ - **`.h3e-run` must be `flex: 0 0 auto`, never `0 1 auto`.** Flex divides a
159
+ deficit in proportion to each item's content height. The scroller's content is
160
+ far taller, so a shrinkable RUN loses most of the contest and clips its lower
161
+ groups. The scroller absorbs all the shrinking; its `min-height: 160px` is the
162
+ floor that stops RUN owning the panel and RUN's `max-height: 55%` is the
163
+ ceiling.
164
+
165
+ Always-open removed the only moment RUN re-read its widgets, so it now resyncs
166
+ on `api`'s `promptQueued` -- the client-side event that fires after
167
+ `control_after_generate` has bumped the seed, and the same one ComfyUI's own
168
+ change tracker uses. The listener is dropped in `node.onRemoved`.
169
+
170
+ **Not done:** PromptMasterLD's `--fsc` UI-scale multiplier, which makes every
171
+ dimension `calc(Npx * var(--fsc))` and puts a zoom slider on the panel. It is
172
+ mechanical churn across every rule in the sheet and was not worth doing blind.
173
+
174
+ ## 10. Ported for public use (2026-08-29)
175
+
176
+ The prompting craft was written down and the pack was made installable by a
177
+ stranger. Four things, in the order they mattered.
178
+
179
+ **The shipped example workflows were broken.** All four in `workflows/` predated
180
+ the 2026-08-28 socket removal: twelve dead media inputs each, three `LoadImage`
181
+ nodes, and 21 widget values against a 28-widget node. Loading one is a new
182
+ user's *first* action, so this outranked any amount of documentation. They are
183
+ quarantined in `_disabled_custom_nodes/h3_legacy_workflows/` (the pack is not
184
+ under its own version control -- deleting would have been unrecoverable) and
185
+ replaced by two built from the verified `H3_Stress_6x7` structure:
186
+
187
+ - **`HandTieClips_Starter.json`** -- two hops, **no references at all**, runs the
188
+ moment the loaders are pointed at files. The empty register is deliberate: an
189
+ `@tag` whose picture is missing is a *hard* error in `resolve_tags`, so a
190
+ starter that shipped with tags would fail on first queue for everyone.
191
+ - **`HandTieClips_Showcase.json`** -- the six-hop continuity test, with the three
192
+ reference filenames generalised.
193
+
194
+ Both use **core ComfyUI plus this pack only**. The dev workflow reaches KJNodes
195
+ (`ModelPreviewOverrideKJ`, `MiniMaxLowVRAMAttention`) and PlagueKind
196
+ (`LTX_lora_loader`, `H3SLAAttention`, `H3AdaLNLoRAFix`); an example that fails
197
+ to load because of a pack the reader never asked for teaches nothing. The
198
+ speed stack is documented, not shipped.
199
+
200
+ **A declared-but-inactive `@tag` reported the wrong cause.** `check()` already
201
+ warned correctly that a picture was missing, but the run then died on
202
+ `resolve_tags` with *"unknown reference '@kitchen'"* -- pointing at the beat's
203
+ spelling, the one thing that was right. `resolve_tags` now takes `declared`
204
+ (every tag in the register, active this hop or not) and separates the two
205
+ failures. The old two-argument behaviour is unchanged when `declared` is
206
+ omitted.
207
+
208
+ **`PROMPTING.md` and `prompt_pack/`.** The craft rules were spread through
209
+ README prose; they are now a standalone guide, and a copy-paste system prompt
210
+ that gets a language model to emit valid plans. Two files under `prompt_pack/`
211
+ are **generated, never hand-written**:
212
+
213
+ - `tools/gen_schema.py` builds `SCHEMA.json` from `directives.VOCAB`,
214
+ `refs.RETENTION` and the duration table, and **asserts** against
215
+ `plan._SHOT_KEYS`, `refs.REF_FIELDS` and `refs.SUBJECT_FIELDS`. Add a camera
216
+ move and the schema follows; add a shot field and the generator fails loudly
217
+ rather than emitting a stale schema. `--check` is the CI form.
218
+ - `tools/gen_example.py` builds `EXAMPLE_6_HOP.md` from the showcase workflow,
219
+ including its hop/reference table, so the worked example and the shipped
220
+ workflow cannot disagree.
221
+
222
+ **Template patterns in the editor.** `js/editor/templates.js` plus a
223
+ **Templates** button in the SCRIPT header. They **append**, never replace --
224
+ replacing would be the one destructive control on the node, and stacking is how
225
+ a chain is actually built. `freeId()` mints the lowest unused `sN` because `id`
226
+ is the hop cache's pointer and two shots sharing one would make `locked` reuse
227
+ the wrong render.
228
+
229
+ No template contains an `@tag`, for the same reason the starter workflow has no
230
+ references. `tools/check_templates.py` extracts the patterns *out of the JS*
231
+ and runs them through `plan.parse_plan` and `plan.check_coherence`, and lints
232
+ every beat for negation -- a template that produced a plan the node rejects
233
+ would be worse than no templates, because a first-time author would blame their
234
+ own writing. It caught two: a beat reading "They stop at the window" (naming a
235
+ cessation, which is law 2) and a line of dialogue containing "did not".
236
+
237
+ That second one is worth recording as an open question: **whether the additive
238
+ prompt bites inside quoted dialogue was never tested.** The templates avoid it,
239
+ and `PROMPTING.md` says plainly that this is untested rather than inventing a
240
+ rule.
241
+
242
+ ## 11. What chain_00057 taught (2026-08-29)
243
+
244
+ The six-hop showcase was rendered for the first time. Three failures, and the
245
+ one that mattered was a code gap the documentation had already claimed was
246
+ fixed.
247
+
248
+ **`locked` and `context` reached hop 1 only.** The register's whole promise is
249
+ that a subject's continuity text carries identity across a hop where the
250
+ photograph is absent. It did not. `subject_prose` is called under `if i == 0`,
251
+ and `_identity_lock` returns `""` when no subject-bearing ref is active -- so on
252
+ the showcase, hops 2, 3, 5 and 6 carried **no identity text of any kind**, and
253
+ hop 5, scheduled with no references at all, lost the character entirely.
254
+ Identity was riding on the pinned frames alone, which is the exact failure the
255
+ register exists to prevent.
256
+
257
+ `refs.continuity_line()` now emits that text on every hop 2+, and
258
+ `_assemble_next` injects it between the lock and the live-frame citation. The
259
+ reason it was suppressed in the first place is real and is preserved: naming
260
+ *pictures* on a pin-only hop sent the encoder back to the plates (chain_00034 --
261
+ commercial kitchen, grey shirt, no apron). So the new line carries **no
262
+ `<Picture N>` and no `<Subject N>`**. It is a description of what stays the
263
+ same, not a citation of anything. `<Subject N>` is excluded specifically because
264
+ there is no `subject_definitions` block on a continuation hop to bind it to.
265
+
266
+ Note this changes the assembled block on hop 2+ of every chain with a register,
267
+ so it invalidates the hop cache. That is correct, not a regression.
268
+
269
+ **Dialogue propagated through five hops.** Shot 1 ended on its spoken line with
270
+ `tail: ongoing`. The audio pin carries the previous hop's tail, so the last
271
+ second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
272
+ the closing instruction; the model satisfied it with the action it could hear.
273
+ Nothing in hops 2-6 gave the audio anywhere else to go.
274
+
275
+ This is the guide's own law 3, broken by the plan written to demonstrate it. The
276
+ fix is authorial, not code: land the line **mid-hop** and leave a non-verbal
277
+ action running into the seam (the knife on the board), and give every
278
+ dialogue-free hop a narrowband sound of its own. Both `PROMPTING.md` and the
279
+ authoring prompt now carry this as a named rule, and the shipped templates were
280
+ rewritten to obey it.
281
+
282
+ **`join: continuous` across a location change morphed one room into the other.**
283
+ Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
284
+ continuous join asks for one unbroken take between two different rooms, which
285
+ is not a thing. `match_cut` is what a walk through a doorway is. The beat also
286
+ read "steps into @kitchen" -- the container phrasing the README warns produces a
287
+ composite of the photograph -- and now names the counter *in* the kitchen.
288
+
289
+ Hop 5's beat is unchanged in the revised plan **on purpose**: it is the
290
+ measurement, and changing it would forfeit the comparison.
291
+
292
+ The revised plan is in the shipped showcase and in
293
+ `user/default/workflows/H3_Stress_6x7_v2.json`; the original that produced
294
+ chain_00057 is left alone so the A/B survives.
295
+
296
+
297
+ ## 12. Renamed to Hand Tie Clips (2026-08-29)
298
+
299
+ The pack was `ComfyUI-H3-Ref-Chain`. It is now `ComfyUI-Hand-Tie-Clips`, and the
300
+ four registered ids moved with it:
301
+
302
+ | was | is |
303
+ |---|---|
304
+ | `H3RefChain` | `HandTieClips` |
305
+ | `H3ContinuityState` | `HTCContinuityState` |
306
+ | `H3ChainPreview` | `HTCChainPreview` |
307
+ | `H3ToneCompensate` | `HTCToneCompensate` |
308
+
309
+ Display names are unchanged (`H3 Ref2VA Chain`, `H3 Chain Preview`, ...) because
310
+ the pack only drives MiniMax H3 checkpoints and a name that hides that costs
311
+ somebody an afternoon. The menu category is `Hand Tie Clips`. `TAG` -- and so the
312
+ console prefix -- is `[HandTieClips]`.
313
+
314
+ **The old ids are still registered, as `DEPRECATED` subclasses.** A type id is
315
+ what every saved `.json` carries, and an unregistered one is a red missing-node
316
+ box, not a warning. A plain alias in `NODE_CLASS_MAPPINGS` would have worked but
317
+ listed each node twice in search: ComfyUI falls back to the mapping key when
318
+ `NODE_DISPLAY_NAME_MAPPINGS` has no entry for it. Subclassing and setting
319
+ `DEPRECATED = True` gets both -- `server.py:783` publishes `deprecated: True`,
320
+ and the frontend's `Comfy.Node.ShowDeprecated` (off by default) keeps it out of
321
+ search while leaving it fully functional in workflows that name it.
322
+
323
+ **The JS had to learn both ids or the aliases would have been worse than
324
+ useless.** `js/h3_ref_chain_ui.js` and `js/h3_chain_preview.js` each compared
325
+ `nodeData.name` against a single string; a legacy node would have loaded with no
326
+ editor at all, which looks exactly like the rename having broken the pack. Both
327
+ now test membership of a `Set`. `js/h3_chain_preview.js` needs it twice: once
328
+ for its own type, once for `CHAIN_TYPES`, which is how a preview walks back up
329
+ `images` to find the chain feeding it.
330
+
331
+ **What deliberately kept the `h3` naming**, and must not be "finished" later:
332
+ module filenames (`h3_ref_chain.py`), the `h3e-` CSS class prefix and `--h3-`
333
+ tokens (515 occurrences -- one missed class silently breaks styling), the
334
+ `/h3_ref_chain/*` routes, the `h3_refchain_preview` event name, and
335
+ `input/h3_refs`. Renaming that folder would orphan every reference photo already
336
+ on disk.
337
+
338
+ The two shipped workflows moved to `HandTieClips_Starter.json` /
339
+ `HandTieClips_Showcase.json` and were rewritten onto the new ids, with their
340
+ `SaveVideo` prefix now `video/HANDTIECLIPS/chain`. The three under
341
+ `user/default/workflows/` were left on the legacy ids on purpose: re-running
342
+ `H3_Stress_6x7_v2.json` is then a live test of the alias path, and their existing
343
+ renders stay together under `output/video/H3REFCHAIN/`.
344
+
345
+
346
+ ## 13. The on-canvas board (2026-08-29)
347
+
348
+ `workflows/HandTieClips_Starter.json` carries six `MarkdownNote` cards to the
349
+ left of the loaders, wrapped in a group titled READ ME. The text lives in
350
+ `tools/notes.py` and is written into the workflow by `tools/build_notes.py`.
351
+
352
+ Why on the canvas: the craft was in `PROMPTING.md`, `prompt_pack/` and the
353
+ Templates panel, and all three require leaving the graph. The rules that decide
354
+ whether a first render works are needed while beats are being written, which is
355
+ on the canvas. The cards are a **condensation**, not a copy -- `PROMPTING.md`
356
+ stays the authority and every card says so.
357
+
358
+ Mechanics worth not rediscovering:
359
+
360
+ - `MarkdownNote` is a core virtual node. The frontend renders it with `marked`
361
+ at `gfm: true` and sanitises with DOMPurify, so headings, GFM tables, bold and
362
+ code fences all work. It needs frontend >= ~1.16.
363
+ - Cards are identified by `properties.htc_card`, which is what makes
364
+ `build_notes.py` idempotent -- it drops marked nodes before writing, so
365
+ re-running replaces the board instead of stacking a second copy.
366
+ - `extra.ds` is **restored** on load, not fitted. Without setting it, a board at
367
+ negative x sits off-screen and is never found. Screen is
368
+ `(world + offset) * scale`, so the offset is what brings it into view.
369
+ - Group serialisation is `{id?, title, bounding:[x,y,w,h], color?, font_size?,
370
+ locked?}` -- confirmed against the frontend's own zod schema, not guessed.
371
+
372
+ **`widgets_values_named`, found while doing this.** Both shipped workflows
373
+ carried a stale copy: `chains: 3`, `duration: 10 s`,
374
+ `control_after_generate: randomize`, and a legacy `ref_plan` naming pictures that
375
+ do not ship -- 25 entries against a 28-widget node. It came from the builder
376
+ deep-copying nodes out of the dev workflow and overwriting only
377
+ `widgets_values`. Dormant while `Comfy.Workflow.NamedValuesRestore` stays off
378
+ (experimental, default false), but anyone who turned that on would have loaded a
379
+ Starter that randomizes its seed and dies on a missing reference.
380
+ `build_notes.py` strips it from both, and `tools/check_workflows.py` now fails if
381
+ it ever comes back.
382
+
383
+ `tools/check_workflows.py` is the promoted version of the validator that guarded
384
+ the shipped workflows against socket/widget drift. It also checks the board:
385
+ every card marked and non-empty, exactly one group, the group enclosing every
386
+ card, and no card reaching past x=0 onto the loaders.
387
+
388
+
389
+ ## 14. The turbo stack ships in the examples (2026-08-29)
390
+
391
+ Both shipped workflows now carry the dev graph's full MODEL wire:
392
+
393
+ UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
394
+ -> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips
395
+
396
+ and **CLIP reaches the chain from the LoRA loader, not the encoder** -- that is
397
+ what makes the text half of every LoRA land, and it is the wire most likely to
398
+ get quietly "fixed" back to the encoder by someone tidying the graph.
399
+
400
+ This reverses the earlier "core ComfyUI and this pack only" rule for the
401
+ examples, at the user's direction: `steps` is 7, which only works with a turbo
402
+ LoRA, so an example without the LoRA stack is not a graph anyone can run at the
403
+ settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
404
+ KJNodes supplies Low VRAM Attention and the preview override.
405
+
406
+ `tools/build_speed_stack.py` inserts and rewires the five nodes idempotently
407
+ (marker `properties.htc_speed`), rebuilding the plain loader -> chain shape
408
+ first so it is reentrant from either state. `tools/check_workflows.py` walks
409
+ **both** wires link by link -- a patch node that is present but bypassed round
410
+ the side looks right on the canvas and does nothing.
411
+
412
+ `H3SLAAttention` widget values are written out in full including
413
+ `reference_protection`, which post-dates the dev workflow's saved values. That
414
+ workflow has 10 entries against an 11-widget node, which is the positional rule
415
+ working as intended: the new widget was appended last, so old values still line
416
+ up and the missing one falls back to its default. The trailing `""` on the LoRA
417
+ loader and the preview override is carried over verbatim for the same reason --
418
+ a value past the last widget is ignored, and dropping one that turns out to
419
+ belong to a widget would shift every value after it.
420
+
421
+ `head_chunks` ships at 4 (the node's own default, safer on unknown VRAM) rather
422
+ than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
423
+ loader names, and `taeh3.safetensors` for the preview override's `tiny_vae`.
424
+
425
+ ## 15. What a shipped diagnosis looked like (2026-08-29)
426
+
427
+ The editor UI vanished after the folder rename -- raw widget boxes, no panel.
428
+ Not a code fault. ComfyUI had been restarted while the pack was still
429
+ `ComfyUI-H3-Ref-Chain`, so the running process held
430
+ `python_module: custom_nodes.ComfyUI-H3-Ref-Chain` and served its web assets
431
+ from a path the move had deleted. `/object_info` had all eight node types;
432
+ `/extensions` listed none of the pack's JS and a direct fetch 404'd.
433
+
434
+ Worth knowing for next time: `/object_info` carries `python_module`, which is
435
+ the fastest way to find out **which copy of a pack a running server actually
436
+ loaded**, and `/extensions` plus a direct fetch of one script separates "the JS
437
+ is broken" from "the JS is not being served at all".
438
+
439
+ ## 16. What the Rain Kitchen renders taught (2026-08-29)
440
+
441
+ Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
442
+ three findings are now in `PROMPTING.md`, the troubleshooting table, the
443
+ authoring prompt and the on-canvas board.
444
+
445
+ **Identity drift is permanent, and the old advice was backwards.** This file and
446
+ `PROMPTING.md` used to say a face plate riding a later hop *beats the pin* and
447
+ that `shots` should therefore be kept tight. chain_00059 falsified it: hop 4
448
+ carried a face plate photographed in a *different kitchen* through a walking
449
+ medium shot and held cleanly, while hop 5 — scheduled with no references at
450
+ all — came back a different person, and hop 6 never recovered even though a
451
+ place plate rode it and restored the room instantly. `locked` holds a face that
452
+ is still right; only a plate rebuilds one that is gone. **Face refs go on every
453
+ hop.** The old advice survives for *place* plates only.
454
+
455
+ **A beat must survive an over-delivered hop.** A hop routinely does more than it
456
+ was asked. Shot 3 asked for "a first slow step along the counter" and delivered
457
+ the whole walk; shot 4 was then handed an instruction its own live frame had
458
+ already satisfied, and the only way to obey was to reset the scene — a hard
459
+ cut 1.5 s *into* the hop, not at the seam, because `_assemble_next` holds the
460
+ incoming frames for a short beat first. Give one hop the whole movement, and
461
+ write the next beat true from either ending ("she *reaches* the window", "she
462
+ takes up the bowl *again*").
463
+
464
+ **A noun with no adjective drifts.** Three hops said only "the bowl"; it came
465
+ back stainless steel. Naming it "the white bowl" everywhere *and* stating it in
466
+ `context` as a property (never a location — "stays in her hands" fights the
467
+ beat the moment she puts it down) held it for six hops. The re-run then proved
468
+ the rule by accident: `context` read "the apron stays tied over the **grey**
469
+ t-shirt", and the t-shirt held all six hops while the apron, one clause away
470
+ with no colour, had turned denim blue by hop 6.
471
+
472
+ ### The code change this bought
473
+
474
+ `refs.resolve_tags` now takes `subject_names` and the node passes it on
475
+ continuation hops only. `<Subject N>` is bound by `subject_definitions:`, which
476
+ is hop-1 material — so on hop 4 the ordinal dangled, exactly the same defect
477
+ as the undescribed bowl. From hop 2 a person tag resolves to the subject's
478
+ `name` instead ("The cook walks down the hallway"), which binds to the
479
+ `continuity_line` sentence every continuation hop already carries. No name means
480
+ the old `<Subject N>` fallback, so nothing regresses.
481
+
482
+ `tools/check_prompts.py` mirrors the new call, and gained the banned-word check
483
+ that previously ran only against `templates.js`. That gap is how the shipped
484
+ Showcase carried "**None** of the kitchen is visible" (law 1 — it *adds* a
485
+ kitchen at cfg 1.0) and "She **stops** at the window" (law 2) through six
486
+ versions. Both are fixed, and the Showcase's face ref now rides all six hops.
487
+
488
+ ## 17. The pre-beta audit (2026-08-29)
489
+
490
+ Read for what a stranger hits, not for what we already check. Six findings; the
491
+ first two would each have cost a tester a run.
492
+
493
+ **The docs promised a stop the code never did.** `refs.check()` is *"warn, never
494
+ raise"*, and its caller only printed. A ref naming a file that is not in
495
+ `h3_refs` had its slot skipped and the chain rendered on — all six hops, with
496
+ the reference silently inactive, which is precisely the uncontrolled output the
497
+ register exists to prevent. Four shipped surfaces said otherwise, including the
498
+ Showcase's own note: *"the run stops and names the reference it could not find.
499
+ Nothing guesses."*
500
+
501
+ Fixed in the code rather than the docs, because there is no reading under which
502
+ rendering without a named-but-absent picture is what the author meant. New
503
+ `refs.missing_files()` returns `(tag, file)` for every ref whose named file did
504
+ not load; `h3_ref_chain.py` raises on it, after printing the register table so
505
+ the error arrives with its context. **The distinction that matters:** a ref with
506
+ *no* `file` stays a warning — the Starter ships that way on purpose so it runs
507
+ before any pictures exist. Only a *named* file that is absent is fatal.
508
+
509
+ **The on-canvas board never got §16's corrections.** `tools/notes.py` still
510
+ carried the row chain_00059 falsified — *"a plate riding a hop it does not
511
+ belong on, beating the pin → tighten `shots`"* — and was missing all three rows
512
+ `PROMPTING.md` gained. The board is what a beginner actually reads, so it was
513
+ shipping the inverse of the rule. Lesson: `PROMPTING.md` is the authority, but
514
+ `tools/notes.py` is a **second copy** of the same craft, and a correction is not
515
+ finished until both move. `build_notes.py` regenerates the Starter's cards *and*
516
+ the Showcase's `Note` from `notes.SHOWCASE_NOTE`, so one rebuild covers both.
517
+
518
+ The rest were packaging, not craft: the MIT `LICENSE` named no copyright holder;
519
+ `pyproject.toml` lacks both fields a Registry publish needs (`PublisherId` and a
520
+ `[project.urls] Repository`), now commented in place; the `README` had **no
521
+ install section at all**; `ComfyUI-H3-Motion-Context` was absent from Needs
522
+ despite the intro calling it the primary guidance path, so testers land on the
523
+ `MiniMaxH3AddGuide` fallback without knowing they changed code paths; and the
524
+ example workflows name quantised checkpoints by filename with no note that they
525
+ are one valid set among many, which reads as a broken graph rather than a
526
+ missing file.
527
+
528
+ Clean on the same pass, worth not re-checking: no personal data anywhere, no
529
+ hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
530
+ syntax, no third-party dependencies, Motion-Context absence handled with fork
531
+ detection, `WEB_DIRECTORY` present, `h3_refs` auto-created.
532
+
533
+ ## 18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)
534
+
535
+ Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The `ref_plan` came
536
+ back structurally perfect -- both subjects named, face plate on all eight hops,
537
+ places scheduled off the hops they do not belong on, zero register warnings. The
538
+ `shot_plan` carried four defects, and tracing each one back to
539
+ `prompt_pack/AUTHORING_PROMPT.md` found more than four gaps.
540
+
541
+ **The prompt taught one of the bugs.** Rule 9 illustrated a beat as
542
+ ``"stands at the counter in `@kitchen`"`` -- backticks and all, because it was
543
+ the one place a tag appeared inside a beat. The model copied the formatting, and
544
+ literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
545
+ prose.
546
+
547
+ **Beat length had no guidance at all.** The only budget in the file was for
548
+ dialogue, so a no-dialogue plan had nothing to size against and every beat came
549
+ back at 22-28 words regardless of a 15 s hop. There is now a word table -- and
550
+ an honest note on it: the shipped plans run 37-39 words at *both* 5 s and 7 s,
551
+ so beat length is near-constant across the only two hop lengths ever rendered.
552
+ The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned
553
+ extrapolation, not a measurement, and the table says so.
554
+
555
+ **Two rules were simply missing.** Nothing told the model to state a visual style
556
+ -- for a "2D anime, Ufotable" concept neither returned block contained the word
557
+ anime, so the text asked for photoreal while the pictures asked for anime. And
558
+ the over-delivery rule from section 16 had never been carried into the prompt at
559
+ all, only into `PROMPTING.md` and the board.
560
+
561
+ **Two statements were wrong.** The prompt said "at most 9 pictures on any one
562
+ hop"; `parse_ref_plan` counts `len(refs)` over the **whole plan**. The code's own
563
+ error message said "on one hop" too, and has been corrected to match what it
564
+ checks. And the `file` bullet still told the model to invent placeholder
565
+ filenames without noting that, as of 0.3.1, a named file absent from `h3_refs`
566
+ **stops the run**.
567
+
568
+ Law 2 is the interesting non-fix. It is stated plainly, with the exact example
569
+ "The cook stops talking", and the model wrote "stops and looks up" anyway. A
570
+ principle is not something a 27B reliably applies to its own output; the literal
571
+ word list `check_prompts.py` enforces now appears in the prompt, because a word
572
+ list is checkable and a principle is not.
573
+
574
+ ## 19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)
575
+
576
+ Three sources landed together: a rendered 8 x 15 s anime chain (`chain_00003`,
577
+ 2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
578
+ written to trip specific rules. Shipped as **0.3.3**.
579
+
580
+ ### The node was fighting every stylised plan
581
+
582
+ `directives.py` prepended `ESTABLISH = "Live-action, natural light, one
583
+ continuous take."` to hop 1 **unconditionally**. At cfg 1.0 with no negative
584
+ branch that is additive, and it landed *ahead* of the style declaration rule 12
585
+ requires. A stop-motion puppet plan compiled to "Live-action, natural light,
586
+ one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
587
+ painted wood", and the two fought.
588
+
589
+ It also explains the anime chain's opening: hop 1 rendered as bright
590
+ naturalistic daylight (**mean luma 72**) against a night plan *and* a night
591
+ place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That
592
+ had been read as drift; it was the node.
593
+
594
+ Never surfaced because both shipped workflows are live-action.
595
+
596
+ Fixed twice over, because either alone leaves a hole: an `establish` widget
597
+ (appended **last** in `optional`, per the positional-widget rule in section 8),
598
+ and `directives.declares_own_medium()` / `establish_for()`, which drop the
599
+ default when shot 1's opening names a medium. A model-authored plan never
600
+ touches a widget, which is why the automatic arm is the one that matters.
601
+
602
+ ### Luminance drifts, and it only goes one way
603
+
604
+ Mean luma per hop across the eight: **72, 46, 35, 17, 11, 11, 19, 14**. Setting
605
+ the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the
606
+ point of the film, plays at the bottom of it.
607
+
608
+ This is the colour law one level up. `locked` holds a face; nothing holds an
609
+ exposure. Each hop inherits the last frame and darkens it slightly and the
610
+ error compounds. **Restate the light as a positive property in every beat** --
611
+ naming a light *source* ("pale moonlight") does not set a level, and at cfg 1.0
612
+ it only adds a moon.
613
+
614
+ ### The seams were never the problem
615
+
616
+ 7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is
617
+ 13.6, inside the range of ordinary in-shot motion, and two seams score below the
618
+ film's own mean. Identity held 114 seconds on face plates riding every hop.
619
+
620
+ The one hard cut in the film is **inside** hop 4, 3.25 s in, at 7.1 sd -- more
621
+ than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
622
+ open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
623
+ the two of them square off". Hop 4 was handed a live frame of a man among trees
624
+ and a beat asserting he stood on open stone, held the forest for 3.25 s, then
625
+ reset the scene. The plan was clean under `check_coherence` and the banned-word
626
+ scan. Hence `plan.check_place_handoff()`.
627
+
628
+ ### What the two models did
629
+
630
+ Near-identical answers from Qwen and Gemma on the same prompt -- same beats to
631
+ the word in 5 of 6 shots, same defects, same invented justification. The prompt
632
+ is prescriptive enough to collapse two models onto one answer; a shared blind
633
+ spot is then invisible from output alone.
634
+
635
+ **The word table was inert.** Both models, asked for six 15 s hops, returned
636
+ beats averaging **54 words** against a 70-100 band -- every beat under the floor
637
+ -- and the same models on much shorter hops returned 40-48. Beat length is
638
+ near-invariant to hop length in model output, now measured in a third setting.
639
+ A reference table does not move a model with a prior on paragraph length; the
640
+ rule is now an instruction to count, with a worked 74-word example.
641
+
642
+ **The banned-word list beat the principle, and made things worse.** Zero literal
643
+ banned words in all four plans -- and "The storm's roar begins to fade...
644
+ raindrops strike the glass with decreasing force" and "The storm has passed".
645
+ The list taught token avoidance and handed them a box to tick. It is now framed
646
+ as crude examples of an idea, with the test stated as: is this happening, or has
647
+ it finished happening?
648
+
649
+ **Both abandoned the second location.** Each plated the opening place, moved the
650
+ story elsewhere, gave the new place no plate, and justified it with a rule that
651
+ does not exist ("to avoid conflicting with the frame pin of the new space");
652
+ Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left
653
+ the lamp room -- four of six hops -- on beat text alone.
654
+
655
+ ### `check_place_handoff` has two arms, and both were narrowed by real plans
656
+
657
+ 1. **Handoff.** Shot N names a place tag shot N-1 never mentions, and shot N's
658
+ own beat does not carry the journey. The arrival vocabulary had to widen: the
659
+ Showcase's shot 6 ("walks back along the hallway and through the doorway to
660
+ the counter in @kitchen") is correct and was being flagged.
661
+ 2. **Abandonment**, not gaps. Warning on any unplated hop also flagged the
662
+ Showcase, which deliberately walks her down an unplated hallway on 4-5 and
663
+ returns the kitchen plate on 6. The rule that survives contact: warn only
664
+ when the plates stop and **never resume**, so the film ends somewhere no
665
+ picture describes.
666
+
667
+ Both shipped workflows are clean under the final version; the anime plan raises
668
+ exactly one warning, on shot 4.
669
+
670
+ ### Audio, for the record
671
+
672
+ No background music and no speech, confirmed on the spectrogram: broadband
673
+ transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
674
+ of seven joins, and the two exceptions are drops the beats themselves ask for.
675
+ The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
676
+ while hop 8 averages -42 dBFS. There is no audio equivalent of
677
+ `HTCToneCompensate`. Left open.
678
+ ## 20. Seven features built blind (2026-08-30)
679
+
680
+ Built in one pass with no browser and no GPU render available -- the user was
681
+ away and explicitly asked for the work anyway. Everything below was verified by
682
+ offline execution only. **Section 21 is what happened when it was finally
683
+ opened in ComfyUI**: two of the seven shipped broken in ways no offline test
684
+ could have caught, and the rest measured out. Read section 19 first for the
685
+ measurements that motivated most of it, and 21 for what survived contact.
686
+
687
+ ### What shipped
688
+
689
+ | # | thing | where |
690
+ |---|---|---|
691
+ | 1 | `tone_compensate=anchor` + `tone_anchor` strength | `tone.py`, wired in `h3_ref_chain.py` |
692
+ | 2 | `dry_run` -- compile every prompt, render nothing | `h3_ref_chain.py` |
693
+ | 3 | `contact_sheet` -- a fourth IMAGE output | new `sheet.py` |
694
+ | 4 | `render_through` -- stop after hop N | `h3_ref_chain.py` |
695
+ | 5 | `quality=draft` -- 0.3 MP, 6 steps | `h3_ref_chain.py` |
696
+ | 6 | **H3 Seam Report** node | new `seam.py` |
697
+ | 7 | over-delivery lint | `plan.py` |
698
+
699
+ Five new widgets, appended LAST (29 -> 34 values). One new output, appended
700
+ LAST (3 -> 4). Both rules are in section 9; both were obeyed.
701
+
702
+ ### The anchor, and why it is not just another tone mode
703
+
704
+ frame_shift/gain_bias/lut are **seam-local**: they cancel the denoiser's tone
705
+ bias on the overlap, which makes each join exact. They cannot see the exposure
706
+ falloff *inside* a hop, and that is what compounds -- hop N darkens across its
707
+ own frames, hands the darker tail to hop N+1, and every individual seam stays
708
+ perfect while the film dims. Section 19 measured 46 -> 11 across hops 2-6.
709
+
710
+ Worth stating plainly because it is counter-intuitive: **a synthetic 8-hop
711
+ chain showed frame_shift making the total slide WORSE** (66/255 vs 35/255 with
712
+ correction off). That is correct behaviour, not a bug. The denoiser's per-hop
713
+ bias happened to lift; cancelling it removed a lift that had been partly
714
+ offsetting the falloff. Seam correction fixes seams. It was never a level
715
+ control and should not be read as one.
716
+
717
+ `anchor` = frame_shift + a second stage pulling each hop's mean back toward
718
+ **hop 1's**. Two properties make it safe to stack:
719
+
720
+ - the pull **ramps from zero** over `ANCHOR_RAMP` (48f) frames, so frame 0 of
721
+ a hop is returned untouched and the seam stays exactly as frame_shift left
722
+ it. Without the ramp a per-hop constant offset re-introduces precisely the
723
+ step frame_shift just removed -- this is the whole design, and the trap
724
+ anyone re-implementing it will fall into;
725
+ - it is **capped** (`ANCHOR_MAX_SHIFT`, 0.06) and scaled by `tone_anchor`
726
+ (0.35), so a slide is corrected across several hops instead of one hop
727
+ snapping back.
728
+
729
+ The correction needs no carry variable between hops: because it is applied
730
+ before `prev_imgs` is taken, the next hop's seam correction matches the
731
+ already-corrected tail and the offset propagates on its own.
732
+
733
+ On the synthetic chain: slide 66 -> 18/255, worst seam step 2.22 -> 2.13/255.
734
+ The seam did not regress, which is the property that mattered.
735
+
736
+ Intent is indistinguishable from drift from the inside, hence the per-shot
737
+ `tone` field: `"free"` skips one hop's pull, `"rebase"` moves the anchor onto
738
+ that hop. A deliberate walk into a cellar needs `rebase` or the chain spends
739
+ the rest of the film brightening it back.
740
+
741
+ ### dry_run: what it must not touch
742
+
743
+ The value is that it costs seconds, so every expensive thing is guarded:
744
+ `MiniMaxH3SigmaShift`, `KSamplerSelect`, `BasicScheduler`, `_model_fingerprint`
745
+ (it hashes patched weights), the hop store, and -- the big one -- the master
746
+ preallocation. `master_imgs` for 8 x 15 s at 1280x736 is 2742 float frames,
747
+ about **31 GB**. A dry run that allocated it would be worse than useless.
748
+
749
+ Hop 2+ needs *a* `prev_imgs` to compute `<Picture N>` ordinals. Content is
750
+ irrelevant to the compiled text, so a `[overlap, 8, 8, 3]` zero tensor stands
751
+ in and the text is byte-identical to a real run's.
752
+
753
+ The smoke test (`tmp/t_dry.py`) replaces all five sampler entry points with
754
+ objects that raise on **any** attribute access, so "did not touch the sampler"
755
+ is asserted rather than assumed. It caught one real bug: the dry block
756
+ referenced `pin_mech_pred` before its assignment, ~40 lines later. Which pin a
757
+ hop gets is decided at render time from whether a sampler latent exists, so a
758
+ dry run genuinely cannot know it -- the sheet reports the `pin_to_qwen`
759
+ *setting* instead. Reporting AddGuide for every hop would have been a lie.
760
+
761
+ ### The over-delivery lint
762
+
763
+ The one defect class every other check structurally misses: both shots are
764
+ individually well-formed, the directives are individually legal, and only the
765
+ JOIN between them is wrong. `tail=settle|hold` promises rest; a following beat
766
+ that opens "She continues...", "Walking to...", "Mid-sentence..." asks the model
767
+ to carry on what the hop before was told to stop.
768
+
769
+ Narrow on purpose. Trailing spaces in `_MID_ACTION` are load-bearing ("keeps "
770
+ not "keepsake", "still " not "stillness"), and `_MID_ACTION_LEAD` is only
771
+ checked at position 0, which is what stops "Morning light..." and "Nothing
772
+ moves..." from firing. Verified against those exact traps, and both shipped
773
+ plans stay quiet.
774
+
775
+ It will miss a beat that opens mid-action without saying so. That is accepted:
776
+ a false positive that blocked a render would be worse than the defect.
777
+
778
+ ### Notes for whoever picks this up
779
+
780
+ - `sheet.py` and `seam.py` catch every exception and return a placeholder
781
+ image. A picture must never lose a finished chain. Do not "clean up" those
782
+ handlers. The placeholder was 1x1 until section 21 -- see there for why an
783
+ inert-looking image is not inert.
784
+ - The contact sheet stores frames through `sheet.small()` (168px tall). Two
785
+ full frames per hop across eight hops is 180 MB held for the whole render for
786
+ no reason.
787
+ - The sheet shows `imgs[overlap_n]` for hops 2+, not `imgs[0]`: the first
788
+ `overlap` frames are trimmed at the join, so `imgs[0]` is a frame the master
789
+ never contains.
790
+ - `tools/check_workflows.py` derives the expected widget list from the live
791
+ `INPUT_TYPES`, so it needed no edit for the five new widgets -- only
792
+ `SaveImage` added to `CORE`, for the Starter's new contact-sheet node.
793
+ (Section 21 added `PreviewAny`, `PreviewImage` and `HTCSeamReport` to those
794
+ allowlists when the seam report was wired into the Starter.)
795
+ - The Starter now ships `contact_sheet=on` with a `SaveImage` wired; the
796
+ Showcase ships it off. Starter is the teaching graph, so the feature is on
797
+ the canvas where it will be found.
798
+
799
+ ### Unverified, in priority order
800
+
801
+ All five items that stood here were closed on 2026-08-30. See section 21.
802
+
803
+ ## 21. What the first ComfyUI session measured (2026-08-30)
804
+
805
+ Section 20's seven features, opened in a browser and run on a GPU for the first
806
+ time. Five measured out. **Two were broken, and neither could have been caught
807
+ by any offline test that existed** -- both failures lived in the gap between
808
+ "the Python is correct" and "the graph runs".
809
+
810
+ ### Bug 1: 0.4.0 never touched `js/`
811
+
812
+ `git show --stat` on the 0.4.0 commit lists twenty files and not one under
813
+ `js/`. The five new widgets were declared in `INPUT_TYPES` and never added to
814
+ `GROUPS` in `js/editor/run_panel.js`, so the run panel did not draw them.
815
+
816
+ It did not *look* broken, which is the interesting part. The panel hides only
817
+ the widgets it successfully drew -- a deliberate design so an undrawable dial
818
+ never vanishes from the node -- so all five fell through to native dials and
819
+ worked fine. `tools/check_workflows.py` passed throughout, because it derives
820
+ from `INPUT_TYPES` and `GROUPS` is display-only.
821
+
822
+ **The lesson: a Python-side widget list and a JS-side widget list are two
823
+ sources of truth, and nothing checks that they agree.** Adding a widget means
824
+ editing both. There is still no checker for this.
825
+
826
+ ### Bug 2: a 1x1 image is not inert
827
+
828
+ A dry run returned `sheet.placeholder()` on `images` -- a 1x1 black frame. The
829
+ Starter wires `images` into `CreateVideo` -> `SaveVideo`. libx264 in yuv420p
830
+ subsamples chroma by 2 and **cannot open a codec context on an odd dimension**,
831
+ so every dry run died in `avcodec_open2` before writing a frame, with a
832
+ traceback naming ComfyUI's video node and nothing of ours.
833
+
834
+ Reproduced in isolation: 1x1 throws, 2x2 encodes. `placeholder()` now takes a
835
+ width and height, floors at 2x2, and rounds odd dimensions down to even; the
836
+ dry run passes the geometry the plan resolved to, so a dry run yields one black
837
+ frame at the real resolution and downstream video nodes are happy.
838
+
839
+ `tools/check_features.py` had asserted `shape == (1, 1, 1, 3)` -- the offline
840
+ suite was *pinning the bug in place*. It now asserts an `encodable()` contract
841
+ instead. **A test that encodes an exact wrong value is worse than no test.**
842
+
843
+ ### `tone_compensate=anchor`, measured on a real chain
844
+
845
+ Three hops, 8 s, 0.3 MP, 6 steps, one seed, one cache. Because the hop store
846
+ writes *before* the tone block runs, flipping tone modes re-grades cached
847
+ renders in ~14 s instead of 164 s -- so the whole sweep is nearly free. Drift is
848
+ hop 3 mean minus hop 1 mean; seams are the step across the join.
849
+
850
+ | `tone_anchor` | drift | seam @192 | seam @362 | flicker |
851
+ |---|---|---|---|---|
852
+ | off | 13.4/255 | 1.2 | 1.9 | 0.4675 |
853
+ | 0.15 | 7.4 (-45%) | 0.9 | 1.0 | 0.4566 |
854
+ | 0.35 | 5.1 (-62%) | 1.4 | 1.6 | 0.4618 |
855
+ | 0.60 | 2.9 (-78%) | 2.1 | 1.8 | 0.4661 |
856
+
857
+ Monotonic, no knee: each step of strength buys ~16pp of drift removal and costs
858
+ ~0.5/255 of seam. Hop 1 is byte-identical across all four, as the design
859
+ requires. **The shipped 0.35 default is defensible and stays.**
860
+
861
+ The propagation claim in section 20 -- that no carry variable is needed --
862
+ is visible in the logs: hop 3's `frame_shift` grew with anchor strength
863
+ (`r+0.0185` -> `r+0.0217` -> `r+0.0256`) because hop 3 measures against hop 2's
864
+ already-corrected tail. It self-propagates, exactly as designed.
865
+
866
+ **Caveat that limits this measurement**: the test scene walks the subject toward
867
+ a bright window, so some of that 13.4/255 is light a real camera would also
868
+ produce. Anchor cannot distinguish motivated light from drift -- that is what
869
+ the per-shot `tone` field is for. A `camera=hold` scene is the run that would
870
+ justify moving off 0.35.
871
+
872
+ ### The seam report node, validated
873
+
874
+ Never executed before this session. Wired into the Starter and checked against
875
+ an independent decode of the mp4: agreement to **±0.07/255** across six seam
876
+ readings under two different hop geometries. Its arithmetic is right.
877
+
878
+ A better result came out of the cross-check. On a 5-hop master the eight largest
879
+ frame-to-frame luma jumps were at f289, f368, f409, f412, f463, f464, f482,
880
+ f497 -- **not one of them a seam**. Every join is quieter than ordinary scene
881
+ motion. That is the pack's central claim, measured.
882
+
883
+ **Trap, and it cost a wrong diagnosis.** 5 hops x 124f and 3 hops x 192f both
884
+ total 532 frames at overlap 22. The node derives hop length from
885
+ `frames`, `hops` and `overlap`, so a wrong `hops` yields a plausible length and
886
+ four confidently wrong seam positions. There is no way for it to know better
887
+ from `images` alone -- but the chain's `info` output carries the real geometry,
888
+ so an optional `info` input that cross-checks would turn this class of mistake
889
+ into an error message. Worth doing.
890
+
891
+ ### `quality=draft` is close to a no-op here
892
+
893
+ Draft's two levers are resolution -> 0.3 MP and steps -> 6. In the turbo regime
894
+ this pack targets, a "final" run is *already* 0.3 MP at 6-8 steps, so the first
895
+ lever does nothing and the second saves one step:
896
+
897
+ - draft, 6 steps: **42.1 s/hop**
898
+ - final, 7 steps: **43.3 / 45.2 / 50.2 / 44.1 s/hop**
899
+
900
+ About 7% apart. Draft only earns its place if final is genuinely heavier --
901
+ 1.0 MP at 14 steps. Kept, because that configuration exists, but it is not the
902
+ fast-preview button it sounds like. `dry_run` is the fast-preview button.
903
+
904
+ ### The rest
905
+
906
+ - **Contact sheet**: correct and genuinely useful on real renders -- first/last
907
+ thumbnails per hop, directives, beat, tone line, seed, `cached`, pin
908
+ mechanism. Legible at 1:1; **not** legible in a node preview, where a 1280px
909
+ sheet scales to ~0.25 and 15px body text renders at ~4px. It is a
910
+ click-to-enlarge document. Fonts left alone deliberately.
911
+ - **`render_through`**: truncates correctly (`rendering hops 1-1 of 2`), and
912
+ re-extending works -- a 3-hop chain extended to 5 loaded hops 1-3 from cache
913
+ and started rendering at hop 4.
914
+ - **Over-delivery lint**: exercised offline; never fired in ComfyUI because both
915
+ shipped plans and the test plan stay clean. Unproven against a real positive.
h3_ref_chain.py CHANGED
@@ -2026,7 +2026,12 @@ class HandTieClips:
2026
  meta={"dry_run": True, "hops": int(n),
2027
  "would_be_frames": int(total_frames),
2028
  "done": True})
2029
- return (_sheet.placeholder(),
 
 
 
 
 
2030
  {"waveform": torch.zeros((1, 2, 1024), dtype=torch.float32),
2031
  "sample_rate": 44100},
2032
  info,
 
2026
  meta={"dry_run": True, "hops": int(n),
2027
  "would_be_frames": int(total_frames),
2028
  "done": True})
2029
+ # Sized to the geometry the chain WOULD have produced, not 1x1:
2030
+ # the images output usually lands in a video encoder, and one black
2031
+ # frame at the real resolution both encodes cleanly and shows the
2032
+ # dimensions the plan resolved to. One frame is not the master --
2033
+ # the 31 GB allocation this mode exists to avoid is untouched.
2034
+ return (_sheet.placeholder(width, height),
2035
  {"waveform": torch.zeros((1, 2, 1024), dtype=torch.float32),
2036
  "sample_rate": 44100},
2037
  info,
js/editor/run_panel.js CHANGED
@@ -38,8 +38,19 @@ const GROUPS = [
38
  "shift_video", "shift_audio"]],
39
  ["join & pin", ["hop_script", "pin_to_qwen", "ref_image_size",
40
  "audio_pin_frames", "pin_renorm", "pin_noise",
41
- "tone_compensate"]],
 
 
 
 
 
 
42
  ["cache", ["cache_hops", "cache_budget_gb"]],
 
 
 
 
 
43
  ];
44
 
45
  /**
 
38
  "shift_video", "shift_audio"]],
39
  ["join & pin", ["hop_script", "pin_to_qwen", "ref_image_size",
40
  "audio_pin_frames", "pin_renorm", "pin_noise",
41
+ // tone_anchor sits next to the mode it modifies: it is
42
+ // read only when tone_compensate is `anchor`, and split
43
+ // across groups nothing says so. Deliberately NOT
44
+ // suppressed in the other modes -- suppression only stops
45
+ // the panel drawing a dial, and an undrawn dial reappears
46
+ // as a raw native widget, which is worse than an inert one.
47
+ "tone_compensate", "tone_anchor"]],
48
  ["cache", ["cache_hops", "cache_budget_gb"]],
49
+ // Added 2026-08-30. 0.4.0 shipped these five on the Python side and never
50
+ // touched js/, so all five fell through to native dials -- the documented
51
+ // fallback doing its job, not a break. Order is the order you reach for
52
+ // them: prove the plan compiles, pick the fidelity, stop short, look.
53
+ ["preview", ["dry_run", "quality", "render_through", "contact_sheet"]],
54
  ];
55
 
56
  /**
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "comfyui-hand-tie-clips"
3
- version = "0.4.0"
4
  description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
5
  readme = "README.md"
6
  license = { file = "LICENSE" }
 
1
  [project]
2
  name = "comfyui-hand-tie-clips"
3
+ version = "0.4.1"
4
  description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
5
  readme = "README.md"
6
  license = { file = "LICENSE" }
sheet.py CHANGED
@@ -100,9 +100,21 @@ def _font(size, bold=False):
100
  return f
101
 
102
 
103
- def placeholder():
104
- """The 1x1 black IMAGE returned when no sheet was built."""
105
- return torch.zeros((1, 1, 1, 3), dtype=torch.float32)
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
 
108
  def _wrap(draw, text, font, max_w):
 
100
  return f
101
 
102
 
103
+ def placeholder(width=16, height=16):
104
+ """The black IMAGE returned when no sheet was built.
105
+
106
+ Even dimensions, minimum 2, are a hard requirement rather than a default.
107
+ This used to be 1x1, which is inert to `SaveImage` but detonates any video
108
+ encoder downstream: libx264 in yuv420p subsamples chroma by 2 and cannot
109
+ open a context whose width or height is odd, so a dry run wired to
110
+ `SaveVideo` -- exactly what the Starter ships -- died in `avcodec_open2`
111
+ before a frame was written, with a traceback naming ComfyUI's video node
112
+ and nothing of ours. A placeholder whose whole job is to keep a graph alive
113
+ must survive the nodes the graph actually contains.
114
+ """
115
+ w = max(2, int(width) & ~1)
116
+ h = max(2, int(height) & ~1)
117
+ return torch.zeros((1, h, w, 3), dtype=torch.float32)
118
 
119
 
120
  def _wrap(draw, text, font, max_w):
tools/check_features.py CHANGED
@@ -1,4 +1,4 @@
1
- r"""Offline regression tests for the 2026-08-30 feature set (CLAUDE.md section 20).
2
 
3
  None of these features could be tested in a browser or against a GPU when they
4
  were written, so this is the only thing standing between them and a silent
@@ -34,6 +34,16 @@ sys.path.insert(0, COMFY)
34
  FAIL = []
35
 
36
 
 
 
 
 
 
 
 
 
 
 
37
  def ck(name, cond, detail=""):
38
  print(" %-4s %-52s %s" % ("ok" if cond else "FAIL", name, detail))
39
  if not cond:
@@ -175,9 +185,13 @@ def main():
175
  ck("builds text-only (dry run shape)",
176
  SH.build([{"hop": 1, "first": None, "last": None, "beat": "x",
177
  "directives": {}}], "t").shape[1] > 20)
178
- ck("empty -> placeholder", tuple(SH.build([]).shape) == (1, 1, 1, 3))
179
  ck("hostile row -> placeholder, no raise",
180
- tuple(SH.build([{"hop": 1, "first": "nope", "beat": "x"}]).shape) == (1, 1, 1, 3))
 
 
 
 
181
  ck("small() shrinks a full frame",
182
  tuple(SH.small(torch.rand(736, 1280, 3)).shape)[0] == SH.THUMB_H)
183
 
@@ -231,7 +245,13 @@ def main():
231
  out, _ = run(tone_compensate="anchor", cache_hops="on")
232
  ck("dry run returns four values", len(out) == 4)
233
  ck("dry run never reached the sampler", True, "asserted by the traps above")
234
- ck("images is the 1x1 placeholder", tuple(out[0].shape) == (1, 1, 1, 3))
 
 
 
 
 
 
235
  ck("audio is silent, not None", out[1]["waveform"].abs().max() == 0)
236
  ck("info carries every compiled prompt", out[2].count("===== hop") == 8)
237
  ck("contact sheet is built on a dry run", out[3].shape[1] > 100)
 
1
+ r"""Offline regression tests for the 2026-08-30 feature set (docs/DEVLOG.md section 20).
2
 
3
  None of these features could be tested in a browser or against a GPU when they
4
  were written, so this is the only thing standing between them and a silent
 
34
  FAIL = []
35
 
36
 
37
+ def encodable(t):
38
+ """An IMAGE a video encoder can actually open: 4-D, one or more frames,
39
+ both dimensions even and >= 2. Checked instead of an exact 1x1 shape
40
+ because the exact shape is what shipped broken."""
41
+ sh = tuple(t.shape)
42
+ return (len(sh) == 4 and sh[0] >= 1 and sh[3] == 3
43
+ and sh[1] >= 2 and sh[2] >= 2
44
+ and sh[1] % 2 == 0 and sh[2] % 2 == 0)
45
+
46
+
47
  def ck(name, cond, detail=""):
48
  print(" %-4s %-52s %s" % ("ok" if cond else "FAIL", name, detail))
49
  if not cond:
 
185
  ck("builds text-only (dry run shape)",
186
  SH.build([{"hop": 1, "first": None, "last": None, "beat": "x",
187
  "directives": {}}], "t").shape[1] > 20)
188
+ ck("empty -> placeholder", encodable(SH.build([])))
189
  ck("hostile row -> placeholder, no raise",
190
+ encodable(SH.build([{"hop": 1, "first": "nope", "beat": "x"}])))
191
+ ck("placeholder floors at 2x2, never 1x1",
192
+ encodable(SH.placeholder(1, 1)) and encodable(SH.placeholder(0, 0)))
193
+ ck("placeholder rounds odd dimensions down to even",
194
+ tuple(SH.placeholder(737, 415).shape) == (1, 414, 736, 3))
195
  ck("small() shrinks a full frame",
196
  tuple(SH.small(torch.rand(736, 1280, 3)).shape)[0] == SH.THUMB_H)
197
 
 
245
  out, _ = run(tone_compensate="anchor", cache_hops="on")
246
  ck("dry run returns four values", len(out) == 4)
247
  ck("dry run never reached the sampler", True, "asserted by the traps above")
248
+ # Was `== (1, 1, 1, 3)`. A 1x1 frame is inert to SaveImage and fatal to
249
+ # every video encoder -- libx264 cannot open a yuv420p context on an odd
250
+ # dimension -- so the Starter's own SaveVideo died on a dry run. The
251
+ # contract is now "encodable, at the geometry the plan resolved to".
252
+ ck("dry-run images is one encodable frame", encodable(out[0]))
253
+ ck("dry-run images carries the planned geometry",
254
+ tuple(out[0].shape) == (1, 736, 1280, 3), str(tuple(out[0].shape)))
255
  ck("audio is silent, not None", out[1]["waveform"].abs().max() == 0)
256
  ck("info carries every compiled prompt", out[2].count("===== hop") == 8)
257
  ck("contact sheet is built on a dry run", out[3].shape[1] > 100)
tools/check_workflows.py CHANGED
@@ -25,8 +25,8 @@ from h3p import plan as PL # noqa: E402
25
  from h3p import h3_ref_chain as H3 # noqa: E402
26
 
27
  CORE = {"UNETLoader", "CLIPLoader", "VAELoader", "CreateVideo", "SaveVideo",
28
- "SaveImage", "Note", "MarkdownNote"}
29
- OURS = {"HandTieClips", "HTCChainPreview"}
30
  # Declared dependencies, not accidents: the turbo stack this node is actually
31
  # run with. Anything outside these three sets is a pack the reader never asked
32
  # for and must not appear in a shipped example.
 
25
  from h3p import h3_ref_chain as H3 # noqa: E402
26
 
27
  CORE = {"UNETLoader", "CLIPLoader", "VAELoader", "CreateVideo", "SaveVideo",
28
+ "SaveImage", "Note", "MarkdownNote", "PreviewAny", "PreviewImage"}
29
+ OURS = {"HandTieClips", "HTCChainPreview", "HTCSeamReport"}
30
  # Declared dependencies, not accidents: the turbo stack this node is actually
31
  # run with. Anything outside these three sets is a pack the reader never asked
32
  # for and must not appear in a shipped example.
tools/notes.py CHANGED
@@ -25,7 +25,7 @@ GREEN = ("#232", "#353") # ComfyUI's green -- card 1 only, so the entry
25
  GROUP = {
26
  "id": 1,
27
  "title": "READ ME -- writing for this node",
28
- "bounding": [-990, -465, 980, 2905],
29
  "color": "#3f789e",
30
  "font_size": 24,
31
  }
@@ -260,14 +260,29 @@ still keys exactly as it will in the full run.
260
  0.3 MP, 6 steps. Enough to read blocking, camera and whether a join lands.
261
  Both values are in the cache key, so a draft never overwrites its final.
262
 
 
 
 
 
 
263
  ---
264
 
265
  Afterwards: **`contact_sheet = on`** gives one row per hop -- first and last
266
  delivered frame, beat, directives, seed, cache hit, tone correction. Wire it
267
  to a Save Image.
268
 
269
- And **H3 Seam Report** takes the chain's `images` and measures every join:
270
- invisible / marginal / visible, plus the chain's cumulative drift.
 
 
 
 
 
 
 
 
 
 
271
  """
272
 
273
  TROUBLE = """\
@@ -285,7 +300,7 @@ TROUBLE = """\
285
  | Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
286
  | The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |
287
  | A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |
288
- | The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. Also restate the light as a positive property in every beat |
289
  | A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |
290
  | A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |
291
  | A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |
@@ -430,7 +445,7 @@ CARDS = [
430
  ("rules", "The rules", [-960, 600], [440, 760], YELLOW, RULES),
431
  ("refs", "References and @tags", [-960, 1420], [440, 640], YELLOW, REFERENCES),
432
  ("directives", "Directives", [-480, -400], [440, 640], YELLOW, DIRECTIVES),
433
- ("trouble", "When it goes wrong", [-480, 280], [440, 840], YELLOW, TROUBLE),
434
- ("check", "Check before you render", [-480, 1160], [440, 560], GREEN, CHECK),
435
- ("author", "Let a model write it", [-480, 1760], [440, 620], YELLOW, AUTHOR),
436
  ]
 
25
  GROUP = {
26
  "id": 1,
27
  "title": "READ ME -- writing for this node",
28
+ "bounding": [-990, -465, 980, 3200],
29
  "color": "#3f789e",
30
  "font_size": 24,
31
  }
 
260
  0.3 MP, 6 steps. Enough to read blocking, camera and whether a join lands.
261
  Both values are in the cache key, so a draft never overwrites its final.
262
 
263
+ It is a **fidelity** lever more than a speed one. Measured: ~42 s/hop against
264
+ ~45 s/hop at 7 steps. If you already render at 0.3 MP and 6-8 steps it saves
265
+ almost nothing -- `dry_run` is the fast button. Draft earns its place when your
266
+ final is genuinely heavier, 1.0 MP at 14 steps.
267
+
268
  ---
269
 
270
  Afterwards: **`contact_sheet = on`** gives one row per hop -- first and last
271
  delivered frame, beat, directives, seed, cache hit, tone correction. Wire it
272
  to a Save Image.
273
 
274
+ And **H3 Seam Report** -- already wired on this canvas -- takes the chain's
275
+ `images` and measures every join: invisible / marginal / visible, plus the
276
+ chain's cumulative drift.
277
+
278
+ **Set `hops` to your shot count.** It derives hop length from frames, hops and
279
+ overlap, so a wrong `hops` does not error -- it returns a plausible length and
280
+ puts every seam at a frame where no join exists.
281
+
282
+ A single reading includes whatever the scene did across the cut, so it is an
283
+ upper bound. To isolate the seam, A/B two renders from the same seed and cache
284
+ changing only `tone_compensate`; the hop store writes before tone is applied,
285
+ so the second run re-grades from cache in seconds.
286
  """
287
 
288
  TROUBLE = """\
 
300
  | Two characters' faces merge | Both declared as the same `subject` | One subject number per person |
301
  | The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |
302
  | A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |
303
+ | The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. `tone_anchor=0.35` removed ~60% of measured drift; 0.6 removes ~78% and costs ~0.5/255 more seam step. Also restate the light as a positive property in every beat |
304
  | A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |
305
  | A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |
306
  | A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |
 
445
  ("rules", "The rules", [-960, 600], [440, 760], YELLOW, RULES),
446
  ("refs", "References and @tags", [-960, 1420], [440, 640], YELLOW, REFERENCES),
447
  ("directives", "Directives", [-480, -400], [440, 640], YELLOW, DIRECTIVES),
448
+ ("trouble", "When it goes wrong", [-480, 280], [440, 900], YELLOW, TROUBLE),
449
+ ("check", "Check before you render", [-480, 1220], [440, 800], GREEN, CHECK),
450
+ ("author", "Let a model write it", [-480, 2060], [440, 620], YELLOW, AUTHOR),
451
  ]
workflows/HandTieClips_Starter.json CHANGED
@@ -1,8 +1,8 @@
1
  {
2
  "id": "h3-ref2va-chain",
3
  "revision": 1,
4
- "last_node_id": 35,
5
- "last_link_id": 19,
6
  "nodes": [
7
  {
8
  "id": 2,
@@ -529,7 +529,8 @@
529
  "type": "IMAGE",
530
  "slot_index": 0,
531
  "links": [
532
- 5
 
533
  ]
534
  },
535
  {
@@ -1088,6 +1089,118 @@
1088
  "HandTieClips/contact_sheet"
1089
  ]
1090
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
  {
1092
  "id": 20,
1093
  "type": "MarkdownNote",
@@ -1100,7 +1213,7 @@
1100
  940
1101
  ],
1102
  "flags": {},
1103
- "order": 100,
1104
  "mode": 0,
1105
  "inputs": [],
1106
  "outputs": [],
@@ -1126,7 +1239,7 @@
1126
  760
1127
  ],
1128
  "flags": {},
1129
- "order": 101,
1130
  "mode": 0,
1131
  "inputs": [],
1132
  "outputs": [],
@@ -1152,7 +1265,7 @@
1152
  640
1153
  ],
1154
  "flags": {},
1155
- "order": 102,
1156
  "mode": 0,
1157
  "inputs": [],
1158
  "outputs": [],
@@ -1178,7 +1291,7 @@
1178
  640
1179
  ],
1180
  "flags": {},
1181
- "order": 103,
1182
  "mode": 0,
1183
  "inputs": [],
1184
  "outputs": [],
@@ -1201,10 +1314,10 @@
1201
  ],
1202
  "size": [
1203
  440,
1204
- 840
1205
  ],
1206
  "flags": {},
1207
- "order": 104,
1208
  "mode": 0,
1209
  "inputs": [],
1210
  "outputs": [],
@@ -1213,7 +1326,7 @@
1213
  "htc_card": "trouble"
1214
  },
1215
  "widgets_values": [
1216
- "## When it goes wrong\n\n| symptom | cause | fix |\n|---|---|---|\n| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |\n| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |\n| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |\n| Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |\n| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |\n| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |\n| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |\n| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |\n| The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |\n| A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |\n| The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. Also restate the light as a positive property in every beat |\n| A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |\n| A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |\n| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |\n| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |\n| The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |\n| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |\n| A deliberately dark scene keeps being brightened | `anchor` cannot tell intent from drift | `\"tone\": \"rebase\"` on that scene's first shot |\n| You cannot tell which hop broke | 114 s is a lot to scrub | `contact_sheet=on` -- one row per hop, first and last frame |\n| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |\n\nEvery error message names the shot or the reference it came from. Nothing\nguesses."
1217
  ],
1218
  "color": "#432",
1219
  "bgcolor": "#653"
@@ -1223,14 +1336,14 @@
1223
  "type": "MarkdownNote",
1224
  "pos": [
1225
  -480,
1226
- 1160
1227
  ],
1228
  "size": [
1229
  440,
1230
- 560
1231
  ],
1232
  "flags": {},
1233
- "order": 105,
1234
  "mode": 0,
1235
  "inputs": [],
1236
  "outputs": [],
@@ -1239,7 +1352,7 @@
1239
  "htc_card": "check"
1240
  },
1241
  "widgets_values": [
1242
- "## Check before you render\n\nA chain is minutes to hours. These three dials cost seconds and catch almost\neverything.\n\n### `dry_run = on`\n\nCompiles every hop's prompt and stops. No model, no sampler. Read the result\non **`info`**, or as a page on **`contact_sheet`**.\n\nWhat you wrote is not what the encoder gets -- directives, continuation\nscaffolding, the identity lock and the `<Picture N>` citations are all\nassembled at render time. This is the only way to see the real thing first.\nEvery plan warning prints on the way through, too.\n\n### `render_through = N`\n\nStop after N hops. With `cache_hops=on`, 3 then 5 then 8 builds the chain up\nand only ever renders the new hops. The plan is **not** truncated -- shot 4\nstill keys exactly as it will in the full run.\n\n### `quality = draft`\n\n0.3 MP, 6 steps. Enough to read blocking, camera and whether a join lands.\nBoth values are in the cache key, so a draft never overwrites its final.\n\n---\n\nAfterwards: **`contact_sheet = on`** gives one row per hop -- first and last\ndelivered frame, beat, directives, seed, cache hit, tone correction. Wire it\nto a Save Image.\n\nAnd **H3 Seam Report** takes the chain's `images` and measures every join:\ninvisible / marginal / visible, plus the chain's cumulative drift.\n"
1243
  ],
1244
  "color": "#232",
1245
  "bgcolor": "#353"
@@ -1249,14 +1362,14 @@
1249
  "type": "MarkdownNote",
1250
  "pos": [
1251
  -480,
1252
- 1760
1253
  ],
1254
  "size": [
1255
  440,
1256
  620
1257
  ],
1258
  "flags": {},
1259
- "order": 106,
1260
  "mode": 0,
1261
  "inputs": [],
1262
  "outputs": [],
@@ -1407,6 +1520,30 @@
1407
  35,
1408
  0,
1409
  "IMAGE"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1410
  ]
1411
  ],
1412
  "groups": [
@@ -1417,7 +1554,7 @@
1417
  -990,
1418
  -465,
1419
  980,
1420
- 2905
1421
  ],
1422
  "color": "#3f789e",
1423
  "font_size": 24
 
1
  {
2
  "id": "h3-ref2va-chain",
3
  "revision": 1,
4
+ "last_node_id": 38,
5
+ "last_link_id": 22,
6
  "nodes": [
7
  {
8
  "id": 2,
 
529
  "type": "IMAGE",
530
  "slot_index": 0,
531
  "links": [
532
+ 5,
533
+ 20
534
  ]
535
  },
536
  {
 
1089
  "HandTieClips/contact_sheet"
1090
  ]
1091
  },
1092
+ {
1093
+ "id": 36,
1094
+ "type": "HTCSeamReport",
1095
+ "pos": [
1096
+ 2991.7,
1097
+ 1100.0
1098
+ ],
1099
+ "size": [
1100
+ 340,
1101
+ 130
1102
+ ],
1103
+ "flags": {},
1104
+ "order": 107,
1105
+ "mode": 0,
1106
+ "title": "H3 Seam Report - measure the joins",
1107
+ "inputs": [
1108
+ {
1109
+ "localized_name": "images",
1110
+ "name": "images",
1111
+ "type": "IMAGE",
1112
+ "link": 20
1113
+ }
1114
+ ],
1115
+ "outputs": [
1116
+ {
1117
+ "localized_name": "report",
1118
+ "name": "report",
1119
+ "type": "STRING",
1120
+ "slot_index": 0,
1121
+ "links": [
1122
+ 21
1123
+ ]
1124
+ },
1125
+ {
1126
+ "localized_name": "chart",
1127
+ "name": "chart",
1128
+ "type": "IMAGE",
1129
+ "slot_index": 1,
1130
+ "links": [
1131
+ 22
1132
+ ]
1133
+ }
1134
+ ],
1135
+ "properties": {
1136
+ "Node name for S&R": "HTCSeamReport"
1137
+ },
1138
+ "widgets_values": [
1139
+ 2,
1140
+ 22,
1141
+ 6
1142
+ ]
1143
+ },
1144
+ {
1145
+ "id": 37,
1146
+ "type": "PreviewAny",
1147
+ "pos": [
1148
+ 3400.0,
1149
+ 1100.0
1150
+ ],
1151
+ "size": [
1152
+ 400,
1153
+ 300
1154
+ ],
1155
+ "flags": {},
1156
+ "order": 108,
1157
+ "mode": 0,
1158
+ "title": "Seam numbers",
1159
+ "inputs": [
1160
+ {
1161
+ "localized_name": "source",
1162
+ "name": "source",
1163
+ "type": "*",
1164
+ "link": 21
1165
+ }
1166
+ ],
1167
+ "outputs": [],
1168
+ "properties": {
1169
+ "Node name for S&R": "PreviewAny",
1170
+ "cnr_id": "comfy-core"
1171
+ },
1172
+ "widgets_values": []
1173
+ },
1174
+ {
1175
+ "id": 38,
1176
+ "type": "PreviewImage",
1177
+ "pos": [
1178
+ 3400.0,
1179
+ 1450.0
1180
+ ],
1181
+ "size": [
1182
+ 400,
1183
+ 320
1184
+ ],
1185
+ "flags": {},
1186
+ "order": 109,
1187
+ "mode": 0,
1188
+ "title": "Seam chart",
1189
+ "inputs": [
1190
+ {
1191
+ "localized_name": "images",
1192
+ "name": "images",
1193
+ "type": "IMAGE",
1194
+ "link": 22
1195
+ }
1196
+ ],
1197
+ "outputs": [],
1198
+ "properties": {
1199
+ "Node name for S&R": "PreviewImage",
1200
+ "cnr_id": "comfy-core"
1201
+ },
1202
+ "widgets_values": []
1203
+ },
1204
  {
1205
  "id": 20,
1206
  "type": "MarkdownNote",
 
1213
  940
1214
  ],
1215
  "flags": {},
1216
+ "order": 110,
1217
  "mode": 0,
1218
  "inputs": [],
1219
  "outputs": [],
 
1239
  760
1240
  ],
1241
  "flags": {},
1242
+ "order": 111,
1243
  "mode": 0,
1244
  "inputs": [],
1245
  "outputs": [],
 
1265
  640
1266
  ],
1267
  "flags": {},
1268
+ "order": 112,
1269
  "mode": 0,
1270
  "inputs": [],
1271
  "outputs": [],
 
1291
  640
1292
  ],
1293
  "flags": {},
1294
+ "order": 113,
1295
  "mode": 0,
1296
  "inputs": [],
1297
  "outputs": [],
 
1314
  ],
1315
  "size": [
1316
  440,
1317
+ 900
1318
  ],
1319
  "flags": {},
1320
+ "order": 114,
1321
  "mode": 0,
1322
  "inputs": [],
1323
  "outputs": [],
 
1326
  "htc_card": "trouble"
1327
  },
1328
  "widgets_values": [
1329
+ "## When it goes wrong\n\n| symptom | cause | fix |\n|---|---|---|\n| The clip cuts to the reference photo in its last seconds | The beat finished before the frames did | Set `tail`, give the beat enough to do |\n| A stray gesture or line in the closing second | `tail: ongoing` on the final shot | `settle` or `hold` |\n| She keeps talking after you asked for quiet | You named the ending | Pose plus a sound |\n| Dialogue continues into hops that have none written | The hop before ended on speech | Land the line early; non-verbal action into the seam; a sound bed on every quiet hop |\n| A character walks between two rooms and one morphs into the other | `continuous` across a location change | `match_cut` |\n| Silence renders as speech | Silence written as an absence | Name room tone, a fridge, a distant car |\n| Ambience is a five-second hiss | Broadband wording | Narrowband, or one discrete event |\n| Two characters' faces merge | Both declared as the same `subject` | One subject number per person |\n| The face becomes a different person partway through | A hop scheduled with no face plate. `locked` holds a face that is still right; only a plate rebuilds one that is gone, and the drift never self-corrects | Put the face ref on **every** hop |\n| A stylised plan renders photoreal | The node's hop-1 establishing line asserts live action | Name the medium in shot 1's first sentence, or clear the `establish` widget |\n| The film gets darker every hop | Each hop dims across its own frames and hands the darker tail on. Seam correction cannot see it | `tone_compensate=anchor`. `tone_anchor=0.35` removed ~60% of measured drift; 0.6 removes ~78% and costs ~0.5/255 more seam step. Also restate the light as a positive property in every beat |\n| A location introduced mid-plan drifts | It has no place plate of its own | Plate it, on the hop it arrives and every hop after |\n| A prop or garment changes colour or material | Named with no adjective, so each hop's encode is free to invent one | Repeat the adjective in every beat, and state it as a property in `context` |\n| A hard cut ~1.5 s into a hop, mid-scene | The previous hop over-delivered, so this beat instructs what its own live frame already did | One movement per hop; write the next beat so it is true from either ending |\n| A continuous join reads as a cut | Framing change with `camera: hold` | Earn it on the move, or `framing: keep` |\n| The run stops, naming a reference | That row's picture is not in `h3_refs` | Drop the file on the row, or clear its picture to run without it |\n| A reference has no effect on some hop | Its `shots` list leaves that hop out | List every hop the picture should ride |\n| A deliberately dark scene keeps being brightened | `anchor` cannot tell intent from drift | `\"tone\": \"rebase\"` on that scene's first shot |\n| You cannot tell which hop broke | 114 s is a lot to scrub | `contact_sheet=on` -- one row per hop, first and last frame |\n| A pasted plan is rejected as invalid JSON | Escaped double quotes mangled in transit | Single quotes around dialogue |\n\nEvery error message names the shot or the reference it came from. Nothing\nguesses."
1330
  ],
1331
  "color": "#432",
1332
  "bgcolor": "#653"
 
1336
  "type": "MarkdownNote",
1337
  "pos": [
1338
  -480,
1339
+ 1220
1340
  ],
1341
  "size": [
1342
  440,
1343
+ 800
1344
  ],
1345
  "flags": {},
1346
+ "order": 115,
1347
  "mode": 0,
1348
  "inputs": [],
1349
  "outputs": [],
 
1352
  "htc_card": "check"
1353
  },
1354
  "widgets_values": [
1355
+ "## Check before you render\n\nA chain is minutes to hours. These three dials cost seconds and catch almost\neverything.\n\n### `dry_run = on`\n\nCompiles every hop's prompt and stops. No model, no sampler. Read the result\non **`info`**, or as a page on **`contact_sheet`**.\n\nWhat you wrote is not what the encoder gets -- directives, continuation\nscaffolding, the identity lock and the `<Picture N>` citations are all\nassembled at render time. This is the only way to see the real thing first.\nEvery plan warning prints on the way through, too.\n\n### `render_through = N`\n\nStop after N hops. With `cache_hops=on`, 3 then 5 then 8 builds the chain up\nand only ever renders the new hops. The plan is **not** truncated -- shot 4\nstill keys exactly as it will in the full run.\n\n### `quality = draft`\n\n0.3 MP, 6 steps. Enough to read blocking, camera and whether a join lands.\nBoth values are in the cache key, so a draft never overwrites its final.\n\nIt is a **fidelity** lever more than a speed one. Measured: ~42 s/hop against\n~45 s/hop at 7 steps. If you already render at 0.3 MP and 6-8 steps it saves\nalmost nothing -- `dry_run` is the fast button. Draft earns its place when your\nfinal is genuinely heavier, 1.0 MP at 14 steps.\n\n---\n\nAfterwards: **`contact_sheet = on`** gives one row per hop -- first and last\ndelivered frame, beat, directives, seed, cache hit, tone correction. Wire it\nto a Save Image.\n\nAnd **H3 Seam Report** -- already wired on this canvas -- takes the chain's\n`images` and measures every join: invisible / marginal / visible, plus the\nchain's cumulative drift.\n\n**Set `hops` to your shot count.** It derives hop length from frames, hops and\noverlap, so a wrong `hops` does not error -- it returns a plausible length and\nputs every seam at a frame where no join exists.\n\nA single reading includes whatever the scene did across the cut, so it is an\nupper bound. To isolate the seam, A/B two renders from the same seed and cache\nchanging only `tone_compensate`; the hop store writes before tone is applied,\nso the second run re-grades from cache in seconds.\n"
1356
  ],
1357
  "color": "#232",
1358
  "bgcolor": "#353"
 
1362
  "type": "MarkdownNote",
1363
  "pos": [
1364
  -480,
1365
+ 2060
1366
  ],
1367
  "size": [
1368
  440,
1369
  620
1370
  ],
1371
  "flags": {},
1372
+ "order": 116,
1373
  "mode": 0,
1374
  "inputs": [],
1375
  "outputs": [],
 
1520
  35,
1521
  0,
1522
  "IMAGE"
1523
+ ],
1524
+ [
1525
+ 20,
1526
+ 7,
1527
+ 0,
1528
+ 36,
1529
+ 0,
1530
+ "IMAGE"
1531
+ ],
1532
+ [
1533
+ 21,
1534
+ 36,
1535
+ 0,
1536
+ 37,
1537
+ 0,
1538
+ "STRING"
1539
+ ],
1540
+ [
1541
+ 22,
1542
+ 36,
1543
+ 1,
1544
+ 38,
1545
+ 0,
1546
+ "IMAGE"
1547
  ]
1548
  ],
1549
  "groups": [
 
1554
  -990,
1555
  -465,
1556
  980,
1557
+ 3200
1558
  ],
1559
  "color": "#3f789e",
1560
  "font_size": 24