Skip to content

feat: Video generation#9163

Open
lstein wants to merge 195 commits into
invoke-ai:mainfrom
lstein:lstein/feature/wan-video-support
Open

feat: Video generation#9163
lstein wants to merge 195 commits into
invoke-ai:mainfrom
lstein:lstein/feature/wan-video-support

Conversation

@lstein

@lstein lstein commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds basic support for AI-based video generation using the Wan 2.2 family of text-to-video and image-to-video models. Currently it can only be used through the workflow editor.

invoke-videos.mp4

This PR adds the following support:

  • A new Video type, with support for upload, thumbnail generation, and metadata storage.
  • New movie player functionality in the viewer.
  • A Wan Denoise Video node for denoising Wan 5D tensors into latents
  • A Latents To Video node for decoding the denoiser latents
  • A few image nodes for selecting frames from videos and concatenating videos together
  • Template workflows that show typical text-to-video and image-to-video graphs.

Important notes

Testing plan

  • Smoke-test T2V Lightning (832×480, 81 frames, 4 steps, CFG 1.0)
  • Smoke-test I2V Lightning with a 16:9 source image
  • Verify auto-switch to new video on completion, full-resolution first-frame preview, inline playback
  • Upload an MP4 from disk; verify it appears in the gallery and plays
  • Drag a gallery video onto a Video Primitive; verify the field populates
  • Exercise the video concatenate node by joining two videos using the various transition options
  • Run on Windows / macOS to confirm the imageio FFmpeg path works on those platforms

Getting Started Hints

Run uv pip install to pick up the new dependency on the ffmpeg library, and of course rebuild the front end.

Install the following from the starter model collection:

  • Wan 2.2 TI2V-5B (Q4_K_M) -- a very small, low-quality video generator suitable for 12 GB VRAM or less

  • Wan 2.2 T2V A14B High Noise (Q4_K_M) - text-to-video transformer, rough phase

  • Wan 2.2 T2V A14B Low Noise (Q4_K_M) - text-to-video transformer, refiner phase

  • Wan 2.2 T2V Lightning High Noise (4-step, V1.1) - turbo LoRA, rough phase

  • Wan 2.2 T2V Lightning Low Noise (4-step, V1.1) - turbo LoRA, refiner phase

The corresponding models for image-to-video are:

  • Wan 2.2 I2V A14B High Noise (Q4_K_M)
  • Wan 2.2 I2V A14B Low Noise (Q4_K_M)
  • Wan 2.2 I2V Lightning High Noise (4-step, V1.1)
  • Wan 2.2 I2V Lightning Low Noise (4-step, V1.1)

The encoder and VAE should download as dependencies.

Give it a spin! There are several working templates in the Workflow library that you can start with:

  • Text to Video - Wan 2.2 -- basic text to video. Select the TI2V-5B model if you are low on VRAM and leave "Transformer (Low Noise) empty. If you have >=16 GB, you can use the high-quality A14B models, apply the high noise transformer model to the Main Model "Transformer" field, and the low noise transformer model to the "Transformer (Low Noise)" field. Select the models you downloaded for the standalone VAE and T5 Encoder fields. Type in a prompt, and use the default values for CFG, image dimensions, frames and steps.
  • Text to Video - Wan 2.2 Lightning -- This is the same as above, but has loaders for the high and low noise lightning LoRAs which will reduce the number of required steps to 4.
  • Image to Video - Wan 2.2 -- basic reference image to video. You need to provide a reference image and a prompt describing what to do with it. The reference image will be the first frame of the image. The image should have the same aspect ratio as the desired video, but doesn't need to be exactly the same dimensions.
  • Image to Video - Wan 2.2 Lightning -- as above, but with nodes for the two lightning LoRAs for dramatic speedups.

The models work best across a limited series of dimensions. The ones I've tested are:

  • 720 x 480
  • 480 x 720
  • 832 x 480
  • 480 x 832

On my RTX 5060Ti it takes 3-4 minutes to generate a video when using the two Lightning LoRAs.

If you have lots of VRAM you can try increasing the frame count, but these videos get big fast. Alternatively, you can create a workflow that captures the last frame of the original video, generates a new video on top of it, and concatenates the two together. I've got a workflow that works well for this, but haven't added it to the branch yet.

🤖 Generated with help from Claude Code

lstein and others added 30 commits May 9, 2026 10:33
Foundation + TI2V-5B MVP + A14B dual-expert MoE for Wan 2.2 image
generation. Wan was trained on video but is competitive with leading
open-source image models when run at num_frames=1; this commit wires
that path into InvokeAI.

Phase 0 — Foundation:
- BaseModelType.Wan + WanVariantType {T2V_A14B, TI2V_5B}
- SubModelType.Transformer2 for the dual-expert MoE
- MainModelDefaultSettings per variant
- step_callback Wan branch (16-channel preview; 48-channel TI2V-5B
  falls back to slicing first 16 channels until proper factors land)
- Frontend enums + node colour

Phase 1 — TI2V-5B Diffusers MVP:
- Main_Diffusers_Wan_Config probe (variant from transformer_2/ +
  vae/config.json::z_dim, with filename heuristic fallback)
- WanDiffusersModel loader (subclasses GenericDiffusersLoader)
- WanT5EncoderField, WanTransformerField (with dual-expert slots),
  WanConditioningField, WanConditioningInfo
- New invocations: wan_model_loader, wan_text_encoder, wan_denoise,
  wan_image_to_latents, wan_latents_to_image
- FlowMatchEulerDiscreteScheduler integration with on-disk config load
- RectifiedFlowInpaintExtension reused for inpaint
- 5D <-> 4D shape juggling: latents stay 4D in InvokeAI's pipeline,
  re-add T=1 only inside the transformer call / VAE encode-decode

Phase 2 — A14B dual-expert MoE:
- Probe reads boundary_ratio from model_index.json
- Loader emits both transformer (high-noise) and transformer_low_noise
  (low-noise expert at transformer_2/) for A14B
- _ExpertSwapper in wan_denoise drives GPU residency between experts:
  high-noise for t >= boundary_ratio * num_train_timesteps, low-noise
  below. Only one expert locked at a time so the cache can evict the
  other - relies on existing CachedModelWithPartialLoad to handle
  oversized models on lower-VRAM GPUs.
- guidance_scale_low_noise field for separate low-noise CFG override

Tests:
- 24 passing tests covering probe variant detection, default settings,
  noise sampling, end-to-end denoise on a synthetic transformer (CPU),
  dual-expert boundary swap, CFG branch
- 1 heavy-test placeholder gated by INVOKEAI_HEAVY_TESTS=1 for the
  real-weights smoke test

Phase 3+ deferred: standalone VAE/encoder configs, GGUF, LoRA,
ControlNet, ref image, inpaint UI, frontend wiring, starter models.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 adds standalone VAE and UMT5-XXL encoder configs so users can run
GGUF-quantized Wan transformers (Phase 4) without installing the full
~30 GB Diffusers pipeline.

VAE configs:
- VAE_Checkpoint_Wan_Config + VAE_Diffusers_Wan_Config (16-channel A14B
  vs 48-channel TI2V-5B, distinguished by decoder.conv_in z_dim).
- 16-channel files share the AutoencoderKLWan architecture with Qwen
  Image; disambiguated via filename heuristic ("wan" in name -> Wan,
  otherwise -> Qwen Image). Mirror exclusion in QwenImage's probe.
- VAELoader gets a Wan branch that builds AutoencoderKLWan(z_dim=...)
  via init_empty_weights, mirroring the QwenImage single-file pattern.
- Existing standard VAE probe excludes both QwenImage- and Wan-style
  state dicts.

UMT5-XXL encoder:
- New ModelType.WanT5Encoder + ModelFormat.WanT5Encoder.
- WanT5Encoder_WanT5Encoder_Config probes the diffusers folder layout
  (text_encoder/config.json with model_type=umt5, or flat layout with
  config.json at root). Refuses full Wan pipelines.
- WanT5EncoderLoader handles both layouts and loads UMT5EncoderModel +
  AutoTokenizer.

Component-source plumbing:
- WanModelLoaderInvocation now exposes wan_t5_encoder_model and
  component_source pickers (mirrors QwenImage pattern). Resolution
  order: standalone > main (if Diffusers) > component_source. Required
  when the main model is a single-file format in Phase 4.

Bug fix in wan_text_encoder:
- Tokenizer was loading via AutoTokenizer.from_pretrained(<root>)
  directly, which fails for nested layouts where files live in
  <root>/tokenizer/. Now routed through the model cache so the
  registered loaders handle layout differences correctly.

Frontend:
- New type guards (isWanVAEModelConfig, isWanT5EncoderModelConfig,
  isWanMainModelConfig, isWanDiffusersMainModelConfig) and hooks/
  selectors (useWanVAEModels, useWanT5EncoderModels,
  useWanDiffusersModels). New zSubModelType / zModelType / zModelFormat
  enum entries for transformer_2 and wan_t5_encoder.

Tests:
- 16 new tests covering z_dim detection, VAE checkpoint/diffusers
  probes, the bidirectional Qwen-vs-Wan filename deferral, and the
  UMT5 encoder probe (nested + flat + T5 + full-pipeline rejection).
- Total Wan test count: 41 passing, 1 heavy-test placeholder skipped.
- Full config test suite (63 tests) still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): unbreak frontend lint after Wan additions

Five issues turned up running `make frontend-lint`:

1. wan_denoise.py used `from __future__ import annotations`, which made
   the `invoke()` return annotation a string ('LatentsOutput'). The
   InvocationRegistry's `get_output_annotation()` returns the raw
   annotation, so OpenAPI generation crashed with
   `'str' object has no attribute '__name__'`. Removed the future-import
   and added `Any` to the typing imports.

2. ModelRecordChanges.variant didn't list WanVariantType, so the
   generated schema's install/update endpoints rejected `t2v_a14b` and
   `ti2v_5b`. Added it.

3. Regenerated frontend/web/src/services/api/schema.ts from the live
   backend so it now includes BaseModelType.wan, ModelType.wan_t5_encoder,
   SubModelType.transformer_2, ModelFormat.wan_t5_encoder, the Wan
   variants, all Wan invocation types and their conditioning/transformer
   field types.

4. modelManagerV2/models.ts: added `wan_t5_encoder` to the category map,
   `wan` to the base color/long-name/short-name maps, the two Wan
   variants to the variant-name map, and `wan_t5_encoder` to the
   format-name map.

5. ModelManagerPanel/ModelFormatBadge.tsx: added `wan_t5_encoder` to
   FORMAT_NAME_MAP and FORMAT_COLOR_MAP.

`make frontend-lint` now passes cleanly (tsc, dpdm, eslint, prettier).
All 41 Wan Python tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

chore(wan): drop unused FE exports flagged by knip

These were forward-compatibility wiring for Phase 9 (the FE graph
builder) that has no consumers yet; knip rightly flagged them. Removed
or de-exported. They'll come back when the graph builder lands and
needs them.

- common.ts: zWanVariantType drops `export` (still used internally by
  zAnyModelVariant).
- types.ts: drop isWanMainModelConfig, isWanDiffusersMainModelConfig,
  isWanVAEModelConfig (no callers). The remaining
  isWanT5EncoderModelConfig is used by models.ts. WanT5EncoderModelConfig
  type drops `export` (still used as the type guard's narrowing target).
- modelsByType.ts: drop the six unused useWan*/selectWan* hooks +
  selectors and their type-guard imports.

`make frontend-lint` (tsc + dpdm + eslint + prettier + knip) now green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(wan): use *-Diffusers HF repo names in plan

The Wan-AI org publishes two flavours of each release:
  * Wan-AI/Wan2.2-{TI2V-5B,T2V-A14B,I2V-A14B}            ← upstream native
  * Wan-AI/Wan2.2-{TI2V-5B,T2V-A14B,I2V-A14B}-Diffusers  ← convertible

The native release has _class_name=WanModel in config.json and ships
weights flat at the repo root with no transformer/, vae/, text_encoder/
subdirs. It is not loadable by Diffusers' WanPipeline.from_pretrained.

Update plan doc to reference the -Diffusers repos throughout (probe
notes, starter-model entries) so the plumbing path matches what the
Diffusers loader actually expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): accept 0 as 'unset' sentinel for guidance_scale_low_noise

The frontend renders Optional[float] inputs with default 0 in the
numeric input rather than passing null/unset. Combined with ge=1.0,
this caused every wan_denoise invocation to fail Pydantic validation
with "Input should be greater than or equal to 1" until the user
manually entered a value (or knew to leave the field disconnected).

The validation error was rejected before invocation logging, so it
never showed up in the server log either - making the failure hard to
diagnose.

Relaxing the constraint to ge=0.0 and treating values below 1.0 as the
"fall back to primary Guidance Scale" sentinel. The user's natural FE
default (0) now works as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): correct preview dimensions and colors for TI2V-5B

Two bugs in the Wan branch of the diffusion step callback:

1. Wrong dimensions. The reported preview size hardcoded `* 8` for the
   spatial downscale ratio, but TI2V-5B's Wan2.2-VAE uses 16x. A
   1024x1024 target was being announced to the FE as 512x512.

2. Wrong colors. The previous fallback for 48-channel TI2V-5B latents
   sliced the first 16 channels and applied the standard 16-channel
   Wan-VAE projection. Those channel layouts are unrelated, so the
   projection produced meaningless colors.

Adding the proper Wan2.2-VAE 48-channel RGB projection matrix (and
bias) from ComfyUI's Wan22 latent format, and selecting the right
matrix + spatial-scale by latent channel count: 16 → A14B (Wan VAE,
8x), 48 → TI2V-5B (Wan2.2-VAE, 16x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): honor model's _class_name when building scheduler

TI2V-5B's scheduler_config.json declares _class_name=UniPCMultistepScheduler
with flow_shift=5.0. The previous code hardcoded
FlowMatchEulerDiscreteScheduler.from_pretrained(...), which silently
constructed a default-config FlowMatch instead of the UniPC the model
expects. The mismatched noise schedule manifests as soft / under-denoised
faces and global graininess in the final images.

Now: read scheduler_config.json, look up the named class on the diffusers
module, and instantiate that class via from_pretrained. UniPC and
FlowMatch share the same step()/set_timesteps()/sigmas/num_train_timesteps
interfaces, so the denoise loop works transparently for either.

A14B continues to use FlowMatchEulerDiscreteScheduler when its scheduler
config says so (its reference is FlowMatchEuler with shift=8.0). Falls
back to FlowMatchEulerDiscreteScheduler defaults when no on-disk config
is available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): match diffusers WanPipeline tokenizer length and latent dtype

Two divergences from the Diffusers reference that were hurting image
quality (soft / grainy / distorted faces at default settings):

1. Tokenizer max_sequence_length was 226 in wan_text_encoder, but the
   model was trained with 512-token sequences. The upstream native
   config.json has text_len: 512, and Diffusers' WanPipeline.__call__
   default is 512 (overriding _get_t5_prompt_embeds's stale 226 default).
   Wan's cross-attention sees padded zeros past the prompt's actual
   length but expects to be looking at a 512-position context window.

2. Latents were stored in bf16 throughout the denoise loop. Diffusers'
   WanPipeline.prepare_latents explicitly uses dtype=torch.float32 and
   only casts to the transformer's dtype right at the forward call:
       latent_model_input = latents.to(transformer_dtype)
   Storing in bf16 between steps accumulates ~40 steps of bf16
   quantization on the scheduler's small per-step deltas. Now
   latent_dtype = torch.float32 throughout, with a per-step cast for
   the transformer forward pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

chore(wan): add diffusers reference comparison script

scripts/wan_diffusers_reference.py runs a Diffusers-format Wan 2.2
checkpoint directly via WanPipeline.from_pretrained, with the same
arguments InvokeAI's wan_denoise uses. Use to A/B against InvokeAI
output when image quality is questionable.

Defaults to enable_model_cpu_offload so the script fits on 16 GB cards
where the full pipeline (transformer + UMT5-XXL + VAE) would otherwise
OOM. --offload {model,sequential,none} controls the strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds single-file GGUF support for Wan 2.2 transformers, the path that
makes A14B usable on consumer GPUs (~7 GB/expert at Q4_K_M instead of
~28 GB at bf16).

Probe (configs/main.py):
- New helpers: _has_wan_keys (Wan vs Qwen/FLUX/Z-Image fingerprint via
  condition_embedder.text_embedder.linear_1 + patch_embedding);
  _detect_wan_gguf_variant (16ch -> A14B, 48ch -> TI2V-5B from
  patch_embedding.weight.shape[1]); _detect_wan_gguf_expert (filename
  heuristic for high_noise / low_noise / none).
- Main_GGUF_Wan_Config(base=Wan, format=GGUFQuantized, variant, expert).
  Tolerates the ComfyUI 'model.diffusion_model.' / 'diffusion_model.'
  prefixes via _has_wan_keys' multi-prefix scan.
- Registered in factory.py.

Loader (model_loaders/wan.py):
- WanGGUFCheckpointModel mirrors the QwenImage GGUF pattern:
  gguf_sd_loader -> strip ComfyUI prefix -> auto-detect arch from state
  dict shapes (num_layers, inner_dim, ffn_dim, text_dim, in_channels,
  num_heads = inner_dim/128) -> init_empty_weights +
  load_state_dict(strict=False, assign=True).

Loader invocation (wan_model_loader.py):
- New 'Transformer (Low Noise)' picker: optional second GGUF for the
  A14B dual-expert MoE. Auto-swaps if the user wired the experts in
  the wrong order. Warns when an A14B GGUF is loaded without a paired
  low-noise expert (single-expert run, degraded quality).
- GGUF mains require either a standalone VAE+encoder or a Diffusers
  Component Source (which can also supply boundary_ratio).
- Diffusers main path unchanged (still pulls both experts from
  transformer/ + transformer_2/).

Tests (tests/.../test_wan_gguf_config.py):
- 14 tests across key fingerprint, variant detection, expert filename
  heuristic, and the full probe (A14B high/low, TI2V-5B, GGUF rejection,
  unrecognised state-dict rejection, explicit override).

Total Wan tests: 55 passing (no regressions). FE lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): support QuantStack-style GGUFs and standalone Diffusers VAE

The city96 Wan 2.2 GGUF repos have been removed from Hugging Face,
leaving QuantStack as the surviving distributor. QuantStack ships the
native upstream Wan key layout (text_embedding.0/2, self_attn/cross_attn,
ffn.0/2, head.head, head.modulation, ...) rather than the diffusers
naming city96 used; biases are stored as F16 rather than BF16; and the
standalone Wan VAE installs as a flat AutoencoderKLWan folder which the
generic loader rejects. Three fixes:

1. Probe now recognises both diffusers and native key layouts via a new
   _is_native_wan_layout helper; _has_wan_keys accepts either text-proj
   fingerprint.

2. GGUF loader converts native -> diffusers keys (mirroring diffusers'
   convert_wan_transformer_to_diffusers) and unwraps non-quantized
   GGMLTensors to plain tensors at compute_dtype. The unwrap is needed
   because conv3d isn't in GGMLTensor's dispatch table, so the F16
   patch_embedding bias would otherwise hit conv3d against bf16 latents.

3. VAELoader gains a VAE_Diffusers_Wan_Config branch that loads
   AutoencoderKLWan directly; the generic path can't handle a flat
   single-class folder when a submodel_type is provided.

Adds 12 tests covering the native layout (probe + converter + unwrap).
Verified end-to-end against Wan2.2-T2V-A14B-Q4_K_M from QuantStack:
1095 tensors round-trip key-for-key against WanTransformer3DModel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probe + config (LoRA_LyCORIS_Wan_Config):
  - Detects Wan LoRAs in three layouts: diffusers PEFT, native upstream PEFT
    (ComfyUI), and Kohya (both naming variants).
  - Anti-pattern guards prevent collisions with Anima (Cosmos DiT q_proj
    convention), QwenImage (transformer_blocks), Flux (double/single blocks),
    and Z-Image (diffusion_model.layers).
  - Optional ``expert: "high" | "low" | None`` field; auto-detected from
    filename (high_noise / low_noise / hyphenated / concatenated variants).

Key conversion (wan_lora_conversion_utils):
  - Native upstream keys (self_attn/cross_attn, ffn.0/2) -> diffusers
    (attn1/attn2, ffn.net.0.proj / ffn.net.2).
  - Strips ``transformer.``, ``diffusion_model.``, ``base_model.model.transformer.``
    prefixes from PEFT-style keys.
  - Kohya layer names mapped through an explicit longest-match table.
  - Output paths use diffusers naming so the LayerPatcher can resolve them
    against WanTransformer3DModel parameter paths.

Loader integration:
  - Adds BaseModelType.Wan branch to LoRALoader._load_model.

Invocation nodes (wan_lora_loader.py):
  - WanLoRALoaderInvocation: single LoRA with auto/both/high/low target field.
  - WanLoRACollectionLoader: list of LoRAs, auto-routed by each LoRA's
    recorded expert tag.
  - Output WanLoRALoaderOutput carries the WanTransformerField with updated
    ``loras`` / ``loras_low_noise`` lists.

Denoise integration:
  - _ExpertSwapper now manages both the model_on_device context and the
    LayerPatcher.apply_smart_model_patches context per expert. LoRA patches
    are entered after device load and exited before device release, with
    fresh iterators per swap.
  - GGUF (quantized) experts request sidecar patching so GGMLTensor weights
    aren't touched directly.
  - Low-noise expert falls back to the primary loras list when
    ``loras_low_noise`` is empty (matches WanTransformerField semantics).

Tests: 81 new tests covering probe accept/reject across formats, anti-pattern
guards on competing architectures, converter round-trips for all three
layouts, invocation target resolution + routing + duplicate guards, and the
_ExpertSwapper lifecycle (lora context opens/closes in the right order
around the device swap, quantized flag forwards, no-LoRA path skips the
patch context, re-entering the same label is a no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): probe Wan LoRA before Anima in the config union

Native-PEFT Wan LoRAs (lightx2v's Lightning, most ComfyUI-trained Wan
LoRAs) carry keys like ``diffusion_model.blocks.X.cross_attn.k.lora_A.weight``.
Anima's probe matches on the bare ``cross_attn``/``self_attn`` substring —
it does not require the Anima-specific ``_proj`` suffix nor any of the
``mlp``/``adaln_modulation`` Cosmos DiT markers — so these Wan LoRAs were
classified as ``BaseModelType.Anima`` because Anima happened to run first.

Reorder the LyCORIS section of ``AnyModelConfig`` so Wan probes first.
Wan's probe is strictly more restrictive (it rejects Anima's ``_proj``
attention suffix via the anti-pattern guard added in the previous commit),
so Anima LoRAs are still correctly classified after this reorder.

Existing users with mis-tagged installs need to delete the affected LoRA
records and reinstall.

Adds two regression tests: a union-ordering assertion, and a sanity check
that demonstrates Anima's probe *would* match Wan native keys if asked
directly — pinning the constraint that motivates the ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

chore(i18n): add Wan2.2 T5 Encoder model-manager label

The frontend source already references ``modelManager.wanT5Encoder``;
the locale key was added with a casing typo (``want5Encoder``). Fix
the key so the Wan T5 Encoder model type renders its display name
correctly in the model manager UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-implementation after the first attempt — which used CLIP-vision
conditioning — was reverted. Wan 2.2 I2V-A14B does NOT use a CLIP-vision
encoder (the Diffusers repo ships ``image_encoder: [null, null]`` in
``model_index.json``); instead it conditions on a reference image by
VAE-encoding it and concatenating the resulting latents (plus a
first-frame mask) to the noise latents along the channel dim. The I2V
transformer therefore has ``in_channels=36`` (16 noise + 16 ref-image
latents + 4 mask) vs ``in_channels=16`` for T2V.

Taxonomy:
  - Re-adds ``WanVariantType.I2V_A14B``.

Probes:
  - Diffusers: ``_detect_wan_variant`` reads ``transformer/config.json::in_channels``;
    36 → I2V_A14B, 16 → T2V_A14B (both share the dual-expert layout).
  - GGUF: ``_detect_wan_gguf_variant`` recognises ``in_channels=36`` from the
    patch_embedding tensor shape and emits I2V_A14B.

Backend extension (``backend/wan/extensions/wan_ref_image_extension.py``):
  - ``preprocess_reference_image`` resizes + normalises to a 5D pixel tensor.
  - ``encode_reference_image_to_condition`` VAE-encodes the image and stacks
    a 4-channel first-frame mask on top, producing the
    ``[1, 20, 1, H/8, W/8]`` condition tensor the denoise loop consumes.
  - Mirrors diffusers ``WanImageToVideoPipeline.prepare_latents`` with
    ``num_frames=1`` and ``expand_timesteps=False``.

Invocation node (``wan_ref_image_encoder.py``):
  - "Reference Image - Wan 2.2": image + VAE + width/height pickers.
  - Output ``WanRefImageConditioningField`` carries the condition tensor
    name plus the dimensions used (so the denoise step can validate dim
    parity).

Denoise integration:
  - ``WanDenoiseInvocation`` gains an optional ``ref_image`` field.
  - Variant gate: rejects ref_image on T2V_A14B and TI2V-5B with a clear
    error before doing any work.
  - Dimension gate: rejects ref-image width/height mismatch vs denoise.
  - At every transformer call, concatenates the 20-channel condition
    tensor to the 16-channel noise latents along the channel dim before
    passing to the transformer (giving the 36-channel input I2V expects).

Tests: 14 new across the probe, the extension, and the denoise loop.
The synthetic ``_ZeroTransformer`` test stand-in now mirrors the real
I2V transformer's ``in_channels=36, out_channels=16`` asymmetry by
slicing its zero output back to 16 channels when the input is 36-wide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): derive GGUF out_channels from proj_out shape (I2V support)

The GGUF loader was setting ``out_channels = in_channels`` which is wrong for
Wan 2.2 I2V-A14B: that variant has ``in_channels=36`` (16 noise + 16 ref-image
latents + 4 first-frame mask, concatenated by the denoise loop) but
``out_channels=16`` since the transformer only predicts the noise component
back. Loading an I2V GGUF would build a transformer with the wrong proj_out
shape and crash:

  RuntimeError: Error(s) in loading state_dict for WanTransformer3DModel:
    size mismatch for proj_out.weight: copying a param with shape
    torch.Size([64, 5120]) from checkpoint, the shape in current model is
    torch.Size([144, 5120]).

(144 = 36 * 4, 64 = 16 * 4 — patch_size=(1, 2, 2) → prod=4)

Read out_channels directly from the ``proj_out.weight`` shape in the state
dict. This is correct for all three Wan 2.2 variants without needing to know
the variant in advance.

Also tighten the num_layers fallback: T2V_A14B and I2V_A14B share 40 layers;
only TI2V-5B has 30. The fallback is rarely hit in practice (the per-block
count comes from the state dict scan), but the previous code would have
defaulted I2V_A14B to 30 layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(model): make Anima LoRA probe mutually exclusive with Wan

InvokeAI's ``Config_Base.CONFIG_CLASSES`` is a Python ``set``, so iteration
order during model probing is non-deterministic across process restarts.
First-match-wins ordering in ``AnyModelConfig`` is documentation only — it
has no effect on which config is iterated first.

Anima's previous probe accepted any state dict containing the substring
``cross_attn`` or ``self_attn``, which collides with Wan's native LoRA key
layout (``diffusion_model.blocks.X.cross_attn.q.lora_down.weight``). Both
probes accepted Wan native LoRAs (including lightx2v's Lightning T2V and I2V
distillations), and the ``matches.sort_key`` tiebreaker only disambiguates
by ModelType, not within LoRA configs. So which config "won" depended on
dict hash order — sometimes Wan, sometimes Anima.

The previous mitigation reordered the AnyModelConfig union to put Wan
before Anima. That worked by luck and was inherently fragile.

Tighten Anima's probe to require Cosmos-DiT-exclusive subcomponents:
``mlp``, ``adaln_modulation``, or ``_proj``-suffixed attention names
(``q_proj``/``k_proj``/``v_proj``/``output_proj``) — none of which appear
in any Wan LoRA. Wan native uses bare ``.q``/``.k``/``.v``/``.o`` on
``self_attn``/``cross_attn``, and ``ffn.N``/``ffn.net.N`` instead of ``mlp``.

The new strict detectors live alongside the original loose ones so the
Anima conversion utility (which runs after probing) still works.

Regression tests in ``test_wan_lora_probe_independence.py`` cover:
- I2V Lightning V1 (the bug-triggering LoRA), T2V Lightning V2, Wan Kohya
  and Wan diffusers PEFT layouts — Wan probe accepts, Anima probe rejects.
- Anima PEFT and Kohya layouts — Anima accepts, Wan rejects.
- A meta-test that runs every LoRA config in CONFIG_CLASSES against the
  Lightning state dicts and asserts exactly one accepts — this catches
  ANY future probe collision, not just Wan vs Anima.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): defer expert model loading in _ExpertSwapper to avoid cache thrash

The swapper used to take pre-loaded ``LoadedModel`` handles at construction:

    high_info = context.models.load(self.transformer.transformer)
    low_info  = context.models.load(self.transformer.transformer_low_noise)
    swapper = _ExpertSwapper(high_info=high_info, low_info=low_info, ...)

With dual ~9 GB A14B GGUF experts plus the ~10 GB UMT5-XXL encoder competing
for the same RAM cache, the LRU policy frequently dropped one expert by the
time the denoise loop swapped into it. The model manager then emitted

    [MODEL CACHE] Locking model cache entry ... but it has already been
    dropped from the RAM cache. This is a sign that the model loading
    order is non-optimal in the invocation code (See ... invoke-ai#7513).

and reloaded the weights from disk (~1.2s extra per swap).

Refactor the swapper to take the ``ModelIdentifierField`` plus the
``InvocationContext`` and call ``context.models.load(model_id)`` lazily
inside ``get()``. Each swap obtains a fresh handle, the LRU window is
small, and the warning goes away.

Config metadata (used to compute ``is_quantized``) is read upfront via
``context.models.get_config()`` — that's metadata, not weights, so it
doesn't put pressure on the cache.

Tests: existing swapper lifecycle tests refactored to use a fake context
whose ``models.load`` is logged. A new ``test_lazy_load_per_swap_not_upfront``
pins the regression — it asserts ``models.load`` is NOT called at swapper
construction, only at first get() per expert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The denoise_mask wiring + RectifiedFlowInpaintExtension integration in
wan_denoise.py was put in place during Phase 2/3 alongside the rest of
the denoise loop. Phase 8 of the plan was about ensuring this path
worked and is locked in by tests.

Three new tests under TestWanDenoiseInpaint:

1. test_preserved_region_matches_init_exactly: builds a half/half mask
   (left = preserve, right = regenerate in user-side convention), runs
   full denoise with the synthetic zero-output transformer, and asserts
   the preserved half of the final latents equals the init exactly while
   the regenerated half does not. Pins the mask-inversion + per-step
   merge behavior.

2. test_inpaint_requires_init_latents: a mask without init latents must
   raise a clear ValueError — the merge has nothing to weld back to.

3. test_no_mask_path_is_unchanged: regression that adding the inpaint
   extension didn't perturb the non-inpaint codepath (with init latents
   + denoising_start=0.5 but no mask, the loop just runs img2img).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(frontend): add I2V_A14B to Wan variant zod enum + manager label

Phase 7 added the I2V_A14B backend variant. The frontend's zod enum
(features/nodes/types/common.ts:zWanVariantType) and the model manager's
variant-label map (features/modelManagerV2/models.ts) were still on the
two-variant list, so:

  - ModelIdentifierField inputs with ui_model_variant filters on Wan
    couldn't list I2V models.
  - The model manager UI showed a raw 'i2v_a14b' string instead of the
    human label.

Phase 9 (full linear-view wiring — type guards, hooks, params slice,
graph builder, tab UI) is in progress on a follow-up commit; this lands
the two small enum fixes first so the I2V probe / install paths work
correctly end-to-end with the existing FE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the minimum frontend wiring needed to generate Wan 2.2 images from
the linear view:

  - buildWanGraph.ts (new): text-to-image graph (model_loader →
    text_encoder × 2 → denoise → l2i). Diffusers main model only —
    transformer, VAE and UMT5 encoder all resolve from the same repo, so
    no Wan-specific params slice fields are required yet. CFG-skip
    branch when guidance_scale ≤ 1.0.
  - useEnqueueGenerate / useEnqueueCanvas dispatchers: route
    base === 'wan' to buildWanGraph.
  - graph/types.ts: add wan_l2i / wan_i2l / wan_denoise / wan_model_loader
    to the relevant node-type unions.
  - addTextToImage / addImageToImage: include wan_denoise / wan_l2i so
    width/height are wired correctly and the txt2img helper accepts the
    Wan l2i node.
  - isMainModelWithoutUnet: include wan_model_loader (Wan has no UNet,
    same as the other modern bases).
  - metadata.py: add wan_txt2img / wan_img2img / wan_inpaint to the
    generation_mode enum (img2img / inpaint pieces land next).
  - schema.ts: regenerated to pick up the metadata enum + new
    Wan invocations.

Pieces left in Phase 9: params slice (standalone VAE / T5 / GGUF
low-noise / LoRA / ref-image fields + selectors), img2img + I2V + inpaint
branches in the graph builder, and Wan-specific UI components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): Phase 9 piece #2 - GGUF support and CFG-Low control in linear view

Adds the three Wan-specific params + UI controls that gate GGUF workflows
plus a separate low-noise CFG slider for A14B users.

Params slice:
  - wanTransformerLowNoise (the second-expert GGUF for A14B)
  - wanComponentSource (Diffusers Wan model providing VAE + UMT5-XXL
    when the main is a GGUF)
  - wanGuidanceScaleLowNoise (optional separate CFG for the low-noise
    expert; null = fall back to the primary CFG)

Plus a `selectIsWan` selector for accordion gating.

UI components:
  - ParamWanModelSelects.tsx (Advanced accordion): two model pickers —
    Transformer (Low Noise) filtered to Wan GGUF mains, and VAE/Encoder
    Source filtered to Wan Diffusers mains. Mirrors the
    ParamQwenImageComponentSourceSelect structure.
  - ParamWanGuidanceScaleLowNoise.tsx (Generation accordion): slider +
    number input with an "auto" indicator when cleared. Default 3.5
    matches the diffusers reference 4.0 / 3.0 split.

Wiring:
  - Generation accordion: ParamWanGuidanceScaleLowNoise shown when base
    is wan, scheduler excluded for wan (same pattern as Anima/Qwen).
  - Advanced accordion: ParamWanModelSelects shown when base is wan, and
    Wan excluded from the SD-family VAE/CFG-rescale blocks.
  - buildWanGraph.ts: forwards the three new params to the model loader
    and denoise nodes (transformer_low_noise_model, component_source,
    guidance_scale_low_noise) and adds them to the graph metadata.

Hooks/types:
  - useWanDiffusersModels + useWanGGUFModels in modelsByType.ts.
  - isWanDiffusersMainModelConfig + isWanGGUFMainModelConfig type guards.
  - Three new locale strings (wanComponentSource, wanTransformerLowNoise,
    wanGuidanceScaleLowNoise[Auto]).

GGUF workflow now works end-to-end in the linear view: pick a Wan GGUF
main, set Transformer (Low Noise) to the paired second-expert GGUF, set
VAE/Encoder Source to any Diffusers Wan repo (TI2V-5B is convenient at
~12 GB) — generate produces an image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): UX polish on the Wan linear-view controls

Bundles four small fixes applied during a usability review of the Wan
linear-view section (piece #2):

1. **Filter Main vs Transformer (Low Noise) dropdowns by expert tag.**
   The Wan GGUF probe records each file's ``expert`` field
   (``"high"`` / ``"low"`` / ``"none"``) via filename heuristic.
   - ``MainModelPicker``: hides ``expert === 'low'`` Wan GGUFs so users
     can't accidentally wire a low-noise expert as the primary main.
   - Transformer (Low Noise) picker (``useWanGGUFLowNoiseModels``):
     shows ``expert === 'low'`` Wan GGUFs only.

   Diffusers Wan mains and TI2V-5B aren't affected — they don't carry
   the ``expert`` field on their config schema. The backend's auto-swap
   safety net stays in place.

2. **Match the primary CFG slider's range.** The Wan low-noise CFG
   slider was constrained to 1–10 while the primary CFG ranges 1–20.
   With the diffusers reference 4/3 split, the low-noise slider thumb
   sat noticeably further right than the primary — visually misleading.
   Both sliders now share the 1–20 range with marks at [1, 10, 20].

3. **Label fits the form column.** "CFG (Low Noise)" → "CFG (Low)" so
   the slider fits cleanly next to its label instead of overlapping.

4. **Indicator state for the low-noise CFG slider.** Replaced the inline
   "(auto)" / "(same as cfg)" text — which kept overlapping the slider
   regardless of how short the label got — with an X-only reset button
   that's only visible when the user has set an explicit value. Absence
   of the X conveys auto/fallback state without any text overhang.

5. **Friendlier Transformer (Low Noise) placeholder.** "Second-expert
   GGUF for A14B (pair with the high-noise main)" → "Add for full
   detail" — concise nudge for users who haven't paired the second
   expert yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): Phase 9 piece #3 - linear-view img2img branch

Adds Wan 2.2 image-to-image to the linear view, mirroring the Qwen Image
pattern. The mode switches on the canvas state — pure-prompt runs go
through addTextToImage as before; canvas runs with an init image go
through addImageToImage which wires a fresh wan_i2l (Image to Latents -
Wan 2.2) node between the init image and the denoise's `latents` input,
honoring the existing denoise_start slider.

buildWanGraph:
  - Drops the txt2img-only guard, branches on generationMode.
  - img2img: spins up a wan_i2l node and hands it to addImageToImage
    alongside the existing denoise / l2i / modelLoader (as vaeSource).
  - inpaint / outpaint still fail loudly — pieces #4-#6.

graphBuilderUtils.getDenoisingStartAndEnd:
  - Adds 'wan' to the simple-linear case (denoising_start = 1 -
    denoisingStrength). Note: Wan's flow-matching schedule is "sticky"
    on the init compared to SDXL — users will likely need denoisingStrength
    ≥ 0.7 to see substantial change, matching the user-found 0.15-0.3
    denoising_start sweet spot from earlier img2img testing. We may
    revisit this with an exponent rescale (like FLUX uses) if the
    response curve feels off.

addImageToImage:
  - Adds 'wan_i2l' to the i2l-node-type union so the Wan i2l can be
    threaded through the shared helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): add wan_denoise to addImageToImage/addInpaint/addOutpaint type checks

Three sibling graph-helper utilities had the same modern-base list as
addTextToImage did, and the buildWanGraph img2img branch tripped one of
them at canvas-Generate time:

    error  [generation]: Failed to build graph
    {name: 'Error', message: 'Wrong assertion encountered'}

The else-branch in each helper assumes 'denoise_latents' (the SD1.5/SDXL
legacy path) and asserts that — failing for any modern base not listed
above the branch. addTextToImage was already updated in Phase 9 piece #1;
this catches the parallel cases that the img2img/inpaint/outpaint flows
go through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): Phase 9 piece #4 - linear-view inpaint and outpaint branches

Wires Wan 2.2 inpaint and outpaint through the existing addInpaint /
addOutpaint helpers. The backend's RectifiedFlowInpaintExtension was
plumbed into wan_denoise.py back in Phase 8 (commit ab54617); this
just connects the FE.

buildWanGraph:
  - generationMode === 'inpaint' → spin up a wan_i2l, call addInpaint
    with denoise + l2i + modelLoader (used as both vaeSource and
    modelLoader since the Wan model loader carries the VAE).
  - generationMode === 'outpaint' → parallel branch with addOutpaint.

addInpaint:
  - i2l-node-type union now includes 'wan_i2l' (the addImageToImage and
    addOutpaint type unions already do — different union shapes).

metadata.py:
  - generation_mode literal adds "wan_outpaint" alongside the existing
    wan_txt2img / wan_img2img / wan_inpaint entries.

isMainModelWithoutUnet already includes wan_model_loader (Phase 9 piece
create_gradient_mask when Wan is the main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): Phase 9 piece #5 - linear-view I2V branch (raster as reference image)

Wan 2.2 I2V-A14B models condition on a reference image whose VAE-encoded
latents are concatenated to the noise along the channel dim each step
(in_channels=36 on the I2V transformer). In the linear view this maps
cleanly onto the existing canvas raster layer: pick an I2V model, drag
an image to raster, generate.

buildWanGraph:
  - Fetch the modelConfig early so the variant gate (i2v_a14b vs the
    rest) can drive the branch shape instead of being a post-hoc check.
  - I2V + txt2img: fail loudly ("Switch to the canvas tab and drag an
    image to the raster layer"). I2V models won't produce useful output
    without a reference, and the backend would crash trying to
    concatenate a missing condition tensor.
  - I2V + img2img: pull the raster image via the canvas compositor,
    wire it through a wan_ref_image_encoder (which VAE-encodes it and
    builds the 4-mask + 16-latent condition tensor backend-side), then
    feed the result into denoise.ref_image. Denoise runs from fresh
    noise (denoising_start=0, no init_latents) — the ref image is
    cross-attention/concat conditioning, not a noise-trajectory anchor.
  - I2V + inpaint/outpaint: fail clearly. Combining ref-image
    conditioning with a denoise mask is conceptually possible but the
    backend interaction hasn't been validated end-to-end.

metadata.py:
  - Adds "wan_i2v" to the generation_mode literal so the metadata field
    on I2V renders correctly.

T2V flows (txt2img / img2img / inpaint / outpaint) are unchanged for
non-I2V Wan variants (T2V-A14B and TI2V-5B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): enforce multiple-of-16 dimensions to match transformer patch grid

Wan 2.2's transformer has ``patch_size=(1, 2, 2)``: it patch-embeds with
stride 2 then un-patches by 2. Combined with the VAE's 8x spatial scale,
canvas H/W must be a multiple of ``8 * 2 = 16`` — not just 8 — for the
patch round-trip to land exactly. Otherwise the latents and noise
prediction disagree by one in the spatial dim and the scheduler step
fails:

    RuntimeError: The size of tensor a (147) must match the size of
    tensor b (146) at non-singleton dimension 3

(here latent_w=147 → patch_w=73 → un-patched_w=146 ≠ 147)

This was silent for T2V at 1024x1024 (already a multiple of 16) but
fired for I2V at non-multiple-of-16 canvas sizes.

Fixes:

- ``optimalDimension.getGridSize``: Wan moves from the default 8 case to
  the multiple-of-16 case (alongside flux / sd-3 / qwen-image / z-image
  which have the same patch arithmetic). The canvas bbox UI now snaps
  Wan dimensions to multiples of 16.

- ``wan_denoise.py`` and ``wan_ref_image_encoder.py``: bump width/height
  ``multiple_of`` from 8 to 16. Defense-in-depth — workflow-editor
  users won't be able to send a non-16-aligned dim either.

Existing backend tests (23 passing) still hold — 1024 is divisible by 16
so the test fixtures didn't exercise the off-by-one path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): show negative prompt box in Wan linear-view

Wan was missing from SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS, so the
linear-view negative-prompt input was hidden even though the Wan denoise
node already wires negative conditioning when CFG > 1
(buildWanGraph.ts:67-75). Adds 'wan' to the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): Phase 9 piece #6 - Wan LoRA collection in linear view

Adds Wan LoRA wiring to buildWanGraph, mirroring the Qwen Image pattern.
The shared LoRASelect / LoRAList UI in the linear view already filters
LoRAs by the selected main model's base, so Wan LoRAs surface
automatically when a Wan main is picked — no UI changes needed.

addWanLoRAs (new):
  - Filters state.loras.loras to enabled Wan LoRAs.
  - For each LoRA: spawns a ``lora_selector`` node and threads it
    through a single ``collect`` collector.
  - Routes the collector into a ``wan_lora_collection_loader`` which
    sits between modelLoader and denoise — modelLoader.transformer →
    loader, then loader.transformer → denoise (rerouting the original
    modelLoader → denoise edge).
  - Emits per-LoRA metadata so PNG metadata + workflow restore work.

The dual-expert routing (high-noise vs low-noise vs untagged) is
handled entirely on the backend by ``WanLoRACollectionLoader`` based on
each LoRA's recorded ``expert`` tag (set by the probe from the filename
heuristic in piece #5 of Phase 5). The FE just hands over the bag of
LoRAs; no per-list FE plumbing needed.

buildWanGraph:
  - Calls addWanLoRAs(state, g, denoise, modelLoader) after the base
    transformer edge is in place. The helper is a no-op when no Wan
    LoRAs are enabled, so it's safe to call unconditionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fix(wan): detect LoRA variant and filter by main model

Wan 2.2 A14B (inner_dim=5120) and TI2V-5B (inner_dim=3072) LoRAs are not
interchangeable — applying one against the wrong main model crashes the
layer patcher with a tensor-shape error (e.g. A14B Lightning on TI2V-5B
mains produced ``shape '[3072, 3072]' is invalid for input of size 26214400``).

Probe Wan LoRAs' inner-dim at install time and record the family on a new
``variant`` field (``a14b`` / ``5b`` / null). The LoRA picker in the linear
view hides incompatible variants when the user selects a main, and the
graph builder filters any still-enabled mismatches at submit time with a
warning. Untagged LoRAs (probe couldn't identify) pass through so they
aren't silently hidden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

feat(wan): ref-image panel, GGUF readiness, and auto-default sources

Wan 2.2 I2V now uses the global Reference Images panel (same UX as Qwen
Image Edit and FLUX.2 Klein) instead of pulling the conditioning image
from a canvas raster layer. Adds:

  - WanReferenceImageConfig zod type + isWanReferenceImageConfig guard;
    integrated into the ref-image discriminated union, settings panel,
    layer hooks, and validators.
  - 'wan' added to SUPPORTS_REF_IMAGES_BASE_MODELS, but the panel only
    shows for the i2v_a14b variant (T2V and TI2V-5B don't consume ref
    images, so the panel is hidden for them).
  - buildWanGraph I2V branch reads the first enabled wan_reference_image
    from refImagesSlice; the canvas-raster-as-ref path is removed. I2V
    now only supports txt2img mode (canvas img2img/inpaint/outpaint
    assert with a clear message).

GGUF Wan readiness check: GGUF mains carry only the transformer, so the
loader needs a Diffusers Component Source (or standalone VAE + UMT5-XXL
encoder) to resolve the VAE and text encoder. Without one, enqueue is
now blocked with a clear reason. The low-noise A14B partner expert
remains optional (loader falls back to the high-noise expert when it's
missing).

Adds standalone Wan VAE and Wan T5 Encoder selectors to the Advanced
accordion (Qwen pattern). Wires them as vae_model / wan_t5_encoder_model
on the wan_model_loader node — backend priority is standalone > diffusers
main > component source.

Auto-default on Wan selection (so GGUF users don't have to fiddle with
Advanced): when the new main is a Wan GGUF, fill the Component Source,
standalone VAE, and standalone T5 encoder with first available matches
if not already set. Component Source is matched by variant family
(A14B GGUF prefers an A14B Diffusers; TI2V-5B prefers a TI2V-5B
Diffusers) since the two families use different VAE channel counts
(16 vs 48); within A14B, T2V and I2V share VAE/encoder so they're
interchangeable as a source. Runs on every Wan selection (including
Diffusers -> GGUF switches), only fills empty slots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wan 2.2 starter pack (selected when the user picks the Wan 2.2 bundle)
brings up the minimal-cost path to running A14B T2V end-to-end:

  - Standalone UMT5-XXL encoder and A14B VAE (so GGUF mains don't need
    a full Diffusers download for their VAE/encoder sources).
  - T2V A14B Q4_K_M and Q8_0 GGUF expert pairs (high + low noise).
  - T2V Lightning V1.1 Seko rank-64 LoRA pair (4-step inference).

Additional Wan 2.2 starter models browseable from the model manager:

  - Full Diffusers T2V A14B, I2V A14B, and TI2V-5B.
  - I2V A14B Q4_K_M and Q8_0 GGUF expert pairs + Lightning V1 LoRA pair.
  - TI2V-5B Q4_K_M and Q8_0 GGUFs + the 48-channel TI2V-5B VAE.

Each "high noise" GGUF lists its low-noise partner plus the shared VAE
and UMT5-XXL encoder as dependencies, so installing one of them pulls
in everything the loader needs. QuantStack's HighNoise/LowNoise file
naming and lightx2v's high_noise_model/low_noise_model.safetensors are
both picked up by the existing filename heuristic in the GGUF probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs(wan): add Wan 2.2 hardware requirements

Adds Wan 2.2 A14B (T2V/I2V) and TI2V-5B rows to the hardware
requirements table with rough VRAM/RAM guidance per quantization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…one VAE/T5

Wan-specific metadata fields embedded by the graph builder
(wan_transformer_low_noise, wan_component_source, wan_vae_model,
wan_t5_encoder_model, wan_guidance_scale_low_noise) had no recall
handlers in features/metadata/parsing.tsx, so recalling an image's
parameters would leave these fields empty. Adds a handler for each
that dispatches the matching paramsSlice action and renders a row in
the metadata viewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships two default workflows in the library, tagged so they appear in
"Browse Workflows" under the wan2.2 / text to image / image to image
tags:

  - Text to Image - Wan 2.2: full T2V/TI2V-5B graph (model loader,
    positive + negative encoders, denoise, l2i). Exposes the five
    model slots, prompts, steps, dual CFG, and dimensions.
  - Image to Image - Wan 2.2: I2V A14B graph that adds a
    wan_ref_image_encoder. Exposes the reference image input plus
    the standard fields.

Both follow default-workflow rules: IDs prefixed with default_,
meta.category = "default", and no references to user-installed
resources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a parallel video pipeline alongside the existing image pipeline so the
gallery can host MP4 alongside PNGs. Implements:

- New service modules (parallel to image equivalents):
    video_records/    record store + sqlite impl
    video_files/      disk file store (mp4 + first-frame webp thumb)
    videos/           orchestrating service
    board_video_records/   board <-> video association
- migration_32 creates `videos` and `board_videos` tables
- /api/v1/videos/ router: upload, list, get DTO, /full (with HTTP Range
  so HTML5 <video> seek/scrub works), /thumbnail, /metadata, star/unstar,
  delete, batch delete, board add/remove
- LocalUrlService.get_video_url and SimpleNameService.create_video_name
- imageio[ffmpeg] dep for video encode (used in later phases)
- Wires all four new services into InvocationServices, dependencies.py,
  api_app.py, and three test fixtures

Verified end-to-end against an in-memory db + tmp output dir: upload,
probe, save (file + thumbnail + record), DTO build, list, delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /api/v1/gallery/items/ and /api/v1/gallery/items/names returning a
unified time-sorted stream of images + videos so the frontend can render
them interleaved with a single virtualized query.

- gallery_common: GalleryItem discriminated union (kind + name + shared
  fields + nullable video duration/fps), GalleryItemRef, names result
- gallery_default: SqliteGalleryService implements UNION ALL across the
  images and videos tables, applying identical filters (origin/category/
  is_intermediate/board_id/search) to each half; pagination via outer
  ORDER BY + LIMIT/OFFSET; counts are summed across the two halves
- URLs are resolved at row -> DTO conversion time so each item routes to
  the correct /api/v1/images or /api/v1/videos endpoint
- Wired into InvocationServices, dependencies.py, api_app.py, and the
  three test fixtures

Existing /api/v1/images endpoints are unchanged so any non-gallery
consumers (queue, recall, metadata workflows) continue to work as-is.

Verified e2e: 2 images + 2 videos inserted in alternating order, both
list_items and list_item_names return the correct interleaved order;
category filter narrows to a single kind; starring an item bumps it to
the top when starred_first=True.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the typed API surface and upload integration so videos can be
uploaded through the same gallery upload button that handles images.

Schema: re-ran pnpm typegen against the running backend to pick up
VideoDTO, VideoRecordChanges, GalleryItem, GalleryItemKind,
GalleryItemRef, GalleryItemNamesResult and the two new paginated
result types.

RTK Query (services/api/endpoints/videos.ts) - parallel to images.ts:
listVideos, getVideoDTO, getVideoMetadata, getVideoNames, uploadVideo,
deleteVideo / deleteVideos, changeVideoIsIntermediate, starVideos /
unstarVideos, addVideoToBoard / removeVideoFromBoard. Imperative helpers
(getVideoDTO, getVideoDTOSafe, uploadVideo, uploadVideos) and the
useVideoDTO convenience hook ride alongside, mirroring the image side.

Tag types and invalidation: added Video / VideoList / VideoMetadata /
VideoNameList / BoardVideosTotal / GalleryItemList / GalleryItemNameList
to the api root. Board-affecting mutations now invalidate the polymorphic
gallery list/name caches so videos and images stay coherent once the
gallery wiring lands in Phase 4. Added a sibling
getTagsToInvalidateForVideoMutation helper.

Upload UX: useImageUploadButton.tsx's dropzone now accepts video/mp4,
video/webm, video/quicktime alongside the existing image MIMEs. The
drop handler splits files into image/video sets and routes each through
its own mutation; a new onUploadVideo callback parallels the existing
onUpload. Existing image-only callers pass through unchanged.

Polymorphic gallery query endpoints + the useGalleryItemDTO hook will
land with Phase 4 where they have actual consumers; the schema types
they'll need are already in place under @knipignore tags.

Verified: pnpm lint (knip, dpdm, eslint, prettier, tsc) all green;
pnpm test 1103/1103 pass; live curl against the running dev server
uploads an MP4 and serves both the webp thumbnail and the MP4 with
a working HTTP Range response (206 + Content-Range).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Videos now appear in the same gallery grid as images, interleaved by
created_at. Video thumbnails get a centered play-button badge so they
read as videos at a glance; everything else (selection, virtualization,
search, paged/virtual gallery views, keyboard nav) is unchanged.

Approach: selection state stays `string[]` of names. The kind is
recovered from the filename extension (.mp4 = video, anything else =
image), which is reliable because the backend's SimpleNameService
always emits `<uuid>.png` for images and `<uuid>.mp4` for videos. This
sidesteps a 32-file cross-cut from changing the selection shape to a
discriminated union, and selection is persist-denylisted so no
migration is needed.

Frontend:
- new isVideoName helper in features/gallery/store/types
- new endpoints/gallery.ts (deferred from Phase 3): useGetGalleryItemNamesQuery
- new ImageGrid/GalleryItemPlayBadge: centered triangular badge over thumbnail
- new ImageGrid/GalleryItemVideoStarIconButton: video-typed star toggle
- new ImageGrid/GalleryVideoItem: counterpart to GalleryImage; reuses
  galleryItemContainerSX, GalleryItemSizeBadge (width/height-only stand-in),
  selection handling (single/shift/ctrl/cmd); alt-click falls through to a
  normal select since comparison is image-only
- use-gallery-image-names now calls the polymorphic gallery names endpoint
  and exposes a mixed flat name list (existing callers - paged grid, search,
  navigation hotkeys - get the same shape)
- useRangeBasedImageFetching partitions visible names by extension; images
  bulk-fetch via the existing getImageDTOsByNames mutation, videos dispatch
  individual getVideoDTO queries (no batch endpoint yet)
- GalleryImageGrid's ImageAtPosition dispatches on isVideoName to render
  GalleryImage or GalleryVideoItem; star hotkey dispatches to the right
  star/unstar mutation based on kind
- pruned the now-unused useGetImageNamesQuery / isImageName exports

Verified: pnpm lint (knip, dpdm, eslint, prettier, tsc) all green;
pnpm test 1103/1103 pass; live curl of /api/v1/gallery/items returns
57 polymorphic items with video duration populated and image duration
null, /api/v1/gallery/items/names returns matching {kind, name} refs.

The useGalleryItemDTO hook is intentionally deferred to Phase 5 where
the polymorphic viewer is its first real consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selecting a video now renders a polymorphic preview inside the existing
viewer panel: thumbnail with a centered play button by default; clicking
play swaps in an HTML5 <video controls autoplay>. Switching to a
different item drops the video element back to idle (auto-pauses) and
selecting an image again returns to the normal image preview.

New components (features/gallery/components/ImageViewer/):
- VideoPlayButtonOverlay: large centered play button with hover/shadow,
  used over the thumbnail in the idle state.
- CurrentVideoPreview: idle/playing state machine. Resets on
  video_name change. The <video> src points at /api/v1/videos/i/.../full
  which supports HTTP Range, so seek/scrub work natively in the browser.

New hook:
- common/hooks/useGalleryItemDTO: polymorphic DTO resolver that
  dispatches between useImageDTO and useVideoDTO based on filename
  extension (isVideoName). Centralizes the kind-dispatch the viewer
  and toolbar both need.

Wiring:
- ImageViewer dispatches on galleryItem.kind to render CurrentImagePreview
  or CurrentVideoPreview. The compare-image DnD drop target is hidden when
  a video is selected (comparison is image-only).
- ImageViewerToolbar hides the image-specific action row
  (CurrentImageButtons - load workflow, recall metadata, edit, etc.) and
  the metadata viewer toggle when a video is selected. The general-purpose
  ToggleProgressButton stays.

Out of scope (per the plan): video deletion from the viewer (use gallery
hover icons), video-specific metadata viewer, comparison-mode support
for videos.

Verified: pnpm lint (knip, dpdm, eslint, prettier, tsc) all green;
pnpm test 1103/1103 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pzone

The gallery-wide drag-and-drop target lives in FullscreenDropzone, not
in useImageUploadButton (which only powers the upload button). It had
its own hardcoded image-only zod allowlist that rejected MP4 files
with "File type / extension is not supported".

- Broaden the zod refines to accept video/mp4, video/webm,
  video/quicktime, video/x-matroska and the matching extensions
- Add isVideoFile helper, split dropped files into image/video sets,
  and route each set through its own uploader (uploadImages /
  uploadVideos). Both update their respective RTK caches and
  invalidate the polymorphic gallery list/names.
- Skip the canvas-paste fast-path for single-video drops — the canvas
  doesn't host videos as layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a three-item context menu (delete, change board, download) on
right-click / long-press of any gallery video item. Mirrors the image
context menu's singleton-portal architecture so re-renders stay cheap.

New files:
- features/gallery/contexts/VideoDTOContext: small React context that
  scopes the active video DTO to the menu items (parallels
  ImageDTOContext).
- features/gallery/components/ContextMenu/MenuItems/
    ContextMenuItemDeleteVideo: window.confirm + deleteVideo mutation.
      Videos can't be referenced from canvas/nodes/refs, so the image
      modal's usage analysis is unnecessary; a one-step confirm matches
      the "minimal" scope.
    ContextMenuItemDownloadVideo: reuses the existing useDownloadItem
      hook against videoDTO.video_url / video_name.
    ContextMenuItemChangeBoardVideo: dispatches videosToChangeSelected
      and opens the (now polymorphic) ChangeBoardModal.
- features/gallery/components/ContextMenu/VideoContextMenu: singleton
  pattern lifted from ImageContextMenu — registers gallery video
  elements via a Map; right-click looks up the target node and opens
  the menu at the cursor.

Extended files:
- features/changeBoardModal/store/slice: added video_names alongside
  image_names plus a videosToChangeSelected action. The two arrays are
  mutually exclusive — setting one clears the other.
- features/changeBoardModal/components/ChangeBoardModal: now dispatches
  the matching video board mutations (add/removeVideoToBoard, plural
  endpoints don't exist yet so videos move one at a time — the menu
  acts on a single selection so this is a one-iteration loop).
- features/gallery/components/ImageGrid/GalleryVideoItem: registers
  itself with useVideoContextMenu.
- app/components/GlobalModalIsolator: mounts the singleton.

Verified: pnpm lint (knip, dpdm, eslint, prettier, tsc) all green;
pnpm test 1103/1103 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new invocation nodes that produce MP4 videos from a Wan 2.2
A14B transformer + VAE, plus the supporting plumbing.

New invocations:
- WanVideoDenoise (wan_video_denoise) — multi-frame counterpart to
  WanDenoise. Same per-step logic (CFG, MoE expert swap at the
  boundary timestep, LoRA patching, scheduler dispatch) — reuses
  _ExpertSwapper, _resolve_variant, and the scheduler/LoRA helpers
  from wan_denoise. Difference: the noise tensor has a real temporal
  dim built from num_frames, and the I2V condition is built across
  all latent frames (frame 0 conditioned, rest zero). Defaults match
  the Wan 2.2 reference: 832x480 / 81 frames / 40 steps / CFG 5.0
  (high) + 4.0 (low). Inpaint / img2img are out of scope for this
  first cut. TI2V-5B is rejected; T2V/I2V A14B only.
- WanLatentsToVideo (wan_l2v) — VAE-decodes 5D latents to RGB frames
  via AutoencoderKLWan (T_pixel = (T_lat - 1) * 4 + 1), then encodes
  an MP4 with imageio[ffmpeg] (libx264, yuv420p for browser
  compatibility). The temp file is moved into outputs/videos/ via
  context.videos.save().

Backend shared pieces:
- make_noise gains num_latent_frames (default 1, backward compatible).
- Added num_latent_frames_for(num_frames, scale=4) helper.
- New encode_reference_image_to_video_condition mirrors diffusers'
  WanImageToVideoPipeline.prepare_latents with last_image=None and
  expand_timesteps=False: pads the reference image with zero
  pixel-frames, VAE-encodes the full pseudo-video, normalises, and
  builds the 4-channel temporal-rearranged first-frame mask. Verified
  numerically: 21 latent frames for num_frames=81, first latent
  frame's 4 mask channels = 1, rest = 0.
- The existing single-frame encoder is left untouched.

Schema / context:
- New VideoField primitive (parallel to ImageField) and VideoOutput
  invocation output (width/height/num_frames/fps/duration/video).
- New VideosInterface on InvocationContext with .save(source_path,
  width, height, duration, fps, ...) returning VideoDTO. Mirrors
  ImagesInterface — falls back to WithBoard / WithMetadata mixins
  and embeds the queue item's workflow/graph as a JSON sidecar.
- WanRefImageConditioningField now carries num_frames so the denoise
  nodes can sanity-check the I2V condition. WanRefImageEncoder bumps
  to v1.1.0 and gains num_frames=1 input (use 81+ for video I2V; the
  encoder dispatches between the single- and multi-frame helpers).
- Image WanDenoise now rejects multi-frame conditions with a clear
  message pointing at WanVideoDenoise.

Verified: pnpm lint (5/5) green; pnpm tests (multiuser auth 122/122
+ broader suite via prior runs); numerical shape checks for noise
and ref-image condition; end-to-end smoke via VideoService.create.

A restart of the InvokeAI server is required to pick up the new
invocations in the workflow editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new default workflows for the workflow editor 'Browse' modal:

- 'Text to Video - Wan 2.2' — model loader -> two text encoders ->
  wan_video_denoise -> wan_l2v. Exposes prompt, model picks, CFG
  (high + low), dimensions, frames, fps, and steps.
- 'Image to Video - Wan 2.2' — same shape plus a wan_ref_image_encoder
  feeding the denoise node's ref_image input. Exposes the reference
  image and the frames field on the ref-image node (must match the
  denoise node's frames — there is a clear validation error if they
  diverge, but the starter has them in sync at 81).

Both default to the Wan 2.2 reference settings: 832x480, 81 frames @
16 FPS (~5 s), 40 steps, CFG 5.0 (high expert) + 4.0 (low expert),
seeded by a rand_int. Pass the existing _sync_default_workflows
validator (id starts with default_, meta.category=default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
run_app.py validates every invocation's return-type annotation against
the output-class registry. wan_latents_to_video.py had a stray
'from __future__ import annotations' which made the `invoke()` return
annotation a string ('VideoOutput') at runtime. The registry mismatch
triggered the unregistered-output warning path, which itself crashed
on output_annotation.__name__ because the annotation was a str:

  AttributeError: 'str' object has no attribute '__name__'

The other Wan invocations don't use future annotations — drop the
import to match. Verified post-fix: api_app import populates 95
output classes, wan_l2v annotation resolves to the real VideoOutput
class and is in the registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same graph as 'Text to Video - Wan 2.2' but with two Apply LoRA - Wan
2.2 nodes chained between the model loader and the denoise node, and
defaults retuned for the Lightning distillation: 4 steps and CFG 1.0
on both experts (CFG=1 skips the negative-conditioning forward pass
entirely, ~20x faster than the 40-step / CFG-5.0 baseline at similar
quality).

Adapted from a user-saved workflow; cleaned for distribution by
stripping the install-specific model/LoRA key bindings (defaults
should not bake in local UUIDs), bumping to a fresh default_-prefixed
id with meta.category=default, exposing the two LoRA fields (lora +
weight) so users can swap LoRAs without diving into the canvas, and
flagging the negative-prompt node as unused at CFG=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new default workflows that wire the Lightning LoRA pair into the
T2V and I2V video pipelines for a ~20x speedup:

- 'Text to Video - Wan 2.2 Lightning' — model loader -> apply LoRA
  (high) -> apply LoRA (low) -> text encoders -> wan_video_denoise
  -> wan_l2v. Defaults to 4 steps and CFG 1.0 (no negative branch).
  Cleaned-up version of Lincoln's saved Lightning workflow: stripped
  per-install model/LoRA keys, switched meta.category to 'default'
  with a default_ id, and exposed both LoRA loaders' lora/weight/
  target fields so users can swap LoRAs without diving into the
  canvas.
- 'Image to Video - Wan 2.2 Lightning' — same chain plus a
  wan_ref_image_encoder (v1.1.0 with num_frames) feeding the denoise
  ref_image input. Defaults match the non-Lightning I2V starter
  (832x480, 81 frames @ 16 FPS) but with 4 steps / CFG 1.0.

LoRA target defaults to 'auto' so properly-tagged Lightning LoRAs
route themselves; both workflow descriptions tell users to set
explicit 'high'/'low' targets if their LoRAs are untagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wan_latents_to_video was passing plugin='pyav' to iio.imwrite, but the
runtime only has imageio-ffmpeg installed (no PyAV). The encode step
at the very end of generation crashed with:

  ImportError: The `pyav` plugin is not installed.
  Use `pip install imageio[pyav]` to install it

Switch to plugin='FFMPEG' — backed by the bundled imageio-ffmpeg
binary that pyproject already requires via imageio[ffmpeg]. libx264
yuv420p is the FFMPEG plugin's default for .mp4, so the explicit
pixel_format is dropped (specifying it just produced a "Multiple
-pix_fmt options" warning).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The video VAE decode + MP4 encode tail can take 30-90s on top of the
denoise loop, and the toast-style signal_progress() messages don't
land in the server log. Add context.logger.info() at:

- VAE decode start: latent frame count -> pixel frame count + resolution
- MP4 encode start: frames, fps, duration, dimensions
- MP4 encode complete: encoded file size
- Video saved: final video_name

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After wan_l2v wrote a successful libx264 MP4 to disk, the invocation
would hang in DiskVideoFileStorage.save() during the cv2.VideoCapture
thumbnail-extraction step. cv2 wheels on this build can't reliably
decode our libx264/yuv420p output (most often the wheel was compiled
without an h264 decoder, but the failure mode is silent hang rather
than a clear error). The net effect: the MP4 ends up in
outputs/videos but the queue item never completes, so the frontend
spinner spins forever and the gallery doesn't pick up the new entry.

Fix: rewrite extract_video_frame and probe_video to try imageio's
FFMPEG plugin first (same backend that did the encoding — so reading
our own output is guaranteed to work), with cv2 retained only as a
fallback for uploaded videos in formats imageio can't decode.

Also add fine-grained log lines + exception guards inside
DiskVideoFileStorage.save() so a future thumbnail failure can no
longer hang the whole save — it now logs a warning and continues,
leaving the video record in place even if the thumbnail step
errored. With logging at each step (video written, thumbnail
written, sidecar written) any future hang will be obvious from the
last log line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After wan_l2v wrote its MP4 successfully, the gallery and viewer were
never updated: the new video didn't appear and the viewer stayed stuck
on the previous "Saving video" progress spinner indefinitely.

Root cause: onInvocationComplete.tsx only inspected results for
isImageField / isImageFieldCollection. VideoField outputs were silently
dropped, so the polymorphic gallery list never invalidated and no
auto-switch happened. The viewer therefore kept rendering
CurrentImagePreview, whose ImageViewerContext-local $progressEvent /
$progressImage atoms intentionally aren't cleared on queue completion
when autoSwitch is on — they rely on the new image's DndImage onLoad
to clear them, which never fires for a video.

Fix: add isVideoField (mirrors isImageField against {video_name}) and
plumb video outputs through onInvocationComplete:
- getResultVideoDTOs pulls VideoDTOs via getVideoDTOSafe
- addVideosToGallery invalidates GalleryItemNameList / GalleryItemList
  so the polymorphic gallery refetches and the new video shows up
- auto-switch dispatches the video name into selection (selection is a
  polymorphic string[]; useGalleryItemDTO already discriminates by
  filename extension)

The selection change swaps CurrentImagePreview for CurrentVideoPreview,
which unmounts the stale progress overlay along with it — so the stuck
spinner clears as a side-effect of the auto-switch.

Also drops the now-stale @knipignore on getVideoDTOSafe, which has a
real consumer now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts a single frame from a VideoField input and saves it as a
regular ImageDTO via context.images.save, so it appears in the gallery
like any other generated image.

Primary use case is I2V "shot extension": take the last frame of a
Wan-generated clip (default frame_index=-1) and feed it back as the
reference image for the next clip, then stitch the MP4s to get videos
longer than the model's single-shot frame budget at a given VRAM.

Negative frame_index is resolved against the actual decoded frame count
via probe_video() rather than passed through to imageio — not all
imageio plugins handle index=-1 uniformly, and being explicit lets us
emit a precise out-of-range error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Joins two or more videos into a single MP4 with one of three transition
modes between consecutive clips:

- cut: hard splice, no blending. Total length = sum of inputs.
- crossfade: linear A→B dissolve over transition_frames. Each boundary
  consumes N frames from both surrounding clips, shrinking total length
  by N per boundary.
- fade_through_black: A fades to black, then B fades in. Each boundary
  consumes N/2 from each side and emits N output frames — total length
  is preserved.

Implementation decodes via imageio's FFMPEG plugin (matching wan_l2v on
the encode side) and runs the blends in numpy. All decoded frames are
kept in memory at once; fine for the few-hundred-frame I2V chains that
motivated this, would want streaming if anyone ever feeds in hour-long
uploads.

Up-front validation enforces matching dimensions across inputs and
checks that each clip has enough frames to spare from its head and tail
for the requested transitions — saves a wasted decode pass when the
transition window is too wide for one of the clips.

Pairs with 'Frame from Video' for I2V shot extension: generate N clips
chained via last-frame-as-ref-image, then glue them with a crossfade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The viewer used a chakra <Image src={thumbnail_url}> in the idle (not-
playing) state, so once a clip auto-selected after generation the
preview snapped from the full-resolution denoise progress image to the
small WebP gallery thumbnail upscaled to fit — visibly soft compared to
what the user was watching seconds earlier.

Switch to a single <video> element that spans both states:

- idle: muted, no controls, preload="metadata". With no `poster` attr
  the browser decodes and shows the video's actual first frame at full
  resolution (this is the documented HTMLVideoElement default).
- playing: same DOM node with controls+audio toggled on, kicked off via
  ref.play(). No reload between states — the decoded buffer carries
  over.

`key={videoName}` swaps the element cleanly when the user moves to a
different clip, dropping any in-progress playback state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JPPhoto

JPPhoto commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Found and working on fixes for the following:

  • invokeai/app/api_app.py:184 reserves one of four global upload slots before authentication or multipart parsing. Four unauthenticated clients can hold slow chunked requests open and force every legitimate video upload to receive 429. To expose this issue, add an ASGI test with four invalid-token slow streams followed by an authenticated upload, and verify unauthenticated requests cannot consume authenticated upload capacity.

  • High: invokeai/frontend/web/src/features/auth/components/UserMenu.tsx:17 clears local authentication and navigates to login even when the backend logout request fails. The only operation that deletes the new HttpOnly media cookie is the successful endpoint response at invokeai/app/api/routers/auth.py:220; after a network or server failure, the UI reports logout while the still-valid cookie continues authorizing known image and video URLs for up to seven days. To expose this issue, add a logout failure test that proves the UI does not claim completion while the server-managed credential remains valid.

  • invokeai/app/invocations/wan_model_loader.py:202 validates VAE compatibility only when using a component source. A standalone 16-channel VAE can be selected with TI2V-5B, or a 48-channel VAE with A14B, even though invokeai/backend/model_manager/configs/vae.py:264 records the necessary latent_channels. The mismatch is discovered only after expensive downstream generation. To expose this issue, add positive and negative loader tests for standalone 16- and 48-channel VAEs against every Wan variant.

  • The media-cookie retry repair is incomplete. invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx:77, invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/IntegerField/VideoFrameIndexFieldInput.tsx:94, and invokeai/frontend/web/src/features/controlLayers/components/RefImage/RefImagePreview.tsx:185 render protected media URLs without useMediaUrl. During restored sessions, their initial request can race and lose to the asynchronous cookie refresh, and the unchanged URL is never retried. To expose this issue, delay cookie refresh, reject the first media request, complete refresh, and assert each consumer changes its URL and retries; also test refresh failure without a retry loop.

  • Mixed image/video selection can put the viewer into an invalid image-comparison state. invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx:180 can assign a video name to imageToCompare, while invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoItem.tsx:61 selects a video without clearing an existing comparison. invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx:10 then opens ImageComparison, which performs image-only queries at invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx:41 and renders blank. To expose this issue, test Alt navigation through a mixed gallery and selecting a video while two images are being compared; image-to-image comparison must remain unchanged.

  • The deterministic ordering fix does not cover the new video-only APIs or regular board covers. invokeai/app/services/video_records/video_records_sqlite.py:60, invokeai/app/services/video_records/video_records_sqlite.py:191, and invokeai/app/services/video_records/video_records_sqlite.py:358 order only by starred state and timestamp. Equal-timestamp videos can reorder between offset pages or become inconsistent cover candidates in invokeai/app/services/boards/boards_default.py:17. To expose this issue, seed several videos with identical timestamps and assert stable ascending/descending pagination and a stable board cover.

  • invokeai/app/api/routers/videos.py:243 leaves the upload temporary file open until the normal read loop completes. If file.read(), tmp.write(), or tmp.close() fails, the finally block at line 293 attempts to unlink an open file; that fails on Windows and is swallowed, leaving the temporary file behind. To expose this issue, inject read and write failures and assert the handle is closed before cleanup and that no temporary path remains.

@JPPhoto

JPPhoto commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

@lstein All of those issues I indicated have been addressed. Please do a review on your end and tag me with your findings/fixes and I will re-review or approve.

lstein and others added 6 commits July 20, 2026 21:49
- Force bfloat16 in the standalone Wan VAE checkpoint loader: `precision: auto`
  resolves to fp16 on CUDA, and fp16 is unstable on the Wan VAE (the diffusers
  folder path already forced bf16). Both starter VAEs route through this loader.
- Count the decoded RGB clip twice in the Wan working-memory estimator:
  diffusers' frame accumulation transiently holds ~2x the clip at peak, which
  the tiled-decode fallback previously undercounted by up to ~2 GB.
- Ignore a wired 'Transformer (Low Noise)' for TI2V-5B (warn instead of raising
  a misleading A14B error), matching the field's documented behavior.
- Release the expert swapper's device context even when LoRA weight-restore
  raises, so a failed unwind can't pin an 8-9 GB expert in VRAM.
- Validate LoRA variant (A14B vs 5B) against the wired transformer in both Wan
  LoRA loaders — a mismatch previously crashed mid-denoise with an opaque
  layer-patcher shape error.
- Fix the WanDiffusersModel exception ladder: the old-diffusers torch_dtype
  retry now also gets the missing-variant OSError fallback, with the matching
  dtype kwarg.
- Mark both Wan ideal-dimensions nodes Prototype like every other Wan node;
  correct the text-encoder docstring (seq_len 512, not 226).
- Add CPU tests for the multi-frame WanVideoDenoise loop (T_lat>1 shapes,
  zero-velocity invariant, A14B I2V 36-channel concat across frames, TI2V-5B
  expand-timesteps mask blend incl. per-token timesteps and frame-0 restore).

Node version bumps: wan_model_loader, wan_lora_loader,
wan_lora_collection_loader -> 1.0.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Purge cached invocation outputs on video deletion: the memory invocation
  cache registered images/tensors/conditioning on_deleted hooks but not videos,
  so re-running an identical graph after deleting its output "succeeded" with a
  cached VideoOutput naming a 404 video.
- Add the single-user early-return to VideosInterface's read-access and
  board-save checks, matching ImagesInterface — after a multiuser->single-user
  switch, video workflows no longer fail with PermissionError where identical
  image operations succeed.
- Restructure staged-delete recovery to match the image side: video_records
  .get() raises rather than returning None, so the explicit commit branch was
  unreachable and recovery semantics lived in the exception handler by luck.
- Return 416 (not 206 with "bytes 0--1/0") for any Range request against a
  zero-length video file; add tests for the whole Range-parser matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add 'video' to the invocation-complete passthrough denylist: a Video
  Primitive completing mid-run invalidated gallery caches and auto-switched the
  user's selection/board to the node's *input* video.
- Show the multi-selection context menu only when the clicked item is part of
  the selection (both image and video menus): right-clicking a video with 2+
  images selected previously produced a menu with every action disabled.
- Clear workflow VideoField references when videos are cascade-deleted via
  board deletion or delete-uncategorized, matching the direct-delete flow.
- Toast on total video-delete-batch failure (the untracked mutation was
  otherwise silent) and on failed logout (the button previously did nothing
  when the server was unreachable).
- Check resp.ok in useDownloadItem so an expired media cookie can't save error
  bodies as .mp4/.png files.
- Provide the LIST_TAG-scoped VideoList tag from listVideos so the star/board
  invalidations that reference it actually match; fix the misleading comment;
  dedupe the doubled BoardVideosTotal tag type.
- Validate VideoField access on workflow load (checkVideoAccess), resetting
  stale refs with a warning like image fields.
- Wire middle-click-open-in-new-tab for gallery videos (the setting label
  already promised it).
- Show the effective fallback (primary CFG) in the low-noise guidance slider
  when unset, instead of a constant the run never uses.
- "Moving 1 image/video to board:" singular form for mixed-media moves.
- Regenerate schema.ts/openapi.json (node version bumps, classification,
  docstring fixes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Update all 12 bundled Wan workflows to current node versions (wan_model_loader
  / wan_lora_loader / wan_lora_collection_loader 1.0.1, wan_ref_image_encoder
  1.2.0, backfilling the optional end_image/num_frames inputs) so fresh installs
  don't open with "node needs update" badges; add a registry-consistency test
  over the bundled Wan/video workflows so stale embeds can't recur.
- Docs: the A14B auto scheduler is UniPC (not FlowMatchEuler); note that the
  bundled TI2V-5B workflows ship 20 steps as a speed compromise vs the 40-50
  quality recommendation.
- Pin imageio[ffmpeg]>=2.37 and psutil>=6 (imageio encode behavior is
  version-sensitive enough that we carry a regression test for it); relock.
- De-flake the thumbnail worker descendant-kill test (0.5s was the only tight
  ceiling in the file; a loaded runner could kill the worker before the child
  pid file existed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main already runs 9-11 min per platform and this PR pushed py3.11 windows-cpu
past the 15-minute cap (cancelled mid-pytest at 15m10s on the last run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the CI-CD Continuous integration / Continuous delivery label Jul 21, 2026
@lstein

lstein commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review round: full-PR audit fixes

I ran a full audit of the PR at 7842719004 (all 319 files, reviewed by area: backend inference, API/services, frontend gallery, frontend nodes, tests/docs/deps) and fixed everything it surfaced. Five commits: 247f17b2c9 (backend), 10731dcdea (api), 27b7a71ec5 (ui), 2577bd264f (workflows/docs/deps), a8f3442e93 (ci).

Majors fixed

  1. Standalone Wan VAE checkpoints loaded in fp16 on default CUDA config (vae.py). precision: auto resolves to float16, but fp16 is unstable on the Wan VAE — the diffusers-folder path already forced bf16 for exactly that reason, and both starter VAEs route through the checkpoint path. Now forces bf16, with a dtype regression test.
  2. Invocation cache never purged video outputs (invocation_cache_memory.py). Delete a video, re-run the identical graph → the cached VideoOutput "succeeds" pointing at a 404. The videos.on_deleted hook is now registered like images/tensors/conditioning.
  3. Socket handler treated the Video Primitive as a gallery producer (onInvocationComplete.tsx). The passthrough denylist was ['load_image', 'image']; a video node completing mid-run invalidated gallery caches and auto-switched selection to the node's input video. Added 'video'.
  4. Mixed multi-selections produced a fully disabled context menu (both image and video menus). With 2+ images selected, right-clicking a video showed the multi menu with every action disabled. Both menus now fall back to the single-item menu when the clicked item isn't part of the selection.
  5. CI: py3.11 windows-cpu was being killed by the 15-minute job timeout (cancelled at 15m10s — not a test failure). Main already runs 9–11 min/platform; raised timeout-minutes to 30.

Minors fixed

  • Backend: tiled Wan video decode under-reserved working memory (decode transiently holds ~2× the RGB clip during frame accumulation); TI2V-5B with a leftover low-noise wire now warns and ignores it per the field docs instead of raising an A14B-specific error; _ExpertSwapper._release releases the device context even if LoRA weight-restore raises; both Wan LoRA loaders reject A14B↔5B variant mismatches up front instead of crashing mid-denoise in the layer patcher; fixed the old-diffusers/missing-variant hole in WanDiffusersModel's load fallbacks; ideal-dimensions nodes marked Prototype; text-encoder docstring corrected (seq_len 512).
  • API/services: VideosInterface gained the single-user multiuser gate its image twin has (multiuser→single-user switches no longer break video workflows); staged-delete recovery restructured to match the image side (the get() is None branch was unreachable); Range requests against a zero-length file now 416 instead of 206 with Content-Range: bytes 0--1/0.
  • Frontend: board-delete and delete-uncategorized now clear workflow VideoField refs like direct deletes; total video-delete-batch failure and failed logout now toast instead of silently no-oping; downloads check resp.ok (an expired media cookie no longer saves error bodies as .mp4); listVideos provides the LIST_TAG-scoped tag the star/board invalidations reference (they matched nothing before); workflow load validates VideoField access like image fields; gallery videos honor the middle-click-open setting; the low-noise guidance slider shows the effective primary-CFG fallback when unset; "Moving 1 image/video to board:"; deduped BoardVideosTotal tag type.
  • Workflows/docs/deps: all 12 bundled Wan workflows updated to current node versions (they shipped with stale wan_ref_image_encoder 1.0.0/1.1.0 embeds → "node needs update" badges on fresh installs), and a new registry-consistency test prevents recurrence; docs corrected (A14B auto scheduler is UniPC, not FlowMatchEuler; noted the bundled TI2V-5B workflows' 20-step speed compromise); imageio[ffmpeg]>=2.37 / psutil>=6 pinned + relocked; de-flaked the thumbnail descendant-kill test's 0.5 s window.

Test coverage added

  • Multi-frame WanVideoDenoiseInvocation CPU tests — the core video loop previously had only a dimension-rejection test. Seven new tests cover T_lat>1 shapes for A14B and TI2V-5B, the zero-velocity noise invariant, A14B I2V 36-channel concat across frames (plus both frame-count mismatch errors), and the TI2V-5B expand-timesteps blend (per-token timesteps gated to 0 at frame-0 positions, blend correctness, final frame-0 restore). Adds ~0.1 s to the suite.
  • Regression tests for each fix above: VAE loader dtype, cache purge on video deletion, Range-parser matrix, TI2V-5B ignore path, LoRA variant validation matrix, LoRA-exit swapper release, bundled-workflow registry consistency.

Verification

  • Backend: 3183 passed / 130 skipped (full tests/ sweep, ~6.5 min)
  • Frontend: 1421 tests / 109 files, tsc + eslint + prettier clean
  • ruff clean; typegen regenerated with CI's exact commands; uv lock --check green

🤖 Generated with Claude Code

@JPPhoto
JPPhoto self-requested a review July 21, 2026 19:13

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each item below is tied to code or behavior added or changed by this PR. Unchanged adjacent code was cited only where the PR introduced a new contract or made an existing path newly relevant. Unrelated pre-existing repository issues were excluded:

  • Cross-user RTK Query data can survive logout and login. invokeai/frontend/web/src/services/api/endpoints/auth.ts:72-87 invalidates only image and board tags, omitting the new Video, VideoList, VideoMetadata, VideoWorkflow, VideoNameList, GalleryItemList, and related tags. invokeai/frontend/web/src/features/auth/store/authSlice.ts:56-72 does not reset API state, while invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts:171-177 leaves selection intact. On a shared browser, user B can receive user A's cached video metadata, workflow, names, or selection before a network request occurs. Test: seed all video and gallery caches as user A, logout or expire the session, login as user B, and assert no A-owned data remains readable or selected.
  • The upload concurrency limit is global across tenants. invokeai/app/api_app.py:169-182 validates only that the caller is authenticated, while invokeai/app/api_app.py:185-260 accounts every upload through one _active counter. One user can hold all four slots with slow chunked requests and force every other user to receive 429. Test: hold the maximum number of upload streams open under user A, submit an upload as user B, and verify a per-user quota prevents A from monopolizing the global capacity; also verify unauthenticated requests consume no slot.
  • Uploaded video metadata is not validated for positive, finite, or bounded dimensions. invokeai/app/util/video_thumbnails.py:247-266 accepts decoder-provided width, height, duration, and FPS without checking zero, negative, NaN, inf, or a maximum pixel count, and invokeai/app/api/routers/videos.py:261-285 passes those values into persistence and thumbnail generation. invokeai/app/util/video_decode_worker.py:35-44 may allocate the complete decoded frame before any parent-side size bound applies, so a small compressed file with extreme dimensions can exhaust substantial memory, especially across concurrent uploads. Test: return invalid and over-limit probe values and verify rejection occurs before videos.create or frame decoding; include valid boundary cases and a highly compressed oversized-resolution fixture.
  • Wan LoRA loaders trust caller-supplied identifiers instead of validating the resolved model configuration. invokeai/app/invocations/wan_lora_loader.py:120-147 checks existence and variant but never verifies that the resolved config is a Wan ModelType.LoRA; invokeai/app/invocations/wan_lora_loader.py:196-214 checks lora.lora.base, which is client-controlled, rather than the actual config's base and type. A hand-authored workflow can label an existing Flux, SDXL, or main-model key as a Wan LoRA and reach model patching or fail after expensive loading. Test: supply identifiers claiming Wan LoRA while their resolved configs report another base or model type, and verify both loaders reject them before producing output; verify a real Wan LoRA succeeds.
  • WanLoRACollectionLoader can apply an already-applied LoRA again. invokeai/app/invocations/wan_lora_loader.py:188-221 deduplicates only entries within the incoming collection and does not compare them with transformer.loras or transformer.loras_low_noise, despite the single loader enforcing that invariant at lines 136-140. Chaining collection loaders can therefore double the effective weight and patching work. Test: begin with the same key in each existing target list, submit it in a collection, and verify it is rejected or retained exactly once; verify a new key is appended normally.
  • TI2V accepts LoRA routing that can never be consumed. invokeai/app/invocations/wan_lora_loader.py:55-69 routes explicit low and auto-routed low-expert LoRAs exclusively into loras_low_noise, while invokeai/app/invocations/wan_denoise.py:525-565 and invokeai/app/invocations/wan_video_denoise.py:280-318 use only the primary list for the single-transformer TI2V path. The node reports success even though the requested LoRA has no effect. Test: use a TI2V transformer with explicit low and an auto-routed low LoRA and require a rejection, remapping, or explicit warning; verify low routing remains valid for dual-expert A14B.
  • _ExpertSwapper._release() retains stale state if the device context fails during exit. In invokeai/app/invocations/wan_denoise.py:254-268, clearing _active_device_ctx, _active_model, and related fields occurs after __exit__; an exception from __exit__ skips those assignments and permits later cleanup to double-exit or reuse stale objects. Test: use a device context whose __exit__ raises, assert the original exception propagates while every active field is cleared, then verify a second close is a no-op.
  • Best-effort thumbnail generation is not matched by resilient consumers. invokeai/app/services/video_files/video_files_disk.py:73-89 deliberately preserves a video when thumbnail generation fails, and invokeai/app/api/routers/videos.py:594-614 then returns 404, but invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx:21-40, GalleryBoard.tsx:146-167, VirtualBoardItem.tsx:77-103, and invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx:76-80 render the missing thumbnail without fallback behavior. The grid fallback in invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoThumbnail.tsx:37-50 also does not seek after metadata loads, unlike CurrentVideoPreview.tsx:268-287, so some browsers can leave it black. Test: return 404 for each thumbnail consumer and verify a stable icon or video fallback; use a browser test to verify the grid seeks and paints a frame after loadedmetadata.
  • Media-cookie recovery permanently gives up after one transient failure. invokeai/frontend/web/src/features/auth/hooks/useMediaCookieRefresh.ts:22-36 sets hasRefreshed.current before the request and swallows every failure without resetting it or scheduling another attempt. A temporary network error or server 5xx leaves an otherwise valid restored session unable to load protected media until reload or login. Test: fail the first refresh with a transient error, recover on reconnect or focus, and verify a bounded retry updates the cookie version; verify an authentication failure does not loop indefinitely.
  • Partial board deletion leaves confirmed-deleted media in per-item caches. invokeai/app/api/routers/boards.py:137-192 returns confirmed deletion lists inside an error response, but invokeai/frontend/web/src/services/api/endpoints/images.ts:328-350 receives no result for the rejected mutation and therefore cannot invalidate those item tags. invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts:13-70 parses the partial result only to clear workflow fields. A deleted video's DTO, metadata, or workflow can remain readable from cache after the gallery list refetches. Test: prefill item caches, dispatch a rejected board deletion containing partial image and video lists, and verify confirmed deletions are invalidated while failed or preserved names remain cached.
  • Changing is_intermediate does not invalidate all video lists. Every listVideos query provides { type: 'VideoList', id: LIST_TAG } at invokeai/frontend/web/src/services/api/endpoints/videos.ts:41-54, but changeVideoIsIntermediate at lines 168-186 invalidates only the video and its current board. A cached filtered list whose arguments omit or differ from that board can continue showing a video that no longer satisfies the filter. Test: cache an unscoped is_intermediate=false list containing a board-owned video, toggle it to true, and verify the list refetches and removes it; verify unrelated item caches are not unnecessarily invalidated.
  • The Wan reference-image node description contradicts its implementation. invokeai/app/invocations/wan_ref_image_encoder.py:45-63 says the output is always 20-channel and that TI2V-5B must omit the node, while lines 133-164 explicitly support the 48-channel TI2V VAE path. This description is exposed in invocation schemas and misdirects users away from a supported workflow. Test: assert the generated invocation schema describes both A14B and TI2V output shapes and contains no obsolete instruction to omit the node for TI2V.
  • Streaming decoding supports fewer files than upload validation and single-frame decoding. invokeai/app/util/video_decode_worker.py:35-142 falls back to OpenCV for frame extraction, probing, and counting, but _stream() at lines 145-153 uses only imageio.imiter. A file accepted during upload through the OpenCV fallback can later fail in frame-range or concatenation nodes that stream it. Test: force the imageio backend to reject a fixture that OpenCV can decode and verify probing, extraction, and streaming all succeed consistently, or reject that format during upload.
  • Failed browser playback leaves the viewer in a false playing state. invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx:119-124 sets isPlaying before calling video.play() and discards the returned promise, while the media element around lines 303-322 has no error-state handler. A codec error, missing media cookie, or rejected autoplay can hide the play overlay while nothing plays and can produce an unhandled promise rejection. Test: make play() reject and emit a media error, then verify playing state rolls back, a stable fallback remains available, and no unhandled rejection occurs.
  • WanLatentsToImageInvocation does not validate temporal shape before treating decoded output as one image. invokeai/app/invocations/wan_latents_to_image.py:75-99 permits any 5D latent tensor, calls squeeze(2) even when the temporal dimension exceeds one, and then applies a three-dimensional rearrange to the remaining four-dimensional tensor. Connecting video latents produces an opaque downstream shape error after VAE decoding. Test: pass 5D latents with more than one temporal frame and require an early, explicit validation error or documented frame selection; verify 4D and single-frame 5D inputs still decode successfully.

@lstein

lstein commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@JPPhoto — I verified all 15 items from your 2026-07-21 review against head 67f48813c2 (each one checked against the actual code, not just the citation). 13 confirmed; 2 I believe are not defects, with evidence inline below in your order. Fixes for the confirmed items, with regression tests, will follow in the next batch of commits.

  1. Cross-user RTK cache after logout — disputed. The store registers a listener on both logout and sessionExpiredLogout that dispatches api.util.resetApiState() (src/app/store/store.ts:268-272), which drops every cached query — Video, VideoList, VideoMetadata, VideoWorkflow, VideoNameList, GalleryItemList included — on all logout paths (UserMenu, ProtectedRoute, and the 401-driven sessionExpiredLogout in services/api/index.ts:127). The narrow invalidatesTags list on the logout mutation is redundant given the listener; I'll add a comment there pointing at the listener so it doesn't mislead a future reader. gallery.selection does retain name strings in memory, but it's on the persist denylist (never survives a reload) and the DTOs behind those names are gone after the reset and re-fetched under user B's credentials. I'll clear selection on logout anyway as belt-and-suspenders.

  2. Global upload slots — confirmed. Auth is now checked before a slot is taken (the slot-before-auth ordering from your earlier round is fixed; unauthenticated requests consume nothing), but _active is a single global counter with no per-user keying, so one tenant's four slow chunked uploads starve every other user into 429. Fix: per-user slot accounting with a per-user cap.

  3. Unvalidated video probe metadata — confirmed. No positive/finite/bounds checks exist anywhere on the upload path. One nuance: NaN/inf width/height are incidentally rejected by the int() coercion, but zero/negative/absurd integer dimensions and NaN/negative duration/fps flow straight into the DB record, and the decode worker allocates the full frame with no memory bound (a 100000×100000 probe → ~30 GB attempted allocation in the child; the 30 s timeout bounds time, not memory). Fix: validate probe output (positive, finite, max pixel count) before videos.create, and enforce a dimension bound before any frame decode.

  4. Wan LoRA loaders trust caller-supplied identifiers — confirmed. exists() is a pure key-existence check, and the variant assert reads only config.variant — SD1/SDXL LoRA configs have no variant field, so they pass it silently, and the collection loader's lora.lora.base check reads the client-controlled identifier rather than the resolved config. A mislabeled key reaches LayerPatcher.apply_smart_model_patches (or fails minutes in at the denoise-time isinstance assert). Fix: both loaders validate the resolved config is type=LoRA, base=Wan before proceeding.

  5. Collection loader can re-apply an already-applied LoRA — confirmed. The added set dedups only within the incoming collection; keys already present in transformer.loras/loras_low_noise are appended a second time (doubled effective weight) — exactly the condition the single loader raises on. Fix: check against the existing lists too and raise, matching the single loader.

  6. TI2V low-routed LoRA never consumed — confirmed. The single-loader docstring does acknowledge the low list is ignored for TI2V, but that's documentation, not user feedback — an explicitly low-targeted LoRA on a 5B main silently no-ops. Fix: log a warning at load time when the main is TI2V-5B and a LoRA routes low, consistent with the model loader's warning for a wired-but-ignored low-noise transformer.

  7. _ExpertSwapper._release stale state — confirmed. The try/finally I added last round covers only the LoRA-exit failure: if the device context's __exit__ raises, the field-clearing assignments after it inside the same finally are skipped, so a later close() double-exits both contexts. Fix: nested try/finally so the fields are cleared unconditionally.

  8. Thumbnail 404 consumers — confirmed, all five. BoardTooltip, GalleryBoard, VirtualBoardItem, and VideoFieldInputComponent all gate their fallbacks on a falsy URL, which never occurs (thumbnail_url is built unconditionally), so a 404 renders a broken image; and GalleryVideoThumbnail's <video> fallback lacks the loadedmetadata micro-seek that CurrentVideoPreview uses, so some browsers paint it black. Fix: onError icon/video fallbacks on the four static consumers, plus the seek on the grid fallback.

  9. Media-cookie refresh gives up after one transient failure — confirmed. hasRefreshed latches before the request fires and every failure is swallowed, so a transient 5xx on app load leaves protected media broken until reload. Fix: bounded retry with backoff for non-401 failures; 401 still bails (the session is genuinely expired).

  10. Partial board deletion leaves per-item caches — confirmed. On the rejected mutation only the static tags fire (result is undefined), so per-item Image*/Video* tags for the confirmed-deleted names are never invalidated. The boardAndImagesDeleted listener already parses those names out of the 500's detail but uses them only to clear node fields. Fix: dispatch api.util.invalidateTags for the parsed names in that same listener.

  11. changeVideoIsIntermediate list invalidation — not a live defect; will harden anyway. Every board-scoped listVideos cache is invalidated via the Board tag it provides, the mutation does invalidate the unscoped gallery tags (GalleryItemList etc. via getTagsToInvalidateForBoardAffectingMutation), the gallery itself uses /gallery/items/ rather than listVideos, and there are currently zero callers of useListVideosQuery — this also exactly mirrors changeImageIsIntermediate. The genuinely imprecise part: an omitted-board_id (all-boards) listVideos query would provide Board:'none', so a future caller could go stale. I'll add the VideoList LIST_TAG invalidation to the mutation to close that latent gap cheaply.

  12. Ref-image node description stale — confirmed. The module/class docstrings say always-20-channel and "omit for TI2V-5B" while the code implements the 48-channel TI2V path with dedicated validation (and the FLF2V error message names TI2V-5B as a supported mode). Fix: rewrite the docstrings to describe both A14B and TI2V-5B output shapes.

  13. _stream() lacks the OpenCV fallback — confirmed. Probe, single-frame extract, and count all fall back to cv2; streaming uses only iio.imiter. So an MP4 accepted at upload via the cv2 path works in single-frame extract but fails in frame-range extract and video concat with "Unable to decode video". Fix: add the cv2 streaming fallback in the worker.

  14. Playback error handling — confirmed. isPlaying is set before play(), the returned promise is discarded with no .catch, and the <video> element has no onError — a rejected play or media error hides the overlay over a dead element. Fix: handle the promise rejection (roll back isPlaying) and add an error handler that restores a usable state.

  15. WanLatentsToImage temporal validation — confirmed (one mechanical detail differs: the ineffective squeeze(2) is on the decoded output, so the failure is an einops rank error at the final rearrange — after the full multi-frame VAE decode has already run, under a working-memory estimate that assumed a single frame). Fix: raise a clear validation error when latents.shape[2] > 1, directing users to wan_latents_to_video for multi-frame latents; 4D and single-frame 5D inputs keep working.

lstein and others added 4 commits July 21, 2026 20:45
The route-authorization audit merged from main (invoke-ai#9367) only recognized the
two Bearer-token dependencies, so it flagged the video media routes (which
authenticate via get_current_media_user_or_default) and the media-cookie
endpoint (which validated its Bearer token inline). Teach the audit about
the media dependency, drop the image media routes from PUBLIC_ROUTES (they
now carry cookie auth on this branch), and give refresh_media_cookie a
CurrentUserOrDefault dependency in place of its duplicated inline
validation. The media-cookie tests now patch auth_dependencies'
ApiDependencies like every other auth-dependent route test.

knip: getDeletedVideosFromDeleteBoardAction was exported but only used
in-module; cover it in the listener unit tests like its image twin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Both Wan LoRA loaders now validate the *resolved* config (type=LoRA,
  base=Wan) instead of trusting the client-supplied identifier fields; a
  mislabeled Flux/SDXL/main key is rejected up front instead of reaching
  the layer patcher.
- The collection loader rejects LoRAs already applied upstream on either
  expert list (same invariant as the single loader) instead of silently
  doubling their effective weight.
- A LoRA routed only to the low-noise list of a TI2V-5B main now logs a
  warning — the single-transformer path never consumes that list, so the
  routing was a silent no-op.
- _ExpertSwapper._release clears its slots in a nested finally, so a
  device-context exit failure can no longer leave stale contexts that a
  later close() would double-exit.
- WanLatentsToImage rejects multi-frame (T>1) video latents with a clear
  error pointing at wan_l2v, before the VAE is even loaded — previously it
  ran the full multi-frame decode and died in an opaque einops rank error.
- wan_ref_image_encoder docstrings now describe both the 20-channel A14B
  and 48-channel TI2V-5B condition paths (they claimed A14B-only and told
  users to omit the node for TI2V-5B, contradicting the implementation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…back

- VideoUploadLimitASGIMiddleware now accounts upload slots per user (cap 2)
  on top of the global cap, so one tenant's slow chunked uploads can no
  longer hold all four slots and starve other users into 429s. Single-user
  mode keeps the whole global capacity (no per-user quota).
- probe_video validates decoder-reported metadata: non-positive or
  over-limit dimensions (> 64 MP) and non-finite/negative durations are
  rejected before the upload path persists them; garbage fps degrades to
  None (unknown). The decode worker refuses to decode frames from files
  whose probed dimensions exceed the bound — a small crafted container
  claiming 100k x 100k would otherwise trigger a ~30 GB allocation.
- The worker's stream command falls back to cv2 like probe/frame/count do,
  so an MP4 accepted at upload via the cv2 path now also works in the
  frame-range and concat nodes. The fallback only engages before the first
  emitted frame; a mid-stream decoder death still surfaces as an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Partial board deletion: the boardAndImagesDeleted listener now invalidates
  the per-item Image*/Video* tags for the confirmed-deleted names it parses
  out of the 500 detail — the rejected mutation runs invalidatesTags with no
  result, so those caches previously stayed readable.
- Media-cookie refresh retries transient failures on a bounded backoff
  (2s, 10s) instead of latching before the request and giving up forever;
  401 still bails (session genuinely expired).
- Thumbnail 404s degrade gracefully: BoardTooltip, GalleryBoard,
  VirtualBoardItem, and VideoFieldInputComponent show an icon fallback via
  fallbackStrategy="onError" (thumbnail generation is best-effort server-
  side), and GalleryVideoThumbnail's <video> fallback does the near-zero
  seek on loadedmetadata so browsers that don't auto-paint the first frame
  no longer show a black tile.
- CurrentVideoPreview handles play() rejection (rolls isPlaying back) and
  media element errors (drops back to the play overlay) instead of hiding
  the overlay over a dead element with an unhandled promise rejection.
- Hardening from the disputed items: changeVideoIsIntermediate also
  invalidates the VideoList LIST_TAG (covers a future omitted-board_id
  list); logout clears gallery.selection and the logout mutation documents
  that resetApiState in store.ts is what actually clears cross-user caches.
- Typegen regenerated for the wan_lora_loader / wan_ref_image_encoder
  docstring updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lstein

lstein commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@JPPhoto — the fixes for all 13 confirmed items from your 2026-07-21 review (plus the two hardenings offered for the disputed items) are pushed in three commits at head 30f8e250d6. Item-by-item, in your original order:

fix(api)d51c661251

  • Global upload slots — the middleware now keeps a per-user slot count (cap 2, MAX_CONCURRENT_VIDEO_UPLOADS_PER_USER) on top of the global cap of 4, keyed by the verified token's user id. One tenant at their cap gets a 429 while others still upload; single-user mode keeps the whole global capacity. Tests: per-user quota blocks only the saturating user, per-user counts released after the request, single-user mode unclipped, unauthenticated requests still consume nothing.
  • Unvalidated probe metadataprobe_video rejects non-positive or over-limit dimensions (> 64 MP ≈ 8K×8K) and non-finite/negative durations before videos.create ever sees them; unusable fps degrades to None (the established "unknown" semantics). The decode worker independently refuses to decode frames/streams from files whose probed dimensions exceed the same bound, so the ~30 GB allocation for a claimed 100k×100k frame can't happen even outside the upload path. 14 new parametrized tests including the boundary case.
  • Streaming decode asymmetry_stream() now falls back to cv2 exactly like probe/frame/count, so an MP4 accepted via the cv2 path works in frame-range extract and concat. The fallback only engages before the first emitted frame — a mid-stream decoder death surfaces as an error rather than restarting from frame 0 (tested), and a stream that produces zero frames from both backends errors instead of exiting cleanly.

fix(backend)74cd320844

  • LoRA loaders trusting identifiers — both loaders now fetch the resolved config and require type=LoRA, base=Wan before anything else (the collection loader's client-side lora.lora.base check is gone). The single loader validates even when no transformer is wired. Tests cover Flux/SDXL LoRAs and a main-model key mislabeled as a Wan LoRA.
  • Collection loader re-applying LoRAs — it now rejects any incoming key already present on either expert list of the incoming transformer field, with the same error the single loader raises; intra-collection duplicates keep their silent-skip semantics.
  • TI2V inert low routing — a LoRA routed only to the low-noise list of a TI2V-5B main logs a warning ("the LoRA will have no effect"), consistent with the model loader's warning for a wired-but-ignored low-noise transformer. Routing that touches the primary list, and low routing on A14B, stay silent (tested both ways).
  • _ExpertSwapper._release stale state — the field-clearing now sits in a nested finally around the device-context exit, so a device __exit__ failure clears every slot before propagating. Test asserts the slots are cleared, the exception propagates, and a second close() performs no double LoRA/device exit.
  • Ref-image node description — module and class docstrings rewritten to describe both output shapes (20-ch A14B concat, 48-ch TI2V-5B blend) and to only tell T2V users to omit the node. Typegen regenerated.
  • WanLatentsToImage temporal validation — 5D latents with T>1 are rejected with an explicit error pointing at wan_l2v, before the VAE is loaded (the check moved above models.load, so the rejection is free). Tests assert no model load happens on rejection and that 4D and single-frame 5D still proceed.

fix(ui)30f8e250d6

  • Thumbnail 404 consumers — BoardTooltip, GalleryBoard, VirtualBoardItem, and VideoFieldInputComponent all render an icon fallback via chakra's fallbackStrategy="onError" (same icon as the missing-URL case), and GalleryVideoThumbnail's <video> fallback now does the loadedmetadata near-zero seek that CurrentVideoPreview uses, so it paints a frame on browsers that don't decode under preload="metadata".
  • Media-cookie refresh giving up — replaced the latch-before-request ref with a success-latch plus bounded backoff retries (2 s, 10 s) for non-401 failures; 401 is never retried (the session itself is expired, global handling owns that), and pending timers are cancelled on unmount/logout.
  • Partial board deletion caches — the boardAndImagesDeleted listener (which already parses the confirmed-deleted names out of the 500's detail) now dispatches api.util.invalidateTags for the per-item Image*/Video* tags of those names; harmlessly redundant on the fulfilled path.
  • Playback error statehandlePlay catches play() rejection and rolls isPlaying back, and the media element has an onError that drops back to the play-overlay state, so a codec/cookie failure leaves a retryable UI instead of hidden controls over a dead element.
  • Disputed-item hardening, as offered: changeVideoIsIntermediate additionally invalidates the VideoList LIST_TAG (closes the latent omitted-board_id gap), logout clears gallery.selection, and the logout mutation now carries a comment pointing at the resetApiState listener in store.ts as the real cross-user cache clear.

Verification: full backend suite 3280 passed / 130 skipped (6m31s); frontend 1454 tests across 117 files, tsc/eslint/prettier/knip/dpdm all clean; ruff check + format clean; typegen regenerated with CI's exact commands (docstring-only schema diff).

@lstein
lstein requested a review from JPPhoto July 22, 2026 01:59

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one looks like a merge blocker:

  • invokeai/frontend/web/src/features/auth/hooks/useMediaCookieRefresh.ts:43: The hook does not retain or abort an in-flight refresh request; cleanup only marks it canceled and clears pending timers. If logout completes at invokeai/app/api/routers/auth.py:248 before a delayed refresh response reaches _set_media_cookie() at line 295, that response recreates the HttpOnly media cookie after logout. On a shared browser, a subsequent user can inherit the previous user's media authorization. Test: Delay the media-cookie response, complete logout first, then release the refresh response and assert the cookie remains absent and protected media returns 401; also cover logout followed immediately by another user's login.

These are worth fixing:

  • invokeai/app/util/video_decode_worker.py:42: _assert_decodable_dims() fails open when _probe() raises and also permits non-positive dimensions. The frame and stream commands then decode anyway at lines 217-224, so the child can allocate an unbounded frame before the parent's record-size or PIL checks run. This does not provide the claimed independent memory bound for unprobeable or malformed files. Test: Make _probe() raise, or return zero/negative dimensions, while the decoder would produce an oversized frame; assert decoding is refused before iio.imread, iio.imiter, or OpenCV is called, while valid bounded dimensions proceed.

  • invokeai/app/util/video_decode_worker.py:185: _stream() returns successfully whenever iio.imiter() ends without raising, even if it emitted zero frames. An imageio backend that silently yields nothing therefore bypasses the new OpenCV fallback, and frame-range or concatenation nodes fail despite OpenCV being able to decode the file. Test: Make iio.imiter() return an empty iterator while OpenCV provides frames and assert those frames are emitted; when both backends produce no frames, assert a decoder-specific error.

This could be fixed in a follow-up PR:

  • invokeai/app/invocations/wan_latents_to_image.py:57: The new early validation rejects only 5D tensors whose temporal dimension is not one. Tensors with ranks other than 4 or 5 still load the VAE before failing in shape indexing, broadcasting, or decoding with an opaque error. Test: Pass 3D and 6D tensors and assert a clear rank error occurs before context.models.load(), while 4D and single-frame 5D inputs continue normally.

@JPPhoto

JPPhoto commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

@lstein I went ahead and committed and pushed fixes for all of those.

@JPPhoto

JPPhoto commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Working on the following:

  • invokeai/app/api_app.py:129-141 refreshes JWTs by re-signing the old TokenData, while invokeai/app/api/auth_dependencies.py:get_current_user() reloads only active status and require_admin() trusts the old is_admin claim. This PR turns previously time-bounded stale privileges into indefinitely renewable privileges: a demoted administrator can continue making admin mutations and refreshing the obsolete admin claim. This is a multi-user merge blocker. Test: create two administrators, demote one in the database, repeatedly use that user's old token across its original expiration, and assert admin requests return 403 and no refreshed token or media cookie is issued.
  • invokeai/frontend/web/src/services/api/index.ts:121-136, invokeai/app/api_app.py:100-141, and invokeai/frontend/web/src/features/auth/hooks/useMediaCookieRefresh.ts:12-16 allow an authenticated mutation started before logout to return afterward and unconditionally restore the old JWT in local storage and the old media cookie. The logout pause covers only the current tab's startup /media-cookie request, not normal mutations or another tab. If user B logs in before the delayed response arrives, the browser can end up sending user A's credentials again. This is a multi-user merge blocker. Test: defer an authenticated mutation and a second-tab media-cookie refresh, log out A, log in B, release both responses, and assert neither A's local token nor A's cookie can replace B's credentials.
  • invokeai/frontend/web/src/features/auth/hooks/useMediaCookieRefresh.ts:86-93 waits indefinitely for every pending refresh, and invokeai/frontend/web/src/features/auth/store/logoutAfterServerConfirmation.ts:1-13 does not send the logout request or clear local authentication until that wait completes. A stalled /media-cookie request therefore makes the logout button hang forever. Test: leave the refresh promise unresolved, advance a bounded timeout or abort signal, and assert logout still reaches a deterministic server/local completion or visible failure state.
  • invokeai/frontend/web/src/services/api/index.ts:130-136 stores refreshed JWTs only in local storage, while invokeai/frontend/web/src/services/events/useSocketIO.ts:41-70 authenticates reconnects from the unchanged Redux token. invokeai/frontend/web/src/features/auth/components/UserProfile.tsx:127-143 can then overwrite a newer refreshed token with that stale Redux token after a profile update. After the original expiry, socket reconnects fail and saving a profile can immediately revert the HTTP session to an expired token. Test: start with an old Redux token and a newer refreshed token, reconnect the socket and save the profile after the old expiry, and assert both operations retain and use the newest token.
  • invokeai/app/util/video_decode_worker.py:_assert_decodable_dims() checks only the dimensions reported before decoding, while _stream(), _extract_frame(), and _emit_stream_frame() neither validate every decoded frame's dimensions nor impose a child-process memory limit. A variable-resolution bitstream can advertise a permitted first resolution and later force the decoder to allocate an enormous frame before the parent's record-size check runs. This is a hostile-upload memory-exhaustion blocker. Test: decode a variable-resolution fixture whose later frame exceeds MAX_FRAME_PIXELS, under a measurable worker memory bound, and assert the worker rejects it without a large allocation or parent failure.
  • invokeai/app/api_app.py:221-295 limits concurrent uploads but allows limited_receive() to await the next body chunk indefinitely. Authenticated clients can drip or stop request bodies while retaining their per-user and global slots; two accounts can occupy all four slots indefinitely and deny uploads to every other tenant. Test: start the maximum number of uploads across multiple users, block their receive() calls after an initial chunk, and assert an idle deadline releases the slots so a healthy upload can proceed.
  • invokeai/app/invocations/wan_denoise.py:381-390,564-598 and invokeai/app/invocations/wan_video_denoise.py:178-185,310-358 decide whether to load negative conditioning solely from the primary guidance scale. With guidance_scale=1 and guidance_scale_low_noise=4, the low-noise expert silently performs no CFG even though its configured scale requires it. Test: exercise a dual-expert schedule with primary scale 1, low-noise scale greater than 1, and negative conditioning; assert the high expert performs one forward pass and the low expert performs conditional and unconditional passes using the low-noise scale.
  • invokeai/app/invocations/wan_latents_to_video.py:85-94,163-175 accepts latent tensors with any batch size, decodes the entire batch, and then silently keeps only decoded[0]. A connected batch of two or more samples wastes substantial VAE work and loses every result except the first. Test: provide valid 4D and 5D latent tensors with batch size 2 and assert the invocation rejects them before loading or running the VAE, unless explicit multi-video output is implemented.
  • invokeai/app/api/routers/videos.py:_is_mp4_file() and upload_video() validate the MP4 container and probe metadata but do not require a decodable frame or a codec supported by the target browsers. invokeai/app/services/video_files/video_files_disk.py:73-89 explicitly accepts failed thumbnail extraction, and invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx:119-136 merely restores the play button after a media error. The API can return 201 for a permanently unplayable gallery item that presents an endless black retry state. Test: upload a metadata-valid MP4 with no decodable frames and one using a codec unsupported by the supported browser runtime; assert rejection, transcoding, or a persistent actionable unsupported-media error.
  • invokeai/app/invocations/video_concat.py:121-171 accepts inputs with different frame rates, assigns every decoded frame the first video's FPS, and does not resample timestamps. Later clips therefore play too quickly or too slowly and the recorded duration is wrong. Test: concatenate equal-duration 16 FPS and 24 FPS clips and assert either a clear mismatch rejection or an output whose segment durations remain correct.
  • invokeai/app/invocations/video_concat.py:146-172 and invokeai/app/invocations/video_frame_extract_range.py:187-220 reconstruct outputs exclusively from decoded image frames. Audio tracks from uploaded MP4s are silently discarded, although the nodes are presented as general concatenate and range operations. Test: process an MP4 containing an audio track and assert audio is preserved, explicitly rejected, or clearly documented as unsupported in docs/src/content/docs/features/gallery.mdx.
  • invokeai/app/api/routers/videos.py:337-396 suppresses every per-item authorization, storage, and database failure and returns HTTP 200 containing only successful deletions. invokeai/frontend/web/src/features/deleteVideoModal/store/state.ts:89-106 and invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx:110-120 treat that response as completion without reporting omitted items. Users can finish a batch or uncategorized deletion believing everything was removed while failed videos remain. Test: make one deletion succeed and one fail in both flows, then assert the survivor remains selected and the UI reports a partial failure with an accurate count.
  • invokeai/frontend/web/src/features/deleteVideoModal/store/state.ts:49-56,139-158 creates a caller-visible promise, but closeSilently() clears its resolve and reject callbacks without settling it. invokeai/frontend/web/src/features/deleteVideoModal/components/DeleteVideoModal.tsx:27-36 wires that silent close to the dialog's generic onClose, so Escape, overlay dismissal, or programmatic closure can leave callers pending permanently. Test: open through delete(), invoke onClose independently of the cancel button, and assert the promise settles and a subsequent dialog does not replace unresolved callbacks.
  • docs/src/content/docs/features/gallery.mdx:46-57 states that selecting Delete Board permanently removes the board and all images and videos, while invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx:181-189 offers distinct "Delete Board Only" and "Delete Board and Assets" outcomes. The warning omits the non-destructive path and is especially misleading in multi-user boards where asset ownership affects deletion. Test: cover both modal actions and update the documentation to describe their distinct effects, including treatment of other users' contributions.

@JPPhoto

JPPhoto commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

More fixes:

  • Hardened token refresh ordering, role freshness, cross-request locking, logout cancellation, profile updates, media-cookie synchronization, and subpath URL handling.
  • Bounded stalled uploads and decoder memory, rejected malformed or undecodable uploads, and validated every decoded frame.
  • Rejected multi-video Wan latent batches and corrected low-noise CFG behavior in both denoise implementations.
  • Rejected ambiguous mixed-FPS concatenation unless output FPS is explicit, and documented audio behavior.
  • Reported partial video deletion failures, fixed modal cancellation, awaited uncategorized deletion, and added playback failure feedback.
  • Regenerated frontend API types and updated gallery documentation and translations.

@JPPhoto

JPPhoto commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Currently working on:

Merge Blockers:

  • invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts:20: the fallback lock is module-local, so separate tabs are not serialized when navigator.locks is unavailable. A delayed media-cookie request from user A can complete after user B logs in and overwrite B's cookie; the generation check cannot undo a Set-Cookie header already processed by the browser. Test: run two independent tab contexts without Web Locks, delay A's cookie response until after B's login, and assert subsequent media requests authenticate as B.

  • invokeai/app/api_app.py:282: idle_timeout_seconds applies independently to every receive(), so a client can send one byte before each deadline and retain an upload slot indefinitely. Two authenticated accounts can occupy all four global slots and keep other users receiving 429 responses. Test: drip-feed four uploads across two users for longer than the timeout and assert an absolute deadline or minimum transfer rate terminates them.

  • invokeai/app/util/video_decode_worker.py:43: allocation limits are installed only on Linux, while _validate_decoded_frame() runs after imageio or OpenCV has allocated the frame. A deceptive video can cause an unbounded decoder allocation on Windows or macOS before rejection. Test: run a small-probed, oversized-later-frame fixture on every supported platform and assert the worker remains within a fixed memory budget.

  • invokeai/app/services/images/images_default.py:294, invokeai/app/services/videos/videos_default.py:318, and invokeai/app/api/routers/boards.py:114: per-file staging failures are logged and omitted from the deletion lists, after which the board is deleted with HTTP 200. "Delete Board and Assets" can leave media uncategorized without warning. Test: inject image and video staging failures and assert the response and frontend identify the partial failure. Document the behavior in docs/src/content/docs/features/gallery.mdx.

  • invokeai/frontend/web/src/features/gallery/components/Boards/DeleteBoardModal.tsx:118: the combined uncategorized deletion discards the image result. If image deletion rejects while video deletion succeeds, the modal closes without reporting that images remain. Test: cover image-rejected/video-fulfilled, image-fulfilled/video-rejected, and both-fulfilled outcomes.

Other Issues:

  • invokeai/app/util/video_thumbnails.py:107: when proc.communicate() raises an exception other than TimeoutExpired, _run_worker() returns without terminating the live worker or descendants. Test: raise OSError from communicate() and assert the complete process tree is terminated and reaped.

  • invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts:137: direct client-state POST requests ignore X-Refreshed-Token. Sessions whose activity consists only of persistence requests can expire while active, and their media cookie remains stale. Test: return a refreshed token from setItem() and clearStorage() and assert local auth state and the media cookie are updated.

  • invokeai/app/api/routers/videos.py:719: star and unstar suppress authorization and storage failures and return HTTP 200 with only successful names. Frontend callers do not detect the omitted names, making partial failures silent. Test: make one update succeed and one fail for each operation and assert the failure is surfaced.

  • invokeai/app/api/routers/videos.py:357: duplicate names in batch deletion can be returned in both deleted_videos and failed_videos because the raw input is processed repeatedly. Test: submit duplicate names and assert each unique name is processed and classified once.

  • invokeai/app/api/routers/videos.py:348: delete, star, and unstar request lists have no item-count or string-length bounds. Large authenticated requests can force many sequential database operations. Test: accept requests at a configured batch limit and reject larger requests before service calls occur.

  • invokeai/app/api/routers/gallery.py:34 and invokeai/app/api/routers/videos.py:658: pagination parameters have no lower or upper bounds. SQLite treats LIMIT -1 as unbounded, allowing every matching DTO to be returned. Test: reject negative offsets, non-positive limits, and limits above a fixed maximum.

  • invokeai/app/api/routers/videos.py:190: upload validation checks the MP4 container and server decoding, but not browser codec support. A server-decodable video can be accepted and remain unplayable in supported browsers. Test: upload a browser-incompatible codec and require rejection, transcoding, or persistent remediation guidance. Document the codec contract in docs/src/content/docs/features/gallery.mdx.

  • invokeai/app/api/routers/videos.py:545: deletion between the existence/stat/open operations in range serving can produce an unhandled FileNotFoundError and HTTP 500. Test: synchronize deletion at each boundary and assert GET and HEAD return a controlled 404.

  • invokeai/app/api/routers/videos.py:53: four concurrent near-limit uploads can consume roughly 8 GB of temporary storage because both multipart and route-level copies exist. Test: exercise near-limit uploads under a quota-controlled temporary directory and verify predictable rejection. Document the requirement in docs/src/content/docs/start-here/system-requirements.mdx.

  • invokeai/app/services/boards/boards_default.py:104: board listing performs separate image-cover, video-cover, image-count, asset-count, and video-count queries for every board, plus owner queries for administrators. The PR adds further per-board queries to an existing N+1 path. Test: instrument query counts for increasing board counts and require aggregate queries rather than linear per-board video lookups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.0 api backend PRs that change backend files CI-CD Continuous integration / Continuous delivery docs PRs that change docs frontend PRs that change frontend files invocations PRs that change invocations python PRs that change python files python-deps PRs that change python dependencies python-tests PRs that change python tests Root services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

3 participants