Commit Graph
274 Commits
Author SHA1 Message Date
digital-garbage 4661fe6ad5 feat(h3): the causal lane runs on our own model, with no third-party pack
The two entry points the rollout was waiting on — prefill_text and forward_chunk
— are written, and with them the whole lane is ours. raven_causal.py is gone:
its pure parts (step rules, the audio sigma remap, the rollout loop) moved into
h3_causal.py, and its bridge to ComfyUI-MiniMax-H3-RAVEN-Streaming is deleted
along with the probe that hunted for that package in custom_nodes. Nothing has
to be installed for the mode to exist.

How a chunk is cut: ONE full-clip PackedLayout is built by ComfyUI's own
extra_conds, and every chunk is a SELECTION of its rows. So a chunk's RoPE
angles, modality tags and timestep classes are the ones a dense forward would
have given those same rows, and the keyframe pins and reference blocks keep the
rows upstream packed for them — which is what makes i2v and r2v work here
rather than be refused. Those rows are the prefix: cached once as chunk 0, so
media chunk i is cache chunk i + 1 and a sink of 2 pins the prompt plus the
opening shot.

A chunk is committed CLEAN — one extra forward at sigma 0 after its last step.
Caching the last denoising step's K/V would hand every later chunk a context
that still carries that step's noise.

Two failures that used to be a crash fifty blocks deep are now refusals by
name: a clip whose soundtrack does not cover its picture, and a clip too short
to have anything for a cache to remember.

Tests: 1800 python (+1 net; the RAVEN bridge's tests are gone, the plan,
session and real-model checks are new), 68 JS. The new upstream check drives
the REAL DiT at toy width through both lanes and confirms the safety property
holds: with no cache passed, the causal model is bit-identical to stock H3.

Not yet run on the rental.
2026-08-26 19:26:36 +03:00
digital-garbage ac0864e94f fix(editor): the render gains stay in Settings, they just stop being project state
Untying them from the project file was the ask; taking the dials away was not.
The six rows are back in Engine Settings exactly as they were.

What stays from the previous commit is the part that answers the ask: they are
stripped from sampler_inputs on load, so a dial moved in a session applies to
that session and the next open reads the key — or the defaults when no key is
active or the key files have been deleted.

Tests: 1777 python, 68 JS.
2026-08-26 19:09:08 +03:00
digital-garbage 48134120a1 fix(editor): the learned render knobs are key-scoped, not project state
Asked twice, and the first fix answered the wrong question. Clearing a dial on
its default stopped the value being STORED, but it was still a project field —
so deleting every refinement key left the old behaviour applying, and the only
way back was typing the neutral value in by hand.

These knobs are learned from ratings and live in the key's own state. A copy in
the project file was a second source of truth that outlived the key it came
from. So:

  - h3_gain_mode / h3_gain_video / h3_gain_prompt / h3_gain_audio /
    h3_prompt_scale / h3_taste_bias are stripped from sampler_inputs on project
    load, so a project that already carries them heals itself on first open
    rather than needing a hand-edited JSON;
  - they are no longer offered in Engine Settings at all, because offering one
    writes it back the moment it is touched — which is how the second copy got
    there;
  - with no key active the sampler already returns neutral, so deleting the key
    files now genuinely resets the behaviour.

Still widgets on the NODE: a raw ComfyUI graph with no Refiner in it drives
them by hand, which is what h3_gain_mode 'manual' exists for. Key-scoped means
not in the project, not removed.

Tests: 1771 python (+15), 68 JS.
2026-08-26 19:04:33 +03:00
digital-garbage 2790a6263d fix(loaders): point at FunPack LoRA Loader, which applies on its own
Every message about loading the chunk-causal adapter named FunPack Apply LoRA
Weights. That is the wrong node: it is the optional stack producer for
prompt-specific trained strengths. FunPack LoRA Loader loads AND applies from
its own file list, and always has — so the instruction sent the user to add a
node they do not need.

Its own description read the same way ("Loads LoRAs from a FunPack LoRA
stack"), which is where the confusion came from. Both it and the docstring now
say the stack is the narrow case, not the way in.

Tests: 1767 passed (+5).
2026-08-26 19:00:58 +03:00
digital-garbage b5594b00a7 feat(loaders): chunk-causal is a switch on our loader, the adapter is just a LoRA
Both halves now go through nodes we already own, so nothing third-party has to
be installed for the feature to exist.

The diffusion loader gains `chunk_causal`, which RE-CLASSES an already-loaded
H3 in place. That is what removes the need for a separate loader entirely: the
alternative is reimplementing ComfyUI's whole diffusion-model load path so the
adapter can be attached before the ModelPatcher exists and its memory ledger
counts it. Re-classing adds no parameter, renames no key and copies nothing —
only which forward runs changes — so the ledger and offload accounting are
untouched and the switch is idempotent.

The adapter is then an ORDINARY LoRA through FunPack Apply LoRA Weights. Our
loader already scores prefix variants against the model's real key map; it just
did not know this one. RAVEN's keys are `base_model.model.dit.<path>`, so
stripping only `base_model.model.` left `dit.` in front of every key and the
whole file matched nothing, silently. One entry in LORA_KEY_PREFIXES fixes it.

A ComfyUI too old for H3, or a non-H3 model, declines with a reason rather than
raising — one optional switch must not turn the loader into a dead node.

Tests: 1751 passed.
2026-08-26 18:55:58 +03:00
digital-garbage 840098869c feat(h3): our own chunk-causal cache, geometry and DiT subclasses
First half of owning the causal lane outright, so the feature needs no
third-party package installed.

h3_causal.py: a ChunkKVCache with sink/window eviction and host offload, the
chunk cut, and CausalAttention / CausalDiTBlock / CausalMiniMaxH3Model as
subclasses of ComfyUI's own H3 classes — same module names, same state dict, so
weights load with no remapping and a PEFT LoRA attaches through ComfyUI's
ordinary adapter (which already reads lora_A/lora_B keys).

The safety property: with cache=None every forward defers to its parent, so the
dense lane is bit-identical to stock H3 and nothing here can degrade an
ordinary generation.

Geometry is BORROWED, not re-derived. A chunk's RoPE positions must be the ones
the dense layout would have given those rows, so the full clip's grid is built
with upstream's own _frame_grid / _video_grid / _audio_grid and sliced per
chunk. An upstream grid change is inherited rather than silently disagreed with.

Two traps handled explicitly:
  - pack_audio is channel-major over the WHOLE clip, so a chunk's audio rows are
    TWO spans, not one. A contiguous slice would hand the model the wrong half
    of the stereo field, quietly.
  - blocks are RE-CLASSED in place, adopting submodules by reference, never
    wrapped. Wrapping renames the state-dict keys of everything inside, which
    has broken unpatching in this project before.

Tests: 1751 passed (+21). prefill_text/forward_chunk still to come.
2026-08-26 18:53:31 +03:00
digital-garbage e0044425ca feat(loaders): raven_lora on our own diffusion loader
FunPack owns its loading pipeline, so the causal lane must not need a
third-party loader node in the graph. `raven_lora` on FunPack Diffusion Model
Loader turns an ordinary H3 checkpoint into the chunk-causal DiT with the
adapter attached. Same checkpoint either way — it is a LoRA, there is no
separate RAVEN model file, and every message now says so.

The weight load itself is DELEGATED to the RAVEN package rather than
reimplemented, and not for convenience: the adapter is an FP32 activation
residual registered on the base Linear leaves and must be attached before the
ModelPatcher is built. ModelPatcher.model_size() memoises, so an adapter
attached afterwards is invisible to Comfy's memory ledger and never moved by
partial CPU offload — the model would report itself smaller than it is and the
residual would end up on the wrong device.

Selecting a LoRA when the causal class is unavailable RAISES with the fix in
the message, rather than quietly loading a plain model: a model without the
adapter would leave the causal lane reading an attention pattern nothing was
trained for, which reads as a quality problem with no visible cause.

Strength is fixed at 1.0 — the adapter is what teaches the chunked attention
pattern, so "partly on" is not a useful state.

Tests: 1730 passed (+12).
2026-08-26 18:37:37 +03:00
digital-garbage 070563f4d5 fix(h3): the causal lane never loaded the model, so its patches never applied
Found by asking how CFG=1.0 reaches the model. It reaches it through
comfy.sample.sample_custom -> sampling_function, which sets uncond_ = None at
1.0 so the negative branch is never evaluated. Nothing wrong there.

But the causal lane bypasses sample_custom entirely, and sample_custom's
prepare_sampling is also what calls load_models_gpu — which is in turn what
applies a patcher's OBJECT PATCHES (partially_load -> patch_model). So on that
path the weights could still be offloaded AND every object patch FunPack
installs would silently not be there: the per-modality AdaLN gains and the
token-refiner edit (prompt scale + learned taste bias) would all have been
absent, with nothing reporting it.

Loaded once for the whole rollout, before the first forward — not per chunk,
which would fight Comfy's own offload decision all the way down the clip. A
failed load degrades to whatever was already resident and says the patches may
be missing, rather than turning a placement problem into a dead run.

Tests: 1719 passed (+5).
2026-08-26 18:29:01 +03:00
digital-garbage 20bd542af2 feat(h3): the causal chunk lane is wired to the Chain Sampler
A mode on the existing sampler, not a new node. h3_causal_chunks generates the
clip in time chunks that each attend to a KV cache of the ones before them, so
a long clip stays one continuous shot with real memory instead of separate
scenes stitched together. Sink and window are exposed as what they actually
are: chunks pinned from the start, and recent chunks still remembered.

Two defects found while auditing the bridge I had sketched earlier (which a
blanket `git add -A` had swept into an unrelated commit — it was dead code, but
it should not have gone in under that message):

  - it read request.video_channels / audio_channels, which do not exist on
    RAVEN's LatentRequest. First real call would have been an AttributeError.
  - it handed BOTH streams the same sigma. H3 denoises audio on its own shifted
    grid (3 against video's 12), so that is exactly the error h3_audio_clock
    exists to correct, and it bites hardest on the few-step schedules this lane
    is built for. Audio now goes through the same remap, transcribed from
    comfy.ldm.minimax.model.time_shift_sigma and verified equal to the live
    function across the range.

The lane REPLACES the sampler rather than wrapping it, so every reason it
cannot run falls through loudly: not H3, pack not installed, stock
bidirectional model. A rollout that fails mid-way raises rather than quietly
falling back to ordinary sampling, which would return a clip under a mode the
user believes ran. The run states that the wired sampler and CFG are unused on
this path, and that there is no per-chunk redo.

Editor: Settings > Engine > Sampler > Experimental, hidden entirely off H3.

Tests: 1714 passed (+18). Never run on hardware.
2026-08-26 18:06:36 +03:00
digital-garbage 0f05d81db8 fix(studio): the enhancer generated twice on every Editor run
The Movie Editor's builder forces split_by_transitions on for EVERY project, and
the per-scene conditionings replace the base entry — so enhancing the base
prompt as well was a second full generation whose result was then discarded. At
the ~59s per call measured on the rental that is a minute of pure waste per run,
and it looked like thinking mode being stuck on.

It was not thinking: that defaults off and is passed explicitly, so it can never
inherit the repair advisor's own default of True. It was two code paths both
doing the work.

Now the base is enhanced only when the run does NOT split, and the skip says so
on the console rather than being silent. One generation per prompt that is
actually encoded.

Tests: 1696 passed (+6).
2026-08-26 17:48:07 +03:00
digital-garbage 5e9de5223c fix(studio): trim the enhancer by repetition, not by an invented length cap
The 2000-character cap was wrong. H3 has no padding, so every prompt token is a
real sequence row and a long prompt is a legitimate thing to want — an enhanced
one especially. Cutting at a fixed length contradicted the token limit the user
had just set.

Length cannot tell "long and detailed" from "stuck". What distinguishes them is
that a model which has stopped writing starts repeating itself, so that is what
is detected now: three occurrences of the same sentence and the prompt is cut
at the first repeat. A long prompt that never repeats is never touched.

The only remaining length bound is the one the user's own token limit already
implies (~4 chars/token), so there is one control rather than two fighting.
Short recurring lines are exempt — "She smiles." legitimately recurs in a shot
list.

Tests: 1690 passed.
2026-08-25 23:03:22 +03:00
digital-garbage 599e6a7fa8 fix(studio): the enhancer ran to the cap and never said where it ran
Reported from a rental: with the length limit raised to 8192 the enhancer used
all of it. The H3 encoder has no trained decoder head — ComfyUI falls back to
tied word embeddings — so it may never emit a stop token, and max_length stops
being a ceiling and becomes the actual length. Nothing downstream noticed: it
just encoded thousands of tokens of rambling as the prompt.

Now capped at 2000 characters and cut back to the last COMPLETE sentence, never
mid-word, and the status says the model did not stop on its own.

Also reported: it seems to run on CPU. Every generation now reports the device
it executed on, how long it took and the cap that applied — a 32B encoder
sharing a card with the DiT can be placed on the CPU by ComfyUI's loader, which
is minutes per call and invisible from the returned text.

Tests: 1685 passed (+9 enhancer).
2026-08-25 22:58:55 +03:00
digital-garbage cae7353174 feat(h3): chunk-causal rollout core, on our schedule and a selectable step rule
RAVEN's contribution is not a sampler, it is a way of shaping the sequence: cut
the clip into time chunks, carry each to completion, commit it into a KV cache
as clean context, generate the next one attending to that cache. The model gets
real memory of what it already drew, so a long clip stays one continuous shot
instead of a stitched chain.

That shaping is separable from the 4-step consistency sampler it ships with,
and this keeps them apart so each can be dissected:

  - the chunk cut and the cache are RAVEN's (prefill_text / forward_chunk);
  - the SCHEDULE is ours — any scheduler, any step count, bong_tangent included;
  - the STEP RULE is a knob: consistency (what the LoRA is distilled for),
    plain flow euler, or rectified-flow ancestral. So a chunked run can be
    compared against an unchunked one without two variables moving.

Audio chunk boundaries are derived from the shared clock (40 latents/s against
a fixed 24 fps, 17 frames per chunk), not from a hard-coded 28/29 cadence — the
cadence comes out as a consequence and stays right if the grid moves. Verified
against the real H3 grid: 192 frames -> 12 chunks, 320 audio latents, matching
RAVEN's own validated run.

How i2v/r2v survives: `known_chunks` leading chunks are taken as given rather
than sampled, but are still COMMITTED. Chunk 0 comes from FunPack's ordinary
dense path — where pins, references and region locks already work — and only
then enters the cache. The causal lane is never asked to model conditioning
rows it has no layout for, which is exactly what RAVEN's own sampler refuses.

Every model interaction arrives as a callable, so the loop is tested with no
ComfyUI, no RAVEN package and no weights.

Core + 39 tests. Sampler wiring and the rental run are NOT done yet.
2026-08-25 22:51:12 +03:00
digital-garbage 01400b4a2b feat(studio): an LLM enhances the prompt before anything is generated from it
Runs after shortcuts and after $variables resolve, before encoding — the model
is handed exactly the text that would otherwise have been encoded, and the
result is what ComfyUI generates from, so nothing downstream knows it happened.

Not the repair advisor: that one is triggered by a rating and fixes a named
fault. This runs on every generation, needs no key, no rating and no previous
run, and only elaborates. Uses advisor_clip when one is wired (the Studio node
already has the socket), the generation CLIP otherwise.

Multi-scene enhances each scene separately. The editor's scene list is
authoritative on count, so rewriting the scenes as one paragraph and
re-splitting could come back with a different number and desync every clip from
its anchor. One generation per distinct text, cached within the run.

Every failure path returns the ORIGINAL prompt — an enhancer that empties the
prompt on a bad generation would render a blank video and blame the model.
Thinking traces, code fences, preambles and whole-answer quotes are stripped;
quotes inside the prompt survive, because speech is quoted on purpose.

Settings > Engine > Studio: toggle, editable system prompt (new textarea field
kind), length limit, temperature, top-p, thinking. Off by default.

Tests: 1637 passed (+24).
2026-08-25 22:44:26 +03:00
digital-garbage 68bcf109ff fix(h3): the token-refiner edit renamed the checkpoint's own weight keys
Reported from a rental: manual gain mode with the refinement keys deleted died
with "'TokenRefiner' object has no attribute 'inner'". Learned mode was fine,
because with no key the gains are neutral and the installer returns before
building anything — so only manual, which ignores keys, ever built the wrapper.

Wrapping a module in an nn.Module renames every weight beneath it
(token_refiner.blocks.* -> token_refiner.inner.blocks.*). patch_model applies
object patches BEFORE loading weights, so the names were recorded WITH .inner;
unpatch_model restores weights BEFORE unwrapping, and with several patchers
alive off one shared model — one clone per scene, plus one for the AdaLN gains
— another patcher unwraps first and the restore walks .inner on a real
TokenRefiner.

Both wrappers now patch `<module>.forward` through the same object-patch
mechanism instead of replacing the module, so the tree and the state dict stay
exactly as the checkpoint built them. Neither is an nn.Module any more, and
each captures the original bound method rather than re-reading `.forward` at
call time — ComfyUI restores a patch by writing the previous value back, which
can be a live wrapper, so re-reading would nest a scale per scene.

Reproduced against faithful patch semantics before and after. AdaLN gains had
the identical latent hazard and are fixed the same way.
2026-08-25 22:33:18 +03:00
digital-garbage 54fa8e4b43 feat(h3): region-locked anchors, and the taste direction lands after the refiner
Region lock: a keyframe pin can cover part of a frame. Transparent areas of an
i2v anchor are dropped from the packed sequence entirely — not blanked, not
noised, absent — so the model invents them. Row deletion re-derives the layout
from upstream's own output; two patches share one keep-mask so they cannot
disagree. Surfaced through the anchor's alpha, which needs no new wire and works
in the Editor. Excluded pixels are filled with the kept area's mean before the
VAE so no hard edge bleeds in. EXPERIMENTAL: fl2va saw whole condition frames.

Taste bias: the liked direction Studio already learns is now also added AFTER
H3's token refiner, where it lands as sent instead of being mixed across tokens
and normalized away. Relative to the span's own row norm, so one strength means
the same thing on every prompt. How far to push is a fifth learned gain,
centred on 0 and signed; h3_taste_bias is the manual override. Needs 3 liked
runs.

Gain state generalized to per-key centres, bounds and exploration width.

Tests: 1608 passed (+46). Untested on a rental.
2026-08-25 21:48:28 +03:00
digital-garbage 551363d1b8 feat(h3): each rating credits the gain it is actually about
The gains read only the scalar reward, so "Missing concept" — reward +0.10
because quality was fine — nudged every gain slightly TOWARD the run that
missed the prompt, and taught the audio gain something about a complaint
that had nothing to do with audio.

Every rating already carries three per-axis signals in [-1, +1]. Route
them: concept -> prompt + prompt_scale, detail/quality -> video, and audio
keeps the overall reward because no rated axis covers it.

The combiner is min, not mean. These axes are conjunctive — the video gain
has to satisfy detail AND quality — so a satisfied axis must not mask a
failed one. Averaging let it: "Missing details" came out at -0.08 for the
gain that governs detail. It is -1.00 now.

Recovering four parameters from one scalar worked, but only by waiting for
random perturbations to decorrelate. Each rating now teaches the parameter
it names.
2026-08-25 21:27:18 +03:00
digital-garbage ffe2a49a9e feat(h3): render gains are learned from ratings, manual is the opt-in
The four H3 render strengths shipped as hand-set dials. They should not
have: four scalars is a SMALLER search than the sigma profile, which
already converges on ratings alone, so typing them is the user doing work
the loop can do.

Same ES update as the sigma profile, with one difference — that profile is
centred on 0, so its applied value IS its perturbation; these are centred
on 1.0, so the perturbation is (applied - value) and that is what the
reward is credited to. Exploration narrows on a like, widens otherwise,
and is seeded from the run seed so a repeat repeats.

Learned values reach the sampler through the conditioning meta, the bridge
h3 token weighting already uses: Studio owns the key, the sampler owns the
application. h3_gain_mode defaults to "learned" and ignores the widgets;
"manual" uses them as typed and still works with no Refiner in the graph.

Learning runs on every rating regardless of the toggle, the same rule
value_guidance follows.

Also fixes four note_on_change calls added earlier this session that were
missing the required tag argument — they would have raised on the non-H3
and unreadable-tags paths.
2026-08-25 18:48:50 +03:00
digital-garbage 0e50d708c4 feat(h3): edit the token refiner's output, and h3_prompt_scale
Qwen text reaches the DiT through condition_proj + a 2-block token
refiner ending in an RMSNorm. Everything steered today happens BEFORE
that, so the refiner mixes the edit across tokens and its final norm
renormalizes the magnitude away — what lands is not what was learned.

TokenRefinerEdit patches the refiner's output instead: a scale and/or a
bias over a row range, landing unchanged in the space the 50 blocks read.
project_into_refiner_space maps a conditioning-space direction across with
condition_proj's weight only (a direction is a difference of points, so
the bias would move the origin).

Ships h3_prompt_scale: how loudly the prompt is read through attention,
distinct from h3_gain_prompt (how much each block writes back into those
rows). Confined to the prompt rows via minimax_token_tags, so a wired
reference's vision block is left alone. Free, 1.0 = model not cloned.

The bias path is the mechanism for a learned direction and is not wired to
one yet: a single [hidden] vector added to every prompt row is
prompt-length-independent, which is the shape a learned direction needs
here since Qwen does not pad.
2026-08-25 18:39:18 +03:00
digital-garbage b696c92c4a feat(h3): per-modality AdaLN write gain on every DiT block
Each H3 block carries AdalnProj(t_dim, hidden, expand=6, modalities=3):
shift/scale/gate for attention and MLP, in three copies indexed
t_row*3 + tag (video 0, text 1, audio 2). The gate scales what a block
writes back into a row range, so scaling one modality's gate is per-block,
per-stream write strength — the only mechanism in this architecture that
separates prompt, picture and soundtrack at the point of writing.

Three sampler widgets, 1.0 = untouched and the model is not cloned. Free:
one vector multiply per block, no extra model calls.

Sampler-side and self-contained — reads no key, rating or learned
direction, so conditioning steering being off does not disable it and the
Refiner need not be wired at all.

Attached with add_object_patch, restored by ComfyUI on unpatch; nothing
survives the run. Pins and reference images carry the VIDEO tag, so the
video gain moves them too — stated on the console and in the tooltip.
2026-08-25 18:31:48 +03:00
digital-garbage e966b0ab19 fix(sampler): Distilled Flow's s_noise is rectified-flow ancestral now
It stepped the full distance to sigma_next and then added
sqrt(sigma^2 - sigma_next^2) of noise on top. That is the VP variance
formula, and adding noise without shortening the step leaves the latent
noisier at sigma_next than the schedule defines — so s_noise never behaved
like euler_ancestral on H3 or LTXAV at any value.

Lands short (sigma_down) and renoises with flow matching's alpha rescaling:
the arithmetic _sample_const_rf_full already carried, and comfy's
sample_euler_ancestral_RF. At s_noise=1.0 it IS euler_ancestral.

s_noise now means eta (fraction of the step taken stochastically); range
raised to 1.0 so euler_ancestral is expressible. A value carried over from
before does NOT mean what it did — the tooltip says so. Non-CONST models
keep the eps path unchanged. Audio still never gets ancestral noise.
2026-08-25 17:53:45 +03:00
digital-garbage 1bcdd1ce1c fix(sampler): final_correction_steps counts steps that can be corrected
The Heun corrector evaluates the model at sigma_next, and the terminal step
lands on sigma 0 — degenerate, and it returns early before the corrector is
reached. The window was measured back from the end of the schedule, so it
started on that step: final_correction_steps=1, the default, performed zero
corrections on any schedule ending at 0.

Counted over correctable steps instead, so N means N.

Default moves 1 -> 0: fixing the arithmetic alone would have added a model
call to every existing run (~14% on a 7-step schedule). 0 is what every run
has actually been doing. quality_sharpness shares the window by design.
2026-08-25 17:45:13 +03:00
digital-garbage d995132c92 feat(h3): a keyframe pin can sit at any frame, not just the first or last
Upstream PackedLayout computes a pin's time coordinate in two branches and
raises for anything between them. They are the two endpoints of one line:
the packed sequence advances FRAME_RESCALE per PIXEL frame, so
cond_t = text_len + FRAME_RESCALE * pixel_index reproduces both exactly at
every clip length, including the irregular (1,4,4,4,4) latent grouping.

Upstream still builds the layout; this rewrites the time column of the
condition rows it produced. Endpoint-only runs call straight through and are
bit-identical. The rule is re-derived from upstream's own constants before
patching, so a changed frame grid disables the feature instead of misplacing
pins.

Unlocks mid-clip guides on the Chain Sampler, which were refused outright.
EXPERIMENTAL: fl2va trained on first/last pins only, so the coordinate is
representable without being learned. Costs no extra model call.
2026-08-25 16:46:21 +03:00
digital-garbageandClaude Opus 5 6e99b0204f fix(h3): the emphasis line reports counts, never the phrases themselves
It named the three strongest learned phrases as examples. Phrase memory holds
the user's prompt, so every paste of the log carried prompt text out with it —
stripped by hand each time, which is a thing that will eventually be forgotten.

The strongest weight replaces them: it answers the question the examples were
there for (is emphasis doing too much) without quoting anything. Counts stay.

Adds a test on the SHAPE, not on one message — joining phrase text into a log
line is how this would come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:42:05 +03:00
digital-garbageandClaude Opus 5 33227b198d diag(vf): say where the value functions trained, and how big they are
"Training is slow" and "training is slow ON THE CPU with four million
parameters" are different problems with different fixes, and nothing in the log
told them apart — leaving it to be inferred from timings, which is how the
first guess at this went to the disk instead.

One line per run per value function, naming the device and the parameter count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:34:26 +03:00
digital-garbageandClaude Opus 5 8cdee7072f feat(editor): Settings > Appearance gets "Show timeline on hover"
Off by default. On, the timeline sits as a strip — its header, so the title,
transport and Composer stay reachable — and opens to full height while the
pointer is on it or something inside it has focus.

It also opens for the whole of a DRAG. During one the pointer is in drag mode
and a strip that stays shut is a drop target you cannot hit, so dragenter holds
it open and drop/dragend closes it. The tracker counts depth rather than
holding a boolean: dragleave for the element being left fires AFTER dragenter
for the one being entered, so a flag closes the strip mid-drag as the pointer
crosses from one clip to the next.

The strip height is MEASURED from the header rather than fixed: .zone-head
wraps when the zone is narrow (34px min-height, 138px on a normal window), so
any constant clips the very controls the strip exists to keep visible. It is
re-measured on resize. flex: 0 0 auto matters — .zone is a shrinkable column
child, and a height with min-height: 0 let the workspace squeeze the strip to
1px. The body collapses rather than being display:none so the lanes are not
torn down and rebuilt on every open.

A browser preference like the colour scheme, not a project one, and registered
in the same Settings section for that reason.

Also in this commit:
- vf: train wherever there is a GPU, not only when the sample sits on one. The
  x0 value function's snapshot loads with map_location="cpu", so keying on the
  sample left it training on the CPU — the run that named it measured
  "x0 vf: train 10.1s" against 46ms for the same net locally.
- The stage line counted nested sub-stages twice and reported 153% of a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:32:11 +03:00
digital-garbageandClaude Opus 5 a689aaa87d perf(vf): train the value functions on the sample's device, not always the CPU
Measured rather than assumed. The three value functions are ~16s of a 26.5s
rated run, and the phase that costs it is the training, not the disk:

  text cond, dim 5120   3,982,467 params  16.2MB  load 19ms  TRAIN 1068ms  save 26ms
  x0 latent, dim 512      221,955 params   0.9MB  load  3ms  TRAIN   46ms  save  2ms

The nets are tiny in FLOPs and not in parameters — a 5120-wide input makes the
first layer 94% of the weights — and twenty Adam steps over that ran on the
CPU because the checkpoint loads with map_location="cpu". Roughly 1.1s here,
about 7.5s on the rental's vCPU, three times per rated generation.

The conditioning is already on the GPU when there is one, so training moves
there and moves back before returning: the buffer and the checkpoint stay on
the CPU so the file remains portable, and inference elsewhere still finds a CPU
module. Adam's state does not follow module.to(), so it is dropped on the move
rather than migrated — it is rebuilt lazily and never persisted, so a run
starts from fresh moments either way.

Nothing about what training does changes; only where it runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:19:39 +03:00
digital-garbageandClaude Opus 5 8e3c707c81 fix(sampler): a wired reference conditioning is not a second scene
The Chain Sampler reads one conditioning entry per SCENE
(scene_conditionings = positive[:max_scenes]). When Studio stopped discarding
the entries past the first of a wired CONDITIONING — the fix for a reference
surviving while the prompt did not — those entries reached the sampler
untagged, and the r2v reference block was counted as a scene of its own. The
run sampled the shot twice, which from outside is "the model ran a second pass
for no reason".

Studio tags every pass-through entry as a companion; the sampler excludes
companions when counting scenes and hands them to every scene instead, so they
still reach the model. Both halves of the original bug stay fixed: nothing is
dropped, and nothing is mistaken for a scene.

Also fixes the liked-conditioning blend, which was the last payload path still
doing a raw lerp: _v2_shape_compatible ignores sequence length by design, so a
direction learned at 586 positions met a 494-position prompt, threw, and took
the whole pull toward what the user rated well with it. Routed through
_v2_payload_like like every other payload.

Adds stage timers for the nine learning steps the first pass did not cover —
55% of a 26.9s run was accounted for, and the rest was unnamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:06:52 +03:00
digital-garbageandClaude Opus 5 ce046fdfd8 perf(studio): time the learning stages, so a slow run names itself
The encoder tally answered "is it the encoder" — at 24.9s it is 2% — and the
next question is which learning stage the rest is. 2.4s at three rated samples
and 24.9s at five is not smooth growth, and the shape of that curve says
nothing about which stage draws it.

Times the six that can dominate a run: phrase memory, conditioning memory, the
three value functions, and applying the learned direction. One line, stages
over 0.05s only, costliest first.

Measurement only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:56:05 +03:00
digital-garbageandClaude Opus 5 3bb7b78557 perf(studio): keep the key's tensors in a binary sidecar, not as base64 in its JSON
One conditioning delta is 586x3584 floats — several megabytes of base64 inside
a document that is parsed and rewritten IN FULL on every generation. That is
the cost that grows with everything ever learned, and it is paid whether or not
any of those tensors are used.

Tensors now go to <key>.json.tensors.pt and the JSON keeps a named reference
whose id is the tensor's path in the tree ("state.global.liked_dir.direction"),
so the key stays readable and a reader can see what a reference points at.

No migration: a key written before this carries inline blobs, is read exactly
as it stands, and moves to the sidecar on its next save. If the sidecar cannot
be written the tensors stay inline — a slow key is fine, a JSON pointing at a
sidecar that does not exist is not. A reference the sidecar has lost reads as
no tensor, so a direction goes unapplied rather than breaking the run.

Deletion already sweeps it: delete_refinement_key globs every <key>.* sidecar
for exactly this reason. Session Reset removes it explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:54:01 +03:00
digital-garbageandClaude Opus 5 47b78e3c52 perf(studio): report what the refinement key costs, and what is making it big
The key is read and rewritten IN FULL on every run and holds base64
conditioning tensors, so its cost grows with everything ever learned — which
is what "2.0s, then 2.4s" looks like from outside. Timing the read and the
write, with the file size and the entry counts of the collections that can
grow without bound, says whether that is where the time goes and which
collection to bound first.

Measurement only. Nothing about what is stored or how changes here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:48:35 +03:00
digital-garbageandClaude Opus 5 b0c22c85dd perf(studio): classify every uncertain phrase in one encode instead of one each
A phrase's vector came from encoding that phrase alone — a full pass of the
text encoder per phrase, and on H3 that encoder is Qwen3-VL-32B. The phrases
are encoded together now, as one joined text, and each vector is the mean of
the rows its own tokens occupy; the tokenizer's offset mapping identifies those
rows exactly, so this is a lookup rather than an approximation.

Each phrase is located at the position it was joined at rather than by
searching the text, because one phrase can be a substring of another and a
search would hand both the same rows.

Falls back to one encode per phrase, unchanged, when the tokenizer offers no
offsets or the spans do not line up with the conditioning that came back. The
cosine step is shared by both paths so a phrase scores the same either way, and
the batched pass is labelled separately in the per-run encoder tally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:36:23 +03:00
digital-garbageandClaude Opus 5 8ae739ad9c perf(studio): embed the category descriptions once per encoder, not once per run
CATEGORY_DESCRIPTIONS is a class attribute of eight fixed strings, so the
vectors depend only on the encoder. They were re-embedded every run — eight
full passes of Qwen3-VL-32B on H3 — to arrive at the same eight vectors.

Held for the process, keyed by the encoder, behind a WEAK reference: pinning a
text encoder in VRAM to save eight passes would be the wrong trade. An identity
check sits on top of the id() key because ids are reused once a model is freed,
and a stale hit would classify phrases against another encoder's geometry.

Not a cache of generated content — nothing user-typed goes into it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:34:08 +03:00
digital-garbageandClaude Opus 5 7cd063302a perf(studio): count text-encoder passes by purpose, per run
The encoder is the most expensive thing Studio touches — on H3 it is
Qwen3-VL-32B — and most calls to it are bookkeeping rather than the prompt:
embedding eight category descriptions to classify a phrase by cosine
similarity, embedding a memory to ask whether it is about this scene.

Every call now records its purpose, its cost, and whether the per-run cache
served it. One line per run says where the time went and what share of Studio
it was, so "26 seconds before generation starts" becomes a list of candidates
rather than a guess. The tally is per run and never persisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:32:44 +03:00
digital-garbageandClaude Opus 5 c454e3b2d9 perf(studio): don't locate phrase ranges for a patch that already declined
Locating a phrase's token range runs the text encoder once per phrase — on H3
that is the same multi-billion-parameter encoder the prompt goes through, up
to eight times. The ranges exist only to feed the attn2 direction patch, and
H3 has no cross-attention for it to hook, so the patch declined on the very
next line and the results were dropped.

The same question is now asked before the work instead of after it. LTX is
unaffected: it reads attn2, so the ranges are still computed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:26:43 +03:00
digital-garbageandClaude Opus 5 71f41259b3 feat(lora): per-block application works on any model, not just LTX
Per-block scaling is derived from the LoRA's own deltas — which blocks it put
energy into — so it is a property of the adapter, not of the architecture. Two
things made it LTX-only anyway:

The gate required image_model in {ltxv, ltxav} and mode ltx2, so a model
FunPack had not heard of got the global fallback without anything ever reading
its weights. Whether per-block can run is now discovered from the patches:
fewer than two numbered blocks and the existing fallback takes over, as before.

The key pattern only matched transformer_blocks.N. — MiniMax H3 names its
stack blocks.N., so even with the gate open every H3 patch counted as global
and per-block would have been a silent no-op. The pattern now covers blocks,
transformer_blocks, double_blocks, single_blocks, joint_blocks and layers.

The semantic anchors (PAG 14, STG 14/19) were absolute indices measured on
LTX's 28-block DiT and meant nothing on a stack of another depth. They are
positions now; a 28-block model still resolves to exactly 14 and 19, so LTX
behaviour is unchanged, and a deeper stack gets the equivalent blocks.

Declines, loudly, when a model numbers blocks in two separate stacks
(Flux-style double/single): both start at 0, so one scale would land on two
unrelated blocks.

Untested on a real model — local runs cannot load one. 13 new tests, 11 of
which fail against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:10:49 +03:00
digital-garbageandClaude Opus 5 e9ba41f62a feat(lora): a LoRA's filename no longer decides how relevant it is
The suggester split each LoRA's FILE NAME into words and matched them against
the prompt; the overlap scaled how far that LoRA's weight moved. That guessed
what a LoRA does from what someone happened to call it, which is a different
question from how much weight it has earned.

Relevance now comes from the type set on the LoRA and the categories present
in the prompt. Weight tuning itself is unchanged — ratings, axis feedback and
the learned offset all behave as before. Behaviour change: a LoRA whose name
echoes the prompt will move less per rating than it used to, and one with an
opaque name will move more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:04:22 +03:00
digital-garbageandClaude Opus 5 c22207c1f9 diag(lora): say which file dropped weights, and how much of it
The drop message named three keys and a count with no file attached, and
note_on_change keyed it globally, so in a stack only the first LoRA's shapes
were ever printed. Each file now carries its name into its own log key, and
the count is reported as dropped-of-matched: 51 of 400 is a variant mismatch,
51 of 51 is a file that contributes nothing.

Adds one stack-level line. Every LoRA dropping every weight is not a property
of the LoRAs but of the model they were all measured against, and that is the
reading worth making explicit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:42:22 +03:00
digital-garbageandClaude Opus 5 494894ef3b fix(lora): a delta that fits by count but not by shape is not a fitting delta
comfy merges with mm(...).reshape(weight.shape) and adds the result. The
reshape succeeds on any pair with the right element count, so loosening the
drop test to a count comparison let transposed and differently fused deltas
merge in silence. A correct adapter always produces exactly
(weight.shape[0], weight.shape[1:].numel()); anything else fitting by count
corrupts the weight at load and surfaces mid-render as an all-NaN latent.

The two abstentions kept: a locon mid and a padded reshape target are still
left to comfy rather than dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:35:21 +03:00
digital-garbage 1c500f0641 fix(studio): a learned direction survives a prompt whose length changed
"conditioning memory failed (size of tensor a (492) must match tensor b
(499))" — the compatibility gate checks the channel width and the batch
and deliberately not the sequence length, so the payload passed and the
lerp threw. On LTX the encoder pads to a fixed length and this never
happened; Qwen does not pad, so on H3 the length moves with every prompt
edit and the entire learned direction was dropped for that run.

_resize_conditioning_sequence_like was already in the file for exactly
this, with zero callers. Both the blend and the repel now use it, and say
once when a payload had to be resampled.

An exact-length payload is untouched, so the common case — same prompt,
re-rolled — stays bit-exact.
2026-08-23 10:24:19 +03:00
digital-garbage dba33fb4f6 fix(h3): weight every learned phrase that is in the prompt, uncapped
Two bugs, one line apart.

Phrase memory accumulates everything the session has rated, including
phrases from prompts since edited away. The cap to 8 was applied BEFORE
checking whether a phrase is in the current prompt, so those could take
all eight slots on rank alone, match nothing, and leave phrases actually
in the prompt unweighted — while appearing in the status line as
"weighted", which is where they were seen.

And there was no reason for a cap. Locating a phrase here is a substring
find and a scan of the tokenizer's offset map. The 8 was carried over
from _v2_find_phrase_token_ranges, which locates a phrase by RE-ENCODING
it through the text encoder — eight of those is eight Qwen3-VL-32B
forward passes, and the cap is the whole point there.

The status line now names what was APPLIED, and counts how many learned
phrases are in this prompt rather than how many exist.
2026-08-23 10:15:37 +03:00
digital-garbage 3148a5f62a fix(h3): the tokenizer's labels are not the user's prompt
"<Picture n>: " and "<Audio n>: " are emitted by the H3 tokenizer, not
written by anyone. An AUDIO reference is the awkward one: it contributes
its label and NO vision block, so it lands inside the same run of text
tags as the prompt.

Placement is now anchored at the END of that run rather than requiring
the run to nearly equal the prompt, so any number of label tokens in
front no longer shifts the bias — or refuses it outright, which is what
the slack cap did.

Protection follows the same boundary: the whole tensor is stashed and
everything before the verified prompt start is restored, so a label is no
more steerable than the picture is. Without a tokenizer it falls back to
the end of the vision block — a label may stay steerable, the reference
never does.
2026-08-23 09:55:21 +03:00
digital-garbage 7b5746fceb fix(h3): protect to the reference/prompt boundary, not the image tags
An r2v conditioning is laid out reference-first: the "<Picture n>: "
label, the vision block, then the encoded prompt. So the boundary is the
only thing Studio needs — rows before it are the picture and are not
Studio's to touch, rows from it on are the prompt and are.

Protecting by the image tags alone left the label steerable, since it is
text sitting between the two.

prompt_region() is now the single place that walk lives; prompt_base and
choose_encoded_text both read it instead of repeating it.
2026-08-23 09:49:44 +03:00
digital-garbage 409f9cf58d fix(h3): place the emphasis from the text that was actually encoded
Skipping was not the answer — Studio has the text. What it did not have
was a way to know WHICH text: it keeps the raw prompt on its own port and
expands per scene, while the editor hands a linked node the already
expanded string. So its copy and the encoded one can differ.

server._expanded_link_texts is exactly what a node outside Studio
received, and the builder now forwards it. And the choice is CHECKED, not
guessed: minimax_token_tags says how many text positions the tensor
holds, so each candidate is tokenized and the one that fits wins, which
also yields the exact base offset. No arithmetic fallback, no assumption
about whose string it was.

Emphasis is only skipped when no candidate tokenizes to the tensor's text
run at all, which means the encoded string genuinely is not here.
2026-08-23 09:47:21 +03:00
digital-garbage 799af4786e fix(h3): don't place phrase emphasis by arithmetic on a wired conditioning
"placed from the conditioning tail" means the tags were not usable and
the bias fell back to cond_len minus the tokens measured here. That holds
only when Studio encoded the text. On a wired conditioning another node
did, from a string this one never saw — the editor hands a linked node
the pre-expanded prompt while Studio keeps the raw one — so the
arithmetic lands the bias on a window of the wrong words with nothing
saying so.

The modality tags are the only trustworthy placement there. Without them
the emphasis is skipped and says why. Studio's own encodes still use the
tail, where the measurement is its own.
2026-08-23 09:42:56 +03:00
digital-garbage 9579163e20 fix(studio): Bounded Attention was overwriting the wired conditioning
512 -> 197 positions inside finalize. _v2_apply_bounded_attention is
always-on: it re-encodes the prompt through CLIP, concatenates the two
sentence halves and REPLACES the tensor with the result. That result has
no reference image in it, because this node never received one.

So on a reference run the picture and the prompt were never both in the
conditioning the sampler got — which half survived depended on which
step ran last, and the only visible symptom was a tag-count mismatch
three functions away that read like housekeeping.

Entries that arrived on a wire now carry funpack_conditioning_owner, and
no step that rebuilds a conditioning by encoding text will touch one.
Same rule the scene split got. Bounded Attention still runs normally on
Studio's own encodes.

Every stage of _v2_finalize_conditioning is length-checked and names
itself in the log if it changes one, so the next such bug reports itself
instead of surfacing as a trimmed tag count.
2026-08-23 09:40:01 +03:00
digital-garbage f530f9fad5 fix(studio): stop discarding a wired conditioning's entries past the first
An r2v node emits the reference block and the encoded prompt as separate
entries. _v2_extract_conditioning reads entry 0 and refine_v2 rebuilt a
single-entry list around it, so everything after the first entry was
dropped: the tensor kept the vision block and lost the prompt. 197
positions of a 512-position tag map, of which 191 are image marks — the
right character, speaking invented syllables.

The extra entries now pass through unchanged. Steering is fitted to the
entry Studio measured; applying it blind to a companion entry of another
length and purpose would be a guess, and discarding them is the bug.

The tag-surplus message said the reference was missing. It is the
opposite — a leading run of image marks IS the reference, and what gets
trimmed is the prompt.
2026-08-23 09:35:33 +03:00
digital-garbage ef5e46303b fix(studio): the scene split no longer discards a wired reference
The split re-encodes each scene from text and its entries replace
output_conditioning WHOLESALE. It re-establishes H3's visual conditioning
from source_image / h3_references — and nothing in the editor pipeline
sets either, because the reference lives on the R2V node, not on Studio.
So a wired reference conditioning was replaced by text-only encodes: not
appearance drift, a different person.

_v2_transition_scene_conditionings returns None when clip is None, so the
wired conditioning survived with CLIP disconnected. That is why
disconnecting CLIP "fixed" it, and why every fix upstream of the split
changed nothing.

Studio cannot split a conditioning it did not build. When one is wired it
keeps it, for every scene, and says so.
2026-08-23 09:24:51 +03:00
digital-garbage 154926523b fix(studio): a reference image's rows are not steerable
H3 tokenizes a reference INTO the conditioning, and comfy's
token_tags_from_embeds_info marks the whole vision block (plus its
flanking vision_start/vision_end) as 0 with text at 1. Those rows are
Qwen's encoding of the picture.

Every steering path is a lerp or add over the whole tensor — learned
directions, taste pull, concept deltas, the absolute store,
negative_erase — so the encoded picture was being pulled toward a
direction learned from TEXT. That is the character appearance drift, and
it only happens with CLIP connected because that is what builds the
directions.

The tag map existed and nothing read it. The vision rows are now stashed
before any steering and put back after all of it. Text rows still steer.
2026-08-23 09:20:52 +03:00
digital-garbage 94afb883fd fix(h3): weight against the text that was encoded, not the raw one
The spans were measured on funpack_scene_text — the RAW prompt, still
holding `$style` and unrolled shortcuts — while the conditioning was
built from the resolved string. Different string, so every span lands on
the wrong words or the placement guard rejects the run. funpack_encode_text
is the one that was encoded; use it.

Phrase memory also stores raw text, so a phrase could carry `$style`
while the prompt had it resolved and match nothing. Variables are a plain
substitution, so both sides are put in the same form. Shortcuts are not
peeked: a /trigger rolls per seed and peeking would give a different roll
from the encoded one, so an unrolled phrase failing to match is correct.
2026-08-23 09:15:20 +03:00