Sandpies
0.4.5: the hop cache stops shelling out to ffmpeg
d07b19b
|
Raw
History Blame Contribute Delete
57.2 kB

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 and store.tensor_digest's cache keying are all unchanged. That is why this was a small diff rather than a rewrite.

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

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

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

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

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

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

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

9. Presentation (2026-08-28)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

was is
H3RefChain HandTieClips
H3ContinuityState HTCContinuityState
H3ChainPreview HTCChainPreview
H3ToneCompensate HTCToneCompensate

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

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

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

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

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

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

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

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

Mechanics worth not rediscovering:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The code change this bought

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The node was fighting every stylised plan

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

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

Never surfaced because both shipped workflows are live-action.

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

Luminance drifts, and it only goes one way

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

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

The seams were never the problem

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

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

What the two models did

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

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

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

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

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

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

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

Audio, for the record

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

20. Seven features built blind (2026-08-30)

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

What shipped

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

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

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

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

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

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

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

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

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

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

dry_run: what it must not touch

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

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

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

The over-delivery lint

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

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

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

Notes for whoever picks this up

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

Unverified, in priority order

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

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

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

Bug 1: 0.4.0 never touched js/

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

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

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

Bug 2: a 1x1 image is not inert

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

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

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

tone_compensate=anchor, measured on a real chain

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

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

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

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

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

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

The seam report node, validated

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

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

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

quality=draft is close to a no-op here

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

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

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

The rest

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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