docs/GROK_TO_CLAUDE.md was a 2026-09-05 agent-to-agent pickup note -- "Grok
finished the writing of v2, you are taking over review + remaining GPU tests"
-- carrying instructions that expired the day they were followed, including a
"do not push, do not publish" line that has been false since 2.0.0. It is not
history anyone needs and it reads oddly to someone unzipping the pack.
GROK_V2_HANDBACK.md opened and closed by pointing at it, so both pointers are
repaired rather than left dangling. The .comfyignore entry goes with the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2.2.0: refine_align, refine_blend=auto, and SLA at the front of the wire
Sandpies+1: Claude Opus 5
4bf5daf
refine_align picks the sigmas the refine pass re-runs. The published
refine_denoise=0.50 + refine_steps=2 pair was a schedule hard-coded to a
4-step base, whatever the hop actually sampled; hop_tail re-runs the hop's own
last sigmas instead. The default stays denoise, so a 2.1 graph reloads on the
schedule it rendered under.
refine_blend=auto derives the blend ramp from this run's overlap instead of
hard-coding 22 frames, and resolves to a literal before the hop key is built,
so auto at the shipped overlap keys byte-identically and cached hops still hit.
SPEED_MODES is one row. A preset may move a field only if the field is a
property of the checkpoint; sampler, scheduler and step count are community
fashion and a stamping preset would overwrite a deliberate choice.
SLA moves to the front of the MODEL wire in both shipped workflows, pinned by
tools/check_workflows.py. That changes the keys _model_fingerprint hashes, so
the first run after updating re-renders every hop even with cach…
2.1.0: refine pass, H3 Cache, and a prompt-pack trust boundary
Sandpies
f131421
Hop refine ships off with frozen defaults; silveroxides block cache is
vendored with notices; new widgets are appended so 2.0 graphs render
identically. CLAUDE.md and .claude/ stay local and no longer ship.
Rework the README as a showcase, and put SWAP in the changelog
Sandpies+1: Claude Opus 5
7dcfb40
The page opened with ten version blockquotes -- roughly 145 lines of patch
notes, back to 0.4.1 -- before it said what the pack does. Somebody arriving
from the registry read four months of small updates first.
**The changelog is now one section at the bottom**, five short paragraphs
instead of ten quoted blocks, with the detail left in CHANGELOG.md where it
belongs. Above it, the page leads: what it gives you, install, the one law,
WRITE, SWAP, the tester's study, then the join and the tone anchor, and only
then a **Reference** section holding the shot-plan schema, directives,
register, media, cache, instruments, defaults, nodes and limits.
New sections:
- **SWAP.** It shipped in this release with no user-facing documentation
anywhere -- not in the README, and not one mention in CHANGELOG.md. Both
now carry the four modes and, more importantly, the two usage rules that
cost renders to establish: do not describe the clip on MEDIA before a swap
(that caption reaches the encod…
Record what four "head swap does not work" reports actually were
Sandpies+1: Claude Opus 5
da94e47
None of them was broken code. A missing frame sequence, a weak citation, a
self-inflicted contradiction, and a conditioning imbalance -- and three of
the four were legible in the log before anything was changed.
The one that mattered was found by the user, not me: the failed run had a
clip caption and the successful one did not. "A man wearing an orange tank
top" reaches the encoder as what <Video 1> IS, and contradicts a beat asking
for somebody else, additively, at cfg 1.0. SWAP's own Describe button wrote
it because the caption prompt asked for "who is in it".
Also records the finding that is not a bug and outlives the rest:
reference_video_size is an influence dial. Core aligns reference frame N with
output frame N, so a clip competes with the identity stills for the same face
wherever it shows one clearly, and at MAX it wins. That is why the swap
arrived four seconds in -- exactly when steam covered the original face.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude…
Make three fixtures resemble the code they guard
Sandpies+1: Claude Opus 5
30834f6
Section 63 found one checker whose fixture built a plain tensor where the
code makes a NestedTensor; it passed while the behaviour it guarded was
broken in every run that used the feature. An audit asked the same question
of the other twenty-one and found three more.
check_audio_lock never called _splice_locked_audio at all -- it tabled the
window arithmetic and fed assert_mask_polarity toy tensors. The splice is
where this feature's two unrecoverable failures live. Now driven with the
shapes off a real cached hop, asserting the video is bit-identical, the mask
is NESTED with the right polarity per stream, and both refusals name their
numbers. Falsified two ways.
check_cache_keys never passed the patches= argument its own fixture has
always accepted, so every assertion ran against an empty dict and the LoRA
branch was dead. A regression that stopped hashing strengths would have
passed -- silently wrong frames, no crash. Now covers key names, key order,
both strengths, and malformed e…
Number the first media slot, and record the reference-cap question
Sandpies+1: Claude Opus 5
9821e0f
The strip read VOICE / VOICE 2 / VOICE 3, which reads as though the first one
is a different kind of thing. Now 1/2/3, same for the reference clips.
No fourth voice: Core's Autogrow declares ref_audios max=3, ref_videos max=3,
ref_images max=9, so three is its ceiling rather than ours. The reference rail
is already the "+" for images and is capped at 9 to match.
Records in POST_V2 why more than nine is nonetheless possible -- execute()
iterates the dict without counting, so the cap is frontend-only -- and why
that is a reason to prove it rather than to do it: passing more entries than
a declared maximum relies on an undeclared property of someone else's node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVz8qNGn2NhrP3KG2Lh5yT
Hold the SWAP contribution for 2.1, with the reasoning
Sandpies+1: Claude Opus 5
7c34124
Good work, not in 2.0.0: a reference-deletion bug that needs a design
decision rather than a fix, no validation budget left after the GPU window,
and a working tree off v1.1 that keeps diverging. Not cherry-picking the safe
parts either -- that fragments their contribution and makes the rebase harder
for features nobody has asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVz8qNGn2NhrP3KG2Lh5yT
Guide the hop before a restart, not every hop
Sandpies+1: Claude Opus 5
11cc336
last_frame_guide=still turns a restart from an obvious jump into a match cut:
hop 3 arrives at the still's framing rather than snapping to it. Two viewers
watching the unguided run called the cut obvious; guided, they did not.
It also plants the photograph at frame_idx=-1 on every hop unconditionally,
which overrides an authored framing directive at every hop ending. A shot set
framing=close plays close for six seconds, snaps to the still's wider framing
in ~0.6 s, and the next hop pushes back in. Watched cold, that reads as the
camera cutting in and out. The directive wins the middle and the guide wins
the end.
before_restart guides only a hop whose next shot is anchor=restart.
The gate is one helper used by both the apply site and the cache-key field,
because those two have to agree -- a key claiming a hop was guided when it was
not is the same class of defect as _pin_mech_for disagreeing with
_pin_continue. The key is on what the hop GETS, so unguided hops keep the
byte-identical…
The seam report measures the joins that were written
Sandpies+1: Claude Opus 5
e45d846
The complaint was that hops/overlap are typed by hand and go stale. The bug
underneath is that they cannot describe a restart chain at all.
seam_positions() solves for the join frames assuming every hop is the same
length and every hop past the first is trimmed. Section 58 made restart hops
write their full length, so on tonight's 4x192 chain with a restart at hop 4
it solves 197.5 frames per hop and measures at 198/373/548 where the joins
are at 192/362/532. Far enough into the flat middle of a hop that three
planted, visible steps all read as 0.00/255 invisible -- a clean bill of
health for a chain with three visible seams, on exactly the experiment this
work exists to run.
run() now records each hop's write position and publishes them on info as a
"seams:" line. HTCSeamReport takes info as an optional forceInput socket, so
it adds no widgets_values entry and old workflows are untouched. Wired: exact
positions, widgets ignored. Unwired: unchanged. Unparseable: falls back
rather tha…
Record what the GPU window settled, including a reading I got wrong
Sandpies+1: Claude Opus 5
8441fda
Tests 3, 4 and 5 passed. anchor=restart stays in the shot with a fresh seed;
restart hops write 724 frames not 702; fp16 survives delivery and SaveVideo
takes it, which offline work could not settle.
The last-frame guide turns the restart jump into a match cut. Its cost is
real and measured -- four hop endings 3.8/255 apart against 39.1 unguided --
and I read it wrong, called for a per-shot redesign before release on the
strength of four stills in a grid. A hop's last frame passes in 1/24 s and
two people watching in motion saw nothing. Shipping as-is, off by default.
What the number does support is a prediction about authored framing, which
is now test 6 rather than an assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVz8qNGn2NhrP3KG2Lh5yT
Record what is deferred past 2.0.0, and why
Sandpies+1: Claude Opus 5
444a693
The VAE skip especially: the handover said it was a one-argument test
(pass vae=None and Core skips the latent blocks). Read against the
installed Core that is false -- vae.encode is unguarded, vae is a required
input, and ref_image_size has no 'none' option. The route that works is
add_object_patch on the consumption end, which is real work and needs a
GPU to evaluate. Written down with the correction so nobody spends an
afternoon on the version that cannot work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVz8qNGn2NhrP3KG2Lh5yT
fp16 for the master, and measure what it costs
Sandpies+1: Claude Opus 5
2c9b5d7
Section 52 deferred the fp16 decision and it was never revisited, so the
master shipped fp32 through all of v2. The master is delivery-only --
prev_imgs is cloned from imgs and nothing in the conditioning path reads it
back -- so this halves the largest allocation in the pack. An 8x15s
1280x736 chain: ~29 GB to 14.4 GB, and half the disk I/O when it spills.
fp16 not bf16: the buffer is clamped to 0..1, so mantissa bits are the whole
question and bf16's seven give exactly 8-bit output precision with nothing
in reserve.
It is not bit-identical, and the checker now says so in the right units. The
assertion first written here -- "fp16 delivers the same 8-bit pixels as
fp32" -- failed. The encode truncates, so a value fp16 nudges below an
integer boundary loses one 255th. Measured over 2M samples: 2.06% of pixels
move, all of them by exactly 1, none by more. The check asserts that bound
rather than the claim that sounded better.
_frame_to_jpeg_b64 and sheet.small already .float() their i…
Cache the latent of a locked hop
Sandpies+1: Claude Opus 5
50f40c8
master_audio_file shipped with the hop cache silently off underneath it.
Every locked hop logged "latent not cached (latent is not representable
without pickling)" and stored no sampler latent. A later cache hit then
leaves prev_sampled empty, the hop after it falls back to the AddGuide
pixel pin, and its cache key -- which contains the predicted mechanism --
stops matching what is on disk. store.py already documents that exact
chain, ending "making cache_hops=on actively worse than off".
_latent_to_flat decomposes `samples` through latents.parts() because it can
be a NestedTensor, then treats every other member as a plain tensor or a
scalar. This is a joint AV latent, so anything shaped like it carries one
tensor per stream, and the audio lock's mechanism IS
out["noise_mask"] = NestedTensor((ones, zeros)). A NestedTensor is not a
torch.Tensor, so it hit the refusal branch.
Nested members are now stored as nested.<key>.<i> and rebuilt through the
same static import and class-name che…
Point the Claude pickup at the handoff commit
Sandpies+1: Grok
42f3a98
Co-Authored-By: Grok <noreply@x.ai>
Handoff to Claude: pickup docs and remaining GPU tests
Sandpies+1: Grok
2b22e73
GPU tests 1-2 passed. Remaining 3-5 live in docs/GROK_V2_GPU_TESTS.md, adapted to this machine's Mia/podcast graph. Pickup is docs/GROK_TO_CLAUDE.md. Unpushed.
Co-Authored-By: Grok <noreply@x.ai>
DEVLOG: GPU tests 1 and 2 passed
Sandpies+1: Grok
d0ba7af
Lock on: windows 0.00-8.00 / 7.08-15.08, passthrough. Lock off: generated voice, no audio locked line. After the xfade rank fix and a ComfyUI restart.
Co-Authored-By: Grok <noreply@x.ai>
Xfade locked hop-1 [C,T] against hop-2 [B,C,T]
Sandpies+1: Grok
d140c0d
GPU test 1 locked hop 1 at 0.00s-8.00s then died joining hop 2: the take was squeezed to 2-D and the hop AUDIO dict is 3-D. _xfade_audio now batches both sides. Reproduces offline as the same RuntimeError.
Co-Authored-By: Grok <noreply@x.ai>
2.0.0: version, changelog, README against the code
Sandpies+1: Grok
c7ece81
pyproject 2.0.0, editor VERSION v2.0.0. README shot fields, MEDIA strip, hop-cache notes and the 'no shot-level refs' line were stale; a duration example used join=cut, which is not a join value. HANDOVER files left as history; BETA_NOTES marked superseded. No behaviour change.
Co-Authored-By: Grok <noreply@x.ai>
Wire shot.refs: choose which stills ride a hop
Sandpies+1: Grok
3bb5d7f
Omitted keeps the register default. Empty list is explicit none -- the choice that drops identity stills on a pin-less restart. A filled list is those tags only, in that order. Unknown tags fail on the queue. The editor round-trips both a filled list and []. GPU-untested: whether refs=[] on hop 4 keeps the couch.
Co-Authored-By: Grok <noreply@x.ai>
Write full length on restart hops, no overlap trim
Sandpies+1: Grok
ddd0fa2
A restart is a chain start and overlaps with nothing. The master write and preallocation now key on hop_is_start, not i==0. Two restarts on a 9 x 8 s / 0.9 s chain keep 1596 frames instead of silently dropping 44. The audio-lock window follows the master head so lips stay on the take. GPU-untested: 40 ms xfade at the cut.
Co-Authored-By: Grok <noreply@x.ai>
Add last_frame_guide: opt-in last-pixel AddGuide
Sandpies+1: Grok
7b313c2
Default off is byte-identical and omitted from the hop key. still pins start_image at AddGuide frame_idx=-1 (last PIXEL frame, not latent T-1 -- FRAME_PER_TOKEN is 1,4,4,4,4). Does not become the next hop's frame 0. Queue-fails without a start image. DiT treating the pin as a bound is untested (GPU).
Co-Authored-By: Grok <noreply@x.ai>
Add master_audio_file: lock every hop to one take
Sandpies+1: Grok
3721260
Empty default is off and does not move cache keys. Window function is
0-based, pure, and table-checked for 8 s and 15 s hops. noise_mask is
1 on video / 0 on audio, asserted. Delivered audio is a passthrough.
Beat transcript is not auto-cut -- unmatched <d> can still pull the
mouth. Sampler honouring NestedTensor noise_mask is untested (GPU).
Co-Authored-By: Grok <noreply@x.ai>
Merge media-3x3 into v2
Sandpies+1: Grok
5ab0db5
Extra video/voice slots appended AFTER pin_mech and tone_anchor_ref so
existing v2 workflows keep those two values. Workflows updated with 12
empty trailing widgets. Incoming DEVLOG as §55.
_dense_media kept; pin_mech still takes mode=.
Co-Authored-By: Grok <noreply@x.ai>
Merge segment-reanchor into v2
Sandpies+1: Grok
25df349
_validate_anchors extracted and called from run(); trunk's
tone_anchor_ref=still and pin_mech=motion_context queue checks kept.
Incoming DEVLOG (The chain forgets how to light her) as §54.
check_anchor.py added to check_all.
Co-Authored-By: Grok <noreply@x.ai>
Merge texture-lab-findings into v2
Sandpies+1: Grok
4bd40c9
Incoming DEVLOG (the texture ladder, run) kept as §53. tone.py /
h3_ref_chain.py auto-merged.
Co-Authored-By: Grok <noreply@x.ai>
Merge master-spill into v2
Sandpies+1: Grok
f67758d
Keep _core_call and pin_mech mode=auto from trunk; add delete-on-close
master memmap from the branch. Incoming DEVLOG as §52.
check_master_spill.py added to check_all.
Co-Authored-By: Grok <noreply@x.ai>
Merge latent-sidecar-safetensors into v2
Sandpies+1: Grok
9c35cc1
Retires torch.save pickle in the hop-cache latent sidecar.
check_latent_sidecar.py added to check_all. Incoming DEVLOG as §51.
Co-Authored-By: Grok <noreply@x.ai>
Merge cache-key-batch into v2
Sandpies+1: Grok
6150e60
Keep both hop-key fields (tone from trunk, pin_qwen from the branch).
pin_qwen uses hop_is_start, not i>0, so a restart is keyed like hop 1.
check_cache_keys.py added to check_all next to check_core_calls.
Incoming DEVLOG kept as §50.
Co-Authored-By: Grok <noreply@x.ai>
Merge docs/voice-and-dials into v2
Sandpies+1: Grok
2130baa
Incoming DEVLOG §45 (Four features that already shipped) kept after
current §48 as §49. README voice/pin_renorm/mp/duration claims aligned
with the code.
Co-Authored-By: Grok <noreply@x.ai>
Withdraw the cause, keep the fix
Sandpies+1: Claude Opus 5
774cadb
A second review pass opened the frames. Hop 4 of both restart runs OPENS on
the couch (21.0-22.5 s), ghosts at 23.0, sits on the identity photograph
23.5-26.0, and is back on the couch at 26.5 s -- inside the same hop, with
no help from hop 5. So the restart's frame-0 anchor worked, and my reading
that the hop was handed a portrait and rendered it is wrong.
What actually fails is the middle of a pin-less hop, where the identity
photographs have nothing holding them off. Run 02 -- a plain relay with a
Motion-Context pin -- does the same thing at t=9-10 s and recovers by t=11,
so it is not restart's invention, just restart's for longer.
Also recorded: background_metrics samples mid-hop, so edge_density 0.003 is
a reading of that 2.5 s window and not the whole hop; and hop index is
confounded with seed here (hop 4 is ...458, hop 7 is ...461 in both runs),
so the asymmetry cannot be attributed yet.
The five contract violations in section 48 are real and the fix stands.
None of them is p…
Anchor on the photograph, and let a restart actually be a chain start
Sandpies+1: Claude Opus 5
0365453
Two changes, landed together because the second was found while wiring the
first and they meet in the same cache key.
tone_anchor_ref=still points the anchor at start_image instead of hop 1.
The ten-run study measured hop 1 already missing the still before any relay
(chroma 30 against 33.6, b* 22 against 26.6), so a chain held on hop 1 was
converging on the first casualty. `still` also pulls hop 1 itself, which is
the only way that gap ever closes. Default is unchanged.
Hop 1 and restart hops now take the correction from frame 0 (ramp=0). The
ramp protects a join; neither has one, and ramping them fades into the look
over the two seconds a viewer is deciding what the shot is.
anchor=restart was being assembled as a CONTINUATION. `hop_restart` was
computed beside the sampler, two hundred lines after the code that decides
which references ride a hop, whether the previous frame is pinned as
<Picture 1>, and whether the prompt says "opens already in progress". So a
restart hop got the p…
Merge pin-mech-switch: the pixel pin has to be reachable to be tested
Sandpies+1: Claude Opus 5
d9c176d
The Lab anchor corrects the delivered frames, and under the Motion-Context
latent join those frames never become the next hop's pin. The AddGuide pixel
join is where they do -- but on beta-1.2.0 AddGuide is only a fallback, with
no way to ask for it. Without this merge the anchor cannot be tested against
the thing it was rebuilt for.
Three conflicts, all resolved by keeping both sides except one: a restart hop
still forces mech "none" ahead of any forced pin_mech, because a restart
relays nothing and so has no mechanism to choose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVz8qNGn2NhrP3KG2Lh5yT
Anchor the tone in Lab, so it can see the colour it is losing
Sandpies+1: Claude Opus 5
939ecfb
A ten-run 9-hop study measured the relay chain losing a fifth of the skin's
chroma (33.6 -> 23.8) and 7 points of L* with the hue angle unmoved. That is
a loss of colourfulness plus a loss of level, and `anchor_pull` matched a
per-channel RGB mean, which cannot tell those apart from a colour shift: a
hop that has greyed out can sit exactly on all three reference means.
anchor_stats now returns four Lab moments -- mean L*, mean a*, mean b*, and
the L* spread -- and anchor_pull corrects all four, the spread as a capped
gain about the hop's own mean. a*/b* are the chroma on its own; the spread is
the mid-scale contrast that hardens when a face bakes.
Kept the seam guarantee: the ramp blends against the untouched source in RGB,
so frame 0 comes back bit-identical through the non-linear round trip.
Held the cost to 15 s on a 362-frame 1344x768 hop, from 32 s: statistics read
24 evenly spaced frames (they agree to 0.005 Lab), frames past the ramp skip a
blend by 1.0, and both colour matri…
Document the media slots for users and record why they were missing
Sandpies+1: Claude Opus 5
355136a
README gains a section before the reference register: the 9/3/3 capacity, that
numbering is DENSE so clearing a slot renumbers the ones after it, that
reference clips now carry their own sound, and that three voices make the trim
window mandatory rather than an optimisation.
DEVLOG 52 records the cause -- nobody had read what core declares -- the
ordinal trap, what the cache needed, and the parity guard that did not exist.
13/13 checks pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013orqT1xNDezvdwv4f51MdW
Record the mechanism, and test the guards that shipped broken twice
Sandpies+1: Claude Opus 5
b82c6f4
DEVLOG 51: the chain does not lose texture, it loses its LIGHTING RESPONSE.
Measured with the same beat on all six hops, so nothing is the writing:
lighting-vs-head-position goes +0.63 to -0.49 (it inverts), the lighting's
swing falls 64%, and the subject's own movement falls 39%. Less movement can
drive a correlation toward zero; it cannot carry it through zero, so the
inversion is a different behaviour rather than a weaker signal.
tone_compensate=anchor over byte-identical cached hops moved the exposure level
and left the swings identical to three decimals, so exposure drift is a symptom
sitting on top of this, not the cause.
That explains section 50: pin_to_qwen, pin_renorm and pin_mech all change how
the previous hop is PRESENTED, and none of them changes that it is inherited.
pin_noise is the exception because it opposes the inheritance -- and measured
against a matched control it held movement, doubled the surviving swing and
prevented the inversion, with 0.06 beating both 0 an…
Point section 50 at what actually degrades, and withdraw a result
Sandpies+1: Claude Opus 5
3bcf417
The texture ladder measured texture, and texture is not where the degradation
lives. Section 51 has the quantity that does decay monotonically -- how much
the lighting responds to the subject -- so 50 now says to read it first.
Three corrections. The band figures are partly POSE: they sample each hop last
frames, lighting tracks head height at +0.70, and the within-hop brightness
swing (0.36) is larger than the across-chain drift (0.29). A reported result is
withdrawn outright -- "background gains 59% mid-band while the face loses it"
disappears once each box is normalised by its own brightness; the background is
not gaining texture, it is 57% brighter. And pin_to_qwen=off, measured worse
here, was already the shipped state for the reporting user, who hits
MAX_REF_IMAGES on every hop and so skips the Qwen pin regardless.
One result got stronger: pin_mech=addguide was doubted against her long-chain
force_pixel_pin test, but an audit found that test had no matched control --
different …
Make the master spill delete itself instead of relying on a sweep
Sandpies+1: Claude Opus 5
5eed278
The sweep never reclaimed anything, and a real render proved it. ComfyUI keeps
the previous run's IMAGE output in its execution cache, so the mapping is still
open when the next run starts; os.remove raised and the handler passed on
OSError without a word. Two renders left two 9 GB files on disk. The docstring
claimed "there is never more than one". Ten renders would have been 92 GB of
silent disk consumption -- worse behaviour than the RAM problem it solves.
The checker agreed with the code because it only ever tested a stale file that
nothing held. Both were self-consistent and neither matched reality.
_open_self_deleting() replaces the policing with lifetime semantics: O_TEMPORARY
on Windows, unlink-while-open on POSIX. The bytes live exactly as long as
something is using them, the file never appears in a listing afterwards, and a
hard kill cleans up because the kernel closes the handles. Verified through the
real access pattern -- the mapping outlives the file object, slice write…
Test the join-quality objection to AddGuide, and record the limit
Sandpies+1: Claude Opus 5
58d275b
pin_mech=addguide won the texture ladder, and the standing reason
Motion-Context is the default is join quality. Measured on the delivered
masters with no new renders: mean absolute frame-to-frame difference AT each
join against the median of the twelve pairs either side, so 1.0x means the cut
looks like ordinary motion.
A motion_context 1.25x / 0.95x E addguide 1.16x / 0.90x
B pin_to_qwen=off 1.26x / 1.03x C band 1.21x / 0.94x
No run shows a discontinuity and AddGuide is lowest at both seams. Frames
either side of both joins inspected directly; neither mode breaks visibly.
The limitation is stated rather than buried: this is a talking head under
camera:hold with very little motion, which is exactly the content where
Motion-Context has least to prove. The reading is "on low-motion content the
cost does not show and the texture gain does", not "AddGuide costs nothing".
Also records that seam 1 bumps ~1.2x in every run while seam 2 sits at or below
baseline -- the …
Record the texture ladder, and correct three claims it disproved
Sandpies+1: Claude Opus 5
1f2e9e1
Four levers measured on a 3-hop 8 s chain at 640x1152, 8 steps lcm under a
baked-turbo checkpoint, one location, one subject, seed fixed. Full write-up in
DEVLOG 50. Three results contradict standing guidance.
pin_to_qwen=off made texture WORSE (0.781 of the reference's face mid-band vs
0.838 for baseline). It was the ladder's first rung and its cheapest lever.
pin_renorm=band did exactly what it claims and it did not matter: it more than
halved the drift in the latent high-band fraction it targets (-1.90% -> -0.85%)
and moved the pixels by 1.4%, which is noise. "Restores the energy ratio
without restoring the structure" is now measured rather than argued.
pin_mech=addguide won at 0.954, and was never in the ladder -- section 06
justified the switch as what makes the experiment possible, not as a lever. The
pin's sigma climbs 1.0441 -> 1.0612 under Motion-Context and sits flat under
AddGuide. Costs whatever join quality Motion-Context was chosen for, which was
not measured.
ref_ima…
Replace the master-spill hedge with measured numbers
Sandpies+1: Claude Opus 5
bb8ce27
The entry said "this does not save 31 GB" and left it there. Instrumenting a
real render says something more useful and more defensible.
A 3-hop 8 s chain at 640x1152 peaked at 49.4 GB RSS, of which the master is
4.4 GB. The number that matters is the remainder: non-master residency is
41.8 GB and does NOT grow with chain length -- model weights, the pinned pool
and decode buffers are fixed cost. The master is the only term that scales.
At 8x15 s and 1280x736 that puts the master at 28.9 GB against a fixed 41.8,
so peak ~70.6 GB on a 64 GB machine, and spilled it is ~41.8 GB either way.
So the claim is scale-dependent and a flat percentage was the wrong way to
state it. Small chain: a tenth of peak, a nicety. Long chain at working
resolution: 40% of peak, and the term that decides whether the run completes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013orqT1xNDezvdwv4f51MdW
Spill the master frame buffer to disk above 2 GiB
Sandpies+1: Claude Opus 5
f1e651f
The master is allocated once at full chain length -- ~31 GB at 8 x 15 s and
1280x736, the number its own comment has always named -- slice-written as each
hop lands, and then not touched again until the final preview frame and the
return. Through every sampling pass it is resident, inactive, and competing with
the DiT, the VAE decode buffers, imgs and prev_imgs.
Above MASTER_SPILL_BYTES it is a np.memmap in ComfyUI's temp directory wrapped
by torch.from_numpy. Every slice-write, the trim and the return are unchanged.
This does NOT save 31 GB and the DEVLOG says so. ComfyUI's IMAGE type is dense,
so the whole master still materialises to be returned. The peak moves from
master + inference to max(master, inference). On the chains where this bites
that is the difference between finishing and an OOM; it is not a saving.
A memmap rather than an incremental writer, deliberately: that machinery exists
to stream tensors of unknown shape and reserves a header for it. This is one
tensor of kn…
Retire the pickle in the hop-cache latent sidecar
Sandpies+1: Claude Opus 5
88ce3d2
store.py wrote the sampler latent with torch.save and read it back with
weights_only=False. The comment beside the read was correct -- our own dict,
written by our own put(), into ComfyUI's temp directory -- but being right about
a finding is not the same as not having one, and this is the next thing a
registry scanner reaches for after the 0.4.1-0.4.3 subprocess work.
Nothing here needed a pickle. The only awkward member is samples, a
comfy.nested_tensor.NestedTensor, and latents.parts() has decomposed that into
plain tensors since the pin levers needed exactly that. The sidecar is now
.latent.safetensors: components as samples.N, tensor members as extra.<key>,
scalar members in a small JSON header. safetensors is already a hard ComfyUI
dependency, so nothing is added.
Refuses rather than guesses. An unrepresentable member fails the whole write
instead of being dropped, because a latent restored minus a key would be quietly
not the one that was stored. That costs nothing: the caller…
Add pin_mech: choose the hop-2+ pin mechanism
Sandpies+1: Claude Opus 5
9672605
Detection already existed -- _motion_context_cls() resolves the upstream class
and rejects incompatible forks, run() logs the path per hop, the preview shows
it. Only the choice was missing, which made one question unanswerable: does the
AddGuide pixel path ratchet less than Motion-Context? Its VAE round trip is
itself a projection and may scrub the latent structure that accumulates, at the
cost of the join quality Motion-Context was chosen for. Asking that used to mean
uninstalling a pack.
pin_mech is auto / motion_context / addguide, default auto = current behaviour.
The override is applied in BOTH _pin_mech_for and _pin_continue. The predictor
exists to put the mechanism in the per-hop cache key before the pin runs, and
its docstring already says every condition mirrors the executor. If the two
disagree the key stops describing what is on disk -- the failure that made
cache_hops=on worse than off before the latent sidecar landed. Both now carry a
comment saying not to change one w…
Close two more bindings the fingerprint could not see
Sandpies+1: Claude Opus 5
0730c62
Review of 5298a45 found the object fix covered one of four shapes a node can
use to hand a configured callable to the model. Both gaps were verified against
the real _model_fingerprint before fixing, and both produced identical
fingerprints for different settings -- the silent-wrong-frames class the batch
exists to close.
A BOUND METHOD loses everything. vars() on one proxies to the underlying
function's __dict__, which is empty, not to the instance, so only __qualname__
survived. A node registering self.forward rather than self is the object bug one
attribute away.
A functools.partial loses everything too: no __name__, no __qualname__, no
__closure__, empty __dict__. It collapsed to the same "fn()" constant a bare
instance did, with its settings sitting untouched in func/args/keywords. That is
the SLA bug one binding away.
_callable_scalars now unwraps all four shapes -- closure, instance, bound
method, partial -- depth-bounded because a partial can wrap a partial.
Corrects a clai…
Fix three holes in hop-cache invalidation
Sandpies+1: Claude Opus 5
5298a45
All three are the same shape: something that changes the rendered frames does
not change the key identifying them. None is live with cache_hops shipping off.
The base checkpoint was not in the key. _model_fingerprint hashed the LoRA
patch keys, the per-key strength scalars and the scalar half of
transformer_options -- all of which describe what was patched ONTO the model,
and none of which describe the model. An int8 build and a bf16 build of the
same architecture under the same LoRA stack produced byte-identical hop keys.
Now hashes the inner model's class, its dtype via ModelPatcher.model_dtype(),
and its parameter count. Two different int8 builds of the same architecture
still match; separating those costs a state-dict walk per run.
_scalars could not see settings carried on an object. The closure walk exists
because H3-SLA-Attention configures itself that way. A node that installs a
configured instance through set_model_patch_replace has no closure at all, and
an instance inherit…
Fix two README claims the code contradicts, document two dials
Sandpies+1: Claude Opus 5
008e0f6
README:133 said "Voice stays as a reference every hop." h3_ref_chain.py:2280
gates it: `hop_voice = voice is not None and (i == 0 or str(hop_script) !=
"next")`. A shot plan forces `next`, so on the default SHOTS path the voice is
a hop 1 reference only -- the chain_00038 behaviour the comment above the line
explains. The console already printed `voice ref stays off this continue`. The
sentence is now conditional rather than wrong; it had produced a public bug
report against working code.
README:576 documented pin_renorm's sigma story and recommended it for 3+ hops.
Section 27 measured sigma inert -- total spread falls across a chain whose
picture is baking -- while `band` took a 12.74% drift to -0.04%. The widget
tooltip has carried this since; the README pointed at the mode that does
nothing and never named the one that works.
Document `mp` in the Reference register. It is live in REF_FIELDS, consumed at
:1916 as cap_mp=, and drawn on every rail row, but the section listed five
sib…
Screenshots that show 1.1, not 1.0.0
Sandpies+1: Claude Opus 5
a21095e
Every shot in the README was taken at 1.0.0, so the text led with the
tabbed panel and the pictures showed the long scrolling column it
replaced -- the headline change of the release was the one thing not
pictured.
Three from the user, on the current build: the four tabs with SCRIPT
open, `lock` and the range button on a shot card, and the MEDIA tab
showing the two things 1.1 added to a reference clip -- the description
field and `video input size`.
docs/img is in .comfyignore, so this changes nothing in the published
package and needs no republish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VB4Dw6F79643vySkf19KV
Decode the reference clip at the size it will actually be used at
Sandpies+1: Claude Opus 5
271a0f9
Section 40 deferred this on the grounds that core already scales reference
video down, so the only cost was disk and load time. Core does scale it down --
in execute(), after load_video has decoded every frame at source resolution,
stacked them, and cast the stack to float32. A 10 s 4K plate spends ~36 GB of
system RAM to hand over ~4.5 GB of pixels core immediately discards.
load_video now computes the target before the loop and passes it to
frame.reformat, so libswscale scales inside the decoder and the full-resolution
RGB array is never built. The default, "match H3", asks core's adapt_canvas
what it would have resized to and never scales up -- a clip already below the
tier is left alone, exactly as core leaves it. Same pixels, different
resampling kernel, none of the memory.
reference_video_size adds the rungs below that (640p..384p) for trading
reference fidelity against memory, which is the feature that was asked for.
Measured on a 226-frame 960x544 plate -- already under the t…
The canvas is a short edge, not an area; and prose reaches the scanner
Sandpies+1: Claude Opus 5
0b085ca
Two corrections, both from evidence that arrived after the fact.
CANVAS. Core's adapt_canvas pins the short edge at 768 and derives the long
edge from the ratio -- its own docstring says so. v1.1 derived both axes from a
megapixel budget and claimed to mirror it. The two agree at no aspect ratio:
16:9 gave 1312x736 against core's 1344x768, 4:3 gave 1152x864 against 1024x768.
The root error was reading "0.98 MP" as 980,000 pixels. 1344x768 = 1,032,192,
and 1,032,192 / 1024^2 = 0.984 -- the quoted number is a mebipixel count of a
short-edge tier. The label was describing exactly the thing I had decided it
could not describe.
_fit_canvas is now core's function with the short edge as an argument, so the
draft tiers run the identical path. The ladder is 768/640/576/512/448, labelled
"768p (0.98 MP)" and so on, with the MP figure being that tier's 16:9 cell.
check_canvas.py imports adapt_canvas and compares against it, rather than
against numbers copied out of it -- the old "area within 5…