Hand Tie Clips -- engineering log
The dated record behind CLAUDE.md, which is the architecture
brief. Everything here is history: what was built on a given day, what it
measured, and what it got wrong. Later sections correct earlier ones -- read to
the end of a thread before trusting the top of it.
Numbering starts at 8 because sections 1-7 became the brief.
8. References are files, not wires (2026-08-28)
The node had 16 sockets and twelve of them were user media: ref_image_1..9,
reference_video, voice, start_image. They occupied roughly 340px down the
left of the node before the editor started, each needing its own Load Image.
They are gone. The node now has five: model, clip, vae, audio_vae,
continuity_state.
A reference names a file under <ComfyUI input>/h3_refs. media.py owns
both halves of that:
resolve(name)is the only thing that turns a name into a path, and it is used by the upload route and the loaders alike. Basename only,normpath, then a prefix check against the reference directory, then an extension whitelist. Verified against absolute paths,../traversal, and traversal carrying a legal extension — all refused.- The loaders return exactly what the sockets delivered — float
[N,H,W,3]in 0..1 — so_ref_frames' resize,_collect_ref_images' dense-pack andstore.tensor_digest's cache keying are all unchanged. That is why this was a small diff rather than a rewrite.
Pixels never enter a widget. Only the basename is stored. PromptMasterLD
measured 1.68 MB of widget value for nine base64 thumbnails and ComfyUI then
failed to save the workflow at all; previews are /view?...&subfolder=h3_refs
URLs, which cost nothing to rebuild and survive a reload. /view also brings
Range support, which a <video> needs to seek — so no thumbnail route was
needed.
IS_CHANGED is new and load-bearing. ComfyUI caches a node's output on its
inputs, and a filename is a stable input even when the bytes behind it change:
overwrite face.png and the previous render would be served. It hashes
name:mtime for every referenced file. Deliberately not float("nan") —
that is the blunt version PromptMasterLD's studio node uses, and it would force
a full re-render of an expensive node on every queue.
Widget order is part of the saved-workflow format. widgets_values is a
positional array -- ComfyUI restores value[i] into widget[i] and never
looks at the name. The three *_file widgets were first added at the top of
optional, which shifted hop_script..tone_compensate by +3; every workflow
saved before that change then loaded audio_pin_frames' integer into ref_plan
and the editor died with (text || "").trim is not a function on load. That
throw was the lucky part -- hop_script, shot_plan and tone_compensate were
being silently misassigned too, and only the type mismatch made any of it
visible.
New widgets go at the bottom of optional, always. Old workflows are then
short rather than misaligned, and the new widget takes its default. There is
an append-only marker comment in INPUT_TYPES saying so. Note seed costs
two array slots, not one: the frontend appends control_after_generate
right after it.
Both plan parsers now coerce (String(text ?? "")) and reject non-object JSON
instead of dereferencing it, so a future misalignment shows an empty JSON tab
rather than aborting the whole workflow load.
The hop cache needed no change at all. chain_salt already digests the
actual tensors, so different pixels behind the same filename still move the key.
Legacy plans cannot be migrated automatically. An old ref_image_N held a
tensor from a Load Image; there is no filename to recover. refs.py keeps the
authored slot as legacy_slot, parse_ref_plan does not raise on it, and
both the rail row and check() say "was wired to ref_image_3 — pick its
picture". Failing the parse would have stopped the editor opening the very plan
the author needs to repair.
9. Presentation (2026-08-28)
The node collapsed on first click and stayed collapsed. Two causes, both in
installHeightGuard (js/editor/widget_utils.js), both ported from
PromptMasterLD/js/claude_prompt.js:6285-6370:
computeLayoutSize().minWidthreported a constant. The layout pass re-reads it on every recompute — selecting the node is enough — and faithfully re-declares the node at its stated minimum. It now tracksnode.size[0]. Safe against feedback:max()against a constant is a fixpoint, not an accumulator, and width never feeds height.- A stale
widget.widthshadowsnode.widthforever after load, because ComfyUI's DOM-widget position updater reads(widget.width ?? node.width). A live getter with a dropped setter makes the stale value unrepresentable.
Two more fixes alongside: node.computeSize()[0] is floored at the panel width
(with no widget declaring one, LiteGraph falls back to NODE_WIDTH * 1.5 = 210
and every resize command is free to crush the panel), and domWidget.computeSize
now answers a width-passing caller with the minimum height and a
no-argument caller with the live height. Reporting the live height to both
pins the resize-drag floor to the current height, so the node could only ever
grow.
chrome()'s memo key was stale by construction. It keyed on
inputs|outputs|widgets.length, none of which change when a widget is hidden
— so every panel height computed after applyVisibility used a chrome
measurement taken before it. The hidden count is now part of the key.
The reference rail was permanently crushed, and its rescue was dead code.
The 7-track grid needs ~536px and the node offers ~510px at NODE_WIDTH 560.
There was a @container (max-width: 460px) block written to relieve it — but
nothing in the codebase declared container-type, so the query had no
containment context and never matched. .h3e-section now declares
container-type: inline-size, and there are two breakpoints.
The palette committed to one look. It used to derive surfaces from the host
theme via color-mix() while hardcoding every accent — the intent was
light-theme safety, but --h3-bg mixed toward #111827 and --h3-sunken
toward #000, so a light theme got dark blue-grey islands anyway. It is now the
PromptMasterLD .ldp-root system: #0a0a0a, one hairline #2a2a2a, zero
radius, one accent #e8ff47. Changing --h3-accent moves the whole panel.
Emphasis inverts (accent fill, --h3-on-accent ink) rather than tinting,
because an acid accent at 30% behind unchanged text is olive mud.
A cascade trap worth remembering: the override block was first inserted before
the reference-rail section, so .h3e-chip-on, .h3e-inactive .h3e-ord and
.h3e-subj-badge all kept winning on source order. Overrides live at the end of
the sheet now. Without a browser, a token audit script is the only thing that
catches this class of bug.
The panel did not fill the node, and the mirror was why. installHeightGuard
kept an independent _h, updated through an onResize hook, so that the
arrange pass could not feed its own growth. Measured during a drag: onResize
and setSize each fired 57 times while _h sat at 876 and node.size[1]
climbed past 1400. measuring was not stuck, which left one gate --
Array.isArray(size). This frontend's node.size is not a plain Array, so
every write was skipped and _h held the install-time height forever. A 1911px
node had a 742px panel.
The repair was to delete the mirror, not fix the hook. _h existed only to
break the arrange loop, and
panelHeight() = max(minHeight, node.size[1] - chrome() - SLACK) // SLACK 8
breaks it outright: the pass wants panelTop + panelHeight + 4, and panelTop
is chrome minus the node's bottom padding, so a panel of exactly size - chrome
asks for up to 4px more than the node has on every frame -- the ~130px/frame
runaway the header comment records. With slack the inequality holds, the loop
settles, and the node's height simply is the panel's height. Nothing to keep
in sync, no hook to get wrong. sync() now only ever grows a too-short node;
the height is the user's to choose.
RUN is pinned and always open. .h3e-root used to be the scroll container
with all four sections inside it, which put RUN below the script -- out of view
on any workflow with more than two shots, and it is the section touched on every
queue. The root is now a flex column holding .h3e-scroll (the authoring
sections) and RUN outside it.
Two flex details, both of which cost a round trip to learn:
.h3e-scrollneedsmin-height: 0. A flex item defaults tomin-height: autoand refuses to shrink below its content, which pushes RUN off the bottom of the node instead of scrolling..h3e-runmust beflex: 0 0 auto, never0 1 auto. Flex divides a deficit in proportion to each item's content height. The scroller's content is far taller, so a shrinkable RUN loses most of the contest and clips its lower groups. The scroller absorbs all the shrinking; itsmin-height: 160pxis the floor that stops RUN owning the panel and RUN'smax-height: 55%is the ceiling.
Always-open removed the only moment RUN re-read its widgets, so it now resyncs
on api's promptQueued -- the client-side event that fires after
control_after_generate has bumped the seed, and the same one ComfyUI's own
change tracker uses. The listener is dropped in node.onRemoved.
Not done: PromptMasterLD's --fsc UI-scale multiplier, which makes every
dimension calc(Npx * var(--fsc)) and puts a zoom slider on the panel. It is
mechanical churn across every rule in the sheet and was not worth doing blind.
10. Ported for public use (2026-08-29)
The prompting craft was written down and the pack was made installable by a stranger. Four things, in the order they mattered.
The shipped example workflows were broken. All four in workflows/ predated
the 2026-08-28 socket removal: twelve dead media inputs each, three LoadImage
nodes, and 21 widget values against a 28-widget node. Loading one is a new
user's first action, so this outranked any amount of documentation. They are
quarantined in _disabled_custom_nodes/h3_legacy_workflows/ (the pack is not
under its own version control -- deleting would have been unrecoverable) and
replaced by two built from the verified H3_Stress_6x7 structure:
HandTieClips_Starter.json-- two hops, no references at all, runs the moment the loaders are pointed at files. The empty register is deliberate: an@tagwhose picture is missing is a hard error inresolve_tags, so a starter that shipped with tags would fail on first queue for everyone.HandTieClips_Showcase.json-- the six-hop continuity test, with the three reference filenames generalised.
Both use core ComfyUI plus this pack only. The dev workflow reaches KJNodes
(ModelPreviewOverrideKJ, MiniMaxLowVRAMAttention) and PlagueKind
(LTX_lora_loader, H3SLAAttention, H3AdaLNLoRAFix); an example that fails
to load because of a pack the reader never asked for teaches nothing. The
speed stack is documented, not shipped.
A declared-but-inactive @tag reported the wrong cause. check() already
warned correctly that a picture was missing, but the run then died on
resolve_tags with "unknown reference '@kitchen'" -- pointing at the beat's
spelling, the one thing that was right. resolve_tags now takes declared
(every tag in the register, active this hop or not) and separates the two
failures. The old two-argument behaviour is unchanged when declared is
omitted.
PROMPTING.md and prompt_pack/. The craft rules were spread through
README prose; they are now a standalone guide, and a copy-paste system prompt
that gets a language model to emit valid plans. Two files under prompt_pack/
are generated, never hand-written:
tools/gen_schema.pybuildsSCHEMA.jsonfromdirectives.VOCAB,refs.RETENTIONand the duration table, and asserts againstplan._SHOT_KEYS,refs.REF_FIELDSandrefs.SUBJECT_FIELDS. Add a camera move and the schema follows; add a shot field and the generator fails loudly rather than emitting a stale schema.--checkis the CI form.tools/gen_example.pybuildsEXAMPLE_6_HOP.mdfrom the showcase workflow, including its hop/reference table, so the worked example and the shipped workflow cannot disagree.
Template patterns in the editor. js/editor/templates.js plus a
Templates button in the SCRIPT header. They append, never replace --
replacing would be the one destructive control on the node, and stacking is how
a chain is actually built. freeId() mints the lowest unused sN because id
is the hop cache's pointer and two shots sharing one would make locked reuse
the wrong render.
No template contains an @tag, for the same reason the starter workflow has no
references. tools/check_templates.py extracts the patterns out of the JS
and runs them through plan.parse_plan and plan.check_coherence, and lints
every beat for negation -- a template that produced a plan the node rejects
would be worse than no templates, because a first-time author would blame their
own writing. It caught two: a beat reading "They stop at the window" (naming a
cessation, which is law 2) and a line of dialogue containing "did not".
That second one is worth recording as an open question: whether the additive
prompt bites inside quoted dialogue was never tested. The templates avoid it,
and PROMPTING.md says plainly that this is untested rather than inventing a
rule.
11. What chain_00057 taught (2026-08-29)
The six-hop showcase was rendered for the first time. Three failures, and the one that mattered was a code gap the documentation had already claimed was fixed.
locked and context reached hop 1 only. The register's whole promise is
that a subject's continuity text carries identity across a hop where the
photograph is absent. It did not. subject_prose is called under if i == 0,
and _identity_lock returns "" when no subject-bearing ref is active -- so on
the showcase, hops 2, 3, 5 and 6 carried no identity text of any kind, and
hop 5, scheduled with no references at all, lost the character entirely.
Identity was riding on the pinned frames alone, which is the exact failure the
register exists to prevent.
refs.continuity_line() now emits that text on every hop 2+, and
_assemble_next injects it between the lock and the live-frame citation. The
reason it was suppressed in the first place is real and is preserved: naming
pictures on a pin-only hop sent the encoder back to the plates (chain_00034 --
commercial kitchen, grey shirt, no apron). So the new line carries no
<Picture N> and no <Subject N>. It is a description of what stays the
same, not a citation of anything. <Subject N> is excluded specifically because
there is no subject_definitions block on a continuation hop to bind it to.
Note this changes the assembled block on hop 2+ of every chain with a register, so it invalidates the hop cache. That is correct, not a regression.
Dialogue propagated through five hops. Shot 1 ended on its spoken line with
tail: ongoing. The audio pin carries the previous hop's tail, so the last
second of hop 1 -- speech -- opened hop 2, and "action is still underway" was
the closing instruction; the model satisfied it with the action it could hear.
Nothing in hops 2-6 gave the audio anywhere else to go.
This is the guide's own law 3, broken by the plan written to demonstrate it. The
fix is authorial, not code: land the line mid-hop and leave a non-verbal
action running into the seam (the knife on the board), and give every
dialogue-free hop a narrowband sound of its own. Both PROMPTING.md and the
authoring prompt now carry this as a named rule, and the shipped templates were
rewritten to obey it.
join: continuous across a location change morphed one room into the other.
Hop 6 walked back from the hallway and the kitchen appeared mid-turn. A
continuous join asks for one unbroken take between two different rooms, which
is not a thing. match_cut is what a walk through a doorway is. The beat also
read "steps into @kitchen" -- the container phrasing the README warns produces a
composite of the photograph -- and now names the counter in the kitchen.
Hop 5's beat is unchanged in the revised plan on purpose: it is the measurement, and changing it would forfeit the comparison.
The revised plan is in the shipped showcase and in
user/default/workflows/H3_Stress_6x7_v2.json; the original that produced
chain_00057 is left alone so the A/B survives.
12. Renamed to Hand Tie Clips (2026-08-29)
The pack was ComfyUI-H3-Ref-Chain. It is now ComfyUI-Hand-Tie-Clips, and the
four registered ids moved with it:
| was | is |
|---|---|
H3RefChain |
HandTieClips |
H3ContinuityState |
HTCContinuityState |
H3ChainPreview |
HTCChainPreview |
H3ToneCompensate |
HTCToneCompensate |
Display names are unchanged (H3 Ref2VA Chain, H3 Chain Preview, ...) because
the pack only drives MiniMax H3 checkpoints and a name that hides that costs
somebody an afternoon. The menu category is Hand Tie Clips. TAG -- and so the
console prefix -- is [HandTieClips].
The old ids are still registered, as DEPRECATED subclasses. A type id is
what every saved .json carries, and an unregistered one is a red missing-node
box, not a warning. A plain alias in NODE_CLASS_MAPPINGS would have worked but
listed each node twice in search: ComfyUI falls back to the mapping key when
NODE_DISPLAY_NAME_MAPPINGS has no entry for it. Subclassing and setting
DEPRECATED = True gets both -- server.py:783 publishes deprecated: True,
and the frontend's Comfy.Node.ShowDeprecated (off by default) keeps it out of
search while leaving it fully functional in workflows that name it.
The JS had to learn both ids or the aliases would have been worse than
useless. js/h3_ref_chain_ui.js and js/h3_chain_preview.js each compared
nodeData.name against a single string; a legacy node would have loaded with no
editor at all, which looks exactly like the rename having broken the pack. Both
now test membership of a Set. js/h3_chain_preview.js needs it twice: once
for its own type, once for CHAIN_TYPES, which is how a preview walks back up
images to find the chain feeding it.
What deliberately kept the h3 naming, and must not be "finished" later:
module filenames (h3_ref_chain.py), the h3e- CSS class prefix and --h3-
tokens (515 occurrences -- one missed class silently breaks styling), the
/h3_ref_chain/* routes, the h3_refchain_preview event name, and
input/h3_refs. Renaming that folder would orphan every reference photo already
on disk.
The two shipped workflows moved to HandTieClips_Starter.json /
HandTieClips_Showcase.json and were rewritten onto the new ids, with their
SaveVideo prefix now video/HANDTIECLIPS/chain. The three under
user/default/workflows/ were left on the legacy ids on purpose: re-running
H3_Stress_6x7_v2.json is then a live test of the alias path, and their existing
renders stay together under output/video/H3REFCHAIN/.
13. The on-canvas board (2026-08-29)
workflows/HandTieClips_Starter.json carries six MarkdownNote cards to the
left of the loaders, wrapped in a group titled READ ME. The text lives in
tools/notes.py and is written into the workflow by tools/build_notes.py.
Why on the canvas: the craft was in PROMPTING.md, prompt_pack/ and the
Templates panel, and all three require leaving the graph. The rules that decide
whether a first render works are needed while beats are being written, which is
on the canvas. The cards are a condensation, not a copy -- PROMPTING.md
stays the authority and every card says so.
Mechanics worth not rediscovering:
MarkdownNoteis a core virtual node. The frontend renders it withmarkedatgfm: trueand sanitises with DOMPurify, so headings, GFM tables, bold and code fences all work. It needs frontend >= ~1.16.- Cards are identified by
properties.htc_card, which is what makesbuild_notes.pyidempotent -- it drops marked nodes before writing, so re-running replaces the board instead of stacking a second copy. extra.dsis restored on load, not fitted. Without setting it, a board at negative x sits off-screen and is never found. Screen is(world + offset) * scale, so the offset is what brings it into view.- Group serialisation is
{id?, title, bounding:[x,y,w,h], color?, font_size?, locked?}-- confirmed against the frontend's own zod schema, not guessed.
widgets_values_named, found while doing this. Both shipped workflows
carried a stale copy: chains: 3, duration: 10 s,
control_after_generate: randomize, and a legacy ref_plan naming pictures that
do not ship -- 25 entries against a 28-widget node. It came from the builder
deep-copying nodes out of the dev workflow and overwriting only
widgets_values. Dormant while Comfy.Workflow.NamedValuesRestore stays off
(experimental, default false), but anyone who turned that on would have loaded a
Starter that randomizes its seed and dies on a missing reference.
build_notes.py strips it from both, and tools/check_workflows.py now fails if
it ever comes back.
tools/check_workflows.py is the promoted version of the validator that guarded
the shipped workflows against socket/widget drift. It also checks the board:
every card marked and non-empty, exactly one group, the group enclosing every
card, and no card reaching past x=0 onto the loaders.
14. The turbo stack ships in the examples (2026-08-29)
Both shipped workflows now carry the dev graph's full MODEL wire:
UNETLoader -> LTX_lora_loader -> H3AdaLNLoRAFix -> MiniMaxLowVRAMAttention
-> H3SLAAttention -> ModelPreviewOverrideKJ -> HandTieClips
and CLIP reaches the chain from the LoRA loader, not the encoder -- that is what makes the text half of every LoRA land, and it is the wire most likely to get quietly "fixed" back to the encoder by someone tidying the graph.
This reverses the earlier "core ComfyUI and this pack only" rule for the
examples, at the user's direction: steps is 7, which only works with a turbo
LoRA, so an example without the LoRA stack is not a graph anyone can run at the
settings it ships with. PlagueKind supplies the loader, the AdaLN fix and SLA;
KJNodes supplies Low VRAM Attention and the preview override.
tools/build_speed_stack.py inserts and rewires the five nodes idempotently
(marker properties.htc_speed), rebuilding the plain loader -> chain shape
first so it is reentrant from either state. tools/check_workflows.py walks
both wires link by link -- a patch node that is present but bypassed round
the side looks right on the canvas and does nothing.
H3SLAAttention widget values are written out in full including
reference_protection, which post-dates the dev workflow's saved values. That
workflow has 10 entries against an 11-widget node, which is the positional rule
working as intended: the new widget was appended last, so old values still line
up and the missing one falls back to its default. The trailing "" on the LoRA
loader and the preview override is carried over verbatim for the same reason --
a value past the last widget is ignored, and dropping one that turns out to
belong to a widget would shift every value after it.
head_chunks ships at 4 (the node's own default, safer on unknown VRAM) rather
than the 2 used here. Two files have to be on disk as well: the turbo LoRA the
loader names, and taeh3.safetensors for the preview override's tiny_vae.
15. What a shipped diagnosis looked like (2026-08-29)
The editor UI vanished after the folder rename -- raw widget boxes, no panel.
Not a code fault. ComfyUI had been restarted while the pack was still
ComfyUI-H3-Ref-Chain, so the running process held
python_module: custom_nodes.ComfyUI-H3-Ref-Chain and served its web assets
from a path the move had deleted. /object_info had all eight node types;
/extensions listed none of the pack's JS and a direct fetch 404'd.
Worth knowing for next time: /object_info carries python_module, which is
the fastest way to find out which copy of a pack a running server actually
loaded, and /extensions plus a direct fetch of one script separates "the JS
is broken" from "the JS is not being served at all".
16. What the Rain Kitchen renders taught (2026-08-29)
Three six-hop renders of the same 6x7 s chain, each isolating one variable. All
three findings are now in PROMPTING.md, the troubleshooting table, the
authoring prompt and the on-canvas board.
Identity drift is permanent, and the old advice was backwards. This file and
PROMPTING.md used to say a face plate riding a later hop beats the pin and
that shots should therefore be kept tight. chain_00059 falsified it: hop 4
carried a face plate photographed in a different kitchen through a walking
medium shot and held cleanly, while hop 5 — scheduled with no references at
all — came back a different person, and hop 6 never recovered even though a
place plate rode it and restored the room instantly. locked holds a face that
is still right; only a plate rebuilds one that is gone. Face refs go on every
hop. The old advice survives for place plates only.
A beat must survive an over-delivered hop. A hop routinely does more than it
was asked. Shot 3 asked for "a first slow step along the counter" and delivered
the whole walk; shot 4 was then handed an instruction its own live frame had
already satisfied, and the only way to obey was to reset the scene — a hard
cut 1.5 s into the hop, not at the seam, because _assemble_next holds the
incoming frames for a short beat first. Give one hop the whole movement, and
write the next beat true from either ending ("she reaches the window", "she
takes up the bowl again").
A noun with no adjective drifts. Three hops said only "the bowl"; it came
back stainless steel. Naming it "the white bowl" everywhere and stating it in
context as a property (never a location — "stays in her hands" fights the
beat the moment she puts it down) held it for six hops. The re-run then proved
the rule by accident: context read "the apron stays tied over the grey
t-shirt", and the t-shirt held all six hops while the apron, one clause away
with no colour, had turned denim blue by hop 6.
The code change this bought
refs.resolve_tags now takes subject_names and the node passes it on
continuation hops only. <Subject N> is bound by subject_definitions:, which
is hop-1 material — so on hop 4 the ordinal dangled, exactly the same defect
as the undescribed bowl. From hop 2 a person tag resolves to the subject's
name instead ("The cook walks down the hallway"), which binds to the
continuity_line sentence every continuation hop already carries. No name means
the old <Subject N> fallback, so nothing regresses.
tools/check_prompts.py mirrors the new call, and gained the banned-word check
that previously ran only against templates.js. That gap is how the shipped
Showcase carried "None of the kitchen is visible" (law 1 — it adds a
kitchen at cfg 1.0) and "She stops at the window" (law 2) through six
versions. Both are fixed, and the Showcase's face ref now rides all six hops.
17. The pre-beta audit (2026-08-29)
Read for what a stranger hits, not for what we already check. Six findings; the first two would each have cost a tester a run.
The docs promised a stop the code never did. refs.check() is "warn, never
raise", and its caller only printed. A ref naming a file that is not in
h3_refs had its slot skipped and the chain rendered on — all six hops, with
the reference silently inactive, which is precisely the uncontrolled output the
register exists to prevent. Four shipped surfaces said otherwise, including the
Showcase's own note: "the run stops and names the reference it could not find.
Nothing guesses."
Fixed in the code rather than the docs, because there is no reading under which
rendering without a named-but-absent picture is what the author meant. New
refs.missing_files() returns (tag, file) for every ref whose named file did
not load; h3_ref_chain.py raises on it, after printing the register table so
the error arrives with its context. The distinction that matters: a ref with
no file stays a warning — the Starter ships that way on purpose so it runs
before any pictures exist. Only a named file that is absent is fatal.
The on-canvas board never got §16's corrections. tools/notes.py still
carried the row chain_00059 falsified — "a plate riding a hop it does not
belong on, beating the pin → tighten shots" — and was missing all three rows
PROMPTING.md gained. The board is what a beginner actually reads, so it was
shipping the inverse of the rule. Lesson: PROMPTING.md is the authority, but
tools/notes.py is a second copy of the same craft, and a correction is not
finished until both move. build_notes.py regenerates the Starter's cards and
the Showcase's Note from notes.SHOWCASE_NOTE, so one rebuild covers both.
The rest were packaging, not craft: the MIT LICENSE named no copyright holder;
pyproject.toml lacks both fields a Registry publish needs (PublisherId and a
[project.urls] Repository), now commented in place; the README had no
install section at all; ComfyUI-H3-Motion-Context was absent from Needs
despite the intro calling it the primary guidance path, so testers land on the
MiniMaxH3AddGuide fallback without knowing they changed code paths; and the
example workflows name quantised checkpoints by filename with no note that they
are one valid set among many, which reads as a broken graph rather than a
missing file.
Clean on the same pass, worth not re-checking: no personal data anywhere, no
hardcoded local paths in runtime code (only two dev-tool docstrings), 3.10-safe
syntax, no third-party dependencies, Motion-Context absence handled with fork
detection, WEB_DIRECTORY present, h3_refs auto-created.
18. What a 27B model got wrong, and what the prompt taught it (2026-08-29)
Qwen3 27B at temperature 0.3 was given an 8 x 15 s concept. The ref_plan came
back structurally perfect -- both subjects named, face plate on all eight hops,
places scheduled off the hops they do not belong on, zero register warnings. The
shot_plan carried four defects, and tracing each one back to
prompt_pack/AUTHORING_PROMPT.md found more than four gaps.
The prompt taught one of the bugs. Rule 9 illustrated a beat as
"stands at the counter in `@kitchen`" -- backticks and all, because it was
the one place a tag appeared inside a beat. The model copied the formatting, and
literal backticks reach the encoder. Fixed, plus a rule that a beat is plain
prose.
Beat length had no guidance at all. The only budget in the file was for dialogue, so a no-dialogue plan had nothing to size against and every beat came back at 22-28 words regardless of a 15 s hop. There is now a word table -- and an honest note on it: the shipped plans run 37-39 words at both 5 s and 7 s, so beat length is near-constant across the only two hop lengths ever rendered. The words-per-second reading that gives 70-100 for a 15 s hop is a reasoned extrapolation, not a measurement, and the table says so.
Two rules were simply missing. Nothing told the model to state a visual style
-- for a "2D anime, Ufotable" concept neither returned block contained the word
anime, so the text asked for photoreal while the pictures asked for anime. And
the over-delivery rule from section 16 had never been carried into the prompt at
all, only into PROMPTING.md and the board.
Two statements were wrong. The prompt said "at most 9 pictures on any one
hop"; parse_ref_plan counts len(refs) over the whole plan. The code's own
error message said "on one hop" too, and has been corrected to match what it
checks. And the file bullet still told the model to invent placeholder
filenames without noting that, as of 0.3.1, a named file absent from h3_refs
stops the run.
Law 2 is the interesting non-fix. It is stated plainly, with the exact example
"The cook stops talking", and the model wrote "stops and looks up" anyway. A
principle is not something a 27B reliably applies to its own output; the literal
word list check_prompts.py enforces now appears in the prompt, because a word
list is checkable and a principle is not.
19. What 114 seconds of rendered film and two 27B models taught (2026-08-29)
Three sources landed together: a rendered 8 x 15 s anime chain (chain_00003,
2742 frames, 114.25 s), and Qwen and Gemma each answering two test prompts
written to trip specific rules. Shipped as 0.3.3.
The node was fighting every stylised plan
directives.py prepended ESTABLISH = "Live-action, natural light, one continuous take." to hop 1 unconditionally. At cfg 1.0 with no negative
branch that is additive, and it landed ahead of the style declaration rule 12
requires. A stop-motion puppet plan compiled to "Live-action, natural light,
one continuous take. ... Hand-drawn stop-motion puppet animation in felt and
painted wood", and the two fought.
It also explains the anime chain's opening: hop 1 rendered as bright naturalistic daylight (mean luma 72) against a night plan and a night place plate, then fell to 46 on hop 2 the moment ESTABLISH stopped riding. That had been read as drift; it was the node.
Never surfaced because both shipped workflows are live-action.
Fixed twice over, because either alone leaves a hole: an establish widget
(appended last in optional, per the positional-widget rule in section 8),
and directives.declares_own_medium() / establish_for(), which drop the
default when shot 1's opening names a medium. A model-authored plan never
touches a widget, which is why the automatic arm is the one that matters.
Luminance drifts, and it only goes one way
Mean luma per hop across the eight: 72, 46, 35, 17, 11, 11, 19, 14. Setting the ESTABLISH artefact aside, hops 2-6 still slide 46 -> 11. The combat, the point of the film, plays at the bottom of it.
This is the colour law one level up. locked holds a face; nothing holds an
exposure. Each hop inherits the last frame and darkens it slightly and the
error compounds. Restate the light as a positive property in every beat --
naming a light source ("pale moonlight") does not set a level, and at cfg 1.0
it only adds a moon.
The seams were never the problem
7 of 7 hop joins are invisible: largest frame-to-frame difference at any seam is 13.6, inside the range of ordinary in-shot motion, and two seams score below the film's own mean. Identity held 114 seconds on face plates riding every hop.
The one hard cut in the film is inside hop 4, 3.25 s in, at 7.1 sd -- more
than double any other jump. Shot 3 ended "ahead the trunks begin to thin toward
open ground"; shot 4 opened "Across the flat moonlit stone of @arena_clearing
the two of them square off". Hop 4 was handed a live frame of a man among trees
and a beat asserting he stood on open stone, held the forest for 3.25 s, then
reset the scene. The plan was clean under check_coherence and the banned-word
scan. Hence plan.check_place_handoff().
What the two models did
Near-identical answers from Qwen and Gemma on the same prompt -- same beats to the word in 5 of 6 shots, same defects, same invented justification. The prompt is prescriptive enough to collapse two models onto one answer; a shared blind spot is then invisible from output alone.
The word table was inert. Both models, asked for six 15 s hops, returned beats averaging 54 words against a 70-100 band -- every beat under the floor -- and the same models on much shorter hops returned 40-48. Beat length is near-invariant to hop length in model output, now measured in a third setting. A reference table does not move a model with a prior on paragraph length; the rule is now an instruction to count, with a worked 74-word example.
The banned-word list beat the principle, and made things worse. Zero literal banned words in all four plans -- and "The storm's roar begins to fade... raindrops strike the glass with decreasing force" and "The storm has passed". The list taught token avoidance and handed them a box to tick. It is now framed as crude examples of an idea, with the test stated as: is this happening, or has it finished happening?
Both abandoned the second location. Each plated the opening place, moved the story elsewhere, gave the new place no plate, and justified it with a rule that does not exist ("to avoid conflicting with the frame pin of the new space"); Qwen cited "rule 8/9", which does not say that. In the lighthouse plan that left the lamp room -- four of six hops -- on beat text alone.
check_place_handoff has two arms, and both were narrowed by real plans
- Handoff. Shot N names a place tag shot N-1 never mentions, and shot N's own beat does not carry the journey. The arrival vocabulary had to widen: the Showcase's shot 6 ("walks back along the hallway and through the doorway to the counter in @kitchen") is correct and was being flagged.
- Abandonment, not gaps. Warning on any unplated hop also flagged the Showcase, which deliberately walks her down an unplated hallway on 4-5 and returns the kitchen plate on 6. The rule that survives contact: warn only when the plates stop and never resume, so the film ends somewhere no picture describes.
Both shipped workflows are clean under the final version; the anime plan raises exactly one warning, on shot 4.
Audio, for the record
No background music and no speech, confirmed on the spectrogram: broadband
transients and noise, no harmonic bands. Seam levels hold within +/-2 dB on five
of seven joins, and the two exceptions are drops the beats themselves ask for.
The real audio issue is range, not seams: peaks reach -1.2 dBFS during the fight
while hop 8 averages -42 dBFS. There is no audio equivalent of
HTCToneCompensate. Left open.
20. Seven features built blind (2026-08-30)
Built in one pass with no browser and no GPU render available -- the user was away and explicitly asked for the work anyway. Everything below was verified by offline execution only. Section 21 is what happened when it was finally opened in ComfyUI: two of the seven shipped broken in ways no offline test could have caught, and the rest measured out. Read section 19 first for the measurements that motivated most of it, and 21 for what survived contact.
What shipped
| # | thing | where |
|---|---|---|
| 1 | tone_compensate=anchor + tone_anchor strength |
tone.py, wired in h3_ref_chain.py |
| 2 | dry_run -- compile every prompt, render nothing |
h3_ref_chain.py |
| 3 | contact_sheet -- a fourth IMAGE output |
new sheet.py |
| 4 | render_through -- stop after hop N |
h3_ref_chain.py |
| 5 | quality=draft -- 0.3 MP, 6 steps |
h3_ref_chain.py |
| 6 | H3 Seam Report node | new seam.py |
| 7 | over-delivery lint | plan.py |
Five new widgets, appended LAST (29 -> 34 values). One new output, appended LAST (3 -> 4). Both rules are in section 9; both were obeyed.
The anchor, and why it is not just another tone mode
frame_shift/gain_bias/lut are seam-local: they cancel the denoiser's tone bias on the overlap, which makes each join exact. They cannot see the exposure falloff inside a hop, and that is what compounds -- hop N darkens across its own frames, hands the darker tail to hop N+1, and every individual seam stays perfect while the film dims. Section 19 measured 46 -> 11 across hops 2-6.
Worth stating plainly because it is counter-intuitive: a synthetic 8-hop chain showed frame_shift making the total slide WORSE (66/255 vs 35/255 with correction off). That is correct behaviour, not a bug. The denoiser's per-hop bias happened to lift; cancelling it removed a lift that had been partly offsetting the falloff. Seam correction fixes seams. It was never a level control and should not be read as one.
anchor = frame_shift + a second stage pulling each hop's mean back toward
hop 1's. Two properties make it safe to stack:
- the pull ramps from zero over
ANCHOR_RAMP(48f) frames, so frame 0 of a hop is returned untouched and the seam stays exactly as frame_shift left it. Without the ramp a per-hop constant offset re-introduces precisely the step frame_shift just removed -- this is the whole design, and the trap anyone re-implementing it will fall into; - it is capped (
ANCHOR_MAX_SHIFT, 0.06) and scaled bytone_anchor(0.35), so a slide is corrected across several hops instead of one hop snapping back.
The correction needs no carry variable between hops: because it is applied
before prev_imgs is taken, the next hop's seam correction matches the
already-corrected tail and the offset propagates on its own.
On the synthetic chain: slide 66 -> 18/255, worst seam step 2.22 -> 2.13/255. The seam did not regress, which is the property that mattered.
Intent is indistinguishable from drift from the inside, hence the per-shot
tone field: "free" skips one hop's pull, "rebase" moves the anchor onto
that hop. A deliberate walk into a cellar needs rebase or the chain spends
the rest of the film brightening it back.
dry_run: what it must not touch
The value is that it costs seconds, so every expensive thing is guarded:
MiniMaxH3SigmaShift, KSamplerSelect, BasicScheduler, _model_fingerprint
(it hashes patched weights), the hop store, and -- the big one -- the master
preallocation. master_imgs for 8 x 15 s at 1280x736 is 2742 float frames,
about 31 GB. A dry run that allocated it would be worse than useless.
Hop 2+ needs a prev_imgs to compute <Picture N> ordinals. Content is
irrelevant to the compiled text, so a [overlap, 8, 8, 3] zero tensor stands
in and the text is byte-identical to a real run's.
The smoke test (tmp/t_dry.py) replaces all five sampler entry points with
objects that raise on any attribute access, so "did not touch the sampler"
is asserted rather than assumed. It caught one real bug: the dry block
referenced pin_mech_pred before its assignment, ~40 lines later. Which pin a
hop gets is decided at render time from whether a sampler latent exists, so a
dry run genuinely cannot know it -- the sheet reports the pin_to_qwen
setting instead. Reporting AddGuide for every hop would have been a lie.
The over-delivery lint
The one defect class every other check structurally misses: both shots are
individually well-formed, the directives are individually legal, and only the
JOIN between them is wrong. tail=settle|hold promises rest; a following beat
that opens "She continues...", "Walking to...", "Mid-sentence..." asks the model
to carry on what the hop before was told to stop.
Narrow on purpose. Trailing spaces in _MID_ACTION are load-bearing ("keeps "
not "keepsake", "still " not "stillness"), and _MID_ACTION_LEAD is only
checked at position 0, which is what stops "Morning light..." and "Nothing
moves..." from firing. Verified against those exact traps, and both shipped
plans stay quiet.
It will miss a beat that opens mid-action without saying so. That is accepted: a false positive that blocked a render would be worse than the defect.
Notes for whoever picks this up
sheet.pyandseam.pycatch every exception and return a placeholder image. A picture must never lose a finished chain. Do not "clean up" those handlers. The placeholder was 1x1 until section 21 -- see there for why an inert-looking image is not inert.- The contact sheet stores frames through
sheet.small()(168px tall). Two full frames per hop across eight hops is 180 MB held for the whole render for no reason. - The sheet shows
imgs[overlap_n]for hops 2+, notimgs[0]: the firstoverlapframes are trimmed at the join, soimgs[0]is a frame the master never contains. tools/check_workflows.pyderives the expected widget list from the liveINPUT_TYPES, so it needed no edit for the five new widgets -- onlySaveImageadded toCORE, for the Starter's new contact-sheet node. (Section 21 addedPreviewAny,PreviewImageandHTCSeamReportto those allowlists when the seam report was wired into the Starter.)- The Starter now ships
contact_sheet=onwith aSaveImagewired; the Showcase ships it off. Starter is the teaching graph, so the feature is on the canvas where it will be found.
Unverified, in priority order
All five items that stood here were closed on 2026-08-30. See section 21.
21. What the first ComfyUI session measured (2026-08-30)
Section 20's seven features, opened in a browser and run on a GPU for the first time. Five measured out. Two were broken, and neither could have been caught by any offline test that existed -- both failures lived in the gap between "the Python is correct" and "the graph runs".
Bug 1: 0.4.0 never touched js/
git show --stat on the 0.4.0 commit lists twenty files and not one under
js/. The five new widgets were declared in INPUT_TYPES and never added to
GROUPS in js/editor/run_panel.js, so the run panel did not draw them.
It did not look broken, which is the interesting part. The panel hides only
the widgets it successfully drew -- a deliberate design so an undrawable dial
never vanishes from the node -- so all five fell through to native dials and
worked fine. tools/check_workflows.py passed throughout, because it derives
from INPUT_TYPES and GROUPS is display-only.
The lesson: a Python-side widget list and a JS-side widget list are two sources of truth, and nothing checks that they agree. Adding a widget means editing both. There is still no checker for this.
Bug 2: a 1x1 image is not inert
A dry run returned sheet.placeholder() on images -- a 1x1 black frame. The
Starter wires images into CreateVideo -> SaveVideo. libx264 in yuv420p
subsamples chroma by 2 and cannot open a codec context on an odd dimension,
so every dry run died in avcodec_open2 before writing a frame, with a
traceback naming ComfyUI's video node and nothing of ours.
Reproduced in isolation: 1x1 throws, 2x2 encodes. placeholder() now takes a
width and height, floors at 2x2, and rounds odd dimensions down to even; the
dry run passes the geometry the plan resolved to, so a dry run yields one black
frame at the real resolution and downstream video nodes are happy.
tools/check_features.py had asserted shape == (1, 1, 1, 3) -- the offline
suite was pinning the bug in place. It now asserts an encodable() contract
instead. A test that encodes an exact wrong value is worse than no test.
tone_compensate=anchor, measured on a real chain
Three hops, 8 s, 0.3 MP, 6 steps, one seed, one cache. Because the hop store writes before the tone block runs, flipping tone modes re-grades cached renders in ~14 s instead of 164 s -- so the whole sweep is nearly free. Drift is hop 3 mean minus hop 1 mean; seams are the step across the join.
tone_anchor |
drift | seam @192 | seam @362 | flicker |
|---|---|---|---|---|
| off | 13.5/255 | +0.9 | +2.1 | 0.4675 |
| 0.15 | 7.4 (-45%) | -1.3 | -1.0 | 0.4566 |
| 0.35 | 5.1 (-62%) | -1.9 | -1.6 | 0.4618 |
| 0.60 | 2.9 (-78%) | -2.6 | -1.8 | 0.4661 |
Seam figures are seam.measure(window=6) -- the shipped node's own method, not
an ad-hoc frame difference -- so the docs and the instrument a user runs agree.
Drift removal is even: 45 / 62 / 78% of the uncorrected slide, ~16pp per step.
The seam is not monotonic. 0.15 pulls it tighter than the uncorrected
chain (2.1 -> 1.3) before it grows again, so there is a shallow optimum below
the default rather than a straight trade. From 0.15 up it costs ~0.6/255 per
step. Note also that the seams flip sign: uncorrected they are positive, and
every corrected run overshoots slightly negative. Hop 1 is byte-identical across
all four, as the design requires. The shipped 0.35 default stays -- it
halves the drift while every seam still reads marginal or better.
The propagation claim in section 20 -- that no carry variable is needed --
is visible in the logs: hop 3's frame_shift grew with anchor strength
(r+0.0185 -> r+0.0217 -> r+0.0256) because hop 3 measures against hop 2's
already-corrected tail. It self-propagates, exactly as designed.
Caveat that limits this measurement: the test scene walks the subject toward
a bright window, so some of that 13.4/255 is light a real camera would also
produce. Anchor cannot distinguish motivated light from drift -- that is what
the per-shot tone field is for. A camera=hold scene is the run that would
justify moving off 0.35.
The seam report node, validated
Never executed before this session. Wired into the Starter and checked against an independent decode of the mp4: agreement to ±0.07/255 across six seam readings under two different hop geometries. Its arithmetic is right.
A better result came out of the cross-check. On a 5-hop master the eight largest frame-to-frame luma jumps were at f289, f368, f409, f412, f463, f464, f482, f497 -- not one of them a seam. Every join is quieter than ordinary scene motion. That is the pack's central claim, measured.
Trap, and it cost a wrong diagnosis. 5 hops x 124f and 3 hops x 192f both
total 532 frames at overlap 22. The node derives hop length from
frames, hops and overlap, so a wrong hops yields a plausible length and
four confidently wrong seam positions. There is no way for it to know better
from images alone -- but the chain's info output carries the real geometry,
so an optional info input that cross-checks would turn this class of mistake
into an error message. Worth doing.
quality=draft is close to a no-op here
Draft's two levers are resolution -> 0.3 MP and steps -> 6. In the turbo regime this pack targets, a "final" run is already 0.3 MP at 6-8 steps, so the first lever does nothing and the second saves one step:
- draft, 6 steps: 42.1 s/hop
- final, 7 steps: 43.3 / 45.2 / 50.2 / 44.1 s/hop
About 7% apart. Draft only earns its place if final is genuinely heavier --
1.0 MP at 14 steps. Kept, because that configuration exists, but it is not the
fast-preview button it sounds like. dry_run is the fast-preview button.
The rest
- Contact sheet: correct and genuinely useful on real renders -- first/last
thumbnails per hop, directives, beat, tone line, seed,
cached, pin mechanism. Legible at 1:1; not legible in a node preview, where a 1280px sheet scales to ~0.25 and 15px body text renders at ~4px. It is a click-to-enlarge document. Fonts left alone deliberately. render_through: truncates correctly (rendering hops 1-1 of 2), and re-extending works -- a 3-hop chain extended to 5 loaded hops 1-3 from cache and started rendering at hop 4.- Over-delivery lint: exercised offline; never fired in ComfyUI because both shipped plans and the test plan stay clean. Unproven against a real positive.
22. Two models, one prompt, the same two mistakes (2026-08-30)
A 3-hop / 10 s brief was written to load six traps into thirty seconds, and
handed to two local models in LM Studio with prompt_pack/SYSTEM_PROMPT.md in
the system box, temperature 0.4, and nothing else. EXAMPLE_6_HOP.md was
deliberately withheld -- it argues for six hops and would have contaminated a
3-hop test. Grading ran every reply through the real parsers rather than by eye.
| qwen | gemma 26b-a4b | |
|---|---|---|
| FAIL | 4 | 2 |
| after the shared prompt bug | 2 | 0 |
Both models made the same tag mistake, which makes it the prompt's. Each
wrote @kitchen in the beat -- correctly, that is rule 10's own example -- and
then invented "tag": "kitchen_plate" for the register. The string _plate
appears nowhere in the prompt; they arrived at the same convention
independently. The cause was in the file: rule 10's only concrete place tag
lives in a beat, the register example held one ref (hero_face, a person),
and the two were never shown together. The invariant was stated, in a field
bullet 150 lines later -- and models copy examples, not bullets. PROMPTING.md,
the human guide, has had a three-ref example including kitchen all along; the
machine prompt was trimmed and lost it.
The silent one. qwen wrote "name": "@cook_face" into subjects. That
parses, resolves, renders, and is wrong: name is what resolve_tags
substitutes for a subject's tag from hop 2 on, so the tag resolved to itself and
a literal at-sign reached the encoder on two of three hops. Nothing caught it.
check_prompts.py would have, but only for the two shipped workflows.
Where the models actually differed is invisible to any parser. Both lifted rule 2's worked answer verbatim ("water runs in slow threads down the window glass"). But hop 3 moves to a hallway, and qwen also copied rule 4's example object -- putting "a single click from the refrigerator" in a corridor, the kitchen appliance following her out of the room. gemma copied rule 4's method and wrote "the low hum of a hallway light". Recitation versus transfer, and only one of them survives a change of location.
Both also left tail off hops 1-2, describing the arrival at rest in prose
instead of directing it -- so the over-delivery lint, which only arms after a
settle, still has not fired on a real positive.
Fixed here: the register example carries a place tag on both sides of the round
trip and says the two spellings are one string; refs.py rejects an @tag in
name, locked or context. Not fixed, because it is a brief-writing lesson
rather than a bug: ending a chain in a location no plate describes earns the
place-handoff warning, and that was the brief's fault, not either model's.
23. The hop cache stops shelling out (2026-08-30)
The Comfy registry flagged all three published versions. The reason is not in
the web UI and status_detail on the node is empty; it is behind
https://api.comfy.org/versions?nodeId=<id>&include_status_reason=true, which
returns the actual findings:
scanner yara_scan
issue_type python_command_injection_risk
file_path store.py lines 131 and 215
description "Detects all os.system and subprocess usage"
severity info
recommendation null
admin_tags any-code-execute
Two findings, both the subprocess.Popen calls that ran ffmpeg for the FFV1
hop cache. The rule does no taint analysis, so a static argument list built from
shutil.which and run with shell=False matches exactly as hard as a shell
injection would. Its 95% confidence is confidence that the call is a
subprocess call, not that it is exploitable.
Appealing looked like the wrong move. plaguekind-nodes -- 22.5k downloads --
has 1.3.8 through 1.4.0 flagged with the same two findings and 1.4.1 onward
Active with status_reason = "Passed automated checks", the string the
scanner writes when it finds nothing. That is a code change, not an admin
override.
But the registry is the weakest reason to have done this. _ffmpeg() raised
if no ffmpeg binary was on PATH, and ComfyUI never requires one -- so the
feature that makes a tone A/B cost 14 s instead of 164 s hard-failed for a large
share of users, on the pack's fastest path, at the exact moment a CivitAI post
would send new people at it.
PyAV is a hard dependency of ComfyUI itself (SaveVideo and CreateVideo are built
on it) and its ffv1 encoder lists rgb48le among 61 pixel formats, so the
format did not have to change: ffv1 / rgb48le / level 3 / coder 1 / context 1,
in matroska. Verified before writing any of it, and again through the real
HopStore:
- PyAV encode -> PyAV decode: bit exact, including 0, 65535 and midpoints
- ffmpeg encode -> PyAV decode: bit exact -- existing caches on disk still read, which is the part that protects users
- file sizes within 44 bytes of each other (145,780 vs 145,824)
- a frame-count mismatch still raises rather than returning a short clip
The decode path also got slightly better on the way: it decodes into one
preallocated (n, h, w, 3) array instead of building a list of frames and
stacking, so there is no second full-size copy. Five documents claimed ffmpeg
was required -- CLAUDE.md, PROMPTING.md, README twice, and the on-canvas card in
the Starter workflow via tools/notes.py. All corrected. README's existing
"No dependencies to install" line, which already listed av, is now true rather
than nearly true.