diff --git a/USER_ISOLATION_IMPLEMENTATION.md b/USER_ISOLATION_IMPLEMENTATION.md
deleted file mode 100644
index 324c40db562..00000000000
--- a/USER_ISOLATION_IMPLEMENTATION.md
+++ /dev/null
@@ -1,169 +0,0 @@
-# User Isolation Implementation Summary
-
-This document describes the implementation of user isolation features in the InvokeAI session queue and processing system to address issues identified in the enhancement request.
-
-## Issues Addressed
-
-### 1. Cross-User Image/Preview Visibility
-**Problem:** When two users are logged in simultaneously and one initiates a generation, the generation preview shows up in both users' browsers and the generated image gets saved to both users' image boards.
-
-**Solution:** Implemented socket-level event filtering based on user authentication:
-
-#### Backend Changes (`invokeai/app/api/sockets.py`):
-- Added socket authentication middleware in `_handle_connect()` method
-- Extracts JWT token from socket auth data or HTTP headers
-- Verifies token using existing `verify_token()` function
-- Stores `user_id` and `is_admin` in socket session for later use
-- Modified `_handle_queue_event()` to filter events by user:
- - For `QueueItemEventBase` events, only emit to:
- - The user who owns the queue item (`user_id` matches)
- - Admin users (`is_admin` is True)
- - For general queue events, emit to all subscribers
-
-#### Event System Changes (`invokeai/app/services/events/events_common.py`):
-- Added `user_id` field to `QueueItemEventBase` class
-- Updated all event builders to include `user_id` from queue items:
- - `InvocationStartedEvent.build()`
- - `InvocationProgressEvent.build()`
- - `InvocationCompleteEvent.build()`
- - `InvocationErrorEvent.build()`
- - `QueueItemStatusChangedEvent.build()`
-
-### 2. Batch Field Values Privacy
-**Problem:** Users can see batch field values from generation processes launched by other users.
-
-**Solution:** Implemented field value sanitization at the API level:
-
-#### API Router Changes (`invokeai/app/api/routers/session_queue.py`):
-- Created `sanitize_queue_item_for_user()` helper function
- - Clears `field_values` for non-admin users viewing other users' items
- - Admins and item owners can see all field values
-- Updated endpoints to require authentication and sanitize responses:
- - `list_all_queue_items()` - Added `CurrentUser` dependency
- - `get_queue_items_by_item_ids()` - Added `CurrentUser` dependency
- - `get_queue_item()` - Added `CurrentUser` dependency
-
-### 3. Queue Updates Across Browser Windows
-**Problem:** When the job queue tab is open in multiple browsers and a generation is begun in one browser window, the queue does not update in the other window.
-
-**Status:** This issue is likely resolved by the socket authentication and event filtering changes. The existing socket subscription mechanism (`subscribe_queue` event) already supports multiple connections per user. Testing is required to confirm this works correctly with the new authentication flow.
-
-### 4. User Information Display
-**Problem:** Queue table lacks user identification, making it difficult to know who launched which job.
-
-**Solution:** Added user information to queue items and UI:
-
-#### Database Layer (`invokeai/app/services/session_queue/session_queue_sqlite.py`):
-- Updated SQL queries to JOIN with `users` table
-- Modified methods to fetch user information:
- - `get_queue_item()` - Now selects `display_name` and `email` from users table
- - `dequeue()` - Includes user info
- - `get_next()` - Includes user info
- - `get_current()` - Includes user info
- - `list_all_queue_items()` - Includes user info
-
-#### Data Model Changes (`invokeai/app/services/session_queue/session_queue_common.py`):
-- Added optional fields to `SessionQueueItem`:
- - `user_display_name: Optional[str]` - Display name from users table
- - `user_email: Optional[str]` - Email from users table
- - Note: `user_id` field already existed from Migration 25
-
-#### Frontend UI Changes:
-- **Constants** (`constants.ts`): Added `user: '8rem'` column width
-- **Header** (`QueueListHeader.tsx`): Added "User" column header
-- **Item Component** (`QueueItemComponent.tsx`):
- - Added logic to display user information (display_name → email → user_id)
- - Added user column to queue item row
- - Added tooltip with full username on hover
- - Added "Hidden for privacy" message when field_values are null for non-owned items
-- **Localization** (`en.json`): Added translations:
- - `"user": "User"`
- - `"fieldValuesHidden": "Hidden for privacy"`
-
-## Security Considerations
-
-### Token Verification
-- Tokens are verified using the existing `verify_token()` function from `invokeai.app.services.auth.token_service`
-- Invalid or missing tokens default to "system" user with non-admin privileges
-- Socket connections without valid tokens are still accepted for backward compatibility but have limited access
-
-### Data Privacy
-- Field values are only visible to:
- - The user who created the queue item
- - Admin users
-- Non-admin users viewing other users' queue items see "Hidden for privacy" instead of field values
-
-### Admin Privileges
-- Admin users can see all queue events and field values across all users
-- Admin status is determined from the JWT token's `is_admin` field
-
-## Migration Notes
-
-No database migration is required. The changes leverage:
-- Existing `user_id` column in `session_queue` table (added in Migration 25)
-- Existing `users` table (added in Migration 25)
-- SQL LEFT JOINs to fetch user information (gracefully handles missing user records)
-
-## Testing Requirements
-
-### Backend Testing
-1. **Socket Authentication:**
- - Verify valid tokens are accepted and user context is stored
- - Verify invalid tokens default to system user
- - Verify expired tokens are rejected
-
-2. **Event Filtering:**
- - User A should only receive events for their own queue items
- - Admin users should receive all events
- - Non-admin users should not receive events from other users
-
-3. **Field Value Sanitization:**
- - Non-admin users should see null field_values for other users' items
- - Admins should see all field values
- - Users should see their own field values
-
-### Frontend Testing
-1. **UI Display:**
- - User column should display in queue list
- - Display name should be shown when available
- - Email should be shown as fallback when display name is missing
- - User ID should be shown when both display name and email are missing
- - Tooltip should show full username on hover
-
-2. **Field Values Display:**
- - "Hidden for privacy" message should appear when viewing other users' items
- - Own items should show field values normally
-
-3. **Multi-Browser Testing:**
- - Open queue tab in two browsers with different users
- - Start generation in one browser
- - Verify other browser doesn't see the preview/progress
- - Verify admin user can see all generations
-
-### Integration Testing
-1. Multi-user scenarios with simultaneous generations
-2. Queue updates across multiple browser windows
-3. Admin vs. non-admin privilege differentiation
-4. Socket reconnection handling
-
-## Known Limitations
-
-1. **TypeScript Types:**
- - The OpenAPI schema needs to be regenerated to include new fields
- - Run: `cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen`
-
-2. **Backward Compatibility:**
- - System user ("system") entries will not have display name or email
- - Existing queue items from before Migration 25 will have user_id="system"
-
-3. **Socket.IO Session Storage:**
- - Socket.IO's in-memory session storage may not persist across server restarts
- - Consider implementing persistent session storage if needed for production
-
-## Future Enhancements
-
-1. Add user filtering to queue list (show only my items vs. all items)
-2. Add permission system for queue management operations (cancel, retry, delete)
-3. Implement queue item ownership transfer for administrative purposes
-4. Add audit logging for queue operations with user attribution
-5. Consider implementing user-specific queue limits or quotas
diff --git a/docs/src/content/docs/features/canvas-projects.mdx b/docs/src/content/docs/features/canvas-projects.mdx
new file mode 100644
index 00000000000..7d079e9d3f4
--- /dev/null
+++ b/docs/src/content/docs/features/canvas-projects.mdx
@@ -0,0 +1,104 @@
+---
+title: Canvas Projects
+description: Save and re-open complete canvas sessions — layers, masks, reference images, parameters and LoRAs — so you can pick up exactly where you left off.
+lastUpdated: 2026-05-14
+---
+
+import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
+
+A **Canvas Project** is a snapshot of everything you have on the canvas at a given moment — every layer, mask, reference image, generation setting, and active LoRA. You can save a project, come back to it later, share it with someone else, or just keep multiple versions of the same scene side-by-side.
+
+
+
+---
+
+## What's included in a project
+
+When you save, the project keeps:
+
+- All raster layers, control layers, inpaint masks and regional guidance regions
+- Global reference images and IP-Adapter / FLUX Redux setups
+- The current generation parameters — model, prompt, seed, scheduler, dimensions, etc.
+- Active LoRAs and their weights
+- The bounding box and your currently-selected layer
+
+It does **not** include items that are still in the staging area (generations you haven't accepted yet), pending queue items, or anything from other tabs like the workflow editor.
+
+---
+
+## Saving a project
+
+Open the save dialog from **File → Save Canvas Project** (or the matching entry in the canvas right-click menu).
+
+You have two choices for where the project goes:
+
+* **Save to Server** — keeps the project on InvokeAI. It shows up in the gallery alongside your images and videos, can be assigned to a board, and can be re-opened from any browser pointed at the same InvokeAI installation.
+* **Download as File** — saves the project as a `.invk` file in your browser's downloads folder. Useful for backups or sharing with someone else.
+
+When saving to the server, pick a board (or leave it as *Uncategorized*) before clicking **Save**.
+
+### Updating a project you already loaded
+
+If you opened the canvas by loading a project from the gallery, the save dialog automatically offers two save modes:
+
+* **Update Existing Project** — overwrites the project you opened with your current canvas. Its name, board, and starred state are preserved. The save dialog shows the project's thumbnail and name so you can double-check which one you're about to overwrite.
+* **Create New Project** — saves your work as a *new* project, leaving the original untouched. Use this as a "Save As".
+
+### About the gallery thumbnail
+
+The thumbnail you see in the gallery is automatically rendered from the visible raster content of your canvas at save time. If your canvas only contains control layers, masks, or reference images (no visible raster), the project saves without a thumbnail and shows a neutral placeholder until you re-save with some visible content.
+
+---
+
+## Loading a project
+
+Two ways to load:
+
+1. **From the gallery** — click a project in the gallery to preview its thumbnail in the viewer, then click **Load Canvas Project** in the viewer toolbar. You can also right-click the project and pick *Load Project*.
+2. **From a file** — use **File → Load Canvas Project from File** and pick a `.invk` file from your disk.
+
+Either way, a confirmation dialog appears first, because:
+
+:::caution
+Loading a project **replaces** your current canvas. Layers, masks, reference images, generation parameters and LoRAs are all overwritten. There is no undo. If you have unsaved work, save it first.
+:::
+
+After you confirm, InvokeAI switches you to the Canvas tab automatically so you can start editing right away.
+
+### What happens if some images are missing?
+
+When you load an older project, some of the images it references may no longer be on the server (especially intermediate generations, which get cleaned up over time). That's fine — every image is also embedded inside the project file, so InvokeAI automatically restores the missing ones and reconnects them to your layers. You don't need to do anything; it just works.
+
+---
+
+## Organizing projects in the gallery
+
+Projects show up in the same gallery grid as images and videos, marked with a small layer-stack badge so you can tell them apart at a glance.
+
+
+
+### Right-click menu
+
+Right-click (or long-press on touch devices) a project for these actions:
+
+* **Load Canvas Project** — same as the toolbar button: opens the load confirmation dialog
+* **Download** — saves a `.invk` copy to your computer, handy as a backup or to share
+* **Delete** — permanently removes the project after a confirmation
+
+### Moving projects between boards
+
+Drag any project from the gallery onto a board in the boards list to move it there. Drag it onto *Uncategorized* to remove it from a board. You can also pick *Change Board* from the right-click menu after selecting one or more projects.
+
+A project can be on **one board at a time**. If you delete a board, all of its contents — images, videos, and canvas projects — are deleted with it, so download anything you want to keep beforehand.
+
+### Selecting multiple items
+
+Shift-click, Ctrl-click and Cmd-click work across all three kinds of gallery items. You can select a range that mixes images, videos and canvas projects together; bulk actions like delete will handle each kind correctly.
+
+---
+
+## Sharing and backing up
+
+Because **Download as File** produces a regular `.invk` file, sharing a project is as simple as sending that file to someone else. They can load it via **File → Load Canvas Project from File** and get an exact copy of your canvas (assuming the models and LoRAs you used are also available on their installation).
+
+For your own backups, the same download is the easiest option — pop the `.invk` files onto cloud storage or a USB drive.
\ No newline at end of file
diff --git a/docs/src/content/docs/features/gallery.mdx b/docs/src/content/docs/features/gallery.mdx
index fec8c918a3e..f2a26b56176 100644
--- a/docs/src/content/docs/features/gallery.mdx
+++ b/docs/src/content/docs/features/gallery.mdx
@@ -1,12 +1,12 @@
---
title: Gallery Panel
-description: Learn how to manage, organize, and use your generated images and assets with the Gallery Panel in InvokeAI.
-lastUpdated: 2026-02-19
+description: Learn how to manage, organize, and use your generated images, videos, and assets with the Gallery Panel in InvokeAI.
+lastUpdated: 2026-05-13
---
import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
-The Gallery Panel is a fast way to review, find, and make use of images you've generated and loaded. The Gallery is divided into **Boards**. The *Uncategorized* board is always present, but you can create your own for better organization.
+The Gallery Panel is a fast way to review, find, and make use of images, videos, and canvas projects you've generated and loaded. The Gallery is divided into **Boards**. The *Uncategorized* board is always present, but you can create your own for better organization. Boards are polymorphic — images, videos, and [canvas projects](/features/canvas-projects) coexist on the same board and appear together in the gallery, sorted by creation time.

@@ -49,10 +49,10 @@ Each board has a context menu accessible via right-click (or Ctrl+click).
- **Auto-add to this Board:** If *Auto-Assign Board on Click* is disabled in settings, use this option to quickly set the selected board as the default destination for new images.
- **Download Board:** Packages all images within the board into a `.zip` file. A notification link will be provided when the download is ready.
-- **Delete Board:** Permanently removes the board and all of its contents.
+- **Delete Board:** Permanently removes the board and all of its contents — both images **and** videos.
:::danger
-Deleting a board will **permanently delete all images** contained within it. Proceed with caution!
+Deleting a board will **permanently delete all images and videos** contained within it. Proceed with caution!
:::
### Board Contents
@@ -105,6 +105,27 @@ Additionally, each image has a context menu (right-click or Ctrl+click) with pow
---
+## Videos in the Gallery
+
+Videos generated by InvokeAI (currently from the Wan 2.2 model family) appear alongside images in the same gallery view. Each video item displays a first-frame still as its thumbnail with a play badge in the corner; selecting it opens the video in the viewer where you can play it back inline.
+
+### Uploading Videos
+
+You can upload existing videos to a board via the standard drop-or-upload affordance. The upload pipeline accepts **MP4 files only**. Other containers (`.mov`, `.webm`, `.mkv`) are not transcoded on upload and are rejected at the API boundary — re-encode them to MP4 (for example with `ffmpeg -i input.mov -c:v libx264 output.mp4`) before uploading.
+
+### Video Context Menu
+
+Each video has a context menu with the same organization actions as images, plus video-appropriate variants:
+
+- **Open in New Tab / Download:** Opens or saves the raw MP4 file.
+- **Star Video:** Pins the video to the top of the gallery.
+- **Change Board:** Moves the video to a different board. *(Drag-and-drop onto board thumbnails also works.)*
+- **Delete Video:** Permanently deletes the video and its thumbnail.
+
+Videos count toward board contents: a board with two images and three videos shows five items in the polymorphic gallery list and reports both totals in its stats.
+
+---
+
## Summary
This walkthrough covers the Gallery interface and Boards. For guidance on prompting and generation workflows, please refer to the [Prompting Guide](/concepts/prompting-guide/) and [AI Image Generation](/concepts/image-generation/).
diff --git a/docs/src/content/docs/features/video-generation.mdx b/docs/src/content/docs/features/video-generation.mdx
new file mode 100644
index 00000000000..3e3d31104a3
--- /dev/null
+++ b/docs/src/content/docs/features/video-generation.mdx
@@ -0,0 +1,251 @@
+---
+title: Video Generation (experimental)
+description: Generate short videos with the Wan 2.2 model family — text-to-video, image-to-video, and the trick for stitching longer sequences.
+lastUpdated: 2026-05-13
+---
+
+import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
+
+InvokeAI ships **experimental support for the Wan 2.2 model family**, which lets you generate short MP4 clips from a text prompt, an image, or both. Output ranges from a few-second loop (the model's training distribution) up to longer sequences assembled with the [concat trick](#making-longer-videos) below.
+
+:::caution[Experimental]
+Video generation is a prototype feature. Workflows, node fields, and starter-model packaging may change between releases. The underlying models are also new — expect rough edges in coherence and artifacts at longer durations.
+:::
+
+---
+
+## Models
+
+Wan 2.2 ships three transformer variants, plus a shared text encoder and two VAEs. All share the same diffusion-style sampling but differ in size, conditioning, and intended task.
+
+### The variants
+
+| Variant | Task | Params | VAE | Conditioning |
+|---|---|---|---|---|
+| **T2V-A14B** | Text → Video | 14B × 2 experts | A14B VAE (16-ch, 8× spatial) | Text only |
+| **I2V-A14B** | Image+Text → Video | 14B × 2 experts | A14B VAE (16-ch, 8× spatial) | Text + reference image (36-channel concat) |
+| **TI2V-5B** | Text → Video OR Image+Text → Video | 5B (single) | Wan 2.2-VAE (48-ch, 16× spatial) | Text, optionally with reference image (first-frame mask blend) |
+
+* **T2V-A14B** generates videos from a text prompt alone. Best motion coherence and prompt-following of the three.
+* **I2V-A14B** locks the first frame to a reference image you supply. Best subject-preservation; the image is concatenated to the noise latents at every step so the model "sees" the reference throughout denoising.
+* **TI2V-5B** is the small single-expert variant. It can do **both** text-to-video and image-to-video with the same checkpoint, at substantially lower VRAM, but with somewhat less stable long-range coherence than the A14B variants.
+
+### High-noise and low-noise transformers (A14B variants only)
+
+The A14B models are a **mixture-of-experts (MoE)** pair. There are actually *two* 14B transformers on disk per variant — a "high-noise" expert and a "low-noise" expert — and the denoise loop swaps between them at a model-defined boundary timestep:
+
+* **High-noise expert** runs early in denoising, when the latents are still mostly noise. It's responsible for composition, layout, and broad motion.
+* **Low-noise expert** runs later, when the latents are close to clean. It refines detail and texture.
+
+InvokeAI handles the swap automatically — both experts have to be installed (the starter bundle handles this), but the workflow only references the "high-noise" model as the main and the "low-noise" model is wired alongside it via the loader node. You don't manage the boundary yourself.
+
+**TI2V-5B is single-expert** — no swap, no boundary, just one model that runs every step. Workflows for TI2V-5B are correspondingly simpler.
+
+### Lightning LoRAs (4-step inference for A14B)
+
+The default A14B variants need ~40–50 denoise steps for clean output. The Wan team also released **Lightning distillation LoRAs** that collapse that to 4 steps with minimal quality loss — about a 10× speedup. There's a pair per variant (one LoRA for the high-noise expert, one for the low-noise), wired through the LoRA loader nodes in the starter workflows.
+
+:::tip
+**TI2V-5B doesn't have a Lightning LoRA.** Its smaller size means each step is cheap; you typically run it at 40–50 steps and end up in a similar wall-clock ballpark as A14B + Lightning.
+:::
+
+### Installing models
+
+The model manager ships two **starter bundles** for video work:
+
+* **Wan 2.2 Text-to-Video** (~36 GB) — UMT5-XXL text encoder, both VAEs, TI2V-5B Q4_K_M, T2V-A14B Q4_K_M (high + low), T2V Lightning (high + low).
+* **Wan 2.2 Image-to-Video** (~32 GB) — UMT5-XXL, A14B VAE, I2V-A14B Q4_K_M (high + low), I2V Lightning (high + low).
+
+The bundles are independent. Installing both ends up at ~56 GB total (shared components — UMT5-XXL and the A14B VAE — are deduplicated on the second install). A 12 GB VRAM card can install only the Text-to-Video bundle and have **TI2V-5B available for both T2V and image-to-video** without ever touching the I2V bundle.
+
+Higher-quality Q8_0 quantizations of every transformer, plus full Diffusers builds of all three variants, are available as a-la-carte installs in the starter models list.
+
+:::tip
+**On a 12 GB VRAM card**: install just the Text-to-Video bundle and use TI2V-5B. The A14B variants will technically run via aggressive offloading but are slow and prone to OOM. TI2V-5B Q4_K_M fits comfortably and is what we recommend for that tier.
+:::
+
+---
+
+## Workflow setup
+
+The shipped starter workflows ("Text to Video - Wan 2.2 Lightning", "Image to Video - Wan 2.2 Lightning") are the easiest starting point — load them from the workflow library, pick your models, set a prompt, and Invoke. The sections below describe what's happening inside so you can build your own.
+
+### Constraints that apply to every video workflow
+
+**Frame count**: `num_frames - 1` must be divisible by **4**. This is dictated by the Wan VAE's temporal compression (4 pixel-frames → 1 latent-frame). Valid values: 5, 9, 13, … **81** (the training default, 5 seconds at 16 fps), 85, 89, etc.
+
+**Pixel dimensions**: must be a multiple of **16** for T2V-A14B and I2V-A14B, and a multiple of **32** for TI2V-5B. The constraint comes from the VAE's spatial downsample × the transformer's 2×2 patch size:
+
+| Variant | VAE spatial | Pixel multiple of |
+|---|---|---|
+| T2V-A14B, I2V-A14B | 8× | **16** |
+| TI2V-5B | 16× | **32** |
+
+Reference values that work: 832×480 (480p), 1280×720 (720p, A14B only — TI2V-5B needs 1280×704 instead since 720 isn't divisible by 32).
+
+**Encoder and denoise dimensions must match**: the `Reference Image - Wan 2.2` encoder and the `Denoise Video - Wan 2.2` node both have their own `width` and `height` fields. They have to be identical or the denoise loop will reject the condition tensor.
+
+:::tip[Use Wan 2.2 I2V Ideal Dimensions]
+The **Wan 2.2 I2V Ideal Dimensions** node takes a source image's W×H and a target preset (480p / 720p / 1080p) and outputs valid (width, height) for the encoder + denoise inputs. Wire it once and feed its outputs into both nodes. Saves the manual snap-to-16/snap-to-32 math.
+:::
+
+### Text-to-video workflow
+
+The minimum node chain for T2V:
+
+```
+Wan Main Model Loader ──┐
+ │
+Wan T5 Text Encoder ────┤
+ ▼
+Wan Compel Conditioning (positive)
+ │
+ ▼
+ Denoise Video - Wan 2.2 ──→ Latents to Video - Wan 2.2 ──→ MP4
+ ▲
+Wan Compel Conditioning (negative) ─┘
+```
+
+For **TI2V-5B T2V** this is the entire graph — load the TI2V-5B model and the TI2V-5B VAE, set width/height/num_frames, and run.
+
+For **T2V-A14B** the main model loader also exposes the low-noise expert slot, and you typically add the **Lightning LoRA pair** (one for each expert) to bring step count down to 4. Recommended:
+
+* Steps: **4** (with Lightning) or **40–50** (without)
+* CFG: **5.0** high-noise / **4.0** low-noise (the dedicated `Guidance Scale (Low Noise)` field on the denoise node)
+* Width × Height: 832×480 (faster, default) or 1280×720 (sharper, 4× the memory)
+
+### Image-to-video workflow
+
+I2V adds a **Reference Image** branch alongside the denoise. The reference image gets VAE-encoded into a conditioning tensor that the denoise loop uses to anchor the video's content:
+
+```
+Wan Main Model Loader ──┐
+Wan T5 Text Encoder ────┤
+Wan Compel Conditioning ┤
+ │
+Image Primitive ──→ Reference Image - Wan 2.2 ──┐
+ │ │
+ ▼ ▼
+ Denoise Video - Wan 2.2 ──→ Latents to Video - Wan 2.2 ──→ MP4
+```
+
+For **I2V-A14B**, both the reference encoder and the denoise node need to use the same width/height. The encoder also takes a `num_frames` parameter that must match the denoise's `num_frames` — set both to 81 by default.
+
+For **TI2V-5B image-to-video**, the conditioning math is different (the model uses a first-frame-mask blend rather than channel concatenation), but the workflow shape is the same. The encoder auto-detects TI2V-5B from the VAE's 48 latent channels and emits the right condition tensor.
+
+:::caution[TI2V-5B I2V dimensional constraint]
+TI2V-5B image-to-video requires **width and height divisible by 32** (not just 16). The encoder will refuse the workflow with a clear error if not. 832×480 works; 1280×720 does not (720 is not divisible by 32). Use 1280×704 for 720p-ish on TI2V-5B.
+:::
+
+### Recommended starting parameters
+
+| | T2V-A14B + Lightning | T2V-A14B | I2V-A14B + Lightning | TI2V-5B (T2V or I2V) |
+|---|---|---|---|---|
+| Steps | 4 | 40–50 | 4 | 40–50 |
+| CFG (high) | 1.0 | 5.0 | 1.0 | 5.0–5.5 |
+| CFG (low) | 1.0 | 4.0 | 1.0 | n/a (single expert) |
+| Num frames | 81 | 81 | 81 | 81 |
+| Width × Height | 832×480 | 832×480 | 832×480 | 832×480 |
+| Scheduler | Auto (FlowMatchEuler) | Auto | Auto | Auto (UniPC) |
+
+---
+
+## Making longer videos
+
+The Wan 2.2 models were trained on **81-frame** clips (5 seconds at 16 fps). Outputs much longer than that suffer rapidly degrading coherence — the temporal positional encoding goes out of distribution and the model loses track of scene content. So instead of asking for `num_frames=200`, the recommended pattern is **chaining**: render a sequence of 81-frame clips where each one's first frame matches the previous clip's last frame, then concatenate them with the `Concatenate Videos` node.
+
+### The basic chain
+
+
+
+1. **Render the first clip** with I2V or T2V, ending on whatever subject/scene you want to continue.
+
+2. **Extract the last frame** of clip 1 using the `Frame from Video` node. Use `frame_index = -1` for the literal last frame, or `-3` / `-5` to step back a few frames (last frames sometimes have boundary artifacts — see the [troubleshooting](#late-frame-artifacts-text-color-blobs) note).
+
+3. **Feed that frame as the reference image** for an I2V run that becomes clip 2. Adjust the prompt for whatever motion you want next.
+
+4. **Repeat** as many times as you want clips.
+
+5. **Concatenate** all the clips into a single MP4 with `Concatenate Videos`. Pick a transition mode based on whether you want a seamless join (`cut` if the bridge frame matches perfectly), a smooth blend (`crossfade`), or a punctuated scene change (`fade_through_black`).
+
+
+
+### Transition modes
+
+The `Concatenate Videos` node offers three:
+
+* **`cut`** — hard splice. Fastest. Total length = sum of inputs. Use this when the bridge frame is genuinely shared (clip 2's first frame = clip 1's last frame) — the seam is invisible.
+* **`crossfade`** — linear A→B dissolve over `transition_frames`. Consumes `transition_frames` from both sides of each boundary. Total length = `sum(inputs) - transition_frames × (n-1)`. Use this when bridge frames don't quite match.
+* **`fade_through_black`** — A fades to black, then B fades in from black. Total length is preserved. Use this for explicit scene changes.
+
+### Quality degradation across iterations
+
+A real failure mode of long chains: each iteration's reference image is itself a *generation output*, so artifacts compound. The model treats codec artifacts and VAE softness in the bridge frame as "style" and reproduces them in the next clip. By the 4th or 5th iteration you can see noticeable softening or color drift.
+
+**Mitigations**:
+
+1. **Pick a bridge frame a few back from the end** (e.g., `frame_index = -3` or `-5`). The very last frame is often the worst frame of a clip due to boundary effects in the temporal attention.
+2. **Refresh the bridge frame with a low-strength img2img pass** before feeding it into the next I2V. An SDXL or FLUX img2img at strength ~0.2 with a quality-focused negative prompt (`blurry, low quality, compression artifacts`) noticeably suppresses the cumulative drift.
+3. **Don't chain more than 4–5 clips** unless you're explicitly doing img2img refinement between each.
+
+---
+
+## Troubleshooting
+
+### OOM errors
+
+Video denoise is memory-intensive — attention scales roughly as `(T_lat × H/16 × W/16)²`, so resolution and frame count both quadratically affect peak VRAM.
+
+* **Drop resolution before frame count.** Going from 1280×720 to 832×480 is a ~2.4× memory drop and visually subtle in most content. Going from 81 frames to 65 only saves ~20%.
+* **TI2V-5B before A14B.** TI2V-5B Q4_K_M peaks around ~6–8 GB at 832×480, versus ~12–14 GB for A14B Q4_K_M. If you're at the OOM edge, switch model family.
+* **OOM at the *reference image encoder* step** is usually allocator fragmentation from a previous run rather than absolute memory pressure. Restart the dev server and try again; if it recurs reproducibly, file an issue.
+
+### Late-frame artifacts (text, color blobs)
+
+If your video looks great for most of its duration but the last ~20% develops Asian text, watermarks, or floating colored shapes, **that's the model's training-data prior leaking through** as temporal coherence weakens at long temporal distance. It's particularly common on TI2V-5B (smaller model, less capacity to hold scene).
+
+**Mitigations**:
+
+* Add to the negative prompt: `text, watermark, logo, subtitles, chinese characters, kanji, ticker, banner`
+* Use a more specific prompt — describe the action you want to *happen* through the clip, not just the static scene
+* Bump CFG to 5.5 (TI2V-5B tolerates this)
+* Stay at `num_frames=81`; values above push temporal RoPE out of distribution and artifacts accelerate
+
+### "Dimensions must be multiples of 16/32"
+
+The encoder and denoise nodes enforce these at runtime. Either:
+
+* Use the **Wan 2.2 I2V Ideal Dimensions** node to compute valid (W, H) automatically from a source image, or
+* Manually round to the right multiple (16 for A14B variants, 32 for TI2V-5B)
+
+### Reference image / denoise dimension mismatch
+
+If the denoise refuses with `Reference-image dimensions … must match denoise dimensions`, both nodes have their own width/height fields and they need to agree. Wire the same values (or the same Ideal Dimensions output) into both.
+
+### TI2V-5B VAE state-dict load error
+
+If `Latents to Video - Wan 2.2` fails with `Error(s) in loading state_dict for AutoencoderKLWan: ... size mismatch for ...`, you have the **wrong VAE installed** for the chosen transformer. TI2V-5B needs the **Wan 2.2 TI2V-5B VAE** (48-channel, Wan 2.2-VAE), not the A14B VAE (16-channel). Both are in the model manager — check the VAE field on the loader and the latents-to-video node.
+
+### Sampler drift on the standalone TI2V-5B GGUF
+
+Standalone GGUF installs don't ship a `scheduler/` config directory. InvokeAI now defaults to `UniPCMultistepScheduler` with the correct Wan-flow params when the model is TI2V-5B and there's no on-disk scheduler — but if you have an older install behaving oddly, the safer alternative is the **full Diffusers TI2V-5B** install (which includes the scheduler config).
+
+### Preview images don't appear
+
+Two known causes:
+
+1. **A video is already loaded in the viewer.** If the last-selected gallery item is a video, the viewer renders the video element. The progress preview overlays on top of it. If you don't see it at all, **hard-refresh the browser** (`Ctrl+Shift+R` / `Cmd+Shift+R`) — Vite's bundle cache occasionally serves a stale build.
+2. **`Show progress in viewer` is disabled.** Check the gallery settings (gear icon at the top of the gallery panel).
+
+### Pipeline runs but the final MP4 is glitchy
+
+This is almost always a **VAE mismatch** or a **scheduler mismatch** — both surface as garbage at the very end of the pipeline. Check that:
+
+* The VAE matches the transformer family (16-ch for A14B, 48-ch for TI2V-5B)
+* You're using the default (auto-selected) scheduler — manually overriding it is currently not supported
+
+---
+
+## Acknowledgements
+
+Wan 2.2 model family by the Alibaba Wan-AI team. Lightning distillation LoRAs by [lightx2v](https://huggingface.co/lightx2v/Wan2.2-Lightning). GGUF quantizations by [QuantStack](https://huggingface.co/QuantStack).
diff --git a/docs/src/content/docs/start-here/system-requirements.mdx b/docs/src/content/docs/start-here/system-requirements.mdx
index 114698ce158..5eff2bc427a 100644
--- a/docs/src/content/docs/start-here/system-requirements.mdx
+++ b/docs/src/content/docs/start-here/system-requirements.mdx
@@ -2,7 +2,7 @@
title: Hardware Requirements
sidebar:
order: 1
-lastUpdated: 2026-02-18
+lastUpdated: 2026-05-11
---
import { Tabs, TabItem, Steps } from '@astrojs/starlight/components'
@@ -28,6 +28,8 @@ The requirements below are rough guidelines for best performance. GPUs with less
| FLUX.2 Klein 4B | 1024x1024 | Nvidia 30xx+ | 12GB | 16GB | FP8 works with 8GB+; Diffusers + encoder |
| FLUX.2 Klein 9B | 1024x1024 | Nvidia 40xx | 24GB | 32GB | FP8 works with 12GB+; Diffusers + encoder |
| Z-Image Turbo | 1024x1024 | Nvidia 20xx+ | 8GB | 16GB | Q4_K 8GB; Q8/BF16 16GB+ |
+| Wan 2.2 A14B (T2V/I2V) | 1280x720 | Nvidia 30xx+ | 12GB | 32GB | Dual-expert MoE; Q4_K_M 12GB; Q8 18GB+; Diffusers requires 32GB+ |
+| Wan 2.2 TI2V-5B | 1280x720 | Nvidia 20xx+ | 8GB | 16GB | Single transformer; Q4_K_M 6GB+; Q8 8GB+; Diffusers 12GB+ |
:::tip[`tmpfs` on Linux]
If your temporary directory is mounted as a `tmpfs`, ensure it has sufficient space.
diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py
index e7468c1bca4..f6db9e6b0f7 100644
--- a/invokeai/app/api/dependencies.py
+++ b/invokeai/app/api/dependencies.py
@@ -10,6 +10,10 @@
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
from invokeai.app.services.board_images.board_images_default import BoardImagesService
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+from invokeai.app.services.board_canvas_project_records.board_canvas_project_records_sqlite import (
+ SqliteBoardCanvasProjectRecordStorage,
+)
+from invokeai.app.services.board_video_records.board_video_records_sqlite import SqliteBoardVideoRecordStorage
from invokeai.app.services.boards.boards_default import BoardService
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
@@ -24,6 +28,7 @@
SeedreamProvider,
)
from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models
+from invokeai.app.services.gallery.gallery_default import SqliteGalleryService
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
@@ -51,6 +56,14 @@
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage
from invokeai.app.services.urls.urls_default import LocalUrlService
from invokeai.app.services.users.users_default import UserService
+from invokeai.app.services.canvas_project_files.canvas_project_files_disk import DiskCanvasProjectFileStorage
+from invokeai.app.services.canvas_project_records.canvas_project_records_sqlite import (
+ SqliteCanvasProjectRecordStorage,
+)
+from invokeai.app.services.canvas_projects.canvas_projects_default import CanvasProjectService
+from invokeai.app.services.video_files.video_files_disk import DiskVideoFileStorage
+from invokeai.app.services.video_records.video_records_sqlite import SqliteVideoRecordStorage
+from invokeai.app.services.videos.videos_default import VideoService
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
@@ -62,6 +75,7 @@
QwenImageConditioningInfo,
SD3ConditioningInfo,
SDXLConditioningInfo,
+ WanConditioningInfo,
ZImageConditioningInfo,
)
from invokeai.backend.util.logging import InvokeAILogger
@@ -107,6 +121,8 @@ def initialize(
raise ValueError("Output folder is not set")
image_files = DiskImageFileStorage(f"{output_folder}/images")
+ video_files = DiskVideoFileStorage(f"{output_folder}/videos")
+ canvas_project_files = DiskCanvasProjectFileStorage(f"{output_folder}/canvas_projects")
model_images_folder = config.models_path
style_presets_folder = config.style_presets_path
@@ -131,6 +147,13 @@ def initialize(
bulk_download = BulkDownloadService()
image_records = SqliteImageRecordStorage(db=db)
images = ImageService()
+ video_records = SqliteVideoRecordStorage(db=db)
+ videos = VideoService()
+ board_video_records = SqliteBoardVideoRecordStorage(db=db)
+ canvas_project_records = SqliteCanvasProjectRecordStorage(db=db)
+ canvas_projects = CanvasProjectService()
+ board_canvas_project_records = SqliteBoardCanvasProjectRecordStorage(db=db)
+ gallery = SqliteGalleryService(db=db)
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
tensors = ObjectSerializerForwardCache(
ObjectSerializerDisk[torch.Tensor](
@@ -152,6 +175,7 @@ def initialize(
ZImageConditioningInfo,
QwenImageConditioningInfo,
AnimaConditioningInfo,
+ WanConditioningInfo,
],
ephemeral=True,
),
@@ -221,6 +245,15 @@ def initialize(
workflow_thumbnails=workflow_thumbnails,
client_state_persistence=client_state_persistence,
users=users,
+ videos=videos,
+ video_files=video_files,
+ video_records=video_records,
+ board_video_records=board_video_records,
+ gallery=gallery,
+ canvas_projects=canvas_projects,
+ canvas_project_files=canvas_project_files,
+ canvas_project_records=canvas_project_records,
+ board_canvas_project_records=board_canvas_project_records,
)
ApiDependencies.invoker = Invoker(services)
diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py
index 6897e90aff4..c216d82e5de 100644
--- a/invokeai/app/api/routers/boards.py
+++ b/invokeai/app/api/routers/boards.py
@@ -21,6 +21,14 @@ class DeleteBoardResult(BaseModel):
description="The image names of the board-images relationships that were deleted."
)
deleted_images: list[str] = Field(description="The names of the images that were deleted.")
+ deleted_board_videos: list[str] = Field(
+ default_factory=list,
+ description="The video names of the board-videos relationships that were deleted.",
+ )
+ deleted_videos: list[str] = Field(
+ default_factory=list,
+ description="The names of the videos that were deleted.",
+ )
@boards_router.post(
@@ -123,12 +131,20 @@ async def delete_board(
categories=None,
is_intermediate=None,
)
+ deleted_videos = ApiDependencies.invoker.services.board_video_records.get_all_board_video_names_for_board(
+ board_id=board_id,
+ categories=None,
+ is_intermediate=None,
+ )
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
+ ApiDependencies.invoker.services.videos.delete_videos_on_board(board_id=board_id)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=[],
deleted_images=deleted_images,
+ deleted_board_videos=[],
+ deleted_videos=deleted_videos,
)
else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
@@ -136,11 +152,20 @@ async def delete_board(
categories=None,
is_intermediate=None,
)
+ deleted_board_videos = (
+ ApiDependencies.invoker.services.board_video_records.get_all_board_video_names_for_board(
+ board_id=board_id,
+ categories=None,
+ is_intermediate=None,
+ )
+ )
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
board_id=board_id,
deleted_board_images=deleted_board_images,
deleted_images=[],
+ deleted_board_videos=deleted_board_videos,
+ deleted_videos=[],
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete board")
diff --git a/invokeai/app/api/routers/canvas_projects.py b/invokeai/app/api/routers/canvas_projects.py
new file mode 100644
index 00000000000..a826105f0fe
--- /dev/null
+++ b/invokeai/app/api/routers/canvas_projects.py
@@ -0,0 +1,565 @@
+import traceback
+from pathlib import Path
+from typing import Optional
+
+from fastapi import Body, File, Form, HTTPException, Query, Response, UploadFile
+from fastapi import Path as PathParam
+from fastapi.responses import FileResponse
+from fastapi.routing import APIRouter
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import CanvasProjectRecordChanges
+from invokeai.app.services.canvas_projects.canvas_projects_common import (
+ AddCanvasProjectsToBoardResult,
+ CanvasProjectDTO,
+ DeleteCanvasProjectsResult,
+ RemoveCanvasProjectsFromBoardResult,
+ StarredCanvasProjectsResult,
+ UnstarredCanvasProjectsResult,
+)
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+canvas_projects_router = APIRouter(prefix="/v1/canvas_projects", tags=["canvas_projects"])
+board_canvas_projects_router = APIRouter(prefix="/v1/board_canvas_projects", tags=["board_canvas_projects"])
+
+# Reasonable size cap for `.invk` uploads. Canvas projects bundle layer image bytes, which
+# can grow large for complex compositions — but they should not be measured in gigabytes.
+MAX_UPLOAD_SIZE = 256 * 1024 * 1024 # 256 MB
+UPLOAD_CHUNK_SIZE = 1024 * 1024
+
+
+def _assert_project_owner(project_name: str, current_user: CurrentUserOrDefault) -> None:
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.canvas_project_records.get_user_id(project_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(project_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to modify this canvas project")
+
+
+def _assert_project_direct_owner(project_name: str, current_user: CurrentUserOrDefault) -> None:
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.canvas_project_records.get_user_id(project_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to move this canvas project")
+
+
+def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if current_user.is_admin:
+ return
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to modify this board")
+
+
+def _assert_project_read_access(project_name: str, current_user: CurrentUserOrDefault) -> None:
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.canvas_project_records.get_user_id(project_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(project_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to access this canvas project")
+
+
+@canvas_projects_router.post(
+ "/upload",
+ operation_id="upload_canvas_project",
+ responses={
+ 201: {"description": "The canvas project was uploaded successfully"},
+ 413: {"description": "Canvas project file too large"},
+ 415: {"description": "Not a supported canvas project file"},
+ },
+ status_code=201,
+ response_model=CanvasProjectDTO,
+)
+async def upload_canvas_project(
+ current_user: CurrentUserOrDefault,
+ response: Response,
+ file: UploadFile = File(description="The canvas project ZIP (.invk) file"),
+ name: str = Form(description="The user-facing project name"),
+ app_version: str = Form(description="The InvokeAI app version captured at save time"),
+ width: int = Form(description="The bbox width at save time"),
+ height: int = Form(description="The bbox height at save time"),
+ image_count: int = Form(description="The number of embedded image files"),
+ thumbnail: Optional[UploadFile] = File(default=None, description="Optional preview WebP thumbnail"),
+ board_id: Optional[str] = Form(default=None, description="Optional board to attach the project to"),
+ is_intermediate: bool = Form(default=False, description="Whether this is an intermediate project"),
+) -> CanvasProjectDTO:
+ """Uploads a canvas project ZIP for the current user, optionally placing it on a board."""
+ if board_id is not None:
+ _assert_board_write_access(board_id, current_user)
+
+ if not (file.filename or "").lower().endswith(".invk"):
+ raise HTTPException(status_code=415, detail="Not a supported canvas project file (.invk expected)")
+
+ try:
+ total = 0
+ chunks: list[bytes] = []
+ while chunk := await file.read(UPLOAD_CHUNK_SIZE):
+ total += len(chunk)
+ if total > MAX_UPLOAD_SIZE:
+ raise HTTPException(
+ status_code=413,
+ detail=f"Canvas project upload exceeds maximum size ({MAX_UPLOAD_SIZE} bytes)",
+ )
+ chunks.append(chunk)
+ zip_bytes = b"".join(chunks)
+
+ thumbnail_bytes: Optional[bytes] = None
+ if thumbnail is not None:
+ thumbnail_bytes = await thumbnail.read()
+ if len(thumbnail_bytes) == 0:
+ thumbnail_bytes = None
+
+ try:
+ project_dto = ApiDependencies.invoker.services.canvas_projects.create(
+ zip_bytes=zip_bytes,
+ name=name,
+ app_version=app_version,
+ width=width,
+ height=height,
+ image_count=image_count,
+ thumbnail_bytes=thumbnail_bytes,
+ project_origin=ResourceOrigin.EXTERNAL,
+ board_id=board_id,
+ is_intermediate=is_intermediate,
+ user_id=current_user.user_id,
+ )
+ response.status_code = 201
+ response.headers["Location"] = project_dto.project_url
+ return project_dto
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to create canvas project")
+ except HTTPException:
+ raise
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to read canvas project upload")
+
+
+@canvas_projects_router.delete(
+ "/i/{project_name}",
+ operation_id="delete_canvas_project",
+ response_model=DeleteCanvasProjectsResult,
+)
+async def delete_canvas_project(
+ current_user: CurrentUserOrDefault,
+ project_name: str = PathParam(description="The name of the canvas project to delete"),
+) -> DeleteCanvasProjectsResult:
+ _assert_project_owner(project_name, current_user)
+
+ deleted_projects: set[str] = set()
+ affected_boards: set[str] = set()
+ try:
+ project_dto = ApiDependencies.invoker.services.canvas_projects.get_dto(project_name)
+ board_id = project_dto.board_id or "none"
+ ApiDependencies.invoker.services.canvas_projects.delete(project_name)
+ deleted_projects.add(project_name)
+ affected_boards.add(board_id)
+ except Exception:
+ pass
+
+ return DeleteCanvasProjectsResult(
+ deleted_projects=list(deleted_projects),
+ affected_boards=list(affected_boards),
+ )
+
+
+@canvas_projects_router.post(
+ "/delete",
+ operation_id="delete_canvas_projects_from_list",
+ response_model=DeleteCanvasProjectsResult,
+)
+async def delete_canvas_projects_from_list(
+ current_user: CurrentUserOrDefault,
+ project_names: list[str] = Body(description="The list of canvas project names to delete", embed=True),
+) -> DeleteCanvasProjectsResult:
+ deleted_projects: set[str] = set()
+ affected_boards: set[str] = set()
+ for project_name in project_names:
+ try:
+ _assert_project_owner(project_name, current_user)
+ project_dto = ApiDependencies.invoker.services.canvas_projects.get_dto(project_name)
+ board_id = project_dto.board_id or "none"
+ ApiDependencies.invoker.services.canvas_projects.delete(project_name)
+ deleted_projects.add(project_name)
+ affected_boards.add(board_id)
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return DeleteCanvasProjectsResult(
+ deleted_projects=list(deleted_projects),
+ affected_boards=list(affected_boards),
+ )
+
+
+@canvas_projects_router.patch(
+ "/i/{project_name}",
+ operation_id="update_canvas_project",
+ response_model=CanvasProjectDTO,
+)
+async def update_canvas_project(
+ current_user: CurrentUserOrDefault,
+ project_name: str = PathParam(description="The name of the canvas project to update"),
+ project_changes: CanvasProjectRecordChanges = Body(description="The changes to apply"),
+) -> CanvasProjectDTO:
+ _assert_project_owner(project_name, current_user)
+ try:
+ return ApiDependencies.invoker.services.canvas_projects.update(project_name, project_changes)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to update canvas project")
+
+
+@canvas_projects_router.put(
+ "/i/{project_name}/file",
+ operation_id="replace_canvas_project_file",
+ responses={
+ 200: {"description": "The canvas project file was replaced successfully"},
+ 413: {"description": "Canvas project file too large"},
+ 415: {"description": "Not a supported canvas project file"},
+ },
+ response_model=CanvasProjectDTO,
+)
+async def replace_canvas_project_file(
+ current_user: CurrentUserOrDefault,
+ project_name: str = PathParam(description="The name of the canvas project to replace"),
+ file: UploadFile = File(description="The new canvas project ZIP (.invk) file"),
+ name: Optional[str] = Form(default=None, description="Optional new user-facing project name"),
+ app_version: str = Form(description="The InvokeAI app version captured at save time"),
+ width: int = Form(description="The bbox width at save time"),
+ height: int = Form(description="The bbox height at save time"),
+ image_count: int = Form(description="The number of embedded image files"),
+ thumbnail: Optional[UploadFile] = File(default=None, description="Optional new WebP thumbnail"),
+) -> CanvasProjectDTO:
+ """Replaces the on-disk ZIP and thumbnail for an existing canvas project. Keeps project_name,
+ board assignment, starred state, ownership. Updates width/height/image_count/app_version and
+ `has_thumbnail` (when a thumbnail is supplied). Optionally renames via the `name` form field."""
+ _assert_project_owner(project_name, current_user)
+
+ if not (file.filename or "").lower().endswith(".invk"):
+ raise HTTPException(status_code=415, detail="Not a supported canvas project file (.invk expected)")
+
+ try:
+ total = 0
+ chunks: list[bytes] = []
+ while chunk := await file.read(UPLOAD_CHUNK_SIZE):
+ total += len(chunk)
+ if total > MAX_UPLOAD_SIZE:
+ raise HTTPException(
+ status_code=413,
+ detail=f"Canvas project upload exceeds maximum size ({MAX_UPLOAD_SIZE} bytes)",
+ )
+ chunks.append(chunk)
+ zip_bytes = b"".join(chunks)
+
+ thumbnail_bytes: Optional[bytes] = None
+ if thumbnail is not None:
+ thumbnail_bytes = await thumbnail.read()
+ if len(thumbnail_bytes) == 0:
+ thumbnail_bytes = None
+
+ try:
+ project_dto = ApiDependencies.invoker.services.canvas_projects.replace_file(
+ project_name=project_name,
+ zip_bytes=zip_bytes,
+ width=width,
+ height=height,
+ image_count=image_count,
+ app_version=app_version,
+ thumbnail_bytes=thumbnail_bytes,
+ )
+ # If the caller also wants to rename, apply that as a separate record change.
+ if name is not None and name != project_dto.name:
+ project_dto = ApiDependencies.invoker.services.canvas_projects.update(
+ project_name, CanvasProjectRecordChanges(name=name)
+ )
+ return project_dto
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to replace canvas project file")
+ except HTTPException:
+ raise
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to read canvas project upload")
+
+
+@canvas_projects_router.post(
+ "/star",
+ operation_id="star_canvas_projects_in_list",
+ response_model=StarredCanvasProjectsResult,
+)
+async def star_canvas_projects_in_list(
+ current_user: CurrentUserOrDefault,
+ project_names: list[str] = Body(description="The list of canvas project names to star", embed=True),
+) -> StarredCanvasProjectsResult:
+ starred_projects: set[str] = set()
+ affected_boards: set[str] = set()
+ for project_name in project_names:
+ try:
+ _assert_project_owner(project_name, current_user)
+ ApiDependencies.invoker.services.canvas_projects.update(
+ project_name, CanvasProjectRecordChanges(starred=True)
+ )
+ starred_projects.add(project_name)
+ board_id = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(
+ project_name
+ )
+ affected_boards.add(board_id or "none")
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return StarredCanvasProjectsResult(
+ starred_projects=list(starred_projects),
+ affected_boards=list(affected_boards),
+ )
+
+
+@canvas_projects_router.post(
+ "/unstar",
+ operation_id="unstar_canvas_projects_in_list",
+ response_model=UnstarredCanvasProjectsResult,
+)
+async def unstar_canvas_projects_in_list(
+ current_user: CurrentUserOrDefault,
+ project_names: list[str] = Body(description="The list of canvas project names to unstar", embed=True),
+) -> UnstarredCanvasProjectsResult:
+ unstarred_projects: set[str] = set()
+ affected_boards: set[str] = set()
+ for project_name in project_names:
+ try:
+ _assert_project_owner(project_name, current_user)
+ ApiDependencies.invoker.services.canvas_projects.update(
+ project_name, CanvasProjectRecordChanges(starred=False)
+ )
+ unstarred_projects.add(project_name)
+ board_id = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(
+ project_name
+ )
+ affected_boards.add(board_id or "none")
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return UnstarredCanvasProjectsResult(
+ unstarred_projects=list(unstarred_projects),
+ affected_boards=list(affected_boards),
+ )
+
+
+@canvas_projects_router.get(
+ "/i/{project_name}",
+ operation_id="get_canvas_project_dto",
+ response_model=CanvasProjectDTO,
+)
+async def get_canvas_project_dto(
+ current_user: CurrentUserOrDefault,
+ project_name: str = PathParam(description="The name of canvas project to get"),
+) -> CanvasProjectDTO:
+ _assert_project_read_access(project_name, current_user)
+ try:
+ return ApiDependencies.invoker.services.canvas_projects.get_dto(project_name)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@canvas_projects_router.get(
+ "/i/{project_name}/full",
+ operation_id="get_canvas_project_full",
+ response_class=Response,
+ responses={
+ 200: {"description": "Return the canvas project ZIP", "content": {"application/zip": {}}},
+ 404: {"description": "Canvas project not found"},
+ },
+)
+async def get_canvas_project_full(
+ project_name: str = PathParam(description="The name of canvas project file to get"),
+) -> Response:
+ """Serves the canvas project ZIP (.invk).
+
+ Like the image/video equivalents, this endpoint is intentionally unauthenticated so the
+ browser can fetch it via standard download flow. Project names are UUIDs, providing
+ security through unguessability.
+ """
+ try:
+ path_str = ApiDependencies.invoker.services.canvas_projects.get_path(project_name)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+ path = Path(path_str)
+ if not path.exists():
+ raise HTTPException(status_code=404)
+
+ return FileResponse(
+ path=path,
+ media_type="application/zip",
+ filename=f"{project_name}.invk",
+ headers={"Content-Disposition": f'attachment; filename="{project_name}.invk"'},
+ )
+
+
+@canvas_projects_router.get(
+ "/i/{project_name}/thumbnail",
+ operation_id="get_canvas_project_thumbnail",
+ response_class=Response,
+ responses={
+ 200: {"description": "Return the canvas project thumbnail", "content": {"image/webp": {}}},
+ 404: {"description": "Canvas project thumbnail not found"},
+ },
+)
+async def get_canvas_project_thumbnail(
+ project_name: str = PathParam(description="The name of canvas project whose thumbnail to get"),
+) -> Response:
+ """Serves the canvas project preview thumbnail (WebP). Unauthenticated for the same reason
+ as `get_canvas_project_full` — project names are UUIDs."""
+ try:
+ path_str = ApiDependencies.invoker.services.canvas_projects.get_path(project_name, thumbnail=True)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+ path = Path(path_str)
+ if not path.exists():
+ raise HTTPException(status_code=404)
+
+ return FileResponse(path=path, media_type="image/webp")
+
+
+@canvas_projects_router.get(
+ "/",
+ operation_id="list_canvas_project_dtos",
+ response_model=OffsetPaginatedResults[CanvasProjectDTO],
+)
+async def list_canvas_project_dtos(
+ current_user: CurrentUserOrDefault,
+ project_origin: Optional[ResourceOrigin] = Query(default=None, description="Filter by project origin"),
+ is_intermediate: Optional[bool] = Query(default=None, description="Filter by is_intermediate flag"),
+ board_id: Optional[str] = Query(default=None, description="Filter by board_id ('none' for unassigned)"),
+ offset: int = Query(default=0, description="The page offset"),
+ limit: int = Query(default=10, description="The number of canvas projects per page"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="Sort direction"),
+ starred_first: bool = Query(default=True, description="Whether starred projects come first"),
+ search_term: Optional[str] = Query(default=None, description="A free-text search term"),
+) -> OffsetPaginatedResults[CanvasProjectDTO]:
+ """Lists canvas project DTOs with pagination and filtering."""
+ return ApiDependencies.invoker.services.canvas_projects.get_many(
+ offset=offset,
+ limit=limit,
+ starred_first=starred_first,
+ order_dir=order_dir,
+ project_origin=project_origin,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+
+
+@board_canvas_projects_router.post(
+ "/",
+ operation_id="add_canvas_project_to_board",
+ response_model=AddCanvasProjectsToBoardResult,
+)
+async def add_canvas_project_to_board(
+ current_user: CurrentUserOrDefault,
+ board_id: str = Body(description="The id of the board to add the project to"),
+ project_name: str = Body(description="The name of the canvas project to add"),
+) -> AddCanvasProjectsToBoardResult:
+ _assert_project_direct_owner(project_name, current_user)
+ _assert_board_write_access(board_id, current_user)
+
+ affected_boards: set[str] = set()
+ added_projects: list[str] = []
+ try:
+ existing_board = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(
+ project_name
+ )
+ if existing_board is not None:
+ affected_boards.add(existing_board)
+ ApiDependencies.invoker.services.board_canvas_project_records.add_project_to_board(
+ board_id=board_id, project_name=project_name
+ )
+ affected_boards.add(board_id)
+ added_projects.append(project_name)
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to add canvas project to board")
+
+ return AddCanvasProjectsToBoardResult(
+ added_projects=added_projects,
+ affected_boards=list(affected_boards),
+ )
+
+
+@board_canvas_projects_router.delete(
+ "/",
+ operation_id="remove_canvas_project_from_board",
+ response_model=RemoveCanvasProjectsFromBoardResult,
+)
+async def remove_canvas_project_from_board(
+ current_user: CurrentUserOrDefault,
+ project_name: str = Body(description="The name of the canvas project to remove from its board", embed=True),
+) -> RemoveCanvasProjectsFromBoardResult:
+ _assert_project_direct_owner(project_name, current_user)
+
+ affected_boards: set[str] = set()
+ removed_projects: list[str] = []
+ try:
+ existing_board = ApiDependencies.invoker.services.board_canvas_project_records.get_board_for_project(
+ project_name
+ )
+ if existing_board is not None:
+ affected_boards.add(existing_board)
+ ApiDependencies.invoker.services.board_canvas_project_records.remove_project_from_board(project_name)
+ affected_boards.add("none")
+ removed_projects.append(project_name)
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to remove canvas project from board")
+
+ return RemoveCanvasProjectsFromBoardResult(
+ removed_projects=removed_projects,
+ affected_boards=list(affected_boards),
+ )
diff --git a/invokeai/app/api/routers/gallery.py b/invokeai/app/api/routers/gallery.py
new file mode 100644
index 00000000000..4ffa5238802
--- /dev/null
+++ b/invokeai/app/api/routers/gallery.py
@@ -0,0 +1,97 @@
+from typing import Optional
+
+from fastapi import HTTPException, Query
+from fastapi.routing import APIRouter
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api.routers.images import _assert_board_read_access
+from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNamesResult
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+gallery_router = APIRouter(prefix="/v1/gallery", tags=["gallery"])
+
+
+@gallery_router.get(
+ "/items/",
+ operation_id="list_gallery_items",
+ response_model=OffsetPaginatedResults[GalleryItem],
+)
+async def list_gallery_items(
+ current_user: CurrentUserOrDefault,
+ origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."),
+ categories: Optional[list[ImageCategory]] = Query(
+ default=None,
+ description="The categories to include. Shared between images and videos.",
+ ),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate items."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find items without a board.",
+ ),
+ offset: int = Query(default=0, description="The page offset"),
+ limit: int = Query(default=10, description="The number of items per page"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred items first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> OffsetPaginatedResults[GalleryItem]:
+ """Returns a paginated, time-sorted stream of polymorphic gallery items (images + videos)."""
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
+
+ return ApiDependencies.invoker.services.gallery.list_items(
+ offset=offset,
+ limit=limit,
+ starred_first=starred_first,
+ order_dir=order_dir,
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+
+
+@gallery_router.get(
+ "/items/names",
+ operation_id="get_gallery_item_names",
+ response_model=GalleryItemNamesResult,
+)
+async def get_gallery_item_names(
+ current_user: CurrentUserOrDefault,
+ origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of items to list."),
+ categories: Optional[list[ImageCategory]] = Query(
+ default=None,
+ description="The categories to include. Shared between images and videos.",
+ ),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate items."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find items without a board.",
+ ),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred items first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> GalleryItemNamesResult:
+ """Returns an ordered (kind, name) list — used to drive virtualized gallery selection."""
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
+
+ try:
+ return ApiDependencies.invoker.services.gallery.list_item_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get gallery item names")
diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py
new file mode 100644
index 00000000000..176d12ed117
--- /dev/null
+++ b/invokeai/app/api/routers/videos.py
@@ -0,0 +1,668 @@
+import re
+import tempfile
+import traceback
+from pathlib import Path
+from typing import Optional
+
+from fastapi import Body, HTTPException, Query, Request, Response, UploadFile
+from fastapi import Path as PathParam
+from fastapi.responses import FileResponse
+from fastapi.routing import APIRouter
+from pydantic import BaseModel, Field
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api.routers.images import _assert_board_read_access
+from invokeai.app.invocations.fields import MetadataField
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.video_records.video_records_common import VideoNamesResult, VideoRecordChanges
+from invokeai.app.services.videos.videos_common import (
+ AddVideosToBoardResult,
+ DeleteVideosResult,
+ RemoveVideosFromBoardResult,
+ StarredVideosResult,
+ UnstarredVideosResult,
+ VideoDTO,
+ VideoUrlsDTO,
+)
+from invokeai.app.util.video_thumbnails import probe_video
+
+videos_router = APIRouter(prefix="/v1/videos", tags=["videos"])
+
+# Videos are immutable; set a high max-age (1 year)
+VIDEO_MAX_AGE = 31536000
+
+# MP4 only — the names service emits `{uuid}.mp4` unconditionally and we don't transcode on
+# upload. Accepting .mov/.webm/.mkv here previously caused those containers to be stored
+# under a .mp4 name and served with the .mp4 MIME type, which silently broke playback in
+# browsers when the container did not match.
+ACCEPTED_VIDEO_MIME_PREFIXES = ("video/mp4",)
+ACCEPTED_VIDEO_EXTENSIONS = (".mp4",)
+
+# Per-chunk size for HTTP Range responses (1 MB)
+RANGE_CHUNK_SIZE = 1024 * 1024
+
+# Upload streaming chunk size (1 MB) and a coarse per-upload size cap. The cap is generous
+# because Wan-generated MP4s for long sequences can run into the hundreds of megabytes;
+# the goal is to prevent a single client from exhausting RAM, not to be a content policy.
+UPLOAD_CHUNK_SIZE = 1024 * 1024
+MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 # 1 GB
+
+
+def _assert_video_owner(video_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user does not own the video and is not an admin."""
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to modify this video")
+
+
+def _assert_video_direct_owner(video_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user is not the direct owner of the video.
+
+ Intentionally stricter than _assert_video_owner: board-ownership and public-board
+ fallbacks are NOT honored. Mirrors _assert_image_direct_owner in board_images.py —
+ board-move operations need to verify the *original* owner, otherwise a user could
+ move someone else's video onto their own board via the board-owner branch.
+ """
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to move this video")
+
+
+def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not mutate the given board.
+
+ Mirrors _assert_board_write_access in board_images.py: admins and the board owner
+ may write; public boards accept contributions from any user.
+ """
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if current_user.is_admin:
+ return
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to modify this board")
+
+
+def _assert_video_read_access(video_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not view the video."""
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.video_records.get_user_id(video_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to access this video")
+
+
+def _is_accepted_video_upload(file: UploadFile) -> bool:
+ if file.content_type and file.content_type.startswith(ACCEPTED_VIDEO_MIME_PREFIXES):
+ return True
+ if file.filename:
+ return file.filename.lower().endswith(ACCEPTED_VIDEO_EXTENSIONS)
+ return False
+
+
+@videos_router.post(
+ "/upload",
+ operation_id="upload_video",
+ responses={
+ 201: {"description": "The video was uploaded successfully"},
+ 415: {"description": "Video upload failed"},
+ },
+ status_code=201,
+ response_model=VideoDTO,
+)
+async def upload_video(
+ current_user: CurrentUserOrDefault,
+ file: UploadFile,
+ request: Request,
+ response: Response,
+ video_category: ImageCategory = Query(description="The category of the video"),
+ is_intermediate: bool = Query(description="Whether this is an intermediate video"),
+ board_id: Optional[str] = Query(default=None, description="The board to add this video to, if any"),
+ session_id: Optional[str] = Query(default=None, description="The session ID associated with this upload, if any"),
+ metadata: Optional[str] = Body(
+ default=None,
+ description="The metadata to associate with the video, must be a stringified JSON dict",
+ embed=True,
+ ),
+) -> VideoDTO:
+ """Uploads a video for the current user."""
+ # Check board access for uploads to a specific board.
+ if board_id is not None:
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if (
+ not current_user.is_admin
+ and board.user_id != current_user.user_id
+ and board.board_visibility != BoardVisibility.Public
+ ):
+ raise HTTPException(status_code=403, detail="Not authorized to upload to this board")
+
+ if not _is_accepted_video_upload(file):
+ raise HTTPException(status_code=415, detail="Not a supported video file")
+
+ # Stream the upload to a tmp file so we can probe and then hand its path to the service.
+ # Reading the full body into memory first risked exhausting RAM on multi-GB uploads;
+ # chunk-stream instead and enforce a hard size cap.
+ tmp = tempfile.NamedTemporaryFile(prefix="invokeai_upload_", suffix=".mp4", delete=False)
+ tmp_path = Path(tmp.name)
+ try:
+ total = 0
+ while chunk := await file.read(UPLOAD_CHUNK_SIZE):
+ total += len(chunk)
+ if total > MAX_UPLOAD_SIZE:
+ tmp.close()
+ raise HTTPException(
+ status_code=413,
+ detail=f"Video upload exceeds maximum size ({MAX_UPLOAD_SIZE} bytes)",
+ )
+ tmp.write(chunk)
+ tmp.close()
+
+ try:
+ width, height, duration, fps = probe_video(tmp_path)
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=415, detail="Failed to read video")
+
+ try:
+ video_dto = ApiDependencies.invoker.services.videos.create(
+ source_path=tmp_path,
+ width=width,
+ height=height,
+ duration=duration,
+ fps=fps,
+ video_origin=ResourceOrigin.EXTERNAL,
+ video_category=video_category,
+ session_id=session_id,
+ board_id=board_id,
+ metadata=metadata,
+ workflow=None,
+ graph=None,
+ is_intermediate=is_intermediate,
+ user_id=current_user.user_id,
+ )
+
+ response.status_code = 201
+ response.headers["Location"] = video_dto.video_url
+ return video_dto
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=500, detail="Failed to create video")
+ finally:
+ # If create() succeeded the file was moved; this unlink is a no-op then.
+ try:
+ tmp_path.unlink(missing_ok=True)
+ except Exception:
+ pass
+
+
+@videos_router.delete("/i/{video_name}", operation_id="delete_video", response_model=DeleteVideosResult)
+async def delete_video(
+ current_user: CurrentUserOrDefault,
+ video_name: str = PathParam(description="The name of the video to delete"),
+) -> DeleteVideosResult:
+ _assert_video_owner(video_name, current_user)
+
+ deleted_videos: set[str] = set()
+ affected_boards: set[str] = set()
+ try:
+ video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name)
+ board_id = video_dto.board_id or "none"
+ ApiDependencies.invoker.services.videos.delete(video_name)
+ deleted_videos.add(video_name)
+ affected_boards.add(board_id)
+ except Exception:
+ pass
+
+ return DeleteVideosResult(
+ deleted_videos=list(deleted_videos),
+ affected_boards=list(affected_boards),
+ )
+
+
+@videos_router.post("/delete", operation_id="delete_videos_from_list", response_model=DeleteVideosResult)
+async def delete_videos_from_list(
+ current_user: CurrentUserOrDefault,
+ video_names: list[str] = Body(description="The list of names of videos to delete", embed=True),
+) -> DeleteVideosResult:
+ deleted_videos: set[str] = set()
+ affected_boards: set[str] = set()
+ for video_name in video_names:
+ try:
+ _assert_video_owner(video_name, current_user)
+ video_dto = ApiDependencies.invoker.services.videos.get_dto(video_name)
+ board_id = video_dto.board_id or "none"
+ ApiDependencies.invoker.services.videos.delete(video_name)
+ deleted_videos.add(video_name)
+ affected_boards.add(board_id)
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return DeleteVideosResult(
+ deleted_videos=list(deleted_videos),
+ affected_boards=list(affected_boards),
+ )
+
+
+@videos_router.patch("/i/{video_name}", operation_id="update_video", response_model=VideoDTO)
+async def update_video(
+ current_user: CurrentUserOrDefault,
+ video_name: str = PathParam(description="The name of the video to update"),
+ video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"),
+) -> VideoDTO:
+ _assert_video_owner(video_name, current_user)
+ try:
+ return ApiDependencies.invoker.services.videos.update(video_name, video_changes)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Failed to update video")
+
+
+@videos_router.get("/i/{video_name}", operation_id="get_video_dto", response_model=VideoDTO)
+async def get_video_dto(
+ current_user: CurrentUserOrDefault,
+ video_name: str = PathParam(description="The name of video to get"),
+) -> VideoDTO:
+ _assert_video_read_access(video_name, current_user)
+ try:
+ return ApiDependencies.invoker.services.videos.get_dto(video_name)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@videos_router.get(
+ "/i/{video_name}/metadata", operation_id="get_video_metadata", response_model=Optional[MetadataField]
+)
+async def get_video_metadata(
+ current_user: CurrentUserOrDefault,
+ video_name: str = PathParam(description="The name of video to get"),
+) -> Optional[MetadataField]:
+ _assert_video_read_access(video_name, current_user)
+ try:
+ return ApiDependencies.invoker.services.videos.get_metadata(video_name)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+def _parse_range_header(range_header: str, file_size: int) -> Optional[tuple[int, int]]:
+ """Parses an HTTP Range header of the form `bytes=START-END`. Returns inclusive (start, end)
+ byte offsets, or None if the header is malformed or unsatisfiable."""
+ match = re.match(r"^bytes=(\d*)-(\d*)$", range_header.strip())
+ if match is None:
+ return None
+ start_str, end_str = match.group(1), match.group(2)
+ if start_str == "" and end_str == "":
+ return None
+ if start_str == "":
+ # suffix range: last N bytes
+ try:
+ suffix_len = int(end_str)
+ except ValueError:
+ return None
+ if suffix_len == 0:
+ return None
+ start = max(file_size - suffix_len, 0)
+ end = file_size - 1
+ else:
+ try:
+ start = int(start_str)
+ except ValueError:
+ return None
+ if end_str == "":
+ end = file_size - 1
+ else:
+ try:
+ end = int(end_str)
+ except ValueError:
+ return None
+ if start > end or start >= file_size:
+ return None
+ end = min(end, file_size - 1)
+ return start, end
+
+
+@videos_router.get(
+ "/i/{video_name}/full",
+ operation_id="get_video_full",
+ response_class=Response,
+ responses={
+ 200: {"description": "Return the full video file", "content": {"video/mp4": {}}},
+ 206: {"description": "Return a byte-range of the video file", "content": {"video/mp4": {}}},
+ 404: {"description": "Video not found"},
+ },
+)
+@videos_router.head(
+ "/i/{video_name}/full",
+ operation_id="get_video_full_head",
+ response_class=Response,
+ responses={
+ 200: {"description": "Return the full video file", "content": {"video/mp4": {}}},
+ 404: {"description": "Video not found"},
+ },
+)
+async def get_video_full(
+ request: Request,
+ video_name: str = PathParam(description="The name of video file to get"),
+) -> Response:
+ """Serves the video file with HTTP Range support so HTML5 seek/scrub works.
+
+ Like the image equivalent, this endpoint is intentionally unauthenticated because browsers
+ load videos via tags which cannot send Bearer tokens. Video names are UUIDs,
+ providing security through unguessability.
+ """
+ try:
+ path_str = ApiDependencies.invoker.services.videos.get_path(video_name)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+ path = Path(path_str)
+ if not path.exists():
+ raise HTTPException(status_code=404)
+
+ file_size = path.stat().st_size
+ range_header = request.headers.get("range") or request.headers.get("Range")
+
+ common_headers = {
+ "Accept-Ranges": "bytes",
+ "Cache-Control": f"max-age={VIDEO_MAX_AGE}",
+ "Content-Disposition": f'inline; filename="{video_name}"',
+ }
+
+ # HEAD: respond with metadata only.
+ if request.method == "HEAD":
+ return Response(
+ status_code=200,
+ media_type="video/mp4",
+ headers={**common_headers, "Content-Length": str(file_size)},
+ )
+
+ if range_header is None:
+ # Stream the file via sendfile() rather than reading it into RAM — multi-GB
+ # MP4 downloads (clients without Range, CLI tools, CDN edge fetches) would
+ # otherwise allocate a multi-GB Python bytes object per request.
+ return FileResponse(
+ path,
+ media_type="video/mp4",
+ headers=common_headers,
+ )
+
+ parsed = _parse_range_header(range_header, file_size)
+ if parsed is None:
+ # Unsatisfiable range.
+ return Response(
+ status_code=416,
+ headers={**common_headers, "Content-Range": f"bytes */{file_size}"},
+ )
+ start, end = parsed
+ length = end - start + 1
+ with open(path, "rb") as f:
+ f.seek(start)
+ # Read at most one chunk; clients ask for more via subsequent ranges.
+ read_length = min(length, RANGE_CHUNK_SIZE)
+ chunk = f.read(read_length)
+ actual_end = start + len(chunk) - 1
+ return Response(
+ chunk,
+ status_code=206,
+ media_type="video/mp4",
+ headers={
+ **common_headers,
+ "Content-Range": f"bytes {start}-{actual_end}/{file_size}",
+ "Content-Length": str(len(chunk)),
+ },
+ )
+
+
+@videos_router.get(
+ "/i/{video_name}/thumbnail",
+ operation_id="get_video_thumbnail",
+ response_class=Response,
+ responses={
+ 200: {"description": "Return the video thumbnail", "content": {"image/webp": {}}},
+ 404: {"description": "Video not found"},
+ },
+)
+async def get_video_thumbnail(
+ video_name: str = PathParam(description="The name of thumbnail file to get"),
+) -> Response:
+ """Returns the first-frame WebP thumbnail of a video. Unauthenticated; UUIDs provide unguessability."""
+ try:
+ path = ApiDependencies.invoker.services.videos.get_path(video_name, thumbnail=True)
+ return FileResponse(
+ path,
+ media_type="image/webp",
+ headers={"Cache-Control": f"max-age={VIDEO_MAX_AGE}"},
+ )
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@videos_router.get("/i/{video_name}/urls", operation_id="get_video_urls", response_model=VideoUrlsDTO)
+async def get_video_urls(
+ current_user: CurrentUserOrDefault,
+ video_name: str = PathParam(description="The name of the video whose URL to get"),
+) -> VideoUrlsDTO:
+ _assert_video_read_access(video_name, current_user)
+ try:
+ video_url = ApiDependencies.invoker.services.videos.get_url(video_name)
+ thumbnail_url = ApiDependencies.invoker.services.videos.get_url(video_name, thumbnail=True)
+ return VideoUrlsDTO(video_name=video_name, video_url=video_url, thumbnail_url=thumbnail_url)
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@videos_router.get("/", operation_id="list_video_dtos", response_model=OffsetPaginatedResults[VideoDTO])
+async def list_video_dtos(
+ current_user: CurrentUserOrDefault,
+ video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."),
+ categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find videos without a board.",
+ ),
+ offset: int = Query(default=0, description="The page offset"),
+ limit: int = Query(default=10, description="The number of videos per page"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> OffsetPaginatedResults[VideoDTO]:
+ """Gets a list of video DTOs for the current user."""
+ # Validate that the caller can read from this board. "none" is handled by the SQL layer.
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
+
+ return ApiDependencies.invoker.services.videos.get_many(
+ offset,
+ limit,
+ starred_first,
+ order_dir,
+ video_origin,
+ categories,
+ is_intermediate,
+ board_id,
+ search_term,
+ current_user.user_id,
+ current_user.is_admin,
+ )
+
+
+@videos_router.get("/names", operation_id="get_video_names")
+async def get_video_names(
+ current_user: CurrentUserOrDefault,
+ video_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of videos to list."),
+ categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of video to include."),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find videos without a board.",
+ ),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> VideoNamesResult:
+ """Gets ordered list of video names with metadata for optimistic updates."""
+ # Validate that the caller can read from this board. "none" is handled by the SQL layer.
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
+
+ try:
+ return ApiDependencies.invoker.services.videos.get_video_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ video_origin=video_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get video names")
+
+
+@videos_router.post("/star", operation_id="star_videos_in_list", response_model=StarredVideosResult)
+async def star_videos_in_list(
+ current_user: CurrentUserOrDefault,
+ video_names: list[str] = Body(description="The list of names of videos to star", embed=True),
+) -> StarredVideosResult:
+ starred_videos: set[str] = set()
+ affected_boards: set[str] = set()
+ for video_name in video_names:
+ try:
+ _assert_video_owner(video_name, current_user)
+ updated = ApiDependencies.invoker.services.videos.update(
+ video_name, changes=VideoRecordChanges(starred=True)
+ )
+ starred_videos.add(video_name)
+ affected_boards.add(updated.board_id or "none")
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return StarredVideosResult(starred_videos=list(starred_videos), affected_boards=list(affected_boards))
+
+
+@videos_router.post("/unstar", operation_id="unstar_videos_in_list", response_model=UnstarredVideosResult)
+async def unstar_videos_in_list(
+ current_user: CurrentUserOrDefault,
+ video_names: list[str] = Body(description="The list of names of videos to unstar", embed=True),
+) -> UnstarredVideosResult:
+ unstarred_videos: set[str] = set()
+ affected_boards: set[str] = set()
+ for video_name in video_names:
+ try:
+ _assert_video_owner(video_name, current_user)
+ updated = ApiDependencies.invoker.services.videos.update(
+ video_name, changes=VideoRecordChanges(starred=False)
+ )
+ unstarred_videos.add(video_name)
+ affected_boards.add(updated.board_id or "none")
+ except HTTPException:
+ raise
+ except Exception:
+ pass
+ return UnstarredVideosResult(unstarred_videos=list(unstarred_videos), affected_boards=list(affected_boards))
+
+
+class VideoBoardArg(BaseModel):
+ board_id: str = Field(description="The id of the board to add or remove the video from")
+ video_name: str = Field(description="The name of the video to add to / remove from the board")
+
+
+@videos_router.post(
+ "/board",
+ operation_id="add_video_to_board",
+ response_model=AddVideosToBoardResult,
+)
+async def add_video_to_board(
+ current_user: CurrentUserOrDefault,
+ arg: VideoBoardArg = Body(),
+) -> AddVideosToBoardResult:
+ _assert_board_write_access(arg.board_id, current_user)
+ _assert_video_direct_owner(arg.video_name, current_user)
+ try:
+ # Capture the source board BEFORE mutating so the frontend can invalidate both
+ # the old and new board caches. Mirrors add_image_to_board.
+ old_board_id = (
+ ApiDependencies.invoker.services.board_video_records.get_board_for_video(arg.video_name) or "none"
+ )
+ ApiDependencies.invoker.services.board_video_records.add_video_to_board(
+ board_id=arg.board_id, video_name=arg.video_name
+ )
+ return AddVideosToBoardResult(
+ added_videos=[arg.video_name],
+ affected_boards=list({arg.board_id, old_board_id}),
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to add video to board")
+
+
+@videos_router.delete(
+ "/board",
+ operation_id="remove_video_from_board",
+ response_model=RemoveVideosFromBoardResult,
+)
+async def remove_video_from_board(
+ current_user: CurrentUserOrDefault,
+ video_name: str = Body(description="The name of the video to remove from its board", embed=True),
+) -> RemoveVideosFromBoardResult:
+ _assert_video_direct_owner(video_name, current_user)
+ old_board_id = ApiDependencies.invoker.services.board_video_records.get_board_for_video(video_name)
+ if old_board_id is not None:
+ _assert_board_write_access(old_board_id, current_user)
+ try:
+ ApiDependencies.invoker.services.board_video_records.remove_video_from_board(video_name=video_name)
+ return RemoveVideosFromBoardResult(
+ removed_videos=[video_name],
+ affected_boards=list({old_board_id or "none", "none"}),
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to remove video from board")
diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py
index 4b79e1eeb0c..b8b6cdff95b 100644
--- a/invokeai/app/api_app.py
+++ b/invokeai/app/api_app.py
@@ -20,9 +20,11 @@
auth,
board_images,
boards,
+ canvas_projects,
client_state,
custom_nodes,
download_queue,
+ gallery,
images,
model_manager,
model_relationships,
@@ -30,6 +32,7 @@
session_queue,
style_presets,
utilities,
+ videos,
virtual_boards,
workflows,
)
@@ -177,6 +180,10 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
app.include_router(model_manager.model_manager_router, prefix="/api")
app.include_router(download_queue.download_queue_router, prefix="/api")
app.include_router(images.images_router, prefix="/api")
+app.include_router(videos.videos_router, prefix="/api")
+app.include_router(canvas_projects.canvas_projects_router, prefix="/api")
+app.include_router(canvas_projects.board_canvas_projects_router, prefix="/api")
+app.include_router(gallery.gallery_router, prefix="/api")
app.include_router(boards.boards_router, prefix="/api")
app.include_router(board_images.board_images_router, prefix="/api")
app.include_router(virtual_boards.virtual_boards_router, prefix="/api")
diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py
index e53aeb417b2..6f5d66b290b 100644
--- a/invokeai/app/invocations/fields.py
+++ b/invokeai/app/invocations/fields.py
@@ -173,6 +173,9 @@ class FieldDescriptions:
z_image_model = "Z-Image model (Transformer) to load"
qwen_image_model = "Qwen Image Edit model (Transformer) to load"
qwen_vl_encoder = "Qwen2.5-VL tokenizer, processor and text/vision encoder"
+ wan_model = "Wan 2.2 model (Transformer) to load"
+ wan_t5_encoder = "UMT5-XXL tokenizer and text encoder for Wan 2.2"
+ wan_ref_image = "Reference-image (VAE-latent) conditioning for Wan 2.2 I2V."
sdxl_main_model = "SDXL Main model (UNet, VAE, CLIP1, CLIP2) to load"
sdxl_refiner_model = "SDXL Refiner Main Modde (UNet, VAE, CLIP2) to load"
onnx_main_model = "ONNX Main model (UNet, VAE, CLIP) to load"
@@ -240,6 +243,12 @@ class ImageField(BaseModel):
image_name: str = Field(description="The name of the image")
+class VideoField(BaseModel):
+ """A video primitive field"""
+
+ video_name: str = Field(description="The name of the video")
+
+
class BoardField(BaseModel):
"""A board primitive field"""
@@ -364,6 +373,39 @@ class AnimaConditioningField(BaseModel):
)
+class WanConditioningField(BaseModel):
+ """A Wan 2.2 conditioning tensor primitive value.
+
+ Wan conditioning is the UMT5-XXL hidden state for the prompt plus an attention
+ mask marking valid (non-padding) tokens.
+ """
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+
+
+class WanRefImageConditioningField(BaseModel):
+ """Reference-image conditioning for Wan 2.2 I2V.
+
+ Carries the 20-channel VAE-latent condition tensor (4-channel first-frame
+ mask + 16-channel ref-image latents). The denoise loop concatenates this
+ to the 16-channel noise latents along the channel dim each step, producing
+ the 36-channel input the I2V-A14B transformer expects.
+
+ Also carries the spatial dims and frame count used to encode the image so
+ the denoise node can sanity-check the user's width/height/num_frames — a
+ latent temporal-dim mismatch is hard to debug from the downstream error.
+ """
+
+ condition_tensor_name: str = Field(description="Name of the saved [1, 20, T_lat, H/8, W/8] condition tensor.")
+ width: int = Field(description="Image width used during VAE encoding (matches denoise width).")
+ height: int = Field(description="Image height used during VAE encoding (matches denoise height).")
+ num_frames: int = Field(
+ default=1,
+ description="Pixel-frame count the condition was built for. 1 for single-frame I2V "
+ "(image output), 81+ for video.",
+ )
+
+
class ConditioningField(BaseModel):
"""A conditioning tensor primitive value"""
diff --git a/invokeai/app/invocations/metadata.py b/invokeai/app/invocations/metadata.py
index da24d8802bb..c5acc6757d9 100644
--- a/invokeai/app/invocations/metadata.py
+++ b/invokeai/app/invocations/metadata.py
@@ -174,6 +174,11 @@ def invoke(self, context: InvocationContext) -> MetadataOutput:
"anima_img2img",
"anima_inpaint",
"anima_outpaint",
+ "wan_txt2img",
+ "wan_img2img",
+ "wan_inpaint",
+ "wan_outpaint",
+ "wan_i2v",
]
diff --git a/invokeai/app/invocations/model.py b/invokeai/app/invocations/model.py
index 0c96cdb1d9d..c33d207fec4 100644
--- a/invokeai/app/invocations/model.py
+++ b/invokeai/app/invocations/model.py
@@ -87,6 +87,14 @@ class Qwen3EncoderField(BaseModel):
loras: List[LoRAField] = Field(default_factory=list, description="LoRAs to apply on model loading")
+class WanT5EncoderField(BaseModel):
+ """Field for the UMT5-XXL text encoder used by Wan 2.2 models."""
+
+ tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel")
+ text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel")
+ loras: List[LoRAField] = Field(default_factory=list, description="LoRAs to apply on model loading")
+
+
class VAEField(BaseModel):
vae: ModelIdentifierField = Field(description="Info to load vae submodel")
seamless_axes: List[str] = Field(default_factory=list, description='Axes("x" and "y") to which apply seamless')
@@ -101,6 +109,46 @@ class TransformerField(BaseModel):
loras: List[LoRAField] = Field(description="LoRAs to apply on model loading")
+class WanTransformerField(BaseModel):
+ """Transformer field for Wan 2.2 models.
+
+ Wan 2.2 A14B is a Mixture-of-Experts model with two transformer experts:
+ a high-noise expert (active at large timesteps) and a low-noise expert
+ (active at small timesteps). TI2V-5B is a single-transformer model and only
+ populates ``transformer``.
+
+ ``boundary_ratio`` matches Diffusers' ``WanPipeline`` semantics: it's the
+ boundary timestep as a fraction of ``num_train_timesteps`` (typically 1000),
+ so ``boundary_ratio=0.875`` means the high-noise expert handles t >= 875 and
+ the low-noise expert handles t < 875.
+ """
+
+ transformer: ModelIdentifierField = Field(
+ description="Primary transformer submodel. For A14B this is the high-noise expert."
+ )
+ transformer_low_noise: ModelIdentifierField | None = Field(
+ default=None,
+ description="Low-noise transformer expert (Wan 2.2 A14B only). None for TI2V-5B.",
+ )
+ loras: List[LoRAField] = Field(
+ default_factory=list,
+ description="LoRAs to apply to the primary transformer. For A14B applied to the high-noise expert.",
+ )
+ loras_low_noise: List[LoRAField] = Field(
+ default_factory=list,
+ description="Optional separate LoRAs for the low-noise expert (Wan 2.2 A14B). "
+ "If empty and transformer_low_noise is set, the primary 'loras' list is reused.",
+ )
+ boundary_ratio: float = Field(
+ default=0.875,
+ ge=0.0,
+ le=1.0,
+ description="Boundary timestep as a fraction of num_train_timesteps (Wan 2.2 A14B only). "
+ "High-noise expert: t >= boundary_ratio * num_train_timesteps. Low-noise expert: t below. "
+ "Ignored for TI2V-5B.",
+ )
+
+
@invocation_output("unet_output")
class UNetOutput(BaseInvocationOutput):
"""Base class for invocations that output a UNet field."""
diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py
index 7ec6c3dc149..426f7894fe2 100644
--- a/invokeai/app/invocations/primitives.py
+++ b/invokeai/app/invocations/primitives.py
@@ -29,10 +29,14 @@
SD3ConditioningField,
TensorField,
UIComponent,
+ VideoField,
+ WanConditioningField,
+ WanRefImageConditioningField,
ZImageConditioningField,
)
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.services.videos.videos_common import VideoDTO
"""
Primitives: Boolean, Integer, Float, String, Image, Latents, Conditioning, Color
@@ -497,6 +501,44 @@ def build(cls, conditioning_name: str) -> "AnimaConditioningOutput":
return cls(conditioning=AnimaConditioningField(conditioning_name=conditioning_name))
+@invocation_output("wan_conditioning_output")
+class WanConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a Wan 2.2 text conditioning tensor."""
+
+ conditioning: WanConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "WanConditioningOutput":
+ return cls(conditioning=WanConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("wan_ref_image_output")
+class WanRefImageOutput(BaseInvocationOutput):
+ """Output of a Wan 2.2 reference-image VAE-encoder."""
+
+ ref_image: WanRefImageConditioningField = OutputField(
+ description="VAE-latent reference-image conditioning for Wan 2.2 I2V.",
+ title="Reference Image",
+ )
+
+ @classmethod
+ def build(
+ cls,
+ condition_tensor_name: str,
+ width: int,
+ height: int,
+ num_frames: int = 1,
+ ) -> "WanRefImageOutput":
+ return cls(
+ ref_image=WanRefImageConditioningField(
+ condition_tensor_name=condition_tensor_name,
+ width=width,
+ height=height,
+ num_frames=num_frames,
+ )
+ )
+
+
@invocation_output("conditioning_output")
class ConditioningOutput(BaseInvocationOutput):
"""Base class for nodes that output a single conditioning tensor"""
@@ -508,6 +550,53 @@ def build(cls, conditioning_name: str) -> "ConditioningOutput":
return cls(conditioning=ConditioningField(conditioning_name=conditioning_name))
+@invocation_output("video_output")
+class VideoOutput(BaseInvocationOutput):
+ """Output of a node that produces a video file (e.g. Wan 2.2 latents-to-video)."""
+
+ video: VideoField = OutputField(description="The output video")
+ width: int = OutputField(description="The width of the video in pixels")
+ height: int = OutputField(description="The height of the video in pixels")
+ num_frames: int = OutputField(description="The number of frames in the video")
+ fps: float = OutputField(description="The frames-per-second of the video")
+ duration: float = OutputField(description="The duration of the video in seconds")
+
+ @classmethod
+ def build(cls, video_dto: VideoDTO) -> "VideoOutput":
+ # Frame count isn't stored on the DTO; derive it from duration * fps when fps is known.
+ fps = video_dto.fps or 0.0
+ num_frames = int(round(video_dto.duration * fps)) if fps > 0 else 0
+ return cls(
+ video=VideoField(video_name=video_dto.video_name),
+ width=video_dto.width,
+ height=video_dto.height,
+ num_frames=num_frames,
+ fps=fps,
+ duration=video_dto.duration,
+ )
+
+
+@invocation(
+ "video",
+ title="Video Primitive",
+ tags=["primitives", "video"],
+ category="primitives",
+ version="1.0.0",
+)
+class VideoInvocation(BaseInvocation):
+ """A video primitive value. Drop a video onto the field to make it available as an input
+ to downstream nodes (e.g. Frame from Video, Concatenate Videos)."""
+
+ video: VideoField = InputField(description="The video to load")
+
+ # Return annotation is a real class (not a forward-ref string) because a previous
+ # `from __future__ import annotations` left wan_l2v with a stringified annotation
+ # and crashed the output-class registry on startup (commit cac366229a).
+ def invoke(self, context: InvocationContext) -> VideoOutput:
+ video_dto = context.videos.get_dto(self.video.video_name)
+ return VideoOutput.build(video_dto=video_dto)
+
+
@invocation_output("conditioning_collection_output")
class ConditioningCollectionOutput(BaseInvocationOutput):
"""Base class for nodes that output a collection of conditioning tensors"""
diff --git a/invokeai/app/invocations/video_concat.py b/invokeai/app/invocations/video_concat.py
new file mode 100644
index 00000000000..b6246ede8eb
--- /dev/null
+++ b/invokeai/app/invocations/video_concat.py
@@ -0,0 +1,237 @@
+"""Concatenate two or more videos with an optional transition.
+
+Pairs naturally with the I2V chaining workflow: feed several Wan-generated
+clips into this node to glue them into one longer video. The transition
+options hide the seam between independently-denoised clips.
+
+Implementation uses imageio (FFMPEG plugin) for both decode and encode, matching
+``wan_latents_to_video`` and ``video_thumbnails`` — so we can read our own
+output without surprises. All decoded frames live in RAM at once; this is fine
+for the short clips the I2V chain produces (a few hundred frames at 832x480),
+but be aware before piping in long uploads.
+"""
+
+import tempfile
+from pathlib import Path
+from typing import Literal, Optional
+
+import imageio.v3 as iio
+import numpy as np
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ InputField,
+ VideoField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.primitives import VideoOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.video_thumbnails import probe_video
+
+TransitionMode = Literal["cut", "crossfade", "fade_through_black"]
+
+
+def _crossfade(a_tail: list[np.ndarray], b_head: list[np.ndarray]) -> list[np.ndarray]:
+ """Linear A→B cross-dissolve. Consumes N frames from each side, returns N blended frames."""
+ n = len(a_tail)
+ out: list[np.ndarray] = []
+ for i in range(n):
+ alpha = (i + 1) / (n + 1)
+ blended = a_tail[i].astype(np.float32) * (1.0 - alpha) + b_head[i].astype(np.float32) * alpha
+ out.append(np.clip(blended, 0, 255).astype(np.uint8))
+ return out
+
+
+def _fade_through_black(a_tail: list[np.ndarray], b_head: list[np.ndarray]) -> list[np.ndarray]:
+ """A fades to black, then black fades to B. Consumes N/2 frames from each side and returns N output frames.
+
+ Asymmetric framing: the first ``len(a_tail)`` output frames are the trailing A frames scaled
+ toward zero brightness; the next ``len(b_head)`` are the leading B frames scaled up from zero.
+ """
+ out: list[np.ndarray] = []
+ n_a = len(a_tail)
+ for i, fa in enumerate(a_tail):
+ # 1.0 at i=0 (fully visible) → near 0 at i=n_a-1 (essentially black).
+ alpha = 1.0 - (i + 1) / (n_a + 1)
+ out.append(np.clip(fa.astype(np.float32) * alpha, 0, 255).astype(np.uint8))
+ n_b = len(b_head)
+ for j, fb in enumerate(b_head):
+ alpha = (j + 1) / (n_b + 1)
+ out.append(np.clip(fb.astype(np.float32) * alpha, 0, 255).astype(np.uint8))
+ return out
+
+
+@invocation(
+ "video_concat",
+ title="Concatenate Videos",
+ tags=["video", "concat", "transition"],
+ category="video",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class VideoConcatInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Join two or more videos into a single MP4.
+
+ Transitions:
+
+ * ``cut`` — hard splice, no blending. Fastest; total length is the sum of inputs.
+ * ``crossfade`` — linear A→B cross-dissolve over ``transition_frames``. Each boundary
+ consumes ``transition_frames`` from both adjacent clips, so total length is
+ ``sum(inputs) - transition_frames * (n - 1)``.
+ * ``fade_through_black`` — A fades to black, then B fades in from black. Each boundary
+ consumes ``transition_frames // 2`` frames from the preceding clip's tail and the
+ remainder (``transition_frames - transition_frames // 2``) from the next clip's head,
+ so the total emitted is exactly ``transition_frames`` per boundary — even for odd
+ ``transition_frames`` — and the overall length equals the sum of inputs.
+
+ All inputs must share the same pixel dimensions. Output frame rate defaults to the
+ first input's fps; override with ``fps`` to force a specific rate (the frames are not
+ resampled, only the container is encoded at the new rate).
+ """
+
+ videos: list[VideoField] = InputField(
+ min_length=2,
+ description="Videos to concatenate, in order. At least two are required.",
+ )
+ transition: TransitionMode = InputField(
+ default="cut",
+ description="Transition between consecutive clips.",
+ )
+ transition_frames: int = InputField(
+ default=8,
+ ge=0,
+ le=240,
+ description="Length of each transition in frames. Ignored when transition is 'cut'.",
+ )
+ fps: Optional[int] = InputField(
+ default=None,
+ ge=1,
+ le=120,
+ description="Output frame rate. Defaults to the first input's fps.",
+ )
+
+ def invoke(self, context: InvocationContext) -> VideoOutput:
+ if len(self.videos) < 2:
+ raise ValueError("video_concat requires at least two input videos.")
+
+ paths: list[Path] = [context.videos.get_path(v.video_name) for v in self.videos]
+
+ # Probe inputs up front: enforce matching dims and pick the default output fps.
+ probes = [probe_video(p) for p in paths]
+ widths = {(w, h) for (w, h, _, _) in probes}
+ if len(widths) > 1:
+ raise ValueError(
+ f"All inputs must share the same dimensions. Got: "
+ f"{sorted(widths)}. Re-render at a single resolution before concatenating."
+ )
+ width, height, _, first_fps = probes[0]
+ output_fps = float(self.fps) if self.fps is not None else (first_fps or 16.0)
+
+ context.util.signal_progress(f"Decoding {len(self.videos)} clip(s)")
+ clip_frames: list[list[np.ndarray]] = []
+ for idx, p in enumerate(paths):
+ # iio.imiter is a generator — collecting to a list keeps things simple and the
+ # downstream blending math straightforward. Memory cost is fine for I2V-length
+ # clips; if this ever needs to handle hour-long uploads, switch to streaming.
+ frames = [np.ascontiguousarray(f) for f in iio.imiter(p, plugin="FFMPEG")]
+ if not frames:
+ raise ValueError(f"Input video {idx} ({self.videos[idx].video_name}) decoded to zero frames.")
+ clip_frames.append(frames)
+
+ # Validate transition windows fit within the surrounding clips.
+ if self.transition != "cut" and self.transition_frames > 0:
+ tf = self.transition_frames
+ # For fade_through_black, split tf asymmetrically so an odd tf still emits exactly
+ # tf frames (per the docstring contract). tail_half is consumed from the previous
+ # clip's tail (the fade-out), head_half from the next clip's head (the fade-in).
+ tail_half = tf // 2
+ head_half = tf - tail_half
+ for i, frames in enumerate(clip_frames):
+ # Each non-edge clip uses transition_frames from both its head and tail.
+ head_need = 0 if i == 0 else (tf if self.transition == "crossfade" else head_half)
+ tail_need = 0 if i == len(clip_frames) - 1 else (tf if self.transition == "crossfade" else tail_half)
+ if head_need + tail_need > len(frames):
+ raise ValueError(
+ f"Clip {i} has {len(frames)} frames but the requested transitions need "
+ f"{head_need} from its head + {tail_need} from its tail. Lower "
+ f"transition_frames or use longer clips."
+ )
+
+ context.util.signal_progress(f"Joining clips ({self.transition})")
+ output_frames = self._assemble(clip_frames)
+
+ if not output_frames:
+ raise ValueError("Concatenation produced zero output frames.")
+
+ num_frames = len(output_frames)
+ duration = num_frames / output_fps
+ context.logger.info(
+ f"Encoding concatenated MP4: {num_frames} frames @ {output_fps:.2f} fps "
+ f"({duration:.2f}s) at {width}x{height}"
+ )
+ context.util.signal_progress(f"Encoding MP4 ({num_frames} frames @ {output_fps:.2f} fps)")
+
+ tmp = tempfile.NamedTemporaryFile(prefix="invokeai_video_concat_", suffix=".mp4", delete=False)
+ tmp.close()
+ tmp_path = Path(tmp.name)
+ try:
+ iio.imwrite(
+ tmp_path,
+ output_frames,
+ plugin="FFMPEG",
+ codec="libx264",
+ fps=output_fps,
+ )
+ video_dto = context.videos.save(
+ source_path=tmp_path,
+ width=width,
+ height=height,
+ duration=duration,
+ fps=output_fps,
+ )
+ context.logger.info(f"Saved concatenated video: {video_dto.video_name}")
+ return VideoOutput.build(video_dto)
+ finally:
+ try:
+ tmp_path.unlink(missing_ok=True)
+ except Exception:
+ pass
+
+ def _assemble(self, clip_frames: list[list[np.ndarray]]) -> list[np.ndarray]:
+ if self.transition == "cut" or self.transition_frames == 0:
+ return [f for frames in clip_frames for f in frames]
+
+ tf = self.transition_frames
+ if self.transition == "crossfade":
+ # Reduction layout: keep clip[i] minus tf from its tail (except the last clip),
+ # then insert tf blended frames at each boundary.
+ output: list[np.ndarray] = []
+ for i, frames in enumerate(clip_frames):
+ head_trim = 0 if i == 0 else tf
+ tail_trim = 0 if i == len(clip_frames) - 1 else tf
+ output.extend(frames[head_trim : len(frames) - tail_trim])
+ if i < len(clip_frames) - 1:
+ a_tail = frames[len(frames) - tail_trim :]
+ b_head = clip_frames[i + 1][:tf]
+ output.extend(_crossfade(a_tail, b_head))
+ return output
+
+ # fade_through_black: each boundary emits exactly `tf` frames. To preserve that
+ # contract for odd tf, the fade-out (consumed from clip[i] tail) gets tf // 2 frames
+ # and the fade-in (consumed from clip[i+1] head) gets the remainder. Even tf is
+ # symmetric as before.
+ tail_half = tf // 2
+ head_half = tf - tail_half
+ if tail_half == 0 and head_half == 0:
+ return [f for frames in clip_frames for f in frames]
+ output_ftb: list[np.ndarray] = []
+ for i, frames in enumerate(clip_frames):
+ head_trim = 0 if i == 0 else head_half
+ tail_trim = 0 if i == len(clip_frames) - 1 else tail_half
+ output_ftb.extend(frames[head_trim : len(frames) - tail_trim])
+ if i < len(clip_frames) - 1:
+ a_tail = frames[len(frames) - tail_trim :] if tail_trim else []
+ b_head = clip_frames[i + 1][:head_half] if head_half else []
+ output_ftb.extend(_fade_through_black(a_tail, b_head))
+ return output_ftb
diff --git a/invokeai/app/invocations/video_frame_extract.py b/invokeai/app/invocations/video_frame_extract.py
new file mode 100644
index 00000000000..66dea47fb71
--- /dev/null
+++ b/invokeai/app/invocations/video_frame_extract.py
@@ -0,0 +1,117 @@
+"""Extract a single frame from a video as an image.
+
+Enables I2V "shot extension": take the last frame of one clip and feed it back
+in as the reference image for the next clip, then concatenate the MP4s
+externally to get a video longer than the model's single-shot frame budget.
+Also useful as a general-purpose video-to-image step.
+"""
+
+import imageio.v3 as iio
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ InputField,
+ VideoField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.video_thumbnails import extract_video_frame, probe_video
+
+
+@invocation(
+ "video_frame_extract",
+ title="Frame from Video",
+ tags=["video", "image", "frame"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class VideoFrameExtractInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Extract a single frame from a video and save it as an image.
+
+ ``frame_index`` is 0-based. Negative indices count from the end, so the
+ default of -1 returns the final frame — the typical setup for chaining
+ I2V clips into a longer sequence.
+ """
+
+ video: VideoField = InputField(description="The video to extract a frame from.")
+ frame_index: int = InputField(
+ default=-1,
+ description="Index of the frame to extract. 0 = first frame, -1 = last frame, -2 = second-to-last, etc.",
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ video_path = context.videos.get_path(self.video.video_name)
+
+ # Resolve negative indices against the actual frame count rather than
+ # trusting imageio plugins to accept index=-1 uniformly. Use the decoder's
+ # frame count (iio.improps) when available — duration*fps can be off-by-one
+ # for VFR uploads or containers with approximate metadata, causing
+ # frame_index=-1 to point past the final frame.
+ index = self.frame_index
+ if index < 0:
+ n_frames = _decoder_frame_count(video_path)
+ if n_frames is None:
+ _, _, duration, fps = probe_video(video_path)
+ if not fps or duration <= 0:
+ raise ValueError(
+ f"Cannot resolve negative frame index for video {self.video.video_name}: "
+ f"probe returned duration={duration}, fps={fps}."
+ )
+ n_frames = int(round(duration * fps))
+ if n_frames <= 0:
+ raise ValueError(f"Video {self.video.video_name} has no decodable frames (probed {n_frames}).")
+ index = n_frames + index
+ if index < 0:
+ raise ValueError(f"frame_index {self.frame_index} is out of range for a {n_frames}-frame video.")
+
+ frame = extract_video_frame(video_path, frame_index=index)
+ if frame is None:
+ raise ValueError(f"Failed to extract frame {index} from {self.video.video_name}.")
+
+ image_dto = context.images.save(image=frame)
+ return ImageOutput.build(image_dto=image_dto)
+
+
+def _decoder_frame_count(video_path) -> int | None:
+ """Return the exact decoded frame count, or None if neither backend can determine it.
+
+ Tries imageio's improps first (works for a handful of codecs that expose nframes in
+ container metadata). For libx264 streams imageio reports ``inf``, so we fall through
+ to cv2's ``CAP_PROP_FRAME_COUNT`` which reads the actual packet count. Both sources
+ are preferred over the ``duration * fps`` estimate used by the legacy code path,
+ which can overshoot by one on VFR uploads or containers with imprecise metadata.
+ """
+ import math
+
+ try:
+ props = iio.improps(video_path, plugin="FFMPEG")
+ except Exception:
+ props = None
+ shape = getattr(props, "shape", None) if props is not None else None
+ if shape:
+ n = shape[0]
+ if not (isinstance(n, float) and not math.isfinite(n)):
+ try:
+ return int(n)
+ except (TypeError, ValueError, OverflowError):
+ pass
+
+ # Fallback: cv2 reads libx264 frame counts exactly. We only import cv2 here because
+ # it's a heavy module and the improps path covers other codecs without paying that cost.
+ try:
+ import cv2
+
+ capture = cv2.VideoCapture(str(video_path))
+ if not capture.isOpened():
+ capture.release()
+ return None
+ try:
+ count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
+ finally:
+ capture.release()
+ return count if count > 0 else None
+ except Exception:
+ return None
diff --git a/invokeai/app/invocations/wan_denoise.py b/invokeai/app/invocations/wan_denoise.py
new file mode 100644
index 00000000000..0d87be3261d
--- /dev/null
+++ b/invokeai/app/invocations/wan_denoise.py
@@ -0,0 +1,692 @@
+"""Wan 2.2 denoise invocation.
+
+Supports both single-transformer (TI2V-5B) and dual-expert MoE (A14B) denoising.
+For A14B the high-noise expert handles timesteps ``t >= boundary_timestep`` and
+the low-noise expert handles ``t < boundary_timestep``, where
+``boundary_timestep = boundary_ratio * num_train_timesteps`` (typically 1000).
+
+To keep VRAM usage manageable both experts are pinned in the model cache
+(system RAM) but only one is GPU-resident at a time. The boundary is normally
+crossed once per denoise, so the swap incurs a single CPU→GPU transfer.
+
+Phase 8 will add inpaint via :class:`RectifiedFlowInpaintExtension`.
+
+The transformer call signature mirrors Diffusers' ``WanPipeline``:
+
+ transformer(
+ hidden_states=latents_5d, # [B, C, 1, H/s, W/s]
+ timestep=t.expand(B), # scheduler-time
+ encoder_hidden_states=prompt_embeds, # [B, seq_len, 4096]
+ attention_kwargs=None,
+ return_dict=False,
+ )[0]
+"""
+
+from contextlib import ExitStack
+from pathlib import Path
+from typing import Any, Callable, Iterable, Iterator, Optional, Tuple
+
+import torch
+import torchvision.transforms as tv_transforms
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WanConditioningField,
+ WanRefImageConditioningField,
+)
+from invokeai.app.invocations.model import LoRAField, WanTransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, WanVariantType
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.wan_lora_constants import WAN_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import WanConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.wan.sampling_utils import get_spatial_scale_factor, make_noise
+
+# Type alias: a factory that produces a fresh iterator of (LoRA patch, weight)
+# pairs each time it is called. We need fresh iterators because the patcher
+# consumes the iterator once per ``apply_smart_model_patches`` invocation, and
+# the expert may be swapped (and re-entered) multiple times in a render.
+LoRAIteratorFactory = Callable[[], Iterable[Tuple[ModelPatchRaw, float]]]
+
+
+def _resolve_variant(context: InvocationContext, transformer_field: WanTransformerField) -> WanVariantType:
+ """Look up the Wan variant from the main model config that produced this transformer."""
+ config = context.models.get_config(transformer_field.transformer)
+ variant = getattr(config, "variant", None)
+ if not isinstance(variant, WanVariantType):
+ raise ValueError(f"Could not determine Wan variant from model {config.name!r}: variant is {variant!r}.")
+ return variant
+
+
+def _scheduler_path_for_transformer(context: InvocationContext, transformer_field: WanTransformerField) -> Path | None:
+ """Return the on-disk ``scheduler/`` directory for the main model, or None."""
+ config = context.models.get_config(transformer_field.transformer)
+ model_root = context.models.get_absolute_path(config)
+ if model_root.is_file():
+ return None
+ candidate = model_root / "scheduler"
+ if (candidate / "scheduler_config.json").exists():
+ return candidate
+ return None
+
+
+def _default_scheduler_for_variant(variant: WanVariantType):
+ """Build a variant-appropriate scheduler when no on-disk config is available.
+
+ Standalone GGUF / single-file installs don't ship a ``scheduler/`` directory,
+ so we have to reconstruct the scheduler from variant knowledge. Values are
+ verbatim from each variant's ``scheduler/scheduler_config.json`` in the
+ matching ``Wan-AI/Wan2.2-*-Diffusers`` repo.
+ """
+ from diffusers import FlowMatchEulerDiscreteScheduler, UniPCMultistepScheduler
+
+ if variant == WanVariantType.TI2V_5B:
+ # Wan-AI/Wan2.2-TI2V-5B-Diffusers/scheduler/scheduler_config.json. The
+ # combination of flow_prediction + use_flow_sigmas + flow_shift=5.0 is
+ # what differentiates this from a generic UniPC schedule; without it
+ # samples drift on this model.
+ return UniPCMultistepScheduler(
+ num_train_timesteps=1000,
+ solver_order=2,
+ prediction_type="flow_prediction",
+ flow_shift=5.0,
+ use_flow_sigmas=True,
+ solver_type="bh2",
+ final_sigmas_type="zero",
+ )
+ # A14B variants ship FlowMatchEulerDiscreteScheduler at default settings.
+ return FlowMatchEulerDiscreteScheduler()
+
+
+class _ExpertSwapper:
+ """Manages GPU residency and LoRA patching of one or two Wan transformer experts.
+
+ Both experts are kept in the model cache (system RAM); only one is on
+ device at a time. ``get(label)`` returns the model for the requested label,
+ swapping GPU residency when the label changes and applying that expert's
+ LoRA patches via ``LayerPatcher.apply_smart_model_patches``.
+
+ Ordering on swap: exit the active expert's LoRA context (restores weights)
+ -> exit ``model_on_device`` (returns expert to RAM) -> load the new expert
+ (fresh handle) -> enter its device context -> apply its LoRAs. This
+ mirrors the pattern used by ``flux_denoise``/``anima_denoise`` but adds
+ the extra context layer needed for dual experts.
+
+ Model handles are obtained lazily inside ``get()`` rather than cached at
+ construction. With dual ~9 GB GGUF experts plus a UMT5-XXL encoder
+ competing for the RAM cache, holding both ``LoadedModel`` handles upfront
+ can leave one of them stale by the time the swap happens — InvokeAI's
+ model cache emits a ``has already been dropped from the RAM cache``
+ warning and reloads from disk per swap. See issue #7513 for the broader
+ pattern.
+ """
+
+ HIGH = "high"
+ LOW = "low"
+
+ def __init__(
+ self,
+ context: InvocationContext,
+ high_model: Any,
+ low_model: Any | None,
+ inference_dtype: torch.dtype,
+ high_lora_factory: LoRAIteratorFactory | None = None,
+ low_lora_factory: LoRAIteratorFactory | None = None,
+ high_is_quantized: bool = False,
+ low_is_quantized: bool = False,
+ ) -> None:
+ self._context = context
+ self._high_model = high_model
+ self._low_model = low_model
+ self._inference_dtype = inference_dtype
+ self._high_lora_factory = high_lora_factory
+ self._low_lora_factory = low_lora_factory
+ self._high_is_quantized = high_is_quantized
+ self._low_is_quantized = low_is_quantized
+ self._active_label: str | None = None
+ self._active_info: Any | None = None
+ self._active_device_ctx: Any | None = None
+ self._active_lora_ctx: Any | None = None
+ self._active_model: Any | None = None
+
+ def get(self, label: str) -> Any:
+ if label not in (self.HIGH, self.LOW):
+ raise ValueError(f"Unknown expert label: {label!r}")
+ if label == self.LOW and self._low_model is None:
+ raise ValueError("Low-noise expert was requested but is not available.")
+ if label == self._active_label:
+ assert self._active_model is not None
+ return self._active_model
+
+ # Capture the outgoing expert's cache record before _release() drops our handle.
+ # We need it to force-unload below.
+ outgoing_cached_model = None
+ if self._active_info is not None:
+ # ``LoadedModel`` exposes its cache_record only via a private attribute. There
+ # is no public ``unload_from_vram`` on the LoadedModel today, and we don't want
+ # to take on a broader backend refactor in this fix; tolerate AttributeError
+ # so a future refactor doesn't break the swap.
+ outgoing_cached_model = getattr(self._active_info, "_cache_record", None)
+ if outgoing_cached_model is not None:
+ outgoing_cached_model = getattr(outgoing_cached_model, "cached_model", None)
+
+ # Release current GPU residency before bringing the other expert on device.
+ self._release()
+
+ # Force the outgoing expert off GPU. The model cache's automatic offload
+ # (inside lock() -> _offload_unlocked_models) decides how much to free based on
+ # ``torch.cuda.memory_allocated()`` minus a 3 GB working-memory budget. With Wan
+ # 81-frame video the intermediate activations from the previous denoise step are
+ # still allocated alongside the just-unlocked high-noise expert, so the cache
+ # underestimates how much room the new expert really needs and partial-loads
+ # most of its layers to CPU. The user-visible symptom: log line "Loaded model
+ # ... VRAM: 2381 MB (25.9%)" instead of ~100% for the incoming expert.
+ #
+ # Sidestep the heuristic by explicitly unloading every weight of the outgoing
+ # expert to RAM. This is safe even if the cache evicted the entry between unlock
+ # and now — the cached_model object still owns the tensors.
+ if outgoing_cached_model is not None:
+ try:
+ outgoing_cached_model.full_unload_from_vram()
+ except Exception:
+ pass
+
+ # Hand the PyTorch allocator a clean slate before partial_load_to_vram measures
+ # free space — the freed blocks stay pinned in the caching allocator until
+ # empty_cache is called.
+ TorchDevice.empty_cache()
+
+ # Load the requested expert lazily so its ``LoadedModel`` handle is
+ # always fresh — see class docstring for the cache-eviction reasoning.
+ model_id = self._high_model if label == self.HIGH else self._low_model
+ info = self._context.models.load(model_id)
+ device_ctx = info.model_on_device()
+ cached_weights, model = device_ctx.__enter__()
+
+ # Apply LoRA patches for this expert. GGUF transformers need sidecar
+ # patching since direct patching of GGMLTensors isn't supported.
+ lora_factory = self._high_lora_factory if label == self.HIGH else self._low_lora_factory
+ is_quantized = self._high_is_quantized if label == self.HIGH else self._low_is_quantized
+ lora_ctx: Any | None = None
+ if lora_factory is not None:
+ lora_ctx = LayerPatcher.apply_smart_model_patches(
+ model=model,
+ patches=lora_factory(),
+ prefix=WAN_LORA_TRANSFORMER_PREFIX,
+ dtype=self._inference_dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=is_quantized,
+ )
+ lora_ctx.__enter__()
+
+ self._active_label = label
+ self._active_info = info
+ self._active_device_ctx = device_ctx
+ self._active_lora_ctx = lora_ctx
+ self._active_model = model
+ return model
+
+ def _release(self) -> None:
+ # LoRA context first so weights are restored before the model leaves GPU.
+ if self._active_lora_ctx is not None:
+ self._active_lora_ctx.__exit__(None, None, None)
+ if self._active_device_ctx is not None:
+ self._active_device_ctx.__exit__(None, None, None)
+ self._active_label = None
+ self._active_info = None
+ self._active_device_ctx = None
+ self._active_lora_ctx = None
+ self._active_model = None
+
+ def close(self) -> None:
+ self._release()
+
+
+@invocation(
+ "wan_denoise",
+ title="Denoise - Wan 2.2",
+ tags=["image", "wan"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanDenoiseInvocation(BaseInvocation):
+ """Run the denoising process with a Wan 2.2 model.
+
+ Drives a flow-matching Euler schedule via Diffusers'
+ ``FlowMatchEulerDiscreteScheduler``. CFG is supported when negative
+ conditioning is provided and ``guidance_scale != 1.0``.
+
+ For Wan 2.2 A14B the high-noise expert handles timesteps at and above
+ ``boundary_ratio * num_train_timesteps``; the low-noise expert handles
+ timesteps below. Both experts share the model cache; only the active one is
+ GPU-resident at any time.
+ """
+
+ transformer: WanTransformerField = InputField(
+ description="Wan transformer field (transformer + optional dual-expert metadata).",
+ input=Input.Connection,
+ title="Transformer",
+ )
+ positive_conditioning: WanConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: Optional[WanConditioningField] = InputField(
+ default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+
+ ref_image: Optional[WanRefImageConditioningField] = InputField(
+ default=None,
+ description=FieldDescriptions.wan_ref_image,
+ input=Input.Connection,
+ title="Reference Image",
+ )
+
+ latents: Optional[LatentsField] = InputField(
+ default=None,
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None,
+ description=FieldDescriptions.denoise_mask,
+ input=Input.Connection,
+ )
+
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ add_noise: bool = InputField(default=True, description="Add noise based on denoising start.")
+
+ guidance_scale: float = InputField(
+ default=4.0,
+ ge=1.0,
+ description="Classifier-free guidance scale. 4.0 is the Wan 2.2 default for A14B; "
+ "TI2V-5B can tolerate higher values up to ~5.5.",
+ title="Guidance Scale",
+ )
+ guidance_scale_low_noise: Optional[float] = InputField(
+ default=None,
+ ge=0.0,
+ description="Optional separate CFG scale for the low-noise expert (Wan 2.2 A14B only). "
+ "Values below 1.0 (including 0) fall back to the primary 'Guidance Scale'. "
+ "Ignored for TI2V-5B.",
+ title="Guidance Scale (Low Noise)",
+ )
+ # Wan transformer has ``patch_size=(1, 2, 2)``: combined with the VAE's
+ # 8x spatial scale, generated H/W must be a multiple of 16 (not just 8)
+ # or the patch round-trip lands off-by-one and the scheduler step fails
+ # with a spatial-dim mismatch.
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ steps: int = InputField(default=40, gt=0, description="Number of denoising steps.")
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _run_diffusion(self, context: InvocationContext) -> torch.Tensor:
+ if self.denoising_start >= self.denoising_end:
+ raise ValueError(
+ f"denoising_start ({self.denoising_start}) must be less than denoising_end ({self.denoising_end})."
+ )
+
+ device = TorchDevice.choose_torch_device()
+ inference_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+
+ variant = _resolve_variant(context, self.transformer)
+ spatial_scale = get_spatial_scale_factor(variant)
+
+ scheduler = self._build_scheduler(context, device)
+
+ pos_cond = self._load_conditioning(context, self.positive_conditioning, device=device, dtype=inference_dtype)
+ do_cfg = self.guidance_scale != 1.0 and self.negative_conditioning is not None
+ neg_cond: WanConditioningInfo | None = None
+ if do_cfg:
+ assert self.negative_conditioning is not None
+ neg_cond = self._load_conditioning(
+ context, self.negative_conditioning, device=device, dtype=inference_dtype
+ )
+
+ # Reference-image conditioning (Wan 2.2 I2V-A14B only). The condition
+ # tensor is 20 channels (4 mask + 16 VAE-encoded image latents); it
+ # gets concatenated to the 16-channel noise latents each step,
+ # yielding the 36-channel input the I2V transformer expects.
+ ref_condition: torch.Tensor | None = None
+ if self.ref_image is not None:
+ if variant != WanVariantType.I2V_A14B:
+ raise ValueError(
+ f"Reference-image conditioning is only supported by the Wan 2.2 I2V variant. "
+ f"The selected transformer is {variant.value!r}. Remove the Reference Image input "
+ "or load an I2V model."
+ )
+ if self.ref_image.width != self.width or self.ref_image.height != self.height:
+ raise ValueError(
+ f"Reference-image dimensions ({self.ref_image.width}x{self.ref_image.height}) must "
+ f"match denoise dimensions ({self.width}x{self.height})."
+ )
+ if self.ref_image.num_frames > 1:
+ # The image denoise produces single-frame output; concatenating a multi-frame
+ # condition to a single-frame noise tensor mismatches the temporal dim and the
+ # downstream tensor-shape error would be unhelpful.
+ raise ValueError(
+ f"This denoise node produces a single-frame image but the reference image was "
+ f"encoded for {self.ref_image.num_frames} frames. Use the Denoise Video - Wan 2.2 "
+ "node for video I2V, or set num_frames=1 on the Reference Image node."
+ )
+ ref_condition = context.tensors.load(self.ref_image.condition_tensor_name).to(
+ device=device, dtype=inference_dtype
+ )
+
+ # Schedule timesteps. set_timesteps populates scheduler.timesteps and
+ # scheduler.sigmas (where sigmas is in [0, 1] flow-matching space).
+ scheduler.set_timesteps(num_inference_steps=self.steps, device=device)
+ timesteps = scheduler.timesteps
+ # sigmas has length steps + 1.
+ sigmas = scheduler.sigmas
+
+ # Apply denoising_start / denoising_end clipping.
+ if self.denoising_start > 0 or self.denoising_end < 1:
+ start_idx = int(self.denoising_start * self.steps)
+ end_idx = int(self.denoising_end * self.steps)
+ timesteps = timesteps[start_idx:end_idx]
+ sigmas = sigmas[start_idx : end_idx + 1]
+ total_steps = len(timesteps)
+
+ # Latents stay in fp32 throughout the denoise loop to avoid accumulating
+ # bf16 quantization across the scheduler's small per-step deltas. We
+ # cast to bf16 only when calling the transformer, matching Diffusers'
+ # WanPipeline (which calls ``prepare_latents(..., dtype=torch.float32)``
+ # then ``latent_model_input = latents.to(transformer_dtype)``).
+ latent_dtype = torch.float32
+
+ # Load init latents (img2img) and convert 4D → 5D.
+ init_latents_5d: torch.Tensor | None = None
+ if self.latents is not None:
+ loaded = context.tensors.load(self.latents.latents_name).to(device=device, dtype=latent_dtype)
+ if loaded.ndim == 4:
+ loaded = loaded.unsqueeze(2)
+ init_latents_5d = loaded
+
+ # Determine the latent channel count. Prefer init_latents shape; otherwise
+ # fall back to the variant default. (We avoid loading the transformer just
+ # to read .config.in_channels; the variant gives us the right answer.)
+ latent_channels = (
+ init_latents_5d.shape[1]
+ if init_latents_5d is not None
+ else (48 if variant == WanVariantType.TI2V_5B else 16)
+ )
+
+ noise = make_noise(
+ batch_size=1,
+ latent_channels=latent_channels,
+ height=self.height,
+ width=self.width,
+ spatial_scale_factor=spatial_scale,
+ device=device,
+ dtype=latent_dtype,
+ seed=self.seed,
+ )
+
+ # Combine init latents + noise per the schedule's starting sigma.
+ if init_latents_5d is not None:
+ if self.add_noise:
+ s_0 = float(sigmas[0])
+ latents = s_0 * noise + (1.0 - s_0) * init_latents_5d
+ else:
+ latents = init_latents_5d
+ else:
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ latents = noise
+
+ if total_steps <= 0:
+ return latents.squeeze(2)
+
+ # Inpaint extension (4D space — the existing extension is shape-agnostic
+ # but operates on the squeezed-T shape we use for masks).
+ inpaint_mask = self._prep_inpaint_mask(context, latents.squeeze(2))
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ if init_latents_5d is None:
+ raise ValueError("Initial latents are required when using an inpaint mask (img2img inpainting).")
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents_5d.squeeze(2),
+ inpaint_mask=inpaint_mask,
+ noise=noise.squeeze(2),
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ # Resolve experts and the boundary timestep that triggers the MoE swap.
+ #
+ # We deliberately do NOT call ``context.models.load(...)`` for the
+ # transformer experts here — that would put both ~9 GB GGUF handles
+ # in the model cache concurrently. With UMT5-XXL (~10 GB) competing
+ # for the same cache, the LRU policy can drop one of them by the
+ # time the denoise loop swaps in, producing the
+ # "has already been dropped from the RAM cache" warning and forcing
+ # a disk reload per swap. The swapper calls ``models.load`` lazily
+ # inside each ``get()`` instead, so handles are always fresh.
+ #
+ # The config metadata (variant / format) is fine to read upfront —
+ # ``get_config`` doesn't touch the weights cache.
+ high_model = self.transformer.transformer
+ low_model = self.transformer.transformer_low_noise
+ low_config = context.models.get_config(low_model) if low_model is not None else None
+ # FlowMatchEulerDiscreteScheduler stores num_train_timesteps in its config
+ # (default 1000). Diffusers' WanPipeline computes:
+ # boundary_timestep = boundary_ratio * num_train_timesteps
+ num_train_timesteps = int(scheduler.config.num_train_timesteps)
+ boundary_timestep = self.transformer.boundary_ratio * num_train_timesteps if low_model is not None else None
+
+ # LoRA wiring. The high-noise expert uses ``transformer.loras``; the
+ # low-noise expert uses ``transformer.loras_low_noise``, falling back
+ # to the primary list if empty (matches the WanTransformerField semantics).
+ # Quantized (GGUF) experts force sidecar patching so GGMLTensor weights
+ # aren't touched directly.
+ high_loras = self.transformer.loras
+ low_loras = self.transformer.loras_low_noise or self.transformer.loras
+ high_config = context.models.get_config(high_model)
+ high_is_quantized = high_config.format == ModelFormat.GGUFQuantized
+ low_is_quantized = low_config.format == ModelFormat.GGUFQuantized if low_config is not None else False
+
+ def high_lora_factory() -> Iterable[Tuple[ModelPatchRaw, float]]:
+ return self._lora_iterator(context, high_loras)
+
+ def low_lora_factory() -> Iterable[Tuple[ModelPatchRaw, float]]:
+ return self._lora_iterator(context, low_loras)
+
+ with ExitStack() as exit_stack:
+ swapper = _ExpertSwapper(
+ context=context,
+ high_model=high_model,
+ low_model=low_model,
+ inference_dtype=inference_dtype,
+ high_lora_factory=high_lora_factory if high_loras else None,
+ low_lora_factory=low_lora_factory if low_loras else None,
+ high_is_quantized=high_is_quantized,
+ low_is_quantized=low_is_quantized,
+ )
+ exit_stack.callback(swapper.close)
+
+ for step_idx, t in enumerate(tqdm(timesteps, desc="Denoising (Wan 2.2)", total=total_steps)):
+ timestep = t.expand(latents.shape[0])
+
+ # Pick the active expert: high-noise for t >= boundary_timestep,
+ # low-noise below. Single-transformer models always use HIGH.
+ if low_model is not None and float(t) < float(boundary_timestep):
+ active_label = _ExpertSwapper.LOW
+ # Treat None or values below 1.0 (incl. the FE's default 0)
+ # as "use the primary guidance_scale".
+ low_cfg = self.guidance_scale_low_noise
+ active_cfg = low_cfg if (low_cfg is not None and low_cfg >= 1.0) else self.guidance_scale
+ else:
+ active_label = _ExpertSwapper.HIGH
+ active_cfg = self.guidance_scale
+
+ transformer = swapper.get(active_label)
+
+ # Cast latents to the transformer's dtype only for the forward
+ # pass; keep the scheduler-level latents in fp32.
+ latent_model_input = latents.to(dtype=inference_dtype)
+
+ # For I2V, concatenate the ref-image condition (4-ch mask + 16-ch
+ # image latents) along the channel dim, producing the 36-channel
+ # input the I2V transformer's patch_embedding expects.
+ if ref_condition is not None:
+ latent_model_input = torch.cat([latent_model_input, ref_condition], dim=1)
+
+ noise_pred_cond = transformer(
+ hidden_states=latent_model_input,
+ timestep=timestep,
+ encoder_hidden_states=pos_cond.prompt_embeds.unsqueeze(0),
+ attention_kwargs=None,
+ return_dict=False,
+ )[0]
+
+ if do_cfg and neg_cond is not None:
+ noise_pred_uncond = transformer(
+ hidden_states=latent_model_input,
+ timestep=timestep,
+ encoder_hidden_states=neg_cond.prompt_embeds.unsqueeze(0),
+ attention_kwargs=None,
+ return_dict=False,
+ )[0]
+ noise_pred = noise_pred_uncond + active_cfg * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ latents = scheduler.step(noise_pred, t, latents, return_dict=False)[0]
+
+ if inpaint_extension is not None:
+ sigma_prev = float(sigmas[step_idx + 1])
+ latents_4d = latents.squeeze(2)
+ latents_4d = inpaint_extension.merge_intermediate_latents_with_init_latents(latents_4d, sigma_prev)
+ latents = latents_4d.unsqueeze(2)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(t.item()),
+ latents=latents.squeeze(2),
+ )
+ )
+
+ # Squeeze T for downstream 4D consumers.
+ return latents.squeeze(2)
+
+ def _build_scheduler(self, context: InvocationContext, device: torch.device):
+ """Construct the scheduler matching the model's on-disk ``scheduler_config.json``.
+
+ Wan model variants ship different schedulers — e.g. TI2V-5B uses
+ ``UniPCMultistepScheduler`` with ``flow_shift=5.0``, while the
+ standard A14B reference uses ``FlowMatchEulerDiscreteScheduler``.
+ We dispatch on ``_class_name`` so the noise schedule matches what the
+ model was trained against. When no on-disk config is available
+ (standalone GGUF / single-file installs that don't ship a
+ ``scheduler/`` directory), fall back to a variant-aware default —
+ TI2V-5B gets its UniPC scheduler with the right flow params instead
+ of the generic FlowMatchEuler, which otherwise produces drifty
+ samples for that model.
+ """
+ import json
+
+ import diffusers
+ from diffusers import FlowMatchEulerDiscreteScheduler
+
+ scheduler_dir = _scheduler_path_for_transformer(context, self.transformer)
+ if scheduler_dir is None:
+ variant = _resolve_variant(context, self.transformer)
+ return _default_scheduler_for_variant(variant)
+
+ # Read the on-disk class name and instantiate that class. Diffusers'
+ # SchedulerMixin.from_pretrained does class dispatch internally, but
+ # only when called from the abstract base; calling a concrete subclass
+ # silently builds the wrong type. Resolve it explicitly.
+ config_path = scheduler_dir / "scheduler_config.json"
+ try:
+ with config_path.open("r", encoding="utf-8") as f:
+ cfg = json.load(f)
+ class_name = cfg.get("_class_name")
+ scheduler_cls = getattr(diffusers, class_name, None) if class_name else None
+ except (OSError, json.JSONDecodeError):
+ scheduler_cls = None
+
+ if scheduler_cls is None:
+ scheduler_cls = FlowMatchEulerDiscreteScheduler
+
+ return scheduler_cls.from_pretrained(str(scheduler_dir), local_files_only=True)
+
+ def _load_conditioning(
+ self,
+ context: InvocationContext,
+ cond_field: WanConditioningField,
+ *,
+ device: torch.device,
+ dtype: torch.dtype,
+ ) -> WanConditioningInfo:
+ cond_data = context.conditioning.load(cond_field.conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ cond_info = cond_data.conditionings[0]
+ assert isinstance(cond_info, WanConditioningInfo)
+ return cond_info.to(device=device, dtype=dtype)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents_4d: torch.Tensor) -> torch.Tensor | None:
+ """Resize the user-supplied mask down to latent resolution.
+
+ Convention matches Anima/FLUX: the original mask has 0 = preserve and
+ 1 = denoise; the extension expects the inverted form.
+ """
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+ mask = 1.0 - mask
+ _, _, latent_h, latent_w = latents_4d.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_h, latent_w],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+ return mask.to(device=latents_4d.device, dtype=latents_4d.dtype)
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.Wan)
+
+ return step_callback
+
+ def _lora_iterator(
+ self, context: InvocationContext, loras: list[LoRAField]
+ ) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Yield (ModelPatchRaw, weight) pairs for the given LoRA list.
+
+ The caller passes either ``transformer.loras`` (high-noise expert) or
+ ``transformer.loras_low_noise`` (low-noise expert) — the fallback to
+ the primary list when low-noise is empty is handled at the call site.
+ """
+ for lora_field in loras:
+ lora_info = context.models.load(lora_field.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw), (
+ f"Wan LoRA model must be ModelPatchRaw, got {type(lora_info.model).__name__}"
+ )
+ yield (lora_info.model, lora_field.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/wan_ideal_dimensions.py b/invokeai/app/invocations/wan_ideal_dimensions.py
new file mode 100644
index 00000000000..9a9c50c936c
--- /dev/null
+++ b/invokeai/app/invocations/wan_ideal_dimensions.py
@@ -0,0 +1,103 @@
+"""Compute Wan 2.2 I2V-compatible pixel dimensions for a target short-side resolution.
+
+Wan's transformer ``patch_size=(1, 2, 2)`` combined with the VAE's 8x spatial
+compression requires pixel dimensions to be multiples of 16 (see
+``wan_ref_image_encoder.py``). This node takes a source image's W×H and a
+target short-side preset (480p / 720p / 1080p) and returns the scaled,
+snapped (width, height) that can be fed directly into ``wan_ref_image_encoder``
+and the matching ``wan_denoise`` inputs.
+"""
+
+import math
+from typing import Literal
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField
+from invokeai.app.invocations.ideal_size import IdealSizeOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+WanTargetResolution = Literal["480p", "720p", "1080p"]
+
+# Short-side pixel count for each preset. "p" notation is by convention the *short*
+# dimension in modern video (so a portrait 720p video is 720 wide × 1280 tall).
+WAN_TARGET_RESOLUTION_PX: dict[str, int] = {
+ "480p": 480,
+ "720p": 720,
+ "1080p": 1080,
+}
+
+WAN_TARGET_RESOLUTION_LABELS: dict[str, str] = {
+ "480p": "480p (Wan native)",
+ "720p": "720p (Wan native, default)",
+ "1080p": "1080p (extrapolated — not a Wan training size)",
+}
+
+
+@invocation(
+ "wan_i2v_ideal_dimensions",
+ title="Wan 2.2 I2V Ideal Dimensions",
+ tags=["wan", "video", "dimensions", "math"],
+ category="video",
+ version="1.1.0",
+)
+class WanI2VIdealDimensionsInvocation(BaseInvocation):
+ """Compute Wan I2V-compatible (width, height) for a chosen resolution preset.
+
+ Scales the input W×H so the shorter side equals the chosen preset (480 / 720 /
+ 1080 px), then snaps each dimension to a multiple of 16 (Wan's pixel-grid
+ constraint). Wire from ``Image Primitive``'s width/height outputs and into
+ ``wan_ref_image_encoder`` / ``wan_denoise``.
+ """
+
+ width: int = InputField(
+ default=1024,
+ gt=0,
+ description="Source image width in pixels.",
+ )
+ height: int = InputField(
+ default=1024,
+ gt=0,
+ description="Source image height in pixels.",
+ )
+ target_resolution: WanTargetResolution = InputField(
+ default="720p",
+ description=(
+ "Short-side resolution preset. 480p and 720p are Wan 2.2's native training "
+ "resolutions; 1080p works but is extrapolation and costs ~2.25x the memory "
+ "of 720p."
+ ),
+ ui_choice_labels=WAN_TARGET_RESOLUTION_LABELS,
+ )
+ rounding: Literal["nearest", "floor", "ceiling"] = InputField(
+ default="nearest",
+ description=(
+ "How to snap each dimension to a multiple of 16. 'floor' rounds down — "
+ "safest for VRAM, guaranteed not to exceed the unsnapped target. "
+ "'ceiling' rounds up. 'nearest' minimizes aspect-ratio drift (default)."
+ ),
+ )
+
+ def invoke(self, context: InvocationContext) -> IdealSizeOutput:
+ short = min(self.width, self.height)
+ if short <= 0:
+ raise ValueError("Source dimensions must be positive.")
+
+ target_short_side = WAN_TARGET_RESOLUTION_PX[self.target_resolution]
+ scale = target_short_side / short
+ raw_w = self.width * scale
+ raw_h = self.height * scale
+
+ if self.rounding == "floor":
+ w = int(raw_w // 16) * 16
+ h = int(raw_h // 16) * 16
+ elif self.rounding == "ceiling":
+ w = int(math.ceil(raw_w / 16)) * 16
+ h = int(math.ceil(raw_h / 16)) * 16
+ else: # nearest
+ w = round(raw_w / 16) * 16
+ h = round(raw_h / 16) * 16
+
+ # Guard against zero from extreme inputs (e.g. floor of <16 raw value).
+ w = max(w, 16)
+ h = max(h, 16)
+ return IdealSizeOutput(width=w, height=h)
diff --git a/invokeai/app/invocations/wan_image_to_latents.py b/invokeai/app/invocations/wan_image_to_latents.py
new file mode 100644
index 00000000000..d5827110d0a
--- /dev/null
+++ b/invokeai/app/invocations/wan_image_to_latents.py
@@ -0,0 +1,104 @@
+"""Wan 2.2 image-to-latents invocation.
+
+Encodes an image to latent space using the Wan VAE (AutoencoderKLWan). The Wan
+VAE expects 5D ``[B, C, T, H, W]`` input with ``T=1`` for single images. After
+encoding, latents are normalised against the per-channel ``latents_mean`` and
+``latents_std`` stored in the VAE config — this matches the Diffusers
+``WanPipeline`` reference and is the inverse of the denormalisation in
+``wan_latents_to_image.py``.
+"""
+
+import einops
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+
+@invocation(
+ "wan_i2l",
+ title="Image to Latents - Wan 2.2",
+ tags=["image", "latents", "vae", "i2l", "wan"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Encodes an image with the Wan VAE (AutoencoderKLWan).
+
+ The output latents have the temporal dimension squeezed out, so downstream
+ nodes see 4D ``[B, C, H, W]``. The denoise loop re-adds ``T=1`` before
+ feeding the transformer.
+ """
+
+ image: ImageField = InputField(description="The image to encode.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ if not isinstance(vae_info.model, AutoencoderKLWan):
+ raise TypeError(f"Expected AutoencoderKLWan for Wan VAE, got {type(vae_info.model).__name__}.")
+
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="encode",
+ image_tensor=image_tensor,
+ vae=vae_info.model,
+ )
+
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoencoderKLWan)
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ with torch.inference_mode():
+ # Wan VAE expects 5D [B, C, T, H, W].
+ if image_tensor.ndim == 4:
+ image_tensor = image_tensor.unsqueeze(2) # [B, C, H, W] -> [B, C, 1, H, W]
+
+ encoded = vae.encode(image_tensor, return_dict=False)[0]
+ latents = encoded.sample().to(dtype=vae_dtype)
+
+ # Normalise to the denoiser's expected zero-centred space:
+ # (latents - mean) / std
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
+ latents = (latents - latents_mean) / latents_std
+
+ # Drop the temporal dim to keep the rest of the InvokeAI pipeline 4D.
+ if latents.ndim == 5:
+ latents = latents.squeeze(2)
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+
+ context.util.signal_progress("Running Wan VAE encode")
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/wan_latents_to_image.py b/invokeai/app/invocations/wan_latents_to_image.py
new file mode 100644
index 00000000000..049959646c1
--- /dev/null
+++ b/invokeai/app/invocations/wan_latents_to_image.py
@@ -0,0 +1,93 @@
+"""Wan 2.2 latents-to-image invocation.
+
+Decodes Wan latents using the Wan VAE (AutoencoderKLWan).
+
+Latents from the denoise loop are in normalised space (zero-centred). Before
+VAE decode they are denormalised using the VAE config's per-channel
+``latents_mean`` / ``latents_std`` (matching Diffusers ``WanPipeline``).
+
+The VAE expects 5D ``[B, C, T, H, W]``; downstream nodes work with 4D, so this
+node re-adds ``T=1`` before decode and squeezes it back out afterwards.
+"""
+
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+
+@invocation(
+ "wan_l2i",
+ title="Latents to Image - Wan 2.2",
+ tags=["latents", "image", "vae", "l2i", "wan"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanLatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Decodes Wan latents back to RGB."""
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, AutoencoderKLWan):
+ raise TypeError(f"Expected AutoencoderKLWan for Wan VAE, got {type(vae_info.model).__name__}.")
+
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="decode",
+ image_tensor=latents,
+ vae=vae_info.model,
+ )
+
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ context.util.signal_progress("Running Wan VAE decode")
+ assert isinstance(vae, AutoencoderKLWan)
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode():
+ # Re-add the temporal dim if upstream squeezed it out.
+ if latents.ndim == 4:
+ latents = latents.unsqueeze(2)
+
+ # Denormalise from denoiser space back to raw VAE space.
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
+ latents = latents * latents_std + latents_mean
+
+ decoded = vae.decode(latents, return_dict=False)[0]
+
+ if decoded.ndim == 5:
+ decoded = decoded.squeeze(2)
+
+ img = decoded.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c")
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/wan_latents_to_video.py b/invokeai/app/invocations/wan_latents_to_video.py
new file mode 100644
index 00000000000..703a8e4cffa
--- /dev/null
+++ b/invokeai/app/invocations/wan_latents_to_video.py
@@ -0,0 +1,151 @@
+"""Wan 2.2 latents-to-video invocation.
+
+Decodes multi-frame Wan latents with the Wan VAE and encodes the result to an
+MP4 file via :mod:`imageio` (backed by the bundled FFmpeg binary from
+``imageio-ffmpeg``). The video is then persisted through ``context.videos.save``,
+which moves the temp file into ``outputs/videos/`` and records the DTO.
+
+Latent shape on input is 5D ``[B, C, T_lat, H_lat, W_lat]`` (typically B=1).
+The VAE expands the temporal dim by 4× during decode minus the initial offset:
+``T_pixel = (T_lat - 1) * 4 + 1`` (e.g. T_lat=21 → 81 pixel frames).
+"""
+
+import tempfile
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+from einops import rearrange
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import VideoOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "wan_l2v",
+ title="Latents to Video - Wan 2.2",
+ tags=["latents", "video", "vae", "l2v", "wan"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanLatentsToVideoInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Decode 5D Wan latents to RGB frames and encode an MP4."""
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+ fps: int = InputField(
+ default=16,
+ ge=1,
+ le=120,
+ description="Frames-per-second for the encoded MP4. Wan 2.2 was trained at 16 FPS.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> VideoOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+ if latents.ndim == 4:
+ # Promote 4D (single-frame) to 5D so this node can also serve as a
+ # one-frame "video" encode if someone wires it that way.
+ latents = latents.unsqueeze(2)
+ if latents.ndim != 5:
+ raise ValueError(
+ f"Wan latents-to-video expects a 5D latent tensor [B, C, T, H, W]; got {tuple(latents.shape)}."
+ )
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, AutoencoderKLWan):
+ raise TypeError(f"Expected AutoencoderKLWan for Wan VAE, got {type(vae_info.model).__name__}.")
+
+ with vae_info.model_on_device() as (_, vae):
+ assert isinstance(vae, AutoencoderKLWan)
+ _, _, t_lat, h_lat, w_lat = latents.shape
+ t_pixel = (t_lat - 1) * 4 + 1
+ context.logger.info(
+ f"Running Wan VAE decode: {t_lat} latent frames -> {t_pixel} pixel frames at {w_lat * 8}x{h_lat * 8}"
+ )
+ context.util.signal_progress("Running Wan VAE decode (video)")
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode():
+ # Denormalise from denoiser space back to VAE space.
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
+ latents = latents * latents_std + latents_mean
+
+ # [B, C=3, T_pixel, H, W] in [-1, 1] (roughly).
+ decoded = vae.decode(latents, return_dict=False)[0]
+
+ decoded = decoded.clamp(-1, 1)
+ # Take batch 0 (we generate one video at a time).
+ decoded = decoded[0] # [C, T, H, W]
+
+ TorchDevice.empty_cache()
+
+ # Convert to a list of numpy uint8 frames [H, W, C].
+ decoded = rearrange(decoded, "c t h w -> t h w c")
+ # [-1, 1] -> [0, 255]
+ frames = (127.5 * (decoded.cpu().float() + 1.0)).round().clamp(0, 255).byte().numpy()
+ frames_list = [np.ascontiguousarray(frames[i]) for i in range(frames.shape[0])]
+
+ if not frames_list:
+ raise ValueError("Wan VAE decode produced zero frames.")
+
+ height, width = frames_list[0].shape[:2]
+ num_frames = len(frames_list)
+ duration = num_frames / float(self.fps)
+
+ # Encode to a temporary MP4 via imageio's FFMPEG plugin (backed by the
+ # bundled imageio-ffmpeg binary). libx264 + yuv420p is the default for
+ # this plugin, which is what we want for broadly-compatible browser
+ # playback — no need to override.
+ tmp = tempfile.NamedTemporaryFile(prefix="invokeai_wan_video_", suffix=".mp4", delete=False)
+ tmp.close()
+ tmp_path = Path(tmp.name)
+ try:
+ context.logger.info(
+ f"Encoding MP4: {num_frames} frames @ {self.fps} fps ({duration:.2f}s) at {width}x{height} via libx264"
+ )
+ context.util.signal_progress(f"Encoding MP4 ({num_frames} frames @ {self.fps} fps)")
+ iio.imwrite(
+ tmp_path,
+ frames_list,
+ plugin="FFMPEG",
+ codec="libx264",
+ fps=self.fps,
+ )
+ encoded_bytes = tmp_path.stat().st_size
+ context.logger.info(f"MP4 encode complete: {encoded_bytes / 1024:.1f} KB")
+ video_dto = context.videos.save(
+ source_path=tmp_path,
+ width=width,
+ height=height,
+ duration=duration,
+ fps=float(self.fps),
+ )
+ context.logger.info(f"Saved video: {video_dto.video_name}")
+ return VideoOutput.build(video_dto)
+ finally:
+ # If save() moved the file this is a no-op; if it failed earlier, we
+ # don't want a lingering temp file.
+ try:
+ tmp_path.unlink(missing_ok=True)
+ except Exception:
+ pass
diff --git a/invokeai/app/invocations/wan_lora_loader.py b/invokeai/app/invocations/wan_lora_loader.py
new file mode 100644
index 00000000000..66034685e00
--- /dev/null
+++ b/invokeai/app/invocations/wan_lora_loader.py
@@ -0,0 +1,188 @@
+from typing import Literal, Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, WanTransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+# Target option for routing a LoRA to one or both Wan A14B expert lists.
+#
+# - ``auto``: read the LoRA config's ``expert`` field (set by the probe / from
+# filename). ``"high"`` -> primary list only, ``"low"`` -> low-noise list
+# only, ``None`` -> both lists.
+# - ``both``: append to both lists regardless of the config.
+# - ``high``: append only to the primary list (high-noise expert).
+# - ``low``: append only to the low-noise list (low-noise expert).
+WanLoRATarget = Literal["auto", "both", "high", "low"]
+
+
+def _resolve_target(target: WanLoRATarget, lora_expert: str | None) -> tuple[bool, bool]:
+ """Return (apply_to_primary, apply_to_low_noise) based on the requested
+ target and the LoRA's recorded expert tag."""
+ if target == "both":
+ return True, True
+ if target == "high":
+ return True, False
+ if target == "low":
+ return False, True
+ # auto
+ if lora_expert == "high":
+ return True, False
+ if lora_expert == "low":
+ return False, True
+ return True, True
+
+
+@invocation_output("wan_lora_loader_output")
+class WanLoRALoaderOutput(BaseInvocationOutput):
+ """Wan 2.2 LoRA loader output."""
+
+ transformer: Optional[WanTransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="Wan Transformer"
+ )
+
+
+@invocation(
+ "wan_lora_loader",
+ title="Apply LoRA - Wan 2.2",
+ tags=["lora", "model", "wan"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA to the Wan 2.2 transformer(s).
+
+ For A14B (dual expert) the LoRA's recorded ``expert`` field determines
+ which expert list it lands in: ``"high"`` -> primary list, ``"low"`` ->
+ low-noise list, ``None`` (untagged) -> both lists. Use the ``target``
+ field to override.
+
+ For TI2V-5B (single transformer) only the primary list is used at denoise
+ time; the low-noise routing is harmless but ignored.
+ """
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.Wan,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
+ target: WanLoRATarget = InputField(
+ default="auto",
+ description="Which expert(s) to apply this LoRA to. 'auto' uses the LoRA's "
+ "recorded expert tag (or both if untagged); 'both'/'high'/'low' override it.",
+ )
+ transformer: WanTransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Wan Transformer",
+ )
+
+ def invoke(self, context: InvocationContext) -> WanLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ output = WanLoRALoaderOutput()
+ if self.transformer is None:
+ return output
+
+ lora_config = context.models.get_config(self.lora)
+ lora_expert = getattr(lora_config, "expert", None)
+ to_primary, to_low_noise = _resolve_target(self.target, lora_expert)
+
+ # Reject duplicates on whichever list(s) we're about to append to.
+ if to_primary and any(item.lora.key == lora_key for item in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to primary transformer list.')
+ if to_low_noise and any(item.lora.key == lora_key for item in self.transformer.loras_low_noise):
+ raise ValueError(f'LoRA "{lora_key}" already applied to low-noise transformer list.')
+
+ output.transformer = self.transformer.model_copy(deep=True)
+ new_lora = LoRAField(lora=self.lora, weight=self.weight)
+ if to_primary:
+ output.transformer.loras.append(new_lora)
+ if to_low_noise:
+ output.transformer.loras_low_noise.append(new_lora)
+
+ return output
+
+
+@invocation(
+ "wan_lora_collection_loader",
+ title="Apply LoRA Collection - Wan 2.2",
+ tags=["lora", "model", "wan"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanLoRACollectionLoader(BaseInvocation):
+ """Apply a collection of LoRAs to the Wan 2.2 transformer(s).
+
+ Each LoRA is routed to the primary and/or low-noise list based on its
+ recorded ``expert`` tag (set by the probe from the filename). Untagged
+ LoRAs go to both lists.
+ """
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None,
+ description="LoRAs to apply. May be a single LoRA or a collection.",
+ title="LoRAs",
+ )
+ transformer: Optional[WanTransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Wan Transformer",
+ )
+
+ def invoke(self, context: InvocationContext) -> WanLoRALoaderOutput:
+ output = WanLoRALoaderOutput()
+
+ if self.transformer is None:
+ return output
+
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ if self.loras is None:
+ return output
+
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added: set[str] = set()
+
+ for lora in loras:
+ if lora is None or lora.lora.key in added:
+ continue
+
+ if not context.models.exists(lora.lora.key):
+ raise ValueError(f"Unknown lora: {lora.lora.key}!")
+
+ if lora.lora.base is not BaseModelType.Wan:
+ raise ValueError(
+ f"LoRA '{lora.lora.key}' is for "
+ f"{lora.lora.base.value if lora.lora.base else 'unknown'} models, "
+ "not Wan 2.2."
+ )
+
+ lora_config = context.models.get_config(lora.lora)
+ lora_expert = getattr(lora_config, "expert", None)
+ to_primary, to_low_noise = _resolve_target("auto", lora_expert)
+
+ added.add(lora.lora.key)
+
+ if to_primary:
+ output.transformer.loras.append(lora)
+ if to_low_noise:
+ output.transformer.loras_low_noise.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/wan_model_loader.py b/invokeai/app/invocations/wan_model_loader.py
new file mode 100644
index 00000000000..a4d986d8aa3
--- /dev/null
+++ b/invokeai/app/invocations/wan_model_loader.py
@@ -0,0 +1,239 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ ModelIdentifierField,
+ VAEField,
+ WanT5EncoderField,
+ WanTransformerField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType
+
+
+@invocation_output("wan_model_loader_output")
+class WanModelLoaderOutput(BaseInvocationOutput):
+ """Wan 2.2 model loader output."""
+
+ transformer: WanTransformerField = OutputField(
+ description="Wan transformer (one or two experts depending on the variant)",
+ title="Transformer",
+ )
+ wan_t5_encoder: WanT5EncoderField = OutputField(
+ description=FieldDescriptions.wan_t5_encoder,
+ title="UMT5-XXL Encoder",
+ )
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "wan_model_loader",
+ title="Main Model - Wan 2.2",
+ tags=["model", "wan"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanModelLoaderInvocation(BaseInvocation):
+ """Loads a Wan 2.2 model, outputting its submodels.
+
+ Components can be mixed and matched, mirroring the Qwen Image loader pattern:
+
+ - Transformer(s):
+ * Diffusers main: emits ``transformer/`` and (for A14B) ``transformer_2/``
+ from the same model record.
+ * GGUF main: emits the single GGUF as the primary transformer; for A14B
+ the second-expert GGUF must be wired to ``Transformer (Low Noise)``.
+ - VAE: standalone Wan VAE > main (if Diffusers) > Component Source (Diffusers).
+ - UMT5-XXL encoder: standalone Wan T5 encoder > main (if Diffusers) >
+ Component Source (Diffusers).
+
+ The Component Source slot lets users supply a Diffusers Wan main model purely
+ for VAE / encoder extraction when the actual transformer is in a single-file
+ format. Together, the standalone VAE + standalone encoder let a GGUF
+ transformer run without a full ~30 GB Diffusers install.
+ """
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.wan_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Wan,
+ ui_model_type=ModelType.Main,
+ title="Transformer",
+ )
+
+ transformer_low_noise_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Optional second GGUF transformer for the A14B low-noise expert. "
+ "Only relevant when the main model is a single-file GGUF and the variant is A14B; "
+ "ignored when the main is a Diffusers A14B (both experts are pulled from "
+ "transformer/ and transformer_2/ already) or when the variant is TI2V-5B.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Wan,
+ ui_model_type=ModelType.Main,
+ ui_model_format=ModelFormat.GGUFQuantized,
+ title="Transformer (Low Noise)",
+ )
+
+ vae_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Wan VAE model. If not set, the VAE is loaded from the main model "
+ "(when in Diffusers format) or from the Component Source.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Wan,
+ ui_model_type=ModelType.VAE,
+ title="VAE",
+ )
+
+ wan_t5_encoder_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Wan UMT5-XXL encoder. If not set, the encoder is loaded from the main "
+ "model (when in Diffusers format) or from the Component Source.",
+ input=Input.Direct,
+ ui_model_type=ModelType.WanT5Encoder,
+ title="Wan T5 Encoder",
+ )
+
+ component_source: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Diffusers Wan main model to extract VAE and/or encoder from. "
+ "Use this if you don't have separate VAE/encoder models. "
+ "Ignored for any submodel that is provided separately.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Wan,
+ ui_model_type=ModelType.Main,
+ ui_model_format=ModelFormat.Diffusers,
+ title="Component Source (Diffusers)",
+ )
+
+ def invoke(self, context: InvocationContext) -> WanModelLoaderOutput:
+ main_config = context.models.get_config(self.model)
+ main_format = main_config.format
+ main_is_diffusers = main_format == ModelFormat.Diffusers
+ main_is_gguf = main_format == ModelFormat.GGUFQuantized
+
+ # Resolve transformer + dual-expert wiring + boundary_ratio.
+ #
+ # Diffusers main: transformer/ is the primary, transformer_2/ is the
+ # low-noise expert (A14B only). boundary_ratio comes from the probed
+ # model_index.json.
+ #
+ # GGUF main: the file itself is one expert (high or low). For A14B,
+ # the user wires the other expert to transformer_low_noise_model.
+ # We swap so the *high*-noise expert is always the primary if needed.
+ # boundary_ratio falls back to 0.875 unless a Diffusers component_source
+ # provides a recorded value.
+ boundary_ratio = 0.875
+ transformer_low_noise: Optional[ModelIdentifierField] = None
+
+ if main_is_diffusers:
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ if getattr(main_config, "has_dual_expert", False):
+ transformer_low_noise = self.model.model_copy(update={"submodel_type": SubModelType.Transformer2})
+ recorded = getattr(main_config, "boundary_ratio", None)
+ if recorded is not None:
+ boundary_ratio = float(recorded)
+ elif main_is_gguf:
+ primary_expert = getattr(main_config, "expert", "none")
+ primary_id = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+
+ if self.transformer_low_noise_model is not None:
+ low_config = context.models.get_config(self.transformer_low_noise_model)
+ if low_config.format != ModelFormat.GGUFQuantized:
+ raise ValueError(
+ f"'Transformer (Low Noise)' must be a GGUF-format Wan model. "
+ f"'{low_config.name}' is in {low_config.format.value} format."
+ )
+ low_id = self.transformer_low_noise_model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ low_expert = getattr(low_config, "expert", "none")
+
+ # Make sure 'transformer' is the high-noise expert and
+ # 'transformer_low_noise' is the low-noise expert. If the user
+ # accidentally swapped them, swap back.
+ if primary_expert == "low" and low_expert == "high":
+ transformer = low_id
+ transformer_low_noise = primary_id
+ else:
+ transformer = primary_id
+ transformer_low_noise = low_id
+ else:
+ transformer = primary_id
+ # A14B without a paired low-noise GGUF will produce degraded
+ # quality (only the high-noise expert runs). Warn but don't
+ # abort — TI2V-5B GGUFs are single-expert and totally fine.
+ if getattr(main_config, "variant", None) and main_config.variant.value == "t2v_a14b":
+ context.logger.warning(
+ "A14B GGUF main was provided without a paired 'Transformer (Low Noise)'. "
+ "Only the high-noise expert will run; image quality will be reduced."
+ )
+
+ # Borrow the boundary_ratio recorded on the optional Diffusers
+ # component_source, when one is wired.
+ if self.component_source is not None:
+ src_cfg = context.models.get_config(self.component_source)
+ src_boundary = getattr(src_cfg, "boundary_ratio", None)
+ if src_boundary is not None:
+ boundary_ratio = float(src_boundary)
+ else:
+ raise ValueError(
+ f"Unsupported main model format for Wan: {main_format.value}. "
+ "Use a Diffusers folder or a GGUF single-file checkpoint."
+ )
+
+ # VAE: standalone override > main (if Diffusers) > component source.
+ if self.vae_model is not None:
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif main_is_diffusers:
+ vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif self.component_source is not None:
+ self._validate_component_source_format(context, self.component_source)
+ vae = self.component_source.model_copy(update={"submodel_type": SubModelType.VAE})
+ else:
+ raise ValueError(
+ "No source for VAE. Either set 'VAE' to a standalone Wan VAE, "
+ "or set 'Component Source' to a Diffusers Wan main model."
+ )
+
+ # Tokenizer + text encoder: standalone override > main (if Diffusers) > component source.
+ if self.wan_t5_encoder_model is not None:
+ tokenizer = self.wan_t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.wan_t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif main_is_diffusers:
+ tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif self.component_source is not None:
+ self._validate_component_source_format(context, self.component_source)
+ tokenizer = self.component_source.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.component_source.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ else:
+ raise ValueError(
+ "No source for Wan T5 encoder. "
+ "Either set 'Wan T5 Encoder' to a standalone UMT5-XXL encoder, "
+ "or set 'Component Source' to a Diffusers Wan main model."
+ )
+
+ return WanModelLoaderOutput(
+ transformer=WanTransformerField(
+ transformer=transformer,
+ transformer_low_noise=transformer_low_noise,
+ boundary_ratio=boundary_ratio,
+ ),
+ wan_t5_encoder=WanT5EncoderField(tokenizer=tokenizer, text_encoder=text_encoder),
+ vae=VAEField(vae=vae),
+ )
+
+ @staticmethod
+ def _validate_component_source_format(context: InvocationContext, model: ModelIdentifierField) -> None:
+ source_config = context.models.get_config(model)
+ if source_config.format != ModelFormat.Diffusers:
+ raise ValueError(
+ f"The Component Source model must be in Diffusers format. "
+ f"The selected model '{source_config.name}' is in {source_config.format.value} format."
+ )
diff --git a/invokeai/app/invocations/wan_ref_image_encoder.py b/invokeai/app/invocations/wan_ref_image_encoder.py
new file mode 100644
index 00000000000..33151157a0e
--- /dev/null
+++ b/invokeai/app/invocations/wan_ref_image_encoder.py
@@ -0,0 +1,161 @@
+"""Reference-image (VAE-latent) encoder for Wan 2.2 I2V-A14B.
+
+Wan 2.2 I2V conditions on a reference image by VAE-encoding it and
+concatenating the resulting latents to the noise latents along the channel
+dim. This invocation produces the 20-channel condition tensor (4-ch first-
+frame mask + 16-ch image latents) the denoise loop will consume.
+
+Supports both single-frame (image I2V, ``num_frames=1``) and multi-frame
+(video I2V, e.g. ``num_frames=81``) condition tensors.
+"""
+
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import WanRefImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.wan.extensions.wan_ref_image_extension import (
+ encode_reference_image_to_condition,
+ encode_reference_image_to_ti2v_condition,
+ encode_reference_image_to_video_condition,
+)
+
+
+@invocation(
+ "wan_ref_image_encoder",
+ title="Reference Image - Wan 2.2",
+ tags=["image", "conditioning", "wan", "i2v"],
+ category="conditioning",
+ version="1.1.0",
+ classification=Classification.Prototype,
+)
+class WanRefImageEncoderInvocation(BaseInvocation):
+ """VAE-encode a reference image into Wan 2.2 I2V conditioning.
+
+ Output is a ``[1, 20, T_lat, height // 8, width // 8]`` condition tensor
+ that the denoise loop concatenates to the 16-channel noise latents each
+ step, producing the 36-channel input the I2V-A14B transformer expects.
+
+ For image (single-frame) I2V leave ``num_frames=1`` (T_lat=1). For video
+ I2V set ``num_frames`` to match the value on the video-denoise node
+ (e.g. 81 for the Wan 2.2 reference defaults).
+
+ Only works with I2V-A14B (the denoise loop's variant gate enforces this).
+ For T2V or TI2V-5B, omit this node entirely.
+ """
+
+ image: ImageField = InputField(description="Reference image to condition on.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection, title="VAE")
+ # Must match wan_denoise's width/height. multiple_of=16 (not 8) because
+ # Wan's transformer patch_size=(1, 2, 2) needs latent H/W to be even.
+ width: int = InputField(
+ default=1024,
+ multiple_of=16,
+ description="Width to resize the reference image to (must match denoise width).",
+ )
+ height: int = InputField(
+ default=1024,
+ multiple_of=16,
+ description="Height to resize the reference image to (must match denoise height).",
+ )
+ num_frames: int = InputField(
+ default=1,
+ ge=1,
+ description="Pixel-frame count to build the condition for. Use 1 for single-frame image "
+ "I2V. For video I2V, set this to match the video-denoise node's num_frames (and ensure "
+ "(num_frames - 1) %% 4 == 0, e.g. 81).",
+ title="Number of Frames",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> WanRefImageOutput:
+ if self.num_frames > 1 and (self.num_frames - 1) % 4 != 0:
+ raise ValueError(
+ f"num_frames must satisfy (num_frames - 1) %% 4 == 0 for the Wan VAE's temporal "
+ f"compression (got {self.num_frames}). Try 5, 9, 13, ..., 81, 85, ..."
+ )
+
+ pil_image = context.images.get_pil(self.image.image_name, "RGB")
+
+ vae_info = context.models.load(self.vae.vae)
+ device = TorchDevice.choose_torch_device()
+ target_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+
+ with vae_info.model_on_device() as (_, vae):
+ if not isinstance(vae, AutoencoderKLWan):
+ raise TypeError(f"Reference-image encoder requires AutoencoderKLWan, got {type(vae).__name__}.")
+ context.util.signal_progress(
+ "VAE-encoding reference image" + (f" ({self.num_frames} frames)" if self.num_frames > 1 else "")
+ )
+ # Free cached allocator blocks left over from earlier nodes (denoise expert
+ # swaps in particular can leave the cache fragmented in ways that look like
+ # free VRAM but fail a single large contiguous request). Mirrors the
+ # pattern used in wan_latents_to_image.py / wan_latents_to_video.py.
+ TorchDevice.empty_cache()
+ # Pick the encoder path by VAE z_dim: 48 means the Wan 2.2-VAE (TI2V-5B),
+ # which uses a single-frame 48-channel condition that the denoise loop
+ # blends with the noisy latents at every step (expand_timesteps path).
+ # 16 means the standard Wan VAE (A14B), which uses the 20-channel
+ # mask + latent condition concatenated to noise along the channel dim.
+ is_ti2v_5b = getattr(vae.config, "z_dim", 16) == 48
+ if is_ti2v_5b:
+ # TI2V-5B I2V needs latent H/W to be even for the transformer
+ # patch_size=(1,2,2), so pixel dims must be multiples of 32
+ # (16x VAE * 2 transformer patch). A14B's 8x VAE only needed
+ # multiples of 16.
+ if self.width % 32 != 0 or self.height % 32 != 0:
+ raise ValueError(
+ f"TI2V-5B I2V requires width and height to be multiples of 32 "
+ f"(got {self.width}x{self.height}). The Wan 2.2-VAE uses 16x "
+ f"spatial compression and the transformer adds a 2x patch on "
+ f"top, so pixel dims must divide by 32 for the patchify step."
+ )
+ condition = encode_reference_image_to_ti2v_condition(
+ image=pil_image,
+ vae=vae,
+ width=self.width,
+ height=self.height,
+ device=device,
+ dtype=target_dtype,
+ )
+ elif self.num_frames <= 1:
+ condition = encode_reference_image_to_condition(
+ image=pil_image,
+ vae=vae,
+ width=self.width,
+ height=self.height,
+ device=device,
+ dtype=target_dtype,
+ )
+ else:
+ condition = encode_reference_image_to_video_condition(
+ image=pil_image,
+ vae=vae,
+ width=self.width,
+ height=self.height,
+ num_frames=self.num_frames,
+ device=device,
+ dtype=target_dtype,
+ )
+
+ condition = condition.detach().to("cpu")
+ # Release this node's VAE-encode intermediates before the next node tries to
+ # partial-load the denoise transformer — the OOM we saw in PR #9163 review
+ # was the I2V expert load racing against still-cached encode activations.
+ TorchDevice.empty_cache()
+ name = context.tensors.save(tensor=condition)
+ return WanRefImageOutput.build(
+ condition_tensor_name=name,
+ width=self.width,
+ height=self.height,
+ num_frames=self.num_frames,
+ )
diff --git a/invokeai/app/invocations/wan_text_encoder.py b/invokeai/app/invocations/wan_text_encoder.py
new file mode 100644
index 00000000000..396819d5434
--- /dev/null
+++ b/invokeai/app/invocations/wan_text_encoder.py
@@ -0,0 +1,112 @@
+import torch
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ UIComponent,
+)
+from invokeai.app.invocations.model import WanT5EncoderField
+from invokeai.app.invocations.primitives import WanConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ ConditioningFieldData,
+ WanConditioningInfo,
+)
+
+# Wan models are trained with 512-token text sequences (matches the
+# upstream config.json's ``text_len: 512`` and the WanPipeline.__call__
+# default). Diffusers' ``_get_t5_prompt_embeds`` has a stale 226 default
+# that gets overridden by ``__call__``; using 512 here matches the actual
+# pipeline behaviour.
+WAN_T5_MAX_SEQ_LEN = 512
+
+
+@invocation(
+ "wan_text_encoder",
+ title="Prompt - Wan 2.2",
+ tags=["prompt", "conditioning", "wan"],
+ category="conditioning",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanTextEncoderInvocation(BaseInvocation):
+ """Encodes a text prompt for Wan 2.2 using the UMT5-XXL encoder.
+
+ Output is the encoder's last hidden state (shape: [seq_len=226, 4096]) plus
+ an attention mask marking valid (non-padding) tokens. The Wan transformer
+ consumes these directly as ``encoder_hidden_states``.
+ """
+
+ prompt: str = InputField(description="Text prompt for Wan 2.2.", ui_component=UIComponent.Textarea)
+ wan_t5_encoder: WanT5EncoderField = InputField(
+ title="UMT5-XXL Encoder",
+ description=FieldDescriptions.wan_t5_encoder,
+ input=Input.Connection,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> WanConditioningOutput:
+ prompt_embeds, attention_mask = self._encode(context)
+
+ # Persist on CPU; the denoise loop will move to device as needed.
+ prompt_embeds = prompt_embeds.detach().to("cpu")
+ attention_mask = attention_mask.detach().to("cpu") if attention_mask is not None else None
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[WanConditioningInfo(prompt_embeds=prompt_embeds, prompt_attention_mask=attention_mask)]
+ )
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return WanConditioningOutput.build(conditioning_name)
+
+ def _encode(self, context: InvocationContext) -> tuple[torch.Tensor, torch.Tensor | None]:
+ from diffusers.pipelines.wan.pipeline_wan import prompt_clean
+ from transformers import UMT5EncoderModel
+
+ cleaned = prompt_clean(self.prompt)
+
+ # Tokenizer + text encoder both routed through the model cache so the
+ # registered loaders handle the nested-vs-flat directory layout for us
+ # (main-model layout: /tokenizer/ + /text_encoder/;
+ # standalone WanT5Encoder layout may also be flat).
+ tokenizer_info = context.models.load(self.wan_t5_encoder.tokenizer)
+ with tokenizer_info.model_on_device() as (_, tokenizer):
+ text_inputs = tokenizer(
+ [cleaned],
+ padding="max_length",
+ max_length=WAN_T5_MAX_SEQ_LEN,
+ truncation=True,
+ add_special_tokens=True,
+ return_attention_mask=True,
+ return_tensors="pt",
+ )
+
+ text_encoder_info = context.models.load(self.wan_t5_encoder.text_encoder)
+ with text_encoder_info.model_on_device() as (_, text_encoder):
+ assert isinstance(text_encoder, UMT5EncoderModel)
+ device = get_effective_device(text_encoder)
+
+ input_ids = text_inputs.input_ids.to(device)
+ attention_mask = text_inputs.attention_mask.to(device)
+
+ context.util.signal_progress("Running UMT5-XXL text encoder")
+ outputs = text_encoder(input_ids, attention_mask)
+ # Drop the batch dim (we always encode one prompt at a time).
+ prompt_embeds = outputs.last_hidden_state.squeeze(0)
+ attention_mask_out = attention_mask.squeeze(0)
+
+ # Match the Diffusers reference: zero out the embeddings past the valid
+ # token count so the transformer sees clean padding.
+ valid_len = int(attention_mask_out.sum().item())
+ if valid_len < prompt_embeds.shape[0]:
+ prompt_embeds = prompt_embeds.clone()
+ prompt_embeds[valid_len:] = 0
+
+ # If every token is valid we don't need the mask downstream.
+ mask_out: torch.Tensor | None = attention_mask_out
+ if attention_mask_out.all():
+ mask_out = None
+
+ return prompt_embeds.to(dtype=torch.bfloat16), mask_out
diff --git a/invokeai/app/invocations/wan_video_denoise.py b/invokeai/app/invocations/wan_video_denoise.py
new file mode 100644
index 00000000000..257433e82cf
--- /dev/null
+++ b/invokeai/app/invocations/wan_video_denoise.py
@@ -0,0 +1,399 @@
+"""Wan 2.2 video denoise invocation (T2V / I2V).
+
+Multi-frame counterpart to :mod:`wan_denoise`. Drives the same flow-matching
+schedule + expert-swap MoE logic, but the noise tensor has a real temporal
+dimension (``T_lat = (num_frames - 1) // 4 + 1``) and the I2V conditioning is
+built across all latent frames (first frame conditioned, rest zero).
+
+Kept as a separate file rather than parameterizing ``WanDenoiseInvocation``
+so the working single-frame T2I path is not risked by the video work; the
+shared bits (expert swapper, scheduler construction, conditioning loading,
+LoRA iteration) live in ``wan_denoise`` and are imported here.
+"""
+
+from contextlib import ExitStack
+from typing import Callable, Iterable, Optional, Tuple
+
+import torch
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ WanConditioningField,
+ WanRefImageConditioningField,
+)
+from invokeai.app.invocations.model import WanTransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.invocations.wan_denoise import (
+ WanDenoiseInvocation,
+ _ExpertSwapper,
+ _resolve_variant,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, WanVariantType
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import WanConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.wan.sampling_utils import (
+ get_default_latent_channels,
+ get_spatial_scale_factor,
+ make_noise,
+ num_latent_frames_for,
+)
+
+
+@invocation(
+ "wan_video_denoise",
+ title="Denoise Video - Wan 2.2",
+ tags=["video", "wan"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class WanVideoDenoiseInvocation(BaseInvocation):
+ """Run the Wan 2.2 denoising loop on a multi-frame latent tensor.
+
+ The output is a 5D ``[1, C, T_lat, H/8, W/8]`` latent tensor ready for
+ :class:`WanLatentsToVideoInvocation` to VAE-decode and encode as MP4.
+
+ Mirrors :class:`WanDenoiseInvocation` for the per-step logic (CFG, MoE
+ expert swap at the boundary timestep, LoRA patching, scheduler selection).
+ Differences from the image denoise:
+
+ * The noise tensor has a real temporal dim built from ``num_frames``.
+ * The I2V condition is built across all latent frames (frame 0
+ conditioned, rest zero) via
+ :func:`encode_reference_image_to_video_condition` upstream — the
+ ``ref_image`` field on this node carries a tensor of shape
+ ``[1, 20, T_lat, H_lat, W_lat]`` instead of ``[1, 20, 1, ...]``.
+ * Inpaint / img2img are not supported — out of scope for the minimal
+ video path. The base ``WanDenoiseInvocation`` still handles those.
+ """
+
+ transformer: WanTransformerField = InputField(
+ description=(
+ "Wan transformer field. Supported: T2V-A14B / I2V-A14B (dual-expert) and "
+ "TI2V-5B (single-expert, handles both T2V and I2V). All three accept a "
+ "Reference Image input for image-to-video; A14B uses the 36-channel concat "
+ "scheme while TI2V-5B uses the expand_timesteps first-frame-mask blend."
+ ),
+ input=Input.Connection,
+ title="Transformer",
+ )
+ positive_conditioning: WanConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: Optional[WanConditioningField] = InputField(
+ default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ ref_image: Optional[WanRefImageConditioningField] = InputField(
+ default=None,
+ description=FieldDescriptions.wan_ref_image,
+ input=Input.Connection,
+ title="Reference Image",
+ )
+
+ guidance_scale: float = InputField(
+ default=5.0,
+ ge=1.0,
+ description="Classifier-free guidance scale. Wan 2.2 video reference uses 5.0 for the "
+ "high-noise expert and 4.0 for the low-noise expert.",
+ title="Guidance Scale",
+ )
+ guidance_scale_low_noise: Optional[float] = InputField(
+ default=4.0,
+ ge=0.0,
+ description="Optional separate CFG scale for the low-noise expert (Wan 2.2 A14B only). "
+ "Values below 1.0 fall back to the primary 'Guidance Scale'.",
+ title="Guidance Scale (Low Noise)",
+ )
+
+ # Wan transformer patch_size=(1, 2, 2) × VAE spatial 8x => H/W multiple of 16.
+ width: int = InputField(default=832, multiple_of=16, description="Width of the generated video.")
+ height: int = InputField(default=480, multiple_of=16, description="Height of the generated video.")
+ num_frames: int = InputField(
+ default=81,
+ ge=5,
+ description="Number of output frames. Must satisfy (num_frames - 1) %% 4 == 0 so the latent "
+ "temporal dim divides cleanly. Wan 2.2 was trained at 81 frames @ 16 FPS (~5 s).",
+ title="Number of Frames",
+ )
+ steps: int = InputField(default=40, gt=0, description="Number of denoising steps.")
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ # Keep the 5D shape (B, C, T, H, W) — wan_latents_to_video expects it.
+ latents = latents.detach().to("cpu")
+ name = context.tensors.save(tensor=latents)
+ # LatentsOutput.build uses latents.size()[3] / [2] for width / height.
+ # For 5D the spatial dims are at indices 4 / 3 instead of 3 / 2, so we
+ # call the constructor directly with the actual H/W from the inputs.
+ from invokeai.app.invocations.fields import LatentsField
+
+ return LatentsOutput(
+ latents=LatentsField(latents_name=name, seed=self.seed),
+ width=self.width,
+ height=self.height,
+ )
+
+ def _run_diffusion(self, context: InvocationContext) -> torch.Tensor:
+ if (self.num_frames - 1) % 4 != 0:
+ raise ValueError(
+ f"num_frames must satisfy (num_frames - 1) %% 4 == 0 for the Wan VAE's temporal "
+ f"compression (got {self.num_frames}). Try 5, 9, 13, ..., 81, 85, ..."
+ )
+
+ device = TorchDevice.choose_torch_device()
+ inference_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+
+ variant = _resolve_variant(context, self.transformer)
+ spatial_scale = get_spatial_scale_factor(variant)
+
+ # Reuse the image denoise's scheduler construction so we pick up whatever
+ # scheduler the variant ships with (FlowMatchEulerDiscreteScheduler,
+ # UniPCMultistepScheduler, etc.).
+ scheduler_builder = WanDenoiseInvocation._build_scheduler # bound on instance below
+ # Bind a minimal instance to call _build_scheduler — it only reads
+ # self.transformer, which is shape-compatible.
+ proxy = WanDenoiseInvocation.model_construct(
+ transformer=self.transformer,
+ positive_conditioning=self.positive_conditioning,
+ )
+ scheduler = scheduler_builder(proxy, context, device)
+
+ pos_cond = self._load_conditioning(context, self.positive_conditioning, device=device, dtype=inference_dtype)
+ do_cfg = self.guidance_scale != 1.0 and self.negative_conditioning is not None
+ neg_cond: WanConditioningInfo | None = None
+ if do_cfg:
+ assert self.negative_conditioning is not None
+ neg_cond = self._load_conditioning(
+ context, self.negative_conditioning, device=device, dtype=inference_dtype
+ )
+
+ # I2V condition tensor. Two flavours:
+ # * A14B I2V — [1, 20, T_lat, H_lat, W_lat] (4 mask + 16 latent channels).
+ # Concatenated to noise latents along the channel dim each step → 36ch.
+ # * TI2V-5B I2V — [1, 48, 1, H_lat, W_lat] (single latent frame, same
+ # channel count as the noise latents). Blended with noise via a
+ # first_frame_mask at every step (expand_timesteps path).
+ # Variant dispatch happens via the condition tensor's channel count below.
+ ref_condition: torch.Tensor | None = None
+ if self.ref_image is not None:
+ if variant not in (WanVariantType.I2V_A14B, WanVariantType.TI2V_5B):
+ raise ValueError(
+ f"Reference-image conditioning is only supported by Wan 2.2 I2V variants "
+ f"(I2V-A14B or TI2V-5B). The selected transformer is {variant.value!r}. "
+ "Remove the Reference Image input or load an I2V variant."
+ )
+ if self.ref_image.width != self.width or self.ref_image.height != self.height:
+ raise ValueError(
+ f"Reference-image dimensions ({self.ref_image.width}x{self.ref_image.height}) must "
+ f"match denoise dimensions ({self.width}x{self.height})."
+ )
+ # A14B encodes one condition tensor per pixel-frame count, so the
+ # encoder's num_frames must match. TI2V-5B's condition is always
+ # single-frame regardless of the output length, so the field's
+ # num_frames is informational only and we skip this check.
+ if variant == WanVariantType.I2V_A14B and self.ref_image.num_frames != self.num_frames:
+ raise ValueError(
+ f"Reference-image num_frames ({self.ref_image.num_frames}) must match denoise "
+ f"num_frames ({self.num_frames}). Re-run the Reference Image - Wan 2.2 node with "
+ f"num_frames={self.num_frames}."
+ )
+ if variant == WanVariantType.TI2V_5B and (self.width % 32 or self.height % 32):
+ raise ValueError(
+ f"TI2V-5B I2V requires width and height to be multiples of 32 "
+ f"(got {self.width}x{self.height}). Wan 2.2-VAE 16x spatial * "
+ f"transformer patch_size 2 = pixel dims must divide by 32."
+ )
+ ref_condition = context.tensors.load(self.ref_image.condition_tensor_name).to(
+ device=device, dtype=inference_dtype
+ )
+
+ scheduler.set_timesteps(num_inference_steps=self.steps, device=device)
+ timesteps = scheduler.timesteps
+ total_steps = len(timesteps)
+
+ # fp32 latents through the loop; cast to inference_dtype only when
+ # calling the transformer (same as wan_denoise).
+ latent_dtype = torch.float32
+ # 48 for TI2V-5B (Wan 2.2-VAE z_dim=48), 16 for A14B variants.
+ latent_channels = get_default_latent_channels(variant)
+ t_lat = num_latent_frames_for(self.num_frames)
+
+ latents = make_noise(
+ batch_size=1,
+ latent_channels=latent_channels,
+ height=self.height,
+ width=self.width,
+ spatial_scale_factor=spatial_scale,
+ device=device,
+ dtype=latent_dtype,
+ seed=self.seed,
+ num_latent_frames=t_lat,
+ )
+
+ if total_steps <= 0:
+ return latents
+
+ # Sanity-check ref-condition shape per variant. A14B expects matched T_lat;
+ # TI2V-5B expects a single latent frame regardless of output length.
+ if ref_condition is not None:
+ if variant == WanVariantType.TI2V_5B:
+ if ref_condition.shape[1] != 48 or ref_condition.shape[2] != 1:
+ raise ValueError(
+ f"TI2V-5B reference condition must be shape [1, 48, 1, H_lat, W_lat] "
+ f"(got channels={ref_condition.shape[1]}, frames={ref_condition.shape[2]}). "
+ "Re-run the Reference Image - Wan 2.2 node with a TI2V-5B VAE."
+ )
+ elif ref_condition.shape[2] != t_lat:
+ raise ValueError(
+ f"Reference-image condition has {ref_condition.shape[2]} latent frames but the "
+ f"denoise loop expected {t_lat}. Ensure the ref-image encoder was called with "
+ f"the same num_frames ({self.num_frames})."
+ )
+
+ # Build the TI2V-5B first-frame mask once: 0 at frame 0 (locked to the
+ # condition), 1 elsewhere (free to denoise). Broadcasts across channel dim.
+ first_frame_mask: torch.Tensor | None = None
+ if ref_condition is not None and variant == WanVariantType.TI2V_5B:
+ _, _, _, h_lat, w_lat = latents.shape
+ first_frame_mask = torch.ones(1, 1, t_lat, h_lat, w_lat, device=device, dtype=inference_dtype)
+ first_frame_mask[:, :, 0] = 0
+
+ step_callback = self._build_step_callback(context)
+
+ high_model = self.transformer.transformer
+ low_model = self.transformer.transformer_low_noise
+ low_config = context.models.get_config(low_model) if low_model is not None else None
+ num_train_timesteps = int(scheduler.config.num_train_timesteps)
+ boundary_timestep = self.transformer.boundary_ratio * num_train_timesteps if low_model is not None else None
+
+ high_loras = self.transformer.loras
+ low_loras = self.transformer.loras_low_noise or self.transformer.loras
+ high_config = context.models.get_config(high_model)
+ high_is_quantized = high_config.format == ModelFormat.GGUFQuantized
+ low_is_quantized = low_config.format == ModelFormat.GGUFQuantized if low_config is not None else False
+
+ def high_lora_factory() -> Iterable[Tuple[ModelPatchRaw, float]]:
+ return proxy._lora_iterator(context, high_loras)
+
+ def low_lora_factory() -> Iterable[Tuple[ModelPatchRaw, float]]:
+ return proxy._lora_iterator(context, low_loras)
+
+ with ExitStack() as exit_stack:
+ swapper = _ExpertSwapper(
+ context=context,
+ high_model=high_model,
+ low_model=low_model,
+ inference_dtype=inference_dtype,
+ high_lora_factory=high_lora_factory if high_loras else None,
+ low_lora_factory=low_lora_factory if low_loras else None,
+ high_is_quantized=high_is_quantized,
+ low_is_quantized=low_is_quantized,
+ )
+ exit_stack.callback(swapper.close)
+
+ for step_idx, t in enumerate(
+ tqdm(timesteps, desc=f"Denoising Wan 2.2 video ({self.num_frames} frames)", total=total_steps)
+ ):
+ if low_model is not None and float(t) < float(boundary_timestep):
+ active_label = _ExpertSwapper.LOW
+ low_cfg = self.guidance_scale_low_noise
+ active_cfg = low_cfg if (low_cfg is not None and low_cfg >= 1.0) else self.guidance_scale
+ else:
+ active_label = _ExpertSwapper.HIGH
+ active_cfg = self.guidance_scale
+
+ transformer = swapper.get(active_label)
+
+ latent_model_input = latents.to(dtype=inference_dtype)
+
+ # Per-variant conditioning. Two distinct mechanisms:
+ if first_frame_mask is not None:
+ # TI2V-5B I2V (expand_timesteps): blend the condition into frame 0
+ # and the noisy latents elsewhere. Per-token timestep tensor
+ # gates the model so it sees timestep=0 at frame-0 positions
+ # (nothing to denoise) and the normal `t` everywhere else.
+ assert ref_condition is not None
+ latent_model_input = (1 - first_frame_mask) * ref_condition + first_frame_mask * latent_model_input
+ # Strided slice matches the transformer's spatial patch_size=2;
+ # flatten gives per-token timesteps. Shape: [1, T_lat * H_lat//2 * W_lat//2].
+ temp_ts = (first_frame_mask[0, 0, :, ::2, ::2] * t).flatten()
+ timestep = temp_ts.unsqueeze(0).expand(latents.shape[0], -1).to(dtype=inference_dtype)
+ elif ref_condition is not None:
+ # A14B I2V: concat 20-ch condition along channel dim → 36-ch input.
+ latent_model_input = torch.cat([latent_model_input, ref_condition], dim=1)
+ timestep = t.expand(latents.shape[0])
+ else:
+ # T2V (any variant): scalar timestep per batch.
+ timestep = t.expand(latents.shape[0])
+
+ noise_pred_cond = transformer(
+ hidden_states=latent_model_input,
+ timestep=timestep,
+ encoder_hidden_states=pos_cond.prompt_embeds.unsqueeze(0),
+ attention_kwargs=None,
+ return_dict=False,
+ )[0]
+
+ if do_cfg and neg_cond is not None:
+ noise_pred_uncond = transformer(
+ hidden_states=latent_model_input,
+ timestep=timestep,
+ encoder_hidden_states=neg_cond.prompt_embeds.unsqueeze(0),
+ attention_kwargs=None,
+ return_dict=False,
+ )[0]
+ noise_pred = noise_pred_uncond + active_cfg * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ latents = scheduler.step(noise_pred, t, latents, return_dict=False)[0]
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(t.item()),
+ # Preview shows the middle frame for video.
+ latents=latents[:, :, t_lat // 2],
+ )
+ )
+
+ # TI2V-5B: frame 0's latents drifted through the scheduler step at each
+ # iteration; restore them to the clean condition before VAE-decoding so
+ # the first output frame matches the reference image. Mirrors
+ # ``WanImageToVideoPipeline`` (pipeline_wan_i2v.py:813-814).
+ if first_frame_mask is not None:
+ assert ref_condition is not None
+ latents = (1 - first_frame_mask) * ref_condition.to(dtype=latents.dtype) + first_frame_mask * latents
+
+ return latents
+
+ def _load_conditioning(
+ self,
+ context: InvocationContext,
+ cond_field: WanConditioningField,
+ *,
+ device: torch.device,
+ dtype: torch.dtype,
+ ) -> WanConditioningInfo:
+ cond_data = context.conditioning.load(cond_field.conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ cond_info = cond_data.conditionings[0]
+ assert isinstance(cond_info, WanConditioningInfo)
+ return cond_info.to(device=device, dtype=dtype)
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.Wan)
+
+ return step_callback
diff --git a/invokeai/app/services/board_canvas_project_records/__init__.py b/invokeai/app/services/board_canvas_project_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_base.py b/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_base.py
new file mode 100644
index 00000000000..d05f2b56e76
--- /dev/null
+++ b/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_base.py
@@ -0,0 +1,35 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+
+class BoardCanvasProjectRecordStorageBase(ABC):
+ """Abstract base class for the one-to-many board↔canvas-project relationship record storage."""
+
+ @abstractmethod
+ def add_project_to_board(self, board_id: str, project_name: str) -> None:
+ """Adds a canvas project to a board."""
+ pass
+
+ @abstractmethod
+ def remove_project_from_board(self, project_name: str) -> None:
+ """Removes a canvas project from a board."""
+ pass
+
+ @abstractmethod
+ def get_all_board_project_names_for_board(
+ self,
+ board_id: str,
+ is_intermediate: Optional[bool] = None,
+ ) -> list[str]:
+ """Gets all canvas projects for a board, as a list of the project names."""
+ pass
+
+ @abstractmethod
+ def get_board_for_project(self, project_name: str) -> Optional[str]:
+ """Gets a canvas project's board id, if it has one."""
+ pass
+
+ @abstractmethod
+ def get_project_count_for_board(self, board_id: str) -> int:
+ """Gets the number of canvas projects for a board."""
+ pass
diff --git a/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_sqlite.py b/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_sqlite.py
new file mode 100644
index 00000000000..da8100c11c7
--- /dev/null
+++ b/invokeai/app/services/board_canvas_project_records/board_canvas_project_records_sqlite.py
@@ -0,0 +1,87 @@
+import sqlite3
+from typing import Optional, cast
+
+from invokeai.app.services.board_canvas_project_records.board_canvas_project_records_base import (
+ BoardCanvasProjectRecordStorageBase,
+)
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class SqliteBoardCanvasProjectRecordStorage(BoardCanvasProjectRecordStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def add_project_to_board(self, board_id: str, project_name: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO board_canvas_projects (board_id, project_name)
+ VALUES (?, ?)
+ ON CONFLICT (project_name) DO UPDATE SET board_id = ?;
+ """,
+ (board_id, project_name, board_id),
+ )
+
+ def remove_project_from_board(self, project_name: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ "DELETE FROM board_canvas_projects WHERE project_name = ?;",
+ (project_name,),
+ )
+
+ def get_all_board_project_names_for_board(
+ self,
+ board_id: str,
+ is_intermediate: Optional[bool] = None,
+ ) -> list[str]:
+ with self._db.transaction() as cursor:
+ params: list[str | bool] = []
+ stmt = """
+ SELECT canvas_projects.project_name
+ FROM canvas_projects
+ LEFT JOIN board_canvas_projects
+ ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE 1=1
+ """
+ if board_id == "none":
+ stmt += " AND board_canvas_projects.board_id IS NULL "
+ else:
+ stmt += " AND board_canvas_projects.board_id = ? "
+ params.append(board_id)
+
+ if is_intermediate is not None:
+ stmt += " AND canvas_projects.is_intermediate = ? "
+ params.append(is_intermediate)
+
+ stmt += ";"
+ cursor.execute(stmt, params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ return [r[0] for r in result]
+
+ def get_board_for_project(self, project_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ "SELECT board_id FROM board_canvas_projects WHERE project_name = ?;",
+ (project_name,),
+ )
+ result = cursor.fetchone()
+ if result is None:
+ return None
+ return cast(str, result[0])
+
+ def get_project_count_for_board(self, board_id: str) -> int:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT COUNT(*)
+ FROM board_canvas_projects
+ INNER JOIN canvas_projects
+ ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE canvas_projects.is_intermediate = FALSE
+ AND board_canvas_projects.board_id = ?;
+ """,
+ (board_id,),
+ )
+ count = cast(int, cursor.fetchone()[0])
+ return count
diff --git a/invokeai/app/services/board_video_records/__init__.py b/invokeai/app/services/board_video_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/board_video_records/board_video_records_base.py b/invokeai/app/services/board_video_records/board_video_records_base.py
new file mode 100644
index 00000000000..6d6e53d7115
--- /dev/null
+++ b/invokeai/app/services/board_video_records/board_video_records_base.py
@@ -0,0 +1,38 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+
+
+class BoardVideoRecordStorageBase(ABC):
+ """Abstract base class for the one-to-many board-video relationship record storage."""
+
+ @abstractmethod
+ def add_video_to_board(self, board_id: str, video_name: str) -> None:
+ """Adds a video to a board."""
+ pass
+
+ @abstractmethod
+ def remove_video_from_board(self, video_name: str) -> None:
+ """Removes a video from a board."""
+ pass
+
+ @abstractmethod
+ def get_all_board_video_names_for_board(
+ self,
+ board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
+ ) -> list[str]:
+ """Gets all board videos for a board, as a list of the video names."""
+ pass
+
+ @abstractmethod
+ def get_board_for_video(self, video_name: str) -> Optional[str]:
+ """Gets a video's board id, if it has one."""
+ pass
+
+ @abstractmethod
+ def get_video_count_for_board(self, board_id: str) -> int:
+ """Gets the number of videos for a board."""
+ pass
diff --git a/invokeai/app/services/board_video_records/board_video_records_sqlite.py b/invokeai/app/services/board_video_records/board_video_records_sqlite.py
new file mode 100644
index 00000000000..ce2fb71a055
--- /dev/null
+++ b/invokeai/app/services/board_video_records/board_video_records_sqlite.py
@@ -0,0 +1,92 @@
+import sqlite3
+from typing import Optional, cast
+
+from invokeai.app.services.board_video_records.board_video_records_base import BoardVideoRecordStorageBase
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class SqliteBoardVideoRecordStorage(BoardVideoRecordStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def add_video_to_board(self, board_id: str, video_name: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT INTO board_videos (board_id, video_name)
+ VALUES (?, ?)
+ ON CONFLICT (video_name) DO UPDATE SET board_id = ?;
+ """,
+ (board_id, video_name, board_id),
+ )
+
+ def remove_video_from_board(self, video_name: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ "DELETE FROM board_videos WHERE video_name = ?;",
+ (video_name,),
+ )
+
+ def get_all_board_video_names_for_board(
+ self,
+ board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
+ ) -> list[str]:
+ with self._db.transaction() as cursor:
+ params: list[str | bool] = []
+ stmt = """
+ SELECT videos.video_name
+ FROM videos
+ LEFT JOIN board_videos ON board_videos.video_name = videos.video_name
+ WHERE 1=1
+ """
+ if board_id == "none":
+ stmt += " AND board_videos.board_id IS NULL "
+ else:
+ stmt += " AND board_videos.board_id = ? "
+ params.append(board_id)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ stmt += f" AND videos.video_category IN ( {placeholders} ) "
+ for c in category_strings:
+ params.append(c)
+
+ if is_intermediate is not None:
+ stmt += " AND videos.is_intermediate = ? "
+ params.append(is_intermediate)
+
+ stmt += ";"
+ cursor.execute(stmt, params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ return [r[0] for r in result]
+
+ def get_board_for_video(self, video_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ "SELECT board_id FROM board_videos WHERE video_name = ?;",
+ (video_name,),
+ )
+ result = cursor.fetchone()
+ if result is None:
+ return None
+ return cast(str, result[0])
+
+ def get_video_count_for_board(self, board_id: str) -> int:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT COUNT(*)
+ FROM board_videos
+ INNER JOIN videos ON board_videos.video_name = videos.video_name
+ WHERE videos.is_intermediate = FALSE
+ AND board_videos.board_id = ?;
+ """,
+ (board_id,),
+ )
+ count = cast(int, cursor.fetchone()[0])
+ return count
diff --git a/invokeai/app/services/canvas_project_files/__init__.py b/invokeai/app/services/canvas_project_files/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/canvas_project_files/canvas_project_files_base.py b/invokeai/app/services/canvas_project_files/canvas_project_files_base.py
new file mode 100644
index 00000000000..f1e04a5b254
--- /dev/null
+++ b/invokeai/app/services/canvas_project_files/canvas_project_files_base.py
@@ -0,0 +1,33 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional
+
+
+class CanvasProjectFileStorageBase(ABC):
+ """Low-level service responsible for storing and retrieving canvas project (.invk) files."""
+
+ @abstractmethod
+ def get_path(self, project_name: str, thumbnail: bool = False, project_subfolder: str = "") -> Path:
+ """Gets the internal path to a canvas project ZIP or its thumbnail WebP."""
+ pass
+
+ @abstractmethod
+ def save(
+ self,
+ zip_bytes: bytes,
+ project_name: str,
+ thumbnail_bytes: Optional[bytes] = None,
+ project_subfolder: str = "",
+ ) -> None:
+ """Saves a canvas project ZIP and optional WebP thumbnail to disk."""
+ pass
+
+ @abstractmethod
+ def delete(self, project_name: str, project_subfolder: str = "") -> None:
+ """Deletes a canvas project file and its thumbnail (if one exists)."""
+ pass
+
+ @abstractmethod
+ def validate_path(self, path: str) -> bool:
+ """Validates the path given for a project or thumbnail."""
+ pass
diff --git a/invokeai/app/services/canvas_project_files/canvas_project_files_common.py b/invokeai/app/services/canvas_project_files/canvas_project_files_common.py
new file mode 100644
index 00000000000..215596a23da
--- /dev/null
+++ b/invokeai/app/services/canvas_project_files/canvas_project_files_common.py
@@ -0,0 +1,19 @@
+class CanvasProjectFileNotFoundException(Exception):
+ """Raised when a canvas project file is not found in storage."""
+
+ def __init__(self, message="Canvas project file not found"):
+ super().__init__(message)
+
+
+class CanvasProjectFileSaveException(Exception):
+ """Raised when a canvas project file cannot be saved."""
+
+ def __init__(self, message="Canvas project file not saved"):
+ super().__init__(message)
+
+
+class CanvasProjectFileDeleteException(Exception):
+ """Raised when a canvas project file cannot be deleted."""
+
+ def __init__(self, message="Canvas project file not deleted"):
+ super().__init__(message)
diff --git a/invokeai/app/services/canvas_project_files/canvas_project_files_disk.py b/invokeai/app/services/canvas_project_files/canvas_project_files_disk.py
new file mode 100644
index 00000000000..1dc9449bd1b
--- /dev/null
+++ b/invokeai/app/services/canvas_project_files/canvas_project_files_disk.py
@@ -0,0 +1,115 @@
+from pathlib import Path
+from typing import Optional, Union
+
+from invokeai.app.services.canvas_project_files.canvas_project_files_base import CanvasProjectFileStorageBase
+from invokeai.app.services.canvas_project_files.canvas_project_files_common import (
+ CanvasProjectFileDeleteException,
+ CanvasProjectFileSaveException,
+)
+from invokeai.app.services.invoker import Invoker
+from invokeai.backend.util.logging import InvokeAILogger
+
+
+def get_canvas_project_thumbnail_name(project_name: str) -> str:
+ """Returns the thumbnail file name for a given project name (project name + .webp)."""
+ return f"{project_name}.webp"
+
+
+def get_canvas_project_file_name(project_name: str) -> str:
+ """Returns the on-disk file name for a given project name (project name + .invk)."""
+ return f"{project_name}.invk"
+
+
+class DiskCanvasProjectFileStorage(CanvasProjectFileStorageBase):
+ """Stores canvas project ZIP (.invk) files on disk under {outputs}/canvas_projects/, with optional
+ WebP thumbnails under {outputs}/canvas_projects/thumbnails/."""
+
+ def __init__(self, output_folder: Union[str, Path]):
+ self.__output_folder = output_folder if isinstance(output_folder, Path) else Path(output_folder)
+ self.__thumbnails_folder = self.__output_folder / "thumbnails"
+ self.__validate_storage_folders()
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def save(
+ self,
+ zip_bytes: bytes,
+ project_name: str,
+ thumbnail_bytes: Optional[bytes] = None,
+ project_subfolder: str = "",
+ ) -> None:
+ logger = InvokeAILogger.get_logger()
+ try:
+ self.__validate_storage_folders()
+ project_path = self.get_path(project_name, project_subfolder=project_subfolder)
+ project_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(project_path, "wb") as f:
+ f.write(zip_bytes)
+ logger.info(f"Canvas project file written: {project_path}")
+
+ if thumbnail_bytes is not None:
+ thumbnail_path = self.get_path(project_name, thumbnail=True, project_subfolder=project_subfolder)
+ thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(thumbnail_path, "wb") as f:
+ f.write(thumbnail_bytes)
+ logger.info(f"Canvas project thumbnail written: {thumbnail_path}")
+ except Exception as e:
+ raise CanvasProjectFileSaveException from e
+
+ def delete(self, project_name: str, project_subfolder: str = "") -> None:
+ try:
+ project_path = self.get_path(project_name, project_subfolder=project_subfolder)
+ if project_path.exists():
+ project_path.unlink()
+
+ thumbnail_path = self.get_path(project_name, thumbnail=True, project_subfolder=project_subfolder)
+ if thumbnail_path.exists():
+ thumbnail_path.unlink()
+ except Exception as e:
+ raise CanvasProjectFileDeleteException from e
+
+ def get_path(self, project_name: str, thumbnail: bool = False, project_subfolder: str = "") -> Path:
+ base_folder = self.__thumbnails_folder if thumbnail else self.__output_folder
+ filename = (
+ get_canvas_project_thumbnail_name(project_name) if thumbnail else get_canvas_project_file_name(project_name)
+ )
+
+ basename = Path(filename).name
+ if basename != filename:
+ raise ValueError("Invalid project name, potential directory traversal detected")
+
+ if project_subfolder:
+ self._validate_subfolder(project_subfolder)
+ project_path = base_folder / project_subfolder / basename
+ else:
+ project_path = base_folder / basename
+
+ resolved_base = base_folder.resolve()
+ resolved_project_path = project_path.resolve()
+ if not resolved_project_path.is_relative_to(resolved_base):
+ raise ValueError("Project path outside outputs folder, potential directory traversal detected")
+ return resolved_project_path
+
+ def validate_path(self, path: Union[str, Path]) -> bool:
+ path = path if isinstance(path, Path) else Path(path)
+ return path.exists()
+
+ @staticmethod
+ def _validate_subfolder(subfolder: str) -> None:
+ """Validates a subfolder path to prevent directory traversal."""
+ if not subfolder:
+ return
+ if "\\" in subfolder:
+ raise ValueError("Backslashes not allowed in subfolder path")
+ if subfolder.startswith("/"):
+ raise ValueError("Absolute paths not allowed in subfolder path")
+ for part in subfolder.split("/"):
+ if part == "..":
+ raise ValueError("Parent directory references not allowed in subfolder path")
+ if part == "":
+ raise ValueError("Empty path segments not allowed in subfolder path")
+
+ def __validate_storage_folders(self) -> None:
+ for folder in (self.__output_folder, self.__thumbnails_folder):
+ folder.mkdir(parents=True, exist_ok=True)
diff --git a/invokeai/app/services/canvas_project_records/__init__.py b/invokeai/app/services/canvas_project_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/canvas_project_records/canvas_project_records_base.py b/invokeai/app/services/canvas_project_records/canvas_project_records_base.py
new file mode 100644
index 00000000000..0806c71a029
--- /dev/null
+++ b/invokeai/app/services/canvas_project_records/canvas_project_records_base.py
@@ -0,0 +1,110 @@
+from abc import ABC, abstractmethod
+from datetime import datetime
+from typing import Optional
+
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CanvasProjectNamesResult,
+ CanvasProjectRecord,
+ CanvasProjectRecordChanges,
+)
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+
+class CanvasProjectRecordStorageBase(ABC):
+ """Low-level service responsible for interfacing with the canvas project record store."""
+
+ @abstractmethod
+ def get(self, project_name: str) -> CanvasProjectRecord:
+ """Gets a canvas project record."""
+ pass
+
+ @abstractmethod
+ def update(self, project_name: str, changes: CanvasProjectRecordChanges) -> None:
+ """Updates a canvas project record."""
+ pass
+
+ @abstractmethod
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[CanvasProjectRecord]:
+ """Gets a page of canvas project records."""
+ pass
+
+ @abstractmethod
+ def delete(self, project_name: str) -> None:
+ """Deletes a canvas project record."""
+ pass
+
+ @abstractmethod
+ def delete_many(self, project_names: list[str]) -> None:
+ """Deletes many canvas project records."""
+ pass
+
+ @abstractmethod
+ def save(
+ self,
+ project_name: str,
+ project_origin: ResourceOrigin,
+ name: str,
+ app_version: str,
+ width: int,
+ height: int,
+ image_count: int,
+ has_thumbnail: bool,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ user_id: Optional[str] = None,
+ project_subfolder: str = "",
+ ) -> datetime:
+ """Saves a canvas project record."""
+ pass
+
+ @abstractmethod
+ def set_has_thumbnail(self, project_name: str, has_thumbnail: bool) -> None:
+ """Updates the has_thumbnail flag for a project."""
+ pass
+
+ @abstractmethod
+ def update_file_metadata(
+ self,
+ project_name: str,
+ width: int,
+ height: int,
+ image_count: int,
+ has_thumbnail: bool,
+ app_version: str,
+ ) -> None:
+ """Updates the fields that change when a project's ZIP is replaced in place."""
+ pass
+
+ @abstractmethod
+ def get_user_id(self, project_name: str) -> Optional[str]:
+ """Gets the user_id of the project owner. Returns None if project not found."""
+ pass
+
+ @abstractmethod
+ def get_project_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> CanvasProjectNamesResult:
+ """Gets ordered list of project names with metadata for optimistic updates."""
+ pass
diff --git a/invokeai/app/services/canvas_project_records/canvas_project_records_common.py b/invokeai/app/services/canvas_project_records/canvas_project_records_common.py
new file mode 100644
index 00000000000..66b416e0ddd
--- /dev/null
+++ b/invokeai/app/services/canvas_project_records/canvas_project_records_common.py
@@ -0,0 +1,114 @@
+import datetime
+from typing import Optional, Union
+
+from pydantic import BaseModel, Field, StrictBool, StrictStr
+
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.util.misc import get_iso_timestamp
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class CanvasProjectRecordNotFoundException(Exception):
+ """Raised when a canvas project record is not found."""
+
+ def __init__(self, message="Canvas project record not found"):
+ super().__init__(message)
+
+
+class CanvasProjectRecordSaveException(Exception):
+ """Raised when a canvas project record cannot be saved."""
+
+ def __init__(self, message="Canvas project record not saved"):
+ super().__init__(message)
+
+
+class CanvasProjectRecordDeleteException(Exception):
+ """Raised when a canvas project record cannot be deleted."""
+
+ def __init__(self, message="Canvas project record not deleted"):
+ super().__init__(message)
+
+
+CANVAS_PROJECT_DTO_COLS = ", ".join(
+ [
+ "canvas_projects." + c
+ for c in [
+ "project_name",
+ "project_origin",
+ "name",
+ "app_version",
+ "width",
+ "height",
+ "image_count",
+ "has_thumbnail",
+ "starred",
+ "is_intermediate",
+ "user_id",
+ "project_subfolder",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ ]
+ ]
+)
+
+
+class CanvasProjectRecord(BaseModelExcludeNull):
+ """Deserialized canvas project record."""
+
+ project_name: str = Field(description="The unique name (ID) of the canvas project.")
+ project_origin: ResourceOrigin = Field(description="The origin of the canvas project.")
+ name: str = Field(description="The user-facing display name of the project.")
+ app_version: str = Field(description="The InvokeAI app version this project was saved under.")
+ width: int = Field(description="The bbox width of the canvas at save time.")
+ height: int = Field(description="The bbox height of the canvas at save time.")
+ image_count: int = Field(description="The number of images embedded in the project ZIP.")
+ has_thumbnail: bool = Field(description="Whether the project has a preview thumbnail on disk.")
+ starred: bool = Field(description="Whether this project is starred.")
+ is_intermediate: bool = Field(description="Whether this is an intermediate project (almost always False).")
+ user_id: str = Field(description="The id of the user that owns this project.")
+ project_subfolder: str = Field(default="", description="The subfolder where the project is stored on disk.")
+ created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the project.")
+ updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the project.")
+ deleted_at: Optional[Union[datetime.datetime, str]] = Field(
+ default=None, description="The deleted timestamp of the project."
+ )
+
+
+class CanvasProjectRecordChanges(BaseModelExcludeNull, extra="allow"):
+ """Allowed mutations on a canvas project record."""
+
+ name: Optional[StrictStr] = Field(default=None, description="The project's new display name.")
+ starred: Optional[StrictBool] = Field(default=None, description="The project's new starred state.")
+ is_intermediate: Optional[StrictBool] = Field(
+ default=None, description="The project's new is_intermediate flag."
+ )
+
+
+def deserialize_canvas_project_record(project_dict: dict) -> CanvasProjectRecord:
+ """Deserializes a canvas project record from a sqlite row dict."""
+ return CanvasProjectRecord(
+ project_name=project_dict.get("project_name", "unknown"),
+ project_origin=ResourceOrigin(project_dict.get("project_origin", ResourceOrigin.INTERNAL.value)),
+ name=project_dict.get("name", ""),
+ app_version=project_dict.get("app_version", "unknown"),
+ width=project_dict.get("width", 0),
+ height=project_dict.get("height", 0),
+ image_count=project_dict.get("image_count", 0),
+ has_thumbnail=bool(project_dict.get("has_thumbnail", False)),
+ starred=bool(project_dict.get("starred", False)),
+ is_intermediate=bool(project_dict.get("is_intermediate", False)),
+ user_id=project_dict.get("user_id", "system"),
+ project_subfolder=project_dict.get("project_subfolder", ""),
+ created_at=project_dict.get("created_at", get_iso_timestamp()),
+ updated_at=project_dict.get("updated_at", get_iso_timestamp()),
+ deleted_at=project_dict.get("deleted_at", None),
+ )
+
+
+class CanvasProjectNamesResult(BaseModel):
+ """Response containing ordered canvas project names with metadata for optimistic updates."""
+
+ project_names: list[str] = Field(description="Ordered list of canvas project names.")
+ starred_count: int = Field(description="Number of starred projects (when starred_first=True).")
+ total_count: int = Field(description="Total number of projects matching the query.")
diff --git a/invokeai/app/services/canvas_project_records/canvas_project_records_sqlite.py b/invokeai/app/services/canvas_project_records/canvas_project_records_sqlite.py
new file mode 100644
index 00000000000..5a9bd1b93f4
--- /dev/null
+++ b/invokeai/app/services/canvas_project_records/canvas_project_records_sqlite.py
@@ -0,0 +1,339 @@
+import sqlite3
+from datetime import datetime
+from typing import Optional, Union, cast
+
+from invokeai.app.services.canvas_project_records.canvas_project_records_base import CanvasProjectRecordStorageBase
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CANVAS_PROJECT_DTO_COLS,
+ CanvasProjectNamesResult,
+ CanvasProjectRecord,
+ CanvasProjectRecordChanges,
+ CanvasProjectRecordDeleteException,
+ CanvasProjectRecordNotFoundException,
+ CanvasProjectRecordSaveException,
+ deserialize_canvas_project_record,
+)
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class SqliteCanvasProjectRecordStorage(CanvasProjectRecordStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def get(self, project_name: str) -> CanvasProjectRecord:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ f"""--sql
+ SELECT {CANVAS_PROJECT_DTO_COLS} FROM canvas_projects
+ WHERE project_name = ?;
+ """,
+ (project_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordNotFoundException from e
+
+ if not result:
+ raise CanvasProjectRecordNotFoundException
+ return deserialize_canvas_project_record(dict(result))
+
+ def get_user_id(self, project_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT user_id FROM canvas_projects
+ WHERE project_name = ?;
+ """,
+ (project_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ if not result:
+ return None
+ return cast(Optional[str], dict(result).get("user_id"))
+
+ def update(self, project_name: str, changes: CanvasProjectRecordChanges) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ if changes.name is not None:
+ cursor.execute(
+ "UPDATE canvas_projects SET name = ? WHERE project_name = ?;",
+ (changes.name, project_name),
+ )
+ if changes.is_intermediate is not None:
+ cursor.execute(
+ "UPDATE canvas_projects SET is_intermediate = ? WHERE project_name = ?;",
+ (changes.is_intermediate, project_name),
+ )
+ if changes.starred is not None:
+ cursor.execute(
+ "UPDATE canvas_projects SET starred = ? WHERE project_name = ?;",
+ (changes.starred, project_name),
+ )
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordSaveException from e
+
+ def set_has_thumbnail(self, project_name: str, has_thumbnail: bool) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ "UPDATE canvas_projects SET has_thumbnail = ? WHERE project_name = ?;",
+ (has_thumbnail, project_name),
+ )
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordSaveException from e
+
+ def update_file_metadata(
+ self,
+ project_name: str,
+ width: int,
+ height: int,
+ image_count: int,
+ has_thumbnail: bool,
+ app_version: str,
+ ) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ UPDATE canvas_projects
+ SET width = ?, height = ?, image_count = ?, has_thumbnail = ?, app_version = ?
+ WHERE project_name = ?;
+ """,
+ (width, height, image_count, has_thumbnail, app_version, project_name),
+ )
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordSaveException from e
+
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[CanvasProjectRecord]:
+ with self._db.transaction() as cursor:
+ count_query = """--sql
+ SELECT COUNT(*)
+ FROM canvas_projects
+ LEFT JOIN board_canvas_projects ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE 1=1
+ """
+ projects_query = f"""--sql
+ SELECT {CANVAS_PROJECT_DTO_COLS}
+ FROM canvas_projects
+ LEFT JOIN board_canvas_projects ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE 1=1
+ """
+
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if project_origin is not None:
+ query_conditions += " AND canvas_projects.project_origin = ? "
+ query_params.append(project_origin.value)
+
+ if is_intermediate is not None:
+ query_conditions += " AND canvas_projects.is_intermediate = ? "
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += " AND board_canvas_projects.board_id IS NULL "
+ if user_id is not None and not is_admin:
+ query_conditions += " AND canvas_projects.user_id = ? "
+ query_params.append(user_id)
+ elif board_id is not None:
+ query_conditions += " AND board_canvas_projects.board_id = ? "
+ query_params.append(board_id)
+ elif user_id is not None and not is_admin:
+ query_conditions += " AND canvas_projects.user_id = ? "
+ query_params.append(user_id)
+
+ if search_term:
+ query_conditions += " AND (canvas_projects.name LIKE ? OR canvas_projects.created_at LIKE ?) "
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ if starred_first:
+ query_pagination = (
+ f" ORDER BY canvas_projects.starred DESC, canvas_projects.created_at {order_dir.value} "
+ "LIMIT ? OFFSET ? "
+ )
+ else:
+ query_pagination = f" ORDER BY canvas_projects.created_at {order_dir.value} LIMIT ? OFFSET ? "
+
+ projects_query += query_conditions + query_pagination + ";"
+ projects_params = query_params.copy()
+ projects_params.extend([limit, offset])
+ cursor.execute(projects_query, projects_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ projects = [deserialize_canvas_project_record(dict(r)) for r in result]
+
+ count_query += query_conditions + ";"
+ cursor.execute(count_query, query_params.copy())
+ count = cast(int, cursor.fetchone()[0])
+
+ return OffsetPaginatedResults(items=projects, offset=offset, limit=limit, total=count)
+
+ def delete(self, project_name: str) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute("DELETE FROM canvas_projects WHERE project_name = ?;", (project_name,))
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordDeleteException from e
+
+ def delete_many(self, project_names: list[str]) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ placeholders = ",".join("?" for _ in project_names)
+ cursor.execute(
+ f"DELETE FROM canvas_projects WHERE project_name IN ({placeholders})", project_names
+ )
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordDeleteException from e
+
+ def save(
+ self,
+ project_name: str,
+ project_origin: ResourceOrigin,
+ name: str,
+ app_version: str,
+ width: int,
+ height: int,
+ image_count: int,
+ has_thumbnail: bool,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ user_id: Optional[str] = None,
+ project_subfolder: str = "",
+ ) -> datetime:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO canvas_projects (
+ project_name,
+ project_origin,
+ name,
+ app_version,
+ width,
+ height,
+ image_count,
+ has_thumbnail,
+ is_intermediate,
+ starred,
+ user_id,
+ project_subfolder
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
+ """,
+ (
+ project_name,
+ project_origin.value,
+ name,
+ app_version,
+ width,
+ height,
+ image_count,
+ has_thumbnail,
+ is_intermediate,
+ starred,
+ user_id or "system",
+ project_subfolder,
+ ),
+ )
+
+ cursor.execute(
+ "SELECT created_at FROM canvas_projects WHERE project_name = ?;",
+ (project_name,),
+ )
+ created_at = datetime.fromisoformat(cursor.fetchone()[0])
+ except sqlite3.Error as e:
+ raise CanvasProjectRecordSaveException from e
+ return created_at
+
+ def get_project_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> CanvasProjectNamesResult:
+ with self._db.transaction() as cursor:
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if project_origin is not None:
+ query_conditions += " AND canvas_projects.project_origin = ? "
+ query_params.append(project_origin.value)
+
+ if is_intermediate is not None:
+ query_conditions += " AND canvas_projects.is_intermediate = ? "
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += " AND board_canvas_projects.board_id IS NULL "
+ if user_id is not None and not is_admin:
+ query_conditions += " AND canvas_projects.user_id = ? "
+ query_params.append(user_id)
+ elif board_id is not None:
+ query_conditions += " AND board_canvas_projects.board_id = ? "
+ query_params.append(board_id)
+ elif user_id is not None and not is_admin:
+ query_conditions += " AND canvas_projects.user_id = ? "
+ query_params.append(user_id)
+
+ if search_term:
+ query_conditions += " AND (canvas_projects.name LIKE ? OR canvas_projects.created_at LIKE ?) "
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ starred_count = 0
+ if starred_first:
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM canvas_projects
+ LEFT JOIN board_canvas_projects
+ ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE canvas_projects.starred = TRUE AND (1=1{query_conditions})
+ """,
+ query_params,
+ )
+ starred_count = cast(int, cursor.fetchone()[0])
+
+ order_clause = (
+ f" ORDER BY canvas_projects.starred DESC, canvas_projects.created_at {order_dir.value} "
+ if starred_first
+ else f" ORDER BY canvas_projects.created_at {order_dir.value} "
+ )
+ cursor.execute(
+ f"""--sql
+ SELECT canvas_projects.project_name
+ FROM canvas_projects
+ LEFT JOIN board_canvas_projects
+ ON board_canvas_projects.project_name = canvas_projects.project_name
+ WHERE 1=1{query_conditions}
+ {order_clause}
+ """,
+ query_params,
+ )
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ project_names = [row[0] for row in result]
+ return CanvasProjectNamesResult(
+ project_names=project_names, starred_count=starred_count, total_count=len(project_names)
+ )
diff --git a/invokeai/app/services/canvas_projects/__init__.py b/invokeai/app/services/canvas_projects/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/canvas_projects/canvas_projects_base.py b/invokeai/app/services/canvas_projects/canvas_projects_base.py
new file mode 100644
index 00000000000..6e14a4c0b72
--- /dev/null
+++ b/invokeai/app/services/canvas_projects/canvas_projects_base.py
@@ -0,0 +1,141 @@
+from abc import ABC, abstractmethod
+from typing import Callable, Optional
+
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CanvasProjectNamesResult,
+ CanvasProjectRecord,
+ CanvasProjectRecordChanges,
+)
+from invokeai.app.services.canvas_projects.canvas_projects_common import CanvasProjectDTO
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+
+class CanvasProjectServiceABC(ABC):
+ """High-level service for canvas project (.invk) management."""
+
+ _on_changed_callbacks: list[Callable[[CanvasProjectDTO], None]]
+ _on_deleted_callbacks: list[Callable[[str], None]]
+
+ def __init__(self) -> None:
+ self._on_changed_callbacks = []
+ self._on_deleted_callbacks = []
+
+ def on_changed(self, on_changed: Callable[[CanvasProjectDTO], None]) -> None:
+ """Register a callback for when a canvas project is changed."""
+ self._on_changed_callbacks.append(on_changed)
+
+ def on_deleted(self, on_deleted: Callable[[str], None]) -> None:
+ """Register a callback for when a canvas project is deleted."""
+ self._on_deleted_callbacks.append(on_deleted)
+
+ def _on_changed(self, item: CanvasProjectDTO) -> None:
+ for callback in self._on_changed_callbacks:
+ callback(item)
+
+ def _on_deleted(self, item_id: str) -> None:
+ for callback in self._on_deleted_callbacks:
+ callback(item_id)
+
+ @abstractmethod
+ def create(
+ self,
+ zip_bytes: bytes,
+ name: str,
+ app_version: str,
+ width: int,
+ height: int,
+ image_count: int,
+ thumbnail_bytes: Optional[bytes] = None,
+ project_origin: ResourceOrigin = ResourceOrigin.INTERNAL,
+ board_id: Optional[str] = None,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ user_id: Optional[str] = None,
+ ) -> CanvasProjectDTO:
+ """Creates a canvas project record + writes the ZIP (and optional thumbnail) to disk."""
+ pass
+
+ @abstractmethod
+ def update(self, project_name: str, changes: CanvasProjectRecordChanges) -> CanvasProjectDTO:
+ """Updates a canvas project."""
+ pass
+
+ @abstractmethod
+ def replace_file(
+ self,
+ project_name: str,
+ zip_bytes: bytes,
+ width: int,
+ height: int,
+ image_count: int,
+ app_version: str,
+ thumbnail_bytes: Optional[bytes] = None,
+ ) -> CanvasProjectDTO:
+ """Replaces the on-disk ZIP and thumbnail for an existing project. Keeps project_name,
+ board assignment, starred state, ownership. Updates dimensions / image count / app version
+ / has_thumbnail."""
+ pass
+
+ @abstractmethod
+ def get_record(self, project_name: str) -> CanvasProjectRecord:
+ """Gets a canvas project record."""
+ pass
+
+ @abstractmethod
+ def get_dto(self, project_name: str) -> CanvasProjectDTO:
+ """Gets a canvas project DTO."""
+ pass
+
+ @abstractmethod
+ def get_path(self, project_name: str, thumbnail: bool = False) -> str:
+ """Gets a canvas project's on-disk path."""
+ pass
+
+ @abstractmethod
+ def get_url(self, project_name: str, thumbnail: bool = False) -> str:
+ """Gets a canvas project's URL."""
+ pass
+
+ @abstractmethod
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[CanvasProjectDTO]:
+ """Gets a paginated list of canvas project DTOs."""
+ pass
+
+ @abstractmethod
+ def delete(self, project_name: str) -> None:
+ """Deletes a canvas project (record + files)."""
+ pass
+
+ @abstractmethod
+ def delete_projects_on_board(self, board_id: str) -> None:
+ """Deletes all canvas projects on a board."""
+ pass
+
+ @abstractmethod
+ def get_project_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> CanvasProjectNamesResult:
+ """Gets ordered list of canvas project names."""
+ pass
diff --git a/invokeai/app/services/canvas_projects/canvas_projects_common.py b/invokeai/app/services/canvas_projects/canvas_projects_common.py
new file mode 100644
index 00000000000..02f76b4425a
--- /dev/null
+++ b/invokeai/app/services/canvas_projects/canvas_projects_common.py
@@ -0,0 +1,63 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import CanvasProjectRecord
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class CanvasProjectUrlsDTO(BaseModelExcludeNull):
+ """The URLs for a canvas project and its thumbnail."""
+
+ project_name: str = Field(description="The unique name of the canvas project.")
+ project_url: str = Field(description="The URL of the canvas project ZIP file (.invk).")
+ thumbnail_url: Optional[str] = Field(
+ default=None, description="The URL of the canvas project's preview thumbnail (WebP), if any."
+ )
+
+
+class CanvasProjectDTO(CanvasProjectRecord, CanvasProjectUrlsDTO):
+ """Deserialized canvas project record, enriched for the frontend."""
+
+ board_id: Optional[str] = Field(
+ default=None, description="The id of the board the canvas project belongs to, if one exists."
+ )
+
+
+def canvas_project_record_to_dto(
+ project_record: CanvasProjectRecord,
+ project_url: str,
+ thumbnail_url: Optional[str],
+ board_id: Optional[str],
+) -> CanvasProjectDTO:
+ """Converts a canvas project record to a canvas project DTO."""
+ return CanvasProjectDTO(
+ **project_record.model_dump(),
+ project_url=project_url,
+ thumbnail_url=thumbnail_url,
+ board_id=board_id,
+ )
+
+
+class CanvasProjectResultWithAffectedBoards(BaseModel):
+ affected_boards: list[str] = Field(description="The ids of boards affected by the operation")
+
+
+class DeleteCanvasProjectsResult(CanvasProjectResultWithAffectedBoards):
+ deleted_projects: list[str] = Field(description="The names of the canvas projects that were deleted")
+
+
+class StarredCanvasProjectsResult(CanvasProjectResultWithAffectedBoards):
+ starred_projects: list[str] = Field(description="The names of the canvas projects that were starred")
+
+
+class UnstarredCanvasProjectsResult(CanvasProjectResultWithAffectedBoards):
+ unstarred_projects: list[str] = Field(description="The names of the canvas projects that were unstarred")
+
+
+class AddCanvasProjectsToBoardResult(CanvasProjectResultWithAffectedBoards):
+ added_projects: list[str] = Field(description="The project names that were added to the board")
+
+
+class RemoveCanvasProjectsFromBoardResult(CanvasProjectResultWithAffectedBoards):
+ removed_projects: list[str] = Field(description="The project names that were removed from their board")
diff --git a/invokeai/app/services/canvas_projects/canvas_projects_default.py b/invokeai/app/services/canvas_projects/canvas_projects_default.py
new file mode 100644
index 00000000000..b13620d76cf
--- /dev/null
+++ b/invokeai/app/services/canvas_projects/canvas_projects_default.py
@@ -0,0 +1,351 @@
+from typing import Optional
+from urllib.parse import quote_plus
+
+from invokeai.app.services.canvas_project_files.canvas_project_files_common import (
+ CanvasProjectFileDeleteException,
+ CanvasProjectFileSaveException,
+)
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CanvasProjectNamesResult,
+ CanvasProjectRecord,
+ CanvasProjectRecordChanges,
+ CanvasProjectRecordDeleteException,
+ CanvasProjectRecordNotFoundException,
+ CanvasProjectRecordSaveException,
+)
+from invokeai.app.services.canvas_projects.canvas_projects_base import CanvasProjectServiceABC
+from invokeai.app.services.canvas_projects.canvas_projects_common import CanvasProjectDTO, canvas_project_record_to_dto
+from invokeai.app.services.image_records.image_records_common import (
+ ImageCategory,
+ InvalidOriginException,
+ ResourceOrigin,
+)
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+
+class CanvasProjectService(CanvasProjectServiceABC):
+ __invoker: Invoker
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def create(
+ self,
+ zip_bytes: bytes,
+ name: str,
+ app_version: str,
+ width: int,
+ height: int,
+ image_count: int,
+ thumbnail_bytes: Optional[bytes] = None,
+ project_origin: ResourceOrigin = ResourceOrigin.INTERNAL,
+ board_id: Optional[str] = None,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ user_id: Optional[str] = None,
+ ) -> CanvasProjectDTO:
+ if project_origin not in ResourceOrigin:
+ raise InvalidOriginException
+
+ project_name = self.__invoker.services.names.create_canvas_project_name()
+
+ # Reuse the image subfolder strategy for canvas project organization.
+ from invokeai.app.services.image_files.image_subfolder_strategy import create_subfolder_strategy
+
+ strategy_name = self.__invoker.services.configuration.image_subfolder_strategy
+ strategy = create_subfolder_strategy(strategy_name)
+ # Canvas projects don't have a true category — use GENERAL as a stand-in so TypeStrategy
+ # bucket them alongside general assets rather than under a dedicated folder.
+ project_subfolder = strategy.get_subfolder(project_name, ImageCategory.GENERAL, is_intermediate or False)
+
+ try:
+ self.__invoker.services.canvas_project_records.save(
+ project_name=project_name,
+ project_origin=project_origin,
+ name=name,
+ app_version=app_version,
+ width=width,
+ height=height,
+ image_count=image_count,
+ has_thumbnail=thumbnail_bytes is not None,
+ is_intermediate=is_intermediate,
+ starred=starred,
+ user_id=user_id,
+ project_subfolder=project_subfolder,
+ )
+ if board_id is not None:
+ try:
+ self.__invoker.services.board_canvas_project_records.add_project_to_board(
+ board_id=board_id, project_name=project_name
+ )
+ except Exception as e:
+ self.__invoker.services.logger.warning(
+ f"Failed to add canvas project to board {board_id}: {str(e)}"
+ )
+
+ self.__invoker.services.canvas_project_files.save(
+ zip_bytes=zip_bytes,
+ project_name=project_name,
+ thumbnail_bytes=thumbnail_bytes,
+ project_subfolder=project_subfolder,
+ )
+
+ project_dto = self.get_dto(project_name)
+ self._on_changed(project_dto)
+ return project_dto
+ except CanvasProjectRecordSaveException:
+ self.__invoker.services.logger.error("Failed to save canvas project record")
+ raise
+ except CanvasProjectFileSaveException:
+ self.__invoker.services.logger.error("Failed to save canvas project file")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error(f"Problem saving canvas project record and file: {str(e)}")
+ raise e
+
+ def update(self, project_name: str, changes: CanvasProjectRecordChanges) -> CanvasProjectDTO:
+ try:
+ self.__invoker.services.canvas_project_records.update(project_name, changes)
+ project_dto = self.get_dto(project_name)
+ self._on_changed(project_dto)
+ return project_dto
+ except CanvasProjectRecordSaveException:
+ self.__invoker.services.logger.error("Failed to update canvas project record")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem updating canvas project record")
+ raise e
+
+ def replace_file(
+ self,
+ project_name: str,
+ zip_bytes: bytes,
+ width: int,
+ height: int,
+ image_count: int,
+ app_version: str,
+ thumbnail_bytes: Optional[bytes] = None,
+ ) -> CanvasProjectDTO:
+ try:
+ record = self.__invoker.services.canvas_project_records.get(project_name)
+
+ # Overwrite the ZIP (and thumbnail if provided) on disk. The files service `save()`
+ # writes through, so no manual delete is needed.
+ self.__invoker.services.canvas_project_files.save(
+ zip_bytes=zip_bytes,
+ project_name=project_name,
+ thumbnail_bytes=thumbnail_bytes,
+ project_subfolder=record.project_subfolder,
+ )
+
+ # When a caller updates a project without supplying a fresh thumbnail, we keep the
+ # existing one — `has_thumbnail` then mirrors the record's previous value.
+ has_thumbnail = thumbnail_bytes is not None or record.has_thumbnail
+
+ self.__invoker.services.canvas_project_records.update_file_metadata(
+ project_name=project_name,
+ width=width,
+ height=height,
+ image_count=image_count,
+ has_thumbnail=has_thumbnail,
+ app_version=app_version,
+ )
+
+ project_dto = self.get_dto(project_name)
+ self._on_changed(project_dto)
+ return project_dto
+ except CanvasProjectRecordNotFoundException:
+ self.__invoker.services.logger.error("Canvas project record not found")
+ raise
+ except CanvasProjectFileSaveException:
+ self.__invoker.services.logger.error("Failed to write canvas project file")
+ raise
+ except CanvasProjectRecordSaveException:
+ self.__invoker.services.logger.error("Failed to update canvas project record")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error(f"Problem replacing canvas project file: {str(e)}")
+ raise e
+
+ def get_record(self, project_name: str) -> CanvasProjectRecord:
+ try:
+ return self.__invoker.services.canvas_project_records.get(project_name)
+ except CanvasProjectRecordNotFoundException:
+ self.__invoker.services.logger.error("Canvas project record not found")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting canvas project record")
+ raise e
+
+ def get_dto(self, project_name: str) -> CanvasProjectDTO:
+ try:
+ record = self.__invoker.services.canvas_project_records.get(project_name)
+ # Cache-buster: append `updated_at` as a query param so the browser refetches the ZIP
+ # and thumbnail after an in-place replace. The path itself is stable (UUID-based), so
+ # without this the browser would keep serving the stale cached bytes.
+ version = quote_plus(str(record.updated_at))
+ project_url = f"{self.__invoker.services.urls.get_canvas_project_url(project_name)}?v={version}"
+ thumbnail_url: Optional[str] = None
+ if record.has_thumbnail:
+ base_thumb = self.__invoker.services.urls.get_canvas_project_url(project_name, thumbnail=True)
+ thumbnail_url = f"{base_thumb}?v={version}"
+ return canvas_project_record_to_dto(
+ project_record=record,
+ project_url=project_url,
+ thumbnail_url=thumbnail_url,
+ board_id=self.__invoker.services.board_canvas_project_records.get_board_for_project(project_name),
+ )
+ except CanvasProjectRecordNotFoundException:
+ self.__invoker.services.logger.error("Canvas project record not found")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting canvas project DTO")
+ raise e
+
+ def get_path(self, project_name: str, thumbnail: bool = False) -> str:
+ try:
+ record = self.__invoker.services.canvas_project_records.get(project_name)
+ return str(
+ self.__invoker.services.canvas_project_files.get_path(
+ project_name, thumbnail=thumbnail, project_subfolder=record.project_subfolder
+ )
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting canvas project path")
+ raise e
+
+ def get_url(self, project_name: str, thumbnail: bool = False) -> str:
+ try:
+ return self.__invoker.services.urls.get_canvas_project_url(project_name, thumbnail=thumbnail)
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting canvas project URL")
+ raise e
+
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[CanvasProjectDTO]:
+ try:
+ results = self.__invoker.services.canvas_project_records.get_many(
+ offset=offset,
+ limit=limit,
+ starred_first=starred_first,
+ order_dir=order_dir,
+ project_origin=project_origin,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ project_dtos: list[CanvasProjectDTO] = []
+ for r in results.items:
+ # Cache-buster: same scheme as get_dto so the listing also surfaces fresh URLs
+ # after an in-place project file replace.
+ version = quote_plus(str(r.updated_at))
+ project_url = f"{self.__invoker.services.urls.get_canvas_project_url(r.project_name)}?v={version}"
+ thumbnail_url: Optional[str] = None
+ if r.has_thumbnail:
+ base_thumb = self.__invoker.services.urls.get_canvas_project_url(
+ r.project_name, thumbnail=True
+ )
+ thumbnail_url = f"{base_thumb}?v={version}"
+ project_dtos.append(
+ canvas_project_record_to_dto(
+ project_record=r,
+ project_url=project_url,
+ thumbnail_url=thumbnail_url,
+ board_id=self.__invoker.services.board_canvas_project_records.get_board_for_project(
+ r.project_name
+ ),
+ )
+ )
+ return OffsetPaginatedResults[CanvasProjectDTO](
+ items=project_dtos, offset=results.offset, limit=results.limit, total=results.total
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting paginated canvas project DTOs")
+ raise e
+
+ def delete(self, project_name: str) -> None:
+ try:
+ record = self.__invoker.services.canvas_project_records.get(project_name)
+ self.__invoker.services.canvas_project_files.delete(
+ project_name, project_subfolder=record.project_subfolder
+ )
+ self.__invoker.services.canvas_project_records.delete(project_name)
+ self._on_deleted(project_name)
+ except CanvasProjectRecordDeleteException:
+ self.__invoker.services.logger.error("Failed to delete canvas project record")
+ raise
+ except CanvasProjectFileDeleteException:
+ self.__invoker.services.logger.error("Failed to delete canvas project file")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem deleting canvas project record and file")
+ raise e
+
+ def delete_projects_on_board(self, board_id: str) -> None:
+ try:
+ project_names = (
+ self.__invoker.services.board_canvas_project_records.get_all_board_project_names_for_board(
+ board_id, is_intermediate=None
+ )
+ )
+ for project_name in project_names:
+ try:
+ record = self.__invoker.services.canvas_project_records.get(project_name)
+ self.__invoker.services.canvas_project_files.delete(
+ project_name, project_subfolder=record.project_subfolder
+ )
+ except Exception:
+ pass
+ self.__invoker.services.canvas_project_records.delete_many(project_names)
+ for project_name in project_names:
+ self._on_deleted(project_name)
+ except CanvasProjectRecordDeleteException:
+ self.__invoker.services.logger.error("Failed to delete canvas project records")
+ raise
+ except CanvasProjectFileDeleteException:
+ self.__invoker.services.logger.error("Failed to delete canvas project files")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error(f"Problem deleting canvas project records and files: {str(e)}")
+ raise e
+
+ def get_project_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ project_origin: Optional[ResourceOrigin] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> CanvasProjectNamesResult:
+ try:
+ return self.__invoker.services.canvas_project_records.get_project_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ project_origin=project_origin,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting canvas project names")
+ raise e
diff --git a/invokeai/app/services/gallery/__init__.py b/invokeai/app/services/gallery/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/gallery/gallery_base.py b/invokeai/app/services/gallery/gallery_base.py
new file mode 100644
index 00000000000..dcf738be305
--- /dev/null
+++ b/invokeai/app/services/gallery/gallery_base.py
@@ -0,0 +1,45 @@
+from abc import ABC, abstractmethod
+from typing import Optional
+
+from invokeai.app.services.gallery.gallery_common import GalleryItem, GalleryItemNamesResult
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+
+
+class GalleryServiceABC(ABC):
+ """High-level service producing a polymorphic stream of images and videos."""
+
+ @abstractmethod
+ def list_items(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[GalleryItem]:
+ """Lists a paginated, time-sorted stream of image + video items."""
+ pass
+
+ @abstractmethod
+ def list_item_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> GalleryItemNamesResult:
+ """Returns ordered (kind, name) refs for optimistic UI / virtualized lists."""
+ pass
diff --git a/invokeai/app/services/gallery/gallery_common.py b/invokeai/app/services/gallery/gallery_common.py
new file mode 100644
index 00000000000..21cf9b66a50
--- /dev/null
+++ b/invokeai/app/services/gallery/gallery_common.py
@@ -0,0 +1,60 @@
+"""Polymorphic gallery types: images, videos and canvas projects in a single time-sorted stream."""
+
+import datetime
+from enum import Enum
+from typing import Optional, Union
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+from invokeai.app.util.metaenum import MetaEnum
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class GalleryItemKind(str, Enum, metaclass=MetaEnum):
+ """Discriminator for polymorphic gallery items."""
+
+ IMAGE = "image"
+ VIDEO = "video"
+ CANVAS_PROJECT = "canvas_project"
+
+
+class GalleryItemRef(BaseModel):
+ """A thin reference to a gallery item — used for ordered name lists."""
+
+ kind: GalleryItemKind = Field(description="Whether the item is an image, video or canvas project.")
+ name: str = Field(description="The unique name of the image, video or canvas project.")
+
+
+class GalleryItem(BaseModelExcludeNull):
+ """A gallery item — image, video or canvas project, with shared fields and a discriminator.
+
+ Frontend code should dispatch on `kind` to render kind-specific UI.
+ """
+
+ kind: GalleryItemKind = Field(description="Whether the item is an image, video or canvas project.")
+ name: str = Field(description="The unique name of the image, video or canvas project.")
+ full_url: str = Field(description="URL to the full-resolution image PNG, video MP4 or canvas project .invk ZIP.")
+ thumbnail_url: str = Field(description="URL to the static (WebP) thumbnail.")
+ width: int = Field(description="The width of the item in pixels.")
+ height: int = Field(description="The height of the item in pixels.")
+ category: ImageCategory = Field(description="The category of the item (canvas projects always GENERAL).")
+ starred: bool = Field(description="Whether the item is starred.")
+ is_intermediate: bool = Field(description="Whether the item is an intermediate output.")
+ board_id: Optional[str] = Field(default=None, description="Owning board id, if any.")
+ created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the item.")
+ # Video-only fields. None for images and canvas projects.
+ duration: Optional[float] = Field(default=None, description="Video duration in seconds. None for non-videos.")
+ fps: Optional[float] = Field(default=None, description="Video frames per second. None for non-videos.")
+ # Canvas-project-only field. None for images and videos.
+ image_count: Optional[int] = Field(
+ default=None, description="Number of embedded images in a canvas project. None for non-projects."
+ )
+
+
+class GalleryItemNamesResult(BaseModel):
+ """Ordered list of gallery item references plus counts for optimistic UI."""
+
+ items: list[GalleryItemRef] = Field(description="Ordered list of (kind, name) references.")
+ starred_count: int = Field(description="Number of starred items (when starred_first=True).")
+ total_count: int = Field(description="Total number of items matching the query.")
diff --git a/invokeai/app/services/gallery/gallery_default.py b/invokeai/app/services/gallery/gallery_default.py
new file mode 100644
index 00000000000..dd0cf482e0d
--- /dev/null
+++ b/invokeai/app/services/gallery/gallery_default.py
@@ -0,0 +1,361 @@
+import sqlite3
+from typing import Optional, Union, cast
+
+from invokeai.app.services.gallery.gallery_base import GalleryServiceABC
+from invokeai.app.services.gallery.gallery_common import (
+ GalleryItem,
+ GalleryItemKind,
+ GalleryItemNamesResult,
+ GalleryItemRef,
+)
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class SqliteGalleryService(GalleryServiceABC):
+ """Implements a polymorphic gallery via UNION ALL across the `images` and `videos` tables.
+
+ Filters are applied identically on each half. The two halves expose a common column set so
+ the result is shape-compatible (a literal `kind` discriminator + a `name` alias + duration/fps
+ that are NULL for images).
+ """
+
+ __invoker: Invoker
+
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def list_items(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[GalleryItem]:
+ image_half, image_params, image_count_query = self._build_half(
+ kind="image",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ video_half, video_params, video_count_query = self._build_half(
+ kind="video",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ project_half, project_params, project_count_query = self._build_half(
+ kind="canvas_project",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+
+ if starred_first:
+ order_clause = f"ORDER BY starred DESC, created_at {order_dir.value}"
+ else:
+ order_clause = f"ORDER BY created_at {order_dir.value}"
+
+ union_query = f"""--sql
+ SELECT * FROM (
+ {image_half}
+ UNION ALL
+ {video_half}
+ UNION ALL
+ {project_half}
+ )
+ {order_clause}
+ LIMIT ? OFFSET ?
+ ;
+ """
+
+ with self._db.transaction() as cursor:
+ cursor.execute(union_query, image_params + video_params + project_params + [limit, offset])
+ rows = cast(list[sqlite3.Row], cursor.fetchall())
+
+ cursor.execute(image_count_query, image_params)
+ image_count = cast(int, cursor.fetchone()[0])
+ cursor.execute(video_count_query, video_params)
+ video_count = cast(int, cursor.fetchone()[0])
+ cursor.execute(project_count_query, project_params)
+ project_count = cast(int, cursor.fetchone()[0])
+
+ urls = self.__invoker.services.urls
+ items = [self._row_to_item(row, urls) for row in rows]
+ return OffsetPaginatedResults[GalleryItem](
+ items=items,
+ offset=offset,
+ limit=limit,
+ total=image_count + video_count + project_count,
+ )
+
+ def list_item_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> GalleryItemNamesResult:
+ image_half, image_params, _ = self._build_half(
+ kind="image",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ names_only=True,
+ )
+ video_half, video_params, _ = self._build_half(
+ kind="video",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ names_only=True,
+ )
+ project_half, project_params, _ = self._build_half(
+ kind="canvas_project",
+ origin=origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ names_only=True,
+ )
+
+ if starred_first:
+ order_clause = f"ORDER BY starred DESC, created_at {order_dir.value}"
+ else:
+ order_clause = f"ORDER BY created_at {order_dir.value}"
+
+ union_query = f"""--sql
+ SELECT * FROM (
+ {image_half}
+ UNION ALL
+ {video_half}
+ UNION ALL
+ {project_half}
+ )
+ {order_clause}
+ ;
+ """
+
+ with self._db.transaction() as cursor:
+ cursor.execute(union_query, image_params + video_params + project_params)
+ rows = cast(list[sqlite3.Row], cursor.fetchall())
+
+ starred_count = 0
+ if starred_first:
+ starred_count = sum(1 for r in rows if r["starred"])
+
+ refs = [GalleryItemRef(kind=GalleryItemKind(row["kind"]), name=row["name"]) for row in rows]
+ return GalleryItemNamesResult(items=refs, starred_count=starred_count, total_count=len(refs))
+
+ def _build_half(
+ self,
+ kind: str,
+ origin: Optional[ResourceOrigin],
+ categories: Optional[list[ImageCategory]],
+ is_intermediate: Optional[bool],
+ board_id: Optional[str],
+ search_term: Optional[str],
+ user_id: Optional[str],
+ is_admin: bool,
+ names_only: bool = False,
+ ) -> tuple[str, list[Union[int, str, bool]], str]:
+ """Builds one half of the union (either `images` or `videos`).
+
+ Returns `(query_with_select, params, count_query)`. Both halves emit the same columns so
+ UNION ALL is shape-compatible: `kind`, `name`, `width`, `height`, `category`, `starred`,
+ `is_intermediate`, `board_id`, `created_at`, `duration`, `fps`.
+
+ `names_only=True` selects only `kind`, `name`, `starred`, `created_at` (the minimum needed
+ for ordering + the counts result).
+ """
+ # Canvas projects have no `category` column — they're conceptually GENERAL — and no
+ # `metadata` column. We project literals to keep the UNION shape-compatible and skip the
+ # category filter / metadata search on this half.
+ is_canvas_project = kind == "canvas_project"
+
+ if kind == "image":
+ base_table = "images"
+ join_table = "board_images"
+ name_col = "image_name"
+ category_col = "image_category"
+ origin_col = "image_origin"
+ duration_expr = "NULL"
+ fps_expr = "NULL"
+ image_count_expr = "NULL"
+ category_select = f"{base_table}.{category_col}"
+ elif kind == "video":
+ base_table = "videos"
+ join_table = "board_videos"
+ name_col = "video_name"
+ category_col = "video_category"
+ origin_col = "video_origin"
+ duration_expr = f"{base_table}.duration"
+ fps_expr = f"{base_table}.fps"
+ image_count_expr = "NULL"
+ category_select = f"{base_table}.{category_col}"
+ elif kind == "canvas_project":
+ base_table = "canvas_projects"
+ join_table = "board_canvas_projects"
+ name_col = "project_name"
+ origin_col = "project_origin"
+ duration_expr = "NULL"
+ fps_expr = "NULL"
+ image_count_expr = f"{base_table}.image_count"
+ # Canvas projects don't have a category column — emit a literal so the UNION matches.
+ category_select = f"'{ImageCategory.GENERAL.value}'"
+ category_col = None # unused, projects bypass the category filter
+ else:
+ raise ValueError(f"Unknown kind: {kind}")
+
+ if names_only:
+ select_cols = (
+ f"'{kind}' AS kind, "
+ f"{base_table}.{name_col} AS name, "
+ f"{base_table}.starred AS starred, "
+ f"{base_table}.created_at AS created_at"
+ )
+ else:
+ select_cols = (
+ f"'{kind}' AS kind, "
+ f"{base_table}.{name_col} AS name, "
+ f"{base_table}.width AS width, "
+ f"{base_table}.height AS height, "
+ f"{category_select} AS category, "
+ f"{base_table}.starred AS starred, "
+ f"{base_table}.is_intermediate AS is_intermediate, "
+ f"{join_table}.board_id AS board_id, "
+ f"{base_table}.created_at AS created_at, "
+ f"{duration_expr} AS duration, "
+ f"{fps_expr} AS fps, "
+ f"{image_count_expr} AS image_count"
+ )
+
+ from_clause = f"FROM {base_table} LEFT JOIN {join_table} ON {join_table}.{name_col} = {base_table}.{name_col}"
+
+ conditions = ""
+ params: list[Union[int, str, bool]] = []
+
+ if origin is not None:
+ conditions += f" AND {base_table}.{origin_col} = ? "
+ params.append(origin.value)
+
+ if categories is not None and category_col is not None:
+ # Canvas projects don't carry a category — when callers filter by category, they
+ # implicitly exclude projects unless GENERAL is in the list. Make that explicit.
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ conditions += f" AND {base_table}.{category_col} IN ( {placeholders} ) "
+ for c in category_strings:
+ params.append(c)
+ elif categories is not None and is_canvas_project:
+ # GENERAL not in categories → exclude all canvas projects.
+ if ImageCategory.GENERAL not in set(categories):
+ conditions += " AND 1=0 "
+
+ if is_intermediate is not None:
+ conditions += f" AND {base_table}.is_intermediate = ? "
+ params.append(is_intermediate)
+
+ if board_id == "none":
+ conditions += f" AND {join_table}.board_id IS NULL "
+ if user_id is not None and not is_admin:
+ conditions += f" AND {base_table}.user_id = ? "
+ params.append(user_id)
+ elif board_id is not None:
+ conditions += f" AND {join_table}.board_id = ? "
+ params.append(board_id)
+ elif user_id is not None and not is_admin:
+ # No board_id supplied — still enforce per-user isolation so
+ # non-admin callers cannot enumerate other users' items.
+ conditions += f" AND {base_table}.user_id = ? "
+ params.append(user_id)
+
+ if search_term:
+ if is_canvas_project:
+ # No `metadata` column on canvas_projects — search the `name` field instead.
+ conditions += f" AND ({base_table}.name LIKE ? OR {base_table}.created_at LIKE ?) "
+ else:
+ conditions += f" AND ({base_table}.metadata LIKE ? OR {base_table}.created_at LIKE ?) "
+ params.append(f"%{search_term.lower()}%")
+ params.append(f"%{search_term.lower()}%")
+
+ half_query = f"SELECT {select_cols} {from_clause} WHERE 1=1 {conditions}"
+ count_query = f"SELECT COUNT(*) {from_clause} WHERE 1=1 {conditions}"
+ return half_query, params, count_query
+
+ def _row_to_item(self, row: sqlite3.Row, urls) -> GalleryItem:
+ kind = GalleryItemKind(row["kind"])
+ name = row["name"]
+ duration: Optional[float] = None
+ fps: Optional[float] = None
+ image_count: Optional[int] = None
+ if kind == GalleryItemKind.IMAGE:
+ full_url = urls.get_image_url(name)
+ thumbnail_url = urls.get_image_url(name, thumbnail=True)
+ elif kind == GalleryItemKind.VIDEO:
+ full_url = urls.get_video_url(name)
+ thumbnail_url = urls.get_video_url(name, thumbnail=True)
+ duration = row["duration"]
+ fps = row["fps"]
+ else:
+ full_url = urls.get_canvas_project_url(name)
+ thumbnail_url = urls.get_canvas_project_url(name, thumbnail=True)
+ image_count = row["image_count"]
+ return GalleryItem(
+ kind=kind,
+ name=name,
+ full_url=full_url,
+ thumbnail_url=thumbnail_url,
+ width=row["width"],
+ height=row["height"],
+ category=ImageCategory(row["category"]),
+ starred=bool(row["starred"]),
+ is_intermediate=bool(row["is_intermediate"]),
+ board_id=row["board_id"],
+ created_at=row["created_at"],
+ duration=duration,
+ fps=fps,
+ image_count=image_count,
+ )
diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py
index 2c95f87b41d..aae6152c04e 100644
--- a/invokeai/app/services/invocation_services.py
+++ b/invokeai/app/services/invocation_services.py
@@ -15,13 +15,23 @@
from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
+ from invokeai.app.services.board_canvas_project_records.board_canvas_project_records_base import (
+ BoardCanvasProjectRecordStorageBase,
+ )
+ from invokeai.app.services.board_video_records.board_video_records_base import BoardVideoRecordStorageBase
from invokeai.app.services.boards.boards_base import BoardServiceABC
from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
+ from invokeai.app.services.canvas_project_files.canvas_project_files_base import CanvasProjectFileStorageBase
+ from invokeai.app.services.canvas_project_records.canvas_project_records_base import (
+ CanvasProjectRecordStorageBase,
+ )
+ from invokeai.app.services.canvas_projects.canvas_projects_base import CanvasProjectServiceABC
from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.download import DownloadQueueServiceBase
from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.external_generation.external_generation_base import ExternalGenerationServiceBase
+ from invokeai.app.services.gallery.gallery_base import GalleryServiceABC
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
from invokeai.app.services.images.images_base import ImageServiceABC
@@ -38,6 +48,9 @@
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
from invokeai.app.services.urls.urls_base import UrlServiceBase
from invokeai.app.services.users.users_base import UserServiceBase
+ from invokeai.app.services.video_files.video_files_base import VideoFileStorageBase
+ from invokeai.app.services.video_records.video_records_base import VideoRecordStorageBase
+ from invokeai.app.services.videos.videos_base import VideoServiceABC
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
@@ -79,6 +92,15 @@ def __init__(
workflow_thumbnails: "WorkflowThumbnailServiceBase",
client_state_persistence: "ClientStatePersistenceABC",
users: "UserServiceBase",
+ videos: "VideoServiceABC",
+ video_files: "VideoFileStorageBase",
+ video_records: "VideoRecordStorageBase",
+ board_video_records: "BoardVideoRecordStorageBase",
+ gallery: "GalleryServiceABC",
+ canvas_projects: "CanvasProjectServiceABC",
+ canvas_project_files: "CanvasProjectFileStorageBase",
+ canvas_project_records: "CanvasProjectRecordStorageBase",
+ board_canvas_project_records: "BoardCanvasProjectRecordStorageBase",
):
self.board_images = board_images
self.board_image_records = board_image_records
@@ -111,3 +133,12 @@ def __init__(
self.workflow_thumbnails = workflow_thumbnails
self.client_state_persistence = client_state_persistence
self.users = users
+ self.videos = videos
+ self.video_files = video_files
+ self.video_records = video_records
+ self.board_video_records = board_video_records
+ self.gallery = gallery
+ self.canvas_projects = canvas_projects
+ self.canvas_project_files = canvas_project_files
+ self.canvas_project_records = canvas_project_records
+ self.board_canvas_project_records = board_canvas_project_records
diff --git a/invokeai/app/services/model_records/model_records_base.py b/invokeai/app/services/model_records/model_records_base.py
index e06f8f2df91..4d5a9d102ca 100644
--- a/invokeai/app/services/model_records/model_records_base.py
+++ b/invokeai/app/services/model_records/model_records_base.py
@@ -33,6 +33,8 @@
Qwen3VariantType,
QwenImageVariantType,
SchedulerPredictionType,
+ WanLoRAVariantType,
+ WanVariantType,
ZImageVariantType,
)
@@ -134,6 +136,8 @@ def validate_source_url(cls, v: Any) -> Optional[str]:
| Flux2VariantType
| ZImageVariantType
| QwenImageVariantType
+ | WanVariantType
+ | WanLoRAVariantType
| Qwen3VariantType
] = Field(description="The variant of the model.", default=None)
prediction_type: Optional[SchedulerPredictionType] = Field(
diff --git a/invokeai/app/services/names/names_base.py b/invokeai/app/services/names/names_base.py
index f892c43c55a..aa062d71fc3 100644
--- a/invokeai/app/services/names/names_base.py
+++ b/invokeai/app/services/names/names_base.py
@@ -9,3 +9,13 @@ class NameServiceBase(ABC):
def create_image_name(self) -> str:
"""Creates a name for an image."""
pass
+
+ @abstractmethod
+ def create_video_name(self) -> str:
+ """Creates a name for a video."""
+ pass
+
+ @abstractmethod
+ def create_canvas_project_name(self) -> str:
+ """Creates a name (UUID, no extension) for a canvas project."""
+ pass
diff --git a/invokeai/app/services/names/names_default.py b/invokeai/app/services/names/names_default.py
index 5804a937d6a..4df2232c07c 100644
--- a/invokeai/app/services/names/names_default.py
+++ b/invokeai/app/services/names/names_default.py
@@ -10,3 +10,13 @@ def create_image_name(self) -> str:
uuid_str = uuid_string()
filename = f"{uuid_str}.png"
return filename
+
+ def create_video_name(self) -> str:
+ uuid_str = uuid_string()
+ filename = f"{uuid_str}.mp4"
+ return filename
+
+ def create_canvas_project_name(self) -> str:
+ # Canvas project names are bare UUIDs without an extension; the file-storage layer
+ # appends `.invk` (and `.webp` for the thumbnail) on disk.
+ return uuid_string()
diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py
index e38766d5ba2..266f6e335b2 100644
--- a/invokeai/app/services/shared/invocation_context.py
+++ b/invokeai/app/services/shared/invocation_context.py
@@ -18,6 +18,7 @@
from invokeai.app.services.model_records.model_records_base import UnknownModelException
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.videos.videos_common import VideoDTO
from invokeai.app.util.step_callback import diffusion_step_callback
from invokeai.backend.model_manager.configs.base import Config_Base
from invokeai.backend.model_manager.configs.factory import AnyModelConfig
@@ -292,6 +293,89 @@ def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
return Path(self._services.images.get_path(image_name, thumbnail))
+class VideosInterface(InvocationContextInterface):
+ """Save and look up videos produced by invocations.
+
+ Mirrors :class:`ImagesInterface` but consumes a path to an already-encoded
+ MP4 (or other supported container) rather than an in-memory PIL image —
+ video encoding is the caller's responsibility (e.g. the
+ ``wan_latents_to_video`` node uses ``imageio[ffmpeg]``).
+ """
+
+ def __init__(self, services: InvocationServices, data: InvocationContextData, util: "UtilInterface") -> None:
+ super().__init__(services, data)
+ self._util = util
+
+ def save(
+ self,
+ source_path: Path,
+ width: int,
+ height: int,
+ duration: float,
+ fps: Optional[float] = None,
+ board_id: Optional[str] = None,
+ image_category: ImageCategory = ImageCategory.GENERAL,
+ metadata: Optional[MetadataField] = None,
+ ) -> VideoDTO:
+ """Save a video produced by an invocation. The file at ``source_path`` is moved into
+ the videos output folder; the caller should treat the path as consumed after this
+ returns.
+
+ ``board_id`` falls back to the invocation's :class:`WithBoard` mixin if unset, and
+ ``metadata`` falls back to the :class:`WithMetadata` mixin. Both can be overridden
+ explicitly. ``image_category`` reuses the image enum since the gallery's category
+ filter is shared between kinds.
+ """
+
+ self._util.signal_progress("Saving video")
+
+ metadata_ = None
+ if metadata:
+ metadata_ = metadata.model_dump_json()
+ elif isinstance(self._data.invocation, WithMetadata) and self._data.invocation.metadata:
+ metadata_ = self._data.invocation.metadata.model_dump_json()
+
+ board_id_ = None
+ if board_id:
+ board_id_ = board_id
+ elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board:
+ board_id_ = self._data.invocation.board.board_id
+
+ workflow_ = None
+ if self._data.queue_item.workflow:
+ workflow_ = self._data.queue_item.workflow.model_dump_json()
+
+ graph_ = None
+ if self._data.queue_item.session.graph:
+ graph_ = self._data.queue_item.session.graph.model_dump_json()
+
+ return self._services.videos.create(
+ source_path=source_path,
+ width=width,
+ height=height,
+ duration=duration,
+ fps=fps,
+ is_intermediate=self._data.invocation.is_intermediate,
+ video_category=image_category,
+ board_id=board_id_,
+ metadata=metadata_,
+ video_origin=ResourceOrigin.INTERNAL,
+ workflow=workflow_,
+ graph=graph_,
+ session_id=self._data.queue_item.session_id,
+ node_id=self._data.invocation.id,
+ user_id=self._data.queue_item.user_id,
+ )
+
+ def get_dto(self, video_name: str) -> VideoDTO:
+ """Get a video DTO by name."""
+ return self._services.videos.get_dto(video_name)
+
+ def get_path(self, video_name: str, thumbnail: bool = False) -> Path:
+ """Get the on-disk path to a video file or its WebP thumbnail."""
+ return Path(self._services.videos.get_path(video_name, thumbnail=thumbnail))
+
+
class TensorsInterface(InvocationContextInterface):
def save(self, tensor: Tensor) -> str:
"""Saves a tensor, returning its name.
@@ -736,6 +820,7 @@ class InvocationContext:
def __init__(
self,
images: ImagesInterface,
+ videos: VideosInterface,
tensors: TensorsInterface,
conditioning: ConditioningInterface,
models: ModelsInterface,
@@ -748,6 +833,8 @@ def __init__(
) -> None:
self.images = images
"""Methods to save, get and update images and their metadata."""
+ self.videos = videos
+ """Methods to save and get videos produced by invocations."""
self.tensors = tensors
"""Methods to save and get tensors, including image, noise, masks, and masked images."""
self.conditioning = conditioning
@@ -790,10 +877,12 @@ def build_invocation_context(
conditioning = ConditioningInterface(services=services, data=data)
models = ModelsInterface(services=services, data=data, util=util)
images = ImagesInterface(services=services, data=data, util=util)
+ videos = VideosInterface(services=services, data=data, util=util)
boards = BoardsInterface(services=services, data=data)
ctx = InvocationContext(
images=images,
+ videos=videos,
logger=logger,
config=config,
tensors=tensors,
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 12642610c8c..14b6c61a85a 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -34,6 +34,8 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import build_migration_31
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import build_migration_32
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_33 import build_migration_33
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -85,6 +87,8 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_29())
migrator.register_migration(build_migration_30())
migrator.register_migration(build_migration_31())
+ migrator.register_migration(build_migration_32())
+ migrator.register_migration(build_migration_33())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py
new file mode 100644
index 00000000000..f1695850f75
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py
@@ -0,0 +1,115 @@
+"""Migration 32: Add `videos` and `board_videos` tables for minimal video support.
+
+The `videos` table parallels `images` but with extra `duration` and `fps` columns.
+The `board_videos` table parallels `board_images`, providing one-to-many board↔video association.
+Foreign-key cascades from `boards` mirror the image side, so deleting a board also removes its videos' associations.
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration32Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._create_videos(cursor)
+ self._create_board_videos(cursor)
+
+ def _create_videos(self, cursor: sqlite3.Cursor) -> None:
+ tables = [
+ """--sql
+ CREATE TABLE IF NOT EXISTS videos (
+ video_name TEXT NOT NULL PRIMARY KEY,
+ video_origin TEXT NOT NULL,
+ video_category TEXT NOT NULL,
+ width INTEGER NOT NULL,
+ height INTEGER NOT NULL,
+ duration REAL NOT NULL DEFAULT 0.0,
+ fps REAL,
+ session_id TEXT,
+ node_id TEXT,
+ metadata TEXT,
+ is_intermediate BOOLEAN DEFAULT FALSE,
+ starred BOOLEAN DEFAULT FALSE,
+ has_workflow BOOLEAN DEFAULT FALSE,
+ user_id TEXT NOT NULL DEFAULT 'system',
+ video_subfolder TEXT NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Soft delete, currently unused
+ deleted_at DATETIME
+ );
+ """
+ ]
+
+ indices = [
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_videos_video_name ON videos(video_name);",
+ "CREATE INDEX IF NOT EXISTS idx_videos_video_origin ON videos(video_origin);",
+ "CREATE INDEX IF NOT EXISTS idx_videos_video_category ON videos(video_category);",
+ "CREATE INDEX IF NOT EXISTS idx_videos_created_at ON videos(created_at);",
+ "CREATE INDEX IF NOT EXISTS idx_videos_starred ON videos(starred);",
+ "CREATE INDEX IF NOT EXISTS idx_videos_user_id ON videos(user_id);",
+ ]
+
+ triggers = [
+ """--sql
+ CREATE TRIGGER IF NOT EXISTS tg_videos_updated_at
+ AFTER UPDATE
+ ON videos FOR EACH ROW
+ BEGIN
+ UPDATE videos SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE video_name = old.video_name;
+ END;
+ """
+ ]
+
+ for stmt in tables + indices + triggers:
+ cursor.execute(stmt)
+
+ def _create_board_videos(self, cursor: sqlite3.Cursor) -> None:
+ tables = [
+ """--sql
+ CREATE TABLE IF NOT EXISTS board_videos (
+ board_id TEXT NOT NULL,
+ video_name TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Soft delete, currently unused
+ deleted_at DATETIME,
+ -- enforce one-to-many board↔video using PK on video_name
+ PRIMARY KEY (video_name),
+ FOREIGN KEY (board_id) REFERENCES boards (board_id) ON DELETE CASCADE,
+ FOREIGN KEY (video_name) REFERENCES videos (video_name) ON DELETE CASCADE
+ );
+ """
+ ]
+
+ indices = [
+ "CREATE INDEX IF NOT EXISTS idx_board_videos_board_id ON board_videos (board_id);",
+ "CREATE INDEX IF NOT EXISTS idx_board_videos_board_id_created_at ON board_videos (board_id, created_at);",
+ ]
+
+ triggers = [
+ """--sql
+ CREATE TRIGGER IF NOT EXISTS tg_board_videos_updated_at
+ AFTER UPDATE
+ ON board_videos FOR EACH ROW
+ BEGIN
+ UPDATE board_videos SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE board_id = old.board_id AND video_name = old.video_name;
+ END;
+ """
+ ]
+
+ for stmt in tables + indices + triggers:
+ cursor.execute(stmt)
+
+
+def build_migration_32() -> Migration:
+ return Migration(
+ from_version=31,
+ to_version=32,
+ callback=Migration32Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py
new file mode 100644
index 00000000000..6774f63f954
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py
@@ -0,0 +1,111 @@
+"""Migration 33: Add `canvas_projects` and `board_canvas_projects` tables for Canvas Project (.invk) support.
+
+The `canvas_projects` table stores metadata for server-persisted Canvas Project ZIP files (`.invk`).
+The `board_canvas_projects` table parallels `board_images` / `board_videos`, providing one-to-many
+board↔project association. Foreign-key cascades from `boards` mirror the image/video sides.
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration33Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._create_canvas_projects(cursor)
+ self._create_board_canvas_projects(cursor)
+
+ def _create_canvas_projects(self, cursor: sqlite3.Cursor) -> None:
+ tables = [
+ """--sql
+ CREATE TABLE IF NOT EXISTS canvas_projects (
+ project_name TEXT NOT NULL PRIMARY KEY,
+ project_origin TEXT NOT NULL,
+ name TEXT NOT NULL,
+ app_version TEXT NOT NULL DEFAULT 'unknown',
+ width INTEGER NOT NULL DEFAULT 0,
+ height INTEGER NOT NULL DEFAULT 0,
+ image_count INTEGER NOT NULL DEFAULT 0,
+ has_thumbnail BOOLEAN DEFAULT FALSE,
+ starred BOOLEAN DEFAULT FALSE,
+ is_intermediate BOOLEAN DEFAULT FALSE,
+ user_id TEXT NOT NULL DEFAULT 'system',
+ project_subfolder TEXT NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Soft delete, currently unused
+ deleted_at DATETIME
+ );
+ """
+ ]
+
+ indices = [
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_canvas_projects_project_name ON canvas_projects(project_name);",
+ "CREATE INDEX IF NOT EXISTS idx_canvas_projects_project_origin ON canvas_projects(project_origin);",
+ "CREATE INDEX IF NOT EXISTS idx_canvas_projects_created_at ON canvas_projects(created_at);",
+ "CREATE INDEX IF NOT EXISTS idx_canvas_projects_starred ON canvas_projects(starred);",
+ "CREATE INDEX IF NOT EXISTS idx_canvas_projects_user_id ON canvas_projects(user_id);",
+ ]
+
+ triggers = [
+ """--sql
+ CREATE TRIGGER IF NOT EXISTS tg_canvas_projects_updated_at
+ AFTER UPDATE
+ ON canvas_projects FOR EACH ROW
+ BEGIN
+ UPDATE canvas_projects SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE project_name = old.project_name;
+ END;
+ """
+ ]
+
+ for stmt in tables + indices + triggers:
+ cursor.execute(stmt)
+
+ def _create_board_canvas_projects(self, cursor: sqlite3.Cursor) -> None:
+ tables = [
+ """--sql
+ CREATE TABLE IF NOT EXISTS board_canvas_projects (
+ board_id TEXT NOT NULL,
+ project_name TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Soft delete, currently unused
+ deleted_at DATETIME,
+ -- enforce one-to-many board↔project using PK on project_name
+ PRIMARY KEY (project_name),
+ FOREIGN KEY (board_id) REFERENCES boards (board_id) ON DELETE CASCADE,
+ FOREIGN KEY (project_name) REFERENCES canvas_projects (project_name) ON DELETE CASCADE
+ );
+ """
+ ]
+
+ indices = [
+ "CREATE INDEX IF NOT EXISTS idx_board_canvas_projects_board_id ON board_canvas_projects (board_id);",
+ "CREATE INDEX IF NOT EXISTS idx_board_canvas_projects_board_id_created_at ON board_canvas_projects (board_id, created_at);",
+ ]
+
+ triggers = [
+ """--sql
+ CREATE TRIGGER IF NOT EXISTS tg_board_canvas_projects_updated_at
+ AFTER UPDATE
+ ON board_canvas_projects FOR EACH ROW
+ BEGIN
+ UPDATE board_canvas_projects SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE board_id = old.board_id AND project_name = old.project_name;
+ END;
+ """
+ ]
+
+ for stmt in tables + indices + triggers:
+ cursor.execute(stmt)
+
+
+def build_migration_33() -> Migration:
+ return Migration(
+ from_version=32,
+ to_version=33,
+ callback=Migration33Callback(),
+ )
diff --git a/invokeai/app/services/urls/urls_base.py b/invokeai/app/services/urls/urls_base.py
index a5602abb3b4..950051a77a1 100644
--- a/invokeai/app/services/urls/urls_base.py
+++ b/invokeai/app/services/urls/urls_base.py
@@ -9,6 +9,16 @@ def get_image_url(self, image_name: str, thumbnail: bool = False) -> str:
"""Gets the URL for an image or thumbnail."""
pass
+ @abstractmethod
+ def get_video_url(self, video_name: str, thumbnail: bool = False) -> str:
+ """Gets the URL for a video or its first-frame thumbnail."""
+ pass
+
+ @abstractmethod
+ def get_canvas_project_url(self, project_name: str, thumbnail: bool = False) -> str:
+ """Gets the URL for a canvas project ZIP (.invk) or its preview thumbnail (WebP)."""
+ pass
+
@abstractmethod
def get_model_image_url(self, model_key: str) -> str:
"""Gets the URL for a model image"""
diff --git a/invokeai/app/services/urls/urls_default.py b/invokeai/app/services/urls/urls_default.py
index 2e4f36d9d51..b42019f633a 100644
--- a/invokeai/app/services/urls/urls_default.py
+++ b/invokeai/app/services/urls/urls_default.py
@@ -17,6 +17,24 @@ def get_image_url(self, image_name: str, thumbnail: bool = False) -> str:
return f"{self._base_url}/images/i/{image_basename}/full"
+ def get_video_url(self, video_name: str, thumbnail: bool = False) -> str:
+ video_basename = os.path.basename(video_name)
+
+ # These paths are determined by the routes in invokeai/app/api/routers/videos.py
+ if thumbnail:
+ return f"{self._base_url}/videos/i/{video_basename}/thumbnail"
+
+ return f"{self._base_url}/videos/i/{video_basename}/full"
+
+ def get_canvas_project_url(self, project_name: str, thumbnail: bool = False) -> str:
+ project_basename = os.path.basename(project_name)
+
+ # These paths are determined by the routes in invokeai/app/api/routers/canvas_projects.py
+ if thumbnail:
+ return f"{self._base_url}/canvas_projects/i/{project_basename}/thumbnail"
+
+ return f"{self._base_url}/canvas_projects/i/{project_basename}/full"
+
def get_model_image_url(self, model_key: str) -> str:
return f"{self._base_url_v2}/models/i/{model_key}/image"
diff --git a/invokeai/app/services/video_files/__init__.py b/invokeai/app/services/video_files/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/video_files/video_files_base.py b/invokeai/app/services/video_files/video_files_base.py
new file mode 100644
index 00000000000..74e9d96fb63
--- /dev/null
+++ b/invokeai/app/services/video_files/video_files_base.py
@@ -0,0 +1,48 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional
+
+
+class VideoFileStorageBase(ABC):
+ """Low-level service responsible for storing and retrieving video files."""
+
+ @abstractmethod
+ def get_path(self, video_name: str, thumbnail: bool = False, video_subfolder: str = "") -> Path:
+ """Gets the internal path to a video or its thumbnail."""
+ pass
+
+ @abstractmethod
+ def save(
+ self,
+ source_path: Path,
+ video_name: str,
+ thumbnail_size: int = 256,
+ video_subfolder: str = "",
+ metadata: Optional[str] = None,
+ workflow: Optional[str] = None,
+ graph: Optional[str] = None,
+ ) -> None:
+ """Saves a video by moving/copying the file at `source_path` into storage, then writes a sibling
+ WEBP thumbnail extracted from the first frame, plus an optional sidecar JSON of metadata/workflow/graph.
+ """
+ pass
+
+ @abstractmethod
+ def delete(self, video_name: str, video_subfolder: str = "") -> None:
+ """Deletes a video file and its thumbnail (if one exists)."""
+ pass
+
+ @abstractmethod
+ def get_workflow(self, video_name: str, video_subfolder: str = "") -> Optional[str]:
+ """Gets the workflow JSON sidecar of a video, if any."""
+ pass
+
+ @abstractmethod
+ def get_graph(self, video_name: str, video_subfolder: str = "") -> Optional[str]:
+ """Gets the graph JSON sidecar of a video, if any."""
+ pass
+
+ @abstractmethod
+ def validate_path(self, path: str) -> bool:
+ """Validates the path given for a video or thumbnail."""
+ pass
diff --git a/invokeai/app/services/video_files/video_files_common.py b/invokeai/app/services/video_files/video_files_common.py
new file mode 100644
index 00000000000..4743d8a7cc8
--- /dev/null
+++ b/invokeai/app/services/video_files/video_files_common.py
@@ -0,0 +1,19 @@
+class VideoFileNotFoundException(Exception):
+ """Raised when a video file is not found in storage."""
+
+ def __init__(self, message="Video file not found"):
+ super().__init__(message)
+
+
+class VideoFileSaveException(Exception):
+ """Raised when a video file cannot be saved."""
+
+ def __init__(self, message="Video file not saved"):
+ super().__init__(message)
+
+
+class VideoFileDeleteException(Exception):
+ """Raised when a video file cannot be deleted."""
+
+ def __init__(self, message="Video file not deleted"):
+ super().__init__(message)
diff --git a/invokeai/app/services/video_files/video_files_disk.py b/invokeai/app/services/video_files/video_files_disk.py
new file mode 100644
index 00000000000..c7af86e3b8d
--- /dev/null
+++ b/invokeai/app/services/video_files/video_files_disk.py
@@ -0,0 +1,190 @@
+import json
+import shutil
+from pathlib import Path
+from typing import Optional, Union
+
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.video_files.video_files_base import VideoFileStorageBase
+from invokeai.app.services.video_files.video_files_common import (
+ VideoFileDeleteException,
+ VideoFileNotFoundException,
+ VideoFileSaveException,
+)
+from invokeai.app.util.thumbnails import make_thumbnail
+from invokeai.app.util.video_thumbnails import extract_video_frame, get_video_thumbnail_name
+from invokeai.backend.util.logging import InvokeAILogger
+
+
+class DiskVideoFileStorage(VideoFileStorageBase):
+ """Stores video files on disk under {outputs}/videos/, with first-frame WebP thumbnails under
+ {outputs}/videos/thumbnails/ and optional JSON sidecars for metadata/workflow/graph under
+ {outputs}/videos/sidecars/."""
+
+ def __init__(self, output_folder: Union[str, Path]):
+ self.__output_folder = output_folder if isinstance(output_folder, Path) else Path(output_folder)
+ self.__thumbnails_folder = self.__output_folder / "thumbnails"
+ self.__sidecars_folder = self.__output_folder / "sidecars"
+ self.__validate_storage_folders()
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def save(
+ self,
+ source_path: Path,
+ video_name: str,
+ thumbnail_size: int = 256,
+ video_subfolder: str = "",
+ metadata: Optional[str] = None,
+ workflow: Optional[str] = None,
+ graph: Optional[str] = None,
+ ) -> None:
+ logger = InvokeAILogger.get_logger()
+ try:
+ self.__validate_storage_folders()
+ video_path = self.get_path(video_name, video_subfolder=video_subfolder)
+ video_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Move if the source is on the same filesystem; otherwise copy then unlink.
+ try:
+ shutil.move(str(source_path), str(video_path))
+ except Exception:
+ shutil.copy2(str(source_path), str(video_path))
+ try:
+ Path(source_path).unlink(missing_ok=True)
+ except Exception:
+ pass
+ logger.info(f"Video file written: {video_path}")
+
+ thumbnail_name = get_video_thumbnail_name(video_name)
+ thumbnail_path = self.get_path(thumbnail_name, thumbnail=True, video_subfolder=video_subfolder)
+ thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Thumbnail extraction is best-effort — if both imageio and cv2 fail, we still want
+ # the video record + file in place and the invocation to complete. A missing
+ # thumbnail leaves the gallery with a broken-image placeholder for that item, which
+ # is annoying but not fatal.
+ try:
+ frame = extract_video_frame(video_path, frame_index=0)
+ except Exception as e:
+ logger.warning(f"Thumbnail extraction raised for {video_name}: {e}")
+ frame = None
+ if frame is not None:
+ thumbnail = make_thumbnail(frame, thumbnail_size)
+ thumbnail.save(thumbnail_path, "WEBP")
+ logger.info(f"Thumbnail written: {thumbnail_path}")
+ else:
+ logger.warning(
+ f"Could not extract a thumbnail frame for {video_name}; gallery thumbnail will be missing."
+ )
+
+ if metadata is not None or workflow is not None or graph is not None:
+ sidecar_path = self.__get_sidecar_path(video_name, video_subfolder=video_subfolder)
+ sidecar_path.parent.mkdir(parents=True, exist_ok=True)
+ sidecar = {
+ "invokeai_metadata": metadata,
+ "invokeai_workflow": workflow,
+ "invokeai_graph": graph,
+ }
+ with open(sidecar_path, "w", encoding="utf-8") as f:
+ json.dump(sidecar, f)
+ logger.info(f"Sidecar written: {sidecar_path}")
+ except Exception as e:
+ raise VideoFileSaveException from e
+
+ def delete(self, video_name: str, video_subfolder: str = "") -> None:
+ try:
+ video_path = self.get_path(video_name, video_subfolder=video_subfolder)
+ if video_path.exists():
+ video_path.unlink()
+
+ thumbnail_name = get_video_thumbnail_name(video_name)
+ thumbnail_path = self.get_path(thumbnail_name, thumbnail=True, video_subfolder=video_subfolder)
+ if thumbnail_path.exists():
+ thumbnail_path.unlink()
+
+ sidecar_path = self.__get_sidecar_path(video_name, video_subfolder=video_subfolder)
+ if sidecar_path.exists():
+ sidecar_path.unlink()
+ except Exception as e:
+ raise VideoFileDeleteException from e
+
+ def get_path(self, video_name: str, thumbnail: bool = False, video_subfolder: str = "") -> Path:
+ base_folder = self.__thumbnails_folder if thumbnail else self.__output_folder
+ filename = get_video_thumbnail_name(video_name) if thumbnail else video_name
+
+ basename = Path(filename).name
+ if basename != filename:
+ raise ValueError("Invalid video name, potential directory traversal detected")
+
+ if video_subfolder:
+ self._validate_subfolder(video_subfolder)
+ video_path = base_folder / video_subfolder / basename
+ else:
+ video_path = base_folder / basename
+
+ resolved_base = base_folder.resolve()
+ resolved_video_path = video_path.resolve()
+ if not resolved_video_path.is_relative_to(resolved_base):
+ raise ValueError("Video path outside outputs folder, potential directory traversal detected")
+ return resolved_video_path
+
+ def get_workflow(self, video_name: str, video_subfolder: str = "") -> Optional[str]:
+ sidecar = self.__read_sidecar(video_name, video_subfolder)
+ if sidecar is None:
+ return None
+ workflow = sidecar.get("invokeai_workflow")
+ return workflow if isinstance(workflow, str) else None
+
+ def get_graph(self, video_name: str, video_subfolder: str = "") -> Optional[str]:
+ sidecar = self.__read_sidecar(video_name, video_subfolder)
+ if sidecar is None:
+ return None
+ graph = sidecar.get("invokeai_graph")
+ return graph if isinstance(graph, str) else None
+
+ def validate_path(self, path: Union[str, Path]) -> bool:
+ path = path if isinstance(path, Path) else Path(path)
+ return path.exists()
+
+ @staticmethod
+ def _validate_subfolder(subfolder: str) -> None:
+ """Validates a subfolder path to prevent directory traversal."""
+ if not subfolder:
+ return
+ if "\\" in subfolder:
+ raise ValueError("Backslashes not allowed in subfolder path")
+ if subfolder.startswith("/"):
+ raise ValueError("Absolute paths not allowed in subfolder path")
+ for part in subfolder.split("/"):
+ if part == "..":
+ raise ValueError("Parent directory references not allowed in subfolder path")
+ if part == "":
+ raise ValueError("Empty path segments not allowed in subfolder path")
+
+ def __get_sidecar_path(self, video_name: str, video_subfolder: str = "") -> Path:
+ sidecar_name = Path(video_name).stem + ".json"
+ if video_subfolder:
+ self._validate_subfolder(video_subfolder)
+ sidecar_path = self.__sidecars_folder / video_subfolder / sidecar_name
+ else:
+ sidecar_path = self.__sidecars_folder / sidecar_name
+ resolved_base = self.__sidecars_folder.resolve()
+ resolved_sidecar_path = sidecar_path.resolve()
+ if not resolved_sidecar_path.is_relative_to(resolved_base):
+ raise ValueError("Sidecar path outside outputs folder, potential directory traversal detected")
+ return resolved_sidecar_path
+
+ def __read_sidecar(self, video_name: str, video_subfolder: str = "") -> Optional[dict]:
+ path = self.__get_sidecar_path(video_name, video_subfolder=video_subfolder)
+ if not path.exists():
+ return None
+ try:
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+ except Exception as e:
+ raise VideoFileNotFoundException from e
+
+ def __validate_storage_folders(self) -> None:
+ for folder in (self.__output_folder, self.__thumbnails_folder, self.__sidecars_folder):
+ folder.mkdir(parents=True, exist_ok=True)
diff --git a/invokeai/app/services/video_records/__init__.py b/invokeai/app/services/video_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/video_records/video_records_base.py b/invokeai/app/services/video_records/video_records_base.py
new file mode 100644
index 00000000000..a334e2a5b66
--- /dev/null
+++ b/invokeai/app/services/video_records/video_records_base.py
@@ -0,0 +1,103 @@
+from abc import ABC, abstractmethod
+from datetime import datetime
+from typing import Optional
+
+from invokeai.app.invocations.fields import MetadataField
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.video_records.video_records_common import (
+ VideoNamesResult,
+ VideoRecord,
+ VideoRecordChanges,
+)
+
+
+class VideoRecordStorageBase(ABC):
+ """Low-level service responsible for interfacing with the video record store."""
+
+ @abstractmethod
+ def get(self, video_name: str) -> VideoRecord:
+ """Gets a video record."""
+ pass
+
+ @abstractmethod
+ def get_metadata(self, video_name: str) -> Optional[MetadataField]:
+ """Gets a video's metadata."""
+ pass
+
+ @abstractmethod
+ def update(self, video_name: str, changes: VideoRecordChanges) -> None:
+ """Updates a video record."""
+ pass
+
+ @abstractmethod
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[VideoRecord]:
+ """Gets a page of video records."""
+ pass
+
+ @abstractmethod
+ def delete(self, video_name: str) -> None:
+ """Deletes a video record."""
+ pass
+
+ @abstractmethod
+ def delete_many(self, video_names: list[str]) -> None:
+ """Deletes many video records."""
+ pass
+
+ @abstractmethod
+ def save(
+ self,
+ video_name: str,
+ video_origin: ResourceOrigin,
+ video_category: ImageCategory,
+ width: int,
+ height: int,
+ duration: float,
+ fps: Optional[float],
+ has_workflow: bool,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ session_id: Optional[str] = None,
+ node_id: Optional[str] = None,
+ metadata: Optional[str] = None,
+ user_id: Optional[str] = None,
+ video_subfolder: str = "",
+ ) -> datetime:
+ """Saves a video record."""
+ pass
+
+ @abstractmethod
+ def get_user_id(self, video_name: str) -> Optional[str]:
+ """Gets the user_id of the video owner. Returns None if video not found."""
+ pass
+
+ @abstractmethod
+ def get_video_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> VideoNamesResult:
+ """Gets ordered list of video names with metadata for optimistic updates."""
+ pass
diff --git a/invokeai/app/services/video_records/video_records_common.py b/invokeai/app/services/video_records/video_records_common.py
new file mode 100644
index 00000000000..d67ae657ecb
--- /dev/null
+++ b/invokeai/app/services/video_records/video_records_common.py
@@ -0,0 +1,137 @@
+import datetime
+from typing import Optional, Union
+
+from pydantic import BaseModel, Field, StrictBool, StrictStr
+
+from invokeai.app.services.image_records.image_records_common import (
+ ImageCategory,
+ ResourceOrigin,
+)
+from invokeai.app.util.misc import get_iso_timestamp
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class VideoRecordNotFoundException(Exception):
+ """Raised when a video record is not found."""
+
+ def __init__(self, message="Video record not found"):
+ super().__init__(message)
+
+
+class VideoRecordSaveException(Exception):
+ """Raised when a video record cannot be saved."""
+
+ def __init__(self, message="Video record not saved"):
+ super().__init__(message)
+
+
+class VideoRecordDeleteException(Exception):
+ """Raised when a video record cannot be deleted."""
+
+ def __init__(self, message="Video record not deleted"):
+ super().__init__(message)
+
+
+VIDEO_DTO_COLS = ", ".join(
+ [
+ "videos." + c
+ for c in [
+ "video_name",
+ "video_origin",
+ "video_category",
+ "width",
+ "height",
+ "duration",
+ "fps",
+ "session_id",
+ "node_id",
+ "has_workflow",
+ "is_intermediate",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "starred",
+ "video_subfolder",
+ ]
+ ]
+)
+
+
+class VideoRecord(BaseModelExcludeNull):
+ """Deserialized video record without metadata."""
+
+ video_name: str = Field(description="The unique name of the video.")
+ video_origin: ResourceOrigin = Field(description="The origin of the video.")
+ video_category: ImageCategory = Field(description="The category of the video (reuses ImageCategory).")
+ width: int = Field(description="The pixel width of the video.")
+ height: int = Field(description="The pixel height of the video.")
+ duration: float = Field(description="The duration of the video in seconds.")
+ fps: Optional[float] = Field(default=None, description="The frames-per-second of the video, if known.")
+ created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the video.")
+ updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the video.")
+ deleted_at: Optional[Union[datetime.datetime, str]] = Field(
+ default=None, description="The deleted timestamp of the video."
+ )
+ is_intermediate: bool = Field(description="Whether this is an intermediate video.")
+ session_id: Optional[str] = Field(default=None, description="The session ID that produced this video, if any.")
+ node_id: Optional[str] = Field(default=None, description="The node ID that produced this video, if any.")
+ starred: bool = Field(description="Whether this video is starred.")
+ has_workflow: bool = Field(description="Whether this video has a workflow associated.")
+ video_subfolder: str = Field(default="", description="The subfolder where the video is stored on disk.")
+
+
+class VideoRecordChanges(BaseModelExcludeNull, extra="allow"):
+ """Allowed mutations on a video record."""
+
+ video_category: Optional[ImageCategory] = Field(default=None, description="The video's new category.")
+ session_id: Optional[StrictStr] = Field(default=None, description="The video's new session ID.")
+ is_intermediate: Optional[StrictBool] = Field(default=None, description="The video's new `is_intermediate` flag.")
+ starred: Optional[StrictBool] = Field(default=None, description="The video's new `starred` state.")
+
+
+def deserialize_video_record(video_dict: dict) -> VideoRecord:
+ """Deserializes a video record from a sqlite row dict."""
+ video_name = video_dict.get("video_name", "unknown")
+ video_origin = ResourceOrigin(video_dict.get("video_origin", ResourceOrigin.INTERNAL.value))
+ video_category = ImageCategory(video_dict.get("video_category", ImageCategory.GENERAL.value))
+ width = video_dict.get("width", 0)
+ height = video_dict.get("height", 0)
+ duration = video_dict.get("duration", 0.0)
+ fps_raw = video_dict.get("fps", None)
+ fps = float(fps_raw) if fps_raw is not None else None
+ session_id = video_dict.get("session_id", None)
+ node_id = video_dict.get("node_id", None)
+ created_at = video_dict.get("created_at", get_iso_timestamp())
+ updated_at = video_dict.get("updated_at", get_iso_timestamp())
+ deleted_at = video_dict.get("deleted_at", None)
+ is_intermediate = video_dict.get("is_intermediate", False)
+ starred = video_dict.get("starred", False)
+ has_workflow = video_dict.get("has_workflow", False)
+ video_subfolder = video_dict.get("video_subfolder", "")
+
+ return VideoRecord(
+ video_name=video_name,
+ video_origin=video_origin,
+ video_category=video_category,
+ width=width,
+ height=height,
+ duration=float(duration),
+ fps=fps,
+ session_id=session_id,
+ node_id=node_id,
+ created_at=created_at,
+ updated_at=updated_at,
+ deleted_at=deleted_at,
+ is_intermediate=is_intermediate,
+ starred=starred,
+ has_workflow=has_workflow,
+ video_subfolder=video_subfolder,
+ )
+
+
+class VideoNamesResult(BaseModel):
+ """Response containing ordered video names with metadata for optimistic updates."""
+
+ video_names: list[str] = Field(description="Ordered list of video names")
+ starred_count: int = Field(description="Number of starred videos (when starred_first=True)")
+ total_count: int = Field(description="Total number of videos matching the query")
diff --git a/invokeai/app/services/video_records/video_records_sqlite.py b/invokeai/app/services/video_records/video_records_sqlite.py
new file mode 100644
index 00000000000..112a0d42aa6
--- /dev/null
+++ b/invokeai/app/services/video_records/video_records_sqlite.py
@@ -0,0 +1,356 @@
+import sqlite3
+from datetime import datetime
+from typing import Optional, Union, cast
+
+from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.app.services.video_records.video_records_base import VideoRecordStorageBase
+from invokeai.app.services.video_records.video_records_common import (
+ VIDEO_DTO_COLS,
+ VideoNamesResult,
+ VideoRecord,
+ VideoRecordChanges,
+ VideoRecordDeleteException,
+ VideoRecordNotFoundException,
+ VideoRecordSaveException,
+ deserialize_video_record,
+)
+
+
+class SqliteVideoRecordStorage(VideoRecordStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def get(self, video_name: str) -> VideoRecord:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ f"""--sql
+ SELECT {VIDEO_DTO_COLS} FROM videos
+ WHERE video_name = ?;
+ """,
+ (video_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ except sqlite3.Error as e:
+ raise VideoRecordNotFoundException from e
+
+ if not result:
+ raise VideoRecordNotFoundException
+ return deserialize_video_record(dict(result))
+
+ def get_user_id(self, video_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT user_id FROM videos
+ WHERE video_name = ?;
+ """,
+ (video_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ if not result:
+ return None
+ return cast(Optional[str], dict(result).get("user_id"))
+
+ def get_metadata(self, video_name: str) -> Optional[MetadataField]:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ SELECT metadata FROM videos
+ WHERE video_name = ?;
+ """,
+ (video_name,),
+ )
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ except sqlite3.Error as e:
+ raise VideoRecordNotFoundException from e
+
+ if not result:
+ raise VideoRecordNotFoundException
+
+ as_dict = dict(result)
+ metadata_raw = cast(Optional[str], as_dict.get("metadata", None))
+ return MetadataFieldValidator.validate_json(metadata_raw) if metadata_raw is not None else None
+
+ def update(self, video_name: str, changes: VideoRecordChanges) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ if changes.video_category is not None:
+ cursor.execute(
+ "UPDATE videos SET video_category = ? WHERE video_name = ?;",
+ (changes.video_category.value, video_name),
+ )
+ if changes.session_id is not None:
+ cursor.execute(
+ "UPDATE videos SET session_id = ? WHERE video_name = ?;",
+ (changes.session_id, video_name),
+ )
+ if changes.is_intermediate is not None:
+ cursor.execute(
+ "UPDATE videos SET is_intermediate = ? WHERE video_name = ?;",
+ (changes.is_intermediate, video_name),
+ )
+ if changes.starred is not None:
+ cursor.execute(
+ "UPDATE videos SET starred = ? WHERE video_name = ?;",
+ (changes.starred, video_name),
+ )
+ except sqlite3.Error as e:
+ raise VideoRecordSaveException from e
+
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[VideoRecord]:
+ with self._db.transaction() as cursor:
+ count_query = """--sql
+ SELECT COUNT(*)
+ FROM videos
+ LEFT JOIN board_videos ON board_videos.video_name = videos.video_name
+ WHERE 1=1
+ """
+ videos_query = f"""--sql
+ SELECT {VIDEO_DTO_COLS}
+ FROM videos
+ LEFT JOIN board_videos ON board_videos.video_name = videos.video_name
+ WHERE 1=1
+ """
+
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if video_origin is not None:
+ query_conditions += " AND videos.video_origin = ? "
+ query_params.append(video_origin.value)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f" AND videos.video_category IN ( {placeholders} ) "
+ for c in category_strings:
+ query_params.append(c)
+
+ if is_intermediate is not None:
+ query_conditions += " AND videos.is_intermediate = ? "
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += " AND board_videos.board_id IS NULL "
+ if user_id is not None and not is_admin:
+ query_conditions += " AND videos.user_id = ? "
+ query_params.append(user_id)
+ elif board_id is not None:
+ query_conditions += " AND board_videos.board_id = ? "
+ query_params.append(board_id)
+ elif user_id is not None and not is_admin:
+ # No board_id supplied — still enforce per-user isolation so
+ # non-admin callers cannot enumerate other users' videos.
+ query_conditions += " AND videos.user_id = ? "
+ query_params.append(user_id)
+
+ if search_term:
+ query_conditions += " AND (videos.metadata LIKE ? OR videos.created_at LIKE ?) "
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ if starred_first:
+ query_pagination = (
+ f" ORDER BY videos.starred DESC, videos.created_at {order_dir.value} LIMIT ? OFFSET ? "
+ )
+ else:
+ query_pagination = f" ORDER BY videos.created_at {order_dir.value} LIMIT ? OFFSET ? "
+
+ videos_query += query_conditions + query_pagination + ";"
+ videos_params = query_params.copy()
+ videos_params.extend([limit, offset])
+ cursor.execute(videos_query, videos_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ videos = [deserialize_video_record(dict(r)) for r in result]
+
+ count_query += query_conditions + ";"
+ cursor.execute(count_query, query_params.copy())
+ count = cast(int, cursor.fetchone()[0])
+
+ return OffsetPaginatedResults(items=videos, offset=offset, limit=limit, total=count)
+
+ def delete(self, video_name: str) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute("DELETE FROM videos WHERE video_name = ?;", (video_name,))
+ except sqlite3.Error as e:
+ raise VideoRecordDeleteException from e
+
+ def delete_many(self, video_names: list[str]) -> None:
+ with self._db.transaction() as cursor:
+ try:
+ placeholders = ",".join("?" for _ in video_names)
+ cursor.execute(f"DELETE FROM videos WHERE video_name IN ({placeholders})", video_names)
+ except sqlite3.Error as e:
+ raise VideoRecordDeleteException from e
+
+ def save(
+ self,
+ video_name: str,
+ video_origin: ResourceOrigin,
+ video_category: ImageCategory,
+ width: int,
+ height: int,
+ duration: float,
+ fps: Optional[float],
+ has_workflow: bool,
+ is_intermediate: Optional[bool] = False,
+ starred: Optional[bool] = False,
+ session_id: Optional[str] = None,
+ node_id: Optional[str] = None,
+ metadata: Optional[str] = None,
+ user_id: Optional[str] = None,
+ video_subfolder: str = "",
+ ) -> datetime:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO videos (
+ video_name,
+ video_origin,
+ video_category,
+ width,
+ height,
+ duration,
+ fps,
+ node_id,
+ session_id,
+ metadata,
+ is_intermediate,
+ starred,
+ has_workflow,
+ user_id,
+ video_subfolder
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
+ """,
+ (
+ video_name,
+ video_origin.value,
+ video_category.value,
+ width,
+ height,
+ float(duration),
+ float(fps) if fps is not None else None,
+ node_id,
+ session_id,
+ metadata,
+ is_intermediate,
+ starred,
+ has_workflow,
+ user_id or "system",
+ video_subfolder,
+ ),
+ )
+
+ cursor.execute(
+ "SELECT created_at FROM videos WHERE video_name = ?;",
+ (video_name,),
+ )
+ created_at = datetime.fromisoformat(cursor.fetchone()[0])
+ except sqlite3.Error as e:
+ raise VideoRecordSaveException from e
+ return created_at
+
+ def get_video_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> VideoNamesResult:
+ with self._db.transaction() as cursor:
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if video_origin is not None:
+ query_conditions += " AND videos.video_origin = ? "
+ query_params.append(video_origin.value)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f" AND videos.video_category IN ( {placeholders} ) "
+ for c in category_strings:
+ query_params.append(c)
+
+ if is_intermediate is not None:
+ query_conditions += " AND videos.is_intermediate = ? "
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += " AND board_videos.board_id IS NULL "
+ if user_id is not None and not is_admin:
+ query_conditions += " AND videos.user_id = ? "
+ query_params.append(user_id)
+ elif board_id is not None:
+ query_conditions += " AND board_videos.board_id = ? "
+ query_params.append(board_id)
+ elif user_id is not None and not is_admin:
+ # No board_id supplied — still enforce per-user isolation so
+ # non-admin callers cannot enumerate other users' videos.
+ query_conditions += " AND videos.user_id = ? "
+ query_params.append(user_id)
+
+ if search_term:
+ query_conditions += " AND (videos.metadata LIKE ? OR videos.created_at LIKE ?) "
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ starred_count = 0
+ if starred_first:
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM videos
+ LEFT JOIN board_videos ON board_videos.video_name = videos.video_name
+ WHERE videos.starred = TRUE AND (1=1{query_conditions})
+ """,
+ query_params,
+ )
+ starred_count = cast(int, cursor.fetchone()[0])
+
+ order_clause = (
+ f" ORDER BY videos.starred DESC, videos.created_at {order_dir.value} "
+ if starred_first
+ else f" ORDER BY videos.created_at {order_dir.value} "
+ )
+ cursor.execute(
+ f"""--sql
+ SELECT videos.video_name
+ FROM videos
+ LEFT JOIN board_videos ON board_videos.video_name = videos.video_name
+ WHERE 1=1{query_conditions}
+ {order_clause}
+ """,
+ query_params,
+ )
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ video_names = [row[0] for row in result]
+ return VideoNamesResult(video_names=video_names, starred_count=starred_count, total_count=len(video_names))
diff --git a/invokeai/app/services/videos/__init__.py b/invokeai/app/services/videos/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/videos/videos_base.py b/invokeai/app/services/videos/videos_base.py
new file mode 100644
index 00000000000..c87dbee4e2f
--- /dev/null
+++ b/invokeai/app/services/videos/videos_base.py
@@ -0,0 +1,147 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Callable, Optional
+
+from invokeai.app.invocations.fields import MetadataField
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.video_records.video_records_common import (
+ VideoNamesResult,
+ VideoRecord,
+ VideoRecordChanges,
+)
+from invokeai.app.services.videos.videos_common import VideoDTO
+
+
+class VideoServiceABC(ABC):
+ """High-level service for video management."""
+
+ _on_changed_callbacks: list[Callable[[VideoDTO], None]]
+ _on_deleted_callbacks: list[Callable[[str], None]]
+
+ def __init__(self) -> None:
+ self._on_changed_callbacks = []
+ self._on_deleted_callbacks = []
+
+ def on_changed(self, on_changed: Callable[[VideoDTO], None]) -> None:
+ """Register a callback for when a video is changed."""
+ self._on_changed_callbacks.append(on_changed)
+
+ def on_deleted(self, on_deleted: Callable[[str], None]) -> None:
+ """Register a callback for when a video is deleted."""
+ self._on_deleted_callbacks.append(on_deleted)
+
+ def _on_changed(self, item: VideoDTO) -> None:
+ for callback in self._on_changed_callbacks:
+ callback(item)
+
+ def _on_deleted(self, item_id: str) -> None:
+ for callback in self._on_deleted_callbacks:
+ callback(item_id)
+
+ @abstractmethod
+ def create(
+ self,
+ source_path: Path,
+ width: int,
+ height: int,
+ duration: float,
+ fps: Optional[float],
+ video_origin: ResourceOrigin,
+ video_category: ImageCategory,
+ node_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ board_id: Optional[str] = None,
+ is_intermediate: Optional[bool] = False,
+ metadata: Optional[str] = None,
+ workflow: Optional[str] = None,
+ graph: Optional[str] = None,
+ user_id: Optional[str] = None,
+ ) -> VideoDTO:
+ """Creates a video by moving/copying the file at `source_path` into storage and recording it."""
+ pass
+
+ @abstractmethod
+ def update(self, video_name: str, changes: VideoRecordChanges) -> VideoDTO:
+ """Updates a video."""
+ pass
+
+ @abstractmethod
+ def get_record(self, video_name: str) -> VideoRecord:
+ """Gets a video record."""
+ pass
+
+ @abstractmethod
+ def get_dto(self, video_name: str) -> VideoDTO:
+ """Gets a video DTO."""
+ pass
+
+ @abstractmethod
+ def get_metadata(self, video_name: str) -> Optional[MetadataField]:
+ """Gets a video's metadata."""
+ pass
+
+ @abstractmethod
+ def get_workflow(self, video_name: str) -> Optional[str]:
+ """Gets a video's workflow."""
+ pass
+
+ @abstractmethod
+ def get_graph(self, video_name: str) -> Optional[str]:
+ """Gets a video's graph."""
+ pass
+
+ @abstractmethod
+ def get_path(self, video_name: str, thumbnail: bool = False) -> str:
+ """Gets a video's on-disk path."""
+ pass
+
+ @abstractmethod
+ def get_url(self, video_name: str, thumbnail: bool = False) -> str:
+ """Gets a video's URL."""
+ pass
+
+ @abstractmethod
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[VideoDTO]:
+ """Gets a paginated list of video DTOs."""
+ pass
+
+ @abstractmethod
+ def delete(self, video_name: str) -> None:
+ """Deletes a video."""
+ pass
+
+ @abstractmethod
+ def delete_videos_on_board(self, board_id: str) -> None:
+ """Deletes all videos on a board."""
+ pass
+
+ @abstractmethod
+ def get_video_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> VideoNamesResult:
+ """Gets ordered list of video names."""
+ pass
diff --git a/invokeai/app/services/videos/videos_common.py b/invokeai/app/services/videos/videos_common.py
new file mode 100644
index 00000000000..bd37bd0366c
--- /dev/null
+++ b/invokeai/app/services/videos/videos_common.py
@@ -0,0 +1,61 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.services.video_records.video_records_common import VideoRecord
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class VideoUrlsDTO(BaseModelExcludeNull):
+ """The URLs for a video and its thumbnail."""
+
+ video_name: str = Field(description="The unique name of the video.")
+ video_url: str = Field(description="The URL of the video file (MP4).")
+ thumbnail_url: str = Field(description="The URL of the video's first-frame thumbnail (WebP).")
+
+
+class VideoDTO(VideoRecord, VideoUrlsDTO):
+ """Deserialized video record, enriched for the frontend."""
+
+ board_id: Optional[str] = Field(
+ default=None, description="The id of the board the video belongs to, if one exists."
+ )
+
+
+def video_record_to_dto(
+ video_record: VideoRecord,
+ video_url: str,
+ thumbnail_url: str,
+ board_id: Optional[str],
+) -> VideoDTO:
+ """Converts a video record to a video DTO."""
+ return VideoDTO(
+ **video_record.model_dump(),
+ video_url=video_url,
+ thumbnail_url=thumbnail_url,
+ board_id=board_id,
+ )
+
+
+class VideoResultWithAffectedBoards(BaseModel):
+ affected_boards: list[str] = Field(description="The ids of boards affected by the operation")
+
+
+class DeleteVideosResult(VideoResultWithAffectedBoards):
+ deleted_videos: list[str] = Field(description="The names of the videos that were deleted")
+
+
+class StarredVideosResult(VideoResultWithAffectedBoards):
+ starred_videos: list[str] = Field(description="The names of the videos that were starred")
+
+
+class UnstarredVideosResult(VideoResultWithAffectedBoards):
+ unstarred_videos: list[str] = Field(description="The names of the videos that were unstarred")
+
+
+class AddVideosToBoardResult(VideoResultWithAffectedBoards):
+ added_videos: list[str] = Field(description="The video names that were added to the board")
+
+
+class RemoveVideosFromBoardResult(VideoResultWithAffectedBoards):
+ removed_videos: list[str] = Field(description="The video names that were removed from their board")
diff --git a/invokeai/app/services/videos/videos_default.py b/invokeai/app/services/videos/videos_default.py
new file mode 100644
index 00000000000..903ae29b8d5
--- /dev/null
+++ b/invokeai/app/services/videos/videos_default.py
@@ -0,0 +1,316 @@
+from pathlib import Path
+from typing import Optional
+
+from invokeai.app.invocations.fields import MetadataField
+from invokeai.app.services.image_records.image_records_common import (
+ ImageCategory,
+ InvalidImageCategoryException,
+ InvalidOriginException,
+ ResourceOrigin,
+)
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.video_files.video_files_common import (
+ VideoFileDeleteException,
+ VideoFileNotFoundException,
+ VideoFileSaveException,
+)
+from invokeai.app.services.video_records.video_records_common import (
+ VideoNamesResult,
+ VideoRecord,
+ VideoRecordChanges,
+ VideoRecordDeleteException,
+ VideoRecordNotFoundException,
+ VideoRecordSaveException,
+)
+from invokeai.app.services.videos.videos_base import VideoServiceABC
+from invokeai.app.services.videos.videos_common import VideoDTO, video_record_to_dto
+
+
+class VideoService(VideoServiceABC):
+ __invoker: Invoker
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def create(
+ self,
+ source_path: Path,
+ width: int,
+ height: int,
+ duration: float,
+ fps: Optional[float],
+ video_origin: ResourceOrigin,
+ video_category: ImageCategory,
+ node_id: Optional[str] = None,
+ session_id: Optional[str] = None,
+ board_id: Optional[str] = None,
+ is_intermediate: Optional[bool] = False,
+ metadata: Optional[str] = None,
+ workflow: Optional[str] = None,
+ graph: Optional[str] = None,
+ user_id: Optional[str] = None,
+ ) -> VideoDTO:
+ if video_origin not in ResourceOrigin:
+ raise InvalidOriginException
+ if video_category not in ImageCategory:
+ raise InvalidImageCategoryException
+
+ video_name = self.__invoker.services.names.create_video_name()
+
+ # Reuse the image subfolder strategy for video organization.
+ from invokeai.app.services.image_files.image_subfolder_strategy import create_subfolder_strategy
+
+ strategy_name = self.__invoker.services.configuration.image_subfolder_strategy
+ strategy = create_subfolder_strategy(strategy_name)
+ video_subfolder = strategy.get_subfolder(video_name, video_category, is_intermediate or False)
+
+ try:
+ self.__invoker.services.video_records.save(
+ video_name=video_name,
+ video_origin=video_origin,
+ video_category=video_category,
+ width=width,
+ height=height,
+ duration=duration,
+ fps=fps,
+ has_workflow=workflow is not None or graph is not None,
+ is_intermediate=is_intermediate,
+ node_id=node_id,
+ metadata=metadata,
+ session_id=session_id,
+ user_id=user_id,
+ video_subfolder=video_subfolder,
+ )
+ if board_id is not None:
+ try:
+ self.__invoker.services.board_video_records.add_video_to_board(
+ board_id=board_id, video_name=video_name
+ )
+ except Exception as e:
+ self.__invoker.services.logger.warning(f"Failed to add video to board {board_id}: {str(e)}")
+
+ self.__invoker.services.video_files.save(
+ source_path=source_path,
+ video_name=video_name,
+ video_subfolder=video_subfolder,
+ metadata=metadata,
+ workflow=workflow,
+ graph=graph,
+ )
+
+ video_dto = self.get_dto(video_name)
+ self._on_changed(video_dto)
+ return video_dto
+ except VideoRecordSaveException:
+ self.__invoker.services.logger.error("Failed to save video record")
+ raise
+ except VideoFileSaveException:
+ self.__invoker.services.logger.error("Failed to save video file")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error(f"Problem saving video record and file: {str(e)}")
+ raise e
+
+ def update(self, video_name: str, changes: VideoRecordChanges) -> VideoDTO:
+ try:
+ self.__invoker.services.video_records.update(video_name, changes)
+ video_dto = self.get_dto(video_name)
+ self._on_changed(video_dto)
+ return video_dto
+ except VideoRecordSaveException:
+ self.__invoker.services.logger.error("Failed to update video record")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem updating video record")
+ raise e
+
+ def get_record(self, video_name: str) -> VideoRecord:
+ try:
+ return self.__invoker.services.video_records.get(video_name)
+ except VideoRecordNotFoundException:
+ self.__invoker.services.logger.error("Video record not found")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video record")
+ raise e
+
+ def get_dto(self, video_name: str) -> VideoDTO:
+ try:
+ video_record = self.__invoker.services.video_records.get(video_name)
+ return video_record_to_dto(
+ video_record=video_record,
+ video_url=self.__invoker.services.urls.get_video_url(video_name),
+ thumbnail_url=self.__invoker.services.urls.get_video_url(video_name, thumbnail=True),
+ board_id=self.__invoker.services.board_video_records.get_board_for_video(video_name),
+ )
+ except VideoRecordNotFoundException:
+ self.__invoker.services.logger.error("Video record not found")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video DTO")
+ raise e
+
+ def get_metadata(self, video_name: str) -> Optional[MetadataField]:
+ try:
+ return self.__invoker.services.video_records.get_metadata(video_name)
+ except VideoRecordNotFoundException:
+ self.__invoker.services.logger.error("Video record not found")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video metadata")
+ raise e
+
+ def get_workflow(self, video_name: str) -> Optional[str]:
+ try:
+ record = self.__invoker.services.video_records.get(video_name)
+ return self.__invoker.services.video_files.get_workflow(video_name, video_subfolder=record.video_subfolder)
+ except VideoFileNotFoundException:
+ self.__invoker.services.logger.error("Video file not found")
+ raise
+ except Exception:
+ self.__invoker.services.logger.error("Problem getting video workflow")
+ raise
+
+ def get_graph(self, video_name: str) -> Optional[str]:
+ try:
+ record = self.__invoker.services.video_records.get(video_name)
+ return self.__invoker.services.video_files.get_graph(video_name, video_subfolder=record.video_subfolder)
+ except VideoFileNotFoundException:
+ self.__invoker.services.logger.error("Video file not found")
+ raise
+ except Exception:
+ self.__invoker.services.logger.error("Problem getting video graph")
+ raise
+
+ def get_path(self, video_name: str, thumbnail: bool = False) -> str:
+ try:
+ record = self.__invoker.services.video_records.get(video_name)
+ return str(
+ self.__invoker.services.video_files.get_path(
+ video_name, thumbnail=thumbnail, video_subfolder=record.video_subfolder
+ )
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video path")
+ raise e
+
+ def get_url(self, video_name: str, thumbnail: bool = False) -> str:
+ try:
+ return self.__invoker.services.urls.get_video_url(video_name, thumbnail=thumbnail)
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video URL")
+ raise e
+
+ def get_many(
+ self,
+ offset: int = 0,
+ limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> OffsetPaginatedResults[VideoDTO]:
+ try:
+ results = self.__invoker.services.video_records.get_many(
+ offset,
+ limit,
+ starred_first,
+ order_dir,
+ video_origin,
+ categories,
+ is_intermediate,
+ board_id,
+ search_term,
+ user_id,
+ is_admin,
+ )
+ video_dtos = [
+ video_record_to_dto(
+ video_record=r,
+ video_url=self.__invoker.services.urls.get_video_url(r.video_name),
+ thumbnail_url=self.__invoker.services.urls.get_video_url(r.video_name, thumbnail=True),
+ board_id=self.__invoker.services.board_video_records.get_board_for_video(r.video_name),
+ )
+ for r in results.items
+ ]
+ return OffsetPaginatedResults[VideoDTO](
+ items=video_dtos, offset=results.offset, limit=results.limit, total=results.total
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting paginated video DTOs")
+ raise e
+
+ def delete(self, video_name: str) -> None:
+ try:
+ record = self.__invoker.services.video_records.get(video_name)
+ self.__invoker.services.video_files.delete(video_name, video_subfolder=record.video_subfolder)
+ self.__invoker.services.video_records.delete(video_name)
+ self._on_deleted(video_name)
+ except VideoRecordDeleteException:
+ self.__invoker.services.logger.error("Failed to delete video record")
+ raise
+ except VideoFileDeleteException:
+ self.__invoker.services.logger.error("Failed to delete video file")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem deleting video record and file")
+ raise e
+
+ def delete_videos_on_board(self, board_id: str) -> None:
+ try:
+ video_names = self.__invoker.services.board_video_records.get_all_board_video_names_for_board(
+ board_id, categories=None, is_intermediate=None
+ )
+ for video_name in video_names:
+ try:
+ record = self.__invoker.services.video_records.get(video_name)
+ self.__invoker.services.video_files.delete(video_name, video_subfolder=record.video_subfolder)
+ except Exception:
+ pass
+ self.__invoker.services.video_records.delete_many(video_names)
+ for video_name in video_names:
+ self._on_deleted(video_name)
+ except VideoRecordDeleteException:
+ self.__invoker.services.logger.error("Failed to delete video records")
+ raise
+ except VideoFileDeleteException:
+ self.__invoker.services.logger.error("Failed to delete video files")
+ raise
+ except Exception as e:
+ self.__invoker.services.logger.error(f"Problem deleting video records and files: {str(e)}")
+ raise e
+
+ def get_video_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ video_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> VideoNamesResult:
+ try:
+ return self.__invoker.services.video_records.get_video_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ video_origin=video_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting video names")
+ raise e
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Image.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Image.json
new file mode 100644
index 00000000000..7975e2c0a56
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Image.json
@@ -0,0 +1,305 @@
+{
+ "id": "default_wan22_i2v_c2d5e1b3-3e4f-5b6c-af7d-8e9f0a1b2c3e",
+ "name": "Image to Image - Wan 2.2",
+ "author": "InvokeAI",
+ "description": "Image-to-image generation with Wan 2.2 I2V A14B. The reference image is VAE-encoded and concatenated to the noise latents each step (the I2V transformer has in_channels=36). Drop a reference image into the 'Reference Image' input, then invoke. Only the I2V A14B variant is supported — T2V and TI2V-5B don't consume reference images.",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, image to image",
+ "notes": "Prerequisite model downloads: a Wan 2.2 I2V A14B main (Diffusers or GGUF expert pair). For GGUF mains, also install the Component Source (Diffusers Wan I2V) OR the standalone Wan VAE + UMT5-XXL encoder. Wan 2.2 I2V was trained for video — at single-frame inference it tends to anchor strongly to the reference. Recommended settings: 30-40 steps and CFG 5-7 (or 4 steps and CFG 1 with the Wan I2V Lightning LoRA pair).",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "fieldName": "image"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 200, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -200 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "invocation",
+ "data": {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "wan_ref_image_encoder",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": { "name": "image", "label": "Reference Image" },
+ "vae": { "name": "vae", "label": "" },
+ "width": { "name": "width", "label": "", "value": 1024 },
+ "height": { "name": "height", "label": "", "value": 1024 }
+ }
+ },
+ "position": { "x": 700, "y": 300 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 550 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "denoise_mask": { "name": "denoise_mask", "label": "" },
+ "denoising_start": { "name": "denoising_start", "label": "", "value": 0 },
+ "denoising_end": { "name": "denoising_end", "label": "", "value": 1 },
+ "add_noise": { "name": "add_noise", "label": "", "value": true },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 5.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)" },
+ "width": { "name": "width", "label": "", "value": 1024 },
+ "height": { "name": "height", "label": "", "value": 1024 },
+ "steps": { "name": "steps", "label": "", "value": 30 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2i",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-denoise",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2i",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-loader-vae-refenc",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-refenc-refimage-denoise",
+ "type": "default",
+ "source": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "ref_image",
+ "targetHandle": "ref_image"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2i",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video Lightning.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video Lightning.json
new file mode 100644
index 00000000000..949af92722d
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video Lightning.json
@@ -0,0 +1,395 @@
+{
+ "id": "default_wan22_i2v_lightning_b6f9c5d7-7a8b-9c0d-ef1f-c03b4c5d6e7f",
+ "name": "Image to Video - Wan 2.2 Lightning",
+ "author": "InvokeAI",
+ "description": "Fast image-to-video generation with Wan 2.2 I2V A14B + the Lightning LoRA pair. The reference image becomes frame 0 of the generated MP4 and the model animates outward from there; the Lightning distillation lets the denoise converge in 4 steps with CFG 1.0 (~20x faster than the standard I2V workflow). The 'Frames' on the Reference Image node must match the value on the Denoise Video node — both default to 81.",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, image to video, video, lightning, lora",
+ "notes": "Prerequisite model downloads: a Wan 2.2 I2V A14B main (Diffusers or GGUF expert pair), plus the matching I2V Lightning LoRA pair (distinct from the T2V Lightning LoRAs). For GGUF mains, also install the Component Source (Diffusers Wan I2V) OR the standalone Wan VAE + UMT5-XXL encoder. The reference image is VAE-encoded across the full latent temporal dim (frame 0 carries the image, remaining frames are zero pixels). If your Lightning LoRAs are untagged, set the 'Apply LoRA (High)' target to 'high' and 'Apply LoRA (Low)' target to 'low' manually. CFG=1.0 skips the negative-conditioning branch; the Negative Prompt content is ignored at runtime.",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "target"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "target"
+ },
+ {
+ "nodeId": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "fieldName": "image"
+ },
+ {
+ "nodeId": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "fieldName": "fps"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 0, "y": 0 }
+ },
+ {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "invocation",
+ "data": {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Apply LoRA (High)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1 },
+ "target": { "name": "target", "label": "", "value": "auto" }
+ }
+ },
+ "position": { "x": 300, "y": 0 }
+ },
+ {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "invocation",
+ "data": {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Apply LoRA (Low)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1 },
+ "target": { "name": "target", "label": "", "value": "auto" }
+ }
+ },
+ "position": { "x": 600, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -300 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt (unused at CFG=1.0)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "invocation",
+ "data": {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "wan_ref_image_encoder",
+ "version": "1.1.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": { "name": "image", "label": "Reference Image" },
+ "vae": { "name": "vae", "label": "" },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 }
+ }
+ },
+ "position": { "x": 700, "y": 400 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 650 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_video_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 1.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)", "value": 1.0 },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 },
+ "steps": { "name": "steps", "label": "", "value": 4 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2v",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" },
+ "fps": { "name": "fps", "label": "FPS", "value": 16 }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-lora1",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora1-lora2",
+ "type": "default",
+ "source": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "target": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora2-denoise",
+ "type": "default",
+ "source": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2v",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-loader-vae-refenc",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-refenc-refimage-denoise",
+ "type": "default",
+ "source": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "ref_image",
+ "targetHandle": "ref_image"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2v",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video.json
new file mode 100644
index 00000000000..e79c2ad1f8e
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Image to Video.json
@@ -0,0 +1,315 @@
+{
+ "id": "default_wan22_i2v_video_e4d7a3b5-5f6a-7b8c-cd9d-ae1f2a3b4c5d",
+ "name": "Image to Video - Wan 2.2",
+ "author": "InvokeAI",
+ "description": "Image-to-video generation with Wan 2.2 I2V A14B. The reference image becomes frame 0 of the generated MP4 and the model animates outward from there. Drop a reference image into the 'Reference Image' input, then invoke. The 'Frames' value on the Reference Image node must match the value on the Denoise Video node — both default to 81 (~5 s @ 16 FPS).",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, image to video, video",
+ "notes": "Prerequisite model downloads: a Wan 2.2 I2V A14B main (Diffusers or GGUF expert pair). For GGUF mains, also install the Component Source (Diffusers Wan I2V) OR the standalone Wan VAE + UMT5-XXL encoder. The reference image is VAE-encoded across the full latent temporal dim (frame 0 carries the image, remaining frames are zero pixels — the model fills them in). Performance note: 81 frames at 832x480 / 40 steps on a 24 GB GPU takes several minutes. Lower `num_frames` (5, 9, 13, ...) and `steps` for quicker iteration. IMPORTANT: keep the `num_frames` on the Reference Image node equal to the value on the Denoise Video node — the denoise loop validates this and will raise if they differ.",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "fieldName": "image"
+ },
+ {
+ "nodeId": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "fieldName": "fps"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 200, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -200 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "invocation",
+ "data": {
+ "id": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "type": "wan_ref_image_encoder",
+ "version": "1.1.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": { "name": "image", "label": "Reference Image" },
+ "vae": { "name": "vae", "label": "" },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 }
+ }
+ },
+ "position": { "x": 700, "y": 300 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 550 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_video_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 5.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)", "value": 4.0 },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 },
+ "steps": { "name": "steps", "label": "", "value": 40 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2v",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" },
+ "fps": { "name": "fps", "label": "FPS", "value": 16 }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-denoise",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2v",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-loader-vae-refenc",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-refenc-refimage-denoise",
+ "type": "default",
+ "source": "7a6edc2d-f38e-a0c1-e27a-8f9dcbb20fce",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "ref_image",
+ "targetHandle": "ref_image"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2v",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Image.json
new file mode 100644
index 00000000000..3fc9395c232
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Image.json
@@ -0,0 +1,264 @@
+{
+ "id": "default_wan22_t2v_b1c4f0a2-2d3e-4a5b-9f6c-7d8e0a1b2c3d",
+ "name": "Text to Image - Wan 2.2",
+ "author": "InvokeAI",
+ "description": "Text-to-image generation with Wan 2.2 (T2V A14B or TI2V-5B). For A14B GGUFs, wire the second-expert transformer into 'Transformer (Low Noise)' and pick a Diffusers Wan as the Component Source (or use standalone VAE + UMT5-XXL encoder). TI2V-5B is a single-transformer model — leave the low-noise slot empty.",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, text to image",
+ "notes": "Prerequisite model downloads: a Wan 2.2 main model (Diffusers or GGUF). For GGUF mains, also install the Component Source (Diffusers Wan) OR the standalone Wan VAE + UMT5-XXL encoder. The Wan 2.2 starter bundle in the Model Manager pulls everything you need for T2V A14B Q4_K_M/Q8_0. Recommended settings: 30-40 steps and CFG 5-7 (or 4 steps and CFG 1 with the Wan Lightning LoRA pair).",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 200, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "a cat" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -200 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 400 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "denoise_mask": { "name": "denoise_mask", "label": "" },
+ "denoising_start": { "name": "denoising_start", "label": "", "value": 0 },
+ "denoising_end": { "name": "denoising_end", "label": "", "value": 1 },
+ "add_noise": { "name": "add_noise", "label": "", "value": true },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 5.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)" },
+ "width": { "name": "width", "label": "", "value": 1024 },
+ "height": { "name": "height", "label": "", "value": 1024 },
+ "steps": { "name": "steps", "label": "", "value": 30 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2i",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-denoise",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2i",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2i",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video (Lightning).json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video (Lightning).json
new file mode 100644
index 00000000000..43df9283ecb
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video (Lightning).json
@@ -0,0 +1,331 @@
+{
+ "id": "default_wan22_t2v_video_lightning_f5e8b4c6-6a7b-8c9d-de1e-bf2a3b4c5d6e",
+ "name": "Text to Video - Wan 2.2 Lightning",
+ "author": "InvokeAI",
+ "description": "Faster text-to-video with the Wan 2.2 Lightning LoRA pair. Runs at 4 denoising steps with CFG disabled (1.0), which skips the negative-conditioning branch — roughly 20× faster than the default 'Text to Video - Wan 2.2' workflow at similar quality. Requires the Wan 2.2 T2V A14B Lightning LoRAs (one for each MoE expert).",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, text to video, video, lightning, lora",
+ "notes": "Prerequisite models: a Wan 2.2 T2V A14B main (Diffusers or GGUF expert pair) PLUS the matching Wan 2.2 T2V Lightning LoRA pair (high-noise + low-noise). Wire the high-noise Lightning LoRA into the first 'Apply LoRA - Wan 2.2' node and the low-noise one into the second; if the LoRAs are properly tagged with their expert ('high'/'low'), leave the 'target' dropdown on 'auto'. Each LoRA's weight defaults to 1.0; lower it if the output looks over-baked. Don't change steps below 4 or raise guidance above 1.0 — that's where the Lightning distillation lives.",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "fieldName": "fps"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 0, "y": 0 }
+ },
+ {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "invocation",
+ "data": {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Lightning LoRA (High)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1.0 },
+ "target": { "name": "target", "label": "", "value": "auto" },
+ "transformer": { "name": "transformer", "label": "" }
+ }
+ },
+ "position": { "x": 230, "y": 0 }
+ },
+ {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "invocation",
+ "data": {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Lightning LoRA (Low)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1.0 },
+ "target": { "name": "target", "label": "", "value": "auto" },
+ "transformer": { "name": "transformer", "label": "" }
+ }
+ },
+ "position": { "x": 460, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "a cat walking through a field of tall grass, cinematic" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -200 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt (unused at CFG=1)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 400 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_video_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 1.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)", "value": 1.0 },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 },
+ "steps": { "name": "steps", "label": "", "value": 4 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2v",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" },
+ "fps": { "name": "fps", "label": "FPS", "value": 16 }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-lora-high",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora-high-lora-low",
+ "type": "default",
+ "source": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "target": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora-low-denoise",
+ "type": "default",
+ "source": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2v",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2v",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video Lightning.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video Lightning.json
new file mode 100644
index 00000000000..af4605ba83d
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video Lightning.json
@@ -0,0 +1,349 @@
+{
+ "id": "default_wan22_t2v_lightning_a5e8b4c6-6f7a-8b9c-de0e-bf2a3b4c5d6e",
+ "name": "Text to Video - Wan 2.2 Lightning",
+ "author": "InvokeAI",
+ "description": "Fast text-to-video generation with Wan 2.2 T2V A14B + the Lightning LoRA pair. Distillation lets the model converge in 4 steps with CFG 1.0 (no negative branch), roughly 20x faster than the standard T2V workflow at the cost of some image quality. Pick the high-noise Lightning LoRA on 'Apply LoRA (High)' and the low-noise one on 'Apply LoRA (Low)' — the 'auto' target routes each to the right expert when the LoRAs are tagged. Defaults: 832x480, 81 frames @ 16 FPS (~5 s).",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, text to video, video, lightning, lora",
+ "notes": "Prerequisite model downloads: a Wan 2.2 T2V A14B main model (Diffusers or GGUF expert pair), plus the matching Lightning LoRA pair (e.g. LightX2V's 4-step distillation). The Lightning LoRAs are A14B-specific — distinct LoRAs exist for T2V and I2V variants. If your Lightning LoRAs are untagged (no 'expert' field), use the LoRA loader's 'target' dropdown to manually route the first to 'high' and the second to 'low'. CFG=1.0 skips the negative-conditioning branch, so the Negative Prompt content is ignored at runtime.",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "fieldName": "target"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "lora"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "weight"
+ },
+ {
+ "nodeId": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "fieldName": "target"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "fieldName": "fps"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 0, "y": 0 }
+ },
+ {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "invocation",
+ "data": {
+ "id": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Apply LoRA (High)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1 },
+ "target": { "name": "target", "label": "", "value": "auto" }
+ }
+ },
+ "position": { "x": 300, "y": 0 }
+ },
+ {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "invocation",
+ "data": {
+ "id": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "type": "wan_lora_loader",
+ "version": "1.0.0",
+ "label": "Apply LoRA (Low)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "lora": { "name": "lora", "label": "" },
+ "weight": { "name": "weight", "label": "", "value": 1 },
+ "target": { "name": "target", "label": "", "value": "auto" }
+ }
+ },
+ "position": { "x": 600, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "a cat walking through a field of tall grass, cinematic" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -300 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt (unused at CFG=1.0)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 200 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 500 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_video_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 1.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)", "value": 1.0 },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 },
+ "steps": { "name": "steps", "label": "", "value": 4 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2v",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" },
+ "fps": { "name": "fps", "label": "FPS", "value": 16 }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-lora1",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora1-lora2",
+ "type": "default",
+ "source": "3466ad11-a931-4adc-b394-9ec287f0c23b",
+ "target": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-lora2-denoise",
+ "type": "default",
+ "source": "9b4cde65-699f-42c1-8889-76ac91fcb62f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2v",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2v",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video.json b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video.json
new file mode 100644
index 00000000000..4a0b26e7134
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Wan 2.2 Text to Video.json
@@ -0,0 +1,269 @@
+{
+ "id": "default_wan22_t2v_video_d3c6f2a4-4e5f-6a7b-bf8c-9d0e1f2a3b4c",
+ "name": "Text to Video - Wan 2.2",
+ "author": "InvokeAI",
+ "description": "Text-to-video generation with Wan 2.2 T2V A14B. Produces an MP4 of `num_frames` frames at `fps` frames-per-second (defaults: 81 frames @ 16 FPS = ~5 s, 832x480). The output lands in the gallery alongside images and plays inline in the viewer. Only the T2V-A14B variant is supported by this workflow; for image generation use the 'Text to Image - Wan 2.2' workflow.",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "wan2.2, text to video, video",
+ "notes": "Prerequisite model downloads: a Wan 2.2 T2V A14B main model (Diffusers or GGUF expert pair). For GGUF mains, also install the Component Source (Diffusers Wan) OR the standalone Wan VAE + UMT5-XXL encoder. The Wan 2.2 starter bundle in the Model Manager pulls everything you need for T2V A14B. Performance note: video generation is GPU- and time-intensive — 81 frames at 832x480 / 40 steps on a 24 GB GPU takes several minutes. Lower `num_frames` (5, 9, 13, ...) and `steps` for quicker iteration. `num_frames` must satisfy (num_frames - 1) %% 4 == 0 due to the Wan VAE's 4x temporal compression.",
+ "exposedFields": [
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "transformer_low_noise_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "component_source"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "fieldName": "wan_t5_encoder_model"
+ },
+ {
+ "nodeId": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "steps"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "guidance_scale_low_noise"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "width"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "height"
+ },
+ {
+ "nodeId": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "fieldName": "num_frames"
+ },
+ {
+ "nodeId": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "fieldName": "fps"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "invocation",
+ "data": {
+ "id": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "type": "wan_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": { "name": "model", "label": "" },
+ "transformer_low_noise_model": { "name": "transformer_low_noise_model", "label": "" },
+ "vae_model": { "name": "vae_model", "label": "" },
+ "wan_t5_encoder_model": { "name": "wan_t5_encoder_model", "label": "" },
+ "component_source": { "name": "component_source", "label": "" }
+ }
+ },
+ "position": { "x": 200, "y": 0 }
+ },
+ {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "invocation",
+ "data": {
+ "id": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Positive Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": "a cat walking through a field of tall grass, cinematic" },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": -200 }
+ },
+ {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "invocation",
+ "data": {
+ "id": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "type": "wan_text_encoder",
+ "version": "1.0.0",
+ "label": "Negative Prompt",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": { "name": "prompt", "label": "", "value": " " },
+ "wan_t5_encoder": { "name": "wan_t5_encoder", "label": "" }
+ }
+ },
+ "position": { "x": 700, "y": 100 }
+ },
+ {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "invocation",
+ "data": {
+ "id": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": { "name": "low", "label": "", "value": 0 },
+ "high": { "name": "high", "label": "", "value": 2147483647 }
+ }
+ },
+ "position": { "x": 700, "y": 400 }
+ },
+ {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "invocation",
+ "data": {
+ "id": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "type": "wan_video_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "transformer": { "name": "transformer", "label": "" },
+ "positive_conditioning": { "name": "positive_conditioning", "label": "" },
+ "negative_conditioning": { "name": "negative_conditioning", "label": "" },
+ "ref_image": { "name": "ref_image", "label": "" },
+ "guidance_scale": { "name": "guidance_scale", "label": "CFG", "value": 5.0 },
+ "guidance_scale_low_noise": { "name": "guidance_scale_low_noise", "label": "CFG (Low)", "value": 4.0 },
+ "width": { "name": "width", "label": "", "value": 832 },
+ "height": { "name": "height", "label": "", "value": 480 },
+ "num_frames": { "name": "num_frames", "label": "Frames", "value": 81 },
+ "steps": { "name": "steps", "label": "", "value": 40 },
+ "seed": { "name": "seed", "label": "", "value": 0 }
+ }
+ },
+ "position": { "x": 1100, "y": -50 }
+ },
+ {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "invocation",
+ "data": {
+ "id": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "type": "wan_l2v",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": { "name": "board", "label": "" },
+ "metadata": { "name": "metadata", "label": "" },
+ "latents": { "name": "latents", "label": "" },
+ "vae": { "name": "vae", "label": "" },
+ "fps": { "name": "fps", "label": "FPS", "value": 16 }
+ }
+ },
+ "position": { "x": 1550, "y": -50 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "edge-loader-transformer-denoise",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "edge-loader-t5-pos",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-t5-neg",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "sourceHandle": "wan_t5_encoder",
+ "targetHandle": "wan_t5_encoder"
+ },
+ {
+ "id": "edge-loader-vae-l2v",
+ "type": "default",
+ "source": "1a0e6c7b-9d2f-4b3c-8e1a-2f3d4c5b6a7e",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "edge-pos-cond-denoise",
+ "type": "default",
+ "source": "2b1f7d8c-ae3f-5c4d-9f2b-3a4e5d6c7b8f",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "edge-neg-cond-denoise",
+ "type": "default",
+ "source": "3c2a8e9d-bf4a-6d5e-af3c-4b5f6e7d8c9a",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "edge-rand-seed-denoise",
+ "type": "default",
+ "source": "5e4cab0b-d16c-8faf-c05e-6d7baf90ebbc",
+ "target": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "edge-denoise-latents-l2v",
+ "type": "default",
+ "source": "4d3b9faf-c05b-7e6f-bf4d-5c6a7f8e9dab",
+ "target": "6f5dcb1c-e27d-9fb0-d16f-7e8cbaa1fcbd",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ }
+ ]
+}
diff --git a/invokeai/app/util/step_callback.py b/invokeai/app/util/step_callback.py
index 08dc9a2265c..9364ec9b8ce 100644
--- a/invokeai/app/util/step_callback.py
+++ b/invokeai/app/util/step_callback.py
@@ -179,6 +179,84 @@
ANIMA_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
+# Wan 2.2 A14B uses the standard 16-channel Wan VAE.
+# Factors come from ComfyUI's Wan21 latent_format (same VAE as A14B).
+WAN_LATENT_RGB_FACTORS = [
+ [-0.1299, -0.1692, 0.2932],
+ [0.0671, 0.0406, 0.0442],
+ [0.3568, 0.2548, 0.1747],
+ [0.0372, 0.2344, 0.1420],
+ [0.0313, 0.0189, -0.0328],
+ [0.0296, -0.0956, -0.0665],
+ [-0.3477, -0.4059, -0.2925],
+ [0.0166, 0.1902, 0.1975],
+ [-0.0412, 0.0267, -0.1364],
+ [-0.1293, 0.0740, 0.1636],
+ [0.0680, 0.3019, 0.1128],
+ [0.0032, 0.0581, 0.0639],
+ [-0.1251, 0.0927, 0.1699],
+ [0.0060, -0.0633, 0.0005],
+ [0.3477, 0.2275, 0.2950],
+ [0.1984, 0.0913, 0.1861],
+]
+
+WAN_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
+
+# Wan 2.2 TI2V-5B uses Wan2.2-VAE with 48 latent channels and 16x spatial downscale.
+# Factors come from ComfyUI's Wan22 latent_format.
+WAN22_LATENT_RGB_FACTORS = [
+ [0.0119, 0.0103, 0.0046],
+ [-0.1062, -0.0504, 0.0165],
+ [0.0140, 0.0409, 0.0491],
+ [-0.0813, -0.0677, 0.0607],
+ [0.0656, 0.0851, 0.0808],
+ [0.0264, 0.0463, 0.0912],
+ [0.0295, 0.0326, 0.0590],
+ [-0.0244, -0.0270, 0.0025],
+ [0.0443, -0.0102, 0.0288],
+ [-0.0465, -0.0090, -0.0205],
+ [0.0359, 0.0236, 0.0082],
+ [-0.0776, 0.0854, 0.1048],
+ [0.0564, 0.0264, 0.0561],
+ [0.0006, 0.0594, 0.0418],
+ [-0.0319, -0.0542, -0.0637],
+ [-0.0268, 0.0024, 0.0260],
+ [0.0539, 0.0265, 0.0358],
+ [-0.0359, -0.0312, -0.0287],
+ [-0.0285, -0.1032, -0.1237],
+ [0.1041, 0.0537, 0.0622],
+ [-0.0086, -0.0374, -0.0051],
+ [0.0390, 0.0670, 0.2863],
+ [0.0069, 0.0144, 0.0082],
+ [0.0006, -0.0167, 0.0079],
+ [0.0313, -0.0574, -0.0232],
+ [-0.1454, -0.0902, -0.0481],
+ [0.0714, 0.0827, 0.0447],
+ [-0.0304, -0.0574, -0.0196],
+ [0.0401, 0.0384, 0.0204],
+ [-0.0758, -0.0297, -0.0014],
+ [0.0568, 0.1307, 0.1372],
+ [-0.0055, -0.0310, -0.0380],
+ [0.0239, -0.0305, 0.0325],
+ [-0.0663, -0.0673, -0.0140],
+ [-0.0416, -0.0047, -0.0023],
+ [0.0166, 0.0112, -0.0093],
+ [-0.0211, 0.0011, 0.0331],
+ [0.1833, 0.1466, 0.2250],
+ [-0.0368, 0.0370, 0.0295],
+ [-0.3441, -0.3543, -0.2008],
+ [-0.0479, -0.0489, -0.0420],
+ [-0.0660, -0.0153, 0.0800],
+ [-0.0101, 0.0068, 0.0156],
+ [-0.0690, -0.0452, -0.0927],
+ [-0.0145, 0.0041, 0.0015],
+ [0.0421, 0.0451, 0.0373],
+ [0.0504, -0.0483, -0.0356],
+ [-0.0837, 0.0168, 0.0055],
+]
+
+WAN22_LATENT_RGB_BIAS = [0.0317, -0.0878, -0.1388]
+
def sample_to_lowres_estimated_image(
samples: torch.Tensor,
@@ -270,6 +348,15 @@ def diffusion_step_callback(
# Anima uses Wan 2.1 VAE with 16 latent channels
latent_rgb_factors = ANIMA_LATENT_RGB_FACTORS
latent_rgb_bias = ANIMA_LATENT_RGB_BIAS
+ elif base_model == BaseModelType.Wan:
+ # A14B (16-ch standard Wan VAE, 8x spatial) vs TI2V-5B (48-ch Wan2.2-VAE,
+ # 16x spatial). The latent channel count uniquely identifies the variant.
+ if sample.shape[-3] == 48:
+ latent_rgb_factors = WAN22_LATENT_RGB_FACTORS
+ latent_rgb_bias = WAN22_LATENT_RGB_BIAS
+ else:
+ latent_rgb_factors = WAN_LATENT_RGB_FACTORS
+ latent_rgb_bias = WAN_LATENT_RGB_BIAS
else:
raise ValueError(f"Unsupported base model: {base_model}")
@@ -287,8 +374,13 @@ def diffusion_step_callback(
latent_rgb_bias=latent_rgb_bias_torch,
)
- width = image.width * 8
- height = image.height * 8
+ # Spatial downscale ratio: 8x is the SD/SDXL/FLUX/Wan-A14B default;
+ # Wan TI2V-5B's Wan2.2-VAE uses 16x.
+ spatial_scale = 8
+ if base_model == BaseModelType.Wan and sample.shape[-3] == 48:
+ spatial_scale = 16
+ width = image.width * spatial_scale
+ height = image.height * spatial_scale
percentage = calc_percentage(intermediate_state)
signal_progress("Denoising", percentage, image, (width, height))
diff --git a/invokeai/app/util/video_thumbnails.py b/invokeai/app/util/video_thumbnails.py
new file mode 100644
index 00000000000..db3783039c4
--- /dev/null
+++ b/invokeai/app/util/video_thumbnails.py
@@ -0,0 +1,100 @@
+"""Video frame/probe helpers used by the video file store.
+
+The primary backend is imageio's FFMPEG plugin (the same one ``wan_l2v`` uses
+to *encode* output MP4s — so reading our own output is guaranteed to work).
+We fall back to ``cv2.VideoCapture`` only if imageio fails; cv2 wheels have
+historically hung on certain codec/container combinations, so we never rely
+on it as the primary path.
+"""
+
+import os
+from pathlib import Path
+from typing import Optional
+
+import imageio.v3 as iio
+from PIL import Image
+
+
+def get_video_thumbnail_name(video_name: str) -> str:
+ """Given a video file name (e.g. .mp4), returns the matching thumbnail name (e.g. .webp)."""
+ return os.path.splitext(video_name)[0] + ".webp"
+
+
+def extract_video_frame(video_path: Path, frame_index: int = 0) -> Optional[Image.Image]:
+ """Extracts a single frame from a video file as a PIL Image. Returns None on failure.
+
+ Tries imageio's FFMPEG plugin first since it's the same encoder we use for
+ output, then falls back to cv2 (with a controlled context that can't hang
+ silently — at worst it raises and we return None).
+ """
+ try:
+ # iio.imread with index=N seeks to that frame directly. Returns RGB HxWxC uint8.
+ frame = iio.imread(video_path, plugin="FFMPEG", index=frame_index)
+ return Image.fromarray(frame)
+ except Exception:
+ pass
+
+ # Fallback: cv2.VideoCapture. Only used if imageio couldn't decode the file
+ # — uploaded videos with unusual codecs may need this path.
+ try:
+ import cv2 # local import so the imageio-only path doesn't pay the cv2 import cost
+
+ capture = cv2.VideoCapture(str(video_path))
+ if not capture.isOpened():
+ capture.release()
+ return None
+ try:
+ if frame_index > 0:
+ capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
+ ok, frame_bgr = capture.read()
+ if not ok or frame_bgr is None:
+ return None
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
+ return Image.fromarray(frame_rgb)
+ finally:
+ capture.release()
+ except Exception:
+ return None
+
+
+def probe_video(video_path: Path) -> tuple[int, int, float, Optional[float]]:
+ """Returns (width, height, duration_seconds, fps_or_none) for a video file.
+
+ Tries imageio's FFMPEG plugin first; falls back to cv2.VideoCapture. Raises
+ FileNotFoundError if neither backend can read the file.
+ """
+ try:
+ meta = iio.immeta(video_path, plugin="FFMPEG")
+ fps_raw = meta.get("fps")
+ duration = float(meta.get("duration", 0.0)) if meta.get("duration") is not None else 0.0
+ size = meta.get("size")
+ if size is None:
+ # Fall through to cv2 — imageio didn't give us dimensions.
+ raise ValueError("imageio probe missing 'size'")
+ width, height = int(size[0]), int(size[1])
+ fps: Optional[float] = float(fps_raw) if fps_raw and fps_raw > 0 else None
+ return width, height, duration, fps
+ except Exception:
+ pass
+
+ try:
+ import cv2
+
+ capture = cv2.VideoCapture(str(video_path))
+ if not capture.isOpened():
+ capture.release()
+ raise FileNotFoundError(f"Unable to open video at {video_path}")
+ try:
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
+ fps_raw = capture.get(cv2.CAP_PROP_FPS)
+ fps_v2: Optional[float] = float(fps_raw) if fps_raw and fps_raw > 0 else None
+ duration = (frame_count / fps_v2) if (fps_v2 and frame_count > 0) else 0.0
+ finally:
+ capture.release()
+ return width, height, duration, fps_v2
+ except FileNotFoundError:
+ raise
+ except Exception as e:
+ raise FileNotFoundError(f"Unable to open video at {video_path}") from e
diff --git a/invokeai/backend/model_manager/configs/factory.py b/invokeai/backend/model_manager/configs/factory.py
index 985cb982d30..feedafe6c38 100644
--- a/invokeai/backend/model_manager/configs/factory.py
+++ b/invokeai/backend/model_manager/configs/factory.py
@@ -54,6 +54,7 @@
LoRA_LyCORIS_SD1_Config,
LoRA_LyCORIS_SD2_Config,
LoRA_LyCORIS_SDXL_Config,
+ LoRA_LyCORIS_Wan_Config,
LoRA_LyCORIS_ZImage_Config,
LoRA_OMI_FLUX_Config,
LoRA_OMI_SDXL_Config,
@@ -78,10 +79,12 @@
Main_Diffusers_SD3_Config,
Main_Diffusers_SDXL_Config,
Main_Diffusers_SDXLRefiner_Config,
+ Main_Diffusers_Wan_Config,
Main_Diffusers_ZImage_Config,
Main_GGUF_Flux2_Config,
Main_GGUF_FLUX_Config,
Main_GGUF_QwenImage_Config,
+ Main_GGUF_Wan_Config,
Main_GGUF_ZImage_Config,
MainModelDefaultSettings,
)
@@ -119,10 +122,13 @@
VAE_Checkpoint_SD1_Config,
VAE_Checkpoint_SD2_Config,
VAE_Checkpoint_SDXL_Config,
+ VAE_Checkpoint_Wan_Config,
VAE_Diffusers_Flux2_Config,
VAE_Diffusers_SD1_Config,
VAE_Diffusers_SDXL_Config,
+ VAE_Diffusers_Wan_Config,
)
+from invokeai.backend.model_manager.configs.wan_t5_encoder import WanT5Encoder_WanT5Encoder_Config
from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
@@ -173,6 +179,7 @@
Annotated[Main_Diffusers_Flux2_Config, Main_Diffusers_Flux2_Config.get_tag()],
Annotated[Main_Diffusers_CogView4_Config, Main_Diffusers_CogView4_Config.get_tag()],
Annotated[Main_Diffusers_QwenImage_Config, Main_Diffusers_QwenImage_Config.get_tag()],
+ Annotated[Main_Diffusers_Wan_Config, Main_Diffusers_Wan_Config.get_tag()],
Annotated[Main_Diffusers_ZImage_Config, Main_Diffusers_ZImage_Config.get_tag()],
# Main (Pipeline) - checkpoint format
# IMPORTANT: FLUX.2 must be checked BEFORE FLUX.1 because FLUX.2 has specific validation
@@ -192,6 +199,7 @@
Annotated[Main_GGUF_Flux2_Config, Main_GGUF_Flux2_Config.get_tag()],
Annotated[Main_GGUF_FLUX_Config, Main_GGUF_FLUX_Config.get_tag()],
Annotated[Main_GGUF_QwenImage_Config, Main_GGUF_QwenImage_Config.get_tag()],
+ Annotated[Main_GGUF_Wan_Config, Main_GGUF_Wan_Config.get_tag()],
Annotated[Main_GGUF_ZImage_Config, Main_GGUF_ZImage_Config.get_tag()],
# VAE - checkpoint format
Annotated[VAE_Checkpoint_SD1_Config, VAE_Checkpoint_SD1_Config.get_tag()],
@@ -199,12 +207,18 @@
Annotated[VAE_Checkpoint_SDXL_Config, VAE_Checkpoint_SDXL_Config.get_tag()],
Annotated[VAE_Checkpoint_FLUX_Config, VAE_Checkpoint_FLUX_Config.get_tag()],
Annotated[VAE_Checkpoint_Flux2_Config, VAE_Checkpoint_Flux2_Config.get_tag()],
+ # IMPORTANT: VAE_Checkpoint_Wan_Config must be checked BEFORE QwenImage —
+ # both share the AutoencoderKLWan architecture and the Wan config relies
+ # on a filename heuristic to claim 16-channel files; ordering here lets
+ # Wan win when the filename suggests it.
+ Annotated[VAE_Checkpoint_Wan_Config, VAE_Checkpoint_Wan_Config.get_tag()],
Annotated[VAE_Checkpoint_QwenImage_Config, VAE_Checkpoint_QwenImage_Config.get_tag()],
Annotated[VAE_Checkpoint_Anima_Config, VAE_Checkpoint_Anima_Config.get_tag()],
# VAE - diffusers format
Annotated[VAE_Diffusers_SD1_Config, VAE_Diffusers_SD1_Config.get_tag()],
Annotated[VAE_Diffusers_SDXL_Config, VAE_Diffusers_SDXL_Config.get_tag()],
Annotated[VAE_Diffusers_Flux2_Config, VAE_Diffusers_Flux2_Config.get_tag()],
+ Annotated[VAE_Diffusers_Wan_Config, VAE_Diffusers_Wan_Config.get_tag()],
# ControlNet - checkpoint format
Annotated[ControlNet_Checkpoint_SD1_Config, ControlNet_Checkpoint_SD1_Config.get_tag()],
Annotated[ControlNet_Checkpoint_SD2_Config, ControlNet_Checkpoint_SD2_Config.get_tag()],
@@ -226,6 +240,13 @@
Annotated[LoRA_LyCORIS_FLUX_Config, LoRA_LyCORIS_FLUX_Config.get_tag()],
Annotated[LoRA_LyCORIS_ZImage_Config, LoRA_LyCORIS_ZImage_Config.get_tag()],
Annotated[LoRA_LyCORIS_QwenImage_Config, LoRA_LyCORIS_QwenImage_Config.get_tag()],
+ # Wan and Anima both target ``blocks.X`` shapes; their LoRA probes are
+ # mutually exclusive — Wan rejects Anima's ``_proj``/``mlp``/
+ # ``adaln_modulation`` markers, Anima requires at least one of those
+ # markers (see ``has_cosmos_dit_*_keys_strict``). Order between these
+ # two doesn't affect correctness; mutual exclusivity is locked in by
+ # ``test_wan_lora_probe_independence.py``.
+ Annotated[LoRA_LyCORIS_Wan_Config, LoRA_LyCORIS_Wan_Config.get_tag()],
Annotated[LoRA_LyCORIS_Anima_Config, LoRA_LyCORIS_Anima_Config.get_tag()],
# LoRA - OMI format
Annotated[LoRA_OMI_SDXL_Config, LoRA_OMI_SDXL_Config.get_tag()],
@@ -251,6 +272,8 @@
# Qwen VL Encoder (Qwen2.5-VL multimodal encoder for Qwen Image)
Annotated[QwenVLEncoder_Diffusers_Config, QwenVLEncoder_Diffusers_Config.get_tag()],
Annotated[QwenVLEncoder_Checkpoint_Config, QwenVLEncoder_Checkpoint_Config.get_tag()],
+ # Wan T5 Encoder (UMT5-XXL for Wan 2.2)
+ Annotated[WanT5Encoder_WanT5Encoder_Config, WanT5Encoder_WanT5Encoder_Config.get_tag()],
# TI - file format
Annotated[TI_File_SD1_Config, TI_File_SD1_Config.get_tag()],
Annotated[TI_File_SD2_Config, TI_File_SD2_Config.get_tag()],
diff --git a/invokeai/backend/model_manager/configs/lora.py b/invokeai/backend/model_manager/configs/lora.py
index 46606a3c0d5..d0372ba3f14 100644
--- a/invokeai/backend/model_manager/configs/lora.py
+++ b/invokeai/backend/model_manager/configs/lora.py
@@ -28,14 +28,23 @@
FluxLoRAFormat,
ModelFormat,
ModelType,
+ WanLoRAVariantType,
ZImageVariantType,
)
from invokeai.backend.model_manager.util.model_util import lora_token_vector_length
from invokeai.backend.patches.lora_conversions.anima_lora_constants import (
has_cosmos_dit_kohya_keys,
+ has_cosmos_dit_kohya_keys_strict,
has_cosmos_dit_peft_keys,
+ has_cosmos_dit_peft_keys_strict,
)
from invokeai.backend.patches.lora_conversions.flux_control_lora_utils import is_state_dict_likely_flux_control
+from invokeai.backend.patches.lora_conversions.wan_lora_constants import (
+ detect_wan_lora_variant,
+ has_non_wan_architecture_keys,
+ has_wan_kohya_keys,
+ has_wan_peft_keys,
+)
class LoraModelDefaultSettings(BaseModel):
@@ -885,16 +894,20 @@ def _validate_looks_like_lora(cls, mod: ModelOnDisk) -> None:
Anima LoRAs have keys like:
- lora_unet_blocks_0_cross_attn_k_proj.lora_down.weight (Kohya format)
- diffusion_model.blocks.0.cross_attn.k_proj.lora_A.weight (diffusers PEFT format)
- - transformer.blocks.0.cross_attn.k_proj.lora_A.weight (diffusers PEFT format)
-
- Detection requires Cosmos DiT-specific subcomponent names (cross_attn,
- self_attn, mlp, adaln_modulation) to avoid false-positives on other
- architectures that also use ``blocks`` in their paths.
+ - transformer.blocks.0.mlp.layer_0.lora_A.weight (Anima-only MLP layer)
+
+ Uses the **strict** Cosmos-DiT detectors, which require an
+ Anima-exclusive subcomponent name (``mlp``, ``adaln_modulation``, or
+ ``_proj``-suffixed attention). The loose detectors would also accept
+ Wan-native LoRAs (which use ``cross_attn``/``self_attn`` too but with
+ bare ``.q``/``.k``/``.v``/``.o`` rather than ``_proj``), so they're not
+ safe for first-match-wins probing — see the regression tests in
+ ``test_wan_lora_probe_independence.py``.
"""
state_dict = mod.load_state_dict()
str_keys = [k for k in state_dict.keys() if isinstance(k, str)]
- has_cosmos_keys = has_cosmos_dit_kohya_keys(str_keys) or has_cosmos_dit_peft_keys(str_keys)
+ has_cosmos_keys = has_cosmos_dit_kohya_keys_strict(str_keys) or has_cosmos_dit_peft_keys_strict(str_keys)
# Also check for LoRA/LoKR weight suffixes
has_lora_suffix = state_dict_has_any_keys_ending_with(
@@ -917,19 +930,112 @@ def _validate_looks_like_lora(cls, mod: ModelOnDisk) -> None:
@classmethod
def _get_base_or_raise(cls, mod: ModelOnDisk) -> BaseModelType:
- """Anima LoRAs target Cosmos DiT blocks (blocks.X.cross_attn, blocks.X.self_attn, etc.).
+ """Anima LoRAs target Cosmos DiT blocks (blocks.X.mlp, blocks.X.adaln_modulation,
+ blocks.X.cross_attn.q_proj, etc.).
- Uses Cosmos DiT-specific subcomponent names to avoid false-positives.
+ Uses the strict Cosmos-DiT detectors to be mutually exclusive with
+ Wan-LoRA detection — see ``_validate_looks_like_lora`` for rationale.
"""
state_dict = mod.load_state_dict()
str_keys = [k for k in state_dict.keys() if isinstance(k, str)]
- if has_cosmos_dit_kohya_keys(str_keys) or has_cosmos_dit_peft_keys(str_keys):
+ if has_cosmos_dit_kohya_keys_strict(str_keys) or has_cosmos_dit_peft_keys_strict(str_keys):
return BaseModelType.Anima
raise NotAMatchError("model does not look like an Anima LoRA")
+class LoRA_LyCORIS_Wan_Config(LoRA_LyCORIS_Config_Base, Config_Base):
+ """Model config for Wan 2.2 LoRA models in LyCORIS format.
+
+ Wan LoRAs target ``WanTransformer3DModel`` blocks. The Wan 2.2 A14B family
+ is dual-expert (high-noise + low-noise) — LoRAs are typically trained
+ against one expert. ``expert`` records which one so the model loader
+ invocation can wire it to the correct ``loras`` / ``loras_low_noise`` list.
+ Many LoRAs are expert-agnostic (TI2V-5B family, or community LoRAs that
+ just don't tag the expert) — these get ``expert=None`` and are applied to
+ both experts by default.
+ """
+
+ base: Literal[BaseModelType.Wan] = Field(default=BaseModelType.Wan)
+ expert: Literal["high", "low"] | None = Field(
+ default=None,
+ description="For Wan 2.2 A14B dual-expert LoRAs: 'high' targets the high-noise expert, "
+ "'low' targets the low-noise expert. None means the LoRA is expert-agnostic "
+ "(TI2V-5B, or community LoRAs without explicit tagging) and is applied to both.",
+ )
+ variant: WanLoRAVariantType | None = Field(
+ default=None,
+ description="The Wan model family this LoRA targets, detected from its inner-dim "
+ "(5120 -> A14B, 3072 -> TI2V-5B). A14B LoRAs are incompatible with TI2V-5B mains "
+ "(and vice versa) — they crash with a shape mismatch in the layer patcher. The "
+ "linear-view graph builder filters LoRAs on variant when building the LoRA "
+ "collection. None means the LoRA's inner-dim couldn't be identified.",
+ )
+
+ @classmethod
+ def _validate_looks_like_lora(cls, mod: ModelOnDisk) -> None:
+ """Wan LoRAs target attn1/attn2/ffn.net (diffusers form) or self_attn/cross_attn/ffn.N (native form)."""
+ state_dict = mod.load_state_dict()
+ str_keys = [k for k in state_dict.keys() if isinstance(k, str)]
+
+ has_wan_keys = has_wan_kohya_keys(str_keys) or has_wan_peft_keys(str_keys)
+ has_lora_suffix = state_dict_has_any_keys_ending_with(
+ state_dict,
+ {
+ "lora_A.weight",
+ "lora_B.weight",
+ "lora_down.weight",
+ "lora_up.weight",
+ "dora_scale",
+ ".lokr_w1",
+ ".lokr_w2",
+ },
+ )
+
+ # Reject if any non-Wan architecture signature is present. Without this
+ # guard a Wan LoRA could be falsely identified by Anima (cross_attn /
+ # self_attn name collision) or vice versa.
+ if has_wan_keys and has_lora_suffix and not has_non_wan_architecture_keys(str_keys):
+ return
+
+ raise NotAMatchError("model does not match Wan LoRA heuristics")
+
+ @classmethod
+ def _get_base_or_raise(cls, mod: ModelOnDisk) -> BaseModelType:
+ state_dict = mod.load_state_dict()
+ str_keys = [k for k in state_dict.keys() if isinstance(k, str)]
+
+ if (has_wan_kohya_keys(str_keys) or has_wan_peft_keys(str_keys)) and not has_non_wan_architecture_keys(
+ str_keys
+ ):
+ return BaseModelType.Wan
+
+ raise NotAMatchError("model does not look like a Wan LoRA")
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ # Run the base-class probe (file-check, lora-suffix, base detection).
+ instance = super().from_model_on_disk(mod, override_fields)
+
+ # Auto-detect the expert tag from the filename if the user didn't
+ # override it. ``high_noise`` / ``low_noise`` / hyphenated / concatenated
+ # variants — mirrors the GGUF transformer probe's heuristic.
+ if instance.expert is None:
+ name = mod.path.stem.lower()
+ if any(s in name for s in ("high_noise", "high-noise", "highnoise")):
+ instance.expert = "high"
+ elif any(s in name for s in ("low_noise", "low-noise", "lownoise")):
+ instance.expert = "low"
+
+ # Auto-detect the model-family variant from inner_dim in the state
+ # dict. The override field skips this if the user has set it.
+ if instance.variant is None:
+ instance.variant = detect_wan_lora_variant(mod.load_state_dict())
+
+ return instance
+
+
class ControlAdapter_Config_Base(ABC, BaseModel):
default_settings: ControlAdapterDefaultSettings | None = Field(None)
diff --git a/invokeai/backend/model_manager/configs/main.py b/invokeai/backend/model_manager/configs/main.py
index e1e408a3483..e2178dd5267 100644
--- a/invokeai/backend/model_manager/configs/main.py
+++ b/invokeai/backend/model_manager/configs/main.py
@@ -31,6 +31,7 @@
QwenImageVariantType,
SchedulerPredictionType,
SubModelType,
+ WanVariantType,
ZImageVariantType,
)
from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
@@ -63,7 +64,12 @@ class MainModelDefaultSettings(BaseModel):
def from_base(
cls,
base: BaseModelType,
- variant: Flux2VariantType | FluxVariantType | ModelVariantType | ZImageVariantType | None = None,
+ variant: Flux2VariantType
+ | FluxVariantType
+ | ModelVariantType
+ | WanVariantType
+ | ZImageVariantType
+ | None = None,
) -> Self | None:
match base:
case BaseModelType.StableDiffusion1:
@@ -93,6 +99,12 @@ def from_base(
return cls(steps=4, cfg_scale=1.0, width=1024, height=1024)
case BaseModelType.QwenImage:
return cls(steps=40, cfg_scale=4.0, width=1024, height=1024)
+ case BaseModelType.Wan:
+ # Wan 2.2 recommended defaults differ by variant.
+ if variant == WanVariantType.TI2V_5B:
+ return cls(steps=30, cfg_scale=5.0, width=1024, height=1024)
+ # Default to A14B settings (also used when variant is unknown).
+ return cls(steps=40, cfg_scale=4.0, width=1024, height=1024)
case _:
# TODO(psyche): Do we want defaults for other base types?
return None
@@ -1383,6 +1395,269 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -
return cls(**override_fields, variant=explicit_variant)
+def _has_wan_keys(state_dict: dict[str | int, Any]) -> bool:
+ """Check if state dict contains Wan 2.2 transformer keys.
+
+ Two layouts are accepted:
+
+ * **Diffusers** (city96-style GGUF, Wan-AI/*-Diffusers safetensors): the text
+ projection is named ``condition_embedder.text_embedder.linear_1``.
+ * **Native upstream** (QuantStack-style GGUF, ComfyUI, Wan-AI's non-Diffusers
+ releases): the text projection is named ``text_embedding.0``.
+
+ Both layouts share ``patch_embedding.weight`` as the input conv. Combined with
+ the text-projection fingerprint, this won't collide with FLUX
+ (``double_blocks/single_blocks``), Qwen Image (``txt_in/img_in``), Z-Image
+ (``cap_embedder``), or Anima (``llm_adapter``).
+
+ Tolerates both bare keys and the ComfyUI ``model.diffusion_model.`` /
+ ``diffusion_model.`` prefixes.
+ """
+ text_proj_options = (
+ "condition_embedder.text_embedder.linear_1.weight",
+ "text_embedding.0.weight",
+ )
+ prefixes = ("", "model.diffusion_model.", "diffusion_model.")
+ keys = state_dict.keys()
+ if not any((p + "patch_embedding.weight") in keys for p in prefixes):
+ return False
+ return any((p + needle) in keys for p in prefixes for needle in text_proj_options)
+
+
+def _is_native_wan_layout(state_dict: dict[str | int, Any]) -> bool:
+ """True if the state dict uses the native upstream Wan key layout.
+
+ Native layout uses ``text_embedding.0/2``, ``self_attn``/``cross_attn``,
+ ``ffn.0/2``, ``head.head``, ``head.modulation``, etc. — what ComfyUI and
+ QuantStack ship. Diffusers layout uses ``condition_embedder.*``, ``attn1``/
+ ``attn2``, ``ffn.net.*``, ``proj_out``, ``scale_shift_table``.
+ """
+ prefixes = ("", "model.diffusion_model.", "diffusion_model.")
+ keys = state_dict.keys()
+ return any((p + "text_embedding.0.weight") in keys for p in prefixes)
+
+
+def _detect_wan_gguf_variant(state_dict: dict[str | int, Any]) -> WanVariantType | None:
+ """Determine A14B (T2V vs I2V) vs TI2V-5B from the GGUF state dict.
+
+ ``patch_embedding.weight`` has shape ``[inner_dim, in_channels, T, H, W]``;
+ ``in_channels`` uniquely identifies the Wan 2.2 variant:
+
+ - 16 → T2V-A14B (noise latents only).
+ - 36 → I2V-A14B (16 noise + 16 ref-image latents + 4 first-frame mask,
+ concatenated along the channel dim — see diffusers
+ ``WanImageToVideoPipeline.prepare_latents``).
+ - 48 → TI2V-5B (Wan2.2-VAE z_dim=48).
+
+ Returns None if the tensor is missing or the channel count is unrecognised.
+ """
+ candidates = (
+ "patch_embedding.weight",
+ "model.diffusion_model.patch_embedding.weight",
+ "diffusion_model.patch_embedding.weight",
+ )
+ for key in candidates:
+ if key in state_dict:
+ tensor = state_dict[key]
+ shape = getattr(tensor, "tensor_shape", None) or getattr(tensor, "shape", None)
+ if shape is None or len(shape) < 2:
+ return None
+ in_channels = int(shape[1])
+ if in_channels == 16:
+ return WanVariantType.T2V_A14B
+ if in_channels == 36:
+ return WanVariantType.I2V_A14B
+ if in_channels == 48:
+ return WanVariantType.TI2V_5B
+ return None
+ return None
+
+
+def _detect_wan_gguf_expert(filename: str) -> Literal["high", "low", "none"]:
+ """Filename heuristic for the A14B dual-expert MoE.
+
+ Community releases tag each expert in the filename — typically
+ ``high_noise`` / ``low_noise`` (or hyphenated/concatenated variants).
+ Returns 'none' when neither marker is present (single-expert model or
+ ambiguous filename).
+ """
+ name = filename.lower()
+ if any(s in name for s in ("high_noise", "high-noise", "highnoise")):
+ return "high"
+ if any(s in name for s in ("low_noise", "low-noise", "lownoise")):
+ return "low"
+ return "none"
+
+
+class Main_GGUF_Wan_Config(Checkpoint_Config_Base, Main_Config_Base, Config_Base):
+ """Model config for GGUF-quantized Wan 2.2 transformer models.
+
+ A14B's MoE ships as two GGUF files (one per expert); ``expert`` records
+ which one this is so the model loader invocation can pair them. TI2V-5B
+ is a single-transformer model and stores ``expert='none'``.
+ """
+
+ base: Literal[BaseModelType.Wan] = Field(default=BaseModelType.Wan)
+ format: Literal[ModelFormat.GGUFQuantized] = Field(default=ModelFormat.GGUFQuantized)
+ variant: WanVariantType = Field()
+ expert: Literal["high", "low", "none"] = Field(
+ default="none",
+ description="For Wan 2.2 A14B's dual-expert MoE: 'high' for the high-noise expert, "
+ "'low' for the low-noise expert. 'none' for single-transformer models (TI2V-5B).",
+ )
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ raise_if_not_file(mod)
+ raise_for_override_fields(cls, override_fields)
+
+ sd = mod.load_state_dict()
+
+ if not _has_ggml_tensors(sd):
+ raise NotAMatchError("state dict does not look like GGUF quantized")
+ if not _has_wan_keys(sd):
+ raise NotAMatchError("state dict does not look like a Wan transformer")
+
+ explicit_variant = override_fields.pop("variant", None)
+ variant = explicit_variant or _detect_wan_gguf_variant(sd)
+ if variant is None:
+ raise NotAMatchError("could not determine Wan variant from state dict")
+
+ explicit_expert = override_fields.pop("expert", None)
+ expert = explicit_expert or _detect_wan_gguf_expert(mod.path.stem)
+
+ return cls(**override_fields, variant=variant, expert=expert)
+
+
+class Main_Diffusers_Wan_Config(Diffusers_Config_Base, Main_Config_Base, Config_Base):
+ """Model config for Wan 2.2 diffusers models.
+
+ Covers both the dual-expert T2V-A14B family and the single-transformer TI2V-5B
+ family. Variant is detected from the on-disk transformer config (latent channel
+ count) plus the presence of a sibling ``transformer_2/`` directory.
+ """
+
+ base: Literal[BaseModelType.Wan] = Field(default=BaseModelType.Wan)
+ variant: WanVariantType = Field()
+ has_dual_expert: bool = Field(
+ default=False,
+ description="Whether this model ships two transformer experts (Wan 2.2 A14B MoE). False for TI2V-5B.",
+ )
+ boundary_ratio: float | None = Field(
+ default=None,
+ description="MoE expert switch point as a fraction of num_train_timesteps (typically 1000). "
+ "None for single-transformer models. Read from model_index.json by Diffusers' WanPipeline.",
+ )
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ raise_if_not_dir(mod)
+
+ raise_for_override_fields(cls, override_fields)
+
+ # Wan repos ship with WanPipeline (T2V) or WanImageToVideoPipeline (I2V/TI2V).
+ # Either class name is sufficient to identify a Wan diffusers model.
+ raise_for_class_name(
+ common_config_paths(mod.path),
+ {
+ "WanPipeline",
+ "WanImageToVideoPipeline",
+ },
+ )
+
+ repo_variant = override_fields.pop("repo_variant", None) or cls._get_repo_variant_or_raise(mod)
+
+ explicit_variant = override_fields.pop("variant", None)
+ has_dual_expert = (mod.path / "transformer_2" / "config.json").exists()
+ variant = explicit_variant or cls._detect_wan_variant(mod, has_dual_expert)
+ boundary_ratio = override_fields.pop("boundary_ratio", None)
+ if boundary_ratio is None:
+ boundary_ratio = cls._read_boundary_ratio(mod)
+
+ return cls(
+ **override_fields,
+ repo_variant=repo_variant,
+ variant=variant,
+ has_dual_expert=has_dual_expert,
+ boundary_ratio=boundary_ratio,
+ )
+
+ @classmethod
+ def _read_boundary_ratio(cls, mod: ModelOnDisk) -> float | None:
+ """Pull ``boundary_ratio`` from ``model_index.json`` if present.
+
+ Diffusers' ``WanPipeline.__init__`` registers it via ``register_to_config``,
+ which persists it as a top-level key in the saved pipeline config.
+ """
+ try:
+ model_index = get_config_dict_or_raise(mod.path / "model_index.json")
+ except NotAMatchError:
+ return None
+ value = model_index.get("boundary_ratio")
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _detect_wan_variant(cls, mod: ModelOnDisk, has_dual_expert: bool) -> WanVariantType:
+ """Detect Wan variant from transformer + VAE config.
+
+ - T2V-A14B: dual transformer experts, standard Wan VAE (z_dim=16),
+ transformer ``in_channels=16`` (text-only conditioning).
+ - I2V-A14B: dual transformer experts, standard Wan VAE,
+ transformer ``in_channels=36`` (text + VAE-encoded reference image
+ + first-frame mask concatenated along the channel dim).
+ - TI2V-5B: single transformer, Wan2.2-VAE (z_dim=48).
+ """
+ if has_dual_expert:
+ # Disambiguate T2V vs I2V via the transformer's input channel count.
+ # Wan 2.2 I2V uses VAE-latent concatenation: 16 noise + 16 ref-image
+ # latents + 4 first-frame mask = 36. (Wan 2.1 I2V used CLIP-vision
+ # via ``image_dim``; that mechanism is absent in Wan 2.2.)
+ in_channels = cls._transformer_in_channels(mod)
+ if in_channels == 36:
+ return WanVariantType.I2V_A14B
+ return WanVariantType.T2V_A14B
+
+ # Single-transformer model: distinguish TI2V-5B from any future single-expert
+ # A14B-derived release by inspecting the VAE latent dimension.
+ try:
+ vae_config = get_config_dict_or_raise(mod.path / "vae" / "config.json")
+ z_dim = vae_config.get("z_dim")
+ if z_dim is not None and int(z_dim) >= 32:
+ return WanVariantType.TI2V_5B
+ except NotAMatchError:
+ # No VAE config to inspect — fall through to the heuristic path below.
+ pass
+
+ # Filename / repo-name heuristic as a last resort.
+ name = mod.path.name.lower()
+ if "5b" in name or "ti2v" in name:
+ return WanVariantType.TI2V_5B
+ return WanVariantType.T2V_A14B
+
+ @staticmethod
+ def _transformer_in_channels(mod: ModelOnDisk) -> int | None:
+ """Read ``in_channels`` from ``transformer/config.json``.
+
+ For Wan 2.2 A14B, this is the canonical discriminator between T2V
+ (``in_channels=16``) and I2V (``in_channels=36``). Returns None if the
+ config can't be read.
+ """
+ try:
+ transformer_config = get_config_dict_or_raise(mod.path / "transformer" / "config.json")
+ except NotAMatchError:
+ return None
+ value = transformer_config.get("in_channels")
+ try:
+ return int(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
class Main_Checkpoint_Anima_Config(Checkpoint_Config_Base, Main_Config_Base, Config_Base):
"""Model config for Anima single-file checkpoint models (safetensors).
diff --git a/invokeai/backend/model_manager/configs/vae.py b/invokeai/backend/model_manager/configs/vae.py
index 5a88cf12781..00b96c3c1ac 100644
--- a/invokeai/backend/model_manager/configs/vae.py
+++ b/invokeai/backend/model_manager/configs/vae.py
@@ -40,6 +40,11 @@ def _is_qwen_image_vae(state_dict: dict[str | int, Any]) -> bool:
1. Diffusers-format encoder/decoder keys (`encoder.conv_in`, `decoder.conv_in`)
2. 5-dimensional convolution weights (3D causal convolutions vs. standard 2D conv in SD/SDXL/FLUX VAEs)
3. 16-dimensional latent space (z_dim=16)
+
+ Note: Wan 2.2 A14B reuses the same architecture (AutoencoderKLWan with z_dim=16),
+ so this function returns True for both. Disambiguation between the two for
+ standalone files relies on the filename heuristic in :func:`_is_wan_vae` and
+ config registration order.
"""
decoder_conv_in_key = "decoder.conv_in.weight"
if decoder_conv_in_key not in state_dict:
@@ -52,6 +57,34 @@ def _is_qwen_image_vae(state_dict: dict[str | int, Any]) -> bool:
return shape[1] == 16
+def _wan_vae_z_dim(state_dict: dict[str | int, Any]) -> int | None:
+ """Return ``z_dim`` for a Wan-family VAE state dict, or ``None`` if it isn't one.
+
+ Wan-family VAEs (AutoencoderKLWan) have 5D convolution weights and a
+ decoder.conv_in input channel count of 16 (Wan 2.1 / A14B / Qwen Image) or
+ 48 (Wan 2.2 TI2V-5B's Wan2.2-VAE).
+ """
+ decoder_conv_in_key = "decoder.conv_in.weight"
+ if decoder_conv_in_key not in state_dict:
+ return None
+ weight = state_dict[decoder_conv_in_key]
+ shape = getattr(weight, "shape", None)
+ if shape is None or len(shape) != 5:
+ return None
+ z = int(shape[1])
+ return z if z in (16, 48) else None
+
+
+def _filename_suggests_wan(mod: ModelOnDisk) -> bool:
+ """Filename heuristic to distinguish standalone Wan VAE files from Qwen Image VAEs.
+
+ Both use the same ``AutoencoderKLWan`` architecture for 16-channel files, so the
+ state dict alone can't tell them apart. Filenames in the wild (community ports,
+ ComfyUI repacks) typically include ``wan`` for Wan releases.
+ """
+ return "wan" in mod.path.name.lower()
+
+
def _is_flux2_vae(state_dict: dict[str | int, Any]) -> bool:
"""Check if state dict is a FLUX.2 VAE (AutoencoderKLFlux2).
@@ -113,9 +146,10 @@ def _validate_looks_like_vae(cls, mod: ModelOnDisk) -> None:
if _is_flux2_vae(state_dict):
raise NotAMatchError("model is a FLUX.2 VAE, not a standard VAE")
- # Exclude Qwen Image VAEs - they have their own config class
- if _is_qwen_image_vae(state_dict):
- raise NotAMatchError("model is a Qwen Image VAE, not a standard VAE")
+ # Exclude Qwen Image / Wan VAEs - they share the AutoencoderKLWan
+ # architecture and each has its own config class.
+ if _is_qwen_image_vae(state_dict) or _wan_vae_z_dim(state_dict) is not None:
+ raise NotAMatchError("model is a Wan-family VAE, not a standard VAE")
@classmethod
def _get_base_or_raise(cls, mod: ModelOnDisk) -> BaseModelType:
@@ -215,9 +249,96 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -
if not _is_qwen_image_vae(state_dict):
raise NotAMatchError("state dict does not look like a Qwen Image VAE")
+ # Defer to VAE_Checkpoint_Wan_Config for files whose names indicate Wan
+ # (both architectures are 16-channel AutoencoderKLWan and otherwise
+ # indistinguishable from the state dict alone).
+ if _filename_suggests_wan(mod):
+ raise NotAMatchError("filename suggests a Wan VAE, not Qwen Image")
+
return cls(**override_fields)
+class VAE_Checkpoint_Wan_Config(Checkpoint_Config_Base, Config_Base):
+ """Model config for Wan 2.2 VAE checkpoint models (AutoencoderKLWan).
+
+ Distinguishes A14B (z_dim=16, standard Wan VAE) from TI2V-5B (z_dim=48,
+ Wan2.2-VAE) via the input channel count of ``decoder.conv_in.weight``.
+ """
+
+ type: Literal[ModelType.VAE] = Field(default=ModelType.VAE)
+ format: Literal[ModelFormat.Checkpoint] = Field(default=ModelFormat.Checkpoint)
+ base: Literal[BaseModelType.Wan] = Field(default=BaseModelType.Wan)
+ latent_channels: Literal[16, 48] = Field(
+ description="VAE latent channel count: 16 for A14B (standard Wan VAE) or 48 for TI2V-5B (Wan2.2-VAE)."
+ )
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ raise_if_not_file(mod)
+
+ raise_for_override_fields(cls, override_fields)
+
+ state_dict = mod.load_state_dict()
+ z_dim = _wan_vae_z_dim(state_dict)
+ if z_dim is None:
+ raise NotAMatchError("state dict does not look like a Wan VAE")
+
+ # 48-channel files are unambiguously Wan2.2-VAE (TI2V-5B). 16-channel
+ # files are architecturally identical to Qwen Image's VAE; require the
+ # filename to suggest Wan to claim them, otherwise let the QwenImage
+ # config win.
+ latent_channels: int = z_dim
+ if latent_channels == 16 and not _filename_suggests_wan(mod):
+ raise NotAMatchError(
+ "16-channel AutoencoderKLWan VAE without 'wan' in filename — deferring to Qwen Image VAE config."
+ )
+
+ explicit = override_fields.pop("latent_channels", None)
+ if explicit is not None:
+ latent_channels = int(explicit)
+
+ return cls(**override_fields, latent_channels=latent_channels)
+
+
+class VAE_Diffusers_Wan_Config(Diffusers_Config_Base, Config_Base):
+ """Model config for Wan 2.2 VAE in diffusers folder layout (AutoencoderKLWan)."""
+
+ type: Literal[ModelType.VAE] = Field(default=ModelType.VAE)
+ format: Literal[ModelFormat.Diffusers] = Field(default=ModelFormat.Diffusers)
+ base: Literal[BaseModelType.Wan] = Field(default=BaseModelType.Wan)
+ latent_channels: Literal[16, 48] = Field(
+ default=16,
+ description="VAE latent channel count: 16 for A14B or 48 for TI2V-5B's Wan2.2-VAE.",
+ )
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ raise_if_not_dir(mod)
+
+ raise_for_override_fields(cls, override_fields)
+
+ raise_for_class_name(
+ common_config_paths(mod.path),
+ {"AutoencoderKLWan"},
+ )
+
+ # Read z_dim from the diffusers config to set latent_channels.
+ latent_channels: int = 16
+ try:
+ config = get_config_dict_or_raise(common_config_paths(mod.path))
+ z = config.get("z_dim")
+ if z is not None and int(z) in (16, 48):
+ latent_channels = int(z)
+ except NotAMatchError:
+ pass
+
+ explicit = override_fields.pop("latent_channels", None)
+ if explicit is not None:
+ latent_channels = int(explicit)
+
+ return cls(**override_fields, latent_channels=latent_channels)
+
+
def _has_anima_vae_keys(state_dict: dict[str | int, Any]) -> bool:
"""Check if state dict looks like an Anima QwenImage VAE (AutoencoderKLQwenImage).
diff --git a/invokeai/backend/model_manager/configs/wan_t5_encoder.py b/invokeai/backend/model_manager/configs/wan_t5_encoder.py
new file mode 100644
index 00000000000..efda6a551a2
--- /dev/null
+++ b/invokeai/backend/model_manager/configs/wan_t5_encoder.py
@@ -0,0 +1,84 @@
+"""Configurations for the UMT5-XXL text encoder used by Wan 2.2.
+
+Wan ships a UMT5-XXL encoder (not the more common T5-XXL). The two are not
+weight-compatible — UMT5 has a different vocabulary and ``model_type``. We
+register a dedicated config + ModelType so users can't accidentally wire a
+FLUX/SD3-style T5-XXL into a Wan slot.
+
+For Phase 3 we accept the diffusers-folder layout only. Single-file UMT5
+checkpoints are uncommon; if they show up later, a checkpoint config can be
+added alongside this one.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Literal, Self
+
+from pydantic import Field
+
+from invokeai.backend.model_manager.configs.base import Config_Base
+from invokeai.backend.model_manager.configs.identification_utils import (
+ NotAMatchError,
+ raise_for_override_fields,
+ raise_if_not_dir,
+)
+from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
+
+
+def _read_text_encoder_model_type(mod: ModelOnDisk) -> str | None:
+ """Return ``model_type`` from the encoder's ``config.json``.
+
+ Diffusers encoder folders may live at the root (``config.json``) or under a
+ ``text_encoder/`` subdirectory. UMT5-XXL sets ``model_type`` to ``"umt5"``;
+ a regular T5-XXL would be ``"t5"``.
+ """
+ candidates: list[Path] = [mod.path / "text_encoder" / "config.json", mod.path / "config.json"]
+ for path in candidates:
+ if path.exists():
+ try:
+ with path.open("r", encoding="utf-8") as f:
+ config = json.load(f)
+ except (json.JSONDecodeError, OSError):
+ continue
+ mt = config.get("model_type")
+ if isinstance(mt, str):
+ return mt.lower()
+ return None
+
+
+class WanT5Encoder_WanT5Encoder_Config(Config_Base):
+ """UMT5-XXL encoder in diffusers folder layout.
+
+ Accepts either:
+ - A directory containing ``text_encoder/`` (and typically ``tokenizer/``) ─ the
+ shape produced by ``Wan-AI/Wan2.2-T2V-A14B::text_encoder+tokenizer``.
+ - A bare ``text_encoder/`` directory whose own ``config.json`` declares
+ ``model_type: umt5``.
+ """
+
+ base: Literal[BaseModelType.Any] = Field(default=BaseModelType.Any)
+ type: Literal[ModelType.WanT5Encoder] = Field(default=ModelType.WanT5Encoder)
+ format: Literal[ModelFormat.WanT5Encoder] = Field(default=ModelFormat.WanT5Encoder)
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self:
+ raise_if_not_dir(mod)
+ raise_for_override_fields(cls, override_fields)
+
+ # Refuse to claim full Wan pipelines — they should match Main_Diffusers_Wan_Config.
+ if (mod.path / "model_index.json").exists() or (mod.path / "transformer").exists():
+ raise NotAMatchError(
+ "directory looks like a full Wan pipeline (model_index.json or transformer/), "
+ "not a standalone Wan T5 encoder"
+ )
+
+ model_type = _read_text_encoder_model_type(mod)
+ if model_type is None:
+ raise NotAMatchError("no encoder config.json found at root or text_encoder/")
+ if model_type != "umt5":
+ raise NotAMatchError(f"encoder model_type is {model_type!r}, not 'umt5'")
+
+ return cls(**override_fields)
diff --git a/invokeai/backend/model_manager/load/model_loaders/lora.py b/invokeai/backend/model_manager/load/model_loaders/lora.py
index 6cf06d48074..a38ad2acd71 100644
--- a/invokeai/backend/model_manager/load/model_loaders/lora.py
+++ b/invokeai/backend/model_manager/load/model_loaders/lora.py
@@ -62,6 +62,7 @@
)
from invokeai.backend.patches.lora_conversions.sd_lora_conversion_utils import lora_model_from_sd_state_dict
from invokeai.backend.patches.lora_conversions.sdxl_lora_conversion_utils import convert_sdxl_keys_to_diffusers_format
+from invokeai.backend.patches.lora_conversions.wan_lora_conversion_utils import lora_model_from_wan_state_dict
from invokeai.backend.patches.lora_conversions.z_image_lora_conversion_utils import lora_model_from_z_image_state_dict
@@ -170,6 +171,10 @@ def _load_model(
elif self._model_base == BaseModelType.Anima:
# Anima LoRAs use Kohya-style or diffusers PEFT format targeting Cosmos DiT blocks.
model = lora_model_from_anima_state_dict(state_dict=state_dict, alpha=None)
+ elif self._model_base == BaseModelType.Wan:
+ # Wan LoRAs use Kohya / diffusers PEFT / native PEFT formats targeting
+ # WanTransformer3DModel attention (attn1/attn2) and FFN blocks.
+ model = lora_model_from_wan_state_dict(state_dict=state_dict, alpha=None)
else:
raise ValueError(f"Unsupported LoRA base model: {self._model_base}")
diff --git a/invokeai/backend/model_manager/load/model_loaders/vae.py b/invokeai/backend/model_manager/load/model_loaders/vae.py
index 720821f3af8..b3d2eae38ee 100644
--- a/invokeai/backend/model_manager/load/model_loaders/vae.py
+++ b/invokeai/backend/model_manager/load/model_loaders/vae.py
@@ -10,6 +10,8 @@
VAE_Checkpoint_Anima_Config,
VAE_Checkpoint_Config_Base,
VAE_Checkpoint_QwenImage_Config,
+ VAE_Checkpoint_Wan_Config,
+ VAE_Diffusers_Wan_Config,
)
from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry
from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader
@@ -21,6 +23,133 @@
SubModelType,
)
+# Architectural defaults for the Wan 2.2-VAE (TI2V-5B). Verbatim from the
+# vae/config.json shipped with Wan-AI/Wan2.2-TI2V-5B-Diffusers — only the
+# values that differ from diffusers' AutoencoderKLWan defaults are listed.
+# latents_mean / latents_std are required because the model normalises latents
+# against them at encode/decode time; the wrong arrays produce silent garbage.
+_WAN_TI2V_5B_VAE_CONFIG: dict = {
+ "base_dim": 160,
+ "decoder_base_dim": 256,
+ "z_dim": 48,
+ "in_channels": 12,
+ "out_channels": 12,
+ "patch_size": 2,
+ "scale_factor_spatial": 16,
+ "is_residual": True,
+ "latents_mean": [
+ -0.2289,
+ -0.0052,
+ -0.1323,
+ -0.2339,
+ -0.2799,
+ 0.0174,
+ 0.1838,
+ 0.1557,
+ -0.1382,
+ 0.0542,
+ 0.2813,
+ 0.0891,
+ 0.1570,
+ -0.0098,
+ 0.0375,
+ -0.1825,
+ -0.2246,
+ -0.1207,
+ -0.0698,
+ 0.5109,
+ 0.2665,
+ -0.2108,
+ -0.2158,
+ 0.2502,
+ -0.2055,
+ -0.0322,
+ 0.1109,
+ 0.1567,
+ -0.0729,
+ 0.0899,
+ -0.2799,
+ -0.1230,
+ -0.0313,
+ -0.1649,
+ 0.0117,
+ 0.0723,
+ -0.2839,
+ -0.2083,
+ -0.0520,
+ 0.3748,
+ 0.0152,
+ 0.1957,
+ 0.1433,
+ -0.2944,
+ 0.3573,
+ -0.0548,
+ -0.1681,
+ -0.0667,
+ ],
+ "latents_std": [
+ 0.4765,
+ 1.0364,
+ 0.4514,
+ 1.1677,
+ 0.5313,
+ 0.4990,
+ 0.4818,
+ 0.5013,
+ 0.8158,
+ 1.0344,
+ 0.5894,
+ 1.0901,
+ 0.6885,
+ 0.6165,
+ 0.8454,
+ 0.4978,
+ 0.5759,
+ 0.3523,
+ 0.7135,
+ 0.6804,
+ 0.5833,
+ 1.4146,
+ 0.8986,
+ 0.5659,
+ 0.7069,
+ 0.5338,
+ 0.4889,
+ 0.4917,
+ 0.4069,
+ 0.4999,
+ 0.6866,
+ 0.4093,
+ 0.5709,
+ 0.6065,
+ 0.6415,
+ 0.4944,
+ 0.5726,
+ 1.2042,
+ 0.5458,
+ 1.6887,
+ 0.3971,
+ 1.0600,
+ 0.3943,
+ 0.5537,
+ 0.5444,
+ 0.4089,
+ 0.7468,
+ 0.7744,
+ ],
+}
+
+
+def _wan_vae_init_kwargs_for(latent_channels: int) -> dict:
+ """Return the AutoencoderKLWan constructor kwargs for a given z_dim.
+
+ z_dim=48 means TI2V-5B's Wan 2.2-VAE (different base dim, patchified IO,
+ 16x spatial). Anything else falls back to the A14B / Wan 2.1 defaults.
+ """
+ if latent_channels == 48:
+ return dict(_WAN_TI2V_5B_VAE_CONFIG)
+ return {"z_dim": latent_channels}
+
@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.VAE, format=ModelFormat.Diffusers)
@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.VAE, format=ModelFormat.Checkpoint)
@@ -39,6 +168,10 @@ def _load_model(
config.path,
torch_dtype=self._torch_dtype,
)
+ elif isinstance(config, VAE_Checkpoint_Wan_Config):
+ return self._load_wan_vae(config)
+ elif isinstance(config, VAE_Diffusers_Wan_Config):
+ return self._load_wan_vae_diffusers(config)
elif isinstance(config, VAE_Checkpoint_QwenImage_Config):
return self._load_qwen_image_vae(config)
elif isinstance(config, VAE_Checkpoint_Config_Base):
@@ -49,6 +182,67 @@ def _load_model(
else:
return super()._load_model(config, submodel_type)
+ def _load_wan_vae(self, config: VAE_Checkpoint_Wan_Config) -> AnyModel:
+ """Load a Wan 2.2 VAE from a single safetensors file.
+
+ Picks the correct ``AutoencoderKLWan`` config based on ``z_dim``. The Wan
+ ecosystem ships two distinct VAE architectures:
+
+ * ``z_dim=16`` — the Wan 2.1 / Wan 2.2 A14B VAE. Diffusers' defaults match
+ this one (base_dim=96, 8x spatial, no patchify, 3 in/out channels).
+ * ``z_dim=48`` — the Wan 2.2-VAE used by TI2V-5B. Larger (base_dim=160,
+ decoder_base_dim=256), 16x spatial, patchify with patch_size=2 (so
+ in/out channels are 12 = 3 RGB x 2x2 patch), residual blocks, and
+ its own latents_mean / latents_std.
+
+ Without overriding those params at construction time, the state dict
+ from the TI2V-5B VAE checkpoint won't load (channel and shape mismatches
+ throughout the encoder + decoder).
+ """
+ import accelerate
+ from diffusers.models.autoencoders.autoencoder_kl_wan import AutoencoderKLWan
+ from safetensors.torch import load_file
+
+ sd = load_file(config.path)
+
+ if self._torch_dtype is not None:
+ for k in list(sd.keys()):
+ if sd[k].is_floating_point():
+ sd[k] = sd[k].to(self._torch_dtype)
+
+ new_sd_size = sum(t.nelement() * t.element_size() for t in sd.values())
+ self._ram_cache.make_room(new_sd_size)
+
+ init_kwargs = _wan_vae_init_kwargs_for(config.latent_channels)
+ with accelerate.init_empty_weights():
+ model = AutoencoderKLWan(**init_kwargs)
+
+ model.load_state_dict(sd, strict=True, assign=True)
+ model.eval()
+ return model
+
+ def _load_wan_vae_diffusers(self, config: VAE_Diffusers_Wan_Config) -> AnyModel:
+ """Load a Wan 2.2 VAE from a flat diffusers folder (AutoencoderKLWan).
+
+ The standalone install ``Wan-AI/Wan2.2-T2V-A14B-Diffusers::vae`` lands as a
+ single-class folder (``config.json`` + ``diffusion_pytorch_model.safetensors``,
+ no ``model_index.json``). The generic loader rejects this when a
+ ``submodel_type`` is requested — we always pass ``SubModelType.VAE`` from
+ the model loader invocation since that's how cached entries are keyed.
+ Loading ``AutoencoderKLWan`` directly here sidesteps the submodel check.
+
+ Forces bfloat16 (same as ``WanDiffusersModel``) — fp16 is unstable on the
+ Wan VAE.
+ """
+ import torch
+ from diffusers.models.autoencoders.autoencoder_kl_wan import AutoencoderKLWan
+
+ return AutoencoderKLWan.from_pretrained(
+ config.path,
+ torch_dtype=torch.bfloat16,
+ local_files_only=True,
+ )
+
def _load_qwen_image_vae(self, config: VAE_Checkpoint_QwenImage_Config) -> AnyModel:
"""Load a Qwen Image VAE from a single safetensors file.
diff --git a/invokeai/backend/model_manager/load/model_loaders/wan.py b/invokeai/backend/model_manager/load/model_loaders/wan.py
new file mode 100644
index 00000000000..f3bb7de7b61
--- /dev/null
+++ b/invokeai/backend/model_manager/load/model_loaders/wan.py
@@ -0,0 +1,354 @@
+"""Loader registrations for Wan 2.2 image-generation models.
+
+Currently covers:
+- Main: Diffusers format (T2V-A14B with dual experts via Transformer +
+ Transformer2 submodels, plus TI2V-5B). Phase 4 will add a GGUFQuantized loader.
+- WanT5Encoder: standalone UMT5-XXL encoder folder (``text_encoder/`` +
+ ``tokenizer/`` subdirs, or a flat ``text_encoder/`` folder).
+- VAE: handled in ``vae.py`` (registered for type=VAE generically).
+"""
+
+from pathlib import Path
+from typing import Optional
+
+import torch
+
+from invokeai.backend.model_manager.configs.base import Checkpoint_Config_Base, Diffusers_Config_Base
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.configs.main import Main_GGUF_Wan_Config, _is_native_wan_layout
+from invokeai.backend.model_manager.load.load_default import ModelLoader
+from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry
+from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader
+from invokeai.backend.model_manager.taxonomy import (
+ AnyModel,
+ BaseModelType,
+ ModelFormat,
+ ModelType,
+ SubModelType,
+ WanVariantType,
+)
+from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
+from invokeai.backend.quantization.gguf.loaders import gguf_sd_loader
+from invokeai.backend.quantization.gguf.utils import TORCH_COMPATIBLE_QTYPES
+from invokeai.backend.util.devices import TorchDevice
+
+
+@ModelLoaderRegistry.register(base=BaseModelType.Wan, type=ModelType.Main, format=ModelFormat.Diffusers)
+class WanDiffusersModel(GenericDiffusersLoader):
+ """Loader for Wan 2.2 diffusers-format models (T2V-A14B and TI2V-5B).
+
+ Forces bfloat16 for the transformer and VAE — fp16 is unstable on Wan VAE
+ (same issue affects the Flux VAE). Resolves the appropriate Hugging Face
+ class for each submodel via the parent loader's ``get_hf_load_class``.
+ """
+
+ def _load_model(
+ self,
+ config: AnyModelConfig,
+ submodel_type: Optional[SubModelType] = None,
+ ) -> AnyModel:
+ if isinstance(config, Checkpoint_Config_Base):
+ raise NotImplementedError("Single-file checkpoint format is not yet supported for Wan models.")
+
+ if submodel_type is None:
+ raise Exception("A submodel type must be provided when loading Wan main pipelines.")
+
+ model_path = Path(config.path)
+ load_class = self.get_hf_load_class(model_path, submodel_type)
+ repo_variant = config.repo_variant if isinstance(config, Diffusers_Config_Base) else None
+ variant = repo_variant.value if repo_variant else None
+ model_path = model_path / submodel_type.value
+
+ # bfloat16 across the board: matches Diffusers WanPipeline reference and
+ # avoids the fp16 instability seen in the Wan VAE.
+ dtype_kwarg = {"dtype": torch.bfloat16}
+ try:
+ result: AnyModel = load_class.from_pretrained(
+ model_path,
+ **dtype_kwarg,
+ variant=variant,
+ local_files_only=True,
+ )
+ except TypeError:
+ # Older diffusers releases use torch_dtype instead of dtype.
+ dtype_kwarg = {"torch_dtype": torch.bfloat16}
+ result = load_class.from_pretrained(
+ model_path,
+ **dtype_kwarg,
+ variant=variant,
+ local_files_only=True,
+ )
+ except OSError as e:
+ # Some Wan repos ship without a fp16 variant suffix on every submodel.
+ # If the requested variant isn't on disk, fall back to the default weights.
+ if variant and "no file named" in str(e):
+ result = load_class.from_pretrained(model_path, **dtype_kwarg, local_files_only=True)
+ else:
+ raise
+
+ return result
+
+
+# Native (upstream) -> Diffusers key rename rules.
+#
+# Mirrors diffusers.loaders.single_file_utils.convert_wan_transformer_to_diffusers
+# (T2V subset; we don't ship VACE / motion / face-adapter conversion). Order
+# matters — `cross_attn`/`self_attn` must come before `.q. .k. .v. .o.` so the
+# attention blocks are renamed before the projection suffix swap. The norm2/3
+# swap uses a placeholder to avoid collisions during the substring rewrite.
+_WAN_NATIVE_TO_DIFFUSERS_RENAMES: tuple[tuple[str, str], ...] = (
+ ("time_embedding.0", "condition_embedder.time_embedder.linear_1"),
+ ("time_embedding.2", "condition_embedder.time_embedder.linear_2"),
+ ("text_embedding.0", "condition_embedder.text_embedder.linear_1"),
+ ("text_embedding.2", "condition_embedder.text_embedder.linear_2"),
+ ("time_projection.1", "condition_embedder.time_proj"),
+ ("cross_attn", "attn2"),
+ ("self_attn", "attn1"),
+ (".o.", ".to_out.0."),
+ (".q.", ".to_q."),
+ (".k.", ".to_k."),
+ (".v.", ".to_v."),
+ (".k_img.", ".add_k_proj."),
+ (".v_img.", ".add_v_proj."),
+ (".norm_k_img.", ".norm_added_k."),
+ ("head.modulation", "scale_shift_table"),
+ ("head.head", "proj_out"),
+ ("modulation", "scale_shift_table"),
+ ("ffn.0", "ffn.net.0.proj"),
+ ("ffn.2", "ffn.net.2"),
+ # norm2 <-> norm3 swap via placeholder
+ ("norm2", "norm__placeholder"),
+ ("norm3", "norm2"),
+ ("norm__placeholder", "norm3"),
+ # I2V-only keys (harmless on T2V)
+ ("img_emb.proj.0", "condition_embedder.image_embedder.norm1"),
+ ("img_emb.proj.1", "condition_embedder.image_embedder.ff.net.0.proj"),
+ ("img_emb.proj.3", "condition_embedder.image_embedder.ff.net.2"),
+ ("img_emb.proj.4", "condition_embedder.image_embedder.norm2"),
+)
+
+
+def _convert_wan_native_to_diffusers(state_dict: dict) -> dict:
+ """Rename native upstream Wan keys (ComfyUI / QuantStack) to diffusers names.
+
+ Pure substring replacement — no tensor manipulation — so it's safe to apply
+ to a dict of GGMLTensors. Returns a new dict; the input is not mutated.
+ """
+ converted: dict = {}
+ for key, value in state_dict.items():
+ if not isinstance(key, str):
+ converted[key] = value
+ continue
+ new_key = key
+ for needle, replacement in _WAN_NATIVE_TO_DIFFUSERS_RENAMES:
+ new_key = new_key.replace(needle, replacement)
+ converted[new_key] = value
+ return converted
+
+
+def _unwrap_unquantized_to_compute_dtype(state_dict: dict) -> dict:
+ """Replace non-quantized GGMLTensor entries with plain tensors at compute_dtype.
+
+ Why: QuantStack-style GGUFs store biases (and other small tensors) as F16,
+ while Wan's ``patch_embedding`` is an ``nn.Conv3d``. ``conv3d`` isn't in
+ GGMLTensor's dispatch table, so PyTorch reads the wrapper's underlying F16
+ storage directly and crashes against bf16 latents
+ (``Input type (c10::BFloat16) and bias type (c10::Half) should be the same``).
+
+ For compatible qtypes (F16/F32/BF16) we just pre-cast to compute_dtype here —
+ they're not quantized, there's no benefit to keeping them wrapped, and
+ unwrapping them sidesteps the missing-op problem entirely. Genuinely
+ quantized tensors (Q4_K, Q6_K, etc.) stay wrapped — their on-demand
+ dequantization through the linear/addmm dispatch path still works.
+ """
+ unwrapped: dict = {}
+ for key, value in state_dict.items():
+ if isinstance(value, GGMLTensor) and value._ggml_quantization_type in TORCH_COMPATIBLE_QTYPES:
+ # GGMLTensor.get_dequantized_tensor() already casts to compute_dtype.
+ unwrapped[key] = value.get_dequantized_tensor()
+ else:
+ unwrapped[key] = value
+ return unwrapped
+
+
+@ModelLoaderRegistry.register(base=BaseModelType.Wan, type=ModelType.Main, format=ModelFormat.GGUFQuantized)
+class WanGGUFCheckpointModel(ModelLoader):
+ """Loader for GGUF-quantized Wan 2.2 transformer models.
+
+ The community typically distributes Wan A14B as two files (one per expert
+ — high-noise + low-noise). Each file is loaded independently here; the
+ pairing happens at the WanModelLoaderInvocation layer. TI2V-5B ships as a
+ single file.
+
+ Mirrors the QwenImage GGUF loader pattern: ``gguf_sd_loader`` -> strip the
+ ComfyUI ``model.diffusion_model.`` / ``diffusion_model.`` prefix if present
+ -> auto-detect arch from state-dict shapes -> ``init_empty_weights`` +
+ ``load_state_dict(strict=False, assign=True)``.
+ """
+
+ def _load_model(
+ self,
+ config: AnyModelConfig,
+ submodel_type: Optional[SubModelType] = None,
+ ) -> AnyModel:
+ if not isinstance(config, Main_GGUF_Wan_Config):
+ raise TypeError(f"Expected Main_GGUF_Wan_Config, got {type(config).__name__}.")
+
+ if submodel_type != SubModelType.Transformer:
+ raise ValueError(
+ "Only the Transformer submodel is available from a GGUF Wan checkpoint. "
+ "Pair with a standalone Wan VAE and Wan T5 encoder for the other components."
+ )
+
+ return self._load_from_singlefile(config)
+
+ def _load_from_singlefile(self, config: Main_GGUF_Wan_Config) -> AnyModel:
+ import accelerate
+ from diffusers import WanTransformer3DModel
+
+ model_path = Path(config.path)
+ target_device = TorchDevice.choose_torch_device()
+ compute_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device)
+
+ sd = gguf_sd_loader(model_path, compute_dtype=compute_dtype)
+
+ # Strip ComfyUI-style prefixes if present.
+ for prefix in ("model.diffusion_model.", "diffusion_model."):
+ if any(isinstance(k, str) and k.startswith(prefix) for k in sd.keys()):
+ sd = {
+ (k[len(prefix) :] if isinstance(k, str) and k.startswith(prefix) else k): v for k, v in sd.items()
+ }
+ break
+
+ # QuantStack and other community releases ship the native upstream Wan key
+ # layout (text_embedding.0, self_attn/cross_attn, ffn.0/2, head.head, ...);
+ # diffusers' WanTransformer3DModel expects condition_embedder.*, attn1/attn2,
+ # ffn.net.*, proj_out. Convert in place if needed.
+ if _is_native_wan_layout(sd):
+ sd = _convert_wan_native_to_diffusers(sd)
+
+ # Pre-cast non-quantized tensors (F16/F32/BF16 biases, scale_shift_table,
+ # patch_embedding.weight, etc.) to compute_dtype. This avoids dtype
+ # mismatches in conv3d at the input (patch_embedding is the only Conv3d
+ # in WanTransformer3DModel; conv3d isn't in GGMLTensor's dispatch table
+ # so the wrapper's underlying storage dtype reaches PyTorch directly).
+ sd = _unwrap_unquantized_to_compute_dtype(sd)
+
+ # Auto-detect architecture from the state dict.
+ num_layers = 0
+ for key in sd.keys():
+ if isinstance(key, str) and key.startswith("blocks."):
+ parts = key.split(".")
+ if len(parts) >= 2:
+ try:
+ num_layers = max(num_layers, int(parts[1]) + 1)
+ except ValueError:
+ pass
+
+ # Patch embedding gives us in_channels (16=A14B, 48=TI2V-5B) and inner dim.
+ patch_w = sd.get("patch_embedding.weight")
+ if patch_w is None:
+ raise RuntimeError("GGUF state dict missing patch_embedding.weight after prefix strip")
+ patch_shape = patch_w.tensor_shape if isinstance(patch_w, GGMLTensor) else patch_w.shape
+ inner_dim = int(patch_shape[0])
+ in_channels = int(patch_shape[1])
+
+ # Wan uses head_dim=128 throughout the family; num_heads = inner_dim / 128.
+ attention_head_dim = 128
+ num_attention_heads = inner_dim // attention_head_dim
+
+ ffn_w = sd.get("blocks.0.ffn.net.0.proj.weight")
+ if ffn_w is None:
+ raise RuntimeError("GGUF state dict missing blocks.0.ffn.net.0.proj.weight after prefix strip")
+ ffn_shape = ffn_w.tensor_shape if isinstance(ffn_w, GGMLTensor) else ffn_w.shape
+ ffn_dim = int(ffn_shape[0])
+
+ text_w = sd.get("condition_embedder.text_embedder.linear_1.weight")
+ text_dim = 4096
+ if text_w is not None:
+ text_shape = text_w.tensor_shape if isinstance(text_w, GGMLTensor) else text_w.shape
+ text_dim = int(text_shape[1])
+
+ # out_channels is read from proj_out.weight directly rather than assumed
+ # equal to in_channels: I2V-A14B has in_channels=36 (16 noise + 16
+ # ref-image latents + 4 mask, concatenated by the denoise loop) but
+ # out_channels=16 (only the noise prediction comes back). proj_out is
+ # ``nn.Linear(inner_dim, out_channels * prod(patch_size))`` and
+ # patch_size is (1, 2, 2) → prod = 4 for the Wan 2.2 family.
+ proj_out_w = sd.get("proj_out.weight")
+ if proj_out_w is None:
+ raise RuntimeError("GGUF state dict missing proj_out.weight after prefix strip")
+ proj_out_shape = proj_out_w.tensor_shape if isinstance(proj_out_w, GGMLTensor) else proj_out_w.shape
+ out_channels = int(proj_out_shape[0]) // 4
+
+ # Layer count fallback (only triggers if the auto-count loop above
+ # found zero blocks, which shouldn't happen for a valid GGUF). T2V/I2V
+ # A14B have 40 layers; TI2V-5B has 30.
+ layer_count_fallback = 30 if config.variant == WanVariantType.TI2V_5B else 40
+
+ model_config: dict = {
+ "patch_size": (1, 2, 2),
+ "in_channels": in_channels,
+ "out_channels": out_channels,
+ "num_layers": num_layers if num_layers > 0 else layer_count_fallback,
+ "attention_head_dim": attention_head_dim,
+ "num_attention_heads": num_attention_heads,
+ "ffn_dim": ffn_dim,
+ "text_dim": text_dim,
+ }
+
+ with accelerate.init_empty_weights():
+ model = WanTransformer3DModel(**model_config)
+
+ model.load_state_dict(sd, strict=False, assign=True)
+ return model
+
+
+@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.WanT5Encoder, format=ModelFormat.WanT5Encoder)
+class WanT5EncoderLoader(ModelLoader):
+ """Loader for the standalone Wan UMT5-XXL encoder.
+
+ Accepts two on-disk layouts:
+ 1. Parent dir with ``text_encoder/`` (and typically ``tokenizer/``) subdirs —
+ what ``Wan-AI/Wan2.2-T2V-A14B::text_encoder+tokenizer`` produces.
+ 2. A flat ``text_encoder/`` folder with ``config.json`` declaring
+ ``model_type: umt5`` directly at the root. In this case the tokenizer
+ is loaded from the same folder via ``AutoTokenizer.from_pretrained``.
+ """
+
+ def _load_model(
+ self,
+ config: AnyModelConfig,
+ submodel_type: Optional[SubModelType] = None,
+ ) -> AnyModel:
+ if submodel_type is None:
+ raise ValueError("A submodel type (Tokenizer or TextEncoder) must be provided.")
+
+ root = Path(config.path)
+ nested_text_encoder = root / "text_encoder"
+ nested_tokenizer = root / "tokenizer"
+
+ if submodel_type == SubModelType.TextEncoder:
+ from transformers import UMT5EncoderModel
+
+ target = nested_text_encoder if nested_text_encoder.exists() else root
+ return UMT5EncoderModel.from_pretrained(
+ str(target),
+ torch_dtype=torch.bfloat16,
+ local_files_only=True,
+ )
+ if submodel_type == SubModelType.Tokenizer:
+ from transformers import AutoTokenizer
+
+ # Prefer a sibling tokenizer/ directory; fall back to the encoder dir
+ # itself, which is normal for "flat" downloads.
+ target = (
+ nested_tokenizer
+ if nested_tokenizer.exists()
+ else (nested_text_encoder if nested_text_encoder.exists() else root)
+ )
+ return AutoTokenizer.from_pretrained(str(target), local_files_only=True)
+
+ raise ValueError(
+ f"Unsupported submodel type for WanT5Encoder: {submodel_type.value if submodel_type else 'None'}"
+ )
diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py
index 306b1482344..464faecdc55 100644
--- a/invokeai/backend/model_manager/starter_models.py
+++ b/invokeai/backend/model_manager/starter_models.py
@@ -15,6 +15,7 @@
ModelFormat,
ModelType,
QwenImageVariantType,
+ WanVariantType,
)
@@ -1299,6 +1300,229 @@ def _gemini_3_resolution_presets(
default_settings=ExternalApiModelDefaultSettings(width=1328, height=1328, num_images=1),
panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]),
)
+# region Wan 2.2 (local)
+# Shared components — all Wan 2.2 variants use the UMT5-XXL text encoder. A14B
+# (both T2V and I2V) uses a 16-channel VAE; TI2V-5B uses a 48-channel VAE. The
+# two VAEs are not interchangeable.
+wan_22_t5_encoder = StarterModel(
+ name="Wan T5 Encoder (UMT5-XXL)",
+ base=BaseModelType.Any,
+ source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::text_encoder+tokenizer",
+ description="UMT5-XXL text encoder used by all Wan 2.2 variants (T2V/I2V A14B and TI2V-5B). "
+ "Required when running a GGUF Wan main without a Diffusers Component Source. (~11GB)",
+ type=ModelType.WanT5Encoder,
+ format=ModelFormat.WanT5Encoder,
+)
+
+wan_22_a14b_vae = StarterModel(
+ name="Wan 2.2 A14B VAE",
+ base=BaseModelType.Wan,
+ source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::vae/diffusion_pytorch_model.safetensors",
+ description="Wan 2.2 A14B VAE (16-channel). Shared between T2V and I2V A14B variants. "
+ "Not interchangeable with the TI2V-5B VAE. (~250MB)",
+ type=ModelType.VAE,
+ format=ModelFormat.Checkpoint,
+)
+
+wan_22_5b_vae = StarterModel(
+ name="Wan 2.2 TI2V-5B VAE",
+ base=BaseModelType.Wan,
+ source="Wan-AI/Wan2.2-TI2V-5B-Diffusers::vae/diffusion_pytorch_model.safetensors",
+ description="Wan 2.2 TI2V-5B VAE (48-channel). Required for the TI2V-5B model family. "
+ "Not interchangeable with the A14B VAE. (~400MB)",
+ type=ModelType.VAE,
+ format=ModelFormat.Checkpoint,
+)
+
+# T2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0).
+# The high-noise GGUF is the "main" entry the user picks; the low-noise GGUF
+# is wired as the partner expert via the Advanced panel. Each high-noise entry
+# lists its low-noise partner plus the shared VAE/encoder as dependencies so
+# the bundle/dependency installer pulls everything together.
+wan_22_t2v_a14b_diffusers = StarterModel(
+ name="Wan 2.2 T2V A14B (Diffusers)",
+ base=BaseModelType.Wan,
+ source="Wan-AI/Wan2.2-T2V-A14B-Diffusers",
+ description="Full Diffusers Wan 2.2 T2V A14B model — both expert transformers, VAE, and UMT5-XXL "
+ "encoder in a single folder. No additional components needed. (~80GB)",
+ type=ModelType.Main,
+ format=ModelFormat.Diffusers,
+ variant=WanVariantType.T2V_A14B,
+)
+
+wan_22_t2v_a14b_low_gguf_q4_k_m = StarterModel(
+ name="Wan 2.2 T2V A14B Low Noise (Q4_K_M)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q4_K_M.gguf",
+ description="Wan 2.2 T2V A14B low-noise expert transformer (Q4_K_M). Paired with the high-noise "
+ "expert; selected via the Advanced 'Transformer (Low Noise)' field. (~9.7GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.T2V_A14B,
+)
+
+wan_22_t2v_a14b_gguf_q4_k_m = StarterModel(
+ name="Wan 2.2 T2V A14B High Noise (Q4_K_M)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf",
+ description="Wan 2.2 T2V A14B high-noise expert transformer (Q4_K_M). Pick this as the main model; "
+ "the low-noise partner is wired in Advanced. Good quality/size balance. (~9.7GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.T2V_A14B,
+ dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q4_k_m],
+)
+
+wan_22_t2v_a14b_low_gguf_q8_0 = StarterModel(
+ name="Wan 2.2 T2V A14B Low Noise (Q8_0)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q8_0.gguf",
+ description="Wan 2.2 T2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.T2V_A14B,
+)
+
+wan_22_t2v_a14b_gguf_q8_0 = StarterModel(
+ name="Wan 2.2 T2V A14B High Noise (Q8_0)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q8_0.gguf",
+ description="Wan 2.2 T2V A14B high-noise expert transformer (Q8_0). Pick as the main; pair with the "
+ "low-noise Q8_0 partner in Advanced. Highest quality quantization. (~15.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.T2V_A14B,
+ dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q8_0],
+)
+
+# T2V Lightning LoRAs — V1.1 Seko rank-64 pair (4-step inference).
+wan_22_t2v_lightning_high = StarterModel(
+ name="Wan 2.2 T2V Lightning High Noise (4-step, V1.1)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/high_noise_model.safetensors",
+ description="Lightning distillation LoRA for the Wan 2.2 T2V A14B high-noise expert — enables "
+ "4-step generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.",
+ type=ModelType.LoRA,
+)
+
+wan_22_t2v_lightning_low = StarterModel(
+ name="Wan 2.2 T2V Lightning Low Noise (4-step, V1.1)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/low_noise_model.safetensors",
+ description="Lightning distillation LoRA for the Wan 2.2 T2V A14B low-noise expert — enables "
+ "4-step generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.",
+ type=ModelType.LoRA,
+)
+
+# I2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0).
+wan_22_i2v_a14b_diffusers = StarterModel(
+ name="Wan 2.2 I2V A14B (Diffusers)",
+ base=BaseModelType.Wan,
+ source="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
+ description="Full Diffusers Wan 2.2 I2V A14B model — both expert transformers, VAE, and UMT5-XXL "
+ "encoder. Use the Reference Images panel to provide the conditioning image. (~80GB)",
+ type=ModelType.Main,
+ format=ModelFormat.Diffusers,
+ variant=WanVariantType.I2V_A14B,
+)
+
+wan_22_i2v_a14b_low_gguf_q4_k_m = StarterModel(
+ name="Wan 2.2 I2V A14B Low Noise (Q4_K_M)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q4_K_M.gguf",
+ description="Wan 2.2 I2V A14B low-noise expert transformer (Q4_K_M). (~9.7GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.I2V_A14B,
+)
+
+wan_22_i2v_a14b_gguf_q4_k_m = StarterModel(
+ name="Wan 2.2 I2V A14B High Noise (Q4_K_M)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf",
+ description="Wan 2.2 I2V A14B high-noise expert transformer (Q4_K_M). Pick as the main; pair with "
+ "the low-noise partner in Advanced. Use the Reference Images panel for the conditioning image. (~9.7GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.I2V_A14B,
+ dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q4_k_m],
+)
+
+wan_22_i2v_a14b_low_gguf_q8_0 = StarterModel(
+ name="Wan 2.2 I2V A14B Low Noise (Q8_0)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q8_0.gguf",
+ description="Wan 2.2 I2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.I2V_A14B,
+)
+
+wan_22_i2v_a14b_gguf_q8_0 = StarterModel(
+ name="Wan 2.2 I2V A14B High Noise (Q8_0)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q8_0.gguf",
+ description="Wan 2.2 I2V A14B high-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.I2V_A14B,
+ dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q8_0],
+)
+
+# I2V Lightning LoRAs — Seko rank-64 pair (4-step inference). Currently only V1.
+wan_22_i2v_lightning_high = StarterModel(
+ name="Wan 2.2 I2V Lightning High Noise (4-step, V1)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/high_noise_model.safetensors",
+ description="Lightning distillation LoRA for the Wan 2.2 I2V A14B high-noise expert — enables "
+ "4-step image-to-image generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.",
+ type=ModelType.LoRA,
+)
+
+wan_22_i2v_lightning_low = StarterModel(
+ name="Wan 2.2 I2V Lightning Low Noise (4-step, V1)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/low_noise_model.safetensors",
+ description="Lightning distillation LoRA for the Wan 2.2 I2V A14B low-noise expert — enables "
+ "4-step image-to-image generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.",
+ type=ModelType.LoRA,
+)
+
+# TI2V-5B — single-transformer model (no expert pair). Uses its own 48-channel VAE.
+wan_22_ti2v_5b_diffusers = StarterModel(
+ name="Wan 2.2 TI2V-5B (Diffusers)",
+ base=BaseModelType.Wan,
+ source="Wan-AI/Wan2.2-TI2V-5B-Diffusers",
+ description="Full Diffusers Wan 2.2 TI2V-5B model — single 5B transformer, 48-channel VAE, and "
+ "UMT5-XXL encoder. Smaller and faster than A14B; runs on consumer GPUs. (~20GB)",
+ type=ModelType.Main,
+ format=ModelFormat.Diffusers,
+ variant=WanVariantType.TI2V_5B,
+)
+
+wan_22_ti2v_5b_gguf_q4_k_m = StarterModel(
+ name="Wan 2.2 TI2V-5B (Q4_K_M)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q4_K_M.gguf",
+ description="Wan 2.2 TI2V-5B transformer (Q4_K_M). Single-expert model — no low-noise partner needed. (~3.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.TI2V_5B,
+ dependencies=[wan_22_5b_vae, wan_22_t5_encoder],
+)
+
+wan_22_ti2v_5b_gguf_q8_0 = StarterModel(
+ name="Wan 2.2 TI2V-5B (Q8_0)",
+ base=BaseModelType.Wan,
+ source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q8_0.gguf",
+ description="Wan 2.2 TI2V-5B transformer (Q8_0). Highest quality quantization. (~5.4GB)",
+ type=ModelType.Main,
+ format=ModelFormat.GGUFQuantized,
+ variant=WanVariantType.TI2V_5B,
+ dependencies=[wan_22_5b_vae, wan_22_t5_encoder],
+)
+# endregion
+
alibabacloud_wan26_t2i = StarterModel(
name="Wan 2.6 Text-to-Image",
base=BaseModelType.External,
@@ -1672,6 +1896,26 @@ def _gemini_3_resolution_presets(
z_image_qwen3_encoder_quantized,
z_image_controlnet_union,
z_image_controlnet_tile,
+ wan_22_t5_encoder,
+ wan_22_a14b_vae,
+ wan_22_5b_vae,
+ wan_22_t2v_a14b_diffusers,
+ wan_22_t2v_a14b_low_gguf_q4_k_m,
+ wan_22_t2v_a14b_gguf_q4_k_m,
+ wan_22_t2v_a14b_low_gguf_q8_0,
+ wan_22_t2v_a14b_gguf_q8_0,
+ wan_22_t2v_lightning_high,
+ wan_22_t2v_lightning_low,
+ wan_22_i2v_a14b_diffusers,
+ wan_22_i2v_a14b_low_gguf_q4_k_m,
+ wan_22_i2v_a14b_gguf_q4_k_m,
+ wan_22_i2v_a14b_low_gguf_q8_0,
+ wan_22_i2v_a14b_gguf_q8_0,
+ wan_22_i2v_lightning_high,
+ wan_22_i2v_lightning_low,
+ wan_22_ti2v_5b_diffusers,
+ wan_22_ti2v_5b_gguf_q4_k_m,
+ wan_22_ti2v_5b_gguf_q8_0,
gemini_flash_image,
gemini_pro_image_preview,
gemini_3_1_flash_image_preview,
@@ -1781,6 +2025,31 @@ def _gemini_3_resolution_presets(
t5_base_encoder,
]
+# Wan 2.2 starter bundles. Split into T2V and I2V so users only pay for the
+# capability they need: a 12 GB card can install just the T2V bundle and have
+# both text-to-video (T2V-A14B) and a low-VRAM image-to-video option (via
+# TI2V-5B, which handles both modes in one ~3.4 GB model). The I2V bundle adds
+# the heavier I2V-A14B path for users with more headroom. Q8 variants and full
+# Diffusers builds stay available as a-la-carte starters.
+wan_t2v_bundle: list[StarterModel] = [
+ wan_22_t5_encoder,
+ wan_22_a14b_vae,
+ wan_22_5b_vae,
+ wan_22_ti2v_5b_gguf_q4_k_m,
+ wan_22_t2v_a14b_gguf_q4_k_m,
+ wan_22_t2v_a14b_low_gguf_q4_k_m,
+ wan_22_t2v_lightning_high,
+ wan_22_t2v_lightning_low,
+]
+wan_i2v_bundle: list[StarterModel] = [
+ wan_22_t5_encoder,
+ wan_22_a14b_vae,
+ wan_22_i2v_a14b_gguf_q4_k_m,
+ wan_22_i2v_a14b_low_gguf_q4_k_m,
+ wan_22_i2v_lightning_high,
+ wan_22_i2v_lightning_low,
+]
+
STARTER_BUNDLES: dict[str, StarterModelBundle] = {
BaseModelType.StableDiffusion1: StarterModelBundle(name="Stable Diffusion 1.5", models=sd1_bundle),
BaseModelType.StableDiffusionXL: StarterModelBundle(name="SDXL", models=sdxl_bundle),
@@ -1789,6 +2058,8 @@ def _gemini_3_resolution_presets(
BaseModelType.ZImage: StarterModelBundle(name="Z-Image Turbo", models=zimage_bundle),
BaseModelType.QwenImage: StarterModelBundle(name="Qwen Image", models=qwen_image_bundle),
BaseModelType.Anima: StarterModelBundle(name="Anima", models=anima_bundle),
+ "wan_t2v": StarterModelBundle(name="Wan 2.2 Text-to-Video", models=wan_t2v_bundle),
+ "wan_i2v": StarterModelBundle(name="Wan 2.2 Image-to-Video", models=wan_i2v_bundle),
}
assert len(STARTER_MODELS) == len({m.source for m in STARTER_MODELS}), "Duplicate starter models"
diff --git a/invokeai/backend/model_manager/taxonomy.py b/invokeai/backend/model_manager/taxonomy.py
index a2e4e58bdc4..7f7b9f21c1e 100644
--- a/invokeai/backend/model_manager/taxonomy.py
+++ b/invokeai/backend/model_manager/taxonomy.py
@@ -58,6 +58,8 @@ class BaseModelType(str, Enum):
"""Indicates the model is associated with Qwen Image Edit 2511 model architecture."""
Anima = "anima"
"""Indicates the model is associated with Anima model architecture (Cosmos Predict2 DiT + LLM Adapter)."""
+ Wan = "wan"
+ """Indicates the model is associated with the Wan 2.2 model architecture (T2V-A14B / TI2V-5B), used for image generation at num_frames=1."""
Unknown = "unknown"
"""Indicates the model's base architecture is unknown."""
@@ -79,6 +81,7 @@ class ModelType(str, Enum):
T5Encoder = "t5_encoder"
Qwen3Encoder = "qwen3_encoder"
QwenVLEncoder = "qwen_vl_encoder"
+ WanT5Encoder = "wan_t5_encoder"
SpandrelImageToImage = "spandrel_image_to_image"
SigLIP = "siglip"
FluxRedux = "flux_redux"
@@ -93,6 +96,7 @@ class SubModelType(str, Enum):
UNet = "unet"
Transformer = "transformer"
+ Transformer2 = "transformer_2"
TextEncoder = "text_encoder"
TextEncoder2 = "text_encoder_2"
TextEncoder3 = "text_encoder_3"
@@ -165,6 +169,44 @@ class QwenImageVariantType(str, Enum):
"""Qwen Image Edit - image editing model with reference image support."""
+class WanVariantType(str, Enum):
+ """Wan 2.2 model variants.
+
+ All variants are used for image generation at num_frames=1. The A14B family
+ is a Mixture-of-Experts (high-noise + low-noise) totalling ~28B params; the
+ T2V sub-variant takes text only, while the I2V sub-variant additionally
+ conditions on a reference image (encoded by the VAE and concatenated to the
+ noise latents along the channel dim — its transformer has ``in_channels=36``
+ instead of ``16``). TI2V-5B is a single ~5B transformer with a
+ higher-compression VAE (z_dim=48).
+ """
+
+ T2V_A14B = "t2v_a14b"
+ """Wan 2.2 T2V-A14B - dual-expert MoE (text only, 16-channel Wan VAE, transformer in_channels=16)."""
+
+ I2V_A14B = "i2v_a14b"
+ """Wan 2.2 I2V-A14B - dual-expert MoE with VAE-latent reference-image conditioning (transformer in_channels=36)."""
+
+ TI2V_5B = "ti2v_5b"
+ """Wan 2.2 TI2V-5B - smaller single-transformer model with Wan2.2-VAE (48 latent channels)."""
+
+
+class WanLoRAVariantType(str, Enum):
+ """Wan 2.2 LoRA variants, identifying which model family a LoRA targets.
+
+ Detected from the LoRA's inner attention dim: A14B has ``inner_dim=5120``,
+ TI2V-5B has ``inner_dim=3072``. A14B and 5B LoRAs are NOT interchangeable —
+ applying one against the wrong main model crashes in the layer patcher
+ with a tensor-shape error.
+ """
+
+ A14B = "a14b"
+ """Targets a Wan 2.2 A14B main (T2V or I2V, inner_dim=5120)."""
+
+ Wan5B = "5b"
+ """Targets the Wan 2.2 TI2V-5B main (inner_dim=3072)."""
+
+
class Qwen3VariantType(str, Enum):
"""Qwen3 text encoder variants based on model size."""
@@ -193,6 +235,7 @@ class ModelFormat(str, Enum):
T5Encoder = "t5_encoder"
Qwen3Encoder = "qwen3_encoder"
QwenVLEncoder = "qwen_vl_encoder"
+ WanT5Encoder = "wan_t5_encoder"
BnbQuantizedLlmInt8b = "bnb_quantized_int8b"
BnbQuantizednf4b = "bnb_quantized_nf4b"
GGUFQuantized = "gguf_quantized"
@@ -248,6 +291,8 @@ class FluxLoRAFormat(str, Enum):
Flux2VariantType,
ZImageVariantType,
QwenImageVariantType,
+ WanVariantType,
+ WanLoRAVariantType,
Qwen3VariantType,
]
variant_type_adapter = TypeAdapter[
@@ -257,6 +302,8 @@ class FluxLoRAFormat(str, Enum):
| Flux2VariantType
| ZImageVariantType
| QwenImageVariantType
+ | WanVariantType
+ | WanLoRAVariantType
| Qwen3VariantType
](
ModelVariantType
@@ -265,5 +312,7 @@ class FluxLoRAFormat(str, Enum):
| Flux2VariantType
| ZImageVariantType
| QwenImageVariantType
+ | WanVariantType
+ | WanLoRAVariantType
| Qwen3VariantType
)
diff --git a/invokeai/backend/patches/lora_conversions/anima_lora_constants.py b/invokeai/backend/patches/lora_conversions/anima_lora_constants.py
index 380e31998a7..5a54de82e86 100644
--- a/invokeai/backend/patches/lora_conversions/anima_lora_constants.py
+++ b/invokeai/backend/patches/lora_conversions/anima_lora_constants.py
@@ -17,7 +17,10 @@
# in ``anima_lora_conversion_utils``) to avoid circular imports.
# ---------------------------------------------------------------------------
-# Cosmos DiT subcomponent names unique to the Anima / Cosmos Predict2 architecture.
+# Cosmos DiT subcomponent names that ALSO appear in Wan (cross_attn, self_attn)
+# plus those unique to Cosmos. Used by ``anima_lora_conversion_utils`` to find
+# block layers during state-dict conversion, where the architecture is already
+# known to be Anima.
_COSMOS_DIT_SUBCOMPONENTS_RE = r"(cross_attn|self_attn|mlp|adaln_modulation)"
# Kohya format: lora_unet_[llm_adapter_]blocks_N_
@@ -29,17 +32,53 @@
)
+# Subcomponents *uniquely* identifying Anima/Cosmos DiT: ``mlp`` and
+# ``adaln_modulation`` (Wan calls those ``ffn`` and ``modulation`` respectively),
+# plus the Cosmos attention naming with a ``_proj`` suffix on the projection
+# letter (Wan native uses bare ``.q``/``.k``/``.v``/``.o`` — no ``_proj``).
+#
+# Used by the probe in ``configs/lora.py`` to make Anima-LoRA detection
+# *mutually exclusive* with Wan-LoRA detection: a state dict carrying only
+# ``cross_attn.q`` / ``ffn.0`` (Wan native) will NOT match here, regardless of
+# the order configs are tried.
+_COSMOS_DIT_EXCLUSIVE_SUBCOMPONENTS_RE = (
+ r"(mlp|adaln_modulation|"
+ r"(?:cross|self)_attn[._](?:[qkv]_proj|output_proj))"
+)
+
+_KOHYA_ANIMA_STRICT_RE = re.compile(r"lora_unet_(llm_adapter_)?blocks_\d+_" + _COSMOS_DIT_EXCLUSIVE_SUBCOMPONENTS_RE)
+_PEFT_ANIMA_STRICT_RE = re.compile(
+ r"(diffusion_model|transformer|base_model\.model\.transformer)\.blocks\.\d+\."
+ + _COSMOS_DIT_EXCLUSIVE_SUBCOMPONENTS_RE
+)
+
+
def has_cosmos_dit_kohya_keys(str_keys: list[str]) -> bool:
- """Check for Kohya-style keys targeting Cosmos DiT blocks with specific subcomponents.
+ """Loose detector — matches any Cosmos-shaped block submodule including
+ those whose names collide with Wan (``cross_attn``, ``self_attn``).
- Requires both the ``lora_unet_[llm_adapter_]blocks_N_`` prefix **and** a
- Cosmos DiT subcomponent name (cross_attn, self_attn, mlp, adaln_modulation)
- to avoid false-positives on other architectures that might also use bare
- ``blocks`` in their key paths.
+ For probe disambiguation between Anima and Wan, prefer
+ ``has_cosmos_dit_kohya_keys_strict``. This loose form is still useful
+ inside the Anima conversion utility, where the architecture is already
+ confirmed to be Anima and we just need to enumerate matching layers.
"""
return any(_KOHYA_ANIMA_RE.search(k) is not None for k in str_keys)
def has_cosmos_dit_peft_keys(str_keys: list[str]) -> bool:
- """Check for diffusers PEFT keys targeting Cosmos DiT blocks with specific subcomponents."""
+ """Loose PEFT-format detector — see ``has_cosmos_dit_kohya_keys`` docstring."""
return any(_PEFT_ANIMA_RE.search(k) is not None for k in str_keys)
+
+
+def has_cosmos_dit_kohya_keys_strict(str_keys: list[str]) -> bool:
+ """Strict Kohya detector requiring an Anima-exclusive submodule (``mlp``,
+ ``adaln_modulation``, or Cosmos's ``_proj``-suffixed attention names).
+
+ Mutually exclusive with the Wan LoRA probe — no Wan LoRA can satisfy this.
+ """
+ return any(_KOHYA_ANIMA_STRICT_RE.search(k) is not None for k in str_keys)
+
+
+def has_cosmos_dit_peft_keys_strict(str_keys: list[str]) -> bool:
+ """Strict PEFT detector. See ``has_cosmos_dit_kohya_keys_strict`` docstring."""
+ return any(_PEFT_ANIMA_STRICT_RE.search(k) is not None for k in str_keys)
diff --git a/invokeai/backend/patches/lora_conversions/wan_lora_constants.py b/invokeai/backend/patches/lora_conversions/wan_lora_constants.py
new file mode 100644
index 00000000000..c7a6859d6f0
--- /dev/null
+++ b/invokeai/backend/patches/lora_conversions/wan_lora_constants.py
@@ -0,0 +1,174 @@
+# Wan 2.2 LoRA prefix constants and key-shape detection helpers.
+#
+# Wan LoRAs come in three shapes in the wild:
+#
+# 1. **Diffusers PEFT** (HF naming), with or without a "transformer." prefix:
+# blocks.0.attn1.to_q.lora_A.weight
+# transformer.blocks.0.attn1.to_q.lora_A.weight
+#
+# 2. **Native upstream PEFT** (ComfyUI / Wan-AI checkpoint naming) with
+# "diffusion_model." or "transformer." prefix:
+# diffusion_model.blocks.0.self_attn.q.lora_A.weight
+# transformer.blocks.0.cross_attn.k.lora_A.weight
+#
+# 3. **Kohya**, with the standard ``lora_unet_blocks__`` shape,
+# in either diffusers naming (``attn1_to_q``) or native naming (``self_attn_q``):
+# lora_unet_blocks_0_attn1_to_q.lora_down.weight
+# lora_unet_blocks_0_self_attn_q.lora_down.weight
+#
+# The detection helpers below are shared with ``configs/lora.py`` so the probe
+# and the conversion code agree on what counts as a Wan LoRA. They keep this
+# file circular-import-free.
+
+import re
+
+from invokeai.backend.model_manager.taxonomy import WanLoRAVariantType
+
+# Prefix for Wan transformer LoRA layers in the ModelPatchRaw layer dict.
+# Same convention as Anima / QwenImage — the LayerPatcher uses this prefix to
+# resolve patches against the loaded transformer's parameter paths.
+WAN_LORA_TRANSFORMER_PREFIX = "lora_transformer-"
+
+
+# Diffusers Wan-specific submodules: attn1/attn2 (self/cross attention with
+# to_q/to_k/to_v/to_out.0 children) and ffn.net (gated FFN). These are unique
+# to WanTransformer3DModel — none of FLUX (double_blocks/single_blocks),
+# QwenImage (transformer_blocks.X.attn), Z-Image (diffusion_model.layers),
+# or Anima/Cosmos (mlp + adaln_modulation) produce this combination.
+_WAN_DIFFUSERS_SUBMODULES = r"(attn1\.|attn2\.|ffn\.net\.)"
+
+# Native upstream Wan submodules. self_attn / cross_attn collide with Anima's
+# Cosmos DiT naming, so we look for the bare ``.q``/``.k``/``.v``/``.o``
+# projection suffix (no ``_proj`` tail) AND/OR the ``ffn.`` MLP layout —
+# Anima uses ``mlp`` instead, so this is mutually exclusive.
+_WAN_NATIVE_SUBMODULES = r"(self_attn\.[qkvo](\.|$)|cross_attn\.[qkvo](\.|$)|ffn\.\d+\.)"
+
+# Anti-patterns: keys that would indicate Anima/Cosmos (mlp / adaln_modulation /
+# the ``q_proj`` projection naming Cosmos uses on its attention blocks),
+# QwenImage (transformer_blocks), Flux (double_blocks / single_blocks), or
+# Z-Image (diffusion_model.layers). If any of these are present, the LoRA is
+# NOT Wan.
+_ANIMA_ANTI_RE = re.compile(r"blocks[\._]\d+[\._](mlp|adaln_modulation)")
+# Anima Cosmos attention uses ``q_proj`` / ``k_proj`` / ``v_proj`` / ``output_proj``
+# under self_attn/cross_attn. Wan native uses just ``q``/``k``/``v``/``o`` — so
+# the ``_proj`` suffix on a self_attn/cross_attn child is a definitive Anima tell,
+# in both Kohya (``self_attn_q_proj``) and PEFT (``self_attn.q_proj``) forms.
+_ANIMA_ATTN_ANTI_RE = re.compile(r"(self_attn|cross_attn)[\._]([qkv]_proj|output_proj)")
+_QWEN_ANTI_RE = re.compile(r"(^|\.)transformer_blocks\.\d+\.")
+_FLUX_ANTI_RE = re.compile(r"(^|\.|_)(double_blocks|single_blocks|single_transformer_blocks)[\._]\d+")
+_Z_IMAGE_ANTI_RE = re.compile(r"diffusion_model\.layers\.\d+\.")
+
+
+# Kohya format: lora_unet_blocks__(attn1_to_X | ffn_N | (self|cross)_attn_X
+# where X is a single q/k/v/o letter). The strict alphabet on the attention
+# child keeps us from matching Anima's ``cross_attn_q_proj`` (which has an
+# additional ``_proj`` segment).
+_KOHYA_WAN_RE = re.compile(
+ r"lora_unet_blocks_\d+_"
+ r"(attn[12]_(to_[qkv]|to_out_0|norm_[qk])"
+ r"|(self_attn|cross_attn)_[qkvo](_|\.|$)"
+ r"|ffn_(\d+|net_\d+_proj|net_\d+))"
+)
+
+# PEFT format: .blocks..
+# Prefix may be empty, "transformer.", "diffusion_model.", or "base_model.model.transformer."
+_PEFT_WAN_DIFFUSERS_RE = re.compile(
+ r"(?:^|(?:diffusion_model|transformer|base_model\.model\.transformer)\.)blocks\.\d+\." + _WAN_DIFFUSERS_SUBMODULES
+)
+_PEFT_WAN_NATIVE_RE = re.compile(
+ r"(?:^|(?:diffusion_model|transformer|base_model\.model\.transformer)\.)blocks\.\d+\." + _WAN_NATIVE_SUBMODULES
+)
+
+
+def has_wan_kohya_keys(str_keys: list[str]) -> bool:
+ """Kohya-style keys naming Wan submodules (attn1/attn2/self_attn/cross_attn/ffn)."""
+ return any(_KOHYA_WAN_RE.search(k) is not None for k in str_keys)
+
+
+def has_wan_peft_keys(str_keys: list[str]) -> bool:
+ """Diffusers PEFT keys naming Wan submodules in either diffusers or native layout."""
+ for k in str_keys:
+ if _PEFT_WAN_DIFFUSERS_RE.search(k) is not None:
+ return True
+ if _PEFT_WAN_NATIVE_RE.search(k) is not None:
+ return True
+ return False
+
+
+def detect_wan_lora_variant(state_dict: dict) -> WanLoRAVariantType | None:
+ """Inspect a Wan LoRA state dict and guess which model family it targets.
+
+ A14B has inner_dim=5120; TI2V-5B has inner_dim=3072. Every transformer
+ block's ``attn1.to_q`` (or native ``self_attn.q``) LoRA pair has weights
+ shaped against the inner dim — ``lora_up.weight`` is ``[inner_dim, rank]``
+ and ``lora_down.weight`` is ``[rank, inner_dim]``. The larger dim of
+ either is the inner dim.
+
+ Returns:
+ ``WanLoRAVariantType.A14B`` if inner_dim == 5120,
+ ``WanLoRAVariantType.Wan5B`` if inner_dim == 3072,
+ ``None`` if no recognisable attn weight is found or inner_dim is
+ ambiguous (e.g. LoRA that only patches FFN at non-standard rank).
+ """
+ # Probe several common key shapes — diffusers PEFT (lora_A/lora_B),
+ # native Kohya naming (lora_up/lora_down), with or without a
+ # diffusion_model/transformer prefix, in diffusers or native attn
+ # naming. The first matching tensor is enough.
+ candidate_suffixes = (
+ # diffusers PEFT
+ ".attn1.to_q.lora_A.weight",
+ ".attn1.to_q.lora_B.weight",
+ ".self_attn.q.lora_A.weight",
+ ".self_attn.q.lora_B.weight",
+ # native (Kohya) PEFT
+ ".attn1.to_q.lora_up.weight",
+ ".attn1.to_q.lora_down.weight",
+ ".self_attn.q.lora_up.weight",
+ ".self_attn.q.lora_down.weight",
+ )
+ kohya_substrings = (
+ "_attn1_to_q.lora_up.weight",
+ "_attn1_to_q.lora_down.weight",
+ "_self_attn_q.lora_up.weight",
+ "_self_attn_q.lora_down.weight",
+ )
+
+ for key, tensor in state_dict.items():
+ if not isinstance(key, str):
+ continue
+ match_suffix = any(key.endswith(suffix) for suffix in candidate_suffixes)
+ match_kohya = any(needle in key for needle in kohya_substrings)
+ if not (match_suffix or match_kohya):
+ continue
+ shape = getattr(tensor, "shape", None)
+ if shape is None or len(shape) < 2:
+ continue
+ inner_dim = max(int(shape[0]), int(shape[1]))
+ if inner_dim == 5120:
+ return WanLoRAVariantType.A14B
+ if inner_dim == 3072:
+ return WanLoRAVariantType.Wan5B
+ # Any other inner_dim is uncharted — bail rather than guess.
+ return None
+
+ return None
+
+
+def has_non_wan_architecture_keys(str_keys: list[str]) -> bool:
+ """True if any key indicates a non-Wan architecture (Anima, Qwen, Flux, Z-Image).
+
+ Used as an exclusion guard — a Wan LoRA should never carry these patterns,
+ so finding them is grounds to reject the Wan probe.
+ """
+ for k in str_keys:
+ if _ANIMA_ANTI_RE.search(k) is not None:
+ return True
+ if _ANIMA_ATTN_ANTI_RE.search(k) is not None:
+ return True
+ if _QWEN_ANTI_RE.search(k) is not None:
+ return True
+ if _FLUX_ANTI_RE.search(k) is not None:
+ return True
+ if _Z_IMAGE_ANTI_RE.search(k) is not None:
+ return True
+ return False
diff --git a/invokeai/backend/patches/lora_conversions/wan_lora_conversion_utils.py b/invokeai/backend/patches/lora_conversions/wan_lora_conversion_utils.py
new file mode 100644
index 00000000000..5592572b246
--- /dev/null
+++ b/invokeai/backend/patches/lora_conversions/wan_lora_conversion_utils.py
@@ -0,0 +1,250 @@
+"""Wan 2.2 LoRA conversion utilities.
+
+Wan LoRAs target the ``WanTransformer3DModel`` attention and FFN layers. We
+normalise every supported source layout to the diffusers parameter-path naming
+the loaded model uses at runtime (``blocks..attn1.to_q``,
+``blocks..attn2.to_k``, ``blocks..ffn.net.0.proj``, etc.).
+
+Supported source layouts:
+
+- **Diffusers PEFT**: ``[transformer.|base_model.model.transformer.]blocks.X.attn1.to_q.lora_A.weight``
+- **Native PEFT** (ComfyUI / Wan-AI native naming, with diffusion_model or transformer prefix):
+ ``diffusion_model.blocks.X.self_attn.q.lora_A.weight``
+- **Kohya** in either naming: ``lora_unet_blocks_X_attn1_to_q.lora_down.weight``
+ or ``lora_unet_blocks_X_self_attn_q.lora_down.weight``
+"""
+
+import re
+from typing import Dict
+
+import torch
+
+from invokeai.backend.patches.layers.base_layer_patch import BaseLayerPatch
+from invokeai.backend.patches.layers.utils import any_lora_layer_from_state_dict
+from invokeai.backend.patches.lora_conversions.wan_lora_constants import (
+ WAN_LORA_TRANSFORMER_PREFIX,
+ has_wan_kohya_keys,
+)
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+
+# Kohya layer-name regex: lora_unet_blocks__
+_KOHYA_KEY_REGEX = re.compile(r"lora_unet_blocks_(\d+)_(.*)")
+
+
+# Kohya submodule name -> diffusers parameter-path tail.
+#
+# Longest-match-first ordering matters because some keys are prefixes of others
+# (e.g. ``attn1_to_q`` vs ``attn1_to_out_0``). The lookup is exact (not prefix),
+# so this is purely cosmetic, but kept consistent with QwenImage's convention.
+_KOHYA_SUBMODULE_MAP: list[tuple[str, str]] = [
+ # --- Diffusers naming ---
+ # Self-attention (attn1)
+ ("attn1_to_q", "attn1.to_q"),
+ ("attn1_to_k", "attn1.to_k"),
+ ("attn1_to_v", "attn1.to_v"),
+ ("attn1_to_out_0", "attn1.to_out.0"),
+ ("attn1_norm_q", "attn1.norm_q"),
+ ("attn1_norm_k", "attn1.norm_k"),
+ # Cross-attention (attn2)
+ ("attn2_to_q", "attn2.to_q"),
+ ("attn2_to_k", "attn2.to_k"),
+ ("attn2_to_v", "attn2.to_v"),
+ ("attn2_to_out_0", "attn2.to_out.0"),
+ ("attn2_norm_q", "attn2.norm_q"),
+ ("attn2_norm_k", "attn2.norm_k"),
+ # FFN diffusers
+ ("ffn_net_0_proj", "ffn.net.0.proj"),
+ ("ffn_net_2", "ffn.net.2"),
+ # --- Native naming (mapped onto diffusers paths) ---
+ # self_attn -> attn1
+ ("self_attn_q", "attn1.to_q"),
+ ("self_attn_k", "attn1.to_k"),
+ ("self_attn_v", "attn1.to_v"),
+ ("self_attn_o", "attn1.to_out.0"),
+ ("self_attn_norm_q", "attn1.norm_q"),
+ ("self_attn_norm_k", "attn1.norm_k"),
+ # cross_attn -> attn2
+ ("cross_attn_q", "attn2.to_q"),
+ ("cross_attn_k", "attn2.to_k"),
+ ("cross_attn_v", "attn2.to_v"),
+ ("cross_attn_o", "attn2.to_out.0"),
+ ("cross_attn_norm_q", "attn2.norm_q"),
+ ("cross_attn_norm_k", "attn2.norm_k"),
+ # FFN native
+ ("ffn_0", "ffn.net.0.proj"),
+ ("ffn_2", "ffn.net.2"),
+]
+
+
+# Layer-path rules used for PEFT-style keys: applied as substring replacements
+# to the *layer path* (everything between an optional prefix and the LoRA suffix).
+# Order matters — see ``convert_wan_transformer_to_diffusers`` in diffusers for
+# the equivalent state-dict-key rules. We use trailing-dot semantics so e.g.
+# ``.q.`` matches ``self_attn.q.something`` but not ``norm_q``.
+#
+# Paths are augmented with a sentinel trailing ``.`` before applying these
+# rules so that bare endings like ``blocks.0.self_attn.q`` get rewritten as
+# ``blocks.0.attn1.to_q``.
+_NATIVE_TO_DIFFUSERS_PATH_RULES: tuple[tuple[str, str], ...] = (
+ ("cross_attn.", "attn2."),
+ ("self_attn.", "attn1."),
+ (".o.", ".to_out.0."),
+ (".q.", ".to_q."),
+ (".k.", ".to_k."),
+ (".v.", ".to_v."),
+ ("ffn.0.", "ffn.net.0.proj."),
+ ("ffn.2.", "ffn.net.2."),
+)
+
+# Prefixes seen on PEFT-style Wan LoRA keys.
+_PEFT_PREFIXES_TO_STRIP: tuple[str, ...] = (
+ "base_model.model.transformer.",
+ "transformer.",
+ "diffusion_model.",
+)
+
+
+def lora_model_from_wan_state_dict(state_dict: Dict[str, torch.Tensor], alpha: float | None = None) -> ModelPatchRaw:
+ """Convert any supported Wan LoRA state dict into a ``ModelPatchRaw``.
+
+ Detects Kohya vs PEFT layouts and dispatches accordingly. Layer paths in
+ the returned patch use diffusers naming (``blocks.X.attn1.to_q``) prefixed
+ with ``WAN_LORA_TRANSFORMER_PREFIX`` so the runtime ``LayerPatcher`` can
+ match them against ``WanTransformer3DModel`` parameters.
+ """
+ str_keys = [k for k in state_dict.keys() if isinstance(k, str)]
+ if has_wan_kohya_keys(str_keys):
+ return _convert_kohya_format(state_dict, alpha)
+ return _convert_peft_format(state_dict, alpha)
+
+
+def _convert_kohya_format(state_dict: Dict[str, torch.Tensor], alpha: float | None) -> ModelPatchRaw:
+ """Convert a Kohya-format Wan LoRA state dict.
+
+ Keys look like ``lora_unet_blocks__.{lora_down,lora_up,alpha}.weight``.
+ Unrecognised submodules are silently skipped (logged at conversion debug level
+ by the layer factory if needed).
+ """
+ layers: dict[str, BaseLayerPatch] = {}
+ grouped = _group_by_layer(state_dict)
+
+ for kohya_layer, layer_dict in grouped.items():
+ path = _kohya_layer_to_diffusers_path(kohya_layer)
+ if path is None:
+ continue
+ values = _normalize_lora_param_names(layer_dict, alpha)
+ layers[f"{WAN_LORA_TRANSFORMER_PREFIX}{path}"] = any_lora_layer_from_state_dict(values)
+
+ return ModelPatchRaw(layers=layers)
+
+
+def _convert_peft_format(state_dict: Dict[str, torch.Tensor], alpha: float | None) -> ModelPatchRaw:
+ """Convert a Diffusers-PEFT or native-PEFT Wan LoRA state dict."""
+ layers: dict[str, BaseLayerPatch] = {}
+ grouped = _group_by_layer(state_dict)
+
+ for raw_layer_key, layer_dict in grouped.items():
+ stripped = _strip_peft_prefix(raw_layer_key)
+ path = _native_layer_path_to_diffusers(stripped)
+ if path is None:
+ continue
+ values = _normalize_lora_param_names(layer_dict, alpha)
+ layers[f"{WAN_LORA_TRANSFORMER_PREFIX}{path}"] = any_lora_layer_from_state_dict(values)
+
+ return ModelPatchRaw(layers=layers)
+
+
+def _kohya_layer_to_diffusers_path(kohya_layer: str) -> str | None:
+ """``lora_unet_blocks_0_self_attn_q`` -> ``blocks.0.attn1.to_q``."""
+ m = _KOHYA_KEY_REGEX.match(kohya_layer)
+ if not m:
+ return None
+ block_idx = m.group(1)
+ sub = m.group(2)
+ for kohya_sub, diffusers_sub in _KOHYA_SUBMODULE_MAP:
+ if sub == kohya_sub:
+ return f"blocks.{block_idx}.{diffusers_sub}"
+ return None
+
+
+def _strip_peft_prefix(layer_key: str) -> str:
+ """Strip ``transformer.``, ``diffusion_model.``, ``base_model.model.transformer.`` if present."""
+ for prefix in _PEFT_PREFIXES_TO_STRIP:
+ if layer_key.startswith(prefix):
+ return layer_key[len(prefix) :]
+ return layer_key
+
+
+def _native_layer_path_to_diffusers(path: str) -> str | None:
+ """Rewrite a stripped PEFT layer path to diffusers naming.
+
+ No-op if the path is already in diffusers form (contains attn1./attn2./ffn.net.).
+ Returns None only if the path can't be plausibly identified as Wan.
+ """
+ if not path.startswith("blocks."):
+ return None
+
+ if "attn1." in path or "attn2." in path or "ffn.net." in path:
+ return path
+
+ # Apply the native-to-diffusers replacements with a sentinel trailing dot
+ # so rules like ``.q.`` fire on a bare-ending ``...self_attn.q``.
+ augmented = path + "."
+ for needle, replacement in _NATIVE_TO_DIFFUSERS_PATH_RULES:
+ augmented = augmented.replace(needle, replacement)
+ return augmented.rstrip(".")
+
+
+def _normalize_lora_param_names(layer_dict: dict[str, torch.Tensor], alpha: float | None) -> dict[str, torch.Tensor]:
+ """Map PEFT-style ``lora_A``/``lora_B`` to ``lora_down``/``lora_up``.
+
+ Kohya-style ``lora_down``/``lora_up`` pass through unchanged.
+ """
+ if "lora_A.weight" in layer_dict:
+ values: dict[str, torch.Tensor] = {
+ "lora_down.weight": layer_dict["lora_A.weight"],
+ "lora_up.weight": layer_dict["lora_B.weight"],
+ }
+ if alpha is not None:
+ values["alpha"] = torch.tensor(alpha)
+ if "alpha" in layer_dict:
+ values["alpha"] = layer_dict["alpha"]
+ if "dora_scale" in layer_dict:
+ values["dora_scale"] = layer_dict["dora_scale"]
+ return values
+ return layer_dict
+
+
+def _group_by_layer(state_dict: Dict[str, torch.Tensor]) -> dict[str, dict[str, torch.Tensor]]:
+ """Group state-dict keys by their layer path (everything before the LoRA-suffix tail)."""
+ grouped: dict[str, dict[str, torch.Tensor]] = {}
+
+ known_suffixes = [
+ ".lora_A.weight",
+ ".lora_B.weight",
+ ".lora_down.weight",
+ ".lora_up.weight",
+ ".dora_scale",
+ ".alpha",
+ ]
+
+ for key in state_dict:
+ if not isinstance(key, str):
+ continue
+
+ layer_name = None
+ key_name = None
+ for suffix in known_suffixes:
+ if key.endswith(suffix):
+ layer_name = key[: -len(suffix)]
+ key_name = suffix[1:] # drop leading dot
+ break
+
+ if layer_name is None:
+ parts = key.rsplit(".", maxsplit=2)
+ layer_name = parts[0]
+ key_name = ".".join(parts[1:])
+
+ grouped.setdefault(layer_name, {})[key_name] = state_dict[key]
+
+ return grouped
diff --git a/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py
index 6a9959f1e87..2274b34890b 100644
--- a/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py
+++ b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py
@@ -130,6 +130,27 @@ def to(self, device: torch.device | None = None, dtype: torch.dtype | None = Non
return self
+@dataclass
+class WanConditioningInfo:
+ """Wan 2.2 text conditioning information from the UMT5-XXL encoder.
+
+ The Wan transformer takes the encoder's last hidden state directly as
+ cross-attention context (``encoder_hidden_states``).
+ """
+
+ prompt_embeds: torch.Tensor
+ """UMT5-XXL hidden states. Shape: (seq_len, hidden_size) where hidden_size=4096."""
+
+ prompt_attention_mask: torch.Tensor | None = None
+ """Attention mask marking valid (non-padding) tokens. Shape: (seq_len,). 1 for valid, 0 for padding."""
+
+ def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None):
+ self.prompt_embeds = self.prompt_embeds.to(device=device, dtype=dtype)
+ if self.prompt_attention_mask is not None:
+ self.prompt_attention_mask = self.prompt_attention_mask.to(device=device)
+ return self
+
+
@dataclass
class ConditioningFieldData:
# If you change this class, adding more types, you _must_ update the instantiation of ObjectSerializerDisk in
@@ -144,6 +165,7 @@ class ConditioningFieldData:
| List[ZImageConditioningInfo]
| List[QwenImageConditioningInfo]
| List[AnimaConditioningInfo]
+ | List[WanConditioningInfo]
)
diff --git a/invokeai/backend/wan/__init__.py b/invokeai/backend/wan/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/backend/wan/extensions/__init__.py b/invokeai/backend/wan/extensions/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/backend/wan/extensions/wan_ref_image_extension.py b/invokeai/backend/wan/extensions/wan_ref_image_extension.py
new file mode 100644
index 00000000000..92bfabcc265
--- /dev/null
+++ b/invokeai/backend/wan/extensions/wan_ref_image_extension.py
@@ -0,0 +1,196 @@
+"""Wan 2.2 I2V reference-image conditioning.
+
+Wan 2.2 I2V-A14B conditions on a reference image by **VAE-encoding** it and
+concatenating the resulting latents to the noise latents along the channel
+dim — its transformer has ``in_channels=36`` (16 noise + 16 ref-image latents
++ 4 first-frame mask) rather than 16.
+
+This module produces the 20-channel condition tensor ``[B, 20, T_lat, H_lat, W_lat]``
+that the denoise loop will concatenate to the 16-channel noise latents each
+step, yielding the 36-channel input the I2V transformer expects.
+
+Mirrors diffusers ``WanImageToVideoPipeline.prepare_latents`` lines 423–481
+with ``num_frames=1`` and ``expand_timesteps=False`` (the defaults for
+single-frame image generation).
+"""
+
+import torch
+import torchvision.transforms.functional as TF
+from diffusers.models.autoencoders import AutoencoderKLWan
+from PIL import Image
+
+# Wan 2.2 VAE temporal scale factor — single frame still consumes a 4-position
+# slice of the mask tensor, which is why the mask contributes 4 channels.
+_WAN_VAE_TEMPORAL_SCALE = 4
+
+
+def preprocess_reference_image(image: Image.Image, width: int, height: int) -> torch.Tensor:
+ """Resize a PIL image to (width, height) and return a normalised [-1, 1]
+ tensor of shape ``[1, 3, 1, height, width]`` ready for ``AutoencoderKLWan.encode``."""
+ if width % 8 != 0 or height % 8 != 0:
+ raise ValueError(f"Reference-image dimensions must be multiples of 8 (got {width}x{height}).")
+ resized = image.convert("RGB").resize((width, height), Image.LANCZOS)
+ # [0, 1] CHW float tensor.
+ pixel = TF.to_tensor(resized)
+ # Scale to [-1, 1] to match the Wan VAE's expected input range.
+ pixel = pixel * 2.0 - 1.0
+ # [3, H, W] -> [1, 3, 1, H, W]: add batch + temporal dims.
+ return pixel.unsqueeze(0).unsqueeze(2)
+
+
+def encode_reference_image_to_ti2v_condition(
+ image: Image.Image,
+ vae: AutoencoderKLWan,
+ width: int,
+ height: int,
+ device: torch.device,
+ dtype: torch.dtype,
+) -> torch.Tensor:
+ """Build the TI2V-5B-style reference condition tensor.
+
+ Returns shape ``[1, 48, 1, height // 16, width // 16]`` — single VAE-encoded
+ latent frame of the reference image, normalised against the VAE's
+ per-channel mean/std. TI2V-5B does **not** use the A14B 4-channel mask;
+ the mask is built inline in the denoise loop (``expand_timesteps`` path)
+ and used to blend this condition with the noisy latents at each step:
+ ``(1 - mask) * condition + mask * latents``.
+
+ Mirrors :class:`diffusers.WanImageToVideoPipeline.prepare_latents` lines
+ 423-466 with ``expand_timesteps=True``.
+
+ Wan 2.2-VAE has 16x spatial compression (vs A14B's 8x), so the latent
+ dims are ``height // 16`` and ``width // 16``.
+ """
+ vae_dtype = next(iter(vae.parameters())).dtype
+ pixel = preprocess_reference_image(image, width=width, height=height).to(device=device, dtype=vae_dtype)
+
+ with torch.inference_mode():
+ encoded = vae.encode(pixel, return_dict=False)[0]
+ latents = encoded.sample() # [1, 48, 1, H_lat, W_lat]
+
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latent_condition = (latents - latents_mean) / latents_std
+
+ return latent_condition.to(dtype=dtype)
+
+
+def encode_reference_image_to_condition(
+ image: Image.Image,
+ vae: AutoencoderKLWan,
+ width: int,
+ height: int,
+ device: torch.device,
+ dtype: torch.dtype,
+) -> torch.Tensor:
+ """Build the 20-channel I2V condition tensor for a reference image.
+
+ Returns shape ``[1, 20, 1, height // 8, width // 8]`` (4-channel first-frame
+ mask concatenated with 16-channel VAE-encoded image latents along the
+ channel dim).
+
+ The output should later be concatenated with the 16-channel noise latents
+ inside the denoise loop to produce the 36-channel input the I2V transformer
+ expects.
+ """
+ vae_dtype = next(iter(vae.parameters())).dtype
+ pixel = preprocess_reference_image(image, width=width, height=height).to(device=device, dtype=vae_dtype)
+
+ with torch.inference_mode():
+ encoded = vae.encode(pixel, return_dict=False)[0]
+ latents = encoded.sample() # [1, 16, 1, H_lat, W_lat]
+
+ # Normalise against the VAE's per-channel mean/std, matching diffusers'
+ # ``WanImageToVideoPipeline.prepare_latents`` (lines 440-459). Note the
+ # multiplication by 1/std == division by std.
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latent_condition = (latents - latents_mean) / latents_std
+
+ latent_condition = latent_condition.to(dtype=dtype)
+
+ # First-frame mask: at num_frames=1 every position is "the first frame"
+ # (i.e., conditioned). After the temporal-scale expansion the mask is
+ # 4 channels of ones at [1, T_lat=1, H_lat, W_lat].
+ _, _, t_lat, h_lat, w_lat = latent_condition.shape
+ mask = torch.ones(1, _WAN_VAE_TEMPORAL_SCALE, t_lat, h_lat, w_lat, device=device, dtype=dtype)
+
+ return torch.cat([mask, latent_condition], dim=1)
+
+
+def encode_reference_image_to_video_condition(
+ image: Image.Image,
+ vae: AutoencoderKLWan,
+ width: int,
+ height: int,
+ num_frames: int,
+ device: torch.device,
+ dtype: torch.dtype,
+) -> torch.Tensor:
+ """Build the multi-frame I2V condition tensor for Wan 2.2 video generation.
+
+ Returns shape ``[1, 20, T_lat, height // 8, width // 8]`` where
+ ``T_lat = (num_frames - 1) // 4 + 1`` (the standard Wan VAE temporal
+ compression — e.g. 21 latent frames for 81 pixel frames). First 4 channels
+ are the rearranged first-frame mask, last 16 channels are the VAE-encoded
+ latents of the image+zero pseudo-video.
+
+ Mirrors :class:`diffusers.WanImageToVideoPipeline.prepare_latents` with
+ ``last_image=None`` and ``expand_timesteps=False``:
+
+ 1. The reference image is concatenated with zero pixel-frames to form a
+ ``[1, 3, num_frames, H, W]`` pseudo-video — only frame 0 carries the
+ image content, all other frames are zero. The model was trained against
+ latents produced this way; padding in latent space after a 1-frame VAE
+ encode would land different values.
+ 2. The VAE encodes that to ``[1, 16, T_lat, H_lat, W_lat]`` and we
+ normalise by the per-channel ``(mean, std)`` from ``vae.config``.
+ 3. The mask starts in pixel-frame space as ``[1, 1, num_frames, ...]``
+ with 1 at frame 0 and 0 elsewhere. The first frame is repeated 4× then
+ the whole thing is reshaped/transposed into ``[1, 4, T_lat, ...]`` —
+ so the first latent frame's 4 mask channels are all 1 and the rest
+ are all 0.
+
+ The denoise loop concatenates the result along the channel dim to the
+ 16-channel noise latents each step, yielding the 36-channel input the
+ Wan 2.2 I2V-A14B transformer expects.
+ """
+ vae_dtype = next(iter(vae.parameters())).dtype
+ pixel = preprocess_reference_image(image, width=width, height=height).to(
+ device=device, dtype=vae_dtype
+ ) # [1, 3, 1, H, W]
+
+ # Pad the temporal dim with zero pixel-frames; the VAE handles temporal
+ # compression to T_lat.
+ if num_frames > 1:
+ zero_frames = torch.zeros(1, 3, num_frames - 1, height, width, device=device, dtype=vae_dtype)
+ video_condition = torch.cat([pixel, zero_frames], dim=2)
+ else:
+ video_condition = pixel
+
+ with torch.inference_mode():
+ encoded = vae.encode(video_condition, return_dict=False)[0]
+ latents = encoded.sample() # [1, 16, T_lat, H_lat, W_lat]
+
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype)
+ latent_condition = (latents - latents_mean) / latents_std
+
+ latent_condition = latent_condition.to(dtype=dtype)
+ _, _, t_lat, h_lat, w_lat = latent_condition.shape
+
+ # Build the mask in pixel-frame space then rearrange to the 4-channel
+ # latent-temporal form the transformer expects.
+ mask_pixel = torch.ones(1, 1, num_frames, h_lat, w_lat, device=device, dtype=dtype)
+ if num_frames > 1:
+ mask_pixel[:, :, 1:] = 0
+
+ first_frame_mask = mask_pixel[:, :, 0:1].repeat_interleave(repeats=_WAN_VAE_TEMPORAL_SCALE, dim=2)
+ mask = torch.cat([first_frame_mask, mask_pixel[:, :, 1:]], dim=2)
+ # mask is now [1, 1, _WAN_VAE_TEMPORAL_SCALE + (num_frames - 1), H_lat, W_lat]
+ # = [1, 1, num_frames + 3, H_lat, W_lat]. Total temporal positions
+ # (num_frames + 3) equals (t_lat * _WAN_VAE_TEMPORAL_SCALE) when
+ # (num_frames - 1) is divisible by 4 (the contract of num_latent_frames_for).
+ mask = mask.view(1, t_lat, _WAN_VAE_TEMPORAL_SCALE, h_lat, w_lat).transpose(1, 2)
+
+ return torch.cat([mask, latent_condition], dim=1)
diff --git a/invokeai/backend/wan/sampling_utils.py b/invokeai/backend/wan/sampling_utils.py
new file mode 100644
index 00000000000..f8e4b6f632a
--- /dev/null
+++ b/invokeai/backend/wan/sampling_utils.py
@@ -0,0 +1,82 @@
+"""Sampling utilities for Wan 2.2 image generation.
+
+Single-frame inference uses 5D ``[B, C, T=1, H, W]`` latent tensors. The
+scale factors are dictated by the model variant:
+
+* A14B — standard Wan VAE: spatial 8x, latent channels 16
+* TI2V-5B — Wan2.2-VAE: spatial 16x, latent channels 48
+"""
+
+from __future__ import annotations
+
+import torch
+
+from invokeai.backend.model_manager.taxonomy import WanVariantType
+
+
+def get_spatial_scale_factor(variant: WanVariantType) -> int:
+ """Return the VAE spatial downsampling factor for a Wan variant."""
+ if variant == WanVariantType.TI2V_5B:
+ return 16
+ return 8 # A14B and any future single-expert variant default to standard Wan VAE.
+
+
+def get_default_latent_channels(variant: WanVariantType) -> int:
+ """Return the default latent-channel count for a Wan variant.
+
+ Use the actual transformer ``in_channels`` from the loaded model when
+ possible; this helper is for cases where we need the count before the
+ transformer is on device (e.g. building the noise tensor before entering
+ the model-on-device context).
+ """
+ if variant == WanVariantType.TI2V_5B:
+ return 48
+ return 16
+
+
+def make_noise(
+ *,
+ batch_size: int,
+ latent_channels: int,
+ height: int,
+ width: int,
+ spatial_scale_factor: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ seed: int,
+ num_latent_frames: int = 1,
+) -> torch.Tensor:
+ """Generate Wan-shaped noise: ``[B, C, T_lat, H/s, W/s]``.
+
+ For single-frame image generation the default ``num_latent_frames=1`` yields
+ a temporal dim of 1 (matching the original behaviour). Video generation
+ passes the latent-space frame count computed from the pixel-frame count via
+ :func:`num_latent_frames_for` (or directly).
+
+ Mirrors Anima's ``_get_noise``: noise is generated on CPU (deterministic
+ across CUDA / ROCm / MPS) and moved to ``device`` afterwards.
+ """
+ return torch.randn(
+ batch_size,
+ latent_channels,
+ num_latent_frames,
+ height // spatial_scale_factor,
+ width // spatial_scale_factor,
+ device="cpu",
+ dtype=torch.float32,
+ generator=torch.Generator(device="cpu").manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+
+# Wan 2.2 VAE temporal compression ratio. 4 pixel-frames collapse to 1 latent-
+# temporal slice (this is also why the I2V conditioning mask has 4 channels).
+WAN_VAE_TEMPORAL_SCALE_FACTOR = 4
+
+
+def num_latent_frames_for(num_frames: int, vae_scale_factor_temporal: int = WAN_VAE_TEMPORAL_SCALE_FACTOR) -> int:
+ """Convert a pixel-frame count to latent-frame count for the Wan VAE.
+
+ Matches Diffusers ``WanPipeline.prepare_latents``: ``(num_frames - 1) // s + 1``
+ (e.g. ``81 -> 21`` for the standard Wan VAE).
+ """
+ return (num_frames - 1) // vae_scale_factor_temporal + 1
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index c164d1dafe1..0e2f84b2ba9 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -529,6 +529,13 @@
"deleteImage_one": "Delete Image",
"deleteImage_other": "Delete {{count}} Images",
"deleteImagePermanent": "Deleted images cannot be restored.",
+ "deleteVideo_one": "Delete Video",
+ "deleteVideo_other": "Delete {{count}} Videos",
+ "deleteVideoConfirmation": "Delete this video?",
+ "deleteCanvasProject_one": "Delete Canvas Project",
+ "deleteCanvasProject_other": "Delete {{count}} Canvas Projects",
+ "deleteCanvasProjectConfirmation": "Delete this canvas project?",
+ "playVideo": "Play video",
"displayBoardSearch": "Board Search",
"displaySearch": "Image Search",
"download": "Download",
@@ -1368,6 +1375,14 @@
"qwenImageQuantizationNone": "None (bf16)",
"qwenImageQuantizationInt8": "8-bit (int8)",
"qwenImageQuantizationNf4": "4-bit (nf4)",
+ "wanT5Encoder": "Wan2.2 T5 Encoder",
+ "wanT5EncoderPlaceholder": "From VAE/Encoder Source",
+ "wanVae": "VAE",
+ "wanVaePlaceholder": "From VAE/Encoder Source",
+ "wanComponentSource": "VAE/Encoder Source (Diffusers)",
+ "wanComponentSourcePlaceholder": "GGUF Wan models require a Diffusers Wan source for VAE + UMT5-XXL",
+ "wanTransformerLowNoise": "Transformer (Low Noise)",
+ "wanTransformerLowNoisePlaceholder": "Add for full detail",
"upcastAttention": "Upcast Attention",
"uploadImage": "Upload Image",
"urlOrLocalPath": "URL or Local Path",
@@ -1669,6 +1684,7 @@
"noFlux2KleinVaeModelSelected": "No VAE selected. Non-diffusers FLUX.2 Klein models require a standalone VAE",
"noFlux2KleinQwen3EncoderModelSelected": "No Qwen3 Encoder selected. Non-diffusers FLUX.2 Klein models require a standalone Qwen3 Encoder",
"noQwenImageComponentSourceSelected": "GGUF Qwen Image models require a Diffusers Component Source for VAE/encoder",
+ "noWanComponentSourceSelected": "GGUF Wan 2.2 models require a Diffusers Component Source for VAE/encoder",
"noZImageVaeSourceSelected": "No VAE source: Select VAE (FLUX) or Qwen3 Source model",
"noZImageQwen3EncoderSourceSelected": "No Qwen3 Encoder source: Select Qwen3 Encoder or Qwen3 Source model",
"noAnimaVaeModelSelected": "No Anima VAE model selected",
@@ -1724,6 +1740,7 @@
"showOptionsPanel": "Show Side Panel (O or T)",
"shift": "Shift",
"shuffle": "Shuffle Seed",
+ "wanGuidanceScaleLowNoise": "CFG (Low)",
"steps": "Steps",
"strength": "Strength",
"symmetry": "Symmetry",
@@ -3168,6 +3185,7 @@
"project": "Project",
"saveProject": "Save Canvas Project",
"loadProject": "Load Canvas Project",
+ "loadProjectFromFile": "Load Canvas Project from File",
"saveSuccess": "Project Saved",
"saveSuccessDesc": "Saved project with {{count}} images",
"saveError": "Failed to Save Project",
@@ -3175,7 +3193,14 @@
"loadSuccessDesc": "Canvas state restored from project file",
"loadError": "Failed to Load Project",
"loadWarning": "Loading a project will replace your current canvas, including all layers, masks, reference images, and generation parameters. This action cannot be undone.",
- "projectName": "Project Name"
+ "projectName": "Project Name",
+ "saveDestination": "Save Destination",
+ "downloadAsFile": "Download as File",
+ "board": "Board",
+ "updateExisting": "Update Existing Project",
+ "createNew": "Create New Project",
+ "updateSuccess": "Project Updated",
+ "updateError": "Failed to Update Project"
},
"stagingArea": {
"accept": "Accept",
diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
index dd1595bdd74..f1bcf0c1088 100644
--- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx
@@ -8,6 +8,7 @@ import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts';
import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix';
import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
+import { isVideoName } from 'features/gallery/store/types';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { memo } from 'react';
import { useImageDTO } from 'services/api/endpoints/images';
@@ -16,7 +17,11 @@ import type { ImageDTO } from 'services/api/types';
export const GlobalImageHotkeys = memo(() => {
useAssertSingleton('GlobalImageHotkeys');
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
- const imageDTO = useImageDTO(lastSelectedItem ?? null);
+ // Recall-hotkeys are image-only; passing a video name through to useImageDTO fires a 404
+ // against /api/v1/images/i/.mp4 and emits a noisy "Image record not found" backend
+ // log on every video selection.
+ const imageName = lastSelectedItem && !isVideoName(lastSelectedItem) ? lastSelectedItem : null;
+ const imageDTO = useImageDTO(imageName);
if (!imageDTO) {
return null;
diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
index e5ec5ccc565..8ac383e4164 100644
--- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx
@@ -10,7 +10,9 @@ import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteIma
import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
+import { CanvasProjectContextMenu } from 'features/gallery/components/ContextMenu/CanvasProjectContextMenu';
import { ImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu';
+import { VideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
@@ -49,6 +51,8 @@ export const GlobalModalIsolator = memo(() => {
+
+
diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts
index 20303fe0183..62f813db357 100644
--- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts
+++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts
@@ -18,6 +18,9 @@ import {
setZImageScheduler,
syncedToOptimalDimension,
vaeSelected,
+ wanComponentSourceSelected,
+ wanT5EncoderModelSelected,
+ wanVaeModelSelected,
zImageQwen3EncoderModelSelected,
zImageQwen3SourceModelSelected,
zImageVaeModelSelected,
@@ -37,6 +40,7 @@ import {
isAspectRatioID,
isFlux2ReferenceImageConfig,
isQwenImageReferenceImageConfig,
+ isWanReferenceImageConfig,
} from 'features/controlLayers/store/types';
import {
initialFlux2ReferenceImage,
@@ -44,6 +48,7 @@ import {
initialFLUXRedux,
initialIPAdapter,
initialQwenImageReferenceImage,
+ initialWanReferenceImage,
} from 'features/controlLayers/store/util';
import { SUPPORTS_REF_IMAGES_BASE_MODELS } from 'features/modelManagerV2/models';
import { zModelIdentifierField } from 'features/nodes/types/common';
@@ -63,6 +68,9 @@ import {
selectQwenVLEncoderModels,
selectRegionalRefImageModels,
selectT5EncoderModels,
+ selectWanDiffusersModels,
+ selectWanT5EncoderModels,
+ selectWanVAEModels,
selectZImageDiffusersModels,
} from 'services/api/hooks/modelsByType';
import type { FLUXKontextModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types';
@@ -321,6 +329,29 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
}
+ // handle Wan 2.2 component source / standalone VAE / standalone T5 encoder -
+ // clear when switching away. (Auto-default happens unconditionally outside
+ // this block so it fires when switching between Wan variants too.)
+ const {
+ wanComponentSource: wanComponentSourceOnLeave,
+ wanVaeModel: wanVaeModelOnLeave,
+ wanT5EncoderModel: wanT5EncoderModelOnLeave,
+ } = state.params;
+ if (newBase !== 'wan') {
+ if (wanComponentSourceOnLeave) {
+ dispatch(wanComponentSourceSelected(null));
+ modelsUpdatedDisabledOrCleared += 1;
+ }
+ if (wanVaeModelOnLeave) {
+ dispatch(wanVaeModelSelected(null));
+ modelsUpdatedDisabledOrCleared += 1;
+ }
+ if (wanT5EncoderModelOnLeave) {
+ dispatch(wanT5EncoderModelSelected(null));
+ modelsUpdatedDisabledOrCleared += 1;
+ }
+ }
+
if (newModel.base !== 'external' && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(newModel.base)) {
// Handle incompatible reference image models - switch to first compatible model, with some smart logic
// to choose the best available model based on the new main model.
@@ -377,6 +408,22 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
continue;
}
+ if (newBase === 'wan') {
+ // Switching TO Wan - convert any non-wan configs to wan_reference_image.
+ // The Wan I2V graph builder consumes the first enabled ref image; T2V /
+ // TI2V variants ignore ref images entirely (matches Qwen-generate behavior).
+ if (!isWanReferenceImageConfig(entity.config)) {
+ dispatch(
+ refImageConfigChanged({
+ id: entity.id,
+ config: { ...initialWanReferenceImage },
+ })
+ );
+ modelsUpdatedDisabledOrCleared += 1;
+ }
+ continue;
+ }
+
if (isFlux2ReferenceImageConfig(entity.config)) {
// Switching AWAY from FLUX.2 - convert flux2_reference_image to the appropriate config type
let newConfig;
@@ -425,6 +472,29 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
continue;
}
+ if (isWanReferenceImageConfig(entity.config)) {
+ // Switching AWAY from Wan - convert to the appropriate config type for the new base.
+ let newConfig;
+ if (newGlobalRefImageModel) {
+ const parsedModel = zModelIdentifierField.parse(newGlobalRefImageModel);
+ if (newModel.base === 'flux' && newModel.name.toLowerCase().includes('kontext')) {
+ newConfig = { ...initialFluxKontextReferenceImage, model: parsedModel };
+ } else if (newGlobalRefImageModel.type === 'flux_redux') {
+ newConfig = { ...initialFLUXRedux, model: parsedModel };
+ } else {
+ newConfig = { ...initialIPAdapter, model: parsedModel };
+ if (parsedModel.base === 'flux') {
+ newConfig.clipVisionModel = 'ViT-L';
+ }
+ }
+ } else {
+ newConfig = { ...initialIPAdapter };
+ }
+ dispatch(refImageConfigChanged({ id: entity.id, config: newConfig }));
+ modelsUpdatedDisabledOrCleared += 1;
+ continue;
+ }
+
// Standard handling for non-flux2 configs
const shouldUpdateModel =
(entity.config.model && entity.config.model.base !== newBase) ||
@@ -480,6 +550,54 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
}
+ // Wan 2.2: auto-default Component Source / standalone VAE / standalone T5 encoder
+ // when the new model is Wan. Runs on every Wan selection (including same-base
+ // switches like Diffusers Wan → GGUF Wan) so the user doesn't have to dig into
+ // Advanced when picking a GGUF main. Only sets fields that are currently empty
+ // and only does it for GGUF mains — Diffusers mains carry everything themselves.
+ if (newBase === 'wan') {
+ const modelConfigsResult = selectModelConfigsQuery(state);
+ const newModelConfig = modelConfigsResult.data
+ ? modelConfigsAdapterSelectors.selectById(modelConfigsResult.data, newModel.key)
+ : null;
+ const isNewModelGGUF = newModelConfig?.type === 'main' && newModelConfig.format === 'gguf_quantized';
+ if (isNewModelGGUF) {
+ const { wanComponentSource, wanVaeModel, wanT5EncoderModel } = state.params;
+ // Match component source by variant family — A14B (t2v_a14b/i2v_a14b) and
+ // TI2V-5B use different VAEs (16-ch vs 48-ch); a mismatched component source
+ // would silently load the wrong VAE and produce broken images. The standalone
+ // VAE / encoder configs don't carry variant info, so those still go first-match.
+ const newVariant =
+ newModelConfig && 'variant' in newModelConfig && typeof newModelConfig.variant === 'string'
+ ? newModelConfig.variant
+ : null;
+ const a14bFamily = newVariant === 't2v_a14b' || newVariant === 'i2v_a14b';
+ if (!wanComponentSource) {
+ const availableWanDiffusers = selectWanDiffusersModels(state);
+ const matchingFamily = availableWanDiffusers.find((m) => {
+ const v = 'variant' in m && typeof m.variant === 'string' ? m.variant : null;
+ return a14bFamily ? v === 't2v_a14b' || v === 'i2v_a14b' : v === newVariant;
+ });
+ const diffusersModel = matchingFamily ?? availableWanDiffusers[0];
+ if (diffusersModel) {
+ dispatch(wanComponentSourceSelected(zModelIdentifierField.parse(diffusersModel)));
+ }
+ }
+ if (!wanVaeModel) {
+ const vae = selectWanVAEModels(state)[0];
+ if (vae) {
+ dispatch(wanVaeModelSelected(zModelIdentifierField.parse(vae)));
+ }
+ }
+ if (!wanT5EncoderModel) {
+ const encoder = selectWanT5EncoderModels(state)[0];
+ if (encoder) {
+ dispatch(wanT5EncoderModelSelected(zModelIdentifierField.parse(encoder)));
+ }
+ }
+ }
+ }
+
// Handle FLUX.2 Klein model changes within the same base (different variants need different encoders)
// Clear the Qwen3 encoder only when switching between different Klein variants
// (e.g., klein_4b needs qwen3_4b, klein_9b needs qwen3_8b)
diff --git a/invokeai/frontend/web/src/common/hooks/useGalleryItemDTO.tsx b/invokeai/frontend/web/src/common/hooks/useGalleryItemDTO.tsx
new file mode 100644
index 00000000000..a83cc18c9ea
--- /dev/null
+++ b/invokeai/frontend/web/src/common/hooks/useGalleryItemDTO.tsx
@@ -0,0 +1,42 @@
+import { isCanvasProjectName, isVideoName } from 'features/gallery/store/types';
+import { useGetCanvasProjectDTOQuery } from 'services/api/endpoints/canvasProjects';
+import { useImageDTO } from 'services/api/endpoints/images';
+import { useVideoDTO } from 'services/api/endpoints/videos';
+import type { CanvasProjectDTO, ImageDTO, VideoDTO } from 'services/api/types';
+
+/**
+ * Resolves either an ImageDTO, VideoDTO or CanvasProjectDTO based on a polymorphic name. The kind
+ * is derived from the filename pattern — images are `.png`, videos `.mp4`, canvas
+ * projects bare UUIDs (no extension) — so we can dispatch without an extra fetch.
+ *
+ * All underlying RTK Query hooks are always called (React rule-of-hooks); only the relevant one
+ * gets a real name, the others receive `null` / `skipToken` and short-circuit.
+ */
+/** @knipignore Re-exported for callers that destructure the hook return into named locals. */
+export type GalleryItemDTO =
+ | { kind: 'image'; dto: ImageDTO }
+ | { kind: 'video'; dto: VideoDTO }
+ | { kind: 'canvas_project'; dto: CanvasProjectDTO };
+
+export const useGalleryItemDTO = (name: string | null | undefined): GalleryItemDTO | null => {
+ const isVideo = name ? isVideoName(name) : false;
+ const isCanvasProject = name ? isCanvasProjectName(name) : false;
+ const imageName = name && !isVideo && !isCanvasProject ? name : null;
+ const videoName = name && isVideo ? name : null;
+ const projectName = name && isCanvasProject ? name : null;
+
+ const imageDTO = useImageDTO(imageName);
+ const videoDTO = useVideoDTO(videoName);
+ const { data: projectDTO } = useGetCanvasProjectDTOQuery(projectName ?? '', { skip: !projectName });
+
+ if (!name) {
+ return null;
+ }
+ if (isCanvasProject) {
+ return projectDTO ? { kind: 'canvas_project', dto: projectDTO } : null;
+ }
+ if (isVideo) {
+ return videoDTO ? { kind: 'video', dto: videoDTO } : null;
+ }
+ return imageDTO ? { kind: 'image', dto: imageDTO } : null;
+};
diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
index fc173de979f..c660cf0daf0 100644
--- a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
+++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx
@@ -10,7 +10,8 @@ import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiUploadBold } from 'react-icons/pi';
import { uploadImages, useUploadImageMutation } from 'services/api/endpoints/images';
-import type { ImageDTO } from 'services/api/types';
+import { uploadVideos, useUploadVideoMutation } from 'services/api/endpoints/videos';
+import type { ImageDTO, VideoDTO } from 'services/api/types';
import { assert } from 'tsafe';
import type { SetOptional } from 'type-fest';
@@ -24,6 +25,18 @@ export const dropzoneAccept: Accept = {
'image/png': ['.png'].reduce(addUpperCaseReducer, [] as string[]),
'image/jpeg': ['.jpg', '.jpeg', '.png'].reduce(addUpperCaseReducer, [] as string[]),
'image/webp': ['.webp'].reduce(addUpperCaseReducer, [] as string[]),
+ 'video/mp4': ['.mp4'].reduce(addUpperCaseReducer, [] as string[]),
+ 'video/webm': ['.webm'].reduce(addUpperCaseReducer, [] as string[]),
+ 'video/quicktime': ['.mov'].reduce(addUpperCaseReducer, [] as string[]),
+};
+
+/** Returns true when the file looks like a video (by MIME or by extension). */
+const isVideoFile = (file: File): boolean => {
+ if (file.type && file.type.startsWith('video/')) {
+ return true;
+ }
+ const name = file.name.toLowerCase();
+ return name.endsWith('.mp4') || name.endsWith('.webm') || name.endsWith('.mov') || name.endsWith('.mkv');
};
type UseImageUploadButtonArgs =
@@ -31,6 +44,8 @@ type UseImageUploadButtonArgs =
isDisabled?: boolean;
allowMultiple: false;
onUpload?: (imageDTO: ImageDTO) => void;
+ /** Called when a single dropped file is a video (parallel to onUpload for images). */
+ onUploadVideo?: (videoDTO: VideoDTO) => void;
onUploadStarted?: (files: File) => void;
onError?: (error: unknown) => void;
}
@@ -38,6 +53,7 @@ type UseImageUploadButtonArgs =
isDisabled?: boolean;
allowMultiple: true;
onUpload?: (imageDTOs: ImageDTO[]) => void;
+ onUploadVideo?: (videoDTOs: VideoDTO[]) => void;
onUploadStarted?: (files: File[]) => void;
onError?: (error: unknown) => void;
};
@@ -65,6 +81,7 @@ const log = logger('gallery');
*/
export const useImageUploadButton = ({
onUpload,
+ onUploadVideo,
isDisabled,
allowMultiple,
onUploadStarted,
@@ -72,6 +89,7 @@ export const useImageUploadButton = ({
}: UseImageUploadButtonArgs) => {
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
const [uploadImage, request] = useUploadImageMutation();
+ const [uploadVideo] = useUploadVideoMutation();
const { t } = useTranslation();
const onDropAccepted = useCallback(
@@ -90,32 +108,68 @@ export const useImageUploadButton = ({
const file = files[0];
assert(file !== undefined); // should never happen
onUploadStarted?.(file);
- const imageDTO = await uploadImage({
- file,
- image_category: 'user',
- is_intermediate: false,
- board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- silent: true,
- }).unwrap();
- if (onUpload) {
- onUpload(imageDTO);
- }
- } else {
- onUploadStarted?.(files);
- let imageDTOs: ImageDTO[] = [];
- imageDTOs = await uploadImages(
- files.map((file, i) => ({
+ if (isVideoFile(file)) {
+ const videoDTO = await uploadVideo({
+ file,
+ video_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ silent: true,
+ }).unwrap();
+ // Cast: TS narrows onUploadVideo by the allowMultiple discriminator above.
+ (onUploadVideo as ((dto: VideoDTO) => void) | undefined)?.(videoDTO);
+ } else {
+ const imageDTO = await uploadImage({
file,
image_category: 'user',
is_intermediate: false,
board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- silent: false,
- isFirstUploadOfBatch: i === 0,
- }))
- );
- if (onUpload) {
- onUpload(imageDTOs);
+ silent: true,
+ }).unwrap();
+ (onUpload as ((dto: ImageDTO) => void) | undefined)?.(imageDTO);
+ }
+ } else {
+ onUploadStarted?.(files);
+
+ // Split the dropped files into images and videos and upload each set through
+ // its own batch helper so a single drop can include a mix.
+ const imageFiles = files.filter((f) => !isVideoFile(f));
+ const videoFiles = files.filter((f) => isVideoFile(f));
+
+ let imageDTOs: ImageDTO[] = [];
+ if (imageFiles.length > 0) {
+ imageDTOs = await uploadImages(
+ imageFiles.map((file, i) => ({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ silent: false,
+ isFirstUploadOfBatch: i === 0,
+ }))
+ );
+ }
+
+ let videoDTOs: VideoDTO[] = [];
+ if (videoFiles.length > 0) {
+ videoDTOs = await uploadVideos(
+ videoFiles.map((file, i) => ({
+ file,
+ video_category: 'user',
+ is_intermediate: false,
+ board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
+ silent: false,
+ isFirstUploadOfBatch: i === 0,
+ }))
+ );
+ }
+
+ if (imageDTOs.length > 0) {
+ (onUpload as ((dtos: ImageDTO[]) => void) | undefined)?.(imageDTOs);
+ }
+ if (videoDTOs.length > 0) {
+ (onUploadVideo as ((dtos: VideoDTO[]) => void) | undefined)?.(videoDTOs);
}
}
} catch (error) {
@@ -127,7 +181,7 @@ export const useImageUploadButton = ({
});
}
},
- [allowMultiple, onUploadStarted, uploadImage, autoAddBoardId, onUpload, onError, t]
+ [allowMultiple, onUploadStarted, uploadImage, uploadVideo, autoAddBoardId, onUpload, onUploadVideo, onError, t]
);
const onDropRejected = useCallback(
diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
index 5ac6ffcb7c9..62cb8eacbf6 100644
--- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
+++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx
@@ -14,6 +14,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
+import { useAddVideoToBoardMutation, useRemoveVideoFromBoardMutation } from 'services/api/endpoints/videos';
import type { BoardDTO } from 'services/api/types';
const selectImagesToChange = createSelector(
@@ -21,6 +22,11 @@ const selectImagesToChange = createSelector(
(changeBoardModal) => changeBoardModal.image_names
);
+const selectVideosToChange = createSelector(
+ selectChangeBoardModalSlice,
+ (changeBoardModal) => changeBoardModal.video_names
+);
+
const selectIsModalOpen = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.isModalOpen
@@ -35,8 +41,11 @@ const ChangeBoardModal = () => {
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
const imagesToChange = useAppSelector(selectImagesToChange);
+ const videosToChange = useAppSelector(selectVideosToChange);
const [addImagesToBoard] = useAddImagesToBoardMutation();
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
+ const [addVideoToBoard] = useAddVideoToBoardMutation();
+ const [removeVideoFromBoard] = useRemoveVideoFromBoardMutation();
const { t } = useTranslation();
// Returns true if the current user can write images to the given board.
@@ -70,7 +79,7 @@ const ChangeBoardModal = () => {
}, [dispatch]);
const handleChangeBoard = useCallback(() => {
- if (!selectedBoardId || imagesToChange.length === 0) {
+ if (!selectedBoardId || (imagesToChange.length === 0 && videosToChange.length === 0)) {
return;
}
@@ -84,8 +93,30 @@ const ChangeBoardModal = () => {
});
}
}
+
+ if (videosToChange.length) {
+ // The video board endpoints take one video at a time; the context menu acts on a single
+ // selection, so this is normally a one-iteration loop.
+ for (const video_name of videosToChange) {
+ if (selectedBoardId === 'none') {
+ removeVideoFromBoard({ video_name });
+ } else {
+ addVideoToBoard({ board_id: selectedBoardId, video_name });
+ }
+ }
+ }
+
dispatch(changeBoardReset());
- }, [addImagesToBoard, dispatch, imagesToChange, removeImagesFromBoard, selectedBoardId]);
+ }, [
+ addImagesToBoard,
+ addVideoToBoard,
+ dispatch,
+ imagesToChange,
+ removeImagesFromBoard,
+ removeVideoFromBoard,
+ selectedBoardId,
+ videosToChange,
+ ]);
const onChange = useCallback((v) => {
if (!v) {
@@ -107,7 +138,7 @@ const ChangeBoardModal = () => {
{t('boards.movingImagesToBoard', {
- count: imagesToChange.length,
+ count: imagesToChange.length + videosToChange.length,
})}
diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts
index 3f72720a420..dadf7a43b36 100644
--- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts
+++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts
@@ -7,6 +7,7 @@ import z from 'zod';
const zChangeBoardModalState = z.object({
isModalOpen: z.boolean().default(false),
image_names: z.array(z.string()).default(() => []),
+ video_names: z.array(z.string()).default(() => []),
});
type ChangeBoardModalState = z.infer;
@@ -21,15 +22,21 @@ const slice = createSlice({
},
imagesToChangeSelected: (state, action: PayloadAction) => {
state.image_names = action.payload;
+ state.video_names = [];
+ },
+ videosToChangeSelected: (state, action: PayloadAction) => {
+ state.video_names = action.payload;
+ state.image_names = [];
},
changeBoardReset: (state) => {
state.image_names = [];
+ state.video_names = [];
state.isModalOpen = false;
},
},
});
-export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = slice.actions;
+export const { isModalOpenChanged, imagesToChangeSelected, videosToChangeSelected, changeBoardReset } = slice.actions;
export const selectChangeBoardModalSlice = (state: RootState) => state.changeBoardModal;
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx
index 149d5b4f175..0042774f1f4 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog.tsx
@@ -7,7 +7,9 @@ import { atom } from 'nanostores';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-const $pendingFile = atom(null);
+type PendingLoad = { kind: 'file'; file: File } | { kind: 'server'; projectName: string };
+
+const $pending = atom(null);
const openFileDialog = (onFileSelected: (file: File) => void) => {
const input = document.createElement('input');
@@ -22,37 +24,57 @@ const openFileDialog = (onFileSelected: (file: File) => void) => {
input.click();
};
-export const useLoadCanvasProjectWithDialog = () => {
- const openDialog = useCallback(() => {
+/**
+ * Opens the OS file picker, then queues the resulting file for the load-confirmation dialog.
+ * Used by the File menu / context menu "Load Canvas Project from File" entry.
+ */
+export const useLoadCanvasProjectFromFileWithDialog = () => {
+ return useCallback(() => {
openFileDialog((file) => {
- $pendingFile.set(file);
+ $pending.set({ kind: 'file', file });
});
}, []);
+};
- return openDialog;
+/**
+ * Queues a server-stored canvas project for the load-confirmation dialog. The ZIP will be
+ * fetched from `/api/v1/canvas_projects/i/{name}/full` on accept.
+ *
+ * Returns a stable callback so consumers (gallery viewer toolbar, click-to-load actions) can
+ * pass it as an onClick handler.
+ */
+export const useLoadCanvasProjectFromServerWithDialog = () => {
+ return useCallback((projectName: string) => {
+ $pending.set({ kind: 'server', projectName });
+ }, []);
};
+// Kept for backwards compatibility with the existing context-menu wiring.
+export const useLoadCanvasProjectWithDialog = useLoadCanvasProjectFromFileWithDialog;
+
export const LoadCanvasProjectConfirmationAlertDialog = memo(() => {
useAssertSingleton('LoadCanvasProjectConfirmationAlertDialog');
const { t } = useTranslation();
- const { loadCanvasProject } = useCanvasProjectLoad();
- const pendingFile = useStore($pendingFile);
+ const { loadCanvasProjectFromFile, loadCanvasProjectFromServer } = useCanvasProjectLoad();
+ const pending = useStore($pending);
const onClose = useCallback(() => {
- $pendingFile.set(null);
+ $pending.set(null);
}, []);
const onAccept = useCallback(() => {
- const file = $pendingFile.get();
- if (file) {
- void loadCanvasProject(file);
+ const p = $pending.get();
+ if (p?.kind === 'file') {
+ void loadCanvasProjectFromFile(p.file);
+ } else if (p?.kind === 'server') {
+ void loadCanvasProjectFromServer(p.projectName);
}
- $pendingFile.set(null);
- }, [loadCanvasProject]);
+ $pending.set(null);
+ }, [loadCanvasProjectFromFile, loadCanvasProjectFromServer]);
return (
{
const isFLUX = useAppSelector(selectIsFLUX);
const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig);
- // FLUX.2 Klein, Qwen Image Edit and external API models do not require a ref image model selection.
+ // FLUX.2 Klein, Qwen Image Edit, Wan 2.2 and external API models do not require a ref image model selection.
const showModelSelector =
- !isFlux2ReferenceImageConfig(config) && !isQwenImageReferenceImageConfig(config) && !isExternalModel;
+ !isFlux2ReferenceImageConfig(config) &&
+ !isQwenImageReferenceImageConfig(config) &&
+ !isWanReferenceImageConfig(config) &&
+ !isExternalModel;
return (
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx
index bf947ba7c44..c4aa558e9b2 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/SaveCanvasProjectDialog.tsx
@@ -1,3 +1,4 @@
+import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import {
AlertDialog,
AlertDialogBody,
@@ -5,21 +6,32 @@ import {
AlertDialogFooter,
AlertDialogHeader,
Button,
+ Combobox,
Flex,
FormControl,
FormLabel,
+ Image,
Input,
+ Radio,
+ RadioGroup,
+ Text,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { useCanvasProjectSave } from 'features/controlLayers/hooks/useCanvasProjectSave';
+import { $currentCanvasProjectName } from 'features/controlLayers/store/currentCanvasProject';
import { atom } from 'nanostores';
import type { ChangeEvent, RefObject } from 'react';
-import { memo, useCallback, useRef, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
+import { useGetCanvasProjectDTOQuery } from 'services/api/endpoints/canvasProjects';
const $isOpen = atom(false);
+type SaveDestination = 'server' | 'file';
+type SaveMode = 'create' | 'update';
+
export const useSaveCanvasProjectWithDialog = () => {
return useCallback(() => {
$isOpen.set(true);
@@ -46,21 +58,90 @@ SaveCanvasProjectDialog.displayName = 'SaveCanvasProjectDialog';
const Content = memo(({ cancelRef }: { cancelRef: RefObject }) => {
const { t } = useTranslation();
- const { saveCanvasProject } = useCanvasProjectSave();
+ const { saveCanvasProjectAsFile, saveCanvasProjectToServer, updateCanvasProjectOnServer } = useCanvasProjectSave();
+ const currentProjectName = useStore($currentCanvasProjectName);
+
+ // Pre-fill name/board from the currently-loaded project when we know it. Skip the query
+ // entirely if there's nothing loaded so we don't make a wasted request.
+ const { data: currentProject } = useGetCanvasProjectDTOQuery(currentProjectName ?? '', {
+ skip: !currentProjectName,
+ });
+
const [name, setName] = useState('Canvas Project');
+ const [destination, setDestination] = useState('server');
+ const [mode, setMode] = useState(currentProjectName ? 'update' : 'create');
+ const [boardId, setBoardId] = useState('none');
+
+ // When the loaded project's DTO arrives, hydrate the form. Doing this in an effect avoids a
+ // jarring re-render-during-render and lets the user override the values afterwards.
+ useEffect(() => {
+ if (currentProject) {
+ setName(currentProject.name);
+ setBoardId(currentProject.board_id ?? 'none');
+ }
+ }, [currentProject]);
- const onChange = useCallback((e: ChangeEvent) => {
+ const onChangeName = useCallback((e: ChangeEvent) => {
setName(e.target.value);
}, []);
+ const onChangeDestination = useCallback((v: string) => {
+ setDestination(v === 'file' ? 'file' : 'server');
+ }, []);
+
+ const onChangeMode = useCallback((v: string) => {
+ setMode(v === 'update' ? 'update' : 'create');
+ }, []);
+
+ const { boardOptions } = useListAllBoardsQuery(
+ {},
+ {
+ selectFromResult: ({ data }) => {
+ const options: ComboboxOption[] = [{ label: t('common.none'), value: 'none' }].concat(
+ (data ?? []).map(({ board_id, board_name }) => ({
+ label: board_name,
+ value: board_id,
+ }))
+ );
+ return { boardOptions: options };
+ },
+ }
+ );
+
+ const boardValue = useMemo(() => boardOptions.find((o) => o.value === boardId), [boardOptions, boardId]);
+
+ const onChangeBoard = useCallback((v) => {
+ if (!v) {
+ return;
+ }
+ setBoardId(v.value);
+ }, []);
+
const onClose = useCallback(() => {
$isOpen.set(false);
}, []);
const onSave = useCallback(() => {
- void saveCanvasProject(name);
+ if (destination === 'server') {
+ if (mode === 'update' && currentProjectName) {
+ void updateCanvasProjectOnServer(currentProjectName, name);
+ } else {
+ void saveCanvasProjectToServer(name, boardId === 'none' ? undefined : boardId);
+ }
+ } else {
+ void saveCanvasProjectAsFile(name);
+ }
$isOpen.set(false);
- }, [name, saveCanvasProject]);
+ }, [
+ destination,
+ mode,
+ currentProjectName,
+ name,
+ boardId,
+ saveCanvasProjectAsFile,
+ saveCanvasProjectToServer,
+ updateCanvasProjectOnServer,
+ ]);
return (
@@ -69,12 +150,75 @@ const Content = memo(({ cancelRef }: { cancelRef: RefObject }
-
- {t('controlLayers.canvasProject.projectName')}
-
-
-
-
+
+
+ {t('controlLayers.canvasProject.projectName')}
+
+
+
+
+ {t('controlLayers.canvasProject.saveDestination')}
+
+
+ {t('controlLayers.stagingArea.saveToGallery')}
+ {t('controlLayers.canvasProject.downloadAsFile')}
+
+
+
+
+ {destination === 'server' && currentProjectName && (
+
+
+
+
+
+ {t('controlLayers.canvasProject.updateExisting')}
+ {currentProject && (
+
+ {currentProject.thumbnail_url && (
+
+ )}
+
+
+ {currentProject.name}
+
+
+ {currentProject.board_id
+ ? t('controlLayers.canvasProject.board')
+ : t('common.none')}
+ {' · '}
+ {currentProject.width}×{currentProject.height}
+
+
+
+ )}
+
+
+ {t('controlLayers.canvasProject.createNew')}
+
+
+
+ )}
+
+ {destination === 'server' && mode === 'create' && (
+
+ {t('controlLayers.canvasProject.board')}
+
+
+ )}
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
index 2027ff41741..10603191df6 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/addLayerHooks.ts
@@ -32,6 +32,7 @@ import type {
QwenImageReferenceImageConfig,
RegionalGuidanceIPAdapterConfig,
T2IAdapterConfig,
+ WanReferenceImageConfig,
} from 'features/controlLayers/store/types';
import {
initialControlNet,
@@ -41,6 +42,7 @@ import {
initialQwenImageReferenceImage,
initialRegionalGuidanceIPAdapter,
initialT2IAdapter,
+ initialWanReferenceImage,
} from 'features/controlLayers/store/util';
import { zModelIdentifierField } from 'features/nodes/types/common';
import { useCallback } from 'react';
@@ -80,7 +82,12 @@ export const selectDefaultControlAdapter = createSelector(
export const getDefaultRefImageConfig = (
getState: AppGetState
-): IPAdapterConfig | FluxKontextReferenceImageConfig | Flux2ReferenceImageConfig | QwenImageReferenceImageConfig => {
+):
+ | IPAdapterConfig
+ | FluxKontextReferenceImageConfig
+ | Flux2ReferenceImageConfig
+ | QwenImageReferenceImageConfig
+ | WanReferenceImageConfig => {
const state = getState();
const mainModelConfig = selectMainModelConfig(state);
@@ -98,6 +105,11 @@ export const getDefaultRefImageConfig = (
return deepClone(initialQwenImageReferenceImage);
}
+ // Wan 2.2 I2V uses the main model's own VAE - no adapter model needed
+ if (base === 'wan') {
+ return deepClone(initialWanReferenceImage);
+ }
+
if (base === 'flux' && mainModelConfig?.name?.toLowerCase().includes('kontext')) {
const config = deepClone(initialFluxKontextReferenceImage);
config.model = zModelIdentifierField.parse(mainModelConfig);
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts
index 21de5d3b22a..670545c7837 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectLoad.ts
@@ -1,137 +1,318 @@
import { logger } from 'app/logging/logger';
+import { getStore } from 'app/store/nanostores/store';
import { useAppDispatch } from 'app/store/storeHooks';
import { parseify } from 'common/util/serialize';
import { canvasProjectRecalled } from 'features/controlLayers/store/canvasSlice';
+import { $currentCanvasProjectName } from 'features/controlLayers/store/currentCanvasProject';
import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice';
import { paramsRecalled } from 'features/controlLayers/store/paramsSlice';
import { refImagesRecalled } from 'features/controlLayers/store/refImagesSlice';
import type { LoRA, ParamsState, RefImageState } from 'features/controlLayers/store/types';
-import type { CanvasProjectState } from 'features/controlLayers/util/canvasProjectFile';
+import type { AnyCanvasProjectManifest, CanvasProjectState } from 'features/controlLayers/util/canvasProjectFile';
import {
+ CANVAS_PROJECT_PREVIEW_FILENAME,
+ CANVAS_PROJECT_VERSION,
checkExistingImages,
collectImageNames,
+ isV2Manifest,
parseManifest,
processWithConcurrencyLimit,
remapCanvasState,
remapRefImages,
} from 'features/controlLayers/util/canvasProjectFile';
import { toast } from 'features/toast/toast';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
import JSZip from 'jszip';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
+import { canvasProjectsApi, fetchCanvasProjectZip } from 'services/api/endpoints/canvasProjects';
import { uploadImage } from 'services/api/endpoints/images';
const log = logger('canvas');
-export const useCanvasProjectLoad = () => {
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
+type ParseResult = {
+ manifest: AnyCanvasProjectManifest;
+ remappedCanvasState: CanvasProjectState;
+ remappedRefImages: RefImageState[];
+ imageNameMapping: Map;
+ remappedImageCount: number;
+};
+
+/**
+ * Parses an in-memory `.invk` ZIP and dispatches the canvas restore actions.
+ *
+ * Returns metadata about the parse so callers can decide whether to trigger an automatic
+ * server-side re-save (for projects loaded from the server when remapping happened).
+ */
+const parseAndApplyCanvasProjectZip = async (
+ blob: Blob,
+ dispatch: ReturnType
+): Promise => {
+ const zip = await JSZip.loadAsync(blob);
+
+ const manifestFile = zip.file('manifest.json');
+ if (!manifestFile) {
+ throw new Error('Invalid project file: missing manifest.json');
+ }
+ const manifestData = JSON.parse(await manifestFile.async('string'));
+ const manifest = parseManifest(manifestData);
+
+ const canvasStateFile = zip.file('canvas_state.json');
+ if (!canvasStateFile) {
+ throw new Error('Invalid project file: missing canvas_state.json');
+ }
+ const canvasState: CanvasProjectState = JSON.parse(await canvasStateFile.async('string'));
+
+ const paramsFile = zip.file('params.json');
+ let projectParams: ParamsState | null = null;
+ if (paramsFile) {
+ projectParams = JSON.parse(await paramsFile.async('string'));
+ }
+
+ const refImagesFile = zip.file('ref_images.json');
+ let refImages: RefImageState[] = [];
+ if (refImagesFile) {
+ refImages = JSON.parse(await refImagesFile.async('string'));
+ }
+
+ const lorasFile = zip.file('loras.json');
+ let loras: LoRA[] = [];
+ if (lorasFile) {
+ loras = JSON.parse(await lorasFile.async('string'));
+ }
+
+ const imageNames = collectImageNames(canvasState, refImages);
+ const { missing } = await checkExistingImages(imageNames);
+
+ const imageNameMapping = new Map();
+ const imagesFolder = zip.folder('images');
+
+ if (imagesFolder && missing.size > 0) {
+ await processWithConcurrencyLimit(Array.from(missing), async (imageName) => {
+ const imageFile = imagesFolder.file(imageName);
+ if (!imageFile) {
+ log.warn(`Image ${imageName} referenced but not found in ZIP`);
+ return;
+ }
- const loadCanvasProject = useCallback(
- async (file: File) => {
try {
- const zip = await JSZip.loadAsync(file);
+ const blob = await imageFile.async('blob');
+ const uploadFile = new File([blob], imageName, { type: 'image/png' });
+ const imageDTO = await uploadImage({
+ file: uploadFile,
+ image_category: 'general',
+ is_intermediate: false,
+ silent: true,
+ });
- // Validate manifest
- const manifestFile = zip.file('manifest.json');
- if (!manifestFile) {
- throw new Error('Invalid project file: missing manifest.json');
+ if (imageDTO.image_name !== imageName) {
+ imageNameMapping.set(imageName, imageDTO.image_name);
}
- const manifestData = JSON.parse(await manifestFile.async('string'));
- parseManifest(manifestData);
+ } catch (error) {
+ log.warn({ error: parseify(error) }, `Failed to upload image ${imageName}`);
+ }
+ });
+ }
- // Read state files
- const canvasStateFile = zip.file('canvas_state.json');
- if (!canvasStateFile) {
- throw new Error('Invalid project file: missing canvas_state.json');
- }
- const canvasState: CanvasProjectState = JSON.parse(await canvasStateFile.async('string'));
+ const remappedCanvasState = remapCanvasState(canvasState, imageNameMapping);
+ const remappedRefImages = remapRefImages(refImages, imageNameMapping);
- const paramsFile = zip.file('params.json');
- let projectParams: ParamsState | null = null;
- if (paramsFile) {
- projectParams = JSON.parse(await paramsFile.async('string'));
- }
+ dispatch(
+ canvasProjectRecalled({
+ rasterLayers: remappedCanvasState.rasterLayers,
+ controlLayers: remappedCanvasState.controlLayers,
+ inpaintMasks: remappedCanvasState.inpaintMasks,
+ regionalGuidance: remappedCanvasState.regionalGuidance,
+ bbox: remappedCanvasState.bbox,
+ selectedEntityIdentifier: remappedCanvasState.selectedEntityIdentifier,
+ bookmarkedEntityIdentifier: remappedCanvasState.bookmarkedEntityIdentifier,
+ })
+ );
- const refImagesFile = zip.file('ref_images.json');
- let refImages: RefImageState[] = [];
- if (refImagesFile) {
- refImages = JSON.parse(await refImagesFile.async('string'));
- }
+ dispatch(refImagesRecalled({ entities: remappedRefImages, replace: true }));
- const lorasFile = zip.file('loras.json');
- let loras: LoRA[] = [];
- if (lorasFile) {
- loras = JSON.parse(await lorasFile.async('string'));
- }
+ if (projectParams) {
+ dispatch(paramsRecalled(projectParams));
+ }
- // Collect all image names referenced in the state
- const imageNames = collectImageNames(canvasState, refImages);
-
- // Check which images already exist on the server
- const { missing } = await checkExistingImages(imageNames);
-
- // Upload missing images from the ZIP
- const imageNameMapping = new Map();
- const imagesFolder = zip.folder('images');
-
- if (imagesFolder && missing.size > 0) {
- await processWithConcurrencyLimit(Array.from(missing), async (imageName) => {
- const imageFile = imagesFolder.file(imageName);
- if (!imageFile) {
- log.warn(`Image ${imageName} referenced but not found in ZIP`);
- return;
- }
-
- try {
- const blob = await imageFile.async('blob');
- const uploadFile = new File([blob], imageName, { type: 'image/png' });
- const imageDTO = await uploadImage({
- file: uploadFile,
- image_category: 'general',
- is_intermediate: false,
- silent: true,
- });
-
- // Map old name to new name (only if different)
- if (imageDTO.image_name !== imageName) {
- imageNameMapping.set(imageName, imageDTO.image_name);
- }
- } catch (error) {
- log.warn({ error: parseify(error) }, `Failed to upload image ${imageName}`);
- }
- });
- }
+ // Always clear LoRAs first, even when the project has none — otherwise an in-place load would
+ // accumulate LoRAs across sessions.
+ dispatch(loraAllDeleted());
+ for (const lora of loras) {
+ dispatch(loraRecalled({ lora }));
+ }
- // Remap image names in state objects
- const remappedCanvasState = remapCanvasState(canvasState, imageNameMapping);
- const remappedRefImages = remapRefImages(refImages, imageNameMapping);
-
- // Dispatch state restoration
- dispatch(
- canvasProjectRecalled({
- rasterLayers: remappedCanvasState.rasterLayers,
- controlLayers: remappedCanvasState.controlLayers,
- inpaintMasks: remappedCanvasState.inpaintMasks,
- regionalGuidance: remappedCanvasState.regionalGuidance,
- bbox: remappedCanvasState.bbox,
- selectedEntityIdentifier: remappedCanvasState.selectedEntityIdentifier,
- bookmarkedEntityIdentifier: remappedCanvasState.bookmarkedEntityIdentifier,
- })
- );
-
- // Restore reference images
- dispatch(refImagesRecalled({ entities: remappedRefImages, replace: true }));
-
- // Restore generation parameters
- if (projectParams) {
- dispatch(paramsRecalled(projectParams));
- }
+ // Re-collect names after remapping so the auto-resave path can report the correct count.
+ const remappedImageCount = collectImageNames(remappedCanvasState, remappedRefImages).size;
+
+ return { manifest, remappedCanvasState, remappedRefImages, imageNameMapping, remappedImageCount };
+};
+
+/**
+ * Rebuilds a `.invk` ZIP from the original ZIP plus the remapped state. Image bytes are renamed
+ * to the new server-side names but their content is preserved bit-for-bit. The preview, params,
+ * and loras files are passed through unchanged.
+ *
+ * This is what makes the auto-resave path idempotent: after we replace the server-side file with
+ * a rebuilt ZIP, the next load will find every referenced image already present on the server
+ * and skip the re-upload entirely.
+ */
+const rebuildZipWithRemapping = async (
+ originalBlob: Blob,
+ remappedCanvasState: CanvasProjectState,
+ remappedRefImages: RefImageState[],
+ manifest: AnyCanvasProjectManifest,
+ remappedImageCount: number,
+ imageNameMapping: Map
+): Promise => {
+ const original = await JSZip.loadAsync(originalBlob);
+ const next = new JSZip();
+
+ // v2 manifest with refreshed image_count.
+ const hasPreview = isV2Manifest(manifest)
+ ? manifest.hasPreview
+ : original.file(CANVAS_PROJECT_PREVIEW_FILENAME) !== null;
+ const width = isV2Manifest(manifest) ? manifest.width : remappedCanvasState.bbox.rect.width;
+ const height = isV2Manifest(manifest) ? manifest.height : remappedCanvasState.bbox.rect.height;
+
+ const newManifest = {
+ version: CANVAS_PROJECT_VERSION,
+ appVersion: manifest.appVersion,
+ createdAt: manifest.createdAt,
+ name: manifest.name,
+ width,
+ height,
+ imageCount: remappedImageCount,
+ hasPreview,
+ };
+ next.file('manifest.json', JSON.stringify(newManifest, null, 2));
+ next.file('canvas_state.json', JSON.stringify(remappedCanvasState, null, 2));
+ next.file('ref_images.json', JSON.stringify(remappedRefImages, null, 2));
+
+ // Pass-through files that aren't affected by image-name remapping.
+ for (const passthroughName of ['params.json', 'loras.json', CANVAS_PROJECT_PREVIEW_FILENAME]) {
+ const f = original.file(passthroughName);
+ if (f) {
+ next.file(passthroughName, await f.async('blob'));
+ }
+ }
+
+ // Copy images, renaming any that were remapped.
+ const imagesFolder = original.folder('images');
+ if (imagesFolder) {
+ const entries: { originalName: string; relativePath: string }[] = [];
+ imagesFolder.forEach((relativePath, file) => {
+ if (file.dir) {
+ return;
+ }
+ entries.push({ originalName: relativePath, relativePath: file.name });
+ });
+
+ const targetFolder = next.folder('images')!;
+ for (const { originalName, relativePath } of entries) {
+ const file = original.file(relativePath);
+ if (!file) {
+ continue;
+ }
+ const newName = imageNameMapping.get(originalName) ?? originalName;
+ targetFolder.file(newName, await file.async('blob'));
+ }
+ }
+
+ return await next.generateAsync({ type: 'blob' });
+};
+
+/**
+ * Pulls the bytes of the existing preview thumbnail out of the original ZIP so we can include
+ * it in the auto-resave (the server's existing thumbnail file gets overwritten on replace, and
+ * we don't want to lose the preview just because we re-saved without a canvas manager available
+ * to render a fresh one).
+ */
+const extractPreviewBlob = async (originalBlob: Blob): Promise => {
+ const zip = await JSZip.loadAsync(originalBlob);
+ const f = zip.file(CANVAS_PROJECT_PREVIEW_FILENAME);
+ return f ? await f.async('blob') : null;
+};
+
+export const useCanvasProjectLoad = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+
+ const loadCanvasProjectFromFile = useCallback(
+ async (file: File) => {
+ try {
+ await parseAndApplyCanvasProjectZip(file, dispatch);
+ // File-loads aren't tracked as "currently loaded server project" — there's nothing to
+ // update on the server. Clear any stale tracking from a previous server-load.
+ $currentCanvasProjectName.set(null);
+ navigationApi.switchToTab('canvas');
+ toast({
+ id: 'CANVAS_PROJECT_LOAD_SUCCESS',
+ title: t('controlLayers.canvasProject.loadSuccess'),
+ description: t('controlLayers.canvasProject.loadSuccessDesc'),
+ status: 'success',
+ });
+ } catch (error) {
+ log.error({ error: parseify(error) }, 'Failed to load canvas project from file');
+ toast({
+ id: 'CANVAS_PROJECT_LOAD_ERROR',
+ title: t('controlLayers.canvasProject.loadError'),
+ description: String(error),
+ status: 'error',
+ });
+ }
+ },
+ [dispatch, t]
+ );
+
+ const loadCanvasProjectFromServer = useCallback(
+ async (projectName: string) => {
+ try {
+ const blob = await fetchCanvasProjectZip(projectName);
+ const result = await parseAndApplyCanvasProjectZip(blob, dispatch);
+ $currentCanvasProjectName.set(projectName);
+ navigationApi.switchToTab('canvas');
- // Restore LoRAs (always clear, even if project has none)
- dispatch(loraAllDeleted());
- for (const lora of loras) {
- dispatch(loraRecalled({ lora }));
+ // Auto-resave: if remapping happened, the server-side ZIP still references the missing
+ // image names. Push a rebuilt ZIP back so subsequent loads don't re-upload the same bytes.
+ if (result.imageNameMapping.size > 0) {
+ try {
+ const rebuilt = await rebuildZipWithRemapping(
+ blob,
+ result.remappedCanvasState,
+ result.remappedRefImages,
+ result.manifest,
+ result.remappedImageCount,
+ result.imageNameMapping
+ );
+ const previewBlob = await extractPreviewBlob(blob);
+ const rebuiltFile = new File([rebuilt], `${projectName}.invk`, { type: 'application/zip' });
+ await getStore()
+ .dispatch(
+ canvasProjectsApi.endpoints.replaceCanvasProjectFile.initiate(
+ {
+ project_name: projectName,
+ file: rebuiltFile,
+ app_version: result.manifest.appVersion,
+ width: isV2Manifest(result.manifest)
+ ? result.manifest.width
+ : result.remappedCanvasState.bbox.rect.width,
+ height: isV2Manifest(result.manifest)
+ ? result.manifest.height
+ : result.remappedCanvasState.bbox.rect.height,
+ image_count: result.remappedImageCount,
+ thumbnail: previewBlob ?? undefined,
+ },
+ { track: false }
+ )
+ )
+ .unwrap();
+ log.info(`Auto-resaved canvas project ${projectName} after ${result.imageNameMapping.size} image remap(s)`);
+ } catch (replaceErr) {
+ // Soft-fail: the load itself succeeded, the user has a working canvas. Surfacing this
+ // as a hard error would be noisy when the underlying restore is fine.
+ log.warn({ error: parseify(replaceErr) }, 'Auto-resave after remapping failed');
+ }
}
toast({
@@ -141,7 +322,7 @@ export const useCanvasProjectLoad = () => {
status: 'success',
});
} catch (error) {
- log.error({ error: parseify(error) }, 'Failed to load canvas project');
+ log.error({ error: parseify(error) }, 'Failed to load canvas project from server');
toast({
id: 'CANVAS_PROJECT_LOAD_ERROR',
title: t('controlLayers.canvasProject.loadError'),
@@ -153,5 +334,5 @@ export const useCanvasProjectLoad = () => {
[dispatch, t]
);
- return { loadCanvasProject };
+ return { loadCanvasProjectFromFile, loadCanvasProjectFromServer };
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts
index 76a91a2efad..5880f609d45 100644
--- a/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useCanvasProjectSave.ts
@@ -1,7 +1,9 @@
import { logger } from 'app/logging/logger';
import { useAppStore } from 'app/store/storeHooks';
import { parseify } from 'common/util/serialize';
+import { useCanvasManagerSafe } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { downloadBlob } from 'features/controlLayers/konva/util';
+import { $currentCanvasProjectName } from 'features/controlLayers/store/currentCanvasProject';
import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
@@ -9,15 +11,21 @@ import { selectCanvasSlice } from 'features/controlLayers/store/selectors';
import type { CanvasProjectManifest, CanvasProjectState } from 'features/controlLayers/util/canvasProjectFile';
import {
CANVAS_PROJECT_EXTENSION,
+ CANVAS_PROJECT_PREVIEW_FILENAME,
CANVAS_PROJECT_VERSION,
collectImageNames,
processWithConcurrencyLimit,
} from 'features/controlLayers/util/canvasProjectFile';
+import { renderCanvasProjectPreview } from 'features/controlLayers/util/canvasProjectPreview';
import { toast } from 'features/toast/toast';
import JSZip from 'jszip';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useGetAppVersionQuery } from 'services/api/endpoints/appInfo';
+import {
+ useReplaceCanvasProjectFileMutation,
+ useUploadCanvasProjectMutation,
+} from 'services/api/endpoints/canvasProjects';
const log = logger('canvas');
@@ -26,81 +34,157 @@ const sanitizeFileName = (name: string): string => {
return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'canvas-project';
};
+/** The shape returned by buildCanvasProjectZip — bundles the ZIP itself with the metadata the
+ * server-upload endpoint needs (so callers don't have to re-extract it from the manifest). */
+export type BuiltCanvasProject = {
+ zip: Blob;
+ previewBlob: Blob | null;
+ manifest: CanvasProjectManifest;
+ imageCount: number;
+};
+
export const useCanvasProjectSave = () => {
const { t } = useTranslation();
const store = useAppStore();
+ // Read the manager out of the nanostore so this hook is safe to use outside the
+ // CanvasManagerProviderGate (e.g. when the dialog is mounted via GlobalModalIsolator).
+ // If the manager isn't mounted, we simply skip preview rendering.
+ const canvasManager = useCanvasManagerSafe();
const { data: appVersion } = useGetAppVersionQuery();
+ const [uploadCanvasProject] = useUploadCanvasProjectMutation();
+ const [replaceCanvasProjectFile] = useReplaceCanvasProjectFileMutation();
+
+ /**
+ * Builds the `.invk` ZIP in-memory plus a parallel preview WebP. Shared by both the
+ * download-to-file path and the upload-to-server path so the on-disk artifacts are byte-for-byte
+ * identical between the two flows.
+ */
+ const buildCanvasProjectZip = useCallback(
+ async (name: string): Promise => {
+ const state = store.getState();
+ const canvasState = selectCanvasSlice(state);
+ const paramsState = selectParamsSlice(state);
+ const refImagesState = selectRefImagesSlice(state);
+ const lorasState = selectLoRAsSlice(state);
+
+ const projectState: CanvasProjectState = {
+ rasterLayers: canvasState.rasterLayers.entities,
+ controlLayers: canvasState.controlLayers.entities,
+ inpaintMasks: canvasState.inpaintMasks.entities,
+ regionalGuidance: canvasState.regionalGuidance.entities,
+ bbox: canvasState.bbox,
+ selectedEntityIdentifier: canvasState.selectedEntityIdentifier,
+ bookmarkedEntityIdentifier: canvasState.bookmarkedEntityIdentifier,
+ };
+
+ const imageNames = collectImageNames(projectState, refImagesState.entities);
+ const previewBlob = canvasManager
+ ? await renderCanvasProjectPreview(canvasManager).catch((err) => {
+ log.warn({ error: parseify(err) }, 'Failed to render canvas preview; saving without thumbnail');
+ return null;
+ })
+ : null;
+
+ const zip = new JSZip();
+
+ const manifest: CanvasProjectManifest = {
+ version: CANVAS_PROJECT_VERSION,
+ appVersion: appVersion?.version ?? 'unknown',
+ createdAt: new Date().toISOString(),
+ name,
+ width: canvasState.bbox.rect.width,
+ height: canvasState.bbox.rect.height,
+ imageCount: imageNames.size,
+ hasPreview: previewBlob !== null,
+ };
+ zip.file('manifest.json', JSON.stringify(manifest, null, 2));
+
+ zip.file('canvas_state.json', JSON.stringify(projectState, null, 2));
+ zip.file('params.json', JSON.stringify(paramsState, null, 2));
+ zip.file('ref_images.json', JSON.stringify(refImagesState.entities, null, 2));
+ zip.file('loras.json', JSON.stringify(lorasState.loras, null, 2));
+
+ if (previewBlob) {
+ zip.file(CANVAS_PROJECT_PREVIEW_FILENAME, previewBlob);
+ }
+
+ const imagesFolder = zip.folder('images')!;
+ await processWithConcurrencyLimit(Array.from(imageNames), async (imageName) => {
+ try {
+ const response = await fetch(`/api/v1/images/i/${imageName}/full`);
+ if (!response.ok) {
+ log.warn(`Failed to fetch image ${imageName}: ${response.status}`);
+ return;
+ }
+ const blob = await response.blob();
+ imagesFolder.file(imageName, blob);
+ } catch (error) {
+ log.warn({ error: parseify(error) }, `Failed to fetch image ${imageName}`);
+ }
+ });
+
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ return { zip: zipBlob, previewBlob, manifest, imageCount: imageNames.size };
+ },
+ [appVersion?.version, canvasManager, store]
+ );
- const saveCanvasProject = useCallback(
+ const saveCanvasProjectAsFile = useCallback(
async (name: string) => {
try {
- const state = store.getState();
- const canvasState = selectCanvasSlice(state);
- const paramsState = selectParamsSlice(state);
- const refImagesState = selectRefImagesSlice(state);
- const lorasState = selectLoRAsSlice(state);
-
- // Build the canvas project state
- const projectState: CanvasProjectState = {
- rasterLayers: canvasState.rasterLayers.entities,
- controlLayers: canvasState.controlLayers.entities,
- inpaintMasks: canvasState.inpaintMasks.entities,
- regionalGuidance: canvasState.regionalGuidance.entities,
- bbox: canvasState.bbox,
- selectedEntityIdentifier: canvasState.selectedEntityIdentifier,
- bookmarkedEntityIdentifier: canvasState.bookmarkedEntityIdentifier,
- };
-
- // Collect all image names referenced in the state
- const imageNames = collectImageNames(projectState, refImagesState.entities);
-
- // Build ZIP
- const zip = new JSZip();
-
- // Add manifest
- const manifest: CanvasProjectManifest = {
- version: CANVAS_PROJECT_VERSION,
- appVersion: appVersion?.version ?? 'unknown',
- createdAt: new Date().toISOString(),
- name,
- };
- zip.file('manifest.json', JSON.stringify(manifest, null, 2));
-
- // Add state files
- zip.file('canvas_state.json', JSON.stringify(projectState, null, 2));
- zip.file('params.json', JSON.stringify(paramsState, null, 2));
- zip.file('ref_images.json', JSON.stringify(refImagesState.entities, null, 2));
- zip.file('loras.json', JSON.stringify(lorasState.loras, null, 2));
-
- // Fetch and add images
- const imagesFolder = zip.folder('images')!;
- await processWithConcurrencyLimit(Array.from(imageNames), async (imageName) => {
- try {
- const response = await fetch(`/api/v1/images/i/${imageName}/full`);
- if (!response.ok) {
- log.warn(`Failed to fetch image ${imageName}: ${response.status}`);
- return;
- }
- const blob = await response.blob();
- imagesFolder.file(imageName, blob);
- } catch (error) {
- log.warn({ error: parseify(error) }, `Failed to fetch image ${imageName}`);
- }
+ const { zip, imageCount } = await buildCanvasProjectZip(name);
+ const fileName = `${sanitizeFileName(name)}${CANVAS_PROJECT_EXTENSION}`;
+ downloadBlob(zip, fileName);
+
+ toast({
+ id: 'CANVAS_PROJECT_SAVE_SUCCESS',
+ title: t('controlLayers.canvasProject.saveSuccess'),
+ description: t('controlLayers.canvasProject.saveSuccessDesc', { count: imageCount }),
+ status: 'success',
});
+ } catch (error) {
+ log.error({ error: parseify(error) }, 'Failed to save canvas project');
+ toast({
+ id: 'CANVAS_PROJECT_SAVE_ERROR',
+ title: t('controlLayers.canvasProject.saveError'),
+ description: String(error),
+ status: 'error',
+ });
+ }
+ },
+ [buildCanvasProjectZip, t]
+ );
- // Generate ZIP blob and trigger download
- const blob = await zip.generateAsync({ type: 'blob' });
+ const saveCanvasProjectToServer = useCallback(
+ async (name: string, boardId?: string) => {
+ try {
+ const { zip, previewBlob, manifest, imageCount } = await buildCanvasProjectZip(name);
const fileName = `${sanitizeFileName(name)}${CANVAS_PROJECT_EXTENSION}`;
- downloadBlob(blob, fileName);
+ const file = new File([zip], fileName, { type: 'application/zip' });
+
+ const dto = await uploadCanvasProject({
+ file,
+ name,
+ app_version: manifest.appVersion,
+ width: manifest.width,
+ height: manifest.height,
+ image_count: manifest.imageCount,
+ thumbnail: previewBlob ?? undefined,
+ board_id: boardId,
+ is_intermediate: false,
+ }).unwrap();
+
+ // Track the newly-created project as "current" so subsequent saves can update in place.
+ $currentCanvasProjectName.set(dto.project_name);
toast({
id: 'CANVAS_PROJECT_SAVE_SUCCESS',
title: t('controlLayers.canvasProject.saveSuccess'),
- description: t('controlLayers.canvasProject.saveSuccessDesc', { count: imageNames.size }),
+ description: t('controlLayers.canvasProject.saveSuccessDesc', { count: imageCount }),
status: 'success',
});
} catch (error) {
- log.error({ error: parseify(error) }, 'Failed to save canvas project');
+ log.error({ error: parseify(error) }, 'Failed to save canvas project to server');
toast({
id: 'CANVAS_PROJECT_SAVE_ERROR',
title: t('controlLayers.canvasProject.saveError'),
@@ -109,8 +193,49 @@ export const useCanvasProjectSave = () => {
});
}
},
- [appVersion?.version, store, t]
+ [buildCanvasProjectZip, t, uploadCanvasProject]
+ );
+
+ /**
+ * In-place update of an existing server project. Replaces the ZIP and thumbnail but keeps the
+ * project_name (UUID), board assignment, starred state and ownership. Optionally renames.
+ */
+ const updateCanvasProjectOnServer = useCallback(
+ async (projectName: string, name: string) => {
+ try {
+ const { zip, previewBlob, manifest, imageCount } = await buildCanvasProjectZip(name);
+ const fileName = `${sanitizeFileName(name)}${CANVAS_PROJECT_EXTENSION}`;
+ const file = new File([zip], fileName, { type: 'application/zip' });
+
+ await replaceCanvasProjectFile({
+ project_name: projectName,
+ file,
+ name,
+ app_version: manifest.appVersion,
+ width: manifest.width,
+ height: manifest.height,
+ image_count: manifest.imageCount,
+ thumbnail: previewBlob ?? undefined,
+ }).unwrap();
+
+ toast({
+ id: 'CANVAS_PROJECT_SAVE_SUCCESS',
+ title: t('controlLayers.canvasProject.updateSuccess'),
+ description: t('controlLayers.canvasProject.saveSuccessDesc', { count: imageCount }),
+ status: 'success',
+ });
+ } catch (error) {
+ log.error({ error: parseify(error) }, 'Failed to update canvas project on server');
+ toast({
+ id: 'CANVAS_PROJECT_SAVE_ERROR',
+ title: t('controlLayers.canvasProject.updateError'),
+ description: String(error),
+ status: 'error',
+ });
+ }
+ },
+ [buildCanvasProjectZip, replaceCanvasProjectFile, t]
);
- return { saveCanvasProject };
+ return { saveCanvasProjectAsFile, saveCanvasProjectToServer, updateCanvasProjectOnServer };
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/currentCanvasProject.ts b/invokeai/frontend/web/src/features/controlLayers/store/currentCanvasProject.ts
new file mode 100644
index 00000000000..08962eb2b98
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/store/currentCanvasProject.ts
@@ -0,0 +1,12 @@
+import { atom } from 'nanostores';
+
+/**
+ * Tracks the server-side project name of the canvas project most recently loaded into the
+ * editor. `null` when no project is loaded — i.e. the canvas was started fresh, loaded from a
+ * local `.invk` file, or the user opted to start a new canvas after saving.
+ *
+ * Consumed by the Save dialog to surface an "Update existing" option, and by the load flow to
+ * auto-resave with remapped image names so repeated loads of the same project don't keep
+ * uploading the same embedded images.
+ */
+export const $currentCanvasProjectName = atom(null);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
index a5200ef1ff8..21fecaf6437 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts
@@ -291,6 +291,37 @@ const slice = createSlice({
qwenImageShiftChanged: (state, action: PayloadAction) => {
state.qwenImageShift = action.payload;
},
+ wanTransformerLowNoiseSelected: (state, action: PayloadAction) => {
+ const result = zParamsState.shape.wanTransformerLowNoise.safeParse(action.payload);
+ if (!result.success) {
+ return;
+ }
+ state.wanTransformerLowNoise = result.data;
+ },
+ wanComponentSourceSelected: (state, action: PayloadAction) => {
+ const result = zParamsState.shape.wanComponentSource.safeParse(action.payload);
+ if (!result.success) {
+ return;
+ }
+ state.wanComponentSource = result.data;
+ },
+ wanVaeModelSelected: (state, action: PayloadAction) => {
+ const result = zParamsState.shape.wanVaeModel.safeParse(action.payload);
+ if (!result.success) {
+ return;
+ }
+ state.wanVaeModel = result.data;
+ },
+ wanT5EncoderModelSelected: (state, action: PayloadAction<{ key: string; name: string; base: string } | null>) => {
+ const result = zParamsState.shape.wanT5EncoderModel.safeParse(action.payload);
+ if (!result.success) {
+ return;
+ }
+ state.wanT5EncoderModel = result.data;
+ },
+ wanGuidanceScaleLowNoiseChanged: (state, action: PayloadAction) => {
+ state.wanGuidanceScaleLowNoise = action.payload;
+ },
vaePrecisionChanged: (state, action: PayloadAction) => {
state.vaePrecision = action.payload;
},
@@ -610,6 +641,11 @@ const resetState = (state: ParamsState): ParamsState => {
newState.qwenImageQwenVLEncoderModel = oldState.qwenImageQwenVLEncoderModel;
newState.qwenImageQuantization = oldState.qwenImageQuantization;
newState.qwenImageShift = oldState.qwenImageShift;
+ newState.wanTransformerLowNoise = oldState.wanTransformerLowNoise;
+ newState.wanComponentSource = oldState.wanComponentSource;
+ newState.wanVaeModel = oldState.wanVaeModel;
+ newState.wanT5EncoderModel = oldState.wanT5EncoderModel;
+ newState.wanGuidanceScaleLowNoise = oldState.wanGuidanceScaleLowNoise;
return newState;
};
@@ -662,6 +698,11 @@ export const {
qwenImageQwenVLEncoderModelSelected,
qwenImageQuantizationChanged,
qwenImageShiftChanged,
+ wanTransformerLowNoiseSelected,
+ wanComponentSourceSelected,
+ wanVaeModelSelected,
+ wanT5EncoderModelSelected,
+ wanGuidanceScaleLowNoiseChanged,
setClipSkip,
shouldUseCpuNoiseChanged,
setColorCompensation,
@@ -752,6 +793,7 @@ export const selectIsAnima = createParamsSelector((params) => params.model?.base
export const selectIsFlux2 = createParamsSelector((params) => params.model?.base === 'flux2');
export const selectIsExternal = createParamsSelector((params) => params.model?.base === 'external');
export const selectIsQwenImage = createParamsSelector((params) => params.model?.base === 'qwen-image');
+export const selectIsWan = createParamsSelector((params) => params.model?.base === 'wan');
export const selectIsFluxKontext = createParamsSelector((params) => {
if (params.model?.base === 'flux' && params.model?.name.toLowerCase().includes('kontext')) {
return true;
@@ -783,6 +825,11 @@ export const selectQwenImageVaeModel = createParamsSelector((params) => params.q
export const selectQwenImageQwenVLEncoderModel = createParamsSelector((params) => params.qwenImageQwenVLEncoderModel);
export const selectQwenImageQuantization = createParamsSelector((params) => params.qwenImageQuantization);
export const selectQwenImageShift = createParamsSelector((params) => params.qwenImageShift);
+export const selectWanTransformerLowNoise = createParamsSelector((params) => params.wanTransformerLowNoise);
+export const selectWanComponentSource = createParamsSelector((params) => params.wanComponentSource);
+export const selectWanVaeModel = createParamsSelector((params) => params.wanVaeModel);
+export const selectWanT5EncoderModel = createParamsSelector((params) => params.wanT5EncoderModel);
+export const selectWanGuidanceScaleLowNoise = createParamsSelector((params) => params.wanGuidanceScaleLowNoise);
export const selectCFGScale = createParamsSelector((params) => params.cfgScale);
export const selectGuidance = createParamsSelector((params) => params.guidance);
@@ -842,7 +889,16 @@ export const selectModelSupportsRefImages = createSelector(selectModel, selectMo
if (model.base === 'external') {
return false;
}
- return SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base);
+ if (!SUPPORTS_REF_IMAGES_BASE_MODELS.includes(model.base)) {
+ return false;
+ }
+ // Wan: only the I2V variant of A14B consumes a reference image. T2V and
+ // TI2V-5B ignore ref images, so hide the panel for those.
+ if (model.base === 'wan') {
+ const variant = modelConfig && 'variant' in modelConfig ? modelConfig.variant : null;
+ return variant === 'i2v_a14b';
+ }
+ return true;
});
export const selectModelSupportsOptimizedDenoising = createSelector(
selectModel,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
index b7026b586a8..6c364e51e88 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/refImagesSlice.ts
@@ -23,6 +23,7 @@ import {
isFLUXReduxConfig,
isIPAdapterConfig,
isQwenImageReferenceImageConfig,
+ isWanReferenceImageConfig,
zRefImagesState,
} from './types';
import { getReferenceImageState, initialFluxKontextReferenceImage, initialFLUXRedux, initialIPAdapter } from './util';
@@ -144,8 +145,12 @@ const slice = createSlice({
return;
}
- // FLUX.2 and Qwen Image Edit reference images don't have a model field - they use built-in support
- if (isFlux2ReferenceImageConfig(entity.config) || isQwenImageReferenceImageConfig(entity.config)) {
+ // FLUX.2, Qwen Image Edit and Wan reference images don't have a model field - they use built-in support
+ if (
+ isFlux2ReferenceImageConfig(entity.config) ||
+ isQwenImageReferenceImageConfig(entity.config) ||
+ isWanReferenceImageConfig(entity.config)
+ ) {
return;
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index cbeccdfa930..6820d6e1ea8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -418,6 +418,15 @@ const zQwenImageReferenceImageConfig = z.object({
});
export type QwenImageReferenceImageConfig = z.infer;
+// Wan 2.2 I2V uses the model's own VAE to encode a single reference image -
+// no separate adapter model needed. Only consumed by the I2V variant of Wan
+// 2.2 (A14B). T2V / TI2V variants ignore the ref image at graph build time.
+const zWanReferenceImageConfig = z.object({
+ type: z.literal('wan_reference_image'),
+ image: zCroppableImageWithDims.nullable(),
+});
+export type WanReferenceImageConfig = z.infer;
+
const zCanvasEntityBase = z.object({
id: zId,
name: zName,
@@ -434,6 +443,7 @@ export const zRefImageState = z.object({
zFluxKontextReferenceImageConfig,
zFlux2ReferenceImageConfig,
zQwenImageReferenceImageConfig,
+ zWanReferenceImageConfig,
]),
});
export type RefImageState = z.infer;
@@ -455,6 +465,9 @@ export const isQwenImageReferenceImageConfig = (
config: RefImageState['config']
): config is QwenImageReferenceImageConfig => config.type === 'qwen_image_reference_image';
+export const isWanReferenceImageConfig = (config: RefImageState['config']): config is WanReferenceImageConfig =>
+ config.type === 'wan_reference_image';
+
const zFillStyle = z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']);
export type FillStyle = z.infer;
export const isFillStyle = (v: unknown): v is FillStyle => zFillStyle.safeParse(v).success;
@@ -843,6 +856,13 @@ export const zParamsState = z.object({
qwenImageQwenVLEncoderModel: zModelIdentifierField.nullable(), // Optional: Standalone Qwen2.5-VL encoder
qwenImageQuantization: z.enum(['none', 'int8', 'nf4']), // BitsAndBytes quantization for Qwen VL encoder
qwenImageShift: z.number().nullable(), // Sigma schedule shift override (e.g. 3.0 for Lightning LoRAs)
+ // Wan 2.2 model components — A14B GGUF needs a paired second-expert transformer
+ // plus a Diffusers source for VAE/T5 unless standalone VAE/encoder models are wired.
+ wanTransformerLowNoise: zParameterModel.nullable(), // A14B GGUF only: second-expert transformer
+ wanComponentSource: zParameterModel.nullable(), // Diffusers Wan model providing VAE + UMT5-XXL
+ wanVaeModel: zParameterVAEModel.nullable(), // Optional: Standalone Wan VAE checkpoint
+ wanT5EncoderModel: zModelIdentifierField.nullable(), // Optional: Standalone UMT5-XXL encoder
+ wanGuidanceScaleLowNoise: z.number().nullable(), // Optional: separate CFG for low-noise expert (A14B). null = same as primary
// Z-Image Seed Variance Enhancer settings
zImageSeedVarianceEnabled: z.boolean(),
zImageSeedVarianceStrength: z.number().min(0).max(2),
@@ -928,6 +948,11 @@ export const getInitialParamsState = (): ParamsState => ({
qwenImageQwenVLEncoderModel: null,
qwenImageQuantization: 'none' as const,
qwenImageShift: null,
+ wanTransformerLowNoise: null,
+ wanComponentSource: null,
+ wanVaeModel: null,
+ wanT5EncoderModel: null,
+ wanGuidanceScaleLowNoise: null,
zImageSeedVarianceEnabled: false,
zImageSeedVarianceStrength: 0.1,
zImageSeedVarianceRandomizePercent: 50,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
index c8cb49dde3f..9f0fd779e70 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
@@ -21,6 +21,7 @@ import type {
RegionalGuidanceIPAdapterConfig,
RgbColor,
T2IAdapterConfig,
+ WanReferenceImageConfig,
ZImageControlConfig,
} from 'features/controlLayers/store/types';
import type { ImageDTO } from 'services/api/types';
@@ -122,6 +123,10 @@ export const initialQwenImageReferenceImage: QwenImageReferenceImageConfig = {
type: 'qwen_image_reference_image',
image: null,
};
+export const initialWanReferenceImage: WanReferenceImageConfig = {
+ type: 'wan_reference_image',
+ image: null,
+};
export const initialT2IAdapter: T2IAdapterConfig = {
type: 't2i_adapter',
model: null,
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
index db5ad4f7662..c1ea2b4797c 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/validators.ts
@@ -147,8 +147,12 @@ export const getGlobalReferenceImageWarnings = (
const { config } = entity;
- // FLUX.2 and Qwen Image Edit reference images don't require a model - it's built-in
- if (config.type !== 'flux2_reference_image' && config.type !== 'qwen_image_reference_image') {
+ // FLUX.2, Qwen Image Edit and Wan reference images don't require a model - it's built-in
+ if (
+ config.type !== 'flux2_reference_image' &&
+ config.type !== 'qwen_image_reference_image' &&
+ config.type !== 'wan_reference_image'
+ ) {
if (!('model' in config) || !config.model) {
// No model selected
warnings.push(WARNINGS.IP_ADAPTER_NO_MODEL_SELECTED);
@@ -159,8 +163,10 @@ export const getGlobalReferenceImageWarnings = (
}
if (!entity.config.image) {
- // No image selected - for Qwen Image Edit, an image is optional (txt2img works without one)
- if (config.type !== 'qwen_image_reference_image') {
+ // No image selected - for Qwen Image Edit and Wan, an image is optional at the
+ // entity level. Wan I2V *requires* one but enforcement happens at graph-build
+ // time so the warning doesn't fire on T2V/TI2V variants that ignore ref images.
+ if (config.type !== 'qwen_image_reference_image' && config.type !== 'wan_reference_image') {
warnings.push(WARNINGS.IP_ADAPTER_NO_IMAGE_SELECTED);
}
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.test.ts b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.test.ts
new file mode 100644
index 00000000000..b9ffa962fbb
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from 'vitest';
+
+import type { CanvasProjectState } from './canvasProjectFile';
+import {
+ CANVAS_PROJECT_VERSION,
+ collectImageNames,
+ isV2Manifest,
+ parseManifest,
+ remapCanvasState,
+ remapRefImages,
+} from './canvasProjectFile';
+
+const baseBbox = {
+ rect: { x: 0, y: 0, width: 512, height: 512 },
+ modelBase: 'sd-1' as const,
+ optimalDimension: 512,
+ aspectRatio: { value: 1, isLocked: false, id: 'Free' as const },
+ scaledSize: { width: 512, height: 512 },
+ scaleMethod: 'auto' as const,
+};
+
+const makeEmptyState = (): CanvasProjectState => ({
+ rasterLayers: [],
+ controlLayers: [],
+ inpaintMasks: [],
+ regionalGuidance: [],
+ bbox: baseBbox,
+ selectedEntityIdentifier: null,
+ bookmarkedEntityIdentifier: null,
+});
+
+/**
+ * Builds a raster-layer entity carrying one image object. Used to seed states with image refs
+ * so we can verify both `collectImageNames` and `remapCanvasState` walk the right fields.
+ */
+const makeRasterLayerWithImage = (layerId: string, imageName: string) =>
+ ({
+ id: layerId,
+ name: null,
+ type: 'raster_layer',
+ isEnabled: true,
+ isLocked: false,
+ position: { x: 0, y: 0 },
+ opacity: 1,
+ objects: [
+ {
+ id: `${layerId}-img`,
+ type: 'image',
+ image: { image_name: imageName, width: 512, height: 512 },
+ },
+ ],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any;
+
+describe('parseManifest', () => {
+ it('accepts a v2 manifest verbatim', () => {
+ const manifest = {
+ version: 2,
+ appVersion: '6.14.0',
+ createdAt: '2026-05-14T18:00:00.000Z',
+ name: 'My Project',
+ width: 1024,
+ height: 1024,
+ imageCount: 3,
+ hasPreview: true,
+ };
+ const parsed = parseManifest(manifest);
+ expect(parsed).toEqual(manifest);
+ expect(isV2Manifest(parsed)).toBe(true);
+ });
+
+ it('accepts a v1 manifest (legacy format) without width/height/imageCount/hasPreview', () => {
+ const manifest = {
+ version: 1,
+ appVersion: '6.11.1.post1',
+ createdAt: '2026-02-26T19:09:01.440Z',
+ name: 'Old Project',
+ };
+ const parsed = parseManifest(manifest);
+ expect(parsed.version).toBe(1);
+ expect(parsed.name).toBe('Old Project');
+ expect(isV2Manifest(parsed)).toBe(false);
+ });
+
+ it('fills missing v1 name with empty string (legacy files predate the name field)', () => {
+ const manifest = {
+ version: 1,
+ appVersion: '6.11.0',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ };
+ const parsed = parseManifest(manifest);
+ expect(parsed.name).toBe('');
+ });
+
+ it('rejects an unknown version', () => {
+ expect(() =>
+ parseManifest({
+ version: 99,
+ appVersion: '?',
+ createdAt: '?',
+ name: '?',
+ })
+ ).toThrow();
+ });
+
+ it('rejects a v2 manifest with missing required fields', () => {
+ expect(() =>
+ parseManifest({
+ version: 2,
+ appVersion: '6.14.0',
+ createdAt: '2026-05-14T18:00:00.000Z',
+ name: 'Missing dims',
+ // missing width/height/imageCount/hasPreview
+ })
+ ).toThrow();
+ });
+
+ it('declares CANVAS_PROJECT_VERSION as 2 so new saves are always v2', () => {
+ expect(CANVAS_PROJECT_VERSION).toBe(2);
+ });
+});
+
+describe('collectImageNames', () => {
+ it('returns an empty set for an empty canvas + empty ref images', () => {
+ expect(collectImageNames(makeEmptyState(), [])).toEqual(new Set());
+ });
+
+ it('walks raster layer image objects', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [makeRasterLayerWithImage('rl-1', 'a.png'), makeRasterLayerWithImage('rl-2', 'b.png')];
+ expect(collectImageNames(state, [])).toEqual(new Set(['a.png', 'b.png']));
+ });
+
+ it('deduplicates names referenced by multiple layers', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [makeRasterLayerWithImage('rl-1', 'shared.png'), makeRasterLayerWithImage('rl-2', 'shared.png')];
+ expect(collectImageNames(state, [])).toEqual(new Set(['shared.png']));
+ });
+
+ it('walks global ref images including their crop variant', () => {
+ const refs = [
+ {
+ id: 'r1',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ config: {
+ image: {
+ original: { image: { image_name: 'ref-orig.png', width: 1, height: 1 }, rect: { x: 0, y: 0, width: 1, height: 1 } },
+ crop: { image: { image_name: 'ref-crop.png', width: 1, height: 1 }, rect: { x: 0, y: 0, width: 1, height: 1 } },
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any,
+ },
+ ];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect(collectImageNames(makeEmptyState(), refs as any)).toEqual(new Set(['ref-orig.png', 'ref-crop.png']));
+ });
+});
+
+describe('remapCanvasState', () => {
+ it('returns the same state untouched when the mapping is empty', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [makeRasterLayerWithImage('rl-1', 'a.png')];
+ const remapped = remapCanvasState(state, new Map());
+ expect(remapped).toBe(state);
+ });
+
+ it('rewrites image_name in raster layer image objects', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [makeRasterLayerWithImage('rl-1', 'old.png')];
+ const mapping = new Map([['old.png', 'new.png']]);
+ const remapped = remapCanvasState(state, mapping);
+ const firstObject = remapped.rasterLayers[0]?.objects[0];
+ expect(firstObject?.type).toBe('image');
+ if (firstObject?.type === 'image' && 'image_name' in firstObject.image) {
+ expect(firstObject.image.image_name).toBe('new.png');
+ }
+ });
+
+ it('leaves unmapped names alone', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [
+ makeRasterLayerWithImage('rl-1', 'mapped.png'),
+ makeRasterLayerWithImage('rl-2', 'unmapped.png'),
+ ];
+ const mapping = new Map([['mapped.png', 'remapped.png']]);
+ const remapped = remapCanvasState(state, mapping);
+ // After remap, collectImageNames should see the new name for `rl-1` and the original for `rl-2`.
+ expect(collectImageNames(remapped, [])).toEqual(new Set(['remapped.png', 'unmapped.png']));
+ });
+
+ it('does not mutate the input state', () => {
+ const state = makeEmptyState();
+ state.rasterLayers = [makeRasterLayerWithImage('rl-1', 'old.png')];
+ remapCanvasState(state, new Map([['old.png', 'new.png']]));
+ // Inputs should be untouched — a fresh collect on the original still sees `old.png`.
+ expect(collectImageNames(state, [])).toEqual(new Set(['old.png']));
+ });
+});
+
+describe('remapRefImages', () => {
+ it('returns the same array untouched when the mapping is empty', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const refs: any[] = [];
+ expect(remapRefImages(refs, new Map())).toBe(refs);
+ });
+
+ it('rewrites both original and crop image_name', () => {
+ const refs = [
+ {
+ id: 'r1',
+ config: {
+ image: {
+ original: { image: { image_name: 'orig.png', width: 1, height: 1 }, rect: { x: 0, y: 0, width: 1, height: 1 } },
+ crop: { image: { image_name: 'crop.png', width: 1, height: 1 }, rect: { x: 0, y: 0, width: 1, height: 1 } },
+ },
+ },
+ },
+ ];
+ const mapping = new Map([
+ ['orig.png', 'orig-new.png'],
+ ['crop.png', 'crop-new.png'],
+ ]);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const remapped = remapRefImages(refs as any, mapping);
+ expect(remapped[0]?.config.image?.original.image.image_name).toBe('orig-new.png');
+ expect(remapped[0]?.config.image?.crop?.image.image_name).toBe('crop-new.png');
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts
index 97ea31e8bb6..2cfca9c939a 100644
--- a/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectFile.ts
@@ -13,23 +13,47 @@ import type {
import { getImageDTOSafe } from 'services/api/endpoints/images';
import { z } from 'zod';
-export const CANVAS_PROJECT_VERSION = 1;
+export const CANVAS_PROJECT_VERSION = 2;
export const CANVAS_PROJECT_EXTENSION = '.invk';
+export const CANVAS_PROJECT_PREVIEW_FILENAME = 'preview.webp';
// #region Manifest
-const zCanvasProjectManifest = z.object({
- version: z.literal(CANVAS_PROJECT_VERSION),
+// v1 — the original format, shipped in PR #8917. Manifest had only name/version/appVersion/createdAt;
+// width/height/imageCount/hasPreview were not captured. Still loadable — missing fields are
+// reconstructed from canvas_state.json at parse time.
+const zCanvasProjectManifestV1 = z.object({
+ version: z.literal(1),
+ appVersion: z.string(),
+ createdAt: z.string(),
+ name: z.string().optional().default(''),
+});
+
+// v2 — adds bbox dimensions, image count, and a flag indicating whether a preview WebP is
+// bundled alongside the manifest. Required for server-side persistence and gallery thumbnails.
+const zCanvasProjectManifestV2 = z.object({
+ version: z.literal(2),
appVersion: z.string(),
createdAt: z.string(),
name: z.string(),
+ width: z.number(),
+ height: z.number(),
+ imageCount: z.number(),
+ hasPreview: z.boolean(),
});
-export type CanvasProjectManifest = z.infer;
-export const parseManifest = (data: unknown): CanvasProjectManifest => {
- return zCanvasProjectManifest.parse(data);
+export type CanvasProjectManifest = z.infer;
+export type CanvasProjectManifestV1 = z.infer;
+export type AnyCanvasProjectManifest = CanvasProjectManifest | CanvasProjectManifestV1;
+
+const zAnyCanvasProjectManifest = z.discriminatedUnion('version', [zCanvasProjectManifestV1, zCanvasProjectManifestV2]);
+
+export const parseManifest = (data: unknown): AnyCanvasProjectManifest => {
+ return zAnyCanvasProjectManifest.parse(data);
};
+export const isV2Manifest = (m: AnyCanvasProjectManifest): m is CanvasProjectManifest => m.version === 2;
+
// #endregion
// #region Canvas Project State
diff --git a/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectPreview.ts b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectPreview.ts
new file mode 100644
index 00000000000..283f0641024
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/util/canvasProjectPreview.ts
@@ -0,0 +1,54 @@
+import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
+import type { Rect } from 'features/controlLayers/store/types';
+
+const PREVIEW_MAX_SIZE = 512;
+const PREVIEW_MIME = 'image/webp';
+const PREVIEW_QUALITY = 0.8;
+
+/**
+ * Renders the current canvas bbox as a downscaled WebP preview blob (≤512 px longside),
+ * for use as the server-side gallery thumbnail of a saved canvas project.
+ *
+ * Returns `null` if the canvas has nothing to render (empty bbox, no visible raster layers).
+ */
+export const renderCanvasProjectPreview = async (canvasManager: CanvasManager): Promise => {
+ const bbox = canvasManager.stateApi.getBbox();
+ const rect: Rect = bbox.rect;
+ if (rect.width === 0 || rect.height === 0) {
+ return null;
+ }
+
+ const rasterAdapters = canvasManager.compositor.getVisibleAdaptersOfType('raster_layer');
+ if (rasterAdapters.length === 0) {
+ return null;
+ }
+
+ const sourceCanvas = canvasManager.compositor.getCompositeCanvas(rasterAdapters, rect);
+
+ // Scale down to PREVIEW_MAX_SIZE longside (no upscale).
+ const longside = Math.max(sourceCanvas.width, sourceCanvas.height);
+ const scale = longside > PREVIEW_MAX_SIZE ? PREVIEW_MAX_SIZE / longside : 1;
+ const targetWidth = Math.max(1, Math.round(sourceCanvas.width * scale));
+ const targetHeight = Math.max(1, Math.round(sourceCanvas.height * scale));
+
+ const target = document.createElement('canvas');
+ target.width = targetWidth;
+ target.height = targetHeight;
+ const ctx = target.getContext('2d');
+ if (!ctx) {
+ return null;
+ }
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
+
+ return await new Promise((resolve) => {
+ target.toBlob(
+ (blob) => {
+ resolve(blob);
+ },
+ PREVIEW_MIME,
+ PREVIEW_QUALITY
+ );
+ });
+};
diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
index c50aa9465f5..a1fa1f6d641 100644
--- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
+++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts
@@ -13,6 +13,7 @@ import type { CanvasState, RefImagesState } from 'features/controlLayers/store/t
import type { ImageUsage } from 'features/deleteImageModal/store/types';
import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
+import { isCanvasProjectName, isVideoName } from 'features/gallery/store/types';
import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
@@ -22,7 +23,9 @@ import { selectUpscaleSlice, type UpscaleState } from 'features/parameters/store
import { selectSystemShouldConfirmOnDelete } from 'features/system/store/systemSlice';
import { atom } from 'nanostores';
import { useMemo } from 'react';
+import { canvasProjectsApi } from 'services/api/endpoints/canvasProjects';
import { imagesApi } from 'services/api/endpoints/images';
+import { videosApi } from 'services/api/endpoints/videos';
import type { Param0 } from 'tsafe';
// Implements an awaitable modal dialog for deleting images
@@ -53,14 +56,69 @@ const getInitialState = (): DeleteImagesModalState => ({
const $deleteModalState = atom(getInitialState());
-const deleteImagesWithDialog = async (image_names: string[], store: AppStore): Promise => {
- const { getState } = store;
- const imageUsage = getImageUsageFromImageNames(image_names, getState());
+/**
+ * Splits a polymorphic name list into the three resource kinds. Used by the bulk delete flow so
+ * that the image-usage modal only opens for actual images — canvas projects and videos go
+ * straight to their respective delete endpoints (no canvas/node/ref references to check).
+ */
+const splitNamesByKind = (names: string[]): { imageNames: string[]; videoNames: string[]; projectNames: string[] } => {
+ const imageNames: string[] = [];
+ const videoNames: string[] = [];
+ const projectNames: string[] = [];
+ for (const name of names) {
+ if (isCanvasProjectName(name)) {
+ projectNames.push(name);
+ } else if (isVideoName(name)) {
+ videoNames.push(name);
+ } else {
+ imageNames.push(name);
+ }
+ }
+ return { imageNames, videoNames, projectNames };
+};
+
+const deleteImagesWithDialog = async (names: string[], store: AppStore): Promise => {
+ const { dispatch, getState } = store;
+ const { imageNames, videoNames, projectNames } = splitNamesByKind(names);
+
+ // Canvas projects and videos don't have the cross-feature references that images do (they
+ // can't be referenced from nodes/canvas/refs), so the usage modal is irrelevant for them.
+ // Fire-and-forget the deletes here; the image half goes through the modal below if there is one.
+ if (projectNames.length > 0) {
+ try {
+ await dispatch(
+ canvasProjectsApi.endpoints.deleteCanvasProjects.initiate({ project_names: projectNames }, { track: false })
+ ).unwrap();
+ } catch {
+ // no-op — RTK Query handles its own error logging
+ }
+ }
+ if (videoNames.length > 0) {
+ for (const video_name of videoNames) {
+ try {
+ await dispatch(videosApi.endpoints.deleteVideo.initiate({ video_name }, { track: false })).unwrap();
+ } catch {
+ // no-op
+ }
+ }
+ }
+
+ if (imageNames.length === 0) {
+ // Pure non-image selection — clear any selection that pointed at the deleted items so the
+ // gallery doesn't keep highlighting ghosts.
+ const state = getState();
+ if (intersection(state.gallery.selection, names).length > 0) {
+ dispatch(imageSelected(null));
+ }
+ return;
+ }
+
+ const imageUsage = getImageUsageFromImageNames(imageNames, getState());
const shouldConfirmOnDelete = selectSystemShouldConfirmOnDelete(getState());
if (!shouldConfirmOnDelete && !isAnyImageInUse(imageUsage)) {
// If we don't need to confirm and the images are not in use, delete them directly
- await handleDeletions(image_names, store);
+ await handleDeletions(imageNames, store);
return;
}
@@ -68,7 +126,7 @@ const deleteImagesWithDialog = async (image_names: string[], store: AppStore): P
$deleteModalState.set({
usagePerImage: imageUsage,
usageSummary: getImageUsageSummary(imageUsage),
- image_names,
+ image_names: imageNames,
isOpen: true,
resolve,
reject,
diff --git a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
index e5d7df68f28..5df7ee23715 100644
--- a/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
+++ b/invokeai/frontend/web/src/features/dnd/FullscreenDropzone.tsx
@@ -16,12 +16,23 @@ import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { uploadImages } from 'services/api/endpoints/images';
+import { uploadVideos } from 'services/api/endpoints/videos';
import { useBoardName } from 'services/api/hooks/useBoardName';
-import type { UploadImageArg } from 'services/api/types';
+import type { UploadImageArg, UploadVideoArg } from 'services/api/types';
import { z } from 'zod';
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/webp'];
-const ACCEPTED_FILE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
+const ACCEPTED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-matroska'];
+const ACCEPTED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'];
+const ACCEPTED_VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
+
+const isVideoFile = (file: File): boolean => {
+ if (file.type && ACCEPTED_VIDEO_TYPES.includes(file.type.toLowerCase())) {
+ return true;
+ }
+ const lower = file.name.toLowerCase();
+ return ACCEPTED_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
+};
// const MAX_IMAGE_SIZE = 4; //In MegaBytes
// const sizeInMB = (sizeInBytes: number, decimalsNum = 2) => {
@@ -39,13 +50,18 @@ const zUploadFile = z
// )
.refine(
(file) => {
- return ACCEPTED_IMAGE_TYPES.includes(file.type.toLowerCase());
+ const type = file.type.toLowerCase();
+ return ACCEPTED_IMAGE_TYPES.includes(type) || ACCEPTED_VIDEO_TYPES.includes(type);
},
{ message: `File type is not supported` }
)
.refine(
(file) => {
- return ACCEPTED_FILE_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext));
+ const lower = file.name.toLowerCase();
+ return (
+ ACCEPTED_IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) ||
+ ACCEPTED_VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext))
+ );
},
{ message: `File extension is not supported` }
);
@@ -86,25 +102,49 @@ export const FullscreenDropzone = memo(() => {
const focusedRegion = getFocusedRegion();
- // While on the canvas tab and when pasting a single image, canvas may want to create a new layer. Let it handle
- // the paste event.
- const [firstImageFile] = files;
- if (focusedRegion === 'canvas' && activeTab === 'canvas' && files.length === 1 && firstImageFile) {
- setFileToPaste(firstImageFile);
+ // While on the canvas tab and when pasting a single image (not a video — the canvas can't
+ // host videos), canvas may want to create a new layer. Let it handle the paste event.
+ const [firstFile] = files;
+ if (
+ focusedRegion === 'canvas' &&
+ activeTab === 'canvas' &&
+ files.length === 1 &&
+ firstFile &&
+ !isVideoFile(firstFile)
+ ) {
+ setFileToPaste(firstFile);
return;
}
const autoAddBoardId = selectAutoAddBoardId(getState());
+ const boardId = autoAddBoardId === 'none' ? undefined : autoAddBoardId;
+
+ // Split files by media type so each batch goes through its own uploader. Image and video
+ // uploaders are independent — they each update their own RTK cache + invalidate the gallery.
+ const imageFiles = files.filter((f) => !isVideoFile(f));
+ const videoFiles = files.filter((f) => isVideoFile(f));
+
+ if (imageFiles.length > 0) {
+ const imageUploadArgs: UploadImageArg[] = imageFiles.map((file, i) => ({
+ file,
+ image_category: 'user',
+ is_intermediate: false,
+ board_id: boardId,
+ isFirstUploadOfBatch: i === 0,
+ }));
+ uploadImages(imageUploadArgs);
+ }
- const uploadArgs: UploadImageArg[] = files.map((file, i) => ({
- file,
- image_category: 'user',
- is_intermediate: false,
- board_id: autoAddBoardId === 'none' ? undefined : autoAddBoardId,
- isFirstUploadOfBatch: i === 0,
- }));
-
- uploadImages(uploadArgs);
+ if (videoFiles.length > 0) {
+ const videoUploadArgs: UploadVideoArg[] = videoFiles.map((file, i) => ({
+ file,
+ video_category: 'user',
+ is_intermediate: false,
+ board_id: boardId,
+ isFirstUploadOfBatch: i === 0,
+ }));
+ uploadVideos(videoUploadArgs);
+ }
},
[activeTab, t]
);
diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts
index 8ed60799407..8558f98520c 100644
--- a/invokeai/frontend/web/src/features/dnd/dnd.ts
+++ b/invokeai/frontend/web/src/features/dnd/dnd.ts
@@ -8,21 +8,26 @@ import { imageDTOToCroppableImage } from 'features/controlLayers/store/util';
import { selectComparisonImages } from 'features/gallery/components/ImageViewer/common';
import type { BoardId } from 'features/gallery/store/types';
import {
+ addCanvasProjectToBoard,
addImagesToBoard,
+ addVideoToBoard,
createNewCanvasEntityFromImage,
newCanvasFromImage,
+ removeCanvasProjectFromBoard,
removeImagesFromBoard,
+ removeVideoFromBoard,
replaceCanvasEntityObjectsWithImage,
setComparisonImage,
setGlobalReferenceImage,
setNodeImageFieldImage,
+ setNodeVideoFieldVideo,
setRegionalGuidanceReferenceImage,
setUpscaleInitialImage,
} from 'features/imageActions/actions';
import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice';
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field';
-import type { ImageDTO } from 'services/api/types';
+import type { CanvasProjectDTO, ImageDTO, VideoDTO } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('dnd');
@@ -83,6 +88,30 @@ export const singleImageDndSource: DndSource = {
};
//#endregion
+//#region Single Video
+const _singleVideo = buildTypeAndKey('single-video');
+type SingleVideoDndSourceData = DndData;
+export const singleVideoDndSource: DndSource = {
+ ..._singleVideo,
+ typeGuard: buildTypeGuard(_singleVideo.key),
+ getData: buildGetData(_singleVideo.key, _singleVideo.type),
+};
+//#endregion
+
+//#region Single Canvas Project
+const _singleCanvasProject = buildTypeAndKey('single-canvas-project');
+type SingleCanvasProjectDndSourceData = DndData<
+ typeof _singleCanvasProject.type,
+ typeof _singleCanvasProject.key,
+ { projectDTO: CanvasProjectDTO }
+>;
+export const singleCanvasProjectDndSource: DndSource = {
+ ..._singleCanvasProject,
+ typeGuard: buildTypeGuard(_singleCanvasProject.key),
+ getData: buildGetData(_singleCanvasProject.key, _singleCanvasProject.type),
+};
+//#endregion
+
//#region Multiple Image
const _multipleImage = buildTypeAndKey('multiple-image');
export type MultipleImageDndSourceData = DndData<
@@ -273,6 +302,32 @@ export const setNodeImageFieldImageDndTarget: DndTarget;
+export const setNodeVideoFieldVideoDndTarget: DndTarget =
+ {
+ ..._setNodeVideoFieldVideo,
+ typeGuard: buildTypeGuard(_setNodeVideoFieldVideo.key),
+ getData: buildGetData(_setNodeVideoFieldVideo.key, _setNodeVideoFieldVideo.type),
+ isValid: ({ sourceData }) => {
+ if (singleVideoDndSource.typeGuard(sourceData)) {
+ return true;
+ }
+ return false;
+ },
+ handler: ({ sourceData, targetData, dispatch }) => {
+ const { videoDTO } = sourceData.payload;
+ const { fieldIdentifier } = targetData.payload;
+ setNodeVideoFieldVideo({ fieldIdentifier, videoDTO, dispatch });
+ },
+ };
+//#endregion
+
//#region Add Images to Image Collection Node Field
const _addImagesToNodeImageFieldCollection = buildTypeAndKey('add-images-to-image-collection-node-field');
export type AddImagesToNodeImageFieldCollection = DndData<
@@ -495,7 +550,7 @@ export type AddImageToBoardDndTargetData = DndData<
>;
export const addImageToBoardDndTarget: DndTarget<
AddImageToBoardDndTargetData,
- SingleImageDndSourceData | MultipleImageDndSourceData
+ SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | SingleCanvasProjectDndSourceData
> = {
..._addToBoard,
typeGuard: buildTypeGuard(_addToBoard.key),
@@ -518,6 +573,27 @@ export const addImageToBoardDndTarget: DndTarget<
}
return canMoveFromSourceBoard(currentBoard, getState);
}
+ if (singleVideoDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
+ const destinationBoard = targetData.payload.boardId;
+ if (currentBoard === destinationBoard) {
+ return false;
+ }
+ // Same source-board permission check as images. Backend additionally
+ // enforces _assert_video_direct_owner — a stricter check the client can't
+ // perform without each video's owner, so we let those failures bubble up
+ // through the mutation rather than blocking the drop preemptively.
+ return canMoveFromSourceBoard(currentBoard, getState);
+ }
+ if (singleCanvasProjectDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.projectDTO.board_id ?? 'none';
+ const destinationBoard = targetData.payload.boardId;
+ if (currentBoard === destinationBoard) {
+ return false;
+ }
+ // Same permission model as videos — backend enforces _assert_project_direct_owner.
+ return canMoveFromSourceBoard(currentBoard, getState);
+ }
return false;
},
handler: ({ sourceData, targetData, dispatch }) => {
@@ -532,6 +608,18 @@ export const addImageToBoardDndTarget: DndTarget<
const { boardId } = targetData.payload;
addImagesToBoard({ image_names, boardId, dispatch });
}
+
+ if (singleVideoDndSource.typeGuard(sourceData)) {
+ const { videoDTO } = sourceData.payload;
+ const { boardId } = targetData.payload;
+ addVideoToBoard({ video_name: videoDTO.video_name, boardId, dispatch });
+ }
+
+ if (singleCanvasProjectDndSource.typeGuard(sourceData)) {
+ const { projectDTO } = sourceData.payload;
+ const { boardId } = targetData.payload;
+ addCanvasProjectToBoard({ project_name: projectDTO.project_name, boardId, dispatch });
+ }
},
};
@@ -546,7 +634,7 @@ export type RemoveImageFromBoardDndTargetData = DndData<
>;
export const removeImageFromBoardDndTarget: DndTarget<
RemoveImageFromBoardDndTargetData,
- SingleImageDndSourceData | MultipleImageDndSourceData
+ SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | SingleCanvasProjectDndSourceData
> = {
..._removeFromBoard,
typeGuard: buildTypeGuard(_removeFromBoard.key),
@@ -569,6 +657,22 @@ export const removeImageFromBoardDndTarget: DndTarget<
return canMoveFromSourceBoard(currentBoard, getState);
}
+ if (singleVideoDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none';
+ if (currentBoard === 'none') {
+ return false;
+ }
+ return canMoveFromSourceBoard(currentBoard, getState);
+ }
+
+ if (singleCanvasProjectDndSource.typeGuard(sourceData)) {
+ const currentBoard = sourceData.payload.projectDTO.board_id ?? 'none';
+ if (currentBoard === 'none') {
+ return false;
+ }
+ return canMoveFromSourceBoard(currentBoard, getState);
+ }
+
return false;
},
handler: ({ sourceData, dispatch }) => {
@@ -581,6 +685,16 @@ export const removeImageFromBoardDndTarget: DndTarget<
const { image_names } = sourceData.payload;
removeImagesFromBoard({ image_names, dispatch });
}
+
+ if (singleVideoDndSource.typeGuard(sourceData)) {
+ const { videoDTO } = sourceData.payload;
+ removeVideoFromBoard({ video_name: videoDTO.video_name, dispatch });
+ }
+
+ if (singleCanvasProjectDndSource.typeGuard(sourceData)) {
+ const { projectDTO } = sourceData.payload;
+ removeCanvasProjectFromBoard({ project_name: projectDTO.project_name, dispatch });
+ }
},
};
@@ -592,6 +706,7 @@ export const dndTargets = [
setRegionalGuidanceReferenceImageDndTarget,
setUpscaleInitialImageDndTarget,
setNodeImageFieldImageDndTarget,
+ setNodeVideoFieldVideoDndTarget,
addImagesToNodeImageFieldCollectionDndTarget,
setComparisonImageDndTarget,
newCanvasEntityFromImageDndTarget,
diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts
index 24d6bea1680..c4979d59592 100644
--- a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts
+++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts
@@ -4,7 +4,13 @@ import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { parseify } from 'common/util/serialize';
-import { dndTargets, multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd';
+import {
+ dndTargets,
+ multipleImageDndSource,
+ singleCanvasProjectDndSource,
+ singleImageDndSource,
+ singleVideoDndSource,
+} from 'features/dnd/dnd';
import { useEffect } from 'react';
const log = logger('dnd');
@@ -19,7 +25,12 @@ export const useDndMonitor = () => {
const sourceData = source.data;
// Check for allowed sources
- if (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) {
+ if (
+ !singleImageDndSource.typeGuard(sourceData) &&
+ !multipleImageDndSource.typeGuard(sourceData) &&
+ !singleVideoDndSource.typeGuard(sourceData) &&
+ !singleCanvasProjectDndSource.typeGuard(sourceData)
+ ) {
return false;
}
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/CanvasProjectContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/CanvasProjectContextMenu.tsx
new file mode 100644
index 00000000000..f3e3389e850
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/CanvasProjectContextMenu.tsx
@@ -0,0 +1,228 @@
+import type { ChakraProps } from '@invoke-ai/ui-library';
+import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { IconMenuItemGroup } from 'common/components/IconMenuItem';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { ContextMenuItemDeleteCanvasProject } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteCanvasProject';
+import { ContextMenuItemDownloadCanvasProject } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadCanvasProject';
+import { ContextMenuItemLoadCanvasProject } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadCanvasProject';
+import { CanvasProjectDTOContextProvider } from 'features/gallery/contexts/CanvasProjectDTOContext';
+import { map } from 'nanostores';
+import type { RefObject } from 'react';
+import { memo, useCallback, useEffect, useRef } from 'react';
+import type { CanvasProjectDTO } from 'services/api/types';
+
+// Mirror of VideoContextMenu, with the two canvas-project actions: delete, download. A
+// "Change Board" entry follows once ChangeBoardModal learns about canvas projects.
+
+const LONGPRESS_DELAY_MS = 500;
+const LONGPRESS_MOVE_THRESHOLD_PX = 10;
+
+const $canvasProjectContextMenuState = map<{
+ isOpen: boolean;
+ projectDTO: CanvasProjectDTO | null;
+ position: { x: number; y: number };
+}>({
+ isOpen: false,
+ projectDTO: null,
+ position: { x: -1, y: -1 },
+});
+
+const onClose = () => {
+ $canvasProjectContextMenuState.setKey('isOpen', false);
+};
+
+const elToProjectMap = new Map();
+
+const getProjectDTOFromMap = (target: Node): CanvasProjectDTO | undefined => {
+ const entry = Array.from(elToProjectMap.entries()).find((entry) => entry[0].contains(target));
+ return entry?.[1];
+};
+
+/**
+ * Register a context menu for a canvas project DTO on a target element. Mirrors useVideoContextMenu.
+ */
+export const useCanvasProjectContextMenu = (
+ projectDTO: CanvasProjectDTO,
+ ref: RefObject | (HTMLElement | null)
+) => {
+ useEffect(() => {
+ if (ref === null) {
+ return;
+ }
+ const el = ref instanceof HTMLElement ? ref : ref.current;
+ if (!el) {
+ return;
+ }
+ elToProjectMap.set(el, projectDTO);
+ return () => {
+ elToProjectMap.delete(el);
+ };
+ }, [projectDTO, ref]);
+};
+
+const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+
+export const CanvasProjectContextMenu = memo(() => {
+ useAssertSingleton('CanvasProjectContextMenu');
+ const state = useStore($canvasProjectContextMenuState);
+ useGlobalMenuClose(onClose);
+
+ return (
+
+
+
+
+
+
+
+ );
+});
+
+CanvasProjectContextMenu.displayName = 'CanvasProjectContextMenu';
+
+const MenuContent = memo(() => {
+ const state = useStore($canvasProjectContextMenuState);
+ if (!state.projectDTO) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+
+ );
+});
+
+MenuContent.displayName = 'CanvasProjectContextMenuContent';
+
+/**
+ * Listens for context-menu events and dispatches to the singleton's state. Split out from the
+ * visible menu so re-renders on `state.isOpen` toggling stay cheap.
+ */
+const CanvasProjectContextMenuEventLogical = memo(() => {
+ const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
+ const longPressTimeoutRef = useRef(0);
+ const animationTimeoutRef = useRef(0);
+
+ const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
+ if (e.shiftKey) {
+ onClose();
+ return;
+ }
+
+ const projectDTO = getProjectDTOFromMap(e.target as Node);
+ if (!projectDTO) {
+ onClose();
+ return;
+ }
+
+ window.clearTimeout(animationTimeoutRef.current);
+ e.preventDefault();
+
+ if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
+ if ($canvasProjectContextMenuState.get().isOpen) {
+ onClose();
+ }
+ animationTimeoutRef.current = window.setTimeout(() => {
+ $canvasProjectContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ projectDTO,
+ });
+ }, 100);
+ } else {
+ $canvasProjectContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ projectDTO,
+ });
+ }
+
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ }, []);
+
+ const onPointerDown = useCallback(
+ (e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ longPressTimeoutRef.current = window.setTimeout(() => {
+ onContextMenu(e);
+ }, LONGPRESS_DELAY_MS);
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ },
+ [onContextMenu]
+ );
+
+ const onPointerMove = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current === null) {
+ return;
+ }
+ const distance = Math.hypot(e.pageX - lastPositionRef.current.x, e.pageY - lastPositionRef.current.y);
+ if (distance > LONGPRESS_MOVE_THRESHOLD_PX) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ const onPointerUp = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ const onPointerCancel = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
+ window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
+ window.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
+ window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
+ window.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
+ return () => {
+ controller.abort();
+ };
+ }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
+
+ useEffect(
+ () => () => {
+ window.clearTimeout(animationTimeoutRef.current);
+ window.clearTimeout(longPressTimeoutRef.current);
+ },
+ []
+ );
+
+ return null;
+});
+
+CanvasProjectContextMenuEventLogical.displayName = 'CanvasProjectContextMenuEventLogical';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoardVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoardVideo.tsx
new file mode 100644
index 00000000000..b7298a87431
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoardVideo.tsx
@@ -0,0 +1,26 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice';
+import { useVideoDTOContext } from 'features/gallery/contexts/VideoDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFoldersBold } from 'react-icons/pi';
+
+export const ContextMenuItemChangeBoardVideo = memo(() => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const videoDTO = useVideoDTOContext();
+
+ const onClick = useCallback(() => {
+ dispatch(videosToChangeSelected([videoDTO.video_name]));
+ dispatch(isModalOpenChanged(true));
+ }, [dispatch, videoDTO.video_name]);
+
+ return (
+ } onClickCapture={onClick}>
+ {t('boards.changeBoard')}
+
+ );
+});
+
+ContextMenuItemChangeBoardVideo.displayName = 'ContextMenuItemChangeBoardVideo';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteCanvasProject.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteCanvasProject.tsx
new file mode 100644
index 00000000000..3c24a049055
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteCanvasProject.tsx
@@ -0,0 +1,32 @@
+import { IconMenuItem } from 'common/components/IconMenuItem';
+import { useCanvasProjectDTOContext } from 'features/gallery/contexts/CanvasProjectDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiTrashSimpleBold } from 'react-icons/pi';
+import { useDeleteCanvasProjectMutation } from 'services/api/endpoints/canvasProjects';
+
+export const ContextMenuItemDeleteCanvasProject = memo(() => {
+ const { t } = useTranslation();
+ const projectDTO = useCanvasProjectDTOContext();
+ const [deleteCanvasProject] = useDeleteCanvasProjectMutation();
+
+ const onClick = useCallback(() => {
+ // Mirror the video flow: one-step native confirm. Canvas projects don't carry references
+ // into nodes/refs/canvas the way images do, so the heavier image usage modal isn't needed.
+ if (window.confirm(t('gallery.deleteCanvasProjectConfirmation'))) {
+ deleteCanvasProject({ project_name: projectDTO.project_name });
+ }
+ }, [deleteCanvasProject, projectDTO.project_name, t]);
+
+ return (
+ }
+ onClickCapture={onClick}
+ aria-label={t('gallery.deleteCanvasProject', { count: 1 })}
+ tooltip={t('gallery.deleteCanvasProject', { count: 1 })}
+ isDestructive
+ />
+ );
+});
+
+ContextMenuItemDeleteCanvasProject.displayName = 'ContextMenuItemDeleteCanvasProject';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteVideo.tsx
new file mode 100644
index 00000000000..ffc63ac61d4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteVideo.tsx
@@ -0,0 +1,33 @@
+import { IconMenuItem } from 'common/components/IconMenuItem';
+import { useVideoDTOContext } from 'features/gallery/contexts/VideoDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiTrashSimpleBold } from 'react-icons/pi';
+import { useDeleteVideoMutation } from 'services/api/endpoints/videos';
+
+export const ContextMenuItemDeleteVideo = memo(() => {
+ const { t } = useTranslation();
+ const videoDTO = useVideoDTOContext();
+ const [deleteVideo] = useDeleteVideoMutation();
+
+ const onClick = useCallback(() => {
+ // Confirm-then-delete via the native dialog. Videos can't be referenced from canvas/nodes/
+ // refs the way images can, so the image modal's usage analysis is unnecessary; a one-step
+ // confirm matches "minimal" scope.
+ if (window.confirm(t('gallery.deleteVideoConfirmation'))) {
+ deleteVideo({ video_name: videoDTO.video_name });
+ }
+ }, [deleteVideo, t, videoDTO.video_name]);
+
+ return (
+ }
+ onClickCapture={onClick}
+ aria-label={t('gallery.deleteVideo', { count: 1 })}
+ tooltip={t('gallery.deleteVideo', { count: 1 })}
+ isDestructive
+ />
+ );
+});
+
+ContextMenuItemDeleteVideo.displayName = 'ContextMenuItemDeleteVideo';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadCanvasProject.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadCanvasProject.tsx
new file mode 100644
index 00000000000..98e1ed8a0df
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadCanvasProject.tsx
@@ -0,0 +1,29 @@
+import { IconMenuItem } from 'common/components/IconMenuItem';
+import { useDownloadItem } from 'common/hooks/useDownloadImage';
+import { useCanvasProjectDTOContext } from 'features/gallery/contexts/CanvasProjectDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiDownloadSimpleBold } from 'react-icons/pi';
+
+export const ContextMenuItemDownloadCanvasProject = memo(() => {
+ const { t } = useTranslation();
+ const projectDTO = useCanvasProjectDTOContext();
+ const { downloadItem } = useDownloadItem();
+
+ const onClick = useCallback(() => {
+ // Suggest a friendlier filename than the bare UUID — append `.invk` so the OS associates it
+ // with the canvas project loader on re-import.
+ downloadItem(projectDTO.project_url, `${projectDTO.name}.invk`);
+ }, [downloadItem, projectDTO]);
+
+ return (
+ }
+ aria-label={t('gallery.download')}
+ tooltip={t('gallery.download')}
+ onClick={onClick}
+ />
+ );
+});
+
+ContextMenuItemDownloadCanvasProject.displayName = 'ContextMenuItemDownloadCanvasProject';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadVideo.tsx
new file mode 100644
index 00000000000..a532a14d7ef
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadVideo.tsx
@@ -0,0 +1,27 @@
+import { IconMenuItem } from 'common/components/IconMenuItem';
+import { useDownloadItem } from 'common/hooks/useDownloadImage';
+import { useVideoDTOContext } from 'features/gallery/contexts/VideoDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiDownloadSimpleBold } from 'react-icons/pi';
+
+export const ContextMenuItemDownloadVideo = memo(() => {
+ const { t } = useTranslation();
+ const videoDTO = useVideoDTOContext();
+ const { downloadItem } = useDownloadItem();
+
+ const onClick = useCallback(() => {
+ downloadItem(videoDTO.video_url, videoDTO.video_name);
+ }, [downloadItem, videoDTO]);
+
+ return (
+ }
+ aria-label={t('gallery.download')}
+ tooltip={t('gallery.download')}
+ onClick={onClick}
+ />
+ );
+});
+
+ContextMenuItemDownloadVideo.displayName = 'ContextMenuItemDownloadVideo';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadCanvasProject.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadCanvasProject.tsx
new file mode 100644
index 00000000000..ae214866df4
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadCanvasProject.tsx
@@ -0,0 +1,24 @@
+import { MenuItem } from '@invoke-ai/ui-library';
+import { useLoadCanvasProjectFromServerWithDialog } from 'features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog';
+import { useCanvasProjectDTOContext } from 'features/gallery/contexts/CanvasProjectDTOContext';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFileArrowUpBold } from 'react-icons/pi';
+
+export const ContextMenuItemLoadCanvasProject = memo(() => {
+ const { t } = useTranslation();
+ const projectDTO = useCanvasProjectDTOContext();
+ const queueLoad = useLoadCanvasProjectFromServerWithDialog();
+
+ const onClick = useCallback(() => {
+ queueLoad(projectDTO.project_name);
+ }, [queueLoad, projectDTO.project_name]);
+
+ return (
+ } onClickCapture={onClick}>
+ {t('controlLayers.canvasProject.loadProject')}
+
+ );
+});
+
+ContextMenuItemLoadCanvasProject.displayName = 'ContextMenuItemLoadCanvasProject';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx
new file mode 100644
index 00000000000..a965b7b1586
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx
@@ -0,0 +1,227 @@
+import type { ChakraProps } from '@invoke-ai/ui-library';
+import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { IconMenuItemGroup } from 'common/components/IconMenuItem';
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { ContextMenuItemChangeBoardVideo } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoardVideo';
+import { ContextMenuItemDeleteVideo } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteVideo';
+import { ContextMenuItemDownloadVideo } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownloadVideo';
+import { VideoDTOContextProvider } from 'features/gallery/contexts/VideoDTOContext';
+import { map } from 'nanostores';
+import type { RefObject } from 'react';
+import { memo, useCallback, useEffect, useRef } from 'react';
+import type { VideoDTO } from 'services/api/types';
+
+// Mirror of ImageContextMenu, but pared down to the three actions the video item supports today:
+// delete, change-board, download. Long-press on touch devices opens the menu the same way.
+
+const LONGPRESS_DELAY_MS = 500;
+const LONGPRESS_MOVE_THRESHOLD_PX = 10;
+
+const $videoContextMenuState = map<{
+ isOpen: boolean;
+ videoDTO: VideoDTO | null;
+ position: { x: number; y: number };
+}>({
+ isOpen: false,
+ videoDTO: null,
+ position: { x: -1, y: -1 },
+});
+
+const onClose = () => {
+ $videoContextMenuState.setKey('isOpen', false);
+};
+
+const elToVideoMap = new Map();
+
+const getVideoDTOFromMap = (target: Node): VideoDTO | undefined => {
+ const entry = Array.from(elToVideoMap.entries()).find((entry) => entry[0].contains(target));
+ return entry?.[1];
+};
+
+/**
+ * Register a context menu for a video DTO on a target element. Mirrors useImageContextMenu.
+ */
+export const useVideoContextMenu = (videoDTO: VideoDTO, ref: RefObject | (HTMLElement | null)) => {
+ useEffect(() => {
+ if (ref === null) {
+ return;
+ }
+ const el = ref instanceof HTMLElement ? ref : ref.current;
+ if (!el) {
+ return;
+ }
+ elToVideoMap.set(el, videoDTO);
+ return () => {
+ elToVideoMap.delete(el);
+ };
+ }, [videoDTO, ref]);
+};
+
+const _hover: ChakraProps['_hover'] = { bg: 'transparent' };
+
+export const VideoContextMenu = memo(() => {
+ useAssertSingleton('VideoContextMenu');
+ const state = useStore($videoContextMenuState);
+ useGlobalMenuClose(onClose);
+
+ return (
+
+
+
+
+
+
+
+ );
+});
+
+VideoContextMenu.displayName = 'VideoContextMenu';
+
+const MenuContent = memo(() => {
+ const state = useStore($videoContextMenuState);
+ if (!state.videoDTO) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+
+ );
+});
+
+MenuContent.displayName = 'VideoContextMenuContent';
+
+/**
+ * Logical component that listens for context-menu events and dispatches to the singleton's state.
+ * Split out from the visible menu to keep re-renders cheap.
+ */
+const VideoContextMenuEventLogical = memo(() => {
+ const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 });
+ const longPressTimeoutRef = useRef(0);
+ const animationTimeoutRef = useRef(0);
+
+ const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => {
+ if (e.shiftKey) {
+ // shift+right-click opens the native context menu
+ onClose();
+ return;
+ }
+
+ const videoDTO = getVideoDTOFromMap(e.target as Node);
+ if (!videoDTO) {
+ // Not over a registered video item — let ImageContextMenu handle it (or close).
+ onClose();
+ return;
+ }
+
+ window.clearTimeout(animationTimeoutRef.current);
+ e.preventDefault();
+
+ if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) {
+ if ($videoContextMenuState.get().isOpen) {
+ onClose();
+ }
+ animationTimeoutRef.current = window.setTimeout(() => {
+ $videoContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ videoDTO,
+ });
+ }, 100);
+ } else {
+ $videoContextMenuState.set({
+ isOpen: true,
+ position: { x: e.pageX, y: e.pageY },
+ videoDTO,
+ });
+ }
+
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ }, []);
+
+ const onPointerDown = useCallback(
+ (e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ longPressTimeoutRef.current = window.setTimeout(() => {
+ onContextMenu(e);
+ }, LONGPRESS_DELAY_MS);
+ lastPositionRef.current = { x: e.pageX, y: e.pageY };
+ },
+ [onContextMenu]
+ );
+
+ const onPointerMove = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current === null) {
+ return;
+ }
+ const distance = Math.hypot(e.pageX - lastPositionRef.current.x, e.pageY - lastPositionRef.current.y);
+ if (distance > LONGPRESS_MOVE_THRESHOLD_PX) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ const onPointerUp = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ const onPointerCancel = useCallback((e: PointerEvent) => {
+ if (e.pointerType === 'mouse') {
+ return;
+ }
+ if (longPressTimeoutRef.current) {
+ clearTimeout(longPressTimeoutRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal });
+ window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal });
+ window.addEventListener('pointerup', onPointerUp, { signal: controller.signal });
+ window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal });
+ window.addEventListener('pointermove', onPointerMove, { signal: controller.signal });
+ return () => {
+ controller.abort();
+ };
+ }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
+
+ useEffect(
+ () => () => {
+ window.clearTimeout(animationTimeoutRef.current);
+ window.clearTimeout(longPressTimeoutRef.current);
+ },
+ []
+ );
+
+ return null;
+});
+
+VideoContextMenuEventLogical.displayName = 'VideoContextMenuEventLogical';
diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx
index 1b20edba172..d4697cd38c1 100644
--- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx
@@ -12,6 +12,7 @@ import {
selectSelectionCount,
} from 'features/gallery/store/gallerySelectors';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
+import { isCanvasProjectName, isVideoName } from 'features/gallery/store/types';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
@@ -28,13 +29,17 @@ import type {
VirtuosoGridHandle,
} from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
+import { canvasProjectsApi } from 'services/api/endpoints/canvasProjects';
import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images';
+import { useStarVideosMutation, useUnstarVideosMutation, useVideoDTO, videosApi } from 'services/api/endpoints/videos';
import { useDebounce } from 'use-debounce';
import { getItemIndex } from './getItemIndex';
import { getItemsPerRow } from './getItemsPerRow';
+import { GalleryCanvasProjectItem } from './ImageGrid/GalleryCanvasProjectItem';
import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage';
import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag';
+import { GalleryVideoItem } from './ImageGrid/GalleryVideoItem';
import { scrollIntoView } from './scrollIntoView';
import { useGalleryImageNames } from './use-gallery-image-names';
import { useScrollableGallery } from './useScrollableGallery';
@@ -47,35 +52,53 @@ type GridContext = {
};
/**
- * Wraps an image - either the placeholder as it is being loaded or the loaded image
+ * Wraps a gallery item — either the placeholder as it is being loaded, or the loaded image / video.
+ *
+ * Names are polymorphic: image names end in `.png`, video names in `.mp4` (see SimpleNameService).
+ * `isVideoName` discriminates so we can subscribe to the right query and render the right component.
+ *
+ * We rely on `useRangeBasedImageFetching` to fetch all DTOs into the RTK Query cache. Here we just
+ * consume the cache — `useQuerySubscription` with `skip: isUninitialized` subscribes only after the
+ * fetch has populated data (see https://github.com/reduxjs/redux-toolkit/discussions/4213).
*/
const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string }) => {
- /*
- * We rely on the useRangeBasedImageFetching to fetch all image DTOs, caching them with RTK Query.
- *
- * In this component, we just want to consume that cache. Unforutnately, RTK Query does not provide a way to
- * subscribe to a query without triggering a new fetch.
- *
- * There is a hack, though:
- * - https://github.com/reduxjs/redux-toolkit/discussions/4213
- *
- * This essentially means "subscribe to the query once it has some data".
- *
- * One issue with this approach. When an item DTO is already cached - for example, because it is selected and
- * rendered in the viewer - it will show up in the grid before the other items have loaded. This is most
- * noticeable when first loading a board. The first item in the board is selected and rendered immediately in
- * the viewer, caching the DTO. The gallery grid renders, and that first item displays as a thumbnail while the
- * others are still placeholders. After a moment, the rest of the items load up and display as thumbnails.
- */
-
- // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index
- const { currentData: imageDTO, isUninitialized } = imagesApi.endpoints.getImageDTO.useQueryState(imageName);
- imagesApi.endpoints.getImageDTO.useQuerySubscription(imageName, { skip: isUninitialized });
+ const isVideo = isVideoName(imageName);
+ const isCanvasProject = isCanvasProjectName(imageName);
+ // Always call all three hooks (React rules of hooks) — the irrelevant ones are no-op subscriptions.
+ const imageState = imagesApi.endpoints.getImageDTO.useQueryState(isVideo || isCanvasProject ? '' : imageName);
+ imagesApi.endpoints.getImageDTO.useQuerySubscription(isVideo || isCanvasProject ? '' : imageName, {
+ skip: isVideo || isCanvasProject || imageState.isUninitialized,
+ });
+ const videoState = videosApi.endpoints.getVideoDTO.useQueryState(isVideo ? imageName : '');
+ videosApi.endpoints.getVideoDTO.useQuerySubscription(isVideo ? imageName : '', {
+ skip: !isVideo || videoState.isUninitialized,
+ });
+ const projectState = canvasProjectsApi.endpoints.getCanvasProjectDTO.useQueryState(isCanvasProject ? imageName : '');
+ canvasProjectsApi.endpoints.getCanvasProjectDTO.useQuerySubscription(isCanvasProject ? imageName : '', {
+ skip: !isCanvasProject || projectState.isUninitialized,
+ });
+
+ if (isCanvasProject) {
+ const projectDTO = projectState.currentData;
+ if (!projectDTO) {
+ return ;
+ }
+ return ;
+ }
+
+ if (isVideo) {
+ const videoDTO = videoState.currentData;
+ if (!videoDTO) {
+ return ;
+ }
+ return ;
+ }
+
+ const imageDTO = imageState.currentData;
if (!imageDTO) {
return ;
}
-
return ;
});
ImageAtPosition.displayName = 'ImageAtPosition';
@@ -310,30 +333,47 @@ const useStarImageHotkey = () => {
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const selectionCount = useAppSelector(selectSelectionCount);
const isGalleryFocused = useIsRegionFocused('gallery');
- const imageDTO = useImageDTO(lastSelectedItem);
+ const isVideo = lastSelectedItem ? isVideoName(lastSelectedItem) : false;
+ const imageDTO = useImageDTO(isVideo ? null : lastSelectedItem);
+ const videoDTO = useVideoDTO(isVideo ? lastSelectedItem : null);
const [starImages] = useStarImagesMutation();
const [unstarImages] = useUnstarImagesMutation();
+ const [starVideos] = useStarVideosMutation();
+ const [unstarVideos] = useUnstarVideosMutation();
+
+ const dto = isVideo ? videoDTO : imageDTO;
const handleStarHotkey = useCallback(() => {
- if (!imageDTO) {
- return;
- }
if (!isGalleryFocused) {
return;
}
- if (imageDTO.starred) {
- unstarImages({ image_names: [imageDTO.image_name] });
+ if (isVideo) {
+ if (!videoDTO) {
+ return;
+ }
+ if (videoDTO.starred) {
+ unstarVideos({ video_names: [videoDTO.video_name] });
+ } else {
+ starVideos({ video_names: [videoDTO.video_name] });
+ }
} else {
- starImages({ image_names: [imageDTO.image_name] });
+ if (!imageDTO) {
+ return;
+ }
+ if (imageDTO.starred) {
+ unstarImages({ image_names: [imageDTO.image_name] });
+ } else {
+ starImages({ image_names: [imageDTO.image_name] });
+ }
}
- }, [imageDTO, isGalleryFocused, starImages, unstarImages]);
+ }, [isGalleryFocused, isVideo, imageDTO, videoDTO, starImages, unstarImages, starVideos, unstarVideos]);
useRegisteredHotkeys({
id: 'starImage',
category: 'gallery',
callback: handleStarHotkey,
- options: { enabled: !!imageDTO && selectionCount === 1 && isGalleryFocused },
- dependencies: [imageDTO, selectionCount, isGalleryFocused, handleStarHotkey],
+ options: { enabled: !!dto && selectionCount === 1 && isGalleryFocused },
+ dependencies: [dto, selectionCount, isGalleryFocused, handleStarHotkey],
});
};
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryCanvasProjectItem.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryCanvasProjectItem.tsx
new file mode 100644
index 00000000000..865b2b17854
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryCanvasProjectItem.tsx
@@ -0,0 +1,153 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { Flex, Image } from '@invoke-ai/ui-library';
+import { createSelector } from '@reduxjs/toolkit';
+import type { AppDispatch, AppGetState } from 'app/store/store';
+import { useAppSelector, useAppStore } from 'app/store/storeHooks';
+import { uniq } from 'es-toolkit';
+import { singleCanvasProjectDndSource } from 'features/dnd/dnd';
+import { firefoxDndFix } from 'features/dnd/util';
+import { useCanvasProjectContextMenu } from 'features/gallery/components/ContextMenu/CanvasProjectContextMenu';
+import { selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
+import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
+import type { MouseEvent, MouseEventHandler } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
+import { galleryApi } from 'services/api/endpoints/gallery';
+import type { CanvasProjectDTO } from 'services/api/types';
+
+import { galleryItemContainerSX } from './galleryItemContainerSX';
+import { GalleryItemProjectBadge } from './GalleryItemProjectBadge';
+
+interface Props {
+ projectDTO: CanvasProjectDTO;
+}
+
+/**
+ * Returns the ordered name list from the most recently cached polymorphic gallery query, so
+ * shift-range selection can span across images/videos/projects.
+ */
+const selectCachedGalleryItemNames = (state: ReturnType): string[] => {
+ const entries = galleryApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']);
+ for (const entry of entries) {
+ if (entry.endpointName !== 'getGalleryItemNames') {
+ continue;
+ }
+ const data = galleryApi.endpoints.getGalleryItemNames.select(entry.originalArgs)(state).data;
+ if (data) {
+ return data.items.map((ref) => ref.name);
+ }
+ }
+ return [];
+};
+
+/**
+ * Click handler mirroring the image/video grid behavior. Canvas projects don't participate in
+ * alt-click comparison (comparison is image-only), so alt-click degrades to a plain selection.
+ */
+const buildOnClick =
+ (projectName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => {
+ const { shiftKey, ctrlKey, metaKey, altKey } = e;
+ const state = getState();
+ const itemNames = selectCachedGalleryItemNames(state);
+
+ if (itemNames.length === 0) {
+ if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
+ dispatch(selectionChanged([projectName]));
+ }
+ return;
+ }
+
+ const selection = state.gallery.selection;
+
+ if (altKey) {
+ dispatch(selectionChanged([projectName]));
+ } else if (shiftKey) {
+ const lastSelectedItem = selection.at(-1);
+ const lastClickedIndex = itemNames.findIndex((name) => name === lastSelectedItem);
+ const currentClickedIndex = itemNames.findIndex((name) => name === projectName);
+ if (lastClickedIndex > -1 && currentClickedIndex > -1) {
+ const start = Math.min(lastClickedIndex, currentClickedIndex);
+ const end = Math.max(lastClickedIndex, currentClickedIndex);
+ const itemsToSelect = itemNames.slice(start, end + 1);
+ if (currentClickedIndex < lastClickedIndex) {
+ itemsToSelect.reverse();
+ }
+ dispatch(selectionChanged(uniq(selection.concat(itemsToSelect))));
+ }
+ } else if (ctrlKey || metaKey) {
+ if (selection.some((n) => n === projectName) && selection.length > 1) {
+ dispatch(selectionChanged(uniq(selection.filter((n) => n !== projectName))));
+ } else {
+ dispatch(selectionChanged(uniq(selection.concat(projectName))));
+ }
+ } else {
+ dispatch(selectionChanged([projectName]));
+ }
+ };
+
+export const GalleryCanvasProjectItem = memo(({ projectDTO }: Props) => {
+ const store = useAppStore();
+ const ref = useRef(null);
+
+ const selectIsSelected = useMemo(
+ () => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((n) => n === projectDTO.project_name)),
+ [projectDTO.project_name]
+ );
+ const isSelected = useAppSelector(selectIsSelected);
+
+ const onClick = useMemo(
+ () => buildOnClick(projectDTO.project_name, store.dispatch, store.getState),
+ [projectDTO, store]
+ );
+
+ const onDoubleClick = useCallback>(() => {
+ navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
+ }, []);
+
+ // Right-click / long-press context menu (delete, download, load).
+ useCanvasProjectContextMenu(projectDTO, ref);
+
+ // Drag source: drop the project onto a board to assign it. Mirrors GalleryVideoItem.
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ return combine(
+ firefoxDndFix(element),
+ draggable({
+ element,
+ getInitialData: () => singleCanvasProjectDndSource.getData({ projectDTO }, projectDTO.project_name),
+ })
+ );
+ }, [projectDTO]);
+
+ return (
+
+ {projectDTO.thumbnail_url ? (
+
+ ) : (
+
+ )}
+
+
+ );
+});
+
+GalleryCanvasProjectItem.displayName = 'GalleryCanvasProjectItem';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
index d03a884a094..8a8d18435dd 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx
@@ -26,6 +26,7 @@ import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
import type { MouseEvent, MouseEventHandler } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiImageBold } from 'react-icons/pi';
+import { galleryApi } from 'services/api/endpoints/gallery';
import { imagesApi } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
@@ -35,12 +36,38 @@ interface Props {
imageDTO: ImageDTO;
}
+/**
+ * Returns the most recently cached polymorphic gallery item names (images + videos + canvas
+ * projects interleaved). Used as a fallback when the image-only name list can't satisfy a
+ * shift-click range (e.g. the previously selected item is a video or canvas project).
+ */
+const selectCachedGalleryItemNames = (state: ReturnType): string[] => {
+ const entries = galleryApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']);
+ for (const entry of entries) {
+ if (entry.endpointName !== 'getGalleryItemNames') {
+ continue;
+ }
+ const data = galleryApi.endpoints.getGalleryItemNames.select(entry.originalArgs)(state).data;
+ if (data) {
+ return data.items.map((ref) => ref.name);
+ }
+ }
+ return [];
+};
+
const buildOnClick =
(imageName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => {
const { shiftKey, ctrlKey, metaKey, altKey } = e;
const state = getState();
const queryArgs = selectGetImageNamesQueryArgs(state);
- const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? [];
+ const imageOnlyNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? [];
+ const selection = state.gallery.selection;
+ // For shift-range, fall back to the polymorphic list when the previous selection contains
+ // a video or canvas project — otherwise the image-only list can't bridge across kinds.
+ const lastSelectedItem = selection.at(-1);
+ const needsPolymorphicList =
+ lastSelectedItem !== undefined && lastSelectedItem !== imageName && !imageOnlyNames.includes(lastSelectedItem);
+ const imageNames = needsPolymorphicList ? selectCachedGalleryItemNames(state) : imageOnlyNames;
// If we don't have the image names cached, we can't perform selection operations
// This can happen if the user clicks on an image before the names are loaded
@@ -52,8 +79,6 @@ const buildOnClick =
return;
}
- const selection = state.gallery.selection;
-
if (altKey) {
if (state.gallery.imageToCompare === imageName) {
dispatch(imageToCompareChanged(null));
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemPlayBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemPlayBadge.tsx
new file mode 100644
index 00000000000..3ea9b00ad50
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemPlayBadge.tsx
@@ -0,0 +1,34 @@
+import { Flex, Icon } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import { PiPlayFill } from 'react-icons/pi';
+
+/**
+ * Centered play-button badge laid over a video thumbnail in the gallery grid. Purely visual —
+ * the gallery item itself owns click selection; the play action lives in the viewer.
+ */
+export const GalleryItemPlayBadge = memo(() => {
+ return (
+
+
+
+ );
+});
+
+GalleryItemPlayBadge.displayName = 'GalleryItemPlayBadge';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemProjectBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemProjectBadge.tsx
new file mode 100644
index 00000000000..f29f2dc5b7f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemProjectBadge.tsx
@@ -0,0 +1,33 @@
+import { Flex, Icon } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import { PiStackBold } from 'react-icons/pi';
+
+/**
+ * Centered stack-icon badge laid over a canvas project thumbnail in the gallery grid. Purely
+ * visual — distinguishes saved Canvas Projects from regular images/videos at a glance.
+ */
+export const GalleryItemProjectBadge = memo(() => {
+ return (
+
+
+
+ );
+});
+
+GalleryItemProjectBadge.displayName = 'GalleryItemProjectBadge';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemVideoStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemVideoStarIconButton.tsx
new file mode 100644
index 00000000000..68d86289e57
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemVideoStarIconButton.tsx
@@ -0,0 +1,35 @@
+import { DndImageIcon } from 'features/dnd/DndImageIcon';
+import { memo, useCallback } from 'react';
+import { PiStarBold, PiStarFill } from 'react-icons/pi';
+import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos';
+import type { VideoDTO } from 'services/api/types';
+
+type Props = {
+ videoDTO: VideoDTO;
+};
+
+export const GalleryItemVideoStarIconButton = memo(({ videoDTO }: Props) => {
+ const [starVideos] = useStarVideosMutation();
+ const [unstarVideos] = useUnstarVideosMutation();
+
+ const toggleStarredState = useCallback(() => {
+ if (videoDTO.starred) {
+ unstarVideos({ video_names: [videoDTO.video_name] });
+ } else {
+ starVideos({ video_names: [videoDTO.video_name] });
+ }
+ }, [starVideos, unstarVideos, videoDTO]);
+
+ return (
+ : }
+ tooltip={videoDTO.starred ? 'Unstar' : 'Star'}
+ position="absolute"
+ top={2}
+ insetInlineEnd={2}
+ />
+ );
+});
+
+GalleryItemVideoStarIconButton.displayName = 'GalleryItemVideoStarIconButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoItem.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoItem.tsx
new file mode 100644
index 00000000000..95cf1d854ee
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoItem.tsx
@@ -0,0 +1,179 @@
+import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
+import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
+import { Flex, Image } from '@invoke-ai/ui-library';
+import { createSelector } from '@reduxjs/toolkit';
+import type { AppDispatch, AppGetState } from 'app/store/store';
+import { useAppSelector, useAppStore } from 'app/store/storeHooks';
+import { uniq } from 'es-toolkit';
+import { singleVideoDndSource } from 'features/dnd/dnd';
+import { firefoxDndFix } from 'features/dnd/util';
+import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu';
+import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors';
+import { selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice';
+import { navigationApi } from 'features/ui/layouts/navigation-api';
+import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared';
+import type { MouseEvent, MouseEventHandler } from 'react';
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { galleryApi } from 'services/api/endpoints/gallery';
+import type { VideoDTO } from 'services/api/types';
+
+import { galleryItemContainerSX } from './galleryItemContainerSX';
+import { GalleryItemPlayBadge } from './GalleryItemPlayBadge';
+import { GalleryItemSizeBadge } from './GalleryItemSizeBadge';
+import { GalleryItemVideoStarIconButton } from './GalleryItemVideoStarIconButton';
+
+interface Props {
+ videoDTO: VideoDTO;
+}
+
+/**
+ * Click handler for selection. Mirrors the image grid's logic but reads the polymorphic
+ * /gallery/items/names cache to know the full ordered list (since a shift-range across a
+ * mixed image+video gallery has to include both kinds).
+ *
+ * Video items do not participate in alt-click comparison (comparison is image-only).
+ */
+const buildOnClick =
+ (videoName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => {
+ const { shiftKey, ctrlKey, metaKey, altKey } = e;
+ // We need the same query args the gallery grid used to fetch its name list. The grid
+ // calls `useGalleryItemNames` which forwards the args to the polymorphic gallery endpoint.
+ // Pull the most recent cached entry to recover the ordering.
+ const state = getState();
+ const itemNames = selectCachedGalleryItemNames(state);
+
+ if (itemNames.length === 0) {
+ // Without an ordered list, only basic single-click selection is possible.
+ if (!shiftKey && !ctrlKey && !metaKey && !altKey) {
+ dispatch(selectionChanged([videoName]));
+ }
+ return;
+ }
+
+ const selection = state.gallery.selection;
+
+ if (altKey) {
+ // Alt-click is image-only (comparison view). Quietly treat as a normal click for videos.
+ dispatch(selectionChanged([videoName]));
+ } else if (shiftKey) {
+ const lastSelectedItem = selection.at(-1);
+ const lastClickedIndex = itemNames.findIndex((name) => name === lastSelectedItem);
+ const currentClickedIndex = itemNames.findIndex((name) => name === videoName);
+ if (lastClickedIndex > -1 && currentClickedIndex > -1) {
+ const start = Math.min(lastClickedIndex, currentClickedIndex);
+ const end = Math.max(lastClickedIndex, currentClickedIndex);
+ const itemsToSelect = itemNames.slice(start, end + 1);
+ if (currentClickedIndex < lastClickedIndex) {
+ itemsToSelect.reverse();
+ }
+ dispatch(selectionChanged(uniq(selection.concat(itemsToSelect))));
+ }
+ } else if (ctrlKey || metaKey) {
+ if (selection.some((n) => n === videoName) && selection.length > 1) {
+ dispatch(selectionChanged(uniq(selection.filter((n) => n !== videoName))));
+ } else {
+ dispatch(selectionChanged(uniq(selection.concat(videoName))));
+ }
+ } else {
+ dispatch(selectionChanged([videoName]));
+ }
+ };
+
+/**
+ * Returns the names of the currently-cached gallery item list (whichever query args were last
+ * used). For most sessions there is exactly one active list, so iterating cache entries is fine.
+ */
+const selectCachedGalleryItemNames = (state: ReturnType): string[] => {
+ const entries = galleryApi.util.selectInvalidatedBy(state, ['GalleryItemNameList']);
+ // selectInvalidatedBy returns subscription entries; for the polymorphic names list we just need
+ // any one match. Fall back to scanning the API state directly for robustness.
+ for (const entry of entries) {
+ if (entry.endpointName !== 'getGalleryItemNames') {
+ continue;
+ }
+ const data = galleryApi.endpoints.getGalleryItemNames.select(entry.originalArgs)(state).data;
+ if (data) {
+ return data.items.map((ref) => ref.name);
+ }
+ }
+ return [];
+};
+
+export const GalleryVideoItem = memo(({ videoDTO }: Props) => {
+ const store = useAppStore();
+ const ref = useRef(null);
+ const [isHovered, setIsHovered] = useState(false);
+ const alwaysShowSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge);
+
+ const selectIsSelected = useMemo(
+ () => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((n) => n === videoDTO.video_name)),
+ [videoDTO.video_name]
+ );
+ const isSelected = useAppSelector(selectIsSelected);
+
+ const onMouseOver = useCallback(() => setIsHovered(true), []);
+ const onMouseOut = useCallback(() => setIsHovered(false), []);
+
+ const onClick = useMemo(() => buildOnClick(videoDTO.video_name, store.dispatch, store.getState), [videoDTO, store]);
+
+ const onDoubleClick = useCallback>(() => {
+ navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID);
+ }, []);
+
+ // Reuse the image item's size-badge component — its only inputs are width/height.
+ const sizeBadgeImageStandIn = useMemo(
+ () => ({ width: videoDTO.width, height: videoDTO.height }),
+ [videoDTO.width, videoDTO.height]
+ );
+
+ // Right-click / long-press context menu (delete, change board, download).
+ useVideoContextMenu(videoDTO, ref);
+
+ // Register the item as a drag source so users can drop videos onto node fields,
+ // ref-image inputs, etc. — mirrors DndImage for image gallery items.
+ useEffect(() => {
+ const element = ref.current;
+ if (!element) {
+ return;
+ }
+ return combine(
+ firefoxDndFix(element),
+ draggable({
+ element,
+ getInitialData: () => singleVideoDndSource.getData({ videoDTO }, videoDTO.video_name),
+ })
+ );
+ }, [videoDTO]);
+
+ return (
+
+
+
+ {(isHovered || alwaysShowSizeBadge) && (
+ [0]['imageDTO']}
+ />
+ )}
+ {(isHovered || videoDTO.starred) && }
+
+ );
+});
+
+GalleryVideoItem.displayName = 'GalleryVideoItem';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentCanvasProjectPreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentCanvasProjectPreview.tsx
new file mode 100644
index 00000000000..027d190ab6e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentCanvasProjectPreview.tsx
@@ -0,0 +1,52 @@
+import { Flex, Image, Text } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { CanvasProjectDTO } from 'services/api/types';
+
+type Props = {
+ projectDTO: CanvasProjectDTO | null;
+};
+
+/**
+ * Viewer preview for a saved canvas project. Shows the preview WebP at full size, with a hint
+ * to use the "Load Project" toolbar button to actually restore the canvas state.
+ *
+ * Unlike CurrentImagePreview, this is read-only: there's no DnD target, no compare, no progress
+ * overlay — a project is a discrete restore action triggered from the toolbar.
+ */
+export const CurrentCanvasProjectPreview = memo(({ projectDTO }: Props) => {
+ const { t } = useTranslation();
+
+ if (!projectDTO) {
+ return null;
+ }
+
+ return (
+
+ {projectDTO.thumbnail_url ? (
+
+ ) : (
+
+ {t('controlLayers.canvasProject.project')}
+
+ )}
+
+ );
+});
+
+CurrentCanvasProjectPreview.displayName = 'CurrentCanvasProjectPreview';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx
new file mode 100644
index 00000000000..859d7d1c9f8
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentVideoPreview.tsx
@@ -0,0 +1,117 @@
+import { Flex } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors';
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import type { VideoDTO } from 'services/api/types';
+
+import { useImageViewerContext } from './context';
+import { NoContentForViewer } from './NoContentForViewer';
+import { ProgressImage } from './ProgressImage2';
+import { ProgressIndicator } from './ProgressIndicator2';
+import { VideoPlayButtonOverlay } from './VideoPlayButtonOverlay';
+
+type Props = {
+ videoDTO: VideoDTO | null;
+};
+
+/**
+ * Counterpart to CurrentImagePreview for videos. A single element spans both states:
+ *
+ * - **idle**: muted, no controls. Without a `poster` attribute the browser decodes and
+ * displays the video's actual first frame at full resolution (much sharper than the
+ * small WebP gallery thumbnail upscaled to fit the viewer). A centered play button
+ * overlay sits on top.
+ * - **playing**: native HTML5 controls + audio. The element is the same DOM node, so the
+ * decoded buffer carries over — no reload when the user hits play.
+ *
+ * Changing the selected video swaps the element via `key={videoName}`, which discards the
+ * old playback state cleanly.
+ *
+ * Mirrors CurrentImagePreview's progress overlay so denoise previews from a new render
+ * appear on top of the previously-loaded video. Without this, a freshly generated render's
+ * progress images had nowhere to display whenever a video was the last-selected gallery
+ * item (and the user only saw the static first-frame still until the new video finished).
+ */
+export const CurrentVideoPreview = memo(({ videoDTO }: Props) => {
+ const videoName = videoDTO?.video_name ?? null;
+ const videoRef = useRef(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
+ const { $progressEvent, $progressImage, onLoadImage } = useImageViewerContext();
+ const progressEvent = useStore($progressEvent);
+ const progressImage = useStore($progressImage);
+ const withProgress = shouldShowProgressInViewer && progressImage !== null;
+
+ // Whenever the selected video changes, drop back to the idle still + play overlay.
+ useEffect(() => {
+ setIsPlaying(false);
+ }, [videoName]);
+
+ const handlePlay = useCallback(() => {
+ setIsPlaying(true);
+ // The ref points at the same element we'll re-render with controls/audio; calling
+ // play() here keeps the user gesture wired to playback without waiting for React.
+ void videoRef.current?.play();
+ }, []);
+
+ // Analogous to in the image viewer: clear any stale
+ // denoise progress overlay once the new video's metadata is in. Without this, the
+ // ImageViewerContext atom stays set after a video render (there's no image load to
+ // trigger its clear), so the overlay sticks over the freshly-selected video forever.
+ //
+ // Also force a first-frame paint via a near-zero seek. With preload="metadata" some
+ // browsers populate dimensions/duration but don't actually decode and display the first
+ // video frame until playback or a seek — the element just shows its black background.
+ // Setting currentTime to 0.0001 nudges the decoder to paint without measurably advancing.
+ const handleLoadedMetadata = useCallback(() => {
+ onLoadImage();
+ const el = videoRef.current;
+ if (el && !isPlaying && el.currentTime === 0) {
+ try {
+ el.currentTime = 0.0001;
+ } catch {
+ // Some browsers throw if metadata isn't fully ready yet; harmless.
+ }
+ }
+ }, [isPlaying, onLoadImage]);
+
+ if (!videoDTO) {
+ return ;
+ }
+
+ return (
+
+
+ {!isPlaying && !withProgress && }
+ {withProgress && (
+
+
+ {progressEvent && (
+
+ )}
+
+ )}
+
+ );
+});
+
+CurrentVideoPreview.displayName = 'CurrentVideoPreview';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
index ce9795ee8b0..c3510502362 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx
@@ -1,12 +1,14 @@
import { Divider, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { useGalleryItemDTO } from 'common/hooks/useGalleryItemDTO';
import { setComparisonImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { CurrentCanvasProjectPreview } from 'features/gallery/components/ImageViewer/CurrentCanvasProjectPreview';
import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview';
+import { CurrentVideoPreview } from 'features/gallery/components/ImageViewer/CurrentVideoPreview';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
-import { useImageDTO } from 'services/api/endpoints/images';
import { ImageViewerToolbar } from './ImageViewerToolbar';
@@ -16,18 +18,35 @@ export const ImageViewer = memo(() => {
const { t } = useTranslation();
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
- const lastSelectedImageDTO = useImageDTO(lastSelectedItem ?? null);
+ const galleryItem = useGalleryItemDTO(lastSelectedItem);
+
+ // Polymorphic preview: videos render the play-overlay/HTML5 video; canvas projects render the
+ // preview WebP with a load-to-canvas affordance in the toolbar; images render the existing
+ // DndImage-based preview with progress / metadata / next-prev affordances.
+ let preview;
+ if (galleryItem?.kind === 'video') {
+ preview = ;
+ } else if (galleryItem?.kind === 'canvas_project') {
+ preview = ;
+ } else {
+ preview = ;
+ }
+
+ const isImage = galleryItem?.kind !== 'video' && galleryItem?.kind !== 'canvas_project';
+
return (
-
-
+ {preview}
+ {isImage && (
+
+ )}
);
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerToolbar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerToolbar.tsx
index b963f5a80d6..a4f82639bb0 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerToolbar.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerToolbar.tsx
@@ -1,24 +1,31 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
+import { useGalleryItemDTO } from 'common/hooks/useGalleryItemDTO';
import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { memo } from 'react';
-import { useImageDTO } from 'services/api/endpoints/images';
import { CurrentImageButtons } from './CurrentImageButtons';
+import { LoadCanvasProjectButton } from './LoadCanvasProjectButton';
import { ToggleProgressButton } from './ToggleProgressButton';
export const ImageViewerToolbar = memo(() => {
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
- const imageDTO = useImageDTO(lastSelectedItem);
+ const galleryItem = useGalleryItemDTO(lastSelectedItem);
+
+ // Videos don't carry workflows or recallable metadata yet — the action row + metadata viewer
+ // toggle are image-specific. We still show the progress button (it's media-agnostic).
+ const showImageActions = galleryItem?.kind === 'image';
+ const showProjectActions = galleryItem?.kind === 'canvas_project';
return (
- {imageDTO && }
+ {showImageActions && }
+ {showProjectActions && }
- {imageDTO && }
+ {showImageActions && }
);
});
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/LoadCanvasProjectButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/LoadCanvasProjectButton.tsx
new file mode 100644
index 00000000000..10f82b88c02
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/LoadCanvasProjectButton.tsx
@@ -0,0 +1,30 @@
+import { Button } from '@invoke-ai/ui-library';
+import { useLoadCanvasProjectFromServerWithDialog } from 'features/controlLayers/components/LoadCanvasProjectConfirmationAlertDialog';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiFileArrowUpBold } from 'react-icons/pi';
+import type { CanvasProjectDTO } from 'services/api/types';
+
+type Props = {
+ projectDTO: CanvasProjectDTO;
+};
+
+/**
+ * Toolbar button that triggers the load-confirmation dialog with the selected canvas project as
+ * source. The actual ZIP fetch + state restore happens after the user confirms.
+ */
+export const LoadCanvasProjectButton = memo(({ projectDTO }: Props) => {
+ const { t } = useTranslation();
+ const queueLoad = useLoadCanvasProjectFromServerWithDialog();
+ const onClick = useCallback(() => {
+ queueLoad(projectDTO.project_name);
+ }, [queueLoad, projectDTO.project_name]);
+
+ return (
+ } onClick={onClick}>
+ {t('controlLayers.canvasProject.loadProject')}
+
+ );
+});
+
+LoadCanvasProjectButton.displayName = 'LoadCanvasProjectButton';
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/VideoPlayButtonOverlay.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/VideoPlayButtonOverlay.tsx
new file mode 100644
index 00000000000..39777bdd90a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/VideoPlayButtonOverlay.tsx
@@ -0,0 +1,40 @@
+import { Flex, Icon } from '@invoke-ai/ui-library';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiPlayFill } from 'react-icons/pi';
+
+type Props = {
+ onClick: () => void;
+};
+
+/**
+ * Large centered play button shown over the still thumbnail in the video viewer. Clicking it
+ * swaps the preview into HTML5 video playback (see CurrentVideoPreview).
+ */
+export const VideoPlayButtonOverlay = memo(({ onClick }: Props) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+});
+
+VideoPlayButtonOverlay.displayName = 'VideoPlayButtonOverlay';
diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts
index 487c5609062..50ee3df9952 100644
--- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts
+++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts
@@ -3,11 +3,28 @@ import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { getDateFromVirtualBoardId, isVirtualBoardId } from 'features/gallery/store/types';
-import { useGetImageNamesQuery } from 'services/api/endpoints/images';
+import { useMemo } from 'react';
+import { useGetGalleryItemNamesQuery } from 'services/api/endpoints/gallery';
import { useGetVirtualBoardImageNamesByDateQuery } from 'services/api/endpoints/virtual_boards';
import { useDebounce } from 'use-debounce';
-const selectFromResult = ({
+type GalleryItemRef = { kind: 'image' | 'video' | 'canvas_project'; name: string };
+
+const selectFromGalleryItemNamesResult = ({
+ currentData,
+ isLoading,
+ isFetching,
+}: {
+ currentData?: { items: GalleryItemRef[] };
+ isLoading: boolean;
+ isFetching: boolean;
+}) => ({
+ items: currentData?.items ?? (EMPTY_ARRAY as GalleryItemRef[]),
+ isLoading,
+ isFetching,
+});
+
+const selectFromVirtualBoardResult = ({
currentData,
isLoading,
isFetching,
@@ -21,41 +38,60 @@ const selectFromResult = ({
isFetching,
});
-const queryOptions = {
+const galleryQueryOptions = {
+ refetchOnReconnect: true,
+ selectFromResult: selectFromGalleryItemNamesResult,
+};
+
+const virtualBoardQueryOptions = {
refetchOnReconnect: true,
- selectFromResult,
+ selectFromResult: selectFromVirtualBoardResult,
};
+/**
+ * Returns the ordered flat list of gallery item names. Names are polymorphic — both image and
+ * video names appear in the same list, interleaved by created_at. Callers that need to know the
+ * kind of a particular name use `isVideoName` from `features/gallery/store/types`.
+ *
+ * Virtual boards (date-based) are image-only for now and call the legacy by-date endpoint.
+ */
export const useGalleryImageNames = () => {
const selectedBoardId = useAppSelector(selectSelectedBoardId);
- const _queryArgs = useAppSelector(selectGetImageNamesQueryArgs);
- const [queryArgs] = useDebounce(_queryArgs, 300);
+ const _imageQueryArgs = useAppSelector(selectGetImageNamesQueryArgs);
+ const [imageQueryArgs] = useDebounce(_imageQueryArgs, 300);
const isVirtual = isVirtualBoardId(selectedBoardId);
- // Regular board query
- const regularResult = useGetImageNamesQuery(isVirtual ? skipToken : queryArgs, queryOptions);
+ // The polymorphic gallery names endpoint shares the same filter args as the image names
+ // endpoint (board_id, categories, search_term, order_dir, starred_first, is_intermediate).
+ const galleryResult = useGetGalleryItemNamesQuery(isVirtual ? skipToken : imageQueryArgs, galleryQueryOptions);
- // Virtual board query
const date = isVirtual ? getDateFromVirtualBoardId(selectedBoardId) : '';
const virtualResult = useGetVirtualBoardImageNamesByDateQuery(
isVirtual
? {
date,
- categories: queryArgs.categories ?? undefined,
- search_term: queryArgs.search_term || undefined,
- order_dir: queryArgs.order_dir,
- starred_first: queryArgs.starred_first,
+ categories: imageQueryArgs.categories ?? undefined,
+ search_term: imageQueryArgs.search_term || undefined,
+ order_dir: imageQueryArgs.order_dir,
+ starred_first: imageQueryArgs.starred_first,
}
: skipToken,
- queryOptions
+ virtualBoardQueryOptions
);
- const result = isVirtual ? virtualResult : regularResult;
+ // Flat names + isLoading exposed for backward compatibility with the existing callers (paged
+ // grid, search, navigation hotkeys). The kind is recoverable from the filename extension.
+ const imageNames = useMemo(() => {
+ if (isVirtual) {
+ return virtualResult.imageNames;
+ }
+ return galleryResult.items.map((ref) => ref.name);
+ }, [isVirtual, virtualResult.imageNames, galleryResult.items]);
return {
- imageNames: result.imageNames,
- isLoading: result.isLoading,
- isFetching: result.isFetching,
- queryArgs,
+ imageNames,
+ isLoading: isVirtual ? virtualResult.isLoading : galleryResult.isLoading,
+ isFetching: isVirtual ? virtualResult.isFetching : galleryResult.isFetching,
+ queryArgs: imageQueryArgs,
};
};
diff --git a/invokeai/frontend/web/src/features/gallery/contexts/CanvasProjectDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/CanvasProjectDTOContext.ts
new file mode 100644
index 00000000000..50484225710
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/contexts/CanvasProjectDTOContext.ts
@@ -0,0 +1,13 @@
+import { createContext, useContext } from 'react';
+import type { CanvasProjectDTO } from 'services/api/types';
+import { assert } from 'tsafe';
+
+const CanvasProjectDTOContext = createContext(null);
+
+export const CanvasProjectDTOContextProvider = CanvasProjectDTOContext.Provider;
+
+export const useCanvasProjectDTOContext = () => {
+ const dto = useContext(CanvasProjectDTOContext);
+ assert(dto !== null, 'useCanvasProjectDTOContext must be used within CanvasProjectDTOContextProvider');
+ return dto;
+};
diff --git a/invokeai/frontend/web/src/features/gallery/contexts/VideoDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/VideoDTOContext.ts
new file mode 100644
index 00000000000..69b822fb1eb
--- /dev/null
+++ b/invokeai/frontend/web/src/features/gallery/contexts/VideoDTOContext.ts
@@ -0,0 +1,13 @@
+import { createContext, useContext } from 'react';
+import type { VideoDTO } from 'services/api/types';
+import { assert } from 'tsafe';
+
+const VideoDTOContext = createContext(null);
+
+export const VideoDTOContextProvider = VideoDTOContext.Provider;
+
+export const useVideoDTOContext = () => {
+ const dto = useContext(VideoDTOContext);
+ assert(dto !== null, 'useVideoDTOContext must be used within VideoDTOContextProvider');
+ return dto;
+};
diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedImageFetching.ts b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedImageFetching.ts
index 8ea8e9023e3..8d5e1b3ef49 100644
--- a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedImageFetching.ts
+++ b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedImageFetching.ts
@@ -1,7 +1,10 @@
import { useAppStore } from 'app/store/storeHooks';
+import { isCanvasProjectName, isVideoName } from 'features/gallery/store/types';
import { useCallback, useEffect, useState } from 'react';
import type { ListRange } from 'react-virtuoso';
+import { canvasProjectsApi } from 'services/api/endpoints/canvasProjects';
import { imagesApi, useGetImageDTOsByNamesMutation } from 'services/api/endpoints/images';
+import { videosApi } from 'services/api/endpoints/videos';
import { useThrottledCallback } from 'use-debounce';
interface UseRangeBasedImageFetchingArgs {
@@ -30,9 +33,12 @@ const getUncachedNames = (imageNames: string[], cachedImageNames: string[], rang
};
/**
- * Hook for bulk fetching image DTOs based on the visible range from virtuoso.
- * Individual image components should use `useGetImageDTOQuery(imageName)` to get their specific DTO.
- * This hook ensures DTOs are bulk fetched and cached efficiently.
+ * Hook for bulk fetching gallery item DTOs based on the visible range from virtuoso.
+ *
+ * Names are polymorphic — image names go through the bulk `getImageDTOsByNames` mutation while
+ * video names dispatch individual `getVideoDTO` queries (the videos API doesn't have a batch
+ * endpoint yet; per-item is fine while video counts are low). Individual components still call
+ * `useGetImageDTOQuery` / `useGetVideoDTOQuery` to subscribe — this hook only triggers fetches.
*/
export const useRangeBasedImageFetching = ({
imageNames,
@@ -43,23 +49,46 @@ export const useRangeBasedImageFetching = ({
const [lastRange, setLastRange] = useState(null);
const [pendingRanges, setPendingRanges] = useState([]);
- const fetchImages = useCallback(
- (ranges: ListRange[], imageNames: string[]) => {
+ const fetchItems = useCallback(
+ (ranges: ListRange[], allNames: string[]) => {
if (!enabled) {
return;
}
- const cachedImageNames = imagesApi.util.selectCachedArgsForQuery(store.getState(), 'getImageDTO');
- const uncachedNames = getUncachedNames(imageNames, cachedImageNames, ranges);
- if (uncachedNames.length === 0) {
- return;
+ const state = store.getState();
+
+ // Images — bulk fetch via the existing batch endpoint.
+ const cachedImageNames = imagesApi.util.selectCachedArgsForQuery(state, 'getImageDTO');
+ const uncachedImageNames = getUncachedNames(allNames, cachedImageNames, ranges).filter(
+ (n) => !isVideoName(n) && !isCanvasProjectName(n)
+ );
+ if (uncachedImageNames.length > 0) {
+ getImageDTOsByNames({ image_names: uncachedImageNames });
+ }
+
+ // Videos — fetch one at a time (no batch endpoint yet). Each `initiate()` is a no-op for
+ // already-cached entries, so this is safe to call repeatedly while scrolling.
+ const cachedVideoNames = videosApi.util.selectCachedArgsForQuery(state, 'getVideoDTO');
+ const uncachedVideoNames = getUncachedNames(allNames, cachedVideoNames, ranges).filter((n) => isVideoName(n));
+ for (const videoName of uncachedVideoNames) {
+ store.dispatch(videosApi.endpoints.getVideoDTO.initiate(videoName));
}
- getImageDTOsByNames({ image_names: uncachedNames });
+
+ // Canvas projects — same pattern as videos. List is small enough that per-item fetches
+ // are fine; bulk endpoint can come later if it becomes a hotspot.
+ const cachedProjectNames = canvasProjectsApi.util.selectCachedArgsForQuery(state, 'getCanvasProjectDTO');
+ const uncachedProjectNames = getUncachedNames(allNames, cachedProjectNames, ranges).filter((n) =>
+ isCanvasProjectName(n)
+ );
+ for (const projectName of uncachedProjectNames) {
+ store.dispatch(canvasProjectsApi.endpoints.getCanvasProjectDTO.initiate(projectName));
+ }
+
setPendingRanges([]);
},
[enabled, getImageDTOsByNames, store]
);
- const throttledFetchImages = useThrottledCallback(fetchImages, 500);
+ const throttledFetchItems = useThrottledCallback(fetchItems, 500);
const onRangeChanged = useCallback((range: ListRange) => {
setLastRange(range);
@@ -68,8 +97,8 @@ export const useRangeBasedImageFetching = ({
useEffect(() => {
const combinedRanges = lastRange ? [...pendingRanges, lastRange] : pendingRanges;
- throttledFetchImages(combinedRanges, imageNames);
- }, [imageNames, lastRange, pendingRanges, throttledFetchImages]);
+ throttledFetchItems(combinedRanges, imageNames);
+ }, [imageNames, lastRange, pendingRanges, throttledFetchItems]);
return {
onRangeChanged,
diff --git a/invokeai/frontend/web/src/features/gallery/store/types.test.ts b/invokeai/frontend/web/src/features/gallery/store/types.test.ts
index 39a7a5602c2..d9dbb347568 100644
--- a/invokeai/frontend/web/src/features/gallery/store/types.test.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/types.test.ts
@@ -1,9 +1,10 @@
import type { S } from 'services/api/types';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
-import { describe, test } from 'vitest';
+import { describe, expect, it, test } from 'vitest';
import type { BoardRecordOrderBy } from './types';
+import { isCanvasProjectName, isVideoName } from './types';
describe('Gallery Types', () => {
// Ensure zod types match OpenAPI types
@@ -11,3 +12,54 @@ describe('Gallery Types', () => {
assert>();
});
});
+
+// The discriminators below are what GalleryImageGrid, useGalleryItemDTO and the bulk-delete
+// splitter dispatch on. The SimpleNameService emits images with `.png`, videos with `.mp4`, and
+// canvas projects as bare UUIDs (no extension) — the discriminators have to stay mutually
+// exclusive so a mixed-type selection routes each name to the correct API endpoint.
+
+describe('isVideoName', () => {
+ it('matches names ending in .mp4 (case-insensitive)', () => {
+ expect(isVideoName('a.mp4')).toBe(true);
+ expect(isVideoName('A.MP4')).toBe(true);
+ });
+
+ it('rejects image and project names', () => {
+ expect(isVideoName('aaf9504b-f1a2-4410-bf43-96f700c49246.png')).toBe(false);
+ expect(isVideoName('aaf9504b-f1a2-4410-bf43-96f700c49246')).toBe(false);
+ });
+});
+
+describe('isCanvasProjectName', () => {
+ it('matches bare UUID v4 strings', () => {
+ expect(isCanvasProjectName('aaf9504b-f1a2-4410-bf43-96f700c49246')).toBe(true);
+ expect(isCanvasProjectName('AAF9504B-F1A2-4410-BF43-96F700C49246')).toBe(true);
+ });
+
+ it('rejects names with an extension', () => {
+ expect(isCanvasProjectName('aaf9504b-f1a2-4410-bf43-96f700c49246.png')).toBe(false);
+ expect(isCanvasProjectName('aaf9504b-f1a2-4410-bf43-96f700c49246.mp4')).toBe(false);
+ expect(isCanvasProjectName('aaf9504b-f1a2-4410-bf43-96f700c49246.invk')).toBe(false);
+ });
+
+ it('rejects malformed UUIDs', () => {
+ expect(isCanvasProjectName('not-a-uuid')).toBe(false);
+ expect(isCanvasProjectName('aaf9504b-f1a2-4410-bf43')).toBe(false);
+ expect(isCanvasProjectName('')).toBe(false);
+ });
+
+ it('is mutually exclusive with isVideoName for any single name', () => {
+ const names = [
+ 'aaf9504b-f1a2-4410-bf43-96f700c49246', // project
+ 'aaf9504b-f1a2-4410-bf43-96f700c49246.png', // image
+ 'aaf9504b-f1a2-4410-bf43-96f700c49246.mp4', // video
+ ];
+ for (const name of names) {
+ const matches = [isCanvasProjectName(name), isVideoName(name)].filter(Boolean);
+ // At most one of the two discriminators may fire for any name. (Images are the
+ // "neither matches" fall-through bucket — they don't get their own discriminator
+ // function.)
+ expect(matches.length).toBeLessThanOrEqual(1);
+ }
+ });
+});
diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts
index c040e5834d7..f25ef237848 100644
--- a/invokeai/frontend/web/src/features/gallery/store/types.ts
+++ b/invokeai/frontend/web/src/features/gallery/store/types.ts
@@ -48,3 +48,17 @@ const VIRTUAL_BOARD_ID_PREFIX = 'by_date:';
export const isVirtualBoardId = (id: string): boolean => id.startsWith(VIRTUAL_BOARD_ID_PREFIX);
export const getDateFromVirtualBoardId = (id: string): string => id.replace(VIRTUAL_BOARD_ID_PREFIX, '');
+
+/**
+ * The polymorphic gallery treats selection as `string[]` of names. The kind is recoverable from
+ * the filename pattern since the backend names images with `.png`, videos with `.mp4` (see
+ * SimpleNameService), and canvas projects with a bare UUID (no extension). Centralizing the
+ * discriminator here so callers don't have to know about the extension contract.
+ */
+export const isVideoName = (name: string): boolean => name.toLowerCase().endsWith('.mp4');
+
+// UUID v4 pattern — bare UUIDs are canvas-project names (no extension). Images and videos always
+// carry an extension, so checking the absence of a `.` is also a valid discriminator. We use the
+// UUID pattern explicitly to defend against future name schemes.
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+export const isCanvasProjectName = (name: string): boolean => UUID_PATTERN.test(name);
diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts
index 2c9293127b4..1745ee64f92 100644
--- a/invokeai/frontend/web/src/features/imageActions/actions.ts
+++ b/invokeai/frontend/web/src/features/imageActions/actions.ts
@@ -35,14 +35,16 @@ import {
import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions';
import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice';
import type { BoardId } from 'features/gallery/store/types';
-import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice';
+import { fieldImageValueChanged, fieldVideoValueChanged } from 'features/nodes/store/nodesSlice';
import type { FieldIdentifier } from 'features/nodes/types/field';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { getOptimalDimension } from 'features/parameters/util/optimalDimension';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
+import { canvasProjectsApi } from 'services/api/endpoints/canvasProjects';
import { imageDTOToFile, imagesApi, uploadImage } from 'services/api/endpoints/images';
-import type { ImageDTO } from 'services/api/types';
+import { videosApi } from 'services/api/endpoints/videos';
+import type { ImageDTO, VideoDTO } from 'services/api/types';
import type { Equals } from 'tsafe';
import { assert } from 'tsafe';
@@ -75,6 +77,15 @@ export const setNodeImageFieldImage = (arg: {
dispatch(fieldImageValueChanged({ ...fieldIdentifier, value: imageDTO }));
};
+export const setNodeVideoFieldVideo = (arg: {
+ videoDTO: VideoDTO;
+ fieldIdentifier: FieldIdentifier;
+ dispatch: AppDispatch;
+}) => {
+ const { videoDTO, fieldIdentifier, dispatch } = arg;
+ dispatch(fieldVideoValueChanged({ ...fieldIdentifier, value: videoDTO }));
+};
+
export const setComparisonImage = (arg: { image_name: string; dispatch: AppDispatch }) => {
const { image_name, dispatch } = arg;
dispatch(imageToCompareChanged(image_name));
@@ -323,3 +334,36 @@ export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: Ap
dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false }));
dispatch(selectionChanged([]));
};
+
+// Single-video counterparts to addImagesToBoard / removeImagesFromBoard. The video router
+// only exposes single-video endpoints today (POST/DELETE /api/v1/videos/board), so the
+// callers loop per video. Backend permissions: add requires _assert_board_write_access
+// (admin/owner/public dest) AND _assert_video_direct_owner; remove requires
+// _assert_video_direct_owner plus write access on the *source* board.
+export const addVideoToBoard = (arg: { video_name: string; boardId: BoardId; dispatch: AppDispatch }) => {
+ const { video_name, boardId, dispatch } = arg;
+ dispatch(videosApi.endpoints.addVideoToBoard.initiate({ video_name, board_id: boardId }, { track: false }));
+ dispatch(selectionChanged([]));
+};
+
+export const removeVideoFromBoard = (arg: { video_name: string; dispatch: AppDispatch }) => {
+ const { video_name, dispatch } = arg;
+ dispatch(videosApi.endpoints.removeVideoFromBoard.initiate({ video_name }, { track: false }));
+ dispatch(selectionChanged([]));
+};
+
+// Single-canvas-project counterparts. The canvas projects router only exposes single-project
+// add/remove endpoints (POST/DELETE /api/v1/board_canvas_projects/), matching the video pattern.
+export const addCanvasProjectToBoard = (arg: { project_name: string; boardId: BoardId; dispatch: AppDispatch }) => {
+ const { project_name, boardId, dispatch } = arg;
+ dispatch(
+ canvasProjectsApi.endpoints.addCanvasProjectToBoard.initiate({ project_name, board_id: boardId }, { track: false })
+ );
+ dispatch(selectionChanged([]));
+};
+
+export const removeCanvasProjectFromBoard = (arg: { project_name: string; dispatch: AppDispatch }) => {
+ const { project_name, dispatch } = arg;
+ dispatch(canvasProjectsApi.endpoints.removeCanvasProjectFromBoard.initiate({ project_name }, { track: false }));
+ dispatch(selectionChanged([]));
+};
diff --git a/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx b/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx
index 2d043c9c816..5394bb67a65 100644
--- a/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx
+++ b/invokeai/frontend/web/src/features/lora/components/LoRASelect.tsx
@@ -44,6 +44,25 @@ const LoRASelect = () => {
) {
return model.variant === currentMainModelConfig.variant;
}
+ // For Wan: A14B (t2v_a14b/i2v_a14b) and TI2V-5B have different inner
+ // dims (5120 vs 3072) — applying the wrong variant crashes the layer
+ // patcher. LoRAs whose variant couldn't be detected (null) are kept
+ // so we don't silently hide ambiguous ones.
+ if (
+ currentMainModelConfig?.base === 'wan' &&
+ 'variant' in currentMainModelConfig &&
+ currentMainModelConfig.variant &&
+ 'variant' in model &&
+ model.variant
+ ) {
+ const expected =
+ currentMainModelConfig.variant === 't2v_a14b' || currentMainModelConfig.variant === 'i2v_a14b'
+ ? 'a14b'
+ : currentMainModelConfig.variant === 'ti2v_5b'
+ ? '5b'
+ : null;
+ return expected === null || model.variant === expected;
+ }
return true;
});
}, [modelConfigs, currentBaseModel, currentMainModelConfig]);
diff --git a/invokeai/frontend/web/src/features/metadata/parsing.tsx b/invokeai/frontend/web/src/features/metadata/parsing.tsx
index c5a31d03a34..9731a4e3b85 100644
--- a/invokeai/frontend/web/src/features/metadata/parsing.tsx
+++ b/invokeai/frontend/web/src/features/metadata/parsing.tsx
@@ -58,6 +58,11 @@ import {
setZImageSeedVarianceStrength,
setZImageShift,
vaeSelected,
+ wanComponentSourceSelected,
+ wanGuidanceScaleLowNoiseChanged,
+ wanT5EncoderModelSelected,
+ wanTransformerLowNoiseSelected,
+ wanVaeModelSelected,
widthChanged,
zImageQwen3EncoderModelSelected,
zImageQwen3SourceModelSelected,
@@ -845,6 +850,133 @@ const QwenImageShift: SingleMetadataHandler = {
};
//#endregion QwenImageShift
+//#region WanTransformerLowNoise
+const WanTransformerLowNoise: SingleMetadataHandler = {
+ [SingleMetadataKey]: true,
+ type: 'WanTransformerLowNoise',
+ parse: (metadata, _store) => {
+ const raw = getProperty(metadata, 'wan_transformer_low_noise');
+ // Reject when the key is absent so the handler is not rendered for non-Wan images
+ if (raw === undefined) {
+ return Promise.reject();
+ }
+ if (raw === null) {
+ return Promise.resolve(null);
+ }
+ return Promise.resolve(zModelIdentifierField.parse(raw));
+ },
+ recall: (value, store) => {
+ store.dispatch(wanTransformerLowNoiseSelected(value));
+ },
+ i18nKey: 'modelManager.wanTransformerLowNoise',
+ LabelComponent: MetadataLabel,
+ ValueComponent: ({ value }: SingleMetadataValueProps) => (
+
+ ),
+};
+//#endregion WanTransformerLowNoise
+
+//#region WanComponentSource
+const WanComponentSource: SingleMetadataHandler = {
+ [SingleMetadataKey]: true,
+ type: 'WanComponentSource',
+ parse: (metadata, _store) => {
+ const raw = getProperty(metadata, 'wan_component_source');
+ if (raw === undefined) {
+ return Promise.reject();
+ }
+ if (raw === null) {
+ return Promise.resolve(null);
+ }
+ return Promise.resolve(zModelIdentifierField.parse(raw));
+ },
+ recall: (value, store) => {
+ store.dispatch(wanComponentSourceSelected(value));
+ },
+ i18nKey: 'modelManager.wanComponentSource',
+ LabelComponent: MetadataLabel,
+ ValueComponent: ({ value }: SingleMetadataValueProps) => (
+
+ ),
+};
+//#endregion WanComponentSource
+
+//#region WanVaeModel
+const WanVaeModel: SingleMetadataHandler = {
+ [SingleMetadataKey]: true,
+ type: 'WanVaeModel',
+ parse: (metadata, _store) => {
+ const raw = getProperty(metadata, 'wan_vae_model');
+ if (raw === undefined) {
+ return Promise.reject();
+ }
+ if (raw === null) {
+ return Promise.resolve(null);
+ }
+ return Promise.resolve(zModelIdentifierField.parse(raw));
+ },
+ recall: (value, store) => {
+ store.dispatch(wanVaeModelSelected(value));
+ },
+ i18nKey: 'modelManager.wanVae',
+ LabelComponent: MetadataLabel,
+ ValueComponent: ({ value }: SingleMetadataValueProps) => (
+
+ ),
+};
+//#endregion WanVaeModel
+
+//#region WanT5EncoderModel
+const WanT5EncoderModel: SingleMetadataHandler = {
+ [SingleMetadataKey]: true,
+ type: 'WanT5EncoderModel',
+ parse: (metadata, _store) => {
+ const raw = getProperty(metadata, 'wan_t5_encoder_model');
+ if (raw === undefined) {
+ return Promise.reject();
+ }
+ if (raw === null) {
+ return Promise.resolve(null);
+ }
+ return Promise.resolve(zModelIdentifierField.parse(raw));
+ },
+ recall: (value, store) => {
+ store.dispatch(wanT5EncoderModelSelected(value));
+ },
+ i18nKey: 'modelManager.wanT5Encoder',
+ LabelComponent: MetadataLabel,
+ ValueComponent: ({ value }: SingleMetadataValueProps) => (
+
+ ),
+};
+//#endregion WanT5EncoderModel
+
+//#region WanGuidanceScaleLowNoise
+const WanGuidanceScaleLowNoise: SingleMetadataHandler = {
+ [SingleMetadataKey]: true,
+ type: 'WanGuidanceScaleLowNoise',
+ parse: (metadata, _store) => {
+ const raw = getProperty(metadata, 'wan_guidance_scale_low_noise');
+ if (raw === undefined) {
+ return Promise.reject();
+ }
+ if (raw === null) {
+ return Promise.resolve(null);
+ }
+ const parsed = z.number().parse(raw);
+ return Promise.resolve(parsed);
+ },
+ recall: (value, store) => {
+ store.dispatch(wanGuidanceScaleLowNoiseChanged(value));
+ },
+ i18nKey: 'parameters.wanGuidanceScaleLowNoise',
+ LabelComponent: MetadataLabel,
+ ValueComponent: ({ value }: SingleMetadataValueProps) => (
+
+ ),
+};
+//#endregion WanGuidanceScaleLowNoise
+
//#region ZImageShift
const ZImageShift: SingleMetadataHandler = {
[SingleMetadataKey]: true,
@@ -1649,6 +1781,11 @@ export const ImageMetadataHandlers = {
QwenImageQwenVLEncoderModel,
QwenImageQuantization,
QwenImageShift,
+ WanTransformerLowNoise,
+ WanComponentSource,
+ WanVaeModel,
+ WanT5EncoderModel,
+ WanGuidanceScaleLowNoise,
ZImageShift,
LoRAs,
CanvasLayers,
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/models.ts b/invokeai/frontend/web/src/features/modelManagerV2/models.ts
index 63179db844a..807e39b0a20 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/models.ts
+++ b/invokeai/frontend/web/src/features/modelManagerV2/models.ts
@@ -22,6 +22,7 @@ import {
isTIModelConfig,
isUnknownModelConfig,
isVAEModelConfig,
+ isWanT5EncoderModelConfig,
} from 'services/api/types';
import { objectEntries } from 'tsafe';
@@ -84,6 +85,11 @@ const MODEL_CATEGORIES: Record = {
i18nKey: 'modelManager.qwenVLEncoder',
filter: isQwenVLEncoderModelConfig,
},
+ wan_t5_encoder: {
+ category: 'wan_t5_encoder',
+ i18nKey: 'modelManager.wanT5Encoder',
+ filter: isWanT5EncoderModelConfig,
+ },
control_lora: {
category: 'control_lora',
i18nKey: 'modelManager.controlLora',
@@ -164,6 +170,7 @@ export const MODEL_BASE_TO_COLOR: Record = {
'z-image': 'cyan',
external: 'orange',
anima: 'invokePurple',
+ wan: 'cyan',
unknown: 'red',
};
@@ -186,6 +193,7 @@ export const MODEL_TYPE_TO_LONG_NAME: Record = {
t5_encoder: 'T5 Encoder',
qwen3_encoder: 'Qwen3 Encoder',
qwen_vl_encoder: 'Qwen2.5-VL Encoder',
+ wan_t5_encoder: 'Wan T5 Encoder',
clip_embed: 'CLIP Embed',
siglip: 'SigLIP',
flux_redux: 'FLUX Redux',
@@ -211,6 +219,7 @@ export const MODEL_BASE_TO_LONG_NAME: Record = {
'z-image': 'Z-Image',
external: 'External',
anima: 'Anima',
+ wan: 'Wan 2.2',
unknown: 'Unknown',
};
@@ -231,6 +240,7 @@ export const MODEL_BASE_TO_SHORT_NAME: Record = {
'z-image': 'Z-Image',
external: 'External',
anima: 'Anima',
+ wan: 'Wan',
unknown: 'Unknown',
};
@@ -251,6 +261,11 @@ export const MODEL_VARIANT_TO_LONG_NAME: Record = {
gigantic: 'CLIP G',
generate: 'Qwen Image',
edit: 'Qwen Image Edit',
+ t2v_a14b: 'Wan 2.2 T2V A14B',
+ i2v_a14b: 'Wan 2.2 I2V A14B',
+ ti2v_5b: 'Wan 2.2 TI2V 5B',
+ a14b: 'Wan 2.2 A14B LoRA',
+ '5b': 'Wan 2.2 5B LoRA',
qwen3_4b: 'Qwen3 4B',
qwen3_8b: 'Qwen3 8B',
qwen3_06b: 'Qwen3 0.6B',
@@ -270,6 +285,7 @@ export const MODEL_FORMAT_TO_LONG_NAME: Record = {
t5_encoder: 'T5 Encoder',
qwen3_encoder: 'Qwen3 Encoder',
qwen_vl_encoder: 'Qwen2.5-VL Encoder',
+ wan_t5_encoder: 'Wan T5 Encoder (UMT5-XXL)',
bnb_quantized_int8b: 'BNB Quantized (int8b)',
bnb_quantized_nf4b: 'BNB Quantized (nf4b)',
gguf_quantized: 'GGUF Quantized',
@@ -278,7 +294,7 @@ export const MODEL_FORMAT_TO_LONG_NAME: Record = {
export const SUPPORTS_OPTIMIZED_DENOISING_BASE_MODELS: BaseModelType[] = ['flux', 'sd-3'];
-export const SUPPORTS_REF_IMAGES_BASE_MODELS: BaseModelType[] = ['sd-1', 'sdxl', 'flux', 'flux2', 'qwen-image'];
+export const SUPPORTS_REF_IMAGES_BASE_MODELS: BaseModelType[] = ['sd-1', 'sdxl', 'flux', 'flux2', 'qwen-image', 'wan'];
export const SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS: BaseModelType[] = [
'sd-1',
@@ -289,4 +305,5 @@ export const SUPPORTS_NEGATIVE_PROMPT_BASE_MODELS: BaseModelType[] = [
'sd-3',
'z-image',
'anima',
+ 'wan',
];
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx
index 71d2efe0e45..1473e6dd076 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge.tsx
@@ -16,6 +16,7 @@ const FORMAT_NAME_MAP: Record = {
t5_encoder: 't5_encoder',
qwen3_encoder: 'qwen3_encoder',
qwen_vl_encoder: 'qwen_vl_encoder',
+ wan_t5_encoder: 'wan_t5_encoder',
bnb_quantized_int8b: 'bnb_quantized_int8b',
bnb_quantized_nf4b: 'quantized',
gguf_quantized: 'gguf',
@@ -37,6 +38,7 @@ const FORMAT_COLOR_MAP: Record = {
t5_encoder: 'base',
qwen3_encoder: 'base',
qwen_vl_encoder: 'base',
+ wan_t5_encoder: 'base',
bnb_quantized_int8b: 'base',
bnb_quantized_nf4b: 'base',
gguf_quantized: 'base',
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
index c8423a8fe4e..20ee4b21c04 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx
@@ -6,6 +6,7 @@ import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { DndImage } from 'features/dnd/DndImage';
import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons';
import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
+import { isVideoName } from 'features/gallery/store/types';
import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import type { AnimationProps } from 'framer-motion';
@@ -19,7 +20,12 @@ import { $lastProgressEvent } from 'services/events/stores';
const CurrentImageNode = (props: NodeProps) => {
const lastSelectedItem = useAppSelector(selectLastSelectedItem);
const lastProgressEvent = useStore($lastProgressEvent);
- const imageDTO = useImageDTO(lastSelectedItem);
+ // Pass a real name only when the selection is an image. Videos use the polymorphic
+ // gallery and would otherwise trigger GET /api/v1/images/i/.mp4 — which 404s
+ // and emits a noisy "Image record not found" backend log every time a video is
+ // clicked in the gallery while a Current Image node is in the workflow.
+ const imageName = lastSelectedItem && !isVideoName(lastSelectedItem) ? lastSelectedItem : null;
+ const imageDTO = useImageDTO(imageName);
if (lastProgressEvent?.image) {
return (
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx
index 890666b0c4e..e025fe39fe2 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeFooter.tsx
@@ -1,7 +1,7 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Flex, FormControlGroup } from '@invoke-ai/ui-library';
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
-import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
+import { useNodeHasGalleryOutput } from 'features/nodes/hooks/useNodeHasGalleryOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { memo } from 'react';
@@ -15,7 +15,7 @@ type Props = {
const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
- const hasImageOutput = useNodeHasImageOutput();
+ const hasGalleryOutput = useNodeHasGalleryOutput();
const isExecutableNode = useIsExecutableNode();
return (
{
>
{isExecutableNode && }
- {isExecutableNode && hasImageOutput && }
+ {isExecutableNode && hasGalleryOutput && }
);
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx
index 7d05ba63da6..4e9912a87be 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/SaveToGalleryCheckbox.tsx
@@ -1,6 +1,6 @@
import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
-import { useNodeHasImageOutput } from 'features/nodes/hooks/useNodeHasImageOutput';
+import { useNodeHasGalleryOutput } from 'features/nodes/hooks/useNodeHasGalleryOutput';
import { useNodeIsIntermediate } from 'features/nodes/hooks/useNodeIsIntermediate';
import { nodeIsIntermediateChanged } from 'features/nodes/store/nodesSlice';
import { NO_PAN_CLASS } from 'features/nodes/types/constants';
@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
- const hasImageOutput = useNodeHasImageOutput();
+ const hasGalleryOutput = useNodeHasGalleryOutput();
const isIntermediate = useNodeIsIntermediate();
const handleChange = useCallback(
(e: ChangeEvent) => {
@@ -25,7 +25,7 @@ const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
[dispatch, nodeId]
);
- if (!hasImageOutput) {
+ if (!hasGalleryOutput) {
return null;
}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
index 60a3f8e472a..98fee4f8006 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx
@@ -57,6 +57,8 @@ import {
isStringGeneratorFieldInputTemplate,
isStylePresetFieldInputInstance,
isStylePresetFieldInputTemplate,
+ isVideoFieldInputInstance,
+ isVideoFieldInputTemplate,
} from 'features/nodes/types/field';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { memo } from 'react';
@@ -70,6 +72,7 @@ import EnumFieldInputComponent from './inputs/EnumFieldInputComponent';
import ImageFieldInputComponent from './inputs/ImageFieldInputComponent';
import SchedulerFieldInputComponent from './inputs/SchedulerFieldInputComponent';
import StylePresetFieldInputComponent from './inputs/StylePresetFieldInputComponent';
+import VideoFieldInputComponent from './inputs/VideoFieldInputComponent';
type Props = {
nodeId: string;
@@ -202,6 +205,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
return ;
}
+ if (isVideoFieldInputTemplate(template)) {
+ if (!isVideoFieldInputInstance(field)) {
+ return null;
+ }
+ return ;
+ }
+
if (isBoardFieldInputTemplate(template)) {
if (!isBoardFieldInputInstance(field)) {
return null;
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx
new file mode 100644
index 00000000000..c3e4d85958b
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/VideoFieldInputComponent.tsx
@@ -0,0 +1,101 @@
+import { Flex, Image, Text } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useAppDispatch } from 'app/store/storeHooks';
+import type { SetNodeVideoFieldVideoDndTargetData } from 'features/dnd/dnd';
+import { setNodeVideoFieldVideoDndTarget } from 'features/dnd/dnd';
+import { DndDropTarget } from 'features/dnd/DndDropTarget';
+import { fieldVideoValueChanged } from 'features/nodes/store/nodesSlice';
+import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
+import type { VideoFieldInputInstance, VideoFieldInputTemplate } from 'features/nodes/types/field';
+import { memo, useCallback, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useGetVideoDTOQuery } from 'services/api/endpoints/videos';
+import { $isConnected } from 'services/events/stores';
+
+import type { FieldComponentProps } from './types';
+
+/**
+ * Counterpart to ImageFieldInputComponent for VideoField inputs. Shows the video's WebP
+ * thumbnail (the first decoded frame at the gallery-default size — same image the gallery
+ * grid uses), with a small dimensions badge in the corner. Users drop a video from the
+ * gallery onto the field; the drop is handled by setNodeVideoFieldVideoDndTarget.
+ */
+const VideoFieldInputComponent = (props: FieldComponentProps) => {
+ const { t } = useTranslation();
+ const { nodeId, field } = props;
+ const dispatch = useAppDispatch();
+ const isConnected = useStore($isConnected);
+
+ const { currentData: videoDTO, isError } = useGetVideoDTOQuery(field.value?.video_name ?? skipToken);
+
+ const handleReset = useCallback(() => {
+ dispatch(
+ fieldVideoValueChanged({
+ nodeId,
+ fieldName: field.name,
+ value: undefined,
+ })
+ );
+ }, [dispatch, field.name, nodeId]);
+
+ const dndTargetData = useMemo(
+ () => setNodeVideoFieldVideoDndTarget.getData({ fieldIdentifier: { nodeId, fieldName: field.name } }),
+ [field.name, nodeId]
+ );
+
+ // If the referenced video was deleted while disconnected, drop the stale reference once
+ // we reconnect — mirrors the image-field behavior.
+ useEffect(() => {
+ if (isConnected && isError) {
+ handleReset();
+ }
+ }, [handleReset, isConnected, isError]);
+
+ return (
+
+ {!videoDTO && (
+
+
+ {t('gallery.drop')}
+
+
+ )}
+ {videoDTO && (
+ <>
+
+
+
+ {`${videoDTO.width}x${videoDTO.height}`}
+ >
+ )}
+
+
+ );
+};
+
+export default memo(VideoFieldInputComponent);
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts
new file mode 100644
index 00000000000..d1e028d2b5f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasGalleryOutput.ts
@@ -0,0 +1,27 @@
+import { some } from 'es-toolkit/compat';
+import { useMemo } from 'react';
+
+import { useNodeTemplateSafe } from './useNodeTemplateSafe';
+
+/**
+ * True when the node produces an output that lands in the gallery — currently ImageField or
+ * VideoField. Used to gate the "Save in gallery" checkbox and the footer that contains it.
+ *
+ * The `image` and `video` primitive nodes are excluded because they pass through an existing
+ * asset without saving a new copy.
+ */
+export const useNodeHasGalleryOutput = (): boolean => {
+ const template = useNodeTemplateSafe();
+ const hasGalleryOutput = useMemo(
+ () =>
+ some(
+ template?.outputs,
+ (output) =>
+ (output.type.name === 'ImageField' && template?.type !== 'image') ||
+ (output.type.name === 'VideoField' && template?.type !== 'video')
+ ),
+ [template]
+ );
+
+ return hasGalleryOutput;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts
deleted file mode 100644
index 523f48919fb..00000000000
--- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { some } from 'es-toolkit/compat';
-import { useMemo } from 'react';
-
-import { useNodeTemplateSafe } from './useNodeTemplateSafe';
-
-export const useNodeHasImageOutput = (): boolean => {
- const template = useNodeTemplateSafe();
- const hasImageOutput = useMemo(
- () =>
- some(
- template?.outputs,
- (output) =>
- output.type.name === 'ImageField' &&
- // the image primitive node (node type "image") does not actually save the image, do not show the image-saving checkboxes
- template?.type !== 'image'
- ),
- [template]
- );
-
- return hasImageOutput;
-};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts b/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts
index b140295801b..14383affd82 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useWithFooter.ts
@@ -1,9 +1,9 @@
import { useIsExecutableNode } from 'features/nodes/hooks/useIsBatchNode';
-import { useNodeHasImageOutput } from './useNodeHasImageOutput';
+import { useNodeHasGalleryOutput } from './useNodeHasGalleryOutput';
export const useWithFooter = () => {
- const hasImageOutput = useNodeHasImageOutput();
+ const hasGalleryOutput = useNodeHasGalleryOutput();
const isExecutableNode = useIsExecutableNode();
- return isExecutableNode && hasImageOutput;
+ return isExecutableNode && hasGalleryOutput;
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index 6713ee8fb42..950f8d444d7 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -49,6 +49,7 @@ import type {
StringFieldValue,
StringGeneratorFieldValue,
StylePresetFieldValue,
+ VideoFieldValue,
} from 'features/nodes/types/field';
import {
zBoardFieldValue,
@@ -71,6 +72,7 @@ import {
zStringFieldValue,
zStringGeneratorFieldValue,
zStylePresetFieldValue,
+ zVideoFieldValue,
} from 'features/nodes/types/field';
import type { AnyEdge, AnyNode, ConnectorNode } from 'features/nodes/types/invocation';
import { isConnectorNode, isInvocationNode, isNotesNode } from 'features/nodes/types/invocation';
@@ -518,6 +520,9 @@ const slice = createSlice({
fieldImageCollectionValueChanged: (state, action: FieldValueAction) => {
fieldValueReducer(state, action, zImageFieldCollectionValue);
},
+ fieldVideoValueChanged: (state, action: FieldValueAction) => {
+ fieldValueReducer(state, action, zVideoFieldValue);
+ },
fieldColorValueChanged: (state, action: FieldValueAction) => {
fieldValueReducer(state, action, zColorFieldValue);
},
@@ -666,6 +671,7 @@ export const {
fieldEnumModelValueChanged,
fieldImageValueChanged,
fieldImageCollectionValueChanged,
+ fieldVideoValueChanged,
fieldLabelChanged,
fieldModelIdentifierValueChanged,
fieldIntegerValueChanged,
diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts
index fb2a1ce946a..f23c34ab170 100644
--- a/invokeai/frontend/web/src/features/nodes/types/common.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/common.ts
@@ -11,6 +11,12 @@ type ImageFieldCollection = z.infer;
export const isImageFieldCollection = (field: unknown): field is ImageFieldCollection =>
zImageFieldCollection.safeParse(field).success;
+export const zVideoField = z.object({
+ video_name: z.string().trim().min(1),
+});
+type VideoField = z.infer;
+export const isVideoField = (field: unknown): field is VideoField => zVideoField.safeParse(field).success;
+
export const zBoardField = z.object({
board_id: z.string().trim().min(1),
});
@@ -100,6 +106,7 @@ export const zBaseModelType = z.enum([
'z-image',
'external',
'anima',
+ 'wan',
'unknown',
]);
export type BaseModelType = z.infer;
@@ -114,6 +121,7 @@ export const zMainModelBase = z.enum([
'qwen-image',
'z-image',
'anima',
+ 'wan',
]);
type MainModelBase = z.infer;
export const isMainModelBase = (base: unknown): base is MainModelBase => zMainModelBase.safeParse(base).success;
@@ -134,6 +142,7 @@ export const zModelType = z.enum([
't5_encoder',
'qwen3_encoder',
'qwen_vl_encoder',
+ 'wan_t5_encoder',
'clip_embed',
'siglip',
'flux_redux',
@@ -144,6 +153,7 @@ export type ModelType = z.infer;
export const zSubModelType = z.enum([
'unet',
'transformer',
+ 'transformer_2',
'text_encoder',
'text_encoder_2',
'text_encoder_3',
@@ -163,6 +173,10 @@ export const zFluxVariantType = z.enum(['dev', 'dev_fill', 'schnell']);
export const zFlux2VariantType = z.enum(['klein_4b', 'klein_4b_base', 'klein_9b', 'klein_9b_base']);
export const zZImageVariantType = z.enum(['turbo', 'zbase']);
const zQwenImageVariantType = z.enum(['generate', 'edit']);
+const zWanVariantType = z.enum(['t2v_a14b', 'i2v_a14b', 'ti2v_5b']);
+/** Wan LoRA variant — identifies which model FAMILY (inner_dim) a LoRA
+ * targets. A14B = inner_dim 5120 (both T2V and I2V), 5B = inner_dim 3072. */
+const zWanLoRAVariantType = z.enum(['a14b', '5b']);
export const zQwen3VariantType = z.enum(['qwen3_4b', 'qwen3_8b', 'qwen3_06b']);
export const zAnyModelVariant = z.union([
zModelVariantType,
@@ -171,6 +185,8 @@ export const zAnyModelVariant = z.union([
zFlux2VariantType,
zZImageVariantType,
zQwenImageVariantType,
+ zWanVariantType,
+ zWanLoRAVariantType,
zQwen3VariantType,
]);
export type AnyModelVariant = z.infer;
@@ -187,6 +203,7 @@ export const zModelFormat = z.enum([
't5_encoder',
'qwen3_encoder',
'qwen_vl_encoder',
+ 'wan_t5_encoder',
'bnb_quantized_int8b',
'bnb_quantized_nf4b',
'gguf_quantized',
diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts
index 9da499ab91c..7383629eb84 100644
--- a/invokeai/frontend/web/src/features/nodes/types/constants.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts
@@ -57,6 +57,7 @@ export const FIELD_COLORS: { [key: string]: string } = {
CogView4MainModelField: 'teal.500',
ZImageMainModelField: 'teal.500',
AnimaMainModelField: 'teal.500',
+ WanMainModelField: 'teal.500',
SDXLMainModelField: 'teal.500',
SDXLRefinerModelField: 'teal.500',
SpandrelImageToImageModelField: 'teal.500',
diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts
index ffd87ae3984..6ad209cec15 100644
--- a/invokeai/frontend/web/src/features/nodes/types/field.ts
+++ b/invokeai/frontend/web/src/features/nodes/types/field.ts
@@ -20,6 +20,7 @@ import {
zModelType,
zSchedulerField,
zStylePresetField,
+ zVideoField,
} from './common';
/**
@@ -156,6 +157,10 @@ const zImageFieldType = zFieldTypeBase.extend({
name: z.literal('ImageField'),
originalType: zStatelessFieldType.optional(),
});
+const zVideoFieldType = zFieldTypeBase.extend({
+ name: z.literal('VideoField'),
+ originalType: zStatelessFieldType.optional(),
+});
const zImageCollectionFieldType = zFieldTypeBase.extend({
name: z.literal('ImageField'),
cardinality: z.literal(COLLECTION),
@@ -211,6 +216,7 @@ const zStatefulFieldType = z.union([
zBooleanFieldType,
zEnumFieldType,
zImageFieldType,
+ zVideoFieldType,
zBoardFieldType,
zStylePresetFieldType,
zModelIdentifierFieldType,
@@ -536,6 +542,26 @@ export const isEnumFieldInputInstance = buildInstanceTypeGuard(zEnumFieldInputIn
export const isEnumFieldInputTemplate = buildTemplateTypeGuard('EnumField');
// #endregion
+// #region VideoField
+export const zVideoFieldValue = zVideoField.optional();
+const zVideoFieldInputInstance = zFieldInputInstanceBase.extend({
+ value: zVideoFieldValue,
+});
+const zVideoFieldInputTemplate = zFieldInputTemplateBase.extend({
+ type: zVideoFieldType,
+ originalType: zFieldType.optional(),
+ default: zVideoFieldValue,
+});
+const zVideoFieldOutputTemplate = zFieldOutputTemplateBase.extend({
+ type: zVideoFieldType,
+});
+export type VideoFieldValue = z.infer;
+export type VideoFieldInputInstance = z.infer;
+export type VideoFieldInputTemplate = z.infer;
+export const isVideoFieldInputInstance = buildInstanceTypeGuard(zVideoFieldInputInstance);
+export const isVideoFieldInputTemplate = buildTemplateTypeGuard('VideoField', ['SINGLE']);
+// #endregion
+
// #region ImageField
export const zImageFieldValue = zImageField.optional();
const zImageFieldInputInstance = zFieldInputInstanceBase.extend({
@@ -1285,6 +1311,7 @@ export const zStatefulFieldValue = z.union([
zEnumFieldValue,
zImageFieldValue,
zImageFieldCollectionValue,
+ zVideoFieldValue,
zBoardFieldValue,
zStylePresetFieldValue,
zModelIdentifierFieldValue,
@@ -1313,6 +1340,7 @@ const zStatefulFieldInputInstance = z.union([
zEnumFieldInputInstance,
zImageFieldInputInstance,
zImageFieldCollectionInputInstance,
+ zVideoFieldInputInstance,
zBoardFieldInputInstance,
zStylePresetFieldInputInstance,
zModelIdentifierFieldInputInstance,
@@ -1340,6 +1368,7 @@ const zStatefulFieldInputTemplate = z.union([
zEnumFieldInputTemplate,
zImageFieldInputTemplate,
zImageFieldCollectionInputTemplate,
+ zVideoFieldInputTemplate,
zBoardFieldInputTemplate,
zStylePresetFieldInputTemplate,
zModelIdentifierFieldInputTemplate,
@@ -1368,6 +1397,7 @@ const zStatefulFieldOutputTemplate = z.union([
zEnumFieldOutputTemplate,
zImageFieldOutputTemplate,
zImageFieldCollectionOutputTemplate,
+ zVideoFieldOutputTemplate,
zBoardFieldOutputTemplate,
zStylePresetFieldOutputTemplate,
zModelIdentifierFieldOutputTemplate,
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addImageToImage.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addImageToImage.ts
index f17ff970f27..103c139b723 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addImageToImage.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addImageToImage.ts
@@ -30,6 +30,7 @@ type AddImageToImageArg = {
| 'qwen_image_i2l'
| 'z_image_i2l'
| 'anima_i2l'
+ | 'wan_i2l'
>;
noise?: Invocation<'noise'>;
denoise: Invocation;
@@ -56,6 +57,7 @@ export const addImageToImage = async ({
| 'qwen_image_l2i'
| 'z_image_l2i'
| 'anima_l2i'
+ | 'wan_l2i'
>
> => {
const { denoising_start, denoising_end } = getDenoisingStartAndEnd(state);
@@ -71,7 +73,8 @@ export const addImageToImage = async ({
denoise.type === 'flux2_denoise' ||
denoise.type === 'sd3_denoise' ||
denoise.type === 'z_image_denoise' ||
- denoise.type === 'anima_denoise'
+ denoise.type === 'anima_denoise' ||
+ denoise.type === 'wan_denoise'
) {
denoise.width = scaledSize.width;
denoise.height = scaledSize.height;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts
index fa01db67e60..03aa0f78a60 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addInpaint.ts
@@ -33,6 +33,7 @@ type AddInpaintArg = {
| 'qwen_image_i2l'
| 'z_image_i2l'
| 'anima_i2l'
+ | 'wan_i2l'
>;
noise?: Invocation<'noise'>;
denoise: Invocation;
@@ -69,7 +70,8 @@ export const addInpaint = async ({
denoise.type === 'flux2_denoise' ||
denoise.type === 'sd3_denoise' ||
denoise.type === 'z_image_denoise' ||
- denoise.type === 'anima_denoise'
+ denoise.type === 'anima_denoise' ||
+ denoise.type === 'wan_denoise'
) {
denoise.width = scaledSize.width;
denoise.height = scaledSize.height;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts
index 0c57087eaad..79d38075e35 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addOutpaint.ts
@@ -62,7 +62,8 @@ export const addOutpaint = async ({
denoise.type === 'flux2_denoise' ||
denoise.type === 'sd3_denoise' ||
denoise.type === 'z_image_denoise' ||
- denoise.type === 'anima_denoise'
+ denoise.type === 'anima_denoise' ||
+ denoise.type === 'wan_denoise'
) {
denoise.width = scaledSize.width;
denoise.height = scaledSize.height;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addTextToImage.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addTextToImage.ts
index 06ece522da5..9e5d8aec82e 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addTextToImage.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addTextToImage.ts
@@ -31,6 +31,7 @@ export const addTextToImage = ({
| 'qwen_image_l2i'
| 'z_image_l2i'
| 'anima_l2i'
+ | 'wan_l2i'
> => {
denoise.denoising_start = 0;
denoise.denoising_end = 1;
@@ -44,7 +45,8 @@ export const addTextToImage = ({
denoise.type === 'flux2_denoise' ||
denoise.type === 'sd3_denoise' ||
denoise.type === 'z_image_denoise' ||
- denoise.type === 'anima_denoise'
+ denoise.type === 'anima_denoise' ||
+ denoise.type === 'wan_denoise'
) {
denoise.width = scaledSize.width;
denoise.height = scaledSize.height;
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addWanLoRAs.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addWanLoRAs.ts
new file mode 100644
index 00000000000..9b7bacccff5
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addWanLoRAs.ts
@@ -0,0 +1,132 @@
+import { logger } from 'app/logging/logger';
+import type { RootState } from 'app/store/store';
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
+import { zModelIdentifierField } from 'features/nodes/types/common';
+import type { Graph } from 'features/nodes/util/graph/generation/Graph';
+import type { Invocation, MainModelConfig, S } from 'services/api/types';
+import { isWanLoRAModelConfig } from 'services/api/types';
+
+const log = logger('system');
+
+/** Map a Wan main-model variant onto the LoRA-variant string used by the
+ * probe. A14B (both T2V and I2V) uses inner_dim=5120 → "a14b". TI2V-5B
+ * uses inner_dim=3072 → "5b". */
+const mainVariantToLoRAVariant = (mainVariant: string | null | undefined): 'a14b' | '5b' | null => {
+ if (mainVariant === 't2v_a14b' || mainVariant === 'i2v_a14b') {
+ return 'a14b';
+ }
+ if (mainVariant === 'ti2v_5b') {
+ return '5b';
+ }
+ return null;
+};
+
+/**
+ * Add Wan 2.2 LoRA wiring to the graph between the model loader and the
+ * denoise node.
+ *
+ * Each enabled Wan LoRA becomes a ``lora_selector`` feeding a ``collect``
+ * node, which fans into a ``wan_lora_collection_loader``. The collection
+ * loader rewrites the model loader's transformer output into a
+ * ``WanTransformerField`` with the appropriate ``loras`` /
+ * ``loras_low_noise`` lists populated based on each LoRA's recorded
+ * ``expert`` tag — high-noise LoRAs land on the primary list, low-noise
+ * LoRAs on ``loras_low_noise``, and untagged LoRAs are applied to both
+ * experts. The dual-expert routing happens entirely on the backend; the
+ * FE just hands the loader the bag of LoRAs.
+ *
+ * Variant filter: each LoRA's full config carries a ``variant`` field
+ * (``"a14b"`` / ``"5b"`` / null) set by the backend probe from the LoRA's
+ * inner-dim. A14B LoRAs have 5120-dim weights and can't be reshaped to
+ * fit a TI2V-5B main (3072-dim) — the layer patcher would crash with a
+ * tensor-size error. We fetch each LoRA's config and skip mismatches,
+ * logging a warning so the user can tell why a LoRA they enabled didn't
+ * take effect.
+ */
+export const addWanLoRAs = async (
+ state: RootState,
+ g: Graph,
+ denoise: Invocation<'wan_denoise'>,
+ modelLoader: Invocation<'wan_model_loader'>,
+ mainConfig: MainModelConfig
+): Promise => {
+ // MainModelConfig is the union of all main-config schemas; ``variant`` is
+ // only present on the discriminated members (Wan, FLUX, ZImage, etc.).
+ // Read it defensively rather than relying on TypeScript narrowing through
+ // a typed parameter.
+ const mainVariant = 'variant' in mainConfig ? ((mainConfig as { variant?: string | null }).variant ?? null) : null;
+ const expectedLoRAVariant = mainVariantToLoRAVariant(mainVariant);
+ const candidateLoRAs = state.loras.loras.filter((l) => l.isEnabled && l.model.base === 'wan');
+
+ if (candidateLoRAs.length === 0) {
+ return;
+ }
+
+ // Fetch each LoRA's config and filter by variant compatibility. LoRAs
+ // with ``variant === null`` are kept (the probe couldn't identify them;
+ // best to try rather than silently drop).
+ const compatibleLoRAs: typeof candidateLoRAs = [];
+ for (const lora of candidateLoRAs) {
+ try {
+ const cfg = await fetchModelConfigWithTypeGuard(lora.model.key, isWanLoRAModelConfig);
+ const loraVariant = cfg.variant ?? null;
+ if (loraVariant !== null && expectedLoRAVariant !== null && loraVariant !== expectedLoRAVariant) {
+ log.warn(
+ { lora: lora.model.name, loraVariant, mainVariant },
+ `Skipping Wan LoRA "${lora.model.name}" — its variant (${loraVariant}) is incompatible with ` +
+ `the selected main model variant (${mainVariant}). ` +
+ `A14B and TI2V-5B have different inner dims and LoRA weights aren't interchangeable.`
+ );
+ continue;
+ }
+ compatibleLoRAs.push(lora);
+ } catch (e) {
+ // If the config can't be fetched, fall back to including the LoRA —
+ // the backend will produce a clearer error if it really doesn't fit.
+ log.warn({ lora: lora.model.name, error: String(e) }, `Failed to read variant for Wan LoRA "${lora.model.name}"`);
+ compatibleLoRAs.push(lora);
+ }
+ }
+
+ if (compatibleLoRAs.length === 0) {
+ return;
+ }
+
+ const loraMetadata: S['LoRAMetadataField'][] = [];
+
+ const loraCollector = g.addNode({
+ id: getPrefixedId('lora_collector'),
+ type: 'collect',
+ });
+ const loraCollectionLoader = g.addNode({
+ type: 'wan_lora_collection_loader',
+ id: getPrefixedId('wan_lora_collection_loader'),
+ });
+
+ g.addEdge(loraCollector, 'collection', loraCollectionLoader, 'loras');
+ g.addEdge(modelLoader, 'transformer', loraCollectionLoader, 'transformer');
+ g.deleteEdgesTo(denoise, ['transformer']);
+ g.addEdge(loraCollectionLoader, 'transformer', denoise, 'transformer');
+
+ for (const lora of compatibleLoRAs) {
+ const { weight } = lora;
+ const parsedModel = zModelIdentifierField.parse(lora.model);
+
+ const loraSelector = g.addNode({
+ type: 'lora_selector',
+ id: getPrefixedId('lora_selector'),
+ lora: parsedModel,
+ weight,
+ });
+
+ loraMetadata.push({
+ model: parsedModel,
+ weight,
+ });
+
+ g.addEdge(loraSelector, 'lora', loraCollector, 'item');
+ }
+
+ g.upsertMetadata({ loras: loraMetadata });
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildWanGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildWanGraph.ts
new file mode 100644
index 00000000000..74b1da57571
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildWanGraph.ts
@@ -0,0 +1,277 @@
+import { logger } from 'app/logging/logger';
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import { selectMainModelConfig, selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
+import { selectRefImagesSlice } from 'features/controlLayers/store/refImagesSlice';
+import { selectCanvasMetadata } from 'features/controlLayers/store/selectors';
+import { isWanReferenceImageConfig } from 'features/controlLayers/store/types';
+import { getGlobalReferenceImageWarnings } from 'features/controlLayers/store/validators';
+import { fetchModelConfigWithTypeGuard } from 'features/metadata/util/modelFetchingHelpers';
+import { zImageField } from 'features/nodes/types/common';
+import { addImageToImage } from 'features/nodes/util/graph/generation/addImageToImage';
+import { addInpaint } from 'features/nodes/util/graph/generation/addInpaint';
+import { addNSFWChecker } from 'features/nodes/util/graph/generation/addNSFWChecker';
+import { addOutpaint } from 'features/nodes/util/graph/generation/addOutpaint';
+import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage';
+import { addWanLoRAs } from 'features/nodes/util/graph/generation/addWanLoRAs';
+import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker';
+import { Graph } from 'features/nodes/util/graph/generation/Graph';
+import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
+import type { GraphBuilderArg, GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types';
+import { selectActiveTab } from 'features/ui/store/uiSelectors';
+import type { Invocation } from 'services/api/types';
+import { isNonRefinerMainModelConfig } from 'services/api/types';
+import type { Equals } from 'tsafe';
+import { assert } from 'tsafe';
+
+const log = logger('system');
+
+/**
+ * Build a graph for Wan 2.2 image generation.
+ *
+ * Phase 9 piece #1: text-to-image only, Diffusers main model with all
+ * components (transformer, VAE, UMT5-XXL encoder) resolved from the main
+ * model itself. Subsequent pieces will add:
+ * - img2img (Latents input + Image-to-Latents wiring + denoising_start)
+ * - I2V (ref-image encoder, A14B I2V variant gate)
+ * - LoRAs (single + collection)
+ * - Inpaint (mask handling)
+ * - Standalone VAE / T5 / GGUF low-noise-expert wiring via params slice
+ */
+export const buildWanGraph = async (arg: GraphBuilderArg): Promise => {
+ const { generationMode, state, manager } = arg;
+
+ log.debug({ generationMode, manager: manager?.id }, 'Building Wan 2.2 graph');
+
+ const model = selectMainModelConfig(state);
+ assert(model, 'No model selected');
+ assert(model.base === 'wan', 'Selected model is not a Wan model');
+
+ // Fetch the full config early so we can branch on variant. I2V flows
+ // route the raster image through wan_ref_image_encoder instead of
+ // wan_i2l, so the variant has to be known before we choose a graph
+ // shape — not after.
+ const modelConfig = await fetchModelConfigWithTypeGuard(model.key, isNonRefinerMainModelConfig);
+ assert(modelConfig.base === 'wan');
+ const isI2V = modelConfig.variant === 'i2v_a14b';
+
+ const params = selectParamsSlice(state);
+ const { cfgScale: cfg_scale, steps } = params;
+ const prompts = selectPresetModifiedPrompts(state);
+
+ const g = new Graph(getPrefixedId('wan_graph'));
+
+ const modelLoader = g.addNode({
+ type: 'wan_model_loader',
+ id: getPrefixedId('wan_model_loader'),
+ model,
+ transformer_low_noise_model: params.wanTransformerLowNoise ?? undefined,
+ component_source: params.wanComponentSource ?? undefined,
+ vae_model: params.wanVaeModel ?? undefined,
+ wan_t5_encoder_model: params.wanT5EncoderModel ?? undefined,
+ });
+
+ const positivePrompt = g.addNode({
+ id: getPrefixedId('positive_prompt'),
+ type: 'string',
+ });
+ const posCond = g.addNode({
+ type: 'wan_text_encoder',
+ id: getPrefixedId('pos_prompt'),
+ });
+
+ // CFG is mathematically inactive at scale 1.0 — skip the negative branch
+ // entirely so each step runs only one forward pass.
+ const useCfg = cfg_scale > 1;
+ const negCond = useCfg
+ ? g.addNode({
+ type: 'wan_text_encoder',
+ id: getPrefixedId('neg_prompt'),
+ prompt: prompts.negative || ' ',
+ })
+ : null;
+
+ const seed = g.addNode({
+ id: getPrefixedId('seed'),
+ type: 'integer',
+ });
+
+ const denoise = g.addNode({
+ type: 'wan_denoise',
+ id: getPrefixedId('denoise_latents'),
+ guidance_scale: cfg_scale,
+ // The denoise node treats values < 1.0 (including the FE's default 0) as
+ // "fall back to the primary guidance_scale". Forward null/undefined when
+ // the user hasn't set an explicit low-noise CFG so the backend handles it.
+ guidance_scale_low_noise: params.wanGuidanceScaleLowNoise ?? undefined,
+ steps,
+ });
+
+ const l2i = g.addNode({
+ type: 'wan_l2i',
+ id: getPrefixedId('l2i'),
+ });
+
+ g.addEdge(modelLoader, 'transformer', denoise, 'transformer');
+ g.addEdge(modelLoader, 'wan_t5_encoder', posCond, 'wan_t5_encoder');
+ g.addEdge(modelLoader, 'vae', l2i, 'vae');
+
+ g.addEdge(positivePrompt, 'value', posCond, 'prompt');
+ g.addEdge(posCond, 'conditioning', denoise, 'positive_conditioning');
+
+ if (negCond) {
+ g.addEdge(modelLoader, 'wan_t5_encoder', negCond, 'wan_t5_encoder');
+ g.addEdge(negCond, 'conditioning', denoise, 'negative_conditioning');
+ }
+
+ g.addEdge(seed, 'value', denoise, 'seed');
+ g.addEdge(denoise, 'latents', l2i, 'latents');
+
+ // Wan LoRAs (high-noise, low-noise, and untagged). The collection loader
+ // is inserted between modelLoader and denoise; both expert routing and
+ // dual-list population happen on the backend based on each LoRA's
+ // recorded ``expert`` tag. The helper also filters out variant-incompatible
+ // LoRAs (e.g. A14B Lightning on a TI2V-5B main) so the layer patcher
+ // doesn't crash on a shape mismatch.
+ await addWanLoRAs(state, g, denoise, modelLoader, modelConfig);
+
+ g.upsertMetadata({
+ cfg_scale,
+ negative_prompt: prompts.negative,
+ model: Graph.getModelMetadataField(modelConfig),
+ steps,
+ wan_transformer_low_noise: params.wanTransformerLowNoise,
+ wan_component_source: params.wanComponentSource,
+ wan_vae_model: params.wanVaeModel,
+ wan_t5_encoder_model: params.wanT5EncoderModel,
+ wan_guidance_scale_low_noise: params.wanGuidanceScaleLowNoise,
+ });
+ g.addEdgeToMetadata(seed, 'value', 'seed');
+ g.addEdgeToMetadata(positivePrompt, 'value', 'positive_prompt');
+
+ let canvasOutput: Invocation = l2i;
+
+ // I2V variants take a reference image from the global Reference Images
+ // panel (same UX as Qwen Image Edit / FLUX.2 Klein). The image is encoded
+ // by the model's own VAE and concatenated to the noise latents along the
+ // channel dim each step (transformer in_channels=36 on I2V). Canvas modes
+ // (img2img/inpaint/outpaint) don't apply to I2V — the ref image fully
+ // replaces what a raster layer used to provide.
+ if (isI2V) {
+ assert(
+ generationMode === 'txt2img',
+ 'Wan 2.2 I2V only supports text-to-image with a reference image. ' +
+ 'Use a T2V or TI2V model for canvas img2img / inpaint / outpaint.'
+ );
+
+ const wanRefEntity = selectRefImagesSlice(state).entities.find(
+ (entity) =>
+ entity.isEnabled &&
+ isWanReferenceImageConfig(entity.config) &&
+ entity.config.image !== null &&
+ getGlobalReferenceImageWarnings(entity, modelConfig).length === 0
+ );
+ assert(
+ wanRefEntity && isWanReferenceImageConfig(wanRefEntity.config) && wanRefEntity.config.image,
+ 'Wan 2.2 I2V requires a reference image. Add one in the Reference Images panel.'
+ );
+
+ canvasOutput = addTextToImage({ g, state, denoise, l2i });
+ const refImageField = zImageField.parse(
+ wanRefEntity.config.image.crop?.image ?? wanRefEntity.config.image.original.image
+ );
+ const refEncoder = g.addNode({
+ type: 'wan_ref_image_encoder',
+ id: getPrefixedId('wan_ref_encoder'),
+ image: refImageField,
+ width: denoise.width,
+ height: denoise.height,
+ });
+ g.addEdge(modelLoader, 'vae', refEncoder, 'vae');
+ g.addEdge(refEncoder, 'ref_image', denoise, 'ref_image');
+
+ g.upsertMetadata({ generation_mode: 'wan_i2v' });
+ } else if (generationMode === 'txt2img') {
+ canvasOutput = addTextToImage({
+ g,
+ state,
+ denoise,
+ l2i,
+ });
+ g.upsertMetadata({ generation_mode: 'wan_txt2img' });
+ } else if (generationMode === 'img2img') {
+ assert(manager !== null);
+ const i2l = g.addNode({
+ type: 'wan_i2l',
+ id: getPrefixedId('wan_i2l'),
+ });
+ canvasOutput = await addImageToImage({
+ g,
+ state,
+ manager,
+ denoise,
+ l2i,
+ i2l,
+ vaeSource: modelLoader,
+ });
+ g.upsertMetadata({ generation_mode: 'wan_img2img' });
+ } else if (generationMode === 'inpaint') {
+ assert(manager !== null);
+ const i2l = g.addNode({
+ type: 'wan_i2l',
+ id: getPrefixedId('wan_i2l'),
+ });
+ canvasOutput = await addInpaint({
+ g,
+ state,
+ manager,
+ l2i,
+ i2l,
+ denoise,
+ vaeSource: modelLoader,
+ modelLoader,
+ seed,
+ });
+ g.upsertMetadata({ generation_mode: 'wan_inpaint' });
+ } else if (generationMode === 'outpaint') {
+ assert(manager !== null);
+ const i2l = g.addNode({
+ type: 'wan_i2l',
+ id: getPrefixedId('wan_i2l'),
+ });
+ canvasOutput = await addOutpaint({
+ g,
+ state,
+ manager,
+ l2i,
+ i2l,
+ denoise,
+ vaeSource: modelLoader,
+ modelLoader,
+ seed,
+ });
+ g.upsertMetadata({ generation_mode: 'wan_outpaint' });
+ } else {
+ assert>(false);
+ }
+
+ if (state.system.shouldUseNSFWChecker) {
+ canvasOutput = addNSFWChecker(g, canvasOutput);
+ }
+ if (state.system.shouldUseWatermarker) {
+ canvasOutput = addWatermarker(g, canvasOutput);
+ }
+
+ g.updateNode(canvasOutput, selectCanvasOutputFields(state));
+
+ if (selectActiveTab(state) === 'canvas') {
+ g.upsertMetadata(selectCanvasMetadata(state));
+ }
+
+ g.setMetadataReceivingNode(canvasOutput);
+
+ return {
+ g,
+ seed,
+ positivePrompt,
+ };
+};
diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
index 28aa74db5ec..9d5f165ef78 100644
--- a/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/graph/graphBuilderUtils.ts
@@ -217,7 +217,8 @@ export const isMainModelWithoutUnet = (modelLoader: Invocation =
ColorField: { r: 0, g: 0, b: 0, a: 1 },
FloatField: 0,
ImageField: undefined,
+ VideoField: undefined,
IntegerField: 0,
ModelIdentifierField: undefined,
SchedulerField: 'dpmpp_3m_k',
diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
index adaa3f413ce..372b1f4472f 100644
--- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
+++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
@@ -24,6 +24,7 @@ import type {
StringFieldInputTemplate,
StringGeneratorFieldInputTemplate,
StylePresetFieldInputTemplate,
+ VideoFieldInputTemplate,
} from 'features/nodes/types/field';
import {
getFloatGeneratorArithmeticSequenceDefaults,
@@ -318,6 +319,20 @@ const buildImageFieldInputTemplate: FieldInputTemplateBuilder = ({
+ schemaObject,
+ baseField,
+ fieldType,
+}) => {
+ const template: VideoFieldInputTemplate = {
+ ...baseField,
+ type: fieldType,
+ default: schemaObject.default ?? undefined,
+ };
+
+ return template;
+};
+
const buildImageFieldCollectionInputTemplate: FieldInputTemplateBuilder = ({
schemaObject,
baseField,
@@ -471,6 +486,7 @@ const TEMPLATE_BUILDER_MAP: Record {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const value = useAppSelector(selectWanTransformerLowNoise);
+ const [modelConfigs, { isLoading }] = useWanGGUFLowNoiseModels();
+
+ const _onChange = useCallback(
+ (model: MainModelConfig | null) => {
+ if (model) {
+ dispatch(wanTransformerLowNoiseSelected(zModelIdentifierField.parse(model)));
+ } else {
+ dispatch(wanTransformerLowNoiseSelected(null));
+ }
+ },
+ [dispatch]
+ );
+
+ const {
+ options,
+ value: comboValue,
+ onChange,
+ noOptionsMessage,
+ } = useModelCombobox({
+ modelConfigs,
+ onChange: _onChange,
+ selectedModel: value,
+ isLoading,
+ });
+
+ return (
+
+ {t('modelManager.wanTransformerLowNoise')}
+
+
+ );
+});
+
+ParamWanTransformerLowNoiseSelect.displayName = 'ParamWanTransformerLowNoiseSelect';
+
+/**
+ * Wan 2.2 Component Source Select
+ *
+ * Picks a Diffusers Wan model whose VAE and UMT5-XXL encoder will be extracted
+ * for the workflow. Required when the main Wan model is a GGUF (since GGUF
+ * mains are transformer-only). Ignored for Diffusers mains, which carry their
+ * own VAE and encoder.
+ */
+const ParamWanComponentSourceSelect = memo(() => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const value = useAppSelector(selectWanComponentSource);
+ const [modelConfigs, { isLoading }] = useWanDiffusersModels();
+
+ const _onChange = useCallback(
+ (model: MainModelConfig | null) => {
+ if (model) {
+ dispatch(wanComponentSourceSelected(zModelIdentifierField.parse(model)));
+ } else {
+ dispatch(wanComponentSourceSelected(null));
+ }
+ },
+ [dispatch]
+ );
+
+ const {
+ options,
+ value: comboValue,
+ onChange,
+ noOptionsMessage,
+ } = useModelCombobox({
+ modelConfigs,
+ onChange: _onChange,
+ selectedModel: value,
+ isLoading,
+ });
+
+ return (
+
+ {t('modelManager.wanComponentSource')}
+
+
+ );
+});
+
+ParamWanComponentSourceSelect.displayName = 'ParamWanComponentSourceSelect';
+
+/**
+ * Wan 2.2 Standalone VAE Select
+ *
+ * Selects a standalone Wan VAE checkpoint. When set, this overrides the VAE
+ * provided by the Component Source (or the main Diffusers model).
+ */
+const ParamWanVaeModelSelect = memo(() => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const vaeModel = useAppSelector(selectWanVaeModel);
+ const [modelConfigs, { isLoading }] = useWanVAEModels();
+
+ const _onChange = useCallback(
+ (model: VAEModelConfig | null) => {
+ if (model) {
+ dispatch(wanVaeModelSelected(zModelIdentifierField.parse(model)));
+ } else {
+ dispatch(wanVaeModelSelected(null));
+ }
+ },
+ [dispatch]
+ );
+
+ const { options, value, onChange, noOptionsMessage } = useModelCombobox({
+ modelConfigs,
+ onChange: _onChange,
+ selectedModel: vaeModel,
+ isLoading,
+ });
+
+ return (
+
+ {t('modelManager.wanVae')}
+
+
+ );
+});
+
+ParamWanVaeModelSelect.displayName = 'ParamWanVaeModelSelect';
+
+/**
+ * Wan 2.2 Standalone UMT5-XXL Encoder Select
+ *
+ * Selects a standalone UMT5-XXL encoder. When set, this overrides the encoder
+ * provided by the Component Source (or the main Diffusers model).
+ */
+const ParamWanT5EncoderModelSelect = memo(() => {
+ const dispatch = useAppDispatch();
+ const { t } = useTranslation();
+ const encoderModel = useAppSelector(selectWanT5EncoderModel);
+ const [modelConfigs, { isLoading }] = useWanT5EncoderModels();
+
+ const _onChange = useCallback(
+ (model: WanT5EncoderModelConfig | null) => {
+ if (model) {
+ dispatch(wanT5EncoderModelSelected(zModelIdentifierField.parse(model)));
+ } else {
+ dispatch(wanT5EncoderModelSelected(null));
+ }
+ },
+ [dispatch]
+ );
+
+ const { options, value, onChange, noOptionsMessage } = useModelCombobox({
+ modelConfigs,
+ onChange: _onChange,
+ selectedModel: encoderModel,
+ isLoading,
+ });
+
+ return (
+
+ {t('modelManager.wanT5Encoder')}
+
+
+ );
+});
+
+ParamWanT5EncoderModelSelect.displayName = 'ParamWanT5EncoderModelSelect';
+
+/**
+ * Combined Wan 2.2 component selectors (low-noise transformer + standalone
+ * VAE + standalone T5 encoder + Component Source).
+ *
+ * Only relevant for GGUF workflows. Diffusers Wan mains have everything
+ * built in; TI2V-5B is a single-expert model with no low-noise pair. Showing
+ * these always is fine since they're optional — but the AdvancedSettingsAccordion
+ * still gates the render on `isWan` so they don't pollute other tabs.
+ */
+const ParamWanModelSelects = () => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default memo(ParamWanModelSelects);
diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamWanGuidanceScaleLowNoise.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamWanGuidanceScaleLowNoise.tsx
new file mode 100644
index 00000000000..5d3f7b3e34a
--- /dev/null
+++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamWanGuidanceScaleLowNoise.tsx
@@ -0,0 +1,94 @@
+import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel, IconButton } from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ selectWanGuidanceScaleLowNoise,
+ wanGuidanceScaleLowNoiseChanged,
+} from 'features/controlLayers/store/paramsSlice';
+import type React from 'react';
+import { memo, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiXBold } from 'react-icons/pi';
+
+// Match the primary ParamCFGScale's range so the slider thumb position is
+// visually comparable between the two CFG sliders at the same numeric value
+// (e.g. CFG=5 and CFG-Low=3 should look correct relative to each other).
+const CONSTRAINTS = {
+ initial: 3.5,
+ sliderMin: 1,
+ sliderMax: 20,
+ numberInputMin: 1,
+ numberInputMax: 200,
+ fineStep: 0.1,
+ coarseStep: 0.5,
+};
+
+const MARKS = [CONSTRAINTS.sliderMin, Math.floor(CONSTRAINTS.sliderMax / 2), CONSTRAINTS.sliderMax];
+
+/**
+ * Wan 2.2 Guidance Scale (Low Noise)
+ *
+ * Optional separate CFG for the A14B low-noise expert. When null (cleared),
+ * the denoise node falls back to the primary guidance_scale. Ignored for
+ * TI2V-5B (single-expert).
+ *
+ * Diffusers reference defaults for A14B: primary 4.0 / low-noise 3.0 — i.e.
+ * a slightly lower CFG on the detail-pass expert produces less over-sharpened
+ * output.
+ */
+const ParamWanGuidanceScaleLowNoise = () => {
+ const { t } = useTranslation();
+ const value = useAppSelector(selectWanGuidanceScaleLowNoise);
+ const dispatch = useAppDispatch();
+
+ const onChange = useCallback((v: number) => dispatch(wanGuidanceScaleLowNoiseChanged(v)), [dispatch]);
+ const onReset = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dispatch(wanGuidanceScaleLowNoiseChanged(null));
+ },
+ [dispatch]
+ );
+
+ const displayValue = value ?? CONSTRAINTS.initial;
+
+ return (
+
+
+ {t('parameters.wanGuidanceScaleLowNoise')}{' '}
+ {value !== null && (
+ }
+ onClick={onReset}
+ minW={4}
+ h={4}
+ />
+ )}
+
+
+
+
+ );
+};
+
+export default memo(ParamWanGuidanceScaleLowNoise);
diff --git a/invokeai/frontend/web/src/features/parameters/types/constants.ts b/invokeai/frontend/web/src/features/parameters/types/constants.ts
index a3ffa24cc64..1674c16009e 100644
--- a/invokeai/frontend/web/src/features/parameters/types/constants.ts
+++ b/invokeai/frontend/web/src/features/parameters/types/constants.ts
@@ -49,6 +49,10 @@ export const CLIP_SKIP_MAP: { [key in BaseModelType]?: { maxClip: number; marker
maxClip: 0,
markers: [],
},
+ wan: {
+ maxClip: 0,
+ markers: [],
+ },
};
/**
diff --git a/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts b/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts
index 2ac59a32e2b..4b2263db2f4 100644
--- a/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts
+++ b/invokeai/frontend/web/src/features/parameters/util/optimalDimension.ts
@@ -63,9 +63,16 @@ export const isInSDXLTrainingDimensions = (width: number, height: number): boole
/**
* Gets the grid size for a given base model. For Flux, the grid size is 16, otherwise it is 8.
* - sd-1, sd-2, sdxl, anima: 8
- * - flux, sd-3, qwen-image, z-image: 16
+ * - flux, sd-3, qwen-image, z-image, wan: 16
* - cogview4: 32
* - default: 8
+ *
+ * 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``; otherwise the patch
+ * round-trip produces an off-by-one and the scheduler step fails with a
+ * spatial-dim mismatch between latents and noise prediction.
+ *
* @param base The base model
* @returns The grid size for the model, defaulting to 8
*/
@@ -77,6 +84,7 @@ export const getGridSize = (base?: BaseModelType | null): number => {
case 'flux2':
case 'sd-3':
case 'qwen-image':
+ case 'wan':
case 'z-image':
return 16;
case 'sd-1':
diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts
index 68e1e9a382e..a4f74e22860 100644
--- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts
+++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts
@@ -17,6 +17,7 @@ import { buildQwenImageGraph } from 'features/nodes/util/graph/generation/buildQ
import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph';
import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph';
import { buildSDXLGraph } from 'features/nodes/util/graph/generation/buildSDXLGraph';
+import { buildWanGraph } from 'features/nodes/util/graph/generation/buildWanGraph';
import { buildZImageGraph } from 'features/nodes/util/graph/generation/buildZImageGraph';
import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils';
import type { GraphBuilderArg } from 'features/nodes/util/graph/types';
@@ -69,6 +70,8 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep
return await buildExternalGraph(graphBuilderArg);
case 'anima':
return await buildAnimaGraph(graphBuilderArg);
+ case 'wan':
+ return await buildWanGraph(graphBuilderArg);
default:
assert(false, `No graph builders for base ${base}`);
}
diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts
index 54b37e1b95e..17d062b86c0 100644
--- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts
+++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts
@@ -15,6 +15,7 @@ import { buildQwenImageGraph } from 'features/nodes/util/graph/generation/buildQ
import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph';
import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph';
import { buildSDXLGraph } from 'features/nodes/util/graph/generation/buildSDXLGraph';
+import { buildWanGraph } from 'features/nodes/util/graph/generation/buildWanGraph';
import { buildZImageGraph } from 'features/nodes/util/graph/generation/buildZImageGraph';
import type { GraphBuilderArg } from 'features/nodes/util/graph/types';
import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types';
@@ -62,6 +63,8 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => {
return await buildExternalGraph(graphBuilderArg);
case 'anima':
return await buildAnimaGraph(graphBuilderArg);
+ case 'wan':
+ return await buildWanGraph(graphBuilderArg);
default:
assert(false, `No graph builders for base ${base}`);
}
diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts
index 230fa3348d6..84b7906018b 100644
--- a/invokeai/frontend/web/src/features/queue/store/readiness.ts
+++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts
@@ -311,6 +311,19 @@ export const getReasonsWhyCannotEnqueueGenerateTab = (arg: {
}
}
+ if (model?.base === 'wan' && model.format === 'gguf_quantized') {
+ // GGUF Wan mains carry only the transformer; VAE + UMT5-XXL encoder must
+ // come from either standalone models or the Component Source (Diffusers).
+ // The low-noise A14B partner expert is optional — if omitted, the loader
+ // will use the high-noise expert for the whole schedule (lower quality
+ // but still produces an image).
+ const hasVaeSource = params.wanVaeModel !== null || params.wanComponentSource !== null;
+ const hasEncoderSource = params.wanT5EncoderModel !== null || params.wanComponentSource !== null;
+ if (!hasVaeSource || !hasEncoderSource) {
+ reasons.push({ content: i18n.t('parameters.invoke.noWanComponentSourceSelected') });
+ }
+ }
+
if (model?.base === 'z-image') {
// Check if VAE source is available (either separate VAE or Qwen3 Source)
const hasVaeSource = params.zImageVaeModel !== null || params.zImageQwen3SourceModel !== null;
@@ -774,6 +787,19 @@ export const getReasonsWhyCannotEnqueueCanvasTab = (arg: {
}
}
+ if (model?.base === 'wan' && model.format === 'gguf_quantized') {
+ // GGUF Wan mains carry only the transformer; VAE + UMT5-XXL encoder must
+ // come from either standalone models or the Component Source (Diffusers).
+ // The low-noise A14B partner expert is optional — if omitted, the loader
+ // will use the high-noise expert for the whole schedule (lower quality
+ // but still produces an image).
+ const hasVaeSource = params.wanVaeModel !== null || params.wanComponentSource !== null;
+ const hasEncoderSource = params.wanT5EncoderModel !== null || params.wanComponentSource !== null;
+ if (!hasVaeSource || !hasEncoderSource) {
+ reasons.push({ content: i18n.t('parameters.invoke.noWanComponentSourceSelected') });
+ }
+ }
+
if (model?.base === 'z-image') {
// Check if VAE source is available (either separate VAE or Qwen3 Source)
const hasVaeSource = params.zImageVaeModel !== null || params.zImageQwen3SourceModel !== null;
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx
index bfb69b945c8..312c9b71df9 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion.tsx
@@ -10,6 +10,7 @@ import {
selectIsFlux2,
selectIsQwenImage,
selectIsSD3,
+ selectIsWan,
selectIsZImage,
selectParamsSlice,
selectVAEKey,
@@ -24,6 +25,7 @@ import ParamFlux2KleinModelSelect from 'features/parameters/components/Advanced/
import ParamQwenImageComponentSourceSelect from 'features/parameters/components/Advanced/ParamQwenImageComponentSourceSelect';
import ParamQwenImageQuantization from 'features/parameters/components/Advanced/ParamQwenImageQuantization';
import ParamT5EncoderModelSelect from 'features/parameters/components/Advanced/ParamT5EncoderModelSelect';
+import ParamWanModelSelects from 'features/parameters/components/Advanced/ParamWanModelSelects';
import ParamZImageQwen3VaeModelSelect from 'features/parameters/components/Advanced/ParamZImageQwen3VaeModelSelect';
import ParamSeamlessXAxis from 'features/parameters/components/Seamless/ParamSeamlessXAxis';
import ParamSeamlessYAxis from 'features/parameters/components/Seamless/ParamSeamlessYAxis';
@@ -54,6 +56,7 @@ export const AdvancedSettingsAccordion = memo(() => {
const isExternal = useAppSelector(selectIsExternal);
const isQwenImage = useAppSelector(selectIsQwenImage);
const isAnima = useAppSelector(selectIsAnima);
+ const isWan = useAppSelector(selectIsWan);
const selectBadges = useMemo(
() =>
@@ -107,13 +110,13 @@ export const AdvancedSettingsAccordion = memo(() => {
return (
- {!isZImage && !isAnima && !isFlux2 && !isQwenImage && (
+ {!isZImage && !isAnima && !isFlux2 && !isQwenImage && !isWan && (
{isFLUX ? : }
{!isFLUX && !isSD3 && }
)}
- {!isFLUX && !isFlux2 && !isSD3 && !isZImage && !isQwenImage && !isAnima && (
+ {!isFLUX && !isFlux2 && !isSD3 && !isZImage && !isQwenImage && !isAnima && !isWan && (
<>
@@ -166,6 +169,11 @@ export const AdvancedSettingsAccordion = memo(() => {
)}
+ {isWan && (
+
+
+
+ )}
);
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx
index 220008a38b0..2ec05cd46d8 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion.tsx
@@ -13,6 +13,7 @@ import {
selectIsFlux2,
selectIsQwenImage,
selectIsSD3,
+ selectIsWan,
selectIsZImage,
selectModelSupportsGuidance,
selectModelSupportsSteps,
@@ -29,6 +30,7 @@ import ParamGuidance from 'features/parameters/components/Core/ParamGuidance';
import ParamQwenImageShift from 'features/parameters/components/Core/ParamQwenImageShift';
import ParamScheduler from 'features/parameters/components/Core/ParamScheduler';
import ParamSteps from 'features/parameters/components/Core/ParamSteps';
+import ParamWanGuidanceScaleLowNoise from 'features/parameters/components/Core/ParamWanGuidanceScaleLowNoise';
import ParamZImageScheduler from 'features/parameters/components/Core/ParamZImageScheduler';
import ParamZImageShift from 'features/parameters/components/Core/ParamZImageShift';
import ParamZImageSeedVarianceSettings from 'features/parameters/components/SeedVariance/ParamZImageSeedVarianceSettings';
@@ -55,6 +57,7 @@ export const GenerationSettingsAccordion = memo(() => {
const isExternal = useAppSelector(selectIsExternal);
const isQwenImage = useAppSelector(selectIsQwenImage);
const isAnima = useAppSelector(selectIsAnima);
+ const isWan = useAppSelector(selectIsWan);
const fluxDypePreset = useAppSelector(selectFluxDypePreset);
const modelSupportsGuidance = useAppSelector(selectModelSupportsGuidance);
const modelSupportsSteps = useAppSelector(selectModelSupportsSteps);
@@ -104,7 +107,8 @@ export const GenerationSettingsAccordion = memo(() => {
!isCogView4 &&
!isZImage &&
!isQwenImage &&
- !isAnima && }
+ !isAnima &&
+ !isWan && }
{!isExternal && (isFLUX || isFlux2) && }
{!isExternal && isZImage && }
{!isExternal && isAnima && }
@@ -114,6 +118,7 @@ export const GenerationSettingsAccordion = memo(() => {
)}
{!isExternal && !isFLUX && !isFlux2 && }
+ {!isExternal && isWan && }
{!isExternal && isZImage && }
{!isExternal && isQwenImage && }
{!isExternal && isFLUX && }
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx
index 66f76dcd153..134ab5f1e62 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx
@@ -17,7 +17,26 @@ export const MainModelPicker = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const activeTab = useAppSelector(selectActiveTab);
- const [modelConfigs] = useMainModels();
+ const [allModelConfigs] = useMainModels();
+ // Low-noise Wan GGUFs belong in the Transformer (Low Noise) slot of the
+ // Wan advanced section, not as a primary main. Filter them out of the main
+ // model dropdown so users can't accidentally wire them backwards.
+ const modelConfigs = useMemo(
+ () =>
+ allModelConfigs.filter((c) => {
+ if (
+ c.type === 'main' &&
+ c.base === 'wan' &&
+ c.format === 'gguf_quantized' &&
+ 'expert' in c &&
+ c.expert === 'low'
+ ) {
+ return false;
+ }
+ return true;
+ }),
+ [allModelConfigs]
+ );
const selectedModelConfig = useSelectedModelConfig();
const onChange = useCallback(
(modelConfig: AnyModelConfigWithExternal) => {
diff --git a/invokeai/frontend/web/src/services/api/endpoints/canvasProjects.ts b/invokeai/frontend/web/src/services/api/endpoints/canvasProjects.ts
new file mode 100644
index 00000000000..69d5e6ba455
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/endpoints/canvasProjects.ts
@@ -0,0 +1,318 @@
+import { getStore } from 'app/store/nanostores/store';
+import type { paths } from 'services/api/schema';
+import type {
+ CanvasProjectDTO,
+ ListCanvasProjectsArgs,
+ ListCanvasProjectsResponse,
+ ReplaceCanvasProjectFileArg,
+ UploadCanvasProjectArg,
+} from 'services/api/types';
+import {
+ getTagsToInvalidateForBoardAffectingMutation,
+ getTagsToInvalidateForCanvasProjectMutation,
+} from 'services/api/util/tagInvalidation';
+import stableHash from 'stable-hash';
+import type { Param0 } from 'tsafe';
+
+import { api, buildV1Url, LIST_TAG } from '..';
+
+/**
+ * Builds an endpoint URL for the canvas projects router.
+ * @example
+ * buildCanvasProjectsUrl('some-path') // 'api/v1/canvas_projects/some-path'
+ */
+const buildCanvasProjectsUrl = (path: string = '', query?: Parameters[1]) =>
+ buildV1Url(`canvas_projects/${path}`, query);
+
+/**
+ * Canvas-project RTK Query endpoints — parallel to imagesApi / videosApi. Used by the canvas
+ * save/load flows and the gallery integration.
+ */
+export const canvasProjectsApi = api.injectEndpoints({
+ endpoints: (build) => ({
+ listCanvasProjects: build.query({
+ query: (queryArgs) => ({
+ url: buildCanvasProjectsUrl('', queryArgs),
+ method: 'GET',
+ }),
+ providesTags: (result, error, queryArgs) => [
+ { type: 'CanvasProjectList', id: stableHash(queryArgs) },
+ { type: 'Board', id: queryArgs.board_id ?? 'none' },
+ 'FetchOnReconnect',
+ ],
+ async onQueryStarted(_, { dispatch, queryFulfilled }) {
+ // Pre-populate the per-project getCanvasProjectDTO cache so click-to-load feels snappy.
+ const res = await queryFulfilled;
+ const projectDTOs = res.data.items;
+ const updates: Param0 = [];
+ for (const projectDTO of projectDTOs) {
+ updates.push({
+ endpointName: 'getCanvasProjectDTO',
+ arg: projectDTO.project_name,
+ value: projectDTO,
+ });
+ }
+ dispatch(canvasProjectsApi.util.upsertQueryEntries(updates));
+ },
+ }),
+
+ getCanvasProjectDTO: build.query({
+ query: (project_name) => ({ url: buildCanvasProjectsUrl(`i/${project_name}`) }),
+ providesTags: (result, error, project_name) => [{ type: 'CanvasProject', id: project_name }],
+ }),
+
+ deleteCanvasProject: build.mutation<
+ paths['/api/v1/canvas_projects/i/{project_name}']['delete']['responses']['200']['content']['application/json'],
+ paths['/api/v1/canvas_projects/i/{project_name}']['delete']['parameters']['path']
+ >({
+ query: ({ project_name }) => ({
+ url: buildCanvasProjectsUrl(`i/${project_name}`),
+ method: 'DELETE',
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ { type: 'CanvasProjectList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ deleteCanvasProjects: build.mutation<
+ paths['/api/v1/canvas_projects/delete']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/canvas_projects/delete']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildCanvasProjectsUrl('delete'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ { type: 'CanvasProjectList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ updateCanvasProject: build.mutation<
+ paths['/api/v1/canvas_projects/i/{project_name}']['patch']['responses']['200']['content']['application/json'],
+ {
+ project_name: string;
+ changes: paths['/api/v1/canvas_projects/i/{project_name}']['patch']['requestBody']['content']['application/json'];
+ }
+ >({
+ query: ({ project_name, changes }) => ({
+ url: buildCanvasProjectsUrl(`i/${project_name}`),
+ method: 'PATCH',
+ body: changes,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation([result.project_name]),
+ ...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
+ ];
+ },
+ }),
+
+ starCanvasProjects: build.mutation<
+ paths['/api/v1/canvas_projects/star']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/canvas_projects/star']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildCanvasProjectsUrl('star'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation(result.starred_projects),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ unstarCanvasProjects: build.mutation<
+ paths['/api/v1/canvas_projects/unstar']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/canvas_projects/unstar']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildCanvasProjectsUrl('unstar'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation(result.unstarred_projects),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ uploadCanvasProject: build.mutation<
+ paths['/api/v1/canvas_projects/upload']['post']['responses']['201']['content']['application/json'],
+ UploadCanvasProjectArg
+ >({
+ query: ({ file, name, app_version, width, height, image_count, thumbnail, board_id, is_intermediate }) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('name', name);
+ formData.append('app_version', app_version);
+ formData.append('width', String(width));
+ formData.append('height', String(height));
+ formData.append('image_count', String(image_count));
+ formData.append('is_intermediate', String(is_intermediate ?? false));
+ if (thumbnail) {
+ formData.append('thumbnail', thumbnail, 'preview.webp');
+ }
+ if (board_id && board_id !== 'none') {
+ formData.append('board_id', board_id);
+ }
+ return {
+ url: buildCanvasProjectsUrl('upload'),
+ method: 'POST',
+ body: formData,
+ };
+ },
+ invalidatesTags: (result) => {
+ if (!result || result.is_intermediate) {
+ return [];
+ }
+ const boardId = result.board_id ?? 'none';
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation([result.project_name]),
+ ...getTagsToInvalidateForBoardAffectingMutation([boardId]),
+ { type: 'CanvasProjectList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ replaceCanvasProjectFile: build.mutation({
+ query: ({ project_name, file, name, app_version, width, height, image_count, thumbnail }) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('app_version', app_version);
+ formData.append('width', String(width));
+ formData.append('height', String(height));
+ formData.append('image_count', String(image_count));
+ if (name !== undefined) {
+ formData.append('name', name);
+ }
+ if (thumbnail) {
+ formData.append('thumbnail', thumbnail, 'preview.webp');
+ }
+ return {
+ url: buildCanvasProjectsUrl(`i/${project_name}/file`),
+ method: 'PUT',
+ body: formData,
+ };
+ },
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation([result.project_name]),
+ ...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
+ { type: 'CanvasProjectList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ addCanvasProjectToBoard: build.mutation<
+ paths['/api/v1/board_canvas_projects/']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/board_canvas_projects/']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildV1Url('board_canvas_projects/'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation(result.added_projects),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ removeCanvasProjectFromBoard: build.mutation<
+ paths['/api/v1/board_canvas_projects/']['delete']['responses']['200']['content']['application/json'],
+ paths['/api/v1/board_canvas_projects/']['delete']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildV1Url('board_canvas_projects/'),
+ method: 'DELETE',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForCanvasProjectMutation(result.removed_projects),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+ }),
+});
+
+export const {
+ useListCanvasProjectsQuery,
+ useGetCanvasProjectDTOQuery,
+ useUploadCanvasProjectMutation,
+ useReplaceCanvasProjectFileMutation,
+ useDeleteCanvasProjectMutation,
+ useDeleteCanvasProjectsMutation,
+ useUpdateCanvasProjectMutation,
+ useStarCanvasProjectsMutation,
+ useUnstarCanvasProjectsMutation,
+ useAddCanvasProjectToBoardMutation,
+ useRemoveCanvasProjectFromBoardMutation,
+} = canvasProjectsApi;
+
+/**
+ * Imperative helper to fetch a CanvasProjectDTO. Mirrors `getVideoDTOSafe` / `getImageDTOSafe`.
+ */
+export const getCanvasProjectDTOSafe = async (
+ project_name: string,
+ options?: Parameters[1]
+): Promise => {
+ const _options = { subscribe: false, ...options };
+ const req = getStore().dispatch(canvasProjectsApi.endpoints.getCanvasProjectDTO.initiate(project_name, _options));
+ try {
+ return await req.unwrap();
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Imperative ZIP fetch for a server-stored canvas project. Returns the raw `.invk` Blob so the
+ * caller can hand it to the existing `parseCanvasProjectZip` parser.
+ */
+export const fetchCanvasProjectZip = async (project_name: string): Promise => {
+ const response = await fetch(buildCanvasProjectsUrl(`i/${project_name}/full`));
+ if (!response.ok) {
+ throw new Error(`Failed to fetch canvas project ${project_name}: HTTP ${response.status}`);
+ }
+ return await response.blob();
+};
diff --git a/invokeai/frontend/web/src/services/api/endpoints/gallery.ts b/invokeai/frontend/web/src/services/api/endpoints/gallery.ts
new file mode 100644
index 00000000000..5b3dd85493e
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/endpoints/gallery.ts
@@ -0,0 +1,58 @@
+import type {
+ GetGalleryItemNamesArgs,
+ GetGalleryItemNamesResult,
+ ListGalleryItemsArgs,
+ ListGalleryItemsResponse,
+} from 'services/api/types';
+import { getListGalleryItemsUrl } from 'services/api/util';
+import stableHash from 'stable-hash';
+
+import { api, buildV1Url } from '..';
+
+/**
+ * Builds an endpoint URL for the gallery router.
+ * @example
+ * buildGalleryUrl('items/') // 'api/v1/gallery/items/'
+ */
+const buildGalleryUrl = (path: string = '', query?: Parameters[1]) =>
+ buildV1Url(`gallery/${path}`, query);
+
+export const galleryApi = api.injectEndpoints({
+ endpoints: (build) => ({
+ /** Paginated polymorphic stream of images + videos, sorted by created_at. */
+ listGalleryItems: build.query({
+ query: (queryArgs) => ({
+ url: getListGalleryItemsUrl(queryArgs),
+ method: 'GET',
+ }),
+ providesTags: (result, error, queryArgs) => [
+ 'GalleryItemList',
+ 'FetchOnReconnect',
+ { type: 'GalleryItemList', id: stableHash(queryArgs) },
+ { type: 'Board', id: queryArgs.board_id ?? 'none' },
+ ],
+ }),
+
+ /**
+ * Ordered (kind, name) refs for virtualized selection. The gallery grid's name list and
+ * keyboard navigation use this — the flat string list is derived by mapping items to `name`.
+ */
+ getGalleryItemNames: build.query({
+ query: (queryArgs) => ({
+ url: buildGalleryUrl('items/names', queryArgs),
+ method: 'GET',
+ }),
+ providesTags: (result, error, queryArgs) => [
+ 'GalleryItemNameList',
+ 'FetchOnReconnect',
+ { type: 'GalleryItemNameList', id: stableHash(queryArgs) },
+ ],
+ }),
+ }),
+});
+
+// useGetGalleryItemNamesQuery is consumed by use-gallery-image-names.ts.
+export const { useGetGalleryItemNamesQuery } = galleryApi;
+
+/** @knipignore Lands with the paged gallery view / future bulk-DTO consumers; not used today. */
+export const { useListGalleryItemsQuery } = galleryApi;
diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts
index 7b150ac3572..a47035e2075 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/images.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts
@@ -296,7 +296,9 @@ export const imagesApi = api.injectEndpoints({
query: ({ board_id }) => ({ url: buildBoardsUrl(board_id), method: 'DELETE' }),
invalidatesTags: () => [
{ type: 'Board', id: LIST_TAG },
- // invalidate the 'No Board' cache
+ // Both images and videos on the board cascade to the 'No Board' bucket on the
+ // backend side; invalidate the 'none' caches for both kinds so the polymorphic
+ // gallery surfaces them. The Gallery* tags refresh the unified gallery list view.
{
type: 'ImageList',
id: getListImagesUrl({
@@ -311,6 +313,10 @@ export const imagesApi = api.injectEndpoints({
categories: ASSETS_CATEGORIES,
}),
},
+ { type: 'VideoList', id: LIST_TAG },
+ 'VideoNameList',
+ 'GalleryItemList',
+ 'GalleryItemNameList',
],
}),
@@ -323,7 +329,15 @@ export const imagesApi = api.injectEndpoints({
method: 'DELETE',
params: { include_images: true },
}),
- invalidatesTags: () => [{ type: 'Board', id: LIST_TAG }],
+ // The backend now also cascade-deletes videos on the board, so the unified gallery
+ // and the video list both need invalidation in addition to the board tag.
+ invalidatesTags: () => [
+ { type: 'Board', id: LIST_TAG },
+ { type: 'VideoList', id: LIST_TAG },
+ 'VideoNameList',
+ 'GalleryItemList',
+ 'GalleryItemNameList',
+ ],
}),
addImageToBoard: build.mutation<
paths['/api/v1/board_images/']['post']['responses']['201']['content']['application/json'],
@@ -484,7 +498,6 @@ export const {
useStarImagesMutation,
useUnstarImagesMutation,
useBulkDownloadImagesMutation,
- useGetImageNamesQuery,
useGetImageDTOsByNamesMutation,
} = imagesApi;
diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts
new file mode 100644
index 00000000000..05193f812e8
--- /dev/null
+++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts
@@ -0,0 +1,332 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { getStore } from 'app/store/nanostores/store';
+import type { paths } from 'services/api/schema';
+import type {
+ GetVideoNamesArgs,
+ GetVideoNamesResult,
+ ListVideosArgs,
+ ListVideosResponse,
+ UploadVideoArg,
+ VideoDTO,
+} from 'services/api/types';
+import { getListVideosUrl } from 'services/api/util';
+import {
+ getTagsToInvalidateForBoardAffectingMutation,
+ getTagsToInvalidateForVideoMutation,
+} from 'services/api/util/tagInvalidation';
+import stableHash from 'stable-hash';
+import type { Param0 } from 'tsafe';
+import type { JsonObject } from 'type-fest';
+
+import { api, buildV1Url, LIST_TAG } from '..';
+
+/**
+ * Builds an endpoint URL for the videos router.
+ * @example
+ * buildVideosUrl('some-path') // 'api/v1/videos/some-path'
+ */
+const buildVideosUrl = (path: string = '', query?: Parameters[1]) =>
+ buildV1Url(`videos/${path}`, query);
+
+/**
+ * Video RTK Query endpoints — parallel to imagesApi. Used by the gallery (Phase 4) and the
+ * viewer / linear flows that land in later phases.
+ */
+export const videosApi = api.injectEndpoints({
+ endpoints: (build) => ({
+ /**
+ * List videos (paginated). Used directly when a video-only view is needed; the gallery
+ * itself uses the polymorphic /gallery/items/ endpoint.
+ */
+ listVideos: build.query({
+ query: (queryArgs) => ({
+ url: getListVideosUrl(queryArgs),
+ method: 'GET',
+ }),
+ providesTags: (result, error, queryArgs) => [
+ { type: 'VideoList', id: stableHash(queryArgs) },
+ { type: 'Board', id: queryArgs.board_id ?? 'none' },
+ 'FetchOnReconnect',
+ ],
+ async onQueryStarted(_, { dispatch, queryFulfilled }) {
+ // Pre-populate the per-video getVideoDTO cache so selection feels snappy.
+ const res = await queryFulfilled;
+ const videoDTOs = res.data.items;
+ const updates: Param0 = [];
+ for (const videoDTO of videoDTOs) {
+ updates.push({
+ endpointName: 'getVideoDTO',
+ arg: videoDTO.video_name,
+ value: videoDTO,
+ });
+ }
+ dispatch(videosApi.util.upsertQueryEntries(updates));
+ },
+ }),
+
+ getVideoDTO: build.query({
+ query: (video_name) => ({ url: buildVideosUrl(`i/${video_name}`) }),
+ providesTags: (result, error, video_name) => [{ type: 'Video', id: video_name }],
+ }),
+
+ getVideoMetadata: build.query({
+ query: (video_name) => ({ url: buildVideosUrl(`i/${video_name}/metadata`) }),
+ providesTags: (result, error, video_name) => [{ type: 'VideoMetadata', id: video_name }],
+ }),
+
+ getVideoNames: build.query({
+ query: (queryArgs) => ({
+ url: buildVideosUrl('names', queryArgs),
+ method: 'GET',
+ }),
+ providesTags: (result, error, queryArgs) => [
+ 'VideoNameList',
+ 'FetchOnReconnect',
+ { type: 'VideoNameList', id: stableHash(queryArgs) },
+ ],
+ }),
+
+ deleteVideo: build.mutation<
+ paths['/api/v1/videos/i/{video_name}']['delete']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/i/{video_name}']['delete']['parameters']['path']
+ >({
+ query: ({ video_name }) => ({
+ url: buildVideosUrl(`i/${video_name}`),
+ method: 'DELETE',
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ { type: 'VideoList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ deleteVideos: build.mutation<
+ paths['/api/v1/videos/delete']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/delete']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildVideosUrl('delete'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ { type: 'VideoList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ /** Toggle a video's is_intermediate flag. */
+ changeVideoIsIntermediate: build.mutation<
+ paths['/api/v1/videos/i/{video_name}']['patch']['responses']['200']['content']['application/json'],
+ { video_name: string; is_intermediate: boolean }
+ >({
+ query: ({ video_name, is_intermediate }) => ({
+ url: buildVideosUrl(`i/${video_name}`),
+ method: 'PATCH',
+ body: { is_intermediate },
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForVideoMutation([result.video_name]),
+ ...getTagsToInvalidateForBoardAffectingMutation([result.board_id ?? 'none']),
+ ];
+ },
+ }),
+
+ starVideos: build.mutation<
+ paths['/api/v1/videos/star']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/star']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildVideosUrl('star'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForVideoMutation(result.starred_videos),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ unstarVideos: build.mutation<
+ paths['/api/v1/videos/unstar']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/unstar']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildVideosUrl('unstar'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForVideoMutation(result.unstarred_videos),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ uploadVideo: build.mutation<
+ paths['/api/v1/videos/upload']['post']['responses']['201']['content']['application/json'],
+ UploadVideoArg
+ >({
+ query: ({ file, video_category, is_intermediate, session_id, board_id, metadata }) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ if (metadata) {
+ formData.append('metadata', JSON.stringify(metadata));
+ }
+ return {
+ url: buildVideosUrl('upload'),
+ method: 'POST',
+ body: formData,
+ params: {
+ video_category,
+ is_intermediate,
+ session_id,
+ board_id: board_id === 'none' ? undefined : board_id,
+ },
+ };
+ },
+ invalidatesTags: (result) => {
+ if (!result || result.is_intermediate) {
+ return [];
+ }
+ const boardId = result.board_id ?? 'none';
+ return [
+ ...getTagsToInvalidateForVideoMutation([result.video_name]),
+ ...getTagsToInvalidateForBoardAffectingMutation([boardId]),
+ { type: 'VideoList', id: LIST_TAG },
+ ];
+ },
+ }),
+
+ addVideoToBoard: build.mutation<
+ paths['/api/v1/videos/board']['post']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/board']['post']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildVideosUrl('board'),
+ method: 'POST',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForVideoMutation(result.added_videos),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+
+ removeVideoFromBoard: build.mutation<
+ paths['/api/v1/videos/board']['delete']['responses']['200']['content']['application/json'],
+ paths['/api/v1/videos/board']['delete']['requestBody']['content']['application/json']
+ >({
+ query: (body) => ({
+ url: buildVideosUrl('board'),
+ method: 'DELETE',
+ body,
+ }),
+ invalidatesTags: (result) => {
+ if (!result) {
+ return [];
+ }
+ return [
+ ...getTagsToInvalidateForVideoMutation(result.removed_videos),
+ ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards),
+ ];
+ },
+ }),
+ }),
+});
+
+export const {
+ useUploadVideoMutation,
+ useGetVideoDTOQuery,
+ useStarVideosMutation,
+ useUnstarVideosMutation,
+ useDeleteVideoMutation,
+ useAddVideoToBoardMutation,
+ useRemoveVideoFromBoardMutation,
+} = videosApi;
+
+/** @knipignore Reserved for follow-up phases (bulk delete / intermediate toggle / video-only views). */
+export const {
+ useListVideosQuery,
+ useGetVideoMetadataQuery,
+ useGetVideoNamesQuery,
+ useDeleteVideosMutation,
+ useChangeVideoIsIntermediateMutation,
+} = videosApi;
+
+/**
+ * Imperative helper to fetch a VideoDTO. Mirrors `getImageDTOSafe`.
+ */
+export const getVideoDTOSafe = async (
+ video_name: string,
+ options?: Parameters[1]
+): Promise => {
+ const _options = { subscribe: false, ...options };
+ const req = getStore().dispatch(videosApi.endpoints.getVideoDTO.initiate(video_name, _options));
+ try {
+ return await req.unwrap();
+ } catch {
+ return null;
+ }
+};
+
+/** @knipignore Multi-phase rollout; consumed by Phase 5 viewer code. */
+export const getVideoDTO = (
+ video_name: string,
+ options?: Parameters[1]
+): Promise => {
+ const _options = { subscribe: false, ...options };
+ const req = getStore().dispatch(videosApi.endpoints.getVideoDTO.initiate(video_name, _options));
+ return req.unwrap();
+};
+
+/** @knipignore Multi-phase rollout; imperative form consumed by Phase 6 invocations. */
+export const uploadVideo = (arg: UploadVideoArg): Promise => {
+ const { dispatch } = getStore();
+ const req = dispatch(videosApi.endpoints.uploadVideo.initiate(arg, { track: false }));
+ return req.unwrap();
+};
+
+export const uploadVideos = async (args: UploadVideoArg[]): Promise => {
+ const { dispatch } = getStore();
+ const results = await Promise.allSettled(
+ args.map((arg) => {
+ const req = dispatch(videosApi.endpoints.uploadVideo.initiate(arg, { track: false }));
+ return req.unwrap();
+ })
+ );
+ return results.filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled').map((r) => r.value);
+};
+
+export const useVideoDTO = (videoName: string | null | undefined) => {
+ const { currentData: videoDTO } = useGetVideoDTOQuery(videoName ?? skipToken);
+ return videoDTO ?? null;
+};
diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
index bd1ac088138..73e65b984f7 100644
--- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
+++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts
@@ -37,6 +37,10 @@ import {
isTextLLMModelConfig,
isTIModelConfig,
isVAEModelConfigOrSubmodel,
+ isWanDiffusersMainModelConfig,
+ isWanGGUFLowNoiseMainModelConfig,
+ isWanT5EncoderModelConfig,
+ isWanVAEModelConfig,
isZImageDiffusersMainModelConfig,
} from 'services/api/types';
@@ -111,6 +115,10 @@ export const useQwenImageDiffusersModels = () => buildModelsHook(isQwenImageDiff
export const useQwenImageVAEModels = () => buildModelsHook(isQwenImageVAEModelConfig)();
export const useQwenVLEncoderModels = () => buildModelsHook(isQwenVLEncoderModelConfig)();
export const useQwen3EncoderModels = () => buildModelsHook(isQwen3EncoderModelConfig)();
+export const useWanDiffusersModels = () => buildModelsHook(isWanDiffusersMainModelConfig)();
+export const useWanGGUFLowNoiseModels = () => buildModelsHook(isWanGGUFLowNoiseMainModelConfig)();
+export const useWanVAEModels = () => buildModelsHook(isWanVAEModelConfig)();
+export const useWanT5EncoderModels = () => buildModelsHook(isWanT5EncoderModelConfig)();
export const useGlobalReferenceImageModels = buildModelsHook(
(config) => isIPAdapterModelConfig(config) || isFluxReduxModelConfig(config) || isFluxKontextModelConfig(config)
);
@@ -154,5 +162,8 @@ export const selectFlux2DiffusersModels = buildModelsSelector(isFlux2DiffusersMa
export const selectFluxVAEModels = buildModelsSelector(isFluxVAEModelConfig);
export const selectAnimaVAEModels = buildModelsSelector(isAnimaVAEModelConfig);
export const selectT5EncoderModels = buildModelsSelector(isT5EncoderModelConfigOrSubmodel);
+export const selectWanDiffusersModels = buildModelsSelector(isWanDiffusersMainModelConfig);
+export const selectWanVAEModels = buildModelsSelector(isWanVAEModelConfig);
+export const selectWanT5EncoderModels = buildModelsSelector(isWanT5EncoderModelConfig);
export const useTextLLMModels = () => buildModelsHook(isTextLLMModelConfig)();
export const useLlavaModels = () => buildModelsHook(isLLaVAModelConfig)();
diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts
index a586273f3a7..3664517e2f2 100644
--- a/invokeai/frontend/web/src/services/api/index.ts
+++ b/invokeai/frontend/web/src/services/api/index.ts
@@ -62,6 +62,19 @@ const tagTypes = [
'UserList',
'CustomNodePacks',
'VirtualBoards',
+ // Video tags (parallel to Image tags).
+ 'Video',
+ 'VideoList',
+ 'VideoMetadata',
+ 'VideoNameList',
+ 'BoardVideosTotal',
+ // Canvas project tags (parallel to Image/Video tags).
+ 'CanvasProject',
+ 'CanvasProjectList',
+ 'BoardCanvasProjectsTotal',
+ // Polymorphic gallery list (images + videos interleaved by created_at).
+ 'GalleryItemList',
+ 'GalleryItemNameList',
] as const;
export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>;
export const LIST_TAG = 'LIST';
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 68f24a26ec1..864f8dbce35 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -1361,6 +1361,475 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/api/v1/videos/upload": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Upload Video
+ * @description Uploads a video for the current user.
+ */
+ post: operations["upload_video"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/i/{video_name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Video Dto */
+ get: operations["get_video_dto"];
+ put?: never;
+ post?: never;
+ /** Delete Video */
+ delete: operations["delete_video"];
+ options?: never;
+ head?: never;
+ /** Update Video */
+ patch: operations["update_video"];
+ trace?: never;
+ };
+ "/api/v1/videos/delete": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Delete Videos From List */
+ post: operations["delete_videos_from_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/i/{video_name}/metadata": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Video Metadata */
+ get: operations["get_video_metadata"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/i/{video_name}/full": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Video Full
+ * @description Serves the video file with HTTP Range support so HTML5 seek/scrub works.
+ *
+ * Like the image equivalent, this endpoint is intentionally unauthenticated because browsers
+ * load videos via tags which cannot send Bearer tokens. Video names are UUIDs,
+ * providing security through unguessability.
+ */
+ get: operations["get_video_full"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ /**
+ * Get Video Full
+ * @description Serves the video file with HTTP Range support so HTML5 seek/scrub works.
+ *
+ * Like the image equivalent, this endpoint is intentionally unauthenticated because browsers
+ * load videos via tags which cannot send Bearer tokens. Video names are UUIDs,
+ * providing security through unguessability.
+ */
+ head: operations["get_video_full_head"];
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/i/{video_name}/thumbnail": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Video Thumbnail
+ * @description Returns the first-frame WebP thumbnail of a video. Unauthenticated; UUIDs provide unguessability.
+ */
+ get: operations["get_video_thumbnail"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/i/{video_name}/urls": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Video Urls */
+ get: operations["get_video_urls"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Video Dtos
+ * @description Gets a list of video DTOs for the current user.
+ */
+ get: operations["list_video_dtos"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/names": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Video Names
+ * @description Gets ordered list of video names with metadata for optimistic updates.
+ */
+ get: operations["get_video_names"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/star": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Star Videos In List */
+ post: operations["star_videos_in_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/unstar": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Unstar Videos In List */
+ post: operations["unstar_videos_in_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/videos/board": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Add Video To Board */
+ post: operations["add_video_to_board"];
+ /** Remove Video From Board */
+ delete: operations["remove_video_from_board"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/upload": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Upload Canvas Project
+ * @description Uploads a canvas project ZIP for the current user, optionally placing it on a board.
+ */
+ post: operations["upload_canvas_project"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/i/{project_name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Canvas Project Dto */
+ get: operations["get_canvas_project_dto"];
+ put?: never;
+ post?: never;
+ /** Delete Canvas Project */
+ delete: operations["delete_canvas_project"];
+ options?: never;
+ head?: never;
+ /** Update Canvas Project */
+ patch: operations["update_canvas_project"];
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/delete": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Delete Canvas Projects From List */
+ post: operations["delete_canvas_projects_from_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/i/{project_name}/file": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Replace Canvas Project File
+ * @description Replaces the on-disk ZIP and thumbnail for an existing canvas project. Keeps project_name,
+ * board assignment, starred state, ownership. Updates width/height/image_count/app_version and
+ * `has_thumbnail` (when a thumbnail is supplied). Optionally renames via the `name` form field.
+ */
+ put: operations["replace_canvas_project_file"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/star": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Star Canvas Projects In List */
+ post: operations["star_canvas_projects_in_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/unstar": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Unstar Canvas Projects In List */
+ post: operations["unstar_canvas_projects_in_list"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/i/{project_name}/full": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Canvas Project Full
+ * @description Serves the canvas project ZIP (.invk).
+ *
+ * Like the image/video equivalents, this endpoint is intentionally unauthenticated so the
+ * browser can fetch it via standard download flow. Project names are UUIDs, providing
+ * security through unguessability.
+ */
+ get: operations["get_canvas_project_full"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/i/{project_name}/thumbnail": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Canvas Project Thumbnail
+ * @description Serves the canvas project preview thumbnail (WebP). Unauthenticated for the same reason
+ * as `get_canvas_project_full` — project names are UUIDs.
+ */
+ get: operations["get_canvas_project_thumbnail"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/canvas_projects/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Canvas Project Dtos
+ * @description Lists canvas project DTOs with pagination and filtering.
+ */
+ get: operations["list_canvas_project_dtos"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/board_canvas_projects/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Add Canvas Project To Board */
+ post: operations["add_canvas_project_to_board"];
+ /** Remove Canvas Project From Board */
+ delete: operations["remove_canvas_project_from_board"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/gallery/items/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Gallery Items
+ * @description Returns a paginated, time-sorted stream of polymorphic gallery items (images + videos).
+ */
+ get: operations["list_gallery_items"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/gallery/items/names": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Gallery Item Names
+ * @description Returns an ordered (kind, name) list — used to drive virtualized gallery selection.
+ */
+ get: operations["get_gallery_item_names"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/v1/boards/": {
parameters: {
query?: never;
@@ -2790,6 +3259,19 @@ export type paths = {
export type webhooks = Record;
export type components = {
schemas: {
+ /** AddCanvasProjectsToBoardResult */
+ AddCanvasProjectsToBoardResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Added Projects
+ * @description The project names that were added to the board
+ */
+ added_projects: string[];
+ };
/** AddImagesToBoardResult */
AddImagesToBoardResult: {
/**
@@ -2844,6 +3326,19 @@ export type components = {
*/
type: "add";
};
+ /** AddVideosToBoardResult */
+ AddVideosToBoardResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Added Videos
+ * @description The video names that were added to the board
+ */
+ added_videos: string[];
+ };
/**
* AdminUserCreateRequest
* @description Request body for admin to create a new user.
@@ -3558,7 +4053,7 @@ export type components = {
*/
type: "anima_text_encoder";
};
- AnyModelConfig: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ AnyModelConfig: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
/**
* AppVersion
* @description App Version Response
@@ -3710,7 +4205,7 @@ export type components = {
* fallback/null value `BaseModelType.Any` for these models, instead of making the model base optional.
* @enum {string}
*/
- BaseModelType: "any" | "sd-1" | "sd-2" | "sd-3" | "sdxl" | "sdxl-refiner" | "flux" | "flux2" | "cogview4" | "z-image" | "external" | "qwen-image" | "anima" | "unknown";
+ BaseModelType: "any" | "sd-1" | "sd-2" | "sd-3" | "sdxl" | "sdxl-refiner" | "flux" | "flux2" | "cogview4" | "z-image" | "external" | "qwen-image" | "anima" | "wan" | "unknown";
/** Batch */
Batch: {
/**
@@ -4091,6 +4586,19 @@ export type components = {
* @enum {string}
*/
BoardVisibility: "private" | "shared" | "public";
+ /** Body_add_canvas_project_to_board */
+ Body_add_canvas_project_to_board: {
+ /**
+ * Board Id
+ * @description The id of the board to add the project to
+ */
+ board_id: string;
+ /**
+ * Project Name
+ * @description The name of the canvas project to add
+ */
+ project_name: string;
+ };
/** Body_add_image_to_board */
Body_add_image_to_board: {
/**
@@ -4161,6 +4669,14 @@ export type components = {
/** @description The workflow to create */
workflow: components["schemas"]["WorkflowWithoutID"];
};
+ /** Body_delete_canvas_projects_from_list */
+ Body_delete_canvas_projects_from_list: {
+ /**
+ * Project Names
+ * @description The list of canvas project names to delete
+ */
+ project_names: string[];
+ };
/** Body_delete_images_from_list */
Body_delete_images_from_list: {
/**
@@ -4169,6 +4685,14 @@ export type components = {
*/
image_names: string[];
};
+ /** Body_delete_videos_from_list */
+ Body_delete_videos_from_list: {
+ /**
+ * Video Names
+ * @description The list of names of videos to delete
+ */
+ video_names: string[];
+ };
/** Body_do_hf_login */
Body_do_hf_login: {
/**
@@ -4276,6 +4800,14 @@ export type components = {
*/
seed?: number | null;
};
+ /** Body_remove_canvas_project_from_board */
+ Body_remove_canvas_project_from_board: {
+ /**
+ * Project Name
+ * @description The name of the canvas project to remove from its board
+ */
+ project_name: string;
+ };
/** Body_remove_image_from_board */
Body_remove_image_from_board: {
/**
@@ -4292,6 +4824,53 @@ export type components = {
*/
image_names: string[];
};
+ /** Body_remove_video_from_board */
+ Body_remove_video_from_board: {
+ /**
+ * Video Name
+ * @description The name of the video to remove from its board
+ */
+ video_name: string;
+ };
+ /** Body_replace_canvas_project_file */
+ Body_replace_canvas_project_file: {
+ /**
+ * File
+ * Format: binary
+ * @description The new canvas project ZIP (.invk) file
+ */
+ file: Blob;
+ /**
+ * Name
+ * @description Optional new user-facing project name
+ */
+ name?: string | null;
+ /**
+ * App Version
+ * @description The InvokeAI app version captured at save time
+ */
+ app_version: string;
+ /**
+ * Width
+ * @description The bbox width at save time
+ */
+ width: number;
+ /**
+ * Height
+ * @description The bbox height at save time
+ */
+ height: number;
+ /**
+ * Image Count
+ * @description The number of embedded image files
+ */
+ image_count: number;
+ /**
+ * Thumbnail
+ * @description Optional new WebP thumbnail
+ */
+ thumbnail?: Blob | null;
+ };
/** Body_set_workflow_thumbnail */
Body_set_workflow_thumbnail: {
/**
@@ -4301,6 +4880,14 @@ export type components = {
*/
image: Blob;
};
+ /** Body_star_canvas_projects_in_list */
+ Body_star_canvas_projects_in_list: {
+ /**
+ * Project Names
+ * @description The list of canvas project names to star
+ */
+ project_names: string[];
+ };
/** Body_star_images_in_list */
Body_star_images_in_list: {
/**
@@ -4309,6 +4896,22 @@ export type components = {
*/
image_names: string[];
};
+ /** Body_star_videos_in_list */
+ Body_star_videos_in_list: {
+ /**
+ * Video Names
+ * @description The list of names of videos to star
+ */
+ video_names: string[];
+ };
+ /** Body_unstar_canvas_projects_in_list */
+ Body_unstar_canvas_projects_in_list: {
+ /**
+ * Project Names
+ * @description The list of canvas project names to unstar
+ */
+ project_names: string[];
+ };
/** Body_unstar_images_in_list */
Body_unstar_images_in_list: {
/**
@@ -4317,6 +4920,14 @@ export type components = {
*/
image_names: string[];
};
+ /** Body_unstar_videos_in_list */
+ Body_unstar_videos_in_list: {
+ /**
+ * Video Names
+ * @description The list of names of videos to unstar
+ */
+ video_names: string[];
+ };
/** Body_update_model_image */
Body_update_model_image: {
/**
@@ -4351,6 +4962,56 @@ export type components = {
*/
is_public: boolean;
};
+ /** Body_upload_canvas_project */
+ Body_upload_canvas_project: {
+ /**
+ * File
+ * Format: binary
+ * @description The canvas project ZIP (.invk) file
+ */
+ file: Blob;
+ /**
+ * Name
+ * @description The user-facing project name
+ */
+ name: string;
+ /**
+ * App Version
+ * @description The InvokeAI app version captured at save time
+ */
+ app_version: string;
+ /**
+ * Width
+ * @description The bbox width at save time
+ */
+ width: number;
+ /**
+ * Height
+ * @description The bbox height at save time
+ */
+ height: number;
+ /**
+ * Image Count
+ * @description The number of embedded image files
+ */
+ image_count: number;
+ /**
+ * Thumbnail
+ * @description Optional preview WebP thumbnail
+ */
+ thumbnail?: Blob | null;
+ /**
+ * Board Id
+ * @description Optional board to attach the project to
+ */
+ board_id?: string | null;
+ /**
+ * Is Intermediate
+ * @description Whether this is an intermediate project
+ * @default false
+ */
+ is_intermediate?: boolean;
+ };
/** Body_upload_image */
Body_upload_image: {
/**
@@ -4369,6 +5030,19 @@ export type components = {
*/
metadata?: string | null;
};
+ /** Body_upload_video */
+ Body_upload_video: {
+ /**
+ * File
+ * Format: binary
+ */
+ file: Blob;
+ /**
+ * Metadata
+ * @description The metadata to associate with the video, must be a stringified JSON dict
+ */
+ metadata?: string | null;
+ };
/**
* Boolean Collection Primitive
* @description A collection of boolean primitive values
@@ -5556,182 +6230,299 @@ export type components = {
type: "canvas_paste_back";
};
/**
- * Canvas V2 Mask and Crop
- * @description Handles Canvas V2 image output masking and cropping
+ * CanvasProjectDTO
+ * @description Deserialized canvas project record, enriched for the frontend.
*/
- CanvasV2MaskAndCropInvocation: {
+ CanvasProjectDTO: {
/**
- * @description The board to save the image to
- * @default null
+ * Project Name
+ * @description The unique name (ID) of the canvas project.
*/
- board?: components["schemas"]["BoardField"] | null;
+ project_name: string;
/**
- * @description Optional metadata to be saved with the image
- * @default null
+ * Project Url
+ * @description The URL of the canvas project ZIP file (.invk).
*/
- metadata?: components["schemas"]["MetadataField"] | null;
+ project_url: string;
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
- */
- id: string;
- /**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
+ * Thumbnail Url
+ * @description The URL of the canvas project's preview thumbnail (WebP), if any.
*/
- is_intermediate?: boolean;
+ thumbnail_url?: string | null;
+ /** @description The origin of the canvas project. */
+ project_origin: components["schemas"]["ResourceOrigin"];
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * Name
+ * @description The user-facing display name of the project.
*/
- use_cache?: boolean;
+ name: string;
/**
- * @description The source image onto which the masked generated image is pasted. If omitted, the masked generated image is returned with transparency.
- * @default null
+ * App Version
+ * @description The InvokeAI app version this project was saved under.
*/
- source_image?: components["schemas"]["ImageField"] | null;
+ app_version: string;
/**
- * @description The image to apply the mask to
- * @default null
+ * Width
+ * @description The bbox width of the canvas at save time.
*/
- generated_image?: components["schemas"]["ImageField"] | null;
+ width: number;
/**
- * @description The mask to apply
- * @default null
+ * Height
+ * @description The bbox height of the canvas at save time.
*/
- mask?: components["schemas"]["ImageField"] | null;
+ height: number;
/**
- * Mask Blur
- * @description The amount to blur the mask by
- * @default 0
+ * Image Count
+ * @description The number of images embedded in the project ZIP.
*/
- mask_blur?: number;
+ image_count: number;
/**
- * type
- * @default canvas_v2_mask_and_crop
- * @constant
+ * Has Thumbnail
+ * @description Whether the project has a preview thumbnail on disk.
*/
- type: "canvas_v2_mask_and_crop";
- };
- /**
- * Center Pad or Crop Image
- * @description Pad or crop an image's sides from the center by specified pixels. Positive values are outside of the image.
- */
- CenterPadCropInvocation: {
+ has_thumbnail: boolean;
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ * Starred
+ * @description Whether this project is starred.
*/
- id: string;
+ starred: boolean;
/**
* Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
- */
- is_intermediate?: boolean;
- /**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * @description Whether this is an intermediate project (almost always False).
*/
- use_cache?: boolean;
+ is_intermediate: boolean;
/**
- * @description The image to crop
- * @default null
+ * User Id
+ * @description The id of the user that owns this project.
*/
- image?: components["schemas"]["ImageField"] | null;
+ user_id: string;
/**
- * Left
- * @description Number of pixels to pad/crop from the left (negative values crop inwards, positive values pad outwards)
- * @default 0
+ * Project Subfolder
+ * @description The subfolder where the project is stored on disk.
+ * @default
*/
- left?: number;
+ project_subfolder?: string;
/**
- * Right
- * @description Number of pixels to pad/crop from the right (negative values crop inwards, positive values pad outwards)
- * @default 0
+ * Created At
+ * @description The created timestamp of the project.
*/
- right?: number;
+ created_at: string;
/**
- * Top
- * @description Number of pixels to pad/crop from the top (negative values crop inwards, positive values pad outwards)
- * @default 0
+ * Updated At
+ * @description The updated timestamp of the project.
*/
- top?: number;
+ updated_at: string;
/**
- * Bottom
- * @description Number of pixels to pad/crop from the bottom (negative values crop inwards, positive values pad outwards)
- * @default 0
+ * Deleted At
+ * @description The deleted timestamp of the project.
*/
- bottom?: number;
+ deleted_at?: string | null;
/**
- * type
- * @default img_pad_crop
- * @constant
+ * Board Id
+ * @description The id of the board the canvas project belongs to, if one exists.
*/
- type: "img_pad_crop";
+ board_id?: string | null;
};
/**
- * Classification
- * @description The classification of an Invocation.
- * - `Stable`: The invocation, including its inputs/outputs and internal logic, is stable. You may build workflows with it, having confidence that they will not break because of a change in this invocation.
- * - `Beta`: The invocation is not yet stable, but is planned to be stable in the future. Workflows built around this invocation may break, but we are committed to supporting this invocation long-term.
- * - `Prototype`: The invocation is not yet stable and may be removed from the application at any time. Workflows built around this invocation may break, and we are *not* committed to supporting this invocation.
- * - `Deprecated`: The invocation is deprecated and may be removed in a future version.
- * - `Internal`: The invocation is not intended for use by end-users. It may be changed or removed at any time, but is exposed for users to play with.
- * - `Special`: The invocation is a special case and does not fit into any of the other classifications.
- * @enum {string}
+ * CanvasProjectRecordChanges
+ * @description Allowed mutations on a canvas project record.
*/
- Classification: "stable" | "beta" | "prototype" | "deprecated" | "internal" | "special";
- /**
- * ClearResult
- * @description Result of clearing the session queue
- */
- ClearResult: {
+ CanvasProjectRecordChanges: {
/**
- * Deleted
- * @description Number of queue items deleted
+ * Name
+ * @description The project's new display name.
*/
- deleted: number;
- };
- /**
- * ClipVariantType
- * @description Variant type.
- * @enum {string}
- */
- ClipVariantType: "large" | "gigantic";
- /**
- * CogView4ConditioningField
- * @description A conditioning tensor primitive value
- */
- CogView4ConditioningField: {
+ name?: string | null;
/**
- * Conditioning Name
- * @description The name of conditioning tensor
+ * Starred
+ * @description The project's new starred state.
*/
- conditioning_name: string;
- };
- /**
- * CogView4ConditioningOutput
- * @description Base class for nodes that output a CogView text conditioning tensor.
- */
- CogView4ConditioningOutput: {
- /** @description Conditioning tensor */
- conditioning: components["schemas"]["CogView4ConditioningField"];
+ starred?: boolean | null;
/**
- * type
- * @default cogview4_conditioning_output
- * @constant
+ * Is Intermediate
+ * @description The project's new is_intermediate flag.
*/
- type: "cogview4_conditioning_output";
+ is_intermediate?: boolean | null;
+ } & {
+ [key: string]: unknown;
};
/**
- * Denoise - CogView4
- * @description Run the denoising process with a CogView4 model.
+ * Canvas V2 Mask and Crop
+ * @description Handles Canvas V2 image output masking and cropping
*/
- CogView4DenoiseInvocation: {
+ CanvasV2MaskAndCropInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The source image onto which the masked generated image is pasted. If omitted, the masked generated image is returned with transparency.
+ * @default null
+ */
+ source_image?: components["schemas"]["ImageField"] | null;
+ /**
+ * @description The image to apply the mask to
+ * @default null
+ */
+ generated_image?: components["schemas"]["ImageField"] | null;
+ /**
+ * @description The mask to apply
+ * @default null
+ */
+ mask?: components["schemas"]["ImageField"] | null;
+ /**
+ * Mask Blur
+ * @description The amount to blur the mask by
+ * @default 0
+ */
+ mask_blur?: number;
+ /**
+ * type
+ * @default canvas_v2_mask_and_crop
+ * @constant
+ */
+ type: "canvas_v2_mask_and_crop";
+ };
+ /**
+ * Center Pad or Crop Image
+ * @description Pad or crop an image's sides from the center by specified pixels. Positive values are outside of the image.
+ */
+ CenterPadCropInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The image to crop
+ * @default null
+ */
+ image?: components["schemas"]["ImageField"] | null;
+ /**
+ * Left
+ * @description Number of pixels to pad/crop from the left (negative values crop inwards, positive values pad outwards)
+ * @default 0
+ */
+ left?: number;
+ /**
+ * Right
+ * @description Number of pixels to pad/crop from the right (negative values crop inwards, positive values pad outwards)
+ * @default 0
+ */
+ right?: number;
+ /**
+ * Top
+ * @description Number of pixels to pad/crop from the top (negative values crop inwards, positive values pad outwards)
+ * @default 0
+ */
+ top?: number;
+ /**
+ * Bottom
+ * @description Number of pixels to pad/crop from the bottom (negative values crop inwards, positive values pad outwards)
+ * @default 0
+ */
+ bottom?: number;
+ /**
+ * type
+ * @default img_pad_crop
+ * @constant
+ */
+ type: "img_pad_crop";
+ };
+ /**
+ * Classification
+ * @description The classification of an Invocation.
+ * - `Stable`: The invocation, including its inputs/outputs and internal logic, is stable. You may build workflows with it, having confidence that they will not break because of a change in this invocation.
+ * - `Beta`: The invocation is not yet stable, but is planned to be stable in the future. Workflows built around this invocation may break, but we are committed to supporting this invocation long-term.
+ * - `Prototype`: The invocation is not yet stable and may be removed from the application at any time. Workflows built around this invocation may break, and we are *not* committed to supporting this invocation.
+ * - `Deprecated`: The invocation is deprecated and may be removed in a future version.
+ * - `Internal`: The invocation is not intended for use by end-users. It may be changed or removed at any time, but is exposed for users to play with.
+ * - `Special`: The invocation is a special case and does not fit into any of the other classifications.
+ * @enum {string}
+ */
+ Classification: "stable" | "beta" | "prototype" | "deprecated" | "internal" | "special";
+ /**
+ * ClearResult
+ * @description Result of clearing the session queue
+ */
+ ClearResult: {
+ /**
+ * Deleted
+ * @description Number of queue items deleted
+ */
+ deleted: number;
+ };
+ /**
+ * ClipVariantType
+ * @description Variant type.
+ * @enum {string}
+ */
+ ClipVariantType: "large" | "gigantic";
+ /**
+ * CogView4ConditioningField
+ * @description A conditioning tensor primitive value
+ */
+ CogView4ConditioningField: {
+ /**
+ * Conditioning Name
+ * @description The name of conditioning tensor
+ */
+ conditioning_name: string;
+ };
+ /**
+ * CogView4ConditioningOutput
+ * @description Base class for nodes that output a CogView text conditioning tensor.
+ */
+ CogView4ConditioningOutput: {
+ /** @description Conditioning tensor */
+ conditioning: components["schemas"]["CogView4ConditioningField"];
+ /**
+ * type
+ * @default cogview4_conditioning_output
+ * @constant
+ */
+ type: "cogview4_conditioning_output";
+ };
+ /**
+ * Denoise - CogView4
+ * @description Run the denoising process with a CogView4 model.
+ */
+ CogView4DenoiseInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -7548,7 +8339,7 @@ export type components = {
* @description The generation mode that output this image
* @default null
*/
- generation_mode?: ("txt2img" | "img2img" | "inpaint" | "outpaint" | "sdxl_txt2img" | "sdxl_img2img" | "sdxl_inpaint" | "sdxl_outpaint" | "flux_txt2img" | "flux_img2img" | "flux_inpaint" | "flux_outpaint" | "flux2_txt2img" | "flux2_img2img" | "flux2_inpaint" | "flux2_outpaint" | "sd3_txt2img" | "sd3_img2img" | "sd3_inpaint" | "sd3_outpaint" | "cogview4_txt2img" | "cogview4_img2img" | "cogview4_inpaint" | "cogview4_outpaint" | "z_image_txt2img" | "z_image_img2img" | "z_image_inpaint" | "z_image_outpaint" | "qwen_image_txt2img" | "qwen_image_img2img" | "qwen_image_inpaint" | "qwen_image_outpaint" | "anima_txt2img" | "anima_img2img" | "anima_inpaint" | "anima_outpaint") | null;
+ generation_mode?: ("txt2img" | "img2img" | "inpaint" | "outpaint" | "sdxl_txt2img" | "sdxl_img2img" | "sdxl_inpaint" | "sdxl_outpaint" | "flux_txt2img" | "flux_img2img" | "flux_inpaint" | "flux_outpaint" | "flux2_txt2img" | "flux2_img2img" | "flux2_inpaint" | "flux2_outpaint" | "sd3_txt2img" | "sd3_img2img" | "sd3_inpaint" | "sd3_outpaint" | "cogview4_txt2img" | "cogview4_img2img" | "cogview4_inpaint" | "cogview4_outpaint" | "z_image_txt2img" | "z_image_img2img" | "z_image_inpaint" | "z_image_outpaint" | "qwen_image_txt2img" | "qwen_image_img2img" | "qwen_image_inpaint" | "qwen_image_outpaint" | "anima_txt2img" | "anima_img2img" | "anima_inpaint" | "anima_outpaint" | "wan_txt2img" | "wan_img2img" | "wan_inpaint" | "wan_outpaint" | "wan_i2v") | null;
/**
* Positive Prompt
* @description The positive prompt parameter
@@ -8180,6 +8971,16 @@ export type components = {
* @description The names of the images that were deleted.
*/
deleted_images: string[];
+ /**
+ * Deleted Board Videos
+ * @description The video names of the board-videos relationships that were deleted.
+ */
+ deleted_board_videos?: string[];
+ /**
+ * Deleted Videos
+ * @description The names of the videos that were deleted.
+ */
+ deleted_videos?: string[];
};
/**
* DeleteByDestinationResult
@@ -8192,6 +8993,19 @@ export type components = {
*/
deleted: number;
};
+ /** DeleteCanvasProjectsResult */
+ DeleteCanvasProjectsResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Deleted Projects
+ * @description The names of the canvas projects that were deleted
+ */
+ deleted_projects: string[];
+ };
/** DeleteImagesResult */
DeleteImagesResult: {
/**
@@ -8234,6 +9048,19 @@ export type components = {
[key: string]: string;
};
};
+ /** DeleteVideosResult */
+ DeleteVideosResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Deleted Videos
+ * @description The names of the videos that were deleted
+ */
+ deleted_videos: string[];
+ };
/**
* Denoise - SD1.5, SDXL
* @description Denoises noisy latents to decodable images
@@ -12039,6 +12866,118 @@ export type components = {
*/
type: "freeu";
};
+ /**
+ * GalleryItem
+ * @description A gallery item — image, video or canvas project, with shared fields and a discriminator.
+ *
+ * Frontend code should dispatch on `kind` to render kind-specific UI.
+ */
+ GalleryItem: {
+ /** @description Whether the item is an image, video or canvas project. */
+ kind: components["schemas"]["GalleryItemKind"];
+ /**
+ * Name
+ * @description The unique name of the image, video or canvas project.
+ */
+ name: string;
+ /**
+ * Full Url
+ * @description URL to the full-resolution image PNG, video MP4 or canvas project .invk ZIP.
+ */
+ full_url: string;
+ /**
+ * Thumbnail Url
+ * @description URL to the static (WebP) thumbnail.
+ */
+ thumbnail_url: string;
+ /**
+ * Width
+ * @description The width of the item in pixels.
+ */
+ width: number;
+ /**
+ * Height
+ * @description The height of the item in pixels.
+ */
+ height: number;
+ /** @description The category of the item (canvas projects always GENERAL). */
+ category: components["schemas"]["ImageCategory"];
+ /**
+ * Starred
+ * @description Whether the item is starred.
+ */
+ starred: boolean;
+ /**
+ * Is Intermediate
+ * @description Whether the item is an intermediate output.
+ */
+ is_intermediate: boolean;
+ /**
+ * Board Id
+ * @description Owning board id, if any.
+ */
+ board_id?: string | null;
+ /**
+ * Created At
+ * @description The created timestamp of the item.
+ */
+ created_at: string;
+ /**
+ * Duration
+ * @description Video duration in seconds. None for non-videos.
+ */
+ duration?: number | null;
+ /**
+ * Fps
+ * @description Video frames per second. None for non-videos.
+ */
+ fps?: number | null;
+ /**
+ * Image Count
+ * @description Number of embedded images in a canvas project. None for non-projects.
+ */
+ image_count?: number | null;
+ };
+ /**
+ * GalleryItemKind
+ * @description Discriminator for polymorphic gallery items.
+ * @enum {string}
+ */
+ GalleryItemKind: "image" | "video" | "canvas_project";
+ /**
+ * GalleryItemNamesResult
+ * @description Ordered list of gallery item references plus counts for optimistic UI.
+ */
+ GalleryItemNamesResult: {
+ /**
+ * Items
+ * @description Ordered list of (kind, name) references.
+ */
+ items: components["schemas"]["GalleryItemRef"][];
+ /**
+ * Starred Count
+ * @description Number of starred items (when starred_first=True).
+ */
+ starred_count: number;
+ /**
+ * Total Count
+ * @description Total number of items matching the query.
+ */
+ total_count: number;
+ };
+ /**
+ * GalleryItemRef
+ * @description A thin reference to a gallery item — used for ordered name lists.
+ */
+ GalleryItemRef: {
+ /** @description Whether the item is an image, video or canvas project. */
+ kind: components["schemas"]["GalleryItemKind"];
+ /**
+ * Name
+ * @description The unique name of the image, video or canvas project.
+ */
+ name: string;
+ };
/**
* Gemini Image Generation
* @description Generate images using a Gemini-hosted external model.
@@ -12253,7 +13192,7 @@ export type components = {
* @description The nodes in this graph
*/
nodes?: {
- [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
};
/**
* Edges
@@ -12290,7 +13229,7 @@ export type components = {
* @description The results of node executions
*/
results: {
- [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* Errors
@@ -15651,7 +16590,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15661,7 +16600,7 @@ export type components = {
* Result
* @description The result of the invocation
*/
- result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
+ result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"];
};
/**
* InvocationErrorEvent
@@ -15715,7 +16654,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -15982,6 +16921,20 @@ export type components = {
unsharp_mask: components["schemas"]["ImageOutput"];
unsharp_mask_oklab: components["schemas"]["ImageOutput"];
vae_loader: components["schemas"]["VAEOutput"];
+ video: components["schemas"]["VideoOutput"];
+ video_concat: components["schemas"]["VideoOutput"];
+ video_frame_extract: components["schemas"]["ImageOutput"];
+ wan_denoise: components["schemas"]["LatentsOutput"];
+ wan_i2l: components["schemas"]["LatentsOutput"];
+ wan_i2v_ideal_dimensions: components["schemas"]["IdealSizeOutput"];
+ wan_l2i: components["schemas"]["ImageOutput"];
+ wan_l2v: components["schemas"]["VideoOutput"];
+ wan_lora_collection_loader: components["schemas"]["WanLoRALoaderOutput"];
+ wan_lora_loader: components["schemas"]["WanLoRALoaderOutput"];
+ wan_model_loader: components["schemas"]["WanModelLoaderOutput"];
+ wan_ref_image_encoder: components["schemas"]["WanRefImageOutput"];
+ wan_text_encoder: components["schemas"]["WanConditioningOutput"];
+ wan_video_denoise: components["schemas"]["LatentsOutput"];
z_image_control: components["schemas"]["ZImageControlOutput"];
z_image_denoise: components["schemas"]["LatentsOutput"];
z_image_denoise_meta: components["schemas"]["LatentsMetaOutput"];
@@ -16045,7 +16998,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -16120,7 +17073,7 @@ export type components = {
* Invocation
* @description The ID of the invocation
*/
- invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
+ invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"];
/**
* Invocation Source Id
* @description The ID of the prepared invocation's source node
@@ -19021,6 +19974,104 @@ export type components = {
*/
base: "sdxl";
};
+ /**
+ * LoRA_LyCORIS_Wan_Config
+ * @description Model config for Wan 2.2 LoRA models in LyCORIS format.
+ *
+ * Wan LoRAs target ``WanTransformer3DModel`` blocks. The Wan 2.2 A14B family
+ * is dual-expert (high-noise + low-noise) — LoRAs are typically trained
+ * against one expert. ``expert`` records which one so the model loader
+ * invocation can wire it to the correct ``loras`` / ``loras_low_noise`` list.
+ * Many LoRAs are expert-agnostic (TI2V-5B family, or community LoRAs that
+ * just don't tag the expert) — these get ``expert=None`` and are applied to
+ * both experts by default.
+ */
+ LoRA_LyCORIS_Wan_Config: {
+ /**
+ * Key
+ * @description A unique key for this model.
+ */
+ key: string;
+ /**
+ * Hash
+ * @description The hash of the model file(s).
+ */
+ hash: string;
+ /**
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
+ */
+ path: string;
+ /**
+ * File Size
+ * @description The size of the model in bytes.
+ */
+ file_size: number;
+ /**
+ * Name
+ * @description Name of the model.
+ */
+ name: string;
+ /**
+ * Description
+ * @description Model description
+ */
+ description: string | null;
+ /**
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
+ */
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
+ /**
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
+ */
+ source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
+ /**
+ * Cover Image
+ * @description Url for image to preview model
+ */
+ cover_image: string | null;
+ /**
+ * Type
+ * @default lora
+ * @constant
+ */
+ type: "lora";
+ /**
+ * Trigger Phrases
+ * @description Set of trigger phrases for this model
+ */
+ trigger_phrases: string[] | null;
+ /** @description Default settings for this model */
+ default_settings: components["schemas"]["LoraModelDefaultSettings"] | null;
+ /**
+ * Format
+ * @default lycoris
+ * @constant
+ */
+ format: "lycoris";
+ /**
+ * Base
+ * @default wan
+ * @constant
+ */
+ base: "wan";
+ /**
+ * Expert
+ * @description For Wan 2.2 A14B dual-expert LoRAs: 'high' targets the high-noise expert, 'low' targets the low-noise expert. None means the LoRA is expert-agnostic (TI2V-5B, or community LoRAs without explicit tagging) and is applied to both.
+ */
+ expert: ("high" | "low") | null;
+ /** @description The Wan model family this LoRA targets, detected from its inner-dim (5120 -> A14B, 3072 -> TI2V-5B). A14B LoRAs are incompatible with TI2V-5B mains (and vice versa) — they crash with a shape mismatch in the layer patcher. The linear-view graph builder filters LoRAs on variant when building the LoRA collection. None means the LoRA's inner-dim couldn't be identified. */
+ variant: components["schemas"]["WanLoRAVariantType"] | null;
+ };
/**
* LoRA_LyCORIS_ZImage_Config
* @description Model config for Z-Image LoRA models in LyCORIS format.
@@ -21097,6 +22148,107 @@ export type components = {
*/
base: "sdxl";
};
+ /**
+ * Main_Diffusers_Wan_Config
+ * @description Model config for Wan 2.2 diffusers models.
+ *
+ * Covers both the dual-expert T2V-A14B family and the single-transformer TI2V-5B
+ * family. Variant is detected from the on-disk transformer config (latent channel
+ * count) plus the presence of a sibling ``transformer_2/`` directory.
+ */
+ Main_Diffusers_Wan_Config: {
+ /**
+ * Key
+ * @description A unique key for this model.
+ */
+ key: string;
+ /**
+ * Hash
+ * @description The hash of the model file(s).
+ */
+ hash: string;
+ /**
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
+ */
+ path: string;
+ /**
+ * File Size
+ * @description The size of the model in bytes.
+ */
+ file_size: number;
+ /**
+ * Name
+ * @description Name of the model.
+ */
+ name: string;
+ /**
+ * Description
+ * @description Model description
+ */
+ description: string | null;
+ /**
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
+ */
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
+ /**
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
+ */
+ source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
+ /**
+ * Cover Image
+ * @description Url for image to preview model
+ */
+ cover_image: string | null;
+ /**
+ * Type
+ * @default main
+ * @constant
+ */
+ type: "main";
+ /**
+ * Trigger Phrases
+ * @description Set of trigger phrases for this model
+ */
+ trigger_phrases: string[] | null;
+ /** @description Default settings for this model */
+ default_settings: components["schemas"]["MainModelDefaultSettings"] | null;
+ /**
+ * Format
+ * @default diffusers
+ * @constant
+ */
+ format: "diffusers";
+ /** @default */
+ repo_variant: components["schemas"]["ModelRepoVariant"];
+ /**
+ * Base
+ * @default wan
+ * @constant
+ */
+ base: "wan";
+ variant: components["schemas"]["WanVariantType"];
+ /**
+ * Has Dual Expert
+ * @description Whether this model ships two transformer experts (Wan 2.2 A14B MoE). False for TI2V-5B.
+ * @default false
+ */
+ has_dual_expert: boolean;
+ /**
+ * Boundary Ratio
+ * @description MoE expert switch point as a fraction of num_train_timesteps (typically 1000). None for single-transformer models. Read from model_index.json by Diffusers' WanPipeline.
+ */
+ boundary_ratio: number | null;
+ };
/**
* Main_Diffusers_ZImage_Config
* @description Model config for Z-Image diffusers models (Z-Image-Turbo, Z-Image-Base).
@@ -21451,10 +22603,14 @@ export type components = {
variant: components["schemas"]["QwenImageVariantType"] | null;
};
/**
- * Main_GGUF_ZImage_Config
- * @description Model config for GGUF-quantized Z-Image transformer models.
+ * Main_GGUF_Wan_Config
+ * @description Model config for GGUF-quantized Wan 2.2 transformer models.
+ *
+ * A14B's MoE ships as two GGUF files (one per expert); ``expert`` records
+ * which one this is so the model loader invocation can pair them. TI2V-5B
+ * is a single-transformer model and stores ``expert='none'``.
*/
- Main_GGUF_ZImage_Config: {
+ Main_GGUF_Wan_Config: {
/**
* Key
* @description A unique key for this model.
@@ -21527,72 +22683,168 @@ export type components = {
config_path: string | null;
/**
* Base
- * @default z-image
+ * @default wan
* @constant
*/
- base: "z-image";
+ base: "wan";
/**
* Format
* @default gguf_quantized
* @constant
*/
format: "gguf_quantized";
- variant: components["schemas"]["ZImageVariantType"];
+ variant: components["schemas"]["WanVariantType"];
+ /**
+ * Expert
+ * @description For Wan 2.2 A14B's dual-expert MoE: 'high' for the high-noise expert, 'low' for the low-noise expert. 'none' for single-transformer models (TI2V-5B).
+ * @default none
+ * @enum {string}
+ */
+ expert: "high" | "low" | "none";
};
/**
- * Combine Masks
- * @description Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`.
+ * Main_GGUF_ZImage_Config
+ * @description Model config for GGUF-quantized Z-Image transformer models.
*/
- MaskCombineInvocation: {
+ Main_GGUF_ZImage_Config: {
/**
- * @description The board to save the image to
- * @default null
+ * Key
+ * @description A unique key for this model.
*/
- board?: components["schemas"]["BoardField"] | null;
+ key: string;
/**
- * @description Optional metadata to be saved with the image
- * @default null
+ * Hash
+ * @description The hash of the model file(s).
*/
- metadata?: components["schemas"]["MetadataField"] | null;
+ hash: string;
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
*/
- id: string;
+ path: string;
/**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
+ * File Size
+ * @description The size of the model in bytes.
*/
- is_intermediate?: boolean;
+ file_size: number;
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * Name
+ * @description Name of the model.
*/
- use_cache?: boolean;
+ name: string;
/**
- * @description The first mask to combine
- * @default null
+ * Description
+ * @description Model description
*/
- mask1?: components["schemas"]["ImageField"] | null;
+ description: string | null;
/**
- * @description The second image to combine
- * @default null
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
*/
- mask2?: components["schemas"]["ImageField"] | null;
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
/**
- * type
- * @default mask_combine
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
+ */
+ source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
+ /**
+ * Cover Image
+ * @description Url for image to preview model
+ */
+ cover_image: string | null;
+ /**
+ * Type
+ * @default main
* @constant
*/
- type: "mask_combine";
+ type: "main";
+ /**
+ * Trigger Phrases
+ * @description Set of trigger phrases for this model
+ */
+ trigger_phrases: string[] | null;
+ /** @description Default settings for this model */
+ default_settings: components["schemas"]["MainModelDefaultSettings"] | null;
+ /**
+ * Config Path
+ * @description Path to the config for this model, if any.
+ */
+ config_path: string | null;
+ /**
+ * Base
+ * @default z-image
+ * @constant
+ */
+ base: "z-image";
+ /**
+ * Format
+ * @default gguf_quantized
+ * @constant
+ */
+ format: "gguf_quantized";
+ variant: components["schemas"]["ZImageVariantType"];
};
/**
- * Mask Edge
- * @description Applies an edge mask to an image
+ * Combine Masks
+ * @description Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`.
*/
- MaskEdgeInvocation: {
+ MaskCombineInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The first mask to combine
+ * @default null
+ */
+ mask1?: components["schemas"]["ImageField"] | null;
+ /**
+ * @description The second image to combine
+ * @default null
+ */
+ mask2?: components["schemas"]["ImageField"] | null;
+ /**
+ * type
+ * @default mask_combine
+ * @constant
+ */
+ type: "mask_combine";
+ };
+ /**
+ * Mask Edge
+ * @description Applies an edge mask to an image
+ */
+ MaskEdgeInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -23230,7 +24482,7 @@ export type components = {
* @description Storage format of model.
* @enum {string}
*/
- ModelFormat: "omi" | "diffusers" | "checkpoint" | "lycoris" | "onnx" | "olive" | "embedding_file" | "embedding_folder" | "invokeai" | "t5_encoder" | "qwen3_encoder" | "qwen_vl_encoder" | "bnb_quantized_int8b" | "bnb_quantized_nf4b" | "gguf_quantized" | "external_api" | "unknown";
+ ModelFormat: "omi" | "diffusers" | "checkpoint" | "lycoris" | "onnx" | "olive" | "embedding_file" | "embedding_folder" | "invokeai" | "t5_encoder" | "qwen3_encoder" | "qwen_vl_encoder" | "wan_t5_encoder" | "bnb_quantized_int8b" | "bnb_quantized_nf4b" | "gguf_quantized" | "external_api" | "unknown";
/** ModelIdentifierField */
ModelIdentifierField: {
/**
@@ -23367,7 +24619,7 @@ export type components = {
* Config
* @description The installed model's config
*/
- config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
};
/**
* ModelInstallDownloadProgressEvent
@@ -23533,7 +24785,7 @@ export type components = {
* Config Out
* @description After successful installation, this will hold the configuration object.
*/
- config_out?: (components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"]) | null;
+ config_out?: (components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"]) | null;
/**
* Inplace
* @description Leave model in its current location; otherwise install under models directory
@@ -23619,7 +24871,7 @@ export type components = {
* Config
* @description The model's config
*/
- config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
/**
* @description The submodel type, if any
* @default null
@@ -23640,7 +24892,7 @@ export type components = {
* Config
* @description The model's config
*/
- config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ config: components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
/**
* @description The submodel type, if any
* @default null
@@ -23766,7 +25018,7 @@ export type components = {
* Variant
* @description The variant of the model.
*/
- variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
+ variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["WanVariantType"] | components["schemas"]["WanLoRAVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
/** @description The prediction type of the model. */
prediction_type?: components["schemas"]["SchedulerPredictionType"] | null;
/**
@@ -23824,7 +25076,7 @@ export type components = {
* @description Model type.
* @enum {string}
*/
- ModelType: "onnx" | "main" | "vae" | "lora" | "control_lora" | "controlnet" | "embedding" | "ip_adapter" | "clip_vision" | "clip_embed" | "t2i_adapter" | "t5_encoder" | "qwen3_encoder" | "qwen_vl_encoder" | "spandrel_image_to_image" | "siglip" | "flux_redux" | "llava_onevision" | "text_llm" | "external_image_generator" | "unknown";
+ ModelType: "onnx" | "main" | "vae" | "lora" | "control_lora" | "controlnet" | "embedding" | "ip_adapter" | "clip_vision" | "clip_embed" | "t2i_adapter" | "t5_encoder" | "qwen3_encoder" | "qwen_vl_encoder" | "wan_t5_encoder" | "spandrel_image_to_image" | "siglip" | "flux_redux" | "llava_onevision" | "text_llm" | "external_image_generator" | "unknown";
/**
* ModelVariantType
* @description Variant type.
@@ -23837,7 +25089,7 @@ export type components = {
*/
ModelsList: {
/** Models */
- models: (components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"])[];
+ models: (components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"])[];
};
/**
* Multiply Integers
@@ -24084,8 +25336,8 @@ export type components = {
*/
items: components["schemas"]["BoardDTO"][];
};
- /** OffsetPaginatedResults[ImageDTO] */
- OffsetPaginatedResults_ImageDTO_: {
+ /** OffsetPaginatedResults[CanvasProjectDTO] */
+ OffsetPaginatedResults_CanvasProjectDTO_: {
/**
* Limit
* @description Limit of items to get
@@ -24105,119 +25357,82 @@ export type components = {
* Items
* @description Items
*/
- items: components["schemas"]["ImageDTO"][];
+ items: components["schemas"]["CanvasProjectDTO"][];
};
- /**
- * Unsharp Mask (Oklab)
- * @description Applies an unsharp mask filter to an image in the Oklab color space
- */
- OklabUnsharpMaskInvocation: {
- /**
- * @description The board to save the image to
- * @default null
- */
- board?: components["schemas"]["BoardField"] | null;
- /**
- * @description Optional metadata to be saved with the image
- * @default null
- */
- metadata?: components["schemas"]["MetadataField"] | null;
- /**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
- */
- id: string;
- /**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
- */
- is_intermediate?: boolean;
+ /** OffsetPaginatedResults[GalleryItem] */
+ OffsetPaginatedResults_GalleryItem_: {
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
- */
- use_cache?: boolean;
- /**
- * @description The image to use
- * @default null
+ * Limit
+ * @description Limit of items to get
*/
- image?: components["schemas"]["ImageField"] | null;
+ limit: number;
/**
- * Radius
- * @description Unsharp mask radius
- * @default 2
+ * Offset
+ * @description Offset from which to retrieve items
*/
- radius?: number;
+ offset: number;
/**
- * Strength
- * @description Unsharp mask strength
- * @default 50
+ * Total
+ * @description Total number of items in result
*/
- strength?: number;
+ total: number;
/**
- * type
- * @default unsharp_mask_oklab
- * @constant
+ * Items
+ * @description Items
*/
- type: "unsharp_mask_oklab";
+ items: components["schemas"]["GalleryItem"][];
};
- /**
- * Adjust Image Hue (Oklch)
- * @description Adjusts the hue of an image in Oklch space.
- */
- OklchImageHueAdjustmentInvocation: {
+ /** OffsetPaginatedResults[ImageDTO] */
+ OffsetPaginatedResults_ImageDTO_: {
/**
- * @description The board to save the image to
- * @default null
+ * Limit
+ * @description Limit of items to get
*/
- board?: components["schemas"]["BoardField"] | null;
+ limit: number;
/**
- * @description Optional metadata to be saved with the image
- * @default null
+ * Offset
+ * @description Offset from which to retrieve items
*/
- metadata?: components["schemas"]["MetadataField"] | null;
+ offset: number;
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ * Total
+ * @description Total number of items in result
*/
- id: string;
+ total: number;
/**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
+ * Items
+ * @description Items
*/
- is_intermediate?: boolean;
+ items: components["schemas"]["ImageDTO"][];
+ };
+ /** OffsetPaginatedResults[VideoDTO] */
+ OffsetPaginatedResults_VideoDTO_: {
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * Limit
+ * @description Limit of items to get
*/
- use_cache?: boolean;
+ limit: number;
/**
- * @description The image to adjust
- * @default null
+ * Offset
+ * @description Offset from which to retrieve items
*/
- image?: components["schemas"]["ImageField"] | null;
+ offset: number;
/**
- * Hue
- * @description The degrees by which to rotate the hue, 0-360
- * @default 0
+ * Total
+ * @description Total number of items in result
*/
- hue?: number;
+ total: number;
/**
- * type
- * @default img_hue_adjust_oklch
- * @constant
+ * Items
+ * @description Items
*/
- type: "img_hue_adjust_oklch";
+ items: components["schemas"]["VideoDTO"][];
};
/**
- * OpenAI Image Generation
- * @description Generate images using an OpenAI-hosted external model.
+ * Unsharp Mask (Oklab)
+ * @description Applies an unsharp mask filter to an image in the Oklab color space
*/
- OpenAIImageGenerationInvocation: {
+ OklabUnsharpMaskInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -24246,147 +25461,253 @@ export type components = {
*/
use_cache?: boolean;
/**
- * @description Main model (UNet, VAE, CLIP) to load
- * @default null
- */
- model?: components["schemas"]["ModelIdentifierField"] | null;
- /**
- * Mode
- * @description Generation mode.
- * @default txt2img
- * @enum {string}
- */
- mode?: "txt2img" | "img2img" | "inpaint";
- /**
- * Prompt
- * @description Prompt
- * @default null
- */
- prompt?: string | null;
- /**
- * Seed
- * @description Seed for random number generation
- * @default null
- */
- seed?: number | null;
- /**
- * Num Images
- * @description Number of images to generate
- * @default 1
- */
- num_images?: number;
- /**
- * Width
- * @description Width of output (px)
- * @default 1024
- */
- width?: number;
- /**
- * Height
- * @description Height of output (px)
- * @default 1024
- */
- height?: number;
- /**
- * Image Size
- * @description Image size preset (e.g. 1K, 2K, 4K)
- * @default null
- */
- image_size?: string | null;
- /**
- * @description Init image (use reference_images instead)
- * @default null
- */
- init_image?: components["schemas"]["ImageField"] | null;
- /**
- * @description Mask image for inpaint
+ * @description The image to use
* @default null
*/
- mask_image?: components["schemas"]["ImageField"] | null;
- /**
- * Reference Images
- * @description Reference images
- * @default []
- */
- reference_images?: components["schemas"]["ImageField"][];
- /**
- * Quality
- * @description Output image quality
- * @default auto
- * @enum {string}
- */
- quality?: "auto" | "high" | "medium" | "low";
+ image?: components["schemas"]["ImageField"] | null;
/**
- * Background
- * @description Background transparency handling
- * @default auto
- * @enum {string}
+ * Radius
+ * @description Unsharp mask radius
+ * @default 2
*/
- background?: "auto" | "transparent" | "opaque";
+ radius?: number;
/**
- * Input Fidelity
- * @description Fidelity to source images (edits only)
- * @default null
+ * Strength
+ * @description Unsharp mask strength
+ * @default 50
*/
- input_fidelity?: ("low" | "high") | null;
+ strength?: number;
/**
* type
- * @default openai_image_generation
+ * @default unsharp_mask_oklab
* @constant
*/
- type: "openai_image_generation";
- };
- /**
- * OrphanedModelInfo
- * @description Information about an orphaned model directory.
- */
- OrphanedModelInfo: {
- /**
- * Path
- * @description Relative path to the orphaned directory from models root
- */
- path: string;
- /**
- * Absolute Path
- * @description Absolute path to the orphaned directory
- */
- absolute_path: string;
- /**
- * Files
- * @description List of model files in this directory
- */
- files: string[];
- /**
- * Size Bytes
- * @description Total size of all files in bytes
- */
- size_bytes: number;
- };
- /**
- * OutputFieldJSONSchemaExtra
- * @description Extra attributes to be added to input fields and their OpenAPI schema. Used by the workflow editor
- * during schema parsing and UI rendering.
- */
- OutputFieldJSONSchemaExtra: {
- field_kind: components["schemas"]["FieldKind"];
- /**
- * Ui Hidden
- * @default false
- */
- ui_hidden: boolean;
- /**
- * Ui Order
- * @default null
- */
- ui_order: number | null;
- /** @default null */
- ui_type: components["schemas"]["UIType"] | null;
+ type: "unsharp_mask_oklab";
};
/**
- * PBR Maps
- * @description Generate Normal, Displacement and Roughness Map from a given image
+ * Adjust Image Hue (Oklch)
+ * @description Adjusts the hue of an image in Oklch space.
*/
- PBRMapsInvocation: {
+ OklchImageHueAdjustmentInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The image to adjust
+ * @default null
+ */
+ image?: components["schemas"]["ImageField"] | null;
+ /**
+ * Hue
+ * @description The degrees by which to rotate the hue, 0-360
+ * @default 0
+ */
+ hue?: number;
+ /**
+ * type
+ * @default img_hue_adjust_oklch
+ * @constant
+ */
+ type: "img_hue_adjust_oklch";
+ };
+ /**
+ * OpenAI Image Generation
+ * @description Generate images using an OpenAI-hosted external model.
+ */
+ OpenAIImageGenerationInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description Main model (UNet, VAE, CLIP) to load
+ * @default null
+ */
+ model?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Mode
+ * @description Generation mode.
+ * @default txt2img
+ * @enum {string}
+ */
+ mode?: "txt2img" | "img2img" | "inpaint";
+ /**
+ * Prompt
+ * @description Prompt
+ * @default null
+ */
+ prompt?: string | null;
+ /**
+ * Seed
+ * @description Seed for random number generation
+ * @default null
+ */
+ seed?: number | null;
+ /**
+ * Num Images
+ * @description Number of images to generate
+ * @default 1
+ */
+ num_images?: number;
+ /**
+ * Width
+ * @description Width of output (px)
+ * @default 1024
+ */
+ width?: number;
+ /**
+ * Height
+ * @description Height of output (px)
+ * @default 1024
+ */
+ height?: number;
+ /**
+ * Image Size
+ * @description Image size preset (e.g. 1K, 2K, 4K)
+ * @default null
+ */
+ image_size?: string | null;
+ /**
+ * @description Init image (use reference_images instead)
+ * @default null
+ */
+ init_image?: components["schemas"]["ImageField"] | null;
+ /**
+ * @description Mask image for inpaint
+ * @default null
+ */
+ mask_image?: components["schemas"]["ImageField"] | null;
+ /**
+ * Reference Images
+ * @description Reference images
+ * @default []
+ */
+ reference_images?: components["schemas"]["ImageField"][];
+ /**
+ * Quality
+ * @description Output image quality
+ * @default auto
+ * @enum {string}
+ */
+ quality?: "auto" | "high" | "medium" | "low";
+ /**
+ * Background
+ * @description Background transparency handling
+ * @default auto
+ * @enum {string}
+ */
+ background?: "auto" | "transparent" | "opaque";
+ /**
+ * Input Fidelity
+ * @description Fidelity to source images (edits only)
+ * @default null
+ */
+ input_fidelity?: ("low" | "high") | null;
+ /**
+ * type
+ * @default openai_image_generation
+ * @constant
+ */
+ type: "openai_image_generation";
+ };
+ /**
+ * OrphanedModelInfo
+ * @description Information about an orphaned model directory.
+ */
+ OrphanedModelInfo: {
+ /**
+ * Path
+ * @description Relative path to the orphaned directory from models root
+ */
+ path: string;
+ /**
+ * Absolute Path
+ * @description Absolute path to the orphaned directory
+ */
+ absolute_path: string;
+ /**
+ * Files
+ * @description List of model files in this directory
+ */
+ files: string[];
+ /**
+ * Size Bytes
+ * @description Total size of all files in bytes
+ */
+ size_bytes: number;
+ };
+ /**
+ * OutputFieldJSONSchemaExtra
+ * @description Extra attributes to be added to input fields and their OpenAPI schema. Used by the workflow editor
+ * during schema parsing and UI rendering.
+ */
+ OutputFieldJSONSchemaExtra: {
+ field_kind: components["schemas"]["FieldKind"];
+ /**
+ * Ui Hidden
+ * @default false
+ */
+ ui_hidden: boolean;
+ /**
+ * Ui Order
+ * @default null
+ */
+ ui_order: number | null;
+ /** @default null */
+ ui_type: components["schemas"]["UIType"] | null;
+ };
+ /**
+ * PBR Maps
+ * @description Generate Normal, Displacement and Roughness Map from a given image
+ */
+ PBRMapsInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -26473,6 +27794,19 @@ export type components = {
*/
sha256?: string | null;
};
+ /** RemoveCanvasProjectsFromBoardResult */
+ RemoveCanvasProjectsFromBoardResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Removed Projects
+ * @description The project names that were removed from their board
+ */
+ removed_projects: string[];
+ };
/** RemoveImagesFromBoardResult */
RemoveImagesFromBoardResult: {
/**
@@ -26486,6 +27820,19 @@ export type components = {
*/
removed_images: string[];
};
+ /** RemoveVideosFromBoardResult */
+ RemoveVideosFromBoardResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Removed Videos
+ * @description The video names that were removed from their board
+ */
+ removed_videos: string[];
+ };
/**
* Resize Latents
* @description Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8.
@@ -28464,6 +29811,19 @@ export type components = {
*/
format: "checkpoint";
};
+ /** StarredCanvasProjectsResult */
+ StarredCanvasProjectsResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Starred Projects
+ * @description The names of the canvas projects that were starred
+ */
+ starred_projects: string[];
+ };
/** StarredImagesResult */
StarredImagesResult: {
/**
@@ -28477,6 +29837,19 @@ export type components = {
*/
starred_images: string[];
};
+ /** StarredVideosResult */
+ StarredVideosResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Starred Videos
+ * @description The names of the videos that were starred
+ */
+ starred_videos: string[];
+ };
/** StarterModel */
StarterModel: {
/** Description */
@@ -28489,7 +29862,7 @@ export type components = {
type: components["schemas"]["ModelType"];
format?: components["schemas"]["ModelFormat"] | null;
/** Variant */
- variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
+ variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["WanVariantType"] | components["schemas"]["WanLoRAVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
/**
* Is Installed
* @default false
@@ -28534,7 +29907,7 @@ export type components = {
type: components["schemas"]["ModelType"];
format?: components["schemas"]["ModelFormat"] | null;
/** Variant */
- variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
+ variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["WanVariantType"] | components["schemas"]["WanLoRAVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
/**
* Is Installed
* @default false
@@ -29047,14 +30420,14 @@ export type components = {
* @description Submodel type.
* @enum {string}
*/
- SubModelType: "unet" | "transformer" | "text_encoder" | "text_encoder_2" | "text_encoder_3" | "tokenizer" | "tokenizer_2" | "tokenizer_3" | "vae" | "vae_decoder" | "vae_encoder" | "scheduler" | "safety_checker";
+ SubModelType: "unet" | "transformer" | "transformer_2" | "text_encoder" | "text_encoder_2" | "text_encoder_3" | "tokenizer" | "tokenizer_2" | "tokenizer_3" | "vae" | "vae_decoder" | "vae_encoder" | "scheduler" | "safety_checker";
/** SubmodelDefinition */
SubmodelDefinition: {
/** Path Or Prefix */
path_or_prefix: string;
model_type: components["schemas"]["ModelType"];
/** Variant */
- variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
+ variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | components["schemas"]["FluxVariantType"] | components["schemas"]["Flux2VariantType"] | components["schemas"]["ZImageVariantType"] | components["schemas"]["QwenImageVariantType"] | components["schemas"]["WanVariantType"] | components["schemas"]["WanLoRAVariantType"] | components["schemas"]["Qwen3VariantType"] | null;
};
/**
* Subtract Integers
@@ -30703,6 +32076,19 @@ export type components = {
*/
type: "unsharp_mask";
};
+ /** UnstarredCanvasProjectsResult */
+ UnstarredCanvasProjectsResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Unstarred Projects
+ * @description The names of the canvas projects that were unstarred
+ */
+ unstarred_projects: string[];
+ };
/** UnstarredImagesResult */
UnstarredImagesResult: {
/**
@@ -30716,6 +32102,19 @@ export type components = {
*/
unstarred_images: string[];
};
+ /** UnstarredVideosResult */
+ UnstarredVideosResult: {
+ /**
+ * Affected Boards
+ * @description The ids of boards affected by the operation
+ */
+ affected_boards: string[];
+ /**
+ * Unstarred Videos
+ * @description The names of the videos that were unstarred
+ */
+ unstarred_videos: string[];
+ };
/**
* UpdateAppGenerationSettingsRequest
* @description Writable generation-related app settings.
@@ -31421,6 +32820,96 @@ export type components = {
*/
base: "sdxl";
};
+ /**
+ * VAE_Checkpoint_Wan_Config
+ * @description Model config for Wan 2.2 VAE checkpoint models (AutoencoderKLWan).
+ *
+ * Distinguishes A14B (z_dim=16, standard Wan VAE) from TI2V-5B (z_dim=48,
+ * Wan2.2-VAE) via the input channel count of ``decoder.conv_in.weight``.
+ */
+ VAE_Checkpoint_Wan_Config: {
+ /**
+ * Key
+ * @description A unique key for this model.
+ */
+ key: string;
+ /**
+ * Hash
+ * @description The hash of the model file(s).
+ */
+ hash: string;
+ /**
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
+ */
+ path: string;
+ /**
+ * File Size
+ * @description The size of the model in bytes.
+ */
+ file_size: number;
+ /**
+ * Name
+ * @description Name of the model.
+ */
+ name: string;
+ /**
+ * Description
+ * @description Model description
+ */
+ description: string | null;
+ /**
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
+ */
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
+ /**
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
+ */
+ source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
+ /**
+ * Cover Image
+ * @description Url for image to preview model
+ */
+ cover_image: string | null;
+ /**
+ * Config Path
+ * @description Path to the config for this model, if any.
+ */
+ config_path: string | null;
+ /**
+ * Type
+ * @default vae
+ * @constant
+ */
+ type: "vae";
+ /**
+ * Format
+ * @default checkpoint
+ * @constant
+ */
+ format: "checkpoint";
+ /**
+ * Base
+ * @default wan
+ * @constant
+ */
+ base: "wan";
+ /**
+ * Latent Channels
+ * @description VAE latent channel count: 16 for A14B (standard Wan VAE) or 48 for TI2V-5B (Wan2.2-VAE).
+ * @enum {integer}
+ */
+ latent_channels: 16 | 48;
+ };
/**
* VAE_Diffusers_Flux2_Config
* @description Model config for FLUX.2 VAE models in diffusers format (AutoencoderKLFlux2).
@@ -31649,508 +33138,573 @@ export type components = {
*/
base: "sdxl";
};
- /** ValidationError */
- ValidationError: {
- /** Location */
- loc: (string | number)[];
- /** Message */
- msg: string;
- /** Error Type */
- type: string;
- };
/**
- * VirtualSubBoardDTO
- * @description A virtual sub-board computed from image metadata, not stored in the database.
+ * VAE_Diffusers_Wan_Config
+ * @description Model config for Wan 2.2 VAE in diffusers folder layout (AutoencoderKLWan).
*/
- VirtualSubBoardDTO: {
+ VAE_Diffusers_Wan_Config: {
/**
- * Virtual Board Id
- * @description The virtual board ID, e.g. 'by_date:2026-03-18'.
+ * Key
+ * @description A unique key for this model.
*/
- virtual_board_id: string;
+ key: string;
/**
- * Board Name
- * @description The display name of the virtual sub-board, e.g. '2026-03-18'.
+ * Hash
+ * @description The hash of the model file(s).
*/
- board_name: string;
+ hash: string;
/**
- * Date
- * @description The ISO date string, e.g. '2026-03-18'.
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
*/
- date: string;
+ path: string;
/**
- * Image Count
- * @description The number of general images for this date.
+ * File Size
+ * @description The size of the model in bytes.
*/
- image_count: number;
+ file_size: number;
/**
- * Asset Count
- * @description The number of asset images for this date.
+ * Name
+ * @description Name of the model.
*/
- asset_count: number;
+ name: string;
/**
- * Cover Image Name
- * @description The most recent image name for this date.
+ * Description
+ * @description Model description
*/
- cover_image_name?: string | null;
- };
- /** Workflow */
- Workflow: {
+ description: string | null;
/**
- * Name
- * @description The name of the workflow.
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
*/
- name: string;
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
/**
- * Author
- * @description The author of the workflow.
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
*/
- author: string;
+ source_api_response: string | null;
/**
- * Description
- * @description The description of the workflow.
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
*/
- description: string;
+ source_url: string | null;
/**
- * Version
- * @description The version of the workflow.
+ * Cover Image
+ * @description Url for image to preview model
*/
- version: string;
+ cover_image: string | null;
/**
- * Contact
- * @description The contact of the workflow.
+ * Format
+ * @default diffusers
+ * @constant
*/
- contact: string;
+ format: "diffusers";
+ /** @default */
+ repo_variant: components["schemas"]["ModelRepoVariant"];
/**
- * Tags
- * @description The tags of the workflow.
+ * Type
+ * @default vae
+ * @constant
*/
- tags: string;
+ type: "vae";
/**
- * Notes
- * @description The notes of the workflow.
+ * Base
+ * @default wan
+ * @constant
*/
- notes: string;
+ base: "wan";
/**
- * Exposedfields
- * @description The exposed fields of the workflow.
+ * Latent Channels
+ * @description VAE latent channel count: 16 for A14B or 48 for TI2V-5B's Wan2.2-VAE.
+ * @default 16
+ * @enum {integer}
*/
- exposedFields: components["schemas"]["ExposedField"][];
- /** @description The meta of the workflow. */
- meta: components["schemas"]["WorkflowMeta"];
+ latent_channels: 16 | 48;
+ };
+ /** ValidationError */
+ ValidationError: {
+ /** Location */
+ loc: (string | number)[];
+ /** Message */
+ msg: string;
+ /** Error Type */
+ type: string;
+ };
+ /** VideoBoardArg */
+ VideoBoardArg: {
/**
- * Nodes
- * @description The nodes of the workflow.
+ * Board Id
+ * @description The id of the board to add or remove the video from
*/
- nodes: {
- [key: string]: components["schemas"]["JsonValue"];
- }[];
+ board_id: string;
/**
- * Edges
- * @description The edges of the workflow.
+ * Video Name
+ * @description The name of the video to add to / remove from the board
*/
- edges: {
- [key: string]: components["schemas"]["JsonValue"];
- }[];
+ video_name: string;
+ };
+ /**
+ * Concatenate Videos
+ * @description Join two or more videos into a single MP4.
+ *
+ * Transitions:
+ *
+ * * ``cut`` — hard splice, no blending. Fastest; total length is the sum of inputs.
+ * * ``crossfade`` — linear A→B cross-dissolve over ``transition_frames``. Each boundary
+ * consumes ``transition_frames`` from both adjacent clips, so total length is
+ * ``sum(inputs) - transition_frames * (n - 1)``.
+ * * ``fade_through_black`` — A fades to black, then B fades in from black. Each boundary
+ * consumes ``transition_frames // 2`` frames from the preceding clip's tail and the
+ * remainder (``transition_frames - transition_frames // 2``) from the next clip's head,
+ * so the total emitted is exactly ``transition_frames`` per boundary — even for odd
+ * ``transition_frames`` — and the overall length equals the sum of inputs.
+ *
+ * All inputs must share the same pixel dimensions. Output frame rate defaults to the
+ * first input's fps; override with ``fps`` to force a specific rate (the frames are not
+ * resampled, only the container is encoded at the new rate).
+ */
+ VideoConcatInvocation: {
/**
- * Form
- * @description The form of the workflow.
+ * @description The board to save the image to
+ * @default null
*/
- form?: {
- [key: string]: components["schemas"]["JsonValue"];
- } | null;
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
/**
* Id
- * @description The id of the workflow.
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
*/
id: string;
- };
- /** WorkflowAndGraphResponse */
- WorkflowAndGraphResponse: {
/**
- * Workflow
- * @description The workflow used to generate the image, as stringified JSON
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
*/
- workflow: string | null;
+ is_intermediate?: boolean;
/**
- * Graph
- * @description The graph used to generate the image, as stringified JSON
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
*/
- graph: string | null;
- };
- /**
- * WorkflowCategory
- * @enum {string}
- */
- WorkflowCategory: "user" | "default";
- /** WorkflowMeta */
- WorkflowMeta: {
+ use_cache?: boolean;
/**
- * Version
- * @description The version of the workflow schema.
+ * Videos
+ * @description Videos to concatenate, in order. At least two are required.
+ * @default null
*/
- version: string;
- /** @description The category of the workflow (user or default). */
- category: components["schemas"]["WorkflowCategory"];
- };
- /** WorkflowRecordDTO */
- WorkflowRecordDTO: {
+ videos?: components["schemas"]["VideoField"][] | null;
/**
- * Workflow Id
- * @description The id of the workflow.
+ * Transition
+ * @description Transition between consecutive clips.
+ * @default cut
+ * @enum {string}
*/
- workflow_id: string;
+ transition?: "cut" | "crossfade" | "fade_through_black";
/**
- * Name
- * @description The name of the workflow.
+ * Transition Frames
+ * @description Length of each transition in frames. Ignored when transition is 'cut'.
+ * @default 8
*/
- name: string;
+ transition_frames?: number;
/**
- * Created At
- * @description The created timestamp of the workflow.
+ * Fps
+ * @description Output frame rate. Defaults to the first input's fps.
+ * @default null
*/
- created_at: string;
+ fps?: number | null;
/**
- * Updated At
- * @description The updated timestamp of the workflow.
+ * type
+ * @default video_concat
+ * @constant
*/
- updated_at: string;
+ type: "video_concat";
+ };
+ /**
+ * VideoDTO
+ * @description Deserialized video record, enriched for the frontend.
+ */
+ VideoDTO: {
/**
- * Opened At
- * @description The opened timestamp of the workflow.
+ * Video Name
+ * @description The unique name of the video.
*/
- opened_at?: string | null;
+ video_name: string;
/**
- * User Id
- * @description The id of the user who owns this workflow.
+ * Video Url
+ * @description The URL of the video file (MP4).
*/
- user_id: string;
+ video_url: string;
/**
- * Is Public
- * @description Whether this workflow is shared with all users.
+ * Thumbnail Url
+ * @description The URL of the video's first-frame thumbnail (WebP).
*/
- is_public: boolean;
- /** @description The workflow. */
- workflow: components["schemas"]["Workflow"];
- };
- /** WorkflowRecordListItemWithThumbnailDTO */
- WorkflowRecordListItemWithThumbnailDTO: {
+ thumbnail_url: string;
+ /** @description The origin of the video. */
+ video_origin: components["schemas"]["ResourceOrigin"];
+ /** @description The category of the video (reuses ImageCategory). */
+ video_category: components["schemas"]["ImageCategory"];
/**
- * Workflow Id
- * @description The id of the workflow.
+ * Width
+ * @description The pixel width of the video.
*/
- workflow_id: string;
+ width: number;
/**
- * Name
- * @description The name of the workflow.
+ * Height
+ * @description The pixel height of the video.
*/
- name: string;
+ height: number;
+ /**
+ * Duration
+ * @description The duration of the video in seconds.
+ */
+ duration: number;
+ /**
+ * Fps
+ * @description The frames-per-second of the video, if known.
+ */
+ fps?: number | null;
/**
* Created At
- * @description The created timestamp of the workflow.
+ * @description The created timestamp of the video.
*/
created_at: string;
/**
* Updated At
- * @description The updated timestamp of the workflow.
+ * @description The updated timestamp of the video.
*/
updated_at: string;
/**
- * Opened At
- * @description The opened timestamp of the workflow.
+ * Deleted At
+ * @description The deleted timestamp of the video.
*/
- opened_at?: string | null;
+ deleted_at?: string | null;
/**
- * User Id
- * @description The id of the user who owns this workflow.
+ * Is Intermediate
+ * @description Whether this is an intermediate video.
*/
- user_id: string;
+ is_intermediate: boolean;
/**
- * Is Public
- * @description Whether this workflow is shared with all users.
+ * Session Id
+ * @description The session ID that produced this video, if any.
*/
- is_public: boolean;
+ session_id?: string | null;
/**
- * Description
- * @description The description of the workflow.
+ * Node Id
+ * @description The node ID that produced this video, if any.
*/
- description: string;
- /** @description The description of the workflow. */
- category: components["schemas"]["WorkflowCategory"];
+ node_id?: string | null;
/**
- * Tags
- * @description The tags of the workflow.
+ * Starred
+ * @description Whether this video is starred.
*/
- tags: string;
+ starred: boolean;
/**
- * Thumbnail Url
- * @description The URL of the workflow thumbnail.
+ * Has Workflow
+ * @description Whether this video has a workflow associated.
*/
- thumbnail_url?: string | null;
+ has_workflow: boolean;
+ /**
+ * Video Subfolder
+ * @description The subfolder where the video is stored on disk.
+ * @default
+ */
+ video_subfolder?: string;
+ /**
+ * Board Id
+ * @description The id of the board the video belongs to, if one exists.
+ */
+ board_id?: string | null;
};
/**
- * WorkflowRecordOrderBy
- * @description The order by options for workflow records
- * @enum {string}
+ * VideoField
+ * @description A video primitive field
*/
- WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name" | "is_public";
- /** WorkflowRecordWithThumbnailDTO */
- WorkflowRecordWithThumbnailDTO: {
+ VideoField: {
/**
- * Workflow Id
- * @description The id of the workflow.
+ * Video Name
+ * @description The name of the video
*/
- workflow_id: string;
+ video_name: string;
+ };
+ /**
+ * Frame from Video
+ * @description Extract a single frame from a video and save it as an image.
+ *
+ * ``frame_index`` is 0-based. Negative indices count from the end, so the
+ * default of -1 returns the final frame — the typical setup for chaining
+ * I2V clips into a longer sequence.
+ */
+ VideoFrameExtractInvocation: {
/**
- * Name
- * @description The name of the workflow.
+ * @description The board to save the image to
+ * @default null
*/
- name: string;
+ board?: components["schemas"]["BoardField"] | null;
/**
- * Created At
- * @description The created timestamp of the workflow.
+ * @description Optional metadata to be saved with the image
+ * @default null
*/
- created_at: string;
+ metadata?: components["schemas"]["MetadataField"] | null;
/**
- * Updated At
- * @description The updated timestamp of the workflow.
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
*/
- updated_at: string;
+ id: string;
/**
- * Opened At
- * @description The opened timestamp of the workflow.
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
*/
- opened_at?: string | null;
+ is_intermediate?: boolean;
/**
- * User Id
- * @description The id of the user who owns this workflow.
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
*/
- user_id: string;
+ use_cache?: boolean;
/**
- * Is Public
- * @description Whether this workflow is shared with all users.
+ * @description The video to extract a frame from.
+ * @default null
*/
- is_public: boolean;
- /** @description The workflow. */
- workflow: components["schemas"]["Workflow"];
+ video?: components["schemas"]["VideoField"] | null;
/**
- * Thumbnail Url
- * @description The URL of the workflow thumbnail.
+ * Frame Index
+ * @description Index of the frame to extract. 0 = first frame, -1 = last frame, -2 = second-to-last, etc.
+ * @default -1
*/
- thumbnail_url?: string | null;
+ frame_index?: number;
+ /**
+ * type
+ * @default video_frame_extract
+ * @constant
+ */
+ type: "video_frame_extract";
};
- /** WorkflowWithoutID */
- WorkflowWithoutID: {
+ /**
+ * Video Primitive
+ * @description A video primitive value. Drop a video onto the field to make it available as an input
+ * to downstream nodes (e.g. Frame from Video, Concatenate Videos).
+ */
+ VideoInvocation: {
/**
- * Name
- * @description The name of the workflow.
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
*/
- name: string;
+ id: string;
/**
- * Author
- * @description The author of the workflow.
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
*/
- author: string;
+ is_intermediate?: boolean;
/**
- * Description
- * @description The description of the workflow.
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
*/
- description: string;
+ use_cache?: boolean;
/**
- * Version
- * @description The version of the workflow.
+ * @description The video to load
+ * @default null
*/
- version: string;
+ video?: components["schemas"]["VideoField"] | null;
/**
- * Contact
- * @description The contact of the workflow.
+ * type
+ * @default video
+ * @constant
*/
- contact: string;
+ type: "video";
+ };
+ /**
+ * VideoNamesResult
+ * @description Response containing ordered video names with metadata for optimistic updates.
+ */
+ VideoNamesResult: {
/**
- * Tags
- * @description The tags of the workflow.
+ * Video Names
+ * @description Ordered list of video names
*/
- tags: string;
+ video_names: string[];
/**
- * Notes
- * @description The notes of the workflow.
+ * Starred Count
+ * @description Number of starred videos (when starred_first=True)
*/
- notes: string;
+ starred_count: number;
/**
- * Exposedfields
- * @description The exposed fields of the workflow.
+ * Total Count
+ * @description Total number of videos matching the query
*/
- exposedFields: components["schemas"]["ExposedField"][];
- /** @description The meta of the workflow. */
- meta: components["schemas"]["WorkflowMeta"];
+ total_count: number;
+ };
+ /**
+ * VideoOutput
+ * @description Output of a node that produces a video file (e.g. Wan 2.2 latents-to-video).
+ */
+ VideoOutput: {
+ /** @description The output video */
+ video: components["schemas"]["VideoField"];
/**
- * Nodes
- * @description The nodes of the workflow.
+ * Width
+ * @description The width of the video in pixels
*/
- nodes: {
- [key: string]: components["schemas"]["JsonValue"];
- }[];
+ width: number;
/**
- * Edges
- * @description The edges of the workflow.
+ * Height
+ * @description The height of the video in pixels
*/
- edges: {
- [key: string]: components["schemas"]["JsonValue"];
- }[];
+ height: number;
/**
- * Form
- * @description The form of the workflow.
+ * Num Frames
+ * @description The number of frames in the video
*/
- form?: {
- [key: string]: components["schemas"]["JsonValue"];
- } | null;
- };
- /**
- * ZImageConditioningField
- * @description A Z-Image conditioning tensor primitive value
- */
- ZImageConditioningField: {
+ num_frames: number;
/**
- * Conditioning Name
- * @description The name of conditioning tensor
+ * Fps
+ * @description The frames-per-second of the video
*/
- conditioning_name: string;
+ fps: number;
/**
- * @description The mask associated with this conditioning tensor for regional prompting. Excluded regions should be set to False, included regions should be set to True.
- * @default null
+ * Duration
+ * @description The duration of the video in seconds
*/
- mask?: components["schemas"]["TensorField"] | null;
- };
- /**
- * ZImageConditioningOutput
- * @description Base class for nodes that output a Z-Image text conditioning tensor.
- */
- ZImageConditioningOutput: {
- /** @description Conditioning tensor */
- conditioning: components["schemas"]["ZImageConditioningField"];
+ duration: number;
/**
* type
- * @default z_image_conditioning_output
+ * @default video_output
* @constant
*/
- type: "z_image_conditioning_output";
+ type: "video_output";
};
/**
- * ZImageControlField
- * @description A Z-Image control conditioning field for spatial control (Canny, HED, Depth, Pose, MLSD).
+ * VideoRecordChanges
+ * @description Allowed mutations on a video record.
*/
- ZImageControlField: {
+ VideoRecordChanges: {
+ /** @description The video's new category. */
+ video_category?: components["schemas"]["ImageCategory"] | null;
/**
- * Image Name
- * @description The name of the preprocessed control image
- */
- image_name: string;
- /** @description The Z-Image ControlNet adapter model */
- control_model: components["schemas"]["ModelIdentifierField"];
- /**
- * Control Context Scale
- * @description The strength of the control signal. Recommended range: 0.65-0.80.
- * @default 0.75
+ * Session Id
+ * @description The video's new session ID.
*/
- control_context_scale?: number;
+ session_id?: string | null;
/**
- * Begin Step Percent
- * @description When the control is first applied (% of total steps)
- * @default 0
+ * Is Intermediate
+ * @description The video's new `is_intermediate` flag.
*/
- begin_step_percent?: number;
+ is_intermediate?: boolean | null;
/**
- * End Step Percent
- * @description When the control is last applied (% of total steps)
- * @default 1
+ * Starred
+ * @description The video's new `starred` state.
*/
- end_step_percent?: number;
+ starred?: boolean | null;
+ } & {
+ [key: string]: unknown;
};
/**
- * Z-Image ControlNet
- * @description Configure Z-Image ControlNet for spatial conditioning.
- *
- * Takes a preprocessed control image (e.g., Canny edges, depth map, pose)
- * and a Z-Image ControlNet adapter model to enable spatial control.
- *
- * Supports 5 control modes: Canny, HED, Depth, Pose, MLSD.
- * Recommended control_context_scale: 0.65-0.80.
+ * VideoUrlsDTO
+ * @description The URLs for a video and its thumbnail.
*/
- ZImageControlInvocation: {
+ VideoUrlsDTO: {
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ * Video Name
+ * @description The unique name of the video.
*/
- id: string;
+ video_name: string;
/**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
+ * Video Url
+ * @description The URL of the video file (MP4).
*/
- is_intermediate?: boolean;
+ video_url: string;
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * Thumbnail Url
+ * @description The URL of the video's first-frame thumbnail (WebP).
*/
- use_cache?: boolean;
+ thumbnail_url: string;
+ };
+ /**
+ * VirtualSubBoardDTO
+ * @description A virtual sub-board computed from image metadata, not stored in the database.
+ */
+ VirtualSubBoardDTO: {
/**
- * @description The preprocessed control image (Canny, HED, Depth, Pose, or MLSD)
- * @default null
+ * Virtual Board Id
+ * @description The virtual board ID, e.g. 'by_date:2026-03-18'.
*/
- image?: components["schemas"]["ImageField"] | null;
+ virtual_board_id: string;
/**
- * Control Model
- * @description ControlNet model to load
- * @default null
+ * Board Name
+ * @description The display name of the virtual sub-board, e.g. '2026-03-18'.
*/
- control_model?: components["schemas"]["ModelIdentifierField"] | null;
+ board_name: string;
/**
- * Control Scale
- * @description Strength of the control signal. Recommended range: 0.65-0.80.
- * @default 0.75
+ * Date
+ * @description The ISO date string, e.g. '2026-03-18'.
*/
- control_context_scale?: number;
+ date: string;
/**
- * Begin Step Percent
- * @description When the control is first applied (% of total steps)
- * @default 0
+ * Image Count
+ * @description The number of general images for this date.
*/
- begin_step_percent?: number;
+ image_count: number;
/**
- * End Step Percent
- * @description When the control is last applied (% of total steps)
- * @default 1
+ * Asset Count
+ * @description The number of asset images for this date.
*/
- end_step_percent?: number;
+ asset_count: number;
/**
- * type
- * @default z_image_control
- * @constant
+ * Cover Image Name
+ * @description The most recent image name for this date.
*/
- type: "z_image_control";
+ cover_image_name?: string | null;
};
/**
- * ZImageControlOutput
- * @description Z-Image Control output containing control configuration.
+ * WanConditioningField
+ * @description A Wan 2.2 conditioning tensor primitive value.
+ *
+ * Wan conditioning is the UMT5-XXL hidden state for the prompt plus an attention
+ * mask marking valid (non-padding) tokens.
*/
- ZImageControlOutput: {
- /** @description Z-Image control conditioning */
- control: components["schemas"]["ZImageControlField"];
+ WanConditioningField: {
+ /**
+ * Conditioning Name
+ * @description The name of conditioning tensor
+ */
+ conditioning_name: string;
+ };
+ /**
+ * WanConditioningOutput
+ * @description Base class for nodes that output a Wan 2.2 text conditioning tensor.
+ */
+ WanConditioningOutput: {
+ /** @description Conditioning tensor */
+ conditioning: components["schemas"]["WanConditioningField"];
/**
* type
- * @default z_image_control_output
+ * @default wan_conditioning_output
* @constant
*/
- type: "z_image_control_output";
+ type: "wan_conditioning_output";
};
/**
- * Denoise - Z-Image
- * @description Run the denoising process with a Z-Image model.
+ * Denoise - Wan 2.2
+ * @description Run the denoising process with a Wan 2.2 model.
*
- * Supports regional prompting by connecting multiple conditioning inputs with masks.
+ * Drives a flow-matching Euler schedule via Diffusers'
+ * ``FlowMatchEulerDiscreteScheduler``. CFG is supported when negative
+ * conditioning is provided and ``guidance_scale != 1.0``.
+ *
+ * For Wan 2.2 A14B the high-noise expert handles timesteps at and above
+ * ``boundary_ratio * num_train_timesteps``; the low-noise expert handles
+ * timesteps below. Both experts share the model cache; only the active one is
+ * GPU-resident at any time.
*/
- ZImageDenoiseInvocation: {
+ WanDenoiseInvocation: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32168,6 +33722,28 @@ export type components = {
* @default true
*/
use_cache?: boolean;
+ /**
+ * Transformer
+ * @description Wan transformer field (transformer + optional dual-expert metadata).
+ * @default null
+ */
+ transformer?: components["schemas"]["WanTransformerField"] | null;
+ /**
+ * @description Positive conditioning tensor
+ * @default null
+ */
+ positive_conditioning?: components["schemas"]["WanConditioningField"] | null;
+ /**
+ * @description Negative conditioning tensor
+ * @default null
+ */
+ negative_conditioning?: components["schemas"]["WanConditioningField"] | null;
+ /**
+ * Reference Image
+ * @description Reference-image (VAE-latent) conditioning for Wan 2.2 I2V.
+ * @default null
+ */
+ ref_image?: components["schemas"]["WanRefImageConditioningField"] | null;
/**
* @description Latents tensor
* @default null
@@ -32197,29 +33773,17 @@ export type components = {
*/
add_noise?: boolean;
/**
- * Transformer
- * @description Z-Image model (Transformer) to load
- * @default null
- */
- transformer?: components["schemas"]["TransformerField"] | null;
- /**
- * Positive Conditioning
- * @description Positive conditioning tensor
- * @default null
+ * Guidance Scale
+ * @description Classifier-free guidance scale. 4.0 is the Wan 2.2 default for A14B; TI2V-5B can tolerate higher values up to ~5.5.
+ * @default 4
*/
- positive_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ guidance_scale?: number;
/**
- * Negative Conditioning
- * @description Negative conditioning tensor
+ * Guidance Scale (Low Noise)
+ * @description Optional separate CFG scale for the low-noise expert (Wan 2.2 A14B only). Values below 1.0 (including 0) fall back to the primary 'Guidance Scale'. Ignored for TI2V-5B.
* @default null
*/
- negative_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
- /**
- * Guidance Scale
- * @description Guidance scale for classifier-free guidance. 1.0 = no CFG (recommended for Z-Image-Turbo). Values > 1.0 amplify guidance.
- * @default 1
- */
- guidance_scale?: number;
+ guidance_scale_low_noise?: number | null;
/**
* Width
* @description Width of the generated image.
@@ -32234,8 +33798,8 @@ export type components = {
height?: number;
/**
* Steps
- * @description Number of denoising steps. 8 recommended for Z-Image-Turbo.
- * @default 8
+ * @description Number of denoising steps.
+ * @default 40
*/
steps?: number;
/**
@@ -32244,46 +33808,23 @@ export type components = {
* @default 0
*/
seed?: number;
- /**
- * @description Z-Image control conditioning for spatial control (Canny, HED, Depth, Pose, MLSD).
- * @default null
- */
- control?: components["schemas"]["ZImageControlField"] | null;
- /**
- * @description VAE Required for control conditioning.
- * @default null
- */
- vae?: components["schemas"]["VAEField"] | null;
- /**
- * Shift
- * @description Override the timestep shift (mu) for the sigma schedule. Leave blank to auto-calculate based on image dimensions (recommended). Lower values (~0.5) produce less noise shifting, higher values (~1.15) produce more.
- * @default null
- */
- shift?: number | null;
- /**
- * Scheduler
- * @description Scheduler (sampler) for the denoising process. Euler is the default and recommended. Heun is 2nd-order (better quality, 2x slower). LCM works with Turbo only (not Base).
- * @default euler
- * @enum {string}
- */
- scheduler?: "euler" | "heun" | "lcm";
/**
* type
- * @default z_image_denoise
+ * @default wan_denoise
* @constant
*/
- type: "z_image_denoise";
+ type: "wan_denoise";
};
/**
- * Denoise - Z-Image + Metadata
- * @description Run denoising process with a Z-Image transformer model + metadata.
+ * Wan 2.2 I2V Ideal Dimensions
+ * @description Compute Wan I2V-compatible (width, height) for a chosen resolution preset.
+ *
+ * Scales the input W×H so the shorter side equals the chosen preset (480 / 720 /
+ * 1080 px), then snaps each dimension to a multiple of 16 (Wan's pixel-grid
+ * constraint). Wire from ``Image Primitive``'s width/height outputs and into
+ * ``wan_ref_image_encoder`` / ``wan_denoise``.
*/
- ZImageDenoiseMetaInvocation: {
- /**
- * @description Optional metadata to be saved with the image
- * @default null
- */
- metadata?: components["schemas"]["MetadataField"] | null;
+ WanI2VIdealDimensionsInvocation: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32302,116 +33843,96 @@ export type components = {
*/
use_cache?: boolean;
/**
- * @description Latents tensor
- * @default null
- */
- latents?: components["schemas"]["LatentsField"] | null;
- /**
- * @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
- * @default null
+ * Width
+ * @description Source image width in pixels.
+ * @default 1024
*/
- denoise_mask?: components["schemas"]["DenoiseMaskField"] | null;
+ width?: number;
/**
- * Denoising Start
- * @description When to start denoising, expressed a percentage of total steps
- * @default 0
+ * Height
+ * @description Source image height in pixels.
+ * @default 1024
*/
- denoising_start?: number;
+ height?: number;
/**
- * Denoising End
- * @description When to stop denoising, expressed a percentage of total steps
- * @default 1
+ * Target Resolution
+ * @description Short-side resolution preset. 480p and 720p are Wan 2.2's native training resolutions; 1080p works but is extrapolation and costs ~2.25x the memory of 720p.
+ * @default 720p
+ * @enum {string}
*/
- denoising_end?: number;
+ target_resolution?: "480p" | "720p" | "1080p";
/**
- * Add Noise
- * @description Add noise based on denoising start.
- * @default true
+ * Rounding
+ * @description How to snap each dimension to a multiple of 16. 'floor' rounds down — safest for VRAM, guaranteed not to exceed the unsnapped target. 'ceiling' rounds up. 'nearest' minimizes aspect-ratio drift (default).
+ * @default nearest
+ * @enum {string}
*/
- add_noise?: boolean;
+ rounding?: "nearest" | "floor" | "ceiling";
/**
- * Transformer
- * @description Z-Image model (Transformer) to load
- * @default null
+ * type
+ * @default wan_i2v_ideal_dimensions
+ * @constant
*/
- transformer?: components["schemas"]["TransformerField"] | null;
+ type: "wan_i2v_ideal_dimensions";
+ };
+ /**
+ * Image to Latents - Wan 2.2
+ * @description Encodes an image with the Wan VAE (AutoencoderKLWan).
+ *
+ * The output latents have the temporal dimension squeezed out, so downstream
+ * nodes see 4D ``[B, C, H, W]``. The denoise loop re-adds ``T=1`` before
+ * feeding the transformer.
+ */
+ WanImageToLatentsInvocation: {
/**
- * Positive Conditioning
- * @description Positive conditioning tensor
+ * @description The board to save the image to
* @default null
*/
- positive_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ board?: components["schemas"]["BoardField"] | null;
/**
- * Negative Conditioning
- * @description Negative conditioning tensor
+ * @description Optional metadata to be saved with the image
* @default null
*/
- negative_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
- /**
- * Guidance Scale
- * @description Guidance scale for classifier-free guidance. 1.0 = no CFG (recommended for Z-Image-Turbo). Values > 1.0 amplify guidance.
- * @default 1
- */
- guidance_scale?: number;
- /**
- * Width
- * @description Width of the generated image.
- * @default 1024
- */
- width?: number;
+ metadata?: components["schemas"]["MetadataField"] | null;
/**
- * Height
- * @description Height of the generated image.
- * @default 1024
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
*/
- height?: number;
+ id: string;
/**
- * Steps
- * @description Number of denoising steps. 8 recommended for Z-Image-Turbo.
- * @default 8
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
*/
- steps?: number;
+ is_intermediate?: boolean;
/**
- * Seed
- * @description Randomness seed for reproducibility.
- * @default 0
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
*/
- seed?: number;
+ use_cache?: boolean;
/**
- * @description Z-Image control conditioning for spatial control (Canny, HED, Depth, Pose, MLSD).
+ * @description The image to encode.
* @default null
*/
- control?: components["schemas"]["ZImageControlField"] | null;
+ image?: components["schemas"]["ImageField"] | null;
/**
- * @description VAE Required for control conditioning.
+ * @description VAE
* @default null
*/
vae?: components["schemas"]["VAEField"] | null;
- /**
- * Shift
- * @description Override the timestep shift (mu) for the sigma schedule. Leave blank to auto-calculate based on image dimensions (recommended). Lower values (~0.5) produce less noise shifting, higher values (~1.15) produce more.
- * @default null
- */
- shift?: number | null;
- /**
- * Scheduler
- * @description Scheduler (sampler) for the denoising process. Euler is the default and recommended. Heun is 2nd-order (better quality, 2x slower). LCM works with Turbo only (not Base).
- * @default euler
- * @enum {string}
- */
- scheduler?: "euler" | "heun" | "lcm";
/**
* type
- * @default z_image_denoise_meta
+ * @default wan_i2l
* @constant
*/
- type: "z_image_denoise_meta";
+ type: "wan_i2l";
};
/**
- * Image to Latents - Z-Image
- * @description Generates latents from an image using Z-Image VAE (supports both Diffusers and FLUX VAE).
+ * Latents to Image - Wan 2.2
+ * @description Decodes Wan latents back to RGB.
*/
- ZImageImageToLatentsInvocation: {
+ WanLatentsToImageInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -32440,10 +33961,10 @@ export type components = {
*/
use_cache?: boolean;
/**
- * @description The image to encode.
+ * @description Latents tensor
* @default null
*/
- image?: components["schemas"]["ImageField"] | null;
+ latents?: components["schemas"]["LatentsField"] | null;
/**
* @description VAE
* @default null
@@ -32451,16 +33972,16 @@ export type components = {
vae?: components["schemas"]["VAEField"] | null;
/**
* type
- * @default z_image_i2l
+ * @default wan_l2i
* @constant
*/
- type: "z_image_i2l";
+ type: "wan_l2i";
};
/**
- * Latents to Image - Z-Image
- * @description Generates an image from latents using Z-Image VAE (supports both Diffusers and FLUX VAE).
+ * Latents to Video - Wan 2.2
+ * @description Decode 5D Wan latents to RGB frames and encode an MP4.
*/
- ZImageLatentsToImageInvocation: {
+ WanLatentsToVideoInvocation: {
/**
* @description The board to save the image to
* @default null
@@ -32498,18 +34019,28 @@ export type components = {
* @default null
*/
vae?: components["schemas"]["VAEField"] | null;
+ /**
+ * Fps
+ * @description Frames-per-second for the encoded MP4. Wan 2.2 was trained at 16 FPS.
+ * @default 16
+ */
+ fps?: number;
/**
* type
- * @default z_image_l2i
+ * @default wan_l2v
* @constant
*/
- type: "z_image_l2i";
+ type: "wan_l2v";
};
/**
- * Apply LoRA Collection - Z-Image
- * @description Applies a collection of LoRAs to a Z-Image transformer.
+ * Apply LoRA Collection - Wan 2.2
+ * @description Apply a collection of LoRAs to the Wan 2.2 transformer(s).
+ *
+ * Each LoRA is routed to the primary and/or low-noise list based on its
+ * recorded ``expert`` tag (set by the probe from the filename). Untagged
+ * LoRAs go to both lists.
*/
- ZImageLoRACollectionLoader: {
+ WanLoRACollectionLoader: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32529,34 +34060,36 @@ export type components = {
use_cache?: boolean;
/**
* LoRAs
- * @description LoRA models and weights. May be a single LoRA or collection.
+ * @description LoRAs to apply. May be a single LoRA or a collection.
* @default null
*/
loras?: components["schemas"]["LoRAField"] | components["schemas"]["LoRAField"][] | null;
/**
- * Transformer
+ * Wan Transformer
* @description Transformer
* @default null
*/
- transformer?: components["schemas"]["TransformerField"] | null;
- /**
- * Qwen3 Encoder
- * @description Qwen3 tokenizer and text encoder
- * @default null
- */
- qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ transformer?: components["schemas"]["WanTransformerField"] | null;
/**
* type
- * @default z_image_lora_collection_loader
+ * @default wan_lora_collection_loader
* @constant
*/
- type: "z_image_lora_collection_loader";
+ type: "wan_lora_collection_loader";
};
/**
- * Apply LoRA - Z-Image
- * @description Apply a LoRA model to a Z-Image transformer and/or Qwen3 text encoder.
+ * Apply LoRA - Wan 2.2
+ * @description Apply a LoRA to the Wan 2.2 transformer(s).
+ *
+ * For A14B (dual expert) the LoRA's recorded ``expert`` field determines
+ * which expert list it lands in: ``"high"`` -> primary list, ``"low"`` ->
+ * low-noise list, ``None`` (untagged) -> both lists. Use the ``target``
+ * field to override.
+ *
+ * For TI2V-5B (single transformer) only the primary list is used at denoise
+ * time; the low-noise routing is harmless but ignored.
*/
- ZImageLoRALoaderInvocation: {
+ WanLoRALoaderInvocation: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32587,58 +34120,75 @@ export type components = {
*/
weight?: number;
/**
- * Z-Image Transformer
- * @description Transformer
- * @default null
+ * Target
+ * @description Which expert(s) to apply this LoRA to. 'auto' uses the LoRA's recorded expert tag (or both if untagged); 'both'/'high'/'low' override it.
+ * @default auto
+ * @enum {string}
*/
- transformer?: components["schemas"]["TransformerField"] | null;
+ target?: "auto" | "both" | "high" | "low";
/**
- * Qwen3 Encoder
- * @description Qwen3 tokenizer and text encoder
+ * Wan Transformer
+ * @description Transformer
* @default null
*/
- qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ transformer?: components["schemas"]["WanTransformerField"] | null;
/**
* type
- * @default z_image_lora_loader
+ * @default wan_lora_loader
* @constant
*/
- type: "z_image_lora_loader";
+ type: "wan_lora_loader";
};
/**
- * ZImageLoRALoaderOutput
- * @description Z-Image LoRA Loader Output
+ * WanLoRALoaderOutput
+ * @description Wan 2.2 LoRA loader output.
*/
- ZImageLoRALoaderOutput: {
+ WanLoRALoaderOutput: {
/**
- * Z-Image Transformer
+ * Wan Transformer
* @description Transformer
* @default null
*/
- transformer: components["schemas"]["TransformerField"] | null;
- /**
- * Qwen3 Encoder
- * @description Qwen3 tokenizer and text encoder
- * @default null
- */
- qwen3_encoder: components["schemas"]["Qwen3EncoderField"] | null;
+ transformer: components["schemas"]["WanTransformerField"] | null;
/**
* type
- * @default z_image_lora_loader_output
+ * @default wan_lora_loader_output
* @constant
*/
- type: "z_image_lora_loader_output";
+ type: "wan_lora_loader_output";
};
/**
- * Main Model - Z-Image
- * @description Loads a Z-Image model, outputting its submodels.
+ * WanLoRAVariantType
+ * @description Wan 2.2 LoRA variants, identifying which model family a LoRA targets.
*
- * Similar to FLUX, you can mix and match components:
- * - Transformer: From Z-Image main model (GGUF quantized or Diffusers format)
- * - VAE: Separate FLUX VAE (shared with FLUX models) or from a Diffusers Z-Image model
- * - Qwen3 Encoder: Separate Qwen3Encoder model or from a Diffusers Z-Image model
+ * Detected from the LoRA's inner attention dim: A14B has ``inner_dim=5120``,
+ * TI2V-5B has ``inner_dim=3072``. A14B and 5B LoRAs are NOT interchangeable —
+ * applying one against the wrong main model crashes in the layer patcher
+ * with a tensor-shape error.
+ * @enum {string}
*/
- ZImageModelLoaderInvocation: {
+ WanLoRAVariantType: "a14b" | "5b";
+ /**
+ * Main Model - Wan 2.2
+ * @description Loads a Wan 2.2 model, outputting its submodels.
+ *
+ * Components can be mixed and matched, mirroring the Qwen Image loader pattern:
+ *
+ * - Transformer(s):
+ * * Diffusers main: emits ``transformer/`` and (for A14B) ``transformer_2/``
+ * from the same model record.
+ * * GGUF main: emits the single GGUF as the primary transformer; for A14B
+ * the second-expert GGUF must be wired to ``Transformer (Low Noise)``.
+ * - VAE: standalone Wan VAE > main (if Diffusers) > Component Source (Diffusers).
+ * - UMT5-XXL encoder: standalone Wan T5 encoder > main (if Diffusers) >
+ * Component Source (Diffusers).
+ *
+ * The Component Source slot lets users supply a Diffusers Wan main model purely
+ * for VAE / encoder extraction when the actual transformer is in a single-file
+ * format. Together, the standalone VAE + standalone encoder let a GGUF
+ * transformer run without a full ~30 GB Diffusers install.
+ */
+ WanModelLoaderInvocation: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32658,49 +34208,55 @@ export type components = {
use_cache?: boolean;
/**
* Transformer
- * @description Z-Image model (Transformer) to load
+ * @description Wan 2.2 model (Transformer) to load
*/
model: components["schemas"]["ModelIdentifierField"];
+ /**
+ * Transformer (Low Noise)
+ * @description Optional second GGUF transformer for the A14B low-noise expert. Only relevant when the main model is a single-file GGUF and the variant is A14B; ignored when the main is a Diffusers A14B (both experts are pulled from transformer/ and transformer_2/ already) or when the variant is TI2V-5B.
+ * @default null
+ */
+ transformer_low_noise_model?: components["schemas"]["ModelIdentifierField"] | null;
/**
* VAE
- * @description Standalone VAE model. Z-Image uses the same VAE as FLUX (16-channel). If not provided, VAE will be loaded from the Qwen3 Source model.
+ * @description Standalone Wan VAE model. If not set, the VAE is loaded from the main model (when in Diffusers format) or from the Component Source.
* @default null
*/
vae_model?: components["schemas"]["ModelIdentifierField"] | null;
/**
- * Qwen3 Encoder
- * @description Standalone Qwen3 Encoder model. If not provided, encoder will be loaded from the Qwen3 Source model.
+ * Wan T5 Encoder
+ * @description Standalone Wan UMT5-XXL encoder. If not set, the encoder is loaded from the main model (when in Diffusers format) or from the Component Source.
* @default null
*/
- qwen3_encoder_model?: components["schemas"]["ModelIdentifierField"] | null;
+ wan_t5_encoder_model?: components["schemas"]["ModelIdentifierField"] | null;
/**
- * Qwen3 Source (Diffusers)
- * @description Diffusers Z-Image model to extract VAE and/or Qwen3 encoder from. Use this if you don't have separate VAE/Qwen3 models. Ignored if both VAE and Qwen3 Encoder are provided separately.
+ * Component Source (Diffusers)
+ * @description Diffusers Wan main model to extract VAE and/or encoder from. Use this if you don't have separate VAE/encoder models. Ignored for any submodel that is provided separately.
* @default null
*/
- qwen3_source_model?: components["schemas"]["ModelIdentifierField"] | null;
+ component_source?: components["schemas"]["ModelIdentifierField"] | null;
/**
* type
- * @default z_image_model_loader
+ * @default wan_model_loader
* @constant
*/
- type: "z_image_model_loader";
+ type: "wan_model_loader";
};
/**
- * ZImageModelLoaderOutput
- * @description Z-Image base model loader output.
+ * WanModelLoaderOutput
+ * @description Wan 2.2 model loader output.
*/
- ZImageModelLoaderOutput: {
+ WanModelLoaderOutput: {
/**
* Transformer
- * @description Transformer
+ * @description Wan transformer (one or two experts depending on the variant)
*/
- transformer: components["schemas"]["TransformerField"];
+ transformer: components["schemas"]["WanTransformerField"];
/**
- * Qwen3 Encoder
- * @description Qwen3 tokenizer and text encoder
+ * UMT5-XXL Encoder
+ * @description UMT5-XXL tokenizer and text encoder for Wan 2.2
*/
- qwen3_encoder: components["schemas"]["Qwen3EncoderField"];
+ wan_t5_encoder: components["schemas"]["WanT5EncoderField"];
/**
* VAE
* @description VAE
@@ -32708,24 +34264,63 @@ export type components = {
vae: components["schemas"]["VAEField"];
/**
* type
- * @default z_image_model_loader_output
+ * @default wan_model_loader_output
* @constant
*/
- type: "z_image_model_loader_output";
+ type: "wan_model_loader_output";
};
/**
- * Seed Variance Enhancer - Z-Image
- * @description Adds seed-based noise to Z-Image conditioning to increase variance between seeds.
+ * WanRefImageConditioningField
+ * @description Reference-image conditioning for Wan 2.2 I2V.
*
- * Z-Image-Turbo can produce relatively similar images with different seeds,
- * making it harder to explore variations of a prompt. This node implements
- * reproducible, seed-based noise injection into text embeddings to increase
- * visual variation while maintaining reproducibility.
+ * Carries the 20-channel VAE-latent condition tensor (4-channel first-frame
+ * mask + 16-channel ref-image latents). The denoise loop concatenates this
+ * to the 16-channel noise latents along the channel dim each step, producing
+ * the 36-channel input the I2V-A14B transformer expects.
*
- * The noise strength is auto-calibrated relative to the embedding's standard
- * deviation, ensuring consistent results across different prompts.
+ * Also carries the spatial dims and frame count used to encode the image so
+ * the denoise node can sanity-check the user's width/height/num_frames — a
+ * latent temporal-dim mismatch is hard to debug from the downstream error.
*/
- ZImageSeedVarianceEnhancerInvocation: {
+ WanRefImageConditioningField: {
+ /**
+ * Condition Tensor Name
+ * @description Name of the saved [1, 20, T_lat, H/8, W/8] condition tensor.
+ */
+ condition_tensor_name: string;
+ /**
+ * Width
+ * @description Image width used during VAE encoding (matches denoise width).
+ */
+ width: number;
+ /**
+ * Height
+ * @description Image height used during VAE encoding (matches denoise height).
+ */
+ height: number;
+ /**
+ * Num Frames
+ * @description Pixel-frame count the condition was built for. 1 for single-frame I2V (image output), 81+ for video.
+ * @default 1
+ */
+ num_frames?: number;
+ };
+ /**
+ * Reference Image - Wan 2.2
+ * @description VAE-encode a reference image into Wan 2.2 I2V conditioning.
+ *
+ * Output is a ``[1, 20, T_lat, height // 8, width // 8]`` condition tensor
+ * that the denoise loop concatenates to the 16-channel noise latents each
+ * step, producing the 36-channel input the I2V-A14B transformer expects.
+ *
+ * For image (single-frame) I2V leave ``num_frames=1`` (T_lat=1). For video
+ * I2V set ``num_frames`` to match the value on the video-denoise node
+ * (e.g. 81 for the Wan 2.2 reference defaults).
+ *
+ * Only works with I2V-A14B (the denoise loop's variant gate enforces this).
+ * For T2V or TI2V-5B, omit this node entirely.
+ */
+ WanRefImageEncoderInvocation: {
/**
* Id
* @description The id of this instance of an invocation. Must be unique among all instances of invocations.
@@ -32744,120 +34339,2509 @@ export type components = {
*/
use_cache?: boolean;
/**
- * Conditioning
- * @description Conditioning tensor
+ * @description Reference image to condition on.
* @default null
*/
- conditioning?: components["schemas"]["ZImageConditioningField"] | null;
+ image?: components["schemas"]["ImageField"] | null;
/**
- * Seed
- * @description Seed for reproducible noise generation. Different seeds produce different noise patterns.
- * @default 0
+ * VAE
+ * @description VAE
+ * @default null
*/
- seed?: number;
+ vae?: components["schemas"]["VAEField"] | null;
/**
- * Strength
- * @description Noise strength as multiplier of embedding std. 0=off, 0.1=subtle, 0.5=strong.
- * @default 0.1
+ * Width
+ * @description Width to resize the reference image to (must match denoise width).
+ * @default 1024
*/
- strength?: number;
+ width?: number;
/**
- * Randomize Percent
- * @description Percentage of embedding values to add noise to (1-100). Lower values create more selective noise patterns.
- * @default 50
+ * Height
+ * @description Height to resize the reference image to (must match denoise height).
+ * @default 1024
*/
- randomize_percent?: number;
+ height?: number;
+ /**
+ * Number of Frames
+ * @description Pixel-frame count to build the condition for. Use 1 for single-frame image I2V. For video I2V, set this to match the video-denoise node's num_frames (and ensure (num_frames - 1) %% 4 == 0, e.g. 81).
+ * @default 1
+ */
+ num_frames?: number;
/**
* type
- * @default z_image_seed_variance_enhancer
+ * @default wan_ref_image_encoder
* @constant
*/
- type: "z_image_seed_variance_enhancer";
+ type: "wan_ref_image_encoder";
};
/**
- * Prompt - Z-Image
- * @description Encodes and preps a prompt for a Z-Image image.
- *
- * Supports regional prompting by connecting a mask input.
+ * WanRefImageOutput
+ * @description Output of a Wan 2.2 reference-image VAE-encoder.
*/
- ZImageTextEncoderInvocation: {
+ WanRefImageOutput: {
/**
- * Id
- * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ * Reference Image
+ * @description VAE-latent reference-image conditioning for Wan 2.2 I2V.
*/
- id: string;
+ ref_image: components["schemas"]["WanRefImageConditioningField"];
/**
- * Is Intermediate
- * @description Whether or not this is an intermediate invocation.
- * @default false
+ * type
+ * @default wan_ref_image_output
+ * @constant
*/
- is_intermediate?: boolean;
+ type: "wan_ref_image_output";
+ };
+ /**
+ * WanT5EncoderField
+ * @description Field for the UMT5-XXL text encoder used by Wan 2.2 models.
+ */
+ WanT5EncoderField: {
+ /** @description Info to load tokenizer submodel */
+ tokenizer: components["schemas"]["ModelIdentifierField"];
+ /** @description Info to load text_encoder submodel */
+ text_encoder: components["schemas"]["ModelIdentifierField"];
/**
- * Use Cache
- * @description Whether or not to use the cache
- * @default true
+ * Loras
+ * @description LoRAs to apply on model loading
*/
- use_cache?: boolean;
+ loras?: components["schemas"]["LoRAField"][];
+ };
+ /**
+ * WanT5Encoder_WanT5Encoder_Config
+ * @description UMT5-XXL encoder in diffusers folder layout.
+ *
+ * Accepts either:
+ * - A directory containing ``text_encoder/`` (and typically ``tokenizer/``) ─ the
+ * shape produced by ``Wan-AI/Wan2.2-T2V-A14B::text_encoder+tokenizer``.
+ * - A bare ``text_encoder/`` directory whose own ``config.json`` declares
+ * ``model_type: umt5``.
+ */
+ WanT5Encoder_WanT5Encoder_Config: {
/**
- * Prompt
- * @description Text prompt to encode.
- * @default null
+ * Key
+ * @description A unique key for this model.
*/
- prompt?: string | null;
+ key: string;
/**
- * Qwen3 Encoder
- * @description Qwen3 tokenizer and text encoder
- * @default null
+ * Hash
+ * @description The hash of the model file(s).
*/
- qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ hash: string;
/**
- * @description A mask defining the region that this conditioning prompt applies to.
- * @default null
+ * Path
+ * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory.
*/
- mask?: components["schemas"]["TensorField"] | null;
+ path: string;
/**
- * type
- * @default z_image_text_encoder
- * @constant
+ * File Size
+ * @description The size of the model in bytes.
*/
- type: "z_image_text_encoder";
- };
+ file_size: number;
+ /**
+ * Name
+ * @description Name of the model.
+ */
+ name: string;
+ /**
+ * Description
+ * @description Model description
+ */
+ description: string | null;
+ /**
+ * Source
+ * @description The original source of the model (path, URL or repo_id).
+ */
+ source: string;
+ /** @description The type of source */
+ source_type: components["schemas"]["ModelSourceType"];
+ /**
+ * Source Api Response
+ * @description The original API response from the source, as stringified JSON.
+ */
+ source_api_response: string | null;
+ /**
+ * Source Url
+ * @description Optional URL for the model (e.g. download page or model page).
+ */
+ source_url: string | null;
+ /**
+ * Cover Image
+ * @description Url for image to preview model
+ */
+ cover_image: string | null;
+ /**
+ * Base
+ * @default any
+ * @constant
+ */
+ base: "any";
+ /**
+ * Type
+ * @default wan_t5_encoder
+ * @constant
+ */
+ type: "wan_t5_encoder";
+ /**
+ * Format
+ * @default wan_t5_encoder
+ * @constant
+ */
+ format: "wan_t5_encoder";
+ };
/**
- * ZImageVariantType
- * @description Z-Image model variants.
+ * Prompt - Wan 2.2
+ * @description Encodes a text prompt for Wan 2.2 using the UMT5-XXL encoder.
+ *
+ * Output is the encoder's last hidden state (shape: [seq_len=226, 4096]) plus
+ * an attention mask marking valid (non-padding) tokens. The Wan transformer
+ * consumes these directly as ``encoder_hidden_states``.
+ */
+ WanTextEncoderInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * Prompt
+ * @description Text prompt for Wan 2.2.
+ * @default null
+ */
+ prompt?: string | null;
+ /**
+ * UMT5-XXL Encoder
+ * @description UMT5-XXL tokenizer and text encoder for Wan 2.2
+ * @default null
+ */
+ wan_t5_encoder?: components["schemas"]["WanT5EncoderField"] | null;
+ /**
+ * type
+ * @default wan_text_encoder
+ * @constant
+ */
+ type: "wan_text_encoder";
+ };
+ /**
+ * WanTransformerField
+ * @description Transformer field for Wan 2.2 models.
+ *
+ * Wan 2.2 A14B is a Mixture-of-Experts model with two transformer experts:
+ * a high-noise expert (active at large timesteps) and a low-noise expert
+ * (active at small timesteps). TI2V-5B is a single-transformer model and only
+ * populates ``transformer``.
+ *
+ * ``boundary_ratio`` matches Diffusers' ``WanPipeline`` semantics: it's the
+ * boundary timestep as a fraction of ``num_train_timesteps`` (typically 1000),
+ * so ``boundary_ratio=0.875`` means the high-noise expert handles t >= 875 and
+ * the low-noise expert handles t < 875.
+ */
+ WanTransformerField: {
+ /** @description Primary transformer submodel. For A14B this is the high-noise expert. */
+ transformer: components["schemas"]["ModelIdentifierField"];
+ /**
+ * @description Low-noise transformer expert (Wan 2.2 A14B only). None for TI2V-5B.
+ * @default null
+ */
+ transformer_low_noise?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Loras
+ * @description LoRAs to apply to the primary transformer. For A14B applied to the high-noise expert.
+ */
+ loras?: components["schemas"]["LoRAField"][];
+ /**
+ * Loras Low Noise
+ * @description Optional separate LoRAs for the low-noise expert (Wan 2.2 A14B). If empty and transformer_low_noise is set, the primary 'loras' list is reused.
+ */
+ loras_low_noise?: components["schemas"]["LoRAField"][];
+ /**
+ * Boundary Ratio
+ * @description Boundary timestep as a fraction of num_train_timesteps (Wan 2.2 A14B only). High-noise expert: t >= boundary_ratio * num_train_timesteps. Low-noise expert: t below. Ignored for TI2V-5B.
+ * @default 0.875
+ */
+ boundary_ratio?: number;
+ };
+ /**
+ * WanVariantType
+ * @description Wan 2.2 model variants.
+ *
+ * All variants are used for image generation at num_frames=1. The A14B family
+ * is a Mixture-of-Experts (high-noise + low-noise) totalling ~28B params; the
+ * T2V sub-variant takes text only, while the I2V sub-variant additionally
+ * conditions on a reference image (encoded by the VAE and concatenated to the
+ * noise latents along the channel dim — its transformer has ``in_channels=36``
+ * instead of ``16``). TI2V-5B is a single ~5B transformer with a
+ * higher-compression VAE (z_dim=48).
* @enum {string}
*/
- ZImageVariantType: "turbo" | "zbase";
+ WanVariantType: "t2v_a14b" | "i2v_a14b" | "ti2v_5b";
+ /**
+ * Denoise Video - Wan 2.2
+ * @description Run the Wan 2.2 denoising loop on a multi-frame latent tensor.
+ *
+ * The output is a 5D ``[1, C, T_lat, H/8, W/8]`` latent tensor ready for
+ * :class:`WanLatentsToVideoInvocation` to VAE-decode and encode as MP4.
+ *
+ * Mirrors :class:`WanDenoiseInvocation` for the per-step logic (CFG, MoE
+ * expert swap at the boundary timestep, LoRA patching, scheduler selection).
+ * Differences from the image denoise:
+ *
+ * * The noise tensor has a real temporal dim built from ``num_frames``.
+ * * The I2V condition is built across all latent frames (frame 0
+ * conditioned, rest zero) via
+ * :func:`encode_reference_image_to_video_condition` upstream — the
+ * ``ref_image`` field on this node carries a tensor of shape
+ * ``[1, 20, T_lat, H_lat, W_lat]`` instead of ``[1, 20, 1, ...]``.
+ * * Inpaint / img2img are not supported — out of scope for the minimal
+ * video path. The base ``WanDenoiseInvocation`` still handles those.
+ */
+ WanVideoDenoiseInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * Transformer
+ * @description Wan transformer field. Supported: T2V-A14B / I2V-A14B (dual-expert) and TI2V-5B (single-expert, handles both T2V and I2V). All three accept a Reference Image input for image-to-video; A14B uses the 36-channel concat scheme while TI2V-5B uses the expand_timesteps first-frame-mask blend.
+ * @default null
+ */
+ transformer?: components["schemas"]["WanTransformerField"] | null;
+ /**
+ * @description Positive conditioning tensor
+ * @default null
+ */
+ positive_conditioning?: components["schemas"]["WanConditioningField"] | null;
+ /**
+ * @description Negative conditioning tensor
+ * @default null
+ */
+ negative_conditioning?: components["schemas"]["WanConditioningField"] | null;
+ /**
+ * Reference Image
+ * @description Reference-image (VAE-latent) conditioning for Wan 2.2 I2V.
+ * @default null
+ */
+ ref_image?: components["schemas"]["WanRefImageConditioningField"] | null;
+ /**
+ * Guidance Scale
+ * @description Classifier-free guidance scale. Wan 2.2 video reference uses 5.0 for the high-noise expert and 4.0 for the low-noise expert.
+ * @default 5
+ */
+ guidance_scale?: number;
+ /**
+ * Guidance Scale (Low Noise)
+ * @description Optional separate CFG scale for the low-noise expert (Wan 2.2 A14B only). Values below 1.0 fall back to the primary 'Guidance Scale'.
+ * @default 4
+ */
+ guidance_scale_low_noise?: number | null;
+ /**
+ * Width
+ * @description Width of the generated video.
+ * @default 832
+ */
+ width?: number;
+ /**
+ * Height
+ * @description Height of the generated video.
+ * @default 480
+ */
+ height?: number;
+ /**
+ * Number of Frames
+ * @description Number of output frames. Must satisfy (num_frames - 1) %% 4 == 0 so the latent temporal dim divides cleanly. Wan 2.2 was trained at 81 frames @ 16 FPS (~5 s).
+ * @default 81
+ */
+ num_frames?: number;
+ /**
+ * Steps
+ * @description Number of denoising steps.
+ * @default 40
+ */
+ steps?: number;
+ /**
+ * Seed
+ * @description Randomness seed for reproducibility.
+ * @default 0
+ */
+ seed?: number;
+ /**
+ * type
+ * @default wan_video_denoise
+ * @constant
+ */
+ type: "wan_video_denoise";
+ };
+ /** Workflow */
+ Workflow: {
+ /**
+ * Name
+ * @description The name of the workflow.
+ */
+ name: string;
+ /**
+ * Author
+ * @description The author of the workflow.
+ */
+ author: string;
+ /**
+ * Description
+ * @description The description of the workflow.
+ */
+ description: string;
+ /**
+ * Version
+ * @description The version of the workflow.
+ */
+ version: string;
+ /**
+ * Contact
+ * @description The contact of the workflow.
+ */
+ contact: string;
+ /**
+ * Tags
+ * @description The tags of the workflow.
+ */
+ tags: string;
+ /**
+ * Notes
+ * @description The notes of the workflow.
+ */
+ notes: string;
+ /**
+ * Exposedfields
+ * @description The exposed fields of the workflow.
+ */
+ exposedFields: components["schemas"]["ExposedField"][];
+ /** @description The meta of the workflow. */
+ meta: components["schemas"]["WorkflowMeta"];
+ /**
+ * Nodes
+ * @description The nodes of the workflow.
+ */
+ nodes: {
+ [key: string]: components["schemas"]["JsonValue"];
+ }[];
+ /**
+ * Edges
+ * @description The edges of the workflow.
+ */
+ edges: {
+ [key: string]: components["schemas"]["JsonValue"];
+ }[];
+ /**
+ * Form
+ * @description The form of the workflow.
+ */
+ form?: {
+ [key: string]: components["schemas"]["JsonValue"];
+ } | null;
+ /**
+ * Id
+ * @description The id of the workflow.
+ */
+ id: string;
+ };
+ /** WorkflowAndGraphResponse */
+ WorkflowAndGraphResponse: {
+ /**
+ * Workflow
+ * @description The workflow used to generate the image, as stringified JSON
+ */
+ workflow: string | null;
+ /**
+ * Graph
+ * @description The graph used to generate the image, as stringified JSON
+ */
+ graph: string | null;
+ };
+ /**
+ * WorkflowCategory
+ * @enum {string}
+ */
+ WorkflowCategory: "user" | "default";
+ /** WorkflowMeta */
+ WorkflowMeta: {
+ /**
+ * Version
+ * @description The version of the workflow schema.
+ */
+ version: string;
+ /** @description The category of the workflow (user or default). */
+ category: components["schemas"]["WorkflowCategory"];
+ };
+ /** WorkflowRecordDTO */
+ WorkflowRecordDTO: {
+ /**
+ * Workflow Id
+ * @description The id of the workflow.
+ */
+ workflow_id: string;
+ /**
+ * Name
+ * @description The name of the workflow.
+ */
+ name: string;
+ /**
+ * Created At
+ * @description The created timestamp of the workflow.
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * @description The updated timestamp of the workflow.
+ */
+ updated_at: string;
+ /**
+ * Opened At
+ * @description The opened timestamp of the workflow.
+ */
+ opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
+ /** @description The workflow. */
+ workflow: components["schemas"]["Workflow"];
+ };
+ /** WorkflowRecordListItemWithThumbnailDTO */
+ WorkflowRecordListItemWithThumbnailDTO: {
+ /**
+ * Workflow Id
+ * @description The id of the workflow.
+ */
+ workflow_id: string;
+ /**
+ * Name
+ * @description The name of the workflow.
+ */
+ name: string;
+ /**
+ * Created At
+ * @description The created timestamp of the workflow.
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * @description The updated timestamp of the workflow.
+ */
+ updated_at: string;
+ /**
+ * Opened At
+ * @description The opened timestamp of the workflow.
+ */
+ opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
+ /**
+ * Description
+ * @description The description of the workflow.
+ */
+ description: string;
+ /** @description The description of the workflow. */
+ category: components["schemas"]["WorkflowCategory"];
+ /**
+ * Tags
+ * @description The tags of the workflow.
+ */
+ tags: string;
+ /**
+ * Thumbnail Url
+ * @description The URL of the workflow thumbnail.
+ */
+ thumbnail_url?: string | null;
+ };
+ /**
+ * WorkflowRecordOrderBy
+ * @description The order by options for workflow records
+ * @enum {string}
+ */
+ WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name" | "is_public";
+ /** WorkflowRecordWithThumbnailDTO */
+ WorkflowRecordWithThumbnailDTO: {
+ /**
+ * Workflow Id
+ * @description The id of the workflow.
+ */
+ workflow_id: string;
+ /**
+ * Name
+ * @description The name of the workflow.
+ */
+ name: string;
+ /**
+ * Created At
+ * @description The created timestamp of the workflow.
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * @description The updated timestamp of the workflow.
+ */
+ updated_at: string;
+ /**
+ * Opened At
+ * @description The opened timestamp of the workflow.
+ */
+ opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
+ /** @description The workflow. */
+ workflow: components["schemas"]["Workflow"];
+ /**
+ * Thumbnail Url
+ * @description The URL of the workflow thumbnail.
+ */
+ thumbnail_url?: string | null;
+ };
+ /** WorkflowWithoutID */
+ WorkflowWithoutID: {
+ /**
+ * Name
+ * @description The name of the workflow.
+ */
+ name: string;
+ /**
+ * Author
+ * @description The author of the workflow.
+ */
+ author: string;
+ /**
+ * Description
+ * @description The description of the workflow.
+ */
+ description: string;
+ /**
+ * Version
+ * @description The version of the workflow.
+ */
+ version: string;
+ /**
+ * Contact
+ * @description The contact of the workflow.
+ */
+ contact: string;
+ /**
+ * Tags
+ * @description The tags of the workflow.
+ */
+ tags: string;
+ /**
+ * Notes
+ * @description The notes of the workflow.
+ */
+ notes: string;
+ /**
+ * Exposedfields
+ * @description The exposed fields of the workflow.
+ */
+ exposedFields: components["schemas"]["ExposedField"][];
+ /** @description The meta of the workflow. */
+ meta: components["schemas"]["WorkflowMeta"];
+ /**
+ * Nodes
+ * @description The nodes of the workflow.
+ */
+ nodes: {
+ [key: string]: components["schemas"]["JsonValue"];
+ }[];
+ /**
+ * Edges
+ * @description The edges of the workflow.
+ */
+ edges: {
+ [key: string]: components["schemas"]["JsonValue"];
+ }[];
+ /**
+ * Form
+ * @description The form of the workflow.
+ */
+ form?: {
+ [key: string]: components["schemas"]["JsonValue"];
+ } | null;
+ };
+ /**
+ * ZImageConditioningField
+ * @description A Z-Image conditioning tensor primitive value
+ */
+ ZImageConditioningField: {
+ /**
+ * Conditioning Name
+ * @description The name of conditioning tensor
+ */
+ conditioning_name: string;
+ /**
+ * @description The mask associated with this conditioning tensor for regional prompting. Excluded regions should be set to False, included regions should be set to True.
+ * @default null
+ */
+ mask?: components["schemas"]["TensorField"] | null;
+ };
+ /**
+ * ZImageConditioningOutput
+ * @description Base class for nodes that output a Z-Image text conditioning tensor.
+ */
+ ZImageConditioningOutput: {
+ /** @description Conditioning tensor */
+ conditioning: components["schemas"]["ZImageConditioningField"];
+ /**
+ * type
+ * @default z_image_conditioning_output
+ * @constant
+ */
+ type: "z_image_conditioning_output";
+ };
+ /**
+ * ZImageControlField
+ * @description A Z-Image control conditioning field for spatial control (Canny, HED, Depth, Pose, MLSD).
+ */
+ ZImageControlField: {
+ /**
+ * Image Name
+ * @description The name of the preprocessed control image
+ */
+ image_name: string;
+ /** @description The Z-Image ControlNet adapter model */
+ control_model: components["schemas"]["ModelIdentifierField"];
+ /**
+ * Control Context Scale
+ * @description The strength of the control signal. Recommended range: 0.65-0.80.
+ * @default 0.75
+ */
+ control_context_scale?: number;
+ /**
+ * Begin Step Percent
+ * @description When the control is first applied (% of total steps)
+ * @default 0
+ */
+ begin_step_percent?: number;
+ /**
+ * End Step Percent
+ * @description When the control is last applied (% of total steps)
+ * @default 1
+ */
+ end_step_percent?: number;
+ };
+ /**
+ * Z-Image ControlNet
+ * @description Configure Z-Image ControlNet for spatial conditioning.
+ *
+ * Takes a preprocessed control image (e.g., Canny edges, depth map, pose)
+ * and a Z-Image ControlNet adapter model to enable spatial control.
+ *
+ * Supports 5 control modes: Canny, HED, Depth, Pose, MLSD.
+ * Recommended control_context_scale: 0.65-0.80.
+ */
+ ZImageControlInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The preprocessed control image (Canny, HED, Depth, Pose, or MLSD)
+ * @default null
+ */
+ image?: components["schemas"]["ImageField"] | null;
+ /**
+ * Control Model
+ * @description ControlNet model to load
+ * @default null
+ */
+ control_model?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Control Scale
+ * @description Strength of the control signal. Recommended range: 0.65-0.80.
+ * @default 0.75
+ */
+ control_context_scale?: number;
+ /**
+ * Begin Step Percent
+ * @description When the control is first applied (% of total steps)
+ * @default 0
+ */
+ begin_step_percent?: number;
+ /**
+ * End Step Percent
+ * @description When the control is last applied (% of total steps)
+ * @default 1
+ */
+ end_step_percent?: number;
+ /**
+ * type
+ * @default z_image_control
+ * @constant
+ */
+ type: "z_image_control";
+ };
+ /**
+ * ZImageControlOutput
+ * @description Z-Image Control output containing control configuration.
+ */
+ ZImageControlOutput: {
+ /** @description Z-Image control conditioning */
+ control: components["schemas"]["ZImageControlField"];
+ /**
+ * type
+ * @default z_image_control_output
+ * @constant
+ */
+ type: "z_image_control_output";
+ };
+ /**
+ * Denoise - Z-Image
+ * @description Run the denoising process with a Z-Image model.
+ *
+ * Supports regional prompting by connecting multiple conditioning inputs with masks.
+ */
+ ZImageDenoiseInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description Latents tensor
+ * @default null
+ */
+ latents?: components["schemas"]["LatentsField"] | null;
+ /**
+ * @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
+ * @default null
+ */
+ denoise_mask?: components["schemas"]["DenoiseMaskField"] | null;
+ /**
+ * Denoising Start
+ * @description When to start denoising, expressed a percentage of total steps
+ * @default 0
+ */
+ denoising_start?: number;
+ /**
+ * Denoising End
+ * @description When to stop denoising, expressed a percentage of total steps
+ * @default 1
+ */
+ denoising_end?: number;
+ /**
+ * Add Noise
+ * @description Add noise based on denoising start.
+ * @default true
+ */
+ add_noise?: boolean;
+ /**
+ * Transformer
+ * @description Z-Image model (Transformer) to load
+ * @default null
+ */
+ transformer?: components["schemas"]["TransformerField"] | null;
+ /**
+ * Positive Conditioning
+ * @description Positive conditioning tensor
+ * @default null
+ */
+ positive_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ /**
+ * Negative Conditioning
+ * @description Negative conditioning tensor
+ * @default null
+ */
+ negative_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ /**
+ * Guidance Scale
+ * @description Guidance scale for classifier-free guidance. 1.0 = no CFG (recommended for Z-Image-Turbo). Values > 1.0 amplify guidance.
+ * @default 1
+ */
+ guidance_scale?: number;
+ /**
+ * Width
+ * @description Width of the generated image.
+ * @default 1024
+ */
+ width?: number;
+ /**
+ * Height
+ * @description Height of the generated image.
+ * @default 1024
+ */
+ height?: number;
+ /**
+ * Steps
+ * @description Number of denoising steps. 8 recommended for Z-Image-Turbo.
+ * @default 8
+ */
+ steps?: number;
+ /**
+ * Seed
+ * @description Randomness seed for reproducibility.
+ * @default 0
+ */
+ seed?: number;
+ /**
+ * @description Z-Image control conditioning for spatial control (Canny, HED, Depth, Pose, MLSD).
+ * @default null
+ */
+ control?: components["schemas"]["ZImageControlField"] | null;
+ /**
+ * @description VAE Required for control conditioning.
+ * @default null
+ */
+ vae?: components["schemas"]["VAEField"] | null;
+ /**
+ * Shift
+ * @description Override the timestep shift (mu) for the sigma schedule. Leave blank to auto-calculate based on image dimensions (recommended). Lower values (~0.5) produce less noise shifting, higher values (~1.15) produce more.
+ * @default null
+ */
+ shift?: number | null;
+ /**
+ * Scheduler
+ * @description Scheduler (sampler) for the denoising process. Euler is the default and recommended. Heun is 2nd-order (better quality, 2x slower). LCM works with Turbo only (not Base).
+ * @default euler
+ * @enum {string}
+ */
+ scheduler?: "euler" | "heun" | "lcm";
+ /**
+ * type
+ * @default z_image_denoise
+ * @constant
+ */
+ type: "z_image_denoise";
+ };
+ /**
+ * Denoise - Z-Image + Metadata
+ * @description Run denoising process with a Z-Image transformer model + metadata.
+ */
+ ZImageDenoiseMetaInvocation: {
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description Latents tensor
+ * @default null
+ */
+ latents?: components["schemas"]["LatentsField"] | null;
+ /**
+ * @description A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved.
+ * @default null
+ */
+ denoise_mask?: components["schemas"]["DenoiseMaskField"] | null;
+ /**
+ * Denoising Start
+ * @description When to start denoising, expressed a percentage of total steps
+ * @default 0
+ */
+ denoising_start?: number;
+ /**
+ * Denoising End
+ * @description When to stop denoising, expressed a percentage of total steps
+ * @default 1
+ */
+ denoising_end?: number;
+ /**
+ * Add Noise
+ * @description Add noise based on denoising start.
+ * @default true
+ */
+ add_noise?: boolean;
+ /**
+ * Transformer
+ * @description Z-Image model (Transformer) to load
+ * @default null
+ */
+ transformer?: components["schemas"]["TransformerField"] | null;
+ /**
+ * Positive Conditioning
+ * @description Positive conditioning tensor
+ * @default null
+ */
+ positive_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ /**
+ * Negative Conditioning
+ * @description Negative conditioning tensor
+ * @default null
+ */
+ negative_conditioning?: components["schemas"]["ZImageConditioningField"] | components["schemas"]["ZImageConditioningField"][] | null;
+ /**
+ * Guidance Scale
+ * @description Guidance scale for classifier-free guidance. 1.0 = no CFG (recommended for Z-Image-Turbo). Values > 1.0 amplify guidance.
+ * @default 1
+ */
+ guidance_scale?: number;
+ /**
+ * Width
+ * @description Width of the generated image.
+ * @default 1024
+ */
+ width?: number;
+ /**
+ * Height
+ * @description Height of the generated image.
+ * @default 1024
+ */
+ height?: number;
+ /**
+ * Steps
+ * @description Number of denoising steps. 8 recommended for Z-Image-Turbo.
+ * @default 8
+ */
+ steps?: number;
+ /**
+ * Seed
+ * @description Randomness seed for reproducibility.
+ * @default 0
+ */
+ seed?: number;
+ /**
+ * @description Z-Image control conditioning for spatial control (Canny, HED, Depth, Pose, MLSD).
+ * @default null
+ */
+ control?: components["schemas"]["ZImageControlField"] | null;
+ /**
+ * @description VAE Required for control conditioning.
+ * @default null
+ */
+ vae?: components["schemas"]["VAEField"] | null;
+ /**
+ * Shift
+ * @description Override the timestep shift (mu) for the sigma schedule. Leave blank to auto-calculate based on image dimensions (recommended). Lower values (~0.5) produce less noise shifting, higher values (~1.15) produce more.
+ * @default null
+ */
+ shift?: number | null;
+ /**
+ * Scheduler
+ * @description Scheduler (sampler) for the denoising process. Euler is the default and recommended. Heun is 2nd-order (better quality, 2x slower). LCM works with Turbo only (not Base).
+ * @default euler
+ * @enum {string}
+ */
+ scheduler?: "euler" | "heun" | "lcm";
+ /**
+ * type
+ * @default z_image_denoise_meta
+ * @constant
+ */
+ type: "z_image_denoise_meta";
+ };
+ /**
+ * Image to Latents - Z-Image
+ * @description Generates latents from an image using Z-Image VAE (supports both Diffusers and FLUX VAE).
+ */
+ ZImageImageToLatentsInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description The image to encode.
+ * @default null
+ */
+ image?: components["schemas"]["ImageField"] | null;
+ /**
+ * @description VAE
+ * @default null
+ */
+ vae?: components["schemas"]["VAEField"] | null;
+ /**
+ * type
+ * @default z_image_i2l
+ * @constant
+ */
+ type: "z_image_i2l";
+ };
+ /**
+ * Latents to Image - Z-Image
+ * @description Generates an image from latents using Z-Image VAE (supports both Diffusers and FLUX VAE).
+ */
+ ZImageLatentsToImageInvocation: {
+ /**
+ * @description The board to save the image to
+ * @default null
+ */
+ board?: components["schemas"]["BoardField"] | null;
+ /**
+ * @description Optional metadata to be saved with the image
+ * @default null
+ */
+ metadata?: components["schemas"]["MetadataField"] | null;
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * @description Latents tensor
+ * @default null
+ */
+ latents?: components["schemas"]["LatentsField"] | null;
+ /**
+ * @description VAE
+ * @default null
+ */
+ vae?: components["schemas"]["VAEField"] | null;
+ /**
+ * type
+ * @default z_image_l2i
+ * @constant
+ */
+ type: "z_image_l2i";
+ };
+ /**
+ * Apply LoRA Collection - Z-Image
+ * @description Applies a collection of LoRAs to a Z-Image transformer.
+ */
+ ZImageLoRACollectionLoader: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * LoRAs
+ * @description LoRA models and weights. May be a single LoRA or collection.
+ * @default null
+ */
+ loras?: components["schemas"]["LoRAField"] | components["schemas"]["LoRAField"][] | null;
+ /**
+ * Transformer
+ * @description Transformer
+ * @default null
+ */
+ transformer?: components["schemas"]["TransformerField"] | null;
+ /**
+ * Qwen3 Encoder
+ * @description Qwen3 tokenizer and text encoder
+ * @default null
+ */
+ qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ /**
+ * type
+ * @default z_image_lora_collection_loader
+ * @constant
+ */
+ type: "z_image_lora_collection_loader";
+ };
+ /**
+ * Apply LoRA - Z-Image
+ * @description Apply a LoRA model to a Z-Image transformer and/or Qwen3 text encoder.
+ */
+ ZImageLoRALoaderInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * LoRA
+ * @description LoRA model to load
+ * @default null
+ */
+ lora?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Weight
+ * @description The weight at which the LoRA is applied to each model
+ * @default 0.75
+ */
+ weight?: number;
+ /**
+ * Z-Image Transformer
+ * @description Transformer
+ * @default null
+ */
+ transformer?: components["schemas"]["TransformerField"] | null;
+ /**
+ * Qwen3 Encoder
+ * @description Qwen3 tokenizer and text encoder
+ * @default null
+ */
+ qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ /**
+ * type
+ * @default z_image_lora_loader
+ * @constant
+ */
+ type: "z_image_lora_loader";
+ };
+ /**
+ * ZImageLoRALoaderOutput
+ * @description Z-Image LoRA Loader Output
+ */
+ ZImageLoRALoaderOutput: {
+ /**
+ * Z-Image Transformer
+ * @description Transformer
+ * @default null
+ */
+ transformer: components["schemas"]["TransformerField"] | null;
+ /**
+ * Qwen3 Encoder
+ * @description Qwen3 tokenizer and text encoder
+ * @default null
+ */
+ qwen3_encoder: components["schemas"]["Qwen3EncoderField"] | null;
+ /**
+ * type
+ * @default z_image_lora_loader_output
+ * @constant
+ */
+ type: "z_image_lora_loader_output";
+ };
+ /**
+ * Main Model - Z-Image
+ * @description Loads a Z-Image model, outputting its submodels.
+ *
+ * Similar to FLUX, you can mix and match components:
+ * - Transformer: From Z-Image main model (GGUF quantized or Diffusers format)
+ * - VAE: Separate FLUX VAE (shared with FLUX models) or from a Diffusers Z-Image model
+ * - Qwen3 Encoder: Separate Qwen3Encoder model or from a Diffusers Z-Image model
+ */
+ ZImageModelLoaderInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * Transformer
+ * @description Z-Image model (Transformer) to load
+ */
+ model: components["schemas"]["ModelIdentifierField"];
+ /**
+ * VAE
+ * @description Standalone VAE model. Z-Image uses the same VAE as FLUX (16-channel). If not provided, VAE will be loaded from the Qwen3 Source model.
+ * @default null
+ */
+ vae_model?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Qwen3 Encoder
+ * @description Standalone Qwen3 Encoder model. If not provided, encoder will be loaded from the Qwen3 Source model.
+ * @default null
+ */
+ qwen3_encoder_model?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * Qwen3 Source (Diffusers)
+ * @description Diffusers Z-Image model to extract VAE and/or Qwen3 encoder from. Use this if you don't have separate VAE/Qwen3 models. Ignored if both VAE and Qwen3 Encoder are provided separately.
+ * @default null
+ */
+ qwen3_source_model?: components["schemas"]["ModelIdentifierField"] | null;
+ /**
+ * type
+ * @default z_image_model_loader
+ * @constant
+ */
+ type: "z_image_model_loader";
+ };
+ /**
+ * ZImageModelLoaderOutput
+ * @description Z-Image base model loader output.
+ */
+ ZImageModelLoaderOutput: {
+ /**
+ * Transformer
+ * @description Transformer
+ */
+ transformer: components["schemas"]["TransformerField"];
+ /**
+ * Qwen3 Encoder
+ * @description Qwen3 tokenizer and text encoder
+ */
+ qwen3_encoder: components["schemas"]["Qwen3EncoderField"];
+ /**
+ * VAE
+ * @description VAE
+ */
+ vae: components["schemas"]["VAEField"];
+ /**
+ * type
+ * @default z_image_model_loader_output
+ * @constant
+ */
+ type: "z_image_model_loader_output";
+ };
+ /**
+ * Seed Variance Enhancer - Z-Image
+ * @description Adds seed-based noise to Z-Image conditioning to increase variance between seeds.
+ *
+ * Z-Image-Turbo can produce relatively similar images with different seeds,
+ * making it harder to explore variations of a prompt. This node implements
+ * reproducible, seed-based noise injection into text embeddings to increase
+ * visual variation while maintaining reproducibility.
+ *
+ * The noise strength is auto-calibrated relative to the embedding's standard
+ * deviation, ensuring consistent results across different prompts.
+ */
+ ZImageSeedVarianceEnhancerInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * Conditioning
+ * @description Conditioning tensor
+ * @default null
+ */
+ conditioning?: components["schemas"]["ZImageConditioningField"] | null;
+ /**
+ * Seed
+ * @description Seed for reproducible noise generation. Different seeds produce different noise patterns.
+ * @default 0
+ */
+ seed?: number;
+ /**
+ * Strength
+ * @description Noise strength as multiplier of embedding std. 0=off, 0.1=subtle, 0.5=strong.
+ * @default 0.1
+ */
+ strength?: number;
+ /**
+ * Randomize Percent
+ * @description Percentage of embedding values to add noise to (1-100). Lower values create more selective noise patterns.
+ * @default 50
+ */
+ randomize_percent?: number;
+ /**
+ * type
+ * @default z_image_seed_variance_enhancer
+ * @constant
+ */
+ type: "z_image_seed_variance_enhancer";
+ };
+ /**
+ * Prompt - Z-Image
+ * @description Encodes and preps a prompt for a Z-Image image.
+ *
+ * Supports regional prompting by connecting a mask input.
+ */
+ ZImageTextEncoderInvocation: {
+ /**
+ * Id
+ * @description The id of this instance of an invocation. Must be unique among all instances of invocations.
+ */
+ id: string;
+ /**
+ * Is Intermediate
+ * @description Whether or not this is an intermediate invocation.
+ * @default false
+ */
+ is_intermediate?: boolean;
+ /**
+ * Use Cache
+ * @description Whether or not to use the cache
+ * @default true
+ */
+ use_cache?: boolean;
+ /**
+ * Prompt
+ * @description Text prompt to encode.
+ * @default null
+ */
+ prompt?: string | null;
+ /**
+ * Qwen3 Encoder
+ * @description Qwen3 tokenizer and text encoder
+ * @default null
+ */
+ qwen3_encoder?: components["schemas"]["Qwen3EncoderField"] | null;
+ /**
+ * @description A mask defining the region that this conditioning prompt applies to.
+ * @default null
+ */
+ mask?: components["schemas"]["TensorField"] | null;
+ /**
+ * type
+ * @default z_image_text_encoder
+ * @constant
+ */
+ type: "z_image_text_encoder";
+ };
+ /**
+ * ZImageVariantType
+ * @description Z-Image model variants.
+ * @enum {string}
+ */
+ ZImageVariantType: "turbo" | "zbase";
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+};
+export type $defs = Record;
+export interface operations {
+ get_setup_status_api_v1_auth_status_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SetupStatusResponse"];
+ };
+ };
+ };
+ };
+ login_api_v1_auth_login_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["LoginRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LoginResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ logout_api_v1_auth_logout_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LogoutResponse"];
+ };
+ };
+ };
+ };
+ get_current_user_info_api_v1_auth_me_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"];
+ };
+ };
+ };
+ };
+ update_current_user_api_v1_auth_me_patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UserProfileUpdateRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ setup_admin_api_v1_auth_setup_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SetupRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SetupResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ generate_password_api_v1_auth_generate_password_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GeneratePasswordResponse"];
+ };
+ };
+ };
+ };
+ list_users_api_v1_auth_users_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"][];
+ };
+ };
+ };
+ };
+ create_user_api_v1_auth_users_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["AdminUserCreateRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_user_api_v1_auth_users__user_id__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description User ID */
+ user_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_user_api_v1_auth_users__user_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description User ID */
+ user_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_user_api_v1_auth_users__user_id__patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description User ID */
+ user_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["AdminUserUpdateRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ parse_dynamicprompts: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_parse_dynamicprompts"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DynamicPromptsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ expand_prompt: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ExpandPromptRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ExpandPromptResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ image_to_prompt: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ImageToPromptRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ImageToPromptResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_model_records: {
+ parameters: {
+ query?: {
+ /** @description Base models to include */
+ base_models?: components["schemas"]["BaseModelType"][] | null;
+ /** @description The type of model to get */
+ model_type?: components["schemas"]["ModelType"] | null;
+ /** @description Exact match on the name of the model */
+ model_name?: string | null;
+ /** @description Exact match on the format of the model (e.g. 'diffusers') */
+ model_format?: components["schemas"]["ModelFormat"] | null;
+ /** @description The field to order by */
+ order_by?: components["schemas"]["ModelRecordOrderBy"];
+ /** @description The direction to order by */
+ direction?: components["schemas"]["SQLiteDirection"];
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModelsList"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_missing_models: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description List of models with missing files */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModelsList"];
+ };
+ };
+ };
+ };
+ get_model_records_by_attrs: {
+ parameters: {
+ query: {
+ /** @description The name of the model */
+ name: string;
+ /** @description The type of the model */
+ type: components["schemas"]["ModelType"];
+ /** @description The base model of the model */
+ base: components["schemas"]["BaseModelType"];
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_model_records_by_hash: {
+ parameters: {
+ query: {
+ /** @description The hash of the model */
+ hash: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_model_record: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Key of the model record to fetch. */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The model configuration was retrieved successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /** @example {
+ * "path": "string",
+ * "name": "string",
+ * "base": "sd-1",
+ * "type": "main",
+ * "format": "checkpoint",
+ * "config_path": "string",
+ * "key": "string",
+ * "hash": "string",
+ * "file_size": 1,
+ * "description": "string",
+ * "source": "string",
+ * "converted_at": 0,
+ * "variant": "normal",
+ * "prediction_type": "epsilon",
+ * "repo_variant": "fp16",
+ * "upcast_attention": false
+ * } */
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The model could not be found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_model: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Unique key of model to remove from model registry. */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Model deleted successfully */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Model not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_model_record: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Unique key of model */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ModelRecordChanges"];
+ };
+ };
+ responses: {
+ /** @description The model was updated successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /** @example {
+ * "path": "string",
+ * "name": "string",
+ * "base": "sd-1",
+ * "type": "main",
+ * "format": "checkpoint",
+ * "config_path": "string",
+ * "key": "string",
+ * "hash": "string",
+ * "file_size": 1,
+ * "description": "string",
+ * "source": "string",
+ * "converted_at": 0,
+ * "variant": "normal",
+ * "prediction_type": "epsilon",
+ * "repo_variant": "fp16",
+ * "upcast_attention": false
+ * } */
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The model could not be found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description There is already a model corresponding to the new name */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
};
- responses: never;
- parameters: never;
- requestBodies: never;
- headers: never;
- pathItems: never;
-};
-export type $defs = Record;
-export interface operations {
- get_setup_status_api_v1_auth_status_get: {
+ reidentify_model: {
parameters: {
query?: never;
header?: never;
+ path: {
+ /** @description Key of the model to reidentify. */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The model configuration was retrieved successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /** @example {
+ * "path": "string",
+ * "name": "string",
+ * "base": "sd-1",
+ * "type": "main",
+ * "format": "checkpoint",
+ * "config_path": "string",
+ * "key": "string",
+ * "hash": "string",
+ * "file_size": 1,
+ * "description": "string",
+ * "source": "string",
+ * "converted_at": 0,
+ * "variant": "normal",
+ * "prediction_type": "epsilon",
+ * "repo_variant": "fp16",
+ * "upcast_attention": false
+ * } */
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The model could not be found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ scan_for_models: {
+ parameters: {
+ query?: {
+ /** @description Directory path to search for models */
+ scan_path?: string;
+ };
+ header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Directory scanned successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["SetupStatusResponse"];
+ "application/json": components["schemas"]["FoundModel"][];
+ };
+ };
+ /** @description Invalid directory path */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- login_api_v1_auth_login_post: {
+ get_hugging_face_models: {
+ parameters: {
+ query?: {
+ /** @description Hugging face repo to search for models */
+ hugging_face_repo?: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Hugging Face repo scanned successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HuggingFaceModels"];
+ };
+ };
+ /** @description Invalid hugging face repo */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_model_image: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description The name of model image file to get */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The model image was fetched successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The model image could not be found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_model_image: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Unique key of model image to remove from model_images directory. */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Model image deleted successfully */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Model image not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_model_image: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Unique key of model */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_update_model_image"];
+ };
+ };
+ responses: {
+ /** @description The model image was updated successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ bulk_delete_models: {
parameters: {
query?: never;
header?: never;
@@ -32866,9 +36850,71 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["LoginRequest"];
+ "application/json": components["schemas"]["BulkDeleteModelsRequest"];
+ };
+ };
+ responses: {
+ /** @description Models deleted (possibly with some failures) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BulkDeleteModelsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ bulk_reidentify_models: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BulkReidentifyModelsRequest"];
+ };
+ };
+ responses: {
+ /** @description Models reidentified (possibly with some failures) */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BulkReidentifyModelsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
};
};
+ };
+ list_model_installs: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -32876,8 +36922,53 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["LoginResponse"];
+ "application/json": components["schemas"]["ModelInstallJob"][];
+ };
+ };
+ };
+ };
+ install_model: {
+ parameters: {
+ query: {
+ /** @description Model source to install, can be a local path, repo_id, or remote URL */
+ source: string;
+ /** @description Whether or not to install a local model in place */
+ inplace?: boolean | null;
+ /** @description access token for the remote resource */
+ access_token?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ModelRecordChanges"];
+ };
+ };
+ responses: {
+ /** @description The model imported successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModelInstallJob"];
+ };
+ };
+ /** @description There is already a model corresponding to this path or repo_id */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Unrecognized file/folder format */
+ 415: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -32888,9 +36979,16 @@ export interface operations {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
+ /** @description The model appeared to import successfully, but could not be found in the model manager */
+ 424: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
};
};
- logout_api_v1_auth_logout_post: {
+ prune_model_install_jobs: {
parameters: {
query?: never;
header?: never;
@@ -32905,52 +37003,98 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["LogoutResponse"];
+ "application/json": unknown;
+ };
+ };
+ /** @description All completed and errored jobs have been pruned */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ install_hugging_face_model: {
+ parameters: {
+ query: {
+ /** @description HuggingFace repo_id to install */
+ source: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The model is being installed */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/html": string;
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description There is already a model corresponding to this path or repo_id */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- get_current_user_info_api_v1_auth_me_get: {
+ get_model_install_job: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description Model install id */
+ id: number;
+ };
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UserDTO"];
+ "application/json": components["schemas"]["ModelInstallJob"];
};
};
- };
- };
- update_current_user_api_v1_auth_me_patch: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["UserProfileUpdateRequest"];
- };
- };
- responses: {
- /** @description Successful Response */
- 200: {
+ /** @description No such job */
+ 404: {
headers: {
[name: string]: unknown;
};
- content: {
- "application/json": components["schemas"]["UserDTO"];
- };
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -32963,27 +37107,33 @@ export interface operations {
};
};
};
- setup_admin_api_v1_auth_setup_post: {
+ cancel_model_install_job: {
parameters: {
query?: never;
header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["SetupRequest"];
+ path: {
+ /** @description Model install job ID */
+ id: number;
};
+ cookie?: never;
};
+ requestBody?: never;
responses: {
- /** @description Successful Response */
- 200: {
+ /** @description The job was cancelled successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["SetupResponse"];
+ "application/json": unknown;
+ };
+ };
+ /** @description No such job */
+ 415: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -32996,67 +37146,72 @@ export interface operations {
};
};
};
- generate_password_api_v1_auth_generate_password_get: {
+ pause_model_install_job: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description Model install job ID */
+ id: number;
+ };
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
- 200: {
+ /** @description The job was paused successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["GeneratePasswordResponse"];
+ "application/json": components["schemas"]["ModelInstallJob"];
};
};
- };
- };
- list_users_api_v1_auth_users_get: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
+ /** @description No such job */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UserDTO"][];
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- create_user_api_v1_auth_users_post: {
+ resume_model_install_job: {
parameters: {
query?: never;
header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["AdminUserCreateRequest"];
+ path: {
+ /** @description Model install job ID */
+ id: number;
};
+ cookie?: never;
};
+ requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description The job was resumed successfully */
201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UserDTO"];
+ "application/json": components["schemas"]["ModelInstallJob"];
+ };
+ };
+ /** @description No such job */
+ 415: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -33069,26 +37224,33 @@ export interface operations {
};
};
};
- get_user_api_v1_auth_users__user_id__get: {
+ restart_failed_model_install_job: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description User ID */
- user_id: string;
+ /** @description Model install job ID */
+ id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
- 200: {
+ /** @description Failed files restarted successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UserDTO"];
+ "application/json": components["schemas"]["ModelInstallJob"];
+ };
+ };
+ /** @description No such job */
+ 415: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -33101,20 +37263,33 @@ export interface operations {
};
};
};
- delete_user_api_v1_auth_users__user_id__delete: {
+ restart_model_install_file: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description User ID */
- user_id: string;
+ /** @description Model install job ID */
+ id: number;
};
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": string;
+ };
+ };
responses: {
- /** @description Successful Response */
- 204: {
+ /** @description File restarted successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ModelInstallJob"];
+ };
+ };
+ /** @description No such job */
+ 415: {
headers: {
[name: string]: unknown;
};
@@ -33131,30 +37306,65 @@ export interface operations {
};
};
};
- update_user_api_v1_auth_users__user_id__patch: {
+ convert_model: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description User ID */
- user_id: string;
+ /** @description Unique key of the safetensors main model to convert to diffusers format. */
+ key: string;
};
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["AdminUserUpdateRequest"];
- };
- };
+ requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Model converted successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UserDTO"];
+ /** @example {
+ * "path": "string",
+ * "name": "string",
+ * "base": "sd-1",
+ * "type": "main",
+ * "format": "checkpoint",
+ * "config_path": "string",
+ * "key": "string",
+ * "hash": "string",
+ * "file_size": 1,
+ * "description": "string",
+ * "source": "string",
+ * "converted_at": 0,
+ * "variant": "normal",
+ * "prediction_type": "epsilon",
+ * "repo_variant": "fp16",
+ * "upcast_attention": false
+ * } */
+ "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_Wan_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_Wan_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_Wan_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["VAE_Diffusers_Wan_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Wan_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["WanT5Encoder_WanT5Encoder_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ };
+ };
+ /** @description Bad request */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Model not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description There is already a model registered at this location */
+ 409: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -33167,18 +37377,14 @@ export interface operations {
};
};
};
- parse_dynamicprompts: {
+ get_starter_models: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["Body_parse_dynamicprompts"];
- };
- };
+ requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -33186,32 +37392,39 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DynamicPromptsResponse"];
+ "application/json": components["schemas"]["StarterModelResponse"];
};
};
- /** @description Validation Error */
- 422: {
+ };
+ };
+ get_stats: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HTTPValidationError"];
+ "application/json": components["schemas"]["CacheStats"] | null;
};
};
};
};
- expand_prompt: {
+ empty_model_cache: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["ExpandPromptRequest"];
- };
- };
+ requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -33219,21 +37432,32 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ExpandPromptResponse"];
+ "application/json": unknown;
};
};
- /** @description Validation Error */
- 422: {
+ };
+ };
+ get_hf_login_status: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HTTPValidationError"];
+ "application/json": components["schemas"]["HFTokenStatus"];
};
};
};
};
- image_to_prompt: {
+ do_hf_login: {
parameters: {
query?: never;
header?: never;
@@ -33242,7 +37466,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["ImageToPromptRequest"];
+ "application/json": components["schemas"]["Body_do_hf_login"];
};
};
responses: {
@@ -33252,7 +37476,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageToPromptResponse"];
+ "application/json": components["schemas"]["HFTokenStatus"];
};
};
/** @description Validation Error */
@@ -33266,22 +37490,9 @@ export interface operations {
};
};
};
- list_model_records: {
+ reset_hf_token: {
parameters: {
- query?: {
- /** @description Base models to include */
- base_models?: components["schemas"]["BaseModelType"][] | null;
- /** @description The type of model to get */
- model_type?: components["schemas"]["ModelType"] | null;
- /** @description Exact match on the name of the model */
- model_name?: string | null;
- /** @description Exact match on the format of the model (e.g. 'diffusers') */
- model_format?: components["schemas"]["ModelFormat"] | null;
- /** @description The field to order by */
- order_by?: components["schemas"]["ModelRecordOrderBy"];
- /** @description The direction to order by */
- direction?: components["schemas"]["SQLiteDirection"];
- };
+ query?: never;
header?: never;
path?: never;
cookie?: never;
@@ -33294,21 +37505,12 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelsList"];
- };
- };
- /** @description Validation Error */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["HTTPValidationError"];
+ "application/json": components["schemas"]["HFTokenStatus"];
};
};
};
};
- list_missing_models: {
+ get_orphaned_models: {
parameters: {
query?: never;
header?: never;
@@ -33317,32 +37519,29 @@ export interface operations {
};
requestBody?: never;
responses: {
- /** @description List of models with missing files */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelsList"];
+ "application/json": components["schemas"]["OrphanedModelInfo"][];
};
};
};
};
- get_model_records_by_attrs: {
+ delete_orphaned_models: {
parameters: {
- query: {
- /** @description The name of the model */
- name: string;
- /** @description The type of the model */
- type: components["schemas"]["ModelType"];
- /** @description The base model of the model */
- base: components["schemas"]["BaseModelType"];
- };
+ query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["DeleteOrphanedModelsRequest"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -33350,7 +37549,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ "application/json": components["schemas"]["DeleteOrphanedModelsResponse"];
};
};
/** @description Validation Error */
@@ -33364,12 +37563,9 @@ export interface operations {
};
};
};
- get_model_records_by_hash: {
+ list_downloads: {
parameters: {
- query: {
- /** @description The hash of the model */
- hash: string;
- };
+ query?: never;
header?: never;
path?: never;
cookie?: never;
@@ -33382,59 +37578,36 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
- };
- };
- /** @description Validation Error */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["HTTPValidationError"];
+ "application/json": components["schemas"]["DownloadJob"][];
};
};
};
};
- get_model_record: {
+ prune_downloads: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description Key of the model record to fetch. */
- key: string;
- };
+ path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description The model configuration was retrieved successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /** @example {
- * "path": "string",
- * "name": "string",
- * "base": "sd-1",
- * "type": "main",
- * "format": "checkpoint",
- * "config_path": "string",
- * "key": "string",
- * "hash": "string",
- * "file_size": 1,
- * "description": "string",
- * "source": "string",
- * "converted_at": 0,
- * "variant": "normal",
- * "prediction_type": "epsilon",
- * "repo_variant": "fp16",
- * "upcast_attention": false
- * } */
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ "application/json": unknown;
};
};
+ /** @description All completed jobs have been pruned */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
/** @description Bad request */
400: {
headers: {
@@ -33442,12 +37615,29 @@ export interface operations {
};
content?: never;
};
- /** @description The model could not be found */
- 404: {
+ };
+ };
+ download: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_download"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ "application/json": components["schemas"]["DownloadJob"];
+ };
};
/** @description Validation Error */
422: {
@@ -33460,26 +37650,28 @@ export interface operations {
};
};
};
- delete_model: {
+ get_download_job: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Unique key of model to remove from model registry. */
- key: string;
+ /** @description ID of the download job to fetch. */
+ id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Model deleted successfully */
- 204: {
+ /** @description Success */
+ 200: {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ "application/json": components["schemas"]["DownloadJob"];
+ };
};
- /** @description Model not found */
+ /** @description The requested download JobID could not be found */
404: {
headers: {
[name: string]: unknown;
@@ -33497,70 +37689,41 @@ export interface operations {
};
};
};
- update_model_record: {
+ cancel_download_job: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Unique key of model */
- key: string;
+ /** @description ID of the download job to cancel. */
+ id: number;
};
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["ModelRecordChanges"];
- };
- };
+ requestBody?: never;
responses: {
- /** @description The model was updated successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /** @example {
- * "path": "string",
- * "name": "string",
- * "base": "sd-1",
- * "type": "main",
- * "format": "checkpoint",
- * "config_path": "string",
- * "key": "string",
- * "hash": "string",
- * "file_size": 1,
- * "description": "string",
- * "source": "string",
- * "converted_at": 0,
- * "variant": "normal",
- * "prediction_type": "epsilon",
- * "repo_variant": "fp16",
- * "upcast_attention": false
- * } */
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
+ "application/json": unknown;
};
};
- /** @description Bad request */
- 400: {
+ /** @description Job has been cancelled */
+ 204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
- /** @description The model could not be found */
+ /** @description The requested download JobID could not be found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
- /** @description There is already a model corresponding to the new name */
- 409: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
/** @description Validation Error */
422: {
headers: {
@@ -33572,93 +37735,68 @@ export interface operations {
};
};
};
- reidentify_model: {
+ cancel_all_download_jobs: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description Key of the model to reidentify. */
- key: string;
- };
+ path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description The model configuration was retrieved successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /** @example {
- * "path": "string",
- * "name": "string",
- * "base": "sd-1",
- * "type": "main",
- * "format": "checkpoint",
- * "config_path": "string",
- * "key": "string",
- * "hash": "string",
- * "file_size": 1,
- * "description": "string",
- * "source": "string",
- * "converted_at": 0,
- * "variant": "normal",
- * "prediction_type": "epsilon",
- * "repo_variant": "fp16",
- * "upcast_attention": false
- * } */
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
- };
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
- /** @description The model could not be found */
- 404: {
- headers: {
- [name: string]: unknown;
+ "application/json": unknown;
};
- content?: never;
};
- /** @description Validation Error */
- 422: {
+ /** @description Download jobs have been cancelled */
+ 204: {
headers: {
[name: string]: unknown;
};
- content: {
- "application/json": components["schemas"]["HTTPValidationError"];
- };
+ content?: never;
};
};
};
- scan_for_models: {
+ upload_image: {
parameters: {
- query?: {
- /** @description Directory path to search for models */
- scan_path?: string;
+ query: {
+ /** @description The category of the image */
+ image_category: components["schemas"]["ImageCategory"];
+ /** @description Whether this is an intermediate image */
+ is_intermediate: boolean;
+ /** @description The board to add this image to, if any */
+ board_id?: string | null;
+ /** @description The session ID associated with this upload, if any */
+ session_id?: string | null;
+ /** @description Whether to crop the image */
+ crop_visible?: boolean | null;
};
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_upload_image"];
+ };
+ };
responses: {
- /** @description Directory scanned successfully */
- 200: {
+ /** @description The image was uploaded successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["FoundModel"][];
+ "application/json": components["schemas"]["ImageDTO"];
};
};
- /** @description Invalid directory path */
- 400: {
+ /** @description Image upload failed */
+ 415: {
headers: {
[name: string]: unknown;
};
@@ -33675,11 +37813,27 @@ export interface operations {
};
};
};
- get_hugging_face_models: {
+ list_image_dtos: {
parameters: {
query?: {
- /** @description Hugging face repo to search for models */
- hugging_face_repo?: string;
+ /** @description The origin of images to list. */
+ image_origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories of image to include. */
+ categories?: components["schemas"]["ImageCategory"][] | null;
+ /** @description Whether to list intermediate images. */
+ is_intermediate?: boolean | null;
+ /** @description The board id to filter by. Use 'none' to find images without a board. */
+ board_id?: string | null;
+ /** @description The page offset */
+ offset?: number;
+ /** @description The number of images per page */
+ limit?: number;
+ /** @description The order of sort */
+ order_dir?: components["schemas"]["SQLiteDirection"];
+ /** @description Whether to sort by starred images first */
+ starred_first?: boolean;
+ /** @description The term to search for */
+ search_term?: string | null;
};
header?: never;
path?: never;
@@ -33687,21 +37841,14 @@ export interface operations {
};
requestBody?: never;
responses: {
- /** @description Hugging Face repo scanned successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HuggingFaceModels"];
- };
- };
- /** @description Invalid hugging face repo */
- 400: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -33714,40 +37861,27 @@ export interface operations {
};
};
};
- get_model_image: {
+ create_image_upload_entry: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of model image file to get */
- key: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_create_image_upload_entry"];
+ };
+ };
responses: {
- /** @description The model image was fetched successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
- };
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
- /** @description The model image could not be found */
- 404: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["ImageUploadEntry"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -33760,31 +37894,26 @@ export interface operations {
};
};
};
- delete_model_image: {
+ get_image_dto: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Unique key of model image to remove from model_images directory. */
- key: string;
+ /** @description The name of image to get */
+ image_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Model image deleted successfully */
- 204: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
- content?: never;
- };
- /** @description Model image not found */
- 404: {
- headers: {
- [name: string]: unknown;
+ content: {
+ "application/json": components["schemas"]["ImageDTO"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -33797,37 +37926,26 @@ export interface operations {
};
};
};
- update_model_image: {
+ delete_image: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Unique key of model */
- key: string;
+ /** @description The name of the image to delete */
+ image_name: string;
};
cookie?: never;
};
- requestBody: {
- content: {
- "multipart/form-data": components["schemas"]["Body_update_model_image"];
- };
- };
+ requestBody?: never;
responses: {
- /** @description The model image was updated successfully */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
- };
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["DeleteImagesResult"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -33840,26 +37958,29 @@ export interface operations {
};
};
};
- bulk_delete_models: {
+ update_image: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of the image to update */
+ image_name: string;
+ };
cookie?: never;
};
requestBody: {
content: {
- "application/json": components["schemas"]["BulkDeleteModelsRequest"];
+ "application/json": components["schemas"]["ImageRecordChanges"];
};
};
responses: {
- /** @description Models deleted (possibly with some failures) */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["BulkDeleteModelsResponse"];
+ "application/json": components["schemas"]["ImageDTO"];
};
};
/** @description Validation Error */
@@ -33873,40 +37994,27 @@ export interface operations {
};
};
};
- bulk_reidentify_models: {
+ get_intermediates_count: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["BulkReidentifyModelsRequest"];
- };
- };
+ requestBody?: never;
responses: {
- /** @description Models reidentified (possibly with some failures) */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["BulkReidentifyModelsResponse"];
- };
- };
- /** @description Validation Error */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["HTTPValidationError"];
+ "application/json": number;
};
};
};
};
- list_model_installs: {
+ clear_intermediates: {
parameters: {
query?: never;
header?: never;
@@ -33921,53 +38029,31 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"][];
+ "application/json": number;
};
};
};
};
- install_model: {
+ get_image_metadata: {
parameters: {
- query: {
- /** @description Model source to install, can be a local path, repo_id, or remote URL */
- source: string;
- /** @description Whether or not to install a local model in place */
- inplace?: boolean | null;
- /** @description access token for the remote resource */
- access_token?: string | null;
- };
+ query?: never;
header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["ModelRecordChanges"];
+ path: {
+ /** @description The name of image to get */
+ image_name: string;
};
+ cookie?: never;
};
+ requestBody?: never;
responses: {
- /** @description The model imported successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
- };
- };
- /** @description There is already a model corresponding to this path or repo_id */
- 409: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
- /** @description Unrecognized file/folder format */
- 415: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["MetadataField"] | null;
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -33978,20 +38064,16 @@ export interface operations {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
- /** @description The model appeared to import successfully, but could not be found in the model manager */
- 424: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
};
};
- prune_model_install_jobs: {
+ get_image_workflow: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of image whose workflow to get */
+ image_name: string;
+ };
cookie?: never;
};
requestBody?: never;
@@ -34002,55 +38084,43 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": components["schemas"]["WorkflowAndGraphResponse"];
};
};
- /** @description All completed and errored jobs have been pruned */
- 204: {
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
- content?: never;
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
- content?: never;
};
};
};
- install_hugging_face_model: {
+ get_image_full: {
parameters: {
- query: {
- /** @description HuggingFace repo_id to install */
- source: string;
- };
+ query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of full-resolution image file to get */
+ image_name: string;
+ };
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description The model is being installed */
- 201: {
+ /** @description Return the full-resolution image */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "text/html": string;
- };
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
+ "image/png": unknown;
};
- content?: never;
};
- /** @description There is already a model corresponding to this path or repo_id */
- 409: {
+ /** @description Image not found */
+ 404: {
headers: {
[name: string]: unknown;
};
@@ -34067,28 +38137,28 @@ export interface operations {
};
};
};
- get_model_install_job: {
+ get_image_full_head: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Model install id */
- id: number;
+ /** @description The name of full-resolution image file to get */
+ image_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Success */
+ /** @description Return the full-resolution image */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
+ "image/png": unknown;
};
};
- /** @description No such job */
+ /** @description Image not found */
404: {
headers: {
[name: string]: unknown;
@@ -34106,29 +38176,29 @@ export interface operations {
};
};
};
- cancel_model_install_job: {
+ get_image_thumbnail: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Model install job ID */
- id: number;
+ /** @description The name of thumbnail image file to get */
+ image_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description The job was cancelled successfully */
- 201: {
+ /** @description Return the image thumbnail */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "image/webp": unknown;
};
};
- /** @description No such job */
- 415: {
+ /** @description Image not found */
+ 404: {
headers: {
[name: string]: unknown;
};
@@ -34145,33 +38215,26 @@ export interface operations {
};
};
};
- pause_model_install_job: {
+ get_image_urls: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Model install job ID */
- id: number;
+ /** @description The name of the image whose URL to get */
+ image_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description The job was paused successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
- };
- };
- /** @description No such job */
- 415: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["ImageUrlsDTO"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -34184,33 +38247,27 @@ export interface operations {
};
};
};
- resume_model_install_job: {
+ delete_images_from_list: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description Model install job ID */
- id: number;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_delete_images_from_list"];
+ };
+ };
responses: {
- /** @description The job was resumed successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
- };
- };
- /** @description No such job */
- 415: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["DeleteImagesResult"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -34223,33 +38280,47 @@ export interface operations {
};
};
};
- restart_failed_model_install_job: {
+ delete_uncategorized_images: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description Model install job ID */
- id: number;
- };
+ path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Failed files restarted successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
+ "application/json": components["schemas"]["DeleteImagesResult"];
};
};
- /** @description No such job */
- 415: {
+ };
+ };
+ star_images_in_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_star_images_in_list"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ "application/json": components["schemas"]["StarredImagesResult"];
+ };
};
/** @description Validation Error */
422: {
@@ -34262,37 +38333,60 @@ export interface operations {
};
};
};
- restart_model_install_file: {
+ unstar_images_in_list: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description Model install job ID */
- id: number;
- };
+ path?: never;
cookie?: never;
};
requestBody: {
content: {
- "application/json": string;
+ "application/json": components["schemas"]["Body_unstar_images_in_list"];
};
};
responses: {
- /** @description File restarted successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ModelInstallJob"];
+ "application/json": components["schemas"]["UnstarredImagesResult"];
};
};
- /** @description No such job */
- 415: {
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ download_images_from_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": components["schemas"]["Body_download_images_from_list"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ImagesDownloaded"];
+ };
};
/** @description Validation Error */
422: {
@@ -34305,66 +38399,34 @@ export interface operations {
};
};
};
- convert_model: {
+ get_bulk_download_item: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description Unique key of the safetensors main model to convert to diffusers format. */
- key: string;
+ /** @description The bulk_download_item_name of the bulk download item to get */
+ bulk_download_item_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Model converted successfully */
+ /** @description Return the complete bulk download item */
200: {
headers: {
[name: string]: unknown;
};
content: {
- /** @example {
- * "path": "string",
- * "name": "string",
- * "base": "sd-1",
- * "type": "main",
- * "format": "checkpoint",
- * "config_path": "string",
- * "key": "string",
- * "hash": "string",
- * "file_size": 1,
- * "description": "string",
- * "source": "string",
- * "converted_at": 0,
- * "variant": "normal",
- * "prediction_type": "epsilon",
- * "repo_variant": "fp16",
- * "upcast_attention": false
- * } */
- "application/json": components["schemas"]["Main_Diffusers_SD1_Config"] | components["schemas"]["Main_Diffusers_SD2_Config"] | components["schemas"]["Main_Diffusers_SDXL_Config"] | components["schemas"]["Main_Diffusers_SDXLRefiner_Config"] | components["schemas"]["Main_Diffusers_SD3_Config"] | components["schemas"]["Main_Diffusers_FLUX_Config"] | components["schemas"]["Main_Diffusers_Flux2_Config"] | components["schemas"]["Main_Diffusers_CogView4_Config"] | components["schemas"]["Main_Diffusers_QwenImage_Config"] | components["schemas"]["Main_Diffusers_ZImage_Config"] | components["schemas"]["Main_Checkpoint_SD1_Config"] | components["schemas"]["Main_Checkpoint_SD2_Config"] | components["schemas"]["Main_Checkpoint_SDXL_Config"] | components["schemas"]["Main_Checkpoint_SDXLRefiner_Config"] | components["schemas"]["Main_Checkpoint_Flux2_Config"] | components["schemas"]["Main_Checkpoint_FLUX_Config"] | components["schemas"]["Main_Checkpoint_ZImage_Config"] | components["schemas"]["Main_Checkpoint_Anima_Config"] | components["schemas"]["Main_BnBNF4_FLUX_Config"] | components["schemas"]["Main_GGUF_Flux2_Config"] | components["schemas"]["Main_GGUF_FLUX_Config"] | components["schemas"]["Main_GGUF_QwenImage_Config"] | components["schemas"]["Main_GGUF_ZImage_Config"] | components["schemas"]["VAE_Checkpoint_SD1_Config"] | components["schemas"]["VAE_Checkpoint_SD2_Config"] | components["schemas"]["VAE_Checkpoint_SDXL_Config"] | components["schemas"]["VAE_Checkpoint_FLUX_Config"] | components["schemas"]["VAE_Checkpoint_Flux2_Config"] | components["schemas"]["VAE_Checkpoint_QwenImage_Config"] | components["schemas"]["VAE_Checkpoint_Anima_Config"] | components["schemas"]["VAE_Diffusers_SD1_Config"] | components["schemas"]["VAE_Diffusers_SDXL_Config"] | components["schemas"]["VAE_Diffusers_Flux2_Config"] | components["schemas"]["ControlNet_Checkpoint_SD1_Config"] | components["schemas"]["ControlNet_Checkpoint_SD2_Config"] | components["schemas"]["ControlNet_Checkpoint_SDXL_Config"] | components["schemas"]["ControlNet_Checkpoint_FLUX_Config"] | components["schemas"]["ControlNet_Checkpoint_ZImage_Config"] | components["schemas"]["ControlNet_Diffusers_SD1_Config"] | components["schemas"]["ControlNet_Diffusers_SD2_Config"] | components["schemas"]["ControlNet_Diffusers_SDXL_Config"] | components["schemas"]["ControlNet_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_SD1_Config"] | components["schemas"]["LoRA_LyCORIS_SD2_Config"] | components["schemas"]["LoRA_LyCORIS_SDXL_Config"] | components["schemas"]["LoRA_LyCORIS_Flux2_Config"] | components["schemas"]["LoRA_LyCORIS_FLUX_Config"] | components["schemas"]["LoRA_LyCORIS_ZImage_Config"] | components["schemas"]["LoRA_LyCORIS_QwenImage_Config"] | components["schemas"]["LoRA_LyCORIS_Anima_Config"] | components["schemas"]["LoRA_OMI_SDXL_Config"] | components["schemas"]["LoRA_OMI_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_SD1_Config"] | components["schemas"]["LoRA_Diffusers_SD2_Config"] | components["schemas"]["LoRA_Diffusers_SDXL_Config"] | components["schemas"]["LoRA_Diffusers_Flux2_Config"] | components["schemas"]["LoRA_Diffusers_FLUX_Config"] | components["schemas"]["LoRA_Diffusers_ZImage_Config"] | components["schemas"]["ControlLoRA_LyCORIS_FLUX_Config"] | components["schemas"]["T5Encoder_T5Encoder_Config"] | components["schemas"]["T5Encoder_BnBLLMint8_Config"] | components["schemas"]["Qwen3Encoder_Qwen3Encoder_Config"] | components["schemas"]["Qwen3Encoder_Checkpoint_Config"] | components["schemas"]["Qwen3Encoder_GGUF_Config"] | components["schemas"]["QwenVLEncoder_Diffusers_Config"] | components["schemas"]["QwenVLEncoder_Checkpoint_Config"] | components["schemas"]["TI_File_SD1_Config"] | components["schemas"]["TI_File_SD2_Config"] | components["schemas"]["TI_File_SDXL_Config"] | components["schemas"]["TI_Folder_SD1_Config"] | components["schemas"]["TI_Folder_SD2_Config"] | components["schemas"]["TI_Folder_SDXL_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD1_Config"] | components["schemas"]["IPAdapter_InvokeAI_SD2_Config"] | components["schemas"]["IPAdapter_InvokeAI_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD1_Config"] | components["schemas"]["IPAdapter_Checkpoint_SD2_Config"] | components["schemas"]["IPAdapter_Checkpoint_SDXL_Config"] | components["schemas"]["IPAdapter_Checkpoint_FLUX_Config"] | components["schemas"]["T2IAdapter_Diffusers_SD1_Config"] | components["schemas"]["T2IAdapter_Diffusers_SDXL_Config"] | components["schemas"]["Spandrel_Checkpoint_Config"] | components["schemas"]["CLIPEmbed_Diffusers_G_Config"] | components["schemas"]["CLIPEmbed_Diffusers_L_Config"] | components["schemas"]["CLIPVision_Diffusers_Config"] | components["schemas"]["SigLIP_Diffusers_Config"] | components["schemas"]["FLUXRedux_Checkpoint_Config"] | components["schemas"]["LlavaOnevision_Diffusers_Config"] | components["schemas"]["TextLLM_Diffusers_Config"] | components["schemas"]["ExternalApiModelConfig"] | components["schemas"]["Unknown_Config"];
- };
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
+ "application/zip": unknown;
};
- content?: never;
};
- /** @description Model not found */
+ /** @description Image not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
- /** @description There is already a model registered at this location */
- 409: {
- headers: {
- [name: string]: unknown;
- };
- content?: never;
- };
/** @description Validation Error */
422: {
headers: {
@@ -34376,29 +38438,24 @@ export interface operations {
};
};
};
- get_starter_models: {
+ get_image_names: {
parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["StarterModelResponse"];
- };
+ query?: {
+ /** @description The origin of images to list. */
+ image_origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories of image to include. */
+ categories?: components["schemas"]["ImageCategory"][] | null;
+ /** @description Whether to list intermediate images. */
+ is_intermediate?: boolean | null;
+ /** @description The board id to filter by. Use 'none' to find images without a board. */
+ board_id?: string | null;
+ /** @description The order of sort */
+ order_dir?: components["schemas"]["SQLiteDirection"];
+ /** @description Whether to sort by starred images first */
+ starred_first?: boolean;
+ /** @description The term to search for */
+ search_term?: string | null;
};
- };
- };
- get_stats: {
- parameters: {
- query?: never;
header?: never;
path?: never;
cookie?: never;
@@ -34411,39 +38468,32 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["CacheStats"] | null;
+ "application/json": components["schemas"]["ImageNamesResult"];
};
};
- };
- };
- empty_model_cache: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- get_hf_login_status: {
+ get_images_by_names: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_get_images_by_names"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -34451,32 +38501,57 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HFTokenStatus"];
+ "application/json": components["schemas"]["ImageDTO"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- do_hf_login: {
+ upload_video: {
parameters: {
- query?: never;
+ query: {
+ /** @description The category of the video */
+ video_category: components["schemas"]["ImageCategory"];
+ /** @description Whether this is an intermediate video */
+ is_intermediate: boolean;
+ /** @description The board to add this video to, if any */
+ board_id?: string | null;
+ /** @description The session ID associated with this upload, if any */
+ session_id?: string | null;
+ };
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
- "application/json": components["schemas"]["Body_do_hf_login"];
+ "multipart/form-data": components["schemas"]["Body_upload_video"];
};
};
responses: {
- /** @description Successful Response */
- 200: {
+ /** @description The video was uploaded successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HFTokenStatus"];
+ "application/json": components["schemas"]["VideoDTO"];
+ };
+ };
+ /** @description Video upload failed */
+ 415: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -34489,11 +38564,14 @@ export interface operations {
};
};
};
- reset_hf_token: {
+ get_video_dto: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of video to get */
+ video_name: string;
+ };
cookie?: never;
};
requestBody?: never;
@@ -34504,16 +38582,28 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["HFTokenStatus"];
+ "application/json": components["schemas"]["VideoDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- get_orphaned_models: {
+ delete_video: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of the video to delete */
+ video_name: string;
+ };
cookie?: never;
};
requestBody?: never;
@@ -34524,21 +38614,33 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["OrphanedModelInfo"][];
+ "application/json": components["schemas"]["DeleteVideosResult"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- delete_orphaned_models: {
+ update_video: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of the video to update */
+ video_name: string;
+ };
cookie?: never;
};
requestBody: {
content: {
- "application/json": components["schemas"]["DeleteOrphanedModelsRequest"];
+ "application/json": components["schemas"]["VideoRecordChanges"];
};
};
responses: {
@@ -34548,7 +38650,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DeleteOrphanedModelsResponse"];
+ "application/json": components["schemas"]["VideoDTO"];
};
};
/** @description Validation Error */
@@ -34562,14 +38664,18 @@ export interface operations {
};
};
};
- list_downloads: {
+ delete_videos_from_list: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_delete_videos_from_list"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -34577,16 +38683,28 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DownloadJob"][];
+ "application/json": components["schemas"]["DeleteVideosResult"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- prune_downloads: {
+ get_video_metadata: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of video to get */
+ video_name: string;
+ };
cookie?: never;
};
requestBody?: never;
@@ -34597,47 +38715,57 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": components["schemas"]["MetadataField"] | null;
};
};
- /** @description All completed jobs have been pruned */
- 204: {
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
- content?: never;
- };
- /** @description Bad request */
- 400: {
- headers: {
- [name: string]: unknown;
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
- content?: never;
};
};
};
- download: {
+ get_video_full: {
parameters: {
query?: never;
header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["Body_download"];
+ path: {
+ /** @description The name of video file to get */
+ video_name: string;
};
+ cookie?: never;
};
+ requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Return the full video file */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DownloadJob"];
+ "video/mp4": unknown;
};
};
+ /** @description Return a byte-range of the video file */
+ 206: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "video/mp4": unknown;
+ };
+ };
+ /** @description Video not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
/** @description Validation Error */
422: {
headers: {
@@ -34649,28 +38777,28 @@ export interface operations {
};
};
};
- get_download_job: {
+ get_video_full_head: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description ID of the download job to fetch. */
- id: number;
+ /** @description The name of video file to get */
+ video_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Success */
+ /** @description Return the full video file */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DownloadJob"];
+ "video/mp4": unknown;
};
};
- /** @description The requested download JobID could not be found */
+ /** @description Video not found */
404: {
headers: {
[name: string]: unknown;
@@ -34688,35 +38816,28 @@ export interface operations {
};
};
};
- cancel_download_job: {
+ get_video_thumbnail: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description ID of the download job to cancel. */
- id: number;
+ /** @description The name of thumbnail file to get */
+ video_name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Return the video thumbnail */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
- };
- };
- /** @description Job has been cancelled */
- 204: {
- headers: {
- [name: string]: unknown;
+ "image/webp": unknown;
};
- content?: never;
};
- /** @description The requested download JobID could not be found */
+ /** @description Video not found */
404: {
headers: {
[name: string]: unknown;
@@ -34734,11 +38855,14 @@ export interface operations {
};
};
};
- cancel_all_download_jobs: {
+ get_video_urls: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of the video whose URL to get */
+ video_name: string;
+ };
cookie?: never;
};
requestBody?: never;
@@ -34749,57 +38873,56 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": unknown;
+ "application/json": components["schemas"]["VideoUrlsDTO"];
};
};
- /** @description Download jobs have been cancelled */
- 204: {
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
- content?: never;
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
};
};
};
- upload_image: {
+ list_video_dtos: {
parameters: {
- query: {
- /** @description The category of the image */
- image_category: components["schemas"]["ImageCategory"];
- /** @description Whether this is an intermediate image */
- is_intermediate: boolean;
- /** @description The board to add this image to, if any */
+ query?: {
+ /** @description The origin of videos to list. */
+ video_origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories of video to include. */
+ categories?: components["schemas"]["ImageCategory"][] | null;
+ /** @description Whether to list intermediate videos. */
+ is_intermediate?: boolean | null;
+ /** @description The board id to filter by. Use 'none' to find videos without a board. */
board_id?: string | null;
- /** @description The session ID associated with this upload, if any */
- session_id?: string | null;
- /** @description Whether to crop the image */
- crop_visible?: boolean | null;
+ /** @description The page offset */
+ offset?: number;
+ /** @description The number of videos per page */
+ limit?: number;
+ /** @description The order of sort */
+ order_dir?: components["schemas"]["SQLiteDirection"];
+ /** @description Whether to sort by starred videos first */
+ starred_first?: boolean;
+ /** @description The term to search for */
+ search_term?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "multipart/form-data": components["schemas"]["Body_upload_image"];
- };
- };
+ requestBody?: never;
responses: {
- /** @description The image was uploaded successfully */
- 201: {
+ /** @description Successful Response */
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageDTO"];
- };
- };
- /** @description Image upload failed */
- 415: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["OffsetPaginatedResults_VideoDTO_"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -34812,24 +38935,20 @@ export interface operations {
};
};
};
- list_image_dtos: {
+ get_video_names: {
parameters: {
query?: {
- /** @description The origin of images to list. */
- image_origin?: components["schemas"]["ResourceOrigin"] | null;
- /** @description The categories of image to include. */
+ /** @description The origin of videos to list. */
+ video_origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories of video to include. */
categories?: components["schemas"]["ImageCategory"][] | null;
- /** @description Whether to list intermediate images. */
+ /** @description Whether to list intermediate videos. */
is_intermediate?: boolean | null;
- /** @description The board id to filter by. Use 'none' to find images without a board. */
+ /** @description The board id to filter by. Use 'none' to find videos without a board. */
board_id?: string | null;
- /** @description The page offset */
- offset?: number;
- /** @description The number of images per page */
- limit?: number;
/** @description The order of sort */
order_dir?: components["schemas"]["SQLiteDirection"];
- /** @description Whether to sort by starred images first */
+ /** @description Whether to sort by starred videos first */
starred_first?: boolean;
/** @description The term to search for */
search_term?: string | null;
@@ -34846,7 +38965,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"];
+ "application/json": components["schemas"]["VideoNamesResult"];
};
};
/** @description Validation Error */
@@ -34860,7 +38979,7 @@ export interface operations {
};
};
};
- create_image_upload_entry: {
+ star_videos_in_list: {
parameters: {
query?: never;
header?: never;
@@ -34869,7 +38988,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["Body_create_image_upload_entry"];
+ "application/json": components["schemas"]["Body_star_videos_in_list"];
};
};
responses: {
@@ -34879,7 +38998,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageUploadEntry"];
+ "application/json": components["schemas"]["StarredVideosResult"];
};
};
/** @description Validation Error */
@@ -34893,17 +39012,18 @@ export interface operations {
};
};
};
- get_image_dto: {
+ unstar_videos_in_list: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of image to get */
- image_name: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_unstar_videos_in_list"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -34911,7 +39031,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageDTO"];
+ "application/json": components["schemas"]["UnstarredVideosResult"];
};
};
/** @description Validation Error */
@@ -34925,17 +39045,18 @@ export interface operations {
};
};
};
- delete_image: {
+ add_video_to_board: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of the image to delete */
- image_name: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["VideoBoardArg"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -34943,7 +39064,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DeleteImagesResult"];
+ "application/json": components["schemas"]["AddVideosToBoardResult"];
};
};
/** @description Validation Error */
@@ -34957,19 +39078,16 @@ export interface operations {
};
};
};
- update_image: {
+ remove_video_from_board: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of the image to update */
- image_name: string;
- };
+ path?: never;
cookie?: never;
};
requestBody: {
content: {
- "application/json": components["schemas"]["ImageRecordChanges"];
+ "application/json": components["schemas"]["Body_remove_video_from_board"];
};
};
responses: {
@@ -34979,7 +39097,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageDTO"];
+ "application/json": components["schemas"]["RemoveVideosFromBoardResult"];
};
};
/** @description Validation Error */
@@ -34993,53 +39111,60 @@ export interface operations {
};
};
};
- get_intermediates_count: {
+ upload_canvas_project: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_upload_canvas_project"];
+ };
+ };
responses: {
- /** @description Successful Response */
- 200: {
+ /** @description The canvas project was uploaded successfully */
+ 201: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": number;
+ "application/json": components["schemas"]["CanvasProjectDTO"];
};
};
- };
- };
- clear_intermediates: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
+ /** @description Canvas project file too large */
+ 413: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Not a supported canvas project file */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": number;
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- get_image_metadata: {
+ get_canvas_project_dto: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description The name of image to get */
- image_name: string;
+ /** @description The name of canvas project to get */
+ project_name: string;
};
cookie?: never;
};
@@ -35051,7 +39176,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["MetadataField"] | null;
+ "application/json": components["schemas"]["CanvasProjectDTO"];
};
};
/** @description Validation Error */
@@ -35065,13 +39190,13 @@ export interface operations {
};
};
};
- get_image_workflow: {
+ delete_canvas_project: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description The name of image whose workflow to get */
- image_name: string;
+ /** @description The name of the canvas project to delete */
+ project_name: string;
};
cookie?: never;
};
@@ -35083,7 +39208,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["WorkflowAndGraphResponse"];
+ "application/json": components["schemas"]["DeleteCanvasProjectsResult"];
};
};
/** @description Validation Error */
@@ -35097,33 +39222,30 @@ export interface operations {
};
};
};
- get_image_full: {
+ update_canvas_project: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description The name of full-resolution image file to get */
- image_name: string;
+ /** @description The name of the canvas project to update */
+ project_name: string;
};
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CanvasProjectRecordChanges"];
+ };
+ };
responses: {
- /** @description Return the full-resolution image */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "image/png": unknown;
- };
- };
- /** @description Image not found */
- 404: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["CanvasProjectDTO"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -35136,33 +39258,27 @@ export interface operations {
};
};
};
- get_image_full_head: {
+ delete_canvas_projects_from_list: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of full-resolution image file to get */
- image_name: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_delete_canvas_projects_from_list"];
+ };
+ };
responses: {
- /** @description Return the full-resolution image */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "image/png": unknown;
- };
- };
- /** @description Image not found */
- 404: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["DeleteCanvasProjectsResult"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -35175,29 +39291,40 @@ export interface operations {
};
};
};
- get_image_thumbnail: {
+ replace_canvas_project_file: {
parameters: {
query?: never;
header?: never;
path: {
- /** @description The name of thumbnail image file to get */
- image_name: string;
+ /** @description The name of the canvas project to replace */
+ project_name: string;
};
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_replace_canvas_project_file"];
+ };
+ };
responses: {
- /** @description Return the image thumbnail */
+ /** @description The canvas project file was replaced successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "image/webp": unknown;
+ "application/json": components["schemas"]["CanvasProjectDTO"];
};
};
- /** @description Image not found */
- 404: {
+ /** @description Canvas project file too large */
+ 413: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Not a supported canvas project file */
+ 415: {
headers: {
[name: string]: unknown;
};
@@ -35214,17 +39341,18 @@ export interface operations {
};
};
};
- get_image_urls: {
+ star_canvas_projects_in_list: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The name of the image whose URL to get */
- image_name: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_star_canvas_projects_in_list"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -35232,7 +39360,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageUrlsDTO"];
+ "application/json": components["schemas"]["StarredCanvasProjectsResult"];
};
};
/** @description Validation Error */
@@ -35246,7 +39374,7 @@ export interface operations {
};
};
};
- delete_images_from_list: {
+ unstar_canvas_projects_in_list: {
parameters: {
query?: never;
header?: never;
@@ -35255,7 +39383,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["Body_delete_images_from_list"];
+ "application/json": components["schemas"]["Body_unstar_canvas_projects_in_list"];
};
};
responses: {
@@ -35265,7 +39393,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DeleteImagesResult"];
+ "application/json": components["schemas"]["UnstarredCanvasProjectsResult"];
};
};
/** @description Validation Error */
@@ -35279,47 +39407,72 @@ export interface operations {
};
};
};
- delete_uncategorized_images: {
+ get_canvas_project_full: {
parameters: {
query?: never;
header?: never;
- path?: never;
+ path: {
+ /** @description The name of canvas project file to get */
+ project_name: string;
+ };
cookie?: never;
};
requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Return the canvas project ZIP */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["DeleteImagesResult"];
+ "application/zip": unknown;
+ };
+ };
+ /** @description Canvas project not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
- star_images_in_list: {
+ get_canvas_project_thumbnail: {
parameters: {
query?: never;
header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": components["schemas"]["Body_star_images_in_list"];
+ path: {
+ /** @description The name of canvas project whose thumbnail to get */
+ project_name: string;
};
+ cookie?: never;
};
+ requestBody?: never;
responses: {
- /** @description Successful Response */
+ /** @description Return the canvas project thumbnail */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["StarredImagesResult"];
+ "image/webp": unknown;
+ };
+ };
+ /** @description Canvas project thumbnail not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
};
+ content?: never;
};
/** @description Validation Error */
422: {
@@ -35332,18 +39485,31 @@ export interface operations {
};
};
};
- unstar_images_in_list: {
+ list_canvas_project_dtos: {
parameters: {
- query?: never;
+ query?: {
+ /** @description Filter by project origin */
+ project_origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description Filter by is_intermediate flag */
+ is_intermediate?: boolean | null;
+ /** @description Filter by board_id ('none' for unassigned) */
+ board_id?: string | null;
+ /** @description The page offset */
+ offset?: number;
+ /** @description The number of canvas projects per page */
+ limit?: number;
+ /** @description Sort direction */
+ order_dir?: components["schemas"]["SQLiteDirection"];
+ /** @description Whether starred projects come first */
+ starred_first?: boolean;
+ /** @description A free-text search term */
+ search_term?: string | null;
+ };
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["Body_unstar_images_in_list"];
- };
- };
+ requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -35351,7 +39517,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["UnstarredImagesResult"];
+ "application/json": components["schemas"]["OffsetPaginatedResults_CanvasProjectDTO_"];
};
};
/** @description Validation Error */
@@ -35365,26 +39531,26 @@ export interface operations {
};
};
};
- download_images_from_list: {
+ add_canvas_project_to_board: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- requestBody?: {
+ requestBody: {
content: {
- "application/json": components["schemas"]["Body_download_images_from_list"];
+ "application/json": components["schemas"]["Body_add_canvas_project_to_board"];
};
};
responses: {
/** @description Successful Response */
- 202: {
+ 200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImagesDownloaded"];
+ "application/json": components["schemas"]["AddCanvasProjectsToBoardResult"];
};
};
/** @description Validation Error */
@@ -35398,33 +39564,27 @@ export interface operations {
};
};
};
- get_bulk_download_item: {
+ remove_canvas_project_from_board: {
parameters: {
query?: never;
header?: never;
- path: {
- /** @description The bulk_download_item_name of the bulk download item to get */
- bulk_download_item_name: string;
- };
+ path?: never;
cookie?: never;
};
- requestBody?: never;
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_remove_canvas_project_from_board"];
+ };
+ };
responses: {
- /** @description Return the complete bulk download item */
+ /** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
- "application/zip": unknown;
- };
- };
- /** @description Image not found */
- 404: {
- headers: {
- [name: string]: unknown;
+ "application/json": components["schemas"]["RemoveCanvasProjectsFromBoardResult"];
};
- content?: never;
};
/** @description Validation Error */
422: {
@@ -35437,20 +39597,24 @@ export interface operations {
};
};
};
- get_image_names: {
+ list_gallery_items: {
parameters: {
query?: {
- /** @description The origin of images to list. */
- image_origin?: components["schemas"]["ResourceOrigin"] | null;
- /** @description The categories of image to include. */
+ /** @description The origin of items to list. */
+ origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories to include. Shared between images and videos. */
categories?: components["schemas"]["ImageCategory"][] | null;
- /** @description Whether to list intermediate images. */
+ /** @description Whether to list intermediate items. */
is_intermediate?: boolean | null;
- /** @description The board id to filter by. Use 'none' to find images without a board. */
+ /** @description The board id to filter by. Use 'none' to find items without a board. */
board_id?: string | null;
+ /** @description The page offset */
+ offset?: number;
+ /** @description The number of items per page */
+ limit?: number;
/** @description The order of sort */
order_dir?: components["schemas"]["SQLiteDirection"];
- /** @description Whether to sort by starred images first */
+ /** @description Whether to sort by starred items first */
starred_first?: boolean;
/** @description The term to search for */
search_term?: string | null;
@@ -35467,7 +39631,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageNamesResult"];
+ "application/json": components["schemas"]["OffsetPaginatedResults_GalleryItem_"];
};
};
/** @description Validation Error */
@@ -35481,18 +39645,29 @@ export interface operations {
};
};
};
- get_images_by_names: {
+ get_gallery_item_names: {
parameters: {
- query?: never;
+ query?: {
+ /** @description The origin of items to list. */
+ origin?: components["schemas"]["ResourceOrigin"] | null;
+ /** @description The categories to include. Shared between images and videos. */
+ categories?: components["schemas"]["ImageCategory"][] | null;
+ /** @description Whether to list intermediate items. */
+ is_intermediate?: boolean | null;
+ /** @description The board id to filter by. Use 'none' to find items without a board. */
+ board_id?: string | null;
+ /** @description The order of sort */
+ order_dir?: components["schemas"]["SQLiteDirection"];
+ /** @description Whether to sort by starred items first */
+ starred_first?: boolean;
+ /** @description The term to search for */
+ search_term?: string | null;
+ };
header?: never;
path?: never;
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": components["schemas"]["Body_get_images_by_names"];
- };
- };
+ requestBody?: never;
responses: {
/** @description Successful Response */
200: {
@@ -35500,7 +39675,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["ImageDTO"][];
+ "application/json": components["schemas"]["GalleryItemNamesResult"];
};
};
/** @description Validation Error */
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 27c6fcbf3c3..eb103270854 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -101,6 +101,7 @@ type FLUX2ModelConfig = Extract;
export type LoRAModelConfig = Extract;
+type WanLoRAModelConfig = Extract;
export type VAEModelConfig = Extract;
export type ControlNetModelConfig = Extract;
export type IPAdapterModelConfig = Extract;
@@ -117,6 +118,7 @@ export type T5EncoderBnbQuantizedLlmInt8bModelConfig = Extract<
>;
export type Qwen3EncoderModelConfig = Extract;
export type QwenVLEncoderModelConfig = Extract;
+export type WanT5EncoderModelConfig = Extract;
export type SpandrelImageToImageModelConfig = Extract;
export type CheckpointModelConfig = Extract;
export type CLIPVisionModelConfig = Extract;
@@ -321,6 +323,13 @@ export const isQwenImageVAEModelConfig = (
);
};
+export const isWanVAEModelConfig = (config: AnyModelConfig, excludeSubmodels?: boolean): config is VAEModelConfig => {
+ return (
+ (config.type === 'vae' || (!excludeSubmodels && config.type === 'main' && checkSubmodels(['vae'], config))) &&
+ config.base === 'wan'
+ );
+};
+
export const isControlNetModelConfig = (config: AnyModelConfig): config is ControlNetModelConfig => {
return config.type === 'controlnet';
};
@@ -379,6 +388,10 @@ export const isQwenVLEncoderModelConfig = (config: AnyModelConfig): config is Qw
return config.type === 'qwen_vl_encoder';
};
+export const isWanT5EncoderModelConfig = (config: AnyModelConfig): config is WanT5EncoderModelConfig => {
+ return config.type === 'wan_t5_encoder';
+};
+
export const isCLIPEmbedModelConfigOrSubmodel = (
config: AnyModelConfig,
excludeSubmodels?: boolean
@@ -486,6 +499,23 @@ export const isQwenImageDiffusersMainModelConfig = (config: AnyModelConfig): con
return config.type === 'main' && config.base === 'qwen-image' && config.format === 'diffusers';
};
+export const isWanDiffusersMainModelConfig = (config: AnyModelConfig): config is MainModelConfig => {
+ return config.type === 'main' && config.base === 'wan' && config.format === 'diffusers';
+};
+
+/** Wan GGUF main models marked as the low-noise expert (the second half
+ * of the A14B MoE pair). Suitable for the Transformer (Low Noise) picker;
+ * also used to filter low-noise GGUFs out of the primary main dropdown. */
+export const isWanGGUFLowNoiseMainModelConfig = (config: AnyModelConfig): config is MainModelConfig => {
+ return (
+ config.type === 'main' && config.base === 'wan' && config.format === 'gguf_quantized' && config.expert === 'low'
+ );
+};
+
+export const isWanLoRAModelConfig = (config: AnyModelConfig): config is WanLoRAModelConfig => {
+ return config.type === 'lora' && config.base === 'wan';
+};
+
export const isTIModelConfig = (config: AnyModelConfig): config is MainModelConfig => {
return config.type === 'embedding';
};
@@ -612,3 +642,100 @@ export type UploadImageArg = {
export type ImageUploadEntryResponse = S['ImageUploadEntry'];
export type ImageUploadEntryRequest = paths['/api/v1/images/']['post']['requestBody']['content']['application/json'];
+
+// Videos
+export type VideoDTO = S['VideoDTO'];
+/** @knipignore Used by Phase 4+ video gallery mutations. */
+export type VideoRecordChanges = S['VideoRecordChanges'];
+/** @knipignore Used by listVideos RTK response typing; surfaced in Phase 4+. */
+export type OffsetPaginatedResults_VideoDTO_ = S['OffsetPaginatedResults_VideoDTO_'];
+export type ListVideosArgs = NonNullable;
+export type ListVideosResponse = paths['/api/v1/videos/']['get']['responses']['200']['content']['application/json'];
+export type GetVideoNamesArgs = NonNullable;
+export type GetVideoNamesResult =
+ paths['/api/v1/videos/names']['get']['responses']['200']['content']['application/json'];
+
+// Canvas Projects (.invk)
+export type CanvasProjectDTO = S['CanvasProjectDTO'];
+export type CanvasProjectRecordChanges = S['CanvasProjectRecordChanges'];
+export type OffsetPaginatedResults_CanvasProjectDTO_ = S['OffsetPaginatedResults_CanvasProjectDTO_'];
+export type ListCanvasProjectsArgs = NonNullable;
+export type ListCanvasProjectsResponse =
+ paths['/api/v1/canvas_projects/']['get']['responses']['200']['content']['application/json'];
+
+export type UploadCanvasProjectArg = {
+ /** The `.invk` (ZIP) file to upload. */
+ file: File;
+ /** The user-facing project name. */
+ name: string;
+ /** App version captured at save time. */
+ app_version: string;
+ /** Bbox width at save time. */
+ width: number;
+ /** Bbox height at save time. */
+ height: number;
+ /** Number of embedded image files. */
+ image_count: number;
+ /** Optional WebP preview thumbnail. */
+ thumbnail?: Blob;
+ /** Optional board id to attach the project to. */
+ board_id?: string;
+ /** Whether the project is an intermediate (defaults to false). */
+ is_intermediate?: boolean;
+};
+
+export type ReplaceCanvasProjectFileArg = {
+ /** The target project to update. UUID — keeps board assignment and starred state. */
+ project_name: string;
+ /** The replacement `.invk` (ZIP) file. */
+ file: File;
+ /** Optional new user-facing project name. */
+ name?: string;
+ /** App version captured at save time. */
+ app_version: string;
+ /** Bbox width at save time. */
+ width: number;
+ /** Bbox height at save time. */
+ height: number;
+ /** Number of embedded image files. */
+ image_count: number;
+ /** Optional replacement WebP preview. Omit to keep the existing thumbnail. */
+ thumbnail?: Blob;
+};
+
+export type UploadVideoArg = {
+ /** The MP4 (or other accepted video) file to upload. */
+ file: File;
+ /** The category of video to upload. Reuses the image category enum. */
+ video_category: ImageCategory;
+ /** Whether the uploaded video is an intermediate (intermediates are not shown in the gallery). */
+ is_intermediate: boolean;
+ /** The session with which to associate the uploaded video, if any. */
+ session_id?: string;
+ /** The board to add the video to, if any. */
+ board_id?: string;
+ /** Metadata JSON to attach to the video record. */
+ metadata?: JsonObject;
+ /** Suppress the upload toast / gallery navigation side effects. */
+ silent?: boolean;
+ /** Whether this is the first upload of a batch (used by toast logic). */
+ isFirstUploadOfBatch?: boolean;
+};
+
+// Polymorphic gallery items (images + videos). Consumed by the gallery wiring in Phase 4.
+/** @knipignore Consumed by gallery wiring in Phase 4. */
+export type GalleryItem = S['GalleryItem'];
+/** @knipignore Consumed by gallery wiring in Phase 4. */
+export type GalleryItemKind = S['GalleryItemKind'];
+/** @knipignore Consumed by gallery wiring in Phase 4. */
+export type GalleryItemRef = S['GalleryItemRef'];
+/** @knipignore Consumed by gallery wiring in Phase 4. */
+export type GalleryItemNamesResult = S['GalleryItemNamesResult'];
+/** @knipignore Consumed by gallery wiring in Phase 4. */
+export type OffsetPaginatedResults_GalleryItem_ = S['OffsetPaginatedResults_GalleryItem_'];
+export type ListGalleryItemsArgs = NonNullable;
+export type ListGalleryItemsResponse =
+ paths['/api/v1/gallery/items/']['get']['responses']['200']['content']['application/json'];
+export type GetGalleryItemNamesArgs = NonNullable;
+export type GetGalleryItemNamesResult =
+ paths['/api/v1/gallery/items/names']['get']['responses']['200']['content']['application/json'];
diff --git a/invokeai/frontend/web/src/services/api/util.ts b/invokeai/frontend/web/src/services/api/util.ts
index 3d92923f2c5..0f2a03407e0 100644
--- a/invokeai/frontend/web/src/services/api/util.ts
+++ b/invokeai/frontend/web/src/services/api/util.ts
@@ -2,7 +2,7 @@ import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/type
import queryString from 'query-string';
import { buildV1Url } from 'services/api';
-import type { ImageDTO, ListImagesArgs } from './types';
+import type { ImageDTO, ListGalleryItemsArgs, ListImagesArgs, ListVideosArgs } from './types';
export const getCategories = (imageDTO: ImageDTO) => {
if (IMAGE_CATEGORIES.includes(imageDTO.image_category)) {
@@ -14,3 +14,11 @@ export const getCategories = (imageDTO: ImageDTO) => {
// Helper to create the url for the listImages endpoint. Also we use it to create the cache key.
export const getListImagesUrl = (queryArgs: ListImagesArgs) =>
buildV1Url(`images/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);
+
+// Helper to create the url for the listVideos endpoint. Also we use it to create the cache key.
+export const getListVideosUrl = (queryArgs: ListVideosArgs) =>
+ buildV1Url(`videos/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);
+
+// Helper to create the url for the polymorphic listGalleryItems endpoint.
+export const getListGalleryItemsUrl = (queryArgs: ListGalleryItemsArgs) =>
+ buildV1Url(`gallery/items/?${queryString.stringify(queryArgs, { arrayFormat: 'none' })}`);
diff --git a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts
index bac3130d312..277b0d68be1 100644
--- a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts
+++ b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts
@@ -4,7 +4,16 @@ import { getListImagesUrl } from 'services/api/util';
import type { ApiTagDescription } from '..';
export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => {
- const tags: ApiTagDescription[] = ['ImageNameList', 'VirtualBoards'];
+ // Whenever an image or video mutation changes a board's contents we also have to refresh
+ // the polymorphic gallery list (and its names companion) since that is what the gallery UI
+ // actually subscribes to once Phase 4 lands.
+ const tags: ApiTagDescription[] = [
+ 'ImageNameList',
+ 'VirtualBoards',
+ 'VideoNameList',
+ 'GalleryItemList',
+ 'GalleryItemNameList',
+ ];
for (const board_id of affected_boards) {
tags.push({
@@ -32,6 +41,37 @@ export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: st
type: 'BoardImagesTotal',
id: board_id,
});
+
+ tags.push({
+ type: 'BoardVideosTotal',
+ id: board_id,
+ });
+
+ tags.push({
+ type: 'BoardCanvasProjectsTotal',
+ id: board_id,
+ });
+ }
+
+ return tags;
+};
+
+export const getTagsToInvalidateForCanvasProjectMutation = (project_names: string[]): ApiTagDescription[] => {
+ const tags: ApiTagDescription[] = [];
+
+ for (const project_name of project_names) {
+ tags.push({ type: 'CanvasProject', id: project_name });
+ }
+
+ return tags;
+};
+
+export const getTagsToInvalidateForVideoMutation = (video_names: string[]): ApiTagDescription[] => {
+ const tags: ApiTagDescription[] = [];
+
+ for (const video_name of video_names) {
+ tags.push({ type: 'Video', id: video_name });
+ tags.push({ type: 'VideoMetadata', id: video_name });
}
return tags;
diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
index ea6a237d4b2..bd3a5f308dc 100644
--- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
+++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx
@@ -10,12 +10,14 @@ import {
} from 'features/gallery/store/gallerySelectors';
import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice';
import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
-import { isImageField, isImageFieldCollection } from 'features/nodes/types/common';
+import { isImageField, isImageFieldCollection, isVideoField } from 'features/nodes/types/common';
import { LIST_ALL_TAG } from 'services/api';
import { boardsApi } from 'services/api/endpoints/boards';
+import { galleryApi } from 'services/api/endpoints/gallery';
import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images';
import { queueApi } from 'services/api/endpoints/queue';
-import type { ImageDTO, S } from 'services/api/types';
+import { getVideoDTOSafe } from 'services/api/endpoints/videos';
+import type { ImageDTO, S, VideoDTO } from 'services/api/types';
import { getCategories } from 'services/api/util';
import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates';
import { getUpdatedNodeExecutionStateOnInvocationComplete } from 'services/events/nodeExecutionState';
@@ -223,6 +225,87 @@ export const buildOnInvocationComplete = (
return imageDTOs;
};
+ const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => {
+ const { result } = data;
+ const videoDTOs: VideoDTO[] = [];
+ for (const [_name, value] of objectEntries(result)) {
+ if (isVideoField(value)) {
+ const videoDTO = await getVideoDTOSafe(value.video_name);
+ if (videoDTO) {
+ videoDTOs.push(videoDTO);
+ }
+ }
+ }
+ return videoDTOs;
+ };
+
+ // Counterpart to addImagesToGallery for VideoField outputs (e.g. Wan 2.2 latents-to-video).
+ // Two key differences from the image path:
+ // 1. The gallery view uses the polymorphic getGalleryItemNames endpoint and we have no
+ // cheap optimistic-insert here, so we invalidate the GalleryItemNameList/GalleryItemList
+ // tags to force a refetch.
+ // 2. The ImageViewerContext's local $progressEvent/$progressImage atoms expect onLoadImage
+ // (DndImage onLoad) to clear them. When auto-switching to a video, the viewer swaps
+ // CurrentImagePreview for CurrentVideoPreview, which unmounts the stale progress overlay
+ // so the stuck "Saving video" spinner goes away on its own.
+ const addVideosToGallery = async (data: S['InvocationCompleteEvent']) => {
+ if (nodeTypeDenylist.includes(data.invocation.type)) {
+ return;
+ }
+
+ const videoDTOs = await getResultVideoDTOs(data);
+ if (videoDTOs.length === 0) {
+ return;
+ }
+
+ const nonIntermediate = videoDTOs.filter((v) => !v.is_intermediate);
+ if (nonIntermediate.length === 0) {
+ return;
+ }
+
+ // Force the polymorphic gallery list to refetch so the new video shows up. Note: this is
+ // a tag invalidation, not an optimistic insert (the image path has a `insertImageIntoNamesResult`
+ // helper, but the polymorphic `GetGalleryItemNamesResult` has a different shape and we don't
+ // have an equivalent yet). The viewer selection below applies immediately, so the user sees
+ // their video right away; the *gallery grid* scroll-to-selection is delayed by one refetch
+ // because `useKeepSelectedImageInView` re-runs when `imageNames` updates and only then can
+ // it find the new name in the list. Worth a follow-up if the scroll lag becomes noticeable.
+ dispatch(galleryApi.util.invalidateTags(['GalleryItemNameList', 'GalleryItemList']));
+
+ const autoSwitch = selectAutoSwitch(getState());
+ if (!autoSwitch) {
+ return;
+ }
+
+ const lastVideoDTO = nonIntermediate.at(-1);
+ if (!lastVideoDTO) {
+ return;
+ }
+
+ const { video_name } = lastVideoDTO;
+ const board_id = lastVideoDTO.board_id ?? 'none';
+
+ // Selection is a polymorphic string[]; useGalleryItemDTO discriminates by filename extension.
+ const selectedBoardId = selectSelectedBoardId(getState());
+ if (board_id !== selectedBoardId) {
+ dispatch(
+ boardIdSelected({
+ boardId: board_id,
+ select: {
+ selection: [video_name],
+ galleryView: 'images',
+ },
+ })
+ );
+ } else {
+ const galleryView = selectGalleryView(getState());
+ if (galleryView !== 'images') {
+ dispatch(galleryViewChanged('images'));
+ }
+ dispatch(imageSelected(video_name));
+ }
+ };
+
const clearCanvasWorkflowIntegrationProcessing = (data: S['InvocationCompleteEvent']) => {
// Check if this is a canvas workflow integration result
// Results go to staging area automatically via destination = canvasSessionId
@@ -270,6 +353,7 @@ export const buildOnInvocationComplete = (
// Add images to gallery (canvas workflow integration results go to staging area automatically)
await addImagesToGallery(data);
+ await addVideosToGallery(data);
$lastProgressEvent.set(null);
};
diff --git a/pyproject.toml b/pyproject.toml
index 155471d9067..49829ee9259 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"onnx==1.16.1",
"onnxruntime==1.19.2",
"opencv-contrib-python",
+ "imageio[ffmpeg]", # video encode (for Wan 2.2 T2V/I2V output)
"safetensors",
"sentencepiece==0.2.0", # 0.2.1 coredumps windows when loading t5 tokenizer
"spandrel",
diff --git a/scripts/wan_diffusers_reference.py b/scripts/wan_diffusers_reference.py
new file mode 100644
index 00000000000..0e67ceac225
--- /dev/null
+++ b/scripts/wan_diffusers_reference.py
@@ -0,0 +1,85 @@
+"""Run TI2V-5B (or any Wan 2.2 Diffusers checkpoint) via the upstream
+WanPipeline directly, with the same arguments InvokeAI's wan_denoise uses.
+
+Use to A/B against InvokeAI output when image quality is questionable.
+Generates one image and saves it next to this script.
+
+Example:
+ python scripts/wan_diffusers_reference.py \
+ --model-path /home/lstein/invokeai-delete/models/ \
+ --prompt "a photograph of a young redheaded woman sitting on a three-legged stool next to a potted fern" \
+ --seed 42 --steps 40 --cfg 4.0 --width 1024 --height 1024
+"""
+
+import argparse
+from pathlib import Path
+
+import torch
+from diffusers import WanPipeline
+
+
+def main() -> None:
+ p = argparse.ArgumentParser()
+ p.add_argument("--model-path", required=True, help="Path to a Diffusers Wan model directory.")
+ p.add_argument("--prompt", required=True)
+ p.add_argument(
+ "--negative",
+ default="",
+ help="Negative prompt (default empty string — matches WanPipeline.encode_prompt behaviour).",
+ )
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--steps", type=int, default=40)
+ p.add_argument("--cfg", type=float, default=4.0)
+ p.add_argument("--width", type=int, default=1024)
+ p.add_argument("--height", type=int, default=1024)
+ p.add_argument("--output", default="wan_diffusers_reference.png")
+ p.add_argument(
+ "--offload",
+ choices=["model", "sequential", "none"],
+ default="model",
+ help="VRAM-saving strategy. 'model' (default) keeps one component on GPU at a time — fits TI2V-5B "
+ "in ~16 GB. 'sequential' is even more aggressive (per-module offload) and slower. "
+ "'none' loads everything to GPU at once (~24 GB+).",
+ )
+ args = p.parse_args()
+
+ print(f"Loading WanPipeline from {args.model_path} ...")
+ pipe = WanPipeline.from_pretrained(args.model_path, torch_dtype=torch.bfloat16)
+
+ if args.offload == "model":
+ # enable_model_cpu_offload puts each component (transformer, vae, text_encoder)
+ # on GPU only while it's actively running; the rest sit on CPU. Adds a little
+ # latency between stages but cuts peak VRAM dramatically.
+ pipe.enable_model_cpu_offload()
+ elif args.offload == "sequential":
+ pipe.enable_sequential_cpu_offload()
+ else:
+ pipe.to("cuda")
+
+ generator = torch.Generator(device="cuda").manual_seed(args.seed)
+
+ print(
+ f"Generating: prompt={args.prompt!r}\n"
+ f" steps={args.steps}, cfg={args.cfg}, size={args.width}x{args.height}, seed={args.seed}"
+ )
+ # num_frames=1 → image generation
+ result = pipe(
+ prompt=args.prompt,
+ negative_prompt=args.negative,
+ height=args.height,
+ width=args.width,
+ num_frames=1,
+ num_inference_steps=args.steps,
+ guidance_scale=args.cfg,
+ generator=generator,
+ output_type="pil",
+ )
+ # WanPipelineOutput.frames is a list of [PIL.Image] sequences (one per video).
+ image = result.frames[0][0]
+ out = Path(args.output)
+ image.save(out)
+ print(f"Saved {out.resolve()}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/app/invocations/test_video_concat.py b/tests/app/invocations/test_video_concat.py
new file mode 100644
index 00000000000..4c4df48698e
--- /dev/null
+++ b/tests/app/invocations/test_video_concat.py
@@ -0,0 +1,100 @@
+"""Regression tests for VideoConcatInvocation._assemble.
+
+Covers JPPhoto's code-review finding (PR #9163): ``fade_through_black`` claimed to
+emit ``transition_frames`` frames per boundary but used a symmetric ``tf // 2``
+split, dropping one frame for odd ``tf``. The fix splits asymmetrically:
+``tail_half = tf // 2`` consumed from the previous clip's tail, ``head_half =
+tf - tail_half`` from the next clip's head, so the emitted total is exactly
+``tf`` for both parities.
+
+We exercise ``_assemble`` directly because it is the pure transformation
+implementing the contract (the surrounding ``invoke()`` deals with imageio
+encode/decode plumbing that isn't germane to the math).
+"""
+
+import numpy as np
+import pytest
+
+from invokeai.app.invocations.fields import VideoField
+from invokeai.app.invocations.video_concat import VideoConcatInvocation
+
+
+def _invocation(transition: str, transition_frames: int) -> VideoConcatInvocation:
+ # ``videos`` requires min_length=2 to construct; values are unused by ``_assemble``.
+ return VideoConcatInvocation(
+ videos=[VideoField(video_name="a"), VideoField(video_name="b")],
+ transition=transition, # type: ignore[arg-type]
+ transition_frames=transition_frames,
+ )
+
+
+def _clip(value: int, n: int) -> list[np.ndarray]:
+ return [np.full((4, 4, 3), value, dtype=np.uint8) for _ in range(n)]
+
+
+class TestFadeThroughBlackOddTf:
+ """fade_through_black must emit exactly tf frames per boundary, even for odd tf."""
+
+ @pytest.mark.parametrize("tf", [1, 2, 3, 4, 5, 7, 8])
+ def test_total_length_preserved(self, tf: int) -> None:
+ clip_a = _clip(200, 10)
+ clip_b = _clip(100, 10)
+ v = _invocation("fade_through_black", tf)
+ out = v._assemble([clip_a, clip_b])
+ # fade_through_black: each boundary consumes tf frames (tail_half from A + head_half from B)
+ # and emits exactly tf frames in their place, so the total length is preserved.
+ assert len(out) == len(clip_a) + len(clip_b)
+
+ def test_three_clip_chain(self) -> None:
+ clip_a = _clip(200, 10)
+ clip_b = _clip(150, 10)
+ clip_c = _clip(100, 10)
+ v = _invocation("fade_through_black", 3)
+ out = v._assemble([clip_a, clip_b, clip_c])
+ # 30 input frames - 2 boundaries each consuming 3 + emitting 3 = 30 output frames.
+ assert len(out) == 30
+
+
+class TestFadeThroughBlackTransitionFrames:
+ """Validation should accept odd tf when both halves fit within their adjacent clips."""
+
+ def test_odd_tf_validation_uses_correct_halves(self) -> None:
+ # tf=5 → tail_half=2, head_half=3. A 5-frame clip would fail with the previous
+ # symmetric split (2+2 ≤ 5 was fine, but the math now needs head_half=3 from
+ # clip[1] head + tail_half=2 from clip[1] tail = 5 ≤ 5 → still fits).
+ clip_a = _clip(200, 5)
+ clip_b = _clip(100, 5)
+ clip_c = _clip(50, 5)
+ v = _invocation("fade_through_black", 5)
+ out = v._assemble([clip_a, clip_b, clip_c])
+ assert len(out) == 15
+
+
+class TestCrossfadeTransitionLength:
+ """Crossfade behaviour is unchanged; pin its length for safety."""
+
+ def test_crossfade_shortens_by_tf_per_boundary(self) -> None:
+ clip_a = _clip(200, 10)
+ clip_b = _clip(100, 10)
+ v = _invocation("crossfade", 5)
+ out = v._assemble([clip_a, clip_b])
+ # 20 input - 5 frames consumed from each side (10 total) + 5 blended emitted = 15.
+ assert len(out) == 15
+
+
+class TestCutNoTransitionFrames:
+ """transition='cut' or tf=0 returns the raw concatenation."""
+
+ def test_cut_concatenates_directly(self) -> None:
+ clip_a = _clip(200, 4)
+ clip_b = _clip(100, 6)
+ v = _invocation("cut", 0)
+ out = v._assemble([clip_a, clip_b])
+ assert len(out) == 10
+
+ def test_fade_through_black_tf_zero(self) -> None:
+ clip_a = _clip(200, 4)
+ clip_b = _clip(100, 6)
+ v = _invocation("fade_through_black", 0)
+ out = v._assemble([clip_a, clip_b])
+ assert len(out) == 10
diff --git a/tests/app/invocations/test_video_frame_extract.py b/tests/app/invocations/test_video_frame_extract.py
new file mode 100644
index 00000000000..ca4553f1579
--- /dev/null
+++ b/tests/app/invocations/test_video_frame_extract.py
@@ -0,0 +1,48 @@
+"""Regression tests for VideoFrameExtractInvocation negative-index resolution.
+
+Covers JPPhoto's code-review finding (PR #9163): the old code computed
+``n_frames = round(duration * fps)`` to resolve ``frame_index=-1``. For uploads
+with inexact metadata that can overshoot the decoded frame count, requesting
+the last frame would fail. The fix queries ``iio.improps(...).shape[0]`` for
+the exact decoder count.
+
+We exercise the private ``_decoder_frame_count`` helper with a real synthetic
+MP4 so the iio integration is actually validated.
+"""
+
+import imageio.v3 as iio
+import numpy as np
+import pytest
+
+from invokeai.app.invocations.video_frame_extract import _decoder_frame_count
+
+
+def _write_mp4(tmp_path, n_frames: int):
+ """Encode a tiny synthetic MP4 with exactly ``n_frames`` frames at 8 fps."""
+ path = tmp_path / "synth.mp4"
+ frames = [np.full((32, 32, 3), 64 + i * 8, dtype=np.uint8) for i in range(n_frames)]
+ iio.imwrite(path, frames, plugin="FFMPEG", codec="libx264", fps=8.0, macro_block_size=1)
+ return path
+
+
+class TestDecoderFrameCountExact:
+ """_decoder_frame_count returns the actual decoded count from the container."""
+
+ @pytest.mark.parametrize("n", [1, 5, 16, 33])
+ def test_matches_encoded_frame_count(self, tmp_path, n: int) -> None:
+ path = _write_mp4(tmp_path, n)
+ assert _decoder_frame_count(path) == n
+
+
+class TestDecoderFrameCountGracefulFallback:
+ """_decoder_frame_count returns None on unreadable inputs so the caller can fall back."""
+
+ def test_missing_path_returns_none(self, tmp_path) -> None:
+ bogus = tmp_path / "does_not_exist.mp4"
+ # Either iio raises (caught) or returns props without shape — both must yield None.
+ assert _decoder_frame_count(bogus) is None
+
+ def test_non_video_file_returns_none(self, tmp_path) -> None:
+ not_a_video = tmp_path / "junk.mp4"
+ not_a_video.write_bytes(b"not actually an mp4")
+ assert _decoder_frame_count(not_a_video) is None
diff --git a/tests/app/invocations/test_wan_denoise.py b/tests/app/invocations/test_wan_denoise.py
new file mode 100644
index 00000000000..e4eba684c39
--- /dev/null
+++ b/tests/app/invocations/test_wan_denoise.py
@@ -0,0 +1,790 @@
+"""CPU-only integration tests for ``WanDenoiseInvocation``.
+
+These tests substitute a synthetic transformer (no weights) for the real
+``WanTransformer3DModel`` so the denoise loop's shape-handling, scheduler
+integration, CFG branch, and step-callback wiring can be exercised on a CPU
+runner. End-to-end tests against real Wan checkpoints are gated behind
+``INVOKEAI_HEAVY_TESTS=1`` and require a working CUDA install.
+"""
+
+from __future__ import annotations
+
+import os
+from contextlib import contextmanager
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+import torch
+import torch.nn as nn
+
+from invokeai.app.invocations.fields import WanConditioningField, WanRefImageConditioningField
+from invokeai.app.invocations.model import WanTransformerField
+from invokeai.app.invocations.wan_denoise import WanDenoiseInvocation
+from invokeai.backend.model_manager.taxonomy import WanVariantType
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ ConditioningFieldData,
+ WanConditioningInfo,
+)
+
+
+class _ZeroTransformer(nn.Module):
+ """Stand-in for ``WanTransformer3DModel``.
+
+ Returns ``torch.zeros_like(hidden_states)`` so the flow-matching scheduler
+ treats every step as a no-op velocity. After N steps the latents equal the
+ initial noise — a useful invariant for shape correctness.
+
+ ``label`` lets dual-expert tests record which expert was invoked.
+ """
+
+ def __init__(self, label: str = "single") -> None:
+ super().__init__()
+ self.dtype = torch.float32
+ self.label = label
+ self.calls: list[tuple[int, ...]] = []
+ self.timesteps_seen: list[float] = []
+
+ def forward( # noqa: D401 — match diffusers signature
+ self,
+ hidden_states: torch.Tensor,
+ timestep: torch.Tensor,
+ encoder_hidden_states: torch.Tensor,
+ attention_kwargs=None,
+ return_dict: bool = True,
+ ):
+ # Record the call so assertions can verify shape contracts.
+ self.calls.append(
+ (
+ tuple(hidden_states.shape),
+ tuple(timestep.shape),
+ tuple(encoder_hidden_states.shape),
+ )
+ )
+ # Record the timestep (t.expand(B) → take first element).
+ self.timesteps_seen.append(float(timestep.flatten()[0].item()))
+ # Real Wan I2V transformer has in_channels=36 (16 noise + 20 ref-image
+ # condition) but out_channels=16. T2V is 16/16 and TI2V-5B is 48/48 —
+ # both have matching in/out. Mirror that by only collapsing the I2V
+ # input width back to 16 channels.
+ out_shape = list(hidden_states.shape)
+ if out_shape[1] == 36:
+ out_shape[1] = 16
+ out = torch.zeros(out_shape, dtype=hidden_states.dtype, device=hidden_states.device)
+ if return_dict:
+ return type("Out", (), {"sample": out})
+ return (out,)
+
+
+@contextmanager
+def _model_on_device_ctx(model: nn.Module):
+ yield (None, model)
+
+
+def _make_loaded_model(model: nn.Module) -> MagicMock:
+ """Mock ``LoadedModel`` exposing only the methods the denoise loop touches."""
+ loaded = MagicMock()
+ loaded.model_on_device = lambda: _model_on_device_ctx(model)
+ return loaded
+
+
+def _build_context(
+ transformer: nn.Module,
+ *,
+ variant: WanVariantType,
+ model_root: Path,
+ pos_cond: WanConditioningInfo,
+ neg_cond: WanConditioningInfo | None,
+ transformer_low: nn.Module | None = None,
+) -> MagicMock:
+ """Build a MagicMock InvocationContext sufficient for ``_run_diffusion``.
+
+ When ``transformer_low`` is provided, ``context.models.load`` routes the
+ request based on the ``ModelIdentifierField.submodel_type`` so dual-expert
+ code paths see two distinct loaded models.
+ """
+ config = MagicMock()
+ config.variant = variant
+ config.format = "diffusers"
+
+ context = MagicMock()
+ context.models.get_config.return_value = config
+ context.models.get_absolute_path.return_value = model_root
+
+ def _load(model_id) -> MagicMock:
+ submodel_type = getattr(model_id, "submodel_type", None)
+ if transformer_low is not None and str(submodel_type) == "SubModelType.Transformer2":
+ return _make_loaded_model(transformer_low)
+ return _make_loaded_model(transformer)
+
+ context.models.load.side_effect = _load
+
+ def _load_conditioning(name: str) -> ConditioningFieldData:
+ if name == "pos":
+ return ConditioningFieldData(conditionings=[pos_cond])
+ if name == "neg" and neg_cond is not None:
+ return ConditioningFieldData(conditionings=[neg_cond])
+ raise KeyError(name)
+
+ context.conditioning.load.side_effect = _load_conditioning
+ context.util.signal_progress = MagicMock()
+ context.util.sd_step_callback = MagicMock()
+ context.logger = MagicMock()
+ return context
+
+
+def _make_conditioning(seq_len: int = 226, hidden: int = 4096) -> WanConditioningInfo:
+ return WanConditioningInfo(
+ prompt_embeds=torch.zeros(seq_len, hidden),
+ prompt_attention_mask=None,
+ )
+
+
+def _make_invocation(
+ transformer_field: WanTransformerField,
+ pos_field: WanConditioningField,
+ neg_field: WanConditioningField | None,
+ *,
+ width: int,
+ height: int,
+ steps: int,
+ guidance_scale: float,
+ guidance_scale_low_noise: float | None = None,
+) -> WanDenoiseInvocation:
+ return WanDenoiseInvocation(
+ id="test",
+ transformer=transformer_field,
+ positive_conditioning=pos_field,
+ negative_conditioning=neg_field,
+ width=width,
+ height=height,
+ steps=steps,
+ guidance_scale=guidance_scale,
+ guidance_scale_low_noise=guidance_scale_low_noise,
+ seed=42,
+ )
+
+
+@pytest.fixture
+def fake_model_root():
+ """A directory layout the denoise helpers can read.
+
+ No ``scheduler/`` subfolder, so the scheduler falls back to defaults — that
+ keeps the test self-contained.
+ """
+ with TemporaryDirectory() as tmp:
+ yield Path(tmp)
+
+
+@pytest.fixture(autouse=True)
+def _force_cpu(monkeypatch):
+ """Pin TorchDevice to CPU + float32 for deterministic, GPU-free tests."""
+ from invokeai.backend.util.devices import TorchDevice
+
+ monkeypatch.setattr(TorchDevice, "choose_torch_device", classmethod(lambda cls: torch.device("cpu")))
+ monkeypatch.setattr(TorchDevice, "choose_bfloat16_safe_dtype", classmethod(lambda cls, device=None: torch.float32))
+
+
+def _wan_transformer_field(*, dual: bool = False, boundary_ratio: float = 0.875) -> WanTransformerField:
+ """Build a WanTransformerField. With ``dual=True`` a low-noise expert slot
+ is also populated so the denoise loop exercises the MoE swap path."""
+ base_id = {
+ "key": "wan-test",
+ "name": "wan-test",
+ "base": "wan",
+ "type": "main",
+ "hash": "h",
+ }
+ field_kwargs: dict = {
+ "transformer": {**base_id, "submodel_type": "transformer"},
+ "boundary_ratio": boundary_ratio,
+ }
+ if dual:
+ field_kwargs["transformer_low_noise"] = {**base_id, "submodel_type": "transformer_2"}
+ return WanTransformerField(**field_kwargs)
+
+
+class TestWanDenoiseShapes:
+ """Verify the denoise loop runs end-to-end on CPU for both variants."""
+
+ @pytest.mark.parametrize(
+ "variant,latent_channels,scale,height,width",
+ [
+ (WanVariantType.T2V_A14B, 16, 8, 64, 64),
+ (WanVariantType.TI2V_5B, 48, 16, 64, 64),
+ ],
+ )
+ def test_run_diffusion_returns_4d_finite(
+ self, variant, latent_channels, scale, height, width, fake_model_root
+ ) -> None:
+ transformer = _ZeroTransformer()
+ pos = _make_conditioning()
+ ctx = _build_context(
+ transformer,
+ variant=variant,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=None,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=None,
+ width=width,
+ height=height,
+ steps=4,
+ guidance_scale=1.0, # disables CFG, so neg conditioning isn't required
+ )
+
+ latents = inv._run_diffusion(ctx)
+
+ # Output is 4D [B, C, H/scale, W/scale] — temporal dim squeezed.
+ assert latents.ndim == 4
+ assert latents.shape == (1, latent_channels, height // scale, width // scale)
+ assert torch.isfinite(latents).all()
+
+ # Transformer should have been called exactly steps times.
+ assert len(transformer.calls) == 4
+ # Hidden states are 5D with T=1.
+ h_shape, t_shape, ctx_shape = transformer.calls[0]
+ assert h_shape == (1, latent_channels, 1, height // scale, width // scale)
+ assert t_shape == (1,)
+ assert ctx_shape == (1, 226, 4096)
+
+ # Step callback invoked once per step.
+ assert ctx.util.sd_step_callback.call_count == 4
+
+ def test_cfg_doubles_transformer_calls(self, fake_model_root) -> None:
+ """With cfg_scale != 1.0 and a negative prompt, each step runs the model twice."""
+ transformer = _ZeroTransformer()
+ pos = _make_conditioning()
+ neg = _make_conditioning()
+ ctx = _build_context(
+ transformer,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=neg,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=WanConditioningField(conditioning_name="neg"),
+ width=64,
+ height=64,
+ steps=3,
+ guidance_scale=4.0,
+ )
+
+ inv._run_diffusion(ctx)
+ # 3 steps × 2 (cond + uncond) = 6 forward calls.
+ assert len(transformer.calls) == 6
+
+ def test_zero_velocity_preserves_initial_noise(self, fake_model_root) -> None:
+ """A zero-output transformer means the flow-match step never updates latents."""
+ transformer = _ZeroTransformer()
+ pos = _make_conditioning()
+ ctx = _build_context(
+ transformer,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=None,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=None,
+ width=64,
+ height=64,
+ steps=4,
+ guidance_scale=1.0,
+ )
+
+ latents = inv._run_diffusion(ctx)
+
+ # Reproduce the same noise the loop would have generated and compare.
+ from invokeai.backend.wan.sampling_utils import make_noise
+
+ expected = make_noise(
+ batch_size=1,
+ latent_channels=16,
+ height=64,
+ width=64,
+ spatial_scale_factor=8,
+ device=torch.device("cpu"),
+ dtype=torch.float32,
+ seed=42,
+ ).squeeze(2)
+
+ assert torch.allclose(latents, expected, atol=1e-5)
+
+
+class TestWanDenoiseDualExpert:
+ """Verify the A14B dual-expert MoE swap behaves correctly."""
+
+ def test_swap_fires_at_boundary(self, fake_model_root) -> None:
+ """High expert handles t >= boundary_timestep, low expert handles t < boundary_timestep."""
+ high = _ZeroTransformer(label="high")
+ low = _ZeroTransformer(label="low")
+ pos = _make_conditioning()
+ ctx = _build_context(
+ high,
+ transformer_low=low,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=None,
+ )
+
+ # boundary_ratio=0.5 → boundary_timestep=500 (default num_train_timesteps=1000).
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(dual=True, boundary_ratio=0.5),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=None,
+ width=64,
+ height=64,
+ steps=10,
+ guidance_scale=1.0,
+ )
+
+ inv._run_diffusion(ctx)
+
+ # Both experts called.
+ assert len(high.timesteps_seen) > 0, "high-noise expert never invoked"
+ assert len(low.timesteps_seen) > 0, "low-noise expert never invoked"
+
+ # Every high-noise timestep is >= 500; every low-noise timestep is < 500.
+ for t in high.timesteps_seen:
+ assert t >= 500.0, f"high-noise expert saw t={t}, should be >= 500"
+ for t in low.timesteps_seen:
+ assert t < 500.0, f"low-noise expert saw t={t}, should be < 500"
+
+ # Total steps adds up.
+ assert len(high.timesteps_seen) + len(low.timesteps_seen) == 10
+
+ def test_no_swap_when_boundary_skipped(self, fake_model_root) -> None:
+ """boundary_ratio=0.0 → boundary_timestep=0 → all timesteps go to high-noise expert."""
+ high = _ZeroTransformer(label="high")
+ low = _ZeroTransformer(label="low")
+ pos = _make_conditioning()
+ ctx = _build_context(
+ high,
+ transformer_low=low,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=None,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(dual=True, boundary_ratio=0.0),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=None,
+ width=64,
+ height=64,
+ steps=4,
+ guidance_scale=1.0,
+ )
+
+ inv._run_diffusion(ctx)
+
+ # boundary_timestep=0 → t >= 0 always → high-noise expert handles every step.
+ assert len(high.timesteps_seen) == 4
+ assert len(low.timesteps_seen) == 0
+
+ def test_full_low_noise_when_boundary_at_max(self, fake_model_root) -> None:
+ """boundary_ratio=1.0 → boundary_timestep=1000 → almost all steps go to low-noise expert.
+
+ With FlowMatchEuler the first timestep is exactly 1000 so the high-noise
+ expert handles it (>= boundary), and every subsequent timestep is < 1000.
+ """
+ high = _ZeroTransformer(label="high")
+ low = _ZeroTransformer(label="low")
+ pos = _make_conditioning()
+ ctx = _build_context(
+ high,
+ transformer_low=low,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=None,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(dual=True, boundary_ratio=1.0),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=None,
+ width=64,
+ height=64,
+ steps=4,
+ guidance_scale=1.0,
+ )
+
+ inv._run_diffusion(ctx)
+
+ # First step is t==1000 → high. All later steps are < 1000 → low.
+ assert len(high.timesteps_seen) == 1
+ assert high.timesteps_seen[0] == 1000.0
+ assert len(low.timesteps_seen) == 3
+
+ def test_cfg_with_dual_experts_doubles_calls_per_step(self, fake_model_root) -> None:
+ """With negative conditioning + cfg_scale != 1, every step runs the active expert twice."""
+ high = _ZeroTransformer(label="high")
+ low = _ZeroTransformer(label="low")
+ pos = _make_conditioning()
+ neg = _make_conditioning()
+ ctx = _build_context(
+ high,
+ transformer_low=low,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ pos_cond=pos,
+ neg_cond=neg,
+ )
+
+ inv = _make_invocation(
+ transformer_field=_wan_transformer_field(dual=True, boundary_ratio=0.5),
+ pos_field=WanConditioningField(conditioning_name="pos"),
+ neg_field=WanConditioningField(conditioning_name="neg"),
+ width=64,
+ height=64,
+ steps=6,
+ guidance_scale=4.0,
+ guidance_scale_low_noise=2.0, # Field accepted by the invocation; effect is implicit.
+ )
+
+ inv._run_diffusion(ctx)
+
+ # Total transformer invocations: 6 steps × 2 (cond + uncond) = 12, split across experts.
+ total = len(high.timesteps_seen) + len(low.timesteps_seen)
+ assert total == 12
+
+ # Each unique timestep appears twice (cond + uncond) on the same expert.
+ from collections import Counter
+
+ high_counts = Counter(high.timesteps_seen)
+ low_counts = Counter(low.timesteps_seen)
+ assert all(v == 2 for v in high_counts.values()), high_counts
+ assert all(v == 2 for v in low_counts.values()), low_counts
+
+ # And the swap actually happened — both experts saw work.
+ assert len(high_counts) > 0 and len(low_counts) > 0
+
+
+@pytest.mark.skipif(
+ os.environ.get("INVOKEAI_HEAVY_TESTS") != "1",
+ reason="End-to-end test requires real Wan weights and CUDA; opt in with INVOKEAI_HEAVY_TESTS=1",
+)
+class TestWanDenoiseHeavy:
+ """Placeholder for a real-weights smoke test once CUDA is available."""
+
+ def test_real_ti2v_5b_runs(self) -> None:
+ pytest.skip("Heavy test stub — implement once a TI2V-5B checkpoint is installable.")
+
+
+class TestWanDenoiseRefImage:
+ """Phase 7: VAE-latent reference-image conditioning for I2V-A14B.
+
+ The denoise loop must concatenate the 20-channel condition tensor to the
+ 16-channel noise latents at every transformer call, producing 36-channel
+ input. Variant gate must fast-fail when ref_image is wired to a non-I2V
+ transformer."""
+
+ def _build_ctx_with_condition(
+ self,
+ transformer: _ZeroTransformer,
+ variant: WanVariantType,
+ model_root: Path,
+ condition_tensor: torch.Tensor | None,
+ ) -> MagicMock:
+ ctx = _build_context(
+ transformer,
+ variant=variant,
+ model_root=model_root,
+ pos_cond=_make_conditioning(),
+ neg_cond=None,
+ )
+ if condition_tensor is not None:
+ ctx.tensors.load.return_value = condition_tensor
+ return ctx
+
+ def _make_inv_with_ref(
+ self,
+ ref_field: "WanRefImageConditioningField | None",
+ *,
+ width: int = 64,
+ height: int = 64,
+ ) -> WanDenoiseInvocation:
+ return WanDenoiseInvocation(
+ id="test",
+ transformer=_wan_transformer_field(dual=True),
+ positive_conditioning=WanConditioningField(conditioning_name="pos"),
+ negative_conditioning=None,
+ ref_image=ref_field,
+ width=width,
+ height=height,
+ steps=3,
+ guidance_scale=1.0,
+ seed=42,
+ )
+
+ def test_ref_image_concatenated_to_36_channels(self, fake_model_root: Path) -> None:
+ """I2V_A14B + ref_image → transformer sees [B, 36, T, H/8, W/8]."""
+ transformer = _ZeroTransformer()
+ # Build the 20-channel condition tensor the encoder would have saved:
+ # 4-ch first-frame mask + 16-ch VAE-encoded image latents.
+ # At 64x64 → 8x8 latent spatial dims.
+ condition = torch.zeros(1, 20, 1, 8, 8)
+ ctx = self._build_ctx_with_condition(transformer, WanVariantType.I2V_A14B, fake_model_root, condition)
+
+ ref_field = WanRefImageConditioningField(condition_tensor_name="condition", width=64, height=64)
+ inv = self._make_inv_with_ref(ref_field)
+ inv._run_diffusion(ctx)
+
+ assert len(transformer.calls) == 3
+ # Every call's hidden_states must have 36 channels (16 noise + 20 condition).
+ for h_shape, *_ in transformer.calls:
+ assert h_shape == (1, 36, 1, 8, 8), f"expected 36-channel input, got {h_shape}"
+
+ def test_no_ref_image_keeps_16_channels(self, fake_model_root: Path) -> None:
+ """Without ref_image → transformer sees [B, 16, T, H/8, W/8] as before."""
+ transformer = _ZeroTransformer()
+ ctx = self._build_ctx_with_condition(
+ transformer, WanVariantType.I2V_A14B, fake_model_root, condition_tensor=None
+ )
+
+ inv = self._make_inv_with_ref(ref_field=None)
+ inv._run_diffusion(ctx)
+
+ for h_shape, *_ in transformer.calls:
+ assert h_shape == (1, 16, 1, 8, 8), f"expected unchanged 16-channel input, got {h_shape}"
+
+ def test_variant_gate_rejects_ref_image_on_t2v(self, fake_model_root: Path) -> None:
+ """T2V_A14B + ref_image must raise — fast-fail before doing any work."""
+ transformer = _ZeroTransformer()
+ condition = torch.zeros(1, 20, 1, 8, 8)
+ ctx = self._build_ctx_with_condition(transformer, WanVariantType.T2V_A14B, fake_model_root, condition)
+
+ ref_field = WanRefImageConditioningField(condition_tensor_name="condition", width=64, height=64)
+ inv = self._make_inv_with_ref(ref_field)
+ with pytest.raises(ValueError, match="only supported by the Wan 2.2 I2V variant"):
+ inv._run_diffusion(ctx)
+
+ def test_variant_gate_rejects_ref_image_on_ti2v(self, fake_model_root: Path) -> None:
+ """TI2V-5B + ref_image must raise — TI2V uses a different image path."""
+ transformer = _ZeroTransformer()
+ condition = torch.zeros(1, 20, 1, 8, 8)
+ ctx = self._build_ctx_with_condition(transformer, WanVariantType.TI2V_5B, fake_model_root, condition)
+
+ ref_field = WanRefImageConditioningField(condition_tensor_name="condition", width=64, height=64)
+ inv = self._make_inv_with_ref(ref_field)
+ with pytest.raises(ValueError, match="only supported by the Wan 2.2 I2V variant"):
+ inv._run_diffusion(ctx)
+
+ def test_dim_mismatch_raises(self, fake_model_root: Path) -> None:
+ """If the encoder's width/height differ from denoise's, fail clearly."""
+ transformer = _ZeroTransformer()
+ condition = torch.zeros(1, 20, 1, 8, 8)
+ ctx = self._build_ctx_with_condition(transformer, WanVariantType.I2V_A14B, fake_model_root, condition)
+
+ ref_field = WanRefImageConditioningField(condition_tensor_name="condition", width=512, height=512)
+ inv = self._make_inv_with_ref(ref_field, width=64, height=64)
+ with pytest.raises(ValueError, match="must match denoise dimensions"):
+ inv._run_diffusion(ctx)
+
+
+class TestWanDenoiseInpaint:
+ """Phase 8: ``denoise_mask`` (inpaint) wiring via ``RectifiedFlowInpaintExtension``.
+
+ User-side mask convention (matches Anima / Flux): 1.0 = preserve,
+ 0.0 = regenerate. After ``_prep_inpaint_mask`` inverts, the extension
+ sees: 0.0 = preserve, 1.0 = regenerate.
+
+ With the synthetic zero-output transformer, the scheduler step is a
+ no-op (noise_pred=0 → latents unchanged). The init latents are placed
+ into the preserved regions at every step via the extension's merge
+ function; the regenerated regions stay as the original noise tensor
+ because the model never updates them.
+ """
+
+ def _build_inpaint_context(
+ self,
+ transformer: _ZeroTransformer,
+ variant: WanVariantType,
+ model_root: Path,
+ init_latents: torch.Tensor,
+ mask: torch.Tensor,
+ ) -> MagicMock:
+ ctx = _build_context(
+ transformer,
+ variant=variant,
+ model_root=model_root,
+ pos_cond=_make_conditioning(),
+ neg_cond=None,
+ )
+
+ # tensors.load needs to return different tensors for the init-latents
+ # and the mask, dispatched by the name field.
+ def _load_tensor(name: str) -> torch.Tensor:
+ if name == "init":
+ return init_latents
+ if name == "mask":
+ return mask
+ raise KeyError(name)
+
+ ctx.tensors.load.side_effect = _load_tensor
+ return ctx
+
+ def test_preserved_region_matches_init_exactly(self, fake_model_root: Path) -> None:
+ from invokeai.app.invocations.fields import DenoiseMaskField, LatentsField
+
+ transformer = _ZeroTransformer()
+ # 64x64 image -> 8x8 latents at scale 8 (T2V-A14B family).
+ # Init latents: fixed value 0.5 so the preserved region is detectable.
+ init_latents = torch.full((1, 16, 8, 8), 0.5)
+ # Mask: 8x8 spatial mask, half-1 (preserve left), half-0 (regenerate right).
+ # User-side convention: 1 = preserve, 0 = regenerate.
+ mask = torch.zeros(1, 1, 8, 8)
+ mask[..., :, :4] = 1.0 # left half preserved
+
+ ctx = self._build_inpaint_context(
+ transformer,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ init_latents=init_latents,
+ mask=mask,
+ )
+
+ inv = WanDenoiseInvocation(
+ id="test",
+ transformer=_wan_transformer_field(),
+ positive_conditioning=WanConditioningField(conditioning_name="pos"),
+ negative_conditioning=None,
+ latents=LatentsField(latents_name="init"),
+ denoise_mask=DenoiseMaskField(mask_name="mask", masked_latents_name=None, gradient=False),
+ width=64,
+ height=64,
+ steps=4,
+ guidance_scale=1.0,
+ denoising_start=0.0,
+ denoising_end=1.0,
+ seed=42,
+ )
+
+ out = inv._run_diffusion(ctx) # [B, C, H_lat, W_lat]
+ assert out.shape == (1, 16, 8, 8)
+
+ # Preserved (left) half: must exactly match the init latents at t_prev=0
+ # (final step's merge produces noised_init = noise*0 + 1*init = init).
+ assert torch.allclose(out[..., :, :4], torch.full_like(out[..., :, :4], 0.5)), (
+ "Preserved region must equal init latents at the end of denoise"
+ )
+
+ # Regenerated (right) half: model never changed anything (zero transformer)
+ # so this region stays equal to the original noise, NOT to init.
+ # Assert it's *not* equal to init — concrete proof the regions are
+ # being handled separately.
+ assert not torch.allclose(out[..., :, 4:], torch.full_like(out[..., :, 4:], 0.5)), (
+ "Regenerated region should NOT equal init — extension must route it through the model path"
+ )
+
+ def test_inpaint_requires_init_latents(self, fake_model_root: Path) -> None:
+ """Providing a mask without init latents must raise — there's nothing
+ to merge back into the preserved regions."""
+ from invokeai.app.invocations.fields import DenoiseMaskField
+
+ transformer = _ZeroTransformer()
+ mask = torch.ones(1, 1, 8, 8)
+ ctx = self._build_inpaint_context(
+ transformer,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ init_latents=torch.zeros(1, 16, 8, 8), # unused
+ mask=mask,
+ )
+
+ inv = WanDenoiseInvocation(
+ id="test",
+ transformer=_wan_transformer_field(),
+ positive_conditioning=WanConditioningField(conditioning_name="pos"),
+ negative_conditioning=None,
+ latents=None, # missing — error
+ denoise_mask=DenoiseMaskField(mask_name="mask", masked_latents_name=None, gradient=False),
+ width=64,
+ height=64,
+ steps=2,
+ guidance_scale=1.0,
+ seed=42,
+ )
+
+ with pytest.raises(ValueError, match="img2img inpainting"):
+ inv._run_diffusion(ctx)
+
+ def test_no_mask_path_is_unchanged(self, fake_model_root: Path) -> None:
+ """Without a denoise_mask, the loop behaves as before — sanity check
+ that adding the inpaint extension didn't introduce a regression on
+ the non-inpaint codepath."""
+ from invokeai.app.invocations.fields import LatentsField
+
+ transformer = _ZeroTransformer()
+ init_latents = torch.full((1, 16, 8, 8), 0.3)
+ ctx = self._build_inpaint_context(
+ transformer,
+ variant=WanVariantType.T2V_A14B,
+ model_root=fake_model_root,
+ init_latents=init_latents,
+ mask=torch.zeros(1, 1, 8, 8), # unused — no mask wired
+ )
+
+ inv = WanDenoiseInvocation(
+ id="test",
+ transformer=_wan_transformer_field(),
+ positive_conditioning=WanConditioningField(conditioning_name="pos"),
+ negative_conditioning=None,
+ latents=LatentsField(latents_name="init"),
+ denoise_mask=None, # no mask
+ width=64,
+ height=64,
+ steps=4,
+ guidance_scale=1.0,
+ denoising_start=0.5, # img2img-style partial denoise
+ denoising_end=1.0,
+ seed=42,
+ )
+
+ out = inv._run_diffusion(ctx)
+ assert out.shape == (1, 16, 8, 8)
+ assert torch.isfinite(out).all()
+
+
+class TestDefaultSchedulerForVariant:
+ """``_default_scheduler_for_variant`` returns the right class + config when no
+ on-disk ``scheduler/`` directory exists (the standalone GGUF / single-file case).
+ """
+
+ def test_ti2v_5b_returns_unipc_with_flow_config(self) -> None:
+ from diffusers import UniPCMultistepScheduler
+
+ from invokeai.app.invocations.wan_denoise import _default_scheduler_for_variant
+
+ s = _default_scheduler_for_variant(WanVariantType.TI2V_5B)
+ assert isinstance(s, UniPCMultistepScheduler)
+ # The combination below is what makes this a "Wan flow" UniPC rather than a
+ # generic UniPC schedule — wrong values here drift on TI2V-5B samples.
+ assert s.config.flow_shift == 5.0
+ assert s.config.prediction_type == "flow_prediction"
+ assert s.config.use_flow_sigmas is True
+ assert s.config.solver_type == "bh2"
+
+ def test_a14b_variants_return_flow_match_euler(self) -> None:
+ from diffusers import FlowMatchEulerDiscreteScheduler
+
+ from invokeai.app.invocations.wan_denoise import _default_scheduler_for_variant
+
+ for v in (WanVariantType.T2V_A14B, WanVariantType.I2V_A14B):
+ assert isinstance(_default_scheduler_for_variant(v), FlowMatchEulerDiscreteScheduler)
diff --git a/tests/app/invocations/test_wan_expert_swapper.py b/tests/app/invocations/test_wan_expert_swapper.py
new file mode 100644
index 00000000000..a38dc3141c1
--- /dev/null
+++ b/tests/app/invocations/test_wan_expert_swapper.py
@@ -0,0 +1,520 @@
+"""Tests for ``_ExpertSwapper``'s LoRA-context lifecycle.
+
+The swapper is responsible for entering and exiting both the
+``model_on_device`` context and the ``LayerPatcher.apply_smart_model_patches``
+context in the right order across an expert swap:
+
+ enter HIGH: enter device(HIGH) -> enter lora(HIGH)
+ swap: exit lora(HIGH) -> exit device(HIGH)
+ enter device(LOW) -> enter lora(LOW)
+ close: exit lora(LOW) -> exit device(LOW)
+
+These tests use a tiny ``nn.Linear`` standing in for each transformer expert
+so we can verify the swapper hands back the right model and routes the right
+LoRA factory at each step.
+"""
+
+from typing import Iterable, Tuple
+from unittest.mock import patch
+
+import torch
+import torch.nn as nn
+
+from invokeai.app.invocations.wan_denoise import _ExpertSwapper
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+
+
+class _FakeModelOnDevice:
+ """Minimal stand-in for the model-cache record's ``model_on_device`` context.
+
+ Tracks enter/exit to verify the swapper's lifecycle invariants."""
+
+ def __init__(self, label: str, model: nn.Module, log: list[str]) -> None:
+ self._label = label
+ self._model = model
+ self._log = log
+
+ def __enter__(self):
+ self._log.append(f"device-enter:{self._label}")
+ # Return shape mirrors the real model cache: (cached_weights, model).
+ return (None, self._model)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self._log.append(f"device-exit:{self._label}")
+ return False
+
+
+class _FakeCachedModel:
+ """Stand-in for ``CachedModelWithPartialLoad``: records full_unload_from_vram calls."""
+
+ def __init__(self, label: str, log: list[str]) -> None:
+ self._label = label
+ self._log = log
+ self.unload_calls = 0
+
+ def full_unload_from_vram(self) -> int:
+ self._log.append(f"full-unload:{self._label}")
+ self.unload_calls += 1
+ return 0
+
+
+class _FakeCacheRecord:
+ def __init__(self, cached_model: _FakeCachedModel) -> None:
+ self.cached_model = cached_model
+
+
+class _FakeInfo:
+ """Mirrors the runtime ``LoadedModel`` enough for the swapper to reach
+ ``info._cache_record.cached_model.full_unload_from_vram()`` on swap."""
+
+ def __init__(self, label: str, model: nn.Module, log: list[str]) -> None:
+ self._label = label
+ self._model = model
+ self._log = log
+ self._cache_record = _FakeCacheRecord(_FakeCachedModel(label, log))
+
+ def model_on_device(self):
+ return _FakeModelOnDevice(self._label, self._model, self._log)
+
+
+class _FakeContext:
+ """Mocks ``InvocationContext.models.load`` returning a fresh ``_FakeInfo``
+ for each call — mirrors the real behaviour where the swapper expects a
+ fresh handle per ``get()``."""
+
+ def __init__(self, infos_by_model_id: dict[str, _FakeInfo], log: list[str]) -> None:
+ self._infos = infos_by_model_id
+ self._log = log
+ # Track how many times each model id was loaded — the lazy-load fix
+ # depends on this count being 1 per swap, not 1 upfront.
+
+ class _Models:
+ def __init__(self, outer):
+ self._outer = outer
+ self.load_calls: list[str] = []
+
+ def load(self, model_id):
+ self.load_calls.append(model_id)
+ self._outer._log.append(f"models.load:{model_id}")
+ return self._outer._infos[model_id]
+
+ self.models = _Models(self)
+
+
+def _make_factory(log: list[str], label: str) -> "callable":
+ """Build a LoRAIteratorFactory that records each invocation in ``log``."""
+
+ def factory() -> Iterable[Tuple[ModelPatchRaw, float]]:
+ log.append(f"lora-factory-call:{label}")
+ return iter([])
+
+ return factory
+
+
+def _stub_lora_context_manager(log: list[str]):
+ """Patch ``LayerPatcher.apply_smart_model_patches`` to a stub that records
+ enter/exit in ``log`` and returns a no-op context manager.
+
+ The stub introspects its arguments so we can verify the swapper passes
+ the correct ``model``, ``patches`` factory output, and prefix.
+ """
+ calls: list[dict] = []
+
+ class _Stub:
+ def __init__(self, model, patches, prefix, dtype, cached_weights, force_sidecar_patching):
+ self.model = model
+ self.patches = patches
+ self.prefix = prefix
+ self.dtype = dtype
+ self.cached_weights = cached_weights
+ self.force_sidecar_patching = force_sidecar_patching
+ calls.append(
+ {
+ "model": model,
+ "prefix": prefix,
+ "dtype": dtype,
+ "force_sidecar_patching": force_sidecar_patching,
+ }
+ )
+
+ def __enter__(self):
+ log.append("lora-enter")
+ # Force the factory's iterator to evaluate so we can assert it was
+ # consumed (mirrors the real LayerPatcher behavior).
+ list(self.patches)
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ log.append("lora-exit")
+ return False
+
+ def factory(model, patches, prefix, dtype, cached_weights, force_sidecar_patching=False):
+ return _Stub(model, patches, prefix, dtype, cached_weights, force_sidecar_patching)
+
+ return factory, calls
+
+
+def test_lifecycle_high_only():
+ """Single-expert (TI2V-5B / A14B with only high loaded): enter HIGH, close."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ ctx = _FakeContext({"high": _FakeInfo("HIGH", high_nn, log)}, log)
+
+ stub, calls = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model=None,
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=None,
+ )
+ model = swapper.get(_ExpertSwapper.HIGH)
+ assert model is high_nn
+ swapper.close()
+
+ assert log == [
+ "models.load:high",
+ "device-enter:HIGH",
+ "lora-factory-call:HIGH",
+ "lora-enter",
+ "lora-exit",
+ "device-exit:HIGH",
+ ]
+ assert len(calls) == 1
+ assert calls[0]["model"] is high_nn
+ assert calls[0]["prefix"] == "lora_transformer-"
+
+
+def test_lifecycle_dual_expert_swap():
+ """A14B: HIGH first, then LOW. Each LoRA context opens/closes with its expert."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ low_nn = nn.Linear(1, 1)
+ ctx = _FakeContext(
+ {"high": _FakeInfo("HIGH", high_nn, log), "low": _FakeInfo("LOW", low_nn, log)},
+ log,
+ )
+
+ stub, calls = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model="low",
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=_make_factory(log, "LOW"),
+ )
+ first = swapper.get(_ExpertSwapper.HIGH)
+ assert first is high_nn
+
+ second = swapper.get(_ExpertSwapper.LOW)
+ assert second is low_nn
+
+ swapper.close()
+
+ expected = [
+ # enter HIGH (models.load first, then device, then lora)
+ "models.load:high",
+ "device-enter:HIGH",
+ "lora-factory-call:HIGH",
+ "lora-enter",
+ # swap to LOW: LoRA out -> device out -> force-unload HIGH -> models.load LOW
+ # -> device in -> LoRA in. The full-unload step shoves HIGH's weights off GPU
+ # before the cache decides how much room LOW gets.
+ "lora-exit",
+ "device-exit:HIGH",
+ "full-unload:HIGH",
+ "models.load:low",
+ "device-enter:LOW",
+ "lora-factory-call:LOW",
+ "lora-enter",
+ # close
+ "lora-exit",
+ "device-exit:LOW",
+ ]
+ assert log == expected
+ # Two patcher invocations, each bound to the expected model.
+ assert len(calls) == 2
+ assert calls[0]["model"] is high_nn
+ assert calls[1]["model"] is low_nn
+
+
+def test_quantized_flag_forwards_to_sidecar():
+ """GGUF (quantized) experts must request sidecar patching."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ ctx = _FakeContext({"high": _FakeInfo("HIGH", high_nn, log)}, log)
+
+ stub, calls = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model=None,
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ high_is_quantized=True,
+ )
+ swapper.get(_ExpertSwapper.HIGH)
+ swapper.close()
+
+ assert calls[0]["force_sidecar_patching"] is True
+
+
+def test_no_lora_factory_skips_lora_context():
+ """When no LoRAs are wired, the swapper doesn't enter the LoRA context."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ ctx = _FakeContext({"high": _FakeInfo("HIGH", high_nn, log)}, log)
+
+ stub, calls = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model=None,
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=None, # no LoRAs
+ low_lora_factory=None,
+ )
+ swapper.get(_ExpertSwapper.HIGH)
+ swapper.close()
+
+ # No "lora-enter" / "lora-exit" entries — LayerPatcher was never invoked.
+ assert "lora-enter" not in log
+ assert "lora-exit" not in log
+ assert len(calls) == 0
+
+
+def test_repeat_get_same_label_is_a_no_op():
+ """Calling get(HIGH) twice in a row must not re-enter the contexts.
+
+ Critically, ``models.load`` must only be called once per actual swap —
+ not on every ``get()``. Caching the loaded model on first entry, and
+ short-circuiting re-entry, prevents per-step cache thrash."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ ctx = _FakeContext({"high": _FakeInfo("HIGH", high_nn, log)}, log)
+
+ stub, calls = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model=None,
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ )
+ swapper.get(_ExpertSwapper.HIGH)
+ swapper.get(_ExpertSwapper.HIGH) # should be a no-op
+ swapper.close()
+
+ # device-enter + lora-enter happen exactly once, and crucially
+ # models.load is called only once — repeat get() must short-circuit
+ # so the cache isn't re-touched every step of the denoise loop.
+ assert log.count("models.load:high") == 1
+ assert log.count("device-enter:HIGH") == 1
+ assert log.count("lora-enter") == 1
+ assert log.count("lora-exit") == 1
+ assert log.count("device-exit:HIGH") == 1
+
+
+def test_lazy_load_per_swap_not_upfront():
+ """Regression for the cache-eviction warning that triggered this fix.
+
+ ``models.load`` must NOT be called at swapper construction. It is called
+ only on the first ``get()`` for each expert. This keeps the per-handle
+ cache window small enough that the LRU policy doesn't drop one expert
+ while the other is being used."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ low_nn = nn.Linear(1, 1)
+ ctx = _FakeContext(
+ {"high": _FakeInfo("HIGH", high_nn, log), "low": _FakeInfo("LOW", low_nn, log)},
+ log,
+ )
+
+ stub, _ = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ # Construction alone must not trigger any models.load call.
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model="low",
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=_make_factory(log, "LOW"),
+ )
+ assert ctx.models.load_calls == [], (
+ "Swapper must not call models.load until get() is invoked — see issue #7513 for cache-eviction rationale."
+ )
+
+ # First get(HIGH): loads HIGH only.
+ swapper.get(_ExpertSwapper.HIGH)
+ assert ctx.models.load_calls == ["high"]
+
+ # Swap to LOW: loads LOW only. HIGH is NOT re-loaded — its handle
+ # was used and released, the next call to it (if any) will re-load.
+ swapper.get(_ExpertSwapper.LOW)
+ assert ctx.models.load_calls == ["high", "low"]
+
+ # Back to HIGH: a fresh load (the previous handle is gone). This is
+ # the right behaviour — each swap gets a guaranteed-fresh handle
+ # rather than a stale reference into the cache.
+ swapper.get(_ExpertSwapper.HIGH)
+ assert ctx.models.load_calls == ["high", "low", "high"]
+
+ swapper.close()
+
+
+def test_empty_cache_called_on_swap():
+ """Regression: each expert swap must trigger ``TorchDevice.empty_cache()`` so
+ the next ``partial_load_to_vram`` sees an un-fragmented allocator.
+
+ A14B users reported the low-noise expert ending up far more CPU-resident than
+ the high-noise one — the previous expert's freed blocks stayed pinned in the
+ PyTorch caching allocator across the swap, and partial_load decided there
+ wasn't room for as much of the incoming expert as there actually was."""
+ log: list[str] = []
+ high_nn = nn.Linear(1, 1)
+ low_nn = nn.Linear(1, 1)
+ ctx = _FakeContext(
+ {"high": _FakeInfo("HIGH", high_nn, log), "low": _FakeInfo("LOW", low_nn, log)},
+ log,
+ )
+
+ stub, _ = _stub_lora_context_manager(log)
+ with (
+ patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ),
+ patch("invokeai.app.invocations.wan_denoise.TorchDevice.empty_cache") as empty_cache_mock,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model="low",
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=_make_factory(log, "LOW"),
+ )
+ swapper.get(_ExpertSwapper.HIGH)
+ first_call_count = empty_cache_mock.call_count
+ assert first_call_count >= 1, "empty_cache should run on the initial expert load too"
+
+ swapper.get(_ExpertSwapper.LOW)
+ assert empty_cache_mock.call_count > first_call_count, (
+ "empty_cache must be called on each HIGH→LOW (or LOW→HIGH) swap"
+ )
+
+ # Same-label re-get is a no-op; empty_cache must NOT be re-invoked.
+ before_no_op = empty_cache_mock.call_count
+ swapper.get(_ExpertSwapper.LOW)
+ assert empty_cache_mock.call_count == before_no_op, (
+ "Re-getting the active expert must short-circuit before empty_cache."
+ )
+
+ swapper.close()
+
+
+def test_outgoing_expert_force_unloaded_from_vram():
+ """Regression: on swap, the previous expert's weights must be explicitly forced
+ off VRAM via ``cached_model.full_unload_from_vram()``.
+
+ A14B users observed the high-noise transformer continuing to occupy ~9 GB of
+ VRAM during the low-noise step, because the cache's automatic offload heuristic
+ underestimated how much room the new expert needed when workspace memory from
+ the previous denoise step was still allocated. The swapper sidesteps that by
+ invoking full_unload_from_vram on the outgoing expert directly."""
+ log: list[str] = []
+ high_info = _FakeInfo("HIGH", nn.Linear(1, 1), log)
+ low_info = _FakeInfo("LOW", nn.Linear(1, 1), log)
+ ctx = _FakeContext({"high": high_info, "low": low_info}, log)
+
+ stub, _ = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model="low",
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=_make_factory(log, "LOW"),
+ )
+ # Initial load: nothing to unload yet.
+ swapper.get(_ExpertSwapper.HIGH)
+ assert high_info._cache_record.cached_model.unload_calls == 0
+ assert low_info._cache_record.cached_model.unload_calls == 0
+
+ # Swap to LOW: HIGH must be force-unloaded; LOW is the incoming expert and
+ # must not be unloaded.
+ swapper.get(_ExpertSwapper.LOW)
+ assert high_info._cache_record.cached_model.unload_calls == 1
+ assert low_info._cache_record.cached_model.unload_calls == 0
+
+ # Swap back to HIGH: LOW must now be force-unloaded.
+ swapper.get(_ExpertSwapper.HIGH)
+ assert low_info._cache_record.cached_model.unload_calls == 1
+
+ swapper.close()
+
+
+def test_force_unload_failure_does_not_break_swap():
+ """If full_unload_from_vram raises (e.g. cache evicted the entry between unlock
+ and now), the swap must still succeed. Reaching into a private attribute is the
+ pragmatic choice today; this test pins the defensive try/except so a future
+ refactor of LoadedModel doesn't break swap reliability."""
+ log: list[str] = []
+
+ class _RaisingCachedModel:
+ def full_unload_from_vram(self):
+ raise RuntimeError("cache evicted me between unlock and unload")
+
+ raising_high = _FakeInfo("HIGH", nn.Linear(1, 1), log)
+ raising_high._cache_record = _FakeCacheRecord(_RaisingCachedModel())
+ low_info = _FakeInfo("LOW", nn.Linear(1, 1), log)
+ ctx = _FakeContext({"high": raising_high, "low": low_info}, log)
+
+ stub, _ = _stub_lora_context_manager(log)
+ with patch(
+ "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches",
+ side_effect=stub,
+ ):
+ swapper = _ExpertSwapper(
+ context=ctx,
+ high_model="high",
+ low_model="low",
+ inference_dtype=torch.bfloat16,
+ high_lora_factory=_make_factory(log, "HIGH"),
+ low_lora_factory=_make_factory(log, "LOW"),
+ )
+ swapper.get(_ExpertSwapper.HIGH)
+ # Should not raise even though the outgoing expert's full_unload throws.
+ model = swapper.get(_ExpertSwapper.LOW)
+ assert model is low_info._model
+ swapper.close()
diff --git a/tests/app/invocations/test_wan_ideal_dimensions.py b/tests/app/invocations/test_wan_ideal_dimensions.py
new file mode 100644
index 00000000000..a46e65c0f4b
--- /dev/null
+++ b/tests/app/invocations/test_wan_ideal_dimensions.py
@@ -0,0 +1,134 @@
+"""Unit tests for WanI2VIdealDimensionsInvocation.
+
+The node is a pure math transform — no context dependencies — so we can call
+``invoke`` with ``None`` directly.
+"""
+
+import pytest
+
+from invokeai.app.invocations.wan_ideal_dimensions import (
+ WAN_TARGET_RESOLUTION_PX,
+ WanI2VIdealDimensionsInvocation,
+)
+
+
+def _resolve(w: int, h: int, target: str = "720p", rounding: str = "nearest") -> tuple[int, int]:
+ inv = WanI2VIdealDimensionsInvocation(
+ width=w,
+ height=h,
+ target_resolution=target, # type: ignore[arg-type]
+ rounding=rounding, # type: ignore[arg-type]
+ )
+ out = inv.invoke(None) # type: ignore[arg-type]
+ return out.width, out.height
+
+
+class TestCommonResolutions:
+ """The output table from the docs."""
+
+ @pytest.mark.parametrize(
+ "w, h, target, expected",
+ [
+ (1920, 1080, "720p", (1280, 720)),
+ (1080, 1920, "720p", (720, 1280)),
+ (832, 480, "720p", (1248, 720)),
+ (4032, 3024, "720p", (960, 720)),
+ (3840, 2160, "720p", (1280, 720)),
+ (1024, 1024, "720p", (720, 720)),
+ (1920, 1080, "480p", (848, 480)),
+ (1920, 1080, "1080p", (1920, 1088)), # 1080 → snaps to 1088 (next multiple of 16)
+ ],
+ )
+ def test_nearest(self, w: int, h: int, target: str, expected: tuple[int, int]) -> None:
+ assert _resolve(w, h, target=target) == expected
+
+
+class TestRoundingModes:
+ """Floor / ceiling produce the expected over- or under-shoot vs. nearest."""
+
+ def test_floor_never_exceeds_raw(self) -> None:
+ # 1920x1080 → 480p has raw_w = 853.33; floor → 848, ceil → 864
+ assert _resolve(1920, 1080, target="480p", rounding="floor") == (848, 480)
+ assert _resolve(1920, 1080, target="480p", rounding="ceiling") == (864, 480)
+
+ def test_floor_and_ceiling_diverge_for_non_grid_aspect(self) -> None:
+ # 21:9-ish: 2048x858, raw_w = 1718.27 → floor 1712, ceil 1728
+ assert _resolve(2048, 858, target="720p", rounding="floor") == (1712, 720)
+ assert _resolve(2048, 858, target="720p", rounding="ceiling") == (1728, 720)
+
+
+class TestPostconditions:
+ """Output invariants that must always hold."""
+
+ @pytest.mark.parametrize(
+ "w, h, target",
+ [
+ (1920, 1080, "480p"),
+ (1920, 1080, "720p"),
+ (1080, 1920, "720p"),
+ (832, 480, "720p"),
+ (2048, 858, "720p"),
+ (4032, 3024, "480p"),
+ (17, 17, "720p"), # tiny input
+ ],
+ )
+ @pytest.mark.parametrize("rounding", ["nearest", "floor", "ceiling"])
+ def test_output_dims_are_multiples_of_16(self, w: int, h: int, target: str, rounding: str) -> None:
+ ow, oh = _resolve(w, h, target=target, rounding=rounding)
+ assert ow % 16 == 0
+ assert oh % 16 == 0
+
+ @pytest.mark.parametrize(
+ "w, h, target",
+ [
+ (1920, 1080, "720p"),
+ (1080, 1920, "720p"),
+ (832, 480, "720p"),
+ ],
+ )
+ def test_output_aspect_ratio_within_1_percent(self, w: int, h: int, target: str) -> None:
+ ow, oh = _resolve(w, h, target=target)
+ input_aspect = w / h
+ output_aspect = ow / oh
+ # 16-grid snap can shift aspect by at most half a 16-step on the long axis,
+ # which is ~1.1% at 720 short.
+ assert abs(output_aspect - input_aspect) / input_aspect < 0.012
+
+ def test_output_dims_never_zero(self) -> None:
+ # Pathologically small input shouldn't return 0×0 even at the smallest preset.
+ ow, oh = _resolve(8, 8, target="480p", rounding="floor")
+ assert ow >= 16
+ assert oh >= 16
+
+
+class TestResolutionPresetTable:
+ """The dropdown values must map to the documented short-side pixel counts."""
+
+ def test_presets_cover_canonical_video_sizes(self) -> None:
+ assert WAN_TARGET_RESOLUTION_PX == {"480p": 480, "720p": 720, "1080p": 1080}
+
+
+class TestInputValidation:
+ """Reject obviously bad inputs at the schema layer."""
+
+ def test_zero_width_rejected(self) -> None:
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ WanI2VIdealDimensionsInvocation(width=0, height=720)
+
+ def test_negative_height_rejected(self) -> None:
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ WanI2VIdealDimensionsInvocation(width=720, height=-1)
+
+ def test_unknown_resolution_rejected(self) -> None:
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ WanI2VIdealDimensionsInvocation(
+ width=1920,
+ height=1080,
+ target_resolution="2160p", # type: ignore[arg-type]
+ )
diff --git a/tests/app/invocations/test_wan_lora_loader.py b/tests/app/invocations/test_wan_lora_loader.py
new file mode 100644
index 00000000000..ce250eff86d
--- /dev/null
+++ b/tests/app/invocations/test_wan_lora_loader.py
@@ -0,0 +1,225 @@
+"""Tests for ``WanLoRALoaderInvocation`` target resolution and routing."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, WanTransformerField
+from invokeai.app.invocations.wan_lora_loader import (
+ WanLoRACollectionLoader,
+ WanLoRALoaderInvocation,
+ _resolve_target,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+# --------------------------------------------------------------------------
+# _resolve_target — pure function, no mocks needed.
+# --------------------------------------------------------------------------
+
+
+class TestResolveTarget:
+ @pytest.mark.parametrize(
+ "target, expert, expected",
+ [
+ ("auto", None, (True, True)),
+ ("auto", "high", (True, False)),
+ ("auto", "low", (False, True)),
+ ("both", None, (True, True)),
+ ("both", "high", (True, True)),
+ ("both", "low", (True, True)),
+ ("high", None, (True, False)),
+ ("high", "low", (True, False)), # explicit target overrides config
+ ("low", None, (False, True)),
+ ("low", "high", (False, True)),
+ ],
+ )
+ def test_target_resolution(self, target, expert, expected):
+ assert _resolve_target(target, expert) == expected
+
+
+# --------------------------------------------------------------------------
+# WanLoRALoaderInvocation — routing into primary vs low-noise lists.
+# --------------------------------------------------------------------------
+
+
+def _make_lora_field(key: str = "lora-1") -> ModelIdentifierField:
+ return ModelIdentifierField(
+ key=key,
+ hash=f"hash-{key}",
+ name=f"name-{key}",
+ base=BaseModelType.Wan,
+ type=ModelType.LoRA,
+ )
+
+
+def _make_transformer_field() -> WanTransformerField:
+ transformer_id = ModelIdentifierField(
+ key="wan-main",
+ hash="wan-main-hash",
+ name="wan-main",
+ base=BaseModelType.Wan,
+ type=ModelType.Main,
+ )
+ return WanTransformerField(transformer=transformer_id)
+
+
+def _make_context(lora_expert: str | None) -> MagicMock:
+ """Mock context where context.models.get_config(lora).expert == lora_expert
+ and context.models.exists returns True for any lora key."""
+ ctx = MagicMock()
+ ctx.models.exists.return_value = True
+ config = MagicMock()
+ config.expert = lora_expert
+ ctx.models.get_config.return_value = config
+ return ctx
+
+
+class TestSingleLoaderRouting:
+ def test_auto_untagged_goes_to_both(self):
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(), transformer=_make_transformer_field())
+ out = inv.invoke(_make_context(lora_expert=None))
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 1
+ assert len(out.transformer.loras_low_noise) == 1
+
+ def test_auto_high_tag_goes_to_primary_only(self):
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(), transformer=_make_transformer_field())
+ out = inv.invoke(_make_context(lora_expert="high"))
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 1
+ assert len(out.transformer.loras_low_noise) == 0
+
+ def test_auto_low_tag_goes_to_low_only(self):
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(), transformer=_make_transformer_field())
+ out = inv.invoke(_make_context(lora_expert="low"))
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 0
+ assert len(out.transformer.loras_low_noise) == 1
+
+ def test_explicit_target_overrides_tag(self):
+ inv = WanLoRALoaderInvocation(
+ id="inv-1",
+ lora=_make_lora_field(),
+ target="high",
+ transformer=_make_transformer_field(),
+ )
+ out = inv.invoke(_make_context(lora_expert="low"))
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 1
+ assert len(out.transformer.loras_low_noise) == 0
+
+ def test_weight_propagates(self):
+ inv = WanLoRALoaderInvocation(
+ id="inv-1",
+ lora=_make_lora_field(),
+ weight=0.42,
+ transformer=_make_transformer_field(),
+ )
+ out = inv.invoke(_make_context(lora_expert=None))
+ assert out.transformer is not None
+ assert out.transformer.loras[0].weight == pytest.approx(0.42)
+
+ def test_unknown_lora_raises(self):
+ ctx = _make_context(lora_expert=None)
+ ctx.models.exists.return_value = False
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(), transformer=_make_transformer_field())
+ with pytest.raises(ValueError, match="Unknown lora"):
+ inv.invoke(ctx)
+
+ def test_duplicate_on_primary_raises(self):
+ existing = LoRAField(lora=_make_lora_field(key="dup"), weight=0.5)
+ transformer = _make_transformer_field()
+ transformer.loras.append(existing)
+
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(key="dup"), transformer=transformer)
+ with pytest.raises(ValueError, match="already applied to primary"):
+ inv.invoke(_make_context(lora_expert="high"))
+
+ def test_duplicate_on_low_noise_raises(self):
+ existing = LoRAField(lora=_make_lora_field(key="dup"), weight=0.5)
+ transformer = _make_transformer_field()
+ transformer.loras_low_noise.append(existing)
+
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(key="dup"), transformer=transformer)
+ with pytest.raises(ValueError, match="already applied to low-noise"):
+ inv.invoke(_make_context(lora_expert="low"))
+
+ def test_no_transformer_returns_empty_output(self):
+ inv = WanLoRALoaderInvocation(id="inv-1", lora=_make_lora_field(), transformer=None)
+ out = inv.invoke(_make_context(lora_expert=None))
+ assert out.transformer is None
+
+
+# --------------------------------------------------------------------------
+# Collection loader — list routing + base validation.
+# --------------------------------------------------------------------------
+
+
+class TestCollectionLoaderRouting:
+ def test_routes_mixed_tagged_loras(self):
+ """A collection of three LoRAs (high, low, untagged) routes correctly."""
+ high_lora = LoRAField(lora=_make_lora_field(key="lora-high"), weight=0.5)
+ low_lora = LoRAField(lora=_make_lora_field(key="lora-low"), weight=0.6)
+ untagged_lora = LoRAField(lora=_make_lora_field(key="lora-any"), weight=0.7)
+
+ inv = WanLoRACollectionLoader(
+ id="inv-1",
+ loras=[high_lora, low_lora, untagged_lora],
+ transformer=_make_transformer_field(),
+ )
+
+ # The collection loader queries each LoRA's config separately. Mock
+ # get_config to return different expert tags by lora key.
+ expert_by_key = {"lora-high": "high", "lora-low": "low", "lora-any": None}
+ ctx = MagicMock()
+ ctx.models.exists.return_value = True
+
+ def get_config(field: ModelIdentifierField) -> MagicMock:
+ config = MagicMock()
+ config.expert = expert_by_key[field.key]
+ return config
+
+ ctx.models.get_config.side_effect = get_config
+ out = inv.invoke(ctx)
+ assert out.transformer is not None
+
+ primary_keys = {item.lora.key for item in out.transformer.loras}
+ low_keys = {item.lora.key for item in out.transformer.loras_low_noise}
+ # high -> primary only; low -> low only; untagged -> both
+ assert primary_keys == {"lora-high", "lora-any"}
+ assert low_keys == {"lora-low", "lora-any"}
+
+ def test_rejects_non_wan_base(self):
+ wrong_base_lora = LoRAField(
+ lora=ModelIdentifierField(key="not-wan", hash="h", name="n", base=BaseModelType.Flux, type=ModelType.LoRA),
+ weight=0.5,
+ )
+ inv = WanLoRACollectionLoader(id="inv-1", loras=[wrong_base_lora], transformer=_make_transformer_field())
+ ctx = MagicMock()
+ ctx.models.exists.return_value = True
+ with pytest.raises(ValueError, match="not Wan 2.2"):
+ inv.invoke(ctx)
+
+ def test_skips_duplicates(self):
+ dup_lora = LoRAField(lora=_make_lora_field(key="dup"), weight=0.5)
+ inv = WanLoRACollectionLoader(
+ id="inv-1",
+ loras=[dup_lora, dup_lora],
+ transformer=_make_transformer_field(),
+ )
+ ctx = MagicMock()
+ ctx.models.exists.return_value = True
+ config = MagicMock()
+ config.expert = None
+ ctx.models.get_config.return_value = config
+
+ out = inv.invoke(ctx)
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 1
+
+ def test_no_loras_returns_clean_copy(self):
+ inv = WanLoRACollectionLoader(id="inv-1", loras=None, transformer=_make_transformer_field())
+ out = inv.invoke(MagicMock())
+ assert out.transformer is not None
+ assert len(out.transformer.loras) == 0
+ assert len(out.transformer.loras_low_noise) == 0
diff --git a/tests/app/routers/test_boards_multiuser.py b/tests/app/routers/test_boards_multiuser.py
index ab64ac8a9b4..be9817cdef0 100644
--- a/tests/app/routers/test_boards_multiuser.py
+++ b/tests/app/routers/test_boards_multiuser.py
@@ -75,6 +75,13 @@ def enable_multiuser_for_tests(monkeypatch: Any, mock_invoker: Invoker):
mock_board_images.get_all_board_image_names_for_board.return_value = []
mock_invoker.services.board_images = mock_board_images
+ # delete_board cascade-deletes videos on the board too — stub the video services
+ # so the route doesn't hit AttributeError on the None placeholders in mock_services.
+ mock_board_video_records = MagicMock()
+ mock_board_video_records.get_all_board_video_names_for_board.return_value = []
+ mock_invoker.services.board_video_records = mock_board_video_records
+ mock_invoker.services.videos = MagicMock()
+
mock_deps = MockApiDependencies(mock_invoker)
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
@@ -675,3 +682,64 @@ def test_admin_can_change_any_board_visibility(client: TestClient, admin_token:
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["board_visibility"] == "public"
+
+
+# ---------------------------------------------------------------------------
+# Video cascade on board deletion (PR #9163 review fix)
+# ---------------------------------------------------------------------------
+
+
+def test_delete_board_with_include_images_cascades_videos(client: TestClient, mock_invoker: Invoker, user1_token: str):
+ """include_images=true must also call delete_videos_on_board (not image-only)."""
+ create = client.post(
+ "/api/v1/boards/?board_name=Cascade+Test+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ # Pretend the board contains videos so the cascade has something to enumerate
+ mock_invoker.services.board_video_records.get_all_board_video_names_for_board.return_value = [
+ "video_a.mp4",
+ "video_b.mp4",
+ ]
+
+ response = client.delete(
+ f"/api/v1/boards/{board_id}?include_images=true",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert body["board_id"] == board_id
+ # Regression guard: video info must appear in the result, and delete_videos_on_board
+ # must have been invoked. Previously the route ignored videos entirely.
+ assert set(body["deleted_videos"]) == {"video_a.mp4", "video_b.mp4"}
+ mock_invoker.services.videos.delete_videos_on_board.assert_called_once_with(board_id=board_id)
+
+
+def test_delete_board_without_include_images_lists_uncategorized_videos(
+ client: TestClient, mock_invoker: Invoker, user1_token: str
+):
+ """include_images=false: videos cascade out of board_videos and become uncategorized.
+
+ The response now reports those names in deleted_board_videos so the frontend can
+ invalidate the right caches.
+ """
+ create = client.post(
+ "/api/v1/boards/?board_name=Soft+Delete+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert create.status_code == status.HTTP_201_CREATED
+ board_id = create.json()["board_id"]
+
+ mock_invoker.services.board_video_records.get_all_board_video_names_for_board.return_value = ["v1.mp4"]
+
+ response = client.delete(
+ f"/api/v1/boards/{board_id}",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert body["deleted_board_videos"] == ["v1.mp4"]
+ # delete_videos_on_board MUST NOT be invoked when include_images is false.
+ mock_invoker.services.videos.delete_videos_on_board.assert_not_called()
diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py
index 3461f37e7e9..145555c524a 100644
--- a/tests/app/routers/test_multiuser_authorization.py
+++ b/tests/app/routers/test_multiuser_authorization.py
@@ -116,6 +116,11 @@ def mock_services() -> InvocationServices:
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
external_generation=None, # type: ignore
+ videos=None, # type: ignore
+ video_files=None, # type: ignore
+ video_records=None, # type: ignore
+ board_video_records=None, # type: ignore
+ gallery=None, # type: ignore
)
diff --git a/tests/app/routers/test_videos_multiuser.py b/tests/app/routers/test_videos_multiuser.py
new file mode 100644
index 00000000000..9c229e003e7
--- /dev/null
+++ b/tests/app/routers/test_videos_multiuser.py
@@ -0,0 +1,202 @@
+"""Multiuser regression tests for the /v1/videos/ routes.
+
+Covers JPPhoto's code-review finding (PR #9163): the list endpoints accepted
+an explicit ``board_id`` with no read-access check, so a non-admin user could
+enumerate videos on someone else's private board if they happened to know its
+id. The fix added ``_assert_board_read_access`` to both ``list_video_dtos``
+and ``get_video_names``.
+
+These tests exercise the HTTP layer end-to-end (auth + route guards) using the
+same fixture pattern as test_boards_multiuser. The storage-level user_id
+filter is covered separately in tests/app/services/video_records.
+"""
+
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api_app import app
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.users.users_common import UserCreateRequest
+
+
+class MockApiDependencies(ApiDependencies):
+ invoker: Invoker
+
+ def __init__(self, invoker: Invoker) -> None:
+ self.invoker = invoker
+
+
+@pytest.fixture
+def setup_jwt_secret():
+ from invokeai.app.services.auth.token_service import set_jwt_secret
+
+ set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+def setup_test_user(
+ mock_invoker: Invoker,
+ email: str,
+ display_name: str,
+ password: str = "TestPass123",
+ is_admin: bool = False,
+) -> str:
+ user_service = mock_invoker.services.users
+ user = user_service.create(
+ UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin)
+ )
+ return user.user_id
+
+
+def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"email": email, "password": password, "remember_me": False},
+ )
+ assert response.status_code == 200
+ return response.json()["token"]
+
+
+@pytest.fixture
+def enable_multiuser_for_videos(monkeypatch: Any, mock_invoker: Invoker):
+ """Enable multiuser and stub services the video routes touch."""
+ mock_invoker.services.configuration.multiuser = True
+
+ # The list routes call services.videos.get_many / get_video_names. We don't care about
+ # the payloads here — only whether the route runs the board-access guard *before* the
+ # service call. A return value of "any non-error response" is enough.
+ mock_videos = MagicMock()
+ mock_videos.get_many.return_value = {"items": [], "offset": 0, "limit": 10, "total": 0}
+ mock_videos.get_video_names.return_value = {"video_names": [], "starred_count": 0, "total_count": 0}
+ mock_invoker.services.videos = mock_videos
+
+ # board_video_records is touched by remove_video_from_board; not exercised by the
+ # list tests but stub it defensively so unrelated routes don't blow up.
+ mock_invoker.services.board_video_records = MagicMock()
+ mock_invoker.services.video_records = MagicMock()
+ mock_invoker.services.board_images = MagicMock()
+ mock_invoker.services.board_images.get_all_board_image_names_for_board.return_value = []
+
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.videos.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
+ yield
+
+
+@pytest.fixture
+def admin_token(setup_jwt_secret: None, enable_multiuser_for_videos: Any, mock_invoker: Invoker, client: TestClient):
+ setup_test_user(mock_invoker, "admin@test.com", "Test Admin", is_admin=True)
+ return get_user_token(client, "admin@test.com")
+
+
+@pytest.fixture
+def user1_token(enable_multiuser_for_videos: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
+ return get_user_token(client, "user1@test.com")
+
+
+@pytest.fixture
+def user2_token(enable_multiuser_for_videos: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
+ return get_user_token(client, "user2@test.com")
+
+
+@pytest.fixture
+def user1_private_board(client: TestClient, user1_token: str) -> str:
+ response = client.post(
+ "/api/v1/boards/?board_name=User1+Private+Board",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ return response.json()["board_id"]
+
+
+# ---------------------------------------------------------------------------
+# Auth requirement
+# ---------------------------------------------------------------------------
+
+
+def test_list_video_dtos_requires_auth(enable_multiuser_for_videos: Any, client: TestClient):
+ response = client.get("/api/v1/videos/")
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+def test_get_video_names_requires_auth(enable_multiuser_for_videos: Any, client: TestClient):
+ response = client.get("/api/v1/videos/names")
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ---------------------------------------------------------------------------
+# Explicit board_id with no read access (the JPPhoto finding)
+# ---------------------------------------------------------------------------
+
+
+def test_list_video_dtos_forbidden_for_other_users_private_board(
+ client: TestClient, user1_private_board: str, user2_token: str
+):
+ """user2 cannot list videos on user1's private board even if they know the board_id."""
+ response = client.get(
+ f"/api/v1/videos/?board_id={user1_private_board}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_get_video_names_forbidden_for_other_users_private_board(
+ client: TestClient, user1_private_board: str, user2_token: str
+):
+ response = client.get(
+ f"/api/v1/videos/names?board_id={user1_private_board}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_owner_can_list_videos_on_their_private_board(client: TestClient, user1_private_board: str, user1_token: str):
+ response = client.get(
+ f"/api/v1/videos/?board_id={user1_private_board}",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+
+def test_admin_can_list_videos_on_any_private_board(client: TestClient, user1_private_board: str, admin_token: str):
+ response = client.get(
+ f"/api/v1/videos/?board_id={user1_private_board}",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+
+# ---------------------------------------------------------------------------
+# Omitted board_id: route should not blow up; isolation enforced at SQL layer
+# ---------------------------------------------------------------------------
+
+
+def test_list_video_dtos_no_board_id_succeeds_for_any_authed_user(client: TestClient, user2_token: str):
+ """The route allows omitted board_id (the SQL layer filters by user_id) — no 403 here."""
+ response = client.get(
+ "/api/v1/videos/",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+
+
+def test_list_video_dtos_none_board_succeeds_for_any_authed_user(client: TestClient, user2_token: str):
+ response = client.get(
+ "/api/v1/videos/?board_id=none",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_200_OK
diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py
index a1fa5ed5ada..f535f7d22ef 100644
--- a/tests/app/routers/test_workflows_multiuser.py
+++ b/tests/app/routers/test_workflows_multiuser.py
@@ -107,6 +107,11 @@ def mock_services() -> InvocationServices:
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
external_generation=None, # type: ignore
+ videos=None, # type: ignore
+ video_files=None, # type: ignore
+ video_records=None, # type: ignore
+ board_video_records=None, # type: ignore
+ gallery=None, # type: ignore
)
diff --git a/tests/app/services/board_canvas_project_records/__init__.py b/tests/app/services/board_canvas_project_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/app/services/board_canvas_project_records/test_board_canvas_project_records_sqlite.py b/tests/app/services/board_canvas_project_records/test_board_canvas_project_records_sqlite.py
new file mode 100644
index 00000000000..66912c45ad7
--- /dev/null
+++ b/tests/app/services/board_canvas_project_records/test_board_canvas_project_records_sqlite.py
@@ -0,0 +1,186 @@
+"""Tests for SqliteBoardCanvasProjectRecordStorage.
+
+Covers the one-to-many board↔project association: add/remove, idempotent
+re-association (moving from one board to another), counts, and FK cascades.
+"""
+
+import pytest
+
+from invokeai.app.services.board_canvas_project_records.board_canvas_project_records_sqlite import (
+ SqliteBoardCanvasProjectRecordStorage,
+)
+from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+from invokeai.app.services.canvas_project_records.canvas_project_records_sqlite import (
+ SqliteCanvasProjectRecordStorage,
+)
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+@pytest.fixture
+def db() -> SqliteDatabase:
+ config = InvokeAIAppConfig(use_memory_db=True)
+ logger = InvokeAILogger.get_logger(config=config)
+ return create_mock_sqlite_database(config, logger)
+
+
+@pytest.fixture
+def projects(db: SqliteDatabase) -> SqliteCanvasProjectRecordStorage:
+ return SqliteCanvasProjectRecordStorage(db=db)
+
+
+@pytest.fixture
+def boards(db: SqliteDatabase) -> SqliteBoardRecordStorage:
+ return SqliteBoardRecordStorage(db=db)
+
+
+@pytest.fixture
+def store(db: SqliteDatabase) -> SqliteBoardCanvasProjectRecordStorage:
+ return SqliteBoardCanvasProjectRecordStorage(db=db)
+
+
+def _save_project(records: SqliteCanvasProjectRecordStorage, project_name: str) -> None:
+ records.save(
+ project_name=project_name,
+ project_origin=ResourceOrigin.INTERNAL,
+ name=project_name,
+ app_version="test-1.0",
+ width=512,
+ height=512,
+ image_count=0,
+ has_thumbnail=False,
+ )
+
+
+@pytest.fixture
+def seeded(
+ boards: SqliteBoardRecordStorage,
+ projects: SqliteCanvasProjectRecordStorage,
+) -> tuple[str, str]:
+ """Two boards and two projects, ready to be associated by individual tests."""
+ board_a = boards.save(board_name="Board A", user_id="system").board_id
+ board_b = boards.save(board_name="Board B", user_id="system").board_id
+ _save_project(projects, "p1")
+ _save_project(projects, "p2")
+ return board_a, board_b
+
+
+class TestAddProjectToBoard:
+ def test_add_creates_association(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ board_a, _ = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ assert store.get_board_for_project("p1") == board_a
+
+ def test_re_add_moves_project_between_boards(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ # The PK on `project_name` means a project can only belong to one board at a time —
+ # `ON CONFLICT DO UPDATE SET board_id` enforces that. Re-adding to a second board
+ # must move it rather than create a duplicate row.
+ board_a, board_b = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ store.add_project_to_board(board_id=board_b, project_name="p1")
+ assert store.get_board_for_project("p1") == board_b
+ assert "p1" not in store.get_all_board_project_names_for_board(board_a)
+ assert "p1" in store.get_all_board_project_names_for_board(board_b)
+
+
+class TestRemoveProjectFromBoard:
+ def test_remove_drops_association(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ board_a, _ = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ store.remove_project_from_board(project_name="p1")
+ assert store.get_board_for_project("p1") is None
+
+ def test_remove_unassociated_is_noop(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ # Removing a project that was never on a board mustn't raise — the row
+ # simply doesn't exist.
+ store.remove_project_from_board(project_name="p1")
+ assert store.get_board_for_project("p1") is None
+
+
+class TestQueries:
+ def test_get_board_for_project_returns_none_when_unassociated(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ assert store.get_board_for_project("p1") is None
+
+ def test_get_all_board_project_names_for_board(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ board_a, board_b = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ store.add_project_to_board(board_id=board_a, project_name="p2")
+ assert set(store.get_all_board_project_names_for_board(board_a)) == {"p1", "p2"}
+ assert store.get_all_board_project_names_for_board(board_b) == []
+
+ def test_get_all_board_project_names_for_none_returns_uncategorized(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ # `board_id="none"` is the sentinel for "uncategorized" — projects without
+ # a board row at all.
+ board_a, _ = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ # p1 is on board_a, p2 has no association.
+ assert set(store.get_all_board_project_names_for_board("none")) == {"p2"}
+
+ def test_get_project_count_for_board_ignores_intermediate(
+ self,
+ db: SqliteDatabase,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ projects: SqliteCanvasProjectRecordStorage,
+ boards: SqliteBoardRecordStorage,
+ ) -> None:
+ from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CanvasProjectRecordChanges,
+ )
+
+ board_id = boards.save(board_name="Counted", user_id="system").board_id
+ _save_project(projects, "p_normal")
+ _save_project(projects, "p_intermediate")
+ projects.update("p_intermediate", CanvasProjectRecordChanges(is_intermediate=True))
+ store.add_project_to_board(board_id=board_id, project_name="p_normal")
+ store.add_project_to_board(board_id=board_id, project_name="p_intermediate")
+ # Intermediate projects are excluded from the count, mirroring how images/videos do it.
+ assert store.get_project_count_for_board(board_id) == 1
+
+
+class TestForeignKeyCascade:
+ def test_deleting_project_removes_association(
+ self,
+ store: SqliteBoardCanvasProjectRecordStorage,
+ projects: SqliteCanvasProjectRecordStorage,
+ seeded: tuple[str, str],
+ ) -> None:
+ # `board_canvas_projects` FK CASCADE on `canvas_projects.project_name` must drop the
+ # association row when the project is deleted directly via the records service.
+ board_a, _ = seeded
+ store.add_project_to_board(board_id=board_a, project_name="p1")
+ projects.delete("p1")
+ # The orphaned association row is gone — `get_board_for_project` returns None and the
+ # board listing no longer includes p1.
+ assert store.get_board_for_project("p1") is None
+ assert "p1" not in store.get_all_board_project_names_for_board(board_a)
diff --git a/tests/app/services/canvas_project_records/__init__.py b/tests/app/services/canvas_project_records/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/app/services/canvas_project_records/test_canvas_project_records_sqlite.py b/tests/app/services/canvas_project_records/test_canvas_project_records_sqlite.py
new file mode 100644
index 00000000000..83da4b9cc52
--- /dev/null
+++ b/tests/app/services/canvas_project_records/test_canvas_project_records_sqlite.py
@@ -0,0 +1,248 @@
+"""Tests for SqliteCanvasProjectRecordStorage.
+
+Covers the CRUD + filter operations, the in-place metadata update used by the
+`PUT /i/{name}/file` endpoint, and the multiuser isolation contract that pins
+the behaviour for `get_many` / `get_project_names` when ``board_id`` is omitted.
+"""
+
+import pytest
+
+from invokeai.app.services.canvas_project_records.canvas_project_records_common import (
+ CanvasProjectRecordChanges,
+ CanvasProjectRecordNotFoundException,
+)
+from invokeai.app.services.canvas_project_records.canvas_project_records_sqlite import (
+ SqliteCanvasProjectRecordStorage,
+)
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.image_records.image_records_common import ResourceOrigin
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+@pytest.fixture
+def store() -> SqliteCanvasProjectRecordStorage:
+ config = InvokeAIAppConfig(use_memory_db=True)
+ logger = InvokeAILogger.get_logger(config=config)
+ db = create_mock_sqlite_database(config, logger)
+ return SqliteCanvasProjectRecordStorage(db=db)
+
+
+def _save(
+ store: SqliteCanvasProjectRecordStorage,
+ project_name: str,
+ user_id: str = "system",
+ name: str = "Test Project",
+ has_thumbnail: bool = False,
+ starred: bool = False,
+) -> None:
+ store.save(
+ project_name=project_name,
+ project_origin=ResourceOrigin.INTERNAL,
+ name=name,
+ app_version="test-1.0",
+ width=1024,
+ height=1024,
+ image_count=2,
+ has_thumbnail=has_thumbnail,
+ starred=starred,
+ user_id=user_id,
+ )
+
+
+class TestCrud:
+ def test_save_then_get_round_trips_fields(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", user_id="alice", name="My Project", has_thumbnail=True, starred=True)
+ record = store.get("p1")
+ assert record.project_name == "p1"
+ assert record.name == "My Project"
+ assert record.app_version == "test-1.0"
+ assert record.width == 1024
+ assert record.height == 1024
+ assert record.image_count == 2
+ assert record.has_thumbnail is True
+ assert record.starred is True
+ assert record.user_id == "alice"
+ assert record.project_origin == ResourceOrigin.INTERNAL
+
+ def test_get_missing_raises(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ with pytest.raises(CanvasProjectRecordNotFoundException):
+ store.get("does-not-exist")
+
+ def test_delete_removes_record(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1")
+ store.delete("p1")
+ with pytest.raises(CanvasProjectRecordNotFoundException):
+ store.get("p1")
+
+ def test_delete_many(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1")
+ _save(store, "p2")
+ _save(store, "p3")
+ store.delete_many(["p1", "p2"])
+ # The remaining one survived.
+ assert store.get("p3").project_name == "p3"
+ with pytest.raises(CanvasProjectRecordNotFoundException):
+ store.get("p1")
+ with pytest.raises(CanvasProjectRecordNotFoundException):
+ store.get("p2")
+
+ def test_get_user_id_returns_none_when_missing(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ assert store.get_user_id("missing") is None
+
+ def test_get_user_id_returns_owner(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", user_id="bob")
+ assert store.get_user_id("p1") == "bob"
+
+
+class TestUpdate:
+ def test_update_name(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", name="Original")
+ store.update("p1", CanvasProjectRecordChanges(name="Renamed"))
+ assert store.get("p1").name == "Renamed"
+
+ def test_update_starred(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", starred=False)
+ store.update("p1", CanvasProjectRecordChanges(starred=True))
+ assert store.get("p1").starred is True
+
+ def test_update_is_intermediate(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1")
+ store.update("p1", CanvasProjectRecordChanges(is_intermediate=True))
+ assert store.get("p1").is_intermediate is True
+
+ def test_update_with_no_changes_is_noop(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", name="Keep")
+ store.update("p1", CanvasProjectRecordChanges())
+ assert store.get("p1").name == "Keep"
+
+
+class TestSetHasThumbnail:
+ def test_toggle_has_thumbnail(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", has_thumbnail=False)
+ store.set_has_thumbnail("p1", True)
+ assert store.get("p1").has_thumbnail is True
+ store.set_has_thumbnail("p1", False)
+ assert store.get("p1").has_thumbnail is False
+
+
+class TestUpdateFileMetadata:
+ """`update_file_metadata` is called by `PUT /i/{name}/file` when the ZIP is replaced in
+ place. It must atomically refresh dimensions, image_count, thumbnail flag and app_version
+ while preserving everything else (board, starred, ownership)."""
+
+ def test_updates_all_file_fields(self, store: SqliteCanvasProjectRecordStorage) -> None:
+ _save(store, "p1", name="Keep", has_thumbnail=False, starred=True)
+ # Sanity: starred is set from the seed.
+ assert store.get("p1").starred is True
+
+ store.update_file_metadata(
+ project_name="p1",
+ width=2048,
+ height=1024,
+ image_count=7,
+ has_thumbnail=True,
+ app_version="test-1.1",
+ )
+
+ record = store.get("p1")
+ assert record.width == 2048
+ assert record.height == 1024
+ assert record.image_count == 7
+ assert record.has_thumbnail is True
+ assert record.app_version == "test-1.1"
+ # Things we must NOT clobber on a file-replace:
+ assert record.name == "Keep"
+ assert record.starred is True
+ assert record.user_id == "system"
+
+
+class TestGetManyMultiuserIsolation:
+ """When `board_id` is omitted, non-admin callers must only see their own projects.
+
+ Mirrors the regression test added for videos in PR #9163 / test_video_records_sqlite.py.
+ """
+
+ @pytest.fixture
+ def seeded(self, store: SqliteCanvasProjectRecordStorage) -> SqliteCanvasProjectRecordStorage:
+ _save(store, "alice_1", user_id="alice")
+ _save(store, "alice_2", user_id="alice")
+ _save(store, "bob_1", user_id="bob")
+ _save(store, "bob_2", user_id="bob")
+ return store
+
+ def test_non_admin_only_sees_own_projects(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_many(user_id="alice", is_admin=False)
+ names = {p.project_name for p in result.items}
+ assert names == {"alice_1", "alice_2"}
+ assert result.total == 2
+
+ def test_admin_sees_every_users_projects(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_many(user_id="alice", is_admin=True)
+ names = {p.project_name for p in result.items}
+ assert names == {"alice_1", "alice_2", "bob_1", "bob_2"}
+
+ def test_no_user_id_returns_all(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_many(user_id=None, is_admin=False)
+ names = {p.project_name for p in result.items}
+ assert names == {"alice_1", "alice_2", "bob_1", "bob_2"}
+
+ def test_explicit_none_board_still_isolates(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ # "none" sentinel = uncategorized. Must still enforce per-user filtering.
+ result = seeded.get_many(board_id="none", user_id="alice", is_admin=False)
+ names = {p.project_name for p in result.items}
+ assert names == {"alice_1", "alice_2"}
+
+
+class TestGetProjectNamesMultiuserIsolation:
+ @pytest.fixture
+ def seeded(self, store: SqliteCanvasProjectRecordStorage) -> SqliteCanvasProjectRecordStorage:
+ _save(store, "alice_1", user_id="alice")
+ _save(store, "alice_2", user_id="alice")
+ _save(store, "bob_1", user_id="bob")
+ return store
+
+ def test_non_admin_only_sees_own(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_project_names(user_id="alice", is_admin=False)
+ assert set(result.project_names) == {"alice_1", "alice_2"}
+ assert result.total_count == 2
+
+ def test_admin_sees_all(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_project_names(user_id="alice", is_admin=True)
+ assert set(result.project_names) == {"alice_1", "alice_2", "bob_1"}
+
+
+class TestGetManyFilters:
+ @pytest.fixture
+ def seeded(self, store: SqliteCanvasProjectRecordStorage) -> SqliteCanvasProjectRecordStorage:
+ _save(store, "p_starred", starred=True, name="Starred One")
+ _save(store, "p_normal", starred=False, name="Normal Two")
+ _save(store, "p_intermediate", starred=False)
+ store.update("p_intermediate", CanvasProjectRecordChanges(is_intermediate=True))
+ return store
+
+ def test_starred_first_orders_starred_above_normal(
+ self, seeded: SqliteCanvasProjectRecordStorage
+ ) -> None:
+ # Two non-intermediate projects: one starred, one not. Starred-first ordering
+ # should put the starred one first.
+ result = seeded.get_many(starred_first=True, is_intermediate=False)
+ starred_names = [p.project_name for p in result.items if p.starred]
+ normal_names = [p.project_name for p in result.items if not p.starred]
+ assert starred_names == ["p_starred"]
+ assert normal_names == ["p_normal"]
+ # The starred project comes first in the result list.
+ assert result.items[0].project_name == "p_starred"
+
+ def test_is_intermediate_filter(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ all_projects = seeded.get_many(is_intermediate=None)
+ non_intermediate = seeded.get_many(is_intermediate=False)
+ intermediate_only = seeded.get_many(is_intermediate=True)
+ assert all_projects.total == 3
+ assert non_intermediate.total == 2
+ assert intermediate_only.total == 1
+ assert intermediate_only.items[0].project_name == "p_intermediate"
+
+ def test_search_term_matches_name(self, seeded: SqliteCanvasProjectRecordStorage) -> None:
+ result = seeded.get_many(search_term="Starred")
+ assert {p.project_name for p in result.items} == {"p_starred"}
diff --git a/tests/app/services/gallery/test_gallery_default.py b/tests/app/services/gallery/test_gallery_default.py
new file mode 100644
index 00000000000..f2255babf89
--- /dev/null
+++ b/tests/app/services/gallery/test_gallery_default.py
@@ -0,0 +1,102 @@
+"""Regression tests for SqliteGalleryService multiuser isolation.
+
+Covers JPPhoto's code-review finding (PR #9163): the gallery /items/ and
+/items/names endpoints returned every user's items when ``board_id`` was
+omitted, because ``_build_half`` only applied a user filter for the explicit
+"none" sentinel. The fix added an ``elif user_id is not None and not is_admin``
+branch; these tests pin the behaviour for both halves of the polymorphic union.
+"""
+
+import pytest
+
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.gallery.gallery_common import GalleryItemKind
+from invokeai.app.services.gallery.gallery_default import SqliteGalleryService
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
+from invokeai.app.services.video_records.video_records_sqlite import SqliteVideoRecordStorage
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+@pytest.fixture
+def services():
+ config = InvokeAIAppConfig(use_memory_db=True)
+ logger = InvokeAILogger.get_logger(config=config)
+ db = create_mock_sqlite_database(config, logger)
+ return {
+ "gallery": SqliteGalleryService(db=db),
+ "images": SqliteImageRecordStorage(db=db),
+ "videos": SqliteVideoRecordStorage(db=db),
+ }
+
+
+def _save_image(store: SqliteImageRecordStorage, name: str, user_id: str) -> None:
+ store.save(
+ image_name=name,
+ image_origin=ResourceOrigin.INTERNAL,
+ image_category=ImageCategory.GENERAL,
+ width=64,
+ height=64,
+ has_workflow=False,
+ is_intermediate=False,
+ user_id=user_id,
+ )
+
+
+def _save_video(store: SqliteVideoRecordStorage, name: str, user_id: str) -> None:
+ store.save(
+ video_name=name,
+ video_origin=ResourceOrigin.INTERNAL,
+ video_category=ImageCategory.GENERAL,
+ width=64,
+ height=64,
+ duration=1.0,
+ fps=8.0,
+ has_workflow=False,
+ is_intermediate=False,
+ user_id=user_id,
+ )
+
+
+@pytest.fixture
+def seeded(services):
+ # Mixed-kind items for two users, no board association — which is the path that
+ # previously bypassed user filtering entirely.
+ _save_image(services["images"], "alice.png", user_id="alice")
+ _save_video(services["videos"], "alice.mp4", user_id="alice")
+ _save_image(services["images"], "bob.png", user_id="bob")
+ _save_video(services["videos"], "bob.mp4", user_id="bob")
+ return services
+
+
+class TestListItemNamesOmittedBoardIdMultiuser:
+ def test_non_admin_only_sees_own_items(self, seeded) -> None:
+ result = seeded["gallery"].list_item_names(user_id="alice", is_admin=False)
+ names = {(item.kind, item.name) for item in result.items}
+ assert names == {
+ (GalleryItemKind.IMAGE, "alice.png"),
+ (GalleryItemKind.VIDEO, "alice.mp4"),
+ }
+ assert result.total_count == 2
+
+ def test_admin_sees_all_items(self, seeded) -> None:
+ result = seeded["gallery"].list_item_names(user_id="alice", is_admin=True)
+ names = {(item.kind, item.name) for item in result.items}
+ assert names == {
+ (GalleryItemKind.IMAGE, "alice.png"),
+ (GalleryItemKind.IMAGE, "bob.png"),
+ (GalleryItemKind.VIDEO, "alice.mp4"),
+ (GalleryItemKind.VIDEO, "bob.mp4"),
+ }
+ assert result.total_count == 4
+
+ def test_explicit_none_board_still_isolates(self, seeded) -> None:
+ # Before the fix this branch was correct; included here as a guard against
+ # accidental regression in the still-functioning code path.
+ result = seeded["gallery"].list_item_names(board_id="none", user_id="alice", is_admin=False)
+ names = {(item.kind, item.name) for item in result.items}
+ assert names == {
+ (GalleryItemKind.IMAGE, "alice.png"),
+ (GalleryItemKind.VIDEO, "alice.mp4"),
+ }
diff --git a/tests/app/services/video_records/test_video_records_sqlite.py b/tests/app/services/video_records/test_video_records_sqlite.py
new file mode 100644
index 00000000000..a4e454b8b52
--- /dev/null
+++ b/tests/app/services/video_records/test_video_records_sqlite.py
@@ -0,0 +1,91 @@
+"""Regression tests for SqliteVideoRecordStorage multiuser isolation.
+
+Covers JPPhoto's code-review finding (PR #9163): when ``board_id`` was omitted
+from /v1/videos/ and /v1/videos/names, the SQL builder applied no user filter
+and a non-admin caller saw every user's videos. The fix added an
+``elif user_id is not None and not is_admin`` branch; these tests pin the
+behaviour so the regression cannot reappear.
+"""
+
+import pytest
+
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
+from invokeai.app.services.video_records.video_records_sqlite import SqliteVideoRecordStorage
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+@pytest.fixture
+def store() -> SqliteVideoRecordStorage:
+ config = InvokeAIAppConfig(use_memory_db=True)
+ logger = InvokeAILogger.get_logger(config=config)
+ db = create_mock_sqlite_database(config, logger)
+ return SqliteVideoRecordStorage(db=db)
+
+
+def _save(store: SqliteVideoRecordStorage, name: str, user_id: str) -> None:
+ store.save(
+ video_name=name,
+ video_origin=ResourceOrigin.INTERNAL,
+ video_category=ImageCategory.GENERAL,
+ width=64,
+ height=64,
+ duration=1.0,
+ fps=8.0,
+ has_workflow=False,
+ is_intermediate=False,
+ user_id=user_id,
+ )
+
+
+@pytest.fixture
+def seeded_store(store: SqliteVideoRecordStorage) -> SqliteVideoRecordStorage:
+ # Two videos per user; all without board association (the bug occurred when board_id
+ # was omitted from the query).
+ _save(store, "alice_1.mp4", user_id="alice")
+ _save(store, "alice_2.mp4", user_id="alice")
+ _save(store, "bob_1.mp4", user_id="bob")
+ _save(store, "bob_2.mp4", user_id="bob")
+ return store
+
+
+class TestGetManyOmittedBoardIdMultiuser:
+ """get_many() with board_id=None must filter by user_id for non-admin callers."""
+
+ def test_non_admin_only_sees_own_videos(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ result = seeded_store.get_many(user_id="alice", is_admin=False)
+ names = {v.video_name for v in result.items}
+ assert names == {"alice_1.mp4", "alice_2.mp4"}
+ assert result.total == 2
+
+ def test_admin_sees_every_users_videos(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ result = seeded_store.get_many(user_id="alice", is_admin=True)
+ names = {v.video_name for v in result.items}
+ assert names == {"alice_1.mp4", "alice_2.mp4", "bob_1.mp4", "bob_2.mp4"}
+
+ def test_no_user_id_returns_all(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ # No user_id means the caller is bypassing user filtering entirely (e.g. internal calls).
+ result = seeded_store.get_many(user_id=None, is_admin=False)
+ names = {v.video_name for v in result.items}
+ assert names == {"alice_1.mp4", "alice_2.mp4", "bob_1.mp4", "bob_2.mp4"}
+
+
+class TestGetVideoNamesOmittedBoardIdMultiuser:
+ """get_video_names() with board_id=None must filter by user_id for non-admin callers."""
+
+ def test_non_admin_only_sees_own_videos(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ result = seeded_store.get_video_names(user_id="alice", is_admin=False)
+ assert set(result.video_names) == {"alice_1.mp4", "alice_2.mp4"}
+ assert result.total_count == 2
+
+ def test_admin_sees_every_users_videos(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ result = seeded_store.get_video_names(user_id="alice", is_admin=True)
+ assert set(result.video_names) == {"alice_1.mp4", "alice_2.mp4", "bob_1.mp4", "bob_2.mp4"}
+
+ def test_explicit_none_board_still_isolates(self, seeded_store: SqliteVideoRecordStorage) -> None:
+ # The "none" sentinel (uncategorized) must also apply the user filter — this was the
+ # only path that was correct *before* the fix; the test guards against accidental
+ # regression there too.
+ result = seeded_store.get_video_names(board_id="none", user_id="alice", is_admin=False)
+ assert set(result.video_names) == {"alice_1.mp4", "alice_2.mp4"}
diff --git a/tests/backend/model_manager/configs/test_wan_gguf_config.py b/tests/backend/model_manager/configs/test_wan_gguf_config.py
new file mode 100644
index 00000000000..46b8ce499fe
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_gguf_config.py
@@ -0,0 +1,260 @@
+"""Tests for the GGUF Wan probe (Main_GGUF_Wan_Config)."""
+
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import gguf
+import pytest
+import torch
+
+from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+from invokeai.backend.model_manager.configs.main import (
+ Main_GGUF_Wan_Config,
+ _detect_wan_gguf_expert,
+ _detect_wan_gguf_variant,
+ _has_wan_keys,
+ _is_native_wan_layout,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, WanVariantType
+from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
+
+
+def _ggml(shape: tuple[int, ...]) -> GGMLTensor:
+ return GGMLTensor(
+ data=torch.zeros((1,), dtype=torch.uint8),
+ ggml_quantization_type=gguf.GGMLQuantizationType.Q4_0,
+ tensor_shape=torch.Size(shape),
+ compute_dtype=torch.float32,
+ )
+
+
+def _wan_a14b_state_dict(prefix: str = "") -> dict:
+ """Synthetic Wan A14B GGUF state dict (16-channel patch embed)."""
+ return {
+ f"{prefix}patch_embedding.weight": _ggml((5120, 16, 1, 2, 2)),
+ f"{prefix}condition_embedder.text_embedder.linear_1.weight": _ggml((5120, 4096)),
+ f"{prefix}blocks.0.attn1.to_q.weight": _ggml((5120, 5120)),
+ f"{prefix}blocks.0.ffn.net.0.proj.weight": _ggml((13824, 5120)),
+ }
+
+
+def _wan_ti2v_state_dict() -> dict:
+ """Synthetic Wan TI2V-5B GGUF state dict (48-channel patch embed)."""
+ return {
+ "patch_embedding.weight": _ggml((3072, 48, 1, 2, 2)),
+ "condition_embedder.text_embedder.linear_1.weight": _ggml((3072, 4096)),
+ "blocks.0.attn1.to_q.weight": _ggml((3072, 3072)),
+ "blocks.0.ffn.net.0.proj.weight": _ggml((14336, 3072)),
+ }
+
+
+def _wan_i2v_a14b_state_dict() -> dict:
+ """Wan 2.2 I2V-A14B GGUF: same shape as T2V except patch_embedding has 36
+ input channels (16 noise + 16 ref-image latents + 4 first-frame mask)."""
+ return {
+ "patch_embedding.weight": _ggml((5120, 36, 1, 2, 2)),
+ "condition_embedder.text_embedder.linear_1.weight": _ggml((5120, 4096)),
+ "blocks.0.attn1.to_q.weight": _ggml((5120, 5120)),
+ "blocks.0.ffn.net.0.proj.weight": _ggml((13824, 5120)),
+ }
+
+
+def _wan_a14b_native_state_dict() -> dict:
+ """Synthetic Wan A14B GGUF state dict using the native upstream key layout
+ (text_embedding/self_attn/cross_attn/ffn.0 — what QuantStack and ComfyUI ship)."""
+ return {
+ "patch_embedding.weight": _ggml((5120, 16, 1, 2, 2)),
+ "text_embedding.0.weight": _ggml((5120, 4096)),
+ "text_embedding.2.weight": _ggml((5120, 5120)),
+ "blocks.0.self_attn.q.weight": _ggml((5120, 5120)),
+ "blocks.0.cross_attn.q.weight": _ggml((5120, 5120)),
+ "blocks.0.ffn.0.weight": _ggml((13824, 5120)),
+ "blocks.0.modulation": _ggml((1, 6, 5120)),
+ "head.head.weight": _ggml((64, 5120)),
+ "head.modulation": _ggml((1, 2, 5120)),
+ }
+
+
+def _build_overrides(model_path: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(model_path),
+ "file_size": 0,
+ "name": name,
+ "source": str(model_path),
+ "source_type": "path",
+ }
+
+
+def _make_mod(path: Path, sd: dict) -> MagicMock:
+ mod = MagicMock()
+ mod.path = path
+ mod.load_state_dict.return_value = sd
+ return mod
+
+
+class TestKeyFingerprint:
+ def test_recognises_bare_keys(self):
+ assert _has_wan_keys(_wan_ti2v_state_dict()) is True
+
+ def test_recognises_comfyui_prefix(self):
+ assert _has_wan_keys(_wan_a14b_state_dict(prefix="model.diffusion_model.")) is True
+
+ def test_recognises_diffusion_model_prefix(self):
+ assert _has_wan_keys(_wan_a14b_state_dict(prefix="diffusion_model.")) is True
+
+ def test_recognises_native_upstream_layout(self):
+ assert _has_wan_keys(_wan_a14b_native_state_dict()) is True
+
+ def test_rejects_qwen_image(self):
+ sd = {"txt_in.weight": _ggml((1, 1)), "img_in.weight": _ggml((1, 1))}
+ assert _has_wan_keys(sd) is False
+
+ def test_rejects_flux(self):
+ sd = {"double_blocks.0.img_attn.proj.weight": _ggml((1, 1))}
+ assert _has_wan_keys(sd) is False
+
+
+class TestNativeLayoutDetection:
+ def test_native_a14b(self):
+ assert _is_native_wan_layout(_wan_a14b_native_state_dict()) is True
+
+ def test_diffusers_a14b_is_not_native(self):
+ assert _is_native_wan_layout(_wan_a14b_state_dict()) is False
+
+ def test_diffusers_ti2v_is_not_native(self):
+ assert _is_native_wan_layout(_wan_ti2v_state_dict()) is False
+
+
+class TestVariantDetection:
+ def test_a14b_from_16ch(self):
+ sd = _wan_a14b_state_dict()
+ assert _detect_wan_gguf_variant(sd) == WanVariantType.T2V_A14B
+
+ def test_ti2v_from_48ch(self):
+ sd = _wan_ti2v_state_dict()
+ assert _detect_wan_gguf_variant(sd) == WanVariantType.TI2V_5B
+
+ def test_i2v_a14b_from_36ch(self):
+ """Wan 2.2 I2V has the same A14B architecture as T2V but with
+ in_channels=36 because the ref-image latents and first-frame mask are
+ concatenated to the noise along the channel dim before patch embedding."""
+ sd = _wan_i2v_a14b_state_dict()
+ assert _detect_wan_gguf_variant(sd) == WanVariantType.I2V_A14B
+
+ def test_unknown_channel_count_returns_none(self):
+ sd = {"patch_embedding.weight": _ggml((1, 32, 1, 2, 2))}
+ assert _detect_wan_gguf_variant(sd) is None
+
+ def test_missing_patch_embedding_returns_none(self):
+ sd = {"blocks.0.attn1.to_q.weight": _ggml((1, 1))}
+ assert _detect_wan_gguf_variant(sd) is None
+
+
+class TestExpertFilenameHeuristic:
+ @pytest.mark.parametrize(
+ "name, expected",
+ [
+ ("wan2.2-t2v-a14b-high_noise-Q4_K_M", "high"),
+ ("Wan2.2-T2V-A14B-High-Noise-Q4_K_M", "high"),
+ ("wan_a14b_highnoise_q4", "high"),
+ ("wan2.2-t2v-a14b-low_noise-Q4_K_M", "low"),
+ ("Wan2.2-A14B-LowNoise-Q4", "low"),
+ ("wan2.2-ti2v-5b-Q4_K_M", "none"),
+ ("wan-A14B-flagship", "none"),
+ ],
+ )
+ def test_filename_heuristic(self, name: str, expected: str):
+ assert _detect_wan_gguf_expert(name) == expected
+
+
+class TestProbe:
+ def test_a14b_high_noise_filename(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan2.2-t2v-a14b-high_noise-Q4_K_M.gguf"
+ f.touch()
+
+ cfg = Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, _wan_a14b_state_dict()),
+ _build_overrides(f, "Wan A14B (high)"),
+ )
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.format == ModelFormat.GGUFQuantized
+ assert cfg.variant == WanVariantType.T2V_A14B
+ assert cfg.expert == "high"
+
+ def test_a14b_low_noise_filename(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan2.2-t2v-a14b-low_noise-Q4_K_M.gguf"
+ f.touch()
+
+ cfg = Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, _wan_a14b_state_dict()),
+ _build_overrides(f, "Wan A14B (low)"),
+ )
+ assert cfg.expert == "low"
+
+ def test_ti2v_5b_unambiguous(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan2.2-ti2v-5b-Q4_K_M.gguf"
+ f.touch()
+
+ cfg = Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, _wan_ti2v_state_dict()),
+ _build_overrides(f, "Wan TI2V-5B"),
+ )
+ assert cfg.variant == WanVariantType.TI2V_5B
+ assert cfg.expert == "none"
+
+ def test_rejects_non_gguf(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan-a14b.safetensors"
+ f.touch()
+ sd = {"patch_embedding.weight": torch.zeros(5120, 16, 1, 2, 2)} # NOT a GGMLTensor
+
+ with pytest.raises(NotAMatchError, match="GGUF"):
+ Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, sd),
+ _build_overrides(f, "non-gguf"),
+ )
+
+ def test_rejects_unrecognised_state_dict(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "junk.gguf"
+ f.touch()
+ sd = {"random.key": _ggml((1, 1))}
+
+ with pytest.raises(NotAMatchError, match="Wan transformer"):
+ Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, sd),
+ _build_overrides(f, "junk"),
+ )
+
+ def test_native_upstream_a14b_high_noise(self):
+ """QuantStack-style GGUF: native upstream keys + HighNoise filename."""
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf"
+ f.touch()
+
+ cfg = Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, _wan_a14b_native_state_dict()),
+ _build_overrides(f, "Wan A14B QuantStack (high)"),
+ )
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.format == ModelFormat.GGUFQuantized
+ assert cfg.variant == WanVariantType.T2V_A14B
+ assert cfg.expert == "high"
+
+ def test_explicit_expert_override(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan-a14b-flagship.gguf"
+ f.touch()
+ overrides = _build_overrides(f, "user-tagged")
+ overrides["expert"] = "low"
+
+ cfg = Main_GGUF_Wan_Config.from_model_on_disk(
+ _make_mod(f, _wan_a14b_state_dict()),
+ overrides,
+ )
+ assert cfg.expert == "low"
diff --git a/tests/backend/model_manager/configs/test_wan_lora_config.py b/tests/backend/model_manager/configs/test_wan_lora_config.py
new file mode 100644
index 00000000000..43f55db06b2
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_lora_config.py
@@ -0,0 +1,372 @@
+"""Tests for the Wan LoRA probe (LoRA_LyCORIS_Wan_Config).
+
+These tests cover detection across the three formats Wan LoRAs ship in:
+
+- **Diffusers PEFT**, with or without a ``transformer.`` prefix
+- **Native upstream PEFT** with ``diffusion_model.`` prefix (ComfyUI-trained)
+- **Kohya** ``lora_unet_blocks_N_`` with both diffusers and native
+ attention naming
+
+And the anti-pattern guards that prevent false positives on:
+
+- Anima (Cosmos DiT — ``cross_attn_q_proj`` / ``mlp`` / ``adaln_modulation``)
+- QwenImage (``transformer_blocks.``)
+- Flux (``double_blocks`` / ``single_blocks`` / ``single_transformer_blocks``)
+- Z-Image (``diffusion_model.layers.``)
+"""
+
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+import torch
+
+from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+from invokeai.backend.model_manager.configs.lora import LoRA_LyCORIS_Wan_Config
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat
+from invokeai.backend.patches.lora_conversions.wan_lora_constants import (
+ has_non_wan_architecture_keys,
+ has_wan_kohya_keys,
+ has_wan_peft_keys,
+)
+
+
+def _make_mod(path: Path, sd: dict) -> MagicMock:
+ mod = MagicMock()
+ mod.path = path
+ mod.load_state_dict.return_value = sd
+ return mod
+
+
+def _overrides(model_path: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(model_path),
+ "file_size": 0,
+ "name": name,
+ "source": str(model_path),
+ "source_type": "path",
+ }
+
+
+def _t(shape: tuple[int, ...]) -> torch.Tensor:
+ return torch.zeros(shape)
+
+
+class TestDiffusersPEFTPositives:
+ def test_attn1_to_q(self):
+ keys = ["transformer.blocks.0.attn1.to_q.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_attn2_to_k(self):
+ keys = ["blocks.0.attn2.to_k.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_ffn_net(self):
+ keys = ["transformer.blocks.0.ffn.net.0.proj.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_base_model_peft_prefix(self):
+ keys = ["base_model.model.transformer.blocks.0.attn1.to_q.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+
+class TestNativePEFTPositives:
+ def test_self_attn_q(self):
+ keys = ["diffusion_model.blocks.0.self_attn.q.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_cross_attn_k(self):
+ keys = ["diffusion_model.blocks.0.cross_attn.k.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_cross_attn_o(self):
+ keys = ["transformer.blocks.0.cross_attn.o.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_ffn_native(self):
+ keys = ["diffusion_model.blocks.0.ffn.0.lora_A.weight"]
+ assert has_wan_peft_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+
+class TestKohyaPositives:
+ def test_kohya_diffusers_attn1_to_q(self):
+ keys = ["lora_unet_blocks_0_attn1_to_q.lora_down.weight"]
+ assert has_wan_kohya_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_kohya_diffusers_attn2_to_out(self):
+ keys = ["lora_unet_blocks_0_attn2_to_out_0.lora_down.weight"]
+ assert has_wan_kohya_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_kohya_native_self_attn_q(self):
+ keys = ["lora_unet_blocks_0_self_attn_q.lora_down.weight"]
+ assert has_wan_kohya_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_kohya_native_cross_attn_v(self):
+ keys = ["lora_unet_blocks_5_cross_attn_v.lora_down.weight"]
+ assert has_wan_kohya_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+ def test_kohya_native_ffn_0(self):
+ keys = ["lora_unet_blocks_0_ffn_0.lora_down.weight"]
+ assert has_wan_kohya_keys(keys) is True
+ assert has_non_wan_architecture_keys(keys) is False
+
+
+class TestArchitectureGuards:
+ """Anti-pattern checks: non-Wan architectures must be flagged so the
+ probe rejects them even if a wan-ish substring matches."""
+
+ @pytest.mark.parametrize(
+ "label, keys",
+ [
+ ("anima_kohya_q_proj", ["lora_unet_blocks_0_cross_attn_q_proj.lora_down.weight"]),
+ ("anima_peft_mlp", ["transformer.blocks.0.mlp.layer1.lora_A.weight"]),
+ ("anima_peft_adaln", ["transformer.blocks.0.adaln_modulation.linear.lora_A.weight"]),
+ ("anima_peft_self_attn_q_proj", ["transformer.blocks.0.self_attn.q_proj.lora_A.weight"]),
+ ("qwen_image", ["transformer_blocks.0.attn.to_q.lora_A.weight"]),
+ ("flux_kohya_double", ["lora_unet_double_blocks_0_img_attn_qkv.lora_down.weight"]),
+ ("flux_kohya_single", ["lora_unet_single_blocks_0_linear1.lora_down.weight"]),
+ ("flux_diffusers_single_transformer", ["transformer.single_transformer_blocks.0.attn.to_q.lora_A.weight"]),
+ ("z_image", ["diffusion_model.layers.0.attn.to_q.lora_A.weight"]),
+ ],
+ )
+ def test_non_wan_archs_are_flagged(self, label: str, keys: list[str]):
+ assert has_non_wan_architecture_keys(keys) is True
+
+
+class TestProbeAcceptance:
+ """End-to-end probe behavior — Wan LoRA must be accepted, non-Wan rejected."""
+
+ def _wan_diffusers_sd(self) -> dict:
+ return {
+ "transformer.blocks.0.attn1.to_q.lora_A.weight": _t((128, 5120)),
+ "transformer.blocks.0.attn1.to_q.lora_B.weight": _t((5120, 128)),
+ "transformer.blocks.0.ffn.net.0.proj.lora_A.weight": _t((128, 5120)),
+ "transformer.blocks.0.ffn.net.0.proj.lora_B.weight": _t((13824, 128)),
+ }
+
+ def _wan_native_sd(self) -> dict:
+ return {
+ "diffusion_model.blocks.0.self_attn.q.lora_A.weight": _t((128, 5120)),
+ "diffusion_model.blocks.0.self_attn.q.lora_B.weight": _t((5120, 128)),
+ }
+
+ def _wan_kohya_sd(self) -> dict:
+ return {
+ "lora_unet_blocks_0_attn1_to_q.lora_down.weight": _t((128, 5120)),
+ "lora_unet_blocks_0_attn1_to_q.lora_up.weight": _t((5120, 128)),
+ }
+
+ def _wan_ti2v5b_sd(self) -> dict:
+ """A TI2V-5B LoRA — inner_dim 3072, not 5120."""
+ return {
+ "transformer.blocks.0.attn1.to_q.lora_A.weight": _t((64, 3072)),
+ "transformer.blocks.0.attn1.to_q.lora_B.weight": _t((3072, 64)),
+ }
+
+ def test_accepts_diffusers_wan(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "my-wan-lora.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ _overrides(f, "wan-lora"),
+ )
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.format == ModelFormat.LyCORIS
+ assert cfg.expert is None
+ assert cfg.variant == "a14b" # 5120-dim state dict
+
+ def test_accepts_native_wan(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan-style-lora.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_native_sd()),
+ _overrides(f, "wan-native"),
+ )
+ assert cfg.base == BaseModelType.Wan
+
+ def test_accepts_kohya_wan(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan-kohya.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_kohya_sd()),
+ _overrides(f, "wan-kohya"),
+ )
+ assert cfg.base == BaseModelType.Wan
+
+ def test_filename_marks_high_noise_expert(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "stylize-high_noise.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ _overrides(f, "high-noise lora"),
+ )
+ assert cfg.expert == "high"
+
+ def test_filename_marks_low_noise_expert(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "fine-detail-LowNoise.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ _overrides(f, "low-noise lora"),
+ )
+ assert cfg.expert == "low"
+
+ def test_explicit_expert_override_wins(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "ambiguous-name.safetensors"
+ f.touch()
+ overrides = _overrides(f, "override")
+ overrides["expert"] = "low"
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ overrides,
+ )
+ assert cfg.expert == "low"
+
+ def test_expert_none_for_untagged_filename(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "my-lora.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ _overrides(f, "untagged"),
+ )
+ assert cfg.expert is None
+
+ def test_variant_detected_as_5b_when_inner_dim_3072(self):
+ """TI2V-5B LoRAs have inner_dim 3072. Detector must classify them as
+ '5b' so the FE filter doesn't route them to an A14B main and crash."""
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "ti2v5b-lora.safetensors"
+ f.touch()
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_ti2v5b_sd()),
+ _overrides(f, "ti2v5b"),
+ )
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.variant == "5b"
+
+ def test_variant_none_when_unrecognised_inner_dim(self):
+ """A future Wan family or a LoRA touching only ffn at non-attn dims
+ should map to variant=None rather than mis-classify."""
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "future-wan.safetensors"
+ f.touch()
+ # Only an ffn LoRA — no attn weight to read inner_dim from.
+ # Also a non-5120, non-3072 dim that would otherwise mis-classify.
+ sd = {
+ "transformer.blocks.0.ffn.net.0.proj.lora_A.weight": _t((128, 4096)),
+ "transformer.blocks.0.ffn.net.0.proj.lora_B.weight": _t((11008, 128)),
+ }
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "future"))
+ assert cfg.variant is None
+
+ def test_explicit_variant_override_wins(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "manual.safetensors"
+ f.touch()
+ overrides = _overrides(f, "manual")
+ overrides["variant"] = "5b"
+ # State dict is 5120-dim (auto-detect would say "a14b") but the
+ # explicit override should stick.
+ cfg = LoRA_LyCORIS_Wan_Config.from_model_on_disk(
+ _make_mod(f, self._wan_diffusers_sd()),
+ overrides,
+ )
+ assert cfg.variant == "5b"
+
+ def test_rejects_anima_lora(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "anima.safetensors"
+ f.touch()
+ sd = {
+ "transformer.blocks.0.cross_attn.q_proj.lora_A.weight": _t((128, 4096)),
+ "transformer.blocks.0.mlp.layer1.lora_A.weight": _t((128, 4096)),
+ }
+ with pytest.raises(NotAMatchError, match="Wan LoRA"):
+ LoRA_LyCORIS_Wan_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "anima"))
+
+ def test_rejects_qwen_image_lora(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "qwen.safetensors"
+ f.touch()
+ sd = {"transformer_blocks.0.attn.to_q.lora_A.weight": _t((128, 4096))}
+ with pytest.raises(NotAMatchError, match="Wan LoRA"):
+ LoRA_LyCORIS_Wan_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "qwen"))
+
+ def test_rejects_flux_lora(self):
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "flux.safetensors"
+ f.touch()
+ sd = {"lora_unet_double_blocks_0_img_attn_qkv.lora_down.weight": _t((128, 3072))}
+ with pytest.raises(NotAMatchError, match="Wan LoRA"):
+ LoRA_LyCORIS_Wan_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "flux"))
+
+
+class TestProbeMutualExclusivity:
+ """Regression: Anima's probe must REJECT Wan-native LoRA keys, so probing
+ is correct regardless of which config the factory iterates first.
+
+ ``Config_Base.CONFIG_CLASSES`` is a ``set``, so iteration order is
+ non-deterministic across Python process restarts. Probes therefore need
+ to be mutually exclusive at the per-config level — see also
+ ``test_wan_lora_probe_independence.py`` for the broader cross-architecture
+ coverage."""
+
+ def test_anima_rejects_wan_native_lora(self):
+ """Wan native LoRAs (``diffusion_model.blocks.X.self_attn.q.lora_*``)
+ used to false-positive on Anima's probe because Anima accepted any
+ ``cross_attn``/``self_attn`` substring. Anima now requires
+ Cosmos-DiT-exclusive markers (``mlp``, ``adaln_modulation``, or the
+ ``_proj`` attention suffix), so a Wan LoRA — which has none of those —
+ is correctly rejected."""
+ from invokeai.backend.model_manager.configs.lora import LoRA_LyCORIS_Anima_Config
+
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan_native_lora.safetensors"
+ f.touch()
+ # Realistic Wan native PEFT keys — what lightx2v's Lightning
+ # distillations and most ComfyUI-trained Wan LoRAs look like.
+ sd = {
+ "diffusion_model.blocks.0.self_attn.q.lora_A.weight": _t((128, 5120)),
+ "diffusion_model.blocks.0.self_attn.q.lora_B.weight": _t((5120, 128)),
+ "diffusion_model.blocks.0.cross_attn.k.lora_A.weight": _t((128, 5120)),
+ "diffusion_model.blocks.0.cross_attn.k.lora_B.weight": _t((5120, 128)),
+ }
+ with pytest.raises(NotAMatchError, match="Anima LoRA"):
+ LoRA_LyCORIS_Anima_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "wan-native-lora"))
+
+ def test_wan_rejects_anima_lora(self):
+ """Mirror direction: a real Anima LoRA must not be matched by Wan.
+ Wan's anti-patterns already cover ``_proj`` suffix, ``mlp``, and
+ ``adaln_modulation``."""
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "anima_lora.safetensors"
+ f.touch()
+ sd = {
+ "transformer.blocks.0.self_attn.q_proj.lora_A.weight": _t((128, 4096)),
+ "transformer.blocks.0.self_attn.q_proj.lora_B.weight": _t((4096, 128)),
+ "transformer.blocks.0.mlp.layer1.lora_A.weight": _t((128, 4096)),
+ "transformer.blocks.0.mlp.layer1.lora_B.weight": _t((4096, 128)),
+ }
+ with pytest.raises(NotAMatchError, match="Wan LoRA"):
+ LoRA_LyCORIS_Wan_Config.from_model_on_disk(_make_mod(f, sd), _overrides(f, "anima-lora"))
diff --git a/tests/backend/model_manager/configs/test_wan_lora_probe_independence.py b/tests/backend/model_manager/configs/test_wan_lora_probe_independence.py
new file mode 100644
index 00000000000..93fdf054639
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_lora_probe_independence.py
@@ -0,0 +1,275 @@
+"""Regression tests for Wan vs Anima LoRA probe mutual exclusivity.
+
+InvokeAI's ``Config_Base.CONFIG_CLASSES`` is a ``set``, so iteration order is
+non-deterministic across Python process restarts. The probe MUST therefore be
+mutually exclusive at the per-config level — first-match-wins is not safe to
+rely on.
+
+The historic bug these tests guard against: Anima's probe accepted anything
+with the ``cross_attn`` or ``self_attn`` substring, which collides with Wan's
+native LoRA key layout (``diffusion_model.blocks.X.cross_attn.q.lora_down.weight``).
+A Wan native LoRA — including lightx2v's Lightning distillations — would
+randomly identify as ``BaseModelType.Anima`` depending on dict hash order.
+
+The fix tightened Anima's probe to require Cosmos-DiT-exclusive markers
+(``mlp``, ``adaln_modulation``, or attention with the ``_proj`` suffix).
+
+Each test below feeds a fixed state dict shape to BOTH the Wan and Anima
+probes individually and asserts at most one accepts — order-independent.
+"""
+
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+import torch
+
+from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+from invokeai.backend.model_manager.configs.lora import (
+ LoRA_LyCORIS_Anima_Config,
+ LoRA_LyCORIS_Wan_Config,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType
+
+
+def _t(shape: tuple[int, ...]) -> torch.Tensor:
+ return torch.zeros(shape)
+
+
+def _make_mod(path: Path, sd: dict) -> MagicMock:
+ mod = MagicMock()
+ mod.path = path
+ mod.load_state_dict.return_value = sd
+ return mod
+
+
+def _overrides(p: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(p),
+ "file_size": 0,
+ "name": name,
+ "source": str(p),
+ "source_type": "path",
+ }
+
+
+def _probe(cls, path: Path, sd: dict, name: str):
+ """Try a probe; return (accepted: bool, instance_or_exc)."""
+ try:
+ return True, cls.from_model_on_disk(_make_mod(path, sd), _overrides(path, name))
+ except NotAMatchError as e:
+ return False, e
+
+
+def _i2v_lightning_v1_keys() -> dict:
+ """Realistic key shape from lightx2v's I2V-A14B Lightning V1 — the actual
+ LoRA that triggered the bug. Native upstream Wan naming with
+ ``diffusion_model.`` prefix, no ``_proj`` suffix on attention."""
+ sd: dict[str, torch.Tensor] = {}
+ for block in range(3):
+ for sub in ("self_attn", "cross_attn"):
+ for proj in ("q", "k", "v", "o"):
+ base = f"diffusion_model.blocks.{block}.{sub}.{proj}"
+ sd[f"{base}.lora_down.weight"] = _t((64, 5120))
+ sd[f"{base}.lora_up.weight"] = _t((5120, 64))
+ sd[f"{base}.alpha"] = torch.tensor(8.0)
+ for ffn_idx in (0, 2):
+ base = f"diffusion_model.blocks.{block}.ffn.{ffn_idx}"
+ sd[f"{base}.lora_down.weight"] = _t((64, 5120))
+ sd[f"{base}.lora_up.weight"] = _t((5120, 64))
+ sd[f"{base}.alpha"] = torch.tensor(8.0)
+ return sd
+
+
+def _t2v_lightning_v2_keys() -> dict:
+ """Same layout as I2V Lightning — both lightx2v releases use native Wan
+ keys with ``diffusion_model.`` prefix. The T2V version had been working
+ (after a manual factory reorder), but only by luck of dict-hash order."""
+ return _i2v_lightning_v1_keys() # structurally identical to I2V V1
+
+
+def _wan_kohya_keys() -> dict:
+ """Hypothetical Kohya-format Wan LoRA — same native naming, underscore
+ separators. Lightning hasn't shipped in this format, but other community
+ LoRAs do."""
+ sd: dict[str, torch.Tensor] = {}
+ for block in range(2):
+ for sub in ("self_attn", "cross_attn"):
+ for proj in ("q", "k", "v", "o"):
+ base = f"lora_unet_blocks_{block}_{sub}_{proj}"
+ sd[f"{base}.lora_down.weight"] = _t((64, 5120))
+ sd[f"{base}.lora_up.weight"] = _t((5120, 64))
+ return sd
+
+
+def _wan_diffusers_peft_keys() -> dict:
+ """Wan diffusers-style LoRA: ``transformer.blocks.X.attn1.to_q.lora_A.weight``
+ etc. Distinct enough from Anima that even the loose probes wouldn't collide,
+ but covered here for completeness."""
+ sd: dict[str, torch.Tensor] = {}
+ for block in range(2):
+ for attn in ("attn1", "attn2"):
+ for to in ("to_q", "to_k", "to_v"):
+ base = f"transformer.blocks.{block}.{attn}.{to}"
+ sd[f"{base}.lora_A.weight"] = _t((64, 5120))
+ sd[f"{base}.lora_B.weight"] = _t((5120, 64))
+ sd[f"transformer.blocks.{block}.ffn.net.0.proj.lora_A.weight"] = _t((64, 5120))
+ sd[f"transformer.blocks.{block}.ffn.net.0.proj.lora_B.weight"] = _t((13824, 64))
+ return sd
+
+
+def _anima_peft_keys() -> dict:
+ """Realistic Anima Cosmos-DiT LoRA: ``q_proj``/``k_proj`` attention naming
+ plus ``mlp`` and ``adaln_modulation`` modules. Wan has none of these."""
+ sd: dict[str, torch.Tensor] = {}
+ for block in range(2):
+ for sub in ("self_attn", "cross_attn"):
+ for proj in ("q_proj", "k_proj", "v_proj", "output_proj"):
+ base = f"transformer.blocks.{block}.{sub}.{proj}"
+ sd[f"{base}.lora_A.weight"] = _t((64, 4096))
+ sd[f"{base}.lora_B.weight"] = _t((4096, 64))
+ sd[f"transformer.blocks.{block}.mlp.layer1.lora_A.weight"] = _t((64, 4096))
+ sd[f"transformer.blocks.{block}.mlp.layer1.lora_B.weight"] = _t((4096, 64))
+ sd[f"transformer.blocks.{block}.adaln_modulation.linear.lora_A.weight"] = _t((64, 4096))
+ sd[f"transformer.blocks.{block}.adaln_modulation.linear.lora_B.weight"] = _t((4096, 64))
+ return sd
+
+
+def _anima_kohya_keys() -> dict:
+ """Same Anima content in Kohya format."""
+ sd: dict[str, torch.Tensor] = {}
+ for block in range(2):
+ for sub in ("self_attn", "cross_attn"):
+ for proj in ("q_proj", "k_proj", "v_proj", "output_proj"):
+ base = f"lora_unet_blocks_{block}_{sub}_{proj}"
+ sd[f"{base}.lora_down.weight"] = _t((64, 4096))
+ sd[f"{base}.lora_up.weight"] = _t((4096, 64))
+ sd[f"lora_unet_blocks_{block}_mlp_layer1.lora_down.weight"] = _t((64, 4096))
+ sd[f"lora_unet_blocks_{block}_mlp_layer1.lora_up.weight"] = _t((4096, 64))
+ return sd
+
+
+# ---------------------------------------------------------------------------
+# Mutual-exclusivity assertions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "label, sd_builder",
+ [
+ ("i2v_lightning_v1", _i2v_lightning_v1_keys),
+ ("t2v_lightning_v2", _t2v_lightning_v2_keys),
+ ("wan_kohya_native", _wan_kohya_keys),
+ ("wan_diffusers_peft", _wan_diffusers_peft_keys),
+ ],
+)
+def test_wan_loras_only_match_wan(label: str, sd_builder) -> None:
+ """Wan probe accepts; Anima probe rejects. Independent of factory order."""
+ sd = sd_builder()
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / f"{label}.safetensors"
+ f.touch()
+
+ wan_ok, wan_result = _probe(LoRA_LyCORIS_Wan_Config, f, sd, label)
+ anima_ok, anima_result = _probe(LoRA_LyCORIS_Anima_Config, f, sd, label)
+
+ assert wan_ok, f"Wan probe must accept {label}; got {wan_result}"
+ assert wan_result.base == BaseModelType.Wan
+ assert not anima_ok, (
+ f"Anima probe must reject {label} so probing is order-independent. Instead it accepted: {anima_result}"
+ )
+
+
+@pytest.mark.parametrize(
+ "label, sd_builder",
+ [
+ ("anima_peft", _anima_peft_keys),
+ ("anima_kohya", _anima_kohya_keys),
+ ],
+)
+def test_anima_loras_only_match_anima(label: str, sd_builder) -> None:
+ """Anima probe accepts; Wan probe rejects. Independent of factory order."""
+ sd = sd_builder()
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / f"{label}.safetensors"
+ f.touch()
+
+ wan_ok, wan_result = _probe(LoRA_LyCORIS_Wan_Config, f, sd, label)
+ anima_ok, anima_result = _probe(LoRA_LyCORIS_Anima_Config, f, sd, label)
+
+ assert anima_ok, f"Anima probe must accept {label}; got {anima_result}"
+ assert anima_result.base == BaseModelType.Anima
+ assert not wan_ok, (
+ f"Wan probe must reject {label} so probing is order-independent. Instead it accepted: {wan_result}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Belt-and-suspenders: confirm CONFIG_CLASSES doesn't ALSO produce a match for
+# any unrelated LoRA config. This is the test that would have caught the
+# original bug regardless of which LoRA configs are registered in the future.
+# ---------------------------------------------------------------------------
+
+
+def test_at_most_one_lora_config_matches_wan_lightning() -> None:
+ """Run every LoRA config in the factory against an I2V Lightning state
+ dict. Only one should accept. If a future LoRA config (a hypothetical
+ new model with cross_attn naming) starts matching too, this test fires
+ so we can tighten that probe rather than relying on factory ordering."""
+ from invokeai.backend.model_manager.configs.base import Config_Base
+ from invokeai.backend.model_manager.taxonomy import ModelType
+
+ sd = _i2v_lightning_v1_keys()
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "wan_lightning.safetensors"
+ f.touch()
+ mod = _make_mod(f, sd)
+ overrides = _overrides(f, "wan_lightning")
+
+ accepting: list[str] = []
+ for cls in Config_Base.CONFIG_CLASSES:
+ # Only LoRA configs are at risk of collision with each other; skip
+ # the rest. (Main models can also probe-accept-then-reject on type
+ # mismatch, but they're disambiguated by ``matches_sort_key``.)
+ if getattr(cls.model_fields.get("type", None), "default", None) != ModelType.LoRA:
+ continue
+ try:
+ cls.from_model_on_disk(mod, dict(overrides))
+ accepting.append(cls.__name__)
+ except (NotAMatchError, Exception):
+ continue
+
+ assert accepting == ["LoRA_LyCORIS_Wan_Config"], (
+ f"Exactly one LoRA config must accept a Wan Lightning LoRA; got {accepting}. "
+ "If a new LoRA config starts matching here, tighten its probe to be "
+ "mutually exclusive with Wan rather than relying on factory ordering."
+ )
+
+
+def test_at_most_one_lora_config_matches_anima_peft() -> None:
+ """Same exclusivity guarantee for the Anima side."""
+ from invokeai.backend.model_manager.configs.base import Config_Base
+ from invokeai.backend.model_manager.taxonomy import ModelType
+
+ sd = _anima_peft_keys()
+ with TemporaryDirectory() as tmp:
+ f = Path(tmp) / "anima_peft.safetensors"
+ f.touch()
+ mod = _make_mod(f, sd)
+ overrides = _overrides(f, "anima_peft")
+
+ accepting: list[str] = []
+ for cls in Config_Base.CONFIG_CLASSES:
+ if getattr(cls.model_fields.get("type", None), "default", None) != ModelType.LoRA:
+ continue
+ try:
+ cls.from_model_on_disk(mod, dict(overrides))
+ accepting.append(cls.__name__)
+ except (NotAMatchError, Exception):
+ continue
+
+ assert accepting == ["LoRA_LyCORIS_Anima_Config"], (
+ f"Exactly one LoRA config must accept an Anima LoRA; got {accepting}."
+ )
diff --git a/tests/backend/model_manager/configs/test_wan_main_config.py b/tests/backend/model_manager/configs/test_wan_main_config.py
new file mode 100644
index 00000000000..d3f4f00451e
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_main_config.py
@@ -0,0 +1,152 @@
+"""Tests for Wan 2.2 model identification (Main_Diffusers_Wan_Config)."""
+
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+
+from invokeai.backend.model_manager.configs.main import Main_Diffusers_Wan_Config
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, WanVariantType
+
+
+def _write_json(path: Path, data: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w") as f:
+ json.dump(data, f)
+
+
+def _build_a14b_layout(root: Path) -> None:
+ """Synthetic on-disk layout for Wan-AI/Wan2.2-T2V-A14B: dual transformers, z_dim=16."""
+ _write_json(root / "model_index.json", {"_class_name": "WanPipeline"})
+ _write_json(root / "transformer" / "config.json", {"_class_name": "WanTransformer3DModel", "in_channels": 16})
+ _write_json(root / "transformer_2" / "config.json", {"_class_name": "WanTransformer3DModel", "in_channels": 16})
+ _write_json(root / "vae" / "config.json", {"_class_name": "AutoencoderKLWan", "z_dim": 16})
+
+
+def _build_ti2v_5b_layout(root: Path) -> None:
+ """Synthetic on-disk layout for Wan-AI/Wan2.2-TI2V-5B: single transformer, z_dim=48."""
+ _write_json(root / "model_index.json", {"_class_name": "WanImageToVideoPipeline"})
+ _write_json(root / "transformer" / "config.json", {"_class_name": "WanTransformer3DModel", "in_channels": 48})
+ _write_json(root / "vae" / "config.json", {"_class_name": "AutoencoderKLWan", "z_dim": 48})
+
+
+def _build_i2v_a14b_layout(root: Path) -> None:
+ """Wan-AI/Wan2.2-I2V-A14B: dual transformers, z_dim=16, transformer in_channels=36.
+
+ The Wan 2.2 I2V transformer concatenates noise latents (16) + ref-image
+ latents (16) + first-frame mask (4) along the channel dim, so its
+ ``in_channels`` is 36 vs 16 for T2V.
+ """
+ _write_json(root / "model_index.json", {"_class_name": "WanImageToVideoPipeline"})
+ _write_json(
+ root / "transformer" / "config.json",
+ {"_class_name": "WanTransformer3DModel", "in_channels": 36, "image_dim": None},
+ )
+ _write_json(
+ root / "transformer_2" / "config.json",
+ {"_class_name": "WanTransformer3DModel", "in_channels": 36, "image_dim": None},
+ )
+ _write_json(root / "vae" / "config.json", {"_class_name": "AutoencoderKLWan", "z_dim": 16})
+
+
+def _build_overrides(model_path: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(model_path),
+ "file_size": 0,
+ "name": name,
+ "source": str(model_path),
+ "source_type": "path",
+ }
+
+
+def _make_mod(model_path: Path) -> MagicMock:
+ mod = MagicMock()
+ mod.path = model_path
+ return mod
+
+
+class TestWanDiffusersIdentification:
+ """Wan diffusers probe: variant detection from transformer / VAE / dir layout."""
+
+ def test_a14b_detected_from_dual_transformer(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-T2V-A14B"
+ _build_a14b_layout(root)
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "A14B"))
+
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.format == ModelFormat.Diffusers
+ assert cfg.variant == WanVariantType.T2V_A14B
+ assert cfg.has_dual_expert is True
+
+ def test_i2v_a14b_detected_from_in_channels_36(self) -> None:
+ """I2V-A14B has the same dual-expert + z_dim=16 layout as T2V, but its
+ transformer's ``in_channels`` is 36 (16 noise + 16 ref-image latents +
+ 4 first-frame mask). That's the canonical Wan 2.2 differentiator."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-I2V-A14B"
+ _build_i2v_a14b_layout(root)
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "I2V"))
+
+ assert cfg.variant == WanVariantType.I2V_A14B
+ assert cfg.has_dual_expert is True
+
+ def test_t2v_a14b_kept_when_in_channels_is_16(self) -> None:
+ """A14B layout with ``in_channels=16`` resolves to T2V (not I2V)."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-T2V-A14B"
+ _build_a14b_layout(root)
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "T2V"))
+
+ assert cfg.variant == WanVariantType.T2V_A14B
+
+ def test_ti2v_5b_detected_from_z_dim(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-TI2V-5B"
+ _build_ti2v_5b_layout(root)
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "TI2V-5B"))
+
+ assert cfg.variant == WanVariantType.TI2V_5B
+ assert cfg.has_dual_expert is False
+
+ def test_filename_heuristic_when_vae_config_missing(self) -> None:
+ """When ``vae/config.json`` is missing, fall back to the directory name."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-TI2V-5B"
+ _write_json(root / "model_index.json", {"_class_name": "WanPipeline"})
+ _write_json(root / "transformer" / "config.json", {"_class_name": "WanTransformer3DModel"})
+ # No vae/config.json — single-transformer + dirname containing "5b" → TI2V-5B.
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "TI2V-5B"))
+
+ assert cfg.variant == WanVariantType.TI2V_5B
+
+ def test_explicit_variant_override_takes_precedence(self) -> None:
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "wan-something"
+ _build_a14b_layout(root)
+ overrides = _build_overrides(root, "Custom A14B")
+ overrides["variant"] = WanVariantType.TI2V_5B # Explicit override.
+
+ cfg = Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), overrides)
+ assert cfg.variant == WanVariantType.TI2V_5B
+ # has_dual_expert is still detected from disk; the override only forces variant.
+ assert cfg.has_dual_expert is True
+
+ def test_rejects_non_wan_pipeline(self) -> None:
+ """A model_index.json that isn't a Wan class name must not match."""
+ from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "not-wan"
+ _write_json(root / "model_index.json", {"_class_name": "FluxPipeline"})
+
+ with pytest.raises(NotAMatchError):
+ Main_Diffusers_Wan_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "fake"))
diff --git a/tests/backend/model_manager/configs/test_wan_t5_encoder_config.py b/tests/backend/model_manager/configs/test_wan_t5_encoder_config.py
new file mode 100644
index 00000000000..4a5732bc10a
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_t5_encoder_config.py
@@ -0,0 +1,96 @@
+"""Tests for the WanT5Encoder config probe (UMT5-XXL diffusers folder)."""
+
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+
+from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+from invokeai.backend.model_manager.configs.wan_t5_encoder import WanT5Encoder_WanT5Encoder_Config
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
+
+
+def _build_overrides(model_path: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(model_path),
+ "file_size": 0,
+ "name": name,
+ "source": str(model_path),
+ "source_type": "path",
+ }
+
+
+def _make_mod(model_path: Path) -> MagicMock:
+ mod = MagicMock()
+ mod.path = model_path
+ return mod
+
+
+def _write_encoder_config(target: Path, model_type: str) -> None:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with target.open("w") as f:
+ json.dump({"model_type": model_type, "architectures": ["UMT5EncoderModel"]}, f)
+
+
+class TestWanT5EncoderProbe:
+ def test_accepts_nested_text_encoder_layout(self):
+ """Standard layout: /text_encoder/config.json with model_type=umt5."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "wan-encoder-bundle"
+ root.mkdir()
+ _write_encoder_config(root / "text_encoder" / "config.json", "umt5")
+
+ cfg = WanT5Encoder_WanT5Encoder_Config.from_model_on_disk(
+ _make_mod(root), _build_overrides(root, "wan-encoder")
+ )
+
+ assert cfg.base == BaseModelType.Any
+ assert cfg.type == ModelType.WanT5Encoder
+ assert cfg.format == ModelFormat.WanT5Encoder
+
+ def test_accepts_flat_encoder_layout(self):
+ """Flat layout: /config.json directly (just the encoder folder)."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "umt5-xxl"
+ root.mkdir()
+ _write_encoder_config(root / "config.json", "umt5")
+
+ cfg = WanT5Encoder_WanT5Encoder_Config.from_model_on_disk(
+ _make_mod(root), _build_overrides(root, "umt5-xxl")
+ )
+ assert cfg.format == ModelFormat.WanT5Encoder
+
+ def test_rejects_t5(self):
+ """A regular T5-XXL encoder must not match (different vocabulary)."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "t5-xxl"
+ root.mkdir()
+ _write_encoder_config(root / "config.json", "t5")
+
+ with pytest.raises(NotAMatchError, match="not 'umt5'"):
+ WanT5Encoder_WanT5Encoder_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "t5-xxl"))
+
+ def test_rejects_full_pipeline(self):
+ """A folder with model_index.json or transformer/ is a full pipeline, not an encoder."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "full-pipeline"
+ root.mkdir()
+ _write_encoder_config(root / "text_encoder" / "config.json", "umt5")
+ (root / "model_index.json").touch()
+
+ with pytest.raises(NotAMatchError, match="full Wan pipeline"):
+ WanT5Encoder_WanT5Encoder_Config.from_model_on_disk(
+ _make_mod(root), _build_overrides(root, "full-pipeline")
+ )
+
+ def test_rejects_missing_config(self):
+ """Empty directory has no encoder config to read."""
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "empty"
+ root.mkdir()
+
+ with pytest.raises(NotAMatchError, match="no encoder config"):
+ WanT5Encoder_WanT5Encoder_Config.from_model_on_disk(_make_mod(root), _build_overrides(root, "empty"))
diff --git a/tests/backend/model_manager/configs/test_wan_vae_config.py b/tests/backend/model_manager/configs/test_wan_vae_config.py
new file mode 100644
index 00000000000..21c3f42a7b8
--- /dev/null
+++ b/tests/backend/model_manager/configs/test_wan_vae_config.py
@@ -0,0 +1,173 @@
+"""Tests for Wan 2.2 VAE config probes (checkpoint + diffusers)."""
+
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import MagicMock
+
+import pytest
+import torch
+
+from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
+from invokeai.backend.model_manager.configs.vae import (
+ VAE_Checkpoint_QwenImage_Config,
+ VAE_Checkpoint_Wan_Config,
+ VAE_Diffusers_Wan_Config,
+ _wan_vae_z_dim,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat
+
+
+def _build_overrides(model_path: Path, name: str) -> dict:
+ return {
+ "hash": "test-hash",
+ "path": str(model_path),
+ "file_size": 0,
+ "name": name,
+ "source": str(model_path),
+ "source_type": "path",
+ }
+
+
+def _make_mod(model_path: Path, state_dict: dict | None = None) -> MagicMock:
+ mod = MagicMock()
+ mod.path = model_path
+ if state_dict is not None:
+ mod.load_state_dict.return_value = state_dict
+ return mod
+
+
+def _wan_vae_state_dict(z_dim: int) -> dict:
+ """Synthetic 5D Wan-style VAE state dict."""
+ return {
+ "decoder.conv_in.weight": torch.zeros(96, z_dim, 1, 3, 3),
+ "encoder.conv_in.weight": torch.zeros(z_dim, 3, 1, 3, 3),
+ }
+
+
+class TestZDimDetection:
+ def test_detects_16_channel(self):
+ assert _wan_vae_z_dim(_wan_vae_state_dict(16)) == 16
+
+ def test_detects_48_channel(self):
+ assert _wan_vae_z_dim(_wan_vae_state_dict(48)) == 48
+
+ def test_rejects_unknown_z_dim(self):
+ # Some other 5D conv weight (not Wan).
+ sd = {"decoder.conv_in.weight": torch.zeros(96, 32, 1, 3, 3)}
+ assert _wan_vae_z_dim(sd) is None
+
+ def test_rejects_4d_conv(self):
+ # Standard SD/SDXL 4D conv — not Wan.
+ sd = {"decoder.conv_in.weight": torch.zeros(96, 16, 3, 3)}
+ assert _wan_vae_z_dim(sd) is None
+
+
+class TestVAECheckpointWanConfig:
+ """Probe + filename-heuristic disambiguation from Qwen Image VAE."""
+
+ def test_48_channel_unambiguous_wan(self):
+ with TemporaryDirectory() as tmp:
+ vae_path = Path(tmp) / "wan2.2-vae.safetensors"
+ vae_path.touch()
+
+ cfg = VAE_Checkpoint_Wan_Config.from_model_on_disk(
+ _make_mod(vae_path, state_dict=_wan_vae_state_dict(48)),
+ _build_overrides(vae_path, "Wan2.2-VAE"),
+ )
+
+ assert cfg.base == BaseModelType.Wan
+ assert cfg.format == ModelFormat.Checkpoint
+ assert cfg.latent_channels == 48
+
+ def test_16_channel_with_wan_in_filename(self):
+ with TemporaryDirectory() as tmp:
+ vae_path = Path(tmp) / "wan-vae.safetensors"
+ vae_path.touch()
+
+ cfg = VAE_Checkpoint_Wan_Config.from_model_on_disk(
+ _make_mod(vae_path, state_dict=_wan_vae_state_dict(16)),
+ _build_overrides(vae_path, "Wan VAE"),
+ )
+
+ assert cfg.latent_channels == 16
+
+ def test_16_channel_without_wan_in_filename_defers(self):
+ """Filename without 'wan' should let Qwen Image VAE win."""
+ with TemporaryDirectory() as tmp:
+ vae_path = Path(tmp) / "qwen_vae.safetensors"
+ vae_path.touch()
+
+ with pytest.raises(NotAMatchError, match="deferring to Qwen Image"):
+ VAE_Checkpoint_Wan_Config.from_model_on_disk(
+ _make_mod(vae_path, state_dict=_wan_vae_state_dict(16)),
+ _build_overrides(vae_path, "QwenImage VAE"),
+ )
+
+ def test_qwen_image_defers_when_filename_says_wan(self):
+ """The mirror case — QwenImage config refuses files whose filenames suggest Wan."""
+ with TemporaryDirectory() as tmp:
+ vae_path = Path(tmp) / "wan-vae.safetensors"
+ vae_path.touch()
+
+ with pytest.raises(NotAMatchError, match="filename suggests a Wan"):
+ VAE_Checkpoint_QwenImage_Config.from_model_on_disk(
+ _make_mod(vae_path, state_dict=_wan_vae_state_dict(16)),
+ _build_overrides(vae_path, "Wan VAE"),
+ )
+
+ def test_rejects_non_wan_state_dict(self):
+ with TemporaryDirectory() as tmp:
+ vae_path = Path(tmp) / "wan-junk.safetensors"
+ vae_path.touch()
+ sd = {"foo.bar": torch.zeros(1)}
+
+ with pytest.raises(NotAMatchError):
+ VAE_Checkpoint_Wan_Config.from_model_on_disk(
+ _make_mod(vae_path, state_dict=sd),
+ _build_overrides(vae_path, "junk"),
+ )
+
+
+class TestVAEDiffusersWanConfig:
+ """Diffusers-folder probe; latent_channels read from vae/config.json."""
+
+ def test_z_dim_from_config_json(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan2.2-VAE"
+ root.mkdir()
+ with (root / "config.json").open("w") as f:
+ json.dump({"_class_name": "AutoencoderKLWan", "z_dim": 48}, f)
+
+ cfg = VAE_Diffusers_Wan_Config.from_model_on_disk(
+ _make_mod(root),
+ _build_overrides(root, "Wan2.2-VAE"),
+ )
+ assert cfg.latent_channels == 48
+ assert cfg.format == ModelFormat.Diffusers
+
+ def test_default_to_16_when_z_dim_missing(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "Wan-VAE"
+ root.mkdir()
+ with (root / "config.json").open("w") as f:
+ json.dump({"_class_name": "AutoencoderKLWan"}, f) # No z_dim.
+
+ cfg = VAE_Diffusers_Wan_Config.from_model_on_disk(
+ _make_mod(root),
+ _build_overrides(root, "Wan-VAE"),
+ )
+ assert cfg.latent_channels == 16
+
+ def test_rejects_non_wan_class(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp) / "FluxVAE"
+ root.mkdir()
+ with (root / "config.json").open("w") as f:
+ json.dump({"_class_name": "AutoencoderKL"}, f)
+
+ with pytest.raises(NotAMatchError):
+ VAE_Diffusers_Wan_Config.from_model_on_disk(
+ _make_mod(root),
+ _build_overrides(root, "FluxVAE"),
+ )
diff --git a/tests/backend/model_manager/load/test_wan_loader.py b/tests/backend/model_manager/load/test_wan_loader.py
new file mode 100644
index 00000000000..31d30522446
--- /dev/null
+++ b/tests/backend/model_manager/load/test_wan_loader.py
@@ -0,0 +1,175 @@
+"""Tests for Wan loader helpers (native -> diffusers key conversion)."""
+
+import gguf
+import torch
+
+from invokeai.backend.model_manager.load.model_loaders.wan import (
+ _convert_wan_native_to_diffusers,
+ _unwrap_unquantized_to_compute_dtype,
+)
+from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
+
+
+def test_converts_text_and_time_embedders():
+ sd = {
+ "text_embedding.0.weight": "a",
+ "text_embedding.0.bias": "b",
+ "text_embedding.2.weight": "c",
+ "time_embedding.0.weight": "d",
+ "time_embedding.2.weight": "e",
+ "time_projection.1.weight": "f",
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert "condition_embedder.text_embedder.linear_1.weight" in out
+ assert "condition_embedder.text_embedder.linear_1.bias" in out
+ assert "condition_embedder.text_embedder.linear_2.weight" in out
+ assert "condition_embedder.time_embedder.linear_1.weight" in out
+ assert "condition_embedder.time_embedder.linear_2.weight" in out
+ assert "condition_embedder.time_proj.weight" in out
+
+
+def test_converts_attention_blocks():
+ sd = {
+ "blocks.0.self_attn.q.weight": 1,
+ "blocks.0.self_attn.k.weight": 2,
+ "blocks.0.self_attn.v.weight": 3,
+ "blocks.0.self_attn.o.weight": 4,
+ "blocks.0.self_attn.norm_q.weight": 5,
+ "blocks.0.self_attn.norm_k.weight": 6,
+ "blocks.0.cross_attn.q.weight": 7,
+ "blocks.0.cross_attn.k.weight": 8,
+ "blocks.0.cross_attn.v.weight": 9,
+ "blocks.0.cross_attn.o.weight": 10,
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert "blocks.0.attn1.to_q.weight" in out
+ assert "blocks.0.attn1.to_k.weight" in out
+ assert "blocks.0.attn1.to_v.weight" in out
+ assert "blocks.0.attn1.to_out.0.weight" in out
+ assert "blocks.0.attn1.norm_q.weight" in out
+ assert "blocks.0.attn1.norm_k.weight" in out
+ assert "blocks.0.attn2.to_q.weight" in out
+ assert "blocks.0.attn2.to_out.0.weight" in out
+
+
+def test_converts_ffn_and_modulation():
+ sd = {
+ "blocks.0.ffn.0.weight": 1,
+ "blocks.0.ffn.0.bias": 2,
+ "blocks.0.ffn.2.weight": 3,
+ "blocks.0.modulation": 4,
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert "blocks.0.ffn.net.0.proj.weight" in out
+ assert "blocks.0.ffn.net.0.proj.bias" in out
+ assert "blocks.0.ffn.net.2.weight" in out
+ assert "blocks.0.scale_shift_table" in out
+
+
+def test_swaps_norm2_and_norm3():
+ """Native norm3 has params (cross-attn norm in diffusers norm2 slot)
+ while native norm2 is the elementwise-affine-False norm. The swap
+ via placeholder must not collide."""
+ sd = {
+ "blocks.0.norm2.weight": "native_norm2",
+ "blocks.0.norm3.weight": "native_norm3",
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert out["blocks.0.norm3.weight"] == "native_norm2"
+ assert out["blocks.0.norm2.weight"] == "native_norm3"
+
+
+def test_converts_head_keys():
+ sd = {
+ "head.head.weight": 1,
+ "head.head.bias": 2,
+ "head.modulation": 3,
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert "proj_out.weight" in out
+ assert "proj_out.bias" in out
+ assert "scale_shift_table" in out
+
+
+def test_diffusers_keys_pass_through_unchanged():
+ """If a state dict is already in diffusers form, the substring rules
+ must be no-ops — none of the native fingerprints are present."""
+ sd = {
+ "patch_embedding.weight": 1,
+ "condition_embedder.text_embedder.linear_1.weight": 2,
+ "blocks.0.attn1.to_q.weight": 3,
+ "blocks.0.ffn.net.0.proj.weight": 4,
+ "scale_shift_table": 5,
+ "proj_out.weight": 6,
+ }
+ out = _convert_wan_native_to_diffusers(sd)
+ assert set(out.keys()) == set(sd.keys())
+ assert all(out[k] == sd[k] for k in sd)
+
+
+def test_does_not_mutate_input():
+ sd = {"text_embedding.0.weight": 1}
+ snapshot = dict(sd)
+ _convert_wan_native_to_diffusers(sd)
+ assert sd == snapshot
+
+
+def test_non_string_keys_pass_through():
+ sd = {0: "ignored", "text_embedding.0.weight": "renamed"}
+ out = _convert_wan_native_to_diffusers(sd)
+ assert out[0] == "ignored"
+ assert "condition_embedder.text_embedder.linear_1.weight" in out
+
+
+def _ggml(data: torch.Tensor, qtype: gguf.GGMLQuantizationType, compute_dtype: torch.dtype) -> GGMLTensor:
+ return GGMLTensor(
+ data=data,
+ ggml_quantization_type=qtype,
+ tensor_shape=data.shape,
+ compute_dtype=compute_dtype,
+ )
+
+
+class TestUnwrapUnquantized:
+ """The QuantStack GGUFs store ``patch_embedding.bias`` as F16 while latents
+ flow through the model as bf16. Conv3d isn't in GGMLTensor's dispatch table,
+ so without unwrapping the F16 wrapper goes into conv3d as-is and crashes
+ with ``Input type (c10::BFloat16) and bias type (c10::Half) should be the same``.
+ These tests guard the unwrap step that prevents that."""
+
+ def test_f16_compatible_qtype_is_unwrapped_and_cast(self):
+ # F16 storage that should become bf16 plain tensor.
+ f16_data = torch.zeros((4,), dtype=torch.float16)
+ sd = {"bias": _ggml(f16_data, gguf.GGMLQuantizationType.F16, torch.bfloat16)}
+ out = _unwrap_unquantized_to_compute_dtype(sd)
+
+ result = out["bias"]
+ assert not isinstance(result, GGMLTensor)
+ assert result.dtype == torch.bfloat16
+
+ def test_f32_compatible_qtype_is_unwrapped_and_cast(self):
+ # patch_embedding.weight in QuantStack is F32 — same path.
+ f32_data = torch.zeros((4,), dtype=torch.float32)
+ sd = {"weight": _ggml(f32_data, gguf.GGMLQuantizationType.F32, torch.bfloat16)}
+ out = _unwrap_unquantized_to_compute_dtype(sd)
+
+ result = out["weight"]
+ assert not isinstance(result, GGMLTensor)
+ assert result.dtype == torch.bfloat16
+
+ def test_quantized_tensor_stays_wrapped(self):
+ # Q4_K and friends must remain GGMLTensor so on-demand dequant works
+ # via the linear/addmm dispatch path. The byte storage shape is fake
+ # but irrelevant for this test.
+ q4_data = torch.zeros((1,), dtype=torch.uint8)
+ sd = {"linear.weight": _ggml(q4_data, gguf.GGMLQuantizationType.Q4_K, torch.bfloat16)}
+ out = _unwrap_unquantized_to_compute_dtype(sd)
+
+ assert isinstance(out["linear.weight"], GGMLTensor)
+ assert out["linear.weight"]._ggml_quantization_type == gguf.GGMLQuantizationType.Q4_K
+
+ def test_plain_torch_tensor_passes_through(self):
+ plain = torch.zeros((4,), dtype=torch.bfloat16)
+ sd = {"plain": plain}
+ out = _unwrap_unquantized_to_compute_dtype(sd)
+ assert out["plain"] is plain
diff --git a/tests/backend/model_manager/load/test_wan_vae_loader.py b/tests/backend/model_manager/load/test_wan_vae_loader.py
new file mode 100644
index 00000000000..09026df26c4
--- /dev/null
+++ b/tests/backend/model_manager/load/test_wan_vae_loader.py
@@ -0,0 +1,58 @@
+"""Tests for the Wan VAE single-file loader helper.
+
+Covers the bug where ``AutoencoderKLWan`` was always instantiated with the A14B
+defaults (base_dim=96, out_channels=3, no patchify), causing the TI2V-5B VAE
+checkpoint to fail state_dict loading with shape mismatches throughout the
+encoder + decoder. The fix routes z_dim=48 to the TI2V-5B-specific
+constructor kwargs.
+"""
+
+import accelerate
+from diffusers.models.autoencoders.autoencoder_kl_wan import AutoencoderKLWan
+
+from invokeai.backend.model_manager.load.model_loaders.vae import _wan_vae_init_kwargs_for
+
+
+def test_a14b_returns_default_z_dim_only() -> None:
+ # The A14B path should still be the trivial case — only z_dim is overridden,
+ # leaving diffusers' defaults (base_dim=96, out_channels=3, etc.) intact.
+ assert _wan_vae_init_kwargs_for(16) == {"z_dim": 16}
+
+
+def test_ti2v_5b_returns_full_architectural_override() -> None:
+ kw = _wan_vae_init_kwargs_for(48)
+ assert kw["z_dim"] == 48
+ assert kw["base_dim"] == 160
+ assert kw["decoder_base_dim"] == 256
+ assert kw["in_channels"] == 12
+ assert kw["out_channels"] == 12
+ assert kw["patch_size"] == 2
+ assert kw["scale_factor_spatial"] == 16
+ assert kw["is_residual"] is True
+ # latents_mean/std need to be 48-vectors so the model can construct.
+ assert len(kw["latents_mean"]) == 48
+ assert len(kw["latents_std"]) == 48
+
+
+def test_ti2v_5b_kwargs_instantiate_with_expected_shapes() -> None:
+ # End-to-end check: the kwargs let AutoencoderKLWan build cleanly and the
+ # resulting model carries the TI2V-5B-shaped layers (z_dim=48, decoder
+ # outputs 12 channels — this is what failed before the fix).
+ with accelerate.init_empty_weights():
+ model = AutoencoderKLWan(**_wan_vae_init_kwargs_for(48))
+ assert model.z_dim == 48
+ assert model.config.base_dim == 160
+ assert model.config.decoder_base_dim == 256
+ assert model.config.out_channels == 12
+ assert model.config.patch_size == 2
+ # decoder.conv_out emits the patchified 12-channel output (3 RGB x 2x2 patch).
+ assert model.decoder.conv_out.weight.shape[0] == 12
+
+
+def test_a14b_kwargs_instantiate_with_expected_shapes() -> None:
+ with accelerate.init_empty_weights():
+ model = AutoencoderKLWan(**_wan_vae_init_kwargs_for(16))
+ assert model.z_dim == 16
+ assert model.config.base_dim == 96
+ assert model.config.out_channels == 3
+ assert model.config.patch_size is None
diff --git a/tests/backend/model_manager/test_wan_default_settings.py b/tests/backend/model_manager/test_wan_default_settings.py
new file mode 100644
index 00000000000..ff66cf4f067
--- /dev/null
+++ b/tests/backend/model_manager/test_wan_default_settings.py
@@ -0,0 +1,25 @@
+"""Tests for Wan 2.2 default settings."""
+
+from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings
+from invokeai.backend.model_manager.taxonomy import BaseModelType, WanVariantType
+
+
+class TestWanDefaultSettings:
+ def test_a14b_defaults(self) -> None:
+ s = MainModelDefaultSettings.from_base(BaseModelType.Wan, WanVariantType.T2V_A14B)
+ assert s is not None
+ assert s.steps == 40
+ assert s.cfg_scale == 4.0
+ assert s.width == 1024
+ assert s.height == 1024
+
+ def test_ti2v_5b_defaults(self) -> None:
+ s = MainModelDefaultSettings.from_base(BaseModelType.Wan, WanVariantType.TI2V_5B)
+ assert s is not None
+ assert s.steps == 30
+ assert s.cfg_scale == 5.0
+
+ def test_no_variant_falls_back_to_a14b_settings(self) -> None:
+ s = MainModelDefaultSettings.from_base(BaseModelType.Wan)
+ assert s is not None
+ assert s.steps == 40
diff --git a/tests/backend/patches/lora_conversions/test_wan_lora_conversion_utils.py b/tests/backend/patches/lora_conversions/test_wan_lora_conversion_utils.py
new file mode 100644
index 00000000000..f9cac4bd61b
--- /dev/null
+++ b/tests/backend/patches/lora_conversions/test_wan_lora_conversion_utils.py
@@ -0,0 +1,175 @@
+"""Tests for Wan LoRA state-dict conversion to ModelPatchRaw."""
+
+import torch
+
+from invokeai.backend.patches.lora_conversions.wan_lora_constants import WAN_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.lora_conversions.wan_lora_conversion_utils import (
+ _kohya_layer_to_diffusers_path,
+ _native_layer_path_to_diffusers,
+ _strip_peft_prefix,
+ lora_model_from_wan_state_dict,
+)
+
+
+def _ab_pair(in_dim: int, out_dim: int, rank: int = 16) -> dict[str, torch.Tensor]:
+ """PEFT-style lora_A (in→rank) + lora_B (rank→out) pair."""
+ return {
+ "lora_A.weight": torch.zeros((rank, in_dim)),
+ "lora_B.weight": torch.zeros((out_dim, rank)),
+ }
+
+
+def _down_up_pair(in_dim: int, out_dim: int, rank: int = 16) -> dict[str, torch.Tensor]:
+ """Kohya-style lora_down + lora_up pair."""
+ return {
+ "lora_down.weight": torch.zeros((rank, in_dim)),
+ "lora_up.weight": torch.zeros((out_dim, rank)),
+ }
+
+
+class TestKohyaLayerToDiffusersPath:
+ def test_diffusers_self_attention(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_attn1_to_q") == "blocks.0.attn1.to_q"
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_5_attn1_to_out_0") == "blocks.5.attn1.to_out.0"
+
+ def test_diffusers_cross_attention(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_attn2_to_k") == "blocks.0.attn2.to_k"
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_attn2_to_v") == "blocks.0.attn2.to_v"
+
+ def test_native_self_attention_maps_to_attn1(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_self_attn_q") == "blocks.0.attn1.to_q"
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_self_attn_o") == "blocks.0.attn1.to_out.0"
+
+ def test_native_cross_attention_maps_to_attn2(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_2_cross_attn_v") == "blocks.2.attn2.to_v"
+
+ def test_ffn_diffusers(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_ffn_net_0_proj") == "blocks.0.ffn.net.0.proj"
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_ffn_net_2") == "blocks.0.ffn.net.2"
+
+ def test_ffn_native_maps_to_diffusers(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_ffn_0") == "blocks.0.ffn.net.0.proj"
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_ffn_2") == "blocks.0.ffn.net.2"
+
+ def test_unknown_submodule_returns_none(self):
+ assert _kohya_layer_to_diffusers_path("lora_unet_blocks_0_unknown_thing") is None
+
+ def test_non_kohya_returns_none(self):
+ assert _kohya_layer_to_diffusers_path("transformer.blocks.0.attn1.to_q") is None
+
+
+class TestPEFTPathConversion:
+ def test_strip_transformer_prefix(self):
+ assert _strip_peft_prefix("transformer.blocks.0.attn1.to_q") == "blocks.0.attn1.to_q"
+
+ def test_strip_diffusion_model_prefix(self):
+ assert _strip_peft_prefix("diffusion_model.blocks.0.self_attn.q") == "blocks.0.self_attn.q"
+
+ def test_strip_base_model_prefix(self):
+ assert _strip_peft_prefix("base_model.model.transformer.blocks.0.attn1.to_q") == "blocks.0.attn1.to_q"
+
+ def test_no_prefix_unchanged(self):
+ assert _strip_peft_prefix("blocks.0.attn1.to_q") == "blocks.0.attn1.to_q"
+
+ def test_diffusers_path_passes_through(self):
+ assert _native_layer_path_to_diffusers("blocks.0.attn1.to_q") == "blocks.0.attn1.to_q"
+ assert _native_layer_path_to_diffusers("blocks.0.ffn.net.0.proj") == "blocks.0.ffn.net.0.proj"
+
+ def test_native_self_attn_becomes_attn1(self):
+ assert _native_layer_path_to_diffusers("blocks.0.self_attn.q") == "blocks.0.attn1.to_q"
+ assert _native_layer_path_to_diffusers("blocks.0.self_attn.k") == "blocks.0.attn1.to_k"
+ assert _native_layer_path_to_diffusers("blocks.0.self_attn.v") == "blocks.0.attn1.to_v"
+ assert _native_layer_path_to_diffusers("blocks.0.self_attn.o") == "blocks.0.attn1.to_out.0"
+
+ def test_native_cross_attn_becomes_attn2(self):
+ assert _native_layer_path_to_diffusers("blocks.7.cross_attn.q") == "blocks.7.attn2.to_q"
+ assert _native_layer_path_to_diffusers("blocks.7.cross_attn.o") == "blocks.7.attn2.to_out.0"
+
+ def test_native_ffn_becomes_diffusers_ffn(self):
+ assert _native_layer_path_to_diffusers("blocks.0.ffn.0") == "blocks.0.ffn.net.0.proj"
+ assert _native_layer_path_to_diffusers("blocks.0.ffn.2") == "blocks.0.ffn.net.2"
+
+ def test_non_block_path_rejected(self):
+ assert _native_layer_path_to_diffusers("patch_embedding.weight") is None
+
+
+class TestLoRAModelFromStateDict:
+ """End-to-end conversion: state dict -> ModelPatchRaw."""
+
+ def test_diffusers_peft_with_transformer_prefix(self):
+ sd = {f"transformer.blocks.0.attn1.to_q.{k}": v for k, v in _ab_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ expected_key = f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.attn1.to_q"
+ assert expected_key in patch.layers
+
+ def test_diffusers_peft_bare(self):
+ sd = {f"blocks.5.attn2.to_k.{k}": v for k, v in _ab_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.5.attn2.to_k" in patch.layers
+
+ def test_native_peft_diffusion_model_prefix(self):
+ sd = {f"diffusion_model.blocks.0.self_attn.q.{k}": v for k, v in _ab_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ # native self_attn.q must be rewritten to attn1.to_q
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.attn1.to_q" in patch.layers
+
+ def test_native_peft_cross_attn_to_attn2(self):
+ sd = {f"diffusion_model.blocks.3.cross_attn.o.{k}": v for k, v in _ab_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.3.attn2.to_out.0" in patch.layers
+
+ def test_native_peft_ffn_to_diffusers(self):
+ sd = {f"diffusion_model.blocks.0.ffn.0.{k}": v for k, v in _ab_pair(5120, 13824).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.ffn.net.0.proj" in patch.layers
+
+ def test_kohya_diffusers_naming(self):
+ sd = {f"lora_unet_blocks_0_attn1_to_q.{k}": v for k, v in _down_up_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.attn1.to_q" in patch.layers
+
+ def test_kohya_native_naming(self):
+ sd = {f"lora_unet_blocks_0_self_attn_q.{k}": v for k, v in _down_up_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.attn1.to_q" in patch.layers
+
+ def test_kohya_ffn_native_naming(self):
+ sd = {f"lora_unet_blocks_0_ffn_0.{k}": v for k, v in _down_up_pair(5120, 13824).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.ffn.net.0.proj" in patch.layers
+
+ def test_multiple_layers(self):
+ """Cover a realistic mix of attn + ffn keys across multiple blocks."""
+ sd = {}
+ for block in range(3):
+ for k, v in _ab_pair(5120, 5120).items():
+ sd[f"transformer.blocks.{block}.attn1.to_q.{k}"] = v
+ sd[f"transformer.blocks.{block}.attn2.to_v.{k}"] = v
+ for k, v in _ab_pair(5120, 13824).items():
+ sd[f"transformer.blocks.{block}.ffn.net.0.proj.{k}"] = v
+
+ patch = lora_model_from_wan_state_dict(sd)
+ expected_paths = []
+ for block in range(3):
+ expected_paths.append(f"blocks.{block}.attn1.to_q")
+ expected_paths.append(f"blocks.{block}.attn2.to_v")
+ expected_paths.append(f"blocks.{block}.ffn.net.0.proj")
+ for path in expected_paths:
+ assert f"{WAN_LORA_TRANSFORMER_PREFIX}{path}" in patch.layers
+
+ def test_alpha_override_propagates(self):
+ sd = {f"blocks.0.attn1.to_q.{k}": v for k, v in _ab_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd, alpha=8.0)
+ layer = patch.layers[f"{WAN_LORA_TRANSFORMER_PREFIX}blocks.0.attn1.to_q"]
+ # any_lora_layer_from_state_dict picks LoRALayer / LoKR / etc. — the
+ # layer object should at minimum have processed the alpha into its state.
+ assert layer is not None
+
+ def test_unknown_kohya_submodule_is_skipped_silently(self):
+ sd = {f"lora_unet_blocks_0_unknown_thing.{k}": v for k, v in _down_up_pair(5120, 5120).items()}
+ patch = lora_model_from_wan_state_dict(sd)
+ assert len(patch.layers) == 0
+
+ def test_empty_state_dict(self):
+ patch = lora_model_from_wan_state_dict({})
+ assert len(patch.layers) == 0
diff --git a/tests/backend/wan/__init__.py b/tests/backend/wan/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/backend/wan/test_sampling_utils.py b/tests/backend/wan/test_sampling_utils.py
new file mode 100644
index 00000000000..ec52f357a87
--- /dev/null
+++ b/tests/backend/wan/test_sampling_utils.py
@@ -0,0 +1,91 @@
+"""Tests for Wan 2.2 sampling utilities."""
+
+import torch
+
+from invokeai.backend.model_manager.taxonomy import WanVariantType
+from invokeai.backend.wan.sampling_utils import (
+ get_default_latent_channels,
+ get_spatial_scale_factor,
+ make_noise,
+)
+
+
+class TestVariantConstants:
+ def test_a14b_uses_8x_spatial(self) -> None:
+ assert get_spatial_scale_factor(WanVariantType.T2V_A14B) == 8
+
+ def test_ti2v_5b_uses_16x_spatial(self) -> None:
+ assert get_spatial_scale_factor(WanVariantType.TI2V_5B) == 16
+
+ def test_a14b_default_channels(self) -> None:
+ assert get_default_latent_channels(WanVariantType.T2V_A14B) == 16
+
+ def test_ti2v_5b_default_channels(self) -> None:
+ assert get_default_latent_channels(WanVariantType.TI2V_5B) == 48
+
+
+class TestMakeNoise:
+ def test_a14b_shape_at_1024(self) -> None:
+ noise = make_noise(
+ batch_size=1,
+ latent_channels=16,
+ height=1024,
+ width=1024,
+ spatial_scale_factor=8,
+ device=torch.device("cpu"),
+ dtype=torch.bfloat16,
+ seed=42,
+ )
+ assert noise.shape == (1, 16, 1, 128, 128)
+ assert noise.dtype == torch.bfloat16
+
+ def test_ti2v_shape_at_1024(self) -> None:
+ noise = make_noise(
+ batch_size=1,
+ latent_channels=48,
+ height=1024,
+ width=1024,
+ spatial_scale_factor=16,
+ device=torch.device("cpu"),
+ dtype=torch.bfloat16,
+ seed=42,
+ )
+ assert noise.shape == (1, 48, 1, 64, 64)
+
+ def test_seed_is_deterministic(self) -> None:
+ kwargs = {
+ "batch_size": 1,
+ "latent_channels": 16,
+ "height": 256,
+ "width": 256,
+ "spatial_scale_factor": 8,
+ "device": torch.device("cpu"),
+ "dtype": torch.float32,
+ "seed": 123,
+ }
+ a = make_noise(**kwargs)
+ b = make_noise(**kwargs)
+ assert torch.allclose(a, b)
+
+ def test_seed_changes_output(self) -> None:
+ a = make_noise(
+ batch_size=1,
+ latent_channels=16,
+ height=256,
+ width=256,
+ spatial_scale_factor=8,
+ device=torch.device("cpu"),
+ dtype=torch.float32,
+ seed=1,
+ )
+ b = make_noise(
+ batch_size=1,
+ latent_channels=16,
+ height=256,
+ width=256,
+ spatial_scale_factor=8,
+ device=torch.device("cpu"),
+ dtype=torch.float32,
+ seed=2,
+ )
+ assert not torch.allclose(a, b)
diff --git a/tests/backend/wan/test_wan_ref_image_extension.py b/tests/backend/wan/test_wan_ref_image_extension.py
new file mode 100644
index 00000000000..5de7b36dfa8
--- /dev/null
+++ b/tests/backend/wan/test_wan_ref_image_extension.py
@@ -0,0 +1,161 @@
+"""Tests for the Wan 2.2 I2V reference-image VAE-latent encoder helper."""
+
+from unittest.mock import MagicMock
+
+import torch
+from PIL import Image
+
+from invokeai.backend.wan.extensions.wan_ref_image_extension import (
+ encode_reference_image_to_condition,
+ preprocess_reference_image,
+)
+
+
+def _make_fake_vae(z_dim: int = 16, spatial_scale: int = 8, temporal_scale: int = 4) -> MagicMock:
+ """Stand-in for ``AutoencoderKLWan`` that returns deterministic latents.
+
+ ``encode(pixel)`` returns a fake distribution whose ``sample()`` yields
+ a tensor sized exactly as the real Wan VAE would: ``[B, z_dim, T_lat, H/8, W/8]``.
+ """
+ vae = MagicMock()
+
+ # ``next(iter(vae.parameters())).dtype`` is queried; pin to float32.
+ param = torch.zeros(1, dtype=torch.float32)
+ vae.parameters = MagicMock(return_value=iter([param]))
+
+ # Config carries per-channel normalisation stats.
+ vae.config = MagicMock()
+ vae.config.latents_mean = [0.0] * z_dim
+ vae.config.latents_std = [1.0] * z_dim
+
+ def fake_encode(pixel: torch.Tensor, return_dict: bool = False):
+ b, _, t, h, w = pixel.shape
+ t_lat = (t - 1) // temporal_scale + 1
+ h_lat = h // spatial_scale
+ w_lat = w // spatial_scale
+ latents = torch.zeros(b, z_dim, t_lat, h_lat, w_lat, dtype=pixel.dtype)
+
+ dist = MagicMock()
+ dist.sample = MagicMock(return_value=latents)
+ # The pipeline does ``vae.encode(...)[0]`` for non-dict returns.
+ return (dist,) if return_dict is False else MagicMock(latent_dist=dist)
+
+ vae.encode = fake_encode
+ return vae
+
+
+class TestPreprocess:
+ def test_resize_to_target_dims(self):
+ img = Image.new("RGB", (200, 300), (128, 128, 128))
+ out = preprocess_reference_image(img, width=64, height=64)
+ # Shape: [batch=1, channels=3, time=1, H, W]
+ assert out.shape == (1, 3, 1, 64, 64)
+
+ def test_normalised_to_minus_one_to_one(self):
+ # Pure-grey image preprocessed should be exactly 0 (since 128/255*2 - 1 ≈ 0.004).
+ img = Image.new("RGB", (64, 64), (255, 255, 255))
+ out = preprocess_reference_image(img, width=64, height=64)
+ # White → 1.0
+ assert torch.allclose(out, torch.ones_like(out), atol=1e-4)
+
+ black = Image.new("RGB", (64, 64), (0, 0, 0))
+ out_b = preprocess_reference_image(black, width=64, height=64)
+ # Black → -1.0
+ assert torch.allclose(out_b, -torch.ones_like(out_b), atol=1e-4)
+
+ def test_rejects_non_multiple_of_8(self):
+ img = Image.new("RGB", (100, 100))
+ import pytest
+
+ with pytest.raises(ValueError, match="multiples of 8"):
+ preprocess_reference_image(img, width=65, height=64)
+
+
+class TestEncodeReferenceImageToCondition:
+ """The condition tensor must be 20-channel (4 mask + 16 image latents)
+ and shaped for the denoise step's later concat with 16-ch noise latents."""
+
+ def test_shape_at_64x64(self):
+ img = Image.new("RGB", (64, 64))
+ vae = _make_fake_vae()
+ cond = encode_reference_image_to_condition(
+ image=img, vae=vae, width=64, height=64, device=torch.device("cpu"), dtype=torch.float32
+ )
+ # [1, 20, 1, 8, 8] — 4-ch mask + 16-ch latents at H/8, W/8.
+ assert cond.shape == (1, 20, 1, 8, 8)
+
+ def test_shape_at_1024x1024(self):
+ img = Image.new("RGB", (1024, 1024))
+ vae = _make_fake_vae()
+ cond = encode_reference_image_to_condition(
+ image=img, vae=vae, width=1024, height=1024, device=torch.device("cpu"), dtype=torch.float32
+ )
+ # 1024/8 = 128 latent spatial dim.
+ assert cond.shape == (1, 20, 1, 128, 128)
+
+ def test_first_four_channels_are_all_ones_mask(self):
+ img = Image.new("RGB", (64, 64))
+ vae = _make_fake_vae()
+ cond = encode_reference_image_to_condition(
+ image=img, vae=vae, width=64, height=64, device=torch.device("cpu"), dtype=torch.float32
+ )
+ mask = cond[:, :4]
+ # First-frame mask is all-ones at num_frames=1 (every position is the first frame).
+ assert torch.equal(mask, torch.ones_like(mask))
+
+ def test_returns_dtype(self):
+ img = Image.new("RGB", (64, 64))
+ vae = _make_fake_vae()
+ cond = encode_reference_image_to_condition(
+ image=img, vae=vae, width=64, height=64, device=torch.device("cpu"), dtype=torch.bfloat16
+ )
+ assert cond.dtype == torch.bfloat16
+
+
+class TestEncodeReferenceImageToTI2VCondition:
+ """TI2V-5B's condition tensor is a single 48-channel latent frame (no mask
+ channels) at the Wan2.2-VAE's 16x spatial scale. The denoise loop blends
+ this with the noise via a first_frame_mask at each step.
+ """
+
+ def test_shape_at_64x64(self):
+ from invokeai.backend.wan.extensions.wan_ref_image_extension import (
+ encode_reference_image_to_ti2v_condition,
+ )
+
+ img = Image.new("RGB", (64, 64))
+ vae = _make_fake_vae(z_dim=48, spatial_scale=16, temporal_scale=4)
+ cond = encode_reference_image_to_ti2v_condition(
+ image=img, vae=vae, width=64, height=64, device=torch.device("cpu"), dtype=torch.float32
+ )
+ # [1, 48, 1, 4, 4] — single latent frame at H/16, W/16.
+ assert cond.shape == (1, 48, 1, 4, 4)
+
+ def test_shape_at_832x480(self):
+ # Common Wan video resolution: latent 30x52 single frame.
+ from invokeai.backend.wan.extensions.wan_ref_image_extension import (
+ encode_reference_image_to_ti2v_condition,
+ )
+
+ img = Image.new("RGB", (832, 480))
+ vae = _make_fake_vae(z_dim=48, spatial_scale=16, temporal_scale=4)
+ cond = encode_reference_image_to_ti2v_condition(
+ image=img, vae=vae, width=832, height=480, device=torch.device("cpu"), dtype=torch.float32
+ )
+ assert cond.shape == (1, 48, 1, 30, 52)
+
+ def test_no_mask_channels(self):
+ # Distinguishing feature vs A14B: no leading 4-ch mask. All 48 channels
+ # are latent content. With latents_mean=0 and latents_std=1, encoded zeros
+ # stay zero (the function returns latents straight through).
+ from invokeai.backend.wan.extensions.wan_ref_image_extension import (
+ encode_reference_image_to_ti2v_condition,
+ )
+
+ img = Image.new("RGB", (64, 64))
+ vae = _make_fake_vae(z_dim=48, spatial_scale=16, temporal_scale=4)
+ cond = encode_reference_image_to_ti2v_condition(
+ image=img, vae=vae, width=64, height=64, device=torch.device("cpu"), dtype=torch.float32
+ )
+ # Fake VAE.encode returns zero latents; normalized zeros stay zero.
+ assert torch.equal(cond, torch.zeros_like(cond))
diff --git a/tests/conftest.py b/tests/conftest.py
index 92f42375ed4..673e2be4e31 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -69,6 +69,11 @@ def mock_services() -> InvocationServices:
model_relationships=None, # type: ignore
client_state_persistence=ClientStatePersistenceSqlite(db=db),
users=UserService(db),
+ videos=None, # type: ignore
+ video_files=None, # type: ignore
+ video_records=None, # type: ignore
+ board_video_records=None, # type: ignore
+ gallery=None, # type: ignore
)
diff --git a/uv.lock b/uv.lock
index ef0ad022177..ed913997713 100644
--- a/uv.lock
+++ b/uv.lock
@@ -981,6 +981,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
+[[package]]
+name = "imageio"
+version = "2.37.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" },
+]
+
+[package.optional-dependencies]
+ffmpeg = [
+ { name = "imageio-ffmpeg" },
+ { name = "psutil" },
+]
+
+[[package]]
+name = "imageio-ffmpeg"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
+ { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
+]
+
[[package]]
name = "importlib-metadata"
version = "8.7.0"
@@ -1021,6 +1054,7 @@ dependencies = [
{ name = "fastapi-events" },
{ name = "gguf" },
{ name = "huggingface-hub" },
+ { name = "imageio", extra = ["ffmpeg"] },
{ name = "mediapipe" },
{ name = "numpy" },
{ name = "onnx" },
@@ -1134,6 +1168,7 @@ requires-dist = [
{ name = "httpx", marker = "extra == 'test'" },
{ name = "huggingface-hub" },
{ name = "humanize", marker = "extra == 'test'", specifier = "==4.12.1" },
+ { name = "imageio", extras = ["ffmpeg"] },
{ name = "jurigged", marker = "extra == 'dev'" },
{ name = "mediapipe", specifier = "==0.10.14" },
{ name = "mkdocs-git-revision-date-localized-plugin", marker = "extra == 'docs'" },
@@ -2899,8 +2934,8 @@ dependencies = [
{ name = "setuptools", marker = "(sys_platform == 'linux' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/pytorch_triton_rocm-3.3.1-cp311-cp311-linux_x86_64.whl", hash = "sha256:8eb26aec84408b2be3d5b942a9edef9fadc6e249afe6aab795872e227ce8f579" },
- { url = "https://download.pytorch.org/whl/pytorch_triton_rocm-3.3.1-cp312-cp312-linux_x86_64.whl", hash = "sha256:977423eee5c542a3f8aa4f527aec1688c4d485f207089cb595a8e638fcc3888a" },
+ { url = "https://download-r2.pytorch.org/whl/pytorch_triton_rocm-3.3.1-cp311-cp311-linux_x86_64.whl", hash = "sha256:8eb26aec84408b2be3d5b942a9edef9fadc6e249afe6aab795872e227ce8f579", upload-time = "2025-06-03T22:26:32Z" },
+ { url = "https://download-r2.pytorch.org/whl/pytorch_triton_rocm-3.3.1-cp312-cp312-linux_x86_64.whl", hash = "sha256:977423eee5c542a3f8aa4f527aec1688c4d485f207089cb595a8e638fcc3888a", upload-time = "2025-06-03T22:26:30Z" },
]
[[package]]
@@ -3492,13 +3527,13 @@ dependencies = [
{ name = "typing-extensions", marker = "extra == 'extra-8-invokeai-cpu' or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf2db5adf77b433844f080887ade049c4705ddf9fe1a32023ff84ff735aa5ad" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8f8b3cfc53010a4b4a3c7ecb88c212e9decc4f5eeb6af75c3c803937d2d60947" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:0bc887068772233f532b51a3e8c8cfc682ae62bef74bf4e0c53526c8b9e4138f" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:a2618775f32eb4126c5b2050686da52001a08cffa331637d9cf51c8250931e00" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5fe6045b8f426bf2d0426e4fe009f1667a954ec2aeb82f1bd0bf60c6d7a85445", upload-time = "2025-06-03T18:27:52Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a1684793e352f03fa14f78857e55d65de4ada8405ded1da2bf4f452179c4b779", upload-time = "2025-06-03T18:27:53Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7b977eccbc85ae2bd19d6998de7b1f1f4bd3c04eaffd3015deb7934389783399", upload-time = "2025-06-03T18:27:58Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf2db5adf77b433844f080887ade049c4705ddf9fe1a32023ff84ff735aa5ad", upload-time = "2025-06-03T18:27:57Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8f8b3cfc53010a4b4a3c7ecb88c212e9decc4f5eeb6af75c3c803937d2d60947", upload-time = "2025-06-03T18:27:57Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:0bc887068772233f532b51a3e8c8cfc682ae62bef74bf4e0c53526c8b9e4138f", upload-time = "2025-06-03T18:27:56Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.7.1%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:a2618775f32eb4126c5b2050686da52001a08cffa331637d9cf51c8250931e00", upload-time = "2025-07-16T16:40:20Z" },
]
[[package]]
@@ -3538,12 +3573,12 @@ dependencies = [
{ name = "typing-extensions", marker = "extra == 'extra-8-invokeai-cuda' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3a0954c54fd7cb9f45beab1272dece2a05b0e77023c1da33ba32a7919661260f" },
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c301dc280458afd95450af794924c98fe07522dd148ff384739b810e3e3179f2" },
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:138c66dcd0ed2f07aafba3ed8b7958e2bed893694990e0b4b55b6b2b4a336aa6" },
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:268e54db9f0bc2b7b9eb089852d3e592c2dea2facc3db494100c3d3b796549fa" },
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0b64f7d0a6f2a739ed052ba959f7b67c677028c9566ce51997f9f90fe573ddaa" },
- { url = "https://download.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:2bb8c05d48ba815b316879a18195d53a6472a03e297d971e916753f8e1053d30" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3a0954c54fd7cb9f45beab1272dece2a05b0e77023c1da33ba32a7919661260f", upload-time = "2025-06-03T18:31:04Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c301dc280458afd95450af794924c98fe07522dd148ff384739b810e3e3179f2", upload-time = "2025-06-03T18:31:06Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:138c66dcd0ed2f07aafba3ed8b7958e2bed893694990e0b4b55b6b2b4a336aa6", upload-time = "2025-06-03T18:31:13Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:268e54db9f0bc2b7b9eb089852d3e592c2dea2facc3db494100c3d3b796549fa", upload-time = "2025-06-03T18:31:20Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0b64f7d0a6f2a739ed052ba959f7b67c677028c9566ce51997f9f90fe573ddaa", upload-time = "2025-06-03T18:31:26Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.7.1%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:2bb8c05d48ba815b316879a18195d53a6472a03e297d971e916753f8e1053d30", upload-time = "2025-06-03T18:31:46Z" },
]
[[package]]
@@ -3571,8 +3606,8 @@ dependencies = [
{ name = "typing-extensions", marker = "(extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra != 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra != 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/rocm6.3/torch-2.7.1%2Brocm6.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:73b7eb3777ffe6b73bf9881686dd659bd71231ac87ac3696d2477e2fe0c036fc" },
- { url = "https://download.pytorch.org/whl/rocm6.3/torch-2.7.1%2Brocm6.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0c10342f64a34998ae8d5084aa1beae7e11defa46a4e05fe9aa6f09ffb0db37" },
+ { url = "https://download-r2.pytorch.org/whl/rocm6.3/torch-2.7.1%2Brocm6.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:73b7eb3777ffe6b73bf9881686dd659bd71231ac87ac3696d2477e2fe0c036fc", upload-time = "2025-06-03T18:34:51Z" },
+ { url = "https://download-r2.pytorch.org/whl/rocm6.3/torch-2.7.1%2Brocm6.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b0c10342f64a34998ae8d5084aa1beae7e11defa46a4e05fe9aa6f09ffb0db37", upload-time = "2025-06-03T18:34:52Z" },
]
[[package]]
@@ -3639,10 +3674,10 @@ dependencies = [
{ name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "extra == 'extra-8-invokeai-cpu' or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b5fa7044bd82c6358e8229351c98070cf3a7bf4a6e89ea46352ae6c65745ef94" },
- { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:433cb4dbced7291f17064cea08ac1e5aebd02ec190e1c207d117ad62a8961f2b" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e0cbc165a472605d0c13da68ae22e84b17a6b815d5e600834777823e1bcb658", upload-time = "2025-06-03T18:37:22Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:9482adee074f60a45fd69892f7488281aadfda7836948c94b0a9b0caf55d1d67", upload-time = "2025-06-03T18:37:22Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b5fa7044bd82c6358e8229351c98070cf3a7bf4a6e89ea46352ae6c65745ef94", upload-time = "2025-06-03T18:37:22Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.22.1%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:433cb4dbced7291f17064cea08ac1e5aebd02ec190e1c207d117ad62a8961f2b", upload-time = "2025-06-03T18:37:22Z" },
]
[[package]]
@@ -3663,10 +3698,10 @@ dependencies = [
{ name = "torch", version = "2.7.1+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-8-invokeai-cuda' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:92568ac46b13a8c88b61589800b1b9c4629be091ea7ce080fc6fc622e11e0915" },
- { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:85ecd729c947151eccea502853be6efc2c0029dc26e6e5148e04684aed008390" },
- { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f64ef9bb91d71ab35d8384912a19f7419e35928685bc67544d58f45148334373" },
- { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:650561ba326d21021243f5e064133dc62dc64d52f79623db5cd76637a9665f96" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:92568ac46b13a8c88b61589800b1b9c4629be091ea7ce080fc6fc622e11e0915", upload-time = "2025-06-03T18:37:28Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp311-cp311-win_amd64.whl", hash = "sha256:85ecd729c947151eccea502853be6efc2c0029dc26e6e5148e04684aed008390", upload-time = "2025-06-03T18:37:28Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f64ef9bb91d71ab35d8384912a19f7419e35928685bc67544d58f45148334373", upload-time = "2025-06-03T18:37:28Z" },
+ { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.22.1%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:650561ba326d21021243f5e064133dc62dc64d52f79623db5cd76637a9665f96", upload-time = "2025-06-03T18:37:28Z" },
]
[[package]]
@@ -3689,8 +3724,8 @@ dependencies = [
{ name = "torch", version = "2.7.1+rocm6.3", source = { registry = "https://download.pytorch.org/whl/rocm6.3" }, marker = "(extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra != 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (extra != 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm')" },
]
wheels = [
- { url = "https://download-r2.pytorch.org/whl/rocm6.3/torchvision-0.22.1%2Brocm6.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c150162c2e1de371e5a52c0eb4a98541f307e01716cfe5c850f25c7caa3d3fc4" },
- { url = "https://download-r2.pytorch.org/whl/rocm6.3/torchvision-0.22.1%2Brocm6.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0dce205fb04d9eb2f6feb74faf17cba9180aff70a8c8ac084912ce41b2dc0ab7" },
+ { url = "https://download-r2.pytorch.org/whl/rocm6.3/torchvision-0.22.1%2Brocm6.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c150162c2e1de371e5a52c0eb4a98541f307e01716cfe5c850f25c7caa3d3fc4", upload-time = "2025-06-03T18:37:32Z" },
+ { url = "https://download-r2.pytorch.org/whl/rocm6.3/torchvision-0.22.1%2Brocm6.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0dce205fb04d9eb2f6feb74faf17cba9180aff70a8c8ac084912ce41b2dc0ab7", upload-time = "2025-06-03T18:37:32Z" },
]
[[package]]