diff --git a/AGENTS.md b/AGENTS.md index 5552556..209425a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,18 +5,23 @@ These rules apply to AI-assisted work in this repository. `CONTRIBUTING.md` is t ## Scope And Runtime Boundary - Keep each change focused on a diagnosed problem. Trace the existing call path and tests before editing, and remove incidental generated files from the diff. -- Hugging Face Diffusers and Modular Diffusers are MoDiff's only supported model-execution layer. Do not add an alternate graph executor, hosted inference provider, independent Transformers application, or another model driver. -- Supporting libraries used by Diffusers and ordinary deterministic media processing are not alternate drivers. They must remain narrowly scoped, documented, and covered by tests. +- MoDiff may execute models through an official library maintained and published by Hugging Face. A Hub repository, organization name, or compatible API is not enough: verify the library's upstream ownership and package provenance, then declare and review the exact integration in MoDiff's executable dependency contract. +- All supported libraries run locally behind MoDiff's existing node graph, resource management, Auto/Expert, file, and security boundaries. Do not add an alternate graph executor, hosted inference provider, browser-side runtime, or library-owned workflow representation. +- Keep nodes and client contracts task- or modality-generic. Library- and model-specific loading, parameter aliases, and output normalization belong in small backend adapters selected from a declared execution specification, not new model-named nodes or frontend branches. +- Transformers is an optional runtime, not a default application dependency. Do not install it during startup, registry discovery, template browsing/opening, or Auto planning. A workflow that requires it must declare a reviewed optional runtime profile, present an explicit install/consent action, and verify the installed version before becoming runnable. +- Ordinary deterministic media processing is allowed when narrowly scoped, documented, and covered by tests. - Never enable arbitrary remote Python code, mutable model revisions, or custom model execution implicitly. Trust-sensitive behavior requires an explicit operator choice and an immutable revision. - Prefer existing module, graph, configuration, error, and test patterns. Do not create a parallel workflow representation or model-loading path. -## Diffusers And Modular Diffusers +## Hugging Face Model Libraries - Keep the reviewed Diffusers revision pinned in the executable installation contract and update its compatibility test when changing it. +- Treat support for each additional official Hugging Face library as an explicit integration: document its purpose and provenance, constrain its compatible version, keep heavyweight imports lazy, and add no-download compatibility and boundary tests. +- Official-library eligibility is not blanket trust for Hub artifacts or repository code. Prefer `safetensors`; pin curated models and adapters immutably; and require a separate explicit operator decision for any reviewed remote-code path. - Use Diffusers loaders, pipelines, components, schedulers, adapters, and offload hooks instead of reimplementing upstream behavior. - Modular blocks should declare inputs, outputs, and dependencies clearly, avoid hidden cross-block state, and remain composable through `init_pipeline`. - Keep model-specific differences explicit and small. Put reusable behavior in the existing shared Diffusers modules rather than copying it into another pipeline. -- Prefer `safetensors`; document and test any unavoidable unsafe deserialization or remote-code boundary. +- For non-Diffusers libraries, put reusable behavior in the corresponding generic task module and preserve the same graph, resource, progress, cancellation, and output contracts. ## Security And Data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18412cc..04b96d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,10 @@ uv pip check --python .venv/bin/python Use the corresponding accelerator instead of `cpu` when the change affects CUDA, ROCm, or MPS. Update the compatibility manifest and public installation guidance only when the evidence supports the claim. MoDiff deliberately has no `uv.lock`; do not generate one or describe the top-level requirements files as a cross-platform lock. +MoDiff may integrate model libraries officially maintained and published by Hugging Face, but each library remains a separately reviewed execution dependency. Verify its upstream ownership, package provenance, license, supported version, loading behavior, and remote-code boundary. Hub hosting alone does not establish that a library or model is maintained by Hugging Face. Keep execution in the existing backend graph and expose task-generic contracts to the client. + +Transformers-specific execution is opt-in. Do not add Transformers to the base application environment or install an optional runtime merely because a template is viewed, nodes are discovered, or Auto compatibility is planned. A requiring workflow must identify its versioned optional runtime, show an explicit install/consent action, perform installation outside graph execution, and re-run package and compatibility checks before the workflow can run. + ## Validation The baseline backend checks are: diff --git a/SECURITY.md b/SECURITY.md index 2850e8f..d46bd07 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,12 +13,15 @@ Do not expose MoDiff directly to an untrusted LAN, the public internet, a shared MoDiff is designed to execute Python and model code: - Custom-module installation can clone a Git repository or copy a local directory into `custom/`, then import it into the live registry. -- Enabling `trust_remote_code`, and custom Modular Diffusers block paths that require remote code, can execute Python supplied by a model repository. MoDiff requires an exact 40-character commit revision for these remote custom paths; do not weaken that check to accept moving branches or tags. +- Reviewed model-execution libraries maintained by Hugging Face run in the backend process with the same filesystem, network, CPU, and accelerator access as MoDiff. Official maintenance reduces neither package supply-chain risk nor the need to review the selected version and integration. +- Repository-supplied Python would run with backend-process permissions. Current custom Modular Diffusers paths are `contract_only` and reject `trust_remote_code` before model construction; exact cached 40-character commits are still required for Hub contract preview. Do not weaken that fail-closed boundary or accept moving branches/tags if executable support is added later. - Model deserialization and optional native/CUDA packages have their own supply-chain and memory-safety risks. - Workflows can allocate substantial CPU, RAM, accelerator memory, disk, and network bandwidth. Install only sources you trust. Review repository ownership, code, dependencies, model licenses, and the exact revision before installation. Prefer immutable commit revisions over moving branches. Disabling a module after import does not undo code that has already run; restart the backend after changing trusted code. +A package being part of the Hugging Face ecosystem is distinct from a model being hosted on the Hub. Do not treat a Hub namespace, model card, or `trust_remote_code` implementation as first-party library code. Optional model runtimes, including Transformers, require an explicit local install action and version verification; template browsing, registry discovery, and Auto planning must remain non-installing operations. + ## Tokens and secrets The Models UI can validate a Hugging Face token and writes it to `[huggingface] token` in `config.ini`. That file is ignored by Git, but the token is plaintext and is not protected by an operating-system credential store. diff --git a/data/graphs/studio/ace-step-audio-pipeline/audio-continuation.json b/data/graphs/studio/ace-step-audio-pipeline/audio-continuation.json index 10aa895..2eee7bb 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/audio-continuation.json +++ b/data/graphs/studio/ace-step-audio-pipeline/audio-continuation.json @@ -540,7 +540,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 diff --git a/data/graphs/studio/ace-step-audio-pipeline/audio-repaint.json b/data/graphs/studio/ace-step-audio-pipeline/audio-repaint.json index 1037e9f..efe4d1c 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/audio-repaint.json +++ b/data/graphs/studio/ace-step-audio-pipeline/audio-repaint.json @@ -452,7 +452,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 diff --git a/data/graphs/studio/ace-step-audio-pipeline/audio-variation.json b/data/graphs/studio/ace-step-audio-pipeline/audio-variation.json index f37d87e..0aed573 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/audio-variation.json +++ b/data/graphs/studio/ace-step-audio-pipeline/audio-variation.json @@ -452,7 +452,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 diff --git a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-chinese-new-year-lora.json b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-chinese-new-year-lora.json index a85238f..3e32399 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-chinese-new-year-lora.json +++ b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-chinese-new-year-lora.json @@ -452,7 +452,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 @@ -1731,6 +1731,13 @@ "type": "bool", "value": true }, + "revision": { + "default": "", + "description": "Required immutable 40-character commit SHA for Hub LoRAs.", + "label": "Revision", + "type": "string", + "value": "cb829a12775740c830a6d49795f16913065dc492" + }, "scale": { "default": 0.7, "display": "slider", diff --git a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-custom-lora.json b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-custom-lora.json index e6f6d79..7f5e611 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-custom-lora.json +++ b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio--ace-step-custom-lora.json @@ -452,7 +452,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 diff --git a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio.json b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio.json index a46fb33..5a992f5 100644 --- a/data/graphs/studio/ace-step-audio-pipeline/text-to-audio.json +++ b/data/graphs/studio/ace-step-audio-pipeline/text-to-audio.json @@ -430,7 +430,7 @@ "display": "slider", "label": "Shift", "max": 10, - "min": 0, + "min": 0.1, "step": 0.1, "type": "float", "value": 3 diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-cinematic-octane-3d.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-cinematic-octane-3d.json index 1e8c4ec..6371969 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-cinematic-octane-3d.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-cinematic-octane-3d.json @@ -1732,7 +1732,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "64e2788c9d236a3e5f62323baa8853116e2a9c7df41cd2a048141eb22cd46d89" @@ -1757,6 +1757,13 @@ "type": "boolean", "value": false }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "6b32a1624d3fdfab4d518223e8311731dd432cd8" + }, "scale": { "default": 1, "display": "slider", @@ -1831,7 +1838,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "cad317378978ba03438c9f00a4fa5ef0628c4a65937c69b8420feaee5e780f81" @@ -1856,6 +1863,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "3ab70503ba8df37565d0212e2876ec48e35e7cb4" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-film-noir.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-film-noir.json index 9bc8e57..d7e9ded 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-film-noir.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-film-noir.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "2970393ce5376e982a594808c1ff0a87f9cec5ddc0da2c094d2a4305d4079324" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "7a7ff13bbae807a2db6c2e4918f3e13bb1265e60" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-ghibli-story.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-ghibli-story.json index 15fe992..2ff5f91 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-ghibli-story.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-ghibli-story.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "5216bd7eeb12bf6f18cd5d40cb090831796b28aca7446577d08c5a7e4a09dc63" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "ed846114c71efc525e7f5a51e274dc976bb970a8" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-oil-painting.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-oil-painting.json index b7d240b..b16e5a0 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-oil-painting.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-oil-painting.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "6de4e6d451ad7690db7185cf84235bf9c15c80aaeb69793fb6647fab62bdd704" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "1118ed195c7304ffc52a6cd42b41a520ef749cd9" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-paper-cutout.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-paper-cutout.json index 3f2ddd5..8304972 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-paper-cutout.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-paper-cutout.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "1863f382199698b8b756d98ecd8060698d5928ed30009cd16f0531f142e4057b" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "5cdd7ac47ad1b99f705ac2a03a39d480d34abb5d" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-photoreal-documentary.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-photoreal-documentary.json index b3770e6..a659738 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-photoreal-documentary.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-photoreal-documentary.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "0a83a924b822b70b5e458d27935ebfa7713edaee04ff9f194209525354031eca" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "1965e17d2e745fcbf8f4004bdbdf603421ef37a8" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-retro-comic.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-retro-comic.json index 18aaecf..2200c03 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-retro-comic.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-retro-comic.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "af31beee9ea67955d36425f25624d2585fa31271680013e5c9731690aeb78f9d" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "46f73222df6c97c9f56c3bef42a11979ba5d5aeb" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-watercolor.json b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-watercolor.json index 812563f..7fc0723 100644 --- a/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-watercolor.json +++ b/data/graphs/studio/flux-dev-pipeline/text-to-image--flux-lora-watercolor.json @@ -1710,7 +1710,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "e63e44417df35456f425329ad4334143a8bc9fd10316345730ade434106b050e" @@ -1735,6 +1735,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "a565b2140a05f1eece244514f90d2e29b3b1d45d" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/edit-image.json b/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/edit-image.json index f48910e..04aa9a7 100644 --- a/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/edit-image.json +++ b/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/edit-image.json @@ -674,7 +674,7 @@ "params": { "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required for Hub weights; local weights are hashed when this descriptor is created.", "label": "Expected SHA-256", "type": "string", "value": "22226e8d05d354bb356627d428809f5afd7819399b077238a2b70a82883a904f" @@ -688,18 +688,6 @@ "model": { "display": "modelselect", "fieldOptions": { - "filter": { - "hub": { - "className": [ - "" - ] - }, - "local": { - "className": [ - "" - ] - } - }, "noValidation": true, "sources": [ "hub", @@ -713,6 +701,13 @@ "value": "lightx2v/Qwen-Image-Edit-2511-Lightning" } }, + "revision": { + "default": "", + "description": "Required exact commit for a Hub LoRA; unused for a local Safetensors file.", + "label": "Revision", + "type": "string", + "value": "d74eba145674fd7e31b949324e148e21e7118abd" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/multi-image-reference-edit.json b/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/multi-image-reference-edit.json index 6a61174..5c38361 100644 --- a/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/multi-image-reference-edit.json +++ b/data/graphs/studio/qwen-image-edit-plus-modular-pipeline/multi-image-reference-edit.json @@ -675,7 +675,7 @@ "params": { "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required for Hub weights; local weights are hashed when this descriptor is created.", "label": "Expected SHA-256", "type": "string", "value": "22226e8d05d354bb356627d428809f5afd7819399b077238a2b70a82883a904f" @@ -689,18 +689,6 @@ "model": { "display": "modelselect", "fieldOptions": { - "filter": { - "hub": { - "className": [ - "" - ] - }, - "local": { - "className": [ - "" - ] - } - }, "noValidation": true, "sources": [ "hub", @@ -714,6 +702,13 @@ "value": "lightx2v/Qwen-Image-Edit-2511-Lightning" } }, + "revision": { + "default": "", + "description": "Required exact commit for a Hub LoRA; unused for a local Safetensors file.", + "label": "Revision", + "type": "string", + "value": "d74eba145674fd7e31b949324e148e21e7118abd" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/qwen-image-modular-pipeline/control-image.json b/data/graphs/studio/qwen-image-modular-pipeline/control-image.json index ac0ec35..680843d 100644 --- a/data/graphs/studio/qwen-image-modular-pipeline/control-image.json +++ b/data/graphs/studio/qwen-image-modular-pipeline/control-image.json @@ -614,6 +614,12 @@ "type": "string", "value": "none" }, + "revision": { + "description": "Required exact lowercase 40-character commit hash for every Hub component.", + "label": "Revision", + "type": "string", + "value": "b13036f066d6dee7c20513e263d3d673055e9de8" + }, "subfolder": { "label": "Subfolder", "type": "string", diff --git a/data/graphs/studio/zimage-modular-pipeline/text-to-image--fast-lora.json b/data/graphs/studio/zimage-modular-pipeline/text-to-image--fast-lora.json index 8f247c8..a225ace 100644 --- a/data/graphs/studio/zimage-modular-pipeline/text-to-image--fast-lora.json +++ b/data/graphs/studio/zimage-modular-pipeline/text-to-image--fast-lora.json @@ -1709,7 +1709,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "1fe0487cfe69b31f6d93ec1a1a6e49f75e9ff77adc8d845380ba7352a3931190" @@ -1734,6 +1734,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "fd6d52d1199ad47f73c18db58339ad01ef766fa7" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/graphs/studio/zimage-modular-pipeline/text-to-image--z-image-lora-style.json b/data/graphs/studio/zimage-modular-pipeline/text-to-image--z-image-lora-style.json index 671b011..f706d0e 100644 --- a/data/graphs/studio/zimage-modular-pipeline/text-to-image--z-image-lora-style.json +++ b/data/graphs/studio/zimage-modular-pipeline/text-to-image--z-image-lora-style.json @@ -1709,7 +1709,7 @@ }, "expected_sha256": { "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", "label": "Expected SHA-256", "type": "string", "value": "1fe0487cfe69b31f6d93ec1a1a6e49f75e9ff77adc8d845380ba7352a3931190" @@ -1734,6 +1734,13 @@ "type": "boolean", "value": true }, + "revision": { + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + "label": "Revision", + "type": "string", + "value": "fd6d52d1199ad47f73c18db58339ad01ef766fa7" + }, "scale": { "default": 1, "display": "slider", diff --git a/data/model-artifact-catalog.json b/data/model-artifact-catalog.json index 81de116..86d60a8 100644 --- a/data/model-artifact-catalog.json +++ b/data/model-artifact-catalog.json @@ -16,6 +16,8 @@ "qualificationEvidence": {"status": "unqualified", "source": "catalog-review-required"} }, "repositoryPins": [ + {"repo": "h94/IP-Adapter", "revision": "018e402774aeeddd60609b4ecdb7e298259dc729", "license": "apache-2.0", "purpose": "sdxl-ip-adapter", "weightName": "sdxl_models/ip-adapter_sdxl.safetensors", "sha256": "ba1002529e783604c5f326d49f0122025392d1d20ac8d573b3eeb3e6dea4ebb6", "byteSize": 702585376, "imageEncoderSubfolder": "models/image_encoder", "imageEncoderClass": "CLIPVisionModelWithProjection", "verifiedAt": "2026-08-12T00:00:00+05:30"}, + {"repo": "nateraw/real-esrgan", "revision": "42efb9c3eeed1f5c0c8a626cf5f7f4481dfbb094", "license": "bsd-3-clause", "purpose": "controlled-video-delivery-upscaler", "weightName": "RealESRGAN_x2plus.pth", "sha256": "49fafd45f8fd7aa8d31ab2a22d14d91b536c34494a5cfe31eb5d89c2fa266abb", "byteSize": 67061725, "verifiedAt": "2026-08-12T00:00:00+05:30"}, {"repo": "diffusers/FLUX.2-klein-4B-modular", "revision": "62ac375aa5308588f111fcd12115f5c54a8b1f4f", "license": "not-declared", "purpose": "reviewed-dynamic-modular-block", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "lllyasviel/FramePackI2V_HY", "revision": "86cef4396041b6002c957852daac4c91aaa47c79", "license": "not-declared", "purpose": "framepack-transformer", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "hunyuanvideo-community/HunyuanVideo", "revision": "e8c2aaa66fe3742a32c11a6766aecbf07c56e773", "license": "not-declared", "purpose": "framepack-base-components", "verifiedAt": "2026-08-03T14:34:36+05:30"}, @@ -24,10 +26,12 @@ {"repo": "stabilityai/stable-audio-open-1.0", "revision": "f21265c1e2710b3bd2386596943f0007f55f802e", "license": "other", "purpose": "built-in-diffusers-audio-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "stabilityai/stable-diffusion-xl-base-1.0", "revision": "462165984030d82259a11f4367a4eed129e94a7b", "license": "openrail++", "purpose": "built-in-modular-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers", "revision": "b184e23a8a16b20f108f727c902e769e873ffc73", "license": "apache-2.0", "purpose": "built-in-modular-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, + {"repo": "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", "revision": "17c30769b1e0b5dcaa1799b117bf20a9c31f59d7", "license": "apache-2.0", "purpose": "built-in-modular-workflow-variant", "verifiedAt": "2026-08-12T00:00:00+05:30"}, {"repo": "Wan-AI/Wan2.2-T2V-A14B-Diffusers", "revision": "5be7df9619b54f4e2667b2755bc6a756675b5cd7", "license": "apache-2.0", "purpose": "built-in-diffusers-video-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "Wan-AI/Wan2.2-Animate-14B-Diffusers", "revision": "6f4df10861c758af86ac3c979aacc1bf5c03eff0", "license": "apache-2.0", "purpose": "built-in-diffusers-video-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "Lightricks/LTX-2", "revision": "47da56e2ad66ce4125a9922b4a8826bf407f9d0a", "license": "other", "purpose": "built-in-diffusers-video-default", "verifiedAt": "2026-08-03T14:34:36+05:30"}, {"repo": "Lightricks/LTX-Video", "revision": "8984fa25007f376c1a299016d0957a37a2f797bb", "license": "other", "purpose": "verified-diffusers-fallback", "verifiedAt": "2026-08-03T14:34:36+05:30"}, + {"repo": "InstantX/Qwen-Image-ControlNet-Union", "revision": "b13036f066d6dee7c20513e263d3d673055e9de8", "license": "apache-2.0", "purpose": "qwen-image-controlnet-component", "verifiedAt": "2026-08-09T23:24:38+05:30"}, {"repo": "fuliucansheng/FLUX.1-Canny-dev-diffusers", "revision": "24df2ba1c46a8c735589cc8f81302678797c9821", "license": "other", "purpose": "verified-diffusers-repair-source", "verifiedAt": "2026-08-03T14:34:36+05:30"} ], "models": [ diff --git a/data/workflow-library-manifest.json b/data/workflow-library-manifest.json index a1a1279..862312c 100644 --- a/data/workflow-library-manifest.json +++ b/data/workflow-library-manifest.json @@ -30,7 +30,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/audio-continuation.json", - "graphHash": "608019534542c088213f7731371dbd66dea934a7e674b459fb1761312d8120a5", + "graphHash": "05cb3a3f0112e3d42164e5856201f8db946342522da5060a6ae6cc9e4f60425d", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -61,7 +61,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/audio-repaint.json", - "graphHash": "f7f833936df140b8359ac9c4196da77bea45ed0745f856cba2512197b738e6aa", + "graphHash": "99ae2ca9631dbf432533cbea763610633649df533d4990827c186cb45445651d", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -92,7 +92,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/audio-variation.json", - "graphHash": "15bd8c5a42e71187a58f316b0d2d8da4b13723e332b543771a4b787bd3b98fc3", + "graphHash": "ebacf59f00b11b572ec534d2688fbaea0909851e790cb35f1c9d59d22f3b541a", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -118,7 +118,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/text-to-audio.json", - "graphHash": "873de53752af7d72b5f2dad14b69d9ad2849184d1b113e5609d1ea5094387027", + "graphHash": "bc4e49d3d34841856f829069055927ac1d29f1f00d11450724b58cb9aa9d697f", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -145,7 +145,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/text-to-audio--ace-step-chinese-new-year-lora.json", - "graphHash": "049f1fe23c6512ab2df2c1d6ceb35c090bcaeab60e4fcd0a50b8b9f707cf38e8", + "graphHash": "024321ec98fecde987bcc3f83b4265cf9c4bf0c4e5ca409ddc84786ae99a4c05", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -172,7 +172,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/ace-step-audio-pipeline/text-to-audio--ace-step-custom-lora.json", - "graphHash": "d2e0a8a4b1f163fdd47d6114db20d39befd37409df11be1cad5f162f26856452", + "graphHash": "affc73c7ac0417c050c274da7074444a422b916c5ca7d43bafce8f37ed18d4a3", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -366,7 +366,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-cinematic-octane-3d.json", - "graphHash": "58d9abf91967ffff73929675c7283fd6f0aff653512f457b4a3d78488468bfdf", + "graphHash": "d1dd5049c0435a6b0cf03c35780fb24e8bf17e25a865ac4991d26fb9472dc0fd", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -394,7 +394,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-film-noir.json", - "graphHash": "0d92e0e3d02176879416e29520b7cdc8c997d449b0e30e4652f551027087d13f", + "graphHash": "830c6ce5e408b19abbb18e1c278341afbcd9e56f2765b69ddf14731f95be64dd", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -422,7 +422,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-ghibli-story.json", - "graphHash": "e6ef11f7cadcec474024cb07a948343fa34cb1a54d4f2e3b7d4c5e9f8b2e895a", + "graphHash": "2afa48e5cf4dcf4fcf8e56b1ad12b94a8e4f8cb550475898f9aeca5b5be8664f", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -450,7 +450,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-oil-painting.json", - "graphHash": "488578d3ca4d7b0d0da7f6503ceffb8b14fd51c0c81cb22a0ab43a5254001ba3", + "graphHash": "8c9fa44a5d86bba5209153a96ad36b00a9a6fa5ab30168fb035dfa1347cf2586", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -478,7 +478,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-paper-cutout.json", - "graphHash": "3bf412d2ceeab67b4f590d0abb546896b7ec545e26cabe2e1d1c35a1b7e016ee", + "graphHash": "94f55212d766dfba5af677f079ebfcf02bd91a2bd2a494dfdf59c6b4bbdd3581", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -506,7 +506,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-photoreal-documentary.json", - "graphHash": "e7771c98041ead543c76268abe7b416c062f3ed6da4cadf290b3cc9872113f03", + "graphHash": "1b2df13428ada60067e80140e623a435cc3fa88e74041b1fabba9e4f55f507db", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -534,7 +534,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-retro-comic.json", - "graphHash": "59388664fbb42f11cad923cc75667d141c94a1ac21edb8e2f9c5b52ac1958256", + "graphHash": "96d21dc1253bd15fc870ca2309df2ea86ec3170f513b538b2dd0b09b4480cae4", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -562,7 +562,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/flux-dev-pipeline/text-to-image--flux-lora-watercolor.json", - "graphHash": "7aa3399589a63d0e07513e65da642c34e689966c12ad0f9fa6dca8c152d4629d", + "graphHash": "4c0beaeadd956ef6a7b4b15c6393b4043407dd64a7d4e1d1332605b3eecc005e", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -982,7 +982,8 @@ "supportTier": "supported", "qualificationStatus": "graph-qualified", "requiredArtifacts": [ - "Qwen/Qwen-Image-Edit-2511" + "Qwen/Qwen-Image-Edit-2511", + "lightx2v/Qwen-Image-Edit-2511-Lightning" ], "requiredInputs": [], "pipelineClasses": [ @@ -992,7 +993,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/qwen-image-edit-plus-modular-pipeline/edit-image.json", - "graphHash": "b05acbd7c0aad0d12e7a27dca09d8ee74ed4041e97b475c0b890ca83bd37bc88", + "graphHash": "e0c8ab087a29620ca03cae6dda2b85d89469362fbfe919e5a5e5f4c18484d55c", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -1008,7 +1009,8 @@ "supportTier": "supported", "qualificationStatus": "graph-qualified", "requiredArtifacts": [ - "Qwen/Qwen-Image-Edit-2511" + "Qwen/Qwen-Image-Edit-2511", + "lightx2v/Qwen-Image-Edit-2511-Lightning" ], "requiredInputs": [], "pipelineClasses": [ @@ -1018,7 +1020,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/qwen-image-edit-plus-modular-pipeline/multi-image-reference-edit.json", - "graphHash": "2d274f95571187c456d6a84e91e1998a89ec4e282e0fb98543951ee4c5c70e0e", + "graphHash": "082221ac8c8f58f66c31856ab891ca5988843eef01aa543f4b48e578c5f345af", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -1060,7 +1062,8 @@ "supportTier": "supported", "qualificationStatus": "graph-qualified", "requiredArtifacts": [ - "Qwen/Qwen-Image-2512" + "Qwen/Qwen-Image-2512", + "InstantX/Qwen-Image-ControlNet-Union" ], "requiredInputs": { "modelRequirements": [ @@ -1068,6 +1071,7 @@ "id": "qwen-controlnet-union", "label": "Qwen ControlNet Union", "repo": "InstantX/Qwen-Image-ControlNet-Union", + "revision": "b13036f066d6dee7c20513e263d3d673055e9de8", "kind": "controlnet", "requiredForModes": [ "control_image" @@ -1088,7 +1092,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/qwen-image-modular-pipeline/control-image.json", - "graphHash": "250adc9a5d838246e2ea1a842a6cd341780fdae88db86aaa17bfa2fb84f3778f", + "graphHash": "18659db5a7e83c5a0d38072491ece1833fcd03e5dc49412ff443c66390286492", "graphQualificationStatus": "graph-qualified", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -1438,7 +1442,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/zimage-modular-pipeline/text-to-image--fast-lora.json", - "graphHash": "979c5a1f191415d7880537d573c957d945d0c3545ec768171c59c3c41666d91c", + "graphHash": "a9c8044ebd36e25c6af6063221dab5b14922b826990a480385430481b6978c9a", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", @@ -1466,7 +1470,7 @@ "minimumAppVersion": "0.2.0", "minimumBackendVersion": "0.2.0", "graphPath": "studio/zimage-modular-pipeline/text-to-image--z-image-lora-style.json", - "graphHash": "135600b9beeb31a2b04be53133513f1cd0ec7356be58401d7102c9a4e03e3645", + "graphHash": "50895ad6b9bc1d73be8446a08fea990c7287b966553321bf39d376fb39a0beac", "graphQualificationStatus": "graph-qualified-gallery-review-pending", "runtimeQualificationStatus": "unqualified", "optimizationQualificationStatus": "unqualified", diff --git a/docs/README.md b/docs/README.md index e9e4c7b..83d9b44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ This directory contains the durable technical guides for the MoDiff backend. Sta | Compare the qualified accelerator profiles and their proof levels | [Runtime support matrix](runtime-support-matrix.md) | | Review optional attention, quantization, and compilation capabilities | [Optional runtime optimizations](optional-runtime-optimizations.md) | | Build Modular Diffusers graphs and understand experimental compatibility | [Modular Diffusers guide](../modules/ModularDiffusers/README.md) | +| Track Diffusers, Modular Diffusers, speech, testing, and asset work | [Hugging Face integration roadmap](hugging-face-integration-roadmap.md) | | Review the Hugging Face-derived engineering and runtime requirements | [Hugging Face engineering alignment](hugging-face-standards.md) | | Review inherited source baselines and per-file modification notices | [Source provenance map](source-provenance.md) | | Contribute code, nodes, dependencies, or client-facing changes | [Contributing](../CONTRIBUTING.md) | diff --git a/docs/api-reference.md b/docs/api-reference.md index 584c75d..2485e51 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -21,12 +21,13 @@ resolve to loopback. | WebSocket | `GET /ws` | Session handshake, queue restoration, progress/events, field signals, and node updates. | | Registry | `GET /nodes` | Return the live registered node contracts used by the bundled client. | | Execution | `POST /graph`, `GET /queue`, `GET /runs/{task_id}`, `DELETE /queue/{task_id}`, `POST /stop` | Queue, inspect, remove, or interrupt graph work. A normally supervised backend replaces its worker when a blocking model call misses the cancellation grace period. | -| Node state | `POST /fields/action`, `GET /cache/{node}/{field}[/{index}]`, `DELETE /cache` | Run dynamic field actions and access/clear node cache values. | +| Node state | `POST /fields/action`, `GET /cache/{node}/{field}[/{index}]`, `DELETE /cache` | Run declared dynamic field actions and access/clear statically declared media or text cache fields. Connector, process-local, and other opaque outputs are not cache-servable. | | Files and graphs | `GET /listdir`, `GET /listgraphs`, `GET /file`, `POST /file`, `GET /preview`, `GET /stream` | Browse the configured working directory, load/save graph files, upload media, and stream previews. | | Saved workflows | `GET /workflows`, `GET/PUT/DELETE /workflows/{workflow_id}` | List, read, replace, or delete versioned workflow records below the configured data directory. | | Media I/O | `GET /media/capabilities`, `/media/probe`, `/media/export`, `/media/preview` | Inspect a managed media identifier or return a cached, converted download/browser preview through the built-in deterministic media tools. | | Runtime | `GET /health`, `/runtime/status`, `/runtime/resources`, `/runtime/options`, `/system_stats`, `/runtime/gpu_processes`; `POST /runtime/gpu_cleanup` | Read readiness, resource, option, and hardware state or request best-effort runtime cleanup. | -| Optimizations | `GET /runtime/optimizations`, `/jobs/{job_id}`, `/receipts`; `POST /runtime/optimizations/install`, `/activate`, `/rollback`, `/enable`, `/probe`, `/qualify` | Stage, validate, select, roll back, and qualify optional app-managed runtime packages and record their local evidence. | +| Optimizations | `GET /runtime/optimizations`, `/jobs/{job_id}`, `/receipts`; `POST /runtime/optimizations/install`, `/activate`, `/rollback`, `/enable`, `/probe`, `/qualify`, `/jobs/{job_id}/cancel` | Inspect runtime features and legacy package contracts, manage recovery, and record bounded local qualification evidence. Hashless package install and activation are unavailable. | +| Optional model runtimes | `GET /runtime/optional-runtimes`, `/jobs/{job_id}`; `POST /runtime/optional-runtimes/install`, `/activate`, `/rollback`, `/jobs/{job_id}/cancel` | Publish the reviewed optional-library contract and its fail-closed staged lifecycle. The current candidate exposes no executable install or activation action. | | Auto resource | `POST /auto_resource/plan`, `POST /auto_resource/plans`, `GET /auto_resource/history`, `DELETE /auto_resource/history` | Plan hardware-aware model recipes and manage local planner history. | | Models | `GET /model_capabilities`, `/model_artifact_catalog`, `/model_fingerprints`, `/local_models`, `/hf_cache`, `/model_cache/diagnostics`, `/hf_hub`; `POST /hf_download`, `/hf_token`; `DELETE /hf_cache/{hash}` | Discover, diagnose, download, authenticate, fingerprint, and delete model artifacts. | | Media lifecycle | `GET /media_assets`, `DELETE /media_assets` | Inspect temporary media records or remove exact unpinned, task-scoped, or age-scoped files while no generation is active. | @@ -74,6 +75,134 @@ substituting capacity used. `GET /runtime/options` returns the live node option device and package compatibility filtering. Both are observations, not proof that a real model workload completed. +### Contract-only Diffusers capabilities + +`GET /model_capabilities` publishes reviewed but unqualified generic adapters +under `experimentalCapabilities`. A record whose `qualificationStatus` is +`contract_only` identifies one exact registered Diffusers pipeline class and +execution kind, its generic `backendPath`, immutable +`defaultRepo`/`revisionCandidates`, exact `runnableModes`, backend-owned +parameter aliases, and mode input contracts. +It also reports `autoEligible: false`, `templateEligible: false`, and +`galleryEligible: false`. + +Contract-only means that the loader/action and reviewed state-flow or fake-call +contract, as applicable, are present; it is not evidence of a completed model +run. These records have no +`executionProfiles` or `optionalRuntimeRequirement`, do not enter the primary +supported `capabilities` list, and cannot be selected by Auto. Expert users can +still inspect the same generic node fields from `/nodes`. Templates, resource +qualification, live media, and Gallery publication require later graph and +remote qualification gates. + +### Studio execution specifications + +For migrated exact pairs, `GET /model_capabilities` publishes a +`studioExecutionSpecSchemaVersion: 1` marker, a bounded +`studioExecutionSpecModes` list, and one or more `studioExecutionSpecs` beside +the pair's `executionProfiles`. The mode list must match the specification modes +exactly: claimed modes fail closed when their specification is absent or +malformed, while unclaimed sibling modes remain on the reviewed migration path. +The root response also includes the same specification catalog. Each +specification binds one exact model/mode pair to its execution-profile ID, +loader module/action, +execution path, pipeline class, default repository, generic graph roles and +positions, typed edges, form bindings, ordered dynamic actions, and declared +Auto override fields. `contentHash` is the canonical +`studio-spec-v1-<8 lowercase hex>` checksum of every semantic field. + +The server validates every node, parameter, input/output handle, connection +type, binding, and graph component against the live `/nodes` registry before +publishing the response. Unknown, incompatible, duplicate, or disconnected +contracts fail the capability request instead of falling back to a client +recipe. A managed submission includes a bounded +`runtimeHints.studioExecutionSpec` receipt containing exactly +`schemaVersion`, `id`, `contentHash`, and the specification role-to-node-ID +map. Graph admission checks that those exact nodes are executable and that all +declared edges and bindings remain present. The receipt and checksum are +consistency identifiers, not authorization tokens or live-model evidence. + +An execution profile may also publish `expert_cuda_policy` with +`schema_version: 1`. This bounded policy declares the CUDA dtypes that Studio +must block, the recommended replacement dtype, the projected offloaded and +resident VRAM budgets, and exact per-quantization resident overrides. The +policy is optional: when it is absent, the client does not infer one from a +model family or pipeline name. Studio consumes it only from the unique +execution profile named by the selected exact specification. Auto admission +continues to use the backend resource plan rather than this Expert-facing +estimate. + +An execution profile may additionally publish +`expert_quantization_policy` with `schema_version: 1`. The policy declares the +exact Expert quantization and offload modes plus the generic Modular Diffusers +quantization node and its reviewed component configuration. Studio applies the +policy only when the selected specification names that exact execution profile; +direct execution paths continue to derive their required loader fields from the +specification bindings. Missing, malformed, or registry-incompatible policy +data never falls back to a model-family or pipeline-name rule. + +An execution profile may publish `expert_quantization_modes` as a bounded, +unique list drawn from `bnb_4bit`, `bnb_8bit`, `quanto_float8`, and +`torchao_float8`. Studio exposes only those choices for the exact selected +model-and-mode specification; absence means that no Expert quantization +selector is advertised. The client does not infer choices from a model family +or pipeline name. + +An execution profile may also publish `expert_mps_policy` with +`schema_version: 1`, a reviewed `qualification` (`unqualified` or +`experimental`), and a bounded fallback action. Studio presents this advisory +only in Expert mode on Apple MPS and only when the selected exact specification +names that profile. The policy remains non-blocking and is omitted for profiles +without a reviewed MPS advisory. + +The current schema-v1 catalog covers the migrated Flux Schnell, Dev, Krea, +Flux2 Klein, Depth, Canny, Redux, Kontext, and Fill image pairs; Wan 2.2 I2V +and TI2V; Wan 2.1 text, video, and color-edit modes; all four LTX condition +modes; all four advertised Wan VACE modes; all four ACE-Step audio modes; +direct Qwen Image and Z-Image text-to-image; Qwen Image Edit direct inpaint, +direct outpaint, and Modular `edit_image`; and Qwen Image Edit Plus Modular +`edit_image` and `multi_image_reference_edit`; Qwen Layered Modular +`layer_decomposition`; and Qwen Image Modular `control_image`. This covers all +39 currently declared execution-profile pairs. Related pairs reuse generic +image, video, or audio topologies while keeping distinct exact profiles, +artifacts, resource policies, form bindings, and receipt identities. + +The generic Diffusers image loader's schema-v1 signal also contains the exact +reviewed field overlay for its selected pipeline class and mode. Connected +generic Generate, Edit, Inpaint, and Control Generate nodes validate that whole +signal before updating optional control visibility. Switching the loader value +therefore refreshes the same generic node; clients do not infer image controls +from a model or pipeline name, and a stale or edited overlay fails closed. + +ACE-Step text-to-audio, +variation, continuation, and repaint use the generic Diffusers runtime recipe, audio +loader/generator, source-audio loader where required, and audio export nodes; +continuation additionally seals loudness matching and joining. Repaint reuses +the source-audio route with its exact task and range bindings. +Qwen Image Edit inpaint seals the reviewed direct pipeline, source-image and +mask loaders, generic inpaint node, preview route, and exact form bindings. +Outpaint reuses that reviewed loader and generator while sealing the distinct +generated canvas/mask node and all boundary-placement bindings. Modular Edit +seals the reviewed Models Loader, prompt encoder, source-image VAE encoder, +denoiser, latent decoder, preview, typed route-state edges, and exact dynamic +form bindings issued for `QwenImageEditModularPipeline`. +Qwen Image Edit Plus reuses that reviewed seven-role Modular edit contract for +both advertised modes while publishing a distinct receipt per mode and binding +the `QwenImageEditPlusModularPipeline` profile and immutable default artifact. +Qwen Layered seals its seven-role source-image, prompt, VAE encode, denoise, +decode, and preview route with the reviewed layer-count and resolution bindings. +Qwen Image Control seals an eight-role graph with the pinned Qwen ControlNet +Union component loader, control-image adapter, typed ControlNet bundle, and +route-state chain through denoise and decode. Its Hub selector and immutable +revision are bound independently from the base Qwen Image artifact. +Wan VACE text-to-video seals the reviewed direct VACE profile and shared +quantization, runtime-recipe, video loader/generator, and export route. Video +inpaint and outpaint additionally seal the source-video normalization and +aligned-mask route with their distinct reviewed mask-growth policies. +Control-to-video seals its separate control-video loader, normalization route, +and exact width, height, and frame-count bindings without adding the source or +mask branches. + ### Auto resource compatibility `POST /auto_resource/plan` and every item returned by @@ -94,6 +223,202 @@ dedicated-VRAM, or shared-memory thresholds. A client waiting for this response should report a pending compatibility check. Existing plan fields remain for execution and backward compatibility. +Every declared schema-version-2 Auto candidate, including +`selectedCandidate` and `nextCandidate`, carries one exact backend-owned loader +target: + +- `autoResourceSchemaVersion` repeats the owning plan schema for durable + history and retry binding; +- `executionProfileId` identifies the exact reviewed execution profile; +- `modelType` and `mode` identify the declared model/task pair; +- `loaderModule` and `loaderAction` identify the loader node contract; +- `executionPath` identifies the reviewed execution adapter; and +- `pipelineClass` identifies the profile's canonical pipeline implementation; +- `optionalRuntimeProfileIds` identifies the exact reviewed optional-runtime + profiles owned by that execution profile; and +- `optionalRuntimeRequirement` binds their requirement schema, delivery mode, + current-required flag, profile IDs, and execution-profile IDs; and +- `studioExecutionSpecContract`, when the pair is specification-owned, binds + the versioned graph-specification ID, content hash, and execution-profile ID; + and +- `modelDependencies` is an exact bounded list of `id`, `kind`, `repo`, and + immutable `revision` receipts for auxiliary or internally loaded model + artifacts required by that model/task pair. Pairs with none publish `[]`. + +The response `modelRequirements` map is also exact-pair data. Its keys are +`:`, and every value carries the same +`loaderModule`/`loaderAction`/`executionPath` target resolved from one unique +execution profile. A model-only aggregate is not returned because different +operations for one Studio model can intentionally use different loaders. Each +entry also carries the same exact `modelDependencies` receipt published on its +candidates. + +For an Auto graph run, `runtimeHints.autoResourceCandidateId` must equal the +selected plan `id`, and `runtimeHints.autoResourceCandidates` must contain +exactly one same-ID entry with the same execution-affecting recipe and proof +state. Auto retry plans carry `candidateId`, `modelType`, `mode`, +`loaderModule`, `loaderAction`, `executionPath`, and `pipelineClass`; the worker +resolves `candidateId` back to that bounded candidate list and applies the +canonical candidate fields. It rejects missing, duplicate, stale, cross-pair, +cross-profile, unqualified, unsupported, or unreviewed retry candidates. When +no candidate-bound retry plan is supplied, Auto ignores a submitted +`resourceRetryModes` list and derives later offload modes from the selected +exact execution profile in canonical memory-pressure order. Expert mode may +still submit its bounded `resourceRetryModes` list. The worker also ignores +client `modelFamily` and `lowVramMode` classifiers; exact model/profile identity +and the selected recipe already carry the reviewed execution facts. + +Immediately before Auto admission, the worker derives +`controlledArtifacts` from executable graph paths rather than trusting a +submitted receipt. Supported Modular, direct-image, and direct-audio LoRA nodes +are resolved through the exact Safetensors contract and contribute their +module/action, safe Hub-or-local content identity, adapter name, scale, +scheduler contract, replacement policy, and descriptor digest. Executable +Spandrel upscalers contribute their pinned Hub snapshot or rehashed local-file +identity, and the loader revalidates template-declared revision, size, and +SHA-256 immediately before loading. Soundtrack and lyric-video branches also +contribute the non-primary Diffusers pipeline's exact repository, immutable +revision, class, and descriptor digest; the selected primary loader is excluded +because its existing Auto artifact receipt already owns that identity. +Disconnected and no-op nodes do not contribute. The worker copies the derived +ordered list to the selected plan and every candidate before comparing them, +and a submitted `controlledArtifacts` field is discarded. Local absolute roots +are represented only by safe filenames/content digests and are not exposed in +public runtime events. A candidate whose readiness came from local history must +also have exact current schema-v8 history for this derived receipt; base-only +evidence is downgraded before Auto admission instead of being represented as +live proof. Qualification proof remains advisory for an otherwise valid +executable graph. Independently safe or passed candidates do not depend on that +local history check. + +Plan application considers only executable loader IDs referenced by graph +`paths`. Direct loaders must already expose the profile's exact +`pipeline_class`; modular `ModelsLoader` nodes must already expose the exact +`model_type`. Module/action equality alone, disconnected nodes, and class-name +substring inference never authorize a rewrite. A plan that matches zero exact +executable loaders, or whose matching loaders expose none of its requested +resource fields, fails closed. An exact target whose requested fields already +have the planned values is a valid idempotent application. + +Auto admission and retry failures use bounded, non-echoing messages with the +`auto_resource` category. Relevant stable codes include +`auto_resource_pair_undeclared`, `auto_resource_pair_mismatch`, +`auto_resource_candidate_mismatch`, and `auto_resource_target_mismatch`. +Clients should refresh Auto for these failures; structurally valid manual +configurations remain available through Expert mode. Controlled LoRA +resolution failures use the same bounded non-echoing envelope with stable code +`controlled_artifact_mismatch` and never copy a submitted repository or local +path into the public error. + +Local Auto history version 7 binds successful and failed evidence to the Auto +schema version, exact execution-profile ID, loader/path/class identity, +optional-runtime profile and delivery contract, artifact revision, optimization +recipe, specification-owned graph contract, immutable model-dependency +receipt, ordered controlled-LoRA receipt, workload shape, and runtime hardware +fingerprint. Evidence from an +older history schema or a replaced execution, optional-runtime, graph, or +model-dependency/controlled-artifact specification is retained on disk for +inspection but cannot promote a current candidate to `live_proven`. Because +the planner does not infer graph-authored controlled blocks, a nonempty +controlled-artifact history receipt is deliberately not reused by a later +base-only plan. + +A specification-owned Auto candidate requires the matching +`runtimeHints.studioExecutionSpec` receipt at execution. The receipt maps the +reviewed roles to the submitted node IDs; the backend then verifies the exact +profile, node identities, typed connections, and form bindings against the +current specification before any node executes. A legacy pair with no backend +graph specification remains outside this receipt claim. + +### Optional model runtime metadata + +Diffusers execution profiles identify their declarative dependencies in +`optional_runtime_profiles`. Auto plans, model capabilities, and file items from +`GET /listgraphs` publish the corresponding `optionalRuntimeProfileIds` and +`optionalRuntimeProfiles`; candidates carry the IDs, and the root +`GET /model_capabilities` response also publishes the complete profile catalog. +Each exact execution-profile record and each resolved Auto, capability, or +workflow item also carries `optionalRuntimeRequirement`, a version-1 object +with exactly these seven fields: + +- `schemaVersion`: literal `1`; +- `delivery`: `base` or `optional_overlay`; +- `requiredNow`: whether this exact executable contract currently requires the + app-owned overlay; +- `profileIds`: zero to 32 unique optional-runtime profile IDs; +- `executionProfileIds`: zero to 32 unique execution-profile IDs; +- `state`: `base_satisfied`, `missing`, `wrong_version`, + `present_unqualified`, `staged`, `active`, `busy_recovery_only`, + `restart_required`, `repair_required`, or `unavailable`; and +- `reason`: a bounded lowercase snake-case code. + +An item with no registered execution contract may publish empty ID arrays only +with `delivery: base` and `requiredNow: false`. An `optional_overlay` +requirement has non-empty ID arrays and becomes runnable only in `active`. +Active means the current worker and catalog both report the overlay active and +every required profile reports `contractState: qualified` and +`cutoverReady: true`. Missing, malformed, ambiguous, duplicate, oversized, or +inconsistent execution/catalog metadata resolves to `unavailable`, not active. + +The current composite Transformers + PEFT profile is contract metadata plus a +non-runnable staged-lifecycle scaffold. It reports +`contractState: candidate_unqualified`, `cutoverReady: false`, a complete +source-controlled six-target wheel lock for its ten exact `stagedRequirements`, +and unavailable install and activation actions. Each lock includes the exact +filename, official PyPI URL, SHA-256, byte size, Python target, platform, and +machine. Its metadata-only package status is `missing`, +`wrong_version`, or `present_unqualified`; unreadable distribution metadata +fails closed as `wrong_version` with `metadataState: unreadable`. These +observations do not change Auto selection, `canAutoRun`, or execution +readiness, and browsing or opening a workflow never imports, installs, or +activates the runtime. Every current Diffusers execution profile has +`delivery: base` and `requiredNow: false`; publishing an optional profile ID is +dependency metadata, not an activation gate. A future cutover must change the +authoritative exact execution profile to `optional_overlay` atomically. + +`GET /runtime/optional-runtimes` returns the same profile catalog plus bounded +overlay state, staged-environment summaries, and a redacted active job summary. +`overlay.processLoadStatus` is one of `base`, `active`, +`busy_recovery_only`, `repair_required`, or `restart_required`. Status listing +does not perform a full overlay hash or import optional packages. A live +runtime mutation gate serializes all graph admission and field actions. After +that mutation completes, persistent recovery or restart status blocks only an +exact execution contract whose `optionalRuntimeRequirement.requiredNow` is +true; base-delivered graphs and field actions remain runnable. + +`POST /graph` checks executable loader nodes referenced by `paths`, and +`POST /fields/action` checks its authorized loader module, action, and values. +The worker repeats the check immediately before execution and again before a +loader module import or field callback. `runtimeHints` are never authority for +this decision. A blocked HTTP execution returns `409` with the fixed keys +`error`, `category: optional_runtime`, +`error_code: optional_runtime_`, `message`, `recovery_hint`, and +`optionalRuntimeRequirement`. A worker failure uses the same bounded object and +may add only task/node identifiers; it does not expose tracebacks, host paths, +process details, or loader diagnostics. + +The optional-runtime mutation routes use exact JSON objects: + +- `POST /runtime/optional-runtimes/install` requires + `{ "profileId": string, "specDigest": "sha256:<64 lowercase hex>", + "consent": true }`. +- `POST /runtime/optional-runtimes/activate` additionally requires a bounded + `environmentId`. +- `POST /runtime/optional-runtimes/rollback` requires + `{ "consent": true }`. +- `POST /runtime/optional-runtimes/jobs/{job_id}/cancel` accepts an empty body + or an empty JSON object. `GET` on the same job path is read-only. + +Unknown or duplicate fields, non-object bodies, non-literal consent, malformed +identifiers/digests, oversized bodies, cross-kind jobs, and stale terminal jobs +fail closed. The current profile rejects install and activation with HTTP `409` +before reserving a lease, creating a job/staging directory, opening the +network, or starting a subprocess. A successful future activation or rollback +requires a worker restart; an unsupervised process remains +`restart_required`. It releases the completed mutation gate: base-delivered +work remains runnable, while `optional_overlay` work stays blocked until the +worker restarts into the qualified active environment. + ### Saved workflows and media The `/workflows/{workflow_id}` store is separate from the legacy file browser. @@ -118,15 +443,28 @@ secure-erasure guarantee. ### Optional runtime optimizations The optimization catalog is app-owned and compatibility-filtered; it is not a -generic package installer. `POST /runtime/optimizations/install` stages one -known capability in an isolated optional environment and returns a job with -HTTP `202`. Activation or rollback can select an environment and request a -supervised worker restart. Enablement changes local opt-in state; probing -records only a compatibility result; qualification additionally asserts that -the exact workload output was reviewed. Installation, activation, rollback, -enablement, probing, and qualification all mutate local state and must be -treated as trusted operator actions. See [Optional runtime -optimizations](optional-runtime-optimizations.md) for the support boundary. +generic package installer. Package profiles without complete immutable +artifact locks publish `canInstall: false` and `canEnable: false` and reject +install/activation before creating a job, lease, staged directory, network +request, or subprocess. Existing hashless environments are +`legacy_unqualified`, are never inserted into the worker import path, and may +only be deactivated to the base environment through the compatibility rollback +route. + +Staged environments are promoted without replacing an existing destination and +are bound to the directory identity captured by the install lease. A bounded +durable promotion journal contains only the environment ID, phase, canonical +manifest/validation digests, and timestamp. Interrupted promotion is reconciled +under the same global install lease; ambiguous or malformed states fail closed +as repair-required and are not projected as runnable environments. + +Runtime-only enablement changes local opt-in state when its capability permits +it; probing records only a compatibility result; qualification additionally +asserts that the exact workload output was reviewed. Public job and receipt +responses are fixed-schema projections and never return raw subprocess output, +commands, tokens, or absolute paths. These mutations remain trusted operator +actions. See [Optional runtime optimizations](optional-runtime-optimizations.md) +for the qualification boundary. `GET /model_artifact_catalog` returns the checked immutable Hugging Face model catalog. `?refresh=1` performs live Hub metadata lookup for the optional @@ -169,6 +507,15 @@ workflow, run identity, or canvas epoch no longer owns the visible document. The extra fields are additive so older single-document clients remain wire compatible. +Diffusers audio loaders publish an exact schema-versioned `audio_contract` +signal for the selected pipeline class and task mode. Its `fieldParams` member +is the reviewed field overlay for the generic audio `Generate` node, including +visibility, required state, task choices, and duration bounds. The receiving +field action reconstructs the canonical contract and requires an exact match +before emitting `set_field_params`; it does not trust a stored or client-edited +overlay. Clients apply that backend-authored update generically and must not +derive audio fields from pipeline or model names. + `GET /queue` is the reconnect-safe task snapshot. It includes queued work, the current task, structured node/phase progress when available, and a bounded set of compact recent terminal receipts. Current and queued graph runs retain the @@ -200,10 +547,17 @@ Uploads are written under configured data subdirectories and share the configure ## Model and code trust - `POST /hf_token` validates a token and writes it in plaintext to ignored `config.ini`. -- `POST /hf_download` accepts a JSON object with `repo_id`, optional `sid`, `repair`, `repair_source_repo_id`, and a `files` string list. It can consume substantial network, disk, RAM, and accelerator resources. +- `POST /hf_download` accepts a JSON object with `repo_id`, optional `sid`, + `repair`, `repair_source_repo_id`, a `files` string list, and an optional exact + lowercase 40-character commit `revision`. Concurrent requests for one + repository may join only when both the immutable revision and file selection + match. When `revision` is omitted for a repository in the reviewed artifact + catalog, the server selects that repository's immutable catalog revision; + uncataloged user-selected repositories retain their existing Hub behavior. It + can consume substantial network, disk, RAM, and accelerator resources. - `DELETE /hf_cache/{hash}` deletes selected cached model revisions. - `POST /custom_modules/install` accepts a Git URL or local directory, places it under `custom/`, and refreshes the live registry. Imported custom code has the backend process's permissions. -- Modular Diffusers nodes may expose `trust_remote_code`. Remote custom pipelines/blocks require explicit trust metadata and an exact 40-character commit revision; moving branches and tags are rejected. +- Modular Diffusers nodes may expose `trust_remote_code` for stored-graph compatibility, but the current backend rejects all custom Modular pipeline and Dynamic Block execution, plus standalone component loading with remote code, before upstream construction. Exact cached 40-character commits may provide bounded declarative contract previews; a preview or persisted checksum is not execution authorization. HTTP reads and mutations require a literal loopback destination and peer. Browser requests with an `Origin` header must also use a loopback `http` or `https` origin; CLI HTTP clients without an `Origin` header remain supported over loopback. WebSocket upgrades use the same destination and peer boundary, browser clients must send a loopback Origin, and native clients without one are accepted only over a loopback connection. The initial `welcome.recent` list uses the same compact receipts as `GET /queue`; full completed workflow snapshots remain available through `GET /runs/{task_id}`. The separate supervisor control server binds to `127.0.0.1` and likewise rejects non-loopback browser origins. diff --git a/docs/hugging-face-integration-roadmap.md b/docs/hugging-face-integration-roadmap.md new file mode 100644 index 0000000..0d070b3 --- /dev/null +++ b/docs/hugging-face-integration-roadmap.md @@ -0,0 +1,3630 @@ +# Hugging Face Integration Roadmap + +This document is the implementation and completion tracker for closing MoDiff's +official Diffusers, Modular Diffusers, and approved Hugging Face speech-runtime +gaps. It is a durable product roadmap rather than a claim that every upstream +pipeline is already runnable. + +The reviewed Diffusers installation remains pinned to commit +`13a7bee4878d62fccc8d25f97e480e68de96fa03`. The latest-upstream check on +2026-08-12 found [Diffusers v0.39.0](https://github.com/huggingface/diffusers/releases/tag/v0.39.0) +as the latest tagged release (release commit +`a3608b512ed7248499a44c61d954965ed9bdae4d`) and +`175fe6b2419a01db9c2ceabd01ec37d2c0305fc2` as the latest `main` commit. The +comparison inventory now uses the reviewed `main` snapshot +`175fe6b2419a01db9c2ceabd01ec37d2c0305fc2`. Re-run the inventory before +changing the Diffusers pin or marking a gap complete. + +### Upstream delta reviewed 2026-08-12 + +The five commits after the 2026-08-09 snapshot contain one model/workflow +change: upstream commit +[`7564fb0`](https://github.com/huggingface/diffusers/commit/7564fb0) adds +LTX-2.5. The other four commits are an import guard, device deduction, LoRA +bookkeeping, and NVIDIA Spark installation documentation; they add no pipeline +family. LTX-2.5 reuses the standard `LTX2Pipeline` family rather than adding a +model-named standard pipeline, but it adds execution behavior that must be +reviewed explicitly: + +- the immutable `Lightricks/LTX-2.5-Diffusers` artifact and its distinct + distilled `transformer/`, full/SFT `transformer_full/`, latent upsampler, and + stage-2 distilled-LoRA receipts; +- the reference distilled sigma schedules and both supported two-stage + generation variants, without substituting a generic step-count schedule; +- `LTX2DurationHead`, the optional Gemma-4 prompt-enhancement component, and + their bounded/explicit controls (no discovery-time model download); +- `LTX2VideoDiffusionDecoderModel` and + `LTX2VideoDiffusionDecodePipeline`, including the production-resolution + NATTEN dependency and video/audio latent handoff contract; and +- the new `LTX2ModularPipeline` and `LTX25ModularPipeline` exports, with + `LTX2AutoBlocks`/`LTX25AutoBlocks` covering text-to-video, + image-to-video, condition-to-video, and IC-LoRA/in-context selection. + +MiniMax H3 was already present in the prior inventory from upstream commit +[`f53d552`](https://github.com/huggingface/diffusers/commit/f53d552) and remains +post-pin. Its Phase 6 item now records the three separate joint video-and-audio +workflows: text-only `t2va`, first/last-keyframe `fl2va`, and omni-reference +`ref2va`. `t2va`/`fl2va` use the repository's `transformer/` partition; +`ref2va` uses `transformer_ref/`. These are future generic video+audio task +contracts, not permission to add a MiniMax-named node or enable an unqualified +artifact. + +## How to update this tracker + +- Use `[x]` only after every required backend, client, test, live-proof, and + asset item for that checkbox is complete. +- Record the backend and client commit or pull-request references in the + completion ledger. Do not create empty commits in either repository; a + backend-only segment instead records the compatible client gate that passed. +- Keep a template hidden or explicitly `qualification_pending` until its live + output and public assets meet the publication contract. +- Update this file in the same change that completes or reschedules a segment. +- Distinguish contract, mocked, tiny-model, live-output, and Gallery-asset proof. + +## Completed groundwork + +- [x] Inventory the current backend profiles, Auto requirements, nodes, graphs, + and Gallery manifest. +- [x] Compare the pinned Diffusers revision with the reviewed upstream snapshot. +- [x] Identify the initial 18 missing official Modular pipeline classes and 69 + missing standard pipeline families listed in the appendices. +- [x] Re-audit upstream through 2026-08-12 and append the two newly exported + LTX2/LTX2.5 Modular classes, bringing the current Modular gap inventory to + 20 without changing the 69-family standard-pipeline inventory. +- [x] Run the pre-change backend baseline: 627 tests and 274 subtests passed; + Ruff, dependency validation, and backend preflight passed. +- [x] Confirm the following owner decisions: + - Official libraries maintained by Hugging Face may be added as reviewed + model runtimes; Transformers speech-to-text is the first planned use. + - Transformers and other workflow-specific Hugging Face model runtimes are + optional and are not installed by the base application installer. + - No hosted inference provider or browser-side model runtime is approved. + - MoDiff will not support Mellon's configuration filename or schema. + - `modiff_pipeline_config.json` is the only MoDiff dynamic-node sidecar. + - Release media should be generated on a separate qualification machine. + - No command or model run on the current development machine may exceed 40 + minutes. + +## Non-negotiable architecture decisions + +### Generic nodes, explicit adapters + +Nodes represent tasks and media contracts, not model names. Existing generic +image, audio, and video nodes should be extended before adding another +`NodeBase` class. New generic contracts are allowed for genuinely different +semantics such as unconditional image generation, perception maps, 3D +artifacts, diffusion text, and speech recognition. + +Model-specific behavior belongs in a declarative execution specification that +records: + +- exact `(modelType, mode)` identity; +- loader and generator module/action; +- upstream pipeline class or approved Hugging Face runtime class; +- required, optional, and aliased inputs; +- normalized output contract; +- immutable model, adapter, and auxiliary revisions; +- dtype, quantization, placement, and offload constraints; +- template coverage and qualification state. + +Do not pass arbitrary form fields to a model and hope its call signature accepts +them. Generic means a stable user contract backed by validated adapters. + +The frontend follows the same rule. It should render a generic task contract +such as `control_image` from backend-declared fields and constraints, without a +Qwen-only or Flux-only graph-building branch. The backend execution +specification selects standard versus Modular loading, maps parameter aliases, +and rejects unsupported combinations. Curated templates remain model/task +specific only as data: they carry reviewed artifacts, defaults, prompts, and +evidence while reusing the same generic node and form implementations. + +### Approved Hugging Face execution boundary + +MoDiff may execute models through official libraries maintained by Hugging +Face, including Diffusers, Transformers, and future reviewed Hugging Face +libraries. This broadens the model-runtime boundary, not the graph or trust +boundary: + +- MoDiff's existing graph executor remains the only graph executor; +- every new library and task receives an explicit generic node/adapter contract; +- executable dependencies and model/auxiliary artifacts are reviewed and + immutably pinned where the source supports revisions; +- safetensors is preferred and unsafe deserialization is an explicit reviewed + exception; +- `trust_remote_code` is never enabled implicitly; +- no hosted Inference Provider or browser-side model runtime is added; +- arbitrary Hub Python is not made trusted merely because it is stored on the + Hugging Face Hub; +- workflow-specific Hugging Face libraries are installed through an explicit, + reviewed first-use action instead of the base application installation; +- file, network, input-size, output-size, resource, and cleanup limits remain in + force. + +Library ownership alone does not prove that every task or model is supported. +Each exact task still progresses through contract, fixture, live, and Auto +qualification states. Transformers automatic speech recognition and speech +translation are the first planned non-Diffusers tasks. They use a generic model +loader and transcription node, not Whisper-specific nodes, with bounded audio +and text contracts. + +### Lazy optional Hugging Face runtimes + +The base application environment must not directly depend on Transformers or +another library needed only by particular templates. An execution specification +declares its required runtime packages. When a user first tries to run a +template or workflow whose package is absent, MoDiff blocks execution and +offers an explicit install action. Merely opening a template, discovering +nodes, or requesting an Auto plan must not download or install packages. + +After confirmation, the backend stages the reviewed package set, validates it +in a fresh process, activates it atomically, restarts when required, and retains +the prior environment for rollback. The client shows download, validation, +activation, restart, failure, and rollback states. A failed or declined install +leaves the workflow unchanged and Expert-visible with a concrete missing-runtime +reason. + +Transformers and PEFT are currently direct project dependencies, and PEFT has +an unconditional Transformers dependency. They therefore move out of the base +environment together in one compatibility segment. Do not remove either until +registry discovery, preflight, existing Diffusers workflows, optional +installation, restart, and rollback all pass from a clean base installation. + +### MoDiff-only dynamic configuration + +MoDiff does not read `mellon_pipeline_config.json` and will not add a fallback +for it. Dynamic Modular configuration uses canonical upstream metadata such as +`modular_model_index.json` and `modular_config.json`. Optional MoDiff UI fields +and defaults use `modiff_pipeline_config.json` only. + +The currently curated `diffusers/FLUX.2-klein-4B-modular` example is not a +valid MoDiff dynamic-block example because its reviewed revision does not +publish `modiff_pipeline_config.json`, and an auxiliary model reference is not +immutably pinned. Remove it from the curated selector and bundled graph until a +reviewed repository satisfies the MoDiff contract. A user-selected repository +without the MoDiff sidecar receives an actionable unsupported-config error. + +### Auto is fail-closed + +Auto may run only an exact registered and qualified `(modelType, mode)` recipe. +An installed artifact, a pipeline class name, a mocked test, or a successful +lighter task is insufficient proof. Unknown or unqualified combinations remain +visible only in Expert mode with a specific reason. + +### Local 40-minute ceiling + +Every local command, download/load/inference job, and test batch must have a +wall-clock timeout of at most 40 minutes. Model smokes should be designed for a +30-minute expected maximum. Request graceful cancellation by 35 minutes and +retain 5 minutes for cleanup and diagnostics. At the hard timeout: + +1. cancel the run; +2. release managed model and accelerator resources; +3. record the candidate as not locally qualified, not as failed upstream + support; +4. move the workload to the remote qualification queue; +5. do not retry the unchanged recipe on this machine. + +Permitted local live candidates are initially limited to small DDPM/DDIM, +Consistency Models, short low-resolution SD/LCM/PAG, small Marigold, and +Whisper Tiny/Base with a short audio fixture. Release assets are still produced +on the remote machine by default. Video, long audio, AudioLDM2 quality/TTS, +large image models, and long-form workflows are remote-only. + +## Commit and asset handoff contract + +Every segment ends at a clean source-control boundary. + +1. **Backend source commit:** nodes/adapters, execution specifications, API + changes, graph contracts, focused tests, and documentation. +2. **Client source commit:** typed capability handling, Studio profile/form, + graph bridge, template metadata, readiness UX, and unit/browser tests. +3. **Integration gate:** complete backend and client checks against the paired + commits. No generated media is required at this point; public Gallery + activation remains blocked. +4. **Remote qualification:** the other machine checks out the exact two commits, + installs the reviewed profiles, runs the model, and records the model and + dependency revisions, graph hash, settings, output checks, runtime, and peak + memory. +5. **Asset publication:** reviewed media is uploaded to the public Hugging Face + Dataset. Generated images, audio, and video are not committed to Git. +6. **Activation commits:** the client commits the immutable Dataset revision, + SHA-256 manifest, rights/provenance record, review, and Gallery status. Its + generated `dist/` is mirrored into backend `web/` using the documented + process; minified files are never edited manually. + +An integration may merge before remote assets exist only when its UI says +`qualification_pending`, Auto is disabled, and no public Gallery entry implies +live proof. Asset activation is a separate committable segment. + +Broad repeatable entries such as a model-family template batch must use one +family/mode per paired commit. Add suffixed ledger rows such as `P2.2a` and +`P2.2b`; do not combine unrelated families merely because they share a phase. + +## Known integration defects that set the initial order + +### Flux Modular falsely claims ControlNet + +Before the P0.1 working-tree fix, the backend capability table said +`FluxModularPipeline` supported +`control_image`, while its node specification explicitly sets `controlnet` to +`None`. The pinned upstream `FluxAutoBlocks` exposes `text2image` and +`image2image`, not a ControlNet workflow. + +A user could encounter the contradiction in two ways: + +1. A consumer of `/model_capabilities` sees `control_image` as runnable for + `FluxModularPipeline`, even though the graph runtime cannot construct it. +2. In a generic Modular graph, connect a Modular `ControlNet` node and switch + the signalled model type to `FluxModularPipeline`. The node-definition update + receives the `None` specification and removes its control image, model, and + scale fields; the Flux denoise definition also lacks `controlnet_bundle`. The + graph becomes unwireable and can retain stale edges. A stale imported or + hand-edited graph that retains those fields reaches execution and tries to + iterate `node_config["params"]`, causing a `NoneType` failure. A full graph + may load large Flux components before reaching that failure. + +There is no checked-in `FluxModularPipeline:control_image` Studio template, so +the normal Gallery path never executes this combination. The current bundled +client also discards `experimentalCapabilities`, so it does not create a normal +Flux Modular Control button from this bad entry. The Flux Canny and Flux Depth +control templates use standard Diffusers pipelines and are not affected. +Exactly one checked-in graph contains the Modular ControlNet node: +`qwen-image-modular-pipeline/control-image.json`. It signals Qwen, whose +configuration is present, and fails only if a user manually changes that graph +to Flux. + +Git history shows that the initial Modular import commit `346c203` inherited +both the Flux option in the generic ControlNet signal map and the explicit +`controlnet: None` marker from the recorded Mellon baseline. The best-supported +interpretation is that the signal map named known models so the generic node +could reconfigure or hide itself; it was not itself intended to declare +support. Its missing symmetric execution guard was still a defect. Commit +`76bbafe` later added the public experimental capability claim. Existing tests +check registry exports and working generic contracts, but do not enforce that +every advertised mode has a non-null node specification and an upstream +workflow. The history does not record why the later claim was added; confusing +standard Flux Control pipelines with Modular Flux support is plausible but is +only an inference. + +P0.1 removes the false capability and makes both dynamic-node update and stale +graph execution return the same actionable unsupported-workflow error. The +generic signal map remains only a reconfiguration mechanism; it is not treated +as proof of a runnable workflow. + +### The curated DynamicBlock example is an incomplete Mellon-to-MoDiff migration + +MoDiff does not currently request or parse `mellon_pipeline_config.json`. +Commit `346c203` originally used Diffusers' inherited `MellonPipelineConfig` +helper with `YiYiXu/FLUX.2-klein-4B-modular`. Commit `57b9bb` introduced +`MoDiffPipelineConfig`, renamed the sidecar to `modiff_pipeline_config.json`, +and documented that legacy filenames are unsupported, but it did not replace +the example. Commit `76bbafe` switched the curated option to the pinned +`diffusers/FLUX.2-klein-4B-modular` repository, which still has Mellon UI +metadata rather than a MoDiff sidecar. + +The current revision-forwarding unit test mocks the configuration loader, so it +cannot discover that the real pinned repository lacks the requested file. This +is fixed by removing/replacing the curated example and adding repository-layout +contract coverage, not by restoring Mellon compatibility. Attribution in the +source-provenance map remains unchanged because provenance is not a runtime +compatibility promise. + +## Model-dependent node and workflow audit + +The 2026-08-07 follow-up audit checked every registered Modular pipeline against +the pinned Diffusers classes and block definitions without loading model +weights. The generic node class is shown at the top of each column; `yes` means +that the registered MoDiff specification has a non-null action contract, not +that a model has completed live qualification. + +| Registered Modular model | Encode Prompt | Image Embeddings | Image Encode | Denoise | Decode Latents | ControlNet | +| --- | --- | --- | --- | --- | --- | --- | +| `StableDiffusionXLModularPipeline` | yes | no | yes | yes | yes | yes | +| `QwenImageModularPipeline` | yes | no | yes | yes | yes | yes | +| `QwenImageEditModularPipeline` | yes | no | yes | yes | yes | no | +| `QwenImageEditPlusModularPipeline` | yes | no | yes | yes | yes | no | +| `QwenImageLayeredModularPipeline` | yes | no | yes | yes | yes | no | +| `FluxModularPipeline` | yes | no | yes | yes | yes | no | +| `FluxKontextModularPipeline` | yes | no | yes | yes | yes | no | +| `Flux2KleinModularPipeline` | yes | no | yes | yes | yes | no | +| `ZImageModularPipeline` | yes | no | yes | yes | yes | no | +| `WanModularPipeline` | yes | no | no | yes | yes | no | +| `WanImage2VideoModularPipeline` | yes | yes | yes | yes | yes | no | + +`DummyCustomPipeline` is deliberately absent from the matrix because its +actions must be discovered from the reviewed `modiff_pipeline_config.json`; it +must never inherit a built-in model's actions. An absent action is an explicit +unsupported model/action pair. The frontend must hide or disable it and the +backend must reject stale or hand-edited graphs before loading weights. + +The custom path currently uses one mutable `DummyCustomPipeline` class and one +registry slot. Loading a standard model resets that slot, while loading a +second custom repository overwrites the first. Runtime payloads restore the +first repository's ID, revision, and trust flag but not its matching sidecar, +so a later node can resolve the wrong or empty action contract. Custom support +therefore needs an immutable per-`(source, repository, revision, explicit trust +choice, sidecar hash)` identity and isolated binding before it can share the +built-in completion claim. + +The audit found the same failure family as the former Flux ControlNet defect in +the other five dynamic nodes. Their definition-update handlers can silently +clear fields for a non-null-to-null model switch, while stale execution can +dereference the missing specification. They also remove connector fields from +the returned parameter mapping in place. Built-in mappings happen to be +reconstructed, but a dynamic custom mapping can be permanently changed by the +first update. Finally, runtime component payloads are not always reconciled +against the node's selected pipeline class, so components from two model types +can reach the wrong action specification. + +The surrounding generic nodes also contain undeclared compatibility rules: + +- `Denoise` has a class-name list that controls whether width and height are + shown for image-conditioned models. +- `ModelsLoader` assumes a text encoder exists and special-cases the Wan I2V + image encoder instead of deriving required and optional components. +- `AutoModelLoader` describes a component and repository but not the owning + Modular pipeline contract. A graph built entirely from standalone component + loaders therefore cannot recover its exact action schema after transient UI + signals are lost. The execution specification must supply that identity; it + must not be guessed from a repository name. +- `Layers` publishes a hard-coded family map and omits several registered + models; missing entries must be researched rather than copied from a nearby + architecture. +- `Guider` is task-generic but does not declare model compatibility. The pinned + Diffusers revision has two guiders that MoDiff does not expose, and latest + `main` adds a third. Flux Modular specifications currently show a guider + input even though the upstream Flux Modular pipelines have no guider + component. +- `Scheduler` offers every registered scheduler to every model without an + exact compatibility contract. + +The standard task nodes have related adapter-specific gaps even though they do +not create model-specific node classes: + +- Image loading validates pipeline class and mode, but individual Generate, + Edit, Inpaint, and Control actions do not revalidate the connected adapter's + allowed mode. Changing only the pipeline-class field can also retain the Flux + Schnell repository default for Z-Image, Flux2 Klein, Flux Fill, Flux Control, + Flux Kontext, or Flux Redux. Auto and templates normally overwrite the + repository, which hid this manual-workflow defect. +- Video generation does revalidate modes, but its loader's final branch assumes + FramePack and an untagged pipeline falls back to Wan VACE. Both must fail + closed when exact recovery is impossible. +- Audio loading knows ACE-Step versus Stable Audio, but generation does not + retain and revalidate the selected mode/task contract. Stable Audio can show + ACE-specific task choices, while its own `text2audio` task is absent from the + visible choices. + +The raw Expert canvas is already structurally generic: it renders `/nodes` +metadata and executes backend `onChange` and `onSignal` actions. Studio graph +authoring is not. Its model union, profiles, role-to-node mapping, topology, +edge recipes, initialization order, form bindings, pipeline classes, and many +readiness/resource messages are selected by Flux, Qwen, Wan, Z-Image, audio, +or video branches. `/model_capabilities` schema v2 can say whether a pair is +runnable, but it cannot yet describe the nodes, handles, bindings, or ordered +dynamic actions needed to build that pair's graph. + +The target contract is a backend-owned `studioExecutionSpec`, keyed by exact +`(modelType, mode, executionProfileId)`. It references existing `/nodes` keys +and declares stable roles, nodes, edges, accepted handle aliases, form fields +and bindings, initialization actions, normalized inputs/outputs, a schema +version, and a content hash. It is configuration for the existing MoDiff graph +executor, not a second graph representation or executor. The client validates +every referenced node, parameter, and handle against `/nodes`, then materializes +the same visible editable graph. Templates carry an execution-spec reference +and reviewed overrides instead of hidden model-specific code. + +### Upstream workflow coverage discovered by the node audit + +| Family | Pinned upstream workflow surface | Current MoDiff gap or mismatch | +| --- | --- | --- | +| SDXL Modular | text-to-image, img2img, and inpaint, each with ControlNet, ControlNet Union, IP-Adapter, and combined variants (18 workflows) | All 18 pinned workflows now have exact generic action/state truth for generator continuation, typed mask/masked latents, crop overlay, exact VAE/ControlNet provenance, and process-local adapter mutation/embedding provenance. The four high-level base modes, including inpaint, are published only as `contract_only`; the combined variants remain manual generic-graph compositions rather than new model-named modes. Multi-ControlNet and multiple-IP-Adapter variants are outside the pinned 18-workflow contract. | +| Qwen Image Modular | text-to-image, img2img, inpaint, plus ControlNet versions of all three | Direct text-to-image and Modular control text-to-image are exposed. The generic main-VAE image/mask/overlay route, ControlNet generator/provenance chain, and exact internal combined img2img/inpaint state-flow contracts are implemented. Combined modes remain unadvertised and still require profile/template/live qualification. | +| Qwen Edit Modular | image-conditioned and image-conditioned inpainting | Edit is exposed. The generic VAE/denoise/decode generator, mask, and overlay route is implemented contract-only; Modular inpaint exposure and qualification remain pending. The separately registered standard inpaint/outpaint path is unaffected. | +| Qwen Edit Plus | upstream block sequence, without an upstream workflow map | Core actions and the generator-only VAE/denoise/decode route exist. Inpaint state is rejected; multi-image input cardinality and field normalization still need contract tests before broader exposure. | +| Qwen Layered | upstream block sequence, without an upstream workflow map | Core actions now expose the pinned shared 640/1024 source resolution plus text-only English-prompt and bounded maximum-sequence controls. Broader exposure remains tied to the reviewed fixed-block contract and later qualification. | +| Flux Modular | text-to-image and img2img | Current modes match; Modular ControlNet remains unsupported. | +| Flux Kontext Modular | text-to-image and image-conditioned | Registered internally but no public Modular execution profile/specification. | +| Flux2 Klein Modular | text-to-image and image-conditioned | Registered internally; current experimental metadata points at a standard pipeline and advertises edit semantics without publishing a Modular execution path. | +| Z-Image Modular | text-to-image and img2img | Text-to-image only is declared; existing generic latent/strength fields make img2img a small contract/profile gap, still requiring qualification. | +| Wan I2V Modular | image-to-video and first/last-frame video | The existing image-to-video mode has its exact split-action typed edges and opaque route. The distinct official FLF checkpoint is now pinned immutably and admitted as a reviewed repository variant of the same generic Models Loader; action admission requires the I2V/FLF input shape to match that exact loader publication. Public FLF profile/template promotion and live execution remain pending. | +| Wan T2V Modular | canonical text-to-video block sequence | The declared text-to-video action matches the available block sequence. | + +This table is an admission inventory, not permission to advertise every +upstream workflow. Support is added only when all required node actions, +parameter adapters, execution specification, tests, and proof level agree. + +### Missing actions inside families MoDiff already supports + +These are action/adapter gaps within existing families, separate from the 69 +entirely missing standard families in Appendix B. They should extend generic +image, video, and audio task nodes; none justifies a model-named node. + +| Family | Existing generic coverage | Confirmed pinned classes/actions not yet covered or exposed | +| --- | --- | --- | +| SDXL | Modular text/image/control/inpaint high-level modes and standard text/img2img/inpaint adapters published contract-only; exact ordinary/bounded single-ControlNet-Union and reviewed single-IP-Adapter generic state flows | instruct-pix2pix; live Modular qualification, multi-ControlNet, multiple IP-Adapters, and templates for combined actions | +| Qwen Image | standard text-to-image and Edit inpaint/outpaint; contract-only standard img2img, inpaint, Edit, and Edit Plus; Modular control text-to-image and edit paths | standard ControlNet, ControlNet inpaint, and Layered adapters; Modular img2img/inpaint combinations | +| Z-Image | standard and Modular text-to-image plus contract-only standard img2img/inpaint | standard ControlNet, ControlNet inpaint, and Omni adapters; Modular img2img exposure | +| Flux | text/image edit, fill, base control, ControlNet, Kontext, Redux, contract-only img2img/inpaint/Kontext-inpaint, and exact true-CFG forwarding | control-img2img, control-inpaint, ControlNet-img2img, and ControlNet-inpaint | +| Flux2 | Klein text/image edit and multi-reference plus contract-only Klein inpaint | KV and full Flux2 after artifact/runtime review | +| Wan | five profiled standard video adapters, Modular T2V/I2V, and contract-only Wan 2.2 T2V/Animate adapters | first/last-frame profile; the three Expert-only VACE video/reference/color modes; a truthful VACE first-frame adapter that synthesizes the required video-and-mask state; live qualification for Animate | +| LTX/LTX2 | profiled LTX condition modes plus contract-only long-prompt I2V and LTX2 condition adapters | latent upsample; LTX2 in-context, HDR, and latent-upsample actions; post-pin LTX-2.5 distilled/full and two-stage recipes, duration head, Gemma-4 prompt enhancer, diffusion decoder, and LTX2/LTX2.5 Modular pipelines; live execution qualification for long/LTX2 | +| Hunyuan Video | FramePack adapter published contract-only | base text-to-video, image-to-video, and SkyReels image-to-video; live FramePack qualification | +| Audio | ACE-Step plus Stable Audio published contract-only | live Stable Audio qualification; unsupported ACE `extract`, `lego`, and `complete` choices remain hidden until their missing inputs exist | + +All entries begin `contract_only`. Real output, resource envelopes, Auto +qualification, and Gallery publication remain separate remote-machine work. + +## Test and evidence ladder + +Each phase below names its applicable levels. + +1. **Static/contract:** registry closure, class and signature availability, + immutable revisions, safetensors/trust policy, schema and manifest checks. +2. **Mocked/tiny:** fake pipeline calls, input aliasing, output normalization, + Modular state/component flow, and tiny upstream fixtures without large + downloads. +3. **Integrated backend/client:** HTTP contracts, Studio graph application, + Auto/Expert gating, stale-request handling, and mocked browser flows. +4. **Local short live:** only an allowlisted workload that is expected to finish + inside 30 minutes and is forcibly bounded at 40 minutes. +5. **Remote qualification:** exact normal recipe on the target qualification + hardware, producing attributable output and a resource receipt. +6. **Gallery publication:** rights review, inventory, hash verification, + anonymous remote verification, activation, and release acceptance. + +Required backend gate: + +```powershell +uvx --from ruff==0.12.7 ruff check . --select E9,F +uv pip check --python .venv/Scripts/python.exe +.venv/Scripts/python.exe -m modiff.preflight --json --check-port 8088 --fail-on-error +.venv/Scripts/python.exe -m pytest -q +``` + +Required client gates for public-contract or UI changes: + +```powershell +npm run check +npm run check:ui +``` + +Template and Gallery changes also require: + +```powershell +npm run gallery:verify +npm run gallery:coverage +npm run workflows:verify +npm run test:asset-storage +``` + +Asset activation additionally follows the complete release gate in the client +`docs/template-gallery-assets.md` guide, including `npm run check:acceptance`, +`npm run release:assets:gate`, `npm run release:template:qualify`, and +`npm run release:resource:qualify` on the external qualification host. + +## Phase 0 — Auto correctness and registry closure + +Priority: immediate. Hardware: CPU only. Assets: none. + +### Committable segments + +- [x] **P0.1 Exact pair validation and Flux truth fix** + - Backend: require an exact `(modelType, mode)` entry; remove + `control_image` from `FluxModularPipeline`; make a `None` node specification + an explicit unsupported result in both node update and execution; never + treat membership in a generic signal map as a capability declaration. + - Client: do not offer a backend mode absent from the exact capability; show + the backend reason and preserve Expert access only for structurally valid + graphs. + - Tests: unknown pair, wrong-mode pair, Flux control capability absence, + dynamic node update, stale imported graph, and mocked Studio mode gating. + - Status 2026-08-07: implementation and source gates are complete in paired + backend `91c9a36` and client `28b12b7`. Auto now requires + the pair in both its requirements and execution-profile registries, rejects + stale plan/form identity mismatches at execution, and cannot be promoted by + installed artifacts, history, or client-supplied proof. The client treats + schema-v2 `runnableModes` (including `[]`) as authoritative and submits the + exact model and mode in runtime hints. + - Evidence: backend Ruff, dependency validation, preflight, and the complete + suite passed (`639 passed, 318 subtests passed`). Client `npm run check` + passed, and the focused mocked-browser regression passed (`1 passed`). The + complete Windows browser run executed all 75 cases: the 74 functional cases + passed, while one unrelated layout-snapshot case reported only the two + absent Win32 JSON baselines; the generated baselines were removed. No model, + media, or Gallery assets were downloaded or generated. +- [x] **P0.2 Explicit resource-plan targeting** + - Backend: put loader module/action and execution path in every specification; + remove class-name substring routing; fail if a plan changes zero matching + loaders. + - Client: verify the returned target matches the visible graph before applying + a plan; surface a mismatch instead of enabling Run. + - Tests: Qwen Modular, Qwen Edit Plus, Qwen Layered, Wan Modular, mixed-loader + graphs, stale candidate IDs, and zero-update plans. + - Status 2026-08-10: paired backend `8fb2cb9` and client `c3e8a17` make the + reviewed execution profile the exact Auto authority for loader module, + loader action, execution path, pipeline class, model type, mode, and pinned + artifact. Plans, selected candidates, candidate lists, retries, history, and + runtime hints retain and cross-check that identity. The backend targets only + exact executable-path loaders, rejects missing, ambiguous, stale, + cross-profile, disconnected, and zero-target plans, and emits bounded + non-echoing failures. The client validates bounded schema-v2 responses, + requires an exact selected/list receipt, checks an enabled managed loader + before readiness, mutation, and submission, and exposes a mismatch instead + of enabling Run. Z-Image Auto uses its reviewed direct-image adapter while + its separate Expert Modular capability remains unchanged. + - Evidence 2026-08-10: the complete backend gate passed 1113 tests with 4 + skips and 1688 subtests; the focused independent P0.2 replay passed 172 + tests and 330 subtests. Ruff E9/F, dependency validation, preflight, and + diff checks passed. The client `npm run check` and all 86 mocked Studio + browser cases passed; focused contracts passed 76/76 and an independent + critical-browser replay passed 7/7. The production bundle is 523003 bytes + gzip, 133 bytes inside the stricter 523136-byte safety target. The mirrored + client matched all 26 generated files byte-for-byte, and a fresh backend + served `/`, `/assets/index.js`, `/health`, and `/runtime/status`. These are + CPU/static/unit/contract/mocked-browser and local HTTP results: no model or + Gallery asset was downloaded, and no model or media output was generated. +- [x] **P0.3 Canonical execution-spec registry and generic node closure** + - Deliver this as the following independently committable, CPU-only paired + segments. None downloads a model or generates an asset. + - [x] **P0.3a.1 Registered Modular dynamic action safety** + - Backend: add one action-contract resolver used by Encode Prompt, Image + Embeddings, Image Encode, Denoise, Decode Latents, and ControlNet; derive + their fields from a defensive copy of the selected model specification; + reject absent actions consistently during definition update and stale + execution; reconcile every connected component's `model_type` against + the selected pipeline rather than letting cached node state win. + - Client: keep the generic `/nodes` renderer and dynamic field-action path; + add a mocked browser matrix proving that switching the connected model + changes fields without a model-name branch, removes unsupported actions, + and reports stale imported edges cleanly. + - Tests: every registered pipeline by every dynamic action; supported and + unsupported updates; selected-versus-connected identity mismatch; unknown + class; repeated custom-sidecar updates without registry mutation; existing + Auto exact-pair behavior unchanged. + - Status 2026-08-07: implementation and source gates are complete for the + eleven registered built-in classes in paired backend `91c9a36` and client + `28b12b7`. The backend now uses + one immutable contract resolver and one actionable model-type resolver + across all six nodes. Runtime recovery rejects mixed selected/connected + identities, while SDXL's valid bundle-only ControlNet contract remains + supported. ControlNet now forwards any signalled model identity instead + of maintaining a class allowlist. The client test uses a synthetic generic + task/model contract and proves backend definitions remove and restore + executable fields with no product model-name branch. + - Evidence: the focused backend run passed (`56 passed, 92 subtests + passed`); the complete backend suite passed (`649 passed, 402 subtests + passed`), along with Ruff, dependency validation, and preflight. Client + `npm run check` passed, the two focused dynamic-definition browser tests + passed, and the complete mocked Studio run passed all 75 functional cases; + its only failure was the pre-existing absent Win32 JSON baselines for one + layout snapshot. `check:ui` likewise reached the pre-existing absent + Win32 PNG baselines. All six generated baseline candidates were removed. + No model, media, or Gallery asset was downloaded or generated. + - [x] **P0.3a.2 Custom Modular contract identity isolation** + - Backend: replace the global mutable Dummy configuration with an immutable + contract identity and bounded cache keyed by source (`hub` or `local`), + repository, revision, explicit remote-code trust choice, the verified + `modiff_pipeline_config.json` hash, and a bounded executable-metadata + manifest hash. Resolve the exact sidecar bytes without network access or + upstream construction. Runtime recovery must restore the matching + declarative contract without one standard or custom loader resetting + another graph. Hub recovery requires an exact cached commit; local + recovery rejects containment and executable symlink escapes. Keep + per-identity custom bindings out of the public built-in enumeration and + use locked, defensive snapshots. + - Client: carry the same backend-issued opaque custom contract identity in + two generic carriers without interpreting repository names or identity + fields: a hidden loader value that is persisted, exported, and included in + run hashes, plus transient output signals that drive connected dynamic + fields. Signal values remain non-durable. Rebase stored nodes onto current + backend action metadata and hidden contract fields so legacy graphs can + acquire the identity without a model-specific migration. + - Security boundary: custom pipeline and Dynamic Block execution is + `contract_only` in this segment, even with `trust_remote_code=false`. + The pinned upstream constructor imports the installed library named by a + repository-controlled component `type_hint` before MoDiff can approve + it. `trust_remote_code=true` and non-boolean lookalikes fail before cache + reuse or upstream calls. Sidecar callbacks are declarative-only, imported + client actions remain inert until rebased from `/nodes`, and + `/fields/action` validates the live module, node, field, and callback. + Built-in Modular execution is separately bound to the registered default + repository, cataloged commit, pipeline class, and installed component + type hints. The generic standalone component loader resolves a reviewed + Diffusers model class from bounded `config.json` rather than letting + repository `model_index.json` select an installed package. + - Tests: custom A, standard B, then A again; interleaved custom A/B nodes; + restart/recovery from self-describing inputs; sidecar hash/revision + mismatch; source/trust/execution-ID tampering; strict boolean validation; + missing, malformed, duplicate-key, or oversized local sidecar; Hub/local + shadowing and local symlink escape; no network during recovery; concurrent + registry access and failed-load cache isolation; repository-class + masquerading; cache-mutated component hints; standalone component + category/class mismatch; arbitrary installed-package dispatch; and + authoritative field-action dispatch. Client tests cover opaque + propagation, disconnect clearing, hidden-value graph round-trip and + run-hash participation, legacy/empty-registry inert hydration and live + rebasing, source/revision commit actions, and switching back to a normal + transient signal. Assets and model weights: none. + - Status 2026-08-09: implementation and all source, contract, browser, bundle, + and fresh-backend HTTP gates are complete in paired backend `91c9a36` and + client `28b12b7`. The identity is a content checksum used for recovery and run + hashing, not authorization. Its executable-metadata manifest detects + bounded Python and loader-config drift but deliberately does not claim + atomic custom-code execution or model-weight proof. Executable custom + pipelines and Dynamic Blocks moved to the reviewed P1.1 admission contract + rather than weakening this boundary. + - Evidence: the combined focused backend matrix passed (`166 passed, 180 + subtests passed`); the complete backend suite passed (`709 passed, 482 + subtests passed`), with only the reviewed upstream `torch_dtype` + deprecation warning. Ruff `E9,F`, dependency validation (`78 packages + compatible`), compile checks, diff checks, and preflight (`ready: true`) + passed. Client `npm run check` passed, including formatting, lint, type + checking, all unit tests, the production build, and the bundle budget + (`523172 / 523264` gzip bytes). The three focused mocked-browser dynamic + contract regressions passed. The exact built `index.js` was mirrored into + the backend without touching backend-owned Gallery media; a fresh backend + served `/` and `/assets/index.js` with HTTP 200 and the expected 1,303,875 + byte asset. No model, media, Gallery asset, Transformers installation, + network model download, or GPU execution occurred. + - [x] **P0.3b Standard adapter fail-closed closure** + - Backend: choose the official default repository after pipeline-class + changes; revalidate image actions against adapter modes; make video loader + dispatch and untagged-pipeline recovery explicit; preserve and validate + audio mode/task/input identity; add permanent direct-profile-to-adapter + closure tests. + - Client: filter generic action and task choices from backend declarations, + with no Flux, Wan, ACE-Step, or Stable Audio selection branch; show an + unsupported-contract error for stale graphs. Remove ModelSelect's + client-owned family inference and repository-name matching so the live + backend `fieldOptions.filter` contract is authoritative for both Hub and + local choices, including future model families. + - Tests: fake pipelines only, including manual class-only changes, wrong + image action, unknown video adapter, Stable Audio task filtering, ACE + mode/task disagreement, every direct profile/class/mode tuple, and a + synthetic future-family ModelSelect option admitted solely by backend + metadata. + - [x] **P0.3b.1 Registry and direct-profile closure:** declare ordered modes, + reviewed primary/compatible repositories, and exact load/execute handlers; + permanently test every public direct profile maps to an adapter and every + advertised mode is implemented. Expert-only adapters need not be promoted + into Studio. CPU/static tests only; assets: none. + - [x] **P0.3b.2 Generic image action identity:** resolve backend-managed + defaults after a class change while preserving explicit Hub/local choices; + tag class, mode, and repository even on cache hits; validate Generate, + Edit, Inpaint/Outpaint, and Control actions before upstream execution; and + publish generic field filters/contracts from the backend. Remove + `FluxControlNetPipeline` from selectable claims until the generic loader + can supply its required ControlNet component. Fake pipelines only; assets: + none. + - [x] **P0.3b.3 Generic video dispatch and recovery:** replace FramePack and + Wan-VACE catch-all fallbacks with exact handler lookup; recover an untagged + object only when runtime class and reviewed repository identify exactly one + adapter; reject shared Wan classes without enough identity; and publish + allowed modes from backend metadata. Fake pipelines only; assets: none. + - [x] **P0.3b.4 Generic audio mode/task/input contract:** bind each ACE-Step + and Stable Audio mode to its valid task, required/forbidden audio inputs, + duration/interval rules, and backend-driven task visibility. Keep + `extract`, `lego`, and `complete` unavailable until their missing dedicated + inputs and modes are designed. Fake pipelines only; assets: none. + - [x] **P0.3b.5 Generic client filtering:** remove repository-name/family + inference from ModelSelect. Apply backend class/id filters and dynamic + field mutations only; prove an opaque future family works without a + production model-name branch and a stale tuple remains blocked. Mocked + browser only; assets: none. + - [x] **P0.3b.6 Generic auxiliary LoRA identity:** replace mutable and + path-only `custom_lora` payloads with one versioned, model-neutral + descriptor. Hub weights require an exact commit, SHA-256, managed-cache + containment, and a literal lowercase `.safetensors` filename; local + weights require an existing resolved Safetensors file and a content + digest. Revalidate the descriptor immediately before every Modular, + stack/mix, and hotswap load so a hand-authored graph cannot bypass the + producer node. Keep scheduler overrides inside a reviewed Diffusers + scheduler contract. Fake pipelines and temporary tiny Safetensors only; + assets: none. + - [x] **P0.3b.7 Paired checkpoint:** run focused and complete backend/client + gates, mirror the reviewed client bundle, perform a fresh HTTP smoke, and + record paired references. Live generation and Gallery assets are not + required for this contract-only phase. + - Status 2026-08-09: P0.3b.1 through P0.3b.6 are implemented and independently + re-reviewed. Image, video, and audio loaders now bind exact class, mode, + source, repository, revision, runtime identity, and backend-issued dynamic + contracts. Unknown or ambiguous adapters fail closed. ModelSelect consumes + only the backend class/id filter grammar, including opaque future-family + tests, and contains no new family-name selection branch. Cache-ignored mode + retags reuse resident pipelines while invalidating descendants so every + action reruns its authoritative preflight. The generic auxiliary LoRA + descriptor revalidates an exact commit, managed alias or contained local + path, SHA-256, and a nonempty Safetensors header before every lifecycle + mutation. Its optional scheduler override is restricted to a bounded, + reviewed FlowMatch contract and is preconstructed for the same pipeline. + - Evidence 2026-08-09: the independent combined P0.3b matrix passed (`436 + passed, 880 subtests, 2 platform skips`), and the final complete backend + suite passed (`873 passed, 2 skipped, 1 warning, 1100 subtests`). Ruff + `E9,F`, dependency validation (`78 packages compatible`), preflight + (`ready: true`), and both diff checks passed. Client `npm run check` passed + in 44.8 seconds, including format, lint, typecheck, contract suites, + production build, and the bundle budget (`522401 / 523264` gzip bytes). + Six focused backend-driven mocked-browser contracts passed, and a fresh + real-browser smoke connected to the backend and materialized the generic + Layered workflow at the backend-bound 640 by 640 resolution. + - Checkpoint closure 2026-08-10: the implementation is recorded in backend + `91c9a36` and client `28b12b7`; the exact-target follow-up checkpoints are + backend `8fb2cb9` and client `c3e8a17`. Four Win32 shared-control snapshots + were generated by the existing Playwright contract, reviewed for font, + clipping, overlap, disabled/error-state, and portalled-listbox correctness, + and committed as client `d226c4b`. The unmodified `npm run check:ui` then + passed all 2 shared-control and 86 mocked Studio cases. The current client + `npm run check` passed with a 523003-byte gzip bundle, and the current + complete backend gate passed 1113 tests with 4 skips and 1688 subtests. + The reviewed build matched all 26 mirrored files byte-for-byte; all 317 + backend-owned local Gallery files remained present; and a fresh backend + returned HTTP 200 for the app, bundle, health, and runtime status before + port 8088 was released. No model, media, Gallery asset, Transformers + installation, network model download, or GPU execution occurred. The + separate P0.5 clean-base migration remains required before the base + installer can claim that Transformers and PEFT are absent by default. + - [x] **P0.3c Upstream workflow and component truth closure** + - Backend: remove false runnable claims first; validate every Modular + action against an upstream workflow or reviewed fixed block sequence; + remove Flux-family Guider ports, add Wan Guider ports, add the two guiders + present at the pin, and record latest-only guiders behind the pin update. + Add missing fields only where their complete state flow is understood. + Correct the standard Flux adapter's modern `true_cfg_scale` and negative- + prompt forwarding so the generic guidance field does not silently target + the wrong upstream parameter. + - Client: consume the corrected action set and never infer support from a + family name, a generic port, an installed artifact, or a nearby workflow. + - Tests: all eleven registered Modular classes, all non-null node actions, + block input/output/component closure, guider compatibility, and explicit + empty runnable sets. New modes remain `contract_only`, Expert-visible, + and excluded from Auto. + - [x] **P0.3c.1 Remove false claims and freeze the upstream matrix:** derive + closure tests from the pinned workflow maps or reviewed fixed block + sequences; withdraw currently advertised modes that cannot be assembled, + including Modular SDXL inpaint, before adding any replacement. Record + unsupported reasons in capabilities instead of exposing a broken action. + Static/no-weight tests only; assets: none. + - Status 2026-08-09: a data-only truth matrix now freezes the exact + workflow maps or reviewed fixed top-level block sequences for all eleven + registered Modular classes. Every advertised Modular mode resolves to a + current generic action sequence, covers its upstream-required user + inputs, and has explicit producer-to-consumer state edges. At that + checkpoint, SDXL Modular inpaint was removed from runnable modes and its + then-missing mask, masked-latent, crop, and overlay state was published + as an additive schema-v2 `unsupportedModes` reason. The later P0.3c.3 + internal base-inpaint closure below supersedes that state-gap detail + without promoting the mode. The existing Flux ControlNet reason is also + normalized. Current clients safely ignore that new optional detail while + continuing to treat the corrected `runnableModes` list as authoritative. + Flux2 Klein retains its legacy Modular `modelType` identifier for + compatibility, but now identifies only the standard + `Flux2KleinPipeline`, generic image backend, and + `flux2-klein:direct` execution profile; a missing referenced profile + empties its public runnable set. + - Evidence 2026-08-09: the focused matrix passed (`8 passed, 24 + subtests`); the adjacent capability, upstream Modular, and direct-profile + batch passed (`46 passed, 158 subtests`). Repository-wide Ruff E9/F and + `git diff --check` passed. The checks instantiated only no-weight block + definitions and generated no model downloads, media, or assets. + - [x] **P0.3c.2 Guider and component-port truth:** remove Guider ports from + Flux, Flux Kontext, and Flux2 Klein; add the upstream Wan T2V/I2V Guider + ports; expose the two guiders present at the pin; keep the latest-only + guider behind a Diffusers-pin update. Validate every port against the + instantiated no-weight block contract. Assets: none. + - Status 2026-08-09: the eleven registered no-weight denoise block + contracts now enforce exact generic component-port closure. Flux, Flux + Kontext, and Flux2 Klein expose no Guider port; Wan T2V and I2V do. + `AdaptiveProjectedMixGuidance` and `PerturbedAttentionGuidance` use the + pinned constructor contracts, while latest-only + `MagnitudeAwareGuidance` remains unregistered. Perturbed Attention maps + the generic Layers payload to `perturbed_guidance_config` and rejects + missing, malformed, or incompatible layer data before construction. + Additional model-to-layer-stack discovery remains tracked under P0.3e; + no unreviewed Wan stack name was guessed here. + - Evidence 2026-08-09: focused upstream contracts passed with `34 passed, + 116 subtests`; the adjacent Modular/schema/identity batch passed with + `113 passed, 1 warning, 185 subtests`; repository-wide Ruff E9/F and + `git diff --check` passed. These were CPU/no-weight tests and generated + no media, downloads, or assets. + - [x] **P0.3c.3 Complete generic state flows:** add mask/processed-mask/ + overlay state for SDXL and Qwen inpaint, a generic IP-Adapter encoding + action for SDXL, and first/last-frame state for Wan I2V. Preserve the + exact Qwen Layered controls recorded below. Add generic + control-edit/control-inpaint fields only as + complete backend adapters, never as family-named frontend nodes. Tiny + fixture tests may run locally; assets: none. + - Preparatory truth cleanup 2026-08-09: removed the stale + `QwenImageEditPlusModularPipeline:inpaint` claim from backend legacy + mode metadata and the client's offline profile and Auto fallbacks. The + blocked mask-contract explanation remains available, schema-v2 + `runnableModes` remains authoritative, and the standard + `QwenImageEditInpaintPipeline` inpaint/outpaint profile is unchanged. + An imported stale Edit Plus inpaint form now receives the exact backend + unsupported-mode blocker and cannot become Auto-ready. Focused backend + tests passed (`4 passed, 39 subtests`), and the Auto execution guard + passed (`1 passed, 3 subtests`); client template/contract tests passed + (`57 passed`), with client format, lint, and typecheck plus + backend Ruff E9/F and both diff checks passing. Static tests only; no + downloads, model execution, generated media, or assets. This does not + complete the P0.3c.3 state-flow work. + - Preparatory Layered-control closure 2026-08-09: the backend's existing + generic Encode Prompt and Encode Image actions now publish the targeted + pinned Layered controls: a shared `resolution` constrained to 640 or + 1024 with default 640, text-only `use_en_prompt` defaulting false, and a + positive `max_sequence_length` capped at 1024 with default 1024. The + client removed its Layered class-name branch and consumes a small + backend-issued `studioBinding` grammar through the existing managed + graph. Opaque field names synchronize only when their complete binding + signatures match. Unknown, extra, malformed, direct-dimension identity, + and out-of-envelope dimension metadata fail closed; valid numeric node + edits canonicalize the source and every peer to the target type. This + slice does not change Auto modes or the existing Layered template; the + new control evidence is contract-only. + - Evidence 2026-08-09: backend upstream/schema/fake-pipeline tests passed + (`49 passed, 134 subtests`), and the adjacent Modular suite passed (`74 + passed, 158 subtests`); client generic graph contracts passed (`38 + passed`), the focused mocked-browser dynamic-refresh test passed (`1 + passed`), and client format, lint, and typecheck plus backend Ruff E9/F + and both scoped diff checks passed. These checks loaded no weights and + generated no downloads, media, templates, or assets. + - Preparatory seed-state closure 2026-08-09: every one of the ten + registered VAE encoder actions whose pinned upstream block exposes + `generator` now declares the same bounded generic seed field. Encode + Image validates the scalar before pipeline initialization, constructs a + Torch generator on the managed pipeline execution device, and forwards + no raw seed. Caller-supplied generator objects and backend/upstream + generator-contract disagreement fail before model identity resolution or + block initialization. Wan I2V's distinct image encoder truthfully remains + unchanged because it has no upstream generator input. Focused + schema/upstream/fake-pipeline tests passed (`54 passed, 160 subtests`), + the adjacent Modular/graph/custom-identity batch passed (`137 passed, 1 + warning, 309 subtests`), and the independent root matrix passed (`121 + passed, 1 warning, 229 subtests`). Ruff E9/F and diff checks passed. No + dependency, client, graph, template, asset, model, GPU, inference, or + Transformers-install action was involved. + - Preparatory opaque-route closure 2026-08-09: built-in Qwen Image and + Qwen Image Edit VAE encoders now carry post-sampling generator state, + processed mask, overlay state, and exact latent pairing through generic + Encode Image -> Denoise -> Decode Latents route handles. Qwen Image Edit + Plus uses the same route only for generator continuity and rejects + inpaint state. Each process-local route is bound to one successful + Models Loader execution, exact component role and model-id inventory, + stage, seed, and the exact paired Torch tensor through a weak identity + reference. Edit Plus multi-image state instead seals a bounded ordered + list of exact tensor identities. Serialized, nested, stale, + cross-loader, role-swapped, same-loader cross-paired, reordered, and + dead-reference values fail before pipeline initialization. Route-aware + node-cache comparison preserves only exact tensor identities, so an + equal-valued clone cannot bypass that validation. Denoise retries clone + the stored post-VAE generator state, and every supported Qwen Decode + requires the matching Denoise route. + - The client selects this topology only from generic live handles: every + Modular graph with a complete route contract connects Denoise to Decode; + image flows additionally connect Encode Image to Denoise; and native + inpaint replaces the Apply Mask fallback only after the mask plus both + route chains are complete and have exact input/output displays plus one + identical unpadded opaque type. Partial, malformed, mismatched, or + out-of-order node definitions keep finalization and Run pending. The + schema-v2 finalization proof binds the sorted exact endpoint set and is + revalidated against live managed edges, so preserved-ID endpoint edits + and forged checksums cannot restore readiness. No model or repository + name controls this branch. + - Preparatory standalone-component provenance 2026-08-09: generic + AutoModelLoader outputs now carry an immutable, process-local binding + that seals the component role, ComponentsManager identity, canonical + source/repository/revision/subfolder/class tuple, and config SHA-256. + Per-loader current-publication identity revokes resident A after A->B; + resident reuse and node-cache hits require the exact live binding, while + config TOCTOU, relabeling, metadata tampering, and stale publications + fail before class resolution or component initialization. This is the + dormant provenance foundation for the next Qwen ControlNet route slice; + it does not add ControlNet fields, runtime behavior, modes, templates, + or client branches. Cache HTTP responses now also reject connector and + other opaque static types with a controlled `400`; only declared media + and text families are served. + - Evidence 2026-08-09: the final dedicated route/provenance suite passed + (`49 passed, 61 subtests`), cache HTTP security passed (`20 passed, 29 + subtests`), and the complete backend gate passed (`931 passed, 2 + skipped, 1 warning, 1211 subtests`). Repository-wide Ruff E9/F, + dependency validation, preflight, and diff checks passed. Client graph + contracts passed (`39 passed`), proof/template contracts passed (`58 + passed`), the focused out-of-order mocked-browser transition passed (`1 + passed`), and full `npm run check` passed with the unchanged bundle + budget (`523126 / 523264` gzip bytes). The reviewed bundle was mirrored + byte-for-byte and fresh HTTP smoke checks returned `200` for `/`, + `/assets/index.js`, and `/health`. These were CPU/static, no-weight, and + mocked-browser checks; they made no model downloads, live inference, + generated media, template, capability, optional-runtime, or Gallery- + asset changes. + - Preparatory Qwen ControlNet route closure 2026-08-09: the generic + ControlNet action now exposes a bounded seed plus opaque route input and + output only when the pinned upstream ControlNet VAE block exposes its + generator. Text control starts from that seed; an image route continues + the exact post-main-VAE generator snapshot. The output seals the exact + current standalone ControlNet publication and an ordered weak identity + for the resulting control latent tensor or bounded tensor list. Denoise + requires the matching ControlNet bundle, component publication, seed, + and inherited image state, continues the generator once, and emits the + already-reviewed Decode route. No large image latent is copied into the + opaque carrier or routed through ControlNet. + - Cache candidates re-resolve and validate the complete effective + image/control inputs, reserved fields, component roles, and route state; + same-object nested mutation cannot reuse a cached result. ControlNet + revalidates both the VAE and standalone publications after block + initialization, after component updates, immediately before the + upstream call, and again after it before publishing any bundle or route. + Init-time, call-time, stale-publication, cross-loader, cloned-latent, + reordered-list, nested-reserved-input, and retry paths all fail closed + at their reviewed boundary. The SDXL bundle-only branch remains + unchanged and has an exact legacy-output regression. + - The client adopts this route using only generic live handles, display + directions, and one exact opaque type. Legacy ControlNet graphs retain + their bundle-only topology. A complete contract connects ControlNet to + Denoise to Decode, and adds Encode Image to ControlNet only when that + prefix is published; ordinary image/control latent and bundle edges stay + typed and direct. Partial, mismatched, or out-of-order definitions keep + the last stable topology and Run proof pending. Seed synchronization is + field-driven across Encode Image, ControlNet, and Denoise; no model or + repository name selects the client branch. + - The existing Qwen ControlNet component is now pinned consistently in + the executable catalog, backend capability requirements, saved graph, + workflow manifest, and client requirement to official repository + `InstantX/Qwen-Image-ControlNet-Union` commit + `b13036f066d6dee7c20513e263d3d673055e9de8`. Metadata-only review confirmed + the Apache-2.0 repository, configuration, and Safetensors files; no + weight was downloaded. This slice promotes no new mode and generates no + template or Gallery asset. + - Evidence 2026-08-09: the final focused backend route/upstream/truth/schema + matrix passed (`120 passed, 247 subtests`), the adjacent matrix passed + (`134 passed, 1 warning, 81 subtests`), the exact adversarial boundary + matrix passed, and the complete backend gate passed (`954 passed, 2 + skipped, 1 warning, 1242 subtests`). Repository-wide Ruff E9/F, + dependency validation, preflight, and diff checks passed. Client graph + contracts passed (`39 passed`), the focused late-definition browser test + passed (`1 passed`), and full `npm run check` passed with the unchanged + budget (`523065 / 523264` gzip bytes). The validated client bundle was + mirrored byte-for-byte; fresh HTTP smoke checks returned `200` for `/`, + `/assets/index.js`, and `/health`. These were CPU/static, no-weight, and + mocked-browser checks with no model download, inference, generated + media, optional-runtime installation, or new asset. + - Preparatory combined-Qwen state-flow closure 2026-08-10: a nonadvertised + pinned truth table now records the exact upstream block order, required + inputs, generic action order, and ordered typed/opaque state edges for + Qwen img2img, inpaint, ControlNet img2img, and ControlNet inpaint. A + no-weight fake-action matrix executes all four paths through the real + Encode Image, optional ControlNet, Denoise, and Decode adapters while + proving generator continuation, mask/overlay preservation, and exact + source/control separation. Public capabilities, profiles, modes, + templates, Auto candidates, and assets are unchanged. + - The generic client may preserve that dormant combined topology only for + canonical `edit_image` or `inpaint` bindings that already own a complete + distinct control-image loader, ControlNet model loader, and ControlNet + action. Legacy text-control retains its single-loader fallback. Missing, + partial, detached, duplicated, wrong-mode, or stale-form role groups + remain pending and mint no proof. Binding mode/model/fingerprint now + gate value application, edge creation, restore, waiting, and final proof + publication; every participating optional node and exact endpoint is + sealed by the binding-scoped schema-v2 proof. No model or repository + name selects this topology. + - Evidence 2026-08-10: focused backend truth/route tests passed (`80 + passed, 128 subtests`), and the complete backend gate passed (`957 + passed, 2 skipped, 1 warning, 1254 subtests`). Client graph contracts + passed (`39 passed`), independent adversarial restore/finalizer review + found no remaining blocker, and full `npm run check` passed with the + unchanged bundle budget (`523078 / 523264` gzip bytes). Repository-wide + Ruff E9/F, dependency validation, preflight, and both diff checks passed. + The validated bundle was mirrored byte-for-byte (SHA-256 + `5beab30710b35c50d105f091cb3d578079b0138fc20fbacc20cce5d818208baf`); + fresh HTTP smoke checks returned `200` for `/`, `/assets/index.js`, and + `/health`, and port 8088 was free after the worker stopped. These were + CPU/static and fake-action checks with no model download, inference, + generated media, optional-runtime installation, template, or asset + change. + - Preparatory SDXL base-inpaint state-flow closure 2026-08-10: a + nonadvertised pinned truth entry now records the exact upstream block + sequence, required inputs, generic action order, and typed/opaque state + edges for base inpaint. The no-weight generic action route preserves + post-VAE generator state, typed mask and masked-image latents, crop + overlay state, exact tensor identity and mutation version, and the + exact resident VAE identity plus derived geometry across cache, + initialization, component update, call, and output boundaries. Bounded + tensor and PIL crop validation fails before expensive execution. + - The generic client latches this dormant typed topology only after a live + Denoise VAE handle or persisted managed VAE edge is observed, then + requires the exact matching VAE, mask, masked-latent, and route handles + before finalization. Missing or malformed later definitions remain + pending instead of downgrading to Apply Mask or a route-only topology; + the latch is binding-fingerprint-scoped, so a new route-only Qwen + binding does not inherit it. The complete native topology has an exact + 16-edge schema-v2 proof, and no model or repository name selects it. + - Public capabilities, profiles, modes, Auto candidates, templates, and + Gallery assets are unchanged. SDXL ControlNet inpaint, ControlNet + Union, IP-Adapter, and combined variants remain explicitly outside this + base contract, and no live model qualification was performed. + - Evidence 2026-08-10: the independent focused backend matrix passed + (`152 passed, 298 subtests`), its exact adversarial slice passed (`14 + passed, 39 subtests`), and the complete backend gate passed (`969 + passed, 2 skipped, 1 warning, 1296 subtests`). Client graph contracts + passed (`39 passed`), independent review and full `npm run check` + passed with the unchanged budget (`523030 / 523264` gzip bytes). + Repository-wide Ruff E9/F, dependency validation, preflight, and both + diff checks passed. The validated client bundle was mirrored + byte-for-byte (1,298,272 bytes; SHA-256 + `e2811ebfc6c9d5cd78f294216daaa69667843af7ae57a1ade0144e1d3eda1ba8`); + fresh HTTP smoke checks returned `200` for `/`, `/assets/index.js`, and + `/health`, and port 8088 was free after the process tree stopped. These + were CPU/static and fake-action checks with no model download, + inference, generated media, optional-runtime installation, template, + or Gallery-asset change. + - Preparatory Wan split-route closure 2026-08-10: the existing + backend-declared Modular `image_to_video` action now preserves exact + image embeddings, condition latents, two-pass area-budget geometry, + post-VAE generator state, source-media snapshots, resident + component/processor identity, and bounded effective configuration + through Image Embeddings, Encode Image, Denoise, Decode, cache, retry, + and TOCTOU boundaries. Raw + mode-specific frame latents remain internal to the VAE action. + First/last-frame truth and typed topology are structural only. The + official FLF repository has a distinct CLIP preprocessing and + transformer positional-embedding contract and is not an executable + catalog artifact, so `last_image` is rejected before contract lookup, + component resolution, block initialization, or cache reuse. No mode, + profile, template, asset, or dependency pin was added. + - The generic client recognizes this dormant Modular video topology only + from complete live roles, handles, display directions, and one exact + opaque route type. An already-present I2V group has an exact 17-edge + schema-v2 proof; the preparatory FLF group adds a distinct last-image + loader and two last-image edges for an exact 19-edge proof. Partial, + removed, remapped, duplicated, type-mismatched, or retained hidden role + groups remain pending and cannot downgrade to the standard video + facade. No model/repository name selects the branch, and no selectable + Studio profile or template was added. + - Evidence 2026-08-10: the dedicated CPU/no-weight Wan boundary suite + passed (`30 passed, 81 subtests`); the route/schema/truth/upstream matrix + passed (`165 passed, 382 subtests`); and the adjacent action recovery, + output, offload, and custom-identity matrix passed (`124 passed, 1 + warning, 106 subtests`). Two independent frozen audits reproduced the + exact cataloged I2V processor/component contracts and adversarial + device, tensor, generator, crop, cache, and TOCTOU boundaries without a + model download. The complete backend gate passed (`999 passed, 2 + skipped, 1 warning, 1377 subtests`); repository-wide Ruff E9/F, + dependency validation, preflight, and diff checks passed. + - Client graph contracts passed (`40 passed`) and full `npm run check` + passed with the unchanged budget (`523110 / 523264` gzip bytes). The + reviewed build was mirrored exactly as `index.js` (1,006,250 bytes, + SHA-256 `801f0f5d06c2d073b77deee696ce048039ab5f68fa6b30da7c09480d9d7459f3`) + plus its static `studio-templates.js` chunk (296,450 bytes, SHA-256 + `e3c722e395f5f93751d14b6e936d5d07d85e5a65e3b73c428d8f26f74aaaddea`). + Fresh HTTP smoke checks returned `200` for `/`, `/assets/index.js`, + `/assets/studio-templates.js`, and `/health`, and port 8088 was free + after the process tree stopped. These were CPU/static, no-weight, and + fake-action checks with no live inference or model, media, template, or + Gallery-asset download/generation. + - Preparatory SDXL ordinary-ControlNet composition closure 2026-08-12: + pinned truth now records the exact upstream + `controlnet_image2image` and `controlnet_inpainting` block order, + required inputs, generic action sequence, and typed/opaque graph edges. + The existing VAE route may reach Denoise alongside the generic + ControlNet bundle only when the standalone loader publication is the + exact ordinary `ControlNetModel` class. Denoise resolves its exact + managed component before pipeline initialization, proves that the same + object was installed, and rechecks publication plus resident identity + at cache, pre-call, and post-call boundaries. Missing bundles, + prepared Qwen control latents, stale publications, Union components, + and init/call-time resident swaps fail closed before a runnable output + route is published. The upstream component remains installed on the + Modular pipeline; only its ordinary control image and scale inputs are + forwarded to the call. + - This slice is internal and no-weight. It does not advertise a new + mode, choose or download an SDXL ControlNet artifact, add an execution + profile, Auto candidate, template, or Gallery asset, or qualify model + output. ControlNet Union and IP-Adapter remain rejected as distinct + component/weight contracts. Focused truth, route, upstream, and Wan + regression tests passed (`173 passed, 456 subtests`). The complete + backend gate passed (`1213 passed, 4 skipped, 1 existing warning, 2048 + subtests`); Ruff E9/F, `py_compile`, dependency validation (78 + compatible packages), preflight, and diff checks passed. The unchanged + compatible client passed complete `npm run check`, including its graph + contracts and a 523108/523264-byte gzip bundle (28 bytes below the + stricter 523136-byte target). + - Public mode/profile promotion, templates/assets, and live qualification + for the completed Qwen and SDXL base/ordinary-ControlNet flows remain + outstanding; SDXL ControlNet Union and IP-Adapter combinations remain + unfinished, so P0.3c.3 stays incomplete. + - Preparatory SDXL ControlNet Union composition closure 2026-08-12: the + same generic ControlNet action now declares an ordinary/Union selector. + The existing client field-action grammar hides the Union control-type + index for ordinary execution and reveals it only for Union. The index is + a bounded generic integer rather than an artifact-specific semantic + name. The action requires the selected variant to match the exact + process-local `ControlNetModel` or `ControlNetUnionModel` publication; + Denoise additionally validates the index against the exact resident + Union model's bounded `num_control_type`, installs that same object, and + repeats the publication/resident checks at cache, initialization, + pre-call, and post-call boundaries. Pinned truth records the complete + single-control Union image-to-image and inpaint block/action/edge flows. + - This Union slice remains internal, CPU/no-weight, and single-control. It + adds no public mode, execution profile, selected repository, Auto + candidate, template, Gallery asset, model download, or output + qualification. Multi-ControlNet, IP-Adapter, public promotion, and live + qualification remain open, so P0.3c.3 stays incomplete. + Focused truth/route/upstream/Wan regressions passed (`173 passed, 459 + subtests`); the complete backend gate passed (`1213 passed, 4 skipped, + 1 existing warning, 2051 subtests`). Ruff E9/F, `py_compile`, dependency + validation (78 compatible packages), preflight, and diff checks passed. + The compatible client passed complete `npm run check`, including 37 + run/field-action tests and the unchanged 523108/523264-byte gzip bundle. + - Preparatory SDXL single-IP-Adapter state-flow closure 2026-08-12: a new + generic **IP-Adapter Embeddings** action derives its fields from the + selected registered pipeline contract and matches the pinned upstream + `StableDiffusionXLIPAdapterStep`. All nine upstream single-adapter + text/image/inpaint plus ordinary/Union ControlNet compositions now have + exact nonadvertised block, action, and typed-state edge truth. The only + admitted descriptor is the Apache-2.0 `h94/IP-Adapter` repository at + exact commit `018e402774aeeddd60609b4ecdb7e298259dc729`, standard SDXL + weight `sdxl_models/ip-adapter_sdxl.safetensors`, reviewed size + 702,585,376 bytes, and SHA-256 + `ba1002529e783604c5f326d49f0122025392d1d20ac8d573b3eeb3e6dea4ebb6`. + Execution resolves and hashes that file from the local Hub cache only; + it never installs or downloads. The pinned image encoder is likewise + local-only and must retain its exact CLIP ViT-H geometry and processor + contract. + - The adapter action mutates only the exact resident SDXL UNet, supports + one standard projection and one bounded scale, and issues a + nonserializable process-local receipt sealing loader/UNet/Guider, + adapter parameter versions, encoder/processor, source pixels, and both + embedding tensor identities. Denoise validates the receipt before + initialization, after component installation, before the upstream call, + after the call, and on cache reuse. A missing bundle, copied state, + wrong loader/component/Guider, changed image/tensor/parameter/scale, or + resident swap fails closed. Models Loader removes only a current owned + adapter before minting a replacement loader receipt and rejects + unreceipted mutation state. + - This is an internal/manual contract-only slice. It adds no public mode, + execution profile, Auto candidate, template, Gallery asset, installer, + dependency cutover, or live output claim. The optional Transformers + runtime must already have been explicitly installed and verified; + registry discovery and graph execution do not install it. Multiple + adapters, Multi-ControlNet, public promotion, templates/assets, and live + qualification remain open, so P0.3c.3 stays incomplete. + - Evidence 2026-08-12: the focused IP-Adapter, route-state, workflow-truth, + upstream-contract, artifact-catalog, and local-resolution matrix passed + `162 tests` with `411 subtests`; the complete backend gate passed + `1228 tests` with `4 skips`, `2086 subtests`, and only the existing + upstream Diffusers `torch_dtype` deprecation warning. Ruff E9/F, + `py_compile`, dependency validation (78 compatible packages), preflight, + port, and diff checks passed. The unchanged compatible client passed + complete `npm run check`, including the `523108/523264`-byte gzip + bundle. Its exact mocked-browser generic `node_definition` contract + passed `1/1` and proved that backend-selected node/field metadata updates + without replacing current field values. These are CPU/static/unit/ + contract/mocked-browser results: no Hub download, adapter installation, + model execution, generated media, or live output qualification occurred. + - P0.3c contract-only closure 2026-08-12: the completed base inpaint flow + is now an exact high-level `inpaint` mode on the existing generic SDXL + Modular capability. That whole capability is explicitly + `contract_only`, pinned to + `stabilityai/stable-diffusion-xl-base-1.0@462165984030d82259a11f4367a4eed129e94a7b`, + and publishes exact reference-image plus mask input requirements. It has + no execution profile and sets Auto, template, and Gallery eligibility + false. The combined ordinary/Union ControlNet and single-IP-Adapter + routes remain constructible manual generic-node compositions; no new + model-named mode or client branch was added. Multi-ControlNet and + multiple-adapter execution are outside the pinned 18-workflow upstream + matrix and require their own future artifact/state-flow review rather + than keeping this scoped truth phase open. + - Evidence 2026-08-12: the final P0.3c truth/capability/route/optional- + runtime matrix passed `227 tests` with `1 skip` and `729 subtests`; the + complete backend gate passed `1228 tests` with `4 skips`, `2088 + subtests`, and only the existing upstream Diffusers `torch_dtype` + deprecation warning. Ruff E9/F, dependency validation (78 compatible + packages), preflight/port, and diff checks passed. The unchanged client + passed complete `npm run check` and its `523108/523264`-byte gzip + budget. A fresh HTTP smoke reported ready and returned schema-v2 SDXL + Modular contract-only truth with exactly four high-level modes, the + pinned revision, zero execution profiles, and all three eligibility + flags false; the owned server tree was stopped and port 8088 was free. + This is CPU/static/unit/contract/HTTP evidence only, not model or media + execution or live qualification. + - Wan FLF artifact admission 2026-08-12: the Apache-2.0 official + `Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers` checkpoint is pinned at exact + commit `17c30769b1e0b5dcaa1799b117bf20a9c31f59d7`. Its 50-file snapshot + contains 21 Safetensors files and no Python source. The existing generic + `WanImage2VideoModularPipeline` Models Loader accepts it only as an exact + reviewed repository variant and still requires the catalog revision. + The loader index boundary recognizes only its exact concrete + `CLIPProcessor`/`CLIPVisionModelWithProjection` declarations; the + Image Embeddings action loads the pinned block's exact + `CLIPImageProcessor` view locally. Image Embeddings and Encode Image bind + `image2video` to the original I2V artifact and `flf2v` to the FLF + artifact before block initialization and cache reuse. Swapping only + `last_image`, repository, revision, or component type fails closed. The + generic Model Manager install route now resolves an omitted revision to + the catalog commit for every reviewed repository, so the existing + model-select Install control cannot fetch mutable `main` for this or + another curated artifact; uncataloged user-selected repositories retain + their prior behavior. The affected loader/route/catalog/download matrix + passed 296 tests with 747 subtests. The complete backend gate passed + 1,211 tests with 4 skips, the existing Diffusers deprecation warning, + and 2,041 subtests; Ruff E9/F, `uv pip check` (78 packages), preflight, + `py_compile`, JSON parsing, and diff checks passed. A no-weight exact + config probe read the pinned Hub `model_index.json` and image-processor + config, admitted their exact reviewed types, normalized only the + validated FLF image-processor load contract to `CLIPImageProcessor`, + and observed its 224-pixel shortest-edge/center-crop configuration. The + temporary config-only snapshot was removed. This is metadata, + no-weight, unit, contract, and fake-action evidence only; no FLF model + weights, inference, generated video, public template, or Gallery asset + were downloaded or exercised. The unchanged compatible client passed + complete `npm run check` with a 523108/523264-byte gzip bundle, 28 bytes + below the stricter 523136-byte target; its mocked Model Manager install + flow passed 1/1 in 5.2 seconds. + Public FLF mode/profile/template promotion, assets, and live execution + remain outside this slice. + - [x] **P0.3c.4 Standard signature and adapter truth:** correct Flux + `true_cfg_scale`/negative-prompt forwarding and register missing standard + image classes only when each maps to an existing generic action with an + exact fake-pipeline signature test. Keep KV/full Flux2 and other + artifact-sensitive variants deferred until their runtime artifacts are + reviewed. Assets: none. + - Status 2026-08-10: modern Flux text, img2img, inpaint, and Kontext + adapters now bind the generic guidance value to `true_cfg_scale` and + forward the negative prompt only through an upstream signature that + declares it. Eleven pinned standard classes now reuse the existing + generic Generate/Edit/Inpaint actions: SDXL base/img2img/inpaint; Qwen + Image img2img/inpaint and Edit/Edit Plus; Z-Image img2img/inpaint; Flux + Kontext inpaint; and Flux2 Klein inpaint. Every class uses an already + reviewed immutable base artifact and remains contract-only with no Auto + profile or public template. Full Flux2, Flux2 KV, ControlNet/composite + variants, Qwen Layered, Z-Image Omni, and SDXL instruct-pix2pix remain + unregistered until their extra inputs or artifacts are reviewed. + - Evidence 2026-08-10: the focused image registry passed 73 tests with 2 + skips and 177 subtests. The adjacent profile, capability, artifact, + offload, Qwen-inpaint, and optional-runtime matrix passed 99 tests and + 176 subtests. The complete backend gate passed 1116 tests with 4 skips, + 1740 subtests, and only the existing upstream Diffusers deprecation + warning. Ruff E9/F, `py_compile`, dependency validation (78 compatible + packages), preflight, and diff checks passed. Tests inspected the pinned + upstream signatures and executed every new adapter through fake + pipelines; no model, artifact, media, network, or GPU execution occurred. + - [x] **P0.3c.5 Contract-only exposure:** publish newly complete modes and + already implemented but unprofiled video/audio adapters as + `contract_only`, with backend-driven parameters and no Auto eligibility. + Add templates only after graph-contract validation; generation and public + Gallery media run later on the qualification machine. + - Status 2026-08-11: `/model_capabilities` now publishes every registered + but unprofiled generic Diffusers adapter as an exact experimental + `contract_only` record: 13 standard image adapters, five video adapters, + and Stable Audio. Each record names one generic loader, exact pipeline + class/mode set, reviewed repository and immutable catalog revision, + backend parameter aliases, and mode input contract. Contract-only + records explicitly disable Auto, template, and Gallery eligibility; + they have no execution profile or optional-runtime execution + requirement and remain outside the primary supported capability list. + - Evidence 2026-08-11: the adapter/profile/upstream-truth matrix passed + 267 tests with 2 skips and 777 subtests. The complete backend gate + passed 1117 tests with 4 skips and 1759 subtests, with only the existing + upstream Diffusers deprecation warning. Ruff E9/F, dependency + validation (78 compatible packages), preflight, `py_compile`, and diff + checks passed. Registry closure tests prove the published set is + exactly the image/video/audio adapter set minus profiled classes and + that every artifact is immutably cataloged. No model, artifact, media, + network, GPU execution, template, or Gallery asset was used or changed. + - [x] **P0.3c.6 Paired checkpoint:** run focused and complete backend/client + gates, mirror the reviewed client bundle, perform a fresh HTTP smoke, and + update the support matrix. No large-model or media qualification is part + of this phase. + - Evidence 2026-08-11: paired backend `896661a8dc13` and client + `d226c4bbc2e0` passed the checkpoint. The backend adapter/profile truth + matrix passed 267 tests with 2 skips and 777 subtests; the full backend + gate passed 1117 tests with 4 skips and 1759 subtests plus the existing + upstream deprecation warning. Ruff E9/F, dependency validation, + preflight, and diff checks passed. The unchanged client passed full + `npm run check`, 2/2 shared-control browser tests, and all 86 mocked + Studio browser tests. Its production bundle remained 523003/523264 + gzip bytes, 133 bytes inside the stricter 523136-byte safety target. + - The 26-file client build was verified byte-for-byte against `web/` with + no extra generated deployment files; all 317 preserved local Gallery + files remained untouched. Exact deployed hashes were + `index.js`=`4eb4230f9e3779c89a49a7155ffc56984e47d0c492d604780cafad3ac3e5e133`, + `studio-templates.js`=`e8552942199e04fe3980da5b91550f7e9964af7894bc43e781d2bceaa62554d4`, + and `graph-vendor.js`=`76e8c330da63ee5806ef230e2567ed978fca7005efd1cf679b8c5d99e8bfd337`. + A fresh supervised HTTP smoke returned `200` for `/` and + `/assets/index.js`, `/health` reported ready, and schema-v2 + `/model_capabilities` returned 20 supported, 25 experimental, and 19 + contract-only records with zero Auto-eligible or profiled contract-only + entries. The owned process tree was stopped and port 8088 was free. + No model download, inference, GPU workload, generated media, template, + or Gallery publication occurred. + - [x] **P0.3d Backend-owned Studio execution specifications** + - Backend: make one exact-pair registry the source for execution profiles, + capabilities, Auto requirements, loader choices, workflow validation, + generic graph roles/edges, form bindings, ordered dynamic actions, schema + version, and content hash. Validate every reference against `/nodes`. + - Client: parse and fail-close the new schema. First materialize Flux + Schnell and Flux Dev text-to-image from the same generic recipe so the two + graphs differ only through backend data. Keep legacy schema-v2 handling + during migration, but never fall back from a malformed new specification. + - Tests: unknown nodes/parameters/handles, dangling edges, invalid bindings, + stable hashing, model switch with identical topology, runtime hints and + proof receipts bound to specification identity, and Auto candidate + overrides restricted to declared bindings. + - Evidence 2026-08-11: backend commit `fd258d8` owns the versioned Flux + Schnell and Flux Dev text-to-image specifications, validates them against + the live node registry, publishes their exact content hashes, and rejects + mismatched execution receipts before graph execution. Client commit + `642ea9c` strictly parses those specifications, materializes both models + through one generic graph recipe, preserves node IDs/topology across the + model switch, and seals the selected specification into finalization and + runtime receipts. The exact backend tree passed 1,121 tests with 4 skips + and 1,762 subtests; the focused specification matrix passed 104 tests with + 466 subtests. Ruff E9/F, `uv pip check` (78 compatible packages), preflight, + and diff checks passed. The client specification contracts passed 136/136, + the complete `npm run check` passed, and the complete mocked Studio browser + suite passed 87/87. The production bundle was 522606/523264 gzip bytes, + 530 bytes below the stricter 523136-byte safety target. The mirrored build + matched all 26 generated files byte-for-byte while preserving 317 backend- + owned Gallery files. A fresh HTTP smoke served the exact entry bundle, + reported 131 nodes, and published both specification IDs with hashes + `studio-spec-v1-9cd1abb5` and `studio-spec-v1-d5ee399d`; its owned process + tree was stopped and port 8088 was free afterward. This is static, unit, + contract, mocked-browser, and local HTTP evidence only: no model download, + model execution, generated media, or live workload qualification occurred. + - [x] **P0.3e Remove remaining frontend and node model switches** + - Migrate one exact pair per backend/client commit from `modelProfiles` and + `graphBridge` into validated specifications. Then move loader component + requirements, Denoise field visibility, Layers allowlists, Guider and + Scheduler compatibility, readiness, and resource metadata into reviewed + declarative overlays. + - Composite creative workflows remain versioned template/recipe data, but + reuse the same generic nodes and bindings. Imported/manual Expert graphs + remain editable and do not silently become managed Studio graphs. + - Tests: graph equivalence for every migrated pair, model switching, + dynamic parameter refresh, input/output normalization, readiness, Auto + fail-closed behavior, and removal of the corresponding class-name branch. + - [x] `FluxKreaPipeline:text_to_image`: backend commit `96f70cb` moves its + execution profile, capability, Auto resource contract, generic roles, + topology, field bindings, and receipt hash into the specification + registry. Client commit `80ac243` removes Krea from the Flux-family graph + switch and resolves the direct loader and `FluxPipeline` class from the + exact backend specification/profile. Schnell, Dev, and Krea retain the + same managed node IDs and edge shape while their artifacts and receipt + identities remain distinct. The focused backend matrix passed 66 tests + with 321 subtests; the exact full backend tree passed 1,121 tests with 4 + skips and 1,762 subtests. The client execution-spec matrix passed 136/136, + the complete `npm run check` passed, and the complete mocked Studio browser + suite passed 87/87. The production bundle was 522633/523264 gzip bytes, + 503 bytes below the stricter 523136-byte safety target. The mirror matched + all 26 generated files byte-for-byte and preserved 317 backend-owned + Gallery files. Ruff E9/F, package compatibility, preflight, formatting, + lint, type, and diff checks passed. This is static, unit, contract, mocked- + browser, build, and local preflight evidence only; no Krea download, model + execution, generated media, or live workload qualification occurred. + - [x] `FluxDepthPipeline:control_image`: backend commit `a299d1d` moves its + execution profile, capability, Auto resource contract, generic image + loader, control-image loader, control generator, preview route, field + bindings, and receipt hash `studio-spec-v1-2d8b881e` into the specification + registry. Client commit `2de0c68` removes Depth from the Flux-family graph + switch and resolves the direct facade plus `FluxControlPipeline` identity + from the exact backend specification/profile. The focused backend matrix + passed 67 tests with 321 subtests; the exact mirrored backend tree passed + 1,122 tests with 4 skips and 1,762 subtests. The client execution-spec + matrix passed 136/136, the complete `npm run check` passed, and the full + mocked Studio browser suite passed 87/87. The production bundle was + 522635/523264 gzip bytes, 501 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files, and the local HTTP smoke + returned 200 for the index and all seven referenced assets. Ruff E9/F, + package compatibility, preflight, formatting, lint, type, and diff checks + passed. This is static, unit, contract, mocked-browser, build, and local + HTTP evidence only; no Depth download, model execution, generated media, + or live workload qualification occurred. + - [x] `FluxCannyPipeline:control_image`: backend commit `14fef9f` moves its + execution profile, capability, Auto resource contract, reviewed compatible + repair source, shared generic control-image recipe, field bindings, and + receipt hash `studio-spec-v1-82045f56` into the specification registry. + Client commit `784e3c7` removes Canny from the Flux-family graph and + `FluxControlPipeline` class switches; Depth and Canny now reuse the exact + same managed role IDs and edge shape while retaining distinct artifacts, + profiles, and receipts. The focused backend matrix passed 95 tests with + 443 subtests; the exact mirrored backend tree passed 1,122 tests with 4 + skips and 1,762 subtests. The client execution-spec matrix passed 136/136, + the complete `npm run check` passed, and the final full mocked Studio + browser suite passed 87/87 after its legacy empty-capability fixture was + corrected to serve the new exact schema-v2 Canny contract. The production + bundle was 522626/523264 gzip bytes, 510 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 backend-owned Gallery files, and the + local HTTP smoke returned 200 for the index and all seven referenced + assets. Ruff E9/F, package compatibility, preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, mocked- + browser, build, and local HTTP evidence only; no Canny download, repair, + model execution, generated media, or live workload qualification occurred. + - [x] `FluxReduxPipeline:edit_image`: backend commit `6be23e7` moves its + execution profile, capability, Auto resource contract, generic image + loader, reference-image loader, edit generator, preview route, field + bindings, and receipt hash `studio-spec-v1-18e2c4ac` into the + specification registry. Client commit `b709126` removes Redux from the + Flux-family graph and pipeline-class switches and materializes the exact + edit recipe from the backend contract. The focused backend matrix passed + 67 tests with 321 subtests; the exact mirrored backend tree passed 1,122 + tests with 4 skips and 1,762 subtests. The focused client graph suite + passed 43/43, the complete `npm run check` passed, the exact Redux browser + transition passed 1/1, and the final full mocked Studio browser suite + passed 87/87. The production bundle was 522627/523264 gzip bytes, 509 + bytes below the stricter 523136-byte safety target. The mirror matched all + 26 generated files byte-for-byte while preserving 317 backend-owned + Gallery files, and the fresh local HTTP smoke returned 200 for the index + and all seven referenced assets. Ruff E9/F, package compatibility, + preflight, formatting, lint, type, and diff checks passed. This is static, + unit, contract, mocked-browser, build, and local HTTP evidence only; no + Redux download, model execution, generated media, or live workload + qualification occurred. + - [x] `WanTI2VPipeline:text_to_video`: backend commit `276dd1f` moves its + execution profile, capability, Auto resource contract, generic video + quantization, execution-recipe, pipeline, generation, and export roles, + field bindings, and receipt hash `studio-spec-v1-da22e734` into the + specification registry. Client commit `049addb` removes the TI2V model + from the legacy video pipeline, artifact, native-flash, and scheduler + switches and materializes the five-node recipe from the backend contract. + Follow-up backend `6983ce6` and client `60f4036` restore the prior video + node coordinates and clear native-flash component selection on CPU; those + corrections are covered by the subsequent I2V full-gate evidence below. + The original focused backend matrix passed 67 tests with 325 subtests; + the exact pre-mirror backend tree passed 1,122 tests with 4 skips and + 1,766 subtests. The focused client specification test and exact mocked- + browser transition each passed 1/1, the complete `npm run check` passed, + and the final full mocked Studio browser suite passed 87/87. The + production bundle was 522709/523264 gzip bytes, 427 bytes below the + stricter 523136-byte safety target. The mirror matched all 26 generated + files byte-for-byte while preserving 317 backend-owned Gallery files, + and a fresh local HTTP smoke returned 200 for the index and all eight + requested generated asset references. Ruff E9/F, package compatibility, + preflight, formatting, lint, type, and diff checks passed. This is static, + unit, contract, mocked-browser, build, and local HTTP evidence only; no + Wan model download, model execution, generated media, or live workload + qualification occurred. + - [x] `WanImageToVideoPipeline:image_to_video`: backend commit `6983ce6` + moves its execution profile, capability, Auto resource contract, generic + video runtime/loader/generator/export roles, generic opening-image loader, + exact image edge, dual-transformer bindings, and receipt hash + `studio-spec-v1-fed2321d` into the specification registry. Client commit + `60f4036` removes the I2V class, artifact, VAE-tiling, quantization, and + native-flash branches from `graphBridge` while preserving the separate + restored Modular-video route. The focused backend matrix passed 68 tests + with 329 subtests; the exact pre-mirror backend tree passed 1,123 tests + with 4 skips and 1,770 subtests. The focused client graph contract passed + 1/1, the I2V and complete eight-spec mocked-browser transitions passed + 2/2, the complete `npm run check` passed, and the final full mocked Studio + browser suite passed 87/87. The production bundle was 522682/523264 gzip + bytes, 454 bytes below the stricter 523136-byte safety target. The mirror + matched all 26 generated files byte-for-byte while preserving 317 backend- + owned Gallery files. A fresh worker returned 200 for the index and all + eight requested assets and published both Wan hashes across eight exact + specifications before its process stopped and port 8088 became free. + Ruff E9/F, `py_compile`, package compatibility, preflight, formatting, + lint, type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no I2V model download, + model execution, generated media, or live workload qualification occurred. + - [x] `WanVideoPipeline:text_to_video`: backend commit `92cd1f5` + moves the exact `wan-text-to-video:direct` profile, mode-specific Auto + requirements, five-node generic video recipe, declarative form bindings, + and receipt hash `studio-spec-v1-10c9a3f2` into the specification registry. + Client commit `0e359ce` adds the exact bounded + `studioExecutionSpecModes` ownership contract, materializes the migrated + mode from the backend recipe, and removes the now-unreachable legacy + `WanPipeline` construction/native-flash switches. The marker/spec mode sets + must match exactly, so a missing, duplicate, unknown, or mismatched claimed + mode fails closed; the unclaimed `video_to_video` and `video_color_edit` + siblings retain their prior `WanVideoToVideoPipeline` graph path. The + focused backend matrix passed 69 tests with 329 subtests; the exact mirrored + backend tree passed 1,124 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The focused client parser/graph + matrix passed 70/70, the exact mocked-browser recipe and sibling-mode + transition passed 1/1, the complete `npm run check` passed, and the final + full mocked Studio browser suite passed 87/87. The production bundle was + 522738/523264 gzip bytes, 398 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files. A fresh worker returned 200 + for the index and all 23 generated assets, published nine exact specs and + the Wan marker/hash, and port 8088 was free after the worker stopped. Ruff + 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no Wan model + download, model execution, generated media, or new live workload + qualification occurred. + - [x] `WanVideoPipeline:video_to_video`: backend commit `93b1e17` + moves the existing `wan-video-to-video:direct` profile, seven-role generic + video-input/normalization recipe, exact source-video and dimension/frame + bindings, and receipt hash `studio-spec-v1-473c930e` into the specification + registry. Client commit `651eeb3` extends the bounded generic role/source + vocabulary and materializes the exact V2V topology from that receipt. The + sibling `video_color_edit` mode remains deliberately unclaimed on its + legacy graph path. The focused backend matrix passed 131 tests with 348 + subtests; the exact mirrored backend tree passed 1,127 tests with 4 skips, + the existing Diffusers deprecation warning, and 1,770 subtests. The + focused client graph contract and exact mocked-browser transition each + passed 1/1, the complete `npm run check` passed, and the final full mocked + Studio browser suite passed 87/87 in 219 seconds. The production bundle + was 522745/523264 gzip bytes, 391 bytes below the stricter 523136-byte + safety target. The mirror matched all 26 generated files byte-for-byte + while preserving 317 backend-owned Gallery files. A fresh supervised + server returned 200 for `/`, the favicon, and all 23 generated assets, + published seventeen exact specs with Wan ownership limited to + `text_to_video` and `video_to_video`, and exposed the exact V2V hash while + omitting a color-edit receipt. Its verified five-process supervisor and + worker tree stopped and port 8088 was free. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no Wan model + download, model execution, generated media, or live workload + qualification occurred. + - [x] `WanVideoPipeline:video_color_edit`: backend commit `09b1d4b` + seals the final Wan 2.1 sibling with the same reviewed seven-role V2V + recipe and a distinct receipt hash `studio-spec-v1-0be460bc`. Client + commit `2525937` materializes that exact receipt and removes the remaining + `WanVideoPipeline` class, repository, and scheduler branches from + `graphBridge`; all three Wan 2.1 modes are now specification-owned. The + focused backend matrix passed 131 tests with 348 subtests; the exact + mirrored backend tree passed 1,127 tests with 4 skips, the existing + Diffusers deprecation warning, and 1,770 subtests. The focused client graph + contract and exact mocked-browser transition each passed 1/1, the complete + `npm run check` passed, and the complete mocked Studio browser suite passed + 87/87 in 219.2 seconds. The production bundle was 522662/523264 gzip bytes, + 474 bytes below the stricter 523136-byte safety target. The mirror matched + all 26 generated files byte-for-byte while preserving 317 backend-owned + Gallery files. A fresh supervised server returned 200 for `/`, the favicon, + and all 23 generated assets and published eighteen exact specs with all + three Wan modes; the V2V and color-edit hashes were distinct while sharing + the reviewed profile. Its verified five-process supervisor and worker tree + stopped and port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, + `uv pip check` (78 packages), preflight, formatting, lint, type, and diff + checks passed. This is static, unit, contract, mocked-browser, build, and + local HTTP evidence only; no Wan model download, model execution, + generated media, or live workload qualification occurred. + - [x] `LTXVideoPipeline:text_to_video`: backend commit `7736dd3` moves + the existing four-mode `ltx-video:direct` profile and the text-to-video + generic video recipe into the versioned specification registry with + receipt `studio-spec-v1-8f100d39`. Client commit `9f2122f` materializes + the exact `LTXConditionPipeline` loader, portable native-math attention, + and LTX-specific generation bindings without adding a model-named graph + branch. Exact ownership remains limited to `text_to_video`; the sibling + image-, video-, and reference-to-video modes retain their prior paths. + The focused backend matrix passed 132 tests with 348 subtests; the exact + mirrored backend tree passed 1,128 tests with 4 skips, the existing + Diffusers deprecation warning, and 1,770 subtests. The focused client + graph contract and exact mocked-browser transition each passed 1/1, the + complete `npm run check` passed, and the final full mocked Studio browser + suite passed 87/87 in 220.8 seconds. The production bundle was + 522678/523264 gzip bytes, 458 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files. A fresh supervised server + returned 200 for `/`, the favicon, and all 23 generated assets, published + nineteen exact specs with only the LTX text mode claimed, and exposed the + exact LTX receipt and class. Its verified five-process tree stopped and + port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, formatting, lint, type, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local HTTP + evidence only; no LTX model download, model execution, generated media, + or live workload qualification occurred. + - [x] `LTXVideoPipeline:video_to_video`: backend commit `e83c760` + adds the reviewed load/normalize video route and binds source-trajectory + `strength` to `conditioningScale` while preserving form `strength` as the + independent `denoise_strength`. Receipt `studio-spec-v1-ad97d224` keeps + the shared `LTXConditionPipeline`, portable attention, and no-scheduler- + shift contract. Client commit `8bd95e6` proves the exact topology and both + values through generic specification materialization. Exact LTX ownership + now covers text-, image-, and video-to-video; reference-to-video remains + unclaimed. The focused backend matrix passed 132 tests with 348 subtests; + the exact mirrored backend tree passed 1,128 tests with 4 skips, the + existing Diffusers deprecation warning, and 1,770 subtests. The focused + client graph contract and exact mocked-browser transition each passed + 1/1, the complete `npm run check` passed, and the full mocked Studio + browser suite passed 87/87 in 221.1 seconds. The unchanged production + bundle was 522678/523264 gzip bytes, 458 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 backend-owned Gallery files. A fresh + supervised server returned 200 for `/`, the favicon, and all 23 generated + assets, published twenty-one exact specs and all three expected LTX + hashes, and exposed both distinct strength bindings. Its verified five- + process tree stopped and port 8088 was free. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, mocked- + browser, build, and local HTTP evidence only; no LTX model download, + model execution, generated media, or live workload qualification + occurred. + - [x] `LTXVideoPipeline:image_to_video`: backend commit `7b8d5c1` + adds the exact image sibling over the reviewed `ltx-video:direct` profile + with the generic source-image role, `image -> reference_images` edge, + reference list/alpha bindings, and receipt `studio-spec-v1-71f17ad0`. + Client commit `709ddd3` proves that the generic specification path + preserves portable native-math attention and LTX generation behavior + without inheriting Wan dual-transformer, forced-tiling, native-flash, or + scheduler bindings. Exact LTX ownership is now text- and image-to-video; + video- and reference-to-video remain deliberately unclaimed. The focused + backend matrix passed 132 tests with 348 subtests; the exact mirrored + backend tree passed 1,128 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The focused client graph contract + and exact mocked-browser transition each passed 1/1, the complete + `npm run check` passed, and the full mocked Studio browser suite passed + 87/87 in 222.4 seconds. The unchanged production bundle was + 522678/523264 gzip bytes, 458 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files. A fresh supervised server + returned 200 for `/`, the favicon, and all 23 generated assets, published + twenty exact specs with only the two migrated LTX modes claimed, and + exposed both expected hashes. Its verified five-process tree stopped and + port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, formatting, lint, type, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local HTTP + evidence only; no LTX model download, model execution, generated media, + or live workload qualification occurred. + - [x] `LTXVideoPipeline:reference_to_video`: backend commit `0bdc364` + seals the fourth LTX mode with the reviewed multi-image reference route + and distinct receipt `studio-spec-v1-0c5abd50`, reusing the exact generic + image role, edge, and bindings rather than adding a mode-specific node. + Client commit `cddd140` proves multiple reference paths, alpha handling, + portable attention, and receipt selection through the shared + materializer. All four LTX modes are now specification-owned. The focused + backend matrix passed 132 tests with 348 subtests; the exact mirrored + backend tree passed 1,128 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The focused client graph contract + and exact mocked-browser transition each passed 1/1, the complete + `npm run check` passed, and the full mocked Studio browser suite passed + 87/87 in 221.3 seconds. The unchanged production bundle was + 522678/523264 gzip bytes, 458 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files. A fresh supervised server + returned 200 for `/`, the favicon, and all 23 generated assets, published + twenty-two exact specs, and exposed all four LTX modes, hashes, and the + single reviewed pipeline class. Its verified five-process tree stopped + and port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` + (78 packages), preflight, formatting, lint, type, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local HTTP + evidence only; no LTX model download, model execution, generated media, + or live workload qualification occurred. + - [x] `AceStepAudioPipeline:text_to_audio`: backend commit `adcaf48` + moves the existing `ace-step-audio:direct` profile and reviewed ACE-Step + repositories into the specification registry, then seals the generic + quantization, recipe, audio loader, generator, and exporter route with + receipt `studio-spec-v1-4bc8ed64`. Client commit `5f91ed9` materializes + the exact `AceStepPipeline` text-to-music recipe, form values, 48 kHz + generation/export contract, and template base-model override through the + shared specification path without adding a model-name graph branch. + Exact ownership at this checkpoint was limited to `text_to_audio`; + variation joined it in the immediately following migration, while + continuation and repaint remained on their existing unclaimed paths. The + focused backend matrix passed 245 tests with 588 subtests; the exact + mirrored backend tree passed 1,129 tests with 4 skips, the existing + Diffusers deprecation warning, and 1,770 subtests. The focused client + graph contract and exact mocked-browser transition each passed 1/1, the + complete `npm run check` passed, and the complete mocked Studio browser + suite passed 87/87 in 221 seconds. The production bundle was + 522816/523264 gzip bytes, 320 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 installer-owned Gallery files. A fresh supervised server + returned 200 with byte-exact content for `/`, the favicon, and all 23 + generated assets, published twenty-three exact specs with only ACE-Step + text-to-audio claimed, and exposed the reviewed receipt and pipeline + class. Its verified six-process tree stopped and port 8088 was free. Ruff + 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no ACE-Step + model download, model execution, generated audio, or live workload + qualification occurred. + - [x] `AceStepAudioPipeline:audio_variation`: backend commit `5f6ffdc` + adds the source-audio loader, exact `audio -> source_audio` edge, `cover` + task binding, and distinct receipt `studio-spec-v1-eb222623` over the + same reviewed `ace-step-audio:direct` profile. Client commit `2a776c0` + extends the bounded generic role/source vocabulary and proves source-file + binding, exact topology, task selection, and receipt materialization + without adding an ACE-Step mode branch. Exact ACE-Step ownership at this + checkpoint covered text generation and variation; continuation joined it + in the immediately following migration, while repaint remained + deliberately unclaimed. The focused backend matrix passed 245 tests with + 588 subtests; the exact mirrored backend tree passed 1,129 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests. + The focused client graph contract and exact mocked-browser transition + each passed 1/1, the complete `npm run check` passed, and the complete + mocked Studio browser suite passed 87/87 in 221.5 seconds. The production + bundle was 522832/523264 gzip bytes, 304 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 installer-owned Gallery files. A + fresh supervised server returned 200 with byte-exact content for `/`, the + favicon, and all 23 generated assets, published twenty-four exact specs, + and exposed exactly the text and variation ACE-Step modes plus the new + receipt. Its owned process tree stopped and port 8088 was free. Ruff + 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no ACE-Step + model download, model execution, generated audio, or live workload + qualification occurred. + - [x] `AceStepAudioPipeline:audio_continuation`: backend commit `10b9b1c` + adds the exact source-audio route, `continuation` task and tail binding, + generic loudness-match and audio-join roles, reviewed loudness/fade + constants, and receipt `studio-spec-v1-541adefc` over the same + `ace-step-audio:direct` profile. Client commit `7e69367` extends only the + bounded generic role/source vocabulary and proves the exact + `Load -> Generate -> MatchLoudness -> Join -> Export` topology, values, + readiness, and receipt without adding an ACE-Step mode branch. Exact + ACE-Step ownership at this checkpoint covered text generation, variation, + and continuation; repaint joined them in the immediately following + migration. The focused + backend matrix passed 245 tests with 588 subtests; the exact mirrored + backend tree passed 1,129 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The complete client graph suite + passed 43/43, the exact mocked-browser transition passed 1/1, the complete + `npm run check` passed, and the complete mocked Studio browser suite + passed 87/87 in 3.7 minutes. The production bundle was + 522943/523264 gzip bytes, 193 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 installer-owned Gallery files. A fresh supervised server + served byte-exact content for `/`, the favicon, and all 23 generated + assets, published twenty-five exact specs with exactly the three claimed + ACE-Step modes, and exposed the continuation receipt. Its verified six- + process tree stopped and port 8088 was free. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, mocked- + browser, build, and local HTTP evidence only; no ACE-Step model download, + model execution, generated audio, or live workload qualification + occurred. + - [x] `AceStepAudioPipeline:audio_repaint`: backend commit `60b87a1` + seals the final ACE-Step sibling with the generic source-audio route, + exact `repaint` task and repaint-range bindings, and distinct receipt + `studio-spec-v1-8f5c37c7` over the same reviewed + `ace-step-audio:direct` profile. Client commit `62dfe17` adds only the + bounded `repaint` binding source and proves the source-file, task, range, + topology, readiness, and receipt through the shared specification + materializer. All four ACE-Step modes are now specification-owned. The + focused backend matrix passed 245 tests with 588 subtests; the exact + mirrored backend tree passed 1,129 tests with 4 skips, the existing + Diffusers deprecation warning, and 1,770 subtests. The complete client + graph suite passed 43/43, the exact mocked-browser transition passed 1/1, + the complete `npm run check` passed, and the complete mocked Studio + browser suite passed 87/87 in 3.7 minutes. The production bundle was + 522950/523264 gzip bytes, 186 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 installer-owned Gallery files. A fresh supervised server + served byte-exact content for `/`, the favicon, and all 23 generated + assets, published twenty-six exact specs with all four ACE-Step modes, + and exposed the repaint receipt. Its verified six-process tree stopped + and port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` + (78 packages), preflight, formatting, lint, type, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local HTTP + evidence only; no ACE-Step model download, model execution, generated + audio, or live workload qualification occurred. + - [x] `QwenImageEditModularPipeline:inpaint`: backend commit `de2160f` + seals the unambiguous `qwen-edit:direct-inpaint` profile, reviewed + `QwenImageEditInpaintPipeline`, source-image and mask loaders, generic + inpaint/preview route, and exact form bindings with receipt + `studio-spec-v1-ac52abb3`. Client commit `f28ff89` materializes that + contract through the shared specification path and removes three inert + model-named direct-Qwen diagnostic predicates. Exact ownership remains + limited to inpaint; the distinct outpaint and Modular edit recipes remain + deliberately unclaimed. The focused backend matrix passed 246 tests with + 588 subtests; the exact mirrored backend tree passed 1,130 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests. + The complete client graph suite passed 43/43, the exact mocked-browser + transition passed 1/1, the complete `npm run check` passed, and the + complete mocked Studio browser suite passed 87/87 in 3.7 minutes. The + production bundle remained 522950/523264 gzip bytes, 186 bytes below the + stricter 523136-byte safety target. The mirror matched all 26 generated + files byte-for-byte while preserving 317 installer-owned Gallery files. + A fresh supervised server served byte-exact content for `/`, the favicon, + and all 23 generated assets, published twenty-seven exact specs, exposed + only the Qwen Image Edit inpaint marker/receipt, and kept outpaint + unclaimed. Its verified six-process tree stopped and port 8088 was free. + Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no Qwen + model download, model execution, generated image, or live workload + qualification occurred. + - [x] `WanVACEPipeline:text_to_video`: backend commit `69a8561`, corrected + by `2616014` and mirrored by `69247d0`, + seals the reviewed `wan-vace:direct` profile, immutable + `Wan-AI/Wan2.1-VACE-1.3B-diffusers` artifact, shared generic video + recipe, exact text-mode bindings, and receipt + `studio-spec-v1-4a34e319`. Client commit `8f05541`, corrected by + `4c9d40c`, proves that the + existing specification materializer builds the exact four-edge route, + pipeline identity, artifact, mode, readiness, and receipt without a new + model-named production branch. The correction binds and persists the + catalog's reviewed `ec4d2cb062b548996b179d493fdd05340de702a1` + revision instead of relying only on the loader's execution-time catalog + resolution. At this checkpoint ownership remained limited to + text-to-video; VACE inpaint, outpaint, and control modes were + deliberately unclaimed until their distinct conditioned-input graphs + were migrated. The focused backend matrix passed 188 tests with 360 + subtests; the exact mirrored backend tree passed 1,131 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests. + The complete client graph suite passed 43/43, the exact mocked-browser + transition passed 1/1, the complete `npm run check` passed, and the final + exact-tree mocked Studio browser suite passed 87/87 in 3.7 minutes. The + production bundle was 522970/523264 gzip bytes, 166 bytes below the + stricter 523136-byte safety target. The mirror matched all 26 generated + files byte-for-byte while preserving 317 installer-owned Gallery files. + A fresh supervised server served byte-exact content for `/`, the + favicon, and all 23 generated assets, published twenty-eight exact + specs, exposed only the Wan VACE text-to-video marker/receipt, and kept + its three conditioned siblings unclaimed. Its verified six-process tree + stopped and port 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip + check` (78 packages), preflight, formatting, lint, type, and diff checks + passed. This is static, unit, contract, mocked-browser, build, and local + HTTP evidence only; no Wan VACE model download, model execution, + generated video, or live workload qualification occurred. + - [x] `WanVACEPipeline:video_inpaint`: backend and bundled-client commit + `0e9c3f2` extends the reviewed `wan-vace:direct` specification with the + exact source-video normalization and aligned-mask route, immutable VACE + revision binding, source and mask file bindings, and reviewed threshold + 127 / 96-pixel inpaint mask-growth policy. Client commit `3f79ca2` + accepts only those new generic roles and binding sources, materializes + the exact nine-role/nine-edge recipe, and verifies receipt + `studio-spec-v1-d0b56303`, both media inputs, mask policy, mode, and Run + readiness without a model-named production branch. At this checkpoint + ownership was limited to `video_inpaint`; VACE outpaint and + control-to-video remained unclaimed until their distinct conditioned- + input contracts were migrated. The + focused backend matrix passed 189 tests with 360 subtests; the complete + backend gate passed 1,132 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The client graph suite passed + 43/43, the exact mocked-browser transition passed 1/1, the complete + `npm run check` passed, and the frozen complete mocked Studio suite + passed 87/87 in 3.6 minutes. The production bundle was + 523045/523264 gzip bytes, 91 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 installer-owned Gallery files. A fresh backend served + byte-exact content for all 25 public generated files, published 29 exact + specs, and exposed exactly the VACE text-to-video and video-inpaint + markers with the inpaint revision source and mask route intact. Its + verified three-process tree stopped and port 8088 was free. Ruff 0.12.7 + E9/F, `py_compile`, `uv pip check` (78 packages), preflight, formatting, + lint, type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no Wan VACE model + download, model execution, generated video, or live workload + qualification occurred. + - [x] `WanVACEPipeline:video_outpaint`: backend and bundled-client commit + `b004af1` adds the distinct outpaint receipt over the reviewed VACE + source-normalization and aligned-boundary-mask route. It retains the + immutable VACE artifact revision and threshold 127 while binding the + legacy outpaint policy to zero mask growth. Client commit `fce224e` + materializes that same nine-role/nine-edge graph, seals receipt + `studio-spec-v1-1fd16911`, and verifies both media inputs, the distinct + zero-growth policy, mode, and Run readiness without a model-named + production branch. Ownership is limited to `video_outpaint`; VACE + control-to-video remains unclaimed until its distinct control-input + contract is migrated. The focused backend matrix passed 190 tests with + 360 subtests; the complete backend gate passed 1,133 tests with 4 skips, + the existing Diffusers deprecation warning, and 1,770 subtests. The + client graph suite passed 43/43, the exact mocked-browser transition + passed 1/1, the complete `npm run check` passed, and the frozen complete + mocked Studio suite passed 87/87 in 3.6 minutes. The production bundle + was 523055/523264 gzip bytes, 81 bytes below the stricter 523136-byte + safety target. The mirror matched all 26 generated files byte-for-byte + while preserving 317 installer-owned Gallery files. A fresh backend + served byte-exact content for all 25 public generated files, published + 30 exact specs, and exposed exactly the VACE text-to-video, video- + inpaint, and video-outpaint markers with the distinct outpaint growth + source intact. Its verified five-process tree stopped and port 8088 was + free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), + preflight, formatting, lint, type, and diff checks passed. This is + static, unit, contract, mocked-browser, build, and local HTTP evidence + only; no Wan VACE model download, model execution, generated video, or + live workload qualification occurred. + - [x] `WanVACEPipeline:control_to_video`: backend and bundled-client commit + `ed07f34` completes the exact specification coverage for all four + advertised VACE modes. It binds the reviewed direct loader and immutable + VACE revision to a separate control-video loader, width/height/frame-count + normalization, generator `video` input, and the existing generic export + route; it does not admit the source-video or mask branches. Client commit + `72ed446` accepts only the added generic control-video role and form source, + materializes the exact seven-role/six-edge graph, seals receipt + `studio-spec-v1-d05d263d`, and verifies the control file, normalized frame + count, mode, absence of source/mask roles, and Run readiness without a + model-named production branch. The focused backend matrix passed 191 tests + with 360 subtests; the complete backend gate passed 1,134 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests in + 45.08 seconds. The client graph suite passed 43/43, the exact mocked-browser + transition passed 1/1, the complete `npm run check` passed, and the frozen + complete mocked Studio suite passed 87/87 in 220.1 seconds. The production + bundle was 523065/523264 gzip bytes, 71 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 installer-owned Gallery files. A fresh + supervised server served byte-exact content for all 25 public generated + files, published 31 exact specs, and exposed exactly all four advertised + VACE markers with the control receipt, topology, and bindings intact. Its + verified five-process tree stopped and port 8088 was free. Ruff 0.12.7 + E9/F, `py_compile`, `uv pip check` (78 packages), preflight, formatting, + lint, type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no Wan VACE model + download, model execution, generated video, or live workload qualification + occurred. + - [x] `QwenImageEditModularPipeline:outpaint`: backend and bundled-client + commit `823357d` extends the reviewed `qwen-edit:direct-inpaint` profile + with the distinct generated-canvas route, removes the separate mask-file + loader from this mode, and seals every boundary-placement binding with + receipt `studio-spec-v1-4ffd900b`. Client commit `de2eba1` accepts only + the existing generic outpaint-canvas role and seven form sources, + materializes the exact seven-role/seven-edge source-to-canvas-to-inpaint + graph, and verifies the source file, canvas dimensions and offsets, mask + route, absence of `loadMask`, receipt, and Run readiness without a new + model-named production branch. Qwen Image Edit inpaint and outpaint are + now specification-owned; its distinct Modular edit recipe remains + deliberately unclaimed. The focused backend matrix passed 192 tests with + 360 subtests; the complete backend gate passed 1,135 tests with 4 skips, + the existing Diffusers deprecation warning, and 1,770 subtests in 41.89 + seconds. The client graph suite passed 43/43, the exact mocked-browser + transition passed 1/1, the complete `npm run check` passed, and the + frozen complete mocked Studio suite passed 87/87 in 219.6 seconds. The + production bundle was 523109/523264 gzip bytes, 27 bytes below the + stricter 523136-byte safety target. The mirror matched all 26 generated + files byte-for-byte while preserving 317 installer-owned Gallery files. + A fresh backend served byte-exact content for all 25 public generated + files, published 32 exact specs, and exposed both Qwen Image Edit modes + with the outpaint receipt, topology, and bindings intact. Its verified + five-process tree stopped and port 8088 was free. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no Qwen model + download, model execution, generated image, or live workload + qualification occurred. + - [x] `ZImageModularPipeline:text_to_image`: backend commit `a4efd6c` + moves the existing direct `z-image:auto` loader profile and generic + five-node image recipe into exact specification ownership with receipt + `studio-spec-v1-0d3c1205`. Client commit `77ceab9` proves the existing + generic materializer consumes that contract without a production source + change. The complete evidence and remaining six-pair boundary are recorded + in the P0.4 receipt item below. + - [x] `QwenImageModularPipeline:text_to_image`: backend commit `6e40bab` + moves the existing direct `qwen-image:t2i-direct` profile and generic + five-node image recipe into exact specification ownership with receipt + `studio-spec-v1-f53ab380`. Client commit `531d4b9` proves the current + official and reviewed prequantized artifact candidates bind and consume + the same generic graph contract without a production source change. The + complete evidence and remaining five-pair boundary are recorded in the + P0.4 receipt item below. + - [x] `FluxKontextPipeline:edit_image`: backend commit `5f4d437` + moves the existing `flux-kontext:direct` profile, exact edit-only Auto + requirements, six-node generic edit recipe, declarative form bindings, + and receipt hash `studio-spec-v1-393009a9` into the specification + registry. Client commit `8ae0dd9` proves the generic specification path + without adding a new model-named production branch. Exact mode ownership + was limited to `edit_image`; `multi_image_reference_edit` remained on its + existing generic legacy graph until the immediately following paired + migration. The focused backend + matrix passed 98 tests with 451 subtests; the exact mirrored backend tree + passed 1,125 tests with 4 skips, the existing Diffusers deprecation + warning, and 1,770 subtests. The focused client graph contract and exact + mocked-browser recipe/sibling transition each passed 1/1, the complete + `npm run check` passed, and the final full mocked Studio browser suite + passed 87/87. The unchanged production bundle was 522738/523264 gzip + bytes, 398 bytes below the stricter 523136-byte safety target. The mirror + matched all 26 generated files byte-for-byte while preserving 317 + backend-owned Gallery files. A fresh worker returned 200 for `/`, the + favicon, and all 23 generated assets, published ten exact specs plus the + Kontext `edit_image` marker/hash, and port 8088 was free after its verified + worker stopped. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, formatting, lint, type, and diff checks passed. This + is static, unit, contract, mocked-browser, build, and local HTTP evidence + only; no Kontext download, model execution, generated media, or live + workload qualification occurred. + - [x] `FluxKontextPipeline:multi_image_reference_edit`: backend commit + `119c720` adds the second exact receipt + `studio-spec-v1-aa060039`, reuses the same reviewed + `flux-kontext:direct` loader profile without making loader-only optional- + runtime resolution ambiguous, and keeps Auto requirements explicitly + edit-only. Client commit `d956a42` removes Kontext from the remaining + Flux-family and pipeline-class switches; both modes now materialize the + same generic six-node edit topology entirely from their distinct backend + specifications. The focused backend matrix passed 98 tests with 451 + subtests; the exact mirrored backend tree passed 1,125 tests with 4 skips, + the existing Diffusers deprecation warning, and 1,770 subtests. The + focused two-mode graph contract and exact mocked-browser transition each + passed 1/1, the complete `npm run check` passed, and the final full mocked + Studio browser suite passed 87/87. The production bundle was + 522724/523264 gzip bytes, 412 bytes below the stricter 523136-byte safety + target. The mirror matched all 26 generated files byte-for-byte while + preserving 317 backend-owned Gallery files. A fresh worker returned 200 + for `/`, the favicon, and all 23 generated assets, published eleven exact + specs plus both Kontext mode markers/hashes, and port 8088 was free after + its verified worker stopped. Ruff 0.12.7 E9/F, `py_compile`, `uv pip + check` (78 packages), preflight, formatting, lint, type, and diff checks + passed. This is static, unit, contract, mocked-browser, build, and local + HTTP evidence only; no Kontext download, model execution, generated + media, or live workload qualification occurred. + - [x] `FluxFillPipeline:inpaint`: backend commit `544c54f` moves the + shared `flux-fill:direct` execution profile and existing inpaint/outpaint + Auto resource policy into the versioned specification registry, while + claiming only the inpaint mode with exact source-image, mask, pipeline, + and preview edges plus declarative form bindings. Client commit `38f8d81` + extends the bounded generic parser/materializer for those roles and proves + receipt `studio-spec-v1-ba8c8dd1`; the sibling `outpaint` mode remains + explicitly unclaimed on its prior generic graph path. The focused backend + matrix passed 71 tests with 329 subtests; the exact mirrored backend tree + passed 1,126 tests with 4 skips, the existing Diffusers deprecation + warning, and 1,770 subtests. The focused client graph contract and exact + mocked-browser inpaint/outpaint transition each passed 1/1, the complete + `npm run check` passed, and the final full mocked Studio browser suite + passed 87/87. The production bundle was 522756/523264 gzip bytes, 380 + bytes below the stricter 523136-byte safety target. The mirror matched all + 26 generated files byte-for-byte while preserving 317 backend-owned + Gallery files. A fresh worker returned 200 for `/`, the favicon, and all + 23 generated assets, published twelve exact specs plus the Fill inpaint + marker/hash, and port 8088 was free after its verified worker stopped. + Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no Fill + model download, model execution, generated media, or live workload + qualification occurred. + - [x] `FluxFillPipeline:outpaint`: backend commit `634c485` adds the + distinct outpaint receipt `studio-spec-v1-5c0d7413` over the same exact + reviewed `flux-fill:direct` profile, source-image/mask topology, and + declarative bindings. Client commit `4c0bd05` proves graph equivalence + across both Fill modes and removes Fill from the legacy Flux-family and + pipeline-class switches; neither production graph construction nor + pipeline selection now branches on `FluxFillPipeline`. The focused + backend matrix passed 71 tests with 329 subtests; the exact mirrored + backend tree passed 1,126 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,770 subtests. The focused client graph contract + and exact mocked-browser two-mode transition each passed 1/1, the complete + `npm run check` passed, and the final full mocked Studio browser suite + passed 87/87. The production bundle was 522741/523264 gzip bytes, 395 + bytes below the stricter 523136-byte safety target. The mirror matched all + 26 generated files byte-for-byte while preserving 317 backend-owned + Gallery files. A fresh supervised server returned 200 for `/`, the + favicon, and all 23 generated assets, published thirteen exact specs plus + both Fill markers/hashes, and port 8088 was free after its verified + supervisor and worker stopped. Ruff 0.12.7 E9/F, `py_compile`, `uv pip + check` (78 packages), preflight, formatting, lint, type, and diff checks + passed. This is static, unit, contract, mocked-browser, build, and local + HTTP evidence only; no Fill model download, model execution, generated + media, or live workload qualification occurred. + - [x] `Flux2KleinPipeline:text_to_image`: backend commit `441cd00` + moves the shared three-mode `flux2-klein:direct` profile, capability, and + Auto policy into the versioned specification registry while claiming + only the text-to-image graph through receipt + `studio-spec-v1-e11dfdc6`. Client commit `931621d` proves the exact + generic Diffusers image recipe and stable role topology, while explicitly + keeping `edit_image` and `multi_image_reference_edit` on their prior + unclaimed legacy path. The focused backend matrix passed 129 tests with + 329 subtests; the exact mirrored backend tree passed 1,127 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests. + The focused client graph contract and exact mocked-browser transition + each passed 1/1, the complete `npm run check` passed, and the final full + mocked Studio browser suite passed 87/87 in 218.2 seconds. The production + bundle remained 522741/523264 gzip bytes, 395 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 backend-owned Gallery files. A fresh + supervised server returned 200 for `/`, the favicon, and all 23 generated + assets, and published fourteen exact specs plus the Klein marker/hash. + Its verified supervisor and worker tree stopped and port 8088 was free. + Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. This is static, unit, + contract, mocked-browser, build, and local HTTP evidence only; no Klein + model download, model execution, generated media, or live workload + qualification occurred. + - [x] `Flux2KleinPipeline:edit_image`: backend commit `ab3bd34` + adds the distinct edit receipt `studio-spec-v1-ab4da919` over the same + reviewed `flux2-klein:direct` profile with an exact source-image, + Diffusers Edit, and preview route. Client commit `84e1d8f` proves the + exact edit graph and transition while keeping only + `multi_image_reference_edit` unclaimed. The focused backend matrix passed + 131 tests with 348 subtests; the exact mirrored backend tree passed 1,127 + tests with 4 skips, the existing Diffusers deprecation warning, and 1,770 + subtests. The focused client graph contract and exact mocked-browser + transition each passed 1/1, the complete `npm run check` passed, and the + final full mocked Studio browser suite passed 87/87 in 220.1 seconds. The + production bundle remained 522741/523264 gzip bytes, 395 bytes below the + stricter 523136-byte safety target. The mirror matched all 26 generated + files byte-for-byte while preserving 317 backend-owned Gallery files. A + fresh supervised server returned 200 for `/`, the favicon, and all 23 + generated assets, and published fifteen exact specs plus both Klein + markers/hashes. Its verified supervisor and worker tree stopped and port + 8088 was free. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, formatting, lint, type, and diff checks passed. This + is static, unit, contract, mocked-browser, build, and local HTTP evidence + only; no Klein model download, model execution, generated media, or live + workload qualification occurred. + - [x] `Flux2KleinPipeline:multi_image_reference_edit`: backend commit + `4527764` adds the final distinct Klein receipt + `studio-spec-v1-756c2d69` over the reviewed shared + `flux2-klein:direct` profile and the exact source-image, Diffusers Edit, + and preview route. Client commit `7180694` proves that exact receipt and + graph transition, and removes `Flux2KleinPipeline` from the legacy Flux + family and pipeline-class switches; all three Klein modes are now + specification-owned. The focused backend matrix passed 131 tests with + 348 subtests; the exact mirrored backend tree passed 1,127 tests with 4 + skips, the existing Diffusers deprecation warning, and 1,770 subtests. + The focused client graph contract and exact mocked-browser transition + each passed 1/1, the complete `npm run check` passed, and the final full + mocked Studio browser suite passed 87/87 in 219.1 seconds. The production + bundle was 522725/523264 gzip bytes, 411 bytes below the stricter + 523136-byte safety target. The mirror matched all 26 generated files + byte-for-byte while preserving 317 backend-owned Gallery files. A fresh + supervised server returned 200 for `/`, the favicon, and all 23 generated + assets, and published sixteen exact specs with all three Klein modes and + the exact multi-reference hash. Its verified five-process supervisor and + worker tree stopped and port 8088 was free. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local HTTP evidence only; no Klein model + download, model execution, generated media, or live workload + qualification occurred. + - [x] Exact-pair specification migration: all 39 currently declared + execution-profile pairs now have a backend-owned Studio specification and + a tested generic client receipt. This closes the legacy exact-pair gap but + does not qualify live model execution or the remaining declarative + overlays. + - [x] Loader-component requirement overlay. Backend commit `732e15c` adds a + bounded `loader_component_outputs` contract to reviewed Modular pipeline + metadata. Wan I2V now declares `image_encoder`; `ModelsLoader` consumes + the generic list for both strict required-component loading and published + component receipts, with no Wan class-name branch. Custom sidecars may + carry only a bounded declarative list and remain `contract_only`; the + loader accepts only node-supported outputs from the built-in registry. + The focused Modular/loading matrix passed 253 tests and 440 subtests. The + complete backend gate passed 1,155 tests with 4 skips and 1,795 subtests, + with only the existing Diffusers `torch_dtype` deprecation warning. Ruff + 0.12.7 E9/F, `compileall`, `uv pip check` (78 packages), preflight, and + diff checks passed. This is static, unit, contract, and local preflight + evidence only; no model download, component load, inference, or generated + media occurred. + - [x] Layer-block allowlist overlay. Backend commit `02afc25` moves the six + existing SDXL, Qwen Image/Edit/Edit Plus, Flux, and Flux Kontext + model-to-transformer-stack selections onto bounded reviewed pipeline + metadata. The generic `Layers` node publishes the generated map without + pipeline-class keys and now requires the connected model signal at both + dynamic-field creation and execution. Missing/unknown identities, + duplicate or unlisted paths, and extra injected block configurations fail + before an upstream guidance object can consume them. The focused schema, + registry, and route matrix passed 184 tests and 360 subtests. The complete + backend gate passed 1,156 tests with 4 skips and 1,802 subtests, with only + the existing Diffusers `torch_dtype` deprecation warning. Ruff 0.12.7 + E9/F, `compileall`, `uv pip check` (78 packages), preflight, and diff + checks passed. This is static, unit, contract, and local preflight evidence + only; no model download, inference, guidance execution, or generated media + occurred. + - [x] Denoise image-latent dimension overlay. Backend commit `7228c1f` + replaces the four-class compatibility branch with bounded reviewed + `denoise_image_latent_dimensions` metadata. Qwen Image Edit/Edit Plus, + Flux Kontext, and Flux2 Klein retain legacy hidden `height` and `width` + values when image latents are present; every other registered pipeline + drops them. Unknown, malformed, duplicate, and unlisted metadata fails + closed, while custom sidecars remain `contract_only` and cannot authorize + this built-in execution exception. The focused schema/security matrix + passed 99 tests and 240 subtests; the wider no-weight Modular route matrix + passed 221 tests and 491 subtests. The complete backend gate passed 1,158 + tests with 4 skips and 1,821 subtests, with only the existing Diffusers + `torch_dtype` deprecation warning. Ruff 0.12.7 E9/F, `compileall`, `uv pip + check` (78 packages), preflight, and diff checks passed. This is static, + unit, contract, and local preflight evidence only; no model download, + inference, or generated media occurred. + - [x] Guider compatibility overlay. Backend commit `51206e6` declares the + exact bounded Diffusers guider choices on reviewed Modular pipeline + metadata. SDXL and Qwen Image/Edit/Edit Plus expose the complete reviewed + set because they also declare layer-stack contracts; Qwen Layered, + Z-Image, and Wan expose only non-layer guiders; Flux variants expose no + guider component. The generic Guider selector consumes the generated map + and revalidates the connected pipeline identity at field refresh and + execution, so missing, unknown, malformed, or incompatible selections + fail before constructing an upstream guider. Client commit `d1b2f88` + preserves scalar values for dynamic single-select options while retaining + array values for multi-select Layers. The focused backend schema/security + matrix passed 101 tests and 258 subtests; the wider no-weight Modular route + matrix passed 223 tests and 509 subtests. The complete backend gate passed + 1,160 tests with 4 skips and 1,839 subtests, with only the existing + Diffusers `torch_dtype` deprecation warning. The complete client + `npm run check` passed, the exact model-signal/Guider/Layers mocked-browser + contract passed 1/1, and the production bundle was 523123/523264 gzip + bytes, 13 bytes below the stricter 523136-byte safety target. Ruff 0.12.7 + E9/F, `compileall`, `uv pip check` (78 packages), preflight, formatting, + lint, type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, guider execution, inference, or generated media occurred. + - [x] Scheduler compatibility overlay. Backend commit `6779a19` publishes + bounded scheduler choices from the pinned official Diffusers component + contracts. SDXL and Wan expose the 14 replacements shared by their Euler + or UniPC scheduler compatibility sets; Qwen, Flux, and Z-Image + flow-matching pipelines expose no unsupported legacy replacement. The + generic Scheduler node requires the connected reviewed pipeline identity + during field refresh and execution, verifies the live component class, + and requires the replacement constructor to return the exact selected + official scheduler class. Unknown, malformed, duplicate, oversized, LCM, + TCD, and incompatible selections fail closed. Client commit `140cab2` + extends the generic signal-relay browser contract across Guider, Layers, + and Scheduler without a model-named client branch. The focused backend + schema/security matrix passed 103 tests and 278 subtests; the wider + no-weight Modular route matrix passed 225 tests and 529 subtests. The + complete backend gate passed 1,162 tests with 4 skips and 1,859 subtests, + with only the existing Diffusers `torch_dtype` deprecation warning. The + complete client `npm run check` passed, the exact mocked-browser contract + passed 1/1 twice, and the production bundle was 523123/523264 gzip bytes, + 13 bytes below the stricter 523136-byte safety target. Ruff 0.12.7 E9/F, + `compileall`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, scheduler execution, inference, or generated media occurred. + - [x] Execution-spec readiness overlay. Client commit `7a02806` centralizes + exact specification lookup and derives managed loader capability checks + from the live binding or the backend-advertised loader module/action. It + replaces the Qwen-specific missing-node branch with a generic check over + every role in the exact specification. A deliberately opaque future + loader contract proves offload readiness without a model-name route, and + a schema-v2 Qwen outpaint browser fixture proves a missing exact role is + reported from the authoritative contract. The focused client contract + passed 67/67, the exact specification browser matrix passed 1/1, the + complete client gate passed, and the final mocked Studio suite passed + 88/88. The production bundle passed at 523080/523264 gzip bytes, including + 56 bytes of headroom against the stricter 523136-byte safety target. This + is static, unit, contract, mocked-browser, and build evidence only; no + model download, inference, or generated media occurred. + - [x] Diffusers audio field-contract overlay. Backend commit `2a98856` + makes every reviewed audio pipeline/mode contract publish its canonical + generic `Generate` field overlay, including visibility, required inputs, + task choices, and duration bounds. The field action reconstructs the + exact contract and rejects a stored or client-edited overlay before any + mutation. Client commit `fba496c` removes the duplicate pipeline-class + visibility switches from managed graph synchronization and soundtrack + construction; generic signal/action handling now applies the backend- + authored fields when the selected pipeline or mode changes. The focused + audio suite passed 59 tests with 257 subtests, the adjacent contract matrix + passed 115 tests with 377 subtests, and the complete backend gate passed + 1,162 tests with 4 skips and 1,888 subtests with only the existing + Diffusers deprecation warning. The focused client graph/specification + matrix passed 110/110, `npm run check` passed, the field-update, + exact-audio-recipe, and soundtrack-proof browser paths passed 3/3, and the + complete mocked Studio suite passed 89/89 in 4.5 minutes. The production + bundle was 522793/523264 gzip bytes, 343 bytes below the stricter + 523136-byte safety target. Ruff E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, formatting, lint, type, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local preflight + evidence only; no model download, audio execution, generated media, or + live workload qualification occurred. + - [x] Auto execution-path authority overlay. Client commit `16b7f12` + removes the pre-plan Qwen and broad family execution-path guesses from + the local resource fallback. Auto now stays path-neutral until an exact + selected schema-v2 backend candidate supplies the reviewed loader path; + Expert likewise describes the editable full graph without claiming a + model-specific execution path. All current model/mode fallbacks are + covered by a zero-invented-path contract, while the mocked exact Auto run + proves `direct-diffusers-image` still reaches the submitted receipt from + the bound backend candidate. The focused resource/request matrix passed + 79/79, `npm run check` passed, the exact Auto submission browser path + passed 1/1, and the final complete mocked Studio suite passed 89/89 in + 4.5 minutes. The production bundle was 522524/523264 gzip bytes, 612 + bytes below the stricter 523136-byte safety target. Formatting, lint, + type, build, bundle, and diff checks passed. This is static, unit, + contract, mocked-browser, and build evidence only; no model download, + inference, or generated media occurred. + - [x] Auto retry-mode authority overlay. Backend commit `a1173db` ignores a + duplicate client `resourceRetryModes` list in Auto and derives fallback + offload modes from the selected exact execution profile in canonical + memory-pressure order; Expert retains its bounded explicit hint. Client + commit `52e98d2` stops submitting `supportedOffloadModes` and + `resourceRetryModes` in Auto while preserving the selected candidate and + candidate-bound retry receipts. The focused backend resource/profile + matrix passed 182 tests with 349 subtests, and the complete backend gate + passed 1,163 tests with 4 skips and 1,888 subtests with only the existing + Diffusers deprecation warning. The focused client request/resource matrix + passed 79/79, `npm run check` passed, the exact Auto submission browser + path passed 1/1, and the complete mocked Studio suite passed 89/89 in 273 + seconds. The production bundle was 522530/523264 gzip bytes, 606 bytes + below the stricter 523136-byte safety target. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, formatting, lint, + type, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, inference, generated media, or live workload qualification + occurred. + - [x] Runtime diagnostic-classifier cleanup. Backend commit `85e80f3` + removes client `modelFamily` and `lowVramMode` from the admitted runtime + hint contract and its CUDA diagnostic projection; exact model type, + execution profile, selected recipe, and graph receipt remain the reviewed + execution identities. Client commit `e464cb7` deletes the Qwen-named + low-memory classifier and stops submitting both duplicate labels. The + focused backend runtime/resource matrix passed 177 tests with 307 + subtests, and the complete backend gate passed 1,164 tests with 4 skips + and 1,888 subtests with only the existing Diffusers deprecation warning. + The focused client template/provenance/run matrix passed 123/123, + `npm run check` passed, the exact Auto submission browser path passed 1/1, + and the complete mocked Studio suite passed 89/89 in 280 seconds. The + production bundle was 522389/523264 gzip bytes, 747 bytes below the + stricter 523136-byte safety target. Ruff 0.12.7 E9/F, `py_compile`, `uv + pip check` (78 packages), preflight, formatting, lint, type, and diff + checks passed. This is static, unit, contract, mocked-browser, build, and + local preflight evidence only; no model download, inference, generated + media, or live workload qualification occurred. + - [x] Diffusers video field-contract overlay. Backend commit `b32241b` + declares a complete reviewed field contract for every registered generic + video adapter/mode pair. The existing exact pipeline signal now drives + mode choices, input visibility and requiredness, adapter-specific + controls, and the shared strength control's declarative Studio binding; + the action reconstructs the canonical signal and rejects a stale or + edited contract before any field mutation. Client commit `947f7d9` + expands the bounded identity-binding grammar only to the reviewed + `strength` and `conditioningScale` form fields and removes the remaining + LTX model-name branch from managed control synchronization. The focused + video suite passed 79 tests with 161 subtests, the adjacent backend matrix + passed 115 tests with 208 subtests, and the complete backend gate passed + 1,165 tests with 4 skips and 1,902 subtests with only the existing + Diffusers deprecation warning. The focused client graph/action matrix + passed 103/103, `npm run check` passed, the selected-pipeline field-update + and tamper browser contract passed 1/1, and the complete mocked Studio + suite passed 90/90 in 283 seconds. The production bundle was + 522359/523264 gzip bytes, 777 bytes below the stricter 523136-byte safety + target. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), + preflight, formatting, lint, type, build, bundle, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local preflight + evidence only; no model download, video execution, generated media, or + live workload qualification occurred. + - [x] Expert CUDA resource-policy overlay. Backend commit `b1f514f` + adds a bounded schema-v1 policy to each exact Qwen execution profile for + blocked CUDA dtypes, the recommended replacement dtype, projected + offloaded/resident VRAM, and per-quantization resident overrides. Other + profiles omit the policy rather than publishing a nullable or inferred + contract. Client commit `0259624` strictly parses the bounded policy and + consumes it only from the unique execution profile named by the selected + exact specification; the Qwen-family and 10/24/80 GiB readiness branches + are removed. A deliberately different 12 GiB unit receipt and a + float16-blocking mocked-browser receipt prove that the backend profile, + not a retained client constant, controls the result. The focused backend + matrix passed 131 tests with 367 subtests, and the complete backend gate + passed 1,167 tests with 4 skips and 1,932 subtests with only the existing + Diffusers deprecation warning. The focused client graph/request/readiness + matrix passed 137/137, `npm run check` passed, the exact policy browser + contract passed 1/1, and the complete mocked Studio suite passed 91/91. + The production bundle was 522840/523264 gzip bytes, 296 bytes below the + stricter 523136-byte safety target. Ruff 0.12.7 E9/F, `py_compile`, `uv + pip check` (78 packages), preflight, formatting, lint, type, build, + bundle, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, inference, generated media, or live workload qualification + occurred. + - [x] Expert quantization resource-policy overlay. Backend commit + `d3125dd` adds one bounded schema-v1 policy to the six exact Qwen + execution profiles, declaring the Expert quantization/offload modes, + generic Modular quantization node, reviewed component/subfolder, BnB + quant type, compute dtype, and double-quant setting. Client commit + `5e8a1e7` strictly parses the policy and resolves it only through the + unique execution profile named by the selected exact specification. + Direct specifications derive required loader fields from their exact + bindings; Modular specifications create, populate, connect, and seal the + declared generic quantization node without a model-family or pipeline-name + fallback. Missing fields, node definitions, or incompatible registry + options fail closed. The focused backend profile suite passed 9 tests + with 81 subtests, the adjacent backend matrix passed 98 tests with 114 + subtests, and the complete backend gate passed 1,168 tests with 4 skips + and 1,941 subtests with only the existing Diffusers deprecation warning. + The focused client graph/request/readiness matrix passed 137/137, + `npm run check` passed, the exact policy/topology and prior-regression + browser matrix passed 4/4 plus the preserved expanded-node contract 1/1, + and the complete mocked Studio suite passed 91/91 in 288.5 seconds. The + production bundle was 523099/523264 gzip bytes, 37 bytes below the + stricter 523136-byte safety target. Ruff 0.12.7 E9/F, `py_compile`, `uv + pip check` (78 packages), preflight, formatting, lint, type, build, + bundle, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, inference, generated media, or live workload qualification + occurred. + - [x] Expert MPS resource-policy overlay. Backend commit `f2ec7ac` adds a + bounded schema-v1 advisory to each exact reviewed Qwen, video, and + Z-Image execution profile that has an Apple MPS qualification status and + fallback action. Other profiles omit the policy. Client commit `06ca70f` + strictly parses the policy and resolves it only through the execution + profile selected by the exact specification; the previous Qwen-family, + Z-Image-family, and video-output readiness branches are removed. The + advisory remains Expert-only and non-blocking. The focused backend + profile/runtime matrix passed 100 tests with 140 subtests, and the + complete backend gate passed 1,170 tests with 4 skips and 1,967 subtests + with only the existing Diffusers deprecation warning. The focused client + graph/request/readiness matrix passed 137/137, `npm run check` passed, + the exact MPS browser contract passed 1/1, and the complete mocked Studio + suite passed 92/92 in 310.7 seconds. The production bundle was + 523116/523264 gzip bytes, 20 bytes below the stricter 523136-byte safety + target. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), + preflight, formatting, lint, type, build, bundle, and diff checks passed. + This is static, unit, contract, mocked-browser, build, and local preflight + evidence only; no model download, inference, generated media, Apple + Silicon workload, or live qualification occurred. + - [x] Generic image and Modular field-contract overlay. Backend commit + `98f3841` gives every reviewed generic Diffusers image pipeline/mode an + exact field-visibility overlay and makes the connected Generate, Edit, + Inpaint/Outpaint, and Control Generate node validate and apply the whole + selected loader contract. The same audit proves Encode Prompt, Denoise, + Image Encode, Decode Latents, and Image Embeddings rebuild their generic + fields from the selected Modular pipeline's registry metadata without a + model-name switch. Client commit `c88e685` adds a mocked-browser contract + that switches one live generic Edit node across Flux Redux + multi-reference, SDXL img2img, and Qwen Image Edit Plus multi-reference + selections and observes the fields changing in place. The focused image + matrix passed 76 tests with 2 skips and 201 subtests, the adjacent + image/Modular/profile matrix passed 134 tests with 2 skips and 504 + subtests, and the complete backend gate passed 1,174 tests with 4 skips + and 1,996 subtests with only the existing Diffusers deprecation warning. + `npm run check` passed, the exact image switching browser contract passed + 1/1, and the complete mocked Studio suite passed 93/93 in 308.7 seconds. + The production bundle remained 523116/523264 gzip bytes, 20 bytes below + the stricter 523136-byte safety target. Ruff 0.12.7 formatting and E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, client lint/type/ + build/bundle, and diff checks passed. This is static, unit, contract, + mocked-browser, build, and local preflight evidence only; no model + download, inference, generated media, or live qualification occurred. + - [x] Exact image-path and Expert quantization-choice cleanup. Client + commit `a63d882` removes the remaining image-facade registry, Flux-family, + Qwen/Z-Image mode, and pipeline-class fallbacks from managed graph + construction; only the exact selected execution profile or an already + bound managed role may select the direct image facade and loader class. + Backend commit `fd514f7` publishes a bounded, unique list of reviewed + Expert quantization choices on each exact Qwen and Flux execution profile, + while profiles without reviewed choices omit the field. Client commit + `5abfab9` strictly parses that list and renders the Expert selector only + from the unique profile selected by the exact specification. The same + client checkpoint preserves the bound execution-spec receipt during tab + hydration, so restored controlled graphs validate the same specification + instead of losing their authority. The focused backend matrix passed 15 + tests with 151 subtests, and the complete backend gate passed 1,176 tests + with 4 skips and 2,021 subtests with only the existing Diffusers + deprecation warning. The focused client contract passed 27/27, the + complete `npm run check` passed, and the final mocked Studio suite passed + 94/94 in 290.8 seconds. The production bundle was 522877/523264 gzip + bytes, 259 bytes below the stricter 523136-byte safety target. Ruff E9/F, + `uv pip check` (78 packages), preflight, formatting, lint, type, build, + bundle, and diff checks passed. This is static, unit, contract, mocked- + browser, build, and local preflight evidence only; no model download, + inference, generated media, or live qualification occurred. + - [x] Exact installed-model loader insertion. Client commit `9cec2db` + removes the remaining Qwen/family loader-choice branches from the model + library insertion path. A known catalog model now derives its loader + module, action, and `model_type` or `pipeline_class` identity from the + authoritative backend execution profiles; an authoritative catalog that + has no matching execution profile fails closed instead of guessing a + facade. Unknown/manual artifacts retain the explicitly editable generic + image/audio/video and Modular fallback. The focused browser regression + proved Qwen Image inserts `DiffusersImage.LoadPipeline` with + `QwenImagePipeline`, and that removing its authoritative execution + profile leaves the canvas unchanged with a bounded error. The complete + `npm run check` passed, the complete mocked Studio suite passed 95/95, + and the production bundle was 522977/523264 gzip bytes, 159 bytes below + the stricter 523136-byte safety target. Type, build, bundle, and diff + checks passed. This is static, unit, contract, and mocked-browser evidence + only; no model download, inference, generated media, or live qualification + occurred. + - [x] Exact Expert quantization retention on model changes. Client commit + `d3ad700` removes the local `Qwen Image` family exception from form + mutation. The model selector now retains an Expert quantization choice + only when the exact target model/mode execution profile declares that + choice; missing, invalid, or non-declaring profiles reset to `none`. + The focused browser regression proved `bnb_4bit` survives Qwen-to-FLUX + switching and is removed when switching to Z-Image. The complete + `npm run check` passed, the complete mocked Studio suite passed 96/96 in + 290.8 seconds, and the production bundle was 522954/523264 gzip bytes, + 182 bytes below the stricter 523136-byte safety target. Formatting, lint, + type, unit/contract, build, bundle, browser, and diff checks passed. No + model download, inference, generated media, or live qualification + occurred. + - [x] Declarative low-memory preset selection. Client commit `229b5d1` + removes the Qwen- and Wan-family branches from both Studio low-memory + entry points. The selected model profile now owns the form patch for + dimensions, frame count, steps, dtype, quantization reset, and offload; + the existing distinct Wan 2.2 and LTX values are therefore no longer + overwritten by the legacy Wan VACE preset. The focused contract covers + Qwen Image, Wan VACE, Wan 2.2 I2V, and LTX. The complete `npm run check` + passed, the complete mocked Studio suite passed 96/96 in 295 seconds, + and the production bundle was 522708/523264 gzip bytes, 428 bytes below + the stricter 523136-byte safety target. Formatting, lint, type, unit/ + contract, build, bundle, browser, and diff checks passed. This is a + declarative client-profile cleanup; moving all low-memory dimensions and + frame limits into backend execution specifications remains part of the + parent metadata-ownership audit. No model download, inference, generated + media, or live qualification occurred. + - [x] Generic Modular readiness identity. Client commit `a32b37a` + removes the last `Qwen Image` family check from managed Run readiness. + An existing/restored graph is classified from its generic managed + ModelsLoader, prompt, and denoise roles; before a graph exists, readiness + uses the exact backend execution profile's `modular-diffusers` path. + The focused 43/43 graph-visual matrix includes the legacy restore path, + the complete `npm run check` passed, and the complete mocked Studio suite + passed 96/96 in 291.9 seconds. The production bundle was + 522707/523264 gzip bytes, 429 bytes below the stricter 523136-byte safety + target. Formatting, lint, type, unit/contract, build, bundle, browser, + and diff checks passed. No model download, inference, generated media, + or live qualification occurred. + - [x] Exact restored-quantization admission. Client commit `0c3a4c5` + removes the Qwen/FLUX family filter from persisted Studio form coercion. + Persistence now retains only the existing bounded quantization enum and + Expert readiness permits a non-`none` choice only when the exact backend + execution profile declares it; stale or unsupported restored choices + block with a bounded corrective issue instead of being guessed or + silently reinterpreted by the client. The focused 68/68 contract matrix, + complete `npm run check`, and complete 96/96 mocked Studio suite passed; + the browser run completed in 293.7 seconds. The production bundle was + 522741/523264 gzip bytes, 395 bytes below the stricter 523136-byte safety + target. Formatting, lint, type, unit/contract, build, bundle, browser, + and diff checks passed. No model download, inference, generated media, + or live qualification occurred. + - [x] Final residual client audit and graph-construction cleanup. Client + commit `4afc515` removes the remaining model- and pipeline-name branches + from fallback role selection and form-to-graph value synchronization. + Exact execution specifications remain authoritative for current managed + profiles; pre-specification registered facades retain their generic + dynamic-definition fallback and consume the selected Auto candidate or + backend execution profile without inventing a client pipeline class. + Remaining model comparisons are identity lookup/filtering, explicit + versioned template recipe data, or imported/manual Expert graph inference, + not managed frontend execution routing. Source contracts passed 43/43, + the three affected registered-facade/dynamic-definition/MPS browser cases + passed together, the complete `npm run check` passed, and the final mocked + Studio suite passed 96/96 in 294.7 seconds. The production bundle was + 522653/523264 gzip bytes, 483 bytes below the stricter 523136-byte safety + target. One preceding full browser run hit the known media-format popover + close race at 95/96; that unrelated test passed alone in 3.3 seconds and + the unchanged complete rerun passed 96/96. No model download, inference, + generated media, or live qualification occurred. +- [x] **P0.4 Proof receipts and current mismatch cleanup** + - Backend: bind history to profile/schema version, graph and loader topology, + auxiliary repositories, adapters, LoRAs, ControlNets, runtime profile, and + artifact revisions. + - Client: invalidate stale proof after any bound field changes and label proof + levels accurately. + - Tests: Z-Image loader identity, Qwen mode mappings, Wan profile closure, + Flux Kontext multi-reference, receipt invalidation, and all checked-in graphs. + - [x] Auto schema/profile history receipt binding. Backend commit `bf0af6b` + corrects the schema-v2 planner so every declared candidate publishes its + exact `executionProfileId` and owning `autoResourceSchemaVersion`; runtime + admission now requires both values to match the current backend contract. + History schema v3 at those commits binds local success/failure evidence to those identities, + so an older history schema, replaced execution profile, or changed planner + schema cannot promote a current candidate to `live_proven`. Client commit + `4cad1b2` preserves the typed receipt and adds the positive response-boundary + contract. This also closes the cross-repository P0.2 regression in which + the frozen client correctly rejected real backend candidates because their + required profile ID was absent while complete mocked fixtures supplied it. + The focused backend matrix passed 169 tests with 298 subtests; the complete + backend gate passed 1,138 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,772 subtests. The focused client request suite + passed 12/12, the complete `npm run check` passed, and the complete mocked + Studio browser suite passed 87/87 in 221.6 seconds. The production bundle + remained 523109/523264 gzip bytes, 27 bytes below the stricter 523136-byte + target. Ruff E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + type, formatting, lint, and diff checks passed. All 26 generated client + files remained byte-identical and 317 Gallery files were preserved. A fresh + backend returned ready health and HTTP 200 for `/`; a real Flux plan + returned two schema-v2 candidates, both bound to `flux-schnell:direct` and + candidate schema 2. The served entry SHA-256 was + `6c1c05f0b199746275d7ef008078a1f0ebea2bfce149b531e7a533f1637e271d`. + Its exact five-process tree was stopped and ports 8088/8089 were free. This + is static/unit/contract/mocked-browser/local-HTTP evidence only; no model + download, inference, generated media, or live workload qualification ran. + - [x] Auto optional-runtime receipt binding. Backend commit `f0ccd13` + advances local history to schema v4 and binds success/failure evidence to + the exact optional-runtime profile list plus the immutable requirement + schema, delivery mode, `requiredNow` flag, and execution-profile IDs. + Runtime admission independently recomputes that contract from the current + backend execution profile and rejects a selected/list receipt that is + self-consistent but stale. Transient package state and explanatory reason + remain outside the history identity; graph-derived optional-runtime + admission remains authoritative immediately before execution. The focused + backend compatibility matrix passed 231 tests with 490 subtests, and the + complete backend gate passed 1,138 tests with 4 skips, the existing + Diffusers deprecation warning, and 1,774 subtests. The client receipt/run + contracts passed 78/78, complete `npm run check` passed, and the exact + optional-runtime, atomic Auto submission, and wrong-loader mocked-browser + cases passed 3/3. The unchanged production client remained + 523109/523264 gzip bytes and all 26 generated files matched the backend + mirror byte-for-byte. Ruff E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, and diff checks passed. This is static, unit, + contract, and mocked-browser evidence only; it does not qualify an overlay, + install packages, download a model, or execute a workload. + - [x] Specification-owned Auto graph receipt binding. Backend commit + `3a0b355` and client commit `0131ea7` advance local history to schema v5 + and bind each of the 32 currently specification-owned model/mode pairs to + the exact Studio execution-spec schema version, ID, content hash, and + execution-profile ID. The client requires that candidate contract to match + the active managed binding before apply or Run. Runtime admission + independently recomputes the current backend contract, requires the graph's + role-to-node receipt, and revalidates the exact reviewed nodes, typed edges, + and form bindings before execution. Missing, extra, malformed, or stale + receipts fail closed, and older history cannot promote a changed graph + contract to `live_proven`. The seven exact profile pairs that do not yet + have a backend Studio execution specification remain explicitly outside + this claim rather than receiving an inferred topology receipt. The focused + backend matrix passed 231 tests with 493 subtests; the complete backend gate + passed 1,138 tests with 4 skips, the existing Diffusers deprecation warning, + and 1,777 subtests. Focused client contracts passed 78/78, the complete + `npm run check` passed, and the complete mocked Studio browser suite passed + 87/87 in 229.1 seconds. The production bundle was 523120/523264 gzip bytes, + 16 bytes below the stricter 523136-byte target; all 26 generated files + matched the backend mirror byte-for-byte and all 317 Gallery files were + preserved. Ruff E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + type, formatting, lint, and diff checks passed. A fresh local HTTP plan + returned `flux-schnell:text-to-image:v1`, content hash + `studio-spec-v1-9cd1abb5`, and profile `flux-schnell:direct`; the served + 1,034,853-byte entry matched SHA-256 + `c2baccd69a6c5302c4063d3b124e107d28f20c8a9a46a9355574d1b539a527e2`. + Its exact process tree was stopped and ports 8088/8089 were free. This is + static, unit, contract, mocked-browser, build, and local-HTTP evidence only; + no model download, inference, generated media, or live workload + qualification occurred. + - [x] Auto auxiliary-artifact receipt binding. Backend/bundle commit + `e2a1bf2` and client commit `453da03` bind the two currently declared + auxiliary or internally loaded model dependencies to exact bounded + `id`/`kind`/repository/immutable-revision receipts: Qwen ControlNet Union + for `QwenImageModularPipeline:control_image`, and FLUX.1-dev for + `FluxReduxPipeline:edit_image`. Schema-v2 candidates, selected/list and + retry identity, exact-pair public requirements, runtime hints, admission, + and local history schema v6 all carry or independently recompute the same + receipt; missing, extra, stale, malformed, or top-level/candidate-mismatched + dependencies fail closed. Capability metadata now publishes the same + immutable requirements. The focused backend matrix passed 139 tests with + 317 subtests; the complete backend gate passed 1,141 tests with 4 skips, + the existing Diffusers deprecation warning, and 1,781 subtests. Focused + client contracts passed 79/79, the complete `npm run check` passed, and the + complete mocked Studio browser suite passed 87/87 in 225.1 seconds. The + production bundle was 523132/523264 gzip bytes, four bytes below the + stricter 523136-byte target; all 26 generated files matched the backend + mirror byte-for-byte and all 317 Gallery files were preserved. Ruff 0.12.7 + E9/F, `py_compile`, `uv pip check` (78 packages), preflight, formatting, + lint, type, and diff checks passed. A fresh local server returned ready + health and HTTP 200 for `/`; its real Qwen Control Auto plan and public + capability response both returned the exact ControlNet Union commit, while + the capability response also returned the exact FLUX.1-dev Redux commit. + The verified process tree stopped and port 8088 was free. This is static, + unit, contract, mocked-browser, build, and local-HTTP evidence only; no + dependency download, model inference, generated media, or live workload + qualification occurred. Executable controlled-LoRA receipts are closed by + the following bounded slice; future auxiliary dependencies remain pending. + - [x] Executable controlled-LoRA Auto receipt binding. Backend commit + `5cb785d` advances local history to schema v7 and derives an ordered receipt + from the submitted executable graph immediately before Auto admission. The + worker recognizes the reviewed Modular, direct-image, and direct-audio LoRA + node contracts, resolves their Hub or local Safetensors through the existing + exact descriptor boundary, rehashes the bytes, excludes disconnected/no-op + adapter nodes, and binds module/action, safe artifact identity, adapter + name, scale, scheduler, replacement policy, and descriptor digest. Submitted + `controlledArtifacts` claims are discarded; the server copies only its own + derived receipt into the selected/list candidate identity, resident-cache + signature, and success/failure history. A base-only `live_proven` result is + downgraded for a nonempty adapter set unless exact current schema-v7 history + exists, while passed or independently safe candidates retain their proof. + Local roots never enter the public receipt or bounded error envelope, and + every loader still revalidates its exact bytes immediately before mutation. + Focused backend tests passed 143 tests with 327 subtests; the exact final + backend tree passed 1,147 tests with 4 skips, the existing Diffusers + deprecation warning, and 1,781 subtests. Ruff 0.12.7 E9/F, `py_compile`, + `uv pip check` (78 packages), preflight, and diff checks passed. The + unchanged client passed complete `npm run check`; its bundle remained + 523132/523264 gzip bytes, and the schema-v3 controlled-family mocked-browser + replay passed 1/1. This is static, unit, contract, build, and mocked-browser + evidence only: no adapter/model download, model execution, generated media, + or live workload qualification occurred. The current non-LoRA controlled + artifact set and client proof labeling are closed by the following bounded + slice; future artifact families require their own reviewed receipts. + - [x] Current controlled-workflow artifact receipt closure. Backend commit + `31cbc47` advances Auto history to schema v8 and derives every current + executable controlled-artifact receipt immediately before admission. It + retains the existing exact LoRA receipts, resolves and rehashes Spandrel + upscalers through their pinned Hub snapshot or redacted local-file identity, + and binds soundtrack/lyric auxiliary Diffusers pipelines to their exact + repository, immutable revision, loader class, and descriptor digest. The + selected primary Auto loader remains owned by its existing artifact receipt; + disconnected nodes are excluded, submitted receipt claims are discarded, + and the Spandrel loader rechecks declared revision, size, and SHA-256 before + loading. Client commit `54a610a` preserves exact revision/hash/size metadata + through each current controlled builder and labels base-only proof accurately + for LoRA, upscaler, soundtrack, and lyric-video contracts. Focused backend + tests passed 150 tests with 327 subtests; the exact backend tree passed 1,183 + tests with 4 skips, 2,021 subtests, and only the existing Diffusers + deprecation warning. Ruff 0.12.7 E9/F, `uv pip check` (78 packages), + preflight, and diff checks passed. Focused client graph/template contracts + passed 112/112, the exact artifact browser cases passed 2/2, complete + `npm run check` passed, and the complete mocked Studio suite passed 97/97. + The production bundle was 523069/523264 gzip bytes, 195 bytes below the hard + cap and 67 bytes below the stricter 523136-byte safety target. This is static, + unit, contract, build, and mocked-browser evidence only: no artifact/model + download, model execution, generated media, or live workload qualification + occurred. + - [x] Z-Image Auto execution-spec closure. Backend commit `a4efd6c` adds + `z-image:text-to-image:v1` as the thirty-third backend-owned Studio + execution specification and binds the existing `z-image:auto` profile to + the exact five-node direct-image graph: `modules.DiffusersImage.LoadPipeline`, + `ZImagePipeline`, the reviewed `Tongyi-MAI/Z-Image-Turbo` artifact, and the + generic quantization/recipe/generate/preview route. The public capability, + selected Auto candidate, managed graph, runtime receipt, and backend + admission now share content hash `studio-spec-v1-0d3c1205`; wrong node + identity or a missing/stale receipt fails closed. Client commit `77ceab9` + adds the exact schema-v2 capability fixture and proves the existing generic + materializer seals the Z-Image receipt and loader class without changing + production client source. The exact final backend tree passed 1,148 tests + with 4 skips, 1,781 subtests, and only the existing Diffusers deprecation + warning. The focused backend contract passed 80 tests with 287 subtests; + Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + and diff checks passed. Complete client `npm run check` passed with the + unchanged 523132/523264-byte gzip bundle, exactly four bytes below the + stricter 523136-byte target, and the exact mocked Studio execution-spec + browser contract passed 1/1. Six exact execution-profile pairs remain + without backend-owned Studio specifications. This is static, unit, + contract, build, and mocked-browser evidence only; no model download, + inference, generated media, or live workload qualification occurred. + - [x] Qwen Image text-to-image execution-spec closure. Backend commit + `6e40bab` adds `qwen-image-2512:text-to-image:v1` as the thirty-fourth + backend-owned Studio specification. It binds the existing + `qwen-image:t2i-direct` profile, `modules.DiffusersImage.LoadPipeline`, + `QwenImagePipeline`, official `Qwen/Qwen-Image-2512` default, reviewed + prequantized fallback, and the generic quantization/recipe/generate/preview + topology to content hash `studio-spec-v1-f53ab380`. Every schema-v2 Auto + candidate carries that exact contract, and the backend independently + requires the matching managed graph receipt before execution. Client commit + `531d4b9` extends only the mocked capability fixture and verifies the + selected candidate, graph binding, shared node IDs, and direct loader class; + production client source and bundle are unchanged. The focused backend + matrix passed 86 tests with 329 subtests; the complete backend gate passed + 1,149 tests with 4 skips, 1,781 subtests, and only the existing Diffusers + deprecation warning. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 + packages), preflight, and diff checks passed. Complete client `npm run + check` passed with the unchanged 523132/523264-byte gzip bundle, four bytes + below the stricter 523136-byte target, and the exact mocked Studio + execution-spec browser contract passed 1/1. Five exact execution-profile + pairs remain without backend-owned Studio specifications. This is static, + unit, contract, build, and mocked-browser evidence only; no model download, + inference, generated media, or live workload qualification occurred. + - [x] Qwen Image Edit Modular execution-spec closure. Backend commit + `4596728` adds `qwen-image-edit:edit-image:v1` as the thirty-fifth + backend-owned Studio specification and binds the existing + `qwen-edit:modular` profile to the exact seven-role, thirteen-edge Modular + graph and fifteen form bindings. Capability publication and Auto candidates + carry content hash `studio-spec-v1-ae6a6ce8`; backend validation resolves + the reviewed pipeline's authoritative dynamic node definitions before + publishing the receipt, and execution admission independently rechecks the + exact executable node identities, edges, and bindings. Client commit + `e8aab4e` extends the bounded role vocabulary and generic specification + materializer so a Modular receipt waits for the backend-issued fields and + typed route-state handles before sealing. It adds no model-name routing + branch. The focused backend matrix passed 87 tests with 329 subtests; the + exact complete backend gate passed 1,150 tests with 4 skips, 1,781 subtests, + and only the existing Diffusers deprecation warning. Ruff 0.12.7 E9/F, + `py_compile`, `uv pip check` (78 packages), preflight, and diff checks + passed. Complete client `npm run check` passed with a 523107/523264-byte + gzip bundle (157-byte hard-cap headroom and 29 bytes below the stricter + 523136-byte target). The exact mocked browser receipt passed 1/1, and the + complete mocked Studio suite passed 87/87. Four exact execution-profile + pairs remain without backend-owned Studio specifications. This is static, + unit, contract, build, and mocked-browser evidence only; no model download, + inference, generated media, or live workload qualification occurred. + - [x] Qwen Image Edit Plus execution-spec closure. Backend commit `0e7a8f1` + adds distinct `qwen-image-edit-plus:edit-image:v1` and + `qwen-image-edit-plus:multi-image-reference-edit:v1` receipts as the + thirty-sixth and thirty-seventh backend-owned Studio specifications. Both + bind the existing `qwen-edit-plus:modular` profile and immutable + `Qwen/Qwen-Image-Edit-2511` artifact to the same reviewed seven-role, + thirteen-edge Modular edit graph and fifteen form bindings, with content + hashes `studio-spec-v1-28b9f604` and `studio-spec-v1-86a68b80`. Client + commit `57a4072` adds contract and mocked-browser coverage only; the + production materializer already consumed both receipts without a new + model-name branch or bundle change. The focused backend matrix passed 88 + tests with 329 subtests; the complete backend gate passed 1,151 tests with + 4 skips, 1,781 subtests, and only the existing Diffusers deprecation + warning. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), + preflight, and diff checks passed. Complete client `npm run check` passed + with the unchanged 523107/523264-byte gzip bundle, 29 bytes below the + stricter 523136-byte target, and the exact mocked-browser receipt passed + 1/1. Two complete 87-case mocked Studio replays each passed all exact-spec + assertions and 86/87 overall; their different unrelated late-run failures + (page bootstrap and workflow-tab scrolling) each passed immediately in + isolation. Two exact execution-profile pairs remain without backend-owned + Studio specifications. This is static, unit, contract, build, and + mocked-browser evidence only; no model download, inference, generated + media, or live workload qualification occurred. + - [x] Qwen Layered execution-spec closure. Backend commit `dd594ba` adds + `qwen-image-layered:layer-decomposition:v1` as the thirty-eighth + backend-owned Studio specification. It binds the reviewed + `qwen-layered:modular` profile and immutable + `Qwen/Qwen-Image-Layered` artifact to the exact seven-role, eleven-edge + Modular source-image/prompt/encode/denoise/decode/preview route and seventeen + form bindings, including the pinned source resolution, layer count, and + maximum sequence length. Client commit `ff3f9c6` proves the generic + materializer seals content hash `studio-spec-v1-dda194f0` without a new + model-name routing branch. The complete backend gate passed 1,152 tests + with 4 skips and 1,781 subtests. Complete client `npm run check` passed, the + exact mocked-browser receipt passed 1/1, and the production bundle was + 523118/523264 gzip bytes, 18 bytes below the stricter 523136-byte target. + Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` (78 packages), preflight, + formatting, lint, type, and diff checks passed. One exact execution-profile + pair remained at that checkpoint. This is static, unit, contract, build, + and mocked-browser evidence only; no model download, inference, generated + media, or live workload qualification occurred. + - [x] Qwen Image Control execution-spec closure. Backend commit `03c358b` + adds `qwen-image-2512:control-image:v1` as the thirty-ninth and final current + exact-pair Studio specification. It binds the existing + `qwen-image:modular` profile to eight reviewed roles, thirteen typed edges, + and thirty-two form bindings, including the separate `AutoModelLoader` + ControlNet component, exact Hub selector and immutable revision, + control-image adapter, ControlNet bundle, and route-state chain through + denoise and decode. Client commit `1102249` extends the bounded role and + binding vocabulary while keeping materialization generic; the pinned model + selector stays a Hub-selector object and the receipt content hash is + `studio-spec-v1-2b0e0b6a`. The final backend gate passed 1,153 tests with 4 + skips and 1,781 subtests; the focused final matrix passed 148 tests with 317 + subtests. Complete client `npm run check` passed, the exact browser receipt + passed 1/1, and the complete mocked Studio suite passed 87/87. The + production bundle was 523129/523264 gzip bytes, seven bytes below the + stricter 523136-byte target. Ruff 0.12.7 E9/F, `py_compile`, `uv pip check` + (78 packages), preflight, formatting, lint, type, and diff checks passed. + All 39 current execution-profile pairs now have backend-owned exact graph + receipts. This is static, unit, contract, build, and mocked-browser evidence + only; no model download, inference, generated media, or live workload + qualification occurred. + - [x] Controlled-workflow finalization-proof closure. + - [x] Bounded schema-v2 upscaler repair: exclude field-level `disabled` + from the proof because it is transient UI/signal state and is not consumed + by graph export. A previously schema-matching, complete graph may reseal + only after the already-modeled upscaler topology has an exact executable + ledger and no binding divergence. The focused graph-visual suite passed + 41/41, including partial-route, field-toggle, core-schema mutation, and + post-finalization edge-deletion cases; the complete mocked Studio suite + passed 83/83. This is not generic controlled-extension proof. + - [x] Schema-v3 controlled-workflow proof: begin only from a matching proof, + perform each trusted extension as one synchronous fail-closed transaction, + validate a strict controlled-role-to-node-key contract, preserve the + independent exact Modular core-route check, and seal every managed + extension node identity/schema, node execution-disabled state, parent/loop + semantics, and actual managed edge endpoint. Restore must revalidate the + complete hash, and abort must roll back or remain proofless. Cover LoRA + (Modular, direct image, and direct audio), video sequence and upscaler + composition, quality-sequence loops, soundtrack/export replacement, and + lyric/mux workflows. These reviewed groups are now covered by the client + proof gate; broader P0.4 receipt work and live execution remain separate. + Evidence 2026-08-10: schema-v3 now persists a bounded controlled-contract + declaration and seals the exact managed role/node identity, reviewed + registry execution shape, node execution-disabled state, parent/quality- + loop membership, and actual edge IDs/endpoints/handles. Each reviewed + builder runs inside one synchronous begin/commit/abort boundary; partial or + failed mutations restore the previous graph/proof/history, successful + replacement defers cache cleanup until after commit, and sequential/reverse + compositions retain prior receipts. Authoritative schema messages clear + the proof before mutation and may reseal only the same topology/execution + baseline; malformed persisted declarations remain quarantined until the + operator explicitly detaches the invalid receipt. Field-level `disabled` + and non-durable callbacks remain proof-neutral, while registry-declared + type/display/input/spawn/data-source shape is revalidated independently of + self-supplied hashes. Focused graph/template/run contracts passed 136/136, + the full client `npm run check` passed, and the complete mocked Studio suite + passed 85/85. The production bundle was 522753/523264 gzip bytes (511-byte + hard-cap headroom and 383 bytes below the stricter safety target). The + tracked packed-template codec regenerates deterministically in the normal + unit gate; 77 runnable and 3 planning templates remained deep-identical, + and all 360 Gallery asset paths/purpose sets remained unchanged. Independent + graph-contract and bundle-semantic audits found no remaining blocker. This + is static/unit/contract/mocked-browser CPU evidence only: no model execution, + media qualification, optional package action, or live runtime cutover ran. +- [ ] **P0.5 Lazy optional Hugging Face runtime installation** + - [x] Contract/status preparation: declare one exact composite + `transformers==5.14.1` + `peft==0.20.0` profile, bind it to every current + Diffusers execution profile, and publish metadata-only status through Auto, + model capabilities, and workflow listings without changing readiness. + - [ ] Staged overlay: generalize the existing package overlay, require an + exact catalog ID/spec digest plus explicit consent, validate in a fresh + process, serialize/cancel installs, and bind validated state to the base + environment identity. + - [x] Fail-closed scaffold: exact request schemas, process/worker mutation + gates, durable jobs, cancellation/watchdog plumbing, artifact-anchored + validation, restart/repair/rollback states, bounded public projections, + and legacy hashless-overlay rejection are implemented and CPU-tested. + The current candidate still rejects before lease, job, network, staging, + or subprocess creation. + - [x] Immutable wheel/installer preparation: bind the complete ten-wheel + Transformers/PEFT closure to exact official PyPI filenames, URLs, + SHA-256 values, and byte sizes for Python 3.12 on Linux, macOS, and + Windows on x86-64 and ARM64. Share one reviewed uv `0.11.26` + archive/executable identity with base setup, require a rehashed receipt, + validate complete unique wheel RECORD hashes/sizes, and place the Windows + watchdog and all descendants in a non-breakaway kill-on-close Job Object. + Install, activation, and cutover flags remain false. + - [x] Promotion storage preparation: bind the install lease to the original + staging-directory identity; use held-parent exclusive, no-replace + promotion plus handle-scoped quarantine cleanup; and persist canonical + manifest/validation digests in a bounded promotion journal. Locked + startup/install/activation/rollback reconciliation completes only one + exact prepared move, acknowledges one exact promoted move, and leaves + malformed, ambiguous, replaced, or missing states fail-closed for repair. + Windows write-through promotion/cleanup and crash-window regressions pass; + action flags remain false pending target-platform execution evidence. + - [x] Windows x86-64 isolated staging proof: under temporary, process-local + future-state flags, pinned uv installed all ten reviewed wheels + (16,930,199 archive bytes) with copy-only cache behavior; 3,441 wheel files + matched their archive anchor; isolated symbol/origin validation passed; + promotion/activation succeeded; a second fresh process loaded exact + Transformers `5.14.1` and PEFT `0.20.0` from the promoted overlay; and + rollback returned to base with no promotion journal or path-bearing + requirements file left behind. Source action/cutover flags remain false. + No model artifact was downloaded or executed. + - [x] Windows x86-64 no-weight staged workload: a second isolated run used + the production artifact-locked install, validation, promotion, + activation, fresh-process, and rollback path. With Hugging Face network + access disabled, exact Transformers `5.14.1` and PEFT `0.20.0` loaded + from the overlay; a tiny local CLIP text encoder accepted PEFT LoRA + adapters and produced a finite `[1, 4, 16]` forward result with four + trainable adapter parameters; Diffusers enabled its PEFT backend; and a + fresh post-rollback process returned to base. Ten locked wheels totaling + 16,930,199 bytes were used, no model artifact was downloaded, and source + flags stayed false. This is not clean-base, supervised server restart, + live model/media, or non-Windows evidence. + - [x] Windows x86-64 clean-base/staged-runtime matrix: a detached + prospective checkout removed Transformers and PEFT from the default + dependencies and required preflight imports, then the managed NVIDIA + installer produced a compatible 64-package CUDA base with all ten staged + distributions absent. Preflight was ready and registry discovery loaded + 132 nodes without loading the optional closure. From that base, the exact + ten-wheel/16,930,199-byte overlay passed install, validation, promotion, + activation, the finite CLIP+LoRA workload in a fresh process, and rollback + to no active environment. The detached checkout and temporary overlay + were removed afterward. This is not live-model, supervised-server, or + non-Windows evidence; the real source dependency/action/cutover flags + remain unchanged. + - [x] Windows x86-64 supervised HTTP lifecycle: from the same prospective + clean base, a real supervised server accepted explicit install consent, + published bounded `installing` and `validating` progress, and retained a + `ready` job bound to environment `runtime-1786525202-55aff828` and the + exact profile/spec digest. Explicit activation returned + `restarting: true`, replaced base worker `CF9j1A2gcK` with active worker + `8ikwPhct-9`, and the new worker reported overlay status `active` plus + Transformers `5.14.1`. Explicit rollback returned `restarting: true`, + replaced that worker with `p49cg4_HIn`, restored process status `base`, + and again reported Transformers absent. A real install first exposed an + invalid keyword call across `call_soon_threadsafe`; both optional-runtime + and legacy optimization progress dispatch now use a bound callback and + have worker-thread regression coverage. The detached checkout, staged + environment, server processes, and diagnostics were removed afterward; + port 8088 was free. Source action/cutover flags remain false. This is not + live-model, live cancellation/repair, or non-Windows evidence. + - [x] Windows x86-64 supervised cancellation and repair: a second clean-base + supervisor began the real locked install, exposed `installing`, accepted + the exact job-scoped cancellation request, and reached terminal + `cancelled` with no staging directory, no promoted environment, no worker + replacement, and Transformers still absent. A subsequent validated + environment was activated, then a one-byte managed-overlay drift was + introduced in the detached qualification tree. On restart, the worker + imported no optional package and published `repair_required` for the + process, active environment, and profile. A new install produced a + separately validated replacement; direct replacement activation was + refused while the corrupt selection remained active, explicit rollback + restarted to base, and activating the exact completed-job environment + restarted into Transformers `5.14.1`. A final rollback returned to a new + base worker with Transformers absent. Setup already prioritizes that exact + completed-job environment receipt over catalog inference. All temporary + processes and managed state were removed. This is not live model/media or + non-Windows evidence. + - [x] Windows x86-64 guarded live-model execution: a detached clean-base + checkout enabled the complete future qualified/action/cutover contract + only in that qualification tree. Model Manager downloaded the + Apache-2.0, safetensors-only, no-custom-code + `optimum-intel-internal-testing/tiny-random-qwen-image` snapshot at exact + commit `ef73a0df0cb8ccfa00cc178ec528c6e681791a10`; validation observed + 17 complete files and 41,663,402 completed bytes and confirmed + `QwenImagePipeline`. A fresh supervised worker activated the exact + ten-wheel composite overlay and ran the existing generic + `DiffusersImage.LoadPipeline -> Generate -> Image.Save` path on an RTX + 4080 at 64 by 64, one step, seed 123. Task `W44WcoUEma-X` completed in + 1.25 seconds and wrote a non-uniform RGB PNG with pixel digest + `sha256:8aef57fdb4aa58e5d2dcacb04b731893d3883f30554e1004e9c49b863a083e6f`; + its runtime receipt reported Diffusers `0.40.0.dev0`, Transformers + `5.14.1`, Torch `2.8.0+cu128`, and CUDA execution. After explicit + rollback, the same exact Qwen loader was rejected before queueing with + HTTP 409 `optional_runtime_staged`, `requiredNow: true`, and no current + task. The run exposed that custom immutable loader pins could not be + passed through Model Manager; `/hf_download` now accepts only an exact + lowercase 40-character optional `revision`, binds concurrent joins to + that revision plus file selection, and forwards it to the app-owned Hub + snapshot operation. The qualification artifact, output, overlay, and + processes were removed afterward. Production dependency, action, + qualification, and cutover declarations remain unchanged. This is not + non-Windows evidence and does not itself authorize the atomic base + dependency cutover. + - [x] Portable non-Windows qualification preparation: add an explicit- + consent, path-redacted qualification command that refuses a non-Python- + 3.12 host, an unverified managed uv executable, or any base interpreter + containing one of the ten staged distributions. In a disposable managed + root it projects the future qualified profile only in memory, uses the + production locked install/validation/promotion/activation path, runs the + offline CLIP+LoRA workload in a fresh child, rolls back, and verifies a + second fresh clean-base child. Focused contract tests cover dormant source + flags, consent-before-preflight, exact artifact selection, forged uv + rejection, and bounded no-overwrite evidence. This prepares a reproducible + Linux/macOS handoff; it is not platform evidence until executed there and + does not replace supervised HTTP restart/cancel/repair or live model/media + qualification. Linux is the next available physical target. No local + macOS host is available; macOS must remain pending until an approved + hosted runner or contributor-controlled Mac produces the same reviewed + evidence. Do not enable global action/cutover flags in the interim. A + Windows-and-Linux-only release would first require an independently + reviewed platform-scoped delivery contract that keeps macOS base-delivered. + - [ ] Executable qualification: add reviewed per-platform wheel and + installer execution evidence plus clean-base/staged workload, + fresh-process containment, restart, and rollback evidence before enabling + either action. + - [ ] First-use execution guard and client install/activation/restart flow. + - [x] Cutover-dormant guard/status scaffold: exact backend execution + profiles own a versioned seven-field requirement; every current profile + remains `base`/`requiredNow: false`; graph and field-action admission are + rechecked at the worker and pre-import boundaries; and the client exposes + read-only Setup status while blocking only an authoritative + `optional_overlay` requirement. The unqualified current catalog exposes no + install or activation control and sends no package mutation request. + Evidence 2026-08-10: the focused backend guard/status matrix passed 371 + tests with 4 skips and 744 subtests in 22.66 seconds; an independent replay + passed the same matrix in 22.53 seconds. The complete backend gate passed + 1086 tests with 4 skips and 1565 subtests in 42.02 seconds, with only the + existing Diffusers `torch_dtype` deprecation warning. Scoped `py_compile`, + Ruff E9/F, `uv pip check` (78 compatible packages), preflight, port, and + diff checks passed. Client optional-runtime contracts passed 111/111 and + graph-mutation contracts passed 24/24. The final warning-clean + `npm run check` passed with a 523072/523264-byte gzip bundle (192-byte + headroom); the complete mocked Studio suite passed 83/83, including the + exact GET-only optional-runtime Setup contract. That contract proves no + install, activate, or rollback control/request is exposed while actions + remain unavailable, and that loading, restart, repair, and qualified-active + status are rendered without stale-active authorization. Independent audits + signed only this cutover-dormant, base-neutral scaffold. These are + static/unit/contract/mocked-browser CPU results: no package action, + dependency cutover, network model download, model execution, media, or + live overlay qualification occurred. + All 26 generated client files were verified byte-identical in the backend + mirror while preserving `web/template-gallery`; the served `index.js` was + 1,005,768 bytes with SHA-256 + `3f71f642055631774a51f1e1e69c5fc5585abe82814016ebd691b1fe69517d6b`. + A fresh worker returned HTTP 200 for `/health`, `/`, and + `/assets/index.js`; the worker and temporary logs were removed and port + 8088 was free afterward. + - [x] Cutover-dormant client controls: Setup parses the bounded backend + environment/job contracts and exposes generic install or repair, progress, + cancellation, activation, and rollback controls only when the exact + profile is backend-qualified, cutover-ready, and action-enabled. Install, + activation, and rollback each require explicit consent; polling is bounded + to the returned job/profile/spec identity; duplicate staged environments + suppress activation; and the current unqualified profile remains GET-only. + Focused optional-runtime contracts passed 33/33, the qualified mocked flow + proved no mutation before consent plus install/progress/cancel/reinstall/ + activation/restart/rollback, and the complete mocked Studio gate passed + 98/98. The production bundle was 522936/523264 gzip bytes, 200 bytes below + the stricter 523136-byte safety target. This is a dormant mocked-browser + control surface, not package-action or workload qualification. + - [ ] Actionable first-use qualification: enable the dormant controls only + after executable overlay qualification, then exercise explicit consent, + bounded install progress/cancellation, activation, supervised restart, + repair, and rollback without making + discovery, template open, Auto planning, or base-delivered execution + depend on optional-runtime status. Do not mark this complete until + reviewed cross-platform wheel locks and installer containment are + qualified, backend version/symbol/origin verification succeeds in the + activated worker, supervised restart/repair/rollback are exercised, and a + staged-runtime workload passes. + - [ ] Atomic base cutover: remove both Transformers and PEFT only after the + clean-base and staged-runtime qualification matrices pass. Before any + execution profile changes to `optional_overlay`, add exact repo-aware client + readiness for shared loader classes so local or unknown repositories match + the backend loader-identity guard instead of being shown as runtime-ready. + - [x] Repo-aware shared-loader readiness and Auto parity: the client now + resolves a managed loader by exact module/action plus model/pipeline + identity, disambiguates shared classes only with an exact Hub repository + from each profile's default/fallback/compatible set, and preserves + base-delivery neutrality when no candidate requires an overlay. Local, + malformed, unknown, or ambiguous selectors fail closed as soon as any + matching profile requires the optional runtime. Schema-v2 Auto candidates + require one consistent artifact repository receipt; applying a reviewed + plan may update the exact managed loader, while readiness and submission + require the live loader to match the effective selected repository. + Evidence 2026-08-12: focused client request/template contracts passed + 109/109; the exact wrong-repository mocked-browser regression passed 1/1; + full `npm run check` passed; and the complete mocked Studio suite passed + 99/99 in 293.7 seconds. The production bundle remained within the fixed + gate at 523108/523264 gzip bytes (28 bytes below the stricter 523136-byte + safety target). All 77 runnable and 3 planning templates remained + deep-identical after the size carve. The backend shared-loader/profile + replay passed 41 tests and 254 subtests in 31.67 seconds, including Hub, + compatible-repository, local, custom, malformed, and executable-path + selection. This closes only the repository/readiness prerequisite; it is + not clean-base, staged workload, restart, rollback, or atomic cutover + evidence. + - Backend: add reviewed package requirements to execution specifications; + generalize the staged optional-runtime installer for official Hugging Face + libraries; verify in a fresh process; support activation, restart, and + rollback; then atomically remove both direct Transformers and PEFT + dependencies only after the clean base profile passes. Bind a validated + overlay to its exact runtime-spec, Python, accelerator-profile, and pinned + Diffusers identities so stale overlays require repair instead of loading. + - Client: when Run first needs a missing runtime, show an explicit install + action and progress. Do not install on application setup, template open, + node discovery, or Auto planning. + - Tests: clean base install without Transformers or PEFT, lightweight + discovery and preflight, missing/wrong-version/repair-required readiness, + decline/cancel and concurrent-install exclusion, successful staged install, + exact version/symbol/origin validation, failed validation, + activation/restart, rollback, and an existing Diffusers workflow whose text + encoder requires the optional composite runtime. + - Assets: none. Hardware: CPU-only package and contract tests. + - Audit evidence (2026-08-09): hiding Transformers while leaving PEFT present + failed registry discovery through Diffusers `ComponentsManager` -> + `peft.helpers`; hiding both under offline flags loaded all 20 module groups + and 132 nodes without importing either package. Candidate pins + `transformers==5.14.1` and `peft==0.20.0` passed local no-weight API probes, + but remain unqualified until the managed cross-platform matrix passes. + - Contract/status evidence (2026-08-10): the standard-library-only profile + catalog publishes exact provenance, requirements, and canonical spec digest + `sha256:8e1b0b6b2baa891d4551caa3cde4d59708eced0fd74c1333b68a1aab7ff924b5`. + The executable specification names the complete overlay-owned closure: + Transformers `5.14.1`, PEFT `0.20.0`, tokenizers `0.22.2`, Typer `0.27.1`, + annotated-doc `0.0.5`, Rich `15.0.0`, markdown-it-py `4.2.0`, mdurl + `0.1.2`, Pygments `2.20.0`, and shellingham `1.5.4`. + Missing, wrong-version, unreadable, and exact-present host metadata remain + observational; exact presence is still `present_unqualified`, with cutover, + install, and activation unavailable. Strict discovery, Auto, capability, + `/listgraphs`, and template-open tests prove no optional package is loaded or + installer path invoked by those surfaces. Unknown profile IDs fail closed, + and all three host states leave Auto selection and readiness identical. + Focused implementation gates passed 64 tests and 219 subtests; an independent + adjacent audit passed 109 tests and 311 subtests plus `py_compile`, Ruff E9/F, + `uv pip check`, and diff checks. The complete backend replay passed 1009 + tests and 1400 subtests with 2 platform skips and the existing Diffusers + deprecation warning; repository-wide Ruff E9/F, package compatibility, + preflight, port, and diff checks also passed. The base dependency declarations + still intentionally include both Transformers and PEFT; no installation, + activation, dependency cutover, network access, or model execution occurred. + - Fail-closed overlay-scaffold evidence (2026-08-10): synthetic locked-wheel + tests bind filenames/hashes to the spec, verify the full ten-distribution + closure, re-anchor retained caches, and reject archive replacement, + self-consistent forged RECORDs, startup hooks, links, path aliases, and + unsafe Windows names before optional imports. Server tests cover strict + install/activate/rollback/cancel schemas, both graph/mutation admission + orders, unsupervised restart blocking, durable monotonic jobs, interrupted + job reconciliation, corrupt-state recovery, catalog bounds, and public/on- + disk redaction. Benign subprocess tests cover cross-process lease exclusion, + ordinary child/grandchild cancellation, and hard-worker-death watchdog + cleanup/reacquisition. The frozen focused matrix passed 69 tests and 66 + subtests with two privilege-only symlink skips; the complete backend replay + passed 1058 tests and 1443 subtests with four platform/privilege skips and + the existing Diffusers `torch_dtype` deprecation warning. Scoped Ruff E9/F, + `py_compile`, package compatibility, preflight, port, and diff checks + passed. An independent adversarial audit signed the currently reachable + fail-closed scaffold and explicitly did not sign enabling an overlay. This + is static/unit/no-network CPU evidence, not a staged package install or + model run. Package actions remain unavailable, legacy hashless overlays are + non-executable, and the staged overlay checkbox remains open for the + qualification blockers above. + +### Phase 0 completion gate + +- [ ] All focused backend tests pass. +- [ ] Complete backend gate passes. +- [x] Client unit and mocked browser gates pass. +- [ ] Existing supported exact pairs retain their public inputs and outputs. +- [ ] Unknown or unsupported pairs cannot become Auto-ready. +- [ ] No generated assets or model downloads were needed. +- [ ] A clean base installation does not install Transformers or PEFT; a + requiring workflow remains blocked until its explicit first-use composite + runtime installation succeeds. + +## Phase 1 — Modular foundation without large model runs + +Priority: after Phase 0. Hardware: CPU and tiny fixtures. Assets: none. + +### Committable segments + +- [ ] **P1.1 Reviewed custom Modular and DynamicBlock execution contract** + - Backend: remove the invalid curated default and bundled graph; accept only + `modiff_pipeline_config.json` for bounded declarative MoDiff UI metadata. + Before enabling execution, validate every repository-supplied component + library and class against MoDiff's reviewed official Hugging Face + dependency contract, validate canonical upstream block/workflow metadata, + and pin every transitive repository. Build a content-addressed private + execution snapshot so cache or local-file mutation cannot race validation. + Any repository Python path additionally needs a task-scoped explicit + operator authorization that cannot be restored from imported workflow data. + - Optional runtimes: a validated component may request Transformers or + another separately approved Hugging Face library only through the P0.5 + first-use install/consent profile. Contract preview, template browsing, + registry discovery, and Auto planning remain non-installing operations. + - Client: show a neutral repository field and actionable missing-sidecar, + unpinned-auxiliary, unapproved component, missing-runtime, immutable-snapshot, + and trust errors. Do not mention Mellon in product UI or treat a persisted + identity checksum as consent. + - Tests: exact filename, no filename fallback, missing file, hostile schema, + arbitrary installed-package dispatch, immutable main and auxiliary + revisions, cache/local mutation and validation-to-load races, imported + authorization replay, auth/network distinction, and no downloads or + optional-library installation during node discovery or contract preview. +- [ ] **P1.2 Generic upstream workflow discovery** + - Backend: derive workflows, required inputs, outputs, and components from + `available_workflows`, `get_workflow()`, block docs, and `init_pipeline()`; + keep small reviewed overlays for MoDiff aliases and UI defaults. + - Client: render task choices and fields from the normalized contract rather + than pipeline-name switches. + - Tests: Sequential, Auto, Loop, state, component reuse, schema round trip, and + unknown workflow rejection. +- [ ] **P1.3 Complete the generic guider registry** + - Add `AdaptiveProjectedMixGuidance`, `MagnitudeAwareGuidance`, and + `PerturbedAttentionGuidance` to the existing Guider node. + - Test constructor parameters, required layers/components, signal updates, and + pinned upstream exports. +- [ ] **P1.4 Register current-pin missing Modular classes as contract-only** + - Split into reviewable image, video, and multimodal batches. + - Do not mark them Auto-ready or live-supported. + - Each batch has backend class/schema tests and client experimental/Expert + visibility tests. + +### Phase 1 completion gate + +- [ ] Complete backend and client gates pass. +- [ ] Registry discovery imports no large model stack and downloads no weights. +- [ ] Every exposed workflow is present in the pinned upstream block definition. +- [ ] DynamicBlock has no Mellon filename, schema, option, or fallback. +- [ ] No assets are generated. + +## Phase 2 — Templates for already implemented execution paths + +Priority: first user-visible expansion. Hardware: contract tests locally; live +output and assets remotely. Assets: remote Dataset only. + +### Committable segments + +- [ ] **P2.1 Generic task-template builder and validator** + - Backend: validate exact execution profile, loader identity, graph inputs, and + output contract for every graph. + - Client: generate task skeletons from generic image/audio/video contracts; + do not clone model-specific graph builders. + - Tests: graph round trips, required media, loader identity, stable IDs, and + Gallery-hidden state while qualification is pending. +- [ ] **P2.2 Existing image paths** + - Stable Diffusion XL basics; direct Flux img2img/inpaint/ControlNet; Flux + Kontext multi-reference; Z-Image img2img; supported Qwen img2img, + edit/inpaint, ControlNet, Edit Plus, and Layered modes; existing registered + Modular image pipelines. +- [ ] **P2.3 Existing audio paths** + - Stable Audio and existing ACE-Step modes using the generic audio nodes. +- [ ] **P2.4 Existing short-video graph paths** + - Wan 2.2 I2V/TI2V, Wan Animate, Wan first/last-frame, LTX long-prompt I2V, + LTX2 joint audio/video, and Hunyuan FramePack. + - This segment commits graph/template contracts only. It does not run video on + the local machine. +- [ ] **P2.5 Remote Gallery qualification and activation** + - Generate examples remotely from the paired commits. + - Review and publish media to an immutable Dataset revision. + - Commit descriptors, hashes, rights/provenance, quality reviews, activation, + and the generated client mirror separately. + +### Phase 2 test and asset gate + +- [ ] Backend graph/catalog/profile integrity tests pass. +- [ ] Client template, quality, Gallery coverage, and mocked browser tests pass. +- [ ] Every public template has a remote live-output receipt for its exact mode. +- [ ] Every media byte is in the Dataset, not either Git repository. +- [ ] Auto remains disabled for any template whose qualification is pending. + +## Phase 3 — Small, fast pipelines and speech recognition + +Priority: first new live execution. Hardware: allowlisted local smoke or remote. +Assets: generated remotely even when a local smoke is allowed. + +### Committable segments + +- [ ] **P3.1 Generic unconditional image generation** + - Add an unconditional task mode/adapter, not DDPM-specific nodes. + - Integrate `DDPMPipeline`, `DDIMPipeline`, and + `ConsistencyModelPipeline` in separate exact-pair entries. + - Local smoke: tiny resolution and bounded steps; hard timeout 40 minutes. +- [ ] **P3.2 Small latent image workflows** + - Stable Diffusion 1.x/2.x text-to-image, img2img, and inpaint. + - LCM 1-4 step workflows and PAG using compatible base weights. + - Local smoke: at most 512px and the minimum meaningful step count. +- [ ] **P3.3 Generic perception output** + - Add prediction-map output semantics and integrate Marigold depth first. + - Add normals, intrinsics, and uncertainty only after the shared output + contract is stable. +- [x] **P3.4 Adopt the official Hugging Face library boundary in repository policy** + - Update backend and client `AGENTS.md`, contributor guidance, Hugging Face + standards, dependency/runtime contracts, and the former Diffusers-only + boundary tests. + - Permit reviewed official Hugging Face libraries while preserving the single + MoDiff graph executor, local execution, immutable sources, no implicit + remote code, generic contracts, rollback, and proof requirements. + - This is a documentation/contract commit and generates no media. + - Status 2026-08-07: implemented and validated in both working trees; paired + commit references are pending. The current direct Transformers and PEFT + dependencies remain an explicitly documented migration gap for P0.5. +- [ ] **P3.5 Hugging Face Transformers speech-to-text implementation** + - Add generic `Load Speech Recognition Model` and `Transcribe Audio` nodes. + - Support transcription, optional translation, language hint, timestamps, and + chunking through a normalized contract. + - Begin with immutable safetensors revisions of Whisper Tiny/Base or another + reviewed Hugging Face ASR model. Do not create Whisper-specific nodes. + - Local smoke: a short rights-approved fixture; hard timeout 40 minutes. + - Security tests: path/media validation, duration/size limits, no remote code, + bounded output, cleanup, and offline cached execution. + - Package the Transformers runtime through P0.5; do not restore it to default + application dependencies. + +### Phase 3 test and asset gate + +- [ ] Static signature and artifact-policy tests pass for every exact model/mode. +- [ ] Tiny/mocked output normalization tests pass. +- [ ] Paired client forms, graph bridges, readiness, errors, and browser flows + pass. +- [ ] Each local smoke completes below 40 minutes or is moved to remote without + a local retry. +- [ ] Remote media and ASR fixtures pass rights review and Dataset verification. +- [ ] Only exact live-qualified recipes may enter Auto. + +## Phase 4 — Medium image, audio, and 3D integrations + +Priority: after the small-model contracts are stable. Hardware: remote by +default. Assets: remote Dataset only. + +### Committable segments + +- [ ] **P4.1 Control adapters:** SD1.5 ControlNet and T2I Adapter with pinned + preprocessors and auxiliary models. +- [ ] **P4.2 SDXL expansion:** Turbo first, then the reviewed text, image, + inpaint, instruct, ControlNet, adapter, PAG, and related combinations. +- [ ] **P4.3 Moderate image families:** DreamLite, Sana/Sana Sprint, and other + candidates admitted by the per-model checklist. +- [ ] **P4.4 Audio generation:** LongCat AudioDiT, Stable Audio quality recipes, + and AudioLDM2 general audio. +- [ ] **P4.5 Diffusers text-to-speech:** AudioLDM2 TTS with a generic speech + synthesis task contract. Require a reviewed safetensors artifact or an + explicit documented unsafe-deserialization exception before execution. +- [ ] **P4.6 Generic 3D artifacts:** Shap-E rendered output first; mesh/PLY/OBJ/GLB + only after a safe artifact/export contract exists. + +### Phase 4 test and asset gate + +- [ ] Unit and tiny-fixture tests cover adapters, outputs, and cleanup. +- [ ] Backend/client integrated gates pass for each independent segment. +- [ ] No Phase 4 live model is required to run locally. +- [ ] Remote receipts include peak memory, runtime, dependency/model revisions, + graph hash, media checks, and cleanup result. +- [ ] Gallery activation follows rights and anonymous byte verification. + +## Phase 5 — Short video qualification + +Priority: after image/audio contracts. Hardware and assets: remote only. + +### Committable segments + +- [ ] Qualify the existing Wan, LTX/LTX2, and Hunyuan FramePack graph paths from + Phase 2 using minimal short outputs. +- [ ] Add Stable Video Diffusion using documented offload and decode chunking. +- [ ] Add AnimateDiff/AnimateLCM with separately pinned base model, + `MotionAdapter`, scheduler rules, and optional LoRA. +- [ ] Evaluate Motif Video, CogVideoX-2B, and similar smaller candidates one at a + time after artifact-size and RAM review. + +### Phase 5 test and asset gate + +- [ ] Static and mocked tests cover frame count, dimensions, conditioning, + scheduler/adapter compatibility, output normalization, and cleanup. +- [ ] Remote smoke uses the minimum supported 8-25 frames and bounded steps. +- [ ] Representative quality proof is limited to approximately 2-4 seconds. +- [ ] Non-black frames, finite tensors, duration/frame rate, and decode/mux + integrity are checked without cross-hardware pixel hashes. +- [ ] Auto remains disabled until the exact short-video recipe has live proof. + +## Phase 6 — Pin update, heavy models, and long-form workflows + +Priority: last. Hardware and assets: dedicated remote qualification only. + +### Committable segments + +- [ ] Review all commits between the current and proposed Diffusers pins; update + the executable dependency, compatibility test, and upstream contract tests in + one isolated change. +- [ ] Add the post-pin Krea2 and Krea2 Turbo Modular classes. +- [ ] Add `MiniMaxH3ModularPipeline` only through generic joint video+audio + specifications for its distinct `t2va`, `fl2va`, and `ref2va` workflows. + Validate the `transformer/` versus `transformer_ref/` partition receipt, + Qwen3-VL conditioning, separate video/audio scheduler state, reference-media + bounds, immutable artifact revision, and remote-only resource envelope before + exposing any mode. +- [ ] Add the post-pin `LTX2ModularPipeline` and `LTX25ModularPipeline`, then + separately qualify LTX-2.5 distilled single-stage, full/SFT plus stage-2 LoRA, + and distilled two-stage recipes. Bind the exact sigma schedules, latent + upsampler, duration head, Gemma-4 prompt enhancer, diffusion decoder/NATTEN + path, and audio/video output handoff; prompt enhancement must remain an + explicit execution action and may not download during discovery or planning. +- [ ] Evaluate HunyuanVideo 1.5, Helios/Pyramid, Wan 14B/22 Modular, full LTX/LTX2, + EasyAnimate, SkyReels, Cosmos/Cosmos3, Kandinsky5 Video, and other heavy video + families. +- [ ] Evaluate large image/cascaded families and DiffusionGemma only on hardware + with sufficient RAM, VRAM, and disk. +- [ ] Keep LLaDA2 blocked unless its remote-code requirement receives an explicit + immutable-code security review. +- [ ] Build the 30-minute video workflow only after chunk generation, checkpoint + resume, deterministic stitching, audio mux, cancellation, and recovery pass + independently. + +### Phase 6 test and asset gate + +- [ ] No Phase 6 live run occurs on the current development machine. +- [ ] Heavy integrations can merge contract-only while clearly Expert-only and + `qualification_pending`. +- [ ] Long-form component tests use synthetic/tiny segments. +- [ ] The approximately six-hour 30-minute-video qualification runs once as a + scheduled release test after all component gates pass. +- [ ] Generated video and receipts are published through the remote asset + workflow; no media is committed to Git. + +## Per-integration admission checklist + +Complete this research before implementing any pipeline or model entry: + +- [ ] Confirm the class and workflow exist at the pinned Diffusers or approved + Transformers revision. +- [ ] Record the exact call signature, required inputs, optional inputs, and + return type. +- [ ] Identify the generic MoDiff task/media contract and necessary aliases. +- [ ] Inspect every model and auxiliary repository at an immutable revision. +- [ ] Record license, gating, remote-code, serialization, and redistribution + constraints. +- [ ] Record stored artifact size and a conservative RAM/VRAM/disk envelope. +- [ ] Use only upstream-supported loaders, adapters, schedulers, and offload + hooks. +- [ ] Define failure behavior and finite lower-resource retries. +- [ ] Define static, mocked/tiny, integrated, live, and asset evidence. +- [ ] Decide local-allowlisted or remote-only before downloading weights. + +## Appendix A — Missing Modular classes + +The current inventory contains 20 classes: 15 present at the MoDiff pin and 5 +that require a pin update. The latter group includes the two LTX2 exports added +after the previous roadmap snapshot. + +Present in the current pin but not registered by MoDiff: + +- [ ] `AnimaModularPipeline` +- [ ] `Cosmos3OmniModularPipeline` +- [ ] `Cosmos3DistilledModularPipeline` +- [ ] `ErnieImageModularPipeline` +- [ ] `Flux2ModularPipeline` +- [ ] `Flux2KleinBaseModularPipeline` +- [ ] `HeliosModularPipeline` +- [ ] `HeliosPyramidModularPipeline` +- [ ] `HeliosPyramidDistilledModularPipeline` +- [ ] `HunyuanVideo15ModularPipeline` +- [ ] `Ideogram4ModularPipeline` +- [ ] `LTXModularPipeline` +- [ ] `StableDiffusion3ModularPipeline` +- [ ] `Wan22ModularPipeline` +- [ ] `Wan22Image2VideoModularPipeline` + +Require a pin update: + +- [ ] `Krea2ModularPipeline` +- [ ] `Krea2TurboModularPipeline` +- [ ] `MiniMaxH3ModularPipeline` +- [ ] `LTX2ModularPipeline` +- [ ] `LTX25ModularPipeline` + +## Appendix B — Missing standard pipeline families + +This is a family inventory, not a requirement to create one node per family. + +### Audio + +- [ ] `audioldm2` +- [ ] `longcat_audio_dit` + +### Text diffusion + +- [ ] `diffusion_gemma` +- [ ] `llada2` + +### 3D and perception + +- [ ] `shap_e` +- [ ] `marigold` +- [ ] `visualcloze` + +### Video + +- [ ] `allegro` +- [ ] `animatediff` +- [ ] `anyflow` +- [ ] `chronoedit` +- [ ] `cogvideo` +- [ ] `consisid` +- [ ] `cosmos` +- [ ] `easyanimate` +- [ ] `helios` +- [ ] `hunyuan_video1_5` +- [ ] `kandinsky5` +- [ ] `latte` +- [ ] `lucy` +- [ ] `mochi` +- [ ] `motif_video` +- [ ] `sana_video` +- [ ] `skyreels_v2` +- [ ] `stable_video_diffusion` + +### Image, unconditional, and generic + +- [ ] `aura_flow` +- [ ] `bria` +- [ ] `bria_fibo` +- [ ] `chroma` +- [ ] `cogview3` +- [ ] `cogview4` +- [ ] `consistency_models` +- [ ] `controlnet` +- [ ] `controlnet_hunyuandit` +- [ ] `controlnet_sd3` +- [ ] `ddim` +- [ ] `ddpm` +- [ ] `deepfloyd_if` +- [ ] `dit` +- [ ] `dreamlite` +- [ ] `ernie_image` +- [ ] `glm_image` +- [ ] `hidream_image` +- [ ] `hunyuan_image` +- [ ] `hunyuandit` +- [ ] `ideogram4` +- [ ] `joyimage` +- [ ] `kandinsky` +- [ ] `kandinsky2_2` +- [ ] `kandinsky3` +- [ ] `kolors` +- [ ] `krea2` +- [ ] `latent_consistency_models` +- [ ] `latent_diffusion` +- [ ] `ledits_pp` +- [ ] `longcat_image` +- [ ] `lumina` +- [ ] `lumina2` +- [ ] `nucleusmoe_image` +- [ ] `omnigen` +- [ ] `ovis_image` +- [ ] `pag` +- [ ] `pixart_alpha` +- [ ] `prx` +- [ ] `sana` +- [ ] `stable_cascade` +- [ ] `stable_diffusion` +- [ ] `stable_diffusion_3` +- [ ] `t2i_adapter` + +## Completion ledger + +Add references only after the corresponding evidence exists. + +| Segment | Backend reference | Client reference | Live proof | Dataset revision | Status | +| --- | --- | --- | --- | --- | --- | +| P0.1 | `91c9a36` | `28b12b7` | Not required | Not required | Complete: exact-pair capability and stale-form execution checks are implemented and passed the recorded complete backend/client and browser gates. | +| P0.2 | `8fb2cb9`; corrected by `bf0af6b` | `c3e8a17`; corrected by `4cad1b2` | Not required | Not required | Complete: exact executable resource-plan targeting, bounded receipt binding, mixed/disconnected/zero-target rejection, and client fail-closed readiness/apply/run checks passed the complete backend/client and mocked-browser gates. The corrective pair makes the real schema-v2 backend publish the profile/schema fields already required by the client and binds them at admission; no model or asset execution was needed. | +| P0.3a.1 | `91c9a36` | `28b12b7` | Not required | Not required | Complete: registered Modular dynamic action safety and its backend/client gates are recorded in the paired implementation commits. | +| P0.3a.2 | `91c9a36` | `28b12b7` | Not required | Not required | Complete: safe declarative custom contract identity/preview and its backend/client/HTTP gates are recorded; executable custom admission remains deferred to P1.1. | +| P0.3b | `91c9a36` (revalidated at `8fb2cb9`) | `28b12b7`; Win32 checkpoint `d226c4b` (revalidated at `c3e8a17`) | Not required | Not required | Complete: P0.3b.1-.7 implementation, complete backend/client gates, reviewed Windows visual baselines, exact bundle mirror, and fresh HTTP smoke passed; live qualification is not part of this segment. | +| P0.3c | `e11263f`, `d81737e` | `aded6ca` (unchanged generic client contract revalidated) | Not required | Not required | Complete for the pinned upstream truth scope: all registered Modular action/component contracts and public modes are exact; SDXL base inpaint is contract-only; all 18 pinned SDXL workflows have exact generic state truth; Qwen/Layered and Wan split-state contracts close; standard image/video/audio adapters are registered only at their proved tier; and no client model-name branch was added. Multi-ControlNet/multiple-IP-Adapter expansion, templates/assets, and live qualification are distinct future gates, not evidence claimed by this phase. | +| P0.3d | `fd258d8` | `642ea9c` | Not required | Not required | Complete: backend-owned versioned Flux Schnell/Dev specifications, strict client parsing, generic graph materialization, exact proof/runtime receipt binding, complete backend/client/browser gates, byte-exact mirror verification, and local HTTP smoke passed. No model or media execution was required. | +| P0.3e | `96f70cb` (Flux Krea T2I), `a299d1d` (Flux Depth control-image), `14fef9f` (Flux Canny control-image), `6be23e7` (Flux Redux edit-image), `5f4d437` (Flux Kontext edit-image), `119c720` (Flux Kontext multi-reference edit), `544c54f` (Flux Fill inpaint), `634c485` (Flux Fill outpaint), `441cd00` (Flux2 Klein T2I), `ab3bd34` (Flux2 Klein edit-image), `4527764` (Flux2 Klein multi-reference edit), `276dd1f` (Wan TI2V text-to-video; corrected by `6983ce6`), `6983ce6` (Wan I2V image-to-video), `92cd1f5` (Wan 2.1 text-to-video), `93b1e17` (Wan 2.1 video-to-video), `09b1d4b` (Wan 2.1 color edit), `7736dd3` (LTX text-to-video), `7b8d5c1` (LTX image-to-video), `e83c760` (LTX video-to-video), `0bdc364` (LTX reference-to-video), `adcaf48` (ACE-Step text-to-audio), `5f6ffdc` (ACE-Step audio variation), `10b9b1c` (ACE-Step audio continuation), `60b87a1` (ACE-Step audio repaint), `de2160f` (Qwen Image Edit inpaint), `823357d` (Qwen Image Edit outpaint and bundle), `69a8561` (Wan VACE text-to-video; corrected by `2616014`, bundle `69247d0`), `0e9c3f2` (Wan VACE video inpaint and bundle), `b004af1` (Wan VACE video outpaint and bundle), `ed07f34` (Wan VACE control-to-video), `a4efd6c` (Z-Image Auto T2I), `6e40bab` (Qwen Image Auto T2I), `4596728` (Qwen Image Edit Modular), `0e7a8f1` (Qwen Image Edit Plus edit and multi-reference), `dd594ba` (Qwen Layered layer decomposition), `03c358b` (Qwen Image Control), `732e15c` (declarative loader-component outputs), `02afc25` (declarative layer-block allowlists), `7228c1f` (declarative Denoise image-latent dimensions), `b32241b` (generic video field overlay), `d3125dd` (Expert quantization resource policy), `f2ec7ac` (Expert MPS resource policy), `98f3841` (generic image and Modular field contracts), `fd514f7` (Expert quantization choices) | `80ac243` (Flux Krea T2I), `2de0c68` (Flux Depth control-image), `784e3c7` (Flux Canny control-image), `b709126` (Flux Redux edit-image), `8ae0dd9` (Flux Kontext edit-image), `d956a42` (Flux Kontext multi-reference edit), `38f8d81` (Flux Fill inpaint), `4c0bd05` (Flux Fill outpaint), `931621d` (Flux2 Klein T2I), `84e1d8f` (Flux2 Klein edit-image), `7180694` (Flux2 Klein multi-reference edit), `049addb` (Wan TI2V text-to-video; corrected by `60f4036`), `60f4036` (Wan I2V image-to-video), `0e359ce` (Wan 2.1 text-to-video), `651eeb3` (Wan 2.1 video-to-video), `2525937` (Wan 2.1 color edit), `9f2122f` (LTX text-to-video), `709ddd3` (LTX image-to-video), `8bd95e6` (LTX video-to-video), `cddd140` (LTX reference-to-video), `5f91ed9` (ACE-Step text-to-audio), `2a776c0` (ACE-Step audio variation), `7e69367` (ACE-Step audio continuation), `62dfe17` (ACE-Step audio repaint), `f28ff89` (Qwen Image Edit inpaint), `de2eba1` (Qwen Image Edit outpaint), `8f05541` (Wan VACE text-to-video; corrected by `4c9d40c`), `3f79ca2` (Wan VACE video inpaint), `fce224e` (Wan VACE video outpaint), `72ed446` (Wan VACE control-to-video), `77ceab9` (Z-Image Auto T2I), `531d4b9` (Qwen Image Auto T2I), `e8aab4e` (Qwen Image Edit Modular), `57a4072` (Qwen Image Edit Plus edit and multi-reference), `ff3f9c6` (Qwen Layered layer decomposition), `1102249` (Qwen Image Control), `947f7d9` (generic video field overlay), `5e8a1e7` (Expert quantization resource policy), `06ca70f` (Expert MPS resource policy), `c88e685` (generic image field switching browser proof), `a63d882` (image identity fallback removal), `5abfab9` (Expert quantization choices), `9cec2db` (exact installed-model loader identity), `d3ad700` (exact model-switch quantization retention), `229b5d1` (declarative low-memory presets), `a32b37a` (generic Modular readiness), `0c3a4c5` (exact restored quantization), `4afc515` (legacy graph fallback cleanup) | Not required | Not required | Complete: all 39 current execution-profile pairs and the shared loader, field, topology, readiness, and resource overlays are declarative and exact. The final residual audit removed active managed graph-construction model/pipeline switches while preserving explicit versioned template recipes, backend adapter normalization, and imported/manual Expert graph inference as declared boundaries. Complete client and 96/96 mocked-browser gates passed; no live model execution was required. | +| P0.3e Guider overlay | `51206e6` | `d1b2f88` | Not required | Not required | Complete: reviewed per-pipeline Guider choices, exact execution validation, scalar/multi-select dynamic option preservation, the complete backend/client gates, and the focused signal-relay mocked-browser contract passed. This closes the Guider portion of the parent P0.3e remaining-work summary. | +| P0.3e Scheduler overlay | `6779a19` | `140cab2` | Not required | Not required | Complete: pinned-upstream scheduler compatibility metadata, live-component and exact-constructor validation, complete backend/client gates, and the focused generic signal-relay mocked-browser contract passed. This closes the Scheduler portion of the parent P0.3e remaining-work summary. | +| P0.3e readiness overlay | `03c358b` (compatible exact-specification contract) | `7a02806` | Not required | Not required | Complete: readiness consumes the live managed loader identity or the unique authoritative execution specification, validates every exact role generically, and no longer routes the removed capability checks by model or pipeline name. Focused 67/67, complete client, exact browser, and final 88/88 mocked Studio gates passed; the bundle remained inside both limits. | +| P0.3e Modular readiness identity | Not required (uses the existing exact execution profile contract) | `a32b37a` | Not required | Not required | Complete: managed Run readiness identifies restored Modular graphs from generic managed roles and new graphs from the exact execution path, with no model-family branch. Focused 43/43, complete client, and final 96/96 mocked Studio gates passed; the bundle remained 429 bytes inside the stricter safety target. | +| P0.3e restored quantization admission | Not required (uses the existing exact execution profile contract) | `0c3a4c5` | Not required | Not required | Complete: bounded persisted quantization is admitted only by the exact selected execution profile, with no family filter. Focused 68/68, complete client, and final 96/96 mocked Studio gates passed; the bundle remained 395 bytes inside the stricter safety target. | +| P0.3e audio field overlay | `2a98856` | `fba496c` | Not required | Not required | Complete: reviewed audio pipeline/mode contracts publish the exact generic Generate field overlay; the backend rejects tampered overlays and the client no longer derives audio visibility from pipeline names. Complete backend/client and final 89/89 mocked Studio gates passed; no live audio execution was required. | +| P0.3e video field overlay | `b32241b` | `947f7d9` | Not required | Not required | Complete: every reviewed generic video adapter/mode owns its field visibility, required inputs, adapter controls, and strength binding; the exact backend action rejects stale contracts and the client no longer identifies LTX to choose the strength control. Complete backend/client and final 90/90 mocked Studio gates passed; no live video execution was required. | +| P0.3e Expert CUDA resource policy | `b1f514f` | `0259624` | Not required | Not required | Complete: exact Qwen execution profiles own the bounded dtype/offloaded/resident/quantized CUDA estimates, the client consumes only the policy attached to the selected exact specification, and no model-family fallback remains for these checks. Complete backend/client and final 91/91 mocked Studio gates passed; no live model execution was required. | +| P0.3e Expert quantization resource policy | `d3125dd` | `5e8a1e7` | Not required | Not required | Complete: exact Qwen execution profiles own the bounded Expert quantization/offload and generic-node component contract; the client strictly consumes it only through the selected exact specification, and direct/Modular readiness plus graph materialization no longer use a model-family quantization branch. Complete backend/client and final 91/91 mocked Studio gates passed; no live model execution was required. | +| P0.3e Expert MPS resource policy | `f2ec7ac` | `06ca70f` | Not required | Not required | Complete: exact reviewed execution profiles own the bounded Expert Apple MPS qualification and fallback advisory; the client strictly consumes it only through the selected exact specification, and no Qwen/Z/video family branch remains in MPS readiness. Complete backend/client and final 92/92 mocked Studio gates passed; no Apple Silicon or live model execution was required. | +| P0.3e image/Modular field overlay | `98f3841` | `c88e685` | Not required | Not required | Complete: exact generic image pipeline/mode contracts drive live field visibility, Modular generic nodes refresh from selected registry metadata, stale image overlays fail closed, and the complete backend/client/final 93/93 mocked Studio gates passed; no live model execution was required. | +| P0.3e image-path and Expert quantization-choice cleanup | `fd514f7` | `a63d882`, `5abfab9` | Not required | Not required | Complete: managed image topology and loader class now come only from the exact selected specification or existing managed binding; exact Qwen/Flux profiles own the bounded Expert quantization choices; controlled tab restore retains its execution-spec receipt. The complete backend/client gates and final 94/94 mocked Studio suite passed, with the bundle 259 bytes inside the stricter safety target. No live model execution was required. | +| P0.3e resource-path overlay | `8fb2cb9` (exact schema-v2 Auto target contract) | `16b7f12` | Not required | Not required | Complete: the client no longer guesses execution paths from Qwen or family identity before planning; exact selected backend candidates remain the only Auto path authority, and the complete 89/89 Studio gate passed. | +| P0.4 | `bf0af6b` (Auto schema/profile history binding), `f0ccd13` (optional-runtime receipt binding), `3a0b355` (specification-owned graph receipt binding), `e2a1bf2` (auxiliary-artifact receipt binding), `5cb785d` (executable controlled-LoRA history/cache receipt binding), `31cbc47` (current controlled-workflow artifact receipts), `a4efd6c` (Z-Image exact graph specification), `6e40bab` (Qwen Image exact graph specification), `4596728` (Qwen Image Edit Modular exact graph specification), `0e7a8f1` (Qwen Image Edit Plus exact graph specifications), `dd594ba` (Qwen Layered exact graph specification), `03c358b` (Qwen Image Control exact graph specification) | `12847d0`, `4cad1b2`, `0131ea7`, `453da03`, `54a610a` (exact controlled-artifact metadata and proof label), `77ceab9`, `531d4b9`, `e8aab4e`, `57a4072`, `ff3f9c6`, `1102249` | Not required | Not required | Complete for the current reviewed contract set: schema-v3 seals LoRA, sequence, upscaler, quality, soundtrack, and lyric/mux graph transformations; Auto candidates/history bind planner/profile/runtime/topology and all current executable auxiliary artifact receipts; every one of the 39 current execution-profile pairs has an exact backend-owned graph specification; stale, malformed, disconnected, or unreviewed receipt claims fail closed; and plan-time UI no longer presents base-only history as proof of controlled artifacts. Future controlled artifact kinds require a new reviewed receipt and qualification slice. | +| P0.5 | `4073711` (portable qualification harness; product cutover pending) | Pending | Windows x86-64 guarded live-model proof complete; staged-runtime cutover pending | Not required | In progress: the exact composite contract/status, fail-closed overlay scaffold, base-neutral guard/status scaffold, dormant backend-qualified consent/install/progress/cancel/activate/repair/rollback controls, repo-aware shared-loader readiness/Auto parity, and Windows x86-64 clean-base, artifact-locked install/no-weight workload, supervised HTTP install/cancel/activate/restart/repair/rollback, and guarded immutable Qwen live-model execution are tested. A bounded explicit-consent harness now carries the same locked install, fresh-process no-weight workload, and rollback check to Linux/macOS without changing production flags. Source dependency/action/cutover declarations remain unchanged. Non-Windows executable overlay qualification and atomic Transformers+PEFT base cutover remain. | +| P1.1 | Pending | Pending | Not required | Not required | Custom execution admission deferred by repository-directed import review | +| P1.2 | Pending | Pending | Not required | Not required | Not started | +| P1.3 | Pending | Pending | Not required | Not required | Not started | +| P1.4 | Pending | Pending | Not required | Not required | Not started | +| P2.1-P2.4 | Pending; add one row per family/mode slice | Pending; add one row per family/mode slice | Remote pending | Pending | Not started | +| P2.5 | Pending | Pending | Pending | Pending | Not started | +| P3.4 | Pending | Pending | Not required | Not required | Policy implementation and gates complete; paired commits pending | +| P3.1-P3.3, P3.5 | Pending; add one row per slice | Pending; add one row per slice | Pending | Pending | Not started | +| P4.1-P4.6 | Pending | Pending | Remote pending | Pending | Not started | +| P5 | Pending | Pending | Remote pending | Pending | Not started | +| P6 | Pending | Pending | Remote pending | Pending | Not started | diff --git a/docs/hugging-face-standards.md b/docs/hugging-face-standards.md index 1442e17..ef5901c 100644 --- a/docs/hugging-face-standards.md +++ b/docs/hugging-face-standards.md @@ -1,6 +1,6 @@ # Hugging Face Engineering Alignment -MoDiff is not part of the Hugging Face organization, but its model runtime is deliberately built on Hugging Face Diffusers and Modular Diffusers. This document records which upstream engineering expectations are project requirements and how contributors verify them. +MoDiff is not part of the Hugging Face organization, but its local model runtime may integrate official libraries maintained and published by Hugging Face. Diffusers and Modular Diffusers remain the original and primary integrations; additional libraries follow the same review, reproducibility, graph, and security requirements. This document records which upstream engineering expectations are project requirements and how contributors verify them. ## Upstream references @@ -29,13 +29,17 @@ Upstream repository layouts and release processes are not copied mechanically. M - Keep public graph, HTTP, WebSocket, and persistence contracts stable. A deliberate migration must update both repositories, tests, and documentation. - Keep optional dependencies optional. Registry discovery and diagnostics must not import every model stack or require accelerator hardware. -### One model-execution boundary +### One graph boundary, reviewed Hugging Face runtimes -- Diffusers and Modular Diffusers are the only supported model execution layer. -- Transformers, Accelerate, PEFT, quantization libraries, and accelerator kernels may support a Diffusers pipeline; they must not become an independent application or alternate workflow driver. -- Ordinary deterministic image, audio, video, tensor, and file operations are allowed when they do not load another model runtime. -- Do not add alternate graph executors, hosted inference providers, arbitrary Python model modules, or another model-execution layer. -- Pin the reviewed Diffusers revision in the executable installer contract. Model and adapter references used by curated workflows must also use immutable revisions where the Hub supports them. +- A model-execution library is eligible when it is officially maintained and published by Hugging Face and its ownership and package provenance have been verified during integration. Eligibility is not automatic support: each library needs a declared use, reviewed dependency/version contract, backend adapter, and compatibility tests. +- Hugging Face Hub hosting does not make a library, model, or repository-supplied Python implementation first-party. Curated model and adapter references use immutable revisions where the Hub supports them, and remote code remains separately trust-gated. +- Every library executes locally through MoDiff's existing node graph, resource lifecycle, progress/cancellation, Auto/Expert, file, and output contracts. Do not add alternate graph executors, hosted inference providers, browser-side model runtimes, or a second workflow representation. +- Frontend nodes and task surfaces remain modality- or task-generic. The backend execution specification selects the reviewed library/model adapter and publishes its dynamic input, parameter, and output contract. The client must not infer Python classes or maintain a parallel model-specific parameter table. +- Transformers is an optional runtime and is not part of the default application installation. Registry discovery, template browsing/opening, and Auto planning may report that it is required but must not install it. Installation requires an explicit user action against a reviewed optional-runtime profile, followed by version and compatibility verification before execution. +- Accelerate, PEFT, quantization libraries, accelerator kernels, and ordinary deterministic image, audio, video, tensor, and file operations may support a model path when narrowly scoped and tested. +- Keep the reviewed Diffusers revision pinned in the executable installer contract. Apply an equally explicit compatible-version or immutable-source contract to every additional execution library. + +Transition status: the legacy base dependency on Transformers and its required preflight check remain until roadmap segment P0.5 migrates existing Diffusers consumers to the staged optional-runtime contract. The policy above is the acceptance criterion for that migration, not a claim that the current installer already omits Transformers. ### Modular Diffusers diff --git a/docs/optional-runtime-optimizations.md b/docs/optional-runtime-optimizations.md index 3e6b34c..f907e32 100644 --- a/docs/optional-runtime-optimizations.md +++ b/docs/optional-runtime-optimizations.md @@ -1,25 +1,39 @@ # Optional runtime optimizations -Last reviewed: 2026-07-29 +Last reviewed: 2026-08-12 -MoDiff treats accelerator extensions as optional runtime capabilities, not as -uncontrolled additions to the main Python environment. An optional package is -installed into an app-owned staged overlay, validated in a fresh process -against the active Python, Torch, Diffusers, and accelerator profile, and only -then offered for activation. Activation requires a worker restart. The last -validated overlay remains available for rollback. +MoDiff treats accelerator extensions and optional model libraries as reviewed +runtime contracts, not as uncontrolled additions to the main Python +environment. The current backend includes the fail-closed control and +artifact-validation scaffold for an app-owned staged overlay, but no package +profile is qualified for installation or activation yet. Existing hashless +optimization overlays are classified as `legacy_unqualified`; they are never +loaded or activated, and an explicit rollback deactivates them to the base +environment. + +The first optional model-library contract moves Transformers `5.14.1` and PEFT +`0.20.0` together with their eight overlay-owned transitive distributions. It +is published as `candidate_unqualified` with `cutoverReady: false`, exact +source-controlled artifact locks, and unavailable install/activation actions. +The lock covers ten wheels on Python 3.12 for Linux, macOS, and Windows on +x86-64 and ARM64. Merely finding the requested versions—or merely publishing +these locks—does not make the contract runnable. ## Product contract -1. Setup shows packages and runtime features supported by the current managed - profile. -2. Package installation never mutates the active interpreter. A failed build - or import probe leaves the running environment unchanged. -3. ABI-sensitive packages are installed without resolving another copy of - Torch. Source builds receive an app-local build toolchain. -4. A successful import/capability probe only proves that the feature can load. +1. Setup and the runtime API show reviewed contracts and their qualification + state; they must not imply that an unavailable package action can run. +2. Read-only discovery, workflow browsing/opening, Auto planning, capability + inspection, and optional-runtime status never import or install an optional + distribution. +3. A future qualified install must use a complete source-controlled wheel set, + exact hashes, an authenticated installer, and an isolated staged directory. + It may never resolve another copy of a base-owned dependency such as Torch. +4. Staging never mutates the active interpreter. Failed, cancelled, stale, or + forged state remains non-active and leaves graph execution fail-closed. +5. A successful import/capability probe only proves that the feature can load. It never authorizes Auto. -5. Auto may select an optimization only after: +6. Auto may select an optimization only after: - the user explicitly enables the capability; - the exact runtime, model artifact, mode, and result-affecting workload match a receipt; @@ -27,23 +41,75 @@ validated overlay remains available for rollback. - the optimized run improves elapsed time or peak accelerator allocation by at least 2%; and - the user reviews and accepts the output. -6. Qualified choices are combined only when each choice has its own matching +7. Qualified choices are combined only when each choice has its own matching receipt. A package or feature update changes the runtime fingerprint and invalidates the old Auto eligibility. -The Setup panel exposes install progress, activation, rollback, opt-in, -compatibility probes, qualification actions, and the upstream documentation. +The backend publishes bounded status, job, cancellation, activation, and +rollback contracts. Setup implements that lifecycle behind the backend-owned +`contractState: qualified`, `cutoverReady`, and per-action availability flags: +install or repair requires explicit consent, polls only the returned bounded job +identity, supports cancellation, and keeps activation and rollback behind their +own consent steps. Ambiguous staged environments do not expose activation. +Current source flags keep the candidate unqualified, so Setup renders no package +controls and sends no package mutation request. Direct install and activation +requests still return a fail-closed conflict before a lease, job, network +request, staged directory, or subprocess is created. Runtime-only optimization +features may still be explicitly selected or qualified when their existing +capability contract permits it. + +## First-use execution boundary + +Optional-runtime dependency metadata is intentionally separate from executable +delivery. Every current Diffusers execution profile is `base` delivered even +though it declares the composite Transformers/PEFT profile, so current missing, +wrong-version, or unqualified observations do not change browsing, Auto, +capability readiness, graph execution, or field actions. A future atomic +cutover changes an exact execution profile to `optional_overlay`; only then is +the optional runtime externally required for that execution. + +Auto plans, workflow listings, model capabilities, and execution profiles +publish the same seven-field `optionalRuntimeRequirement` contract: +`schemaVersion`, `delivery`, `requiredNow`, `profileIds`, +`executionProfileIds`, `state`, and `reason`. The schema version is `1`; both ID +lists contain at most 32 unique bounded IDs. A no-contract base item may use +empty lists, while an overlay requirement must identify at least one optional +runtime and one execution profile. States are `base_satisfied`, `missing`, +`wrong_version`, `present_unqualified`, `staged`, `active`, +`busy_recovery_only`, `restart_required`, `repair_required`, and `unavailable`. +Malformed or ambiguous contracts and status catalogs fail closed as +`unavailable`. + +A base-delivered `base_satisfied` requirement is execution-ready. When +`requiredNow` is true, only `state: active` is ready. Active requires the +current worker and status catalog to agree on the active overlay, and each +required profile must have `contractState: qualified` and +`cutoverReady: true`. The current `candidate_unqualified` profile therefore +cannot become runnable. Graph admission inspects only loader nodes on +executable paths; field actions use their authorized module, action, and +values. Both are rechecked at the worker/pre-import boundary, and client +`runtimeHints` cannot authorize execution. + +A non-active required overlay returns HTTP `409` with a bounded public blocker: +`error`, `category: optional_runtime`, `error_code`, `message`, +`recovery_hint`, and the seven-field `optionalRuntimeRequirement`. Worker +failures use the same redacted contract without tracebacks, local paths, +process details, or loader diagnostics. A live runtime mutation gate continues +to serialize every graph and field action. Persistent recovery or restart state +blocks only overlay-required execution; base-delivered work remains runnable, +including after an unsupervised activation records `restart_required` and +releases its completed mutation gate. ## Reviewed package pins -| Capability | Reviewed version | App-managed | Auto eligibility | +| Capability | Reviewed version | Current package action | Auto eligibility | | --- | ---: | --- | --- | -| Hugging Face Hub kernels | `kernels 0.16.0` | NVIDIA/Linux | Exact qualified workload only | -| FlashAttention 2 | `flash-attn 2.8.3.post1` | CUDA/ROCm source build | Exact qualified workload only | -| TorchAO | `torchao 0.17.0` | Supported profiles | Exact qualified workload only | -| Optimum Quanto | `optimum-quanto 0.2.7` | Supported profiles | Exact qualified workload only | -| bitsandbytes | `bitsandbytes 0.50.0` | NVIDIA profiles | Exact qualified workload only | -| SageAttention | `sageattention 1.0.6` | NVIDIA/Linux | Manual experiment; never Auto | +| Hugging Face Hub kernels | `kernels 0.16.0` | Unavailable pending artifact locks | Exact qualified workload only | +| FlashAttention 2 | `flash-attn 2.8.3.post1` | Unavailable pending artifact/build locks | Exact qualified workload only | +| TorchAO | `torchao 0.17.0` | Unavailable pending artifact locks | Exact qualified workload only | +| Optimum Quanto | `optimum-quanto 0.2.7` | Unavailable pending artifact locks | Exact qualified workload only | +| bitsandbytes | `bitsandbytes 0.50.0` | Unavailable pending artifact locks | Exact qualified workload only | +| SageAttention | `sageattention 1.0.6` | Unavailable pending artifact locks | Manual experiment; never Auto | | xFormers | `0.0.32.post2` for the Torch 2.8 CUDA profile | Base NVIDIA profile | Exact qualified workload only | | AMD AITER | No universal pin | No generic installer | Manual, qualified Instinct/ABI combinations only | @@ -58,6 +124,198 @@ Showing a generic install action would risk replacing the managed Torch ABI. Setup links to the official build instructions for an administrator evaluating a qualified deployment. +## Staged-overlay qualification boundary + +The staged-overlay implementation is intentionally not an executable product +claim. Qualification still requires, at minimum: + +- cross-platform execution of the locked installer/promotion boundary and + clean-base, staged, restart/rollback, and representative no-weight/live + workload evidence on each target class; and +- stricter fresh-process side-effect containment evidence for the installed + Transformers/PEFT closure. + +Until those gates are closed, `installActionAvailable`, +`activationAvailable`, and `cutoverReady` remain false. The compatibility +routes do not make a candidate eligible by themselves. + +### Portable target qualification + +`scripts/qualify_optional_runtime.py` prepares the same bounded qualification +on each supported operating-system/architecture pair without exposing a +product API or changing the source-controlled profile flags. Preflight is +offline and non-mutating apart from a disposable copy of the already verified +managed uv executable: + +```bash +./scripts/with-runtime-env.sh ./.venv/bin/python \ + scripts/qualify_optional_runtime.py --preflight-only +``` + +On Windows, the equivalent inspection command is: + +```powershell +.\.venv\Scripts\python.exe scripts\qualify_optional_runtime.py --preflight-only +``` + +The command must report `status: ready` before the networked run. In particular, +Python must be 3.12, the managed uv receipt and executable must match the exact +source-controlled platform lock, and all ten staged distributions must be +absent from the base interpreter. Run from a dedicated prospective-base +checkout; do not uninstall packages from a normal development environment and +do not weaken the clean-base check. Keep any prospective dependency diff with +the evidence so the tested source state is reviewable. + +The explicit-consent run downloads only the selected immutable wheel set into +a new temporary managed root. It uses the production install, validation, +promotion, activation, and rollback code; runs an offline tiny CLIP+LoRA +Transformers/PEFT workload in a fresh child; then starts another fresh child to +prove that rollback returned to a base process where every staged distribution +is absent. Evidence is bounded, excludes local paths, refuses to overwrite an +existing file, and should be written outside the repository: + +```bash +./scripts/with-runtime-env.sh ./.venv/bin/python \ + scripts/qualify_optional_runtime.py --consent \ + --evidence ../modiff-optional-runtime-linux-x86_64.json +``` + +Use the same command on macOS and on each reviewed architecture. A passing JSON +file proves only the locked temporary overlay, fresh-process no-weight workload, +and rollback boundary on that exact host/source revision. It does not prove a +supervised HTTP restart/cancel/repair sequence, accelerator execution, a live +model/media result, or another platform. Run and record those remaining target +checks separately before changing any production action or cutover flag. + +The next available physical target is Linux. No local macOS qualification host +is currently available, so macOS remains an explicit open gate. Its evidence +must come from a reviewed hosted macOS runner or a contributor-controlled Mac; +until then, do not enable global action/cutover flags. A Windows-and-Linux-only +cutover would require a separate reviewed platform-scoped delivery design that +keeps macOS base-delivered and tested—it is not implied by skipping the macOS +matrix. + +Qualification preparation now includes exact filename, URL, SHA-256, and size +locks for all sixty platform-wheel records, plus one immutable uv `0.11.26` +archive/executable pair for each supported target. The base installer writes a +receipt only after rehashing the reviewed executable, and the overlay path +rehashes it independently. Archive validation requires one matching METADATA, +WHEEL, and complete unique RECORD; every non-RECORD row must carry the exact +SHA-256 and size of its archived file, and RECORD must cover the archive exactly. +On Windows, the watchdog enters a non-breakaway Job Object with kill-on-close +before launching the installer, so cancellation or parent death contains the +entire descendant tree. These controls remain dormant while action flags are +false. + +Promotion and cleanup now operate on exact filesystem objects rather than +resolved path strings. Windows renames the held source handle with +`FileRenameInfoEx`, write-through, and no replacement; cleanup quarantines the +same held directory and disposes each descendant by handle. Linux requires +`renameat2(RENAME_NOREPLACE)` relative to opened parents, while macOS requires +`renameatx_np(RENAME_EXCL)`; an unavailable exclusive primitive fails closed. +The install lease binds the original staging directory identity, so a replaced +name cannot be promoted or cleaned as though it were the validated tree. + +Before promotion, MoDiff durably records the exact environment ID and canonical +manifest/validation digests. A post-rename record is written only after the +destination is re-inspected against those digests. On the next locked startup, +install, activation, or rollback operation, an interrupted prepared record is +either completed from the still-valid staged directory, acknowledged against +the already-promoted exact directory, or left as an explicit repair condition; +malformed, missing, duplicated, or identity-mismatched states never downgrade +to an absent journal. + +Windows x86-64 qualification on 2026-08-12 exercised the dormant future-state +path in an isolated temporary managed root: the reviewed uv executable installed +all ten locked wheels (16,930,199 archive bytes), authenticated 3,441 wheel +files, passed isolated import/symbol/origin validation, promoted with a cleared +journal, activated only in the temporary state, and loaded +`transformers==5.14.1` plus `peft==0.20.0` from the overlay in a second fresh +process before rolling back to base. The run exposed and closed two Windows uv +integration details: local hashes must be expressed as `name @ file://...` +requirements with a separate `--hash=sha256:...`, and `--link-mode copy` is +required so the authenticated overlay never shares hardlinks with uv's cache. +The path-bearing requirements document plus uv's bounded `.lock` and +`uv_cache.json` bookkeeping are removed before authentication/promotion. No +model artifact was downloaded or executed. This is one Windows qualification, +not Linux/macOS/ARM64 or representative model-workload evidence, so action and +cutover flags remain false. + +A second isolated Windows x86-64 run on 2026-08-12 exercised a bounded local +no-weight workload through that same production install, validation, promotion, +activation, fresh-process, and rollback path. With every Hugging Face offline +flag enabled, the activated worker loaded Transformers `5.14.1` and PEFT +`0.20.0` from the artifact-anchored overlay, constructed a tiny local CLIP text +encoder, injected PEFT LoRA adapters into its query/value projections, and +completed a finite `[1, 4, 16]` forward result with four trainable adapter +parameters. Diffusers reported its PEFT backend active. The fresh worker then +rolled back to a base process with no active environment. The run downloaded no +model artifact and changed no source action flag. It closes the Windows +no-weight staged-workload check only; it is not a clean-base install, supervised +server restart, live model/media run, or evidence for another target platform. + +The prospective clean-base Windows x86-64 matrix then installed the reviewed +NVIDIA backend from a detached checkout with Transformers and PEFT removed from +the project dependencies and from required preflight imports. The managed +installer produced a compatible 64-package CUDA base; all ten overlay +distributions were absent; preflight was ready; and registry discovery loaded +132 nodes without loading any staged distribution. Starting from that base, the +same ten locked wheels and 16,930,199-byte archive set passed validation, +promotion, activation, the finite CLIP+LoRA workload above in a fresh process, +and rollback to a process with no active environment. This proves the Windows +clean-base/staged-runtime dependency split. It still does not qualify a live +model artifact, supervised server restart/repair, or another platform, and the +source dependency/action/cutover declarations therefore remain unchanged. + +A supervised HTTP lifecycle then ran from that prospective clean base with only +the future action/cutover flags enabled in the detached process. The real +install endpoint staged and validated the same ten-wheel, 16,930,199-byte +closure and retained a ready job bound to its exact environment, profile, and +spec digest. Activation returned `restarting: true`, replaced the base worker, +and the new worker reported an active overlay and Transformers `5.14.1`. +Rollback returned `restarting: true`, replaced the active worker again, restored +the base process status, and reported Transformers absent. The install exposed +and fixed an invalid keyword call at the worker-thread progress boundary; both +optional-runtime and legacy optimization installers now schedule a bound update +callback, with regressions for each path. The temporary supervisor, detached +checkout, staged environment, and diagnostics were removed and port 8088 was +free. This closes Windows supervised install/activation/restart/rollback only; +live cancellation/repair, live model/media execution, non-Windows execution, +and source cutover remain pending. + +A second supervised clean-base run exercised cancellation and repair through +the production HTTP boundary. Cancellation during the installer subprocess +advanced the exact job through `cancelling` to `cancelled`, removed staging, +promoted no environment, and left the same Transformers-free worker running. +After a later validated activation, a qualification-only one-byte overlay drift +caused the next worker to import no optional package and report +`repair_required`. Reinstall created a separately validated replacement, while +activation correctly refused to replace the still-selected corrupt runtime +directly. Explicit rollback restarted to base; activation of the replacement +environment from the completed job receipt restarted into Transformers +`5.14.1`; a final rollback restarted to base with Transformers absent. Setup +uses that completed-job environment identity before catalog inference, so the +immediate repair activation stays bound to the exact new receipt. The detached +tree, both staged environments, jobs, and server processes were removed. This +closes Windows supervised cancellation/repair, but not live model/media or +non-Windows execution and not source cutover. + +A third detached Windows x86-64 run exercised the complete future qualified +guard with a real model and media output. Model Manager downloaded the +Apache-2.0, safetensors-only, no-custom-code +`optimum-intel-internal-testing/tiny-random-qwen-image` snapshot at immutable +commit `ef73a0df0cb8ccfa00cc178ec528c6e681791a10`. The activated composite +overlay then supplied Transformers `5.14.1` to the existing generic Qwen image +loader; `LoadPipeline -> Generate -> Image.Save` completed one 64 by 64 CUDA +step and produced a non-uniform RGB PNG. After rollback to a Transformers-free +base worker, the same loader was rejected before queueing with HTTP 409 +`optional_runtime_staged`. This run also closed an app-owned download mismatch: +`POST /hf_download` now accepts a bounded exact lowercase 40-character commit +`revision`, forwards it to the Hub snapshot operation, and permits concurrent +join only for the same revision and file selection. All qualification-only +state was removed. This closes the Windows guarded live-model/media check, not +non-Windows qualification or the production dependency/action/cutover gate. + ## Runtime features The following features have concrete runtime implementations and remain diff --git a/main.py b/main.py index 749ae9a..4a99dd4 100644 --- a/main.py +++ b/main.py @@ -2,13 +2,6 @@ import os -from modiff.optimization_packages import activate_runtime_overlay - -# Optional accelerator packages are staged and validated out-of-process. Make -# only the explicitly activated environment visible, before importing Torch or -# any MoDiff module that can transitively import it. -activate_runtime_overlay() - os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # Diffusers reads this once while its modules are imported. Configure it before # the worker imports any node packages so large sharded pipelines can load @@ -58,6 +51,11 @@ def handle_loop_exception(loop, context): async def worker_main(): # Import heavyweight model/runtime modules only inside the replaceable # worker. The small parent supervisor must never own accelerator state. + from modiff.optimization_packages import activate_runtime_overlay + + # Plain-path activation is stdlib-only and fresh-validates the sealed + # overlay before any worker import can transitively load Torch/Diffusers. + activate_runtime_overlay() from modiff.server import server await server.run() diff --git a/modiff/NodeBase.py b/modiff/NodeBase.py index b35c9ed..0f55e23 100644 --- a/modiff/NodeBase.py +++ b/modiff/NodeBase.py @@ -306,7 +306,8 @@ class NodeBase: CALLBACK = 'execute' # Subclasses may list validated inputs that affect how a resident object is # used but not how it is constructed. Changes to these values should update - # the node's current parameters without discarding expensive cached output. + # the node's current parameters without discarding expensive cached output, + # while still invalidating results produced by connected descendants. cache_ignored_params = frozenset() def __init__(self, node_id=None): @@ -341,6 +342,11 @@ def invalidate_cache(self): """ self._cache_invalidated = True + def _cache_params_equal(self, previous, current): + """Compare cached inputs, allowing security-sensitive nodes to tighten equality.""" + + return deep_equal(previous, current) + def __call__(self, **kwargs): self._interrupt = False self._progress_started_at = None @@ -427,13 +433,20 @@ def matches_option(candidate, option): current_cache_params = { key: value for key, value in params.items() if key not in ignored_cache_params } + previous_ignored_params = { + key: value for key, value in self.params.items() if key in ignored_cache_params + } + current_ignored_params = { + key: value for key, value in params.items() if key in ignored_cache_params + } + ignored_params_changed = not deep_equal(previous_ignored_params, current_ignored_params) # If any load-relevant value changed, or output is empty, execute the # node. Validated passthrough inputs are still recorded below so # diagnostics reflect the current graph invocation. if ( self._cache_invalidated - or (not deep_equal(previous_cache_params, current_cache_params)) + or (not self._cache_params_equal(previous_cache_params, current_cache_params)) or any(v is None for v in self.output.values()) ): self._cache_invalidated = False @@ -479,6 +492,11 @@ def matches_option(candidate, option): "node": self.node_id, }, self._sid) else: + # A cache-ignored value can reconfigure the same resident output + # without repeating its expensive construction. Preserve that + # cache hit, but publish the semantic change so connected nodes do + # not reuse results computed under the previous contract. + self._has_changed = ignored_params_changed self.params = params return self.output diff --git a/modiff/auto_resource.py b/modiff/auto_resource.py index 5894b86..2c9957b 100644 --- a/modiff/auto_resource.py +++ b/modiff/auto_resource.py @@ -1,6 +1,8 @@ from __future__ import annotations +from copy import deepcopy import json +import math import os import time from pathlib import Path @@ -17,18 +19,19 @@ ) from modiff.diffusers_profiles import ( ACE_STEP_REPO, - FLUX_CANNY_REPO, - FLUX_DEPTH_REPO, - FLUX_DEV_REPO, - FLUX_FILL_REPO, - FLUX_KONTEXT_REPO, - FLUX_KREA_REPO, - FLUX_REDUX_REPO, - FLUX_SCHNELL_REPO, - FLUX2_KLEIN_REPO, + FLUX_CANNY_REPO as FLUX_CANNY_REPO, + FLUX_DEPTH_REPO as FLUX_DEPTH_REPO, + FLUX_DEV_FP8_REPO as FLUX_DEV_FP8_REPO, + FLUX_KONTEXT_NVFP4_REPO as FLUX_KONTEXT_NVFP4_REPO, + FLUX_KONTEXT_REPO as FLUX_KONTEXT_REPO, + FLUX_KREA_REPO as FLUX_KREA_REPO, + FLUX_REDUX_REPO as FLUX_REDUX_REPO, + FLUX_SCHNELL_REPO as FLUX_SCHNELL_REPO, LTX_VIDEO_REPO, QWEN_IMAGE_2512_PREQUANTIZED_REPO, QWEN_IMAGE_2512_REPO, + execution_profiles_for_execution, + optional_runtime_profile_ids_for_execution, ) from modiff.hardware import disk_snapshot, get_hardware_snapshot, system_memory_snapshot from modiff.model_artifact_catalog import ( @@ -37,6 +40,13 @@ catalog_model, community_artifact_is_discoverable, ) +from modiff.optional_runtimes import public_optional_runtime_profiles +from modiff.optional_runtime_execution import optional_runtime_requirement_for_execution +from modiff.studio_execution_specs import ( + studio_auto_model_requirements, + studio_execution_spec_for_pair, + studio_model_dependencies_for_pair, +) GIB = 1024**3 @@ -58,13 +68,11 @@ QWEN_IMAGE_EDIT_PLUS_REPO = "Qwen/Qwen-Image-Edit-2511" QWEN_IMAGE_LAYERED_REPO = "Qwen/Qwen-Image-Layered" WAN_VACE_REPO = "Wan-AI/Wan2.1-VACE-1.3B-diffusers" -FLUX_DEV_FP8_REPO = "black-forest-labs/FLUX.1-dev-FP8" -FLUX_KONTEXT_NVFP4_REPO = "black-forest-labs/FLUX.1-Kontext-dev-NVFP4" READY_PROOF_STATUSES = {"passed", "declared_safe", "live_proven"} PROVEN_PROOF_STATUSES = READY_PROOF_STATUSES FAILED_HERE_PROOF_STATUS = "failed_here_before" -AUTO_HISTORY_VERSION = 2 +AUTO_HISTORY_VERSION = 8 AUTO_RESOURCE_SCHEMA_VERSION = 2 AUTO_HISTORY_RELATIVE_PATH = Path("auto_resource") / "history.json" @@ -96,7 +104,7 @@ "ZImageModularPipeline": { "supportedTasks": ["text_to_image"], "defaultRepo": Z_IMAGE_REPO, - "executionPath": "modular-diffusers", + "executionPath": "direct-diffusers-image", "qualityDefaults": {"width": 1024, "height": 1024, "steps": 8, "guidanceScale": 1}, "minimum": {"accelerator": "gpu_or_cpu", "vramBytes": 0, "systemRamBytes": 8 * GIB}, "recommended": {"accelerator": "gpu", "vramBytes": 8 * GIB, "systemRamBytes": 16 * GIB}, @@ -196,7 +204,7 @@ "guardedReason": "Prefer the Apache-2.0 Diffusers-compatible prequantized Qwen Image Edit artifact on nominal 16 GiB CUDA systems before attempting official BF16 disk offload.", }, "QwenImageEditPlusModularPipeline": { - "supportedTasks": ["edit_image", "multi_image_reference_edit", "inpaint"], + "supportedTasks": ["edit_image", "multi_image_reference_edit"], "defaultRepo": QWEN_IMAGE_EDIT_PLUS_REPO, "executionPath": "modular-diffusers", "pipelineClass": "QwenImageEditPlusModularPipeline", @@ -278,22 +286,6 @@ OFFLOAD_MODE_NONE, ], }, - "WanVideoPipeline:text_to_video": { - "supportedTasks": ["text_to_video"], - "defaultRepo": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers", - "executionPath": "direct-diffusers-video", - "pipelineClass": "WanPipeline", - "qualityDefaults": {"width": 832, "height": 480, "steps": 30, "guidanceScale": 5, "numFrames": 81}, - "minimum": {"accelerator": "cuda", "vramBytes": 10 * GIB, "systemRamBytes": 24 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 12 * GIB, "systemRamBytes": 32 * GIB}, - "highQuality": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB}, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - }, "LTXVideoPipeline": { "supportedTasks": ["text_to_video", "image_to_video", "video_to_video", "reference_to_video"], "defaultRepo": LTX_VIDEO_REPO, @@ -346,220 +338,104 @@ ], "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "scipy"], }, - "FluxSchnellPipeline": { - "supportedTasks": ["text_to_image"], - "defaultRepo": FLUX_SCHNELL_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxPipeline", - "qualityDefaults": {"width": 1024, "height": 1024, "steps": 4, "guidanceScale": 0, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 12 * GIB, "systemRamBytes": 24 * GIB, "diskFreeBytes": 25 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 16 * GIB, "systemRamBytes": 32 * GIB, "diskFreeBytes": 35 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], - }, - "FluxDevPipeline": { - "supportedTasks": ["text_to_image"], - "defaultRepo": FLUX_DEV_REPO, - "preferredLowerMemoryRepo": FLUX_DEV_FP8_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 20, "guidanceScale": 3.5, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "lowerMemory": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - }, - "Flux2KleinPipeline": { - "supportedTasks": ["text_to_image", "edit_image", "multi_image_reference_edit"], - "defaultRepo": FLUX2_KLEIN_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "Flux2KleinPipeline", - "qualityDefaults": {"width": 1024, "height": 1024, "steps": 4, "guidanceScale": 1, "maxSequenceLength": 512}, - "minimum": {"accelerator": "cuda", "vramBytes": 13 * GIB, "systemRamBytes": 24 * GIB, "diskFreeBytes": 25 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 20 * GIB, "systemRamBytes": 32 * GIB, "diskFreeBytes": 35 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "supportedOffloadModes": [OFFLOAD_MODE_NONE, OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], - }, - "FluxKreaPipeline": { - "supportedTasks": ["text_to_image"], - "defaultRepo": FLUX_KREA_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 3.5, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "onLoadQuantization": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - "guardedReason": "FLUX Krea has broad guarded Auto coverage through on-load float8 quantization and Diffusers offload.", - }, - "FluxKontextPipeline": { - "supportedTasks": ["edit_image"], - "defaultRepo": FLUX_KONTEXT_REPO, - "preferredLowerMemoryRepo": FLUX_KONTEXT_NVFP4_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxKontextPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 3.5, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "lowerMemory": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "torchao_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "torchao"], - "guardedReason": "FLUX Kontext uses the NVFP4 lower-memory artifact when available; failures are remembered for this machine.", - }, - "FluxFillPipeline": { - "supportedTasks": ["inpaint", "outpaint"], - "defaultRepo": FLUX_FILL_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxFillPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 30, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "onLoadQuantization": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - "guardedReason": "FLUX Fill has guarded Auto coverage through generic Diffusers inpaint/outpaint nodes and on-load quantization.", - }, - "FluxDepthPipeline": { - "supportedTasks": ["control_image"], - "defaultRepo": FLUX_DEPTH_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxControlPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 10, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "onLoadQuantization": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - "guardedReason": "FLUX Depth has guarded Auto coverage through generic control-image Diffusers nodes.", - }, - "FluxCannyPipeline": { - "supportedTasks": ["control_image"], - "defaultRepo": FLUX_CANNY_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxControlPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 10, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "onLoadQuantization": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - "guardedReason": "FLUX Canny has guarded Auto coverage through generic control-image Diffusers nodes.", - }, - "FluxReduxPipeline": { - "supportedTasks": ["edit_image", "multi_image_reference_edit"], - "defaultRepo": FLUX_REDUX_REPO, - "executionPath": "direct-diffusers-image", - "pipelineClass": "FluxReduxPipeline", - "qualityDefaults": {"width": 768, "height": 768, "steps": 24, "guidanceScale": 3.5, "maxSequenceLength": 256}, - "minimum": {"accelerator": "cuda", "vramBytes": 24 * GIB, "systemRamBytes": 48 * GIB, "diskFreeBytes": 45 * GIB}, - "recommended": {"accelerator": "cuda", "vramBytes": 32 * GIB, "systemRamBytes": 64 * GIB, "diskFreeBytes": 60 * GIB}, - "fullResidency": HIGH_MEMORY_FULL_RESIDENCY, - "onLoadQuantization": { - "accelerator": "cuda", - "vramBytes": 16 * GIB, - "systemRamBytes": 32 * GIB, - "diskFreeBytes": 45 * GIB, - "quantizationMode": "quanto_float8", - "quantizedComponents": ["transformer", "text_encoder_2"], - }, - "supportedOffloadModes": [ - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_DISK, - OFFLOAD_MODE_NONE, - ], - "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], - "guardedReason": "FLUX Redux has guarded Auto coverage through generic Diffusers image/reference nodes.", - }, } +AUTO_MODEL_REQUIREMENTS.update(studio_auto_model_requirements()) + + +def _auto_requirements_for_pair(model_type: str, mode: str) -> dict[str, Any] | None: + """Return the exact effective Auto specification for one declared pair. + + Resource requirements may be shared by several modes, but their loader + target is never inferred from a pipeline-class name or a stale generic + execution-path hint. One unique execution profile owns the effective + module, action, execution path, and pipeline class. + """ + + normalized_model = str(model_type or "").strip() + normalized_mode = str(mode or "").strip() + if not normalized_model or not normalized_mode: + return None + + exact_key = f"{normalized_model}:{normalized_mode}" + if exact_key in AUTO_MODEL_REQUIREMENTS: + requirements = AUTO_MODEL_REQUIREMENTS[exact_key] + else: + requirements = AUTO_MODEL_REQUIREMENTS.get(normalized_model) + if not isinstance(requirements, dict): + return None + + supported_tasks = { + str(task).strip() + for task in requirements.get("supportedTasks") or [] + if str(task).strip() + } + if normalized_mode not in supported_tasks: + return None + + profiles = execution_profiles_for_execution(normalized_model, normalized_mode) + if len(profiles) != 1: + return None + profile = profiles[0] + effective = { + **requirements, + "supportedTasks": [normalized_mode], + "executionProfileId": profile.id, + "loaderModule": profile.loader_module, + "loaderAction": profile.loader_action, + "executionPath": profile.execution_path, + "pipelineClass": profile.pipeline_class, + "defaultRepo": profile.default_repo, + "fallbackRepo": profile.fallback_repo, + "compatibleRepos": list(profile.compatible_repos), + "modelDependencies": studio_model_dependencies_for_pair(normalized_model, normalized_mode), + } + allowed_lower_memory_repos = { + repo + for repo in (profile.fallback_repo, *profile.compatible_repos) + if isinstance(repo, str) and repo + } + if effective.get("preferredLowerMemoryRepo") not in allowed_lower_memory_repos: + effective.pop("preferredLowerMemoryRepo", None) + return effective + + +def auto_resource_pair_is_declared(model_type: str, mode: str) -> bool: + """Return whether both Auto requirements and an execution profile declare a pair.""" + + return _auto_requirements_for_pair(model_type, mode) is not None + + +def _declared_auto_modes(model_type: str) -> list[str]: + normalized_model = str(model_type or "").strip() + modes = set() + for key, requirements in AUTO_MODEL_REQUIREMENTS.items(): + if key != normalized_model and not key.startswith(f"{normalized_model}:"): + continue + modes.update( + str(task).strip() + for task in requirements.get("supportedTasks") or [] + if str(task).strip() + ) + return sorted(mode for mode in modes if _auto_requirements_for_pair(normalized_model, mode) is not None) + + +def _public_auto_model_requirements() -> dict[str, dict[str, Any]]: + """Publish only exact pair specifications with one canonical loader target.""" + + specifications: dict[str, dict[str, Any]] = {} + model_types = { + str(key).split(":", 1)[0] + for key in AUTO_MODEL_REQUIREMENTS + if str(key).split(":", 1)[0] + } + for model_type in sorted(model_types): + for mode in _declared_auto_modes(model_type): + specification = _auto_requirements_for_pair(model_type, mode) + if specification is not None: + specifications[f"{model_type}:{mode}"] = specification + return specifications + def _now_ms() -> int: return int(time.time() * 1000) @@ -998,12 +874,16 @@ def _candidate_history_signature( *, runtime_fingerprint: dict[str, Any] | None = None, hardware: dict[str, Any] | None = None, + history_schema_version: int = AUTO_HISTORY_VERSION, ) -> dict[str, Any]: resolution = candidate.get("artifactResolution") if isinstance(candidate.get("artifactResolution"), dict) else {} resolved = resolution.get("resolved") if isinstance(resolution.get("resolved"), dict) else {} workload = _candidate_workload_signature(candidate) return { + "historySchemaVersion": history_schema_version, + "autoResourceSchemaVersion": _safe_int(candidate.get("autoResourceSchemaVersion")) or 0, "hardwareFingerprint": _hardware_history_key(runtime_fingerprint, hardware), + "executionProfileId": str(candidate.get("executionProfileId") or ""), "modelType": str(candidate.get("modelType") or ""), "mode": str(candidate.get("mode") or ""), "artifact": str(candidate.get("resolvedArtifact") or candidate.get("artifact") or candidate.get("modelRepo") or ""), @@ -1020,11 +900,292 @@ def _candidate_history_signature( "channelsLast": bool(candidate.get("channelsLast")), "layerwiseCasting": bool(candidate.get("layerwiseCasting")), "pipelineClass": str(candidate.get("pipelineClass") or ""), + "loaderModule": str(candidate.get("loaderModule") or ""), + "loaderAction": str(candidate.get("loaderAction") or ""), "executionPath": str(candidate.get("executionPath") or ""), + "optionalRuntime": _candidate_optional_runtime_signature(candidate), + "studioExecutionSpec": _candidate_studio_execution_spec_signature(candidate), + "modelDependencies": _candidate_model_dependencies_signature(candidate), + "controlledArtifacts": _candidate_controlled_artifacts_signature(candidate), "workload": workload, } +def _candidate_optional_runtime_signature(candidate: dict[str, Any]) -> dict[str, Any] | None: + profile_ids = candidate.get("optionalRuntimeProfileIds") + requirement = candidate.get("optionalRuntimeRequirement") + if ( + not isinstance(profile_ids, list) + or any(not isinstance(item, str) for item in profile_ids) + or not isinstance(requirement, dict) + ): + return None + requirement_profile_ids = requirement.get("profileIds") + execution_profile_ids = requirement.get("executionProfileIds") + if ( + isinstance(requirement.get("schemaVersion"), bool) + or not isinstance(requirement.get("schemaVersion"), int) + or not isinstance(requirement.get("delivery"), str) + or type(requirement.get("requiredNow")) is not bool + or not isinstance(requirement_profile_ids, list) + or any(not isinstance(item, str) for item in requirement_profile_ids) + or not isinstance(execution_profile_ids, list) + or any(not isinstance(item, str) for item in execution_profile_ids) + ): + return None + return { + "profileIds": list(profile_ids), + "requirement": { + "schemaVersion": requirement["schemaVersion"], + "delivery": requirement["delivery"], + "requiredNow": requirement["requiredNow"], + "profileIds": list(requirement_profile_ids), + "executionProfileIds": list(execution_profile_ids), + }, + } + + +def _candidate_studio_execution_spec_signature(candidate: dict[str, Any]) -> dict[str, Any] | None: + contract = candidate.get("studioExecutionSpecContract") + if not isinstance(contract, dict) or set(contract) != { + "schemaVersion", + "id", + "contentHash", + "executionProfileId", + }: + return None + if ( + isinstance(contract.get("schemaVersion"), bool) + or not isinstance(contract.get("schemaVersion"), int) + or not isinstance(contract.get("id"), str) + or not isinstance(contract.get("contentHash"), str) + or not isinstance(contract.get("executionProfileId"), str) + ): + return None + return { + "schemaVersion": contract["schemaVersion"], + "id": contract["id"], + "contentHash": contract["contentHash"], + "executionProfileId": contract["executionProfileId"], + } + + +def _candidate_model_dependencies_signature(candidate: dict[str, Any]) -> list[dict[str, str]] | None: + dependencies = candidate.get("modelDependencies") + if not isinstance(dependencies, list) or len(dependencies) > 32: + return None + output = [] + for dependency in dependencies: + if not isinstance(dependency, dict) or set(dependency) != {"id", "kind", "repo", "revision"}: + return None + if not all(isinstance(dependency.get(key), str) and dependency[key] for key in dependency): + return None + output.append({key: dependency[key] for key in ("id", "kind", "repo", "revision")}) + if len({dependency["id"] for dependency in output}) != len(output): + return None + return sorted(output, key=lambda dependency: (dependency["kind"], dependency["id"], dependency["repo"])) + + +def _candidate_controlled_artifacts_signature(candidate: dict[str, Any]) -> list[dict[str, Any]] | None: + receipts = candidate.get("controlledArtifacts") + if receipts is None: + return [] + if not isinstance(receipts, list) or len(receipts) > 32: + return None + output = [] + for receipt in receipts: + kind = receipt.get("kind") if isinstance(receipt, dict) else None + if kind in {"spandrel_upscaler", "diffusers_pipeline"}: + expected_keys = { + "schemaVersion", + "kind", + "module", + "action", + "artifact", + "descriptorSha256", + *({"pipelineClass"} if kind == "diffusers_pipeline" else set()), + } + artifact = receipt.get("artifact") if isinstance(receipt, dict) else None + source = artifact.get("source") if isinstance(artifact, dict) else None + expected_artifact_keys = ( + {"source", "repository", "revision", "weightName", "sha256"} + if kind == "spandrel_upscaler" and source == "hub" + else {"source", "weightName", "sha256"} + if kind == "spandrel_upscaler" and source == "local" + else {"source", "repository", "revision"} + if kind == "diffusers_pipeline" and source == "hub" + else None + ) + module_action = (receipt.get("module"), receipt.get("action")) if isinstance(receipt, dict) else None + expected_module_action = ( + ("modules.Spandrel", "Upscaler") + if kind == "spandrel_upscaler" + else { + ("modules.DiffusersAudio", "LoadPipeline"), + ("modules.DiffusersVideo", "LoadPipeline"), + } + ) + module_action_matches = ( + module_action == expected_module_action + if kind == "spandrel_upscaler" + else module_action in expected_module_action + ) + descriptor_sha256 = receipt.get("descriptorSha256") if isinstance(receipt, dict) else None + pipeline_class = receipt.get("pipelineClass") if isinstance(receipt, dict) else None + if ( + not isinstance(receipt, dict) + or set(receipt) != expected_keys + or receipt.get("schemaVersion") != 1 + or not module_action_matches + or not isinstance(artifact, dict) + or set(artifact) != expected_artifact_keys + or not all( + isinstance(artifact.get(key), str) + and artifact[key] + and len(artifact[key]) <= 1024 + and not any(ord(character) < 32 for character in artifact[key]) + for key in artifact + ) + or not isinstance(descriptor_sha256, str) + or len(descriptor_sha256) != 64 + or any(character not in "0123456789abcdef" for character in descriptor_sha256) + or source == "hub" + and ( + len(artifact.get("revision", "")) != 40 + or any(character not in "0123456789abcdef" for character in artifact.get("revision", "")) + ) + or kind == "spandrel_upscaler" + and ( + len(artifact.get("sha256", "")) != 64 + or any(character not in "0123456789abcdef" for character in artifact.get("sha256", "")) + ) + or kind == "diffusers_pipeline" + and ( + not isinstance(pipeline_class, str) + or not pipeline_class + or len(pipeline_class) > 256 + or not pipeline_class.replace("_", "a").isalnum() + or pipeline_class[0].isdigit() + ) + ): + return None + try: + payload = {key: value for key, value in receipt.items() if key != "descriptorSha256"} + encoded_payload = json.dumps( + payload, + allow_nan=False, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + import hashlib + + if hashlib.sha256(encoded_payload.encode("utf-8")).hexdigest() != descriptor_sha256: + return None + encoded = json.dumps( + receipt, + allow_nan=False, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (RecursionError, TypeError, ValueError): + return None + if len(encoded.encode("utf-8")) > 32 * 1024: + return None + output.append(json.loads(encoded)) + continue + if not isinstance(receipt, dict) or set(receipt) != { + "schemaVersion", + "kind", + "module", + "action", + "artifact", + "adapterName", + "scale", + "scheduler", + "replaceExisting", + "descriptorSha256", + }: + return None + module = receipt.get("module") + action = receipt.get("action") + if not isinstance(module, str) or not isinstance(action, str) or (module, action) not in { + ("modules.ModularDiffusers", "Lora"), + ("modules.DiffusersImage", "LoadAdapter"), + ("modules.DiffusersAudio", "LoadAdapter"), + }: + return None + artifact = receipt.get("artifact") + if not isinstance(artifact, dict): + return None + source = artifact.get("source") + expected_artifact_keys = ( + {"source", "repository", "revision", "weightName", "sha256"} + if source == "hub" + else {"source", "weightName", "sha256"} + if source == "local" + else None + ) + scale = receipt.get("scale") + descriptor_sha256 = receipt.get("descriptorSha256") + if ( + receipt.get("schemaVersion") != 1 + or receipt.get("kind") != "diffusers_lora" + or set(artifact) != expected_artifact_keys + or not all( + isinstance(artifact.get(key), str) + and artifact[key] + and len(artifact[key]) <= 1024 + for key in artifact + ) + or not isinstance(receipt.get("adapterName"), str) + or not receipt["adapterName"] + or len(receipt["adapterName"]) > 256 + or any(ord(character) < 32 for character in receipt["adapterName"]) + or isinstance(scale, bool) + or type(scale) not in {int, float} + or not math.isfinite(float(scale)) + or not -20 <= float(scale) <= 20 + or receipt.get("replaceExisting") is not None + and type(receipt.get("replaceExisting")) is not bool + or not isinstance(descriptor_sha256, str) + or len(descriptor_sha256) != 64 + or any(character not in "0123456789abcdef" for character in descriptor_sha256) + or len(artifact.get("sha256", "")) != 64 + or any(character not in "0123456789abcdef" for character in artifact.get("sha256", "")) + or source == "hub" + and ( + len(artifact.get("revision", "")) != 40 + or any(character not in "0123456789abcdef" for character in artifact.get("revision", "")) + ) + ): + return None + scheduler = receipt.get("scheduler") + if scheduler is not None and ( + not isinstance(scheduler, dict) + or set(scheduler) != {"class_name", "config"} + or not isinstance(scheduler.get("class_name"), str) + or len(scheduler["class_name"]) > 128 + or not isinstance(scheduler.get("config"), dict) + ): + return None + try: + encoded = json.dumps( + receipt, + allow_nan=False, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + except (RecursionError, TypeError, ValueError): + return None + if len(encoded.encode("utf-8")) > 32 * 1024: + return None + output.append(json.loads(encoded)) + return output + + def _candidate_workload_signature(candidate: dict[str, Any]) -> dict[str, Any]: """Return only workload fields that affect this media kind's resource proof.""" @@ -1082,7 +1243,10 @@ def _runtime_candidate_from_hints(runtime_hints: dict[str, Any] | None) -> dict[ "channelsLast": runtime_hints.get("channelsLast"), "layerwiseCasting": runtime_hints.get("layerwiseCasting"), "pipelineClass": runtime_hints.get("pipelineClass"), + "loaderModule": runtime_hints.get("loaderModule"), + "loaderAction": runtime_hints.get("loaderAction"), "executionPath": runtime_hints.get("executionPath"), + "controlledArtifacts": runtime_hints.get("controlledArtifacts"), "generation": runtime_hints.get("generation") if isinstance(runtime_hints.get("generation"), dict) else {}, "artifactResolution": runtime_hints.get("artifactResolution") if isinstance(runtime_hints.get("artifactResolution"), dict) else {}, } @@ -1093,6 +1257,8 @@ def _history_candidate_summary(candidate: dict[str, Any]) -> dict[str, Any]: resolved = resolution.get("resolved") if isinstance(resolution.get("resolved"), dict) else {} return { "id": candidate.get("id"), + "autoResourceSchemaVersion": candidate.get("autoResourceSchemaVersion"), + "executionProfileId": candidate.get("executionProfileId"), "modelType": candidate.get("modelType"), "mode": candidate.get("mode"), "artifact": candidate.get("resolvedArtifact") or candidate.get("artifact") or candidate.get("modelRepo"), @@ -1109,7 +1275,26 @@ def _history_candidate_summary(candidate: dict[str, Any]) -> dict[str, Any]: "channelsLast": bool(candidate.get("channelsLast")), "layerwiseCasting": bool(candidate.get("layerwiseCasting")), "pipelineClass": candidate.get("pipelineClass"), + "loaderModule": candidate.get("loaderModule"), + "loaderAction": candidate.get("loaderAction"), "executionPath": candidate.get("executionPath"), + "optionalRuntimeProfileIds": ( + candidate.get("optionalRuntimeProfileIds") + if isinstance(candidate.get("optionalRuntimeProfileIds"), list) + else None + ), + "optionalRuntimeRequirement": ( + candidate.get("optionalRuntimeRequirement") + if isinstance(candidate.get("optionalRuntimeRequirement"), dict) + else None + ), + "studioExecutionSpecContract": ( + candidate.get("studioExecutionSpecContract") + if isinstance(candidate.get("studioExecutionSpecContract"), dict) + else None + ), + "modelDependencies": _candidate_model_dependencies_signature(candidate), + "controlledArtifacts": _candidate_controlled_artifacts_signature(candidate), "generation": candidate.get("generation") if isinstance(candidate.get("generation"), dict) else {}, } @@ -1149,7 +1334,10 @@ def record_auto_resource_success( measurement: dict[str, Any] | None = None, ) -> dict[str, Any] | None: candidate = _runtime_candidate_from_hints(runtime_hints) - if not candidate: + if not candidate or _auto_requirements_for_pair( + str(candidate.get("modelType") or ""), + str(candidate.get("mode") or ""), + ) is None: return None history = read_auto_resource_history(data_dir) key = auto_resource_history_key(candidate, runtime_fingerprint=runtime_fingerprint) @@ -1526,6 +1714,9 @@ def _apply_catalog_hardware_support( output: list[dict[str, Any]] = [] for candidate in candidates: item = _clone_candidate(candidate) + if item.get("exactPairDeclared") is False: + output.append(item) + continue artifact = catalog_artifact(str(item.get("modelType") or ""), str(item.get("artifact") or "")) missing: list[str] = [] if artifact: @@ -1576,10 +1767,14 @@ def _apply_community_confirmation( output: list[dict[str, Any]] = [] for candidate in candidates: item = _clone_candidate(candidate) + if item.get("exactPairDeclared") is False: + output.append(item) + continue repo = str(item.get("resolvedArtifact") or item.get("artifact") or "").strip().lower() if ( repo == confirmed and item.get("requiresConfirmation") + and item.get("profileArtifactCompatible") is not False and item.get("installed") and not item.get("requirementsMissing") ): @@ -1814,6 +2009,8 @@ def _candidate( model_type: str, mode: str, execution_path: str, + loader_module: str | None, + loader_action: str | None, artifact: str, dtype: str, quantization_mode: str, @@ -1893,6 +2090,8 @@ def _candidate( "rank": rank, "modelType": model_type, "mode": mode, + "loaderModule": loader_module, + "loaderAction": loader_action, "executionPath": execution_path, "pipelineClass": pipeline_class, "artifact": artifact, @@ -1975,12 +2174,15 @@ def _catalog_community_candidates( *, model_type: str, mode: str, + loader_module: str, + loader_action: str, execution_path: str, pipeline_class: str, generation: dict[str, Any], local_models: list[dict[str, Any]] | None, hardware: dict[str, Any], requirements: dict[str, Any], + profile_artifacts: set[str], existing_artifacts: set[str], ) -> list[dict[str, Any]]: model = catalog_model(model_type) or {} @@ -2003,6 +2205,8 @@ def _catalog_community_candidates( rank=70 + index, model_type=model_type, mode=mode, + loader_module=loader_module, + loader_action=loader_action, execution_path=execution_path, artifact=repo, dtype="bfloat16", @@ -2023,6 +2227,7 @@ def _catalog_community_candidates( candidate["healthBadge"] = "Community option" candidate["compatibilityEvidence"]["label"] = "Community option" candidate["requiresConfirmation"] = True + candidate["profileArtifactCompatible"] = repo in profile_artifacts output.append(candidate) return output @@ -2056,6 +2261,13 @@ def _qwen_text_to_image_candidates( prequantized_installed = bool(prequantized_cache_status.get("installed")) or _has_installed(QWEN_IMAGE_2512_PREQUANTIZED_REPO, installed) model_type = str(form.get("modelType") or "QwenImageModularPipeline") mode = str(form.get("mode") or "text_to_image") + specification = _auto_requirements_for_pair(model_type, mode) + if specification is None: + return _undeclared_pair_candidates(form) + loader_module = str(specification["loaderModule"]) + loader_action = str(specification["loaderAction"]) + execution_path = str(specification["executionPath"]) + pipeline_class = str(specification["pipelineClass"]) offload_mode = _qwen_auto_offload_for(hardware) native_offload_mode = _qwen_native_offload_for(hardware) @@ -2088,7 +2300,9 @@ def _qwen_text_to_image_candidates( rank=1, model_type=model_type, mode=mode, - execution_path="direct-diffusers-image", + loader_module=loader_module, + loader_action=loader_action, + execution_path=execution_path, artifact=QWEN_IMAGE_2512_PREQUANTIZED_REPO, dtype="bfloat16", quantization_mode="none", @@ -2100,13 +2314,16 @@ def _qwen_text_to_image_candidates( installed=prequantized_installed, requirements_missing=prequantized_missing + prequantized_cache_missing, artifact_status=prequantized_cache_status, + pipeline_class=pipeline_class, ), _candidate( candidate_id="qwen-t2i-prequantized-sequential-cpu", rank=2, model_type=model_type, mode=mode, - execution_path="direct-diffusers-image", + loader_module=loader_module, + loader_action=loader_action, + execution_path=execution_path, artifact=QWEN_IMAGE_2512_PREQUANTIZED_REPO, dtype="bfloat16", quantization_mode="none", @@ -2118,13 +2335,16 @@ def _qwen_text_to_image_candidates( installed=prequantized_installed, requirements_missing=prequantized_missing + prequantized_cache_missing, artifact_status=prequantized_cache_status, + pipeline_class=pipeline_class, ), _candidate( candidate_id="qwen-t2i-prequantized-group-disk", rank=3, model_type=model_type, mode=mode, - execution_path="direct-diffusers-image", + loader_module=loader_module, + loader_action=loader_action, + execution_path=execution_path, artifact=QWEN_IMAGE_2512_PREQUANTIZED_REPO, dtype="bfloat16", quantization_mode="none", @@ -2143,13 +2363,16 @@ def _qwen_text_to_image_candidates( offload_mode=OFFLOAD_MODE_GROUP_DISK, ) + prequantized_cache_missing, artifact_status=prequantized_cache_status, + pipeline_class=pipeline_class, ), _candidate( candidate_id="qwen-t2i-official-bf16-native", rank=4, model_type=model_type, mode=mode, - execution_path="direct-diffusers-image", + loader_module=loader_module, + loader_action=loader_action, + execution_path=execution_path, artifact=QWEN_IMAGE_2512_REPO, dtype="bfloat16", quantization_mode="none", @@ -2162,13 +2385,16 @@ def _qwen_text_to_image_candidates( installed=official_installed, requirements_missing=official_missing + official_cache_missing, artifact_status=official_cache_status, + pipeline_class=pipeline_class, ), _candidate( candidate_id="qwen-t2i-official-transformer-bnb4-manual", rank=5, model_type=model_type, mode=mode, - execution_path="direct-diffusers-image", + loader_module=loader_module, + loader_action=loader_action, + execution_path=execution_path, artifact=QWEN_IMAGE_2512_REPO, dtype="bfloat16", quantization_mode="bnb_4bit", @@ -2180,6 +2406,7 @@ def _qwen_text_to_image_candidates( installed=official_installed, manual_only_reason="On-the-fly BnB quantization is not an Auto default because package/kernel compatibility varies; use Manual if you want this configuration.", artifact_status=official_cache_status, + pipeline_class=pipeline_class, ), ] return candidates @@ -2240,6 +2467,49 @@ def _cache_missing_for_status(status: dict[str, Any], label: str) -> list[str]: return [] +def _undeclared_pair_candidates(form: dict[str, Any]) -> list[dict[str, Any]]: + model_type = str(form.get("modelType") or "").strip() + mode = str(form.get("mode") or "").strip() + pair_label = f"{model_type or ''}:{mode or ''}" + declared_modes = _declared_auto_modes(model_type) + if declared_modes: + reason = ( + f"No Auto recipe is declared for the exact model/task pair '{pair_label}'. " + f"Declared Auto modes for {model_type} are: {', '.join(declared_modes)}. " + "This workflow remains available for explicit Expert configuration when its graph structure " + "and runtime inputs are valid." + ) + else: + reason = ( + f"No Auto recipe is declared for the exact model/task pair '{pair_label}'. " + "This workflow remains available for explicit Expert configuration when its graph structure " + "and runtime inputs are valid." + ) + + candidate = _candidate( + candidate_id=f"{model_type or 'studio'}-{mode or 'mode'}-expert-only", + rank=99, + model_type=model_type, + mode=mode, + loader_module=None, + loader_action=None, + execution_path=str(form.get("executionPath") or ""), + artifact=str(form.get("defaultRepo") or form.get("modelRepo") or ""), + dtype=str(form.get("dtype") or "bfloat16"), + quantization_mode="none", + quantized_components=[], + offload_mode=str(form.get("offloadMode") or OFFLOAD_MODE_NONE), + quality_tier="expert-only", + reason=reason, + generation=_generation_for_requirements(model_type, form, {}), + installed=True, + manual_only_reason=reason, + pipeline_class=str(form.get("pipelineClass") or "") or None, + ) + candidate["exactPairDeclared"] = False + return [candidate] + + def _declared_profile_candidates( form: dict[str, Any], local_models: list[dict[str, Any]] | None, @@ -2248,13 +2518,16 @@ def _declared_profile_candidates( model_type = str(form.get("modelType") or "") mode = str(form.get("mode") or "") installed = _repo_id_set(local_models) - key = f"{model_type}:{mode}" if f"{model_type}:{mode}" in AUTO_MODEL_REQUIREMENTS else model_type - requirements = AUTO_MODEL_REQUIREMENTS.get(key) or {} + requirements = _auto_requirements_for_pair(model_type, mode) + if requirements is None: + return _undeclared_pair_candidates(form) default_repo = str(requirements.get("defaultRepo") or form.get("defaultRepo") or form.get("modelRepo") or "") lower_memory_repo = str(requirements.get("preferredLowerMemoryRepo") or "") manual_only_reason = requirements.get("manualOnlyReason") - execution_path = str(requirements.get("executionPath") or ("direct-wan-vace" if model_type == "WanVACEPipeline" else "modular-diffusers")) - pipeline_class = str(requirements.get("pipelineClass") or "") + loader_module = str(requirements["loaderModule"]) + loader_action = str(requirements["loaderAction"]) + execution_path = str(requirements["executionPath"]) + pipeline_class = str(requirements["pipelineClass"]) generation_defaults = requirements.get("qualityDefaults") if isinstance(requirements.get("qualityDefaults"), dict) else {} minimum = requirements.get("minimum") if isinstance(requirements.get("minimum"), dict) else requirements.get("recommended") @@ -2302,6 +2575,8 @@ def _declared_profile_candidates( rank=10, model_type=model_type, mode=mode, + loader_module=loader_module, + loader_action=loader_action, execution_path=execution_path, artifact=lower_memory_repo, dtype=str(form.get("dtype") or "bfloat16"), @@ -2337,6 +2612,8 @@ def _declared_profile_candidates( rank=5 if full_residency_ready else 30, model_type=model_type, mode=mode, + loader_module=loader_module, + loader_action=loader_action, execution_path=execution_path, artifact=default_repo, dtype=str(form.get("dtype") or "bfloat16"), @@ -2366,15 +2643,27 @@ def _declared_profile_candidates( str(candidate.get("resolvedArtifact") or candidate.get("artifact") or "").lower() for candidate in candidates } + profile_artifacts = { + str(repo) + for repo in ( + requirements.get("defaultRepo"), + requirements.get("fallbackRepo"), + *(requirements.get("compatibleRepos") or []), + ) + if isinstance(repo, str) and repo + } candidates.extend(_catalog_community_candidates( model_type=model_type, mode=mode, + loader_module=loader_module, + loader_action=loader_action, execution_path=execution_path, pipeline_class=pipeline_class, generation=generation, local_models=local_models, hardware=hardware, requirements=requirements, + profile_artifacts=profile_artifacts, existing_artifacts=existing_artifacts, )) @@ -2384,6 +2673,8 @@ def _declared_profile_candidates( rank=99, model_type=model_type, mode=mode, + loader_module=loader_module, + loader_action=loader_action, execution_path=execution_path, artifact=default_repo, dtype=str(form.get("dtype") or "bfloat16"), @@ -2412,6 +2703,8 @@ def _normalized_history_entry_signature(entry: dict[str, Any]) -> dict[str, Any] return None candidate = _clone_candidate(candidate) for key in ( + "autoResourceSchemaVersion", + "executionProfileId", "modelType", "mode", "artifact", @@ -2428,13 +2721,24 @@ def _normalized_history_entry_signature(entry: dict[str, Any]) -> dict[str, Any] "channelsLast", "layerwiseCasting", "pipelineClass", + "loaderModule", + "loaderAction", "executionPath", + "optionalRuntimeProfileIds", + "optionalRuntimeRequirement", + "studioExecutionSpecContract", + "modelDependencies", + "controlledArtifacts", ): if candidate.get(key) is None and stored.get(key) is not None: candidate[key] = stored[key] hardware_fingerprint = stored.get("hardwareFingerprint") hardware = {"runtimeFingerprint": hardware_fingerprint} if hardware_fingerprint else None - return _candidate_history_signature(candidate, hardware=hardware) + return _candidate_history_signature( + candidate, + hardware=hardware, + history_schema_version=_safe_int(stored.get("historySchemaVersion")) or 0, + ) def _history_signatures_are_compatible(current: dict[str, Any], stored: dict[str, Any]) -> bool: @@ -2452,6 +2756,30 @@ def _history_signatures_are_compatible(current: dict[str, Any], stored: dict[str return all(key in current_workload and current_workload[key] == value for key, value in stored_workload.items()) +def matching_auto_resource_success_history( + data_dir: str | os.PathLike[str], + *, + candidate: dict[str, Any], + runtime_fingerprint: dict[str, Any] | None, + history: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Return only exact current-schema success evidence for one runtime candidate.""" + + current = _candidate_history_signature(candidate, runtime_fingerprint=runtime_fingerprint) + key = auto_resource_history_key(candidate, runtime_fingerprint=runtime_fingerprint) + history = history if isinstance(history, dict) else read_auto_resource_history(data_dir) + entries = history.get("entries") if isinstance(history.get("entries"), dict) else {} + entry = entries.get(key) if isinstance(entries.get(key), dict) else None + if ( + not entry + or not entry.get("successCount") + or int(entry.get("lastFailureAt") or 0) > int(entry.get("lastSuccessAt") or 0) + or _normalized_history_entry_signature(entry) != current + ): + return None + return deepcopy(entry) + + def _compatible_success_history_entry( candidate: dict[str, Any], *, @@ -2513,6 +2841,10 @@ def _apply_history_to_candidates( for candidate in candidates: item = _clone_candidate(candidate) key = auto_resource_history_key(item, hardware=hardware) + if item.get("exactPairDeclared") is False: + item["historyKey"] = key + output.append(item) + continue entry = entries.get(key) if isinstance(entries.get(key), dict) else None compatible_key = None if entry is None: @@ -2830,11 +3162,49 @@ def build_auto_resource_plan( hardware_override = payload.get("hardwareOverride") if isinstance(payload.get("hardwareOverride"), dict) else None hardware = hardware_override or _hardware_snapshot(runtime_fingerprint, data_dir) - if model_type == "QwenImageModularPipeline" and mode == "text_to_image": + exact_pair_requirements = _auto_requirements_for_pair(model_type, mode) + if exact_pair_requirements is None: + candidates = _undeclared_pair_candidates(form) + elif model_type == "QwenImageModularPipeline" and mode == "text_to_image": candidates = _qwen_text_to_image_candidates(form, local_models, hardware) else: candidates = _declared_profile_candidates(form, local_models, hardware) + exact_pair_declared = exact_pair_requirements is not None + optional_runtime_profile_ids = optional_runtime_profile_ids_for_execution( + model_type, + mode, + ) + optional_runtime_profiles = public_optional_runtime_profiles( + optional_runtime_profile_ids + ) + optional_runtime_requirement = optional_runtime_requirement_for_execution( + model_type, + mode, + ) + studio_execution_spec = studio_execution_spec_for_pair(model_type, mode) + studio_execution_spec_contract = ( + { + "schemaVersion": studio_execution_spec["schemaVersion"], + "id": studio_execution_spec["id"], + "contentHash": studio_execution_spec["contentHash"], + "executionProfileId": studio_execution_spec["executionProfileId"], + } + if studio_execution_spec is not None + else None + ) + execution_profile_id = str((exact_pair_requirements or {}).get("executionProfileId") or "") + for candidate in candidates: + candidate["autoResourceSchemaVersion"] = AUTO_RESOURCE_SCHEMA_VERSION + if exact_pair_declared: + candidate["executionProfileId"] = execution_profile_id + candidate["exactPairDeclared"] = exact_pair_declared + candidate["optionalRuntimeProfileIds"] = list(optional_runtime_profile_ids) + candidate["optionalRuntimeRequirement"] = dict(optional_runtime_requirement) + candidate["modelDependencies"] = deepcopy((exact_pair_requirements or {}).get("modelDependencies") or []) + if studio_execution_spec_contract is not None: + candidate["studioExecutionSpecContract"] = dict(studio_execution_spec_contract) + candidates = _apply_catalog_hardware_support(candidates, hardware) candidates = _apply_community_confirmation(candidates, form) candidates = _apply_history_to_candidates(candidates, history=history or read_auto_resource_history(data_dir), hardware=hardware) @@ -2846,6 +3216,8 @@ def build_auto_resource_plan( ) workload_key = workload_key_for_form(form) for candidate in candidates: + if candidate.get("exactPairDeclared") is False: + continue artifact = str( candidate.get("resolvedArtifact") or candidate.get("artifact") @@ -2869,7 +3241,8 @@ def build_auto_resource_plan( ready = [ candidate for candidate in candidates - if candidate.get("proof", {}).get("status") in READY_PROOF_STATUSES + if candidate.get("exactPairDeclared") is not False + and candidate.get("proof", {}).get("status") in READY_PROOF_STATUSES ] manual_only = [ candidate for candidate in candidates @@ -2930,6 +3303,10 @@ def build_auto_resource_plan( "schemaVersion": AUTO_RESOURCE_SCHEMA_VERSION, "resourceMode": "auto", "resourcePreference": preference, + "exactPairDeclared": exact_pair_declared, + "optionalRuntimeProfileIds": list(optional_runtime_profile_ids), + "optionalRuntimeProfiles": optional_runtime_profiles, + "optionalRuntimeRequirement": optional_runtime_requirement, "status": status, "readiness": readiness, "statusLabel": status_label, @@ -2951,7 +3328,10 @@ def build_auto_resource_plan( "nextCandidate": next((candidate for candidate in candidates if candidate is not selected and candidate.get("proof", {}).get("status") in READY_PROOF_STATUSES), None), "hardware": hardware, "hardwareSnapshot": hardware, - "modelRequirements": AUTO_MODEL_REQUIREMENTS, + # Aggregate model-only requirements cannot faithfully represent models + # whose modes use different loaders. Schema v2 publishes one exact + # specification per model/task pair instead. + "modelRequirements": _public_auto_model_requirements(), "requirementsMatched": selected.get("requirementsMatched") if isinstance(selected, dict) else [], "requirementsMissing": requirements_missing, "candidateReasons": list(dict.fromkeys(candidate_reasons)), diff --git a/modiff/auxiliary_ip_adapter.py b/modiff/auxiliary_ip_adapter.py new file mode 100644 index 0000000..83c1726 --- /dev/null +++ b/modiff/auxiliary_ip_adapter.py @@ -0,0 +1,142 @@ +"""Exact, local-only resolution for reviewed SDXL IP-Adapter artifacts.""" + +from __future__ import annotations + +import hashlib +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from huggingface_hub import hf_hub_download +from huggingface_hub.utils import EntryNotFoundError, LocalEntryNotFoundError + +from modiff.model_artifact_catalog import catalog_repository_pin + + +_EXACT_REVISION = re.compile(r"^[0-9a-f]{40}$") +_EXACT_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_PURPOSE = "sdxl-ip-adapter" +_IMAGE_ENCODER_CLASS = "CLIPVisionModelWithProjection" +_MAX_WEIGHT_BYTES = 2 * 1024 * 1024 * 1024 + + +@dataclass(frozen=True) +class ResolvedSDXLIPAdapter: + repository: str + revision: str + weight_name: str + content_sha256: str + byte_size: int + image_encoder_subfolder: str + image_encoder_class: str + load_directory: Path + + @property + def identity(self) -> tuple[str, str, str, str, int, str, str]: + return ( + self.repository, + self.revision, + self.weight_name, + self.content_sha256, + self.byte_size, + self.image_encoder_subfolder, + self.image_encoder_class, + ) + + +def _exact_posix_path(value: Any, *, label: str) -> str: + if not isinstance(value, str) or not value or value != value.strip() or len(value) > 512: + raise ValueError(f"Reviewed IP-Adapter {label} must be a bounded relative POSIX path.") + path = PurePosixPath(value) + if path.is_absolute() or any(part in ("", ".", "..") or ":" in part for part in path.parts): + raise ValueError(f"Reviewed IP-Adapter {label} must be a traversal-free relative POSIX path.") + return path.as_posix() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as reader: + for chunk in iter(lambda: reader.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def resolve_reviewed_sdxl_ip_adapter( + *, + selection: Any, + revision: Any, + weight_name: Any, +) -> ResolvedSDXLIPAdapter: + """Resolve one exact reviewed adapter from the local Hub cache only.""" + + if not isinstance(selection, Mapping): + raise TypeError("SDXL IP-Adapter model selection must be a model-selector object.") + if selection.get("source") != "hub": + raise ValueError("SDXL IP-Adapter currently supports only reviewed immutable Hub artifacts.") + repository = selection.get("value") + if not isinstance(repository, str) or not repository or repository != repository.strip() or len(repository) > 512: + raise ValueError("SDXL IP-Adapter requires one bounded reviewed repository ID.") + pin = catalog_repository_pin(repository) + if not isinstance(pin, dict) or pin.get("purpose") != _PURPOSE: + raise ValueError("The selected repository is not a reviewed SDXL IP-Adapter artifact.") + canonical_repository = pin.get("repo") + if repository != canonical_repository: + raise ValueError("The selected SDXL IP-Adapter repository spelling is not canonical.") + + selected_revision = str(revision or "").strip() + reviewed_revision = pin.get("revision") + if ( + not _EXACT_REVISION.fullmatch(selected_revision) + or selected_revision != reviewed_revision + ): + raise ValueError("SDXL IP-Adapter requires its reviewed immutable repository revision.") + selected_weight = _exact_posix_path(weight_name, label="weight name") + reviewed_weight = _exact_posix_path(pin.get("weightName"), label="catalog weight name") + if selected_weight != reviewed_weight: + raise ValueError("SDXL IP-Adapter requires its reviewed single-adapter weight file.") + image_encoder_subfolder = _exact_posix_path( + pin.get("imageEncoderSubfolder"), + label="image-encoder subfolder", + ) + if pin.get("imageEncoderClass") != _IMAGE_ENCODER_CLASS: + raise ValueError("The reviewed SDXL IP-Adapter image-encoder class is invalid.") + content_sha256 = pin.get("sha256") + byte_size = pin.get("byteSize") + if not isinstance(content_sha256, str) or not _EXACT_SHA256.fullmatch(content_sha256): + raise ValueError("The reviewed SDXL IP-Adapter weight digest is invalid.") + if type(byte_size) is not int or not 1 <= byte_size <= _MAX_WEIGHT_BYTES: + raise ValueError("The reviewed SDXL IP-Adapter weight size is invalid.") + + try: + cached_path = hf_hub_download( + repo_id=canonical_repository, + revision=selected_revision, + filename=selected_weight, + local_files_only=True, + ) + except (EntryNotFoundError, LocalEntryNotFoundError) as error: + raise FileNotFoundError( + "The reviewed SDXL IP-Adapter weight is not installed locally; graph execution never downloads it." + ) from error + try: + resolved_path = Path(cached_path).resolve(strict=True) + stat = resolved_path.stat() + except (OSError, RuntimeError, TypeError) as error: + raise FileNotFoundError("The cached SDXL IP-Adapter weight could not be resolved safely.") from error + if not resolved_path.is_file() or stat.st_size != byte_size: + raise ValueError("The cached SDXL IP-Adapter weight does not match its reviewed byte size.") + if _sha256_file(resolved_path) != content_sha256: + raise ValueError("The cached SDXL IP-Adapter weight failed its reviewed SHA-256 check.") + + return ResolvedSDXLIPAdapter( + repository=canonical_repository, + revision=selected_revision, + weight_name=resolved_path.name, + content_sha256=content_sha256, + byte_size=byte_size, + image_encoder_subfolder=image_encoder_subfolder, + image_encoder_class=_IMAGE_ENCODER_CLASS, + load_directory=resolved_path.parent, + ) diff --git a/modiff/auxiliary_lora.py b/modiff/auxiliary_lora.py new file mode 100644 index 0000000..13f0ea3 --- /dev/null +++ b/modiff/auxiliary_lora.py @@ -0,0 +1,919 @@ +"""Immutable, model-neutral identity contract for Diffusers LoRA weights. + +The graph-visible descriptor is intentionally self-contained, but never +trusted by a loader. Every consumer validates the schema, resolves the exact +cached or local Safetensors alias, and hashes the bytes again immediately +before calling Diffusers. Header validation rejects malformed or empty files; +model/component key compatibility remains Diffusers' responsibility and is not +transactional across a multi-adapter load. +""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import math +import os +import re +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + + +LORA_DESCRIPTOR_SCHEMA = "modiff.diffusers-lora.v1" +MAX_LORA_ADAPTERS = 32 +MAX_DESCRIPTOR_BYTES = 32 * 1024 +MAX_SCHEDULER_CONFIG_BYTES = 16 * 1024 +MAX_JSON_DEPTH = 8 +MAX_JSON_VALUES = 512 +MAX_JSON_CONTAINER_ITEMS = 256 +MAX_JSON_STRING_CHARS = 4096 + +_EXACT_COMMIT = re.compile(r"^[0-9a-f]{40}$") +_EXACT_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_SCHEDULER_EXPORT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_SCHEDULER_CONFIG_CONTROL_KEYS = { + "pretrained_model_name_or_path", + "return_unused_kwargs", +} +_FLOW_MATCH_SCHEDULER = "FlowMatchEulerDiscreteScheduler" +_FLOW_MATCH_DEFAULTS = { + "num_train_timesteps": 1000, + "shift": 1.0, + "use_dynamic_shifting": False, + "base_shift": 0.5, + "max_shift": 1.15, + "base_image_seq_len": 256, + "max_image_seq_len": 4096, + "invert_sigmas": False, + "shift_terminal": None, + "use_karras_sigmas": False, + "use_exponential_sigmas": False, + "use_beta_sigmas": False, + "time_shift_type": "exponential", + "stochastic_sampling": False, +} +_FLOW_MATCH_BOOLEAN_FIELDS = { + "use_dynamic_shifting", + "invert_sigmas", + "use_karras_sigmas", + "use_exponential_sigmas", + "use_beta_sigmas", + "stochastic_sampling", +} +_FLOW_MATCH_SEQUENCE_FIELDS = {"base_image_seq_len", "max_image_seq_len"} +_FLOW_MATCH_OPTIONAL_SHIFT_FIELDS = {"base_shift", "max_shift"} +_FLOW_MATCH_MAX_TRAIN_TIMESTEPS = 100_000 +_FLOW_MATCH_MAX_SEQUENCE_LENGTH = 1_000_000 +_DESCRIPTOR_KEYS = { + "schema", + "artifact", + "adapter_name", + "scale", + "scheduler", + "descriptor_sha256", +} +_HUB_ARTIFACT_KEYS = {"source", "repository", "revision", "weight_name", "sha256"} +_LOCAL_ARTIFACT_KEYS = {"source", "root", "weight_name", "sha256"} +_SCHEDULER_KEYS = {"class_name", "config"} +_CONTROLLED_LORA_NODE_CONTRACTS = { + ("modules.ModularDiffusers", "Lora"): ("model", None, 1.0), + ("modules.DiffusersImage", "LoadAdapter"): ("adapter_path", "default", 1.0), + ("modules.DiffusersAudio", "LoadAdapter"): ("adapter_path", "audio_style", 0.7), +} + + +@dataclass(frozen=True) +class ResolvedLoraDescriptor: + """A descriptor whose exact weight bytes were revalidated for loading.""" + + descriptor_sha256: str + source: str + repository: str | None + revision: str | None + load_directory: Path + weight_name: str + content_sha256: str + adapter_name: str + scale: float + scheduler_class_name: str | None + scheduler_config: dict[str, Any] + + +def _reject_duplicate_json_keys(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError(f"Duplicate scheduler JSON key {key!r} is not allowed.") + value[key] = item + return value + + +def _reject_nonfinite_json_number(value): + raise ValueError(f"Non-finite scheduler JSON number {value!r} is not allowed.") + + +def _validate_json_shape(value: Any, *, description: str) -> None: + pending = [(value, 0)] + value_count = 0 + while pending: + item, depth = pending.pop() + value_count += 1 + if value_count > MAX_JSON_VALUES: + raise ValueError(f"{description} exceeds the {MAX_JSON_VALUES}-value limit.") + if depth > MAX_JSON_DEPTH: + raise ValueError(f"{description} exceeds the {MAX_JSON_DEPTH}-level nesting limit.") + if type(item) is dict: + if len(item) > MAX_JSON_CONTAINER_ITEMS: + raise ValueError( + f"{description} exceeds the {MAX_JSON_CONTAINER_ITEMS}-item object limit." + ) + for key, nested in item.items(): + if not isinstance(key, str): + raise TypeError(f"{description} object keys must be strings.") + if len(key) > MAX_JSON_STRING_CHARS: + raise ValueError(f"{description} contains an oversized object key.") + pending.append((nested, depth + 1)) + elif type(item) is list: + if len(item) > MAX_JSON_CONTAINER_ITEMS: + raise ValueError( + f"{description} exceeds the {MAX_JSON_CONTAINER_ITEMS}-item array limit." + ) + pending.extend((nested, depth + 1) for nested in item) + elif isinstance(item, str): + if len(item) > MAX_JSON_STRING_CHARS: + raise ValueError( + f"{description} contains a string longer than {MAX_JSON_STRING_CHARS} characters." + ) + elif item is None or type(item) in {bool, int}: + continue + elif type(item) is float: + if not math.isfinite(item): + raise ValueError(f"{description} contains a non-finite number.") + else: + raise TypeError(f"{description} contains unsupported value type {type(item).__name__}.") + + +def _canonical_json(value: Any, *, description: str, maximum_bytes: int) -> str: + _validate_json_shape(value, description=description) + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError) as error: + raise ValueError(f"{description} must contain finite JSON values only.") from error + if len(encoded.encode("utf-8")) > maximum_bytes: + raise ValueError(f"{description} exceeds the {maximum_bytes}-byte limit.") + return encoded + + +def _parse_scheduler_config(value: Any) -> dict[str, Any]: + if isinstance(value, str): + if len(value) > MAX_SCHEDULER_CONFIG_BYTES or len(value.encode("utf-8")) > MAX_SCHEDULER_CONFIG_BYTES: + raise ValueError( + f"LoRA scheduler config exceeds the {MAX_SCHEDULER_CONFIG_BYTES}-byte limit." + ) + try: + value = json.loads( + value or "{}", + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_nonfinite_json_number, + ) + except json.JSONDecodeError as error: + raise ValueError(f"LoRA scheduler config must be valid JSON: {error}") from error + except RecursionError as error: + raise ValueError( + f"LoRA scheduler config exceeds the {MAX_JSON_DEPTH}-level nesting limit." + ) from error + if type(value) is not dict: + raise TypeError("LoRA scheduler config must decode to a JSON object.") + encoded = _canonical_json( + value, + description="LoRA scheduler config", + maximum_bytes=MAX_SCHEDULER_CONFIG_BYTES, + ) + return json.loads(encoded) + + +def reviewed_scheduler_class(name: str): + """Return one installed, top-level Diffusers SchedulerMixin export.""" + + if not isinstance(name, str) or not name or name != name.strip(): + raise ValueError("LoRA scheduler class must be an exact nonblank Diffusers export name.") + if not _SCHEDULER_EXPORT.fullmatch(name): + raise ValueError("LoRA scheduler class must be a simple Diffusers export name.") + if name != _FLOW_MATCH_SCHEDULER: + raise ValueError( + f"LoRA scheduler class {name!r} is not in MoDiff's reviewed scheduler contract." + ) + + import diffusers + from diffusers import SchedulerMixin + + scheduler_class = getattr(diffusers, name, None) + if ( + not isinstance(scheduler_class, type) + or scheduler_class is SchedulerMixin + or not issubclass(scheduler_class, SchedulerMixin) + ): + raise ValueError(f"LoRA scheduler class {name!r} is not an installed Diffusers SchedulerMixin export.") + if not str(getattr(scheduler_class, "__module__", "")).startswith("diffusers."): + raise ValueError(f"LoRA scheduler class {name!r} is not owned by the installed Diffusers package.") + if not callable(getattr(scheduler_class, "from_config", None)): + raise ValueError(f"LoRA scheduler class {name!r} does not expose the reviewed Diffusers config API.") + return scheduler_class + + +def _validate_scheduler_overrides(scheduler_class: type, config: dict[str, Any]) -> None: + try: + parameters = inspect.signature(scheduler_class.__init__).parameters + except (TypeError, ValueError) as error: + raise ValueError( + f"LoRA scheduler class {scheduler_class.__name__!r} does not expose a reviewable constructor." + ) from error + explicit_parameters = { + name + for name, parameter in parameters.items() + if name != "self" + and not name.startswith("_") + and parameter.kind + in { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } + } + allowed = set(_FLOW_MATCH_DEFAULTS) if scheduler_class.__name__ == _FLOW_MATCH_SCHEDULER else set() + if not allowed.issubset(explicit_parameters): + raise ValueError( + f"LoRA scheduler class {scheduler_class.__name__!r} no longer matches its reviewed constructor." + ) + unsupported = sorted( + name + for name in config + if name in _SCHEDULER_CONFIG_CONTROL_KEYS or name not in allowed + ) + if unsupported: + raise ValueError( + f"LoRA scheduler config contains unsupported constructor parameters: {', '.join(unsupported)}." + ) + + +def _bounded_scheduler_float( + value: Any, + *, + field: str, + minimum: float, + maximum: float, + allow_none: bool = False, +) -> float | None: + if value is None and allow_none: + return None + if type(value) not in {int, float}: + raise TypeError(f"LoRA scheduler field {field!r} must be a finite number.") + normalized = float(value) + if not math.isfinite(normalized) or not minimum <= normalized <= maximum: + raise ValueError( + f"LoRA scheduler field {field!r} must be between {minimum} and {maximum}." + ) + return normalized + + +def _normalize_flow_match_config(config: dict[str, Any], *, partial: bool) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for field, value in config.items(): + if field in _FLOW_MATCH_BOOLEAN_FIELDS: + if type(value) is not bool: + raise TypeError(f"LoRA scheduler field {field!r} must be a boolean.") + normalized[field] = value + elif field == "num_train_timesteps": + if type(value) is not int or not 1 <= value <= _FLOW_MATCH_MAX_TRAIN_TIMESTEPS: + raise ValueError( + "LoRA scheduler field 'num_train_timesteps' must be an integer between " + f"1 and {_FLOW_MATCH_MAX_TRAIN_TIMESTEPS}." + ) + normalized[field] = value + elif field in _FLOW_MATCH_SEQUENCE_FIELDS: + if type(value) is not int or not 1 <= value <= _FLOW_MATCH_MAX_SEQUENCE_LENGTH: + raise ValueError( + f"LoRA scheduler field {field!r} must be an integer between 1 and " + f"{_FLOW_MATCH_MAX_SEQUENCE_LENGTH}." + ) + normalized[field] = value + elif field == "shift": + normalized[field] = _bounded_scheduler_float( + value, + field=field, + minimum=1e-6, + maximum=100.0, + ) + elif field in _FLOW_MATCH_OPTIONAL_SHIFT_FIELDS: + normalized[field] = _bounded_scheduler_float( + value, + field=field, + minimum=1e-6, + maximum=100.0, + allow_none=True, + ) + elif field == "shift_terminal": + normalized[field] = _bounded_scheduler_float( + value, + field=field, + minimum=0.0, + maximum=0.999999, + allow_none=True, + ) + elif field == "time_shift_type": + if type(value) is not str or value not in {"exponential", "linear"}: + raise ValueError("LoRA scheduler field 'time_shift_type' must be 'exponential' or 'linear'.") + normalized[field] = value + else: + raise ValueError(f"LoRA scheduler field {field!r} is not reviewed.") + + if partial: + return normalized + if set(normalized) != set(_FLOW_MATCH_DEFAULTS): + raise ValueError("The effective FlowMatch scheduler config is incomplete.") + if normalized["base_image_seq_len"] >= normalized["max_image_seq_len"]: + raise ValueError("FlowMatch base_image_seq_len must be smaller than max_image_seq_len.") + if normalized["use_dynamic_shifting"] and ( + normalized["base_shift"] is None or normalized["max_shift"] is None + ): + raise ValueError("FlowMatch dynamic shifting requires finite base_shift and max_shift values.") + if sum( + int(normalized[field]) + for field in ("use_karras_sigmas", "use_exponential_sigmas", "use_beta_sigmas") + ) > 1: + raise ValueError("FlowMatch permits only one alternate sigma schedule.") + return normalized + + +def reviewed_scheduler_effective_config( + scheduler_class: type, + current_config: Any, + overrides: dict[str, Any], +) -> dict[str, Any]: + """Build one bounded config without forwarding ConfigMixin control kwargs.""" + + if scheduler_class.__name__ != _FLOW_MATCH_SCHEDULER: + raise ValueError(f"Scheduler {scheduler_class.__name__!r} has no reviewed MoDiff config contract.") + if not isinstance(current_config, Mapping): + raise TypeError("The pipeline scheduler config must be a mapping.") + _validate_scheduler_overrides(scheduler_class, overrides) + effective = { + field: overrides.get(field, current_config.get(field, default)) + for field, default in _FLOW_MATCH_DEFAULTS.items() + } + return _normalize_flow_match_config(effective, partial=False) + + +def normalize_scheduler_contract(class_name: Any, config: Any) -> dict[str, Any] | None: + if class_name in (None, ""): + normalized_config = _parse_scheduler_config(config if config is not None else {}) + if normalized_config: + raise ValueError("LoRA scheduler config requires an explicit reviewed scheduler class.") + return None + if not isinstance(class_name, str): + raise TypeError("LoRA scheduler class must be a string.") + scheduler_class = reviewed_scheduler_class(class_name) + normalized_config = _parse_scheduler_config(config if config is not None else {}) + _validate_scheduler_overrides(scheduler_class, normalized_config) + normalized_config = _normalize_flow_match_config(normalized_config, partial=True) + return { + "class_name": class_name, + "config": normalized_config, + } + + +def _exact_sha256(value: Any, *, required: bool) -> str | None: + if value in (None, "") and not required: + return None + if not isinstance(value, str) or value != value.strip() or not _EXACT_SHA256.fullmatch(value): + raise ValueError("LoRA SHA-256 must contain exactly 64 lowercase hexadecimal digits.") + return value + + +def _exact_weight_name(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError("A LoRA weight requires an exact relative filename.") + if len(value) > 1024 or "\\" in value or ":" in value or "\x00" in value or value.startswith("/"): + raise ValueError("A LoRA weight_name must be a safe relative repository filename.") + parts = value.split("/") + if len(parts) > 64 or any(part in {"", ".", ".."} for part in parts): + raise ValueError("A LoRA weight_name must stay inside its selected repository or directory.") + if not PurePosixPath(value).name.endswith(".safetensors"): + raise ValueError("A LoRA weight_name must use a literal lowercase .safetensors alias.") + return value + + +def _exact_repository(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip() or value.count("/") != 1: + raise ValueError("A Hub LoRA requires an exact namespace/repository ID.") + from utils.huggingface import validate_hf_repo_id + + try: + validate_hf_repo_id(value) + except Exception as error: + raise ValueError("A Hub LoRA requires a valid namespace/repository ID.") from error + return value + + +def _exact_revision(value: Any) -> str: + if not isinstance(value, str) or value != value.strip() or not _EXACT_COMMIT.fullmatch(value): + raise ValueError("A Hub LoRA requires a lowercase 40-character commit revision.") + return value + + +def _exact_adapter_name(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError("A LoRA adapter name must be an exact nonblank string.") + if len(value) > 256 or any(ord(character) < 32 for character in value): + raise ValueError("A LoRA adapter name is oversized or contains control characters.") + return value + + +def _exact_scale(value: Any) -> float: + if isinstance(value, bool): + raise ValueError("A LoRA scale must be a finite number between -20 and 20.") + try: + scale = float(value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError("A LoRA scale must be a finite number between -20 and 20.") from error + if not math.isfinite(scale) or not -20 <= scale <= 20: + raise ValueError("A LoRA scale must be a finite number between -20 and 20.") + return scale + + +def _sha256_file(path: Path) -> str: + try: + before = path.stat() + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): + digest.update(chunk) + after = path.stat() + except OSError as error: + raise FileNotFoundError(f"LoRA weight could not be read: {path}") from error + identity_fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, field, None) != getattr(after, field, None) for field in identity_fields): + raise ValueError("LoRA weight changed while its SHA-256 was being verified.") + return digest.hexdigest() + + +def _preflight_safetensors_file(path: Path) -> None: + """Validate the Safetensors header without materializing tensor bodies.""" + + from safetensors import SafetensorError, safe_open + + try: + with safe_open(path, framework="np", device="cpu") as handle: + keys = list(handle.keys()) + except (OSError, SafetensorError, ValueError) as error: + raise ValueError("LoRA weight must be a valid Safetensors file.") from error + if not keys: + raise ValueError("LoRA Safetensors weight must contain at least one tensor.") + + +def _managed_hub_alias(repository: str, revision: str, weight_name: str) -> tuple[Path, Path]: + from utils import huggingface as huggingface_utils + + cached = huggingface_utils.cached_file_path(repository, weight_name, revision=revision) + if not cached: + raise FileNotFoundError( + f"LoRA {repository}@{revision}/{weight_name} is not installed. " + "Install the pinned file through Model Manager first." + ) + cached_path = Path(cached).expanduser() + if not cached_path.is_absolute(): + raise ValueError("Installed Hub LoRA cache entries must use absolute managed-cache paths.") + + configured_root = huggingface_utils.CONFIG.hf["cache_dir"] or huggingface_utils.HUGGINGFACE_HUB_CACHE + lexical_root = Path(os.path.abspath(Path(configured_root).expanduser())) + lexical_alias = Path(os.path.abspath(cached_path)) + expected_lexical_alias = ( + lexical_root + / f"models--{repository.replace('/', '--')}" + / "snapshots" + / revision + / Path(*PurePosixPath(weight_name).parts) + ) + if os.path.normcase(str(lexical_alias)) != os.path.normcase(str(expected_lexical_alias)): + raise ValueError( + "Installed Hub LoRA cache lookup returned an alias outside the exact repository snapshot path." + ) + try: + cache_root = lexical_root.resolve(strict=True) + expected_repo_root = cache_root / f"models--{repository.replace('/', '--')}" + repo_root = expected_repo_root.resolve(strict=True) + if repo_root != expected_repo_root: + raise ValueError("Managed Hub repository root cannot be a link to another cache location.") + expected_snapshot_root = repo_root / "snapshots" / revision + snapshot_root = expected_snapshot_root.resolve(strict=True) + if snapshot_root != expected_snapshot_root: + raise ValueError("Managed Hub snapshot root cannot be a link to another revision.") + expected_alias = snapshot_root.joinpath(*PurePosixPath(weight_name).parts) + alias = expected_alias.parent.resolve(strict=True) / expected_alias.name + if alias.parent != expected_alias.parent: + raise ValueError("Managed Hub snapshot subdirectories cannot redirect the adapter alias.") + relative_alias = alias.relative_to(snapshot_root).as_posix() + except (OSError, RuntimeError, ValueError) as error: + raise ValueError( + "Installed Hub LoRA alias is not contained in the exact managed repository snapshot." + ) from error + if relative_alias != weight_name or not alias.is_file(): + raise ValueError( + "Installed Hub LoRA cache lookup did not preserve the exact pinned .safetensors snapshot alias." + ) + + resolved_weight = huggingface_utils.resolve_managed_hf_cache_file(alias) + try: + resolved_weight.relative_to(repo_root) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError("Installed Hub LoRA resolves outside its managed repository cache.") from error + return alias, resolved_weight + + +def _local_alias(root_value: Any, weight_name: str) -> tuple[Path, Path, Path]: + if not isinstance(root_value, str) or not root_value or root_value != root_value.strip(): + raise ValueError("A local LoRA descriptor requires an exact absolute root path.") + raw_root = Path(root_value).expanduser() + if not raw_root.is_absolute(): + raise ValueError("A local LoRA descriptor root must be absolute.") + try: + root = raw_root.resolve(strict=True) + if root != raw_root or not root.is_dir(): + raise ValueError("A local LoRA descriptor root must already be resolved to an existing directory.") + requested = root.joinpath(*PurePosixPath(weight_name).parts) + alias = requested.parent.resolve(strict=True) / requested.name + if alias.relative_to(root).as_posix() != weight_name or not alias.is_file(): + raise ValueError("Local LoRA weight is not the declared file inside its resolved root.") + resolved_weight = alias.resolve(strict=True) + resolved_weight.relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + if isinstance(error, ValueError) and str(error).startswith("A local LoRA descriptor root"): + raise + raise ValueError("Local LoRA weight must resolve to a contained existing file.") from error + if not resolved_weight.is_file(): + raise FileNotFoundError("Local LoRA weight is not a file.") + return root, alias, resolved_weight + + +def _local_selection_alias(value: Any, weight_name: Any) -> tuple[Path, Path, str]: + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError("A local LoRA requires an exact file or directory path.") + selected = Path(value).expanduser() + try: + if selected.is_dir(): + root = selected.resolve(strict=True) + normalized_weight = _exact_weight_name(weight_name) + root, alias, _ = _local_alias(str(root), normalized_weight) + return root, alias, normalized_weight + if not selected.is_file(): + raise FileNotFoundError(f"Local LoRA path does not exist: {selected}") + supplied_weight = None if weight_name in (None, "") else _exact_weight_name(weight_name) + root = selected.parent.resolve(strict=True) + alias = root / selected.name + normalized_weight = _exact_weight_name(alias.name) + if supplied_weight is not None and supplied_weight != normalized_weight: + raise ValueError("Local LoRA file selection and weight_name refer to different files.") + root, alias, _ = _local_alias(str(root), normalized_weight) + return root, alias, normalized_weight + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"Local LoRA path does not exist: {selected}") from error + + +def _descriptor_digest(payload: dict[str, Any]) -> str: + encoded = _canonical_json( + payload, + description="LoRA descriptor", + maximum_bytes=MAX_DESCRIPTOR_BYTES, + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def build_lora_descriptor( + *, + selection: Any, + weight_name: Any, + revision: Any, + expected_sha256: Any, + adapter_name: Any, + scale: Any, + scheduler_class: Any = "", + scheduler_config: Any = None, +) -> dict[str, Any]: + """Build a canonical descriptor after resolving and hashing its bytes.""" + + if not isinstance(selection, dict) or "source" not in selection or "value" not in selection: + raise TypeError("A LoRA selection must explicitly provide source and value fields.") + source = selection.get("source") + if source not in {"hub", "local"}: + raise ValueError("A LoRA selection source must be exactly 'hub' or 'local'.") + normalized_name = _exact_adapter_name(adapter_name) + normalized_scale = _exact_scale(scale) + scheduler = normalize_scheduler_contract( + scheduler_class, + scheduler_config if scheduler_config is not None else {}, + ) + + if source == "hub": + repository = _exact_repository(selection.get("value")) + normalized_revision = _exact_revision(revision) + normalized_weight = _exact_weight_name(weight_name) + expected_digest = _exact_sha256(expected_sha256, required=True) + alias, _ = _managed_hub_alias(repository, normalized_revision, normalized_weight) + actual_digest = _sha256_file(alias) + if actual_digest != expected_digest: + raise ValueError( + f"LoRA {repository}@{normalized_revision}/{normalized_weight} failed its pinned SHA-256 verification." + ) + _preflight_safetensors_file(alias) + artifact = { + "source": "hub", + "repository": repository, + "revision": normalized_revision, + "weight_name": normalized_weight, + "sha256": actual_digest, + } + else: + if revision not in (None, ""): + raise ValueError("A local LoRA cannot carry a Hub revision.") + expected_digest = _exact_sha256(expected_sha256, required=False) + root, alias, normalized_weight = _local_selection_alias(selection.get("value"), weight_name) + actual_digest = _sha256_file(alias) + if expected_digest is not None and actual_digest != expected_digest: + raise ValueError("Local LoRA failed its supplied SHA-256 verification.") + _preflight_safetensors_file(alias) + artifact = { + "source": "local", + "root": str(root), + "weight_name": normalized_weight, + "sha256": actual_digest, + } + + payload = { + "schema": LORA_DESCRIPTOR_SCHEMA, + "artifact": artifact, + "adapter_name": normalized_name, + "scale": normalized_scale, + "scheduler": scheduler, + } + return {**payload, "descriptor_sha256": _descriptor_digest(payload)} + + +def _graph_param_value(node: Mapping[str, Any], key: str, default: Any = None) -> Any: + params = node.get("params") + if not isinstance(params, Mapping): + return default + param = params.get(key) + if not isinstance(param, Mapping): + return default + return param.get("value", param.get("default", default)) + + +def controlled_lora_receipts_from_graph(graph: Any) -> list[dict[str, Any]]: + """Validate executable controlled LoRA nodes and return exact safe receipts. + + The API graph is the authority here: caller-supplied runtime receipts are + deliberately ignored. Node IDs are used only to reproduce the Modular + adapter name; local filesystem roots remain represented by the descriptor + digest and are never copied into the public receipt. + """ + + if not isinstance(graph, Mapping): + return [] + nodes = graph.get("nodes") + paths = graph.get("paths") + if not isinstance(nodes, Mapping) or not isinstance(paths, list): + return [] + nodes_by_id = {str(node_id): node for node_id, node in nodes.items()} + + executable_ids: list[str] = [] + seen_ids: set[str] = set() + for path in paths: + if not isinstance(path, list): + continue + for raw_node_id in path: + node_id = str(raw_node_id) + if node_id not in seen_ids: + seen_ids.add(node_id) + executable_ids.append(node_id) + + controlled_ids = [ + node_id + for node_id in executable_ids + if isinstance(nodes_by_id.get(node_id), Mapping) + and (nodes_by_id[node_id].get("module"), nodes_by_id[node_id].get("action")) + in _CONTROLLED_LORA_NODE_CONTRACTS + ] + if len(controlled_ids) > MAX_LORA_ADAPTERS: + raise ValueError(f"At most {MAX_LORA_ADAPTERS} executable LoRA adapters are supported.") + + receipts: list[dict[str, Any]] = [] + for node_id in controlled_ids: + node = nodes_by_id[node_id] + module = node.get("module") + action = node.get("action") + selection_key, default_adapter_name, default_scale = _CONTROLLED_LORA_NODE_CONTRACTS[ + (module, action) + ] + selection = _graph_param_value(node, selection_key) + image_selection_is_empty = module == "modules.DiffusersImage" and ( + selection is None + or isinstance(selection, str) + and not selection.strip() + or isinstance(selection, Mapping) + and isinstance(selection.get("value"), str) + and not selection["value"].strip() + ) + if image_selection_is_empty: + # The direct-image adapter explicitly supports a no-op empty + # selection. It contributes no execution artifact receipt. + continue + if isinstance(selection, str): + selection = { + "source": "local" if module == "modules.DiffusersAudio" else "hub", + "value": selection, + } + + weight_name = _graph_param_value( + node, + "weight_name", + "adapter_model.safetensors" if module == "modules.DiffusersAudio" else None, + ) + if module == "modules.ModularDiffusers": + requested_weight = str(weight_name or "") + name_seed = PurePosixPath(requested_weight.replace("\\", "/")).stem + if not name_seed and isinstance(selection, Mapping): + name_seed = PurePosixPath( + str(selection.get("value") or "").replace("\\", "/") + ).stem + adapter_name = f"{name_seed or 'lora'}_{node_id}" + else: + adapter_name = _graph_param_value(node, "adapter_name", default_adapter_name) + + descriptor = build_lora_descriptor( + selection=selection, + weight_name=weight_name, + revision=_graph_param_value(node, "revision", ""), + expected_sha256=_graph_param_value(node, "expected_sha256", ""), + adapter_name=adapter_name, + scale=_graph_param_value(node, "scale", default_scale), + scheduler_class=( + _graph_param_value(node, "scheduler_class", "") + if module == "modules.ModularDiffusers" + else "" + ), + scheduler_config=( + _graph_param_value(node, "scheduler_config", "{}") + if module == "modules.ModularDiffusers" + else None + ), + ) + if module == "modules.DiffusersImage" and not -2 <= descriptor["scale"] <= 2: + raise ValueError("Diffusers image adapter scale must be between -2 and 2.") + if module == "modules.DiffusersAudio" and not 0 <= descriptor["scale"] <= 2: + raise ValueError("Diffusers audio adapter scale must be between 0 and 2.") + + replace_existing = None + if module != "modules.ModularDiffusers": + replace_existing = _graph_param_value(node, "replace_existing", True) + if type(replace_existing) is not bool: + raise TypeError("Diffusers adapter replace_existing must be a boolean.") + + artifact = descriptor["artifact"] + safe_artifact = { + "source": artifact["source"], + "weightName": artifact["weight_name"], + "sha256": artifact["sha256"], + } + if artifact["source"] == "hub": + safe_artifact.update( + repository=artifact["repository"], + revision=artifact["revision"], + ) + receipts.append( + { + "schemaVersion": 1, + "kind": "diffusers_lora", + "module": module, + "action": action, + "artifact": safe_artifact, + "adapterName": descriptor["adapter_name"], + "scale": descriptor["scale"], + "scheduler": descriptor["scheduler"], + "replaceExisting": replace_existing, + "descriptorSha256": descriptor["descriptor_sha256"], + } + ) + return receipts + + +def resolve_lora_descriptor(value: Any) -> ResolvedLoraDescriptor: + """Strictly revalidate one descriptor and its exact current bytes.""" + + if type(value) is not dict: + raise TypeError("LoRA loaders require a versioned descriptor from the generic LoRA node.") + _validate_json_shape(value, description="LoRA descriptor") + if set(value) != _DESCRIPTOR_KEYS: + raise ValueError("LoRA descriptor fields do not match the supported versioned contract.") + if value.get("schema") != LORA_DESCRIPTOR_SCHEMA: + raise ValueError(f"Unsupported LoRA descriptor schema {value.get('schema')!r}.") + supplied_descriptor_digest = _exact_sha256(value.get("descriptor_sha256"), required=True) + raw_payload = {key: value[key] for key in _DESCRIPTOR_KEYS if key != "descriptor_sha256"} + if _descriptor_digest(raw_payload) != supplied_descriptor_digest: + raise ValueError("LoRA descriptor identity digest does not match its fields.") + payload = deepcopy(raw_payload) + + artifact = payload.get("artifact") + if type(artifact) is not dict: + raise TypeError("LoRA descriptor artifact must be an object.") + source = artifact.get("source") + adapter_name = _exact_adapter_name(payload.get("adapter_name")) + scale = _exact_scale(payload.get("scale")) + scheduler_value = payload.get("scheduler") + if scheduler_value is None: + scheduler = None + else: + if type(scheduler_value) is not dict or set(scheduler_value) != _SCHEDULER_KEYS: + raise ValueError("LoRA scheduler fields do not match the supported contract.") + scheduler = normalize_scheduler_contract( + scheduler_value.get("class_name"), + scheduler_value.get("config"), + ) + + if source == "hub": + if set(artifact) != _HUB_ARTIFACT_KEYS: + raise ValueError("Hub LoRA artifact fields do not match the supported contract.") + repository = _exact_repository(artifact.get("repository")) + revision = _exact_revision(artifact.get("revision")) + normalized_weight = _exact_weight_name(artifact.get("weight_name")) + expected_digest = _exact_sha256(artifact.get("sha256"), required=True) + alias, _ = _managed_hub_alias(repository, revision, normalized_weight) + elif source == "local": + if set(artifact) != _LOCAL_ARTIFACT_KEYS: + raise ValueError("Local LoRA artifact fields do not match the supported contract.") + repository = None + revision = None + normalized_weight = _exact_weight_name(artifact.get("weight_name")) + expected_digest = _exact_sha256(artifact.get("sha256"), required=True) + _, alias, _ = _local_alias(artifact.get("root"), normalized_weight) + else: + raise ValueError("LoRA descriptor source must be exactly 'hub' or 'local'.") + + actual_digest = _sha256_file(alias) + if actual_digest != expected_digest: + raise ValueError("LoRA weight content no longer matches its descriptor SHA-256.") + _preflight_safetensors_file(alias) + return ResolvedLoraDescriptor( + descriptor_sha256=supplied_descriptor_digest, + source=source, + repository=repository, + revision=revision, + load_directory=alias.parent, + weight_name=alias.name, + content_sha256=actual_digest, + adapter_name=adapter_name, + scale=scale, + scheduler_class_name=(scheduler or {}).get("class_name"), + scheduler_config=deepcopy((scheduler or {}).get("config") or {}), + ) + + +def resolve_lora_descriptors(value: Any) -> list[ResolvedLoraDescriptor]: + values = value if isinstance(value, list) else [value] + if not values: + raise ValueError("At least one LoRA descriptor is required.") + if len(values) > MAX_LORA_ADAPTERS: + raise ValueError(f"At most {MAX_LORA_ADAPTERS} LoRA adapters may be loaded together.") + resolved = [resolve_lora_descriptor(item) for item in values] + names = [item.adapter_name for item in resolved] + if len(set(names)) != len(names): + raise ValueError("Connected LoRA descriptors must use unique adapter names.") + return resolved + + +def scheduler_override_contract( + resolved: list[ResolvedLoraDescriptor], +) -> tuple[type, dict[str, Any]] | None: + overrides = [ + (item.scheduler_class_name, item.scheduler_config) + for item in resolved + if item.scheduler_class_name is not None + ] + if not overrides: + return None + canonical = [ + (class_name, _canonical_json(config, description="LoRA scheduler config", maximum_bytes=MAX_SCHEDULER_CONFIG_BYTES)) + for class_name, config in overrides + ] + if any(item != canonical[0] for item in canonical[1:]): + raise ValueError("Connected LoRAs declare incompatible scheduler contracts.") + class_name, _ = canonical[0] + return reviewed_scheduler_class(class_name), deepcopy(overrides[0][1]) diff --git a/modiff/controlled_artifacts.py b/modiff/controlled_artifacts.py new file mode 100644 index 0000000..0faf9da --- /dev/null +++ b/modiff/controlled_artifacts.py @@ -0,0 +1,318 @@ +"""Exact executable receipts for Studio-controlled auxiliary model artifacts. + +Client receipt claims are never authoritative. The server derives these +receipts from executable graph paths immediately before admission, resolves +reviewed Hub revisions, and hashes single-file upscalers from the managed +cache (or their explicit local file) before execution. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from modiff.auxiliary_lora import controlled_lora_receipts_from_graph +from modiff.model_artifact_catalog import resolve_model_revision + + +MAX_CONTROLLED_ARTIFACTS = 32 +MAX_ARTIFACT_RECEIPT_BYTES = 32 * 1024 +_EXACT_COMMIT = re.compile(r"^[0-9a-f]{40}$") +_EXACT_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_PIPELINE_CLASS = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,255}$") +_MODELISH_SUFFIXES = {".bin", ".ckpt", ".pkl", ".pt", ".pth", ".safetensors"} +_CONTROLLED_PIPELINE_CONTRACTS = { + ("modules.DiffusersAudio", "LoadPipeline"), + ("modules.DiffusersVideo", "LoadPipeline"), +} +_CONTROLLED_UPSCALER_CONTRACT = ("modules.Spandrel", "Upscaler") + + +@dataclass(frozen=True) +class ResolvedUpscalerArtifact: + path: Path + receipt: dict[str, Any] + + +def _graph_param_value(node: Mapping[str, Any], key: str, default: Any = None) -> Any: + params = node.get("params") + if not isinstance(params, Mapping): + return default + param = params.get(key) + if not isinstance(param, Mapping): + return default + return param.get("value", param.get("default", default)) + + +def _executable_node_ids(graph: Mapping[str, Any]) -> list[str]: + paths = graph.get("paths") + if not isinstance(paths, list): + return [] + output: list[str] = [] + seen: set[str] = set() + for path in paths: + if not isinstance(path, list): + continue + for raw_node_id in path: + node_id = str(raw_node_id) + if node_id not in seen: + seen.add(node_id) + output.append(node_id) + return output + + +def _exact_repository(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip() or value.count("/") != 1: + raise ValueError("A controlled Hub artifact requires an exact namespace/repository ID.") + from utils.huggingface import validate_hf_repo_id + + try: + validate_hf_repo_id(value) + except Exception as error: + raise ValueError("A controlled Hub artifact requires a valid namespace/repository ID.") from error + return value + + +def _exact_revision(value: Any) -> str: + if not isinstance(value, str) or value != value.strip() or not _EXACT_COMMIT.fullmatch(value): + raise ValueError("A controlled Hub artifact requires a lowercase 40-character commit revision.") + return value + + +def _exact_sha256(value: Any, *, required: bool) -> str | None: + if value in (None, "") and not required: + return None + if not isinstance(value, str) or value != value.strip() or not _EXACT_SHA256.fullmatch(value): + raise ValueError("A controlled artifact SHA-256 must contain 64 lowercase hexadecimal digits.") + return value + + +def _exact_weight_name(value: Any) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError("A controlled single-file artifact requires an exact relative filename.") + if len(value) > 1024 or "\\" in value or ":" in value or "\x00" in value or value.startswith("/"): + raise ValueError("A controlled single-file artifact must use a safe relative filename.") + parts = value.split("/") + if len(parts) > 64 or any(part in {"", ".", ".."} for part in parts): + raise ValueError("A controlled single-file artifact must stay inside its selected repository.") + if PurePosixPath(value).suffix.lower() not in _MODELISH_SUFFIXES: + raise ValueError("A controlled single-file artifact must use a reviewed model-file extension.") + return value + + +def _sha256_file(path: Path) -> str: + try: + before = path.stat() + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): + digest.update(chunk) + after = path.stat() + except OSError as error: + raise FileNotFoundError("Controlled artifact bytes could not be read.") from error + identity_fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns") + if any(getattr(before, field, None) != getattr(after, field, None) for field in identity_fields): + raise ValueError("Controlled artifact bytes changed while their SHA-256 was being verified.") + return digest.hexdigest() + + +def _canonical_digest(value: dict[str, Any]) -> str: + try: + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")) + except (RecursionError, TypeError, ValueError) as error: + raise ValueError("Controlled artifact receipt must contain finite JSON values only.") from error + if len(encoded.encode("utf-8")) > MAX_ARTIFACT_RECEIPT_BYTES: + raise ValueError("Controlled artifact receipt exceeds its bounded size.") + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _managed_hub_file(repository: str, revision: str, weight_name: str) -> Path: + from utils import huggingface as huggingface_utils + + cached = huggingface_utils.cached_file_path(repository, weight_name, revision=revision) + if not cached: + raise FileNotFoundError("The exact controlled Hub artifact is not installed.") + cached_path = Path(cached).expanduser() + if not cached_path.is_absolute(): + raise ValueError("Installed controlled Hub cache entries must use absolute paths.") + + configured_root = huggingface_utils.CONFIG.hf["cache_dir"] or huggingface_utils.HUGGINGFACE_HUB_CACHE + lexical_root = Path(os.path.abspath(Path(configured_root).expanduser())) + lexical_alias = Path(os.path.abspath(cached_path)) + expected_alias = ( + lexical_root + / f"models--{repository.replace('/', '--')}" + / "snapshots" + / revision + / Path(*PurePosixPath(weight_name).parts) + ) + if os.path.normcase(str(lexical_alias)) != os.path.normcase(str(expected_alias)): + raise ValueError("Controlled Hub cache lookup did not preserve the exact repository snapshot alias.") + try: + cache_root = lexical_root.resolve(strict=True) + repo_root = (cache_root / f"models--{repository.replace('/', '--')}").resolve(strict=True) + snapshot_root = (repo_root / "snapshots" / revision).resolve(strict=True) + if repo_root != cache_root / f"models--{repository.replace('/', '--')}": + raise ValueError("Managed Hub repository roots cannot redirect to another location.") + if snapshot_root != repo_root / "snapshots" / revision: + raise ValueError("Managed Hub snapshots cannot redirect to another revision.") + alias = snapshot_root.joinpath(*PurePosixPath(weight_name).parts) + if alias.parent.resolve(strict=True) != alias.parent or not alias.is_file(): + raise ValueError("Controlled Hub snapshot subdirectories cannot redirect the artifact alias.") + resolved = huggingface_utils.resolve_managed_hf_cache_file(alias) + resolved.relative_to(repo_root) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError("Installed controlled Hub artifact is outside its exact managed snapshot.") from error + return resolved + + +def _selection(value: Any) -> tuple[str, str, Mapping[str, Any]]: + if isinstance(value, str): + if not value or value != value.strip(): + raise ValueError("A controlled model selection must be an exact nonblank string.") + return "local", value, {} + if not isinstance(value, Mapping): + raise TypeError("A controlled model selection must provide source and value fields.") + source = value.get("source") + selected = value.get("value") + if source not in {"hub", "local"} or not isinstance(selected, str) or not selected or selected != selected.strip(): + raise ValueError("A controlled model selection requires an exact hub or local value.") + return source, selected, value + + +def resolve_upscaler_artifact(selection: Any) -> ResolvedUpscalerArtifact: + """Resolve and rehash one generic Spandrel model selection.""" + + source, selected, metadata = _selection(selection) + expected_sha256 = _exact_sha256(metadata.get("sha256"), required=False) + expected_size = metadata.get("byteSize") + if expected_size is not None and ( + isinstance(expected_size, bool) or not isinstance(expected_size, int) or expected_size <= 0 + ): + raise ValueError("Controlled artifact byteSize must be a positive integer.") + + if source == "hub": + parts = selected.split("/") + if len(parts) < 3: + raise ValueError("A controlled Hub upscaler must include repository and filename.") + repository = _exact_repository("/".join(parts[:2])) + weight_name = _exact_weight_name("/".join(parts[2:])) + revision = metadata.get("revision") or resolve_model_revision(repository, source="hub") + revision = _exact_revision(revision) + path = _managed_hub_file(repository, revision, weight_name) + safe_artifact = { + "source": "hub", + "repository": repository, + "revision": revision, + "weightName": weight_name, + } + else: + if metadata.get("revision") not in (None, ""): + raise ValueError("A local controlled artifact cannot carry a Hub revision.") + raw_path = Path(selected).expanduser() + if not raw_path.is_absolute(): + from modiff.config import CONFIG + + raw_path = Path(CONFIG.paths["models"]) / raw_path + try: + path = raw_path.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise FileNotFoundError("The selected local controlled artifact does not exist.") from error + if not path.is_file() or path.suffix.lower() not in _MODELISH_SUFFIXES: + raise ValueError("The selected local controlled artifact must be a model file.") + weight_name = path.name + safe_artifact = {"source": "local", "weightName": weight_name} + + actual_size = path.stat().st_size + if expected_size is not None and actual_size != expected_size: + raise ValueError("Controlled artifact bytes do not match the declared size.") + actual_sha256 = _sha256_file(path) + if expected_sha256 is not None and actual_sha256 != expected_sha256: + raise ValueError("Controlled artifact bytes do not match the declared SHA-256.") + safe_artifact["sha256"] = actual_sha256 + payload = { + "schemaVersion": 1, + "kind": "spandrel_upscaler", + "module": _CONTROLLED_UPSCALER_CONTRACT[0], + "action": _CONTROLLED_UPSCALER_CONTRACT[1], + "artifact": safe_artifact, + } + return ResolvedUpscalerArtifact( + path=path, + receipt={**payload, "descriptorSha256": _canonical_digest(payload)}, + ) + + +def _pipeline_receipt(node: Mapping[str, Any]) -> dict[str, Any]: + source, selected, metadata = _selection(_graph_param_value(node, "model_id")) + if source != "hub": + raise ValueError("Controlled auxiliary Diffusers pipelines require an immutable Hub artifact.") + repository = _exact_repository(selected) + pipeline_class = _graph_param_value(node, "pipeline_class") + if not isinstance(pipeline_class, str) or not _PIPELINE_CLASS.fullmatch(pipeline_class): + raise ValueError("Controlled auxiliary Diffusers pipelines require an exact reviewed class.") + revision = _graph_param_value(node, "revision", metadata.get("revision")) + revision = _exact_revision(resolve_model_revision(repository, revision, source="hub")) + payload = { + "schemaVersion": 1, + "kind": "diffusers_pipeline", + "module": node.get("module"), + "action": node.get("action"), + "artifact": {"source": "hub", "repository": repository, "revision": revision}, + "pipelineClass": pipeline_class, + } + return {**payload, "descriptorSha256": _canonical_digest(payload)} + + +def _is_primary_pipeline(node: Mapping[str, Any], primary_candidate: Mapping[str, Any] | None) -> bool: + if not isinstance(primary_candidate, Mapping): + return False + return ( + node.get("module") == primary_candidate.get("loaderModule") + and node.get("action") == primary_candidate.get("loaderAction") + and _graph_param_value(node, "pipeline_class") == primary_candidate.get("pipelineClass") + ) + + +def controlled_artifact_receipts_from_graph( + graph: Any, + *, + primary_candidate: Mapping[str, Any] | None = None, +) -> list[dict[str, Any]]: + """Return server-derived receipts for every executable controlled artifact.""" + + if not isinstance(graph, Mapping): + return [] + nodes = graph.get("nodes") + if not isinstance(nodes, Mapping): + return [] + receipts = controlled_lora_receipts_from_graph(graph) + nodes_by_id = {str(node_id): node for node_id, node in nodes.items()} + executable_nodes = [ + nodes_by_id[node_id] + for node_id in _executable_node_ids(graph) + if isinstance(nodes_by_id.get(node_id), Mapping) + ] + pipeline_nodes = [ + node + for node in executable_nodes + if (node.get("module"), node.get("action")) in _CONTROLLED_PIPELINE_CONTRACTS + ] + for node in executable_nodes: + contract = (node.get("module"), node.get("action")) + if contract == _CONTROLLED_UPSCALER_CONTRACT: + receipts.append(resolve_upscaler_artifact(_graph_param_value(node, "model_id")).receipt) + elif contract in _CONTROLLED_PIPELINE_CONTRACTS and ( + not _is_primary_pipeline(node, primary_candidate) + and (isinstance(primary_candidate, Mapping) or len(pipeline_nodes) > 1) + ): + receipts.append(_pipeline_receipt(node)) + if len(receipts) > MAX_CONTROLLED_ARTIFACTS: + raise ValueError(f"At most {MAX_CONTROLLED_ARTIFACTS} executable controlled artifacts are supported.") + return receipts diff --git a/modiff/diffusers_profiles.py b/modiff/diffusers_profiles.py index bebb556..7f00f9c 100644 --- a/modiff/diffusers_profiles.py +++ b/modiff/diffusers_profiles.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace +import re from modiff.diffusers_offload_modes import ( OFFLOAD_MODE_GROUP_CPU, @@ -9,37 +10,175 @@ OFFLOAD_MODE_NONE, OFFLOAD_MODE_SEQUENTIAL_CPU, ) +from modiff.modular_workflow_contracts import ( + FLUX_MODULAR_CONTROL_UNSUPPORTED, +) +from modiff.model_artifact_catalog import require_catalog_revision +from modiff.optional_runtimes import ( + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + public_optional_runtime_profiles, +) +from modiff.studio_execution_specs import ( + FLUX_CANNY_REPO as FLUX_CANNY_REPO, + FLUX_CANNY_VERIFIED_REPAIR_REPO as FLUX_CANNY_VERIFIED_REPAIR_REPO, + FLUX_DEPTH_REPO as FLUX_DEPTH_REPO, + FLUX_DEV_FP8_REPO as FLUX_DEV_FP8_REPO, + FLUX_DEV_REPO, + FLUX_KREA_REPO as FLUX_KREA_REPO, + FLUX_KONTEXT_NVFP4_REPO as FLUX_KONTEXT_NVFP4_REPO, + FLUX_KONTEXT_REPO as FLUX_KONTEXT_REPO, + FLUX_FILL_REPO as FLUX_FILL_REPO, + FLUX2_KLEIN_REPO as FLUX2_KLEIN_REPO, + FLUX_REDUX_REPO as FLUX_REDUX_REPO, + FLUX_SCHNELL_REPO as FLUX_SCHNELL_REPO, + ACE_STEP_LORA_BASE_REPO as ACE_STEP_LORA_BASE_REPO, + ACE_STEP_REPO as ACE_STEP_REPO, + LTX_VIDEO_FALLBACK_REPO as LTX_VIDEO_FALLBACK_REPO, + LTX_VIDEO_REPO as LTX_VIDEO_REPO, + WAN_22_I2V_A14B_REPO as WAN_22_I2V_A14B_REPO, + WAN_22_TI2V_5B_REPO as WAN_22_TI2V_5B_REPO, + WAN_T2V_1_3B_REPO as WAN_T2V_1_3B_REPO, + studio_execution_profile_definitions, +) QWEN_IMAGE_2512_REPO = "Qwen/Qwen-Image-2512" QWEN_IMAGE_2512_PREQUANTIZED_REPO = "unsloth/Qwen-Image-2512-unsloth-bnb-4bit" -ACE_STEP_REPO = "ACE-Step/acestep-v15-xl-turbo-diffusers" -FLUX_SCHNELL_REPO = "black-forest-labs/FLUX.1-schnell" -FLUX_DEV_REPO = "black-forest-labs/FLUX.1-dev" -FLUX_KREA_REPO = "black-forest-labs/FLUX.1-Krea-dev" -FLUX_KONTEXT_REPO = "black-forest-labs/FLUX.1-Kontext-dev" -FLUX_FILL_REPO = "black-forest-labs/FLUX.1-Fill-dev" -FLUX_DEPTH_REPO = "black-forest-labs/FLUX.1-Depth-dev" -FLUX_CANNY_REPO = "black-forest-labs/FLUX.1-Canny-dev" -FLUX_CANNY_VERIFIED_REPAIR_REPO = "fuliucansheng/FLUX.1-Canny-dev-diffusers" -FLUX_REDUX_REPO = "black-forest-labs/FLUX.1-Redux-dev" -FLUX2_KLEIN_REPO = "black-forest-labs/FLUX.2-klein-4B" -LTX_VIDEO_REPO = "Lightricks/LTX-Video-0.9.8-13B-distilled" -LTX_VIDEO_FALLBACK_REPO = "Lightricks/LTX-Video" -WAN_T2V_1_3B_REPO = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers" -WAN_22_TI2V_5B_REPO = "Wan-AI/Wan2.2-TI2V-5B-Diffusers" VERIFIED_REPAIR_SOURCES = { FLUX_CANNY_REPO: FLUX_CANNY_VERIFIED_REPAIR_REPO, } +OPTIONAL_RUNTIME_REQUIREMENT_SCHEMA_VERSION = 1 +OPTIONAL_RUNTIME_DELIVERY_BASE = "base" +OPTIONAL_RUNTIME_DELIVERY_OVERLAY = "optional_overlay" +OPTIONAL_RUNTIME_DELIVERIES = frozenset( + {OPTIONAL_RUNTIME_DELIVERY_BASE, OPTIONAL_RUNTIME_DELIVERY_OVERLAY} +) +_OPTIONAL_RUNTIME_PROFILE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}") +_EXECUTION_PROFILE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._:-]{0,127}") +_GIB = 1024**3 + + +@dataclass(frozen=True) +class ExpertCudaPolicy: + schema_version: int + blocked_dtypes: tuple[str, ...] + recommended_dtype: str + offloaded_vram_bytes: int + resident_vram_bytes: int + quantized_resident_vram_bytes: tuple[tuple[str, int], ...] + + def __post_init__(self) -> None: + dtypes = {"float32", "float16", "bfloat16"} + quantization_modes = {"bnb_4bit", "bnb_8bit", "quanto_float8", "torchao_float8"} + quantized_modes = tuple(mode for mode, _ in self.quantized_resident_vram_bytes) + byte_values = ( + self.offloaded_vram_bytes, + self.resident_vram_bytes, + *(value for _, value in self.quantized_resident_vram_bytes), + ) + if ( + self.schema_version != 1 + or not self.blocked_dtypes + or len(set(self.blocked_dtypes)) != len(self.blocked_dtypes) + or not set(self.blocked_dtypes).issubset(dtypes) + or self.recommended_dtype not in dtypes + or self.recommended_dtype in self.blocked_dtypes + or len(set(quantized_modes)) != len(quantized_modes) + or not set(quantized_modes).issubset(quantization_modes) + or any(not isinstance(value, int) or value <= 0 or value > 1024 * _GIB for value in byte_values) + ): + raise ValueError("Invalid reviewed Expert CUDA policy.") + + +QWEN_EXPERT_CUDA_POLICY = ExpertCudaPolicy( + schema_version=1, + blocked_dtypes=("float32",), + recommended_dtype="bfloat16", + offloaded_vram_bytes=10 * _GIB, + resident_vram_bytes=80 * _GIB, + quantized_resident_vram_bytes=(("bnb_4bit", 24 * _GIB),), +) + + +@dataclass(frozen=True) +class ExpertQuantizationPolicy: + schema_version: int + quantization_mode: str + offload_mode: str + modular_node: str + subfolder: str + component: str + four_bit_quant_type: str + compute_dtype: str + double_quant: bool + + def __post_init__(self) -> None: + token = re.compile(r"[a-z][a-z0-9_]{0,63}") + if ( + self.schema_version != 1 + or self.quantization_mode != "bnb_4bit" + or self.offload_mode + not in { + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + } + or re.fullmatch(r"modules\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+", self.modular_node) is None + or token.fullmatch(self.subfolder) is None + or token.fullmatch(self.component) is None + or self.four_bit_quant_type not in {"nf4", "fp4"} + or self.compute_dtype not in {"float32", "float16", "bfloat16"} + or not isinstance(self.double_quant, bool) + ): + raise ValueError("Invalid reviewed Expert quantization policy.") + + +QWEN_EXPERT_QUANTIZATION_POLICY = ExpertQuantizationPolicy( + schema_version=1, + quantization_mode="bnb_4bit", + offload_mode=OFFLOAD_MODE_MODEL_CPU, + modular_node="modules.ModularDiffusers.QuantizationConfigNode", + subfolder="transformer", + component="qwen_low_vram", + four_bit_quant_type="nf4", + compute_dtype="bfloat16", + double_quant=True, +) + + +@dataclass(frozen=True) +class ExpertMpsPolicy: + schema_version: int + qualification: str + fallback_action: str + + def __post_init__(self) -> None: + if ( + self.schema_version != 1 + or self.qualification not in {"unqualified", "experimental"} + or self.fallback_action not in {"open_setup", "switch_to_z_image"} + ): + raise ValueError("Invalid reviewed Expert MPS policy.") + + +MPS_UNQUALIFIED_POLICY = ExpertMpsPolicy(1, "unqualified", "open_setup") +MPS_UNQUALIFIED_WITH_Z_IMAGE_FALLBACK_POLICY = ExpertMpsPolicy( + 1, "unqualified", "switch_to_z_image" +) +MPS_EXPERIMENTAL_POLICY = ExpertMpsPolicy(1, "experimental", "open_setup") + @dataclass(frozen=True) class DiffusersExecutionProfile: id: str model_type: str modes: tuple[str, ...] - backend_path: str + loader_module: str + loader_action: str + execution_path: str pipeline_class: str default_repo: str fallback_repo: str | None @@ -50,10 +189,98 @@ class DiffusersExecutionProfile: max_low_memory_side: int | None max_low_memory_steps: int | None live_proof: bool + # All current profiles load Transformers-backed components and use the PEFT + # integration surface. A future pure-Diffusers profile must opt out with + # ``optional_runtime_profiles=()`` rather than inheriting this composite. + optional_runtime_profiles: tuple[str, ...] = (TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID,) + # Keep optional-runtime discovery metadata separate from executable + # delivery. Every current profile is still satisfied by the reviewed base + # environment; only the atomic dependency cutover may change this to + # ``optional_overlay`` and make first-use status an execution prerequisite. + optional_runtime_delivery: str = OPTIONAL_RUNTIME_DELIVERY_BASE + compatible_repos: tuple[str, ...] = () + expert_quantization_modes: tuple[str, ...] = () + expert_cuda_policy: ExpertCudaPolicy | None = None + expert_quantization_policy: ExpertQuantizationPolicy | None = None + expert_mps_policy: ExpertMpsPolicy | None = None + + def __post_init__(self) -> None: + expected_loader = { + "modular-diffusers": ("modules.ModularDiffusers", "ModelsLoader"), + "direct-diffusers-image": ("modules.DiffusersImage", "LoadPipeline"), + "direct-diffusers-video": ("modules.DiffusersVideo", "LoadPipeline"), + "direct-wan-vace": ("modules.DiffusersVideo", "LoadPipeline"), + "direct-diffusers-audio": ("modules.DiffusersAudio", "LoadPipeline"), + }.get(self.execution_path) + if expected_loader is None: + raise ValueError( + f"Diffusers execution profile {self.id!r} has unsupported execution path " + f"{self.execution_path!r}." + ) + if (self.loader_module, self.loader_action) != expected_loader: + raise ValueError( + f"Diffusers execution profile {self.id!r} loader " + f"{self.loader_module}.{self.loader_action} does not match execution path " + f"{self.execution_path!r}." + ) + reviewed_quantization_modes = { + "bnb_4bit", + "bnb_8bit", + "quanto_float8", + "torchao_float8", + } + if ( + len(set(self.expert_quantization_modes)) != len(self.expert_quantization_modes) + or not set(self.expert_quantization_modes).issubset(reviewed_quantization_modes) + ): + raise ValueError(f"Diffusers execution profile {self.id!r} has invalid Expert quantization modes.") + + @property + def backend_path(self) -> str: + """Return the legacy combined loader key from the explicit target.""" - def to_public_dict(self) -> dict: + return f"{self.loader_module}.{self.loader_action}" + + def to_public_dict( + self, + *, + observe_optional_runtime: bool = False, + optional_runtime_catalog_resolver=None, + ) -> dict: data = asdict(self) - return {key: list(value) if isinstance(value, tuple) else value for key, value in data.items()} + public = {key: list(value) if isinstance(value, tuple) else value for key, value in data.items()} + if self.expert_cuda_policy: + public["expert_cuda_policy"] = { + **public["expert_cuda_policy"], + "blocked_dtypes": list(self.expert_cuda_policy.blocked_dtypes), + "quantized_resident_vram_bytes": [ + list(item) for item in self.expert_cuda_policy.quantized_resident_vram_bytes + ], + } + else: + public.pop("expert_cuda_policy") + if not self.expert_quantization_policy: + public.pop("expert_quantization_policy") + if not self.expert_quantization_modes: + public.pop("expert_quantization_modes") + if not self.expert_mps_policy: + public.pop("expert_mps_policy") + public["backend_path"] = self.backend_path + if observe_optional_runtime: + # Lazy to keep the declarative profile module independent of + # overlay storage during registry import. + from modiff.optional_runtime_execution import ( + optional_runtime_requirement_for_profiles as observed_requirement, + ) + + requirement = observed_requirement( + (self,), + catalog_resolver=optional_runtime_catalog_resolver, + ) + else: + requirement = optional_runtime_requirement_for_profiles((self,)) + public["optionalRuntimeRequirement"] = requirement + return public DIFFUSERS_EXECUTION_PROFILES: dict[str, DiffusersExecutionProfile] = { @@ -61,8 +288,10 @@ def to_public_dict(self) -> dict: id="z-image:auto", model_type="ZImageModularPipeline", modes=("text_to_image",), - backend_path="modules.ModularDiffusers.ModelsLoader", - pipeline_class="ZImageModularPipeline", + loader_module="modules.DiffusersImage", + loader_action="LoadPipeline", + execution_path="direct-diffusers-image", + pipeline_class="ZImagePipeline", default_repo="Tongyi-MAI/Z-Image-Turbo", fallback_repo=None, quantizable_components=(), @@ -77,7 +306,9 @@ def to_public_dict(self) -> dict: id="qwen-image:t2i-direct", model_type="QwenImageModularPipeline", modes=("text_to_image",), - backend_path="modules.DiffusersImage.LoadPipeline", + loader_module="modules.DiffusersImage", + loader_action="LoadPipeline", + execution_path="direct-diffusers-image", pipeline_class="QwenImagePipeline", default_repo=QWEN_IMAGE_2512_REPO, fallback_repo=QWEN_IMAGE_2512_PREQUANTIZED_REPO, @@ -99,7 +330,9 @@ def to_public_dict(self) -> dict: id="qwen-image:modular", model_type="QwenImageModularPipeline", modes=("control_image",), - backend_path="modules.ModularDiffusers.ModelsLoader", + loader_module="modules.ModularDiffusers", + loader_action="ModelsLoader", + execution_path="modular-diffusers", pipeline_class="QwenImageModularPipeline", default_repo=QWEN_IMAGE_2512_REPO, fallback_repo=None, @@ -115,7 +348,9 @@ def to_public_dict(self) -> dict: id="qwen-edit:direct-inpaint", model_type="QwenImageEditModularPipeline", modes=("inpaint", "outpaint"), - backend_path="modules.DiffusersImage.LoadPipeline", + loader_module="modules.DiffusersImage", + loader_action="LoadPipeline", + execution_path="direct-diffusers-image", pipeline_class="QwenImageEditInpaintPipeline", default_repo="Qwen/Qwen-Image-Edit", fallback_repo=None, @@ -137,7 +372,9 @@ def to_public_dict(self) -> dict: id="qwen-edit:modular", model_type="QwenImageEditModularPipeline", modes=("edit_image",), - backend_path="modules.ModularDiffusers.ModelsLoader", + loader_module="modules.ModularDiffusers", + loader_action="ModelsLoader", + execution_path="modular-diffusers", pipeline_class="QwenImageEditModularPipeline", default_repo="Qwen/Qwen-Image-Edit", fallback_repo=None, @@ -153,7 +390,9 @@ def to_public_dict(self) -> dict: id="qwen-edit-plus:modular", model_type="QwenImageEditPlusModularPipeline", modes=("edit_image", "multi_image_reference_edit"), - backend_path="modules.ModularDiffusers.ModelsLoader", + loader_module="modules.ModularDiffusers", + loader_action="ModelsLoader", + execution_path="modular-diffusers", pipeline_class="QwenImageEditPlusModularPipeline", default_repo="Qwen/Qwen-Image-Edit-2511", fallback_repo=None, @@ -169,7 +408,9 @@ def to_public_dict(self) -> dict: id="qwen-layered:modular", model_type="QwenImageLayeredModularPipeline", modes=("layer_decomposition",), - backend_path="modules.ModularDiffusers.ModelsLoader", + loader_module="modules.ModularDiffusers", + loader_action="ModelsLoader", + execution_path="modular-diffusers", pipeline_class="QwenImageLayeredModularPipeline", default_repo="Qwen/Qwen-Image-Layered", fallback_repo=None, @@ -190,7 +431,9 @@ def to_public_dict(self) -> dict: "video_outpaint", "control_to_video", ), - backend_path="modules.DiffusersVideo.LoadPipeline", + loader_module="modules.DiffusersVideo", + loader_action="LoadPipeline", + execution_path="direct-wan-vace", pipeline_class="WanVACEPipeline", default_repo="Wan-AI/Wan2.1-VACE-1.3B-diffusers", fallback_repo=None, @@ -208,273 +451,110 @@ def to_public_dict(self) -> dict: max_low_memory_steps=24, live_proof=False, ), - "wan-video-to-video:direct": DiffusersExecutionProfile( - id="wan-video-to-video:direct", - model_type="WanVideoPipeline", - modes=("video_to_video", "video_color_edit"), - backend_path="modules.DiffusersVideo.LoadPipeline", - pipeline_class="WanVideoToVideoPipeline", - default_repo=WAN_T2V_1_3B_REPO, - fallback_repo=None, - quantizable_components=(), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=832, - max_low_memory_steps=30, - live_proof=False, - ), - "wan-text-to-video:direct": DiffusersExecutionProfile( - id="wan-text-to-video:direct", - model_type="WanVideoPipeline", - modes=("text_to_video",), - backend_path="modules.DiffusersVideo.LoadPipeline", - pipeline_class="WanPipeline", - default_repo=WAN_T2V_1_3B_REPO, - fallback_repo=None, - quantizable_components=(), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=832, - max_low_memory_steps=30, - live_proof=True, - ), - "wan-22-image-to-video:direct": DiffusersExecutionProfile( - id="wan-22-image-to-video:direct", - model_type="WanImageToVideoPipeline", - modes=("image_to_video",), - backend_path="modules.DiffusersVideo.LoadPipeline", - pipeline_class="WanImageToVideoPipeline", - default_repo="Wan-AI/Wan2.2-I2V-A14B-Diffusers", - fallback_repo=None, - quantizable_components=("transformer", "transformer_2", "text_encoder"), - default_quantized_components=("transformer", "transformer_2"), - supported_offload_modes=( - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=832, - max_low_memory_steps=40, - live_proof=False, - ), - "wan-22-ti2v-5b:direct": DiffusersExecutionProfile( - id="wan-22-ti2v-5b:direct", - model_type="WanTI2VPipeline", - modes=("text_to_video",), - backend_path="modules.DiffusersVideo.LoadPipeline", - pipeline_class="WanTI2VPipeline", - default_repo=WAN_22_TI2V_5B_REPO, - fallback_repo=None, - quantizable_components=("transformer", "text_encoder"), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=1280, - max_low_memory_steps=50, - live_proof=False, - ), - "ltx-video:direct": DiffusersExecutionProfile( - id="ltx-video:direct", - model_type="LTXVideoPipeline", - modes=("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), - backend_path="modules.DiffusersVideo.LoadPipeline", - pipeline_class="LTXConditionPipeline", - default_repo=LTX_VIDEO_REPO, - fallback_repo=LTX_VIDEO_FALLBACK_REPO, - quantizable_components=("transformer", "text_encoder"), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=704, - max_low_memory_steps=8, - live_proof=False, - ), - "ace-step-audio:direct": DiffusersExecutionProfile( - id="ace-step-audio:direct", - model_type="AceStepAudioPipeline", - modes=("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), - backend_path="modules.DiffusersAudio.LoadPipeline", - pipeline_class="AceStepPipeline", - default_repo=ACE_STEP_REPO, - fallback_repo=None, - quantizable_components=(), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=None, - max_low_memory_steps=8, - live_proof=False, - ), - "flux-schnell:direct": DiffusersExecutionProfile( - id="flux-schnell:direct", - model_type="FluxSchnellPipeline", - modes=("text_to_image",), - backend_path="modules.DiffusersImage.LoadPipeline", - pipeline_class="FluxPipeline", - default_repo=FLUX_SCHNELL_REPO, - fallback_repo=None, - quantizable_components=("transformer", "text_encoder_2"), - default_quantized_components=(), - supported_offload_modes=( - OFFLOAD_MODE_NONE, - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, - ), - retry_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=1024, - max_low_memory_steps=4, - live_proof=False, - ), - "flux-dev:direct": DiffusersExecutionProfile( - id="flux-dev:direct", - model_type="FluxDevPipeline", - modes=("text_to_image",), - backend_path="modules.DiffusersImage.LoadPipeline", - pipeline_class="FluxPipeline", - default_repo=FLUX_DEV_REPO, - fallback_repo=None, - quantizable_components=("transformer", "text_encoder_2"), - default_quantized_components=("transformer",), - supported_offload_modes=(OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), - retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=768, - max_low_memory_steps=20, - live_proof=False, - ), } +DIFFUSERS_EXECUTION_PROFILES.update( + { + profile_id: DiffusersExecutionProfile(**definition) + for profile_id, definition in studio_execution_profile_definitions().items() + } +) -def _flux_execution_profile( - profile_id: str, - model_type: str, - modes: tuple[str, ...], - pipeline_class: str, - repo: str, - *, - live_proof: bool = False, -) -> DiffusersExecutionProfile: - """Build the shared generic-image execution contract for FLUX variants.""" - - return DiffusersExecutionProfile( - id=profile_id, - model_type=model_type, - modes=modes, - backend_path="modules.DiffusersImage.LoadPipeline", - pipeline_class=pipeline_class, - default_repo=repo, - fallback_repo=None, - quantizable_components=("transformer", "text_encoder_2"), - default_quantized_components=("transformer",), - supported_offload_modes=( - OFFLOAD_MODE_MODEL_CPU, - OFFLOAD_MODE_SEQUENTIAL_CPU, - OFFLOAD_MODE_GROUP_CPU, - OFFLOAD_MODE_GROUP_DISK, +for profile_id in ( + "qwen-image:t2i-direct", + "qwen-image:modular", + "qwen-edit:direct-inpaint", + "qwen-edit:modular", + "qwen-edit-plus:modular", + "qwen-layered:modular", +): + DIFFUSERS_EXECUTION_PROFILES[profile_id] = replace( + DIFFUSERS_EXECUTION_PROFILES[profile_id], + expert_cuda_policy=QWEN_EXPERT_CUDA_POLICY, + expert_quantization_policy=QWEN_EXPERT_QUANTIZATION_POLICY, + expert_quantization_modes=("bnb_4bit",), + expert_mps_policy=( + MPS_UNQUALIFIED_WITH_Z_IMAGE_FALLBACK_POLICY + if profile_id == "qwen-image:t2i-direct" + else MPS_UNQUALIFIED_POLICY ), - retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), - max_low_memory_side=768, - max_low_memory_steps=24, - live_proof=live_proof, ) +for profile_id in ( + "wan-vace:direct", + "wan-22-image-to-video:direct", + "wan-22-ti2v-5b:direct", + "wan-text-to-video:direct", + "wan-video-to-video:direct", + "ltx-video:direct", +): + DIFFUSERS_EXECUTION_PROFILES[profile_id] = replace( + DIFFUSERS_EXECUTION_PROFILES[profile_id], + expert_mps_policy=MPS_UNQUALIFIED_POLICY, + ) -DIFFUSERS_EXECUTION_PROFILES.update( - { - "flux2-klein:direct": _flux_execution_profile( - "flux2-klein:direct", - "Flux2KleinPipeline", - ("text_to_image", "edit_image", "multi_image_reference_edit"), - "Flux2KleinPipeline", - FLUX2_KLEIN_REPO, - live_proof=True, - ), - "flux-krea:direct": _flux_execution_profile( - "flux-krea:direct", "FluxKreaPipeline", ("text_to_image",), "FluxPipeline", FLUX_KREA_REPO - ), - "flux-kontext:direct": _flux_execution_profile( - "flux-kontext:direct", - "FluxKontextPipeline", - ("edit_image", "multi_image_reference_edit"), - "FluxKontextPipeline", - FLUX_KONTEXT_REPO, - ), - "flux-fill:direct": _flux_execution_profile( - "flux-fill:direct", "FluxFillPipeline", ("inpaint", "outpaint"), "FluxFillPipeline", FLUX_FILL_REPO - ), - "flux-depth:direct": _flux_execution_profile( - "flux-depth:direct", "FluxDepthPipeline", ("control_image",), "FluxControlPipeline", FLUX_DEPTH_REPO - ), - "flux-canny:direct": _flux_execution_profile( - "flux-canny:direct", "FluxCannyPipeline", ("control_image",), "FluxControlPipeline", FLUX_CANNY_REPO - ), - "flux-redux:direct": _flux_execution_profile( - "flux-redux:direct", - "FluxReduxPipeline", - ("edit_image",), - "FluxReduxPipeline", - FLUX_REDUX_REPO, - ), - } +DIFFUSERS_EXECUTION_PROFILES["z-image:auto"] = replace( + DIFFUSERS_EXECUTION_PROFILES["z-image:auto"], + expert_mps_policy=MPS_EXPERIMENTAL_POLICY, ) +SDXL_BASE_REPO = "stabilityai/stable-diffusion-xl-base-1.0" + EXPERIMENTAL_DIFFUSERS_PIPELINES = [ { "modelType": "StableDiffusionXLModularPipeline", "label": "Stable Diffusion XL (Modular)", "mediaKind": "image", + "defaultRepo": SDXL_BASE_REPO, "pipelineClasses": ["StableDiffusionXLModularPipeline"], - "runnableModes": ["text_to_image", "image_to_image", "inpaint", "control_image"], + "backendPath": "modules.ModularDiffusers.ModelsLoader", + "executionKind": "modular", + "runnableModes": ["text_to_image", "image_to_image", "control_image", "inpaint"], + "inputContracts": { + "image_to_image": {"requiredImages": ["referenceImages"]}, + "control_image": {"requiredImages": ["controlImage"]}, + "inpaint": {"requiredImages": ["referenceImages", "maskImage"]}, + }, + "qualificationStatus": "contract_only", + "revisionCandidates": [require_catalog_revision(SDXL_BASE_REPO)], + "autoEligible": False, + "templateEligible": False, + "galleryEligible": False, }, { "modelType": "FluxModularPipeline", "label": "FLUX (Modular)", "mediaKind": "image", "pipelineClasses": ["FluxModularPipeline"], - "runnableModes": ["text_to_image", "image_to_image", "control_image"], + "backendPath": "modules.ModularDiffusers.ModelsLoader", + "executionKind": "modular", + "runnableModes": ["text_to_image", "image_to_image"], + "unsupportedModes": {"control_image": FLUX_MODULAR_CONTROL_UNSUPPORTED}, + }, + { + # Auto uses the established direct DiffusersImage facade for this + # model/task pair. Keep the separately supported Modular workflow + # visible as an Expert capability without creating a second Auto + # execution profile for the same exact pair. + "modelType": "ZImageModularPipeline", + "label": "Z-Image (Modular)", + "mediaKind": "image", + "defaultRepo": "Tongyi-MAI/Z-Image-Turbo", + "pipelineClasses": ["ZImageModularPipeline"], + "backendPath": "modules.ModularDiffusers.ModelsLoader", + "executionKind": "modular", + "runnableModes": ["text_to_image"], }, { "modelType": "Flux2KleinModularPipeline", - "label": "FLUX.2 Klein (Modular)", + "label": "FLUX.2 Klein (Standard Diffusers)", "mediaKind": "image", "defaultRepo": "black-forest-labs/FLUX.2-klein-4B", - "pipelineClasses": ["Flux2KleinPipeline", "Flux2KleinModularPipeline"], + "pipelineClasses": ["Flux2KleinPipeline"], "backendPath": "modules.DiffusersImage.LoadPipeline", + "executionKind": "standard", + "executionModelType": "Flux2KleinPipeline", + "executionProfileIds": ["flux2-klein:direct"], "runnableModes": ["text_to_image", "edit_image", "multi_image_reference_edit"], }, { @@ -482,6 +562,8 @@ def _flux_execution_profile( "label": "Wan Text to Video (Modular)", "mediaKind": "video", "pipelineClasses": ["WanModularPipeline"], + "backendPath": "modules.ModularDiffusers.ModelsLoader", + "executionKind": "modular", "runnableModes": ["text_to_video"], }, { @@ -489,12 +571,344 @@ def _flux_execution_profile( "label": "Wan Image to Video (Modular)", "mediaKind": "video", "pipelineClasses": ["WanImage2VideoModularPipeline"], + "backendPath": "modules.ModularDiffusers.ModelsLoader", + "executionKind": "modular", "runnableModes": ["image_to_video"], }, ] -def public_experimental_pipelines() -> list[dict]: +# These adapters have a reviewed generic loader/action contract, but no Auto +# execution profile or public template. Keep that distinction explicit: a +# contract-only capability lets Expert clients discover the exact backend +# class, modes, repository, and input shape without making the pair eligible +# for Auto selection or presenting static/mock evidence as live qualification. +# +# The registry tests compare this table with the task-module adapter maps and +# the immutable artifact catalog. A new class therefore cannot be published +# here by copying a Diffusers name alone. +CONTRACT_ONLY_DIFFUSERS_PIPELINES = ( + # Standard image adapters. The final eleven were admitted by P0.3c.4; + # FLUX img2img/inpaint were already implemented but likewise unprofiled. + ("StableDiffusionXLPipeline", "image", SDXL_BASE_REPO, ("text_to_image",)), + ( + "StableDiffusionXLImg2ImgPipeline", + "image", + SDXL_BASE_REPO, + ("edit_image",), + ), + ( + "StableDiffusionXLInpaintPipeline", + "image", + SDXL_BASE_REPO, + ("inpaint", "outpaint"), + ), + ("QwenImageImg2ImgPipeline", "image", QWEN_IMAGE_2512_REPO, ("edit_image",)), + ("QwenImageInpaintPipeline", "image", QWEN_IMAGE_2512_REPO, ("inpaint", "outpaint")), + ("QwenImageEditPipeline", "image", "Qwen/Qwen-Image-Edit", ("edit_image",)), + ( + "QwenImageEditPlusPipeline", + "image", + "Qwen/Qwen-Image-Edit-2511", + ("edit_image", "multi_image_reference_edit"), + ), + ("ZImageImg2ImgPipeline", "image", "Tongyi-MAI/Z-Image-Turbo", ("edit_image",)), + ("ZImageInpaintPipeline", "image", "Tongyi-MAI/Z-Image-Turbo", ("inpaint", "outpaint")), + ("FluxImg2ImgPipeline", "image", FLUX_DEV_REPO, ("edit_image",)), + ("FluxInpaintPipeline", "image", FLUX_DEV_REPO, ("inpaint",)), + ( + "FluxKontextInpaintPipeline", + "image", + FLUX_KONTEXT_REPO, + ("inpaint", "outpaint"), + ), + ("Flux2KleinInpaintPipeline", "image", FLUX2_KLEIN_REPO, ("inpaint", "outpaint")), + # Implemented generic video adapters which intentionally have no execution + # profile yet. Qualification and templates remain later remote work. + ("Wan22Pipeline", "video", "Wan-AI/Wan2.2-T2V-A14B-Diffusers", ("text_to_video",)), + ( + "WanAnimatePipeline", + "video", + "Wan-AI/Wan2.2-Animate-14B-Diffusers", + ("character_animate", "character_replace"), + ), + ( + "LTXI2VLongMultiPromptPipeline", + "video", + LTX_VIDEO_REPO, + ("image_to_video",), + ), + ( + "LTX2ConditionPipeline", + "video", + "Lightricks/LTX-2", + ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), + ), + ( + "HunyuanVideoFramepackPipeline", + "video", + "lllyasviel/FramePackI2V_HY", + ("image_to_video",), + ), + # Stable Audio already runs through the generic Diffusers audio facade; it + # remains Expert/contract-only until its graph and live resource envelope + # are qualified. + ("StableAudioPipeline", "audio", "stabilityai/stable-audio-open-1.0", ("text_to_audio",)), +) + + +_CONTRACT_ONLY_BACKEND_PATHS = { + "image": "modules.DiffusersImage.LoadPipeline", + "video": "modules.DiffusersVideo.LoadPipeline", + "audio": "modules.DiffusersAudio.LoadPipeline", +} + +_CONTRACT_ONLY_INPUT_CONTRACTS = { + "edit_image": {"requiredImages": ["referenceImages"]}, + "multi_image_reference_edit": {"requiredImages": ["referenceImages"]}, + "inpaint": {"requiredImages": ["referenceImages", "maskImage"]}, + "outpaint": {"requiredImages": ["referenceImages"]}, + "image_to_video": {"requiredImages": ["referenceImages"]}, + "video_to_video": {"requiredVideos": ["sourceVideo"]}, + "reference_to_video": {"requiredImages": ["referenceImages"]}, + "character_animate": { + "requiredImages": ["referenceImages"], + "requiredVideos": ["poseVideo", "faceVideo"], + }, + "character_replace": { + "requiredImages": ["referenceImages"], + "requiredVideos": ["poseVideo", "faceVideo", "backgroundVideo", "maskVideo"], + }, +} + + +def _contract_only_pipeline_capability( + pipeline_class: str, + media_kind: str, + default_repo: str, + modes: tuple[str, ...], +) -> dict: + revision = require_catalog_revision(default_repo) + return { + "modelType": pipeline_class, + "label": pipeline_class, + "mediaKind": media_kind, + "defaultRepo": default_repo, + "pipelineClasses": [pipeline_class], + "backendPath": _CONTRACT_ONLY_BACKEND_PATHS[media_kind], + "executionKind": "standard", + "runnableModes": list(modes), + "inputContracts": { + mode: dict(_CONTRACT_ONLY_INPUT_CONTRACTS[mode]) + for mode in modes + if mode in _CONTRACT_ONLY_INPUT_CONTRACTS + }, + "qualificationStatus": "contract_only", + "revisionCandidates": [revision], + "autoEligible": False, + "templateEligible": False, + "galleryEligible": False, + } + + +EXPERIMENTAL_DIFFUSERS_PIPELINES.extend( + _contract_only_pipeline_capability(*contract) + for contract in CONTRACT_ONLY_DIFFUSERS_PIPELINES +) + + +def optional_runtime_requirement_for_profiles( + profiles: tuple[DiffusersExecutionProfile, ...] | list[DiffusersExecutionProfile], +) -> dict: + """Describe whether selected execution profiles require an overlay now. + + This helper is declarative and deliberately does not inspect installed + distributions or managed overlay state. Runtime observations are added by + :mod:`modiff.optional_runtime_execution` only for ``optional_overlay`` + delivery. + """ + + selected = tuple(profiles) + profile_ids: list[str] = [] + invalid_profile_ids = False + deliveries: set[str] = set() + execution_profile_ids: list[str] = [] + base_profile_with_optional_ids = False + for profile in selected: + if ( + isinstance(profile.id, str) + and _EXECUTION_PROFILE_ID_PATTERN.fullmatch(profile.id) + ): + if profile.id in execution_profile_ids: + invalid_profile_ids = True + else: + execution_profile_ids.append(profile.id) + else: + invalid_profile_ids = True + delivery = profile.optional_runtime_delivery + if delivery not in OPTIONAL_RUNTIME_DELIVERIES: + deliveries.add("invalid") + else: + deliveries.add(delivery) + valid_ids_for_profile = 0 + profile_seen_ids: set[str] = set() + for raw_profile_id in profile.optional_runtime_profiles: + if ( + not isinstance(raw_profile_id, str) + or not _OPTIONAL_RUNTIME_PROFILE_ID_PATTERN.fullmatch(raw_profile_id) + or raw_profile_id in profile_seen_ids + ): + invalid_profile_ids = True + continue + profile_id = raw_profile_id + profile_seen_ids.add(profile_id) + valid_ids_for_profile += 1 + if profile_id not in profile_ids: + profile_ids.append(profile_id) + if delivery == OPTIONAL_RUNTIME_DELIVERY_BASE and valid_ids_for_profile: + base_profile_with_optional_ids = True + + requires_overlay = OPTIONAL_RUNTIME_DELIVERY_OVERLAY in deliveries + contract_invalid = bool( + invalid_profile_ids + or len(profile_ids) > 32 + or len(execution_profile_ids) > 32 + or "invalid" in deliveries + or (requires_overlay and base_profile_with_optional_ids) + or (requires_overlay and not profile_ids) + ) + if contract_invalid: + delivery = OPTIONAL_RUNTIME_DELIVERY_OVERLAY + required_now = True + state = "unavailable" + reason = "execution_profile_contract_invalid" + elif requires_overlay: + delivery = OPTIONAL_RUNTIME_DELIVERY_OVERLAY + required_now = True + state = "unavailable" + reason = "optional_runtime_status_required" + else: + delivery = OPTIONAL_RUNTIME_DELIVERY_BASE + required_now = False + state = "base_satisfied" + reason = "base_runtime_contract" if profile_ids else "no_optional_runtime_required" + + return { + "schemaVersion": OPTIONAL_RUNTIME_REQUIREMENT_SCHEMA_VERSION, + "delivery": delivery, + "requiredNow": required_now, + "profileIds": profile_ids[:32], + "executionProfileIds": execution_profile_ids[:32], + "state": state, + "reason": reason, + } + + +def execution_profiles_for_execution( + model_type: str, + mode: str | None = None, +) -> tuple[DiffusersExecutionProfile, ...]: + """Resolve declared profiles for one backend-owned model/mode pair.""" + + normalized_model_type = str(model_type or "").strip() + normalized_mode = str(mode or "").strip() + return tuple( + profile + for profile in DIFFUSERS_EXECUTION_PROFILES.values() + if profile.model_type == normalized_model_type + and (not normalized_mode or normalized_mode in profile.modes) + ) + + +def optional_runtime_requirement_for_execution( + model_type: str, + mode: str | None = None, +) -> dict: + """Return declarative optional-runtime delivery for one exact pair.""" + + return optional_runtime_requirement_for_profiles( + execution_profiles_for_execution(model_type, mode) + ) + + +def resolve_execution_profiles_for_loader( + module: str, + action: str, + values: dict, +) -> tuple[tuple[DiffusersExecutionProfile, ...], str | None]: + """Resolve an executable loader from authoritative node parameters. + + The returned reason is non-``None`` when the loader belongs to a declared + Diffusers backend path but its exact execution profile cannot be proven. + Base delivery intentionally tolerates that legacy/Expert ambiguity. Once a + relevant profile selects ``optional_overlay``, the execution guard treats + the same reason as a fail-closed contract blocker. + """ + + backend_path = f"{str(module or '').strip()}.{str(action or '').strip()}" + backend_profiles = tuple( + profile + for profile in DIFFUSERS_EXECUTION_PROFILES.values() + if profile.backend_path == backend_path + ) + if not backend_profiles: + return (), None + if not isinstance(values, dict): + return backend_profiles, "loader_parameters_invalid" + + identity_key = "model_type" if action == "ModelsLoader" else "pipeline_class" + raw_identity = values.get(identity_key) + identity = raw_identity.strip() if isinstance(raw_identity, str) else "" + if not identity: + return backend_profiles, "loader_identity_missing" + + matching = tuple( + profile + for profile in backend_profiles + if ( + profile.model_type == identity + if action == "ModelsLoader" + else profile.pipeline_class == identity + ) + ) + if not matching: + return backend_profiles, "loader_selection_unregistered" + if len(matching) == 1: + return matching, None + + raw_repository = values.get("model_id") or values.get("repo_id") + if isinstance(raw_repository, str): + repository = raw_repository.strip() + repository_source = "hub" + elif ( + isinstance(raw_repository, dict) + and set(raw_repository).issubset({"source", "value"}) + and raw_repository.get("source") in {"hub", "local"} + and isinstance(raw_repository.get("value"), str) + ): + repository = raw_repository["value"].strip() + repository_source = raw_repository["source"] + else: + repository = "" + repository_source = "" + if repository and repository_source == "hub": + repository_matches = tuple( + profile + for profile in matching + if repository + in {profile.default_repo, profile.fallback_repo, *profile.compatible_repos} + ) + if len(repository_matches) == 1: + return repository_matches, None + + return matching, "loader_profile_ambiguous" + + +def public_experimental_pipelines( + *, + observe_optional_runtime: bool = False, + optional_runtime_catalog_resolver=None, +) -> list[dict]: parameter_aliases = { "modelRepository": ["model_id", "model", "repo"], "guidanceScale": ["guidance_scale", "true_cfg_scale", "guidance"], @@ -503,26 +917,135 @@ def public_experimental_pipelines() -> list[dict]: "maskImage": ["mask_image", "mask"], "controlImage": ["control_image", "conditioning_image"], } + public_pipelines = [] + for pipeline in EXPERIMENTAL_DIFFUSERS_PIPELINES: + profile_ids = pipeline.get("executionProfileIds", []) + execution_profiles = [ + DIFFUSERS_EXECUTION_PROFILES[profile_id].to_public_dict( + observe_optional_runtime=observe_optional_runtime, + optional_runtime_catalog_resolver=optional_runtime_catalog_resolver, + ) + for profile_id in profile_ids + if profile_id in DIFFUSERS_EXECUTION_PROFILES + ] + if len(execution_profiles) != len(profile_ids): + # A dangling profile reference must never leave a mode looking + # runnable. Tests make this branch a permanent registry invariant. + execution_profiles = [] + runnable_modes = [] + pipeline_classes = [] + backend_path = None + qualification_status = "invalid_contract" + elif execution_profiles: + runnable_modes = list( + dict.fromkeys(mode for profile in execution_profiles for mode in profile["modes"]) + ) + pipeline_classes = list( + dict.fromkeys(profile["pipeline_class"] for profile in execution_profiles) + ) + backend_paths = {profile["backend_path"] for profile in execution_profiles} + backend_path = next(iter(backend_paths)) if len(backend_paths) == 1 else None + qualification_status = pipeline.get("qualificationStatus", "unqualified") + else: + runnable_modes = list(pipeline["runnableModes"]) + pipeline_classes = list(pipeline["pipelineClasses"]) + backend_path = pipeline.get("backendPath") + qualification_status = pipeline.get("qualificationStatus", "unqualified") + + optional_runtime_profile_ids = list( + dict.fromkeys( + profile_id + for profile in execution_profiles + for profile_id in profile.get("optional_runtime_profiles", []) + ) + ) + if not optional_runtime_profile_ids: + optional_runtime_profile_ids = list( + pipeline.get("optionalRuntimeProfileIds") + or (TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID,) + ) + selected_profile_contracts = tuple( + DIFFUSERS_EXECUTION_PROFILES[profile_id] + for profile_id in profile_ids + if profile_id in DIFFUSERS_EXECUTION_PROFILES + ) + if observe_optional_runtime and selected_profile_contracts: + from modiff.optional_runtime_execution import ( + optional_runtime_requirement_for_profiles as observed_requirement, + ) + + optional_runtime_requirement = observed_requirement( + selected_profile_contracts, + catalog_resolver=optional_runtime_catalog_resolver, + ) + else: + optional_runtime_requirement = optional_runtime_requirement_for_profiles( + selected_profile_contracts + ) + + public_pipelines.append( + { + **pipeline, + "pipelineClasses": pipeline_classes, + "backendPath": backend_path, + "runnableModes": runnable_modes, + "schemaVersion": 2, + "supportTier": "experimental", + "executionProfiles": execution_profiles, + "inputContracts": dict(pipeline.get("inputContracts", {})), + "unsupportedModes": { + mode: dict(contract) for mode, contract in pipeline.get("unsupportedModes", {}).items() + }, + "parameterAliases": parameter_aliases, + "defaults": dict(pipeline.get("defaults", {})), + "artifactCandidates": [pipeline["defaultRepo"]] if pipeline.get("defaultRepo") else [], + "revisionCandidates": list(pipeline.get("revisionCandidates", [])), + "quantizationSupport": dict( + pipeline.get( + "quantizationSupport", + {"defaultMode": "none", "components": [], "offloadModes": []}, + ) + ), + "qualificationStatus": qualification_status, + "optionalRuntimeProfileIds": optional_runtime_profile_ids, + "optionalRuntimeProfiles": public_optional_runtime_profiles( + optional_runtime_profile_ids + ), + **( + { + "optionalRuntimeRequirement": optional_runtime_requirement + } + if execution_profiles + else {} + ), + } + ) + return public_pipelines + + +def public_execution_profiles( + *, + observe_optional_runtime: bool = False, + optional_runtime_catalog_resolver=None, +) -> list[dict]: return [ - { - **pipeline, - "schemaVersion": 2, - "supportTier": "experimental", - "executionProfiles": [], - "inputContracts": pipeline.get("inputContracts", {}), - "parameterAliases": parameter_aliases, - "defaults": pipeline.get("defaults", {}), - "artifactCandidates": [pipeline["defaultRepo"]] if pipeline.get("defaultRepo") else [], - "revisionCandidates": pipeline.get("revisionCandidates", []), - "quantizationSupport": pipeline.get( - "quantizationSupport", - {"defaultMode": "none", "components": [], "offloadModes": []}, - ), - "qualificationStatus": pipeline.get("qualificationStatus", "unqualified"), - } - for pipeline in EXPERIMENTAL_DIFFUSERS_PIPELINES + profile.to_public_dict( + observe_optional_runtime=observe_optional_runtime, + optional_runtime_catalog_resolver=optional_runtime_catalog_resolver, + ) + for profile in DIFFUSERS_EXECUTION_PROFILES.values() ] -def public_execution_profiles() -> list[dict]: - return [profile.to_public_dict() for profile in DIFFUSERS_EXECUTION_PROFILES.values()] +def optional_runtime_profile_ids_for_execution( + model_type: str, + mode: str | None = None, +) -> tuple[str, ...]: + """Resolve optional runtime IDs for one declared model/mode pair.""" + + profile_ids: list[str] = [] + for profile in execution_profiles_for_execution(model_type, mode): + for profile_id in profile.optional_runtime_profiles: + if profile_id not in profile_ids: + profile_ids.append(profile_id) + return tuple(profile_ids) diff --git a/modiff/install.py b/modiff/install.py index 915771a..55d5636 100644 --- a/modiff/install.py +++ b/modiff/install.py @@ -9,12 +9,14 @@ import platform import re import shutil +import stat import subprocess import sys import tarfile import tempfile import time import urllib.request +import uuid import zipfile from pathlib import Path from typing import Any @@ -33,6 +35,7 @@ runtime_contract_paths, ) from modiff.setup_catalog import CATALOG, PHASES, enrich_issue +from modiff.tool_locks import UV_TOOL_LOCKS ROOT = Path(__file__).resolve().parents[1] VENV = ROOT / ".venv" @@ -45,9 +48,10 @@ WEB_ROOT = ROOT / "web" TOOL_ARCHIVES = { - ("linux", "x86_64", "uv"): ("https://github.com/astral-sh/uv/releases/download/0.11.26/uv-x86_64-unknown-linux-gnu.tar.gz", "6426a73c3837e6e2483ee344cbc00f36394d179afcba6183cb77437e67db4af0"), - ("macos", "arm64", "uv"): ("https://github.com/astral-sh/uv/releases/download/0.11.26/uv-aarch64-apple-darwin.tar.gz", "8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6"), - ("windows", "x86_64", "uv"): ("https://github.com/astral-sh/uv/releases/download/0.11.26/uv-x86_64-pc-windows-msvc.zip", "4e1278ede866be6c0bf32d2f466cc6de7a9fb399ecf20c9ce2d186e52424be47"), + **{ + (os_name, machine, "uv"): (str(lock["url"]), str(lock["archiveSha256"])) + for (os_name, machine), lock in UV_TOOL_LOCKS.items() + }, ("linux", "x86_64", "node"): ("https://nodejs.org/dist/v24.12.0/node-v24.12.0-linux-x64.tar.xz", "bdebee276e58d0ef5448f3d5ac12c67daa963dd5e0a9bb621a53d1cefbc852fd"), ("macos", "arm64", "node"): ("https://nodejs.org/dist/v24.12.0/node-v24.12.0-darwin-arm64.tar.gz", "319f221adc5e44ff0ed57e8a441b2284f02b8dc6fc87b8eb92a6a93643fd8080"), ("windows", "x86_64", "node"): ("https://nodejs.org/dist/v24.12.0/node-v24.12.0-win-x64.zip", "9c125f61ae947b52e779095830f9cac267846a043ef7192183c84016aaad2812"), @@ -536,6 +540,43 @@ def _ensure_uv() -> str: uv = _find_executable(_download_tool("uv"), ("uv.exe", "uv")) if not uv: raise RuntimeError("The app-local uv archive did not contain the expected executable") + managed_info = managed_uv.lstat() + if ( + not stat.S_ISDIR(managed_info.st_mode) + or stat.S_ISLNK(managed_info.st_mode) + or bool( + getattr(managed_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("The app-local uv tool directory is unsafe") + key = (normalized_os(), normalized_arch()) + lock = UV_TOOL_LOCKS.get(key) + executable = Path(uv).resolve(strict=True) + if lock is None or executable.is_symlink(): + raise RuntimeError("The app-local uv executable has no reviewed platform lock") + try: + relative = executable.relative_to(managed_uv.resolve(strict=True)).as_posix() + except ValueError as exc: + raise RuntimeError("The app-local uv executable escapes its managed directory") from exc + executable_hash = hashlib.sha256(executable.read_bytes()).hexdigest() + if relative != lock["executable"] or executable_hash != lock["executableSha256"]: + raise RuntimeError("The app-local uv executable failed its reviewed integrity check") + receipt = { + "schemaVersion": 1, + "archiveSha256": lock["archiveSha256"], + "executable": relative, + "executableSha256": executable_hash, + } + temporary = managed_uv / f".receipt.{uuid.uuid4().hex}.tmp" + try: + with temporary.open("x", encoding="utf-8", newline="\n") as output: + output.write(json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n") + output.flush() + os.fsync(output.fileno()) + temporary.replace(managed_uv / "receipt.json") + finally: + temporary.unlink(missing_ok=True) return uv diff --git a/modiff/modular_workflow_contracts.py b/modiff/modular_workflow_contracts.py new file mode 100644 index 0000000..2536578 --- /dev/null +++ b/modiff/modular_workflow_contracts.py @@ -0,0 +1,924 @@ +"""Pinned Modular Diffusers workflow truth used by MoDiff contract tests. + +This module is intentionally data-only: importing capability metadata must not +instantiate Diffusers pipelines or import the model stack. The corresponding +tests instantiate the no-weight block definitions from the reviewed Diffusers +revision and compare them with this matrix. + +The matrix distinguishes upstream availability from MoDiff executability. An +upstream workflow is not a runnable MoDiff mode until the listed generic node +actions can carry every required user input and intermediate state edge. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +PINNED_DIFFUSERS_REVISION = "13a7bee4878d62fccc8d25f97e480e68de96fa03" +WAN_I2V_REPOSITORY = "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers" +WAN_FLF_REPOSITORY = "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers" + +# One installed Modular pipeline class can have multiple official weight/config +# contracts. Keep the allowed repositories beside the pinned workflow truth; +# the loader still requires an immutable catalog revision for the selected +# repository, and the downstream action validates the workflow/repository pair. +PINNED_MODULAR_REPOSITORY_VARIANTS = { + "WanImage2VideoModularPipeline": (WAN_I2V_REPOSITORY, WAN_FLF_REPOSITORY), +} +# Standard Hub indexes name the concrete classes serialized by each checkpoint, +# while the installed Modular blocks declare their reviewed base/factory types. +# These are exact repository-scoped aliases, not general subclass admission. +PINNED_MODULAR_REPOSITORY_COMPONENT_TYPES = { + WAN_I2V_REPOSITORY: { + "tokenizer": ("transformers", "T5TokenizerFast"), + "image_encoder": ("transformers", "CLIPVisionModelWithProjection"), + }, + WAN_FLF_REPOSITORY: { + "tokenizer": ("transformers", "T5TokenizerFast"), + "image_processor": ("transformers", "CLIPProcessor"), + "image_encoder": ("transformers", "CLIPVisionModelWithProjection"), + }, +} +# The FLF standard pipeline serializes a combined processor wrapper even though +# its image_processor subfolder contains only the reviewed CLIP image processor +# config consumed by the installed Modular image block. Validate the serialized +# declaration above, then normalize this one load contract to the block type. +PINNED_MODULAR_REPOSITORY_LOAD_COMPONENT_TYPES = { + WAN_FLF_REPOSITORY: { + "image_processor": ("transformers", "CLIPImageProcessor"), + }, +} +WAN_WORKFLOW_REPOSITORIES = ( + ("image2video", WAN_I2V_REPOSITORY), + ("flf2v", WAN_FLF_REPOSITORY), +) + + +@dataclass(frozen=True) +class UpstreamWorkflowTruth: + """One exact entry from an upstream AutoBlocks ``_workflow_map``.""" + + name: str + required_inputs: frozenset[str] + + +@dataclass(frozen=True) +class StateEdgeTruth: + """A value that must be wireable between two generic MoDiff actions.""" + + producer_action: str + producer_output: str + consumer_action: str + consumer_input: str + + +@dataclass(frozen=True) +class ModularModeTruth: + """A public mode backed by a constructible current Modular node contract.""" + + upstream_workflow: str | None + required_upstream_inputs: frozenset[str] + action_sequence: tuple[str, ...] + state_edges: tuple[StateEdgeTruth, ...] + upstream_block_sequence: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ModularStateFlowTruth: + """A reviewed internal action flow that does not advertise a public mode.""" + + upstream_workflow: str + required_upstream_inputs: frozenset[str] + upstream_block_sequence: tuple[str, ...] + action_sequence: tuple[str, ...] + state_edges: tuple[StateEdgeTruth, ...] + + +@dataclass(frozen=True) +class PinnedModularPipelineTruth: + """Pinned upstream surface plus the narrower executable MoDiff surface.""" + + blocks_class: str + workflows: tuple[UpstreamWorkflowTruth, ...] = () + fixed_block_sequence: tuple[str, ...] = () + constructor_config: tuple[tuple[str, object], ...] = () + modes: tuple[tuple[str, ModularModeTruth], ...] = () + state_flows: tuple[tuple[str, ModularStateFlowTruth], ...] = () + + def mode(self, name: str) -> ModularModeTruth | None: + return dict(self.modes).get(name) + + def state_flow(self, name: str) -> ModularStateFlowTruth | None: + return dict(self.state_flows).get(name) + + +def _workflow(name: str, *required_inputs: str) -> UpstreamWorkflowTruth: + return UpstreamWorkflowTruth(name, frozenset(required_inputs)) + + +_TEXT_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), +) + +_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), +) + +# SDXL keeps its tensor-valued state on ordinary typed graph edges while the +# opaque route binds generator continuation, component provenance, and Decode. +_SDXL_ROUTE_TEXT_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_ROUTE_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_ROUTE_CONTROL_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_ROUTE_INPAINT_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "mask", "denoise", "mask"), + StateEdgeTruth( + "vae_encoder", + "masked_image_latents", + "denoise", + "masked_image_latents", + ), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_ROUTE_CONTROL_INPAINT_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "mask", "denoise", "mask"), + StateEdgeTruth( + "vae_encoder", + "masked_image_latents", + "denoise", + "masked_image_latents", + ), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_SDXL_IP_ADAPTER_EDGE = ( + StateEdgeTruth("ip_adapter", "ip_adapter", "denoise", "ip_adapter"), +) + +_SDXL_INPAINT_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.denoise", + "decode", +) + +_SDXL_CONTROLNET_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.controlnet_input", + "denoise.denoise", + "decode", +) + +_SDXL_IP_ADAPTER_BLOCK_SEQUENCE = ( + "text_encoder", + "ip_adapter", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.denoise", + "decode", +) + +_SDXL_IP_ADAPTER_TEXT_BLOCK_SEQUENCE = ( + "text_encoder", + "ip_adapter", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.denoise", + "decode", +) + +_SDXL_IP_ADAPTER_CONTROL_BLOCK_SEQUENCE = ( + "text_encoder", + "ip_adapter", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.controlnet_input", + "denoise.denoise", + "decode", +) + +_SDXL_IP_ADAPTER_CONTROL_TEXT_BLOCK_SEQUENCE = ( + "text_encoder", + "ip_adapter", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.controlnet_input", + "denoise.denoise", + "decode", +) + +_QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_CONTROL_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), +) + +# Qwen ControlNet seals its post-control-VAE generator for Denoise, and Decode +# remains loader-bound even when no image VAE route precedes ControlNet. +_QWEN_CONTROL_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("controlnet", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +# Main image latents remain a direct typed edge into Denoise. The opaque main +# VAE route instead passes through ControlNet so its post-control-VAE generator, +# mask/overlay state, and exact control-latent identity reach Denoise together. +_QWEN_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("vae_encoder", "image_latents", "denoise", "image_latents"), + StateEdgeTruth("vae_encoder", "route_state_out", "controlnet", "route_state_in"), + StateEdgeTruth("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + StateEdgeTruth("controlnet", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +_QWEN_IMAGE2IMAGE_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder.preprocess", + "vae_encoder.encode", + "denoise.input.text_inputs", + "denoise.input.additional_inputs", + "denoise.prepare_latents", + "denoise.set_timesteps", + "denoise.prepare_img2img_latents", + "denoise.prepare_rope_inputs", + "denoise.denoise", + "denoise.after_denoise", + "decode.decode", + "decode.postprocess", +) + +_QWEN_INPAINT_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder.preprocess", + "vae_encoder.encode", + "denoise.input.text_inputs", + "denoise.input.additional_inputs", + "denoise.prepare_latents", + "denoise.set_timesteps", + "denoise.prepare_inpaint_latents.add_noise_to_latents", + "denoise.prepare_inpaint_latents.create_mask_latents", + "denoise.prepare_rope_inputs", + "denoise.denoise", + "denoise.after_denoise", + "decode.decode", + "decode.postprocess", +) + +_QWEN_CONTROL_IMAGE2IMAGE_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder.preprocess", + "vae_encoder.encode", + "controlnet_vae_encoder", + "denoise.input.text_inputs", + "denoise.input.additional_inputs", + "denoise.controlnet_input", + "denoise.prepare_latents", + "denoise.set_timesteps", + "denoise.prepare_img2img_latents", + "denoise.prepare_rope_inputs", + "denoise.controlnet_before_denoise", + "denoise.controlnet_denoise", + "denoise.after_denoise", + "decode.decode", + "decode.postprocess", +) + +_QWEN_CONTROL_INPAINT_BLOCK_SEQUENCE = ( + "text_encoder", + "vae_encoder.preprocess", + "vae_encoder.encode", + "controlnet_vae_encoder", + "denoise.input.text_inputs", + "denoise.input.additional_inputs", + "denoise.controlnet_input", + "denoise.prepare_latents", + "denoise.set_timesteps", + "denoise.prepare_inpaint_latents.add_noise_to_latents", + "denoise.prepare_inpaint_latents.create_mask_latents", + "denoise.prepare_rope_inputs", + "denoise.controlnet_before_denoise", + "denoise.controlnet_denoise", + "denoise.after_denoise", + "decode.decode", + "decode.postprocess", +) + +_WAN_IMAGE_TO_OUTPUT_EDGES = ( + StateEdgeTruth("text_encoder", "embeddings", "denoise", "embeddings"), + StateEdgeTruth("image_encoder", "image_embeds", "denoise", "image_embeds"), + StateEdgeTruth("image_encoder", "route_state_out", "vae_encoder", "route_state_in"), + StateEdgeTruth( + "vae_encoder", + "image_condition_latents", + "denoise", + "image_condition_latents", + ), + StateEdgeTruth("vae_encoder", "route_state_out", "denoise", "route_state_in"), + StateEdgeTruth("denoise", "latents", "decoder", "latents"), + StateEdgeTruth("denoise", "route_state_out", "decoder", "route_state_in"), +) + +# The raw first/last-frame VAE latents are intentionally not an edge into +# Denoise. Pinned Wan consumes only the prepared image_condition_latents. +_WAN_IMAGE2VIDEO_BLOCK_SEQUENCE = ( + "text_encoder", + "image_encoder.image_resize", + "image_encoder.image_encoder", + "vae_encoder.image_resize", + "vae_encoder.vae_encoder", + "vae_encoder.prepare_first_frame_latents", + "denoise.input", + "denoise.additional_inputs", + "denoise.set_timesteps", + "denoise.prepare_latents", + "denoise.denoise", + "decode", +) + +_WAN_FLF2V_BLOCK_SEQUENCE = ( + "text_encoder", + "image_encoder.image_resize", + "image_encoder.last_image_resize", + "image_encoder.image_encoder", + "vae_encoder.image_resize", + "vae_encoder.last_image_resize", + "vae_encoder.vae_encoder", + "vae_encoder.prepare_first_last_frame_latents", + "denoise.input", + "denoise.additional_inputs", + "denoise.set_timesteps", + "denoise.prepare_latents", + "denoise.denoise", + "decode", +) + + +PINNED_MODULAR_WORKFLOW_TRUTH: dict[str, PinnedModularPipelineTruth] = { + "StableDiffusionXLModularPipeline": PinnedModularPipelineTruth( + blocks_class="StableDiffusionXLAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image2image", "image", "prompt"), + _workflow("inpainting", "mask_image", "image", "prompt"), + _workflow("controlnet_text2image", "control_image", "prompt"), + _workflow("controlnet_image2image", "control_image", "image", "prompt"), + _workflow("controlnet_inpainting", "control_image", "mask_image", "image", "prompt"), + _workflow("controlnet_union_text2image", "control_image", "control_mode", "prompt"), + _workflow( + "controlnet_union_image2image", + "control_image", + "control_mode", + "image", + "prompt", + ), + _workflow( + "controlnet_union_inpainting", + "control_image", + "control_mode", + "mask_image", + "image", + "prompt", + ), + _workflow("ip_adapter_text2image", "ip_adapter_image", "prompt"), + _workflow("ip_adapter_image2image", "ip_adapter_image", "image", "prompt"), + _workflow("ip_adapter_inpainting", "ip_adapter_image", "mask_image", "image", "prompt"), + _workflow( + "ip_adapter_controlnet_text2image", + "ip_adapter_image", + "control_image", + "prompt", + ), + _workflow( + "ip_adapter_controlnet_image2image", + "ip_adapter_image", + "control_image", + "image", + "prompt", + ), + _workflow( + "ip_adapter_controlnet_inpainting", + "ip_adapter_image", + "control_image", + "mask_image", + "image", + "prompt", + ), + _workflow( + "ip_adapter_controlnet_union_text2image", + "ip_adapter_image", + "control_image", + "control_mode", + "prompt", + ), + _workflow( + "ip_adapter_controlnet_union_image2image", + "ip_adapter_image", + "control_image", + "control_mode", + "image", + "prompt", + ), + _workflow( + "ip_adapter_controlnet_union_inpainting", + "ip_adapter_image", + "control_image", + "control_mode", + "mask_image", + "image", + "prompt", + ), + ), + modes=( + ( + "text_to_image", + ModularModeTruth( + "text2image", + frozenset({"prompt"}), + ("text_encoder", "denoise", "decoder"), + _SDXL_ROUTE_TEXT_TO_OUTPUT_EDGES, + ), + ), + ( + "image_to_image", + ModularModeTruth( + "image2image", + frozenset({"image", "prompt"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _SDXL_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "control_image", + ModularModeTruth( + "controlnet_text2image", + frozenset({"control_image", "prompt"}), + ("text_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_TO_OUTPUT_EDGES, + ), + ), + ( + "inpaint", + ModularModeTruth( + "inpainting", + frozenset({"mask_image", "image", "prompt"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _SDXL_ROUTE_INPAINT_TO_OUTPUT_EDGES, + _SDXL_INPAINT_BLOCK_SEQUENCE, + ), + ), + ), + state_flows=( + ( + "inpainting", + ModularStateFlowTruth( + "inpainting", + frozenset({"mask_image", "image", "prompt"}), + _SDXL_INPAINT_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _SDXL_ROUTE_INPAINT_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_image2image", + ModularStateFlowTruth( + "controlnet_image2image", + frozenset({"control_image", "image", "prompt"}), + _SDXL_CONTROLNET_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_inpainting", + ModularStateFlowTruth( + "controlnet_inpainting", + frozenset({"control_image", "mask_image", "image", "prompt"}), + _SDXL_CONTROLNET_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_INPAINT_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_union_image2image", + ModularStateFlowTruth( + "controlnet_union_image2image", + frozenset({"control_image", "control_mode", "image", "prompt"}), + _SDXL_CONTROLNET_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_union_inpainting", + ModularStateFlowTruth( + "controlnet_union_inpainting", + frozenset({"control_image", "control_mode", "mask_image", "image", "prompt"}), + _SDXL_CONTROLNET_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_INPAINT_TO_OUTPUT_EDGES, + ), + ), + ( + "ip_adapter_text2image", + ModularStateFlowTruth( + "ip_adapter_text2image", + frozenset({"ip_adapter_image", "prompt"}), + _SDXL_IP_ADAPTER_TEXT_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "denoise", "decoder"), + _SDXL_ROUTE_TEXT_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_image2image", + ModularStateFlowTruth( + "ip_adapter_image2image", + frozenset({"ip_adapter_image", "image", "prompt"}), + _SDXL_IP_ADAPTER_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "denoise", "decoder"), + _SDXL_ROUTE_IMAGE_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_inpainting", + ModularStateFlowTruth( + "ip_adapter_inpainting", + frozenset({"ip_adapter_image", "mask_image", "image", "prompt"}), + _SDXL_IP_ADAPTER_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "denoise", "decoder"), + _SDXL_ROUTE_INPAINT_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_text2image", + ModularStateFlowTruth( + "ip_adapter_controlnet_text2image", + frozenset({"ip_adapter_image", "control_image", "prompt"}), + _SDXL_IP_ADAPTER_CONTROL_TEXT_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_image2image", + ModularStateFlowTruth( + "ip_adapter_controlnet_image2image", + frozenset({"ip_adapter_image", "control_image", "image", "prompt"}), + _SDXL_IP_ADAPTER_CONTROL_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_inpainting", + ModularStateFlowTruth( + "ip_adapter_controlnet_inpainting", + frozenset({"ip_adapter_image", "control_image", "mask_image", "image", "prompt"}), + _SDXL_IP_ADAPTER_CONTROL_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_INPAINT_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_union_text2image", + ModularStateFlowTruth( + "ip_adapter_controlnet_union_text2image", + frozenset({"ip_adapter_image", "control_image", "control_mode", "prompt"}), + _SDXL_IP_ADAPTER_CONTROL_TEXT_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_union_image2image", + ModularStateFlowTruth( + "ip_adapter_controlnet_union_image2image", + frozenset({"ip_adapter_image", "control_image", "control_mode", "image", "prompt"}), + _SDXL_IP_ADAPTER_CONTROL_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ( + "ip_adapter_controlnet_union_inpainting", + ModularStateFlowTruth( + "ip_adapter_controlnet_union_inpainting", + frozenset( + {"ip_adapter_image", "control_image", "control_mode", "mask_image", "image", "prompt"} + ), + _SDXL_IP_ADAPTER_CONTROL_BLOCK_SEQUENCE, + ("text_encoder", "ip_adapter", "vae_encoder", "controlnet", "denoise", "decoder"), + _SDXL_ROUTE_CONTROL_INPAINT_TO_OUTPUT_EDGES + _SDXL_IP_ADAPTER_EDGE, + ), + ), + ), + ), + "QwenImageModularPipeline": PinnedModularPipelineTruth( + blocks_class="QwenImageAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image2image", "prompt", "image"), + _workflow("inpainting", "prompt", "mask_image", "image"), + _workflow("controlnet_text2image", "prompt", "control_image"), + _workflow("controlnet_image2image", "prompt", "image", "control_image"), + _workflow("controlnet_inpainting", "prompt", "mask_image", "image", "control_image"), + ), + modes=( + ( + "control_image", + ModularModeTruth( + "controlnet_text2image", + frozenset({"prompt", "control_image"}), + ("text_encoder", "controlnet", "denoise", "decoder"), + _QWEN_CONTROL_TO_OUTPUT_EDGES, + ), + ), + ), + state_flows=( + ( + "image2image", + ModularStateFlowTruth( + "image2image", + frozenset({"prompt", "image"}), + _QWEN_IMAGE2IMAGE_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "inpainting", + ModularStateFlowTruth( + "inpainting", + frozenset({"prompt", "mask_image", "image"}), + _QWEN_INPAINT_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_image2image", + ModularStateFlowTruth( + "controlnet_image2image", + frozenset({"prompt", "image", "control_image"}), + _QWEN_CONTROL_IMAGE2IMAGE_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _QWEN_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "controlnet_inpainting", + ModularStateFlowTruth( + "controlnet_inpainting", + frozenset({"prompt", "mask_image", "image", "control_image"}), + _QWEN_CONTROL_INPAINT_BLOCK_SEQUENCE, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + _QWEN_ROUTE_CONTROL_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "QwenImageEditModularPipeline": PinnedModularPipelineTruth( + blocks_class="QwenImageEditAutoBlocks", + workflows=( + _workflow("image_conditioned", "prompt", "image"), + _workflow("image_conditioned_inpainting", "prompt", "mask_image", "image"), + ), + modes=( + ( + "edit_image", + ModularModeTruth( + "image_conditioned", + frozenset({"prompt", "image"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "QwenImageEditPlusModularPipeline": PinnedModularPipelineTruth( + blocks_class="QwenImageEditPlusAutoBlocks", + fixed_block_sequence=("text_encoder", "vae_encoder", "denoise", "decode"), + modes=( + ( + "edit_image", + ModularModeTruth( + None, + frozenset({"prompt", "image"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ( + "multi_image_reference_edit", + ModularModeTruth( + None, + frozenset({"prompt", "image"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _QWEN_ROUTE_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "QwenImageLayeredModularPipeline": PinnedModularPipelineTruth( + blocks_class="QwenImageLayeredAutoBlocks", + fixed_block_sequence=("text_encoder", "vae_encoder", "denoise", "decode"), + modes=( + ( + "layer_decomposition", + ModularModeTruth( + None, + frozenset({"prompt", "image", "layers"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "FluxModularPipeline": PinnedModularPipelineTruth( + blocks_class="FluxAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image2image", "image", "prompt"), + ), + modes=( + ( + "text_to_image", + ModularModeTruth( + "text2image", + frozenset({"prompt"}), + ("text_encoder", "denoise", "decoder"), + _TEXT_TO_OUTPUT_EDGES, + ), + ), + ( + "image_to_image", + ModularModeTruth( + "image2image", + frozenset({"image", "prompt"}), + ("text_encoder", "vae_encoder", "denoise", "decoder"), + _IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "FluxKontextModularPipeline": PinnedModularPipelineTruth( + blocks_class="FluxKontextAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image_conditioned", "image", "prompt"), + ), + ), + "Flux2KleinModularPipeline": PinnedModularPipelineTruth( + blocks_class="Flux2KleinAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image_conditioned", "image", "prompt"), + ), + constructor_config=(("is_distilled", True),), + ), + "ZImageModularPipeline": PinnedModularPipelineTruth( + blocks_class="ZImageAutoBlocks", + workflows=( + _workflow("text2image", "prompt"), + _workflow("image2image", "image", "prompt"), + ), + modes=( + ( + "text_to_image", + ModularModeTruth( + "text2image", + frozenset({"prompt"}), + ("text_encoder", "denoise", "decoder"), + _TEXT_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "WanModularPipeline": PinnedModularPipelineTruth( + blocks_class="WanBlocks", + fixed_block_sequence=("text_encoder", "denoise", "decode"), + modes=( + ( + "text_to_video", + ModularModeTruth( + None, + frozenset({"prompt"}), + ("text_encoder", "denoise", "decoder"), + _TEXT_TO_OUTPUT_EDGES, + ), + ), + ), + ), + "WanImage2VideoModularPipeline": PinnedModularPipelineTruth( + blocks_class="WanImage2VideoAutoBlocks", + workflows=( + _workflow("flf2v", "image", "last_image", "prompt"), + _workflow("image2video", "image", "prompt"), + ), + modes=( + ( + "image_to_video", + ModularModeTruth( + "image2video", + frozenset({"image", "prompt"}), + ("text_encoder", "image_encoder", "vae_encoder", "denoise", "decoder"), + _WAN_IMAGE_TO_OUTPUT_EDGES, + _WAN_IMAGE2VIDEO_BLOCK_SEQUENCE, + ), + ), + ), + state_flows=( + ( + "flf2v", + ModularStateFlowTruth( + "flf2v", + frozenset({"image", "last_image", "prompt"}), + _WAN_FLF2V_BLOCK_SEQUENCE, + ("text_encoder", "image_encoder", "vae_encoder", "denoise", "decoder"), + _WAN_IMAGE_TO_OUTPUT_EDGES, + ), + ), + ), + ), +} + + +FLUX_MODULAR_CONTROL_UNSUPPORTED = { + "status": "unsupported", + "reason": ( + "Pinned Diffusers Flux Modular blocks provide text-to-image and image-to-image only; no Modular " + "ControlNet workflow can be assembled." + ), + "missingState": ["controlnet_workflow"], +} diff --git a/modiff/optimization_packages.py b/modiff/optimization_packages.py index 6fc1aed..f6740ed 100644 --- a/modiff/optimization_packages.py +++ b/modiff/optimization_packages.py @@ -16,50 +16,232 @@ import hashlib import importlib.metadata import json +import math import os +import platform +import re import shutil -import site +import stat import subprocess import sys import threading import time import uuid +from urllib.parse import urlparse from collections.abc import Callable from copy import deepcopy from pathlib import Path from typing import Any +from modiff.optional_runtimes import ( + OPTIONAL_RUNTIME_PROFILES, + optional_runtime_base_contracts, + public_optional_runtime_profiles, +) +from modiff.runtime_overlays import ( + InstallLease, + OverlayCancelled, + OverlayInstallBusy, + active_install, + binding_digest, + binding_matches, + cache_locked_artifacts, + current_base_binding, + ensure_managed_directory, + flush_managed_directory, + normalize_locked_wheel_install, + release_install, + reserve_install, + run_cancellable_command, + run_fresh_validation, + sanitized_install_environment, + overlay_file_seal_matches, + promote_staged_environment, + remove_managed_directory, + remove_managed_file, + verify_artifact_anchored_overlay, + locked_artifact_file_seal, + managed_directory_identity, +) +from modiff.tool_locks import UV_TOOL_LOCKS + ROOT = Path(__file__).resolve().parents[1] MANAGED_ROOT = Path(os.environ.get("MODIFF_MANAGED_ROOT") or ROOT / ".modiff") OPTIMIZATION_ROOT = MANAGED_ROOT / "optimizations" ENVIRONMENTS_DIR = OPTIMIZATION_ROOT / "environments" STAGING_DIR = OPTIMIZATION_ROOT / "staging" +ARTIFACTS_DIR = OPTIMIZATION_ROOT / "artifacts" STATE_PATH = OPTIMIZATION_ROOT / "state.json" RECEIPTS_PATH = OPTIMIZATION_ROOT / "qualification-receipts.json" +PROMOTION_PATH = OPTIMIZATION_ROOT / "promotion.json" CATALOG_SCHEMA_VERSION = 1 -STATE_SCHEMA_VERSION = 1 +STATE_SCHEMA_VERSION = 2 RECEIPT_SCHEMA_VERSION = 1 +_CATALOG_ENVIRONMENT_LIMIT = 32 +_CATALOG_ENVIRONMENT_SCAN_LIMIT = 4096 +_CATALOG_INACTIVE_DOCUMENT_LIMIT = 2 * 1024 * 1024 +_CATALOG_PRIORITY_DOCUMENT_LIMIT = 8 * 1024 * 1024 _STATE_LOCK = threading.RLock() +class _ManagedJsonInvalid(RuntimeError): + pass + + def _now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) -def _atomic_json(path: Path, value: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(path) - +def _atomic_json(path: Path, value: dict[str, Any], *, root: Path) -> None: + trusted_root_path = ensure_managed_directory(Path(root), managed_root=MANAGED_ROOT) + trusted_root = trusted_root_path.resolve(strict=True) + parent = path.parent.resolve(strict=True) + parent.relative_to(trusted_root) + parent_info = path.parent.lstat() + if ( + not stat.S_ISDIR(parent_info.st_mode) + or stat.S_ISLNK(parent_info.st_mode) + or bool( + getattr(parent_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The managed JSON parent is unsafe.") + try: + target_info = path.lstat() + if ( + not stat.S_ISREG(target_info.st_mode) + or stat.S_ISLNK(target_info.st_mode) + or target_info.st_nlink != 1 + or bool( + getattr(target_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The managed JSON target is unsafe.") + except FileNotFoundError: + pass + body = (json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n").encode("utf-8") + if len(body) > 32 * 1024 * 1024: + raise OSError("The managed JSON document exceeds its safe size.") + temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp" + try: + with temporary.open("xb") as output: + output.write(body) + output.flush() + os.fsync(output.fileno()) + path.parent.resolve(strict=True).relative_to(trusted_root) + temporary.replace(path) + flush_managed_directory(path.parent, managed_root=MANAGED_ROOT) + finally: + temporary.unlink(missing_ok=True) + + +def _read_json( + path: Path, + fallback: dict[str, Any], + *, + root: Path | None = None, + max_bytes: int = 32 * 1024 * 1024, + reject_invalid_existing: bool = False, +) -> dict[str, Any]: + def no_duplicates(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("Duplicate JSON key") + value[key] = item + return value + + def reject_constant(_value): + raise ValueError("Non-finite JSON number") + + def invalid(): + if reject_invalid_existing: + raise _ManagedJsonInvalid("The managed JSON document is invalid.") + return deepcopy(fallback) -def _read_json(path: Path, fallback: dict[str, Any]) -> dict[str, Any]: try: - value = json.loads(path.read_text(encoding="utf-8")) - return value if isinstance(value, dict) else deepcopy(fallback) - except (OSError, TypeError, ValueError): + raw_root = Path(root or path.parent).absolute() + root_details = raw_root.lstat() + if ( + not stat.S_ISDIR(root_details.st_mode) + or stat.S_ISLNK(root_details.st_mode) + or bool( + getattr(root_details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + return invalid() + try: + raw_root.relative_to(Path(MANAGED_ROOT).absolute()) + except ValueError: + trusted_root = raw_root.resolve(strict=True) + else: + trusted_root = _verified_existing_managed_directory( + raw_root, + managed_root=MANAGED_ROOT, + ) + parent_details = path.parent.lstat() + if ( + not stat.S_ISDIR(parent_details.st_mode) + or stat.S_ISLNK(parent_details.st_mode) + or bool( + getattr(parent_details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + return invalid() + parent = path.parent.resolve(strict=True) + parent.relative_to(trusted_root) + details = path.lstat() + if ( + not stat.S_ISREG(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or details.st_nlink != 1 + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + return invalid() + resolved = path.resolve(strict=True) + resolved.relative_to(trusted_root) + if details.st_size > max_bytes: + return invalid() + raw = path.read_bytes() + if len(raw) != details.st_size: + return invalid() + value = json.loads( + raw.decode("utf-8"), + object_pairs_hook=no_duplicates, + parse_constant=reject_constant, + ) + pending = [(value, 0)] + nodes = 0 + while pending: + item, depth = pending.pop() + nodes += 1 + if nodes > 200_000 or depth > 64: + return invalid() + if isinstance(item, dict): + pending.extend((child, depth + 1) for child in item.values()) + elif isinstance(item, list): + pending.extend((child, depth + 1) for child in item) + return value if isinstance(value, dict) else invalid() + except _ManagedJsonInvalid: + raise + except (OSError, TypeError, ValueError, UnicodeDecodeError, RecursionError) as exc: + if reject_invalid_existing: + try: + path.lstat() + except FileNotFoundError: + return deepcopy(fallback) + except OSError as probe_error: + raise _ManagedJsonInvalid("The managed JSON document is inaccessible.") from probe_error + raise _ManagedJsonInvalid("The managed JSON document is invalid.") from exc return deepcopy(fallback) @@ -68,6 +250,8 @@ def _default_state() -> dict[str, Any]: "schemaVersion": STATE_SCHEMA_VERSION, "activeEnvironmentId": None, "previousEnvironmentId": None, + "activeTrustClass": None, + "previousTrustClass": None, "enabledCapabilities": [], "updatedAt": _now(), } @@ -75,54 +259,710 @@ def _default_state() -> dict[str, Any]: def read_state() -> dict[str, Any]: with _STATE_LOCK: - state = _read_json(STATE_PATH, _default_state()) - state.setdefault("schemaVersion", STATE_SCHEMA_VERSION) - state.setdefault("enabledCapabilities", []) - return state + try: + raw = _read_json( + STATE_PATH, + _default_state(), + root=OPTIMIZATION_ROOT, + reject_invalid_existing=True, + ) + storage_status = "ok" + except _ManagedJsonInvalid: + raw = _default_state() + storage_status = "repair_required" + + def environment_id(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", value) + else None + ) + + def trust_class(value): + return value if value in {"artifact_locked_optional", "legacy_optimization"} else None + + enabled = raw.get("enabledCapabilities") + if not isinstance(enabled, list): + storage_status = "repair_required" + enabled = [] + normalized_enabled = [ + item + for item in enabled + if isinstance(item, str) + and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,127}", item) + and item in _catalog() + ] + active_environment_id = environment_id(raw.get("activeEnvironmentId")) + previous_environment_id = environment_id(raw.get("previousEnvironmentId")) + active_trust_class = trust_class(raw.get("activeTrustClass")) + previous_trust_class = trust_class(raw.get("previousTrustClass")) + updated_at = raw.get("updatedAt") + expected_keys = { + "schemaVersion", + "activeEnvironmentId", + "previousEnvironmentId", + "activeTrustClass", + "previousTrustClass", + "enabledCapabilities", + "updatedAt", + } + if ( + set(raw) != expected_keys + or raw.get("schemaVersion") != STATE_SCHEMA_VERSION + or ( + raw.get("activeEnvironmentId") is not None + and active_environment_id is None + ) + or ( + raw.get("previousEnvironmentId") is not None + and previous_environment_id is None + ) + or (raw.get("activeTrustClass") is not None and active_trust_class is None) + or ( + raw.get("previousTrustClass") is not None + and previous_trust_class is None + ) + or (active_environment_id is None) != (active_trust_class is None) + or (previous_environment_id is None) != (previous_trust_class is None) + or ( + active_environment_id is not None + and active_environment_id == previous_environment_id + ) + or len(enabled) > 64 + or len(normalized_enabled) != len(enabled) + or len(set(normalized_enabled)) != len(normalized_enabled) + or _public_utc_timestamp(updated_at) is None + ): + storage_status = "repair_required" + return { + "schemaVersion": STATE_SCHEMA_VERSION, + "activeEnvironmentId": active_environment_id, + "previousEnvironmentId": previous_environment_id, + "activeTrustClass": active_trust_class, + "previousTrustClass": previous_trust_class, + "enabledCapabilities": normalized_enabled[:64], + "updatedAt": updated_at if isinstance(updated_at, str) else _now(), + "_storageStatus": storage_status, + } def _write_state(state: dict[str, Any]) -> dict[str, Any]: with _STATE_LOCK: state = deepcopy(state) + state.pop("_storageStatus", None) state["schemaVersion"] = STATE_SCHEMA_VERSION state["updatedAt"] = _now() - _atomic_json(STATE_PATH, state) + _atomic_json(STATE_PATH, state, root=OPTIMIZATION_ROOT) return state -def _safe_environment_path(environment_id: str | None) -> Path | None: - if not environment_id or not isinstance(environment_id, str): - return None - candidate = (ENVIRONMENTS_DIR / environment_id).resolve() +def _reset_state_to_base() -> dict[str, Any]: + """Repair a corrupt state file without ever following an unsafe entry.""" + + with _STATE_LOCK: + trusted_root = _verified_existing_managed_directory( + OPTIMIZATION_ROOT, + managed_root=MANAGED_ROOT, + ) + if STATE_PATH.absolute().parent != OPTIMIZATION_ROOT.absolute(): + raise OSError("The runtime state path is outside its managed root.") + state_path = trusted_root / STATE_PATH.name + try: + details = state_path.lstat() + except FileNotFoundError: + details = None + if details is not None: + reparse = bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + if stat.S_ISDIR(details.st_mode): + raise OSError("The runtime state path is an unsafe directory.") + if stat.S_ISLNK(details.st_mode) or reparse or ( + stat.S_ISREG(details.st_mode) and details.st_nlink != 1 + ): + state_path.unlink() + elif not stat.S_ISREG(details.st_mode): + raise OSError("The runtime state path is not a regular file.") + return _write_state(_default_state()) + + +def _canonical_digest(value: dict[str, Any]) -> str: + body = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") + return f"sha256:{hashlib.sha256(body).hexdigest()}" + + +def _promotion_anchor(inspection: dict[str, Any]) -> dict[str, str]: + if inspection.get("status") != "ready": + raise RuntimeError("An optional-runtime promotion candidate is not fully validated.") + manifest = inspection.get("manifest") + validation = inspection.get("validation") + if not isinstance(manifest, dict) or not isinstance(validation, dict): + raise RuntimeError("An optional-runtime promotion candidate lacks its validation records.") + return { + "manifestDigest": _canonical_digest(manifest), + "validationDigest": _canonical_digest(validation), + } + + +def _read_promotion_record() -> dict[str, Any] | None: + _verified_existing_managed_directory( + OPTIMIZATION_ROOT, + managed_root=MANAGED_ROOT, + ) + if PROMOTION_PATH.absolute().parent != OPTIMIZATION_ROOT.absolute(): + raise OSError("The optional-runtime promotion journal is outside its managed root.") try: - candidate.relative_to(ENVIRONMENTS_DIR.resolve()) - except ValueError: - return None - if not (candidate / "validation.json").is_file(): + record = _read_json( + PROMOTION_PATH, + {}, + root=OPTIMIZATION_ROOT, + max_bytes=16 * 1024, + reject_invalid_existing=True, + ) + except _ManagedJsonInvalid as exc: + raise RuntimeError("The optional-runtime promotion journal requires repair.") from exc + if not record: return None - validation = _read_json(candidate / "validation.json", {}) - if validation.get("status") != "passed": + expected_keys = { + "schemaVersion", + "environmentId", + "phase", + "manifestDigest", + "validationDigest", + "updatedAt", + } + digest = r"sha256:[0-9a-f]{64}" + if ( + set(record) != expected_keys + or record.get("schemaVersion") != 1 + or not isinstance(record.get("environmentId"), str) + or not re.fullmatch( + r"runtime-[0-9]{1,16}-[0-9a-f]{8}", + record["environmentId"], + ) + or record.get("phase") not in {"prepared", "promoted"} + or not isinstance(record.get("manifestDigest"), str) + or not re.fullmatch(digest, record["manifestDigest"]) + or not isinstance(record.get("validationDigest"), str) + or not re.fullmatch(digest, record["validationDigest"]) + or _public_utc_timestamp(record.get("updatedAt")) is None + ): + raise RuntimeError("The optional-runtime promotion journal requires repair.") + return record + + +def _write_promotion_record( + environment_id: str, + *, + phase: str, + anchor: dict[str, str], +) -> dict[str, Any]: + record = { + "schemaVersion": 1, + "environmentId": environment_id, + "phase": phase, + "manifestDigest": anchor["manifestDigest"], + "validationDigest": anchor["validationDigest"], + "updatedAt": _now(), + } + _atomic_json(PROMOTION_PATH, record, root=OPTIMIZATION_ROOT) + return record + + +def _promotion_matches(record: dict[str, Any], inspection: dict[str, Any]) -> bool: + try: + anchor = _promotion_anchor(inspection) + except RuntimeError: + return False + return all(anchor[key] == record[key] for key in ("manifestDigest", "validationDigest")) + + +def _clear_promotion_record() -> None: + remove_managed_file( + PROMOTION_PATH, + parent=OPTIMIZATION_ROOT, + managed_root=MANAGED_ROOT, + ) + + +def _reconcile_promotion(lease: InstallLease) -> str | None: + """Complete or acknowledge one exact interrupted promotion under the install lease.""" + + record = _read_promotion_record() + if record is None: return None + environment_id = record["environmentId"] + staged = STAGING_DIR / environment_id + destination = ENVIRONMENTS_DIR / environment_id + + def entry_exists(path: Path) -> bool: + try: + path.lstat() + except FileNotFoundError: + return False + return True + + staged_exists = entry_exists(staged) + destination_exists = entry_exists(destination) + if staged_exists == destination_exists: + raise RuntimeError("The optional-runtime promotion journal has an ambiguous filesystem state.") + if destination_exists: + inspection = _environment_inspection( + environment_id, + environment_root=_verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, + ), + ) + if not _promotion_matches(record, inspection): + raise RuntimeError("The promoted optional runtime no longer matches its durable journal.") + if record["phase"] == "prepared": + _write_promotion_record(environment_id, phase="promoted", anchor=record) + _clear_promotion_record() + return environment_id + if record["phase"] != "prepared": + raise RuntimeError("The optional-runtime promotion journal is missing its promoted environment.") + inspection = _environment_inspection( + environment_id, + environment_root=_verified_existing_managed_directory( + STAGING_DIR, + managed_root=MANAGED_ROOT, + ), + ) + if not _promotion_matches(record, inspection): + raise RuntimeError("The staged optional runtime no longer matches its durable journal.") + lease.staged_identity = managed_directory_identity(staged) + promote_staged_environment(lease, staged, destination) + promoted = _environment_inspection( + environment_id, + environment_root=_verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, + ), + ) + if not _promotion_matches(record, promoted): + raise RuntimeError("The recovered optional runtime failed its post-promotion identity check.") + _write_promotion_record(environment_id, phase="promoted", anchor=record) + _clear_promotion_record() + return environment_id + + +def _optimization_spec(capability_id: str) -> dict[str, Any]: + capability = _catalog().get(capability_id) + if capability is None: + raise ValueError(f"Unknown optimization capability {capability_id!r}.") + base_packages = [ + {"distribution": "packaging", "importName": "packaging", "specifier": ">=20.0"}, + {"distribution": "numpy", "importName": "numpy", "specifier": ">=1.17"}, + {"distribution": "torch", "importName": "torch", "specifier": ">=2.6.0"}, + { + "distribution": "huggingface-hub", + "importName": "huggingface_hub", + "specifier": ">=1.23.0,<2.0", + }, + ] + spec = { + "schemaVersion": 1, + "kind": "optimization", + "id": capability_id, + "basePackages": base_packages, + **deepcopy(capability), + } + return {"kind": "optimization", "id": capability_id, "spec": spec, "specDigest": _canonical_digest(spec)} + + +def _optional_runtime_spec(profile_id: str) -> dict[str, Any]: + try: + profile = OPTIONAL_RUNTIME_PROFILES[profile_id] + except KeyError as exc: + raise ValueError(f"Unknown optional runtime profile {profile_id!r}.") from exc + spec = profile.to_spec_dict() + return { + "kind": "optional_runtime", + "id": profile_id, + "spec": spec, + "specDigest": profile.spec_digest, + } + + +def _spec_is_current(record: dict[str, Any]) -> bool: + try: + kind = str(record.get("kind") or "") + identifier = str(record.get("id") or "") + current = ( + _optional_runtime_spec(identifier) + if kind == "optional_runtime" + else _optimization_spec(identifier) + if kind == "optimization" + else None + ) + return bool( + current + and record.get("specDigest") == current["specDigest"] + and record.get("spec") == current["spec"] + and (kind != "optional_runtime" or current["spec"].get("activationAvailable") is True) + ) + except (KeyError, TypeError, ValueError): + return False + + +def _optimization_package_contracts(capability_id: str) -> list[dict[str, Any]]: + capability = _catalog().get(capability_id) or {} + requirements = [ + str(value) + for value in [*(capability.get("buildPackages") or []), *(capability.get("packages") or [])] + ] + contracts = [] + seen = set() + primary = str(capability.get("distribution") or "").lower().replace("_", "-") + for requirement in requirements: + if "==" not in requirement or requirement.count("==") != 1: + raise RuntimeError("Optimization package requirements must be exact reviewed pins.") + distribution, version = requirement.split("==", 1) + normalized = distribution.lower().replace("_", "-") + if not normalized or not version or normalized in seen: + continue + seen.add(normalized) + contracts.append( + { + "distribution": normalized, + "importName": ( + str(capability.get("importName")) + if normalized == primary and capability.get("importName") + else normalized.replace("-", "_") + ), + "requiredVersion": version, + "requirement": f"{distribution}=={version}", + "requiredSymbols": [], + "role": "optimization_dependency" if normalized != primary else "optimization_root", + } + ) + return contracts + + +def _expected_contracts_for_specs( + specs: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + package_contracts: list[dict[str, Any]] = [] + base_contracts: list[dict[str, Any]] = [] + for record in specs: + if not _spec_is_current(record): + raise RuntimeError("An overlay executable spec is stale or unavailable.") + if record["kind"] == "optional_runtime": + profile = OPTIONAL_RUNTIME_PROFILES[record["id"]] + package_contracts = _merge_contracts( + package_contracts, + [package.to_spec_dict() for package in profile.packages], + ) + base_contracts = _merge_contracts( + base_contracts, + list(optional_runtime_base_contracts([profile.id])), + ) + else: + package_contracts = _merge_contracts( + package_contracts, + _optimization_package_contracts(record["id"]), + ) + base_contracts = _merge_contracts( + base_contracts, + list(record["spec"].get("basePackages") or []), + ) + return package_contracts, base_contracts + + +def _verified_existing_managed_directory(path: Path, *, managed_root: Path) -> Path: + """Resolve an existing managed directory without following reparse components.""" + + raw_root = Path(managed_root).absolute() + root_details = raw_root.lstat() + if ( + not stat.S_ISDIR(root_details.st_mode) + or stat.S_ISLNK(root_details.st_mode) + or bool( + getattr(root_details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The managed runtime root is unsafe.") + trusted_root = raw_root.resolve(strict=True) + raw_path = Path(path).absolute() + relative = raw_path.relative_to(raw_root) + current = raw_root + for component in relative.parts: + current = current / component + details = current.lstat() + if ( + not stat.S_ISDIR(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("A managed runtime directory component is unsafe.") + current.resolve(strict=True).relative_to(trusted_root) + return current.resolve(strict=True) + + +def _environment_inspection( + environment_id: str | None, + *, + verify_integrity: bool = True, + document_max_bytes: int = 32 * 1024 * 1024, + environment_root: Path | None = None, +) -> dict[str, Any]: + if not isinstance(environment_id, str) or not environment_id or len(environment_id) > 128: + return {"status": "repair_required", "reason": "invalid_environment_id"} + if any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" for character in environment_id): + return {"status": "repair_required", "reason": "invalid_environment_id"} + try: + trusted_environments = environment_root or _verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, + ) + except (FileNotFoundError, OSError, RuntimeError, ValueError): + return {"status": "repair_required", "reason": "environment_root_unsafe"} + raw_candidate = trusted_environments / environment_id + try: + raw_info = raw_candidate.lstat() + except OSError: + return {"status": "repair_required", "reason": "environment_missing"} + if not stat.S_ISDIR(raw_info.st_mode) or stat.S_ISLNK(raw_info.st_mode) or ( + getattr(raw_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ): + return {"status": "repair_required", "reason": "environment_link_or_type"} + candidate = raw_candidate.resolve() + try: + candidate.relative_to(trusted_environments) + except ValueError: + return {"status": "repair_required", "reason": "environment_escape"} + manifest = _read_json( + candidate / "manifest.json", + {}, + root=candidate, + max_bytes=document_max_bytes, + ) + validation = _read_json( + candidate / "validation.json", + {}, + root=candidate, + max_bytes=document_max_bytes, + ) site_packages = candidate / "site-packages" - return site_packages if site_packages.is_dir() else None + manifest_specs = manifest.get("specs") + if ( + not isinstance(manifest_specs, list) + or not manifest_specs + or len(manifest_specs) > 32 + or not all(isinstance(record, dict) for record in manifest_specs) + ): + return {"status": "repair_required", "reason": "stale_executable_spec"} + try: + expected_packages, expected_base = _expected_contracts_for_specs(manifest_specs) + except (AttributeError, KeyError, RuntimeError, TypeError, ValueError): + return {"status": "repair_required", "reason": "stale_executable_spec"} + expected_spec_ids = [ + {"kind": item["kind"], "id": item["id"], "specDigest": item["specDigest"]} + for item in manifest_specs + ] + spec_kinds = {str(item.get("kind") or "") for item in manifest_specs} + expected_trust_class = ( + "artifact_locked_optional" + if spec_kinds == {"optional_runtime"} + else "legacy_optimization" + if spec_kinds == {"optimization"} + else None + ) + detail = validation.get("detail") if isinstance(validation.get("detail"), dict) else {} + observed_packages = sorted( + (str(item.get("distribution")), str(item.get("version"))) + for item in detail.get("packages") or [] + if isinstance(item, dict) + ) + exact_packages = sorted( + (str(item.get("distribution")), str(item.get("requiredVersion"))) + for item in expected_packages + ) + if ( + manifest.get("schemaVersion") != 2 + or manifest.get("kind") != "runtime_overlay" + or manifest.get("id") != environment_id + or expected_trust_class is None + or manifest.get("trustClass") != expected_trust_class + or validation.get("status") != "passed" + or validation.get("schemaVersion") != 2 + or validation.get("environmentId") != environment_id + or validation.get("specs") != expected_spec_ids + or detail.get("status") != "passed" + or observed_packages != exact_packages + or not all(_spec_is_current(record) for record in manifest_specs) + or manifest.get("packageContracts") != expected_packages + or manifest.get("basePackageContracts") != expected_base + or not isinstance(validation.get("binding"), dict) + or validation.get("bindingDigest") != binding_digest(validation.get("binding")) + or not isinstance(validation.get("fileSeal"), dict) + or not validation.get("fileSeal") + ): + return {"status": "repair_required", "reason": "missing_or_stale_validation"} + expected_distributions = [ + str(package.get("distribution") or "") for package in manifest["packageContracts"] + ] + expected_artifacts: list[dict[str, Any]] = [] + if expected_trust_class == "artifact_locked_optional": + try: + for record in manifest_specs: + expected_artifacts.extend( + _artifact_install_plan(OPTIONAL_RUNTIME_PROFILES[record["id"]]) + ) + except (KeyError, RuntimeError, TypeError, ValueError): + return {"status": "repair_required", "reason": "artifact_lock_unavailable"} + if manifest.get("artifactLocks") != expected_artifacts: + return {"status": "repair_required", "reason": "artifact_lock_drift"} + if ( + validation.get("trustClass") != expected_trust_class + or not isinstance(manifest.get("artifactAnchorDigest"), str) + or validation.get("artifactAnchorDigest") != manifest.get("artifactAnchorDigest") + ): + return {"status": "repair_required", "reason": "artifact_anchor_missing"} + elif manifest.get("artifactLocks") not in ([], None) or validation.get("artifactAnchorDigest") is not None: + return {"status": "repair_required", "reason": "legacy_artifact_claim"} + if not verify_integrity: + return { + "status": "recorded", + "sitePackages": site_packages, + "manifest": manifest, + "validation": validation, + } + if not binding_matches(validation["binding"], manifest["basePackageContracts"]): + return {"status": "repair_required", "reason": "host_binding_drift"} + if expected_trust_class == "artifact_locked_optional": + try: + artifact_anchor = verify_artifact_anchored_overlay( + site_packages, + expected_artifacts, + ARTIFACTS_DIR, + ) + except (OSError, RuntimeError, TypeError, ValueError): + return {"status": "repair_required", "reason": "artifact_anchor_drift"} + if ( + artifact_anchor.get("digest") != manifest.get("artifactAnchorDigest") + or artifact_anchor.get("fileSeal") != validation.get("fileSeal") + ): + return {"status": "repair_required", "reason": "artifact_anchor_drift"} + elif not overlay_file_seal_matches( + site_packages, expected_distributions, validation["fileSeal"] + ): + return {"status": "repair_required", "reason": "overlay_file_drift"} + return { + "status": "ready", + "sitePackages": site_packages, + "manifest": manifest, + "validation": validation, + } + + +def _fresh_validation_matches(inspection: dict[str, Any], lease: InstallLease) -> bool: + manifest = inspection["manifest"] + binding = current_base_binding(manifest["basePackageContracts"]) + trusted_file_seal = None + if manifest.get("trustClass") == "artifact_locked_optional": + anchor = verify_artifact_anchored_overlay( + inspection["sitePackages"], + manifest["artifactLocks"], + ARTIFACTS_DIR, + lease=lease, + ) + if anchor.get("digest") != manifest.get("artifactAnchorDigest"): + return False + trusted_file_seal = anchor["fileSeal"] + validation = run_fresh_validation( + inspection["sitePackages"], + packages=manifest["packageContracts"], + binding=binding, + specs=manifest["specs"], + lease=lease, + trusted_file_seal=trusted_file_seal, + ) + persisted = inspection["validation"] + return bool( + validation.get("status") == "passed" + and validation.get("bindingDigest") == persisted.get("bindingDigest") + and validation.get("fileSeal") == persisted.get("fileSeal") + and validation.get("detail") == persisted.get("detail") + ) + + +def _safe_environment_path( + environment_id: str | None, + lease: InstallLease, + *, + expected_trust_class: str, +) -> Path | None: + inspection = _environment_inspection(environment_id) + if ( + inspection.get("status") != "ready" + or inspection.get("manifest", {}).get("trustClass") != expected_trust_class + ): + return None + return inspection["sitePackages"] if _fresh_validation_matches(inspection, lease) else None def activate_runtime_overlay() -> str | None: """Add the validated active overlay before importing heavyweight modules.""" - state = read_state() - environment_id = state.get("activeEnvironmentId") - site_packages = _safe_environment_path(environment_id) - if site_packages is None: - return None - site.addsitedir(str(site_packages)) - # addsitedir appends; optional packages must win over incompatible packages - # from the base environment after the overlay passed the core import probe. - normalized = str(site_packages) - if normalized in sys.path: - sys.path.remove(normalized) - sys.path.insert(0, normalized) - os.environ["MODIFF_OPTIMIZATION_ENVIRONMENT"] = str(environment_id) - return str(environment_id) + deadline = time.monotonic() + 5.0 + while True: + try: + lease = reserve_install("startup_activation", "active_environment") + break + except OverlayInstallBusy: + if time.monotonic() >= deadline: + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "busy_recovery_only" + return None + time.sleep(0.05) + except (OSError, RuntimeError, ValueError): + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + return None + try: + _reconcile_promotion(lease) + state = read_state() + if state.get("_storageStatus") != "ok": + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + return None + environment_id = state.get("activeEnvironmentId") + if environment_id is None: + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "base" + return None + try: + active_trust_class = state.get("activeTrustClass") + if active_trust_class != "artifact_locked_optional": + site_packages = None + else: + site_packages = _safe_environment_path( + environment_id, + lease, + expected_trust_class=active_trust_class, + ) + except (OSError, RuntimeError, TypeError, ValueError): + site_packages = None + if site_packages is None: + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + return None + # Never use site.addsitedir: wheel-supplied .pth files can execute + # code. Hold the cross-process lease through the final plain-path + # insertion so state cannot race validation. + sys.dont_write_bytecode = True + normalized = str(site_packages) + if normalized in sys.path: + sys.path.remove(normalized) + sys.path.insert(0, normalized) + os.environ["MODIFF_OPTIMIZATION_ENVIRONMENT"] = str(environment_id) + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "active" + return str(environment_id) + finally: + release_install(lease) def _catalog() -> dict[str, dict[str, Any]]: @@ -324,6 +1164,84 @@ def _profile_id(runtime_profile: dict[str, Any] | None) -> str: ) +def _catalog_environment_ids( + state: dict[str, Any], environment_root: Path +) -> tuple[list[str], dict[str, Any]]: + """Return a bounded view that never drops the active or rollback target.""" + + priority: list[str] = [] + for key in ("activeEnvironmentId", "previousEnvironmentId"): + value = state.get(key) + if isinstance(value, str) and value not in priority: + priority.append(value) + + discovered: list[str] = [] + observed = 0 + scan_truncated = False + try: + with os.scandir(environment_root) as entries: + for entry in entries: + observed += 1 + if observed > _CATALOG_ENVIRONMENT_SCAN_LIMIT: + scan_truncated = True + break + name = str(entry.name) + if ( + name in priority + or len(name) > 128 + or not name + or any( + character + not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + for character in name + ) + ): + continue + discovered.append(name) + except OSError: + discovered = [] + + remaining = max(0, _CATALOG_ENVIRONMENT_LIMIT - len(priority)) + selected = [*priority, *sorted(set(discovered))[:remaining]] + omitted_by_limit = len(set(discovered)) > remaining + return selected, { + "returned": len(selected), + "observed": min(observed, _CATALOG_ENVIRONMENT_SCAN_LIMIT), + "limit": _CATALOG_ENVIRONMENT_LIMIT, + "scanLimit": _CATALOG_ENVIRONMENT_SCAN_LIMIT, + "truncated": bool(scan_truncated or omitted_by_limit), + } + + +def _public_environment_id(value: Any) -> str | None: + return ( + value + if isinstance(value, str) + and re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", value) + else None + ) + + +def _public_utc_timestamp(value: Any) -> str | None: + if not isinstance(value, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", value + ): + return None + try: + parsed = time.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + return value if time.strftime("%Y-%m-%dT%H:%M:%SZ", parsed) == value else None + + +def _public_sha256_digest(value: Any) -> str | None: + return ( + value + if isinstance(value, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", value) + else None + ) + + def public_catalog( *, runtime_profile: dict[str, Any] | None = None, @@ -397,35 +1315,122 @@ def public_catalog( "requiresRestart": item.get("kind") == "package", } ) + if item.get("kind") == "package": + item["canInstall"] = False + item["canEnable"] = False + item["disabledReason"] = ( + "This legacy package profile has no reviewed immutable artifact lock and remains unqualified." + ) if item.get("kind") == "external": item["disabledReason"] = ( reason or "No generally safe app-managed wheel matches every supported ROCm device and Torch ABI." ) capabilities.append(item) environments = [] - if ENVIRONMENTS_DIR.is_dir(): - for environment in sorted(ENVIRONMENTS_DIR.iterdir()): - if not environment.is_dir(): - continue - manifest = _read_json(environment / "manifest.json", {}) - validation = _read_json(environment / "validation.json", {}) + environment_scan = { + "returned": 0, + "observed": 0, + "limit": _CATALOG_ENVIRONMENT_LIMIT, + "scanLimit": _CATALOG_ENVIRONMENT_SCAN_LIMIT, + "truncated": False, + } + try: + catalog_environment_root = _verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, + ) + except (FileNotFoundError, OSError, RuntimeError, ValueError): + catalog_environment_root = None + if catalog_environment_root is not None: + environment_ids, environment_scan = _catalog_environment_ids( + state, catalog_environment_root + ) + priority_ids = { + state.get("activeEnvironmentId"), + state.get("previousEnvironmentId"), + } + for environment_id in environment_ids: + inspection = _environment_inspection( + environment_id, + verify_integrity=False, + document_max_bytes=( + _CATALOG_PRIORITY_DOCUMENT_LIMIT + if environment_id in priority_ids + else _CATALOG_INACTIVE_DOCUMENT_LIMIT + ), + environment_root=catalog_environment_root, + ) + manifest = inspection.get("manifest") if inspection.get("status") in {"ready", "recorded"} else {} + validation = inspection.get("validation") if inspection.get("status") in {"ready", "recorded"} else {} + public_environment_status = ( + "legacy_unqualified" + if (manifest or {}).get("trustClass") == "legacy_optimization" + else inspection.get("status") + ) + specs = (manifest or {}).get("specs") + capabilities_from_specs = [ + str(item.get("id")) + for item in specs or [] + if isinstance(item, dict) + and item.get("kind") == "optimization" + and str(item.get("id")) in _catalog() + ] environments.append( { - "id": environment.name, - "createdAt": manifest.get("createdAt"), - "capabilities": manifest.get("capabilities") or [], - "validation": validation, - "active": environment.name == state.get("activeEnvironmentId"), + "id": _public_environment_id(environment_id), + "createdAt": _public_utc_timestamp((manifest or {}).get("createdAt")), + "capabilities": [ + str(item)[:128] + for item in capabilities_from_specs + if str(item) in _catalog() + ][:32], + "validation": { + "status": ( + validation.get("status") + if validation.get("status") in {"passed", "failed"} + else None + ), + "validatedAt": _public_utc_timestamp(validation.get("validatedAt")), + "bindingDigest": _public_sha256_digest(validation.get("bindingDigest")), + }, + "status": public_environment_status, + "active": environment_id == state.get("activeEnvironmentId"), + "activationAvailable": False, } ) receipts = read_receipts().get("receipts") or [] + public_state = { + "schemaVersion": STATE_SCHEMA_VERSION, + "activeEnvironmentId": ( + _public_environment_id(state.get("activeEnvironmentId")) + ), + "previousEnvironmentId": ( + _public_environment_id(state.get("previousEnvironmentId")) + ), + "enabledCapabilities": sorted( + item for item in enabled if item in _catalog() + ), + } + process_load_status = os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS") + if state.get("_storageStatus") != "ok": + process_load_status = "repair_required" + elif process_load_status not in { + "base", + "active", + "busy_recovery_only", + "repair_required", + "restart_required", + }: + process_load_status = "base" return { "schemaVersion": CATALOG_SCHEMA_VERSION, - "state": state, + "processLoadStatus": process_load_status, + "state": public_state, "profile": profile_id, "platform": os_name, "capabilities": capabilities, "environments": environments, + "environmentScan": environment_scan, "qualification": { "receiptCount": len(receipts), "qualifiedCount": sum(1 for item in receipts if item.get("status") == "qualified"), @@ -434,262 +1439,860 @@ def public_catalog( } -def _uv_executable() -> str: - managed = MANAGED_ROOT / "tools" / "uv" - candidates = [managed / "uv.exe", managed / "uv", managed] - if managed.is_dir(): - candidates.extend(sorted(managed.rglob("uv.exe"))) - candidates.extend(sorted(managed.rglob("uv"))) - for candidate in candidates: - if candidate.is_file(): - return str(candidate) - uv = shutil.which("uv") - if uv: - return uv - raise RuntimeError("MoDiff's managed uv installer is unavailable. Run the normal MoDiff setup repair first.") +def _verified_uv_executable() -> str: + tool_root = _verified_existing_managed_directory( + MANAGED_ROOT / "tools" / "uv", + managed_root=MANAGED_ROOT, + ) + receipt = _read_json(tool_root / "receipt.json", {}, root=tool_root) + machine = _machine_name() + reviewed = UV_TOOL_LOCKS.get((_platform_name(), machine)) + if not reviewed: + raise RuntimeError( + "MoDiff has no reviewed immutable uv executable lock for this platform." + ) + expected_archive = reviewed.get("archiveSha256") + expected_executable = reviewed.get("executableSha256") + relative = receipt.get("executable") + if ( + receipt.get("schemaVersion") != 1 + or receipt.get("archiveSha256") != expected_archive + or not isinstance(relative, str) + or not relative + or len(relative) > 256 + ): + raise RuntimeError("MoDiff's managed uv executable has no verified receipt.") + executable = (tool_root / relative).resolve(strict=True) + try: + executable.relative_to(tool_root) + except ValueError as exc: + raise RuntimeError("The managed uv receipt escapes its tool directory.") from exc + if not executable.is_file() or executable.is_symlink(): + raise RuntimeError("The managed uv executable is unavailable or linked.") + hasher = hashlib.sha256() + with executable.open("rb") as source: + while chunk := source.read(1024 * 1024): + hasher.update(chunk) + if ( + not isinstance(expected_executable, str) + or len(expected_executable) != 64 + or hasher.hexdigest() != expected_executable + or receipt.get("executableSha256") != expected_executable + ): + raise RuntimeError("The managed uv executable failed its integrity check.") + return str(executable) -def _validation_environment(site_packages: Path) -> dict[str, str]: - environment = os.environ.copy() - current = environment.get("PYTHONPATH") - environment["PYTHONPATH"] = os.pathsep.join([str(site_packages), *([current] if current else [])]) - return environment +def _platform_name() -> str: + if sys.platform.startswith("win"): + return "windows" + if sys.platform == "darwin": + return "macos" + return "linux" -def _run_validation(site_packages: Path, capability_ids: list[str], timeout: int = 180) -> dict[str, Any]: - catalog = _catalog() - imports = [str(catalog[item]["importName"]) for item in capability_ids if catalog[item].get("importName")] - script = """ -import importlib -import json -import torch -import diffusers -results = {} -for name in json.loads(__IMPORTS__): +def _machine_name() -> str: + machine = platform.machine().strip().lower().replace("-", "_") + return {"amd64": "x86_64", "aarch64": "arm64"}.get(machine, machine) + + +def _artifact_install_plan(profile) -> list[dict[str, Any]]: + """Resolve one complete, immutable wheel set for this Python/platform.""" + + platform_name = _platform_name() + python_tag = f"cp{sys.version_info.major}{sys.version_info.minor}" + machine = _machine_name() + expected = {package.distribution: package.version for package in profile.packages} + from packaging.tags import sys_tags + from packaging.utils import canonicalize_name, parse_wheel_filename + + supported_tags = set(sys_tags()) + selected: dict[str, dict[str, Any]] = {} + for artifact in profile.artifact_locks: + if not isinstance(artifact, dict): + continue + artifact_machine = str(artifact.get("machine") or "").strip().lower().replace("-", "_") + if ( + artifact.get("platform") != platform_name + or artifact.get("pythonTag") != python_tag + or artifact_machine not in {machine, "any"} + ): + continue + distribution = str(artifact.get("distribution") or "").lower().replace("_", "-") + version = str(artifact.get("version") or "") + filename = str(artifact.get("filename") or "") + url = str(artifact.get("url") or "") + digest = str(artifact.get("sha256") or "").lower() + byte_size = artifact.get("byteSize") + parsed = urlparse(url) + try: + port = parsed.port + except ValueError as exc: + raise RuntimeError("The optional-runtime artifact URL has an invalid port.") from exc + try: + wheel_name, wheel_version, _build, wheel_tags = parse_wheel_filename(filename) + except ValueError as exc: + raise RuntimeError("The optional-runtime artifact filename is not a valid wheel.") from exc + if not set(wheel_tags).intersection(supported_tags): + continue + if ( + distribution not in expected + or version != expected[distribution] + or distribution in selected + or not filename.endswith(".whl") + or parsed.scheme != "https" + or parsed.hostname != "files.pythonhosted.org" + or parsed.username is not None + or parsed.password is not None + or port is not None + or bool(parsed.query) + or bool(parsed.fragment) + or Path(parsed.path).name != filename + or canonicalize_name(wheel_name) != canonicalize_name(distribution) + or str(wheel_version) != version + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + or isinstance(byte_size, bool) + or not isinstance(byte_size, int) + or byte_size <= 0 + or byte_size > 512 * 1024**2 + ): + raise RuntimeError("The optional-runtime artifact lock is invalid for this platform.") + selected[distribution] = { + "distribution": distribution, + "version": version, + "filename": filename, + "url": url, + "sha256": digest, + "byteSize": byte_size, + "platform": platform_name, + "pythonTag": python_tag, + "machine": artifact_machine, + } + if set(selected) != set(expected): + raise RuntimeError("The optional-runtime artifact lock is incomplete for this Python and platform.") + return [selected[package.distribution] for package in profile.packages] + + +def _artifact_install_urls(profile) -> list[str]: + """Compatibility projection used by contract tests and diagnostics.""" + + return [f"{item['url']}#sha256={item['sha256']}" for item in _artifact_install_plan(profile)] + + +def validate_optional_runtime_install_request( + profile_id: str, + spec_digest: str, + *, + consent: Any, +) -> dict[str, Any]: + """Fail closed before a lease, staging directory, or subprocess exists.""" + + if not isinstance(profile_id, str) or not profile_id or len(profile_id) > 256: + raise ValueError("A bounded optional runtime profileId is required.") + if not isinstance(spec_digest, str) or len(spec_digest) != 71: + raise ValueError("An exact optional runtime specDigest is required.") + if consent is not True: + raise ValueError("Explicit consent=true is required to install an optional runtime.") + spec = _optional_runtime_spec(profile_id) + profile = OPTIONAL_RUNTIME_PROFILES[profile_id] + if spec_digest != spec["specDigest"]: + raise ValueError("The optional runtime specDigest does not match the reviewed catalog.") + if profile.install_action_available is not True: + raise RuntimeError("This optional runtime is not qualified for installation.") + base_contracts = list(optional_runtime_base_contracts([profile_id])) + # Establish constraints, exact observed versions/origins, accelerator lock, + # and Diffusers identity before a lease or staging directory can exist. + current_base_binding(base_contracts) + artifacts = _artifact_install_plan(profile) + installer = _verified_uv_executable() + return { + "profile": profile, + "spec": spec, + "artifactLocks": artifacts, + "baseContracts": base_contracts, + "installerExecutable": installer, + } + + +def validate_optional_runtime_activation_request( + profile_id: str, + spec_digest: str, + *, + consent: Any, +) -> dict[str, Any]: + if consent is not True: + raise ValueError("Explicit consent=true is required to activate an optional runtime.") + spec = _optional_runtime_spec(profile_id) + profile = OPTIONAL_RUNTIME_PROFILES[profile_id] + if spec_digest != spec["specDigest"]: + raise ValueError("The optional runtime specDigest does not match the reviewed catalog.") + if profile.activation_available is not True: + raise RuntimeError("This optional runtime is not qualified for activation.") + return spec + + +def _merge_contracts(existing: list[dict[str, Any]], additions: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged = {str(item.get("distribution") or ""): deepcopy(item) for item in existing} + for contract in additions: + distribution = str(contract.get("distribution") or "") + previous = merged.get(distribution) + if previous is None: + merged[distribution] = deepcopy(contract) + continue + if previous == contract: + continue + # Base-owned requirements from two reviewed specs may legitimately + # narrow the same distribution. Preserve one import identity and form + # the deterministic intersection of both specifier sets. Staged wheel + # contracts remain exact and may never be merged this way. + if ( + "requirement" not in previous + and "requirement" not in contract + and previous.get("importName") == contract.get("importName") + and set(previous) <= {"distribution", "importName", "specifier", "platforms"} + and set(contract) <= {"distribution", "importName", "specifier", "platforms"} + and previous.get("platforms") == contract.get("platforms") + ): + constraints = { + item.strip() + for value in (previous.get("specifier"), contract.get("specifier")) + for item in str(value or "").split(",") + if item.strip() + } + combined = deepcopy(previous) + combined["specifier"] = ",".join(sorted(constraints)) + merged[distribution] = combined + continue + raise RuntimeError(f"Overlay package contract conflict for {distribution!r}.") + return [merged[name] for name in sorted(merged)] + + +def _locked_requirements_body( + artifacts: list[dict[str, Any]], + cached_artifacts: list[Path], +) -> bytes: + if len(artifacts) != len(cached_artifacts) or not artifacts: + raise RuntimeError("A complete locked requirements set is required.") + lines = [ + f"{artifact['distribution']} @ {path.resolve(strict=True).as_uri()} --hash=sha256:{artifact['sha256']}" + for artifact, path in zip(artifacts, cached_artifacts, strict=True) + ] + body = ("\n".join(lines) + "\n").encode("utf-8") + if len(body) > 64 * 1024: + raise RuntimeError("The locked optional-runtime requirements document is oversized.") + return body + + +def _install_reviewed_overlay( + *, + spec: dict[str, Any], + package_contracts: list[dict[str, Any]], + base_contracts: list[dict[str, Any]], + install_urls: list[str], + lease: InstallLease, + progress: Callable[[dict[str, Any]], None] | None, + hash_locked: bool = True, + installer_executable: str | None = None, + artifact_locks: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + def report(phase: str, message: str) -> None: + if progress: + progress({"phase": phase, "message": message, "updatedAt": _now()}) + + recovered_environment = _reconcile_promotion(lease) + if recovered_environment is not None: + raise RuntimeError( + "An interrupted optional-runtime promotion was recovered. Retry the requested operation." + ) + state = read_state() + if state.get("_storageStatus") != "ok": + raise RuntimeError("Repair or reset the corrupt runtime state before installing an overlay.") + if not installer_executable: + raise RuntimeError("A source-controlled verified installer executable is required.") + environment_id = f"runtime-{int(time.time())}-{uuid.uuid4().hex[:8]}" + staged = STAGING_DIR / environment_id + destination = ENVIRONMENTS_DIR / environment_id + site_packages = staged / "site-packages" try: - module = importlib.import_module(name) - results[name] = {"ok": True, "version": str(getattr(module, "__version__", "unknown"))} - except Exception as exc: - results[name] = {"ok": False, "error": f"{type(exc).__name__}: {exc}"} -try: - from diffusers.models.attention_dispatch import AttentionBackendName - attention_backends = {str(item.value) for item in AttentionBackendName} -except Exception: - attention_backends = set() -required_attention = { - "hub_attention_kernels": "flash_hub", - "flash_attention_2": "flash", - "sage_attention": "sage", - "xformers": "xformers", - "aiter": "aiter", -} -for capability, backend in required_attention.items(): - if capability in json.loads(__CAPABILITIES__) and backend not in attention_backends: - results[f"diffusers:{backend}"] = { - "ok": False, - "error": f"Diffusers does not expose the required {backend!r} attention backend", + ensure_managed_directory(OPTIMIZATION_ROOT, managed_root=MANAGED_ROOT) + ensure_managed_directory(STAGING_DIR, managed_root=MANAGED_ROOT) + ensure_managed_directory(ENVIRONMENTS_DIR, managed_root=MANAGED_ROOT) + staged.mkdir(parents=False, exist_ok=False) + staged_info = staged.lstat() + if not stat.S_ISDIR(staged_info.st_mode) or stat.S_ISLNK(staged_info.st_mode): + raise RuntimeError("The optional-runtime staging directory is unsafe.") + staged.resolve(strict=True).relative_to(STAGING_DIR.resolve(strict=True)) + lease.staged_identity = managed_directory_identity(staged) + specs: list[dict[str, Any]] = [] + existing_packages: list[dict[str, Any]] = [] + existing_base: list[dict[str, Any]] = [] + active = _environment_inspection(state.get("activeEnvironmentId")) + requested_trust_class = "artifact_locked_optional" if hash_locked else "legacy_optimization" + if ( + active.get("status") == "ready" + and not hash_locked + and active.get("manifest", {}).get("trustClass") == requested_trust_class + and _fresh_validation_matches(active, lease) + ): + report("copying", "Copying the last validated optional environment.") + shutil.copytree(active["sitePackages"], site_packages) + active_manifest = active["manifest"] + specs = deepcopy(active_manifest.get("specs") or []) + existing_packages = deepcopy(active_manifest.get("packageContracts") or []) + existing_base = deepcopy(active_manifest.get("basePackageContracts") or []) + else: + site_packages.mkdir() + specs = [ + item + for item in specs + if not (item.get("kind") == spec["kind"] and item.get("id") == spec["id"]) + ] + specs.append(deepcopy(spec)) + packages = _merge_contracts(existing_packages, package_contracts) + base_packages = _merge_contracts(existing_base, base_contracts) + binding = current_base_binding(base_packages) + selected_artifacts = [dict(item) for item in (artifact_locks or [])] + if hash_locked: + if spec.get("kind") != "optional_runtime" or not selected_artifacts: + raise RuntimeError("A locked optional runtime requires a complete artifact plan.") + # Optional and hashless legacy overlays are deliberately separate + # trust classes. Never carry legacy bytes into an authenticated + # optional environment. + if any(item.get("kind") != "optional_runtime" for item in specs): + raise RuntimeError("Hashless legacy packages cannot be mixed into an optional runtime.") + ensure_managed_directory(ARTIFACTS_DIR, managed_root=MANAGED_ROOT) + cached_artifacts = cache_locked_artifacts( + selected_artifacts, + ARTIFACTS_DIR, + lease=lease, + ) + # Authenticate and structurally inspect every wheel before giving + # any archive to the installer/extractor. + locked_artifact_file_seal( + selected_artifacts, + ARTIFACTS_DIR, + lease=lease, + ) + requirements_body = _locked_requirements_body( + selected_artifacts, + cached_artifacts, + ) + requirements_path = staged / "locked-requirements.txt" + with requirements_path.open("xb") as output: + output.write(requirements_body) + output.flush() + os.fsync(output.fileno()) + flush_managed_directory(staged, managed_root=MANAGED_ROOT) + effective_install_urls = [] + else: + if any(item.get("kind") != "optimization" for item in specs): + raise RuntimeError("Optional-runtime packages cannot be mixed into a legacy overlay.") + effective_install_urls = list(install_urls) + manifest = { + "schemaVersion": 2, + "kind": "runtime_overlay", + "id": environment_id, + "trustClass": requested_trust_class, + "createdAt": _now(), + "specs": specs, + "packageContracts": packages, + "basePackageContracts": base_packages, + "requestedPackages": [package["requirement"] for package in package_contracts], + "artifactLocks": selected_artifacts, } -print(json.dumps({ - "torch": str(torch.__version__), - "diffusers": str(diffusers.__version__), - "cudaAvailable": bool(torch.cuda.is_available()), - "hip": str(getattr(torch.version, "hip", None)), - "imports": results, -})) -if not all(item["ok"] for item in results.values()): - raise SystemExit(2) -""" - script = script.replace("__IMPORTS__", repr(json.dumps(imports))).replace( - "__CAPABILITIES__", - repr(json.dumps(capability_ids)), + _atomic_json(staged / "manifest.json", manifest, root=staged) + report("installing", "Installing the reviewed wheel set into a staged environment.") + command = [ + installer_executable, + "--no-config", + "pip", + "install", + "--python", + sys.executable, + "--target", + str(site_packages), + "--upgrade", + "--no-deps", + "--link-mode", + "copy", + ] + if hash_locked: + command.extend( + [ + "--no-index", + "--require-hashes", + "--only-binary", + ":all:", + "--requirement", + str(requirements_path), + ] + ) + command.extend(effective_install_urls) + try: + install_result = run_cancellable_command( + command, + environment=sanitized_install_environment(site_packages), + lease=lease, + timeout=1800, + cwd=staged, + ) + finally: + if hash_locked: + remove_managed_file( + requirements_path, + parent=staged, + managed_root=MANAGED_ROOT, + ) + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime installation was cancelled.") + _atomic_json( + staged / "install.json", + { + "returnCode": install_result["returnCode"], + "elapsedSeconds": install_result["elapsedSeconds"], + "commandDigest": _canonical_digest({"command": command}), + }, + root=staged, + ) + if install_result["returnCode"] != 0: + raise RuntimeError("The reviewed optional-runtime wheel set could not be staged.") + artifact_anchor = None + if hash_locked: + normalize_locked_wheel_install( + site_packages, + selected_artifacts, + ARTIFACTS_DIR, + lease=lease, + ) + artifact_anchor = verify_artifact_anchored_overlay( + site_packages, + selected_artifacts, + ARTIFACTS_DIR, + lease=lease, + ) + manifest["artifactAnchorDigest"] = artifact_anchor["digest"] + _atomic_json(staged / "manifest.json", manifest, root=staged) + report("validating", "Validating exact versions, origins, symbols, and host binding.") + validation = run_fresh_validation( + site_packages, + packages=packages, + binding=binding, + specs=specs, + lease=lease, + trusted_file_seal=(artifact_anchor or {}).get("fileSeal"), + ) + validation["schemaVersion"] = 2 + validation["environmentId"] = environment_id + validation["specs"] = [ + {"kind": item["kind"], "id": item["id"], "specDigest": item["specDigest"]} + for item in specs + ] + validation["validatedAt"] = _now() + validation["trustClass"] = requested_trust_class + validation["artifactAnchorDigest"] = (artifact_anchor or {}).get("digest") + _atomic_json(staged / "validation.json", validation, root=staged) + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime installation was cancelled.") + if validation.get("status") != "passed": + raise RuntimeError("The staged optional runtime failed isolated validation.") + if binding != current_base_binding(base_packages) or not all(_spec_is_current(item) for item in specs): + raise RuntimeError("The host or executable runtime spec changed before promotion.") + staged_inspection = _environment_inspection( + environment_id, + environment_root=_verified_existing_managed_directory( + STAGING_DIR, + managed_root=MANAGED_ROOT, + ), + ) + promotion_anchor = _promotion_anchor(staged_inspection) + _write_promotion_record( + environment_id, + phase="prepared", + anchor=promotion_anchor, + ) + promote_staged_environment(lease, staged, destination) + promoted_inspection = _environment_inspection( + environment_id, + environment_root=_verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, + ), + ) + if _promotion_anchor(promoted_inspection) != promotion_anchor: + raise RuntimeError("The optional runtime changed identity during promotion.") + _write_promotion_record( + environment_id, + phase="promoted", + anchor=promotion_anchor, + ) + _clear_promotion_record() + report("ready", "Validation passed. Explicit activation and restart are still required.") + return { + "environmentId": environment_id, + "specs": [{"kind": item["kind"], "id": item["id"], "specDigest": item["specDigest"]} for item in specs], + "validation": { + "status": "passed", + "bindingDigest": validation["bindingDigest"], + "validatedAt": validation["validatedAt"], + }, + "requiresActivation": True, + "activeRuntimeChanged": False, + } + finally: + try: + if not lease.committed: + remove_managed_directory( + staged, + parent=STAGING_DIR, + expected_identity=lease.staged_identity, + ) + except (FileNotFoundError, OSError, RuntimeError, ValueError): + pass + finally: + release_install(lease) + + +def install_optional_runtime( + profile_id: str, + spec_digest: str, + *, + consent: Any, + lease: InstallLease | None = None, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + request = validate_optional_runtime_install_request(profile_id, spec_digest, consent=consent) + owned_lease = lease or reserve_install("optional_runtime", profile_id) + profile = request["profile"] + return _install_reviewed_overlay( + spec=request["spec"], + package_contracts=[package.to_spec_dict() for package in profile.packages], + base_contracts=request["baseContracts"], + install_urls=[], + lease=owned_lease, + progress=progress, + installer_executable=request["installerExecutable"], + artifact_locks=request["artifactLocks"], ) - started = time.monotonic() + + +def public_optional_runtime_catalog() -> dict[str, Any]: + state = read_state() + process_load_status = os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS", "base") + if state.get("_storageStatus") != "ok": + process_load_status = "repair_required" + if process_load_status not in { + "base", + "active", + "busy_recovery_only", + "repair_required", + "restart_required", + }: + process_load_status = "repair_required" + active_id = state.get("activeEnvironmentId") + public_active_id = _public_environment_id(active_id) + environments = [] + inspections: dict[str, dict[str, Any]] = {} + environment_scan = { + "returned": 0, + "observed": 0, + "limit": _CATALOG_ENVIRONMENT_LIMIT, + "scanLimit": _CATALOG_ENVIRONMENT_SCAN_LIMIT, + "truncated": False, + } try: - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - timeout=timeout, - check=False, - env=_validation_environment(site_packages), + catalog_environment_root = _verified_existing_managed_directory( + ENVIRONMENTS_DIR, + managed_root=MANAGED_ROOT, ) - except (OSError, subprocess.SubprocessError) as exc: - return {"status": "failed", "error": str(exc), "elapsedSeconds": time.monotonic() - started} - detail = None - stdout = result.stdout.strip() - if stdout: - try: - detail = json.loads(stdout.splitlines()[-1]) - except ValueError: - detail = {"stdout": stdout[-4000:]} + except (FileNotFoundError, OSError, RuntimeError, ValueError): + catalog_environment_root = None + if catalog_environment_root is not None: + environment_ids, environment_scan = _catalog_environment_ids( + state, catalog_environment_root + ) + priority_ids = {active_id, state.get("previousEnvironmentId")} + for environment_id in environment_ids: + public_environment_id = _public_environment_id(environment_id) + inspection = _environment_inspection( + environment_id, + verify_integrity=False, + document_max_bytes=( + _CATALOG_PRIORITY_DOCUMENT_LIMIT + if environment_id in priority_ids + else _CATALOG_INACTIVE_DOCUMENT_LIMIT + ), + environment_root=catalog_environment_root, + ) + inspections[environment_id] = inspection + manifest = inspection.get("manifest") if inspection.get("status") in {"ready", "recorded"} else {} + trust_class = (manifest or {}).get("trustClass") + environments.append( + { + "id": public_environment_id, + "createdAt": _public_utc_timestamp((manifest or {}).get("createdAt")), + "specs": [ + { + "kind": item.get("kind"), + "id": item.get("id"), + "specDigest": item.get("specDigest"), + } + for item in (manifest or {}).get("specs") or [] + if isinstance(item, dict) + ][:32], + "status": ( + "legacy_unqualified" + if trust_class == "legacy_optimization" + else "staged_unchecked" + if inspection.get("status") == "recorded" + else inspection.get("status") + ), + "active": environment_id == active_id, + } + ) + profiles = public_optional_runtime_profiles() + for profile in profiles: + matching = [ + environment + for environment in environments + if any( + spec.get("kind") == "optional_runtime" + and spec.get("id") == profile["id"] + and spec.get("specDigest") == profile["specDigest"] + for spec in environment["specs"] + ) + ] + profile["overlayStatus"] = ( + "active" + if process_load_status == "active" + and any( + environment["active"] + and environment["status"] in {"ready", "staged_unchecked"} + for environment in matching + ) + else "repair_required" + if process_load_status in {"busy_recovery_only", "repair_required", "restart_required"} + and any(environment["active"] for environment in matching) + else "staged" + if any(environment["status"] == "ready" for environment in matching) + else "staged_unchecked" + if any(environment["status"] == "staged_unchecked" for environment in matching) + else "repair_required" + if matching + else "missing" + ) + active_inspection = inspections.get(active_id) if active_id else None + if active_id and active_inspection is None: + active_inspection = _environment_inspection(active_id, verify_integrity=False) + recorded_active_status = active_inspection.get("status") if active_inspection else "missing" + active_status = ( + "active" + if process_load_status == "active" and recorded_active_status in {"ready", "recorded"} + else "repair_required" + if process_load_status in {"busy_recovery_only", "repair_required"} + else "restart_required" + if process_load_status == "restart_required" + else "staged_unchecked" + if recorded_active_status == "recorded" + else recorded_active_status + ) + active_job = active_install() return { - "status": "passed" if result.returncode == 0 else "failed", - "returnCode": result.returncode, - "detail": detail, - "stderr": result.stderr.strip()[-4000:], - "elapsedSeconds": time.monotonic() - started, - "validatedAt": _now(), + "schemaVersion": 1, + "profiles": profiles, + "overlay": { + "processLoadStatus": process_load_status, + "state": { + "activeEnvironmentId": public_active_id, + "previousEnvironmentId": ( + _public_environment_id(state.get("previousEnvironmentId")) + ), + "activeStatus": active_status, + }, + "environments": environments, + "environmentScan": environment_scan, + }, + "activeInstallJob": ( + { + "ownerKind": ( + active_job.get("ownerKind") + if active_job.get("ownerKind") in {"optional_runtime", "optimization"} + else None + ), + "ownerId": ( + active_job.get("ownerId") + if isinstance(active_job.get("ownerId"), str) + and re.fullmatch( + r"[a-z0-9][a-z0-9_.-]{0,127}", active_job.get("ownerId") + ) + else None + ), + } + if active_job + else None + ), } +def activate_optional_runtime_environment( + environment_id: str, + profile_id: str, + spec_digest: str, + *, + consent: Any, +) -> dict[str, Any]: + required = validate_optional_runtime_activation_request(profile_id, spec_digest, consent=consent) + return _activate_environment_transaction( + environment_id, + expected_trust_class="artifact_locked_optional", + required_spec={ + "kind": "optional_runtime", + "id": profile_id, + "specDigest": required["specDigest"], + }, + ) + + +def rollback_optional_runtime_environment(*, consent: Any) -> dict[str, Any]: + if consent is not True: + raise ValueError("Explicit consent=true is required to roll back an optional runtime.") + return _rollback_environment_transaction(expected_trust_class="artifact_locked_optional") + + def install_capability( capability_id: str, *, runtime_profile: dict[str, Any] | None, hardware: dict[str, Any] | None, progress: Callable[[dict[str, Any]], None] | None = None, + lease: InstallLease | None = None, ) -> dict[str, Any]: + del runtime_profile, hardware, progress, lease catalog = _catalog() capability = catalog.get(capability_id) if capability is None or capability.get("kind") != "package": raise ValueError(f"{capability_id!r} is not an app-installable optimization package.") - status = public_catalog(runtime_profile=runtime_profile, hardware=hardware) - public_item = next(item for item in status["capabilities"] if item["id"] == capability_id) - if not public_item.get("compatible"): + raise RuntimeError( + "Legacy optimization package installation remains unavailable until its complete immutable artifact lock is reviewed." + ) + + +def _activate_environment_transaction( + environment_id: str, + *, + expected_trust_class: str, + required_spec: dict[str, str] | None = None, +) -> dict[str, Any]: + if expected_trust_class == "legacy_optimization": raise RuntimeError( - public_item.get("disabledReason") or "This package is not compatible with the active runtime." + "Legacy optimization overlays remain unqualified and cannot be activated without immutable artifact locks." ) + lease = reserve_install("activation", str(environment_id)) + try: + _reconcile_promotion(lease) + state = read_state() + if state.get("_storageStatus") != "ok": + raise RuntimeError("Repair or reset the corrupt runtime state before activation.") + inspection = _environment_inspection(environment_id) + if ( + inspection.get("status") != "ready" + or inspection.get("manifest", {}).get("trustClass") != expected_trust_class + ): + raise ValueError("Only a currently validated matching environment can be activated.") + if required_spec is not None and not any( + item.get("kind") == required_spec["kind"] + and item.get("id") == required_spec["id"] + and item.get("specDigest") == required_spec["specDigest"] + for item in inspection["manifest"].get("specs") or [] + ): + raise ValueError("The environment does not contain the requested optional runtime spec.") + if not _fresh_validation_matches(inspection, lease): + raise ValueError("Only a currently validated staged environment can be activated.") + if state.get("activeEnvironmentId") == environment_id: + if state.get("activeTrustClass") != expected_trust_class: + raise RuntimeError("The active environment trust-class receipt is stale.") + return {"state": state, "restartRequired": False} + current = state.get("activeEnvironmentId") + if current is not None: + current_inspection = _environment_inspection(current) + if ( + state.get("activeTrustClass") != expected_trust_class + or current_inspection.get("manifest", {}).get("trustClass") + != expected_trust_class + ): + raise RuntimeError( + "Roll back the current runtime trust class before activating another class." + ) + state["previousEnvironmentId"] = state.get("activeEnvironmentId") + state["previousTrustClass"] = state.get("activeTrustClass") + state["activeEnvironmentId"] = environment_id + state["activeTrustClass"] = expected_trust_class + state = _write_state(state) + return {"state": state, "restartRequired": True} + finally: + release_install(lease) - def report(phase: str, message: str, **extra: Any) -> None: - if progress: - progress({"phase": phase, "message": message, "updatedAt": _now(), **extra}) - state = read_state() - environment_id = f"opt-{int(time.time())}-{uuid.uuid4().hex[:8]}" - staged = STAGING_DIR / environment_id - site_packages = staged / "site-packages" - staged.mkdir(parents=True, exist_ok=False) - active_site = _safe_environment_path(state.get("activeEnvironmentId")) - existing_capabilities: list[str] = [] - if active_site is not None: - report("copying", "Copying the last validated optional environment.") - shutil.copytree(active_site, site_packages) - active_manifest = _read_json(active_site.parent / "manifest.json", {}) - existing_capabilities = [str(item) for item in active_manifest.get("capabilities") or []] - else: - site_packages.mkdir() +def activate_environment(environment_id: str) -> dict[str, Any]: + """Compatibility activation for hashless legacy optimization overlays.""" - capabilities = list(dict.fromkeys([*existing_capabilities, capability_id])) - manifest = { - "schemaVersion": 1, - "id": environment_id, - "createdAt": _now(), - "basePython": sys.version.split()[0], - "baseExecutable": sys.executable, - "baseProfile": _profile_id(runtime_profile), - "capabilities": capabilities, - "buildPackages": capability.get("buildPackages") or [], - "requestedPackages": capability.get("packages") or [], - } - _atomic_json(staged / "manifest.json", manifest) - report("installing", f"Installing {capability['label']} into a staged environment.") - uv = _uv_executable() - base_command = [ - uv, - "pip", - "install", - "--python", - sys.executable, - "--target", - str(site_packages), - "--upgrade", - ] - install_environment = _validation_environment(site_packages) - target_bin = site_packages / ("Scripts" if os.name == "nt" else "bin") - ninja_bin = site_packages / "ninja" / "data" / "bin" - install_environment["PATH"] = os.pathsep.join( - [str(target_bin), str(ninja_bin), install_environment.get("PATH", "")] + return _activate_environment_transaction( + environment_id, + expected_trust_class="legacy_optimization", ) - commands: list[list[str]] = [] - build_packages = [str(item) for item in capability.get("buildPackages") or []] - if build_packages: - # Source builds need their toolchain inside the isolated overlay before - # package metadata or extension compilation runs. - commands.append([*base_command, "--no-deps", *build_packages]) - command = list(base_command) - if capability.get("installMode") == "source": - command.append("--no-build-isolation") - if not capability.get("includeDependencies"): - # Optional ABI packages must use the already-qualified base Torch. - # Never let an isolated target resolver install a second Torch build. - command.append("--no-deps") - command.extend(str(item) for item in capability.get("packages") or []) - commands.append(command) - install_started = time.monotonic() - results = [] - for current_command in commands: - result = subprocess.run( - current_command, - capture_output=True, - text=True, - timeout=3600, - check=False, - env=install_environment, - ) - results.append(result) - if result.returncode != 0: - break - install_detail = { - "returnCode": result.returncode, - "elapsedSeconds": time.monotonic() - install_started, - "commands": len(results), - "stdout": "\n".join(item.stdout.strip() for item in results)[-8000:], - "stderr": "\n".join(item.stderr.strip() for item in results)[-8000:], - } - _atomic_json(staged / "install.json", install_detail) - if result.returncode != 0: - report("failed", f"{capability['label']} could not be staged.", error=install_detail["stderr"]) - raise RuntimeError(install_detail["stderr"] or install_detail["stdout"] or "Package installation failed.") - - report("validating", "Validating Torch, Diffusers, and the optional package in a fresh process.") - validation = _run_validation(site_packages, capabilities) - _atomic_json(staged / "validation.json", validation) - if validation.get("status") != "passed": - report("failed", "The staged environment failed validation. The active runtime was not changed.") - raise RuntimeError(validation.get("stderr") or "The staged package failed its compatibility probe.") - - destination = ENVIRONMENTS_DIR / environment_id - ENVIRONMENTS_DIR.mkdir(parents=True, exist_ok=True) - staged.replace(destination) - report("ready", "Validation passed. Activate the staged environment to restart MoDiff with it.") - return { - "environmentId": environment_id, - "capabilities": capabilities, - "validation": validation, - "requiresActivation": True, - "activeRuntimeChanged": False, - } -def activate_environment(environment_id: str) -> dict[str, Any]: - site_packages = _safe_environment_path(environment_id) - if site_packages is None: - raise ValueError("Only a validated staged environment can be activated.") - state = read_state() - if state.get("activeEnvironmentId") == environment_id: - return {"state": state, "restartRequired": False} - state["previousEnvironmentId"] = state.get("activeEnvironmentId") - state["activeEnvironmentId"] = environment_id - state = _write_state(state) - return {"state": state, "restartRequired": True} +def _rollback_environment_transaction(*, expected_trust_class: str) -> dict[str, Any]: + lease = reserve_install("rollback", "previous_environment") + try: + _reconcile_promotion(lease) + state = read_state() + if state.get("_storageStatus") != "ok": + state = _reset_state_to_base() + return {"state": state, "restartRequired": True} + current = state.get("activeEnvironmentId") + if expected_trust_class == "legacy_optimization": + if current is not None and state.get("activeTrustClass") != expected_trust_class: + raise RuntimeError( + "This rollback route does not own the active environment trust class." + ) + state = _write_state(_default_state()) + return {"state": state, "restartRequired": current is not None} + if current is not None and state.get("activeTrustClass") != expected_trust_class: + raise RuntimeError("This rollback route does not own the active environment trust class.") + previous = state.get("previousEnvironmentId") + if previous is not None: + if state.get("previousTrustClass") != expected_trust_class: + raise RuntimeError("The previous environment belongs to another trust class.") + inspection = _environment_inspection(previous) + if ( + inspection.get("status") != "ready" + or inspection.get("manifest", {}).get("trustClass") != expected_trust_class + or not _fresh_validation_matches(inspection, lease) + ): + raise RuntimeError("The previous optional environment requires repair before rollback.") + state["activeEnvironmentId"] = previous + state["activeTrustClass"] = state.get("previousTrustClass") + state["previousEnvironmentId"] = current + state["previousTrustClass"] = expected_trust_class if current is not None else None + state = _write_state(state) + return {"state": state, "restartRequired": current != previous} + finally: + release_install(lease) def rollback_environment() -> dict[str, Any]: - state = read_state() - previous = state.get("previousEnvironmentId") - if previous is not None and _safe_environment_path(previous) is None: - raise RuntimeError("The previous optional environment is no longer available.") - current = state.get("activeEnvironmentId") - state["activeEnvironmentId"] = previous - state["previousEnvironmentId"] = current - state = _write_state(state) - return {"state": state, "restartRequired": current != previous} + """Compatibility rollback limited to hashless legacy optimization overlays.""" + + return _rollback_environment_transaction(expected_trust_class="legacy_optimization") def set_capability_enabled(capability_id: str, enabled: bool) -> dict[str, Any]: if capability_id not in _catalog(): raise ValueError(f"Unknown optimization capability {capability_id!r}.") state = read_state() + if state.get("_storageStatus") != "ok": + raise RuntimeError("Repair or reset the corrupt runtime state before changing capabilities.") values = {str(item) for item in state.get("enabledCapabilities") or []} if enabled: values.add(capability_id) @@ -703,9 +2306,20 @@ def read_receipts() -> dict[str, Any]: value = _read_json( RECEIPTS_PATH, {"schemaVersion": RECEIPT_SCHEMA_VERSION, "receipts": [], "updatedAt": _now()}, + root=OPTIMIZATION_ROOT, ) - value.setdefault("receipts", []) - return value + receipts = value.get("receipts") + if value.get("schemaVersion") != RECEIPT_SCHEMA_VERSION or not isinstance(receipts, list): + return { + "schemaVersion": RECEIPT_SCHEMA_VERSION, + "receipts": [], + "updatedAt": _now(), + } + return { + "schemaVersion": RECEIPT_SCHEMA_VERSION, + "receipts": [item for item in receipts if isinstance(item, dict)][:500], + "updatedAt": _public_utc_timestamp(value.get("updatedAt")) or _now(), + } def _stable_hash(value: Any) -> str: @@ -824,6 +2438,28 @@ def record_probe_receipt( ) -> dict[str, Any]: if capability_id not in _catalog(): raise ValueError(f"Unknown optimization capability {capability_id!r}.") + detail = result.get("detail") if isinstance(result.get("detail"), dict) else {} + sanitized_result: dict[str, Any] = { + "status": "passed" if result.get("status") == "passed" else "failed", + "diagnosticDigest": _stable_hash(result), + } + for key in ("supported", "compileAvailable", "cudaAvailable"): + if isinstance(detail.get(key), bool): + sanitized_result[key] = detail[key] + device_count = detail.get("deviceCount") + if isinstance(device_count, int) and not isinstance(device_count, bool) and 0 <= device_count <= 1024: + sanitized_result["deviceCount"] = device_count + return_code = result.get("returnCode") + if isinstance(return_code, int) and not isinstance(return_code, bool) and -255 <= return_code <= 255: + sanitized_result["returnCode"] = return_code + elapsed = result.get("elapsedSeconds") + if ( + isinstance(elapsed, (int, float)) + and not isinstance(elapsed, bool) + and math.isfinite(elapsed) + and 0 <= elapsed <= 3600 + ): + sanitized_result["elapsedSeconds"] = float(elapsed) receipt = { "id": f"probe-{uuid.uuid4().hex}", "schemaVersion": RECEIPT_SCHEMA_VERSION, @@ -832,7 +2468,7 @@ def record_probe_receipt( "capabilityId": capability_id, "environmentId": environment_id or read_state().get("activeEnvironmentId"), "runtimeFingerprintHash": _stable_hash(runtime_fingerprint), - "result": deepcopy(result), + "result": sanitized_result, "createdAt": _now(), # Import/synthetic probes never authorize Auto for a model workload. "autoEligible": False, @@ -841,7 +2477,7 @@ def record_probe_receipt( document = read_receipts() document["receipts"] = [receipt, *document.get("receipts", [])][:500] document["updatedAt"] = _now() - _atomic_json(RECEIPTS_PATH, document) + _atomic_json(RECEIPTS_PATH, document, root=OPTIMIZATION_ROOT) return receipt @@ -849,10 +2485,10 @@ def probe_capability(capability_id: str, *, runtime_fingerprint: Any) -> dict[st capability = _catalog().get(capability_id) if capability is None: raise ValueError(f"Unknown optimization capability {capability_id!r}.") - state = read_state() - active_site = _safe_environment_path(state.get("activeEnvironmentId")) if capability.get("importName"): - validation = _run_validation(active_site or Path(), [capability_id]) + raise RuntimeError( + "Legacy optimization package probes remain unavailable until immutable artifact locks are reviewed." + ) else: script = """ import json @@ -903,7 +2539,7 @@ def probe_capability(capability_id: str, *, runtime_fingerprint: Any) -> dict[st capability_id=capability_id, runtime_fingerprint=runtime_fingerprint, result=validation, - environment_id=state.get("activeEnvironmentId"), + environment_id=read_state().get("activeEnvironmentId"), ) @@ -941,7 +2577,7 @@ def record_workload_observation( document = read_receipts() document["receipts"] = [receipt, *document.get("receipts", [])][:500] document["updatedAt"] = _now() - _atomic_json(RECEIPTS_PATH, document) + _atomic_json(RECEIPTS_PATH, document, root=OPTIMIZATION_ROOT) return receipt @@ -972,7 +2608,7 @@ def record_workload_baseline( document = read_receipts() document["receipts"] = [receipt, *document.get("receipts", [])][:500] document["updatedAt"] = _now() - _atomic_json(RECEIPTS_PATH, document) + _atomic_json(RECEIPTS_PATH, document, root=OPTIMIZATION_ROOT) return receipt @@ -1039,7 +2675,7 @@ def qualify_receipt(receipt_id: str, *, output_reviewed: bool) -> dict[str, Any] receipt["baselineReceiptId"] = baseline.get("id") receipt["benchmarkEvidence"] = evidence document["updatedAt"] = _now() - _atomic_json(RECEIPTS_PATH, document) + _atomic_json(RECEIPTS_PATH, document, root=OPTIMIZATION_ROOT) return deepcopy(receipt) @@ -1083,7 +2719,6 @@ def delete_environment(environment_id: str) -> None: state = read_state() if environment_id in {state.get("activeEnvironmentId"), state.get("previousEnvironmentId")}: raise RuntimeError("Active and rollback environments cannot be deleted.") - path = (ENVIRONMENTS_DIR / environment_id).resolve() - path.relative_to(ENVIRONMENTS_DIR.resolve()) - if path.is_dir(): - shutil.rmtree(path) + if not re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", str(environment_id or "")): + raise ValueError("A valid managed environment identifier is required.") + remove_managed_directory(ENVIRONMENTS_DIR / environment_id, parent=ENVIRONMENTS_DIR) diff --git a/modiff/optional_runtime_execution.py b/modiff/optional_runtime_execution.py new file mode 100644 index 0000000..eac2766 --- /dev/null +++ b/modiff/optional_runtime_execution.py @@ -0,0 +1,427 @@ +"""Fail-closed first-use checks for backend-declared optional runtimes. + +Execution profiles, not model names or client hints, decide whether a graph is +still satisfied by the base environment or requires an activated app-owned +overlay. Base delivery is deliberately a zero-observation fast path so the +pre-cutover application remains readiness-neutral. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +import os +import re +from typing import Any + +from modiff.diffusers_profiles import ( + OPTIONAL_RUNTIME_DELIVERY_OVERLAY, + DiffusersExecutionProfile, + execution_profiles_for_execution, + optional_runtime_requirement_for_profiles as declarative_requirement_for_profiles, + resolve_execution_profiles_for_loader, +) +from modiff.optimization_packages import public_optional_runtime_catalog + + +_PROCESS_BLOCK_STATES = { + "busy_recovery_only", + "repair_required", + "restart_required", +} +_EXECUTION_READY_STATE = "active" +_PUBLIC_STATES = { + "base_satisfied", + "missing", + "wrong_version", + "present_unqualified", + "staged", + "active", + "busy_recovery_only", + "restart_required", + "repair_required", + "unavailable", +} +_RESOLUTION_REASONS = { + "loader_parameters_invalid", + "loader_identity_missing", + "loader_selection_unregistered", + "loader_profile_ambiguous", +} +_STATE_PRIORITY = { + "active": 0, + "staged": 1, + "present_unqualified": 2, + "wrong_version": 3, + "missing": 4, + "restart_required": 5, + "busy_recovery_only": 6, + "repair_required": 7, + "unavailable": 8, +} +_PROFILE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}") +_DIGEST_PATTERN = re.compile(r"sha256:[0-9a-f]{64}") +_PACKAGE_STATES = {"missing", "present_unqualified", "wrong_version"} +_OVERLAY_STATES = { + "active", + "missing", + "repair_required", + "staged", + "staged_unchecked", +} +_PROCESS_STATES = {"active", "base", *_PROCESS_BLOCK_STATES} + + +def _copy_requirement(requirement: dict[str, Any], *, state: str, reason: str) -> dict[str, Any]: + if state not in _PUBLIC_STATES: + state = "unavailable" + reason = "optional_runtime_status_invalid" + if not isinstance(reason, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,127}", reason): + reason = "optional_runtime_status_invalid" + profile_ids = list( + dict.fromkeys( + item + for item in requirement.get("profileIds", []) + if isinstance(item, str) and item + ) + )[:32] + execution_profile_ids = list( + dict.fromkeys( + item + for item in requirement.get("executionProfileIds", []) + if isinstance(item, str) and item + ) + )[:32] + return { + "schemaVersion": 1, + "delivery": ( + requirement.get("delivery") + if requirement.get("delivery") in {"base", OPTIONAL_RUNTIME_DELIVERY_OVERLAY} + else OPTIONAL_RUNTIME_DELIVERY_OVERLAY + ), + "requiredNow": bool(requirement.get("requiredNow")), + "profileIds": profile_ids, + "executionProfileIds": execution_profile_ids, + "state": state, + "reason": reason, + } + + +def _catalog_profile_state(profile: dict[str, Any], process_status: str) -> tuple[str, str]: + overlay_status = profile.get("overlayStatus") + if overlay_status == "repair_required": + return "repair_required", "optional_runtime_overlay_repair_required" + if overlay_status in {"staged", "staged_unchecked"}: + return "staged", "optional_runtime_staged" + if ( + process_status == "active" + and overlay_status == "active" + and profile.get("contractState") == "qualified" + and profile.get("cutoverReady") is True + ): + return "active", "optional_runtime_active" + + package_status = profile.get("status") + if package_status == "missing": + return "missing", "optional_runtime_missing" + if package_status == "wrong_version": + return "wrong_version", "optional_runtime_wrong_version" + if package_status == "present_unqualified": + return "present_unqualified", "optional_runtime_present_unqualified" + return "unavailable", "optional_runtime_status_invalid" + + +def _validated_catalog(catalog: Any) -> tuple[str, dict[str, dict[str, Any]]] | None: + if not isinstance(catalog, dict) or catalog.get("schemaVersion") != 1: + return None + overlay = catalog.get("overlay") + process_status = overlay.get("processLoadStatus") if isinstance(overlay, dict) else None + if process_status not in _PROCESS_STATES: + return None + profiles = catalog.get("profiles") + if not isinstance(profiles, list) or len(profiles) > 32: + return None + + by_id: dict[str, dict[str, Any]] = {} + for profile in profiles: + if not isinstance(profile, dict) or profile.get("schemaVersion") != 1: + return None + profile_id = profile.get("id") + if ( + not isinstance(profile_id, str) + or not _PROFILE_ID_PATTERN.fullmatch(profile_id) + or profile_id in by_id + ): + return None + if ( + not isinstance(profile.get("label"), str) + or not profile["label"].strip() + or len(profile["label"]) > 1024 + or not isinstance(profile.get("contractState"), str) + or not profile["contractState"].strip() + or len(profile["contractState"]) > 128 + or not isinstance(profile.get("specDigest"), str) + or not _DIGEST_PATTERN.fullmatch(profile["specDigest"]) + or profile.get("status") not in _PACKAGE_STATES + or profile.get("overlayStatus") not in _OVERLAY_STATES + ): + return None + if any( + type(profile.get(key)) is not bool + for key in ( + "cutoverReady", + "installActionAvailable", + "activationAvailable", + ) + ): + return None + by_id[profile_id] = profile + return process_status, by_id + + +def optional_runtime_requirement_for_profiles( + profiles: Iterable[DiffusersExecutionProfile], + *, + resolution_reason: str | None = None, + catalog_resolver: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Resolve public execution readiness without importing optional packages.""" + + selected = tuple(profiles) + requirement = declarative_requirement_for_profiles(selected) + if not requirement["requiredNow"]: + # Critical pre-cutover invariant: base delivery never consults local + # package/overlay status and therefore cannot change current readiness. + return requirement + if requirement["state"] == "unavailable" and requirement["reason"] == "execution_profile_contract_invalid": + return requirement + if resolution_reason is not None: + reason = ( + f"execution_profile_{resolution_reason}" + if resolution_reason in _RESOLUTION_REASONS + else "execution_profile_resolution_invalid" + ) + return _copy_requirement(requirement, state="unavailable", reason=reason) + + process_hint = os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS", "base") + if process_hint in _PROCESS_BLOCK_STATES: + return _copy_requirement( + requirement, + state=process_hint, + reason=f"optional_runtime_{process_hint}", + ) + + try: + catalog = (catalog_resolver or public_optional_runtime_catalog)() + except (OSError, RuntimeError, TypeError, ValueError): + return _copy_requirement( + requirement, + state="unavailable", + reason="optional_runtime_status_unavailable", + ) + validated_catalog = _validated_catalog(catalog) + if validated_catalog is None: + return _copy_requirement( + requirement, + state="unavailable", + reason="optional_runtime_status_invalid", + ) + process_status, by_id = validated_catalog + if process_status != process_hint: + return _copy_requirement( + requirement, + state="unavailable", + reason="optional_runtime_process_status_mismatch", + ) + if process_status in _PROCESS_BLOCK_STATES: + return _copy_requirement( + requirement, + state=process_status, + reason=f"optional_runtime_{process_status}", + ) + requested = requirement["profileIds"] + if any(profile_id not in by_id for profile_id in requested): + return _copy_requirement( + requirement, + state="unavailable", + reason="optional_runtime_profile_unknown", + ) + + states = [_catalog_profile_state(by_id[profile_id], process_status) for profile_id in requested] + if states and all(state == "active" for state, _reason in states): + return _copy_requirement( + requirement, + state="active", + reason="optional_runtime_active", + ) + state, reason = max(states, key=lambda item: _STATE_PRIORITY.get(item[0], 99)) + return _copy_requirement(requirement, state=state, reason=reason) + + +def optional_runtime_requirement_for_execution( + model_type: str, + mode: str | None = None, + *, + catalog_resolver: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + return optional_runtime_requirement_for_profiles( + execution_profiles_for_execution(model_type, mode), + catalog_resolver=catalog_resolver, + ) + + +def loader_optional_runtime_requirement( + module: str, + action: str, + values: dict[str, Any], + *, + catalog_resolver: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + profiles, resolution_reason = resolve_execution_profiles_for_loader( + module, + action, + values, + ) + return optional_runtime_requirement_for_profiles( + profiles, + resolution_reason=resolution_reason, + catalog_resolver=catalog_resolver, + ) + + +def _static_node_values(node: dict[str, Any]) -> dict[str, Any]: + params = node.get("params") + if not isinstance(params, dict): + return {} + values: dict[str, Any] = {} + for key, param in params.items(): + if not isinstance(key, str) or not isinstance(param, dict): + continue + if param.get("sourceId") and param.get("sourceKey"): + continue + values[key] = param.get("value") + return values + + +def graph_optional_runtime_requirement( + graph: dict[str, Any], + *, + catalog_resolver: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Resolve graph loader contracts without trusting ``runtimeHints``.""" + + nodes = graph.get("nodes") if isinstance(graph, dict) else None + if not isinstance(nodes, dict): + return declarative_requirement_for_profiles(()) + + paths = graph.get("paths") + if not isinstance(paths, list): + return declarative_requirement_for_profiles(()) + + executable_node_ids: list[str] = [] + seen_node_ids: set[str] = set() + for path in paths: + if not isinstance(path, list): + continue + for node_id in path: + if ( + isinstance(node_id, str) + and node_id in nodes + and node_id not in seen_node_ids + ): + seen_node_ids.add(node_id) + executable_node_ids.append(node_id) + + selected: list[DiffusersExecutionProfile] = [] + blocking_resolution_reason: str | None = None + for node_id in executable_node_ids: + node = nodes[node_id] + if not isinstance(node, dict): + continue + module = node.get("module") + action = node.get("action") + if not isinstance(module, str) or not isinstance(action, str): + continue + profiles, resolution_reason = resolve_execution_profiles_for_loader( + module, + action, + _static_node_values(node), + ) + if not profiles: + continue + for profile in profiles: + if profile not in selected: + selected.append(profile) + if resolution_reason and any( + profile.optional_runtime_delivery == OPTIONAL_RUNTIME_DELIVERY_OVERLAY + for profile in profiles + ): + blocking_resolution_reason = resolution_reason + + return optional_runtime_requirement_for_profiles( + selected, + resolution_reason=blocking_resolution_reason, + catalog_resolver=catalog_resolver, + ) + + +def optional_runtime_requirement_blocks_execution(requirement: dict[str, Any]) -> bool: + return bool( + isinstance(requirement, dict) + and requirement.get("requiredNow") is True + and requirement.get("state") != _EXECUTION_READY_STATE + ) + + +_BLOCK_MESSAGES = { + "missing": "This workflow requires an optional runtime that is not installed.", + "wrong_version": "This workflow requires a different exact optional-runtime version.", + "present_unqualified": "The observed packages are not a qualified active optional runtime.", + "staged": "This workflow's optional runtime is staged but not active in this worker.", + "busy_recovery_only": "Optional-runtime recovery is busy; graph execution is temporarily unavailable.", + "restart_required": "Restart MoDiff to load the selected validated optional runtime.", + "repair_required": "The selected optional runtime requires repair or rollback before execution.", + "unavailable": "This workflow's optional-runtime execution contract is unavailable.", +} +_RECOVERY_HINTS = { + "missing": "Review this runtime in Setup. Unavailable package actions remain disabled.", + "wrong_version": "Review or repair the exact runtime in Setup; do not reuse an unverified host package.", + "present_unqualified": "Use only a validated app-owned runtime. Exact host package presence is not qualification.", + "staged": "Activate the validated staged runtime explicitly, then restart the worker when requested.", + "busy_recovery_only": "Wait for runtime recovery to finish, then refresh runtime status.", + "restart_required": "Restart the supervised backend worker, then retry the workflow.", + "repair_required": "Open Setup and choose a supported repair or rollback action.", + "unavailable": "Refresh runtime status and review the execution profile in Setup.", +} + + +def optional_runtime_blocker_payload(requirement: dict[str, Any]) -> dict[str, Any]: + state = requirement.get("state") if requirement.get("state") in _BLOCK_MESSAGES else "unavailable" + return { + "error": True, + "category": "optional_runtime", + "error_code": f"optional_runtime_{state}", + "message": _BLOCK_MESSAGES[state], + "recovery_hint": _RECOVERY_HINTS[state], + "optionalRuntimeRequirement": _copy_requirement( + requirement, + state=state, + reason=str(requirement.get("reason") or "optional_runtime_status_unavailable")[:128], + ), + } + + +class OptionalRuntimeExecutionBlocked(RuntimeError): + """Structured worker-side equivalent of the HTTP admission blocker.""" + + def __init__(self, requirement: dict[str, Any]): + payload = optional_runtime_blocker_payload(requirement) + super().__init__(payload["message"]) + self.modiff_category = payload["category"] + self.modiff_error_code = payload["error_code"] + self.modiff_recovery_hint = payload["recovery_hint"] + self.modiff_optional_runtime_requirement = payload["optionalRuntimeRequirement"] + + +def assert_optional_runtime_ready(requirement: dict[str, Any]) -> None: + if optional_runtime_requirement_blocks_execution(requirement): + raise OptionalRuntimeExecutionBlocked(requirement) diff --git a/modiff/optional_runtimes.py b/modiff/optional_runtimes.py new file mode 100644 index 0000000..5f64ec2 --- /dev/null +++ b/modiff/optional_runtimes.py @@ -0,0 +1,646 @@ +"""Declarative contracts for workflow-specific optional model runtimes. + +This module is intentionally Python-standard-library only. Registry discovery, +template browsing, and Auto planning may inspect these contracts and local +distribution metadata, but must never import, install, or activate the declared +packages. Installation and activation remain a later, explicit P0.5 slice. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from importlib import metadata +import json +import sys +from types import MappingProxyType +from typing import Callable, Iterable, Mapping + + +OPTIONAL_RUNTIME_SCHEMA_VERSION = 1 +TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID = "huggingface-transformers-peft-5.14.1-0.20.0" +_MAX_OBSERVED_VERSION_LENGTH = 128 + +_OPTIONAL_RUNTIME_TARGETS = ( + ("linux", "cp312", "x86_64"), + ("linux", "cp312", "arm64"), + ("macos", "cp312", "x86_64"), + ("macos", "cp312", "arm64"), + ("windows", "cp312", "x86_64"), + ("windows", "cp312", "arm64"), +) +_PURE_RUNTIME_WHEELS = ( + ("transformers", "5.14.1", "transformers-5.14.1-py3-none-any.whl", "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", "9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", 11625234), + ("peft", "0.20.0", "peft-0.20.0-py3-none-any.whl", "https://files.pythonhosted.org/packages/28/79/13bcabb8048126422d5c4b880575d40886c726f354db88cfeed4325525bb/peft-0.20.0-py3-none-any.whl", "0fbba16ffebfad3de96e06f2da6860fd860292324b85b6141909fa1e26ea9233", 775777), + ("typer", "0.27.1", "typer-0.27.1-py3-none-any.whl", "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", "53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", 122874), + ("annotated-doc", "0.0.5", "annotated_doc-0.0.5-py3-none-any.whl", "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", "117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", 5302), + ("rich", "15.0.0", "rich-15.0.0-py3-none-any.whl", "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", 310654), + ("markdown-it-py", "4.2.0", "markdown_it_py-4.2.0-py3-none-any.whl", "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", 91687), + ("mdurl", "0.1.2", "mdurl-0.1.2-py3-none-any.whl", "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 9979), + ("pygments", "2.20.0", "pygments-2.20.0-py3-none-any.whl", "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", 1231151), + ("shellingham", "1.5.4", "shellingham-1.5.4-py2.py3-none-any.whl", "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", "7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", 9755), +) +_TOKENIZERS_RUNTIME_WHEELS = { + ("linux", "x86_64"): ("tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", 3274982), + ("linux", "arm64"): ("tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", "2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", 3290736), + ("macos", "x86_64"): ("tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", "544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", 3100275), + ("macos", "arm64"): ("tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", "1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", 2981472), + ("windows", "x86_64"): ("tokenizers-0.22.2-cp39-abi3-win_amd64.whl", "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", "c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", 2747786), + ("windows", "arm64"): ("tokenizers-0.22.2-cp39-abi3-win_arm64.whl", "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", "9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", 2612133), +} + + +def _transformers_peft_artifact_locks() -> tuple[dict, ...]: + locks = [] + for platform_name, python_tag, machine in _OPTIONAL_RUNTIME_TARGETS: + tokenizers = _TOKENIZERS_RUNTIME_WHEELS[(platform_name, machine)] + wheels = ( + _PURE_RUNTIME_WHEELS[0], + _PURE_RUNTIME_WHEELS[1], + ("tokenizers", "0.22.2", *tokenizers), + *_PURE_RUNTIME_WHEELS[2:], + ) + for distribution, version, filename, url, sha256, byte_size in wheels: + locks.append( + { + "distribution": distribution, + "version": version, + "filename": filename, + "url": url, + "sha256": sha256, + "byteSize": byte_size, + "platform": platform_name, + "pythonTag": python_tag, + "machine": machine, + } + ) + return tuple(locks) + + +@dataclass(frozen=True) +class OptionalRuntimePackageContract: + """One exact distribution in a reviewed optional-runtime specification.""" + + distribution: str + import_name: str + version: str + publisher: str + project_url: str + distribution_url: str + license: str + role: str + required_symbols: tuple[str, ...] + required_class_symbols: tuple[str, ...] = () + + @property + def requirement(self) -> str: + return f"{self.distribution}=={self.version}" + + def to_spec_dict(self) -> dict: + return { + "distribution": self.distribution, + "importName": self.import_name, + "requiredVersion": self.version, + "requirement": self.requirement, + # Provenance and license are reviewed contract declarations, not + # claims derived from mutable installed-distribution metadata. + "publisher": self.publisher, + "projectUrl": self.project_url, + "distributionUrl": self.distribution_url, + "license": self.license, + "role": self.role, + "requiredSymbols": list(self.required_symbols), + "requiredClassSymbols": list(self.required_class_symbols), + } + + +@dataclass(frozen=True) +class OptionalRuntimeBaseContract: + """One dependency deliberately supplied by the verified base profile.""" + + distribution: str + import_name: str + specifier: str = "" + platforms: tuple[str, ...] = () + + def to_spec_dict(self) -> dict: + value = { + "distribution": self.distribution, + "importName": self.import_name, + "specifier": self.specifier, + } + if self.platforms: + value["platforms"] = list(self.platforms) + return value + + +@dataclass(frozen=True) +class OptionalRuntimeProfile: + """An exact composite runtime contract that is not yet executable.""" + + id: str + label: str + packages: tuple[OptionalRuntimePackageContract, ...] + base_packages: tuple[OptionalRuntimeBaseContract, ...] + required_diffusers_symbols: tuple[str, ...] = () + require_peft_backend: bool = False + artifact_locks: tuple[dict, ...] = () + pipeline_adapter_symbols: tuple[str, ...] = () + excluded_qualification_symbols: tuple[str, ...] = () + pipeline_adapter_methods: tuple[tuple[str, tuple[str, ...]], ...] = () + contract_state: str = "candidate_unqualified" + install_policy: str = "explicit_first_use" + cutover_ready: bool = False + install_action_available: bool = False + activation_available: bool = False + + def to_spec_dict(self) -> dict: + """Return immutable fields used to identify the exact reviewed spec.""" + + return { + "schemaVersion": OPTIONAL_RUNTIME_SCHEMA_VERSION, + "id": self.id, + "label": self.label, + "contractState": self.contract_state, + "cutoverReady": self.cutover_ready, + "installActionAvailable": self.install_action_available, + "activationAvailable": self.activation_available, + "installPolicy": self.install_policy, + "packages": [ + package.to_spec_dict() for package in self.packages if package.role == "runtime_root" + ], + "stagedPackages": [package.to_spec_dict() for package in self.packages], + "baseRequirements": [package.to_spec_dict() for package in self.base_packages], + "requiredDiffusersSymbols": list(self.required_diffusers_symbols), + "requirePeftBackend": self.require_peft_backend, + "artifactLocks": [dict(artifact) for artifact in self.artifact_locks], + "pipelineAdapterSymbols": list(self.pipeline_adapter_symbols), + "excludedQualificationSymbols": list(self.excluded_qualification_symbols), + "pipelineAdapterMethods": [ + {"method": method, "requiredParameters": list(parameters)} + for method, parameters in self.pipeline_adapter_methods + ], + } + + @property + def spec_digest(self) -> str: + canonical = json.dumps( + self.to_spec_dict(), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return f"sha256:{hashlib.sha256(canonical).hexdigest()}" + + +_TRANSFORMERS_PEFT_PROFILE = OptionalRuntimeProfile( + id=TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + label="Hugging Face Transformers + PEFT", + packages=( + OptionalRuntimePackageContract( + distribution="transformers", + import_name="transformers", + version="5.14.1", + publisher="Hugging Face", + project_url="https://github.com/huggingface/transformers", + distribution_url="https://pypi.org/project/transformers/", + license="Apache-2.0", + role="runtime_root", + required_symbols=( + "Qwen2_5_VLForConditionalGeneration", + "Qwen2Tokenizer", + "Qwen2VLProcessor", + "AutoTokenizer", + "CLIPImageProcessor", + "CLIPVisionModel", + "UMT5EncoderModel", + "T5EncoderModel", + "T5TokenizerFast", + "PreTrainedModel", + "PreTrainedTokenizerFast", + "CLIPTextModel", + "CLIPTextModelWithProjection", + "CLIPTokenizer", + "CLIPVisionModelWithProjection", + "SiglipImageProcessor", + "SiglipVisionModel", + "Qwen2TokenizerFast", + "Qwen3ForCausalLM", + "AutoProcessor", + "Mistral3ForConditionalGeneration", + "BitsAndBytesConfig", + "transformers.core_model_loading:tqdm", + "transformers.utils.logging:tqdm", + "transformers.utils.logging:get_verbosity", + "transformers.utils.logging:set_verbosity", + ), + required_class_symbols=( + "Qwen2_5_VLForConditionalGeneration", + "Qwen2Tokenizer", + "Qwen2VLProcessor", + "AutoTokenizer", + "CLIPImageProcessor", + "CLIPVisionModel", + "UMT5EncoderModel", + "T5EncoderModel", + "T5TokenizerFast", + "PreTrainedModel", + "PreTrainedTokenizerFast", + "CLIPTextModel", + "CLIPTextModelWithProjection", + "CLIPTokenizer", + "CLIPVisionModelWithProjection", + "SiglipImageProcessor", + "SiglipVisionModel", + "Qwen2TokenizerFast", + "Qwen3ForCausalLM", + "AutoProcessor", + "Mistral3ForConditionalGeneration", + "BitsAndBytesConfig", + ), + ), + OptionalRuntimePackageContract( + distribution="peft", + import_name="peft", + version="0.20.0", + publisher="Hugging Face", + project_url="https://github.com/huggingface/peft", + distribution_url="https://pypi.org/project/peft/", + license="Apache-2.0", + role="runtime_root", + required_symbols=( + "LoraConfig", + "PeftConfig", + "PeftModel", + "inject_adapter_in_model", + "set_peft_model_state_dict", + "tuners.tuners_utils.BaseTunerLayer", + "tuners.lora.layer.LoraLayer", + "utils.other._get_submodules", + "utils.save_and_load.get_peft_model_state_dict", + "peft.utils.hotswap:check_hotswap_configs_compatible", + "peft.utils.hotswap:hotswap_adapter_from_state_dict", + "peft.utils.hotswap:prepare_model_for_compiled_hotswap", + ), + required_class_symbols=( + "LoraConfig", + "PeftConfig", + "PeftModel", + "tuners.tuners_utils.BaseTunerLayer", + "tuners.lora.layer.LoraLayer", + ), + ), + OptionalRuntimePackageContract( + distribution="tokenizers", + import_name="tokenizers", + version="0.22.2", + publisher="Hugging Face", + project_url="https://github.com/huggingface/tokenizers", + distribution_url="https://pypi.org/project/tokenizers/", + license="Apache-2.0", + role="runtime_dependency", + required_symbols=("Tokenizer", "tokenizers.tokenizers:Tokenizer"), + required_class_symbols=("Tokenizer", "tokenizers.tokenizers:Tokenizer"), + ), + OptionalRuntimePackageContract( + distribution="typer", + import_name="typer", + version="0.27.1", + publisher="FastAPI", + project_url="https://github.com/fastapi/typer", + distribution_url="https://pypi.org/project/typer/", + license="MIT", + role="runtime_dependency", + required_symbols=("Typer",), + required_class_symbols=("Typer",), + ), + OptionalRuntimePackageContract( + distribution="annotated-doc", + import_name="annotated_doc", + version="0.0.5", + publisher="FastAPI", + project_url="https://github.com/fastapi/annotated-doc", + distribution_url="https://pypi.org/project/annotated-doc/", + license="MIT", + role="runtime_dependency", + required_symbols=("Doc",), + required_class_symbols=("Doc",), + ), + OptionalRuntimePackageContract( + distribution="rich", + import_name="rich", + version="15.0.0", + publisher="Textualize", + project_url="https://github.com/Textualize/rich", + distribution_url="https://pypi.org/project/rich/", + license="MIT", + role="runtime_dependency", + required_symbols=("print",), + ), + OptionalRuntimePackageContract( + distribution="markdown-it-py", + import_name="markdown_it", + version="4.2.0", + publisher="Executable Books", + project_url="https://github.com/executablebooks/markdown-it-py", + distribution_url="https://pypi.org/project/markdown-it-py/", + license="MIT", + role="runtime_dependency", + required_symbols=("MarkdownIt",), + required_class_symbols=("MarkdownIt",), + ), + OptionalRuntimePackageContract( + distribution="mdurl", + import_name="mdurl", + version="0.1.2", + publisher="Executable Books", + project_url="https://github.com/executablebooks/mdurl", + distribution_url="https://pypi.org/project/mdurl/", + license="MIT", + role="runtime_dependency", + required_symbols=("encode",), + ), + OptionalRuntimePackageContract( + distribution="pygments", + import_name="pygments", + version="2.20.0", + publisher="Pygments", + project_url="https://github.com/pygments/pygments", + distribution_url="https://pypi.org/project/Pygments/", + license="BSD-2-Clause", + role="runtime_dependency", + required_symbols=("highlight",), + ), + OptionalRuntimePackageContract( + distribution="shellingham", + import_name="shellingham", + version="1.5.4", + publisher="Sarugaku", + project_url="https://github.com/sarugaku/shellingham", + distribution_url="https://pypi.org/project/shellingham/", + license="ISC", + role="runtime_dependency", + required_symbols=("detect_shell",), + ), + ), + # These transitive dependencies remain owned by the reviewed base profile. + # Their exact observed versions and metadata origins are frozen into each + # validation binding; the accelerator lock digest alone is not sufficient. + base_packages=tuple( + OptionalRuntimeBaseContract( + distribution=distribution, + import_name=import_name, + specifier=specifier, + ) + for distribution, import_name, specifier in ( + ("accelerate", "accelerate", ">=0.21.0"), + ("anyio", "anyio", ""), + ("certifi", "certifi", ""), + ("click", "click", ""), + ("filelock", "filelock", ""), + ("fsspec", "fsspec", ""), + ("h11", "h11", ""), + ("hf-xet", "hf_xet", ""), + ("httpcore", "httpcore", ""), + ("httpx", "httpx", ""), + ("huggingface-hub", "huggingface_hub", ">=1.5.0,<2.0"), + ("idna", "idna", ""), + ("jinja2", "jinja2", ""), + ("markupsafe", "markupsafe", ""), + ("mpmath", "mpmath", ""), + ("networkx", "networkx", ""), + ("numpy", "numpy", ">=1.17"), + ("packaging", "packaging", ">=20.0"), + ("pillow", "PIL", ">=10.0.1,<=15.0"), + ("protobuf", "google.protobuf", ">=6.31.1"), + ("psutil", "psutil", ""), + ("pyyaml", "yaml", ">=5.1"), + ("regex", "regex", ">=2025.10.22"), + ("safetensors", "safetensors", ">=0.8.0"), + ("sentencepiece", "sentencepiece", ">=0.2.0"), + ("setuptools", "setuptools", ""), + ("sympy", "sympy", ""), + ("torch", "torch", ">=2.6.0"), + ("torchvision", "torchvision", ">=0.21.0"), + ("tqdm", "tqdm", ">=4.60"), + ("typing-extensions", "typing_extensions", ""), + ) + ) + + ( + OptionalRuntimeBaseContract( + distribution="colorama", + import_name="colorama", + specifier="", + platforms=("windows",), + ), + ), + required_diffusers_symbols=( + "ComponentSpec", + "ComponentsManager", + "ModularPipeline", + "AutoencoderKLWan", + "BitsAndBytesConfig", + "QwenImagePipeline", + "QwenImageEditInpaintPipeline", + "QwenImageModularPipeline", + "QwenImageEditModularPipeline", + "QwenImageEditPlusModularPipeline", + "QwenImageLayeredModularPipeline", + "WanVACEPipeline", + "WanVideoToVideoPipeline", + "WanPipeline", + "WanImageToVideoPipeline", + "WanModularPipeline", + "WanImage2VideoModularPipeline", + "LTXConditionPipeline", + "AceStepPipeline", + "StableDiffusionXLModularPipeline", + "StableDiffusionXLPipeline", + "FluxPipeline", + "Flux2KleinPipeline", + "FluxKontextPipeline", + "FluxFillPipeline", + "FluxControlPipeline", + "FluxPriorReduxPipeline", + "FluxModularPipeline", + "FluxKontextModularPipeline", + "Flux2KleinModularPipeline", + ), + require_peft_backend=True, + # Exact locks do not enable installation by themselves. Qualification, + # action availability, activation, and cutover remain separate gates. + artifact_locks=_transformers_peft_artifact_locks(), + pipeline_adapter_symbols=( + "AceStepPipeline", + "FluxPipeline", + "Flux2KleinPipeline", + "LTXConditionPipeline", + "QwenImagePipeline", + "StableDiffusionXLPipeline", + "WanPipeline", + ), + excluded_qualification_symbols=("add_weighted_adapter",), + pipeline_adapter_methods=( + ("load_lora_weights", ("pretrained_model_name_or_path_or_dict", "adapter_name", "hotswap")), + ("set_adapters", ("adapter_names", "adapter_weights")), + ("delete_adapters", ("adapter_names",)), + ("fuse_lora", ("components", "lora_scale", "safe_fusing", "adapter_names")), + ("unfuse_lora", ("components",)), + ("unload_lora_weights", ()), + ("get_list_adapters", ()), + ("enable_lora_hotswap", ("kwargs",)), + ), +) + +OPTIONAL_RUNTIME_PROFILES: Mapping[str, OptionalRuntimeProfile] = MappingProxyType( + {_TRANSFORMERS_PEFT_PROFILE.id: _TRANSFORMERS_PEFT_PROFILE} +) + + +def _selected_profiles(profile_ids: Iterable[str] | None) -> tuple[OptionalRuntimeProfile, ...]: + requested_ids = tuple(OPTIONAL_RUNTIME_PROFILES) if profile_ids is None else tuple(profile_ids) + selected: list[OptionalRuntimeProfile] = [] + seen: set[str] = set() + for raw_profile_id in requested_ids: + profile_id = str(raw_profile_id or "").strip() + if profile_id in seen: + continue + try: + profile = OPTIONAL_RUNTIME_PROFILES[profile_id] + except KeyError as exc: + raise ValueError(f"Unknown optional runtime profile {profile_id!r}.") from exc + seen.add(profile_id) + selected.append(profile) + return tuple(selected) + + +def _bounded_observed_version(value: object) -> str: + """Return bounded printable metadata text suitable for a public response.""" + + rendered = str(value).strip() + rendered = "".join(character if character.isprintable() else "?" for character in rendered) + if len(rendered) > _MAX_OBSERVED_VERSION_LENGTH: + rendered = rendered[: _MAX_OBSERVED_VERSION_LENGTH - 3] + "..." + return rendered + + +def _package_status( + package: OptionalRuntimePackageContract, + *, + version_resolver: Callable[[str], str], +) -> dict: + public = package.to_spec_dict() + try: + installed_version = _bounded_observed_version(version_resolver(package.distribution)) + except metadata.PackageNotFoundError: + return {**public, "status": "missing"} + except (ValueError, TypeError, OSError): + # Corrupt or unreadable local distribution metadata cannot establish an + # exact match. Classify it with the same fail-closed readiness as a + # version mismatch without reflecting untrusted exception text. + return { + **public, + "status": "wrong_version", + "metadataState": "unreadable", + } + + return { + **public, + "status": "present_unqualified" if installed_version == package.version else "wrong_version", + "installedVersion": installed_version, + } + + +def public_optional_runtime_profiles( + profile_ids: Iterable[str] | None = None, + *, + version_resolver: Callable[[str], str] | None = None, +) -> list[dict]: + """Publish exact contracts plus local metadata-only presence observations. + + Exact package presence is intentionally reported as ``present_unqualified``: + this slice neither validates an overlay origin nor makes the runtime ready + for optional-runtime cutover. + """ + + resolve_version = version_resolver or metadata.version + public_profiles = [] + for profile in _selected_profiles(profile_ids): + root_packages = [package for package in profile.packages if package.role == "runtime_root"] + package_statuses = [ + _package_status(package, version_resolver=resolve_version) + for package in root_packages + ] + package_states = {package["status"] for package in package_statuses} + if "missing" in package_states: + status = "missing" + elif "wrong_version" in package_states: + status = "wrong_version" + else: + status = "present_unqualified" + + public_profiles.append( + { + **profile.to_spec_dict(), + "specDigest": profile.spec_digest, + "status": status, + "requirements": [package.requirement for package in root_packages], + "stagedRequirements": [package.requirement for package in profile.packages], + "packages": package_statuses, + } + ) + return public_profiles + + +def optional_runtime_requirements(profile_ids: Iterable[str]) -> tuple[str, ...]: + """Resolve exact requirements without consulting the host environment.""" + + requirements: list[str] = [] + for profile in _selected_profiles(profile_ids): + for package in profile.packages: + if package.requirement not in requirements: + requirements.append(package.requirement) + return tuple(requirements) + + +def optional_runtime_base_distributions( + profile_ids: Iterable[str], + *, + platform_name: str | None = None, +) -> tuple[str, ...]: + """Resolve base-owned dependency names for the current platform.""" + + selected_platform = platform_name or ( + "windows" if sys.platform.startswith("win") else "macos" if sys.platform == "darwin" else "linux" + ) + distributions: list[str] = [] + for profile in _selected_profiles(profile_ids): + for package in profile.base_packages: + if package.platforms and selected_platform not in package.platforms: + continue + if package.distribution not in distributions: + distributions.append(package.distribution) + return tuple(distributions) + + +def optional_runtime_base_contracts( + profile_ids: Iterable[str], + *, + platform_name: str | None = None, +) -> tuple[dict, ...]: + """Resolve executable base-owned contracts for fresh-process validation.""" + + selected_platform = platform_name or ( + "windows" if sys.platform.startswith("win") else "macos" if sys.platform == "darwin" else "linux" + ) + contracts: list[dict] = [] + seen: set[str] = set() + for profile in _selected_profiles(profile_ids): + for package in profile.base_packages: + if package.platforms and selected_platform not in package.platforms: + continue + if package.distribution in seen: + continue + seen.add(package.distribution) + contracts.append(package.to_spec_dict()) + return tuple(contracts) diff --git a/modiff/preflight.py b/modiff/preflight.py index fb25ddb..6083cb9 100644 --- a/modiff/preflight.py +++ b/modiff/preflight.py @@ -21,7 +21,6 @@ ("nanoid", "nanoid"), ("torch", "torch"), ("diffusers", "diffusers"), - ("transformers", "transformers"), ("huggingface_hub", "huggingface-hub"), ("accelerate", "accelerate"), ("safetensors", "safetensors"), @@ -34,7 +33,6 @@ ("kornia", "kornia"), ("imageio", "imageio"), ("imageio_ffmpeg", "imageio-ffmpeg"), - ("peft", "peft"), ("torchsde", "torchsde"), ("ftfy", "ftfy"), ("einops", "einops"), @@ -47,23 +45,17 @@ ("nunchaku", "nunchaku"), ("torchao", "torchao"), ], + # Optional-runtime packages are metadata observations only. Even --full + # must not import an unqualified base copy before the overlay boundary has + # validated its exact version, symbols, origin, and host binding. + "optional_runtime": [ + ("transformers", "transformers"), + ("peft", "peft"), + ], } CANONICAL_ENTRYPOINT = "python -m modiff.preflight" -# These APIs are part of MoDiff's pinned Diffusers contract rather than -# optional feature detection. Treating an older release wheel as healthy can -# otherwise let the app start successfully and fail only after a long model -# load, as happened with ACE-Step LoRA workflows. -REQUIRED_RUNTIME_APIS = { - "diffusers": ( - ("AceStepPipeline", "load_lora_weights"), - ("AceStepPipeline", "set_adapters"), - ("AceStepPipeline", "unload_lora_weights"), - ), -} - - def setup_guidance(root): return { "preferredCommand": "./install.sh", @@ -179,20 +171,8 @@ def package_status(module_name, distribution_name, import_check=True): status["available"] = True status["import_ms"] = round((time.perf_counter() - started) * 1000) status["version"] = getattr(module, "__version__", status.get("version")) - missing_apis = [] - for owner_name, attribute_name in REQUIRED_RUNTIME_APIS.get(module_name, ()): - owner = getattr(module, owner_name, None) - if owner is None or not callable(getattr(owner, attribute_name, None)): - missing_apis.append(f"{owner_name}.{attribute_name}") - if missing_apis: - status["available"] = False - status["contractMissing"] = missing_apis - status["error"] = ( - "Installed package does not satisfy MoDiff's pinned runtime contract: " - + ", ".join(missing_apis) - + ". Repair the managed environment before starting MoDiff." - ) except Exception as error: + status["available"] = False status["error"] = str(error) return status @@ -215,7 +195,11 @@ def build_report(args): for group, checks in PACKAGE_CHECKS.items(): packages[group] = [] for module_name, distribution_name in checks: - status = package_status(module_name, distribution_name, import_check=group == "required" or args.full) + status = package_status( + module_name, + distribution_name, + import_check=(group == "required" or args.full) and group != "optional_runtime", + ) packages[group].append(status) if group == "required" and not status["available"]: missing_required.append(module_name) diff --git a/modiff/runtime_overlays.py b/modiff/runtime_overlays.py new file mode 100644 index 0000000..4826443 --- /dev/null +++ b/modiff/runtime_overlays.py @@ -0,0 +1,2525 @@ +"""Security boundary for app-managed Python package overlays. + +The functions in this module are deliberately standard-library only. They +stage packages without mutating the running interpreter, serialize every +installer through one process- and OS-backed lease, and validate a completed overlay in +an isolated child interpreter before it can be promoted or activated. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import base64 +import configparser +import csv +from email.parser import BytesParser +from email.policy import default as email_policy +import hashlib +import importlib +import importlib.util +from importlib import metadata +import json +import io +import os +from pathlib import Path +import platform +from pathlib import PurePosixPath +import signal +import stat +import subprocess +import sys +import tempfile +import threading +import time +import unicodedata +import uuid +from typing import Any, Iterable +from urllib.request import HTTPSHandler, ProxyHandler, Request, build_opener +import zipfile + + +PYPI_SIMPLE_INDEX = "https://pypi.org/simple" +PINNED_DIFFUSERS_SOURCE_URL = "https://github.com/huggingface/diffusers.git" +PINNED_DIFFUSERS_COMMIT = "13a7bee4878d62fccc8d25f97e480e68de96fa03" +PINNED_DIFFUSERS_VERSION = "0.40.0.dev0" +_DIGEST_PREFIX = "sha256:" +MANAGED_ROOT = Path( + os.environ.get("MODIFF_MANAGED_ROOT") or Path(__file__).resolve().parents[1] / ".modiff" +) +INSTALL_LEASE_PATH = MANAGED_ROOT / "optimizations" / "install.lock" +_INSTALL_LOCK = threading.Lock() +_ACTIVE_INSTALL: "InstallLease | None" = None +MAX_LOCKED_ARCHIVE_BYTES = 512 * 1024**2 +MAX_LOCKED_ARCHIVE_TOTAL_BYTES = 2 * 1024**3 +MAX_LOCKED_WHEEL_ENTRIES = 100_000 +MAX_LOCKED_WHEEL_MEMBER_BYTES = 512 * 1024**2 +MAX_LOCKED_WHEEL_TOTAL_BYTES = 2 * 1024**3 + + +class OverlayCancelled(RuntimeError): + """Raised after the caller cancels an in-progress staging operation.""" + + +class OverlayInstallBusy(RuntimeError): + """Raised when another optional-runtime install owns the global lease.""" + + +class OverlayStorageUnsafe(RuntimeError): + """Raised when the managed lease/staging root fails containment checks.""" + + +def ensure_managed_directory(path: Path, *, managed_root: Path = MANAGED_ROOT) -> Path: + """Create/validate a directory chain without accepting links or reparse points.""" + + root = Path(managed_root) + root.mkdir(parents=True, exist_ok=True) + root_info = root.lstat() + if ( + not stat.S_ISDIR(root_info.st_mode) + or stat.S_ISLNK(root_info.st_mode) + or bool( + getattr(root_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("The managed runtime root is a link or reparse point.") + trusted_root = root.resolve(strict=True) + raw_path = Path(path) + try: + relative = raw_path.absolute().relative_to(root.absolute()) + except ValueError as exc: + raise RuntimeError("The managed runtime directory escapes its root.") from exc + current = root + for part in relative.parts: + current = current / part + try: + current.mkdir() + except FileExistsError: + pass + info = current.lstat() + if ( + not stat.S_ISDIR(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or bool( + getattr(info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("A managed runtime directory is a link or reparse point.") + current.resolve(strict=True).relative_to(trusted_root) + return current.resolve(strict=True) + + +def _managed_child( + path: Path, + parent: Path, + *, + managed_root: Path | None = None, +) -> tuple[Path, str]: + """Return one validated direct-child name beneath an anchored parent.""" + + raw_path = Path(path).absolute() + raw_parent = Path(parent).absolute() + if ( + raw_path.parent != raw_parent + or raw_path.name in {"", ".", ".."} + or len(raw_path.name) > 255 + or any(ord(character) < 32 or ord(character) == 127 for character in raw_path.name) + ): + raise OverlayStorageUnsafe("A managed runtime operation has an invalid child path.") + try: + trusted_parent = ensure_managed_directory( + raw_parent, + managed_root=MANAGED_ROOT if managed_root is None else managed_root, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("A managed runtime parent directory is unsafe.") from exc + return trusted_parent, raw_path.name + + +def _safe_directory_details(details: os.stat_result) -> bool: + return ( + stat.S_ISDIR(details.st_mode) + and not stat.S_ISLNK(details.st_mode) + and not bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ) + + +def _windows_open_path( + path: Path, + *, + directory: bool, + writable: bool = False, +) -> tuple[Any, tuple[int, int]]: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateFileW.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + kernel32.CreateFileW.restype = wintypes.HANDLE + desired_access = 0x00010000 | 0x00000080 | 0x00100000 + if writable: + desired_access |= 0x40000000 # GENERIC_WRITE + if directory: + desired_access |= 0x00000001 | 0x00000020 # FILE_LIST_DIRECTORY | FILE_TRAVERSE + handle = kernel32.CreateFileW( + str(path), + desired_access, + 0x00000001 | 0x00000002 | 0x00000004, + None, + 3, # OPEN_EXISTING + 0x02000000 | 0x00200000, # BACKUP_SEMANTICS | OPEN_REPARSE_POINT + None, + ) + if handle == wintypes.HANDLE(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + + class FILE_INFO(ctypes.Structure): + _fields_ = [ + ("attributes", wintypes.DWORD), + ("creation_low", wintypes.DWORD), + ("creation_high", wintypes.DWORD), + ("access_low", wintypes.DWORD), + ("access_high", wintypes.DWORD), + ("write_low", wintypes.DWORD), + ("write_high", wintypes.DWORD), + ("volume_serial", wintypes.DWORD), + ("size_high", wintypes.DWORD), + ("size_low", wintypes.DWORD), + ("links", wintypes.DWORD), + ("index_high", wintypes.DWORD), + ("index_low", wintypes.DWORD), + ] + + kernel32.GetFileInformationByHandle.argtypes = (wintypes.HANDLE, ctypes.POINTER(FILE_INFO)) + kernel32.GetFileInformationByHandle.restype = wintypes.BOOL + info = FILE_INFO() + if not kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)): + error = ctypes.get_last_error() + kernel32.CloseHandle(handle) + raise ctypes.WinError(error) + is_directory = bool(info.attributes & 0x00000010) + is_reparse = bool(info.attributes & 0x00000400) + if is_directory != directory or is_reparse: + kernel32.CloseHandle(handle) + raise OverlayStorageUnsafe("A managed runtime entry is not an exact regular path.") + identity = (int(info.volume_serial), (int(info.index_high) << 32) | int(info.index_low)) + return handle, identity + + +def _windows_close_handle(handle: Any) -> None: + import ctypes + + if handle is not None: + ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(handle) + + +def _windows_rename_directory( + source: Path, + destination: Path, + *, + expected_identity: tuple[int, int] | None = None, +) -> None: + import ctypes + from ctypes import wintypes + + source_handle = parent_handle = destination_handle = None + try: + source_handle, source_identity = _windows_open_path(source, directory=True) + if expected_identity is not None and source_identity != expected_identity: + raise OverlayStorageUnsafe("The staged runtime directory changed identity.") + parent_handle, _ = _windows_open_path(destination.parent, directory=True) + handle_destination = _windows_final_path(parent_handle) / destination.name + try: + handle_destination.lstat() + except FileNotFoundError: + pass + else: + raise OverlayStorageUnsafe("A managed runtime promotion target already exists.") + + destination_name = str(handle_destination) + name_length = len(destination_name) + + class FILE_RENAME_INFO_EX(ctypes.Structure): + _fields_ = [ + ("flags", wintypes.DWORD), + ("root", wintypes.HANDLE), + ("name_bytes", wintypes.DWORD), + ("name", wintypes.WCHAR * (name_length + 1)), + ] + + rename = FILE_RENAME_INFO_EX() + rename.flags = 0x00000002 # FILE_RENAME_FLAG_WRITE_THROUGH + rename.root = None + rename.name_bytes = len(destination_name.encode("utf-16-le")) + rename.name = destination_name + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.SetFileInformationByHandle.argtypes = ( + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ) + kernel32.SetFileInformationByHandle.restype = wintypes.BOOL + if not kernel32.SetFileInformationByHandle( + source_handle, + 22, # FileRenameInfoEx + ctypes.byref(rename), + FILE_RENAME_INFO_EX.name.offset + rename.name_bytes, + ): + raise ctypes.WinError(ctypes.get_last_error()) + destination_handle, destination_identity = _windows_open_path( + handle_destination, + directory=True, + ) + if destination_identity != source_identity: + raise OverlayStorageUnsafe("The promoted runtime directory changed identity.") + finally: + _windows_close_handle(destination_handle) + _windows_close_handle(parent_handle) + _windows_close_handle(source_handle) + + +def _posix_open_directory(path: str | Path, *, directory_fd: int | None = None) -> int: + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + return os.open(path, flags, dir_fd=directory_fd) + + +def _posix_rename_noreplace( + source_parent_fd: int, + source_name: str, + destination_parent_fd: int, + destination_name: str, +) -> None: + import ctypes + + library = ctypes.CDLL(None, use_errno=True) + source = os.fsencode(source_name) + destination = os.fsencode(destination_name) + if sys.platform.startswith("linux") and hasattr(library, "renameat2"): + operation = library.renameat2 + flag = 1 # RENAME_NOREPLACE + elif sys.platform == "darwin" and hasattr(library, "renameatx_np"): + operation = library.renameatx_np + flag = 0x00000004 # RENAME_EXCL + else: + raise OverlayStorageUnsafe( + "This platform lacks the required exclusive handle-relative rename primitive." + ) + operation.argtypes = ( + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ) + operation.restype = ctypes.c_int + if operation( + source_parent_fd, + source, + destination_parent_fd, + destination, + flag, + ) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination_name) + + +def _posix_rename_directory( + source: Path, + destination: Path, + *, + expected_identity: tuple[int, int] | None = None, +) -> None: + source_parent_fd = destination_parent_fd = source_fd = None + try: + source_parent_fd = _posix_open_directory(source.parent) + destination_parent_fd = _posix_open_directory(destination.parent) + source_fd = _posix_open_directory(source.name, directory_fd=source_parent_fd) + source_details = os.fstat(source_fd) + named_details = os.stat(source.name, dir_fd=source_parent_fd, follow_symlinks=False) + if ( + not _safe_directory_details(source_details) + or ( + expected_identity is not None + and (source_details.st_dev, source_details.st_ino) != expected_identity + ) + or (source_details.st_dev, source_details.st_ino) + != (named_details.st_dev, named_details.st_ino) + ): + raise OverlayStorageUnsafe("The staged runtime directory changed identity.") + try: + os.stat(destination.name, dir_fd=destination_parent_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise OverlayStorageUnsafe("A managed runtime promotion target already exists.") + _posix_rename_noreplace( + source_parent_fd, + source.name, + destination_parent_fd, + destination.name, + ) + promoted = os.stat(destination.name, dir_fd=destination_parent_fd, follow_symlinks=False) + if (source_details.st_dev, source_details.st_ino) != (promoted.st_dev, promoted.st_ino): + raise OverlayStorageUnsafe("The promoted runtime directory changed identity.") + os.fsync(destination_parent_fd) + if source_parent_fd != destination_parent_fd: + os.fsync(source_parent_fd) + finally: + for descriptor in (source_fd, destination_parent_fd, source_parent_fd): + if descriptor is not None: + os.close(descriptor) + + +def managed_directory_identity(path: Path) -> tuple[int, int]: + """Capture the filesystem identity of one safe managed directory.""" + + trusted_parent, name = _managed_child(path, path.parent) + anchored = trusted_parent / name + if os.name == "nt": + handle = None + try: + handle, identity = _windows_open_path(anchored, directory=True) + return identity + finally: + _windows_close_handle(handle) + parent_descriptor = descriptor = None + try: + parent_descriptor = _posix_open_directory(trusted_parent) + descriptor = _posix_open_directory(name, directory_fd=parent_descriptor) + details = os.fstat(descriptor) + return int(details.st_dev), int(details.st_ino) + finally: + for opened in (descriptor, parent_descriptor): + if opened is not None: + os.close(opened) + + +def flush_managed_directory(path: Path, *, managed_root: Path | None = None) -> None: + """Durably flush metadata changes for one exact managed directory.""" + + try: + anchored = ensure_managed_directory( + path, + managed_root=MANAGED_ROOT if managed_root is None else managed_root, + ) + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("The managed directory flush target is unsafe.") from exc + if os.name == "nt": + import ctypes + from ctypes import wintypes + + handle = None + try: + handle, _ = _windows_open_path(anchored, directory=True, writable=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.FlushFileBuffers.argtypes = (wintypes.HANDLE,) + kernel32.FlushFileBuffers.restype = wintypes.BOOL + if not kernel32.FlushFileBuffers(handle): + raise ctypes.WinError(ctypes.get_last_error()) + finally: + _windows_close_handle(handle) + return + descriptor = None + try: + descriptor = _posix_open_directory(anchored) + os.fsync(descriptor) + finally: + if descriptor is not None: + os.close(descriptor) + + +def promote_managed_directory( + source: Path, + destination: Path, + *, + expected_identity: tuple[int, int] | None = None, +) -> None: + """Move one exact managed directory without replacing an existing target.""" + + source_parent, source_name = _managed_child(source, source.parent) + destination_parent, destination_name = _managed_child(destination, destination.parent) + anchored_source = source_parent / source_name + anchored_destination = destination_parent / destination_name + try: + if os.name == "nt": + _windows_rename_directory( + anchored_source, + anchored_destination, + expected_identity=expected_identity, + ) + else: + _posix_rename_directory( + anchored_source, + anchored_destination, + expected_identity=expected_identity, + ) + except OverlayStorageUnsafe: + raise + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("The staged runtime directory could not be promoted safely.") from exc + + +def _posix_remove_contents(directory_fd: int) -> None: + for name in os.listdir(directory_fd): + details = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if stat.S_ISDIR(details.st_mode) and not stat.S_ISLNK(details.st_mode): + child_fd = _posix_open_directory(name, directory_fd=directory_fd) + try: + opened = os.fstat(child_fd) + if (details.st_dev, details.st_ino) != (opened.st_dev, opened.st_ino): + raise OverlayStorageUnsafe("A managed cleanup directory changed identity.") + _posix_remove_contents(child_fd) + current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino): + raise OverlayStorageUnsafe("A managed cleanup directory changed identity.") + os.rmdir(name, dir_fd=directory_fd) + finally: + os.close(child_fd) + else: + os.unlink(name, dir_fd=directory_fd) + + +def _windows_final_path(handle: Any) -> Path: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetFinalPathNameByHandleW.argtypes = ( + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ) + kernel32.GetFinalPathNameByHandleW.restype = wintypes.DWORD + size = kernel32.GetFinalPathNameByHandleW(handle, None, 0, 0) + if not size or size > 32768: + raise ctypes.WinError(ctypes.get_last_error()) + buffer = ctypes.create_unicode_buffer(size) + written = kernel32.GetFinalPathNameByHandleW(handle, buffer, size, 0) + if not written or written >= size: + raise ctypes.WinError(ctypes.get_last_error()) + return Path(buffer.value) + + +def _windows_delete_handle(handle: Any) -> None: + import ctypes + from ctypes import wintypes + + class FILE_DISPOSITION_INFO_EX(ctypes.Structure): + _fields_ = [("flags", wintypes.DWORD)] + + disposition = FILE_DISPOSITION_INFO_EX(0x00000001 | 0x00000002 | 0x00000010) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.SetFileInformationByHandle.argtypes = ( + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ) + kernel32.SetFileInformationByHandle.restype = wintypes.BOOL + if not kernel32.SetFileInformationByHandle( + handle, + 21, # FileDispositionInfoEx + ctypes.byref(disposition), + ctypes.sizeof(disposition), + ): + raise ctypes.WinError(ctypes.get_last_error()) + + +def _windows_remove_contents(directory_handle: Any) -> None: + directory = _windows_final_path(directory_handle) + with os.scandir(directory) as entries: + names = [entry.name for entry in entries] + for name in names: + child = directory / name + child_details = child.lstat() + is_directory = stat.S_ISDIR(child_details.st_mode) + child_handle = None + try: + child_handle, _ = _windows_open_path(child, directory=is_directory) + if is_directory: + _windows_remove_contents(child_handle) + _windows_delete_handle(child_handle) + finally: + _windows_close_handle(child_handle) + + +def remove_managed_directory( + path: Path, + *, + parent: Path, + expected_identity: tuple[int, int] | None = None, +) -> bool: + """Quarantine and remove one exact direct-child directory without following links.""" + + trusted_parent, name = _managed_child(path, parent) + source = trusted_parent / name + try: + details = source.lstat() + except FileNotFoundError: + return False + if not _safe_directory_details(details): + raise OverlayStorageUnsafe("The managed cleanup target is not a regular directory.") + quarantine = trusted_parent / f".cleanup-{uuid.uuid4().hex}" + if os.name == "nt": + _windows_rename_directory( + source, + quarantine, + expected_identity=expected_identity, + ) + handle = None + try: + handle, _ = _windows_open_path(quarantine, directory=True) + _windows_remove_contents(handle) + _windows_delete_handle(handle) + finally: + _windows_close_handle(handle) + return True + + parent_fd = directory_fd = None + try: + parent_fd = _posix_open_directory(trusted_parent) + directory_fd = _posix_open_directory(name, directory_fd=parent_fd) + opened = os.fstat(directory_fd) + named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if ( + (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino) + or ( + expected_identity is not None + and (opened.st_dev, opened.st_ino) != expected_identity + ) + ): + raise OverlayStorageUnsafe("The managed cleanup target changed identity.") + _posix_rename_noreplace(parent_fd, name, parent_fd, quarantine.name) + quarantined = os.stat(quarantine.name, dir_fd=parent_fd, follow_symlinks=False) + if (opened.st_dev, opened.st_ino) != (quarantined.st_dev, quarantined.st_ino): + raise OverlayStorageUnsafe("The quarantined cleanup target changed identity.") + _posix_remove_contents(directory_fd) + quarantined = os.stat(quarantine.name, dir_fd=parent_fd, follow_symlinks=False) + if (opened.st_dev, opened.st_ino) != (quarantined.st_dev, quarantined.st_ino): + raise OverlayStorageUnsafe("The quarantined cleanup target changed identity.") + os.rmdir(quarantine.name, dir_fd=parent_fd) + return True + except OverlayStorageUnsafe: + raise + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("The managed runtime directory could not be removed safely.") from exc + finally: + for descriptor in (directory_fd, parent_fd): + if descriptor is not None: + os.close(descriptor) + + +def remove_managed_file( + path: Path, + *, + parent: Path, + managed_root: Path | None = None, +) -> bool: + """Remove one exact regular direct-child file without following replacements.""" + + trusted_parent, name = _managed_child( + path, + parent, + managed_root=managed_root, + ) + target = trusted_parent / name + try: + details = target.lstat() + except FileNotFoundError: + return False + if ( + not stat.S_ISREG(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or getattr(details, "st_nlink", 1) != 1 + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OverlayStorageUnsafe("The managed cleanup target is not a regular file.") + if os.name == "nt": + handle = None + try: + handle, _ = _windows_open_path(target, directory=False) + _windows_delete_handle(handle) + return True + except OverlayStorageUnsafe: + raise + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("The managed runtime file could not be removed safely.") from exc + finally: + _windows_close_handle(handle) + + parent_fd = target_fd = None + try: + parent_fd = _posix_open_directory(trusted_parent) + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + target_fd = os.open(name, flags, dir_fd=parent_fd) + opened = os.fstat(target_fd) + named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if ( + not stat.S_ISREG(opened.st_mode) + or getattr(opened, "st_nlink", 1) != 1 + or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino) + ): + raise OverlayStorageUnsafe("The managed cleanup file changed identity.") + os.unlink(name, dir_fd=parent_fd) + return True + except OverlayStorageUnsafe: + raise + except (OSError, RuntimeError, ValueError) as exc: + raise OverlayStorageUnsafe("The managed runtime file could not be removed safely.") from exc + finally: + for descriptor in (target_fd, parent_fd): + if descriptor is not None: + os.close(descriptor) + + +@dataclass +class InstallLease: + """One authoritative process-local install and its running subprocess.""" + + token: str + owner_kind: str + owner_id: str + cancel_event: threading.Event + process: subprocess.Popen | None = None + lock_file: Any = None + committed: bool = False + staged_identity: tuple[int, int] | None = None + + +def _acquire_os_lock() -> Any: + lock_file = None + try: + lock_parent = ensure_managed_directory( + INSTALL_LEASE_PATH.parent, + managed_root=MANAGED_ROOT, + ) + lock_path = lock_parent / INSTALL_LEASE_PATH.name + flags = os.O_RDWR | os.O_CREAT + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(lock_path, flags, 0o600) + lock_file = os.fdopen(descriptor, "r+b", closefd=True) + path_info = lock_path.lstat() + file_info = os.fstat(lock_file.fileno()) + if ( + not stat.S_ISREG(path_info.st_mode) + or stat.S_ISLNK(path_info.st_mode) + or bool( + getattr(path_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + or getattr(path_info, "st_nlink", 1) != 1 + or (path_info.st_dev, path_info.st_ino) != (file_info.st_dev, file_info.st_ino) + ): + raise OSError("The optional-runtime lease file is unsafe.") + lock_path.resolve(strict=True).relative_to(lock_parent) + except (OSError, RuntimeError, ValueError) as exc: + if lock_file is not None: + lock_file.close() + raise OverlayStorageUnsafe("The optional-runtime lease path is unsafe.") from exc + try: + if os.name == "nt": + import msvcrt + + if lock_file.seek(0, os.SEEK_END) == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, IOError) as exc: + lock_file.close() + raise OverlayInstallBusy("Another optional-runtime installation is already active.") from exc + return lock_file + + +def _release_os_lock(lock_file: Any) -> None: + if lock_file is None: + return + try: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + except (OSError, IOError): + pass + finally: + lock_file.close() + + +def reserve_install(owner_kind: str, owner_id: str) -> InstallLease: + """Reserve the one install slot before an HTTP handler returns ``202``.""" + + global _ACTIVE_INSTALL + kind = str(owner_kind or "").strip() + identifier = str(owner_id or "").strip() + if not kind or not identifier or len(kind) > 64 or len(identifier) > 256: + raise ValueError("A bounded install owner kind and identifier are required.") + with _INSTALL_LOCK: + if _ACTIVE_INSTALL is not None: + raise OverlayInstallBusy("Another optional-runtime installation is already active.") + lock_file = _acquire_os_lock() + lease = InstallLease( + token=f"overlay-install-{uuid.uuid4().hex}", + owner_kind=kind, + owner_id=identifier, + cancel_event=threading.Event(), + lock_file=lock_file, + ) + _ACTIVE_INSTALL = lease + return lease + + +def active_install() -> dict[str, str] | None: + with _INSTALL_LOCK: + if _ACTIVE_INSTALL is None: + return None + return { + "token": _ACTIVE_INSTALL.token, + "ownerKind": _ACTIVE_INSTALL.owner_kind, + "ownerId": _ACTIVE_INSTALL.owner_id, + } + + +def cancel_install(token: str) -> bool: + """Signal cancellation and terminate the exact child process, if running.""" + + with _INSTALL_LOCK: + lease = _ACTIVE_INSTALL + if lease is None or lease.token != token or lease.committed: + return False + lease.cancel_event.set() + process = lease.process + if process is not None: + _terminate_process(process) + return True + + +def release_install(lease: InstallLease) -> None: + global _ACTIVE_INSTALL + with _INSTALL_LOCK: + if _ACTIVE_INSTALL is lease: + _ACTIVE_INSTALL = None + lease.process = None + lock_file = lease.lock_file + lease.lock_file = None + _release_os_lock(lock_file) + + +def promote_staged_environment(lease: InstallLease, staged: Path, destination: Path) -> None: + """Atomically commit only while the exact uncancelled lease is authoritative.""" + + with _INSTALL_LOCK: + if _ACTIVE_INSTALL is not lease or lease.cancel_event.is_set() or lease.committed: + raise OverlayCancelled("Optional-runtime installation was cancelled before promotion.") + promote_managed_directory( + staged, + destination, + expected_identity=lease.staged_identity, + ) + lease.committed = True + + +def _set_lease_process(lease: InstallLease, process: subprocess.Popen | None) -> None: + with _INSTALL_LOCK: + if _ACTIVE_INSTALL is not lease: + if process is not None: + _terminate_process(process) + raise OverlayCancelled("The optional-runtime install lease is no longer active.") + lease.process = process + + +def _terminate_process(process: subprocess.Popen) -> None: + if os.name == "nt" and process.poll() is not None: + return + process_group = process.pid + try: + if os.name != "nt": + os.killpg(process_group, signal.SIGTERM) + else: + system_root = Path(os.environ.get("SystemRoot") or r"C:\Windows") + taskkill = (system_root / "System32" / "taskkill.exe").resolve() + subprocess.run( + [str(taskkill), "/PID", str(process.pid), "/T", "/F"], + capture_output=True, + check=False, + timeout=5, + ) + process.wait(timeout=2) + except (OSError, ProcessLookupError, subprocess.TimeoutExpired): + pass + try: + if os.name != "nt": + # The leader can exit while a child ignores SIGTERM. Always issue + # the terminal signal to the original process group as well. + os.killpg(process_group, signal.SIGKILL) + else: + if process.poll() is None: + process.kill() + if process.poll() is None: + process.wait(timeout=2) + except (OSError, ProcessLookupError, subprocess.TimeoutExpired): + pass + + +def sanitized_install_environment(site_packages: Path) -> dict[str, str]: + """Return an install environment that cannot redirect the reviewed index.""" + + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith(("PIP_", "UV_")) + and key.upper() not in {"PYTHONPATH", "PYTHONHOME"} + } + environment.update( + { + "PIP_NO_INPUT": "1", + "UV_NO_PROGRESS": "1", + "UV_NO_CONFIG": "1", + "MODIFF_STAGED_SITE_PACKAGES": str(site_packages), + "PYTHONDONTWRITEBYTECODE": "1", + } + ) + return environment + + +def _stream_sha256( + path: Path, + *, + maximum_bytes: int, + cancel_event: threading.Event | None = None, +) -> tuple[str, int]: + details = path.lstat() + if ( + not stat.S_ISREG(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or details.st_nlink != 1 + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + or details.st_size > maximum_bytes + ): + raise RuntimeError("A locked optional-runtime artifact is unsafe or oversized.") + hasher = hashlib.sha256() + observed = 0 + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + if cancel_event is not None and cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime artifact verification was cancelled.") + observed += len(chunk) + if observed > maximum_bytes: + raise RuntimeError("A locked optional-runtime artifact exceeds its safe limit.") + hasher.update(chunk) + if observed != details.st_size: + raise RuntimeError("A locked optional-runtime artifact changed while it was read.") + return hasher.hexdigest(), observed + + +def locked_artifact_path(archive_root: Path, artifact: dict[str, Any]) -> Path: + """Derive a contained cache path from a reviewed artifact lock.""" + + digest = str(artifact.get("sha256") or "").lower() + filename = str(artifact.get("filename") or "") + if ( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + or not filename + or len(filename) > 256 + or Path(filename).name != filename + or not filename.endswith(".whl") + ): + raise RuntimeError("The optional-runtime artifact lock is malformed.") + root = archive_root.resolve(strict=True) + candidate = (root / digest / filename).resolve(strict=False) + try: + candidate.relative_to(root) + except ValueError as exc: + raise RuntimeError("The optional-runtime artifact cache path escapes its root.") from exc + return candidate + + +def cache_locked_artifacts( + artifacts: Iterable[dict[str, Any]], + archive_root: Path, + *, + lease: InstallLease, +) -> list[Path]: + """Acquire exact catalog URLs into a hash-addressed cache under the lease.""" + + archive_root.mkdir(parents=True, exist_ok=True) + root_info = archive_root.lstat() + if ( + not stat.S_ISDIR(root_info.st_mode) + or stat.S_ISLNK(root_info.st_mode) + or bool( + getattr(root_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("The optional-runtime artifact cache root is unsafe.") + opener = build_opener(ProxyHandler({}), HTTPSHandler()) + cached: list[Path] = [] + total = 0 + for artifact in artifacts: + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime artifact acquisition was cancelled.") + destination = locked_artifact_path(archive_root, artifact) + expected_size = artifact.get("byteSize") + if ( + isinstance(expected_size, bool) + or not isinstance(expected_size, int) + or expected_size <= 0 + or expected_size > MAX_LOCKED_ARCHIVE_BYTES + ): + raise RuntimeError("The optional-runtime artifact size lock is malformed.") + destination.parent.mkdir(parents=False, exist_ok=True) + parent_info = destination.parent.lstat() + if not stat.S_ISDIR(parent_info.st_mode) or stat.S_ISLNK(parent_info.st_mode): + raise RuntimeError("The optional-runtime artifact cache directory is unsafe.") + if destination.exists(): + observed_digest, observed_size = _stream_sha256( + destination, + maximum_bytes=MAX_LOCKED_ARCHIVE_BYTES, + cancel_event=lease.cancel_event, + ) + if observed_digest != artifact["sha256"] or observed_size != expected_size: + raise RuntimeError("A cached optional-runtime artifact failed its catalog digest.") + total += observed_size + if total > MAX_LOCKED_ARCHIVE_TOTAL_BYTES: + raise RuntimeError("The locked artifact set exceeds its reviewed size bound.") + cached.append(destination) + continue + temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.part") + try: + request = Request( + str(artifact["url"]), + headers={"User-Agent": "MoDiff optional-runtime artifact acquisition"}, + ) + with opener.open(request, timeout=15) as response, temporary.open("xb") as output: + if response.geturl() != artifact["url"]: + raise RuntimeError("A locked artifact URL redirected outside its reviewed source.") + length = response.headers.get("Content-Length") + if length is not None and int(length) != expected_size: + raise RuntimeError("A locked optional-runtime artifact has an unexpected size.") + hasher = hashlib.sha256() + observed_size = 0 + while chunk := response.read(1024 * 1024): + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime artifact acquisition was cancelled.") + observed_size += len(chunk) + if observed_size > MAX_LOCKED_ARCHIVE_BYTES: + raise RuntimeError("A locked optional-runtime artifact is oversized.") + output.write(chunk) + hasher.update(chunk) + output.flush() + os.fsync(output.fileno()) + if hasher.hexdigest() != artifact["sha256"] or observed_size != expected_size: + raise RuntimeError("A downloaded optional-runtime artifact failed its catalog digest.") + temporary.replace(destination) + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + total += observed_size + if total > MAX_LOCKED_ARCHIVE_TOTAL_BYTES: + raise RuntimeError("The locked artifact set exceeds its reviewed size bound.") + cached.append(destination) + return cached + + +def _wheel_target_path(member_name: str) -> str: + if ( + not member_name + or len(member_name) > 1024 + or "\\" in member_name + or any(ord(character) < 32 or ord(character) == 127 for character in member_name) + ): + raise RuntimeError("A locked wheel contains an invalid member path.") + path = PurePosixPath(member_name) + if ( + path.is_absolute() + or len(path.parts) > 64 + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise RuntimeError("A locked wheel contains a traversal path.") + reserved = {"CON", "PRN", "AUX", "NUL"} | { + f"{prefix}{index}" for prefix in ("COM", "LPT") for index in range(1, 10) + } + for part in path.parts: + if ( + ":" in part + or any(character in '<>"|?*' for character in part) + or part.rstrip(" .") != part + or part.split(".", 1)[0].upper() in reserved + ): + raise RuntimeError("A locked wheel contains a Windows-unsafe member path.") + parts = list(path.parts) + if parts[0].endswith(".data"): + if len(parts) < 3 or parts[1] not in {"purelib", "platlib", "scripts"}: + raise RuntimeError("A locked wheel uses an unsupported data installation scheme.") + parts = (["bin"] if parts[1] == "scripts" else []) + parts[2:] + if not parts: + raise RuntimeError("A locked wheel member has no install target.") + target = PurePosixPath(*parts).as_posix() + if PurePosixPath(target).suffix.lower() == ".pth" or PurePosixPath(target).name.lower() in { + "sitecustomize.py", + "usercustomize.py", + }: + raise RuntimeError("A locked wheel contains a forbidden startup hook.") + return target + + +def _validate_locked_wheel_record( + body: bytes, + archive_files: dict[str, tuple[str, int]], + record_name: str, +) -> None: + """Require RECORD to authenticate every file in the reviewed wheel once.""" + + try: + text = body.decode("utf-8", errors="strict") + rows = csv.reader(io.StringIO(text, newline=""), strict=True) + observed: set[str] = set() + for row_count, row in enumerate(rows, start=1): + if row_count > MAX_LOCKED_WHEEL_ENTRIES or len(row) != 3: + raise RuntimeError("A locked wheel RECORD has an invalid row structure.") + name, digest, size = row + if not name or name in observed: + raise RuntimeError("A locked wheel RECORD contains an invalid or duplicate path.") + _wheel_target_path(name) + observed.add(name) + expected = archive_files.get(name) + if expected is None: + raise RuntimeError("A locked wheel RECORD names a file outside the wheel archive.") + if name == record_name: + if digest or size: + raise RuntimeError("A locked wheel RECORD must leave its own digest and size empty.") + continue + expected_digest, expected_size = expected + encoded_digest = base64.urlsafe_b64encode(bytes.fromhex(expected_digest)).rstrip(b"=").decode("ascii") + if digest != f"sha256={encoded_digest}" or size != str(expected_size): + raise RuntimeError("A locked wheel RECORD does not match its archived file bytes.") + except (csv.Error, UnicodeDecodeError) as exc: + raise RuntimeError("A locked wheel RECORD is not valid bounded UTF-8 CSV.") from exc + if observed != set(archive_files): + raise RuntimeError("A locked wheel RECORD does not cover every archived file exactly once.") + + +def _expanded_wheel_filename_tags(filename_parts: list[str]) -> set[str]: + components = [part.split(".") for part in filename_parts[-3:]] + if any( + not values + or len(values) > 16 + or any( + not value + or len(value) > 64 + or any(character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" for character in value) + for value in values + ) + for values in components + ): + raise RuntimeError("A locked wheel filename contains invalid compatibility tags.") + tags = { + f"{python_tag}-{abi_tag}-{platform_tag}" + for python_tag in components[0] + for abi_tag in components[1] + for platform_tag in components[2] + } + if not tags or len(tags) > 256: + raise RuntimeError("A locked wheel filename contains too many compatibility tags.") + return tags + + +def locked_artifact_file_seal( + artifacts: Iterable[dict[str, Any]], + archive_root: Path, + *, + lease: InstallLease | None = None, +) -> dict[str, str]: + """Authenticate wheel archives and derive their exact extracted file seal.""" + + root = archive_root.resolve(strict=True) + expected_files: dict[str, str] = {} + casefolded: set[str] = set() + total_archive_bytes = 0 + total_member_bytes = 0 + entries = 0 + seen_distributions: set[str] = set() + for artifact in artifacts: + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime artifact verification was cancelled.") + archive = locked_artifact_path(root, artifact) + archive.resolve(strict=True).relative_to(root) + observed_digest, observed_size = _stream_sha256( + archive, + maximum_bytes=MAX_LOCKED_ARCHIVE_BYTES, + cancel_event=lease.cancel_event if lease is not None else None, + ) + total_archive_bytes += observed_size + if ( + total_archive_bytes > MAX_LOCKED_ARCHIVE_TOTAL_BYTES + or observed_digest != artifact.get("sha256") + or observed_size != artifact.get("byteSize") + ): + raise RuntimeError("A locked wheel archive failed its catalog identity.") + filename_parts = str(artifact.get("filename") or "").removesuffix(".whl").split("-") + distribution = _normalized_distribution(artifact.get("distribution")) + version = str(artifact.get("version") or "") + if ( + len(filename_parts) < 5 + or _normalized_distribution(filename_parts[0]) != distribution + or filename_parts[1] != version + or distribution in seen_distributions + ): + raise RuntimeError("A locked wheel filename does not match its reviewed project/version.") + seen_distributions.add(distribution) + metadata_documents: list[bytes] = [] + wheel_documents: list[bytes] = [] + record_targets: list[str] = [] + record_documents: list[bytes] = [] + archive_files: dict[str, tuple[str, int]] = {} + metadata_parents: set[str] = set() + try: + wheel = zipfile.ZipFile(archive) + except (OSError, zipfile.BadZipFile) as exc: + raise RuntimeError("A locked optional-runtime artifact is not a valid wheel ZIP.") from exc + with wheel: + seen_archive_members: set[str] = set() + for info in wheel.infolist(): + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime artifact verification was cancelled.") + entries += 1 + if entries > MAX_LOCKED_WHEEL_ENTRIES: + raise RuntimeError("The locked wheel set contains too many members.") + target = _wheel_target_path(info.filename.rstrip("/")) + folded = unicodedata.normalize("NFC", target).casefold() + if target in seen_archive_members or folded in casefolded: + raise RuntimeError("The locked wheel set contains duplicate install targets.") + seen_archive_members.add(target) + mode = (info.external_attr >> 16) & 0xFFFF + file_type = stat.S_IFMT(mode) + expected_types = {0, stat.S_IFDIR} if info.is_dir() else {0, stat.S_IFREG} + if stat.S_ISLNK(mode) or file_type not in expected_types: + raise RuntimeError("A locked wheel contains a non-regular member.") + if info.is_dir(): + continue + casefolded.add(folded) + if info.file_size > MAX_LOCKED_WHEEL_MEMBER_BYTES: + raise RuntimeError("A locked wheel member exceeds its safe size.") + total_member_bytes += info.file_size + if total_member_bytes > MAX_LOCKED_WHEEL_TOTAL_BYTES: + raise RuntimeError("The locked wheel set exceeds its extracted size bound.") + hasher = hashlib.sha256() + observed_member_size = 0 + bounded_document = ( + bytearray() + if target.endswith( + (".dist-info/METADATA", ".dist-info/WHEEL", ".dist-info/RECORD") + ) + else None + ) + with wheel.open(info, "r") as source: + while chunk := source.read(1024 * 1024): + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled( + "Optional-runtime artifact verification was cancelled." + ) + observed_member_size += len(chunk) + if observed_member_size > info.file_size: + raise RuntimeError("A locked wheel member expanded beyond its declared size.") + hasher.update(chunk) + if bounded_document is not None: + document_limit = ( + 4 * 1024 * 1024 + if target.endswith(".dist-info/RECORD") + else 2 * 1024 * 1024 + ) + if len(bounded_document) + len(chunk) > document_limit: + raise RuntimeError("A locked wheel metadata document is oversized.") + bounded_document.extend(chunk) + if observed_member_size != info.file_size: + raise RuntimeError("A locked wheel member was truncated.") + member_digest = hasher.hexdigest() + expected_files[target] = member_digest + archive_name = PurePosixPath(info.filename).as_posix() + archive_files[archive_name] = (member_digest, observed_member_size) + if target.endswith(".dist-info/METADATA"): + metadata_documents.append(bytes(bounded_document or b"")) + metadata_parents.add(PurePosixPath(target).parent.as_posix()) + elif target.endswith(".dist-info/WHEEL"): + wheel_documents.append(bytes(bounded_document or b"")) + metadata_parents.add(PurePosixPath(target).parent.as_posix()) + elif target.endswith(".dist-info/RECORD"): + record_targets.append(target) + record_documents.append(bytes(bounded_document or b"")) + metadata_parents.add(PurePosixPath(target).parent.as_posix()) + if ( + len(metadata_documents) != 1 + or len(wheel_documents) != 1 + or len(record_targets) != 1 + or len(metadata_parents) != 1 + ): + raise RuntimeError("A locked wheel must contain one matching METADATA, WHEEL, and RECORD.") + _validate_locked_wheel_record(record_documents[0], archive_files, record_targets[0]) + dist_info_name = next(iter(metadata_parents)).removesuffix(".dist-info") + if "-" not in dist_info_name: + raise RuntimeError("A locked wheel has an invalid dist-info directory name.") + dist_info_project, dist_info_version = dist_info_name.rsplit("-", 1) + if ( + _normalized_distribution(dist_info_project) != distribution + or dist_info_version != version + ): + raise RuntimeError("A locked wheel dist-info path does not match its catalog lock.") + parsed_metadata = BytesParser(policy=email_policy).parsebytes(metadata_documents[0]) + if ( + _normalized_distribution(parsed_metadata.get("Name")) != distribution + or str(parsed_metadata.get("Version") or "") != version + ): + raise RuntimeError("A locked wheel METADATA identity does not match its catalog lock.") + parsed_wheel = BytesParser(policy=email_policy).parsebytes(wheel_documents[0]) + expected_wheel_tags = _expanded_wheel_filename_tags(filename_parts) + wheel_tags = [str(value) for value in (parsed_wheel.get_all("Tag") or [])] + if ( + not parsed_wheel.get("Wheel-Version") + or str(parsed_wheel.get("Root-Is-Purelib") or "").lower() + not in {"true", "false"} + or len(wheel_tags) != len(set(wheel_tags)) + or set(wheel_tags) != expected_wheel_tags + ): + raise RuntimeError("A locked wheel has an invalid WHEEL identity document.") + return dict(sorted(expected_files.items())) + + +def normalize_locked_wheel_install( + site_packages: Path, + artifacts: Iterable[dict[str, Any]], + archive_root: Path, + *, + lease: InstallLease | None = None, +) -> None: + """Remove bounded installer bookkeeping and restore authenticated RECORDs.""" + + artifact_list = [dict(item) for item in artifacts] + expected = locked_artifact_file_seal(artifact_list, archive_root, lease=lease) + root = site_packages.resolve(strict=True) + metadata_roots = { + PurePosixPath(path).parent.as_posix() + for path in expected + if path.endswith(".dist-info/METADATA") + } + for metadata_root in metadata_roots: + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime normalization was cancelled.") + for filename in ("INSTALLER", "direct_url.json", "REQUESTED", "uv_cache.json"): + relative = f"{metadata_root}/{filename}" + if relative in expected: + continue + candidate = root.joinpath(*PurePosixPath(relative).parts) + try: + details = candidate.lstat() + except FileNotFoundError: + continue + if ( + not stat.S_ISREG(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("An installer-generated receipt is not a safe regular file.") + candidate.resolve(strict=True).relative_to(root) + candidate.unlink() + if ".lock" not in expected: + lock_path = root / ".lock" + try: + lock_details = lock_path.lstat() + except FileNotFoundError: + pass + else: + if ( + not stat.S_ISREG(lock_details.st_mode) + or stat.S_ISLNK(lock_details.st_mode) + or getattr(lock_details, "st_nlink", 1) != 1 + or bool( + getattr(lock_details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise RuntimeError("The installer-generated overlay lock is not a safe regular file.") + lock_path.resolve(strict=True).relative_to(root) + lock_path.unlink() + generated_scripts: set[str] = set() + for artifact in artifact_list: + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime normalization was cancelled.") + archive = locked_artifact_path(archive_root, artifact) + with zipfile.ZipFile(archive) as wheel: + for info in wheel.infolist(): + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime normalization was cancelled.") + if info.is_dir(): + continue + target = _wheel_target_path(info.filename) + if target.endswith(".dist-info/entry_points.txt"): + if info.file_size > 2 * 1024 * 1024: + raise RuntimeError("A locked wheel entry-point document is oversized.") + parser = configparser.ConfigParser(interpolation=None, strict=True) + parser.optionxform = str + try: + parser.read_string(wheel.read(info).decode("utf-8")) + except (UnicodeDecodeError, configparser.Error) as exc: + raise RuntimeError("A locked wheel has invalid entry-point metadata.") from exc + for section in ("console_scripts", "gui_scripts"): + if not parser.has_section(section): + continue + for script_name in parser.options(section): + if ( + not script_name + or len(script_name) > 128 + or any( + character not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" + for character in script_name + ) + ): + raise RuntimeError("A locked wheel has an unsafe entry-point name.") + generated_scripts.add(script_name) + if not target.endswith(".dist-info/RECORD"): + continue + if info.file_size > 4 * 1024 * 1024: + raise RuntimeError("A locked wheel RECORD is oversized.") + body = wheel.read(info) + if hashlib.sha256(body).hexdigest() != expected[target]: + raise RuntimeError("A locked wheel RECORD failed its archive identity.") + destination = root.joinpath(*PurePosixPath(target).parts) + destination.parent.resolve(strict=True).relative_to(root) + try: + details = destination.lstat() + if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode): + raise RuntimeError("An installed wheel RECORD is not a regular file.") + except FileNotFoundError: + pass + temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("xb") as output: + output.write(body) + output.flush() + os.fsync(output.fileno()) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + for script_name in generated_scripts: + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime normalization was cancelled.") + for relative in tuple( + f"{directory}/{filename}" + for directory in ("bin", "Scripts") + for filename in ( + script_name, + f"{script_name}.exe", + f"{script_name}-script.py", + f"{script_name}.exe.manifest", + ) + ): + if relative in expected: + continue + candidate = root.joinpath(*PurePosixPath(relative).parts) + try: + details = candidate.lstat() + except FileNotFoundError: + continue + if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode): + raise RuntimeError("An installer-generated console script is not a regular file.") + candidate.resolve(strict=True).relative_to(root) + candidate.unlink() + for directory in (root / "bin", root / "Scripts"): + try: + if not any(directory.iterdir()): + directory.rmdir() + except FileNotFoundError: + pass + + +def _overlay_directory_inventory( + site_packages: Path, *, cancel_event: threading.Event | None = None +) -> set[str]: + root = site_packages.resolve(strict=True) + directories: set[str] = set() + pending = [root] + entries = 1 + while pending: + if cancel_event is not None and cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime overlay verification was cancelled.") + parent = pending.pop() + with os.scandir(parent) as children: + for child in children: + entries += 1 + if entries > 100_000: + raise RuntimeError("The overlay directory inventory exceeds its safe bound.") + path = Path(child.path) + details = path.lstat() + if stat.S_ISDIR(details.st_mode): + resolved = path.resolve(strict=True) + resolved.relative_to(root) + directories.add(resolved.relative_to(root).as_posix()) + pending.append(resolved) + return directories + + +def _expected_parent_directories(relative_files: Iterable[str]) -> set[str]: + directories: set[str] = set() + for filename in relative_files: + parent = PurePosixPath(filename).parent + while parent != PurePosixPath("."): + directories.add(parent.as_posix()) + parent = parent.parent + return directories + + +def verify_artifact_anchored_overlay( + site_packages: Path, + artifacts: Iterable[dict[str, Any]], + archive_root: Path, + *, + lease: InstallLease | None = None, +) -> dict[str, Any]: + """Match every extracted byte to rehashed catalog-locked wheel archives.""" + + artifact_list = [dict(item) for item in artifacts] + if not artifact_list: + raise RuntimeError("An artifact-anchored overlay requires a complete wheel lock.") + expected = locked_artifact_file_seal(artifact_list, archive_root, lease=lease) + root = site_packages.resolve(strict=True) + cancel_event = lease.cancel_event if lease is not None else None + actual_paths = scan_overlay_tree(site_packages, cancel_event=cancel_event) + actual_names = {path.resolve(strict=True).relative_to(root).as_posix() for path in actual_paths} + if ( + actual_names != set(expected) + or _overlay_directory_inventory(site_packages, cancel_event=cancel_event) + != _expected_parent_directories(expected) + ): + raise RuntimeError("The extracted overlay does not exactly match its locked wheels.") + actual: dict[str, str] = {} + for path in actual_paths: + if lease is not None and lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime overlay verification was cancelled.") + resolved = path.resolve(strict=True) + relative = resolved.relative_to(root).as_posix() + digest, _size = _stream_sha256( + resolved, + maximum_bytes=MAX_LOCKED_WHEEL_MEMBER_BYTES, + cancel_event=lease.cancel_event if lease is not None else None, + ) + if digest != expected[relative]: + raise RuntimeError("An extracted overlay file differs from its locked wheel.") + actual[relative] = digest + anchor_body = { + "schemaVersion": 1, + "artifacts": artifact_list, + "fileSeal": dict(sorted(actual.items())), + } + encoded = json.dumps(anchor_body, sort_keys=True, separators=(",", ":")).encode("utf-8") + anchor_body["digest"] = f"sha256:{hashlib.sha256(encoded).hexdigest()}" + return anchor_body + + +def observed_overlay_file_seal(site_packages: Path) -> dict[str, str]: + root = site_packages.resolve(strict=True) + seal: dict[str, str] = {} + for path in scan_overlay_tree(site_packages): + resolved = path.resolve(strict=True) + relative = resolved.relative_to(root).as_posix() + digest, _size = _stream_sha256(resolved, maximum_bytes=MAX_LOCKED_WHEEL_MEMBER_BYTES) + seal[relative] = digest + return dict(sorted(seal.items())) + + +_WATCHDOG_SCRIPT = r''' +import ctypes +from ctypes import wintypes +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +parent_pid = int(sys.argv[1]) +command = json.loads(sys.argv[2]) + +def install_windows_job(): + if os.name != "nt": + return None + class BASIC_LIMITS(ctypes.Structure): + _fields_ = [ + ("per_process_time", ctypes.c_longlong), + ("per_job_time", ctypes.c_longlong), + ("flags", wintypes.DWORD), + ("minimum_working_set", ctypes.c_size_t), + ("maximum_working_set", ctypes.c_size_t), + ("active_process_limit", wintypes.DWORD), + ("affinity", ctypes.c_size_t), + ("priority_class", wintypes.DWORD), + ("scheduling_class", wintypes.DWORD), + ] + class IO_COUNTERS(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in ( + "read_operations", "write_operations", "other_operations", + "read_bytes", "write_bytes", "other_bytes", + )] + class EXTENDED_LIMITS(ctypes.Structure): + _fields_ = [ + ("basic", BASIC_LIMITS), + ("io", IO_COUNTERS), + ("process_memory", ctypes.c_size_t), + ("job_memory", ctypes.c_size_t), + ("peak_process_memory", ctypes.c_size_t), + ("peak_job_memory", ctypes.c_size_t), + ] + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR) + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = ( + wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, + ) + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE) + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + job = kernel32.CreateJobObjectW(None, None) + if not job: + raise ctypes.WinError(ctypes.get_last_error()) + limits = EXTENDED_LIMITS() + limits.basic.flags = 0x00002000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not kernel32.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)): + raise ctypes.WinError(ctypes.get_last_error()) + if not kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess()): + raise ctypes.WinError(ctypes.get_last_error()) + return job + +windows_job = install_windows_job() + +def parent_alive(): + if os.name != "nt": + return os.getppid() == parent_pid + SYNCHRONIZE = 0x00100000 + WAIT_TIMEOUT = 0x00000102 + handle = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, parent_pid) + if not handle: + return False + try: + return ctypes.windll.kernel32.WaitForSingleObject(handle, 0) == WAIT_TIMEOUT + finally: + ctypes.windll.kernel32.CloseHandle(handle) + +child = subprocess.Popen(command) +while child.poll() is None: + if not parent_alive(): + if os.name == "nt": + ctypes.windll.kernel32.TerminateJobObject(windows_job, 137) + raise SystemExit(137) + os.killpg(os.getpgrp(), signal.SIGKILL) + time.sleep(0.05) +raise SystemExit(child.returncode) +''' + + +def run_cancellable_command( + command: list[str], + *, + environment: dict[str, str], + lease: InstallLease, + timeout: int, + cwd: Path | None = None, +) -> dict[str, Any]: + """Run one owned child process and make cancellation stop that process.""" + + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime installation was cancelled.") + creation: dict[str, Any] = {"start_new_session": True} + lock_fd = lease.lock_file.fileno() + os.set_inheritable(lock_fd, True) + if os.name == "nt": + creation = { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP + | getattr(subprocess, "CREATE_NO_WINDOW", 0), + "close_fds": False, + } + else: + creation["pass_fds"] = (lock_fd,) + started = time.monotonic() + working_directory = Path(cwd or tempfile.gettempdir()).resolve(strict=True) + if not working_directory.is_dir(): + raise RuntimeError("Optional-runtime subprocess working directory is unavailable.") + with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as stdout_file, tempfile.TemporaryFile( + mode="w+", encoding="utf-8", errors="replace" + ) as stderr_file: + watchdog_command = [ + sys.executable, + "-I", + "-B", + "-c", + _WATCHDOG_SCRIPT, + str(os.getpid()), + json.dumps(command, separators=(",", ":")), + ] + try: + process = subprocess.Popen( + watchdog_command, + stdout=stdout_file, + stderr=stderr_file, + text=True, + encoding="utf-8", + errors="replace", + env=environment, + cwd=str(working_directory), + **creation, + ) + finally: + os.set_inheritable(lock_fd, False) + _set_lease_process(lease, process) + try: + while process.poll() is None: + if lease.cancel_event.wait(0.05): + _terminate_process(process) + raise OverlayCancelled("Optional-runtime installation was cancelled.") + if time.monotonic() - started > timeout: + _terminate_process(process) + raise RuntimeError("Optional-runtime subprocess exceeded its time limit.") + output_bytes = os.fstat(stdout_file.fileno()).st_size + os.fstat(stderr_file.fileno()).st_size + if output_bytes > 8 * 1024**2: + _terminate_process(process) + raise RuntimeError("Optional-runtime subprocess output exceeded its safe limit.") + if lease.cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime installation was cancelled.") + output_bytes = os.fstat(stdout_file.fileno()).st_size + os.fstat(stderr_file.fileno()).st_size + if output_bytes > 8 * 1024**2: + raise RuntimeError("Optional-runtime subprocess output exceeded its safe limit.") + stdout_file.seek(0, os.SEEK_END) + stdout_size = stdout_file.tell() + stdout_file.seek(max(0, stdout_size - 8000)) + stderr_file.seek(0, os.SEEK_END) + stderr_size = stderr_file.tell() + stderr_file.seek(max(0, stderr_size - 8000)) + return { + "returnCode": int(process.returncode or 0), + "stdout": stdout_file.read(), + "stderr": stderr_file.read(), + "elapsedSeconds": time.monotonic() - started, + } + finally: + _set_lease_process(lease, None) + + +def _normalized_distribution(value: str) -> str: + return str(value or "").strip().lower().replace("_", "-").replace(".", "-") + + +def _resolved_distribution_origin(distribution: metadata.Distribution) -> str: + raw_path = getattr(distribution, "_path", None) + if raw_path is None: + raise RuntimeError("Installed distribution metadata has no resolvable origin.") + return str(Path(raw_path).resolve(strict=True)) + + +def _import_identity(import_name: str, metadata_origin: str) -> dict[str, Any]: + spec = importlib.util.find_spec(import_name) + if spec is None: + raise RuntimeError(f"Required base module {import_name!r} is unavailable.") + metadata_root = Path(metadata_origin).resolve(strict=True).parent + origin = spec.origin + resolved_origin = None + if origin not in {None, "namespace", "built-in", "frozen"}: + resolved = Path(origin).resolve(strict=True) + try: + resolved.relative_to(metadata_root) + except ValueError as exc: + raise RuntimeError(f"Base module {import_name!r} does not match its distribution origin.") from exc + resolved_origin = str(resolved) + locations = [] + for location in list(spec.submodule_search_locations or []): + resolved = Path(location).resolve(strict=True) + try: + resolved.relative_to(metadata_root) + except ValueError as exc: + raise RuntimeError(f"Base package {import_name!r} has an unexpected search path.") from exc + locations.append(str(resolved)) + if resolved_origin is None and not locations: + raise RuntimeError(f"Base module {import_name!r} has no filesystem import identity.") + return {"origin": resolved_origin, "searchLocations": sorted(locations)} + + +def _diffusers_identity() -> dict[str, str]: + try: + distribution = metadata.distribution("diffusers") + except metadata.PackageNotFoundError as exc: + raise RuntimeError("The pinned Diffusers distribution is unavailable.") from exc + try: + direct = json.loads(distribution.read_text("direct_url.json") or "") + except (TypeError, ValueError) as exc: + raise RuntimeError("Diffusers has no readable immutable source receipt.") from exc + vcs = direct.get("vcs_info") if isinstance(direct, dict) else None + source = str(direct.get("url") or "") if isinstance(direct, dict) else "" + commit = str(vcs.get("commit_id") or "") if isinstance(vcs, dict) else "" + requested = str(vcs.get("requested_revision") or "") if isinstance(vcs, dict) else "" + if ( + str(distribution.version) != PINNED_DIFFUSERS_VERSION + or source != PINNED_DIFFUSERS_SOURCE_URL + or not isinstance(vcs, dict) + or vcs.get("vcs") != "git" + or commit != PINNED_DIFFUSERS_COMMIT + or requested != PINNED_DIFFUSERS_COMMIT + or "dir_info" in direct + ): + raise RuntimeError("Installed Diffusers does not match MoDiff's reviewed source and commit.") + metadata_origin = _resolved_distribution_origin(distribution) + return { + "distribution": "diffusers", + "version": str(distribution.version), + "sourceUrl": source, + "vcs": "git", + "dirInfoPresent": False, + "commitId": commit, + "requestedRevision": requested, + "metadataOrigin": metadata_origin, + "importIdentity": _import_identity("diffusers", metadata_origin), + } + + +def _accelerator_identity() -> dict[str, str]: + # runtime_profile is itself standard-library only and does not import Torch. + from modiff.runtime_profile import ( + MANIFEST_PATH, + PROJECT_ROOT, + RUNTIME_CONTRACT_SCHEMA, + load_manifest, + lock_digest, + read_state, + runtime_contract_paths, + ) + + saved = read_state(Path(sys.prefix)) + if ( + not isinstance(saved, dict) + or saved.get("runtime_contract_schema") != RUNTIME_CONTRACT_SCHEMA + ): + raise RuntimeError("The accelerator profile has no verified installation receipt.") + profile_id = str(saved.get("profile") or "") + installed_digest = str(saved.get("lock_digest") or "") + manifest = load_manifest() + profile = manifest.get("profiles", {}).get(profile_id) + requirement_name = profile.get("requirements") if isinstance(profile, dict) else None + if not profile_id or not isinstance(requirement_name, str) or not requirement_name: + raise RuntimeError("The accelerator profile contract is unavailable.") + requirement = PROJECT_ROOT / requirement_name + current_digest = lock_digest( + requirement, + contract_paths=runtime_contract_paths(requirement), + profile=profile_id, + ) + if installed_digest != current_digest: + raise RuntimeError("The accelerator profile lock has drifted and requires repair.") + contract_files = [] + for path in (*runtime_contract_paths(requirement), MANIFEST_PATH): + resolved = Path(path).resolve(strict=True) + body = resolved.read_bytes() + contract_files.append( + { + "path": str(resolved), + "size": len(body), + "sha256": hashlib.sha256(body).hexdigest(), + } + ) + return { + "profileId": profile_id, + "lockDigest": current_digest, + "manifestRevision": str(manifest.get("revision") or ""), + "contractFiles": contract_files, + } + + +def current_base_binding(base_distributions: Iterable[str | dict[str, Any]]) -> dict[str, Any]: + """Freeze every host identity an overlay is allowed to depend on.""" + + base_packages = [] + seen: set[str] = set() + for raw_contract in base_distributions: + if isinstance(raw_contract, dict): + raw_name = raw_contract.get("distribution") + import_name = str(raw_contract.get("importName") or "").strip() + specifier = str(raw_contract.get("specifier") or "").strip() + else: + raw_name = raw_contract + import_name = str(raw_contract).replace("-", "_") + specifier = "" + name = _normalized_distribution(raw_name) + if not name or not import_name or name in seen: + continue + seen.add(name) + try: + distribution = metadata.distribution(name) + except metadata.PackageNotFoundError as exc: + raise RuntimeError(f"Required base distribution {name!r} is unavailable.") from exc + base_packages.append( + { + "distribution": name, + "importName": import_name, + "specifier": specifier, + "version": str(distribution.version), + "metadataOrigin": _resolved_distribution_origin(distribution), + } + ) + base_packages[-1]["importIdentity"] = _import_identity( + import_name, + base_packages[-1]["metadataOrigin"], + ) + packaging_contract = next( + (item for item in base_packages if item["distribution"] == "packaging"), + None, + ) + if packaging_contract is None: + raise RuntimeError("The optional-runtime base contract must include packaging.") + packaging_module = importlib.import_module("packaging") + if str(Path(packaging_module.__file__).resolve(strict=True)) != packaging_contract["importIdentity"]["origin"]: + raise RuntimeError("The trusted packaging module does not match its frozen import origin.") + from packaging.specifiers import SpecifierSet + + for package in base_packages: + if package["specifier"] and package["version"] not in SpecifierSet(package["specifier"]): + raise RuntimeError( + f"Base distribution {package['distribution']!r} violates its reviewed constraint." + ) + return { + "schemaVersion": 1, + "python": { + "implementation": sys.implementation.name, + "version": platform.python_version(), + "cacheTag": str(sys.implementation.cache_tag or ""), + "hexVersion": sys.hexversion, + }, + "accelerator": _accelerator_identity(), + "diffusers": _diffusers_identity(), + "basePackages": sorted(base_packages, key=lambda item: item["distribution"]), + } + + +def binding_digest(binding: dict[str, Any]) -> str: + body = json.dumps(binding, sort_keys=True, separators=(",", ":")).encode("utf-8") + return f"{_DIGEST_PREFIX}{hashlib.sha256(body).hexdigest()}" + + +def binding_matches( + expected: dict[str, Any], + base_distributions: Iterable[str | dict[str, Any]], +) -> bool: + try: + return expected == current_base_binding(base_distributions) + except (OSError, RuntimeError, TypeError, ValueError): + return False + + +def scan_overlay_tree( + site_packages: Path, *, cancel_event: threading.Event | None = None +) -> list[Path]: + """Reject links, reparse points, and .pth startup directives.""" + + root = site_packages.resolve(strict=True) + maximum_entries = 100_000 + maximum_bytes = 2 * 1024**3 + maximum_file_bytes = 512 * 1024**2 + maximum_depth = 64 + entries = 1 + total_bytes = 0 + regular_files: list[Path] = [] + pending = [site_packages] + while pending: + if cancel_event is not None and cancel_event.is_set(): + raise OverlayCancelled("Optional-runtime overlay scan was cancelled.") + path = pending.pop() + try: + depth = len(path.relative_to(site_packages).parts) + except ValueError as exc: + raise RuntimeError("The staged overlay escapes its managed root.") from exc + if depth > maximum_depth: + raise RuntimeError("The staged overlay exceeds the maximum directory depth.") + try: + info = path.lstat() + except OSError as exc: + raise RuntimeError("The staged overlay contains an unreadable path.") from exc + if stat.S_ISLNK(info.st_mode) or ( + getattr(info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ): + raise RuntimeError("The staged overlay contains a link or reparse-point escape.") + try: + path.resolve(strict=True).relative_to(root) + except ValueError as exc: + raise RuntimeError("The staged overlay escapes its managed site-packages root.") from exc + if path.is_file() and path.suffix.lower() == ".pth": + raise RuntimeError("The staged overlay contains a forbidden .pth startup directive.") + if path.name.lower() in {"sitecustomize.py", "usercustomize.py"}: + raise RuntimeError("The staged overlay contains a forbidden interpreter startup module.") + if stat.S_ISREG(info.st_mode): + if info.st_nlink != 1: + raise RuntimeError("The staged overlay contains a multiply-linked file.") + regular_files.append(path) + total_bytes += int(info.st_size) + if info.st_size > maximum_file_bytes: + raise RuntimeError("The staged overlay contains an oversized file.") + if total_bytes > maximum_bytes: + raise RuntimeError("The staged overlay exceeds the maximum validated size.") + elif stat.S_ISDIR(info.st_mode): + try: + with os.scandir(path) as children: + for child in children: + entries += 1 + if entries > maximum_entries: + raise RuntimeError("The staged overlay contains too many filesystem entries.") + pending.append(Path(child.path)) + except OSError as exc: + raise RuntimeError("The staged overlay contains an unreadable directory.") from exc + else: + raise RuntimeError("The staged overlay contains a non-regular filesystem entry.") + return regular_files + + +def overlay_file_seal(site_packages: Path, expected_distributions: Iterable[str]) -> dict[str, str]: + """Require every staged file to be owned by a reviewed wheel RECORD.""" + + root = site_packages.resolve(strict=True) + regular_files = scan_overlay_tree(site_packages) + expected = {_normalized_distribution(name) for name in expected_distributions} + # Bound every metadata document before importlib.metadata is allowed to + # parse it. Its ``Distribution.files`` and ``metadata`` properties may + # otherwise materialize an attacker-sized RECORD/METADATA document. + metadata_roots: set[Path] = set() + for path in regular_files: + relative = path.resolve(strict=True).relative_to(root) + if len(relative.parts) != 2 or not relative.parts[0].endswith(".dist-info"): + continue + if relative.name not in {"METADATA", "RECORD"}: + continue + metadata_root = (root / relative.parts[0]).resolve(strict=True) + metadata_roots.add(metadata_root) + if len(metadata_roots) != len(expected): + raise RuntimeError("The staged wheel metadata inventory is incomplete or unexpected.") + for metadata_root in metadata_roots: + metadata_path = metadata_root / "METADATA" + record_path = metadata_root / "RECORD" + try: + metadata_info = metadata_path.lstat() + record_info = record_path.lstat() + except OSError as exc: + raise RuntimeError("A staged wheel has missing metadata inventory.") from exc + if ( + not stat.S_ISREG(metadata_info.st_mode) + or not stat.S_ISREG(record_info.st_mode) + or metadata_info.st_size > 2 * 1024 * 1024 + or record_info.st_size > 4 * 1024 * 1024 + ): + raise RuntimeError("A staged wheel has oversized or invalid metadata inventory.") + with record_path.open("r", encoding="utf-8", errors="strict", newline="") as record: + for record_rows, row in enumerate(record, start=1): + if record_rows > 100_000 or len(row) > 16_384: + raise RuntimeError("A staged wheel RECORD exceeds its safe parsing bounds.") + discovered: dict[str, list[metadata.Distribution]] = {} + for distribution in metadata.distributions(path=[str(root)]): + raw_metadata_root = getattr(distribution, "_path", None) + try: + metadata_root = Path(raw_metadata_root).resolve(strict=True) + except (OSError, TypeError) as exc: + raise RuntimeError("A staged distribution metadata origin is invalid.") from exc + if metadata_root not in metadata_roots: + raise RuntimeError("The staged overlay contains unexpected distribution metadata.") + name = _normalized_distribution(distribution.metadata.get("Name") or "") + discovered.setdefault(name, []).append(distribution) + if set(discovered) != expected or any(len(values) != 1 for values in discovered.values()): + raise RuntimeError("The staged distribution set does not match the reviewed closure.") + owned: set[Path] = set() + for distributions in discovered.values(): + distribution = distributions[0] + files = distribution.files + if files is None: + raise RuntimeError("A staged distribution has no RECORD file inventory.") + for relative in files: + try: + lexical = Path(distribution.locate_file(relative)).resolve(strict=False) + lexical.relative_to(root) + except ValueError: + # Wheel console scripts may be recorded outside target + # site-packages; they are not importable overlay content. + continue + try: + located = lexical.resolve(strict=True) + except OSError as exc: + raise RuntimeError("A staged wheel RECORD entry is missing.") from exc + if not located.is_file(): + raise RuntimeError("A staged wheel RECORD entry is not a regular file.") + file_hash = getattr(relative, "hash", None) + if file_hash is not None: + hasher = hashlib.new(file_hash.mode) + with located.open("rb") as source: + while chunk := source.read(1024 * 1024): + hasher.update(chunk) + observed = base64.urlsafe_b64encode(hasher.digest()).rstrip(b"=").decode("ascii") + if observed != file_hash.value: + raise RuntimeError("A staged wheel file does not match its RECORD digest.") + owned.add(located) + actual = {path.resolve(strict=True) for path in regular_files} + owned_relative = {path.relative_to(root).as_posix() for path in owned} + if ( + actual != owned + or _overlay_directory_inventory(site_packages) + != _expected_parent_directories(owned_relative) + ): + raise RuntimeError("The staged overlay contains unowned or missing wheel files.") + seal: dict[str, str] = {} + for path in sorted(actual, key=lambda item: item.as_posix()): + relative = path.relative_to(root).as_posix() + hasher = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + hasher.update(chunk) + seal[relative] = hasher.hexdigest() + return seal + + +def overlay_file_seal_matches( + site_packages: Path, + expected_distributions: Iterable[str], + expected_seal: dict[str, str], +) -> bool: + try: + return overlay_file_seal(site_packages, expected_distributions) == expected_seal + except (OSError, RuntimeError, TypeError, ValueError): + return False + + +_VALIDATION_SCRIPT = r''' +import importlib +import importlib.util +from importlib import metadata +import hashlib +import inspect +import json +import os +from pathlib import Path +import platform +import stat +import sys + +payload_body = Path(sys.argv[1]).read_bytes() +if len(payload_body) > 4 * 1024 * 1024 or hashlib.sha256(payload_body).hexdigest() != sys.argv[2]: + raise RuntimeError("the isolated validation payload failed its bounded digest") +payload = json.loads(payload_body.decode("utf-8")) +site = Path(payload["sitePackages"]).resolve(strict=True) + +def verify_trusted_file_seal(): + expected = payload.get("trustedFileSeal") + if not isinstance(expected, dict) or not expected: + raise RuntimeError("the isolated validator has no trusted file seal") + actual = {} + pending = [(site, 0)] + entries = 1 + total_bytes = 0 + while pending: + path, depth = pending.pop() + if depth > 64: + raise RuntimeError("the overlay exceeds the validator depth bound") + details = path.lstat() + if stat.S_ISLNK(details.st_mode) or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ): + raise RuntimeError("the overlay contains a link or reparse point") + path.resolve(strict=True).relative_to(site) + if stat.S_ISDIR(details.st_mode): + with os.scandir(path) as children: + for child in children: + entries += 1 + if entries > 100_000: + raise RuntimeError("the overlay exceeds the validator entry bound") + pending.append((Path(child.path), depth + 1)) + continue + if not stat.S_ISREG(details.st_mode): + raise RuntimeError("the overlay contains a non-regular entry") + if details.st_nlink != 1: + raise RuntimeError("the overlay contains a multiply-linked file") + relative = path.resolve(strict=True).relative_to(site).as_posix() + if path.suffix.lower() == ".pth" or path.name.lower() in {"sitecustomize.py", "usercustomize.py"}: + raise RuntimeError("the overlay contains an interpreter startup hook") + if details.st_size > 512 * 1024**2: + raise RuntimeError("an overlay file exceeds the validator size bound") + total_bytes += details.st_size + if total_bytes > 2 * 1024**3: + raise RuntimeError("the overlay exceeds the validator total size bound") + hasher = hashlib.sha256() + observed = 0 + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + observed += len(chunk) + if observed > details.st_size: + raise RuntimeError("an overlay file changed while validation read it") + hasher.update(chunk) + if observed != details.st_size: + raise RuntimeError("an overlay file changed while validation read it") + actual[relative] = hasher.hexdigest() + if actual != expected: + raise RuntimeError("the overlay no longer matches its trusted file seal") + +verify_trusted_file_seal() +sys.path.insert(0, str(site)) + +def deny_network(event, _args): + if event in {"socket.connect", "socket.getaddrinfo", "socket.gethostbyname"}: + raise RuntimeError("network access is forbidden during optional-runtime validation") + +sys.addaudithook(deny_network) + +def inside(path): + try: + Path(path).resolve(strict=True).relative_to(site) + return True + except (OSError, TypeError, ValueError): + return False + +def inside_root(path, root): + try: + Path(path).resolve(strict=True).relative_to(Path(root).resolve(strict=True)) + return True + except (OSError, TypeError, ValueError): + return False + +def assert_import_origin(import_name, metadata_origin, *, must_be_overlay, expected_identity=None): + spec = importlib.util.find_spec(import_name) + if spec is None: + raise RuntimeError(f"module {import_name!r} has no import specification") + roots = list(spec.submodule_search_locations or []) + origin = spec.origin + candidates = [value for value in [origin, *roots] if value not in {None, "namespace", "built-in", "frozen"}] + if not candidates: + raise RuntimeError(f"module {import_name!r} has no filesystem origin") + metadata_root = str(Path(metadata_origin).resolve(strict=True).parent) + observed_origin = None + observed_locations = [] + for candidate in candidates: + if must_be_overlay: + if not inside(candidate): + raise RuntimeError(f"module {import_name!r} escapes the overlay") + elif inside(candidate) or not inside_root(candidate, metadata_root): + raise RuntimeError(f"base module {import_name!r} has an unexpected import origin") + if origin not in {None, "namespace", "built-in", "frozen"}: + observed_origin = str(Path(origin).resolve(strict=True)) + observed_locations = sorted(str(Path(value).resolve(strict=True)) for value in roots) + if expected_identity is not None and { + "origin": observed_origin, + "searchLocations": observed_locations, + } != expected_identity: + raise RuntimeError(f"base module {import_name!r} import identity drifted") + +for spec_record in payload["specs"]: + canonical = json.dumps(spec_record["spec"], sort_keys=True, separators=(",", ":")).encode("utf-8") + observed_digest = "sha256:" + hashlib.sha256(canonical).hexdigest() + if observed_digest != spec_record["specDigest"]: + raise RuntimeError("the executable optional-runtime spec digest does not match") + +canonical_binding = json.dumps(payload["binding"], sort_keys=True, separators=(",", ":")).encode("utf-8") +if "sha256:" + hashlib.sha256(canonical_binding).hexdigest() != payload["bindingDigest"]: + raise RuntimeError("the host binding digest does not match") +for contract_file in payload["binding"]["accelerator"]["contractFiles"]: + body = Path(contract_file["path"]).resolve(strict=True).read_bytes() + if len(body) != contract_file["size"] or hashlib.sha256(body).hexdigest() != contract_file["sha256"]: + raise RuntimeError("the accelerator profile contract changed during validation") + +overlay_distributions = {} +for distribution in metadata.distributions(path=[str(site)]): + name = str(distribution.metadata.get("Name") or "").lower().replace("_", "-").replace(".", "-") + overlay_distributions.setdefault(name, []).append(distribution) + +expected_distributions = {contract["distribution"] for contract in payload["packages"]} +if set(overlay_distributions) != expected_distributions: + raise RuntimeError("the overlay distribution set does not match the reviewed staged closure") + +observed = [] +for contract in payload["packages"]: + name = contract["distribution"] + matches = overlay_distributions.get(name, []) + if len(matches) != 1: + raise RuntimeError(f"overlay distribution {name!r} has {len(matches)} metadata records") + distribution = matches[0] + if str(distribution.version) != contract["requiredVersion"]: + raise RuntimeError(f"overlay distribution {name!r} has the wrong version") + if not inside(getattr(distribution, "_path", None)): + raise RuntimeError(f"overlay distribution {name!r} metadata escapes the overlay") + assert_import_origin(contract["importName"], getattr(distribution, "_path", None), must_be_overlay=True) + module = importlib.import_module(contract["importName"]) + if not inside(getattr(module, "__file__", None)): + raise RuntimeError(f"overlay module {contract['importName']!r} did not load from the overlay") + origin = getattr(getattr(module, "__spec__", None), "origin", None) + if origin not in {None, "namespace"} and not inside(origin): + raise RuntimeError(f"overlay module {contract['importName']!r} spec escapes the overlay") + for package_path in list(getattr(module, "__path__", []) or []): + if not inside(package_path): + raise RuntimeError(f"overlay module {contract['importName']!r} package path escapes the overlay") + required_classes = set(contract.get("requiredClassSymbols", [])) + for symbol in contract.get("requiredSymbols", []): + if ":" in symbol: + module_name, attribute_path = symbol.split(":", 1) + value = importlib.import_module(module_name) + if not inside(getattr(value, "__file__", None)): + raise RuntimeError("a reviewed overlay submodule escaped the overlay") + else: + attribute_path = symbol + value = module + for part in attribute_path.split("."): + if not hasattr(value, part): + raise RuntimeError(f"overlay module {contract['importName']!r} lacks a reviewed symbol") + value = getattr(value, part) + if ".dummy_" in str(getattr(value, "__module__", "")): + raise RuntimeError(f"overlay module {contract['importName']!r} resolved a dummy symbol") + if symbol in required_classes: + if not inspect.isclass(value): + raise RuntimeError(f"overlay module {contract['importName']!r} resolved a non-class symbol") + elif not callable(value): + raise RuntimeError(f"overlay module {contract['importName']!r} resolved a non-callable symbol") + observed.append({"distribution": name, "version": str(distribution.version)}) + +for base in payload["binding"]["basePackages"]: + name = base["distribution"] + if name in overlay_distributions: + raise RuntimeError(f"base-owned distribution {name!r} was shadowed into the overlay") + distribution = metadata.distribution(name) + origin = str(Path(getattr(distribution, "_path", "")).resolve(strict=True)) + if str(distribution.version) != base["version"] or origin != base["metadataOrigin"]: + raise RuntimeError(f"base-owned distribution {name!r} drifted during validation") + assert_import_origin( + base["importName"], + origin, + must_be_overlay=False, + expected_identity=base["importIdentity"], + ) + base_module = importlib.import_module(base["importName"]) + actual_origin = getattr(base_module, "__file__", None) + if base["importIdentity"]["origin"] is not None and str(Path(actual_origin).resolve(strict=True)) != base["importIdentity"]["origin"]: + raise RuntimeError(f"base module {base['importName']!r} loaded from an unexpected origin") + actual_locations = sorted(str(Path(value).resolve(strict=True)) for value in list(getattr(base_module, "__path__", []) or [])) + if actual_locations != base["importIdentity"]["searchLocations"]: + raise RuntimeError(f"base package {base['importName']!r} loaded with an unexpected search path") + +from packaging.specifiers import SpecifierSet +for base in payload["binding"]["basePackages"]: + if base.get("specifier") and base["version"] not in SpecifierSet(base["specifier"]): + raise RuntimeError(f"base-owned distribution {base['distribution']!r} violates its reviewed constraint") + +diffusers = metadata.distribution("diffusers") +direct = json.loads(diffusers.read_text("direct_url.json") or "") +vcs = direct.get("vcs_info") if isinstance(direct, dict) else None +diffusers_identity = payload["binding"]["diffusers"] +if ( + str(diffusers.version) != diffusers_identity["version"] + or direct.get("url") != diffusers_identity["sourceUrl"] + or not isinstance(vcs, dict) + or vcs.get("vcs") != diffusers_identity["vcs"] + or vcs.get("commit_id") != diffusers_identity["commitId"] + or vcs.get("requested_revision") != diffusers_identity["requestedRevision"] + or ("dir_info" in direct) != diffusers_identity["dirInfoPresent"] + or str(Path(getattr(diffusers, "_path", "")).resolve(strict=True)) != diffusers_identity["metadataOrigin"] +): + raise RuntimeError("Diffusers identity drifted during validation") +assert_import_origin( + "diffusers", + diffusers_identity["metadataOrigin"], + must_be_overlay=False, + expected_identity=diffusers_identity["importIdentity"], +) +diffusers_module = importlib.import_module("diffusers") +if str(Path(diffusers_module.__file__).resolve(strict=True)) != diffusers_identity["importIdentity"]["origin"]: + raise RuntimeError("Diffusers loaded from an unexpected origin") +if sorted(str(Path(value).resolve(strict=True)) for value in list(diffusers_module.__path__)) != diffusers_identity["importIdentity"]["searchLocations"]: + raise RuntimeError("Diffusers loaded with an unexpected package path") +dummy_type = importlib.import_module("diffusers.utils.import_utils").DummyObject +diffusers_root = str(Path(diffusers_identity["metadataOrigin"]).resolve(strict=True).parent) +for spec_record in payload["specs"]: + for symbol in spec_record["spec"].get("requiredDiffusersSymbols", []): + value = diffusers_module + for part in symbol.split("."): + if not hasattr(value, part): + raise RuntimeError("Diffusers lacks a reviewed optional-runtime symbol") + value = getattr(value, part) + if ( + not inspect.isclass(value) + or getattr(value, "__name__", None) != symbol.split(".")[-1] + or not str(getattr(value, "__module__", "")).startswith("diffusers") + or not inside_root(inspect.getfile(value), diffusers_root) + or isinstance(value, dummy_type) + or ".utils.dummy_" in str(getattr(value, "__module__", "")) + ): + raise RuntimeError("Diffusers resolved a dummy optional-runtime symbol") + required_method_parameters = { + item["method"]: set(item.get("requiredParameters", [])) + for item in spec_record["spec"].get("pipelineAdapterMethods", []) + } + for pipeline_symbol in spec_record["spec"].get("pipelineAdapterSymbols", []): + pipeline_class = getattr(diffusers_module, pipeline_symbol, None) + if not inspect.isclass(pipeline_class) or isinstance(pipeline_class, dummy_type): + raise RuntimeError("Diffusers lacks a real reviewed pipeline adapter class") + for method_name, parameter_names in required_method_parameters.items(): + method = getattr(pipeline_class, method_name, None) + if not callable(method) or not parameter_names.issubset(inspect.signature(method).parameters): + raise RuntimeError("A reviewed Diffusers pipeline adapter signature drifted") + if ( + spec_record.get("kind") == "optional_runtime" + and "add_weighted_adapter" + not in spec_record["spec"].get("excludedQualificationSymbols", []) + ): + raise RuntimeError("The unproven weighted-adapter merge API must remain excluded") +if any(spec_record["spec"].get("requirePeftBackend") for spec_record in payload["specs"]): + if importlib.import_module("diffusers.utils").USE_PEFT_BACKEND is not True: + raise RuntimeError("Diffusers did not enable its reviewed PEFT backend") + +python_identity = payload["binding"]["python"] +if ( + sys.implementation.name != python_identity["implementation"] + or platform.python_version() != python_identity["version"] + or str(sys.implementation.cache_tag or "") != python_identity["cacheTag"] + or sys.hexversion != python_identity["hexVersion"] +): + raise RuntimeError("Python identity drifted during validation") + +print(json.dumps({"status": "passed", "packages": observed}, sort_keys=True)) +''' + + +def run_fresh_validation( + site_packages: Path, + *, + packages: Iterable[dict[str, Any]], + binding: dict[str, Any], + specs: Iterable[dict[str, Any]], + lease: InstallLease, + timeout: int = 180, + trusted_file_seal: dict[str, str] | None = None, +) -> dict[str, Any]: + """Validate exact versions, origins, symbols, and host binding in ``-I``.""" + + package_list = list(packages) + expected_names = [str(package["distribution"]) for package in package_list] + before_seal = ( + observed_overlay_file_seal(site_packages) + if trusted_file_seal is not None + else overlay_file_seal(site_packages, expected_names) + ) + if trusted_file_seal is not None and before_seal != trusted_file_seal: + raise RuntimeError("The staged overlay does not match its catalog-derived file seal.") + payload = { + "sitePackages": str(site_packages.resolve(strict=True)), + "packages": package_list, + "binding": binding, + "bindingDigest": binding_digest(binding), + "specs": list(specs), + "trustedFileSeal": before_seal, + } + validation_environment = { + key: value + for key, value in os.environ.items() + if key.upper() not in {"PYTHONPATH", "PYTHONHOME"} + } + validation_environment.update( + { + "HF_HUB_OFFLINE": "1", + "TRANSFORMERS_OFFLINE": "1", + "DIFFUSERS_OFFLINE": "1", + "HF_HUB_DISABLE_TELEMETRY": "1", + "DO_NOT_TRACK": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "TOKENIZERS_PARALLELISM": "false", + "CUDA_VISIBLE_DEVICES": "-1", + } + ) + with tempfile.TemporaryDirectory(prefix="modiff-overlay-validation-") as temporary: + validation_root = Path(temporary).resolve(strict=True) + payload_path = validation_root / "payload.json" + payload_body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + if len(payload_body) > 4 * 1024 * 1024: + raise RuntimeError("The isolated validation payload exceeds its safe limit.") + payload_path.write_bytes(payload_body) + result = run_cancellable_command( + [ + sys.executable, + "-I", + "-s", + "-B", + "-c", + _VALIDATION_SCRIPT, + str(payload_path), + hashlib.sha256(payload_body).hexdigest(), + ], + environment=validation_environment, + lease=lease, + timeout=timeout, + cwd=validation_root, + ) + detail = None + if result["stdout"].strip(): + try: + detail = json.loads(result["stdout"].strip().splitlines()[-1]) + except ValueError: + detail = None + expected = sorted(str(package["distribution"]) for package in payload["packages"]) + observed = ( + sorted(str(package.get("distribution")) for package in detail.get("packages", [])) + if isinstance(detail, dict) and isinstance(detail.get("packages"), list) + else [] + ) + passed = ( + result["returnCode"] == 0 + and isinstance(detail, dict) + and detail.get("status") == "passed" + and observed == expected + and not lease.cancel_event.is_set() + and ( + observed_overlay_file_seal(site_packages) + if trusted_file_seal is not None + else overlay_file_seal(site_packages, expected_names) + ) + == before_seal + ) + return { + "status": "passed" if passed else "failed", + "returnCode": result["returnCode"], + "detail": detail if passed else None, + "error": None if passed else "The isolated optional-runtime validation failed.", + "elapsedSeconds": result["elapsedSeconds"], + "binding": binding if passed else None, + "bindingDigest": binding_digest(binding) if passed else None, + "fileSeal": before_seal if passed else None, + } diff --git a/modiff/server.py b/modiff/server.py index 6aa7ad1..4fcc317 100644 --- a/modiff/server.py +++ b/modiff/server.py @@ -1,6 +1,7 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. import logging import asyncio +import math from aiohttp import web, WSMsgType from aiohttp.web_fileresponse import CONTENT_TYPES as AIOHTTP_CONTENT_TYPES from aiohttp_cors import setup as cors_setup, ResourceOptions @@ -256,6 +257,20 @@ def is_image_data_type(data_type): return False +def is_cache_servable_data_type(data_type): + if is_image_data_type(data_type): + return True + if isinstance(data_type, str): + return data_type in {"audio", "video", "text"} or data_type.startswith("str") + if isinstance(data_type, (list, tuple, set)): + return any( + item in {"audio", "video", "text"} + or (isinstance(item, str) and item.startswith("str")) + for item in data_type + ) + return False + + def image_dimensions(value): width = getattr(value, "width", None) height = getattr(value, "height", None) @@ -381,6 +396,7 @@ def byte_range_response(request, body, *, content_type, charset=None, filename=N from modiff.config import CONFIG +from modiff.controlled_artifacts import controlled_artifact_receipts_from_graph from modiff.diffusers_offload import ( OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK, @@ -391,35 +407,75 @@ def byte_range_response(request, body, *, content_type, charset=None, filename=N from modiff.diffusers_profiles import ( QWEN_IMAGE_2512_PREQUANTIZED_REPO, VERIFIED_REPAIR_SOURCES, + execution_profiles_for_execution, + optional_runtime_profile_ids_for_execution, public_execution_profiles, public_experimental_pipelines, ) from modiff.hardware import format_hardware_summary, get_hardware_snapshot, legacy_torch_status from modiff.runtime_profile import runtime_profile from modiff.auto_resource import ( + AUTO_RESOURCE_SCHEMA_VERSION, PROVEN_PROOF_STATUSES, artifact_cache_status, + auto_resource_pair_is_declared, build_auto_resource_plan, build_auto_resource_plans, clear_auto_resource_history, + matching_auto_resource_success_history, read_auto_resource_history, record_auto_resource_failure, record_auto_resource_success, ) -from modiff.model_artifact_catalog import public_model_artifact_catalog, refreshed_hub_metadata +from modiff.model_artifact_catalog import ( + IMMUTABLE_HUB_REVISION, + catalog_revision, + public_model_artifact_catalog, + refreshed_hub_metadata, +) from modiff.optimization_packages import ( + activate_optional_runtime_environment, activate_environment as activate_optimization_environment, + install_optional_runtime, install_capability as install_optimization_capability, optimization_selections_from_graph, probe_capability as probe_optimization_capability, public_catalog as public_optimization_catalog, + public_optional_runtime_catalog, qualify_receipt as qualify_optimization_receipt, read_receipts as read_optimization_receipts, record_workload_observation as record_optimization_workload_observation, record_workload_baseline as record_optimization_workload_baseline, rollback_environment as rollback_optimization_environment, + rollback_optional_runtime_environment, set_capability_enabled as set_optimization_capability_enabled, workload_key_for_form as optimization_workload_key_for_form, + validate_optional_runtime_activation_request, + validate_optional_runtime_install_request, +) +from modiff.runtime_overlays import ( + OverlayCancelled, + OverlayInstallBusy, + cancel_install as cancel_runtime_install, + release_install as release_runtime_install, + reserve_install as reserve_runtime_install, +) +from modiff.optional_runtimes import public_optional_runtime_profiles +from modiff.optional_runtime_execution import ( + assert_optional_runtime_ready, + graph_optional_runtime_requirement, + loader_optional_runtime_requirement, + optional_runtime_blocker_payload, + optional_runtime_requirement_blocks_execution, + optional_runtime_requirement_for_execution, +) +from modiff.studio_execution_specs import ( + assert_studio_execution_graph, + studio_capability_definitions, + studio_execution_spec_for_pair, + studio_model_dependencies_for_pair, + studio_model_requirements_for_pair, + validate_studio_execution_specs, ) from modiff.modelstore import modelstore from modules import MODULE_MAP, parse_module_map @@ -454,6 +510,53 @@ def byte_range_response(request, body, *, content_type, charset=None, filename=N ], } +# Runtime-hint copies are persisted and included in task events, so these +# execution selectors must be primitive, bounded, and drawn from the same +# closed vocabularies as the generic Diffusers runtime nodes. Keep these +# values local instead of importing modules.DiffusersRuntime during server +# startup; that module owns model-runtime imports which remain lazy. +RUNTIME_ATTENTION_BACKENDS = { + "auto", + "native", + "_native_flash", + "_native_efficient", + "_native_math", + "_native_cudnn", + "flex", + "flash", + "flash_hub", + "flash_varlen", + "flash_varlen_hub", + "flash_4_hub", + "_flash_3", + "_flash_varlen_3", + "_flash_3_hub", + "_flash_3_varlen_hub", + "aiter", + "sage", + "sage_hub", + "sage_varlen", + "xformers", +} +RUNTIME_DENOISER_CACHE_MODES = { + "none", + "first_block", + "magcache", + "taylorseer", + "pab", + "fastercache", + "text_kv", +} +RUNTIME_DEVICE_MAPS = { + "none", + "cuda", + "auto", + "balanced", + "balanced_low_0", + "cpu", + "manual", +} + # The official 0.9.8 13B repository duplicates pipeline components under a # nested VAE tree and includes large preview media. A full snapshot is about # 93 GB; the root Diffusers pipeline needs only these component files. This @@ -603,28 +706,14 @@ class MissingConnectedOutputError(RuntimeError): }, "modes": ["text_to_image", "control_image"], "executionStatus": "supported_with_model", - "additionalRequirements": [ - { - "id": "qwen-controlnet-union", - "label": "Qwen ControlNet Union", - "repo": "InstantX/Qwen-Image-ControlNet-Union", - "kind": "controlnet", - "requiredForModes": ["control_image"], - "description": "Required for Qwen Image Control image workflows.", - } - ], + "additionalRequirements": studio_model_requirements_for_pair( + "QwenImageModularPipeline", "control_image" + ), "modeRequirements": { "control_image": { - "modelRequirements": [ - { - "id": "qwen-controlnet-union", - "label": "Qwen ControlNet Union", - "repo": "InstantX/Qwen-Image-ControlNet-Union", - "kind": "controlnet", - "requiredForModes": ["control_image"], - "description": "Required for Qwen Image Control image workflows.", - } - ], + "modelRequirements": studio_model_requirements_for_pair( + "QwenImageModularPipeline", "control_image" + ), "requiredImages": ["controlImage"], "note": "Requires the Qwen ControlNet Union model plus one control image.", } @@ -699,7 +788,10 @@ class MissingConnectedOutputError(RuntimeError): "offloadMode": OFFLOAD_MODE_MODEL_CPU, "steps": 24, }, - "modes": ["edit_image", "multi_image_reference_edit", "inpaint"], + # Legacy clients fall back to this list when schema-v2 runnableModes is + # unavailable. Keep it aligned with the executable profile so an + # imported Edit Plus form cannot revive the unimplemented mask path. + "modes": ["edit_image", "multi_image_reference_edit"], "executionStatus": "supported_with_model", "notes": ["Inpaint mask execution still requires a confirmed backend mask graph contract."], "inpaintContract": QWEN_IMAGE_EDIT_PLUS_INPAINT_CONTRACT, @@ -847,103 +939,6 @@ class MissingConnectedOutputError(RuntimeError): "video_color_edit": {"requiredVideos": ["sourceVideo"], "note": "Requires one source video."}, }, }, - "WanImageToVideoPipeline": { - "modelType": "WanImageToVideoPipeline", - "label": "Wan 2.2 I2V A14B", - "displayName": "Wan2.2-I2V-A14B-Diffusers", - "family": "Wan Video", - "supportTier": "supported", - "qualificationStatus": "graph-qualified-execution-pending", - "qualifiedModes": [], - "defaultRepo": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 832, "height": 480, "aspectRatio": "16:9"}, - "recommendedSteps": 40, - "recommendedGuidance": 3.5, - "guidanceLabel": "High-noise guidance", - "supportsImageInput": True, - "supportsMask": False, - "supportsMultiImage": True, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": False, - "supportsVideoInput": False, - "supportsVideoMask": False, - "outputKind": "video", - "recommendedFrames": 81, - "recommendedFps": 16, - "conditioningScale": 1.0, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_MODEL_CPU, - "steps": 40, - "width": 832, - "height": 480, - "numFrames": 81, - }, - "modes": ["image_to_video"], - "executionStatus": "supported_with_model", - "notes": [ - "Uses the generic Diffusers video facade with the official dual-expert WanImageToVideoPipeline.", - "The quality workflow quantizes both denoising experts to Quanto INT8 and runs five-second shots sequentially.", - "Human review remains required before generated examples are promoted to the gallery.", - ], - "modeRequirements": { - "image_to_video": { - "requiredImages": ["referenceImages"], - "note": "The story workflow requires one ordered opening keyframe per shot.", - }, - }, - }, - "WanTI2VPipeline": { - "modelType": "WanTI2VPipeline", - "label": "Wan 2.2 TI2V 5B", - "displayName": "Wan2.2-TI2V-5B-Diffusers", - "family": "Wan Video", - "supportTier": "supported", - "qualificationStatus": "graph-qualified-execution-pending", - "qualifiedModes": [], - "defaultRepo": "Wan-AI/Wan2.2-TI2V-5B-Diffusers", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1280, "height": 704, "aspectRatio": "16:9"}, - "recommendedSteps": 50, - "recommendedGuidance": 5.0, - "guidanceLabel": "Guidance", - "supportsImageInput": False, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "supportsVideoInput": False, - "supportsVideoMask": False, - "outputKind": "video", - "recommendedFrames": 121, - "recommendedFps": 24, - "conditioningScale": 1.0, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_MODEL_CPU, - "steps": 50, - "width": 1280, - "height": 704, - "numFrames": 121, - }, - "modes": ["text_to_video"], - "executionStatus": "supported_with_model", - "notes": [ - "Uses the official dense Wan 2.2 5B high-compression video model for five-second 720p shots.", - "The current Diffusers WanPipeline exposes text-to-video; A14B remains the image-to-video adapter.", - "Human review remains required before generated examples are promoted to the gallery.", - ], - "modeRequirements": {}, - }, "LTXVideoPipeline": { "modelType": "LTXVideoPipeline", "label": "LTX-Video", @@ -1035,98 +1030,6 @@ class MissingConnectedOutputError(RuntimeError): "audio_repaint": {"requiredAudio": ["sourceAudio"], "note": "Requires source audio plus repaint timing."}, }, }, - "FluxSchnellPipeline": { - "modelType": "FluxSchnellPipeline", - "label": "FLUX.1 schnell", - "displayName": "FLUX.1-schnell", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-schnell", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 4, - "recommendedGuidance": 0.0, - "guidanceLabel": "Guidance", - "supportsImageInput": False, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_MODEL_CPU, - "steps": 4, - "width": 1024, - "height": 1024, - }, - "modes": ["text_to_image"], - "executionStatus": "supported_with_model", - }, - "FluxDevPipeline": { - "modelType": "FluxDevPipeline", - "label": "FLUX.1 dev", - "displayName": "FLUX.1-dev", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-dev", - "alternateArtifact": "black-forest-labs/FLUX.1-dev-FP8", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 768, "height": 768, "aspectRatio": "1:1"}, - "recommendedSteps": 20, - "recommendedGuidance": 3.5, - "guidanceLabel": "Guidance", - "supportsImageInput": False, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_GROUP_DISK, - "steps": 20, - "width": 768, - "height": 768, - }, - "modes": ["text_to_image"], - "executionStatus": "supported_with_model", - "notes": ["Auto prefers the FP8 artifact on 16 GB CUDA when available."], - }, - "FluxKreaPipeline": { - "modelType": "FluxKreaPipeline", - "label": "FLUX.1 Krea dev", - "displayName": "FLUX.1-Krea-dev", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-Krea-dev", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 28, - "recommendedGuidance": 3.5, - "guidanceLabel": "Guidance", - "supportsImageInput": False, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_GROUP_DISK, - "steps": 20, - "width": 768, - "height": 768, - }, - "modes": ["text_to_image"], - "executionStatus": "expert_only", - }, "FluxKontextPipeline": { "modelType": "FluxKontextPipeline", "label": "FLUX.1 Kontext dev", @@ -1194,149 +1097,12 @@ class MissingConnectedOutputError(RuntimeError): "inpaint": {"requiredImages": ["referenceImages", "maskImage"], "note": "Requires source and mask images."} }, }, - "FluxDepthPipeline": { - "modelType": "FluxDepthPipeline", - "label": "FLUX.1 Depth dev", - "displayName": "FLUX.1-Depth-dev", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-Depth-dev", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 28, - "recommendedGuidance": 3.5, - "guidanceLabel": "Guidance", - "supportsImageInput": True, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": True, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_GROUP_DISK, - "steps": 20, - "width": 768, - "height": 768, - }, - "modes": ["control_image"], - "executionStatus": "expert_only", - }, - "FluxCannyPipeline": { - "modelType": "FluxCannyPipeline", - "label": "FLUX.1 Canny dev", - "displayName": "FLUX.1-Canny-dev", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-Canny-dev", - "artifactCandidates": [ - "black-forest-labs/FLUX.1-Canny-dev", - "fuliucansheng/FLUX.1-Canny-dev-diffusers", - ], - "verifiedRepairSources": [ - { - "repo": "fuliucansheng/FLUX.1-Canny-dev-diffusers", - "verification": "matching filename, size, and LFS SHA-256 plus local byte verification", - } - ], - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 28, - "recommendedGuidance": 3.5, - "guidanceLabel": "Guidance", - "supportsImageInput": True, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": True, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_GROUP_DISK, - "steps": 20, - "width": 768, - "height": 768, - }, - "modes": ["control_image"], - "executionStatus": "expert_only", - }, - "FluxReduxPipeline": { - "modelType": "FluxReduxPipeline", - "label": "FLUX.1 Redux dev", - "displayName": "FLUX.1-Redux-dev", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.1-Redux-dev", - "artifactCandidates": ["black-forest-labs/FLUX.1-Redux-dev", "black-forest-labs/FLUX.1-dev"], - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 28, - "recommendedGuidance": 3.5, - "guidanceLabel": "Guidance", - "supportsImageInput": True, - "supportsMask": False, - "supportsMultiImage": False, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_GROUP_DISK, - "steps": 20, - "width": 768, - "height": 768, - }, - "modes": ["edit_image"], - "executionStatus": "expert_only", - }, - "Flux2KleinPipeline": { - "modelType": "Flux2KleinPipeline", - "label": "FLUX.2 Klein 4B", - "displayName": "FLUX.2-klein-4B", - "family": "FLUX Image", - "defaultRepo": "black-forest-labs/FLUX.2-klein-4B", - "artifactLabel": "Diffusers repo", - "defaultDtype": "bfloat16", - "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, - "recommendedSteps": 4, - "recommendedGuidance": 1.0, - "guidanceLabel": "Guidance", - "supportsImageInput": True, - "supportsMask": False, - "supportsMultiImage": True, - "supportsControlImage": False, - "supportsLayers": False, - "supportsLora": True, - "offloadSupport": DIRECT_OFFLOAD_SUPPORT, - "lowVram": { - "dtype": "bfloat16", - "autoOffload": True, - "offloadMode": OFFLOAD_MODE_MODEL_CPU, - "steps": 4, - "width": 768, - "height": 768, - }, - "modes": ["text_to_image", "edit_image", "multi_image_reference_edit"], - "executionStatus": "supported_with_model", - "modeRequirements": { - "edit_image": {"requiredImages": ["referenceImages"], "note": "Requires one source/reference image."}, - "multi_image_reference_edit": { - "requiredImages": ["referenceImages"], - "note": "Requires two or more reference images.", - }, - }, - "notes": [ - "Qualified through the generic Diffusers image facade for text, single-reference, and multi-reference generation." - ], - }, } +# The migrated exact pairs are generated from the execution-spec registry. +# The remaining records stay on the schema-v2 migration path until P0.3e. +STUDIO_MODEL_CAPABILITIES.update(studio_capability_definitions()) + class WebServer: def __init__( @@ -1404,6 +1170,10 @@ def __init__( pass self.task_graphs = {} self.optimization_jobs = {} + self._runtime_install_leases = {} + self._runtime_install_gate_tokens = {} + self._runtime_mutation_gate = None + self._active_nonruntime_mutations = 0 self.main_queue = asyncio.Queue() self.background_queue = asyncio.Queue() @@ -1452,6 +1222,7 @@ def __init__( self.client_max_size = client_max_size self.work_dir = work_dir self.data_dir = data_dir + self._load_runtime_jobs() # Prime interval counters at startup so the first browser request can # usually report active time instead of waiting for a second poll. self._runtime_disk_activity_sampler.sample(self.data_dir) @@ -1504,12 +1275,34 @@ def __init__( web.get("/runtime/optimizations", self.runtime_optimizations), web.post("/runtime/optimizations/install", self.runtime_optimization_install), web.get("/runtime/optimizations/jobs/{job_id}", self.runtime_optimization_job), + web.post( + "/runtime/optimizations/jobs/{job_id}/cancel", + self.runtime_optimization_job_cancel, + ), web.post("/runtime/optimizations/activate", self.runtime_optimization_activate), web.post("/runtime/optimizations/rollback", self.runtime_optimization_rollback), web.post("/runtime/optimizations/enable", self.runtime_optimization_enable), web.post("/runtime/optimizations/probe", self.runtime_optimization_probe), web.get("/runtime/optimizations/receipts", self.runtime_optimization_receipts), web.post("/runtime/optimizations/qualify", self.runtime_optimization_qualify), + web.get("/runtime/optional-runtimes", self.runtime_optional_runtimes), + web.post("/runtime/optional-runtimes/install", self.runtime_optional_runtime_install), + web.get( + "/runtime/optional-runtimes/jobs/{job_id}", + self.runtime_optimization_job, + ), + web.post( + "/runtime/optional-runtimes/jobs/{job_id}/cancel", + self.runtime_optimization_job_cancel, + ), + web.post( + "/runtime/optional-runtimes/activate", + self.runtime_optional_runtime_activate, + ), + web.post( + "/runtime/optional-runtimes/rollback", + self.runtime_optional_runtime_rollback, + ), web.get("/system_stats", self.system_stats), web.get("/runtime/gpu_processes", self.runtime_gpu_processes), web.post("/runtime/gpu_cleanup", self.runtime_gpu_cleanup), @@ -1926,22 +1719,63 @@ def _persist_supervisor_queue_state(self, *, force=False): logger.warning("Could not persist supervisor queue state", exc_info=True) try: temporary.unlink(missing_ok=True) - except OSError: + except (OSError, TypeError, ValueError): pass - async def queue_task(self, task, args, future, sid, name=None, runtime_hints=None): - task_id = nanoid.generate(size=12) + async def queue_task( + self, + task, + args, + future, + sid, + name=None, + runtime_hints=None, + optional_runtime_requirement=None, + ): + if self._runtime_mutation_gate is not None: + raise OverlayInstallBusy( + "Runtime work is unavailable during runtime mutation or recovery." + ) task_name = name or f"Unnamed task ({task.__name__})" graph = ( args[0] if task_name == "Graph execution" and isinstance(args, tuple) and args and isinstance(args[0], dict) else None ) + overlay_status = os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS", "base") + if overlay_status in { + "busy_recovery_only", + "repair_required", + "restart_required", + }: + requirement = ( + graph_optional_runtime_requirement(graph) + if graph is not None + else optional_runtime_requirement + ) + base_requirement = bool( + isinstance(requirement, dict) + and requirement.get("delivery") == "base" + and requirement.get("requiredNow") is False + and requirement.get("state") == "base_satisfied" + ) + if requirement is not None and not base_requirement: + raise OverlayInstallBusy( + "Runtime work is unavailable during runtime mutation or recovery." + ) + task_id = nanoid.generate(size=12) runtime_hints = ( self._coerce_runtime_hints(graph.get("runtimeHints")) if graph else self._coerce_runtime_hints(runtime_hints) ) + if graph is not None: + if runtime_hints is None: + graph.pop("runtimeHints", None) + else: + # Replace the untrusted object before the graph is copied, + # queued, persisted, or later inspected by plan application. + graph["runtimeHints"] = runtime_hints self.queued_tasks[task_id] = { "task": task, @@ -2295,7 +2129,14 @@ async def _main_worker(self): terminal_message["message"] = "Execution interrupted by the user." elif isinstance(failure_payload, dict): terminal_message.update(failure_payload) - if runtime_cleanup is not None: + if ( + runtime_cleanup is not None + and not ( + isinstance(failure_payload, dict) + and failure_payload.get("category") + == "optional_runtime" + ) + ): terminal_message["runtimeCleanup"] = runtime_cleanup if preview_state: terminal_message["preview_slots"] = preview_state["previewSlots"] @@ -2644,7 +2485,19 @@ def _field_action_runtime_hints(self, data): } ) - def _execute_field_action(self, fn, identity, include_current_task, values, ref): + def _execute_field_action( + self, + fn, + identity, + include_current_task, + module, + action, + values, + ref, + ): + assert_optional_runtime_ready( + loader_optional_runtime_requirement(module, action, values) + ) from modiff.NodeBase import node_message_context message_identity = dict(identity) if isinstance(identity, dict) else {} @@ -2658,30 +2511,144 @@ def _execute_field_action(self, fn, identity, include_current_task, values, ref) with node_message_context(message_identity): return fn(values, ref) + @staticmethod + def _declared_field_exec_actions(field_definition): + """Collect backend method names from one authoritative field contract.""" + + if not isinstance(field_definition, dict): + return set() + allowed = set() + for event_name in ("onChange", "onSignal"): + pending = [field_definition.get(event_name)] + while pending: + descriptor = pending.pop() + if isinstance(descriptor, str): + if descriptor: + allowed.add(descriptor) + elif isinstance(descriptor, (list, tuple)): + pending.extend(descriptor) + elif isinstance(descriptor, dict) and descriptor.get("action") == "exec": + method_name = descriptor.get("data") + if isinstance(method_name, str) and method_name: + allowed.add(method_name) + return allowed + + def _authorize_field_action(self, *, module, action, field_key, method_name): + """Validate a field RPC against the live backend node definition.""" + + if not all(isinstance(value, str) and value for value in (module, action, field_key, method_name)): + raise ValueError("Field actions require non-empty module, action, fieldKey, and fn strings.") + module_definition = self.modules.get(module) + if not isinstance(module_definition, dict): + raise ValueError(f"Unknown field-action module {module!r}.") + action_definition = module_definition.get(action) + if not isinstance(action_definition, dict): + raise ValueError(f"Unknown field-action node {module}.{action}.") + params = action_definition.get("params") + field_definition = params.get(field_key) if isinstance(params, dict) else None + if not isinstance(field_definition, dict): + raise ValueError(f"Unknown field {field_key!r} for field-action node {module}.{action}.") + allowed = self._declared_field_exec_actions(field_definition) + if method_name not in allowed: + raise ValueError( + f"Field {module}.{action}.{field_key} does not authorize backend action {method_name!r}." + ) + async def field_action(self, request): data = await request.json() + if not isinstance(data, dict): + return web.json_response( + {"error": True, "message": "Field action payload must be a JSON object."}, + status=400, + ) + if self._runtime_mutation_gate is not None: + return web.json_response( + { + "error": True, + "error_code": "runtime_mutation_busy", + "message": "Field actions are unavailable during runtime mutation or recovery.", + }, + status=409, + ) node = data.get("node") sid = data.get("sid") - fn = data.get("fn") + method_name = data.get("fn") values = data.get("values") key = data.get("fieldKey", None) queue = data.get("queue", False) + module = data.get("module") + action = data.get("action") + if not isinstance(node, str) or not node: + return web.json_response( + {"error": True, "message": "Field actions require a non-empty node id."}, + status=400, + ) + if not isinstance(values, dict): + return web.json_response( + {"error": True, "message": "Field action values must be a JSON object."}, + status=400, + ) + if type(queue) is not bool: + return web.json_response( + {"error": True, "message": "Field action queue must be a JSON boolean."}, + status=400, + ) + try: + self._authorize_field_action( + module=module, + action=action, + field_key=key, + method_name=method_name, + ) + except ValueError as error: + return web.json_response( + {"error": True, "message": str(error)}, + status=400, + ) + optional_runtime_requirement = loader_optional_runtime_requirement( + module, + action, + values, + ) + if optional_runtime_requirement_blocks_execution( + optional_runtime_requirement + ): + return web.json_response( + optional_runtime_blocker_payload(optional_runtime_requirement), + status=409, + ) runtime_hints = self._field_action_runtime_hints(data) message_identity = self._run_identity_payload(runtime_hints) if sid: message_identity["sid"] = sid if node not in self.node_cache: - module = data.get("module") - action = data.get("action") work_module = import_module(f"{module}.main") work_action = getattr(work_module, action) work_action = work_action(node_id=node) self.node_cache[node] = work_action - self.node_cache[node]._sid = sid # always update the sid as it may change over time + cached_node = self.node_cache[node] + if ( + getattr(cached_node, "module_name", None) != module + or getattr(cached_node, "class_name", None) != action + ): + return web.json_response( + { + "error": True, + "message": "The cached node does not match the requested field-action module and action.", + }, + status=409, + ) - fn = getattr(self.node_cache[node], fn) + cached_node._sid = sid # always update the sid as it may change over time + + callback = getattr(cached_node, method_name, None) + if not callable(callback): + return web.json_response( + {"error": True, "message": "The authorized backend field action is not callable."}, + status=409, + ) ref = { "node": node, "key": key, @@ -2689,7 +2656,14 @@ async def field_action(self, request): } if queue: - task = partial(self._execute_field_action, fn, message_identity, True) + task = partial( + self._execute_field_action, + callback, + message_identity, + True, + module, + action, + ) task_id = await self.queue_task( task, (values, ref), @@ -2697,6 +2671,7 @@ async def field_action(self, request): sid, name="Field action", runtime_hints=runtime_hints, + optional_runtime_requirement=optional_runtime_requirement, ) else: # Run field action in executor to avoid blocking the event loop @@ -2705,10 +2680,29 @@ async def field_action(self, request): try: await self.loop.run_in_executor( None, - partial(self._execute_field_action, fn, message_identity, False, values, ref), + partial( + self._execute_field_action, + callback, + message_identity, + False, + module, + action, + values, + ref, + ), ) except Exception as e: logger.error(f"Error executing field action synchronously: {e}") + blocked_requirement = getattr( + e, + "modiff_optional_runtime_requirement", + None, + ) + if isinstance(blocked_requirement, dict): + return web.json_response( + optional_runtime_blocker_payload(blocked_requirement), + status=409, + ) return web.json_response( { "error": True, @@ -2723,7 +2717,7 @@ async def field_action(self, request): return web.json_response( { "error": False, - "message": f"Field action `{fn}` for node `{node}` queued for processing", + "message": f"Field action `{method_name}` for node `{node}` queued for processing", "sid": sid, "task_id": task_id, "ref": ref, @@ -2744,14 +2738,47 @@ async def cache(self, request): if node not in self.node_cache: return web.HTTPNotFound(text=f"Node {node} not found in cache.") - # get the actual value from the node cache - if field in self.node_cache[node].output: - data = self.node_cache[node].output[field] - elif field in self.node_cache[node].params: - data = self.node_cache[node].params[field] + cached_node = self.node_cache[node] + cached_output = getattr(cached_node, "output", None) + cached_params = getattr(cached_node, "params", None) + if isinstance(cached_output, dict) and dict.__contains__(cached_output, field): + cached_values = cached_output + elif isinstance(cached_params, dict) and dict.__contains__(cached_params, field): + cached_values = cached_params else: return web.HTTPNotFound(text=f"Field {field} not found in node {node} cache.") + # Dynamic outputs may carry process-local capabilities or other opaque + # runtime state. Only fields in the authoritative static node contract + # are cache-servable, and validate that contract before touching the + # cached value or invoking any media conversion path. + module = getattr(cached_node, "module_name", None) + action = getattr(cached_node, "class_name", None) + modules = self.modules + module_definition = ( + dict.get(modules, module) + if isinstance(modules, dict) and isinstance(module, str) + else None + ) + action_definition = ( + dict.get(module_definition, action) + if isinstance(module_definition, dict) and isinstance(action, str) + else None + ) + params = dict.get(action_definition, "params") if isinstance(action_definition, dict) else None + field_definition = dict.get(params, field) if isinstance(params, dict) else None + if not isinstance(field_definition, dict) or not dict.__contains__(field_definition, "type"): + return web.HTTPBadRequest( + text=f"Field {field} is not declared as a cache-served field for node {node}." + ) + + type = dict.get(field_definition, "type") + if not is_cache_servable_data_type(type): + return web.HTTPBadRequest( + text=f"Field {field} has a type that cannot be served from node cache." + ) + + data = dict.__getitem__(cached_values, field) if data is None: return web.HTTPNotFound(text=f"Field {field} is empty in node {node} cache.") @@ -2759,12 +2786,9 @@ async def cache(self, request): index = max(0, min(len(data) - 1, int(index))) if index else 0 data = data[index] - # check the registry for the type of the field - module = self.node_cache[node].module_name - action = self.node_cache[node].class_name - field_definition = self.modules[module][action]["params"][field] - type = field_definition.get("type") - fieldOptions = field_definition.get("fieldOptions", {}) + fieldOptions = dict.get(field_definition, "fieldOptions", {}) + if not isinstance(fieldOptions, dict): + fieldOptions = {} filename = request.query.get("filename", f"{field}") download_format = str(request.query.get("download_format") or "").strip().lower() @@ -3049,7 +3073,56 @@ async def _mutation_origin_middleware(self, request, handler): origin_error = self._untrusted_origin_response(request) if origin_error is not None: return origin_error - return await handler(request) + method = str(getattr(request, "method", "GET")).upper() + path = str(getattr(request, "path", "")) + runtime_transaction = bool( + method == "POST" + and ( + path + in { + "/runtime/optimizations/install", + "/runtime/optimizations/activate", + "/runtime/optimizations/rollback", + "/runtime/optional-runtimes/install", + "/runtime/optional-runtimes/activate", + "/runtime/optional-runtimes/rollback", + } + or re.fullmatch( + r"/runtime/(?:optimizations|optional-runtimes)/jobs/optjob-[A-Za-z0-9_-]{12}/cancel", + path, + ) + ) + ) + query = getattr(request, "query", {}) or {} + converting_get = bool( + method == "GET" + and ( + path in {"/media/export", "/media/preview", "/preview"} + or (path == "/file" and query.get("download_format")) + or (path.startswith("/cache/") and query.get("download_format")) + ) + ) + tracked_mutation = ( + method in {"POST", "PUT", "PATCH", "DELETE"} and not runtime_transaction + ) or converting_get + if tracked_mutation: + if self._runtime_mutation_gate is not None: + return web.json_response( + { + "error": True, + "error_code": "runtime_mutation_busy", + "message": "This mutation is unavailable during runtime mutation or recovery.", + }, + status=409, + ) + self._active_nonruntime_mutations += 1 + try: + return await handler(request) + finally: + if tracked_mutation: + self._active_nonruntime_mutations = max( + 0, self._active_nonruntime_mutations - 1 + ) def _untrusted_origin_response(self, request): if self._trusted_browser_origin(request): @@ -3190,6 +3263,14 @@ async def listgraphs(self, request): graphs = list_files(str(path), recursive=True, extensions=["json"]) workflow_metadata: dict[str, dict] = {} + optional_runtime_catalog_snapshot = None + + def request_optional_runtime_catalog(): + nonlocal optional_runtime_catalog_snapshot + if optional_runtime_catalog_snapshot is None: + optional_runtime_catalog_snapshot = public_optional_runtime_catalog() + return optional_runtime_catalog_snapshot + manifest_path = Path(self.data_dir) / "workflow-library-manifest.json" if manifest_path.exists(): try: @@ -3236,6 +3317,15 @@ async def listgraphs(self, request): except (ValueError, TypeError): relative_graph_path = "" metadata = workflow_metadata.get(relative_graph_path, {}) + optional_runtime_profile_ids = optional_runtime_profile_ids_for_execution( + metadata.get("modelType"), + metadata.get("mode"), + ) + optional_runtime_requirement = optional_runtime_requirement_for_execution( + metadata.get("modelType"), + metadata.get("mode"), + catalog_resolver=request_optional_runtime_catalog, + ) file_item = { "isDir": False, "name": Path(raw_name).stem, @@ -3246,6 +3336,11 @@ async def listgraphs(self, request): "supportTier": metadata.get("supportTier"), "qualificationStatus": metadata.get("qualificationStatus"), "requiredArtifacts": metadata.get("requiredArtifacts", []), + "optionalRuntimeProfileIds": list(optional_runtime_profile_ids), + "optionalRuntimeProfiles": public_optional_runtime_profiles( + optional_runtime_profile_ids + ), + "optionalRuntimeRequirement": optional_runtime_requirement, } parent_children.append(file_item) @@ -5170,6 +5265,24 @@ async def graph(self, request): # if not sid: # return web.json_response({"error": True, "message": "Missing session id"}, status=400) + if self._runtime_mutation_gate is not None: + return web.json_response( + { + "error": True, + "error_code": "runtime_mutation_busy", + "message": "A runtime install, activation, rollback, or restart is in progress.", + }, + status=409, + ) + + optional_runtime_requirement = graph_optional_runtime_requirement(graph) + + if optional_runtime_requirement_blocks_execution(optional_runtime_requirement): + return web.json_response( + optional_runtime_blocker_payload(optional_runtime_requirement), + status=409, + ) + runtime_block = self._auto_resource_runtime_block() if runtime_block: issue = runtime_block["issue"] @@ -5292,6 +5405,29 @@ def _current_dynamic_message_identity_payload(self): return payload def _exception_payload(self, e, task_id=None, sid=None, node_id=None, node_name=None, traceback_text=None): + optional_runtime_requirement = getattr( + e, + "modiff_optional_runtime_requirement", + None, + ) + if isinstance(optional_runtime_requirement, dict): + payload = optional_runtime_blocker_payload( + optional_runtime_requirement + ) + if isinstance(task_id, str) and re.fullmatch( + r"[A-Za-z0-9_-]{1,64}", task_id + ): + payload["task_id"] = task_id + if isinstance(node_id, str) and re.fullmatch( + r"[A-Za-z0-9_-]{1,128}", node_id + ): + payload["node"] = node_id + if isinstance(node_name, str) and re.fullmatch( + r"[A-Za-z0-9_.:-]{1,256}", node_name + ): + payload["node_name"] = node_name + return payload + exception_type = type(e).__name__ message = str(e) or exception_type classification = self._classify_exception(e, message=message, exception_type=exception_type) @@ -5304,7 +5440,7 @@ def _exception_payload(self, e, task_id=None, sid=None, node_id=None, node_name= if oom: memory_summary = message.split("\n")[0] - return { + payload = { "task_id": task_id, "sid": sid, "node": node_id, @@ -5323,6 +5459,7 @@ def _exception_payload(self, e, task_id=None, sid=None, node_id=None, node_name= "runtime_budget": self.current_task.get("runtimeBudget") if self.current_task else None, "loader_diagnostics": self._loader_diagnostics_snapshot(), } + return payload def _classify_exception(self, e, message=None, exception_type=None): exception_type = exception_type or type(e).__name__ @@ -5682,6 +5819,213 @@ def _apply_deterministic_mode(self, graph): return applied + @staticmethod + def _bounded_auto_value(value, *, field_name, depth, budget): + if depth > 8: + raise ValueError("nested value is too deep") + budget["entries"] += 1 + if budget["entries"] > 4096: + raise ValueError("too many nested values") + if value is None or isinstance(value, (bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("numeric value is not finite") + return value + if isinstance(value, str): + limit = 512 if field_name in { + "id", + "candidateId", + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + "artifact", + "baseArtifact", + "modelRepo", + "resolvedArtifact", + "repo", + "revision", + } else 4096 + if len(value) > limit: + raise ValueError("string value is too long") + budget["chars"] += len(value) + if budget["chars"] > 65536: + raise ValueError("too much string data") + return value + if isinstance(value, list): + if len(value) > 32: + raise ValueError("array is too large") + return [ + WebServer._bounded_auto_value( + item, + field_name=field_name, + depth=depth + 1, + budget=budget, + ) + for item in value + ] + if isinstance(value, dict): + if len(value) > 128: + raise ValueError("object is too large") + output = {} + for key, item in value.items(): + if not isinstance(key, str) or len(key) > 128: + raise ValueError("object key is invalid") + output[key] = WebServer._bounded_auto_value( + item, + field_name=key, + depth=depth + 1, + budget=budget, + ) + return output + raise ValueError("value is not JSON-compatible") + + @staticmethod + def _project_bounded_auto_mapping(value, *, retry=False): + if not isinstance(value, dict): + raise WebServer._auto_resource_contract_error( + "Auto candidate data is malformed. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + allowed = {"id", *WebServer._auto_candidate_execution_fields()} + if retry: + allowed.update({"candidateId", "index", "reason", "onCategories", "onErrorCodes"}) + budget = {"entries": 0, "chars": 0} + try: + projected = { + key: WebServer._bounded_auto_value( + value[key], + field_name=key, + depth=0, + budget=budget, + ) + for key in allowed + if key in value + } + if len(json.dumps(projected, ensure_ascii=False, separators=(",", ":"))) > 65536: + raise ValueError("mapping is too large") + except (OverflowError, RecursionError, TypeError, ValueError): + raise WebServer._auto_resource_contract_error( + "Auto candidate data exceeds the supported execution contract. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) from None + return projected + + @staticmethod + def _bounded_runtime_container_value( + value, + *, + depth, + budget, + max_depth, + max_entries, + max_items, + max_keys, + max_string_chars, + max_total_chars, + ): + if depth > max_depth: + raise ValueError("nested value is too deep") + budget["entries"] += 1 + if budget["entries"] > max_entries: + raise ValueError("too many nested values") + if value is None or isinstance(value, (bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("numeric value is not finite") + return value + if isinstance(value, str): + if len(value) > max_string_chars: + raise ValueError("string value is too long") + budget["chars"] += len(value) + if budget["chars"] > max_total_chars: + raise ValueError("too much string data") + return value + if isinstance(value, list): + if len(value) > max_items: + raise ValueError("array is too large") + return [ + WebServer._bounded_runtime_container_value( + item, + depth=depth + 1, + budget=budget, + max_depth=max_depth, + max_entries=max_entries, + max_items=max_items, + max_keys=max_keys, + max_string_chars=max_string_chars, + max_total_chars=max_total_chars, + ) + for item in value + ] + if isinstance(value, dict): + if len(value) > max_keys: + raise ValueError("object is too large") + output = {} + for key, item in value.items(): + if not isinstance(key, str) or len(key) > 256: + raise ValueError("object key is invalid") + budget["chars"] += len(key) + if budget["chars"] > max_total_chars: + raise ValueError("too much string data") + output[key] = WebServer._bounded_runtime_container_value( + item, + depth=depth + 1, + budget=budget, + max_depth=max_depth, + max_entries=max_entries, + max_items=max_items, + max_keys=max_keys, + max_string_chars=max_string_chars, + max_total_chars=max_total_chars, + ) + return output + raise ValueError("value is not JSON-compatible") + + @staticmethod + def _project_bounded_runtime_container( + value, + *, + expected_type, + max_depth=8, + max_entries=4096, + max_items=256, + max_keys=256, + max_string_chars=65536, + max_total_chars=262144, + max_serialized_chars=262144, + ): + if not isinstance(value, expected_type): + raise WebServer._auto_resource_contract_error( + "Runtime hints contain malformed structured data. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + budget = {"entries": 0, "chars": 0} + try: + projected = WebServer._bounded_runtime_container_value( + value, + depth=0, + budget=budget, + max_depth=max_depth, + max_entries=max_entries, + max_items=max_items, + max_keys=max_keys, + max_string_chars=max_string_chars, + max_total_chars=max_total_chars, + ) + if len(json.dumps(projected, ensure_ascii=False, separators=(",", ":"))) > max_serialized_chars: + raise ValueError("structured value is too large") + except (OverflowError, RecursionError, TypeError, ValueError): + raise WebServer._auto_resource_contract_error( + "Runtime hints exceed the supported structured-data contract. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) from None + return projected + def _coerce_runtime_hints(self, value): if not isinstance(value, dict): return None @@ -5692,13 +6036,15 @@ def _coerce_runtime_hints(self, value): "cudaIndex", "cudaMemoryFreeBytes", "cudaMemoryTotalBytes", - "modelFamily", "modelType", + "mode", "modelRepo", "modelName", "resolvedModelRepo", "resolvedArtifact", "modelDependencies", + "loaderModule", + "loaderAction", "executionPath", "pipelineClass", "dtype", @@ -5731,7 +6077,6 @@ def _coerce_runtime_hints(self, value): "enforceCudaBudget", "compatibilityProbe", "compatibilityStatus", - "lowVramMode", "requestedCudaReserveBytes", "requestedCudaBudgetBytes", "clientRunId", @@ -5744,18 +6089,21 @@ def _coerce_runtime_hints(self, value): "maxRuntimeSeconds", "autoFieldOverrides", "optimizationQualificationForm", + "studioExecutionSpec", } hints = {key: value.get(key) for key in allowed if key in value} for key in ( "source", "device", - "modelFamily", "modelType", + "mode", "modelRepo", "modelName", "resolvedModelRepo", "resolvedArtifact", + "loaderModule", + "loaderAction", "executionPath", "pipelineClass", "dtype", @@ -5764,8 +6112,6 @@ def _coerce_runtime_hints(self, value): "quantizationMode", "offloadMode", "offloadDiskPath", - "resourceRetryLastError", - "resourceRetryLastCode", "cudaBudgetPolicy", "compatibilityStatus", "autoResourceProofStatus", @@ -5776,7 +6122,53 @@ def _coerce_runtime_hints(self, value): "nodeId", ): if key in hints and hints[key] is not None and not isinstance(hints[key], str): - hints[key] = str(hints[key]) + raw_value = hints[key] + if ( + not isinstance(raw_value, (bool, int, float)) + or isinstance(raw_value, float) and not math.isfinite(raw_value) + ): + raise self._auto_resource_contract_error( + "Runtime hint identity has an invalid primitive shape. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + hints[key] = str(raw_value) + if key in hints and isinstance(hints[key], str) and len(hints[key]) > 512: + raise self._auto_resource_contract_error( + "Auto runtime identity exceeds the supported execution contract. " + "Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + + enum_fields = { + "deviceMap": RUNTIME_DEVICE_MAPS, + "attentionBackend": RUNTIME_ATTENTION_BACKENDS, + "denoiserCache": RUNTIME_DENOISER_CACHE_MODES, + } + for key, admitted_values in enum_fields.items(): + if key not in hints or hints[key] is None: + hints.pop(key, None) + continue + if not isinstance(hints[key], str) or hints[key] not in admitted_values: + raise self._auto_resource_contract_error( + "Runtime hint execution selector is unsupported. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + + for key in ( + "autoOffload", + "enforceCudaBudget", + "regionalCompile", + "channelsLast", + "layerwiseCasting", + ): + if key not in hints or hints[key] is None: + hints.pop(key, None) + continue + if not isinstance(hints[key], bool): + raise self._auto_resource_contract_error( + "Runtime hint flag has an invalid primitive shape. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) workflow_canvas_epoch = hints.get("workflowCanvasEpoch") if workflow_canvas_epoch is not None and ( @@ -5787,23 +6179,40 @@ def _coerce_runtime_hints(self, value): ): hints.pop("workflowCanvasEpoch", None) + known_offload_modes = { + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + } for key in ("quantizedComponents", "supportedOffloadModes", "resourceRetryModes"): if key in hints and hints[key] is not None: if isinstance(hints[key], list): - hints[key] = [str(item) for item in hints[key] if item is not None] + if len(hints[key]) > 32: + raise self._auto_resource_contract_error( + "Runtime hint list exceeds the supported execution contract. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + normalized = [ + item + for item in hints[key] + if isinstance(item, str) and len(item) <= 128 + ] + if key != "quantizedComponents": + normalized = [item for item in normalized if item in known_offload_modes] + hints[key] = normalized else: hints.pop(key, None) if "modelDependencies" in hints and hints["modelDependencies"] is not None: - dependencies = hints["modelDependencies"] - if isinstance(dependencies, list): - hints["modelDependencies"] = [ - {key: str(item[key]) for key in ("id", "kind", "repo") if key in item and item[key] is not None} - for item in dependencies - if isinstance(item, dict) and item.get("repo") - ] - else: - hints.pop("modelDependencies", None) + dependencies = self._model_dependencies_signature(hints["modelDependencies"]) + if dependencies is None: + raise self._auto_resource_contract_error( + "Model dependency receipt is malformed. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + hints["modelDependencies"] = dependencies if ( "resourcePlan" in hints @@ -5811,6 +6220,74 @@ def _coerce_runtime_hints(self, value): and not isinstance(hints["resourcePlan"], dict) ): hints.pop("resourcePlan", None) + elif isinstance(hints.get("resourcePlan"), dict): + hints["resourcePlan"] = self._project_bounded_runtime_container( + hints["resourcePlan"], + expected_type=dict, + max_depth=8, + max_entries=4096, + max_items=64, + max_keys=128, + max_string_chars=4096, + max_total_chars=65536, + max_serialized_chars=65536, + ) + # Active retry state is produced only by this worker after a + # failed attempt; submitted copies are not execution authority. + hints["resourcePlan"].pop("activeRetryPlan", None) + + if "studioExecutionSpec" in hints: + receipt = hints["studioExecutionSpec"] + if not isinstance(receipt, dict): + raise self._auto_resource_contract_error( + "Studio execution specification receipt is malformed. Rebuild the managed graph.", + code="studio_execution_spec_mismatch", + ) + allowed_receipt_keys = {"schemaVersion", "id", "contentHash", "nodes"} + if set(receipt) != allowed_receipt_keys or not isinstance(receipt.get("nodes"), dict): + raise self._auto_resource_contract_error( + "Studio execution specification receipt is malformed. Rebuild the managed graph.", + code="studio_execution_spec_mismatch", + ) + nodes = receipt["nodes"] + if ( + receipt.get("schemaVersion") != 1 + or not isinstance(receipt.get("id"), str) + or len(receipt["id"]) > 128 + or not isinstance(receipt.get("contentHash"), str) + or re.fullmatch(r"studio-spec-v1-[0-9a-f]{8}", receipt["contentHash"]) is None + or len(nodes) > 32 + or any( + not isinstance(role, str) + or not role + or len(role) > 64 + or not isinstance(node_id, str) + or not node_id + or len(node_id) > 128 + for role, node_id in nodes.items() + ) + ): + raise self._auto_resource_contract_error( + "Studio execution specification receipt is invalid. Rebuild the managed graph.", + code="studio_execution_spec_mismatch", + ) + hints["studioExecutionSpec"] = { + key: deepcopy(receipt[key]) for key in ("schemaVersion", "id", "contentHash", "nodes") + } + specification = studio_execution_spec_for_pair( + str(hints.get("modelType") or ""), + str(hints.get("mode") or ""), + ) + if ( + specification is None + or receipt["id"] != specification["id"] + or receipt["contentHash"] != specification["contentHash"] + or set(nodes) != {role[0] for role in specification["roles"]} + ): + raise self._auto_resource_contract_error( + "Studio execution specification receipt does not match this workflow. Rebuild the managed graph.", + code="studio_execution_spec_mismatch", + ) if ( "autoResourcePlan" in hints @@ -5818,6 +6295,8 @@ def _coerce_runtime_hints(self, value): and not isinstance(hints["autoResourcePlan"], dict) ): hints.pop("autoResourcePlan", None) + elif isinstance(hints.get("autoResourcePlan"), dict): + hints["autoResourcePlan"] = self._project_bounded_auto_mapping(hints["autoResourcePlan"]) if ( "autoResourceCandidates" in hints @@ -5825,20 +6304,54 @@ def _coerce_runtime_hints(self, value): and not isinstance(hints["autoResourceCandidates"], list) ): hints.pop("autoResourceCandidates", None) + elif isinstance(hints.get("autoResourceCandidates"), list): + if len(hints["autoResourceCandidates"]) > 64: + raise self._auto_resource_contract_error( + "Auto candidate list exceeds the supported execution contract. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + if any(not isinstance(candidate, dict) for candidate in hints["autoResourceCandidates"]): + raise self._auto_resource_contract_error( + "Auto candidate list is malformed. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + hints["autoResourceCandidates"] = [ + self._project_bounded_auto_mapping(candidate) + for candidate in hints["autoResourceCandidates"] + if isinstance(candidate, dict) + ] + if len(json.dumps(hints["autoResourceCandidates"], ensure_ascii=False)) > 1_048_576: + raise self._auto_resource_contract_error( + "Auto candidate list exceeds the supported execution contract. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) - if ( - "resourceRetryHistory" in hints - and hints["resourceRetryHistory"] is not None - and not isinstance(hints["resourceRetryHistory"], list) + # Retry state is derived by the worker. Submitted copies must never + # influence a new run or survive into queue/completion payloads. + for key in ( + "resourceRetryAttempt", + "resourceRetryHistory", + "resourceRetryLastError", + "resourceRetryLastCode", ): - hints.pop("resourceRetryHistory", None) + hints.pop(key, None) if "resourceRetryPlans" in hints and hints["resourceRetryPlans"] is not None: if isinstance(hints["resourceRetryPlans"], list): + if len(hints["resourceRetryPlans"]) > 32: + raise self._auto_resource_contract_error( + "Auto retry list exceeds the supported execution contract. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + if any(not isinstance(item, dict) for item in hints["resourceRetryPlans"]): + raise self._auto_resource_contract_error( + "Auto retry list is malformed. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) plans = [] for item in hints["resourceRetryPlans"]: if isinstance(item, dict): - plans.append(deepcopy(item)) + plans.append(self._project_bounded_auto_mapping(item, retry=True)) hints["resourceRetryPlans"] = plans else: hints.pop("resourceRetryPlans", None) @@ -5849,6 +6362,18 @@ def _coerce_runtime_hints(self, value): and not isinstance(hints["compatibilityProbe"], dict) ): hints.pop("compatibilityProbe", None) + elif isinstance(hints.get("compatibilityProbe"), dict): + hints["compatibilityProbe"] = self._project_bounded_runtime_container( + hints["compatibilityProbe"], + expected_type=dict, + max_depth=8, + max_entries=4096, + max_items=256, + max_keys=256, + max_string_chars=4096, + max_total_chars=262144, + max_serialized_chars=262144, + ) for key in ( "cudaIndex", @@ -5868,6 +6393,13 @@ def _coerce_runtime_hints(self, value): workflow_title = hints["workflowTitle"] if workflow_title is None: hints.pop("workflowTitle", None) + elif not isinstance(workflow_title, (str, bool, int, float)) or ( + isinstance(workflow_title, float) and not math.isfinite(workflow_title) + ): + raise self._auto_resource_contract_error( + "Workflow title has an invalid primitive shape. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) else: hints["workflowTitle"] = str(workflow_title).strip()[:256] if not hints["workflowTitle"]: @@ -5875,25 +6407,81 @@ def _coerce_runtime_hints(self, value): if "workflowSnapshot" in hints: workflow_snapshot = hints["workflowSnapshot"] if isinstance(workflow_snapshot, dict): - hints["workflowSnapshot"] = deepcopy(workflow_snapshot) + hints["workflowSnapshot"] = self._project_bounded_runtime_container( + workflow_snapshot, + expected_type=dict, + max_depth=32, + max_entries=200000, + max_items=20000, + max_keys=20000, + max_string_chars=1_048_576, + max_total_chars=8_388_608, + max_serialized_chars=8_388_608, + ) else: hints.pop("workflowSnapshot", None) if "autoFieldOverrides" in hints: overrides = hints["autoFieldOverrides"] if isinstance(overrides, list): - hints["autoFieldOverrides"] = [ - { - key: deepcopy(item[key]) - for key in ("schemaVersion", "nodeId", "fieldKey", "formKey", "value", "updatedAt") - if key in item - } - for item in overrides[:256] - if isinstance(item, dict) - and isinstance(item.get("nodeId"), str) - and isinstance(item.get("fieldKey"), str) - ] + if len(overrides) > 256: + raise self._auto_resource_contract_error( + "Auto field override list exceeds the supported execution contract. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + projected_overrides = [] + for item in overrides: + if ( + not isinstance(item, dict) + or not isinstance(item.get("nodeId"), str) + or not isinstance(item.get("fieldKey"), str) + or len(item["nodeId"]) > 512 + or len(item["fieldKey"]) > 512 + ): + raise self._auto_resource_contract_error( + "Auto field override identity is malformed. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + projected_overrides.append( + self._project_bounded_runtime_container( + { + key: item[key] + for key in ("schemaVersion", "nodeId", "fieldKey", "formKey", "value", "updatedAt") + if key in item + }, + expected_type=dict, + max_depth=8, + max_entries=4096, + max_items=64, + max_keys=16, + max_string_chars=4096, + max_total_chars=65536, + max_serialized_chars=65536, + ) + ) + if len(json.dumps(projected_overrides, ensure_ascii=False)) > 1_048_576: + raise self._auto_resource_contract_error( + "Auto field override data exceeds the supported execution contract. Refresh the workflow before running it.", + code="auto_resource_candidate_mismatch", + ) + hints["autoFieldOverrides"] = projected_overrides else: hints.pop("autoFieldOverrides", None) + if "optimizationQualificationForm" in hints: + form = hints["optimizationQualificationForm"] + if isinstance(form, dict): + hints["optimizationQualificationForm"] = self._project_bounded_runtime_container( + form, + expected_type=dict, + max_depth=12, + max_entries=16384, + max_items=512, + max_keys=512, + max_string_chars=65536, + max_total_chars=524288, + max_serialized_chars=524288, + ) + else: + hints.pop("optimizationQualificationForm", None) if "maxRuntimeSeconds" in hints: # Quality-first local video models can legitimately need more than # six hours at their upstream-recommended step count. Keep a hard @@ -5901,14 +6489,16 @@ def _coerce_runtime_hints(self, value): # merely to fit the old gallery-oriented limit. hints["maxRuntimeSeconds"] = max(60, min(43200, hints["maxRuntimeSeconds"])) - for key in ("autoOffload", "lowVramMode"): - if key in hints and hints[key] is not None: - hints[key] = bool(hints[key]) - if "enforceCudaBudget" in hints and hints["enforceCudaBudget"] is not None: - hints["enforceCudaBudget"] = bool(hints["enforceCudaBudget"]) if hints.get("cudaBudgetPolicy") not in (None, "advisory", "enforced"): hints.pop("cudaBudgetPolicy", None) + if isinstance(hints.get("resourceRetryPlans"), list): + canonical_plans = self._coerce_retry_plan_list(hints) + hints["resourceRetryPlans"] = [ + self._sanitize_retry_plan_for_hints(plan) + for plan in canonical_plans + ] + return hints def _cuda_index_from_runtime_hints(self, hints): @@ -6056,7 +6646,6 @@ def _apply_cuda_runtime_budget(self, runtime_hints): "quantization_mode": runtime_hints.get("quantizationMode") if runtime_hints else None, "auto_offload": runtime_hints.get("autoOffload") if runtime_hints else None, "offload_mode": runtime_hints.get("offloadMode") if runtime_hints else None, - "low_vram_mode": runtime_hints.get("lowVramMode") if runtime_hints else None, } def _resource_retry_modes(self, runtime_hints): @@ -6068,12 +6657,19 @@ def _resource_retry_modes(self, runtime_hints): OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK, ] - requested = runtime_hints.get("resourceRetryModes") - modes = requested if isinstance(requested, list) else allowed + if runtime_hints.get("resourceMode") == "auto": + selected = runtime_hints.get("autoResourcePlan") + target = selected if isinstance(selected, dict) else runtime_hints + profile = self._resource_plan_execution_profile(target, runtime_hints=runtime_hints) + modes = list(profile.retry_offload_modes) + else: + requested = runtime_hints.get("resourceRetryModes") + modes = requested if isinstance(requested, list) else allowed modes = [mode for mode in modes if mode in allowed] current_mode = runtime_hints.get("offloadMode") - if current_mode in modes: - return modes[modes.index(current_mode) + 1 :] + if current_mode in allowed: + current_index = allowed.index(current_mode) + return [mode for mode in modes if allowed.index(mode) > current_index] return modes def _coerce_retry_plan_list(self, runtime_hints): @@ -6086,22 +6682,406 @@ def _coerce_retry_plan_list(self, runtime_hints): for index, raw_plan in enumerate(raw_plans): if not isinstance(raw_plan, dict): continue - plan = deepcopy(raw_plan) - plan.setdefault("index", index) - plan.setdefault("reason", f"retry_plan_{index + 1}") + if runtime_hints.get("resourceMode") == "auto": + plan = self._canonical_auto_retry_plan(runtime_hints, raw_plan, index=index) + else: + plan = deepcopy(raw_plan) + plan["index"] = index + plan["reason"] = f"retry_plan_{index + 1}" + plan.pop("candidateId", None) + plan.pop("id", None) + self._normalize_retry_plan_triggers(plan) + profile = self._resource_plan_execution_profile(plan, runtime_hints=runtime_hints) + self._assert_resource_plan_values_supported(plan, profile) plans.append(plan) return plans + selected = runtime_hints.get("autoResourcePlan") + target_source = selected if isinstance(selected, dict) else runtime_hints + target = { + key: target_source.get(key) + for key in ( + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + ) + } + target.update( + (key, target_source[key]) + for key in ("autoResourceSchemaVersion", "executionProfileId") + if key in target_source + ) + if not all(isinstance(target[key], str) and target[key] for key in ( + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + )): + return [] return [ { "index": index, "reason": f"{mode}_after_oom", + "candidateId": target_source.get("id"), + **target, "offloadMode": mode, "onCategories": ["oom"], } for index, mode in enumerate(self._resource_retry_modes(runtime_hints)) ] + @staticmethod + def _auto_resource_contract_error(message, *, code="auto_resource_target_mismatch"): + error = RuntimeError(message) + setattr(error, "modiff_error_code", code) + setattr(error, "modiff_category", "auto_resource") + setattr( + error, + "modiff_recovery_hint", + "Refresh Auto so every plan is resolved from the current exact candidate and loader contract.", + ) + setattr(error, "modiff_auto_resource_status", "expert_only") + return error + + @staticmethod + def _controlled_artifact_contract_error(): + error = RuntimeError( + "A controlled workflow artifact does not match its exact executable receipt. " + "Repair or rebuild the workflow before running it." + ) + setattr(error, "modiff_error_code", "controlled_artifact_mismatch") + setattr(error, "modiff_category", "model") + setattr( + error, + "modiff_recovery_hint", + "Repair the pinned artifact in Model Manager or rebuild the controlled workflow block.", + ) + return error + + def _bind_controlled_artifact_receipts(self, graph, runtime_hints): + if not isinstance(runtime_hints, dict): + return [] + try: + selected = runtime_hints.get("autoResourcePlan") + receipts = controlled_artifact_receipts_from_graph( + graph, + primary_candidate=selected if isinstance(selected, dict) else None, + ) + except Exception as exc: + raise self._controlled_artifact_contract_error() from exc + runtime_hints["controlledArtifacts"] = deepcopy(receipts) + if runtime_hints.get("resourceMode") == "auto": + if isinstance(selected, dict): + selected["controlledArtifacts"] = deepcopy(receipts) + candidates = runtime_hints.get("autoResourceCandidates") + if isinstance(candidates, list): + for candidate in candidates: + if isinstance(candidate, dict): + candidate["controlledArtifacts"] = deepcopy(receipts) + if receipts: + runtime_fingerprint = self._runtime_fingerprint() + auto_history = read_auto_resource_history(self.data_dir) + candidates_to_check = [selected] if isinstance(selected, dict) else [] + candidates_to_check.extend( + candidate + for candidate in (candidates if isinstance(candidates, list) else []) + if isinstance(candidate, dict) + ) + for candidate in candidates_to_check: + proof = candidate.get("proof") + if not isinstance(proof, dict) or proof.get("status") != "live_proven": + continue + exact_history = matching_auto_resource_success_history( + self.data_dir, + candidate=candidate, + runtime_fingerprint=runtime_fingerprint, + history=auto_history, + ) + if exact_history is None: + candidate["proof"] = { + **proof, + "status": "skipped", + "source": "controlled_artifact_history_required", + "message": ( + "Earlier base-only Auto evidence does not qualify this exact controlled artifact set." + ), + } + candidate["successHistory"] = None + else: + candidate["successHistory"] = exact_history + return receipts + + @staticmethod + def _normalize_retry_plan_triggers(plan): + # These are the stable resource-pressure classifications for which a + # loader recipe change can be corrective. Keep this narrower than the + # full runtime classifier while preserving the existing Qwen kernel + # fallback contract. + allowed_categories = {"oom", "cuda_kernel"} + allowed_error_codes = {"cuda_oom", "cuda_kernel_unsupported"} + if "onCategories" in plan: + plan["onCategories"] = [ + value + for value in plan.get("onCategories") or [] + if isinstance(value, str) and value in allowed_categories + ] + if "onErrorCodes" in plan: + plan["onErrorCodes"] = [ + value + for value in plan.get("onErrorCodes") or [] + if isinstance(value, str) and value in allowed_error_codes + ] + + @staticmethod + def _auto_candidate_execution_fields(): + return ( + "autoResourceSchemaVersion", + "executionProfileId", + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + "artifact", + "baseArtifact", + "modelRepo", + "resolvedArtifact", + "artifactRevision", + "artifactResolution", + "dtype", + "quantizationMode", + "loadedQuantization", + "quantizedComponents", + "bnb4ComputeDtype", + "offloadMode", + "autoOffload", + "deviceMap", + "generation", + "attentionBackend", + "regionalCompile", + "denoiserCache", + "channelsLast", + "layerwiseCasting", + "modelDependencies", + "optionalRuntimeProfileIds", + "optionalRuntimeRequirement", + "studioExecutionSpecContract", + "proof", + "readiness", + "canAutoRun", + "exactPairDeclared", + "profileArtifactCompatible", + "requiresConfirmation", + "artifactTrust", + "knownBadReasons", + "requirementsMissing", + "requirements", + ) + + def _runtime_auto_candidate(self, runtime_hints, candidate_id): + candidates = runtime_hints.get("autoResourceCandidates") if isinstance(runtime_hints, dict) else None + if ( + not isinstance(candidate_id, str) + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", candidate_id) is None + or not isinstance(candidates, list) + ): + raise self._auto_resource_contract_error( + "Auto candidate binding is missing. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + matches = [ + candidate + for candidate in candidates + if isinstance(candidate, dict) and candidate.get("id") == candidate_id + ] + if len(matches) != 1: + raise self._auto_resource_contract_error( + "Auto candidate binding is stale or ambiguous. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + return matches[0] + + def _assert_same_auto_candidate_recipe(self, plan, candidate): + if not isinstance(plan, dict) or not isinstance(candidate, dict): + raise self._auto_resource_contract_error( + "Auto candidate recipe is unavailable. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + mismatches = [ + key + for key in self._auto_candidate_execution_fields() + if plan.get(key) != candidate.get(key) + ] + if plan.get("controlledArtifacts") != candidate.get("controlledArtifacts"): + mismatches.append("controlledArtifacts") + if mismatches: + raise self._auto_resource_contract_error( + "Auto candidate recipe does not match the current candidate list. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + + @staticmethod + def _resource_plan_artifact(plan): + if not isinstance(plan, dict): + return None + resolution = plan.get("artifactResolution") + resolved = resolution.get("resolved") if isinstance(resolution, dict) else None + values = [ + plan.get("modelRepo"), + plan.get("resolvedArtifact"), + plan.get("artifact"), + resolved.get("repo") if isinstance(resolved, dict) else None, + ] + repos = [value for value in values if isinstance(value, str) and value] + if not repos: + return None + if any(repo != repos[0] for repo in repos[1:]): + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains inconsistent artifact identities. Refresh Auto before running this workflow." + ) + return repos[0] + + @staticmethod + def _assert_resource_plan_artifact_compatible(plan, profile): + repo = WebServer._resource_plan_artifact(plan) + if repo is None: + return + compatible = { + item + for item in (profile.default_repo, profile.fallback_repo, *profile.compatible_repos) + if isinstance(item, str) and item + } + if repo not in compatible: + raise WebServer._auto_resource_contract_error( + "Auto resource plan artifact is not admitted by its exact execution profile. " + "Refresh Auto before running this workflow." + ) + + @staticmethod + def _assert_resource_plan_values_supported(plan, profile): + offload_mode = plan.get("offloadMode") + if offload_mode is not None and offload_mode not in profile.supported_offload_modes: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported offload mode. Refresh Auto before running this workflow." + ) + dtype = plan.get("dtype") + if dtype is not None and dtype not in {"float32", "float16", "bfloat16"}: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported dtype. Refresh Auto before running this workflow." + ) + compute_dtype = plan.get("bnb4ComputeDtype") + if compute_dtype is not None and compute_dtype not in {"float32", "float16", "bfloat16"}: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported compute dtype. Refresh Auto before running this workflow." + ) + quantization_mode = plan.get("quantizationMode") + if quantization_mode is not None and quantization_mode not in { + "none", + "bnb_4bit", + "bnb_8bit", + "quanto_float8", + "quanto_int8", + "torchao_float8", + "torchao_int8_weight_only", + }: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported quantization mode. Refresh Auto before running this workflow." + ) + device_map = plan.get("deviceMap") + if device_map is not None and device_map not in RUNTIME_DEVICE_MAPS: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported device map. Refresh Auto before running this workflow." + ) + attention_backend = plan.get("attentionBackend") + if attention_backend is not None and attention_backend not in RUNTIME_ATTENTION_BACKENDS: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported attention backend. Refresh Auto before running this workflow." + ) + denoiser_cache = plan.get("denoiserCache") + if denoiser_cache is not None and denoiser_cache not in RUNTIME_DENOISER_CACHE_MODES: + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an unsupported denoiser cache. Refresh Auto before running this workflow." + ) + for key in ("autoOffload", "regionalCompile", "channelsLast", "layerwiseCasting"): + if key in plan and plan.get(key) is not None and not isinstance(plan.get(key), bool): + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains an invalid runtime flag. Refresh Auto before running this workflow." + ) + components = plan.get("quantizedComponents") + if components is not None: + admitted = set(profile.quantizable_components) + if ( + not isinstance(components, list) + or any(not isinstance(item, str) or item not in admitted for item in components) + ): + raise WebServer._auto_resource_contract_error( + "Auto resource plan contains unsupported quantized components. " + "Refresh Auto before running this workflow." + ) + + def _canonical_auto_retry_plan(self, runtime_hints, raw_plan, *, index): + candidate_id = raw_plan.get("candidateId") if isinstance(raw_plan, dict) else None + candidate = self._runtime_auto_candidate(runtime_hints, candidate_id) + retry_identity_fields = ( + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + ) + if any(raw_plan.get(key) != candidate.get(key) for key in retry_identity_fields): + raise self._auto_resource_contract_error( + "Auto retry identity does not match its current candidate. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + if any( + key in raw_plan and raw_plan.get(key) != candidate.get(key) + for key in self._auto_candidate_execution_fields() + if key not in retry_identity_fields + ): + raise self._auto_resource_contract_error( + "Auto retry recipe does not match its current candidate. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + selected = runtime_hints.get("autoResourcePlan") + selected_id = selected.get("id") if isinstance(selected, dict) else None + selected_candidate = self._runtime_auto_candidate(runtime_hints, selected_id) + self._assert_same_auto_candidate_recipe(selected, selected_candidate) + + selected_profile = self._resource_plan_execution_profile(selected, runtime_hints=runtime_hints) + retry_profile = self._resource_plan_execution_profile(candidate, runtime_hints=runtime_hints) + if retry_profile.id != selected_profile.id: + raise self._auto_resource_contract_error( + "Auto retry candidate does not belong to the selected execution profile. " + "Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + if not self._auto_resource_candidate_is_proven(candidate): + raise self._auto_resource_contract_error( + "Auto retry candidate is not runnable. Refresh Auto before running this workflow.", + code="auto_resource_candidate_mismatch", + ) + self._assert_resource_plan_artifact_compatible(candidate, retry_profile) + self._assert_resource_plan_values_supported(candidate, retry_profile) + + plan = deepcopy(candidate) + plan["candidateId"] = candidate_id + plan["index"] = index + plan["reason"] = f"candidate_retry_{index + 1}" + for key in ("onCategories", "onErrorCodes"): + values = raw_plan.get(key) + if isinstance(values, list): + plan[key] = list(values) + self._normalize_retry_plan_triggers(plan) + return plan + def _set_param_value_if_present(self, node, key, value): params = node.get("params") if isinstance(node, dict) else None if not isinstance(params, dict) or key not in params or not isinstance(params[key], dict): @@ -6122,38 +7102,336 @@ def _set_model_repo_if_present(self, node, key, repo): params = node.get("params") if isinstance(node, dict) else None if not repo or not isinstance(params, dict) or key not in params or not isinstance(params[key], dict): return False - current = params[key].get("value") - current_repo = current.get("value") if isinstance(current, dict) else current - if current_repo == repo: + current = params[key].get("value") + current_repo = current.get("value") if isinstance(current, dict) else current + current_source = current.get("source") if isinstance(current, dict) else "hub" + if current_repo == repo and current_source == "hub": + return False + if isinstance(current, dict): + params[key]["value"] = {**current, "source": "hub", "value": repo} + else: + params[key]["value"] = {"source": "hub", "value": repo} + return True + + @staticmethod + def _auto_resource_artifact_revision(plan, repo): + """Resolve the exact Hub commit owned by one Auto artifact.""" + + if not isinstance(plan, dict) or not isinstance(repo, str) or not repo: + raise WebServer._auto_resource_contract_error( + "Auto repository mutation requires a reviewed artifact identity." + ) + resolution = plan.get("artifactResolution") + resolved = resolution.get("resolved") if isinstance(resolution, dict) else None + declared = plan.get("artifactRevision") + if declared in (None, "") and isinstance(resolved, dict): + declared = resolved.get("revision") + if declared not in (None, ""): + if ( + not isinstance(declared, str) + or declared != declared.strip() + or declared != declared.lower() + or not IMMUTABLE_HUB_REVISION.fullmatch(declared) + ): + raise WebServer._auto_resource_contract_error( + "Auto artifact requires an exact lowercase 40-character commit revision." + ) + else: + declared = None + + reviewed = catalog_revision(repo) + if reviewed is not None and declared is not None and declared != reviewed: + raise WebServer._auto_resource_contract_error( + "Auto artifact revision does not match the reviewed catalog commit." + ) + revision = reviewed or declared + if revision is None: + raise WebServer._auto_resource_contract_error( + "Auto artifact has no immutable reviewed revision." + ) + return revision + + def _set_model_repo_and_revision_if_present( + self, + node, + key, + repo, + revision, + *, + node_id, + pinned_fields, + dry_run=False, + ): + """Mutate a generic loader's Hub repository and commit as one identity.""" + + params = node.get("params") if isinstance(node, dict) else None + field = params.get(key) if isinstance(params, dict) else None + if not repo or not isinstance(field, dict): + return False + node_key = str(node_id) + if (node_key, key) in pinned_fields: + # A pinned repository selection owns its existing revision too. + return False + + current = field.get("value", field.get("default")) + current_repo = current.get("value") if isinstance(current, dict) else current + current_source = current.get("source") if isinstance(current, dict) else "hub" + repository_changes = current_repo != repo or current_source != "hub" + revision_field = params.get("revision") if isinstance(params, dict) else None + if not isinstance(revision_field, dict): + if repository_changes: + raise self._auto_resource_contract_error( + "Auto cannot change a loader repository without a revision field on the same loader." + ) + return False + + revision_is_pinned = (node_key, "revision") in pinned_fields + current_revision = revision_field.get("value", revision_field.get("default")) + if revision_is_pinned and repository_changes and current_revision != revision: + raise self._auto_resource_contract_error( + "Auto cannot change a loader repository while its revision override is pinned to a different commit. " + "Unpin both fields or switch to Expert." + ) + + revision_changes = not revision_is_pinned and current_revision != revision + if dry_run: + return repository_changes or revision_changes + changed = self._set_model_repo_if_present(node, key, repo) + if revision_changes: + revision_field["value"] = revision + changed = True + return changed + + @staticmethod + def _resource_plan_execution_profile(plan, *, runtime_hints=None): + """Resolve and validate the one backend-owned target for a plan.""" + + if not isinstance(plan, dict): + raise WebServer._auto_resource_contract_error( + "Auto resource plan must be a JSON object with an exact loader target." + ) + hints = runtime_hints if isinstance(runtime_hints, dict) else {} + for key in ("modelType", "mode"): + plan_value = plan.get(key) + hint_value = hints.get(key) + if ( + isinstance(plan_value, str) + and isinstance(hint_value, str) + and hint_value + and plan_value != hint_value + ): + raise WebServer._auto_resource_contract_error( + f"Auto resource plan {key} does not match the active workflow. " + "Refresh Auto before running this workflow." + ) + model_type = plan.get("modelType") + mode = plan.get("mode") + if ( + not isinstance(model_type, str) + or not model_type + or model_type != model_type.strip() + or not isinstance(mode, str) + or not mode + or mode != mode.strip() + ): + raise WebServer._auto_resource_contract_error( + "Auto resource plan requires an exact modelType and mode before it can target a loader." + ) + profiles = execution_profiles_for_execution(model_type, mode) + if len(profiles) != 1: + raise WebServer._auto_resource_contract_error( + "Auto resource plan does not resolve to one exact execution profile." + ) + profile = profiles[0] + expected = { + "loaderModule": profile.loader_module, + "loaderAction": profile.loader_action, + "executionPath": profile.execution_path, + "pipelineClass": profile.pipeline_class, + } + if hints.get("resourceMode") == "auto" or any( + key in plan for key in ("autoResourceSchemaVersion", "executionProfileId") + ): + expected.update( + autoResourceSchemaVersion=AUTO_RESOURCE_SCHEMA_VERSION, + executionProfileId=profile.id, + ) + mismatches = [ + key + for key, value in expected.items() + if plan.get(key) != value + ] + if mismatches: + raise WebServer._auto_resource_contract_error( + f"Auto resource plan target does not match execution profile {profile.id!r}: " + + ", ".join(mismatches) + + ". Refresh Auto before running this workflow." + ) + hint_mismatches = [ + key + for key, value in expected.items() + if hints.get(key) not in (None, "") and hints.get(key) != value + ] + if hint_mismatches: + raise WebServer._auto_resource_contract_error( + f"Active workflow target does not match execution profile {profile.id!r}: " + + ", ".join(hint_mismatches) + + ". Refresh Auto before running this workflow." + ) + if hints.get("resourceMode") == "auto" or any( + key in plan for key in ("optionalRuntimeProfileIds", "optionalRuntimeRequirement") + ): + expected_profile_ids = list( + optional_runtime_profile_ids_for_execution(model_type, mode) + ) + expected_requirement = WebServer._optional_runtime_contract_signature( + optional_runtime_requirement_for_execution(model_type, mode) + ) + if ( + plan.get("optionalRuntimeProfileIds") != expected_profile_ids + or WebServer._optional_runtime_contract_signature( + plan.get("optionalRuntimeRequirement") + ) + != expected_requirement + ): + raise WebServer._auto_resource_contract_error( + "Auto resource plan optional-runtime receipt does not match its execution profile. " + "Refresh Auto before running this workflow." + ) + if hints.get("resourceMode") == "auto" or "studioExecutionSpecContract" in plan: + specification = studio_execution_spec_for_pair(model_type, mode) + expected_contract = WebServer._studio_execution_spec_contract_signature( + specification + ) + declared_contract = plan.get("studioExecutionSpecContract") + declared_keys_are_exact = ( + isinstance(declared_contract, dict) + and set(declared_contract) + == {"schemaVersion", "id", "contentHash", "executionProfileId"} + ) + if ( + (expected_contract is None and declared_contract is not None) + or ( + expected_contract is not None + and ( + not declared_keys_are_exact + or WebServer._studio_execution_spec_contract_signature(declared_contract) + != expected_contract + ) + ) + ): + raise WebServer._auto_resource_contract_error( + "Auto resource plan graph receipt does not match the current Studio execution specification. " + "Refresh Auto before running this workflow." + ) + if hints.get("resourceMode") == "auto" or "modelDependencies" in plan: + expected_dependencies = WebServer._model_dependencies_signature( + studio_model_dependencies_for_pair(model_type, mode) + ) + if ( + WebServer._model_dependencies_signature(plan.get("modelDependencies")) + != expected_dependencies + or "modelDependencies" in hints + and WebServer._model_dependencies_signature(hints.get("modelDependencies")) + != expected_dependencies + ): + raise WebServer._auto_resource_contract_error( + "Auto resource plan model dependencies do not match the current reviewed artifact contract. " + "Refresh Auto before running this workflow." + ) + return profile + + @staticmethod + def _optional_runtime_contract_signature(requirement): + if not isinstance(requirement, dict): + return None + profile_ids = requirement.get("profileIds") + execution_profile_ids = requirement.get("executionProfileIds") + if ( + isinstance(requirement.get("schemaVersion"), bool) + or not isinstance(requirement.get("schemaVersion"), int) + or not isinstance(requirement.get("delivery"), str) + or type(requirement.get("requiredNow")) is not bool + or not isinstance(profile_ids, list) + or any(not isinstance(item, str) for item in profile_ids) + or not isinstance(execution_profile_ids, list) + or any(not isinstance(item, str) for item in execution_profile_ids) + ): + return None + return { + "schemaVersion": requirement["schemaVersion"], + "delivery": requirement["delivery"], + "requiredNow": requirement["requiredNow"], + "profileIds": list(profile_ids), + "executionProfileIds": list(execution_profile_ids), + } + + @staticmethod + def _studio_execution_spec_contract_signature(contract): + if not isinstance(contract, dict): + return None + if ( + isinstance(contract.get("schemaVersion"), bool) + or not isinstance(contract.get("schemaVersion"), int) + or not isinstance(contract.get("id"), str) + or not isinstance(contract.get("contentHash"), str) + or not isinstance(contract.get("executionProfileId"), str) + ): + return None + return { + "schemaVersion": contract["schemaVersion"], + "id": contract["id"], + "contentHash": contract["contentHash"], + "executionProfileId": contract["executionProfileId"], + } + + @staticmethod + def _model_dependencies_signature(dependencies): + if not isinstance(dependencies, list) or len(dependencies) > 32: + return None + output = [] + for dependency in dependencies: + if not isinstance(dependency, dict) or set(dependency) != { + "id", + "kind", + "repo", + "revision", + }: + return None + if not all( + isinstance(dependency.get(key), str) + and dependency[key] + and len(dependency[key]) <= 512 + for key in dependency + ): + return None + output.append({key: dependency[key] for key in ("id", "kind", "repo", "revision")}) + if len({dependency["id"] for dependency in output}) != len(output): + return None + return sorted(output, key=lambda dependency: (dependency["kind"], dependency["id"], dependency["repo"])) + + @staticmethod + def _resource_plan_node_matches_profile(node, profile): + if ( + not isinstance(node, dict) + or node.get("module") != profile.loader_module + or node.get("action") != profile.loader_action + ): + return False + params = node.get("params") + if not isinstance(params, dict): return False - if isinstance(current, dict): - params[key]["value"] = {**current, "value": repo} - else: - params[key]["value"] = {"source": "hub", "value": repo} - return True - - def _resource_plan_loader_module(self, plan): - """Resolve the direct-loader family owned by a structured Auto plan. + identity_key = "model_type" if profile.loader_action == "ModelsLoader" else "pipeline_class" + identity_param = params.get(identity_key) + if not isinstance(identity_param, dict): + return False + identity = identity_param.get("value", identity_param.get("default")) + expected_identity = profile.model_type if identity_key == "model_type" else profile.pipeline_class + return identity == expected_identity - A Studio graph may intentionally contain more than one independent - Diffusers pipeline (for example ACE-Step audio plus LTX video). Model - and pipeline-class overrides belong only to the plan's family; applying - them to every loader corrupts the other branch before execution. - """ - pipeline_class = str(plan.get("pipelineClass") or "").strip().lower() if isinstance(plan, dict) else "" - if not pipeline_class: - return None - if "acestep" in pipeline_class or "audio" in pipeline_class: - return "modules.DiffusersAudio" - if any(token in pipeline_class for token in ("wan", "ltx", "video", "framepack", "hunyuan", "mochi")): - return "modules.DiffusersVideo" - return "modules.DiffusersImage" - - def _resource_plan_targets_node_family(self, node, plan): - target_module = self._resource_plan_loader_module(plan) - if target_module is None: - return True - return isinstance(node, dict) and node.get("module") == target_module + def _resource_plan_targets_node_family(self, node, plan, *, runtime_hints=None): + profile = self._resource_plan_execution_profile(plan, runtime_hints=runtime_hints) + return self._resource_plan_node_matches_profile(node, profile) def _retry_plan_matches(self, plan, classification): if not isinstance(plan, dict) or not isinstance(classification, dict): @@ -6196,21 +7474,43 @@ def _sanitize_retry_plan_for_hints(self, plan): allowed = { "index", "reason", + "autoResourceSchemaVersion", + "executionProfileId", + "modelType", + "mode", + "loaderModule", + "loaderAction", "executionPath", "modelRepo", "resolvedArtifact", + "artifactRevision", + "artifactResolution", "quantizationMode", "quantizedComponents", "bnb4ComputeDtype", "dtype", "pipelineClass", + "candidateId", + "id", "offloadMode", "deviceMap", "generation", "onCategories", "onErrorCodes", } - return {key: deepcopy(plan.get(key)) for key in allowed if key in plan} + sanitized = {key: deepcopy(plan.get(key)) for key in allowed if key in plan} + for key in ("candidateId", "id"): + value = sanitized.get(key) + if not isinstance(value, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", value) is None: + sanitized.pop(key, None) + index = sanitized.get("index") + sanitized["reason"] = ( + f"retry_plan_{index + 1}" + if isinstance(index, int) and not isinstance(index, bool) and 0 <= index < 32 + else "retry_plan" + ) + self._normalize_retry_plan_triggers(sanitized) + return sanitized def _apply_resource_retry_to_graph(self, graph, offload_mode): nodes = graph.get("nodes", {}) @@ -6245,7 +7545,32 @@ def _apply_resource_retry_plan_to_graph(self, graph, plan): if not isinstance(nodes, dict) or not isinstance(plan, dict): return [] - runtime_hints = graph.get("runtimeHints") + runtime_hints = self._coerce_runtime_hints(graph.get("runtimeHints")) + profile = self._resource_plan_execution_profile(plan, runtime_hints=runtime_hints) + if isinstance(runtime_hints, dict) and runtime_hints.get("resourceMode") == "auto": + self._assert_resource_plan_artifact_compatible(plan, profile) + self._assert_resource_plan_values_supported(plan, profile) + paths = graph.get("paths") + executable_node_ids = None + if isinstance(paths, list): + executable_node_ids = { + str(node_id) + for path in paths + if isinstance(path, list) + for node_id in path + } + target_nodes = [ + (node_id, node) + for node_id, node in nodes.items() + if (executable_node_ids is None or str(node_id) in executable_node_ids) + and self._resource_plan_node_matches_profile(node, profile) + ] + if not target_nodes: + raise self._auto_resource_contract_error( + f"Auto resource plan target {profile.loader_module}.{profile.loader_action} matched zero exact " + "loader identities. Refresh Auto before running this workflow." + ) + target_node_ids = {str(node_id) for node_id, _node in target_nodes} raw_overrides = runtime_hints.get("autoFieldOverrides") if isinstance(runtime_hints, dict) else None pinned_fields = { (str(item.get("nodeId")), str(item.get("fieldKey"))) @@ -6259,50 +7584,84 @@ def set_param(node_id, node, param_key, value): return self._set_param_value_if_present(node, param_key, value) def set_model_repo(node_id, node, param_key, value): - if (str(node_id), param_key) in pinned_fields: - return False - return self._set_model_repo_if_present(node, param_key, value) + return self._set_model_repo_and_revision_if_present( + node, + param_key, + value, + artifact_revision, + node_id=node_id, + pinned_fields=pinned_fields, + ) offload_mode = plan.get("offloadMode") device_map = plan.get("deviceMap") model_repo = plan.get("modelRepo") or plan.get("resolvedArtifact") + artifact_revision = ( + self._auto_resource_artifact_revision(plan, model_repo) + if isinstance(model_repo, str) and model_repo + else None + ) quantization_mode = plan.get("quantizationMode") quantized_components = plan.get("quantizedComponents") compute_dtype = plan.get("bnb4ComputeDtype") dtype = plan.get("dtype") - target_recipe_ids = set() - for node in nodes.values(): - if not isinstance(node, dict): - continue - module = node.get("module") - action = node.get("action") - compatible_loader = ( - module == "modules.ModularDiffusers" and action in ("ModelsLoader", "DynamicPipelineLoader") - ) or ( - module in ("modules.DiffusersImage", "modules.DiffusersAudio", "modules.DiffusersVideo") - and action == "LoadPipeline" + applicable_param_keys = set() + if isinstance(offload_mode, str): + applicable_param_keys.update(("offload_mode", "auto_offload")) + if isinstance(device_map, str): + applicable_param_keys.add("device_map") + if isinstance(model_repo, str) and model_repo: + applicable_param_keys.update(("model_id", "repo_id")) + if isinstance(dtype, str): + applicable_param_keys.add("dtype") + if profile.loader_module == "modules.DiffusersImage": + if isinstance(quantization_mode, str): + applicable_param_keys.add("quantization_mode") + if isinstance(quantized_components, list): + applicable_param_keys.add("quantized_components") + if isinstance(compute_dtype, str): + applicable_param_keys.add("bnb_4bit_compute_dtype") + if not any( + isinstance(node.get("params"), dict) + and any(key in node["params"] for key in applicable_param_keys) + for _node_id, node in target_nodes + ): + raise self._auto_resource_contract_error( + f"Auto resource plan target {profile.loader_module}.{profile.loader_action} matched loader nodes " + "but none exposes an applicable plan field. Refresh Auto before running this workflow." ) - if not compatible_loader or not self._resource_plan_targets_node_family(node, plan): - continue + target_recipe_ids = set() + for _node_id, node in target_nodes: recipe_param = (node.get("params") or {}).get("execution_recipe") recipe_source_id = recipe_param.get("sourceId") if isinstance(recipe_param, dict) else None if isinstance(recipe_source_id, str) and recipe_source_id: target_recipe_ids.add(recipe_source_id) + # Validate every target before mutating any node. A pinned stale commit + # must fail the whole Auto rewrite without leaving a partially changed + # graph behind. + if isinstance(model_repo, str) and model_repo: + for node_id, node in target_nodes: + for param_key in ("model_id", "repo_id"): + self._set_model_repo_and_revision_if_present( + node, + param_key, + model_repo, + artifact_revision, + node_id=node_id, + pinned_fields=pinned_fields, + dry_run=True, + ) + updated = [] for node_id, node in nodes.items(): if not isinstance(node, dict): continue action = node.get("action") module = node.get("module") - compatible_loader = ( - module == "modules.ModularDiffusers" and action in ("ModelsLoader", "DynamicPipelineLoader") - ) or ( - module in ("modules.DiffusersImage", "modules.DiffusersAudio", "modules.DiffusersVideo") - and action == "LoadPipeline" - ) if ( str(node_id) in target_recipe_ids + and (executable_node_ids is None or str(node_id) in executable_node_ids) and module == "modules.DiffusersRuntime" and action == "DiffusersExecutionRecipe" ): @@ -6314,7 +7673,7 @@ def set_model_repo(node_id, node, param_key, value): if recipe_changed: updated.append(str(node_id)) - if compatible_loader and self._resource_plan_targets_node_family(node, plan): + if str(node_id) in target_node_ids: changed = False if isinstance(offload_mode, str): changed = set_param(node_id, node, "offload_mode", offload_mode) or changed @@ -6324,8 +7683,10 @@ def set_model_repo(node_id, node, param_key, value): if isinstance(model_repo, str) and model_repo: changed = set_model_repo(node_id, node, "model_id", model_repo) or changed changed = set_model_repo(node_id, node, "repo_id", model_repo) or changed - if isinstance(plan.get("pipelineClass"), str): - changed = set_param(node_id, node, "pipeline_class", plan.get("pipelineClass")) or changed + if profile.loader_action == "ModelsLoader": + changed = set_param(node_id, node, "model_type", profile.model_type) or changed + else: + changed = set_param(node_id, node, "pipeline_class", profile.pipeline_class) or changed if isinstance(dtype, str): changed = set_param(node_id, node, "dtype", dtype) or changed if module == "modules.DiffusersImage" and action == "LoadPipeline": @@ -6367,9 +7728,97 @@ def _auto_resource_requires_proven_candidate(self, runtime_hints): return False def _assert_auto_resource_candidate_ready(self, runtime_hints): + if not isinstance(runtime_hints, dict) or runtime_hints.get("resourceMode") != "auto": + return + + auto_plan = runtime_hints.get("autoResourcePlan") + plan_model_type = str(auto_plan.get("modelType") or "").strip() if isinstance(auto_plan, dict) else "" + plan_mode = str(auto_plan.get("mode") or "").strip() if isinstance(auto_plan, dict) else "" + hint_model_type = str(runtime_hints.get("modelType") or "").strip() + hint_mode = str(runtime_hints.get("mode") or "").strip() + pair_mismatch = bool( + (plan_model_type and hint_model_type and plan_model_type != hint_model_type) + or (plan_mode and hint_mode and plan_mode != hint_mode) + ) + if pair_mismatch: + error = RuntimeError( + "Auto resource plan pair does not match the requested workflow pair. " + "Refresh the Auto plan or switch to Expert before executing this workflow." + ) + setattr(error, "modiff_error_code", "auto_resource_pair_mismatch") + setattr(error, "modiff_category", "auto_resource") + setattr( + error, + "modiff_recovery_hint", + "Refresh Auto so the selected candidate is resolved for the current model and task. " + "Structurally valid workflows can still be configured explicitly in Expert mode.", + ) + setattr(error, "modiff_auto_resource_status", "expert_only") + raise error + + model_type = plan_model_type + mode = plan_mode + if ( + not isinstance(auto_plan, dict) + or not model_type + or not mode + or not auto_resource_pair_is_declared(model_type, mode) + ): + error = RuntimeError( + "Auto has no declared execution recipe for the exact model/task pair. " + "Refresh the Auto plan or switch to Expert before executing this workflow." + ) + setattr(error, "modiff_error_code", "auto_resource_pair_undeclared") + setattr(error, "modiff_category", "auto_resource") + setattr( + error, + "modiff_recovery_hint", + "Use Auto only for a model and task pair declared by both the backend resource requirements " + "and execution profiles. Structurally valid workflows can still be configured explicitly in Expert mode.", + ) + setattr(error, "modiff_auto_resource_status", "expert_only") + raise error + + plan_candidate_id = auto_plan.get("id") + selected_candidate_id = runtime_hints.get("autoResourceCandidateId") + if ( + not isinstance(plan_candidate_id, str) + or not plan_candidate_id + or not isinstance(selected_candidate_id, str) + or not selected_candidate_id + or selected_candidate_id != plan_candidate_id + ): + error = RuntimeError( + "Auto resource candidate ID does not match the selected plan ID. " + "Refresh Auto before executing this workflow." + ) + setattr(error, "modiff_error_code", "auto_resource_candidate_mismatch") + setattr(error, "modiff_category", "auto_resource") + setattr(error, "modiff_recovery_hint", "Refresh Auto and reselect a candidate for the current graph.") + setattr(error, "modiff_auto_resource_status", "expert_only") + raise error + + candidate = self._runtime_auto_candidate(runtime_hints, plan_candidate_id) + self._assert_same_auto_candidate_recipe(auto_plan, candidate) + + try: + profile = self._resource_plan_execution_profile(auto_plan, runtime_hints=runtime_hints) + self._assert_resource_plan_artifact_compatible(auto_plan, profile) + self._assert_resource_plan_values_supported(auto_plan, profile) + except RuntimeError as exc: + error = RuntimeError(str(exc)) + setattr(error, "modiff_error_code", "auto_resource_target_mismatch") + setattr(error, "modiff_category", "auto_resource") + setattr( + error, + "modiff_recovery_hint", + "Refresh Auto so the selected candidate targets the exact loader contract in this workflow.", + ) + setattr(error, "modiff_auto_resource_status", "expert_only") + raise error from exc + if not self._auto_resource_requires_proven_candidate(runtime_hints): return - auto_plan = runtime_hints.get("autoResourcePlan") if isinstance(runtime_hints, dict) else None if self._auto_resource_candidate_is_proven(auto_plan): return @@ -6387,9 +7836,9 @@ def _assert_auto_resource_candidate_ready(self, runtime_hints): return status = runtime_hints.get("compatibilityStatus") or runtime_hints.get("autoResourceProofStatus") or "unproven" - model_name = runtime_hints.get("modelName") or runtime_hints.get("modelType") or "this workflow" error = RuntimeError( - f"Auto resource plan is not ready for {model_name}. Refresh the Auto plan or choose Expert settings before executing this workflow." + "Auto resource plan is not ready for this workflow. Refresh the Auto plan or choose Expert " + "settings before executing this workflow." ) setattr(error, "modiff_error_code", "auto_resource_unproven") setattr(error, "modiff_category", "auto_resource") @@ -6647,6 +8096,8 @@ def _auto_candidate_cache_signature(runtime_hints): or runtime_hints.get("resolvedArtifact") or runtime_hints.get("modelRepo") ), + "loaderModule": candidate.get("loaderModule") or runtime_hints.get("loaderModule"), + "loaderAction": candidate.get("loaderAction") or runtime_hints.get("loaderAction"), "executionPath": candidate.get("executionPath") or runtime_hints.get("executionPath"), "pipelineClass": candidate.get("pipelineClass") or runtime_hints.get("pipelineClass"), # The same model/artifact can be represented by an assembled @@ -6666,6 +8117,7 @@ def _auto_candidate_cache_signature(runtime_hints): "denoiserCache": candidate.get("denoiserCache") or runtime_hints.get("denoiserCache"), "channelsLast": candidate.get("channelsLast") or runtime_hints.get("channelsLast"), "layerwiseCasting": candidate.get("layerwiseCasting") or runtime_hints.get("layerwiseCasting"), + "controlledArtifacts": runtime_hints.get("controlledArtifacts") or [], } return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() @@ -7209,6 +8661,7 @@ def _restore_execution_process_state(self, state, graph): logger.warning("Could not fully restore process-wide execution settings: %s", exc) def execute_graph(self, graph): + assert_optional_runtime_ready(graph_optional_runtime_requirement(graph)) process_state = self._capture_execution_process_state() try: return self._execute_graph(graph) @@ -7224,8 +8677,11 @@ def _execute_graph(self, graph): graph_execution_time = time.time() base_runtime_hints = self._coerce_runtime_hints(graph.get("runtimeHints")) + assert_studio_execution_graph(graph, base_runtime_hints) if isinstance(base_runtime_hints, dict): + self._bind_controlled_artifact_receipts(graph, base_runtime_hints) base_runtime_hints["loaderContract"] = self._graph_loader_contract(nodes) + graph["runtimeHints"] = deepcopy(base_runtime_hints) auto_runtime_preparation = self._prepare_auto_runtime_for_graph(base_runtime_hints) if self.current_task and auto_runtime_preparation is not None: self.current_task["autoRuntimePreparation"] = auto_runtime_preparation @@ -7250,9 +8706,15 @@ def _execute_graph(self, graph): if runtime_hints and active_retry_plan is None and attempt_index == 0: self._assert_auto_resource_candidate_ready(runtime_hints) auto_plan = runtime_hints.get("autoResourcePlan") - if isinstance(auto_plan, dict) and self._auto_resource_candidate_is_proven(auto_plan): - updated_nodes = self._apply_resource_retry_plan_to_graph(graph, auto_plan) - if updated_nodes: + if isinstance(auto_plan, dict): + proven = self._auto_resource_candidate_is_proven(auto_plan) + # Every Auto plan must address one exact visible loader, + # even when qualification is still advisory. Simulate + # unproven plans on a copy so validation cannot mutate the + # user's graph. + target_graph = graph if proven else deepcopy(graph) + updated_nodes = self._apply_resource_retry_plan_to_graph(target_graph, auto_plan) + if proven and updated_nodes: self.queue_message( { "type": "auto_resource_plan_applied", @@ -7298,7 +8760,7 @@ def _execute_graph(self, graph): plan["offloadMode"] = retry_mode plan["autoOffload"] = retry_mode != OFFLOAD_MODE_NONE retry_message = ( - f"Retrying with {str(retry_mode or active_retry_plan.get('reason') or 'safer plan').replace('_', '-')} " + f"Retrying with validated resource plan {retry_plan_index + 1} " f"after {retry_history[-1]['errorCode'] if retry_history else 'resource pressure'}." ) retry_progress = self.record_node_progress( @@ -7836,6 +9298,14 @@ def execute_node(self, id, node, sid, quiet=False, param_overrides=None): raise TypeError("Node parameter overrides must be a dictionary.") args.update(param_overrides) + # Re-resolve the exact loader from authoritative node arguments at the + # last boundary before importing its module. This closes admission to + # worker and connected-parameter TOCTOU gaps without trusting runtime + # hints or triggering an install/activation path. + assert_optional_runtime_ready( + loader_optional_runtime_requirement(module, action, args) + ) + if not quiet: reset_memory_stats() start_time = time.time() @@ -8352,70 +9822,681 @@ def _optimization_runtime_context(self): hardware = get_hardware_snapshot(self.data_dir, refresh=not bool(self.current_task)) return runtime_fingerprint, hardware, runtime_profile(hardware, venv=Path(sys.prefix)) + def _runtime_job_root(self, *, create=False): + data_root_path = Path(self.data_dir) + if create: + data_root_path.mkdir(parents=True, exist_ok=True) + data_root_info = data_root_path.lstat() + if ( + not stat.S_ISDIR(data_root_info.st_mode) + or stat.S_ISLNK(data_root_info.st_mode) + or bool( + getattr(data_root_info, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The configured runtime data root is unsafe.") + data_root = data_root_path.resolve(strict=True) + current = data_root + for name in ("runtime", "optimization-jobs"): + candidate = current / name + if create: + try: + candidate.mkdir() + except FileExistsError: + pass + details = candidate.lstat() + if ( + not stat.S_ISDIR(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The runtime job receipt directory is unsafe.") + resolved = candidate.resolve(strict=True) + resolved.relative_to(data_root) + current = resolved + return current + + @staticmethod + def _parse_runtime_job_document(raw): + def reject_duplicates(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate JSON key") + value[key] = item + return value + + return json.loads( + raw.decode("utf-8"), + object_pairs_hook=reject_duplicates, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("non-finite")), + ) + + def _read_runtime_job_file(self, job_id): + if not self._valid_runtime_job_id(job_id): + return None + try: + job_root = self._runtime_job_root(create=False) + path = job_root / f"{job_id}.json" + details = path.lstat() + if ( + not stat.S_ISREG(details.st_mode) + or stat.S_ISLNK(details.st_mode) + or details.st_nlink != 1 + or details.st_size > 64 * 1024 + or bool( + getattr(details, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + return None + resolved = path.resolve(strict=True) + resolved.relative_to(job_root) + raw = path.read_bytes() + if len(raw) != details.st_size: + return None + value = self._parse_runtime_job_document(raw) + return value if isinstance(value, dict) and value.get("id") == job_id else None + except (OSError, TypeError, ValueError, UnicodeDecodeError, RecursionError): + return None + + def _load_runtime_jobs(self): + try: + job_root = self._runtime_job_root(create=False) + except OSError: + return + jobs = [] + scanned = 0 + try: + with os.scandir(job_root) as entries: + for entry in entries: + scanned += 1 + if scanned > 10_000: + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + break + match = re.fullmatch(r"(optjob-[A-Za-z0-9_-]{12})\.json", entry.name) + if not match: + continue + job = self._read_runtime_job_file(match.group(1)) + if not isinstance(job, dict): + continue + if job.get("kind") not in {"optimization", "optional_runtime"}: + continue + if job.get("status") in {"queued", "running", "cancelling"}: + job["status"] = "failed" + job["error"] = "Optional-runtime installation failed." + job["progress"] = { + "phase": "failed", + "message": "The prior worker exited before this installation completed.", + "updatedAt": time.time(), + } + job["updatedAt"] = time.time() + try: + self._persist_optimization_job(job) + except (OSError, TypeError, ValueError): + logger.warning( + "Could not reconcile an interrupted runtime job", exc_info=True + ) + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + jobs.append(job) + except OSError: + return + for job in sorted( + jobs, + key=lambda item: item.get("updatedAt") + if isinstance(item.get("updatedAt"), (int, float)) + else 0, + reverse=True, + )[:100]: + self.optimization_jobs[job["id"]] = job + def _persist_optimization_job(self, job): + job_id = str(job.get("id") or "") if isinstance(job, dict) else "" + if not self._valid_runtime_job_id(job_id): + raise OSError("The runtime job ID is invalid.") + job_dir = self._runtime_job_root(create=True) + path = job_dir / f"{job_id}.json" + try: + existing = path.lstat() + if ( + not stat.S_ISREG(existing.st_mode) + or stat.S_ISLNK(existing.st_mode) + or existing.st_nlink != 1 + or bool( + getattr(existing, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + ): + raise OSError("The runtime job receipt target is unsafe.") + except FileNotFoundError: + pass + temporary = job_dir / f".{job_id}.{nanoid.generate(size=12)}.tmp" + body = (json.dumps(job, indent=2, allow_nan=False) + "\n").encode("utf-8") + if len(body) > 64 * 1024: + raise OSError("The runtime job receipt exceeds its safe size.") try: - job_dir = Path(self.data_dir) / "runtime" / "optimization-jobs" - job_dir.mkdir(parents=True, exist_ok=True) - path = job_dir / f"{job.get('id')}.json" - temporary = path.with_suffix(".json.tmp") - temporary.write_text(json.dumps(job, indent=2, default=str) + "\n", encoding="utf-8") + with temporary.open("xb") as output: + output.write(body) + output.flush() + os.fsync(output.fileno()) temporary.replace(path) - except Exception: - logger.debug("Could not persist optional-runtime installation job", exc_info=True) + finally: + temporary.unlink(missing_ok=True) def _update_optimization_job(self, job_id, **updates): job = self.optimization_jobs.get(job_id) if not isinstance(job, dict): return - job.update(updates) - job["updatedAt"] = time.time() - self._persist_optimization_job(job) + current_status = str(job.get("status") or "") + requested_status = updates.get("status", current_status) + terminal = {"ready", "failed", "cancelled"} + transitions = { + "queued": {"queued", "running", "cancelling", "cancelled", "failed", "ready"}, + "running": {"running", "cancelling", "cancelled", "failed", "ready"}, + "cancelling": {"cancelling", "cancelled", "failed"}, + } + if current_status in terminal or requested_status not in transitions.get( + current_status, set() + ): + return + next_job = deepcopy(job) + next_job.update(updates) + next_job["updatedAt"] = time.time() + try: + self._persist_optimization_job(next_job) + except (OSError, TypeError, ValueError): + logger.warning("Could not persist optional-runtime installation job", exc_info=True) + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "repair_required" + return + self.optimization_jobs[job_id] = next_job + + def _reserve_worker_runtime_gate(self, kind, identifier): + if ( + self._runtime_mutation_gate is not None + or self.current_task + or self.queued_tasks + or self._active_nonruntime_mutations + or self.hf_download_tasks + ): + raise OverlayInstallBusy( + "Finish or stop active and queued runs before changing the runtime environment." + ) + token = f"runtime-gate-{nanoid.generate(size=16)}" + self._runtime_mutation_gate = { + "token": token, + "kind": str(kind)[:64], + "identifier": str(identifier)[:256], + } + return token + + def _release_worker_runtime_gate(self, token): + gate = self._runtime_mutation_gate + if isinstance(gate, dict) and gate.get("token") == token: + self._runtime_mutation_gate = None + + async def _strict_runtime_control_json(self, request, *, allowed, required=(), allow_empty=False): + content_length = getattr(request, "content_length", None) + if content_length is not None and ( + not isinstance(content_length, int) + or isinstance(content_length, bool) + or content_length < 0 + or content_length > 4096 + ): + raise ValueError("Runtime control request exceeds 4096 bytes.") + + def reject_duplicates(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("Runtime control request contains a duplicate JSON key.") + value[key] = item + return value + + content = getattr(request, "content", None) + if content is not None and hasattr(content, "read"): + chunks = [] + total = 0 + while not content.at_eof(): + chunk = await content.read(min(4097 - total, 4097)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > 4096: + raise ValueError("Runtime control request exceeds 4096 bytes.") + raw = b"".join(chunks) + elif hasattr(request, "read"): + raw = await request.read() + if len(raw) > 4096: + raise ValueError("Runtime control request exceeds 4096 bytes.") + else: + raw = None + if raw is not None: + if not raw and allow_empty: + body = {} + else: + try: + body = json.loads( + raw.decode("utf-8"), + object_pairs_hook=reject_duplicates, + parse_constant=lambda _value: (_ for _ in ()).throw( + ValueError("Runtime control request contains a non-finite number.") + ), + ) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError) as exc: + raise ValueError("Runtime control request must be a JSON object.") from exc + else: + try: + body = await request.json() + except (TypeError, ValueError, RecursionError) as exc: + raise ValueError("Runtime control request must be a JSON object.") from exc + if not isinstance(body, dict): + raise ValueError("Runtime control request must be a JSON object.") + keys = set(body) + if not keys.issubset(set(allowed)) or not set(required).issubset(keys): + raise ValueError("Runtime control request has missing or unknown fields.") + return body + + @staticmethod + def _valid_runtime_job_id(job_id): + return bool(re.fullmatch(r"optjob-[A-Za-z0-9_-]{12}", str(job_id or ""))) + + @staticmethod + def _public_runtime_job(job): + if not isinstance(job, dict): + return None + job_id = str(job.get("id") or "") + if not WebServer._valid_runtime_job_id(job_id): + return None + def environment_id(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", value) + else None + ) + + def contract_id(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", value) + else None + ) + + def spec_digest(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"sha256:[0-9a-f]{64}", value) + else None + ) + + def utc_timestamp(value): + if not isinstance(value, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + value, + ): + return None + try: + parsed = time.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + return value if time.strftime("%Y-%m-%dT%H:%M:%SZ", parsed) == value else None + + def finite_time(value): + return ( + float(value) + if isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + else None + ) + + result = job.get("result") if isinstance(job.get("result"), dict) else {} + public_result = { + "environmentId": environment_id(result.get("environmentId")), + "specs": [ + { + "kind": item.get("kind"), + "id": contract_id(item.get("id")), + "specDigest": spec_digest(item.get("specDigest")), + } + for item in (result.get("specs") or []) + if isinstance(item, dict) + and item.get("kind") in {"optimization", "optional_runtime"} + and contract_id(item.get("id")) is not None + and spec_digest(item.get("specDigest")) is not None + ][:32], + "requiresActivation": result.get("requiresActivation") is True, + "activeRuntimeChanged": result.get("activeRuntimeChanged") is True, + } + capabilities = result.get("capabilities") + if isinstance(capabilities, list): + public_result["capabilities"] = [ + item for item in capabilities[:32] if contract_id(item) is not None + ] + validation = result.get("validation") + if isinstance(validation, dict): + public_result["validation"] = { + "status": ( + validation.get("status") + if validation.get("status") in {"passed", "failed"} + else None + ), + "validatedAt": utc_timestamp(validation.get("validatedAt")), + "bindingDigest": spec_digest(validation.get("bindingDigest")), + } + progress = job.get("progress") if isinstance(job.get("progress"), dict) else {} + status = ( + str(job.get("status")) + if job.get("status") + in {"queued", "running", "cancelling", "cancelled", "failed", "ready"} + else "failed" + ) + phase = str(progress.get("phase") or "") + phase_messages = { + "queued": "Installation is queued.", + "copying": "Preparing the isolated staged environment.", + "downloading": "Acquiring reviewed runtime artifacts.", + "installing": "Installing reviewed artifacts into the isolated stage.", + "validating": "Validating the staged runtime in an isolated process.", + "promoting": "Promoting the validated staged runtime.", + "ready": "Validation passed. Explicit activation and restart are required.", + "cancelling": "Cancelling the staged installation.", + "cancelled": "Installation was cancelled; the active environment was unchanged.", + "failed": "Installation failed; the active environment was unchanged.", + } + if phase not in phase_messages: + phase = status if status in phase_messages else "failed" + public = { + "id": job_id, + "status": status, + "progress": { + "phase": phase, + "message": phase_messages[phase], + "updatedAt": finite_time(progress.get("updatedAt")), + }, + "createdAt": finite_time(job.get("createdAt")), + "updatedAt": finite_time(job.get("updatedAt")), + } + for key in ("capabilityId", "profileId"): + normalized = contract_id(job.get(key)) + if normalized is not None: + public[key] = normalized + normalized_digest = spec_digest(job.get("specDigest")) + if normalized_digest is not None: + public["specDigest"] = normalized_digest + if result: + public["result"] = public_result + if job.get("error"): + public["error"] = "Optional-runtime installation failed." + return public + + @staticmethod + def _public_optimization_receipt(receipt): + if not isinstance(receipt, dict): + return None + receipt_id = receipt.get("id") + if not isinstance(receipt_id, str) or not re.fullmatch( + r"(?:probe|workload|baseline)-[0-9a-f]{32}", receipt_id + ): + return None + kind = receipt.get("kind") + status = receipt.get("status") + if kind not in {"compatibility_probe", "workload", "workload_baseline"} or status not in { + "probe_passed", + "probe_failed", + "observed", + "qualified", + }: + return None + + def contract_id(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", value) + else None + ) + + def environment_id(value): + return ( + value + if isinstance(value, str) + and re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", value) + else None + ) + + def timestamp(value): + if not isinstance(value, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + value, + ): + return None + try: + parsed = time.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + return value if time.strftime("%Y-%m-%dT%H:%M:%SZ", parsed) == value else None + + public = { + "id": receipt_id, + "schemaVersion": 1, + "kind": kind, + "status": status, + "capabilityId": contract_id(receipt.get("capabilityId")), + "environmentId": environment_id(receipt.get("environmentId")), + "createdAt": timestamp(receipt.get("createdAt")), + "qualifiedAt": timestamp(receipt.get("qualifiedAt")), + "autoEligible": receipt.get("autoEligible") is True, + } + fingerprint = receipt.get("runtimeFingerprintHash") + if isinstance(fingerprint, str) and re.fullmatch(r"[0-9a-f]{64}", fingerprint): + public["runtimeFingerprintHash"] = fingerprint + if kind == "compatibility_probe": + result = receipt.get("result") if isinstance(receipt.get("result"), dict) else {} + public["validationStatus"] = ( + result.get("status") if result.get("status") in {"passed", "failed"} else "failed" + ) + evidence = receipt.get("benchmarkEvidence") + if isinstance(evidence, dict): + public_evidence = {"improved": evidence.get("improved") is True} + for key in ("elapsedRatio", "peakMemoryRatio"): + value = evidence.get(key) + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + ): + public_evidence[key] = float(value) + public["benchmarkEvidence"] = public_evidence + baseline_id = receipt.get("baselineReceiptId") + if isinstance(baseline_id, str) and re.fullmatch(r"baseline-[0-9a-f]{32}", baseline_id): + public["baselineReceiptId"] = baseline_id + return public + + @staticmethod + def _public_runtime_mutation_result(result): + value = result if isinstance(result, dict) else {} + state = value.get("state") if isinstance(value.get("state"), dict) else {} + enabled_capabilities = ( + state.get("enabledCapabilities") + if isinstance(state.get("enabledCapabilities"), list) + else [] + ) + + def environment_id(raw): + if not isinstance(raw, str) or not re.fullmatch( + r"runtime-[0-9]{1,16}-[0-9a-f]{8}", raw + ): + return None + return raw + + def timestamp(raw): + if not isinstance(raw, str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + raw, + ): + return None + try: + parsed = time.strptime(raw, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return None + return raw if time.strftime("%Y-%m-%dT%H:%M:%SZ", parsed) == raw else None + + return { + "state": { + "schemaVersion": state.get("schemaVersion") + if isinstance(state.get("schemaVersion"), int) + and not isinstance(state.get("schemaVersion"), bool) + else 2, + "activeEnvironmentId": environment_id(state.get("activeEnvironmentId")), + "previousEnvironmentId": environment_id(state.get("previousEnvironmentId")), + "enabledCapabilities": [ + str(item)[:128] + for item in enabled_capabilities + if isinstance(item, str) and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,127}", item) + ][:64], + "updatedAt": timestamp(state.get("updatedAt")), + }, + "restartRequired": value.get("restartRequired") is True, + } async def runtime_optimizations(self, _request): _fingerprint, hardware, profile = self._optimization_runtime_context() return web.json_response(public_optimization_catalog(runtime_profile=profile, hardware=hardware)) - async def _run_optimization_install_job(self, job_id, capability_id, profile, hardware): + async def runtime_optional_runtimes(self, _request): + return web.json_response(public_optional_runtime_catalog()) + + async def _run_optimization_install_job( + self, job_id, capability_id, profile, hardware, lease, gate_token + ): + loop = asyncio.get_running_loop() + + def progress(update): + loop.call_soon_threadsafe( + partial( + self._update_optimization_job, + job_id, + status="running", + progress=update, + ) + ) + + try: + result = await asyncio.to_thread( + install_optimization_capability, + capability_id, + runtime_profile=profile, + hardware=hardware, + progress=progress, + lease=lease, + ) + self._update_optimization_job( + job_id, + status="ready", + progress={ + "phase": "ready", + "message": "Validation passed. Activate to restart MoDiff with this optional environment.", + "updatedAt": time.time(), + }, + result=result, + ) + except OverlayCancelled: + self._update_optimization_job( + job_id, + status="cancelled", + progress={ + "phase": "cancelled", + "message": "Optional-runtime installation was cancelled and staging was removed.", + "updatedAt": time.time(), + }, + ) + except Exception as exc: + logger.warning("Optional runtime package installation failed: %s", exc) + self._update_optimization_job( + job_id, + status="failed", + progress={ + "phase": "failed", + "message": "Optional-runtime installation failed. The active environment was unchanged.", + "updatedAt": time.time(), + }, + error="Optional-runtime installation failed.", + ) + finally: + self._runtime_install_leases.pop(job_id, None) + self._runtime_install_gate_tokens.pop(job_id, None) + release_runtime_install(lease) + self._release_worker_runtime_gate(gate_token) + + async def _run_optional_runtime_install_job( + self, job_id, profile_id, spec_digest, lease, gate_token + ): loop = asyncio.get_running_loop() def progress(update): loop.call_soon_threadsafe( - self._update_optimization_job, - job_id, - status="running", - progress=update, + partial( + self._update_optimization_job, + job_id, + status="running", + progress=update, + ) ) try: result = await asyncio.to_thread( - install_optimization_capability, - capability_id, - runtime_profile=profile, - hardware=hardware, + install_optional_runtime, + profile_id, + spec_digest, + consent=True, + lease=lease, progress=progress, ) self._update_optimization_job( job_id, status="ready", + result=result, progress={ "phase": "ready", - "message": "Validation passed. Activate to restart MoDiff with this optional environment.", + "message": "Validation passed. Explicit activation and restart are required.", "updatedAt": time.time(), }, - result=result, ) - except Exception as exc: - logger.warning("Optional runtime package installation failed: %s", exc) + except OverlayCancelled: + self._update_optimization_job( + job_id, + status="cancelled", + progress={ + "phase": "cancelled", + "message": "Optional-runtime installation was cancelled and staging was removed.", + "updatedAt": time.time(), + }, + ) + except Exception: + logger.warning("Optional model-runtime installation failed", exc_info=True) self._update_optimization_job( job_id, status="failed", + error="Optional-runtime installation failed.", progress={ "phase": "failed", - "message": str(exc), + "message": "Optional-runtime installation failed. The active environment was unchanged.", "updatedAt": time.time(), }, - error=str(exc), ) + finally: + self._runtime_install_leases.pop(job_id, None) + self._runtime_install_gate_tokens.pop(job_id, None) + release_runtime_install(lease) + self._release_worker_runtime_gate(gate_token) async def runtime_optimization_install(self, request): if self.current_task or self.queued_tasks: @@ -8428,46 +10509,370 @@ async def runtime_optimization_install(self, request): status=409, ) try: - body = await request.json() - except Exception: - body = {} + body = await self._strict_runtime_control_json( + request, + allowed={"capabilityId"}, + required={"capabilityId"}, + ) + except ValueError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=400) capability_id = str(body.get("capabilityId") or "").strip() if not capability_id: return web.json_response({"error": True, "message": "capabilityId is required."}, status=400) _fingerprint, hardware, profile = self._optimization_runtime_context() - job_id = f"optjob-{nanoid.generate(size=12)}" - job = { - "id": job_id, - "capabilityId": capability_id, - "status": "queued", - "progress": { - "phase": "queued", - "message": "Waiting to stage the optional package.", + catalog = public_optimization_catalog(runtime_profile=profile, hardware=hardware) + capability = next( + (item for item in catalog.get("capabilities", []) if item.get("id") == capability_id), + None, + ) + if ( + not capability + or capability.get("kind") != "package" + or not capability.get("compatible") + or capability.get("canInstall") is not True + ): + return web.json_response( + {"error": True, "message": "This optimization package is unavailable."}, + status=400, + ) + try: + gate_token = self._reserve_worker_runtime_gate("optimization_install", capability_id) + except OverlayInstallBusy as exc: + return web.json_response( + {"error": True, "error_code": "optimization_install_busy", "message": str(exc)}, + status=409, + ) + try: + lease = reserve_runtime_install("optimization", capability_id) + except OverlayInstallBusy as exc: + self._release_worker_runtime_gate(gate_token) + return web.json_response( + {"error": True, "error_code": "optimization_install_busy", "message": str(exc)}, + status=409, + ) + try: + job_id = f"optjob-{nanoid.generate(size=12)}" + job = { + "id": job_id, + "kind": "optimization", + "capabilityId": capability_id, + "status": "queued", + "progress": { + "phase": "queued", + "message": "Waiting to stage the optional package.", + "updatedAt": time.time(), + }, + "createdAt": time.time(), "updatedAt": time.time(), - }, - "createdAt": time.time(), - "updatedAt": time.time(), - } - self.optimization_jobs[job_id] = job - self._persist_optimization_job(job) - asyncio.create_task(self._run_optimization_install_job(job_id, capability_id, profile, hardware)) - return web.json_response({"error": False, "job": job}, status=202) + } + self.optimization_jobs[job_id] = job + self._runtime_install_leases[job_id] = lease + self._runtime_install_gate_tokens[job_id] = gate_token + self._persist_optimization_job(job) + asyncio.create_task( + self._run_optimization_install_job( + job_id, capability_id, profile, hardware, lease, gate_token + ) + ) + except Exception: + self.optimization_jobs.pop(locals().get("job_id", ""), None) + self._runtime_install_leases.pop(locals().get("job_id", ""), None) + self._runtime_install_gate_tokens.pop(locals().get("job_id", ""), None) + release_runtime_install(lease) + self._release_worker_runtime_gate(gate_token) + logger.exception("Could not start the optional-runtime installation job") + return web.json_response( + {"error": True, "message": "Could not start the optional-runtime installation job."}, + status=500, + ) + return web.json_response( + {"error": False, "job": self._public_runtime_job(job)}, status=202 + ) + + async def runtime_optional_runtime_install(self, request): + if self.current_task or self.queued_tasks: + return web.json_response( + { + "error": True, + "error_code": "optional_runtime_install_busy", + "message": "Finish or stop active and queued runs before changing optional runtimes.", + }, + status=409, + ) + try: + body = await self._strict_runtime_control_json( + request, + allowed={"profileId", "specDigest", "consent"}, + required={"profileId", "specDigest", "consent"}, + ) + profile_id = body.get("profileId") + spec_digest = body.get("specDigest") + if ( + not isinstance(profile_id, str) + or not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,127}", profile_id) + or not isinstance(spec_digest, str) + or not re.fullmatch(r"sha256:[0-9a-f]{64}", spec_digest) + or body.get("consent") is not True + ): + raise ValueError( + "profileId, exact specDigest, and literal consent=true are required." + ) + # Qualification, digest, artifact-lock, base binding, and managed + # installer checks all run before a lease, job, staging path, or + # subprocess can be created. + validate_optional_runtime_install_request(profile_id, spec_digest, consent=True) + except ValueError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=400) + except RuntimeError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) + try: + gate_token = self._reserve_worker_runtime_gate("optional_runtime_install", profile_id) + except OverlayInstallBusy as exc: + return web.json_response( + {"error": True, "error_code": "optional_runtime_install_busy", "message": str(exc)}, + status=409, + ) + try: + lease = reserve_runtime_install("optional_runtime", profile_id) + except OverlayInstallBusy as exc: + self._release_worker_runtime_gate(gate_token) + return web.json_response( + {"error": True, "error_code": "optional_runtime_install_busy", "message": str(exc)}, + status=409, + ) + try: + job_id = f"optjob-{nanoid.generate(size=12)}" + job = { + "id": job_id, + "kind": "optional_runtime", + "profileId": profile_id, + "specDigest": spec_digest, + "status": "queued", + "progress": { + "phase": "queued", + "message": "Waiting to stage the reviewed optional runtime.", + "updatedAt": time.time(), + }, + "createdAt": time.time(), + "updatedAt": time.time(), + } + self.optimization_jobs[job_id] = job + self._runtime_install_leases[job_id] = lease + self._runtime_install_gate_tokens[job_id] = gate_token + self._persist_optimization_job(job) + asyncio.create_task( + self._run_optional_runtime_install_job( + job_id, profile_id, spec_digest, lease, gate_token + ) + ) + except Exception: + self.optimization_jobs.pop(locals().get("job_id", ""), None) + self._runtime_install_leases.pop(locals().get("job_id", ""), None) + self._runtime_install_gate_tokens.pop(locals().get("job_id", ""), None) + release_runtime_install(lease) + self._release_worker_runtime_gate(gate_token) + logger.exception("Could not start the optional-runtime installation job") + return web.json_response( + {"error": True, "message": "Could not start the optional-runtime installation job."}, + status=500, + ) + return web.json_response( + {"error": False, "job": self._public_runtime_job(job)}, status=202 + ) async def runtime_optimization_job(self, request): job_id = str(request.match_info.get("job_id") or "") + expected_kind = ( + "optional_runtime" + if str(getattr(request, "path", "")).startswith("/runtime/optional-runtimes/") + else "optimization" + ) + if not self._valid_runtime_job_id(job_id): + return web.json_response( + {"error": True, "message": "Optimization installation job not found."}, status=404 + ) job = self.optimization_jobs.get(job_id) if not isinstance(job, dict): - path = Path(self.data_dir) / "runtime" / "optimization-jobs" / f"{job_id}.json" - try: - value = json.loads(path.read_text(encoding="utf-8")) - job = value if isinstance(value, dict) else None - except (OSError, TypeError, ValueError): - job = None - if not job: + job = self._read_runtime_job_file(job_id) + if isinstance(job, dict) and job.get("kind") != expected_kind: + job = None + public_job = self._public_runtime_job(job) + if not public_job: + return web.json_response( + {"error": True, "message": "Optimization installation job not found."}, status=404 + ) + return web.json_response({"error": False, "job": public_job}) + + async def runtime_optimization_job_cancel(self, request): + job_id = str(request.match_info.get("job_id") or "") + expected_kind = ( + "optional_runtime" + if str(getattr(request, "path", "")).startswith("/runtime/optional-runtimes/") + else "optimization" + ) + if not self._valid_runtime_job_id(job_id): return web.json_response( {"error": True, "message": "Optimization installation job not found."}, status=404 ) - return web.json_response({"error": False, "job": job}) + try: + await self._strict_runtime_control_json( + request, allowed=set(), required=set(), allow_empty=True + ) + except ValueError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=400) + job = self.optimization_jobs.get(job_id) + if not isinstance(job, dict) or job.get("kind") != expected_kind: + return web.json_response( + {"error": True, "message": "The installation is not active in this worker."}, + status=409, + ) + if job.get("status") in {"ready", "failed", "cancelled"}: + return web.json_response( + {"error": True, "message": "The installation job is already complete."}, status=409 + ) + lease = self._runtime_install_leases.get(job_id) + cancelled = ( + await asyncio.to_thread(cancel_runtime_install, lease.token) + if lease is not None + else False + ) + if not cancelled: + return web.json_response( + {"error": True, "message": "The installation can no longer be cancelled."}, status=409 + ) + self._update_optimization_job( + job_id, + status="cancelling", + progress={ + "phase": "cancelling", + "message": "Cancelling the exact installer process tree and removing staging.", + "updatedAt": time.time(), + }, + ) + return web.json_response( + {"error": False, "job": self._public_runtime_job(self.optimization_jobs[job_id])}, + status=202, + ) + + async def runtime_optional_runtime_activate(self, request): + if self.current_task or self.queued_tasks: + return web.json_response( + { + "error": True, + "error_code": "optional_runtime_activation_busy", + "message": "Finish or stop active and queued runs before activating an optional runtime.", + }, + status=409, + ) + gate_token = None + keep_gate = False + try: + body = await self._strict_runtime_control_json( + request, + allowed={"environmentId", "profileId", "specDigest", "consent"}, + required={"environmentId", "profileId", "specDigest", "consent"}, + ) + environment_id = body.get("environmentId") + profile_id = body.get("profileId") + spec_digest = body.get("specDigest") + if ( + not isinstance(environment_id, str) + or not re.fullmatch(r"runtime-[0-9]{1,16}-[0-9a-f]{8}", environment_id) + or not isinstance(profile_id, str) + or not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,127}", profile_id) + or not isinstance(spec_digest, str) + or not re.fullmatch(r"sha256:[0-9a-f]{64}", spec_digest) + or body.get("consent") is not True + ): + raise ValueError( + "environmentId, profileId, exact specDigest, and literal consent=true are required." + ) + validate_optional_runtime_activation_request( + profile_id, spec_digest, consent=True + ) + gate_token = self._reserve_worker_runtime_gate( + "optional_runtime_activation", environment_id + ) + result = await asyncio.to_thread( + activate_optional_runtime_environment, + environment_id, + profile_id, + spec_digest, + consent=True, + ) + result = self._public_runtime_mutation_result(result) + restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() + if result.get("restartRequired"): + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "restart_required" + keep_gate = restarting + return web.json_response( + { + "error": False, + **result, + "restarting": restarting, + "message": ( + "The validated optional runtime is active. MoDiff is restarting." + if restarting + else "The validated optional runtime is active. Restart MoDiff to load it." + if result.get("restartRequired") + else "This optional runtime is already active." + ), + } + ) + except ValueError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=400) + except (RuntimeError, OverlayInstallBusy) as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) + finally: + if gate_token is not None and not keep_gate: + self._release_worker_runtime_gate(gate_token) + + async def runtime_optional_runtime_rollback(self, request): + if self.current_task or self.queued_tasks: + return web.json_response( + { + "error": True, + "error_code": "optional_runtime_rollback_busy", + "message": "Finish or stop active and queued runs before rolling back an optional runtime.", + }, + status=409, + ) + gate_token = None + keep_gate = False + try: + body = await self._strict_runtime_control_json( + request, allowed={"consent"}, required={"consent"} + ) + if body.get("consent") is not True: + raise ValueError("Literal consent=true is required to roll back an optional runtime.") + gate_token = self._reserve_worker_runtime_gate( + "optional_runtime_rollback", "previous_environment" + ) + result = await asyncio.to_thread(rollback_optional_runtime_environment, consent=True) + result = self._public_runtime_mutation_result(result) + restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() + if result.get("restartRequired"): + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "restart_required" + keep_gate = restarting + return web.json_response( + { + "error": False, + **result, + "restarting": restarting, + "message": ( + "The previous optional runtime is restored. MoDiff is restarting." + if restarting + else "The previous optional runtime is selected. Restart MoDiff to finish rollback." + ), + } + ) + except ValueError as exc: + return web.json_response({"error": True, "message": str(exc)}, status=400) + except (RuntimeError, OverlayInstallBusy) as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) + finally: + if gate_token is not None and not keep_gate: + self._release_worker_runtime_gate(gate_token) def _schedule_optional_runtime_restart(self): if os.environ.get("MODIFF_WORKER_SUPERVISED") != "1": @@ -8491,28 +10896,51 @@ async def runtime_optimization_activate(self, request): }, status=409, ) + gate_token = None + keep_gate = False try: - body = await request.json() - result = activate_optimization_environment(str(body.get("environmentId") or "")) - except (ValueError, RuntimeError) as exc: + body = await self._strict_runtime_control_json( + request, allowed={"environmentId"}, required={"environmentId"} + ) + environment_id = body.get("environmentId") + if not isinstance(environment_id, str) or not re.fullmatch( + r"runtime-[0-9]{1,16}-[0-9a-f]{8}", environment_id + ): + raise ValueError("A valid environmentId is required.") + gate_token = self._reserve_worker_runtime_gate( + "optimization_activation", environment_id + ) + result = await asyncio.to_thread( + activate_optimization_environment, environment_id + ) + result = self._public_runtime_mutation_result(result) + restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() + if result.get("restartRequired"): + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "restart_required" + keep_gate = restarting + return web.json_response( + { + "error": False, + **result, + "restarting": restarting, + "message": ( + "The validated optional runtime is active. MoDiff is restarting." + if restarting + else "The validated optional runtime is active. Restart MoDiff to load it." + if result.get("restartRequired") + else "This optional runtime is already active." + ), + } + ) + except ValueError as exc: return web.json_response({"error": True, "message": str(exc)}, status=400) - restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() - return web.json_response( - { - "error": False, - **result, - "restarting": restarting, - "message": ( - "The validated optional runtime is active. MoDiff is restarting." - if restarting - else "The validated optional runtime is active. Restart MoDiff to load it." - if result.get("restartRequired") - else "This optional runtime is already active." - ), - } - ) + except (RuntimeError, OverlayInstallBusy) as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) + finally: + if gate_token is not None and not keep_gate: + self._release_worker_runtime_gate(gate_token) - async def runtime_optimization_rollback(self, _request): + async def runtime_optimization_rollback(self, request): if self.current_task or self.queued_tasks: return web.json_response( { @@ -8522,23 +10950,40 @@ async def runtime_optimization_rollback(self, _request): }, status=409, ) + gate_token = None + keep_gate = False try: - result = rollback_optimization_environment() - except RuntimeError as exc: + await self._strict_runtime_control_json( + request, allowed=set(), required=set(), allow_empty=True + ) + gate_token = self._reserve_worker_runtime_gate( + "optimization_rollback", "previous_environment" + ) + result = await asyncio.to_thread(rollback_optimization_environment) + result = self._public_runtime_mutation_result(result) + restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() + if result.get("restartRequired"): + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "restart_required" + keep_gate = restarting + return web.json_response( + { + "error": False, + **result, + "restarting": restarting, + "message": ( + "The previous optional runtime is restored. MoDiff is restarting." + if restarting + else "The previous optional runtime is selected. Restart MoDiff to finish rollback." + ), + } + ) + except ValueError as exc: return web.json_response({"error": True, "message": str(exc)}, status=400) - restarting = bool(result.get("restartRequired")) and self._schedule_optional_runtime_restart() - return web.json_response( - { - "error": False, - **result, - "restarting": restarting, - "message": ( - "The previous optional runtime is restored. MoDiff is restarting." - if restarting - else "The previous optional runtime is selected. Restart MoDiff to finish rollback." - ), - } - ) + except (RuntimeError, OverlayInstallBusy) as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) + finally: + if gate_token is not None and not keep_gate: + self._release_worker_runtime_gate(gate_token) async def runtime_optimization_enable(self, request): try: @@ -8610,7 +11055,7 @@ async def runtime_optimization_probe(self, request): return web.json_response( { "error": False, - "receipt": receipt, + "receipt": self._public_optimization_receipt(receipt), "message": ( "Compatibility probe passed. This does not authorize Auto until a real workload is qualified." if receipt.get("status") == "probe_passed" @@ -8620,7 +11065,20 @@ async def runtime_optimization_probe(self, request): ) async def runtime_optimization_receipts(self, _request): - return web.json_response(read_optimization_receipts()) + document = read_optimization_receipts() + receipts = document.get("receipts") if isinstance(document, dict) else [] + if not isinstance(receipts, list): + receipts = [] + return web.json_response( + { + "schemaVersion": 1, + "receipts": [ + projected + for item in receipts + if (projected := self._public_optimization_receipt(item)) is not None + ][:500], + } + ) async def runtime_optimization_qualify(self, request): try: @@ -8634,7 +11092,7 @@ async def runtime_optimization_qualify(self, request): return web.json_response( { "error": False, - "receipt": receipt, + "receipt": self._public_optimization_receipt(receipt), "message": "This exact runtime, model, workload, and optimization selection is now eligible for Auto.", } ) @@ -9348,8 +11806,23 @@ async def runtime_gpu_cleanup(self, request): async def model_capabilities(self, request): query = str(request.query.get("q", "")).lower().strip() + optional_runtime_catalog_snapshot = None + execution_specs = validate_studio_execution_specs(self.modules) + specs_by_model = {} + for specification in execution_specs: + specs_by_model.setdefault(specification["modelType"], []).append(specification) + + def request_optional_runtime_catalog(): + nonlocal optional_runtime_catalog_snapshot + if optional_runtime_catalog_snapshot is None: + optional_runtime_catalog_snapshot = public_optional_runtime_catalog() + return optional_runtime_catalog_snapshot + profiles_by_model = {} - for profile in public_execution_profiles(): + for profile in public_execution_profiles( + observe_optional_runtime=True, + optional_runtime_catalog_resolver=request_optional_runtime_catalog, + ): profiles_by_model.setdefault(profile.get("model_type"), []).append(profile) capabilities = [] @@ -9375,6 +11848,13 @@ async def model_capabilities(self, request): quantized_components = sorted( {component for profile in profiles for component in profile.get("quantizable_components", [])} ) + optional_runtime_profile_ids = list( + dict.fromkeys( + profile_id + for profile in profiles + for profile_id in profile.get("optional_runtime_profiles", []) + ) + ) capability.update( { "schemaVersion": 2, @@ -9382,6 +11862,7 @@ async def model_capabilities(self, request): "supportTier": capability.get("supportTier") or "supported", "pipelineClasses": pipeline_classes, "executionProfiles": profiles, + "studioExecutionSpecs": specs_by_model.get(capability.get("modelType"), []), "runnableModes": runnable_modes, "inputContracts": capability.get("modeRequirements") or {}, "parameterAliases": { @@ -9407,8 +11888,21 @@ async def model_capabilities(self, request): "offloadModes": (capability.get("offloadSupport") or {}).get("modes", []), }, "qualificationStatus": capability.get("qualificationStatus") or "graph-qualified", + "optionalRuntimeProfileIds": optional_runtime_profile_ids, + "optionalRuntimeProfiles": public_optional_runtime_profiles( + optional_runtime_profile_ids + ), + "optionalRuntimeRequirement": optional_runtime_requirement_for_execution( + capability.get("modelType"), + catalog_resolver=request_optional_runtime_catalog, + ), } ) + if capability["studioExecutionSpecs"]: + capability["studioExecutionSpecSchemaVersion"] = 1 + capability["studioExecutionSpecModes"] = sorted( + specification["mode"] for specification in capability["studioExecutionSpecs"] + ) capabilities.append(capability) if query: capabilities = [ @@ -9426,8 +11920,16 @@ async def model_capabilities(self, request): "schemaVersion": 2, "count": len(capabilities), "capabilities": capabilities, - "diffusersExecutionProfiles": public_execution_profiles(), - "experimentalCapabilities": public_experimental_pipelines(), + "diffusersExecutionProfiles": public_execution_profiles( + observe_optional_runtime=True, + optional_runtime_catalog_resolver=request_optional_runtime_catalog, + ), + "studioExecutionSpecs": execution_specs, + "optionalRuntimeProfiles": public_optional_runtime_profiles(), + "experimentalCapabilities": public_experimental_pipelines( + observe_optional_runtime=True, + optional_runtime_catalog_resolver=request_optional_runtime_catalog, + ), "source": "modiff-backend", } ) @@ -10213,7 +12715,10 @@ async def hf_hub(self, request): sid = request.query.get("sid") future = asyncio.Future() - await self.queue_task(search_hub, query, future, sid, name="Hugging Face search") + try: + await self.queue_task(search_hub, query, future, sid, name="Hugging Face search") + except OverlayInstallBusy as exc: + return web.json_response({"error": True, "message": str(exc)}, status=409) try: result = await future @@ -10303,6 +12808,7 @@ def progress_cb(progress): bool(entry.get("repair")), entry.get("repair_source_repo_id"), entry.get("requested_files"), + entry.get("revision"), ), serialize_model_io=True, ) @@ -10323,6 +12829,24 @@ async def hf_download(self, request): query = getattr(request, "query", {}) or {} repo_id = payload.get("repo_id") or query.get("repo_id") sid = payload.get("sid") or query.get("sid") + raw_revision = payload.get("revision") if "revision" in payload else query.get("revision") + revision = None + if raw_revision is not None: + if ( + not isinstance(raw_revision, str) + or raw_revision != raw_revision.strip() + or raw_revision != raw_revision.lower() + or not IMMUTABLE_HUB_REVISION.fullmatch(raw_revision) + ): + return web.json_response( + { + "error": "Model downloads require an exact lowercase 40-character commit revision.", + "code": "invalid_huggingface_revision", + "retryable": False, + }, + status=400, + ) + revision = raw_revision repair_value = payload.get("repair") if "repair" in payload else query.get("repair") repair = repair_value is True or str(repair_value or "").lower() in {"1", "true", "yes"} repair_source_repo_id = ( @@ -10366,12 +12890,18 @@ async def hf_download(self, request): status=400, ) + if revision is None: + revision = catalog_revision(repo_id) + if repo_id in self.hf_download_tasks: entry = self.hf_download_tasks[repo_id] - if sorted(entry.get("requested_files") or []) != requested_files: + if ( + sorted(entry.get("requested_files") or []) != requested_files + or entry.get("revision") != revision + ): return web.json_response( { - "error": "A different file selection is already downloading for this repository.", + "error": "A different immutable snapshot or file selection is already downloading for this repository.", "repo_id": repo_id, "retryable": True, }, @@ -10402,6 +12932,7 @@ async def hf_download(self, request): "repair": repair, "repair_source_repo_id": repair_source_repo_id, "requested_files": requested_files, + "revision": revision, } entry["future"] = self.loop.create_task(self._run_hf_download_task(repo_id, entry)) self.hf_download_tasks[repo_id] = entry diff --git a/modiff/studio_execution_specs.py b/modiff/studio_execution_specs.py new file mode 100644 index 0000000..0757c90 --- /dev/null +++ b/modiff/studio_execution_specs.py @@ -0,0 +1,2933 @@ +from __future__ import annotations + +from copy import deepcopy +import json +from typing import Any + +from modiff.diffusers_offload_modes import ( + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_SEQUENTIAL_CPU, +) +from modiff.model_artifact_catalog import require_catalog_revision + + +STUDIO_EXECUTION_SPEC_SCHEMA_VERSION = 1 +STUDIO_EXECUTION_SPEC_CANONICALIZATION_VERSION = 1 + +FLUX_SCHNELL_REPO = "black-forest-labs/FLUX.1-schnell" +FLUX_DEV_REPO = "black-forest-labs/FLUX.1-dev" +FLUX_DEV_FP8_REPO = "black-forest-labs/FLUX.1-dev-FP8" +FLUX_KREA_REPO = "black-forest-labs/FLUX.1-Krea-dev" +FLUX_DEPTH_REPO = "black-forest-labs/FLUX.1-Depth-dev" +FLUX_CANNY_REPO = "black-forest-labs/FLUX.1-Canny-dev" +FLUX_CANNY_VERIFIED_REPAIR_REPO = "fuliucansheng/FLUX.1-Canny-dev-diffusers" +FLUX_REDUX_REPO = "black-forest-labs/FLUX.1-Redux-dev" +FLUX_KONTEXT_REPO = "black-forest-labs/FLUX.1-Kontext-dev" +FLUX_KONTEXT_NVFP4_REPO = "black-forest-labs/FLUX.1-Kontext-dev-NVFP4" +FLUX_FILL_REPO = "black-forest-labs/FLUX.1-Fill-dev" +FLUX2_KLEIN_REPO = "black-forest-labs/FLUX.2-klein-4B" +WAN_22_I2V_A14B_REPO = "Wan-AI/Wan2.2-I2V-A14B-Diffusers" +WAN_22_TI2V_5B_REPO = "Wan-AI/Wan2.2-TI2V-5B-Diffusers" +WAN_T2V_1_3B_REPO = "Wan-AI/Wan2.1-T2V-1.3B-Diffusers" +LTX_VIDEO_REPO = "Lightricks/LTX-Video-0.9.8-13B-distilled" +LTX_VIDEO_FALLBACK_REPO = "Lightricks/LTX-Video" +ACE_STEP_REPO = "ACE-Step/acestep-v15-xl-turbo-diffusers" +ACE_STEP_LORA_BASE_REPO = "Runware/acestep-v15-turbo-diffusers" +QWEN_CONTROLNET_REPO = "InstantX/Qwen-Image-ControlNet-Union" + +_STUDIO_MODEL_DEPENDENCY_REQUIREMENTS = { + ("QwenImageModularPipeline", "control_image"): ( + { + "id": "qwen-controlnet-union", + "label": "Qwen ControlNet Union", + "repo": QWEN_CONTROLNET_REPO, + "revision": require_catalog_revision(QWEN_CONTROLNET_REPO), + "kind": "controlnet", + "requiredForModes": ["control_image"], + "description": "Required for Qwen Image Control image workflows.", + }, + ), + ("FluxReduxPipeline", "edit_image"): ( + { + "id": "flux-redux-base", + "label": "FLUX.1-dev base pipeline", + "repo": FLUX_DEV_REPO, + "revision": require_catalog_revision(FLUX_DEV_REPO, model_type="FluxDevPipeline"), + "kind": "base", + "requiredForModes": ["edit_image"], + "description": "Redux supplies reference embeddings to the app-installed FLUX.1-dev base pipeline.", + }, + ), +} + + +def studio_model_requirements_for_pair(model_type: str, mode: str) -> list[dict[str, Any]]: + return deepcopy(list(_STUDIO_MODEL_DEPENDENCY_REQUIREMENTS.get((model_type, mode), ()))) + + +def studio_model_dependencies_for_pair(model_type: str, mode: str) -> list[dict[str, str]]: + return [ + {key: requirement[key] for key in ("id", "kind", "repo", "revision")} + for requirement in studio_model_requirements_for_pair(model_type, mode) + ] + +_GIB = 1024**3 +_HIGH_MEMORY_FULL_RESIDENCY = { + "accelerator": "cuda", + "vramBytes": 80 * _GIB, + "systemRamBytes": 64 * _GIB, +} +_DIRECT_OFFLOAD_MODES = ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, +) + +_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("diffusersImagePipeline", "modules.DiffusersImage.LoadPipeline", -520, -80), + ("diffusersImageGenerate", "modules.DiffusersImage.Generate", -120, -80), + ("preview", "modules.Image.Preview", 980, -80), +) +_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "diffusersImagePipeline", "execution_recipe"), + ("diffusersImagePipeline", "pipeline", "diffusersImageGenerate", "pipeline"), + ("diffusersImageGenerate", "images", "preview", "image"), +) +_IMAGE_PIPELINE_BINDINGS = ( + ("diffusersQuantization", "backend", "quantizationMode"), + ("diffusersQuantization", "components", "quantizedComponents"), + ("diffusersQuantization", "dtype", "dtype"), + ("diffusersRecipe", "device_map", "deviceMapNone"), + ("diffusersRecipe", "offload_mode", "offloadMode"), + ("diffusersRecipe", "device", "device"), + ("diffusersRecipe", "attention_backend", "attentionBackend"), + ("diffusersRecipe", "attention_components", "empty"), + ("diffusersRecipe", "vae_slicing", "true"), + ("diffusersRecipe", "vae_tiling", "true"), + ("diffusersRecipe", "regional_compile", "regionalCompile"), + ("diffusersRecipe", "denoiser_cache", "denoiserCache"), + ("diffusersRecipe", "layerwise_casting", "layerwiseCasting"), + ("diffusersRecipe", "channels_last", "channelsLast"), + ("diffusersImagePipeline", "model_id", "artifact"), + ("diffusersImagePipeline", "pipeline_class", "pipelineClass"), + ("diffusersImagePipeline", "mode", "mode"), + ("diffusersImagePipeline", "dtype", "dtype"), + ("diffusersImagePipeline", "device", "device"), + ("diffusersImagePipeline", "quantization_mode", "quantizationMode"), + ("diffusersImagePipeline", "quantized_components", "pipelineQuantizedComponents"), + ("diffusersImagePipeline", "auto_offload", "autoOffload"), + ("diffusersImagePipeline", "offload_mode", "offloadMode"), +) +_GRAPH_BINDINGS = _IMAGE_PIPELINE_BINDINGS + ( + ("diffusersImageGenerate", "prompt", "prompt"), + ("diffusersImageGenerate", "negative_prompt", "negativePrompt"), + ("diffusersImageGenerate", "width", "width"), + ("diffusersImageGenerate", "height", "height"), + ("diffusersImageGenerate", "seed", "seed"), + ("diffusersImageGenerate", "num_inference_steps", "steps"), + ("diffusersImageGenerate", "guidance_scale", "guidanceScale"), + ("diffusersImageGenerate", "strength", "strength"), + ("diffusersImageGenerate", "output_type", "outputType"), + ("diffusersImageGenerate", "max_sequence_length", "maxSequenceLength"), +) +_MODULAR_EDIT_GRAPH_ROLES = ( + ("models", "modules.ModularDiffusers.ModelsLoader", -720, -80), + ("prompt", "modules.ModularDiffusers.EncodePrompt", -360, -240), + ("loadImage", "modules.Image.Load", -720, 320), + ("imageEncode", "modules.ModularDiffusers.ImageEncode", -360, 320), + ("denoise", "modules.ModularDiffusers.Denoise", 80, -80), + ("decode", "modules.ModularDiffusers.DecodeLatents", 440, -80), + ("preview", "modules.Image.Preview", 800, -80), +) +_MODULAR_EDIT_GRAPH_EDGES = ( + ("models", "text_encoders", "prompt", "text_encoders"), + ("models", "unet_out", "denoise", "unet"), + ("models", "scheduler", "denoise", "scheduler"), + ("models", "vae_out", "imageEncode", "vae"), + ("models", "vae_out", "decode", "vae"), + ("loadImage", "image", "prompt", "image"), + ("loadImage", "image", "imageEncode", "image"), + ("prompt", "embeddings", "denoise", "embeddings"), + ("imageEncode", "image_latents", "denoise", "image_latents"), + ("imageEncode", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decode", "latents"), + ("denoise", "route_state_out", "decode", "route_state_in"), + ("decode", "images", "preview", "image"), +) +_MODULAR_EDIT_GRAPH_BINDINGS = ( + ("models", "model_type", "pipelineClass"), + ("models", "repo_id", "artifact"), + ("models", "dtype", "dtype"), + ("models", "device", "device"), + ("models", "auto_offload", "autoOffload"), + ("models", "offload_mode", "offloadMode"), + ("models", "trust_remote_code", "false"), + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "alphaMode"), + ("prompt", "prompt", "prompt"), + ("prompt", "negative_prompt", "negativePrompt"), + ("imageEncode", "seed", "seed"), + ("denoise", "seed", "seed"), + ("denoise", "num_inference_steps", "steps"), + ("denoise", "guidance_scale", "guidanceScale"), +) +_MODULAR_LAYERED_GRAPH_EDGES = tuple( + edge for edge in _MODULAR_EDIT_GRAPH_EDGES if edge[1] != "route_state_out" +) +_MODULAR_LAYERED_GRAPH_BINDINGS = ( + ("models", "model_type", "pipelineClass"), + ("models", "repo_id", "artifact"), + ("models", "dtype", "dtype"), + ("models", "device", "device"), + ("models", "auto_offload", "autoOffload"), + ("models", "offload_mode", "offloadMode"), + ("models", "trust_remote_code", "false"), + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "addAlpha"), + ("prompt", "prompt", "prompt"), + ("prompt", "negative_prompt", "negativePrompt"), + ("prompt", "max_sequence_length", "maxSequenceLength"), + ("imageEncode", "seed", "seed"), + ("denoise", "seed", "seed"), + ("denoise", "num_inference_steps", "steps"), + ("denoise", "guidance_scale", "guidanceScale"), + ("denoise", "layers", "layers"), +) +_MODULAR_CONTROL_GRAPH_ROLES = ( + ("models", "modules.ModularDiffusers.ModelsLoader", -720, -80), + ("prompt", "modules.ModularDiffusers.EncodePrompt", -360, -240), + ("loadImage", "modules.Image.Load", -720, 320), + ("controlnetModel", "modules.ModularDiffusers.AutoModelLoader", -360, 520), + ("controlnet", "modules.ModularDiffusers.Controlnet", 80, 320), + ("denoise", "modules.ModularDiffusers.Denoise", 80, -80), + ("decode", "modules.ModularDiffusers.DecodeLatents", 440, -80), + ("preview", "modules.Image.Preview", 800, -80), +) +_MODULAR_CONTROL_GRAPH_EDGES = ( + ("models", "text_encoders", "prompt", "text_encoders"), + ("models", "unet_out", "denoise", "unet"), + ("models", "scheduler", "denoise", "scheduler"), + ("models", "vae_out", "controlnet", "vae"), + ("models", "vae_out", "decode", "vae"), + ("loadImage", "image", "controlnet", "control_image"), + ("controlnetModel", "model", "controlnet", "controlnet"), + ("prompt", "embeddings", "denoise", "embeddings"), + ("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + ("controlnet", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decode", "latents"), + ("denoise", "route_state_out", "decode", "route_state_in"), + ("decode", "images", "preview", "image"), +) +_MODULAR_CONTROL_GRAPH_BINDINGS = ( + ("models", "model_type", "pipelineClass"), + ("models", "repo_id", "artifact"), + ("models", "dtype", "dtype"), + ("models", "device", "device"), + ("models", "auto_offload", "autoOffload"), + ("models", "offload_mode", "offloadMode"), + ("models", "trust_remote_code", "false"), + ("loadImage", "file", "controlImage"), + ("loadImage", "alpha_channel", "alphaMode"), + ("controlnetModel", "model_type", "kind"), + ("controlnetModel", "model_id", "repo"), + ("controlnetModel", "dtype", "dtype"), + ("controlnetModel", "subfolder", "empty"), + ("controlnetModel", "variant", "empty"), + ("controlnetModel", "trust_remote_code", "false"), + ("controlnetModel", "revision", "revision"), + ("controlnetModel", "device", "device"), + ("controlnetModel", "auto_offload", "autoOffload"), + ("controlnetModel", "offload_mode", "offloadMode"), + ("prompt", "prompt", "prompt"), + ("prompt", "negative_prompt", "negativePrompt"), + ("controlnet", "model_type", "pipelineClass"), + ("controlnet", "width", "width"), + ("controlnet", "height", "height"), + ("controlnet", "seed", "seed"), + ("controlnet", "controlnet_conditioning_scale", "conditioningScale"), + ("denoise", "width", "width"), + ("denoise", "height", "height"), + ("denoise", "seed", "seed"), + ("denoise", "num_inference_steps", "steps"), + ("denoise", "guidance_scale", "guidanceScale"), + ("denoise", "strength", "strength"), +) +_CONTROL_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("diffusersImagePipeline", "modules.DiffusersImage.LoadPipeline", -520, -80), + ("loadImage", "modules.Image.Load", -520, 300), + ("diffusersImageControl", "modules.DiffusersImage.ControlGenerate", -120, -80), + ("preview", "modules.Image.Preview", 980, -80), +) +_CONTROL_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "diffusersImagePipeline", "execution_recipe"), + ("diffusersImagePipeline", "pipeline", "diffusersImageControl", "pipeline"), + ("loadImage", "image", "diffusersImageControl", "control_image"), + ("diffusersImageControl", "images", "preview", "image"), +) +_CONTROL_GRAPH_BINDINGS = _IMAGE_PIPELINE_BINDINGS + ( + ("loadImage", "file", "controlImage"), + ("loadImage", "alpha_channel", "alphaMode"), + ("diffusersImageControl", "prompt", "prompt"), + ("diffusersImageControl", "negative_prompt", "negativePrompt"), + ("diffusersImageControl", "width", "width"), + ("diffusersImageControl", "height", "height"), + ("diffusersImageControl", "seed", "seed"), + ("diffusersImageControl", "num_inference_steps", "steps"), + ("diffusersImageControl", "guidance_scale", "guidanceScale"), + ("diffusersImageControl", "strength", "strength"), + ("diffusersImageControl", "output_type", "outputType"), + ("diffusersImageControl", "max_sequence_length", "maxSequenceLength"), +) +_EDIT_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("diffusersImagePipeline", "modules.DiffusersImage.LoadPipeline", -520, -80), + ("loadImage", "modules.Image.Load", -520, 300), + ("diffusersImageEdit", "modules.DiffusersImage.Edit", -120, -80), + ("preview", "modules.Image.Preview", 980, -80), +) +_EDIT_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "diffusersImagePipeline", "execution_recipe"), + ("diffusersImagePipeline", "pipeline", "diffusersImageEdit", "pipeline"), + ("loadImage", "image", "diffusersImageEdit", "image"), + ("diffusersImageEdit", "images", "preview", "image"), +) +_EDIT_GRAPH_BINDINGS = _IMAGE_PIPELINE_BINDINGS + ( + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "alphaMode"), + ("diffusersImageEdit", "prompt", "prompt"), + ("diffusersImageEdit", "negative_prompt", "negativePrompt"), + ("diffusersImageEdit", "width", "width"), + ("diffusersImageEdit", "height", "height"), + ("diffusersImageEdit", "seed", "seed"), + ("diffusersImageEdit", "num_inference_steps", "steps"), + ("diffusersImageEdit", "guidance_scale", "guidanceScale"), + ("diffusersImageEdit", "strength", "strength"), + ("diffusersImageEdit", "reference_strength", "conditioningScale"), + ("diffusersImageEdit", "output_type", "outputType"), + ("diffusersImageEdit", "max_sequence_length", "maxSequenceLength"), +) +_INPAINT_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("diffusersImagePipeline", "modules.DiffusersImage.LoadPipeline", -520, -80), + ("loadImage", "modules.Image.Load", -520, 300), + ("loadMask", "modules.Image.Load", -520, 560), + ("diffusersImageInpaint", "modules.DiffusersImage.Inpaint", -120, -80), + ("preview", "modules.Image.Preview", 980, -80), +) +_INPAINT_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "diffusersImagePipeline", "execution_recipe"), + ("diffusersImagePipeline", "pipeline", "diffusersImageInpaint", "pipeline"), + ("loadImage", "image", "diffusersImageInpaint", "image"), + ("loadMask", "image", "diffusersImageInpaint", "mask_image"), + ("diffusersImageInpaint", "images", "preview", "image"), +) +_INPAINT_GRAPH_BINDINGS = _IMAGE_PIPELINE_BINDINGS + ( + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "alphaMode"), + ("loadMask", "file", "maskImage"), + ("loadMask", "alpha_channel", "removeAlpha"), + ("diffusersImageInpaint", "prompt", "prompt"), + ("diffusersImageInpaint", "negative_prompt", "negativePrompt"), + ("diffusersImageInpaint", "width", "width"), + ("diffusersImageInpaint", "height", "height"), + ("diffusersImageInpaint", "seed", "seed"), + ("diffusersImageInpaint", "num_inference_steps", "steps"), + ("diffusersImageInpaint", "guidance_scale", "guidanceScale"), + ("diffusersImageInpaint", "strength", "strength"), + ("diffusersImageInpaint", "reference_strength", "conditioningScale"), + ("diffusersImageInpaint", "output_type", "outputType"), + ("diffusersImageInpaint", "max_sequence_length", "maxSequenceLength"), +) +_QWEN_OUTPAINT_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("diffusersImagePipeline", "modules.DiffusersImage.LoadPipeline", -520, -80), + ("loadImage", "modules.Image.Load", -520, 300), + ("qwenOutpaintCanvas", "modules.DiffusersImage.OutpaintCanvas", -520, 300), + ("diffusersImageInpaint", "modules.DiffusersImage.Inpaint", -120, -80), + ("preview", "modules.Image.Preview", 980, -80), +) +_QWEN_OUTPAINT_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "diffusersImagePipeline", "execution_recipe"), + ("diffusersImagePipeline", "pipeline", "diffusersImageInpaint", "pipeline"), + ("loadImage", "image", "qwenOutpaintCanvas", "image"), + ("qwenOutpaintCanvas", "canvas", "diffusersImageInpaint", "image"), + ("qwenOutpaintCanvas", "mask_image", "diffusersImageInpaint", "mask_image"), + ("diffusersImageInpaint", "images", "preview", "image"), +) +_QWEN_OUTPAINT_GRAPH_BINDINGS = tuple(item for item in _INPAINT_GRAPH_BINDINGS if item[0] != "loadMask") + ( + ("qwenOutpaintCanvas", "width", "width"), + ("qwenOutpaintCanvas", "height", "height"), + ("qwenOutpaintCanvas", "left", "outpaintLeft"), + ("qwenOutpaintCanvas", "right", "outpaintRight"), + ("qwenOutpaintCanvas", "top", "outpaintTop"), + ("qwenOutpaintCanvas", "bottom", "outpaintBottom"), + ("qwenOutpaintCanvas", "overlap", "outpaintOverlap"), + ("qwenOutpaintCanvas", "feather", "outpaintFeather"), + ("qwenOutpaintCanvas", "fill_color", "outpaintFillColor"), +) +_VIDEO_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("wanPipeline", "modules.DiffusersVideo.LoadPipeline", -520, -80), + ("wanGenerate", "modules.DiffusersVideo.Generate", 220, -80), + ("videoExport", "modules.Video.Export", 640, -80), +) +_VIDEO_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "wanPipeline", "execution_recipe"), + ("wanPipeline", "pipeline", "wanGenerate", "pipeline"), + ("wanGenerate", "video_out", "videoExport", "video"), +) +_VIDEO_GRAPH_BINDINGS = ( + ("diffusersQuantization", "backend", "quantizationMode"), + ("diffusersQuantization", "components", "quantizedComponents"), + ("diffusersQuantization", "dtype", "dtype"), + ("diffusersRecipe", "device_map", "deviceMapNone"), + ("diffusersRecipe", "offload_mode", "offloadMode"), + ("diffusersRecipe", "device", "device"), + ("diffusersRecipe", "attention_backend", "nativeFlashAttention"), + ("diffusersRecipe", "attention_components", "transformer"), + ("diffusersRecipe", "vae_slicing", "true"), + ("diffusersRecipe", "vae_tiling", "videoVaeTiling"), + ("diffusersRecipe", "regional_compile", "regionalCompile"), + ("diffusersRecipe", "denoiser_cache", "denoiserCache"), + ("diffusersRecipe", "layerwise_casting", "layerwiseCasting"), + ("diffusersRecipe", "channels_last", "channelsLast"), + ("wanPipeline", "model_id", "artifact"), + ("wanPipeline", "pipeline_class", "pipelineClass"), + ("wanPipeline", "revision", "empty"), + ("wanPipeline", "dtype", "dtype"), + ("wanPipeline", "device", "device"), + ("wanPipeline", "auto_offload", "autoOffload"), + ("wanPipeline", "offload_mode", "offloadMode"), + ("wanGenerate", "prompt", "prompt"), + ("wanGenerate", "mode", "mode"), + ("wanGenerate", "negative_prompt", "negativePrompt"), + ("wanGenerate", "width", "width"), + ("wanGenerate", "height", "height"), + ("wanGenerate", "seed", "seed"), + ("wanGenerate", "num_frames", "numFrames"), + ("wanGenerate", "num_inference_steps", "steps"), + ("wanGenerate", "guidance_scale", "guidanceScale"), + ("wanGenerate", "scheduler_flow_shift", "shift"), + ("wanGenerate", "conditioning_scale", "conditioningScale"), + ("wanGenerate", "strength", "strength"), + ("wanGenerate", "denoise_strength", "strength"), + ("wanGenerate", "frame_rate", "fps"), + ("wanGenerate", "guidance_scale_2", "guidanceScale2"), + ("wanGenerate", "use_guidance_scale_2", "useGuidanceScale2"), + ("wanGenerate", "output_type", "outputType"), + ("wanGenerate", "max_sequence_length", "maxSequenceLength"), + ("wanGenerate", "attention_kwargs_json", "attentionKwargsJson"), + ("videoExport", "fps", "fps"), +) +_WAN_VACE_GRAPH_BINDINGS = tuple( + (role, param, "wanVaceRevision") if role == "wanPipeline" and param == "revision" else (role, param, source) + for role, param, source in _VIDEO_GRAPH_BINDINGS +) +_I2V_GRAPH_ROLES = _VIDEO_GRAPH_ROLES + ( + ("loadImage", "modules.Image.Load", -520, 300), +) +_I2V_GRAPH_EDGES = _VIDEO_GRAPH_EDGES + ( + ("loadImage", "image", "wanGenerate", "reference_images"), +) +_I2V_GRAPH_BINDINGS = tuple( + ( + role, + param, + "dualQuantizedComponents" + if role == "diffusersQuantization" and param == "components" + else "dualTransformer" + if role == "diffusersRecipe" and param == "attention_components" + else "true" + if role == "diffusersRecipe" and param == "vae_tiling" + else source, + ) + for role, param, source in _VIDEO_GRAPH_BINDINGS + if not (role == "wanGenerate" and param == "scheduler_flow_shift") +) + ( + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "alphaMode"), +) +_V2V_GRAPH_ROLES = _VIDEO_GRAPH_ROLES + ( + ("loadVideo", "modules.Video.Load", -520, 260), + ("normalizeVideo", "modules.VideoConditioning.Normalize", -160, 260), +) +_V2V_GRAPH_EDGES = _VIDEO_GRAPH_EDGES + ( + ("loadVideo", "video", "normalizeVideo", "video"), + ("normalizeVideo", "output", "wanGenerate", "video"), +) +_V2V_GRAPH_BINDINGS = _VIDEO_GRAPH_BINDINGS + ( + ("loadVideo", "file", "sourceVideo"), + ("normalizeVideo", "width", "width"), + ("normalizeVideo", "height", "height"), + ("normalizeVideo", "num_frames", "numFrames"), +) +_VACE_INPAINT_GRAPH_ROLES = _V2V_GRAPH_ROLES + ( + ("loadMaskVideo", "modules.Video.Load", -520, 520), + ("alignMaskVideo", "modules.VideoConditioning.AlignMask", -160, 520), +) +_VACE_INPAINT_GRAPH_EDGES = _V2V_GRAPH_EDGES + ( + ("normalizeVideo", "output", "alignMaskVideo", "video"), + ("loadMaskVideo", "video", "alignMaskVideo", "mask"), + ("alignMaskVideo", "output", "wanGenerate", "mask"), +) +_VACE_INPAINT_GRAPH_BINDINGS = tuple( + (role, param, "wanVaceRevision") if role == "wanPipeline" and param == "revision" else (role, param, source) + for role, param, source in _V2V_GRAPH_BINDINGS +) + ( + ("loadMaskVideo", "file", "maskVideo"), + ("alignMaskVideo", "threshold", "maskThreshold127"), + ("alignMaskVideo", "grow_pixels", "inpaintMaskGrow96"), +) +_VACE_OUTPAINT_GRAPH_BINDINGS = tuple( + (role, param, "outpaintMaskGrow0") if role == "alignMaskVideo" and param == "grow_pixels" else (role, param, source) + for role, param, source in _VACE_INPAINT_GRAPH_BINDINGS +) +_VACE_CONTROL_GRAPH_ROLES = _VIDEO_GRAPH_ROLES + ( + ("loadControlVideo", "modules.Video.Load", -520, 260), + ("normalizeVideo", "modules.VideoConditioning.Normalize", -160, 260), +) +_VACE_CONTROL_GRAPH_EDGES = _VIDEO_GRAPH_EDGES + ( + ("loadControlVideo", "video", "normalizeVideo", "video"), + ("normalizeVideo", "output", "wanGenerate", "video"), +) +_VACE_CONTROL_GRAPH_BINDINGS = _WAN_VACE_GRAPH_BINDINGS + ( + ("loadControlVideo", "file", "controlVideo"), + ("normalizeVideo", "width", "width"), + ("normalizeVideo", "height", "height"), + ("normalizeVideo", "num_frames", "numFrames"), +) +_LTX_T2V_GRAPH_BINDINGS = tuple( + ( + role, + param, + "nativeMath" + if role == "diffusersRecipe" and param == "attention_backend" + else "empty" + if role == "diffusersRecipe" and param == "attention_components" + else source, + ) + for role, param, source in _VIDEO_GRAPH_BINDINGS + if not (role == "wanGenerate" and param == "scheduler_flow_shift") +) +_LTX_I2V_GRAPH_BINDINGS = _LTX_T2V_GRAPH_BINDINGS + ( + ("loadImage", "file", "referenceImages"), + ("loadImage", "alpha_channel", "alphaMode"), +) +_LTX_V2V_GRAPH_BINDINGS = tuple( + ( + role, + param, + "conditioningScale" if role == "wanGenerate" and param == "strength" else source, + ) + for role, param, source in _LTX_T2V_GRAPH_BINDINGS +) + ( + ("loadVideo", "file", "sourceVideo"), + ("normalizeVideo", "width", "width"), + ("normalizeVideo", "height", "height"), + ("normalizeVideo", "num_frames", "numFrames"), +) +_AUDIO_GRAPH_ROLES = ( + ("diffusersQuantization", "modules.DiffusersRuntime.PipelineQuantizationConfigV2", -1280, -80), + ("diffusersRecipe", "modules.DiffusersRuntime.DiffusersExecutionRecipe", -900, -80), + ("audioPipeline", "modules.DiffusersAudio.LoadPipeline", -520, -80), + ("audioGenerate", "modules.DiffusersAudio.Generate", -120, -80), + ("audioExport", "modules.Audio.Export", 1060, -80), +) +_AUDIO_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "audioPipeline", "execution_recipe"), + ("audioPipeline", "pipeline", "audioGenerate", "pipeline"), + ("audioGenerate", "audio", "audioExport", "audio"), +) +_AUDIO_GRAPH_BINDINGS = ( + ("diffusersQuantization", "backend", "quantizationMode"), + ("diffusersQuantization", "components", "quantizedComponents"), + ("diffusersQuantization", "dtype", "dtype"), + ("diffusersRecipe", "device_map", "deviceMapNone"), + ("diffusersRecipe", "offload_mode", "offloadMode"), + ("diffusersRecipe", "device", "device"), + ("diffusersRecipe", "attention_backend", "attentionBackend"), + ("diffusersRecipe", "attention_components", "empty"), + ("diffusersRecipe", "vae_slicing", "true"), + ("diffusersRecipe", "vae_tiling", "true"), + ("diffusersRecipe", "regional_compile", "regionalCompile"), + ("diffusersRecipe", "denoiser_cache", "denoiserCache"), + ("diffusersRecipe", "layerwise_casting", "layerwiseCasting"), + ("diffusersRecipe", "channels_last", "channelsLast"), + ("audioPipeline", "model_id", "artifact"), + ("audioPipeline", "pipeline_class", "pipelineClass"), + ("audioPipeline", "mode", "mode"), + ("audioPipeline", "dtype", "dtype"), + ("audioPipeline", "device", "device"), + ("audioPipeline", "auto_offload", "autoOffload"), + ("audioPipeline", "offload_mode", "offloadMode"), + ("audioGenerate", "task_type", "text2music"), + ("audioGenerate", "prompt", "prompt"), + ("audioGenerate", "negative_prompt", "negativePrompt"), + ("audioGenerate", "lyrics", "lyrics"), + ("audioGenerate", "audio_duration", "audioDuration"), + ("audioGenerate", "extension_duration", "extensionDuration"), + ("audioGenerate", "vocal_language", "vocalLanguage"), + ("audioGenerate", "seed", "seed"), + ("audioGenerate", "num_inference_steps", "steps"), + ("audioGenerate", "guidance_scale", "guidanceScale"), + ("audioGenerate", "shift", "shift"), + ("audioGenerate", "bpm", "bpmNormalized"), + ("audioGenerate", "keyscale", "keyscale"), + ("audioGenerate", "timesignature", "timesignature"), + ("audioGenerate", "repainting_start", "repaintingStart"), + ("audioGenerate", "repainting_end", "repaintingEnd"), + ("audioGenerate", "audio_cover_strength", "audioCoverStrength"), + ("audioGenerate", "return_continuation_tail", "false"), + ("audioGenerate", "sample_rate", "sampleRate48000"), + ("audioExport", "sample_rate", "sampleRate48000"), +) +_AUDIO_VARIATION_GRAPH_ROLES = ( + ("loadAudio", "modules.Audio.Load", -520, 300), + *_AUDIO_GRAPH_ROLES, +) +_AUDIO_VARIATION_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "audioPipeline", "execution_recipe"), + ("audioPipeline", "pipeline", "audioGenerate", "pipeline"), + ("loadAudio", "audio", "audioGenerate", "source_audio"), + ("audioGenerate", "audio", "audioExport", "audio"), +) +_AUDIO_VARIATION_GRAPH_BINDINGS = ( + ("loadAudio", "file", "sourceAudio"), + *( + (role, param, "cover") if role == "audioGenerate" and param == "task_type" else (role, param, source) + for role, param, source in _AUDIO_GRAPH_BINDINGS + ), +) +_AUDIO_CONTINUATION_GRAPH_ROLES = ( + *_AUDIO_VARIATION_GRAPH_ROLES, + ("audioLoudnessMatch", "modules.Audio.MatchLoudness", 300, -80), + ("audioJoin", "modules.Audio.Join", 680, -80), +) +_AUDIO_CONTINUATION_GRAPH_EDGES = ( + ("diffusersQuantization", "quantization_config", "diffusersRecipe", "quantization_config"), + ("diffusersRecipe", "execution_recipe", "audioPipeline", "execution_recipe"), + ("audioPipeline", "pipeline", "audioGenerate", "pipeline"), + ("loadAudio", "audio", "audioGenerate", "source_audio"), + ("audioGenerate", "audio", "audioLoudnessMatch", "audio"), + ("loadAudio", "audio", "audioLoudnessMatch", "reference"), + ("audioLoudnessMatch", "output", "audioJoin", "continuation"), + ("loadAudio", "audio", "audioJoin", "source"), + ("audioJoin", "output", "audioExport", "audio"), +) +_AUDIO_CONTINUATION_GRAPH_BINDINGS = ( + ("loadAudio", "file", "sourceAudio"), + *( + ( + role, + param, + "continuation" + if role == "audioGenerate" and param == "task_type" + else "true" + if role == "audioGenerate" and param == "return_continuation_tail" + else source, + ) + for role, param, source in _AUDIO_GRAPH_BINDINGS + ), + ("audioLoudnessMatch", "reference_window_seconds", "referenceWindow15"), + ("audioLoudnessMatch", "target_peak_dbfs", "targetPeakMinus1"), + ("audioLoudnessMatch", "max_adjustment_db", "maxAdjustment12"), + ("audioJoin", "boundary_fade_seconds", "boundaryFade001"), +) +_AUDIO_REPAINT_GRAPH_ROLES = _AUDIO_VARIATION_GRAPH_ROLES +_AUDIO_REPAINT_GRAPH_EDGES = _AUDIO_VARIATION_GRAPH_EDGES +_AUDIO_REPAINT_GRAPH_BINDINGS = ( + ("loadAudio", "file", "sourceAudio"), + *( + (role, param, "repaint") if role == "audioGenerate" and param == "task_type" else (role, param, source) + for role, param, source in _AUDIO_GRAPH_BINDINGS + ), +) +_AUTO_FIELDS = ( + "resolvedArtifact", + "artifact", + "installTarget.repo", + "modelRepo", + "pipelineClass", + "dtype", + "offloadMode", + "quantizedComponents", + "attentionBackend", + "regionalCompile", + "denoiserCache", + "layerwiseCasting", + "channelsLast", +) +_BINDING_SOURCES = frozenset( + item[2] + for item in ( + *_GRAPH_BINDINGS, + *_MODULAR_EDIT_GRAPH_BINDINGS, + *_MODULAR_LAYERED_GRAPH_BINDINGS, + *_MODULAR_CONTROL_GRAPH_BINDINGS, + *_CONTROL_GRAPH_BINDINGS, + *_EDIT_GRAPH_BINDINGS, + *_INPAINT_GRAPH_BINDINGS, + *_QWEN_OUTPAINT_GRAPH_BINDINGS, + *_VIDEO_GRAPH_BINDINGS, + *_WAN_VACE_GRAPH_BINDINGS, + *_I2V_GRAPH_BINDINGS, + *_V2V_GRAPH_BINDINGS, + *_VACE_INPAINT_GRAPH_BINDINGS, + *_VACE_OUTPAINT_GRAPH_BINDINGS, + *_VACE_CONTROL_GRAPH_BINDINGS, + *_LTX_T2V_GRAPH_BINDINGS, + *_LTX_I2V_GRAPH_BINDINGS, + *_LTX_V2V_GRAPH_BINDINGS, + *_AUDIO_GRAPH_BINDINGS, + *_AUDIO_VARIATION_GRAPH_BINDINGS, + *_AUDIO_CONTINUATION_GRAPH_BINDINGS, + *_AUDIO_REPAINT_GRAPH_BINDINGS, + ) +) + +_MODULAR_EDIT_PLUS_PROFILE = { + "id": "qwen-edit-plus:modular", + "model_type": "QwenImageEditPlusModularPipeline", + "modes": ("edit_image", "multi_image_reference_edit"), + "loader_module": "modules.ModularDiffusers", + "loader_action": "ModelsLoader", + "execution_path": "modular-diffusers", + "pipeline_class": "QwenImageEditPlusModularPipeline", + "default_repo": "Qwen/Qwen-Image-Edit-2511", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_GROUP_DISK,), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), +} +_MODULAR_LAYERED_PROFILE = { + "id": "qwen-layered:modular", + "model_type": "QwenImageLayeredModularPipeline", + "modes": ("layer_decomposition",), + "loader_module": "modules.ModularDiffusers", + "loader_action": "ModelsLoader", + "execution_path": "modular-diffusers", + "pipeline_class": "QwenImageLayeredModularPipeline", + "default_repo": "Qwen/Qwen-Image-Layered", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_GROUP_DISK,), + "max_low_memory_side": 768, + "max_low_memory_steps": 30, + "live_proof": False, + "compatible_repos": (), +} +_MODULAR_CONTROL_PROFILE = { + "id": "qwen-image:modular", + "model_type": "QwenImageModularPipeline", + "modes": ("control_image",), + "loader_module": "modules.ModularDiffusers", + "loader_action": "ModelsLoader", + "execution_path": "modular-diffusers", + "pipeline_class": "QwenImageModularPipeline", + "default_repo": "Qwen/Qwen-Image-2512", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_GROUP_DISK,), + "max_low_memory_side": 768, + "max_low_memory_steps": 28, + "live_proof": False, + "compatible_repos": (), +} +_AUTO_FIELD_ALLOWLIST = frozenset(_AUTO_FIELDS) +_EXPERT_IMAGE_QUANTIZATION_MODES = ( + "bnb_4bit", + "bnb_8bit", + "quanto_float8", + "torchao_float8", +) + + +def _profile( + profile_id: str, + model_type: str, + repo: str, + *, + default_quantized_components: tuple[str, ...], + supported_offload_modes: tuple[str, ...], + retry_offload_modes: tuple[str, ...], + max_low_memory_side: int, + max_low_memory_steps: int, + compatible_repos: tuple[str, ...] = (), + mode: str = "text_to_image", + pipeline_class: str = "FluxPipeline", +) -> dict[str, Any]: + return { + "id": profile_id, + "model_type": model_type, + "modes": (mode,), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": pipeline_class, + "default_repo": repo, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": default_quantized_components, + "supported_offload_modes": supported_offload_modes, + "retry_offload_modes": retry_offload_modes, + "max_low_memory_side": max_low_memory_side, + "max_low_memory_steps": max_low_memory_steps, + "live_proof": False, + "compatible_repos": compatible_repos, + } + + +def _capability( + model_type: str, + label: str, + display_name: str, + repo: str, + *, + width: int, + steps: int, + guidance: float, + low_vram_mode: str, + alternate_artifact: str | None = None, + low_vram_width: int | None = None, + low_vram_steps: int | None = None, + execution_status: str = "supported_with_model", +) -> dict[str, Any]: + return { + "modelType": model_type, + "label": label, + "displayName": display_name, + "family": "FLUX Image", + "defaultRepo": repo, + **({"alternateArtifact": alternate_artifact} if alternate_artifact else {}), + "artifactLabel": "Diffusers repo", + "defaultDtype": "bfloat16", + "defaultSize": {"width": width, "height": width, "aspectRatio": "1:1"}, + "recommendedSteps": steps, + "recommendedGuidance": guidance, + "guidanceLabel": "Guidance", + "supportsImageInput": False, + "supportsMask": False, + "supportsMultiImage": False, + "supportsControlImage": False, + "supportsLayers": False, + "supportsLora": True, + "offloadSupport": { + "default": OFFLOAD_MODE_MODEL_CPU, + "lowVram": OFFLOAD_MODE_MODEL_CPU, + "emergency": OFFLOAD_MODE_GROUP_DISK, + "modes": list(_DIRECT_OFFLOAD_MODES), + }, + "lowVram": { + "dtype": "bfloat16", + "autoOffload": True, + "offloadMode": low_vram_mode, + "steps": low_vram_steps if low_vram_steps is not None else steps, + "width": low_vram_width if low_vram_width is not None else width, + "height": low_vram_width if low_vram_width is not None else width, + }, + "modes": ["text_to_image"], + "executionStatus": execution_status, + } + + +STUDIO_EXECUTION_SPEC_DEFINITIONS: dict[str, dict[str, Any]] = { + "flux-schnell:text-to-image:v1": { + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "profile": _profile( + "flux-schnell:direct", + "FluxSchnellPipeline", + FLUX_SCHNELL_REPO, + default_quantized_components=(), + supported_offload_modes=_DIRECT_OFFLOAD_MODES, + retry_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + max_low_memory_side=1024, + max_low_memory_steps=4, + ), + "capability": _capability( + "FluxSchnellPipeline", + "FLUX.1 schnell", + "FLUX.1-schnell", + FLUX_SCHNELL_REPO, + width=1024, + steps=4, + guidance=0.0, + low_vram_mode=OFFLOAD_MODE_MODEL_CPU, + ), + "autoRequirements": { + "supportedTasks": ["text_to_image"], + "defaultRepo": FLUX_SCHNELL_REPO, + "qualityDefaults": { + "width": 1024, + "height": 1024, + "steps": 4, + "guidanceScale": 0, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 12 * _GIB, + "systemRamBytes": 24 * _GIB, + "diskFreeBytes": 25 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 35 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], + }, + }, + "flux-dev:text-to-image:v1": { + "modelType": "FluxDevPipeline", + "mode": "text_to_image", + "profile": _profile( + "flux-dev:direct", + "FluxDevPipeline", + FLUX_DEV_REPO, + default_quantized_components=("transformer",), + supported_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + max_low_memory_side=768, + max_low_memory_steps=20, + compatible_repos=(FLUX_DEV_FP8_REPO,), + ), + "capability": { + **_capability( + "FluxDevPipeline", + "FLUX.1 dev", + "FLUX.1-dev", + FLUX_DEV_REPO, + width=768, + steps=20, + guidance=3.5, + low_vram_mode=OFFLOAD_MODE_GROUP_DISK, + alternate_artifact=FLUX_DEV_FP8_REPO, + ), + "notes": ["Auto prefers the FP8 artifact on 16 GB CUDA when available."], + }, + "autoRequirements": { + "supportedTasks": ["text_to_image"], + "defaultRepo": FLUX_DEV_REPO, + "preferredLowerMemoryRepo": FLUX_DEV_FP8_REPO, + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 20, + "guidanceScale": 3.5, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "lowerMemory": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": [ + "diffusers", + "transformers", + "accelerate", + "torch", + "optimum-quanto", + ], + }, + }, + "flux-krea:text-to-image:v1": { + "modelType": "FluxKreaPipeline", + "mode": "text_to_image", + "profile": _profile( + "flux-krea:direct", + "FluxKreaPipeline", + FLUX_KREA_REPO, + default_quantized_components=("transformer",), + supported_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + max_low_memory_side=768, + max_low_memory_steps=24, + ), + "capability": _capability( + "FluxKreaPipeline", + "FLUX.1 Krea dev", + "FLUX.1-Krea-dev", + FLUX_KREA_REPO, + width=1024, + steps=28, + guidance=3.5, + low_vram_mode=OFFLOAD_MODE_GROUP_DISK, + low_vram_width=768, + low_vram_steps=20, + execution_status="expert_only", + ), + "autoRequirements": { + "supportedTasks": ["text_to_image"], + "defaultRepo": FLUX_KREA_REPO, + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 3.5, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "onLoadQuantization": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": [ + "diffusers", + "transformers", + "accelerate", + "torch", + "optimum-quanto", + ], + "guardedReason": "FLUX Krea has broad guarded Auto coverage through on-load float8 quantization and Diffusers offload.", + }, + }, + "flux-depth:control-image:v1": { + "modelType": "FluxDepthPipeline", + "mode": "control_image", + "profile": _profile( + "flux-depth:direct", + "FluxDepthPipeline", + FLUX_DEPTH_REPO, + default_quantized_components=("transformer",), + supported_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + max_low_memory_side=768, + max_low_memory_steps=24, + mode="control_image", + pipeline_class="FluxControlPipeline", + ), + "capability": { + **_capability( + "FluxDepthPipeline", + "FLUX.1 Depth dev", + "FLUX.1-Depth-dev", + FLUX_DEPTH_REPO, + width=1024, + steps=28, + guidance=3.5, + low_vram_mode=OFFLOAD_MODE_GROUP_DISK, + low_vram_width=768, + low_vram_steps=20, + execution_status="expert_only", + ), + "supportsImageInput": True, + "supportsControlImage": True, + "modes": ["control_image"], + }, + "autoRequirements": { + "supportedTasks": ["control_image"], + "defaultRepo": FLUX_DEPTH_REPO, + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 10, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "onLoadQuantization": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": [ + "diffusers", + "transformers", + "accelerate", + "torch", + "optimum-quanto", + ], + "guardedReason": "FLUX Depth has guarded Auto coverage through generic control-image Diffusers nodes.", + }, + "roles": _CONTROL_GRAPH_ROLES, + "edges": _CONTROL_GRAPH_EDGES, + "bindings": _CONTROL_GRAPH_BINDINGS, + }, + "flux-canny:control-image:v1": { + "modelType": "FluxCannyPipeline", + "mode": "control_image", + "profile": _profile( + "flux-canny:direct", + "FluxCannyPipeline", + FLUX_CANNY_REPO, + default_quantized_components=("transformer",), + supported_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + max_low_memory_side=768, + max_low_memory_steps=24, + compatible_repos=(FLUX_CANNY_VERIFIED_REPAIR_REPO,), + mode="control_image", + pipeline_class="FluxControlPipeline", + ), + "capability": { + **_capability( + "FluxCannyPipeline", + "FLUX.1 Canny dev", + "FLUX.1-Canny-dev", + FLUX_CANNY_REPO, + width=1024, + steps=28, + guidance=3.5, + low_vram_mode=OFFLOAD_MODE_GROUP_DISK, + low_vram_width=768, + low_vram_steps=20, + execution_status="expert_only", + ), + "artifactCandidates": [ + FLUX_CANNY_REPO, + FLUX_CANNY_VERIFIED_REPAIR_REPO, + ], + "verifiedRepairSources": [ + { + "repo": FLUX_CANNY_VERIFIED_REPAIR_REPO, + "verification": "matching filename, size, and LFS SHA-256 plus local byte verification", + } + ], + "supportsImageInput": True, + "supportsControlImage": True, + "modes": ["control_image"], + }, + "autoRequirements": { + "supportedTasks": ["control_image"], + "defaultRepo": FLUX_CANNY_REPO, + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 10, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "onLoadQuantization": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": [ + "diffusers", + "transformers", + "accelerate", + "torch", + "optimum-quanto", + ], + "guardedReason": "FLUX Canny has guarded Auto coverage through generic control-image Diffusers nodes.", + }, + "roles": _CONTROL_GRAPH_ROLES, + "edges": _CONTROL_GRAPH_EDGES, + "bindings": _CONTROL_GRAPH_BINDINGS, + }, + "flux-redux:edit-image:v1": { + "modelType": "FluxReduxPipeline", + "mode": "edit_image", + "profile": _profile( + "flux-redux:direct", + "FluxReduxPipeline", + FLUX_REDUX_REPO, + default_quantized_components=("transformer",), + supported_offload_modes=( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + retry_offload_modes=(OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + max_low_memory_side=768, + max_low_memory_steps=24, + mode="edit_image", + pipeline_class="FluxReduxPipeline", + ), + "capability": { + **_capability( + "FluxReduxPipeline", + "FLUX.1 Redux dev", + "FLUX.1-Redux-dev", + FLUX_REDUX_REPO, + width=1024, + steps=28, + guidance=3.5, + low_vram_mode=OFFLOAD_MODE_GROUP_DISK, + low_vram_width=768, + low_vram_steps=20, + execution_status="expert_only", + ), + "artifactCandidates": [FLUX_REDUX_REPO, FLUX_DEV_REPO], + "supportsImageInput": True, + "modes": ["edit_image"], + "additionalRequirements": studio_model_requirements_for_pair( + "FluxReduxPipeline", "edit_image" + ), + "modeRequirements": { + "edit_image": { + "modelRequirements": studio_model_requirements_for_pair( + "FluxReduxPipeline", "edit_image" + ), + "requiredImages": ["referenceImages"], + "note": "Requires reference images plus the reviewed FLUX.1-dev base pipeline.", + } + }, + }, + "autoRequirements": { + "supportedTasks": ["edit_image"], + "defaultRepo": FLUX_REDUX_REPO, + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 3.5, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "onLoadQuantization": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": [ + "diffusers", + "transformers", + "accelerate", + "torch", + "optimum-quanto", + ], + "guardedReason": "FLUX Redux has guarded Auto coverage through generic Diffusers image/reference nodes.", + }, + "roles": _EDIT_GRAPH_ROLES, + "edges": _EDIT_GRAPH_EDGES, + "bindings": _EDIT_GRAPH_BINDINGS, + }, + "flux-kontext:edit-image:v1": { + "modelType": "FluxKontextPipeline", + "mode": "edit_image", + "profile": { + "id": "flux-kontext:direct", + "model_type": "FluxKontextPipeline", + "modes": ("edit_image", "multi_image_reference_edit"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "FluxKontextPipeline", + "default_repo": FLUX_KONTEXT_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (FLUX_KONTEXT_NVFP4_REPO,), + }, + "autoRequirements": { + "supportedTasks": ["edit_image"], + "defaultRepo": FLUX_KONTEXT_REPO, + "preferredLowerMemoryRepo": FLUX_KONTEXT_NVFP4_REPO, + "executionPath": "direct-diffusers-image", + "pipelineClass": "FluxKontextPipeline", + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 3.5, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "lowerMemory": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "torchao_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "torchao"], + "guardedReason": ( + "FLUX Kontext uses the NVFP4 lower-memory artifact when available; failures are remembered for " + "this machine." + ), + }, + "roles": _EDIT_GRAPH_ROLES, + "edges": _EDIT_GRAPH_EDGES, + "bindings": _EDIT_GRAPH_BINDINGS, + }, + "flux-kontext:multi-image-reference-edit:v1": { + "modelType": "FluxKontextPipeline", + "mode": "multi_image_reference_edit", + "profile": { + "id": "flux-kontext:direct", + "model_type": "FluxKontextPipeline", + "modes": ("edit_image", "multi_image_reference_edit"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "FluxKontextPipeline", + "default_repo": FLUX_KONTEXT_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (FLUX_KONTEXT_NVFP4_REPO,), + }, + "roles": _EDIT_GRAPH_ROLES, + "edges": _EDIT_GRAPH_EDGES, + "bindings": _EDIT_GRAPH_BINDINGS, + }, + "flux-fill:inpaint:v1": { + "modelType": "FluxFillPipeline", + "mode": "inpaint", + "profile": { + "id": "flux-fill:direct", + "model_type": "FluxFillPipeline", + "modes": ("inpaint", "outpaint"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "FluxFillPipeline", + "default_repo": FLUX_FILL_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "autoRequirements": { + "supportedTasks": ["inpaint", "outpaint"], + "defaultRepo": FLUX_FILL_REPO, + "executionPath": "direct-diffusers-image", + "pipelineClass": "FluxFillPipeline", + "qualityDefaults": { + "width": 768, + "height": 768, + "steps": 24, + "guidanceScale": 30, + "maxSequenceLength": 256, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 32 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 60 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "onLoadQuantization": { + "accelerator": "cuda", + "vramBytes": 16 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 45 * _GIB, + "quantizationMode": "quanto_float8", + "quantizedComponents": ["transformer", "text_encoder_2"], + }, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch", "optimum-quanto"], + "guardedReason": ( + "FLUX Fill has guarded Auto coverage through generic Diffusers inpaint/outpaint nodes and " + "on-load quantization." + ), + }, + "roles": _INPAINT_GRAPH_ROLES, + "edges": _INPAINT_GRAPH_EDGES, + "bindings": _INPAINT_GRAPH_BINDINGS, + }, + "flux-fill:outpaint:v1": { + "modelType": "FluxFillPipeline", + "mode": "outpaint", + "profile": { + "id": "flux-fill:direct", + "model_type": "FluxFillPipeline", + "modes": ("inpaint", "outpaint"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "FluxFillPipeline", + "default_repo": FLUX_FILL_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _INPAINT_GRAPH_ROLES, + "edges": _INPAINT_GRAPH_EDGES, + "bindings": _INPAINT_GRAPH_BINDINGS, + }, + "flux2-klein:text-to-image:v1": { + "modelType": "Flux2KleinPipeline", + "mode": "text_to_image", + "profile": { + "id": "flux2-klein:direct", + "model_type": "Flux2KleinPipeline", + "modes": ("text_to_image", "edit_image", "multi_image_reference_edit"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "Flux2KleinPipeline", + "default_repo": FLUX2_KLEIN_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": True, + "compatible_repos": (), + }, + "capability": { + "modelType": "Flux2KleinPipeline", + "label": "FLUX.2 Klein 4B", + "displayName": "FLUX.2-klein-4B", + "family": "FLUX Image", + "defaultRepo": FLUX2_KLEIN_REPO, + "artifactLabel": "Diffusers repo", + "defaultDtype": "bfloat16", + "defaultSize": {"width": 1024, "height": 1024, "aspectRatio": "1:1"}, + "recommendedSteps": 4, + "recommendedGuidance": 1.0, + "guidanceLabel": "Guidance", + "supportsImageInput": True, + "supportsMask": False, + "supportsMultiImage": True, + "supportsControlImage": False, + "supportsLayers": False, + "supportsLora": True, + "offloadSupport": { + "default": OFFLOAD_MODE_MODEL_CPU, + "lowVram": OFFLOAD_MODE_MODEL_CPU, + "emergency": OFFLOAD_MODE_GROUP_DISK, + "modes": list(_DIRECT_OFFLOAD_MODES), + }, + "lowVram": { + "dtype": "bfloat16", + "autoOffload": True, + "offloadMode": OFFLOAD_MODE_MODEL_CPU, + "steps": 4, + "width": 768, + "height": 768, + }, + "modes": ["text_to_image", "edit_image", "multi_image_reference_edit"], + "executionStatus": "supported_with_model", + "modeRequirements": { + "edit_image": { + "requiredImages": ["referenceImages"], + "note": "Requires one source/reference image.", + }, + "multi_image_reference_edit": { + "requiredImages": ["referenceImages"], + "note": "Requires two or more reference images.", + }, + }, + "notes": [ + "Qualified through the generic Diffusers image facade for text, single-reference, and multi-reference generation." + ], + }, + "autoRequirements": { + "supportedTasks": ["text_to_image", "edit_image", "multi_image_reference_edit"], + "defaultRepo": FLUX2_KLEIN_REPO, + "executionPath": "direct-diffusers-image", + "pipelineClass": "Flux2KleinPipeline", + "qualityDefaults": { + "width": 1024, + "height": 1024, + "steps": 4, + "guidanceScale": 1, + "maxSequenceLength": 512, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 13 * _GIB, + "systemRamBytes": 24 * _GIB, + "diskFreeBytes": 25 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 20 * _GIB, + "systemRamBytes": 32 * _GIB, + "diskFreeBytes": 35 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "supportedOffloadModes": [ + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ], + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], + }, + "roles": _GRAPH_ROLES, + "edges": _GRAPH_EDGES, + "bindings": _GRAPH_BINDINGS, + }, + "flux2-klein:edit-image:v1": { + "modelType": "Flux2KleinPipeline", + "mode": "edit_image", + "profile": { + "id": "flux2-klein:direct", + "model_type": "Flux2KleinPipeline", + "modes": ("text_to_image", "edit_image", "multi_image_reference_edit"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "Flux2KleinPipeline", + "default_repo": FLUX2_KLEIN_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": True, + "compatible_repos": (), + }, + "roles": _EDIT_GRAPH_ROLES, + "edges": _EDIT_GRAPH_EDGES, + "bindings": _EDIT_GRAPH_BINDINGS, + }, + "flux2-klein:multi-image-reference-edit:v1": { + "modelType": "Flux2KleinPipeline", + "mode": "multi_image_reference_edit", + "profile": { + "id": "flux2-klein:direct", + "model_type": "Flux2KleinPipeline", + "modes": ("text_to_image", "edit_image", "multi_image_reference_edit"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "Flux2KleinPipeline", + "default_repo": FLUX2_KLEIN_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder_2"), + "default_quantized_components": ("transformer",), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": True, + "compatible_repos": (), + }, + "roles": _EDIT_GRAPH_ROLES, + "edges": _EDIT_GRAPH_EDGES, + "bindings": _EDIT_GRAPH_BINDINGS, + }, + "wan-22-i2v-a14b:image-to-video:v1": { + "modelType": "WanImageToVideoPipeline", + "mode": "image_to_video", + "profile": { + "id": "wan-22-image-to-video:direct", + "model_type": "WanImageToVideoPipeline", + "modes": ("image_to_video",), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "WanImageToVideoPipeline", + "default_repo": WAN_22_I2V_A14B_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "transformer_2", "text_encoder"), + "default_quantized_components": ("transformer", "transformer_2"), + "supported_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 40, + "live_proof": False, + "compatible_repos": (), + }, + "capability": { + "modelType": "WanImageToVideoPipeline", + "label": "Wan 2.2 I2V A14B", + "displayName": "Wan2.2-I2V-A14B-Diffusers", + "family": "Wan Video", + "supportTier": "supported", + "qualificationStatus": "graph-qualified-execution-pending", + "qualifiedModes": [], + "defaultRepo": WAN_22_I2V_A14B_REPO, + "artifactLabel": "Diffusers repo", + "defaultDtype": "bfloat16", + "defaultSize": {"width": 832, "height": 480, "aspectRatio": "16:9"}, + "recommendedSteps": 40, + "recommendedGuidance": 3.5, + "guidanceLabel": "High-noise guidance", + "supportsImageInput": True, + "supportsMask": False, + "supportsMultiImage": True, + "supportsControlImage": False, + "supportsLayers": False, + "supportsLora": False, + "supportsVideoInput": False, + "supportsVideoMask": False, + "outputKind": "video", + "recommendedFrames": 81, + "recommendedFps": 16, + "conditioningScale": 1.0, + "offloadSupport": { + "default": OFFLOAD_MODE_MODEL_CPU, + "lowVram": OFFLOAD_MODE_MODEL_CPU, + "emergency": OFFLOAD_MODE_GROUP_DISK, + "modes": list(_DIRECT_OFFLOAD_MODES), + }, + "lowVram": { + "dtype": "bfloat16", + "autoOffload": True, + "offloadMode": OFFLOAD_MODE_MODEL_CPU, + "steps": 40, + "width": 832, + "height": 480, + "numFrames": 81, + }, + "modes": ["image_to_video"], + "executionStatus": "supported_with_model", + "notes": [ + "Uses the generic Diffusers video facade with the official dual-expert WanImageToVideoPipeline.", + "The quality workflow quantizes both denoising experts to Quanto INT8 and runs five-second shots sequentially.", + "Human review remains required before generated examples are promoted to the gallery.", + ], + "modeRequirements": { + "image_to_video": { + "requiredImages": ["referenceImages"], + "note": "The story workflow requires one ordered opening keyframe per shot.", + }, + }, + }, + "autoRequirements": { + "supportedTasks": ["image_to_video"], + "defaultRepo": WAN_22_I2V_A14B_REPO, + "executionPath": "direct-diffusers-video", + "pipelineClass": "WanImageToVideoPipeline", + "qualityDefaults": { + "width": 832, + "height": 480, + "steps": 40, + "guidanceScale": 3.5, + "numFrames": 81, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 140 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 80 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 140 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ], + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], + "guardedReason": "Wan 2.2 I2V A14B uses the generic Diffusers video graph with a dual-transformer execution contract.", + }, + "roles": _I2V_GRAPH_ROLES, + "edges": _I2V_GRAPH_EDGES, + "bindings": _I2V_GRAPH_BINDINGS, + }, + "wan-22-ti2v-5b:text-to-video:v1": { + "modelType": "WanTI2VPipeline", + "mode": "text_to_video", + "profile": { + "id": "wan-22-ti2v-5b:direct", + "model_type": "WanTI2VPipeline", + "modes": ("text_to_video",), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "WanTI2VPipeline", + "default_repo": WAN_22_TI2V_5B_REPO, + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": 1280, + "max_low_memory_steps": 50, + "live_proof": False, + "compatible_repos": (), + }, + "capability": { + "modelType": "WanTI2VPipeline", + "label": "Wan 2.2 TI2V 5B", + "displayName": "Wan2.2-TI2V-5B-Diffusers", + "family": "Wan Video", + "supportTier": "supported", + "qualificationStatus": "graph-qualified-execution-pending", + "qualifiedModes": [], + "defaultRepo": WAN_22_TI2V_5B_REPO, + "artifactLabel": "Diffusers repo", + "defaultDtype": "bfloat16", + "defaultSize": {"width": 1280, "height": 704, "aspectRatio": "16:9"}, + "recommendedSteps": 50, + "recommendedGuidance": 5.0, + "guidanceLabel": "Guidance", + "supportsImageInput": False, + "supportsMask": False, + "supportsMultiImage": False, + "supportsControlImage": False, + "supportsLayers": False, + "supportsLora": True, + "supportsVideoInput": False, + "supportsVideoMask": False, + "outputKind": "video", + "recommendedFrames": 121, + "recommendedFps": 24, + "conditioningScale": 1.0, + "offloadSupport": { + "default": OFFLOAD_MODE_MODEL_CPU, + "lowVram": OFFLOAD_MODE_MODEL_CPU, + "emergency": OFFLOAD_MODE_GROUP_DISK, + "modes": list(_DIRECT_OFFLOAD_MODES), + }, + "lowVram": { + "dtype": "bfloat16", + "autoOffload": True, + "offloadMode": OFFLOAD_MODE_MODEL_CPU, + "steps": 50, + "width": 1280, + "height": 704, + "numFrames": 121, + }, + "modes": ["text_to_video"], + "executionStatus": "supported_with_model", + "notes": [ + "Uses the official dense Wan 2.2 5B high-compression video model for five-second 720p shots.", + "The current Diffusers WanPipeline exposes text-to-video; A14B remains the image-to-video adapter.", + "Human review remains required before generated examples are promoted to the gallery.", + ], + "modeRequirements": {}, + }, + "autoRequirements": { + "supportedTasks": ["text_to_video"], + "defaultRepo": WAN_22_TI2V_5B_REPO, + "executionPath": "direct-diffusers-video", + "pipelineClass": "WanTI2VPipeline", + "qualityDefaults": { + "width": 1280, + "height": 704, + "steps": 50, + "guidanceScale": 5, + "numFrames": 121, + }, + "minimum": { + "accelerator": "cuda", + "vramBytes": 24 * _GIB, + "systemRamBytes": 48 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "recommended": { + "accelerator": "cuda", + "vramBytes": 40 * _GIB, + "systemRamBytes": 64 * _GIB, + "diskFreeBytes": 45 * _GIB, + }, + "fullResidency": _HIGH_MEMORY_FULL_RESIDENCY, + "supportedOffloadModes": list(_DIRECT_OFFLOAD_MODES), + "requiredPackages": ["diffusers", "transformers", "accelerate", "torch"], + "guardedReason": "Wan 2.2 TI2V 5B uses the generic Diffusers video graph with an exact direct loader contract.", + }, + "roles": _VIDEO_GRAPH_ROLES, + "edges": _VIDEO_GRAPH_EDGES, + "bindings": _VIDEO_GRAPH_BINDINGS, + }, + "wan-21-t2v-1.3b:text-to-video:v1": { + "modelType": "WanVideoPipeline", + "mode": "text_to_video", + "autoRequirementKey": "WanVideoPipeline:text_to_video", + "profile": { + "id": "wan-text-to-video:direct", + "model_type": "WanVideoPipeline", + "modes": ("text_to_video",), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "WanPipeline", + "default_repo": WAN_T2V_1_3B_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 30, + "live_proof": True, + "compatible_repos": (), + }, + "autoRequirements": { + "supportedTasks": ["text_to_video"], + "defaultRepo": WAN_T2V_1_3B_REPO, + "executionPath": "direct-diffusers-video", + "pipelineClass": "WanPipeline", + "qualityDefaults": { + "width": 832, + "height": 480, + "steps": 30, + "guidanceScale": 5, + "numFrames": 81, + }, + "minimum": {"accelerator": "cuda", "vramBytes": 10 * _GIB, "systemRamBytes": 24 * _GIB}, + "recommended": {"accelerator": "cuda", "vramBytes": 12 * _GIB, "systemRamBytes": 32 * _GIB}, + "highQuality": {"accelerator": "cuda", "vramBytes": 24 * _GIB, "systemRamBytes": 48 * _GIB}, + "supportedOffloadModes": [ + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + OFFLOAD_MODE_NONE, + ], + }, + "roles": _VIDEO_GRAPH_ROLES, + "edges": _VIDEO_GRAPH_EDGES, + "bindings": _VIDEO_GRAPH_BINDINGS, + }, + "wan-21-t2v-1.3b:video-to-video:v1": { + "modelType": "WanVideoPipeline", + "mode": "video_to_video", + "profile": { + "id": "wan-video-to-video:direct", + "model_type": "WanVideoPipeline", + "modes": ("video_to_video", "video_color_edit"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "WanVideoToVideoPipeline", + "default_repo": WAN_T2V_1_3B_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 30, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _V2V_GRAPH_ROLES, + "edges": _V2V_GRAPH_EDGES, + "bindings": _V2V_GRAPH_BINDINGS, + }, + "wan-21-t2v-1.3b:video-color-edit:v1": { + "modelType": "WanVideoPipeline", + "mode": "video_color_edit", + "profile": { + "id": "wan-video-to-video:direct", + "model_type": "WanVideoPipeline", + "modes": ("video_to_video", "video_color_edit"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "WanVideoToVideoPipeline", + "default_repo": WAN_T2V_1_3B_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 30, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _V2V_GRAPH_ROLES, + "edges": _V2V_GRAPH_EDGES, + "bindings": _V2V_GRAPH_BINDINGS, + }, + "ltx-video-0.9.8-13b-distilled:text-to-video:v1": { + "modelType": "LTXVideoPipeline", + "mode": "text_to_video", + "profile": { + "id": "ltx-video:direct", + "model_type": "LTXVideoPipeline", + "modes": ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "LTXConditionPipeline", + "default_repo": LTX_VIDEO_REPO, + "fallback_repo": LTX_VIDEO_FALLBACK_REPO, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 704, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _VIDEO_GRAPH_ROLES, + "edges": _VIDEO_GRAPH_EDGES, + "bindings": _LTX_T2V_GRAPH_BINDINGS, + }, + "ltx-video-0.9.8-13b-distilled:image-to-video:v1": { + "modelType": "LTXVideoPipeline", + "mode": "image_to_video", + "profile": { + "id": "ltx-video:direct", + "model_type": "LTXVideoPipeline", + "modes": ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "LTXConditionPipeline", + "default_repo": LTX_VIDEO_REPO, + "fallback_repo": LTX_VIDEO_FALLBACK_REPO, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 704, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _I2V_GRAPH_ROLES, + "edges": _I2V_GRAPH_EDGES, + "bindings": _LTX_I2V_GRAPH_BINDINGS, + }, + "ltx-video-0.9.8-13b-distilled:video-to-video:v1": { + "modelType": "LTXVideoPipeline", + "mode": "video_to_video", + "profile": { + "id": "ltx-video:direct", + "model_type": "LTXVideoPipeline", + "modes": ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "LTXConditionPipeline", + "default_repo": LTX_VIDEO_REPO, + "fallback_repo": LTX_VIDEO_FALLBACK_REPO, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 704, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _V2V_GRAPH_ROLES, + "edges": _V2V_GRAPH_EDGES, + "bindings": _LTX_V2V_GRAPH_BINDINGS, + }, + "ltx-video-0.9.8-13b-distilled:reference-to-video:v1": { + "modelType": "LTXVideoPipeline", + "mode": "reference_to_video", + "profile": { + "id": "ltx-video:direct", + "model_type": "LTXVideoPipeline", + "modes": ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-video", + "pipeline_class": "LTXConditionPipeline", + "default_repo": LTX_VIDEO_REPO, + "fallback_repo": LTX_VIDEO_FALLBACK_REPO, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 704, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _I2V_GRAPH_ROLES, + "edges": _I2V_GRAPH_EDGES, + "bindings": _LTX_I2V_GRAPH_BINDINGS, + }, + "ace-step-v1.5-xl-turbo:text-to-audio:v1": { + "modelType": "AceStepAudioPipeline", + "mode": "text_to_audio", + "profile": { + "id": "ace-step-audio:direct", + "model_type": "AceStepAudioPipeline", + "modes": ("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), + "loader_module": "modules.DiffusersAudio", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-audio", + "pipeline_class": "AceStepPipeline", + "default_repo": ACE_STEP_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": None, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (ACE_STEP_LORA_BASE_REPO,), + }, + "roles": _AUDIO_GRAPH_ROLES, + "edges": _AUDIO_GRAPH_EDGES, + "bindings": _AUDIO_GRAPH_BINDINGS, + }, + "ace-step-v1.5-xl-turbo:audio-variation:v1": { + "modelType": "AceStepAudioPipeline", + "mode": "audio_variation", + "profile": { + "id": "ace-step-audio:direct", + "model_type": "AceStepAudioPipeline", + "modes": ("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), + "loader_module": "modules.DiffusersAudio", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-audio", + "pipeline_class": "AceStepPipeline", + "default_repo": ACE_STEP_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": None, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (ACE_STEP_LORA_BASE_REPO,), + }, + "roles": _AUDIO_VARIATION_GRAPH_ROLES, + "edges": _AUDIO_VARIATION_GRAPH_EDGES, + "bindings": _AUDIO_VARIATION_GRAPH_BINDINGS, + }, + "ace-step-v1.5-xl-turbo:audio-continuation:v1": { + "modelType": "AceStepAudioPipeline", + "mode": "audio_continuation", + "profile": { + "id": "ace-step-audio:direct", + "model_type": "AceStepAudioPipeline", + "modes": ("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), + "loader_module": "modules.DiffusersAudio", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-audio", + "pipeline_class": "AceStepPipeline", + "default_repo": ACE_STEP_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": None, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (ACE_STEP_LORA_BASE_REPO,), + }, + "roles": _AUDIO_CONTINUATION_GRAPH_ROLES, + "edges": _AUDIO_CONTINUATION_GRAPH_EDGES, + "bindings": _AUDIO_CONTINUATION_GRAPH_BINDINGS, + }, + "ace-step-v1.5-xl-turbo:audio-repaint:v1": { + "modelType": "AceStepAudioPipeline", + "mode": "audio_repaint", + "profile": { + "id": "ace-step-audio:direct", + "model_type": "AceStepAudioPipeline", + "modes": ("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), + "loader_module": "modules.DiffusersAudio", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-audio", + "pipeline_class": "AceStepPipeline", + "default_repo": ACE_STEP_REPO, + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": None, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (ACE_STEP_LORA_BASE_REPO,), + }, + "roles": _AUDIO_REPAINT_GRAPH_ROLES, + "edges": _AUDIO_REPAINT_GRAPH_EDGES, + "bindings": _AUDIO_REPAINT_GRAPH_BINDINGS, + }, + "qwen-image-edit:inpaint:v1": { + "modelType": "QwenImageEditModularPipeline", + "mode": "inpaint", + "profile": { + "id": "qwen-edit:direct-inpaint", + "model_type": "QwenImageEditModularPipeline", + "modes": ("inpaint", "outpaint"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "QwenImageEditInpaintPipeline", + "default_repo": "Qwen/Qwen-Image-Edit", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _INPAINT_GRAPH_ROLES, + "edges": _INPAINT_GRAPH_EDGES, + "bindings": _INPAINT_GRAPH_BINDINGS, + }, + "wan-vace-1.3b:text-to-video:v1": { + "modelType": "WanVACEPipeline", + "mode": "text_to_video", + "profile": { + "id": "wan-vace:direct", + "model_type": "WanVACEPipeline", + "modes": ("text_to_video", "video_inpaint", "video_outpaint", "control_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-wan-vace", + "pipeline_class": "WanVACEPipeline", + "default_repo": "Wan-AI/Wan2.1-VACE-1.3B-diffusers", + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _VIDEO_GRAPH_ROLES, + "edges": _VIDEO_GRAPH_EDGES, + "bindings": _WAN_VACE_GRAPH_BINDINGS, + }, + "wan-vace-1.3b:video-inpaint:v1": { + "modelType": "WanVACEPipeline", + "mode": "video_inpaint", + "profile": { + "id": "wan-vace:direct", + "model_type": "WanVACEPipeline", + "modes": ("text_to_video", "video_inpaint", "video_outpaint", "control_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-wan-vace", + "pipeline_class": "WanVACEPipeline", + "default_repo": "Wan-AI/Wan2.1-VACE-1.3B-diffusers", + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _VACE_INPAINT_GRAPH_ROLES, + "edges": _VACE_INPAINT_GRAPH_EDGES, + "bindings": _VACE_INPAINT_GRAPH_BINDINGS, + }, + "wan-vace-1.3b:video-outpaint:v1": { + "modelType": "WanVACEPipeline", + "mode": "video_outpaint", + "profile": { + "id": "wan-vace:direct", + "model_type": "WanVACEPipeline", + "modes": ("text_to_video", "video_inpaint", "video_outpaint", "control_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-wan-vace", + "pipeline_class": "WanVACEPipeline", + "default_repo": "Wan-AI/Wan2.1-VACE-1.3B-diffusers", + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _VACE_INPAINT_GRAPH_ROLES, + "edges": _VACE_INPAINT_GRAPH_EDGES, + "bindings": _VACE_OUTPAINT_GRAPH_BINDINGS, + }, + "wan-vace-1.3b:control-to-video:v1": { + "modelType": "WanVACEPipeline", + "mode": "control_to_video", + "profile": { + "id": "wan-vace:direct", + "model_type": "WanVACEPipeline", + "modes": ("text_to_video", "video_inpaint", "video_outpaint", "control_to_video"), + "loader_module": "modules.DiffusersVideo", + "loader_action": "LoadPipeline", + "execution_path": "direct-wan-vace", + "pipeline_class": "WanVACEPipeline", + "default_repo": "Wan-AI/Wan2.1-VACE-1.3B-diffusers", + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": (OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 832, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _VACE_CONTROL_GRAPH_ROLES, + "edges": _VACE_CONTROL_GRAPH_EDGES, + "bindings": _VACE_CONTROL_GRAPH_BINDINGS, + }, + "qwen-image-edit:outpaint:v1": { + "modelType": "QwenImageEditModularPipeline", + "mode": "outpaint", + "profile": { + "id": "qwen-edit:direct-inpaint", + "model_type": "QwenImageEditModularPipeline", + "modes": ("inpaint", "outpaint"), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "QwenImageEditInpaintPipeline", + "default_repo": "Qwen/Qwen-Image-Edit", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": _DIRECT_OFFLOAD_MODES, + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _QWEN_OUTPAINT_GRAPH_ROLES, + "edges": _QWEN_OUTPAINT_GRAPH_EDGES, + "bindings": _QWEN_OUTPAINT_GRAPH_BINDINGS, + }, + "z-image:text-to-image:v1": { + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + "profile": { + "id": "z-image:auto", + "model_type": "ZImageModularPipeline", + "modes": ("text_to_image",), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "ZImagePipeline", + "default_repo": "Tongyi-MAI/Z-Image-Turbo", + "fallback_repo": None, + "quantizable_components": (), + "default_quantized_components": (), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_DISK), + "max_low_memory_side": 1024, + "max_low_memory_steps": 8, + "live_proof": False, + "compatible_repos": (), + }, + }, + "qwen-image-2512:text-to-image:v1": { + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + "profile": { + "id": "qwen-image:t2i-direct", + "model_type": "QwenImageModularPipeline", + "modes": ("text_to_image",), + "loader_module": "modules.DiffusersImage", + "loader_action": "LoadPipeline", + "execution_path": "direct-diffusers-image", + "pipeline_class": "QwenImagePipeline", + "default_repo": "Qwen/Qwen-Image-2512", + "fallback_repo": "unsloth/Qwen-Image-2512-unsloth-bnb-4bit", + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": (), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": ( + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_SEQUENTIAL_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "max_low_memory_side": 1328, + "max_low_memory_steps": 50, + "live_proof": False, + "compatible_repos": (), + }, + }, + "qwen-image-edit:edit-image:v1": { + "modelType": "QwenImageEditModularPipeline", + "mode": "edit_image", + "profile": { + "id": "qwen-edit:modular", + "model_type": "QwenImageEditModularPipeline", + "modes": ("edit_image",), + "loader_module": "modules.ModularDiffusers", + "loader_action": "ModelsLoader", + "execution_path": "modular-diffusers", + "pipeline_class": "QwenImageEditModularPipeline", + "default_repo": "Qwen/Qwen-Image-Edit", + "fallback_repo": None, + "quantizable_components": ("transformer", "text_encoder"), + "default_quantized_components": ("transformer", "text_encoder"), + "supported_offload_modes": ( + OFFLOAD_MODE_NONE, + OFFLOAD_MODE_MODEL_CPU, + OFFLOAD_MODE_GROUP_CPU, + OFFLOAD_MODE_GROUP_DISK, + ), + "retry_offload_modes": (OFFLOAD_MODE_GROUP_DISK,), + "max_low_memory_side": 768, + "max_low_memory_steps": 24, + "live_proof": False, + "compatible_repos": (), + }, + "roles": _MODULAR_EDIT_GRAPH_ROLES, + "edges": _MODULAR_EDIT_GRAPH_EDGES, + "bindings": _MODULAR_EDIT_GRAPH_BINDINGS, + }, + "qwen-image-edit-plus:edit-image:v1": { + "modelType": "QwenImageEditPlusModularPipeline", + "mode": "edit_image", + "profile": _MODULAR_EDIT_PLUS_PROFILE, + "roles": _MODULAR_EDIT_GRAPH_ROLES, + "edges": _MODULAR_EDIT_GRAPH_EDGES, + "bindings": _MODULAR_EDIT_GRAPH_BINDINGS, + }, + "qwen-image-edit-plus:multi-image-reference-edit:v1": { + "modelType": "QwenImageEditPlusModularPipeline", + "mode": "multi_image_reference_edit", + "profile": _MODULAR_EDIT_PLUS_PROFILE, + "roles": _MODULAR_EDIT_GRAPH_ROLES, + "edges": _MODULAR_EDIT_GRAPH_EDGES, + "bindings": _MODULAR_EDIT_GRAPH_BINDINGS, + }, + "qwen-image-layered:layer-decomposition:v1": { + "modelType": "QwenImageLayeredModularPipeline", + "mode": "layer_decomposition", + "profile": _MODULAR_LAYERED_PROFILE, + "roles": _MODULAR_EDIT_GRAPH_ROLES, + "edges": _MODULAR_LAYERED_GRAPH_EDGES, + "bindings": _MODULAR_LAYERED_GRAPH_BINDINGS, + }, + "qwen-image-2512:control-image:v1": { + "modelType": "QwenImageModularPipeline", + "mode": "control_image", + "profile": _MODULAR_CONTROL_PROFILE, + "roles": _MODULAR_CONTROL_GRAPH_ROLES, + "edges": _MODULAR_CONTROL_GRAPH_EDGES, + "bindings": _MODULAR_CONTROL_GRAPH_BINDINGS, + }, +} + +_EXPERT_IMAGE_QUANTIZATION_PROFILE_IDS = { + "flux-canny:direct", + "flux-depth:direct", + "flux-dev:direct", + "flux-fill:direct", + "flux-kontext:direct", + "flux-krea:direct", + "flux-redux:direct", + "flux-schnell:direct", + "flux2-klein:direct", +} +for _definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.values(): + if _definition["profile"]["id"] in _EXPERT_IMAGE_QUANTIZATION_PROFILE_IDS: + _definition["profile"]["expert_quantization_modes"] = _EXPERT_IMAGE_QUANTIZATION_MODES + + +def _ordered_value(value: Any) -> Any: + if isinstance(value, dict): + return {key: _ordered_value(value[key]) for key in sorted(value)} + if isinstance(value, (list, tuple)): + return [_ordered_value(item) for item in value] + return value + + +def _stable_json(value: Any) -> str: + return json.dumps(_ordered_value(value), ensure_ascii=False, separators=(",", ":")) + + +def _hash_string(value: str) -> str: + digest = 0x811C9DC5 + encoded = value.encode("utf-16-le") + for index in range(0, len(encoded), 2): + digest ^= encoded[index] | encoded[index + 1] << 8 + digest = digest * 0x01000193 & 0xFFFFFFFF + return f"{digest:08x}" + + +def _public_spec(spec_id: str, definition: dict[str, Any]) -> dict[str, Any]: + profile = definition["profile"] + payload = { + "schemaVersion": STUDIO_EXECUTION_SPEC_SCHEMA_VERSION, + "canonicalizationVersion": STUDIO_EXECUTION_SPEC_CANONICALIZATION_VERSION, + "id": spec_id, + "modelType": definition["modelType"], + "mode": definition["mode"], + "executionProfileId": profile["id"], + "loaderModule": profile["loader_module"], + "loaderAction": profile["loader_action"], + "executionPath": profile["execution_path"], + "pipelineClass": profile["pipeline_class"], + "defaultRepo": profile["default_repo"], + "roles": definition.get("roles", _GRAPH_ROLES), + "edges": definition.get("edges", _GRAPH_EDGES), + "bindings": definition.get("bindings", _GRAPH_BINDINGS), + "autoFields": _AUTO_FIELDS, + "actions": (), + } + payload["contentHash"] = f"studio-spec-v1-{_hash_string(_stable_json(payload))}" + return deepcopy(payload) + + +def studio_execution_profile_definitions() -> dict[str, dict[str, Any]]: + return { + definition["profile"]["id"]: deepcopy(definition["profile"]) + for definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.values() + } + + +def studio_auto_model_requirements() -> dict[str, dict[str, Any]]: + return { + definition.get("autoRequirementKey", definition["modelType"]): deepcopy(definition["autoRequirements"]) + for definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.values() + if "autoRequirements" in definition + } + + +def studio_capability_definitions() -> dict[str, dict[str, Any]]: + return { + definition["modelType"]: deepcopy(definition["capability"]) + for definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.values() + if "capability" in definition + } + + +def studio_execution_spec_for_pair(model_type: str, mode: str) -> dict[str, Any] | None: + matches = [ + _public_spec(spec_id, definition) + for spec_id, definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.items() + if definition["modelType"] == model_type and definition["mode"] == mode + ] + return matches[0] if len(matches) == 1 else None + + +def _param_types(param: dict[str, Any]) -> set[str]: + value = param.get("type") + if isinstance(value, str): + return {value} + if isinstance(value, list): + return {item for item in value if isinstance(item, str)} + return set() + + +_MODULAR_NODE_TYPES = { + "modules.ModularDiffusers.EncodePrompt": "text_encoder", + "modules.ModularDiffusers.ImageEmbeddings": "image_encoder", + "modules.ModularDiffusers.ImageEncode": "vae_encoder", + "modules.ModularDiffusers.Denoise": "denoise", + "modules.ModularDiffusers.DecodeLatents": "decoder", + "modules.ModularDiffusers.Controlnet": "controlnet", +} + + +def _execution_spec_role_params( + public: dict[str, Any], + node_key: str, + node: dict[str, Any], +) -> dict[str, Any]: + params = deepcopy(node["params"]) + node_type = _MODULAR_NODE_TYPES.get(node_key) + if public["executionPath"] != "modular-diffusers" or node_type is None: + return params + + # Modular action fields are backend-issued after the Models Loader selects + # a reviewed pipeline. Validate the public receipt against that same + # authoritative contract instead of treating the deliberately small static + # registry definition as the executable schema. + from modules.ModularDiffusers.modular_utils import ( + pipeline_class_from_model_type, + require_modiff_node_contract, + ) + + pipeline_class = pipeline_class_from_model_type(public["pipelineClass"]) + _blocks, config = require_modiff_node_contract( + pipeline_class, + node_type, + require_blocks=False, + resolve_blocks=False, + ) + params.update(config["params"]) + return params + + +def validate_studio_execution_specs(modules: dict[str, Any]) -> list[dict[str, Any]]: + """Validate every public execution-spec reference against the live node registry.""" + + output = [] + pairs = set() + profiles: dict[str, dict[str, Any]] = {} + for spec_id, definition in STUDIO_EXECUTION_SPEC_DEFINITIONS.items(): + public = _public_spec(spec_id, definition) + pair = (public["modelType"], public["mode"]) + profile_id = public["executionProfileId"] + profile = definition["profile"] + if pair in pairs or (profile_id in profiles and profiles[profile_id] != profile): + raise ValueError("Studio execution specifications must have unique pairs and consistent execution profiles.") + pairs.add(pair) + profiles[profile_id] = profile + + roles = {} + for item in public["roles"]: + if not isinstance(item, (list, tuple)) or len(item) != 4: + raise ValueError("Studio execution specification role is invalid.") + role, node_key, x, y = item + if ( + not isinstance(role, str) + or not role + or role in roles + or not isinstance(node_key, str) + or "." not in node_key + or not isinstance(x, int) + or not isinstance(y, int) + ): + raise ValueError("Studio execution specification role is invalid.") + module, action = node_key.rsplit(".", 1) + node = modules.get(module, {}).get(action) + if not isinstance(node, dict) or not isinstance(node.get("params"), dict): + raise ValueError("Studio execution specification references an unknown node.") + roles[role] = {**node, "params": _execution_spec_role_params(public, node_key, node)} + + connections = set() + adjacency = {role: set() for role in roles} + for item in public["edges"]: + if not isinstance(item, (list, tuple)) or len(item) != 4: + raise ValueError("Studio execution specification edge is invalid.") + source_role, source_handle, target_role, target_handle = item + edge = tuple(item) + if edge in connections or source_role not in roles or target_role not in roles: + raise ValueError("Studio execution specification edge is invalid.") + connections.add(edge) + source_param = roles[source_role]["params"].get(source_handle) + target_param = roles[target_role]["params"].get(target_handle) + if ( + not isinstance(source_param, dict) + or source_param.get("display") != "output" + or not isinstance(target_param, dict) + or target_param.get("display") != "input" + or not (_param_types(source_param) & _param_types(target_param)) + ): + raise ValueError("Studio execution specification references an incompatible handle.") + adjacency[source_role].add(target_role) + adjacency[target_role].add(source_role) + visited = set() + pending = [next(iter(roles))] + while pending: + role = pending.pop() + if role in visited: + continue + visited.add(role) + pending.extend(adjacency[role] - visited) + if visited != set(roles): + raise ValueError("Studio execution specification graph is disconnected.") + + binding_targets = set() + for item in public["bindings"]: + if not isinstance(item, (list, tuple)) or len(item) != 3: + raise ValueError("Studio execution specification binding is invalid.") + role, param, source = item + target = (role, param) + target_param = roles.get(role, {}).get("params", {}).get(param) + if ( + role not in roles + or not isinstance(target_param, dict) + or target_param.get("display") == "output" + or source not in _BINDING_SOURCES + or target in binding_targets + ): + raise ValueError("Studio execution specification binding is invalid.") + binding_targets.add(target) + if set(public["autoFields"]) != _AUTO_FIELD_ALLOWLIST or public["actions"]: + raise ValueError("Studio execution specification action or Auto binding is invalid.") + + expected_hash = f"studio-spec-v1-{_hash_string(_stable_json({key: value for key, value in public.items() if key != 'contentHash'}))}" + if public["contentHash"] != expected_hash: + raise ValueError("Studio execution specification content hash is invalid.") + output.append(public) + return output + + +def assert_studio_execution_graph(graph: dict[str, Any], runtime_hints: dict[str, Any] | None) -> None: + """Fail closed when a submitted managed graph does not match its spec receipt.""" + + if not isinstance(runtime_hints, dict): + return + receipt = runtime_hints.get("studioExecutionSpec") + candidate = runtime_hints.get("autoResourcePlan") + candidate_contract = ( + candidate.get("studioExecutionSpecContract") + if isinstance(candidate, dict) + else None + ) + if receipt is None: + if candidate_contract is not None: + raise RuntimeError( + "Studio execution specification receipt is required for this Auto graph. Rebuild the managed graph." + ) + return + if not isinstance(receipt, dict): + raise RuntimeError("Studio execution specification receipt is invalid. Rebuild the managed graph.") + spec_id = receipt.get("id") + definition = STUDIO_EXECUTION_SPEC_DEFINITIONS.get(spec_id) if isinstance(spec_id, str) else None + if definition is None: + raise RuntimeError("Studio execution specification receipt is unknown. Rebuild the managed graph.") + spec = _public_spec(spec_id, definition) + expected_candidate_contract = { + "schemaVersion": spec["schemaVersion"], + "id": spec["id"], + "contentHash": spec["contentHash"], + "executionProfileId": spec["executionProfileId"], + } + if ( + receipt.get("schemaVersion") != STUDIO_EXECUTION_SPEC_SCHEMA_VERSION + or receipt.get("contentHash") != spec["contentHash"] + or runtime_hints.get("modelType") != spec["modelType"] + or runtime_hints.get("mode") != spec["mode"] + ): + raise RuntimeError("Studio execution specification receipt does not match this workflow.") + if candidate_contract is not None and candidate_contract != expected_candidate_contract: + raise RuntimeError("Studio execution specification does not match the selected Auto graph contract.") + if isinstance(candidate, dict) and candidate.get("executionProfileId") != spec["executionProfileId"]: + raise RuntimeError("Studio execution specification does not match the selected Auto profile.") + node_ids = receipt.get("nodes") + if not isinstance(node_ids, dict) or set(node_ids) != {item[0] for item in spec["roles"]}: + raise RuntimeError("Studio execution specification node receipt is invalid. Rebuild the managed graph.") + nodes = graph.get("nodes") + paths = graph.get("paths") + if not isinstance(nodes, dict) or not isinstance(paths, list): + raise RuntimeError("Studio execution specification graph is invalid. Rebuild the managed graph.") + executable_ids = { + str(node_id) + for path in paths + if isinstance(path, list) + for node_id in path + } + for role, node_key, _x, _y in spec["roles"]: + node_id = node_ids.get(role) + node = nodes.get(node_id) if isinstance(node_id, str) else None + if ( + not isinstance(node, dict) + or str(node_id) not in executable_ids + or f"{node.get('module')}.{node.get('action')}" != node_key + ): + raise RuntimeError("Studio execution specification node identity does not match the executable graph.") + for source_role, source_handle, target_role, target_handle in spec["edges"]: + source_id = node_ids[source_role] + target = nodes[node_ids[target_role]] + param = (target.get("params") or {}).get(target_handle) + if ( + not isinstance(param, dict) + or param.get("sourceId") != source_id + or param.get("sourceKey") != source_handle + ): + raise RuntimeError("Studio execution specification edge does not match the executable graph.") + for role, param, _source in spec["bindings"]: + node = nodes[node_ids[role]] + if param not in (node.get("params") or {}): + raise RuntimeError("Studio execution specification binding is missing from the executable graph.") diff --git a/modiff/tool_locks.py b/modiff/tool_locks.py new file mode 100644 index 0000000..232d582 --- /dev/null +++ b/modiff/tool_locks.py @@ -0,0 +1,57 @@ +"""Immutable installer-tool identities shared by setup and runtime overlays.""" + +from types import MappingProxyType + + +UV_TOOL_LOCKS = MappingProxyType( + { + ("linux", "x86_64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-x86_64-unknown-linux-gnu.tar.gz", + "archiveSha256": "6426a73c3837e6e2483ee344cbc00f36394d179afcba6183cb77437e67db4af0", + "executable": "uv-x86_64-unknown-linux-gnu/uv", + "executableSha256": "29b90e884c384e1578ac37335521d807c192aa44d5a4a9b9f4690bb3850e179d", + } + ), + ("linux", "arm64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-aarch64-unknown-linux-gnu.tar.gz", + "archiveSha256": "befa1a59c91e96eb601b0fd9a97c03dd666f17baba644b2b4db9c59a767e387e", + "executable": "uv-aarch64-unknown-linux-gnu/uv", + "executableSha256": "9a36adc1a125e969a6952ef69b8072960a532f45e3434b972250e61801861c5b", + } + ), + ("macos", "x86_64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-x86_64-apple-darwin.tar.gz", + "archiveSha256": "922b460202707dd5f4ccacbadbe7f6a546cc46e82a99bf50ca99a7977a78eddd", + "executable": "uv-x86_64-apple-darwin/uv", + "executableSha256": "ef0df4073dd04f3827b40c55ecb9c99144598a4eec728dd109d76fd7bead0375", + } + ), + ("macos", "arm64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-aarch64-apple-darwin.tar.gz", + "archiveSha256": "8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6", + "executable": "uv-aarch64-apple-darwin/uv", + "executableSha256": "c9300ed8425e2c85230259a172066a32b475bc56f7ebe907783b2459159ea554", + } + ), + ("windows", "x86_64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-x86_64-pc-windows-msvc.zip", + "archiveSha256": "4e1278ede866be6c0bf32d2f466cc6de7a9fb399ecf20c9ce2d186e52424be47", + "executable": "uv.exe", + "executableSha256": "deeaa21aac3e3e40b3fa00788208aa9a319cefbb3c2aa598cf580565a82ebc34", + } + ), + ("windows", "arm64"): MappingProxyType( + { + "url": "https://github.com/astral-sh/uv/releases/download/0.11.26/uv-aarch64-pc-windows-msvc.zip", + "archiveSha256": "98246149741f558e25e45ecf2b0b20f34de0634269f2bf0dcb4012d4b6ba289a", + "executable": "uv.exe", + "executableSha256": "f13a990b845aba00a30734c6c678e71b321148fdf8e28101033cce4d2b7452c5", + } + ), + } +) diff --git a/modules/Audio/main.py b/modules/Audio/main.py index 51e5c52..516e069 100644 --- a/modules/Audio/main.py +++ b/modules/Audio/main.py @@ -47,7 +47,26 @@ def _collapse_single(values): def _audio_to_numpy(audio): import torch + sample_layout = None + declared_channels = None if isinstance(audio, dict): + sample_layout = audio.get("sample_layout") + if sample_layout not in {None, "channels_first", "frames_first"}: + raise ValueError("Audio sample_layout must be exactly channels_first or frames_first.") + if "channels" in audio: + raw_channels = audio.get("channels") + try: + numeric_channels = float(raw_channels) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError("Audio channels metadata must be a positive integer.") from error + if ( + isinstance(raw_channels, bool) + or not np.isfinite(numeric_channels) + or not numeric_channels.is_integer() + or numeric_channels <= 0 + ): + raise ValueError("Audio channels metadata must be a positive integer.") + declared_channels = int(numeric_channels) if "samples" in audio: data = audio["samples"] elif "audio" in audio: @@ -62,15 +81,54 @@ def _audio_to_numpy(audio): elif isinstance(data, np.ndarray): array = _pcm_to_float32(data) elif isinstance(data, str): + if sample_layout not in {None, "frames_first"}: + raise ValueError("Decoded audio files use frames_first sample_layout; the supplied metadata conflicts.") loaded = _read_wav(resolve_runtime_input_path(data)) + if declared_channels is not None and declared_channels != loaded["channels"]: + raise ValueError( + f"Audio channels metadata declares {declared_channels} channels, but the decoded file has " + f"{loaded['channels']}." + ) return loaded["samples"], loaded["sample_rate"] else: array = np.asarray(data, dtype=np.float32) if array.ndim == 1: + if declared_channels not in {None, 1}: + raise ValueError( + f"Audio channels metadata declares {declared_channels} channels, but the waveform is one-dimensional." + ) array = array[:, None] - elif array.ndim == 2 and array.shape[0] <= 8 and array.shape[1] > array.shape[0]: - array = array.T + elif array.ndim == 2: + rows, columns = (int(value) for value in array.shape) + if sample_layout is not None: + layout_channels = rows if sample_layout == "channels_first" else columns + if declared_channels is not None and declared_channels != layout_channels: + raise ValueError( + f"Audio channels metadata declares {declared_channels} channels, but sample_layout " + f"{sample_layout} identifies {layout_channels}." + ) + elif declared_channels is not None: + rows_match = rows == declared_channels + columns_match = columns == declared_channels + if rows_match and columns_match: + if declared_channels != 1: + raise ValueError( + "Audio sample layout is ambiguous because both axes match the declared channel count." + ) + elif rows_match: + sample_layout = "channels_first" + elif columns_match: + sample_layout = "frames_first" + else: + raise ValueError( + f"Audio channels metadata declares {declared_channels} channels, but neither sample axis matches." + ) + + if sample_layout == "channels_first": + array = array.T + elif sample_layout is None and array.shape[0] <= 8 and array.shape[1] > array.shape[0]: + array = array.T sample_rate = int(audio.get("sample_rate", 48000)) if isinstance(audio, dict) else 48000 return np.clip(array, -1.0, 1.0), sample_rate @@ -90,6 +148,7 @@ def _read_wav(path): return { "path": str(path), "samples": array, + "sample_layout": "frames_first", "sample_rate": int(sample_rate), "channels": int(channels), "duration_seconds": duration, @@ -340,6 +399,7 @@ def execute(self, **kwargs): output = { "samples": trimmed, + "sample_layout": "frames_first", "sample_rate": int(sample_rate), "channels": int(trimmed.shape[1] if trimmed.ndim == 2 else 1), "duration_seconds": float(trimmed.shape[0] / sample_rate) if sample_rate else 0.0, @@ -466,6 +526,7 @@ def execute(self, **kwargs): shifted = np.clip(shifted, -1.0, 1.0) output = { "samples": shifted, + "sample_layout": "frames_first", "sample_rate": int(sample_rate), "channels": int(shifted.shape[1]), "duration_seconds": target_frames / sample_rate, @@ -570,6 +631,7 @@ def execute(self, **kwargs): ) output = { "samples": matched, + "sample_layout": "frames_first", "sample_rate": int(sample_rate), "channels": int(matched.shape[1] if matched.ndim == 2 else 1), "duration_seconds": float(matched.shape[0] / sample_rate) if sample_rate else 0.0, @@ -657,6 +719,7 @@ def execute(self, **kwargs): joined = np.concatenate([source, continuation], axis=0) output = { "samples": np.clip(joined, -1.0, 1.0), + "sample_layout": "frames_first", "sample_rate": int(sample_rate), "channels": int(joined.shape[1]), "duration_seconds": float(joined.shape[0] / sample_rate) if sample_rate else 0.0, diff --git a/modules/DiffusersAdapters/main.py b/modules/DiffusersAdapters/main.py index 9c95b43..f7ad490 100644 --- a/modules/DiffusersAdapters/main.py +++ b/modules/DiffusersAdapters/main.py @@ -5,6 +5,7 @@ from typing import Any from modiff.NodeBase import NodeBase +from modiff.auxiliary_lora import resolve_lora_descriptor, resolve_lora_descriptors def _string_list(value: Any) -> list[str]: @@ -19,39 +20,6 @@ def _string_list(value: Any) -> list[str]: return [str(item).strip() for item in values if str(item).strip()] -def _resolve_local_adapter(selection: Any, weight_name: str | None = None) -> tuple[Path, str | None]: - if isinstance(selection, dict): - value = str(selection.get("value") or "").strip() - source = selection.get("source") or "hub" - else: - value = str(selection or "").strip() - source = "local" if Path(value).expanduser().exists() else "hub" - if not value: - raise ValueError("A LoRA adapter is required.") - weight_name = str(weight_name or "").strip() or None - if source == "hub": - from utils.huggingface import cached_file_path - - repo_id = value - if not weight_name: - parts = value.split("/") - if len(parts) >= 3: - repo_id, weight_name = "/".join(parts[:2]), "/".join(parts[2:]) - if not weight_name: - raise ValueError("A Hub LoRA needs a pinned weight name installed through Model Manager.") - cached = cached_file_path(repo_id, weight_name) - if not cached: - raise FileNotFoundError(f"LoRA {repo_id}/{weight_name} is not installed.") - path = Path(cached) - return path.parent, path.name - path = Path(value).expanduser() - if path.is_file(): - return path.parent, path.name - if not path.is_dir(): - raise FileNotFoundError(f"LoRA path does not exist: {path}") - return path, weight_name - - def inspect_lora_file(path: Path, *, base_model: str = "") -> dict[str, Any]: from safetensors import safe_open @@ -105,16 +73,6 @@ def inspect_lora_file(path: Path, *, base_model: str = "") -> dict[str, Any]: } -def _adapter_descriptor(value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - raise TypeError("LoRA operations need adapter objects from a LoRA node.") - required = ("lora_path", "adapter_name") - missing = [name for name in required if not value.get(name)] - if missing: - raise ValueError(f"LoRA adapter is missing: {', '.join(missing)}.") - return dict(value) - - def active_adapter_names(pipeline: Any) -> set[str]: getter = getattr(pipeline, "get_list_adapters", None) if not callable(getter): @@ -128,28 +86,48 @@ def active_adapter_names(pipeline: Any) -> set[str]: def apply_lora_mix(pipeline: Any, adapters: Any) -> dict[str, Any]: if pipeline is None: raise ValueError("LoRA Stack / Mix needs a pipeline.") - values = adapters if isinstance(adapters, list) else [adapters] - values = [_adapter_descriptor(item) for item in values if item is not None] - if not values: - raise ValueError("LoRA Stack / Mix needs at least one adapter.") + resolved = resolve_lora_descriptors(adapters) + if any(item.scheduler_class_name is not None for item in resolved): + raise ValueError( + "Diffusers LoRA Stack / Mix cannot apply scheduler-bearing descriptors; " + "connect them through the Modular models loader instead." + ) load = getattr(pipeline, "load_lora_weights", None) activate = getattr(pipeline, "set_adapters", None) if not callable(load) or not callable(activate): raise ValueError("This Diffusers pipeline does not expose the multi-adapter LoRA API.") loaded = active_adapter_names(pipeline) + identities = dict(getattr(pipeline, "_modiff_lora_identities", {}) or {}) + replace = { + item.adapter_name + for item in resolved + if item.adapter_name in loaded and identities.get(item.adapter_name) != item.descriptor_sha256 + } + delete = getattr(pipeline, "delete_adapters", None) + if replace and not callable(delete): + raise ValueError("This Diffusers pipeline cannot replace a loaded adapter with a new immutable identity.") + names = [] weights = [] - for adapter in values: - name = str(adapter["adapter_name"]) + for item in resolved: + name = item.adapter_name + if name in replace: + delete(name) + loaded.remove(name) + identities.pop(name, None) if name not in loaded: - load_kwargs = {"adapter_name": name} - if adapter.get("weight_name"): - load_kwargs["weight_name"] = adapter["weight_name"] - load(adapter["lora_path"], **load_kwargs) + load( + str(item.load_directory), + weight_name=item.weight_name, + adapter_name=name, + use_safetensors=True, + ) loaded.add(name) + identities[name] = item.descriptor_sha256 names.append(name) - weights.append(float(adapter.get("scale", 1.0))) + weights.append(item.scale) activate(names, weights) + pipeline._modiff_lora_identities = identities return {"adapter_names": names, "adapter_weights": weights} @@ -158,8 +136,12 @@ class LoRAInspectValidate(NodeBase): category = "Diffusers Adapters" resizable = True params = { - "adapter": {"label": "Adapter", "display": "modelselect", "type": "string", "fieldOptions": {"noValidation": True, "sources": ["hub", "local"]}}, - "weight_name": {"label": "Weight Name", "type": "string", "default": ""}, + "adapter": { + "label": "Adapter", + "display": "input", + "type": "custom_lora", + "required": True, + }, "base_model": {"label": "Expected Base Model", "type": "string", "default": ""}, "report": {"label": "Inspection", "display": "output", "type": "string"}, "compatibility": {"label": "Compatibility", "display": "output", "type": "string"}, @@ -167,16 +149,8 @@ class LoRAInspectValidate(NodeBase): } def execute(self, **kwargs): - directory, weight_name = _resolve_local_adapter(kwargs.get("adapter"), kwargs.get("weight_name")) - if not weight_name: - candidates = sorted(directory.glob("*.safetensors")) - if len(candidates) != 1: - raise ValueError("Choose a weight name when the adapter directory does not contain exactly one Safetensors file.") - path = candidates[0] - else: - path = directory / weight_name - if not path.is_file(): - raise FileNotFoundError(f"LoRA weight file does not exist: {path}") + adapter = resolve_lora_descriptor(kwargs.get("adapter")) + path = adapter.load_directory / adapter.weight_name report = inspect_lora_file(path, base_model=kwargs.get("base_model") or "") return { "report": json.dumps(report, sort_keys=True), @@ -212,15 +186,30 @@ class LoRAHotswap(NodeBase): def execute(self, **kwargs): pipeline = kwargs.get("pipeline") - adapter = _adapter_descriptor(kwargs.get("replacement")) + adapter = resolve_lora_descriptor(kwargs.get("replacement")) + if adapter.scheduler_class_name is not None: + raise ValueError( + "Diffusers LoRA hotswap cannot apply a scheduler-bearing descriptor; " + "connect it through the Modular models loader instead." + ) slot = str(kwargs.get("slot_name") or "default_0") if slot not in active_adapter_names(pipeline): raise ValueError(f"LoRA hotswap slot {slot!r} is not loaded. Load the initial adapter before hotswapping it.") - load_kwargs = {"adapter_name": slot, "hotswap": True} - if adapter.get("weight_name"): - load_kwargs["weight_name"] = adapter["weight_name"] - pipeline.load_lora_weights(adapter["lora_path"], **load_kwargs) - pipeline.set_adapters([slot], [float(adapter.get("scale", 1.0))]) + load = getattr(pipeline, "load_lora_weights", None) + activate = getattr(pipeline, "set_adapters", None) + if not callable(load) or not callable(activate): + raise ValueError("This Diffusers pipeline does not expose the reviewed LoRA hotswap API.") + load( + str(adapter.load_directory), + weight_name=adapter.weight_name, + adapter_name=slot, + hotswap=True, + use_safetensors=True, + ) + activate([slot], [adapter.scale]) + identities = dict(getattr(pipeline, "_modiff_lora_identities", {}) or {}) + identities[slot] = adapter.descriptor_sha256 + pipeline._modiff_lora_identities = identities return {"output": pipeline} diff --git a/modules/DiffusersAudio/main.py b/modules/DiffusersAudio/main.py index e153acc..4360a85 100644 --- a/modules/DiffusersAudio/main.py +++ b/modules/DiffusersAudio/main.py @@ -1,8 +1,10 @@ import inspect import hashlib import logging +import os from dataclasses import dataclass -from pathlib import Path +from math import isfinite +from pathlib import Path, PurePosixPath from typing import Any import numpy as np @@ -17,14 +19,19 @@ apply_pipeline_offload, offload_mode_param, ) -from modiff.model_artifact_catalog import resolve_model_revision -from utils.huggingface import local_files_only +from modiff.config import CONFIG +from modiff.model_artifact_catalog import IMMUTABLE_HUB_REVISION, catalog_revision +from modiff.path_identifiers import resolve_managed_path_identifier, resolve_runtime_input_path +from utils.huggingface import local_files_only, validate_hf_repo_id from utils.torch_utils import DEFAULT_DEVICE, DEVICE_LIST, str_to_dtype logger = logging.getLogger("modiff") ACE_STEP_DEFAULT_REPO = "ACE-Step/acestep-v15-xl-turbo-diffusers" STABLE_AUDIO_DEFAULT_REPO = "stabilityai/stable-audio-open-1.0" +ACE_MAX_DURATION_SECONDS = 240.0 +ACE_CONTINUATION_MAX_EXTENSION_SECONDS = 180.0 +ACE_CONTINUATION_DEFAULT_EXTENSION_SECONDS = 15.0 DEVICE_OPTIONS = list(DEVICE_LIST.keys()) DIRECT_AUDIO_OFFLOAD_MODES = [ OFFLOAD_MODE_NONE, @@ -33,7 +40,8 @@ OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK, ] -ACE_TASK_TYPES = ["text2music", "cover", "repaint", "continuation", "extract", "lego", "complete"] +ACE_TASK_TYPES = ["text2music", "cover", "continuation", "repaint"] +STALE_ACE_TASK_TYPES = frozenset({"extract", "lego", "complete"}) AUDIO_SAMPLE_RATE_OPTIONS = { "44100": "44.1 kHz", "48000": "48 kHz", @@ -41,12 +49,124 @@ "96000": "96 kHz", } +_AUDIO_CONTRACT_VISIBILITY_FIELDS = ( + "negative_prompt", + "stable_audio_steps", + "stable_audio_guidance", + "num_waveforms", + "lyrics", + "vocal_language", + "num_inference_steps", + "guidance_scale", + "shift", + "bpm", + "keyscale", + "timesignature", + "audio_duration", + "extension_duration", + "return_continuation_tail", + "repainting_start", + "repainting_end", + "audio_cover_strength", +) +_ACE_COMMON_VISIBLE_FIELDS = ( + "lyrics", + "vocal_language", + "num_inference_steps", + "guidance_scale", + "shift", + "bpm", + "keyscale", + "timesignature", +) +_STABLE_AUDIO_VISIBLE_FIELDS = ( + "negative_prompt", + "stable_audio_steps", + "stable_audio_guidance", + "num_waveforms", + "audio_duration", +) + + +@dataclass(frozen=True) +class AudioModeContract: + mode: str + task_type: str + upstream_task_type: str + source_audio: str + reference_audio: str + visible_fields: tuple[str, ...] + validate_repaint_interval: bool = False + max_duration_seconds: float | None = None + max_extension_seconds: float | None = None + + def __post_init__(self) -> None: + if len(set(self.visible_fields)) != len(self.visible_fields) or any( + field not in _AUDIO_CONTRACT_VISIBILITY_FIELDS for field in self.visible_fields + ): + raise ValueError("Audio mode contracts must declare unique reviewed visibility fields.") + + def field_param_overlay(self) -> dict[str, dict[str, Any]]: + overlay = { + field: {"hidden": field not in self.visible_fields} + for field in _AUDIO_CONTRACT_VISIBILITY_FIELDS + } + overlay["task_type"] = { + "options": [self.task_type], + "default": self.task_type, + "value": self.task_type, + } + overlay["source_audio"] = { + "required": self.source_audio == "required", + "hidden": self.source_audio == "forbidden", + } + overlay["reference_audio"] = { + "required": self.reference_audio == "required", + "hidden": self.reference_audio == "forbidden", + } + overlay["audio_duration"]["max"] = self.max_duration_seconds or ACE_MAX_DURATION_SECONDS + overlay["extension_duration"]["max"] = ( + self.max_extension_seconds or ACE_CONTINUATION_MAX_EXTENSION_SECONDS + ) + return overlay + + def signal_value(self, pipeline_class: str, repository: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "audio", + "pipelineClass": pipeline_class, + "mode": self.mode, + "repository": repository, + "taskType": self.task_type, + "upstreamTaskType": self.upstream_task_type, + "sourceAudio": self.source_audio, + "referenceAudio": self.reference_audio, + "validateRepaintInterval": self.validate_repaint_interval, + "maxDurationSeconds": self.max_duration_seconds, + "maxExtensionSeconds": self.max_extension_seconds, + "fieldParams": self.field_param_overlay(), + } + @dataclass(frozen=True) class AudioPipelineAdapter: pipeline_class: str - modes: frozenset[str] - task_types: frozenset[str] + default_repo: str + mode_contracts: tuple[AudioModeContract, ...] + source_audio_channels: int | None = None + duplicate_mono_source: bool = False + + @property + def modes(self) -> tuple[str, ...]: + return tuple(contract.mode for contract in self.mode_contracts) + + def contract_for_mode(self, mode: str) -> AudioModeContract: + for contract in self.mode_contracts: + if contract.mode == mode: + return contract + supported = ", ".join(self.modes) or "none" + raise ValueError(f"{self.pipeline_class} does not support {mode}. Supported modes: {supported}.") def resolve_pipeline_class(self): import diffusers @@ -60,23 +180,242 @@ def resolve_pipeline_class(self): AUDIO_PIPELINE_ADAPTERS = { "AceStepPipeline": AudioPipelineAdapter( pipeline_class="AceStepPipeline", - modes=frozenset({"text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"}), - task_types=frozenset(ACE_TASK_TYPES), + default_repo=ACE_STEP_DEFAULT_REPO, + mode_contracts=( + AudioModeContract( + "text_to_audio", + "text2music", + "text2music", + "forbidden", + "forbidden", + visible_fields=(*_ACE_COMMON_VISIBLE_FIELDS, "audio_duration"), + max_duration_seconds=ACE_MAX_DURATION_SECONDS, + ), + AudioModeContract( + "audio_variation", + "cover", + "cover", + "required", + "forbidden", + visible_fields=(*_ACE_COMMON_VISIBLE_FIELDS, "audio_duration", "audio_cover_strength"), + max_duration_seconds=ACE_MAX_DURATION_SECONDS, + ), + AudioModeContract( + "audio_continuation", + "continuation", + "repaint", + "required", + "forbidden", + visible_fields=(*_ACE_COMMON_VISIBLE_FIELDS, "extension_duration", "return_continuation_tail"), + max_duration_seconds=ACE_MAX_DURATION_SECONDS, + max_extension_seconds=ACE_CONTINUATION_MAX_EXTENSION_SECONDS, + ), + AudioModeContract( + "audio_repaint", + "repaint", + "repaint", + "required", + "forbidden", + visible_fields=(*_ACE_COMMON_VISIBLE_FIELDS, "repainting_start", "repainting_end"), + validate_repaint_interval=True, + max_duration_seconds=ACE_MAX_DURATION_SECONDS, + ), + ), + # The reviewed ACE-Step artifact uses AutoencoderOobleck with + # ``audio_channels=2``. A mono waveform has one unambiguous, + # deterministic stereo representation; layouts with more than two + # channels do not, so they fail closed below instead of being mixed + # according to an undeclared speaker layout. + source_audio_channels=2, + duplicate_mono_source=True, ), "StableAudioPipeline": AudioPipelineAdapter( pipeline_class="StableAudioPipeline", - modes=frozenset({"text_to_audio"}), - task_types=frozenset({"text2audio"}), + default_repo=STABLE_AUDIO_DEFAULT_REPO, + mode_contracts=( + AudioModeContract( + "text_to_audio", + "text2audio", + "text2audio", + "forbidden", + "forbidden", + visible_fields=_STABLE_AUDIO_VISIBLE_FIELDS, + max_duration_seconds=47, + ), + ), ), } +def get_audio_pipeline_adapter(name: Any) -> AudioPipelineAdapter: + if not isinstance(name, str) or not name or name != name.strip(): + raise ValueError("A registered Diffusers audio pipeline class is required.") + adapter = AUDIO_PIPELINE_ADAPTERS.get(name) + if adapter is None: + supported = ", ".join(AUDIO_PIPELINE_ADAPTERS) + raise ValueError(f"Unsupported Diffusers audio pipeline class {name}. Supported classes: {supported}.") + return adapter + + +def _loader_audio_pipeline_adapter(values: dict[str, Any]) -> AudioPipelineAdapter: + """Require the exact class serialized by the generic loader field.""" + + if not isinstance(values, dict): + raise ValueError("Diffusers audio loader values must be an object.") + return get_audio_pipeline_adapter(values.get("pipeline_class")) + + +def _loader_audio_mode(values: dict[str, Any], adapter: AudioPipelineAdapter) -> str: + mode = values.get("mode") if isinstance(values, dict) else None + if not isinstance(mode, str) or not mode or mode != mode.strip(): + raise ValueError("A registered Diffusers audio mode is required.") + adapter.contract_for_mode(mode) + return mode + + +def _canonical_model_source(source: Any, *, label: str) -> str: + if not isinstance(source, str) or not source or source != source.strip(): + raise ValueError(f"{label} source must be exactly hub or local.") + normalized = source.casefold() + if normalized not in {"hub", "local"}: + raise ValueError(f"{label} source must be exactly hub or local.") + return normalized + + +def _validated_hub_repository(value: str, *, label: str) -> str: + if value.count("/") != 1: + raise ValueError(f"{label} must use an exact Hugging Face namespace/repository ID.") + try: + validate_hf_repo_id(value) + except ValueError as error: + raise ValueError(f"{label} must use an exact Hugging Face namespace/repository ID.") from error + try: + resolves_locally = Path(value).expanduser().exists() + except (OSError, RuntimeError) as error: + raise ValueError(f"{label} could not be validated as a Hugging Face repository ID.") from error + if resolves_locally: + raise ValueError( + f"{label} resolves to an existing local filesystem target. Select source=local for local models." + ) + return value + + +def _validated_local_model_directory(value: str) -> str: + try: + resolved = Path(value).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError(f"Local audio model directory does not exist: {value}") from error + if not resolved.is_dir(): + raise ValueError(f"Local audio model selection must be a directory: {value}") + return str(resolved) + + +def _resolve_audio_model_selection(adapter: AudioPipelineAdapter, value: Any): + if value is None or (isinstance(value, str) and not value.strip()): + source = "hub" + selected = adapter.default_repo + elif isinstance(value, dict): + source = _canonical_model_source(value.get("source"), label="Audio model") + raw_selected = value.get("value") + if not isinstance(raw_selected, str): + raise ValueError("Audio model value must be a repository ID or local path string.") + selected = raw_selected.strip() + if not selected: + if source == "hub": + selected = adapter.default_repo + else: + raise ValueError("A local audio model path is required.") + elif isinstance(value, str): + source = "hub" + selected = value.strip() + else: + raise ValueError("Audio model selection must be a repository ID or a hub/local selection object.") + + if source == "hub": + selected = _validated_hub_repository(selected, label="Audio model repository") + else: + selected = _validated_local_model_directory(selected) + managed_defaults = {candidate.default_repo.casefold() for candidate in AUDIO_PIPELINE_ADAPTERS.values()} + if source != "local" and selected.casefold() in managed_defaults: + return {"source": "hub", "value": adapter.default_repo} + return {"source": source, "value": selected} + + +def _resolve_audio_loader_revision(model_selection: dict[str, str], model_id: str, revision: Any) -> str | None: + """Resolve one immutable Hub identity while preserving local loading behavior.""" + + source = model_selection["source"] + if source == "local": + return None + + catalog_pin = catalog_revision(model_id) + if revision is None or revision == "": + if catalog_pin is not None: + return catalog_pin + raise ValueError( + f"Custom Hugging Face audio repository {model_id!r} requires an explicit immutable " + "lowercase 40-character commit SHA revision." + ) + if ( + not isinstance(revision, str) + or revision != revision.strip() + or revision != revision.lower() + or not IMMUTABLE_HUB_REVISION.fullmatch(revision) + ): + raise ValueError("Hugging Face audio revision must be an exact lowercase 40-character commit SHA.") + if catalog_pin is not None and revision != catalog_pin: + raise ValueError( + f"Cataloged Hugging Face audio repository {model_id!r} is pinned to {catalog_pin}; " + f"the requested revision {revision} does not match." + ) + return revision + + +def _required_sha256(value: Any, *, label: str) -> str: + if not isinstance(value, str): + raise ValueError(f"{label} must be an exact 64-character SHA-256 digest.") + normalized = value.strip().lower().removeprefix("sha256:") + if len(normalized) != 64 or any(character not in "0123456789abcdef" for character in normalized): + raise ValueError(f"{label} must be an exact 64-character SHA-256 digest.") + return normalized + + +def _validated_hub_filename(value: str, *, label: str) -> str: + path = PurePosixPath(value) + if not value or "\\" in value or path.is_absolute() or ".." in path.parts or path.as_posix() != value: + raise ValueError(f"{label} must be an exact relative Hub file path without traversal.") + return value + + +def _require_lowercase_safetensors_filename(value: str, *, label: str) -> str: + # The pinned Diffusers LoRA loader selects its deserializer with a + # case-sensitive suffix check. ``use_safetensors=True`` does not override + # an explicit ``.bin`` (or differently-cased) weight name. + if not value.endswith(".safetensors"): + raise ValueError(f"{label} must end with the literal lowercase .safetensors suffix.") + return value + + +def _audio_contract_signal(adapter: AudioPipelineAdapter, mode: str, model_selection: Any) -> dict[str, Any]: + # Static registry construction must not inspect the process working + # directory. Runtime/action callers normalize the selection first. + repository = repo_value(model_selection) or adapter.default_repo + return adapter.contract_for_mode(mode).signal_value(adapter.pipeline_class, repository) + + def repo_value(value: Any) -> str: if isinstance(value, dict): return str(value.get("value") or "") return str(value or "") +DEFAULT_AUDIO_CONTRACT = _audio_contract_signal( + AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"], + "text_to_audio", + ACE_STEP_DEFAULT_REPO, +) + + def none_if_blank(value: Any): if value is None: return None @@ -89,6 +428,40 @@ def value_or_default(value: Any, default: Any): return default if value is None else value +def _bounded_float( + value: Any, + *, + default: float, + label: str, + minimum: float, + maximum: float, + minimum_inclusive: bool = True, +) -> float: + if isinstance(value, bool): + raise ValueError(f"{label} must be a finite number.") + try: + number = float(value_or_default(value, default)) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"{label} must be a finite number.") from error + minimum_valid = number >= minimum if minimum_inclusive else number > minimum + if not isfinite(number) or not minimum_valid or number > maximum: + comparison = "at least" if minimum_inclusive else "greater than" + raise ValueError(f"{label} must be finite, {comparison} {minimum:g}, and at most {maximum:g}.") + return number + + +def _bounded_int(value: Any, *, default: int, label: str, minimum: int, maximum: int) -> int: + if isinstance(value, bool): + raise ValueError(f"{label} must be an integer from {minimum} through {maximum}.") + try: + number = float(value_or_default(value, default)) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"{label} must be an integer from {minimum} through {maximum}.") from error + if not isfinite(number) or not number.is_integer() or number < minimum or number > maximum: + raise ValueError(f"{label} must be an integer from {minimum} through {maximum}.") + return int(number) + + def _pcm_to_float32(array: np.ndarray) -> np.ndarray: if array.dtype.kind == "u": midpoint = float(np.iinfo(array.dtype).max + 1) / 2.0 @@ -101,10 +474,7 @@ def _pcm_to_float32(array: np.ndarray) -> np.ndarray: def pipeline_class_from_name(name: str): - adapter = AUDIO_PIPELINE_ADAPTERS.get(name) - if adapter is None: - raise ValueError(f"Unsupported Diffusers audio pipeline class: {name}") - return adapter.resolve_pipeline_class() + return get_audio_pipeline_adapter(name).resolve_pipeline_class() def supports_arg(pipeline: Any, arg_name: str) -> bool: @@ -114,40 +484,188 @@ def supports_arg(pipeline: Any, arg_name: str) -> bool: return False -def audio_to_numpy(audio: Any) -> tuple[np.ndarray, int]: - import torch - from scipy.io import wavfile +class AudioInputContractError(ValueError): + """An audio input violates MoDiff's path or media-layout boundary.""" + + +def _resolve_audio_source_path(value: str | os.PathLike[str]) -> Path: + """Resolve one graph-controlled audio file inside a configured media root.""" + + try: + runtime_path = resolve_runtime_input_path(value) + managed_path = resolve_managed_path_identifier( + runtime_path, + work_root=CONFIG.paths["work_dir"], + data_root=CONFIG.paths["data"], + ) + except (OSError, RuntimeError, TypeError, ValueError) as error: + raise AudioInputContractError("The audio source path identifier is invalid.") from error + if managed_path is None: + raise AudioInputContractError( + "The audio source path must stay inside the configured MoDiff work or data directory." + ) + try: + resolved = managed_path.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise AudioInputContractError(f"The audio source file does not exist: {value}") from error + if not resolved.is_file(): + raise AudioInputContractError(f"The audio source must be a file: {value}") + return resolved + + +def _declared_audio_channels(audio: Any) -> int | None: + if not isinstance(audio, dict) or "channels" not in audio: + return None + value = audio.get("channels") + try: + numeric = float(value) + except (TypeError, ValueError, OverflowError) as error: + raise AudioInputContractError("Audio channel metadata must be a positive integer.") from error + if isinstance(value, bool) or not isfinite(numeric) or not numeric.is_integer() or numeric <= 0: + raise AudioInputContractError("Audio channel metadata must be a positive integer.") + return int(numeric) + + +def _declared_audio_layout(audio: Any) -> str | None: + if not isinstance(audio, dict) or "sample_layout" not in audio: + return None + value = audio.get("sample_layout") + if value not in {"channels_first", "frames_first"}: + raise AudioInputContractError( + "Audio sample_layout metadata must be exactly channels_first or frames_first." + ) + return value + + +def _is_torch_audio_tensor(value: Any) -> bool: + value_type = type(value) + return ( + str(getattr(value_type, "__module__", "")).startswith("torch") + and callable(getattr(value, "detach", None)) + and callable(getattr(value, "cpu", None)) + and callable(getattr(value, "numpy", None)) + and hasattr(value, "shape") + ) + + +def _normalize_audio_layout( + array: np.ndarray, + *, + declared_channels: int | None, + declared_layout: str | None, + decoded_file: bool, +) -> np.ndarray: + if array.ndim == 1: + if declared_channels not in {None, 1}: + raise AudioInputContractError( + f"Audio channel metadata declares {declared_channels} channels, but the waveform is one-dimensional." + ) + return array[None, :] + if array.ndim != 2: + return array + + rows, columns = (int(value) for value in array.shape) + if rows <= 0 or columns <= 0: + return array + if decoded_file: + # scipy.io.wavfile returns [frames, channels]. Decoder provenance is + # authoritative even for tiny files where shape heuristics cannot be. + decoded_channels = columns + if declared_channels is not None and declared_channels != decoded_channels: + raise AudioInputContractError( + f"Audio channel metadata declares {declared_channels} channels, but the decoded file has " + f"{decoded_channels}." + ) + return array.T + + if declared_layout is not None: + layout_channels = rows if declared_layout == "channels_first" else columns + if declared_channels is not None and declared_channels != layout_channels: + raise AudioInputContractError( + f"Audio channel metadata declares {declared_channels} channels, but sample_layout " + f"{declared_layout} identifies {layout_channels}." + ) + return array if declared_layout == "channels_first" else array.T + + if declared_channels is not None: + rows_match = rows == declared_channels + columns_match = columns == declared_channels + if rows_match and columns_match: + if declared_channels == 1: + return array + raise AudioInputContractError( + "Audio sample layout is ambiguous because both axes match the declared channel count." + ) + if rows_match: + return array + if columns_match: + return array.T + raise AudioInputContractError( + f"Audio channel metadata declares {declared_channels} channels, but neither sample axis matches." + ) + + if rows <= 8 and columns <= 8 and (rows, columns) != (1, 1): + raise AudioInputContractError( + "Short two-dimensional audio has an ambiguous channel orientation; provide exact channels metadata." + ) + return array.T if rows > columns else array + + +def audio_to_numpy(audio: Any, *, clip: bool = True) -> tuple[np.ndarray, int]: + declared_channels = _declared_audio_channels(audio) + declared_layout = _declared_audio_layout(audio) data = audio sample_rate = 48000 if isinstance(audio, dict): - if "samples" in audio: - data = audio["samples"] - elif "audio" in audio: - data = audio["audio"] - elif "array" in audio: - data = audio["array"] - sample_rate = int(audio.get("sample_rate") or sample_rate) - if isinstance(data, str): - sample_rate, wav_data = wavfile.read(Path(data)) + for field in ("samples", "audio", "array", "path", "file"): + if field in audio: + data = audio[field] + break + if "sample_rate" in audio: + sample_rate = int(audio.get("sample_rate")) + + decoded_file = isinstance(data, (str, os.PathLike)) + if decoded_file: + if declared_layout not in {None, "frames_first"}: + raise AudioInputContractError( + "Decoded audio files use frames_first sample_layout; the supplied metadata conflicts." + ) + audio_path = _resolve_audio_source_path(data) + from scipy.io import wavfile + + sample_rate, wav_data = wavfile.read(audio_path) data = wav_data - if isinstance(data, torch.Tensor): + if _is_torch_audio_tensor(data): data = data.detach().float().cpu().numpy() array = np.asarray(data) array = _pcm_to_float32(array) - if array.ndim == 1: - array = array[None, :] - elif array.ndim == 2 and array.shape[0] > array.shape[1]: - array = array.T - return np.clip(array, -1.0, 1.0), sample_rate + array = _normalize_audio_layout( + array, + declared_channels=declared_channels, + declared_layout=declared_layout, + decoded_file=decoded_file, + ) + if clip: + array = np.clip(array, -1.0, 1.0) + return array, sample_rate def audio_to_tensor(audio: Any, device: Any, target_sample_rate: int | None = None): + array, sample_rate = audio_to_numpy(audio) + return _audio_array_to_tensor(array, sample_rate, device, target_sample_rate) + + +def _audio_array_to_tensor( + array: np.ndarray, + sample_rate: int, + device: Any, + target_sample_rate: int | None = None, +): import torch from scipy.signal import resample_poly from math import gcd - array, sample_rate = audio_to_numpy(audio) if target_sample_rate and sample_rate != target_sample_rate: divisor = gcd(sample_rate, target_sample_rate) array = resample_poly( @@ -159,6 +677,77 @@ def audio_to_tensor(audio: Any, device: Any, target_sample_rate: int | None = No return torch.from_numpy(array).to(device=device, dtype=torch.float32) +@dataclass(frozen=True) +class ValidatedAudioInput: + samples: np.ndarray + sample_rate: int + duration_seconds: float + + +def _normalize_source_audio_channels( + source: ValidatedAudioInput, + *, + adapter: AudioPipelineAdapter, + label: str, +) -> ValidatedAudioInput: + expected_channels = adapter.source_audio_channels + if expected_channels is None: + return source + + actual_channels = int(source.samples.shape[0]) + if actual_channels == expected_channels: + return source + if actual_channels == 1 and expected_channels == 2 and adapter.duplicate_mono_source: + return ValidatedAudioInput( + samples=np.repeat(source.samples, 2, axis=0), + sample_rate=source.sample_rate, + duration_seconds=source.duration_seconds, + ) + raise ValueError( + f"{label} must contain exactly {expected_channels} channels for {adapter.pipeline_class}; " + f"received {actual_channels}. Mono is duplicated deterministically, but multichannel downmixing " + "requires channel-layout metadata and is not implicit." + ) + + +def _validated_audio_input(audio: Any, *, label: str, maximum_duration: float) -> ValidatedAudioInput: + if isinstance(audio, dict) and "sample_rate" in audio: + raw_sample_rate = audio.get("sample_rate") + try: + numeric_sample_rate = float(raw_sample_rate) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"{label} sample rate must be a positive integer.") from error + if ( + isinstance(raw_sample_rate, bool) + or not isfinite(numeric_sample_rate) + or not numeric_sample_rate.is_integer() + or numeric_sample_rate <= 0 + ): + raise ValueError(f"{label} sample rate must be a positive integer.") + try: + # Validate the decoded values before clipping. Clipping first would + # silently turn +/-infinity into valid-looking full-scale PCM. + samples, sample_rate = audio_to_numpy(audio, clip=False) + except AudioInputContractError: + raise + except (OSError, TypeError, ValueError, IndexError, OverflowError) as error: + raise ValueError(f"{label} must be decodable mono or multichannel PCM audio.") from error + if samples.ndim != 2 or samples.shape[0] <= 0 or samples.shape[1] <= 0: + raise ValueError(f"{label} must contain at least one channel and one audio frame.") + if not np.isfinite(samples).all(): + raise ValueError(f"{label} samples must all be finite.") + if sample_rate <= 0: + raise ValueError(f"{label} sample rate must be a positive integer.") + duration = float(samples.shape[-1] / sample_rate) + if not isfinite(duration) or duration <= 0 or duration > maximum_duration: + raise ValueError(f"{label} duration must be finite, greater than 0, and at most {maximum_duration:g} seconds.") + return ValidatedAudioInput( + samples=np.clip(samples, -1.0, 1.0), + sample_rate=sample_rate, + duration_seconds=duration, + ) + + def resample_audio_object(audio: dict[str, Any], target_sample_rate: int) -> dict[str, Any]: from math import gcd @@ -204,6 +793,7 @@ def _audio_array_to_object(array: np.ndarray, sample_rate: int) -> dict[str, Any duration_samples = int(array.shape[1]) return { "samples": np.clip(array, -1.0, 1.0), + "sample_layout": "channels_first", "sample_rate": int(sample_rate), "channels": channels, "duration_seconds": float(duration_samples / sample_rate) if sample_rate else 0.0, @@ -236,8 +826,10 @@ def crop_tail(audio: dict[str, Any], start_seconds: float, duration_seconds: flo samples = np.asarray(audio["samples"], dtype=np.float32) sample_rate = int(audio.get("sample_rate") or 48000) start = max(0, int(start_seconds * sample_rate)) - end = start + int(duration_seconds * sample_rate) if duration_seconds and duration_seconds > 0 else samples.shape[-1] - cropped = samples[..., start:min(end, samples.shape[-1])] + end = ( + start + int(duration_seconds * sample_rate) if duration_seconds and duration_seconds > 0 else samples.shape[-1] + ) + cropped = samples[..., start : min(end, samples.shape[-1])] return { **audio, "samples": cropped, @@ -251,14 +843,27 @@ class LoadPipeline(NodeBase): label = "Load Diffusers Audio Pipeline" category = "Diffusers Audio" resizable = True + # Mode selects a generation contract but does not change the resident + # Diffusers object. Revalidate and retag it on every graph invocation. + cache_ignored_params = frozenset({"mode", "audio_contract"}) params = { - "pipeline": {"label": "Pipeline", "display": "output", "type": "audio_diffusion_pipeline"}, + "pipeline": { + "label": "Pipeline", + "display": "output", + "type": "audio_diffusion_pipeline", + "signal": { + "direction": "output", + "origin": "audio_contract", + "value": DEFAULT_AUDIO_CONTRACT, + }, + }, "model_id": { "label": "Model", "display": "modelselect", "type": "string", "value": {"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, "fieldOptions": {"noValidation": True, "sources": ["hub", "local"]}, + "onChange": "update_audio_contract", }, "pipeline_class": { "label": "Pipeline Class", @@ -267,12 +872,22 @@ class LoadPipeline(NodeBase): # choices without importing Diffusers or executing this module. "options": ["AceStepPipeline", "StableAudioPipeline"], "default": "AceStepPipeline", + "fieldOptions": {"noValidation": True}, + "onChange": "update_audio_contract", }, "mode": { "label": "Mode", "type": "string", "options": ["text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"], "default": "text_to_audio", + "fieldOptions": {"noValidation": True}, + "onChange": "update_audio_contract", + }, + "audio_contract": { + "label": "Audio Contract", + "type": "object", + "default": DEFAULT_AUDIO_CONTRACT, + "hidden": True, }, "revision": {"label": "Revision", "type": "string", "default": ""}, "dtype": { @@ -294,22 +909,99 @@ class LoadPipeline(NodeBase): "resolved_artifact": {"label": "Resolved Artifact", "display": "output", "type": "string"}, } + def update_audio_contract(self, values, ref): + """Publish the selected generic audio task contract without loading model code.""" + + values = values if isinstance(values, dict) else {} + adapter = _loader_audio_pipeline_adapter(values) + requested_mode = values.get("mode") + if not isinstance(requested_mode, str) or not requested_mode or requested_mode != requested_mode.strip(): + raise ValueError("A registered Diffusers audio mode is required.") + ref_key = ref.get("key") if isinstance(ref, dict) else None + if requested_mode not in adapter.modes: + if ref_key != "pipeline_class": + adapter.contract_for_mode(requested_mode) + requested_mode = adapter.modes[0] + model_selection = _resolve_audio_model_selection(adapter, values.get("model_id")) + signal_value = _audio_contract_signal(adapter, requested_mode, model_selection) + self.set_field_params( + "mode", + {"options": list(adapter.modes), "default": adapter.modes[0], "value": requested_mode}, + ) + field_values = {"model_id": model_selection, "audio_contract": signal_value} + selected_revision = values.get("revision") + if model_selection["source"] == "local": + field_values["revision"] = "" + else: + managed_revision = catalog_revision(model_selection["value"]) + if managed_revision is not None: + field_values["revision"] = managed_revision + elif ref_key == "model_id": + # A model field action carries no trustworthy previous + # repository identity. Never let a newly selected custom Hub + # repository inherit the commit shown for its predecessor. + field_values["revision"] = "" + elif selected_revision not in (None, ""): + # Class and mode actions preserve an explicit custom pin only + # after validating that it is still an immutable revision. + _resolve_audio_loader_revision( + model_selection, + model_selection["value"], + selected_revision, + ) + self.set_field_value(field_values) + self.set_field_params( + "pipeline", + { + "signal": { + "direction": "output", + "origin": "audio_contract", + "value": signal_value, + } + }, + ) + + def __call__(self, **kwargs): + adapter = _loader_audio_pipeline_adapter(kwargs) + mode = _loader_audio_mode(kwargs, adapter) + values = dict(kwargs) + values["pipeline_class"] = adapter.pipeline_class + values["mode"] = mode + values["model_id"] = _resolve_audio_model_selection(adapter, values.get("model_id")) + model_id = repo_value(values["model_id"]) + values["revision"] = _resolve_audio_loader_revision( + values["model_id"], + model_id, + values.get("revision"), + ) + result = super().__call__(**values) + pipeline = result.get("pipeline") if isinstance(result, dict) else None + if pipeline is not None: + self._tag_pipeline(pipeline, adapter, mode, model_id, values["revision"]) + return result + + @staticmethod + def _tag_pipeline( + pipeline: Any, + adapter: AudioPipelineAdapter, + mode: str, + repository: str, + revision: str | None, + ): + setattr(pipeline, "_modiff_audio_pipeline_class", adapter.pipeline_class) + setattr(pipeline, "_modiff_audio_mode", mode) + setattr(pipeline, "_modiff_audio_repo", repository or adapter.default_repo) + setattr(pipeline, "_modiff_audio_revision", revision) + def execute(self, **kwargs): + adapter = _loader_audio_pipeline_adapter(kwargs) + pipeline_class_name = adapter.pipeline_class + mode = _loader_audio_mode(kwargs, adapter) + model_selection = _resolve_audio_model_selection(adapter, kwargs.get("model_id")) + model_id = repo_value(model_selection) + from modules.DiffusersRuntime.main import apply_execution_recipe_to_pipeline, loader_runtime_options - pipeline_class_name = str(kwargs.get("pipeline_class") or "AceStepPipeline") - model_selection = kwargs.get("model_id") - model_id = repo_value(model_selection) - if not model_id or (pipeline_class_name == "StableAudioPipeline" and model_id == ACE_STEP_DEFAULT_REPO): - model_id = STABLE_AUDIO_DEFAULT_REPO if pipeline_class_name == "StableAudioPipeline" else ACE_STEP_DEFAULT_REPO - model_source = "hub" - else: - model_source = model_selection.get("source") if isinstance(model_selection, dict) else None - mode = str(kwargs.get("mode") or "text_to_audio") - adapter = AUDIO_PIPELINE_ADAPTERS.get(pipeline_class_name) - if adapter is None or mode not in adapter.modes: - supported = ', '.join(sorted(adapter.modes if adapter else [])) or 'none' - raise ValueError(f"{pipeline_class_name} does not support {mode}. Supported modes: {supported}.") pipeline_class = pipeline_class_from_name(pipeline_class_name) dtype = str_to_dtype(kwargs.get("dtype") or "bfloat16") recipe, device, offload_mode, recipe_load_kwargs = loader_runtime_options( @@ -318,11 +1010,7 @@ def execute(self, **kwargs): default_offload_mode=OFFLOAD_MODE_MODEL_CPU, direct_device_load=True, ) - revision = resolve_model_revision( - model_id, - none_if_blank(kwargs.get("revision")), - source=model_source, - ) + revision = _resolve_audio_loader_revision(model_selection, model_id, kwargs.get("revision")) load_kwargs = { "torch_dtype": dtype, @@ -335,7 +1023,7 @@ def execute(self, **kwargs): self.progress(-1, phase="loading", message=f"Loading {pipeline_class_name}") with self.diffusers_loading_progress(): pipeline = pipeline_class.from_pretrained(model_id, **load_kwargs) - setattr(pipeline, "_modiff_audio_pipeline_class", pipeline_class_name) + self._tag_pipeline(pipeline, adapter, mode, model_id, revision) if recipe: apply_execution_recipe_to_pipeline(pipeline, recipe) elif kwargs.get("enable_vae_tiling", True): @@ -360,7 +1048,7 @@ def execute(self, **kwargs): class LoadAdapter(NodeBase): - """Load an ACE-Step LoRA from MoDiff's managed cache or a local folder.""" + """Load a safetensors-only ACE-Step LoRA from managed cache or a local folder.""" label = "Load Diffusers Audio LoRA" category = "Diffusers Audio" @@ -371,16 +1059,106 @@ class LoadAdapter(NodeBase): "label": "LoRA", "display": "modelselect", "type": "string", + "required": True, "fieldOptions": {"noValidation": True, "sources": ["hub", "local"]}, }, - "weight_name": {"label": "Weight name", "type": "string", "default": "adapter_model.safetensors"}, - "expected_sha256": {"label": "Expected SHA-256", "type": "string", "default": ""}, + "weight_name": { + "label": "Weight name", + "type": "string", + "default": "adapter_model.safetensors", + "description": "Must end with the literal lowercase .safetensors suffix.", + }, + "revision": { + "label": "Revision", + "type": "string", + "default": "", + "description": "Required immutable 40-character commit SHA for Hub LoRAs.", + }, + "expected_sha256": { + "label": "Expected SHA-256", + "type": "string", + "default": "", + "description": "Required content digest for Hub LoRA weights.", + }, "adapter_name": {"label": "Adapter name", "type": "string", "default": "audio_style"}, "replace_existing": {"label": "Replace existing adapters", "type": "bool", "default": True}, - "scale": {"label": "Strength", "display": "slider", "type": "float", "default": 0.7, "min": 0, "max": 2, "step": 0.05}, + "scale": { + "label": "Strength", + "display": "slider", + "type": "float", + "default": 0.7, + "min": 0, + "max": 2, + "step": 0.05, + }, "output": {"label": "Pipeline", "display": "output", "type": "audio_diffusion_pipeline"}, } + @staticmethod + def _selection(value: Any) -> dict[str, str]: + if isinstance(value, dict): + source = _canonical_model_source(value.get("source"), label="Audio LoRA") + raw_adapter_path = value.get("value") + if not isinstance(raw_adapter_path, str): + raise ValueError("Audio LoRA value must be a repository ID or local path string.") + adapter_path = raw_adapter_path.strip() + elif isinstance(value, str): + source = "local" + adapter_path = value.strip() + else: + raise ValueError("Audio LoRA selection must be a local path or a hub/local selection object.") + if not adapter_path: + raise ValueError("Audio LoRA repository ID or local path is required.") + if source == "hub": + adapter_path = _validated_hub_repository(adapter_path, label="Audio LoRA repository") + else: + try: + local_target = Path(adapter_path).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"Audio LoRA path does not exist: {adapter_path}") from error + if not (local_target.is_file() or local_target.is_dir()): + raise ValueError("Audio LoRA local target must be a file or directory.") + adapter_path = str(local_target) + return {"source": source, "value": adapter_path} + + @staticmethod + def _weight_name(selection: dict[str, str], value: Any) -> str: + if value is None or (isinstance(value, str) and not value.strip()): + weight_name = "adapter_model.safetensors" + elif isinstance(value, str): + weight_name = value.strip() + else: + raise ValueError("Audio LoRA weight name must be a string.") + + if selection["source"] == "hub": + weight_name = _validated_hub_filename(weight_name, label="Audio LoRA weight name") + else: + selected_path = Path(selection["value"]) + if selected_path.is_file(): + weight_name = selected_path.name + return _require_lowercase_safetensors_filename(weight_name, label="Audio LoRA weight name") + + def __call__(self, **kwargs): + values = dict(kwargs) + # NodeBase normally interprets legacy plain modelselect strings as the + # first declared source (Hub). Audio LoRA plain strings have always + # meant local paths, so canonicalize before NodeBase sees the value. + values["adapter_path"] = self._selection(values.get("adapter_path")) + values["weight_name"] = self._weight_name(values["adapter_path"], values.get("weight_name")) + if values["adapter_path"]["source"] == "hub": + values["revision"] = _resolve_audio_loader_revision( + values["adapter_path"], + values["adapter_path"]["value"], + values.get("revision"), + ) + values["expected_sha256"] = _required_sha256( + values.get("expected_sha256"), + label="Hub audio LoRA expected SHA-256", + ) + else: + values["revision"] = None + return super().__call__(**values) + def execute(self, **kwargs): pipeline = kwargs.get("pipeline") if pipeline is None: @@ -392,38 +1170,90 @@ def execute(self, **kwargs): "This Diffusers revision does not expose ACE-Step LoRA support. Install MoDiff's pinned dependencies." ) - selection = kwargs.get("adapter_path") - adapter_path = repo_value(selection) - if not adapter_path: - return {"output": pipeline} - weight_name = str(kwargs.get("weight_name") or "adapter_model.safetensors").strip() - source = selection.get("source") if isinstance(selection, dict) else "local" + selection = self._selection(kwargs.get("adapter_path")) + source = selection["source"] + adapter_path = selection["value"] + + weight_name = self._weight_name(selection, kwargs.get("weight_name")) if source == "hub": - from utils.huggingface import cached_file_path + from utils.huggingface import cached_file_path, resolve_managed_hf_cache_file - cached = cached_file_path(adapter_path, weight_name) + revision = _resolve_audio_loader_revision(selection, adapter_path, kwargs.get("revision")) + expected = _required_sha256( + kwargs.get("expected_sha256"), + label="Hub audio LoRA expected SHA-256", + ) + cached = cached_file_path(adapter_path, weight_name, revision=revision) if not cached: raise FileNotFoundError( - f"Audio LoRA {adapter_path}/{weight_name} is not installed. Install it through Model Manager first." + f"Audio LoRA {adapter_path}@{revision}/{weight_name} is not installed. " + "Install that exact revision through Model Manager first." ) - cached_path = Path(cached) - expected = str(kwargs.get("expected_sha256") or "").strip().lower().removeprefix("sha256:") - if expected: - digest = hashlib.sha256() - with cached_path.open("rb") as handle: - for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): - digest.update(chunk) - if digest.hexdigest() != expected: - raise ValueError("The audio LoRA failed its pinned SHA-256 verification. Repair it in Model Manager.") - adapter_path = str(cached_path.parent) - weight_name = cached_path.name + cached_alias = Path(cached).expanduser() + _require_lowercase_safetensors_filename( + cached_alias.name, + label="Installed Hub audio LoRA cache entry", + ) + cached_path = resolve_managed_hf_cache_file(cached) + digest = hashlib.sha256() + with cached_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != expected: + raise ValueError("The audio LoRA failed its pinned SHA-256 verification. Repair it in Model Manager.") + # Keep the reviewed snapshot alias: its literal lowercase suffix + # makes pinned Diffusers select safetensors. The resolved target is + # used only for cache containment and digest verification because + # normal Hub aliases may resolve to extensionless blob names. + adapter_path = str(cached_alias.parent) + weight_name = cached_alias.name + else: + try: + local_target = Path(adapter_path).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"Audio LoRA path does not exist: {adapter_path}") from error + if local_target.is_file(): + adapter_path = str(local_target.parent) + weight_name = local_target.name + elif local_target.is_dir(): + requested_weight = Path(weight_name) + if requested_weight.is_absolute(): + raise ValueError("Audio LoRA weight name must stay inside the selected local folder.") + try: + local_weight = (local_target / requested_weight).resolve(strict=True) + local_weight.relative_to(local_target) + except (OSError, RuntimeError, ValueError) as error: + raise FileNotFoundError( + f"Audio LoRA weight does not exist inside the selected folder: {weight_name}" + ) from error + if not local_weight.is_file(): + raise FileNotFoundError("The selected local audio LoRA weight is not a file.") + adapter_path = str(local_weight.parent) + weight_name = local_weight.name + else: + raise FileNotFoundError(f"Audio LoRA target is not a file or folder: {local_target}") + _require_lowercase_safetensors_filename(weight_name, label="Local audio LoRA weight name") adapter_name = str(kwargs.get("adapter_name") or "audio_style").strip() + if not adapter_name: + raise ValueError("Audio LoRA adapter name is required.") + scale = _bounded_float( + kwargs.get("scale"), + default=0.7, + label="Audio LoRA strength", + minimum=0, + maximum=2, + ) if kwargs.get("replace_existing", True) and callable(getattr(pipeline, "unload_lora_weights", None)): pipeline.unload_lora_weights() - pipeline.load_lora_weights(adapter_path, weight_name=weight_name, adapter_name=adapter_name) + pipeline.load_lora_weights( + adapter_path, + weight_name=weight_name, + adapter_name=adapter_name, + use_safetensors=True, + ) if callable(getattr(pipeline, "set_adapters", None)): - pipeline.set_adapters([adapter_name], [float(kwargs.get("scale", 0.7))]) + pipeline.set_adapters([adapter_name], [scale]) return {"output": pipeline} @@ -450,9 +1280,19 @@ def execute(self, **kwargs): raise ValueError("Set Audio LoRA Blend needs a pipeline input.") names = [part.strip() for part in str(kwargs.get("adapter_names") or "").split(",") if part.strip()] try: - weights = [float(part.strip()) for part in str(kwargs.get("adapter_weights") or "").split(",") if part.strip()] + weights = [ + _bounded_float( + part.strip(), + default=0.7, + label="Audio LoRA weight", + minimum=0, + maximum=2, + ) + for part in str(kwargs.get("adapter_weights") or "").split(",") + if part.strip() + ] except ValueError as exc: - raise ValueError("Audio LoRA weights must be comma-separated numbers.") from exc + raise ValueError("Audio LoRA weights must be comma-separated finite values from 0 through 2.") from exc if not names or len(names) != len(weights): raise ValueError("Audio LoRA adapter names and weights must contain the same number of entries.") setter = getattr(pipeline, "set_adapters", None) @@ -497,6 +1337,292 @@ def execute(self, **kwargs): return {"output": pipeline} +def _has_audio_input(value: Any) -> bool: + return value is not None and not (isinstance(value, str) and value.strip() == "") + + +def _pipeline_audio_contract(pipeline: Any) -> tuple[AudioPipelineAdapter, AudioModeContract]: + runtime_class = type(pipeline).__name__ + runtime_candidates = [ + adapter for adapter in AUDIO_PIPELINE_ADAPTERS.values() if adapter.pipeline_class == runtime_class + ] + pipeline_class_name = getattr(pipeline, "_modiff_audio_pipeline_class", None) + mode = getattr(pipeline, "_modiff_audio_mode", None) + if pipeline_class_name is None: + if runtime_class not in AUDIO_PIPELINE_ADAPTERS: + raise ValueError( + "The audio pipeline has no MoDiff class/mode identity and is not an exact supported Diffusers " + "audio pipeline class. Reload it with Load Diffusers Audio Pipeline." + ) + pipeline_class_name = runtime_class + + adapter = get_audio_pipeline_adapter(pipeline_class_name) + if runtime_candidates and adapter not in runtime_candidates: + runtime_names = ", ".join(candidate.pipeline_class for candidate in runtime_candidates) + raise ValueError( + f"Diffusers audio pipeline identity is inconsistent: runtime class {runtime_class} supports " + f"{runtime_names}, but the pipeline is tagged as {adapter.pipeline_class}." + ) + tagged_repo = getattr(pipeline, "_modiff_audio_repo", None) + if isinstance(tagged_repo, str) and tagged_repo.strip(): + repository_key = tagged_repo.strip().casefold() + managed_repo_adapters = [ + candidate + for candidate in AUDIO_PIPELINE_ADAPTERS.values() + if candidate.default_repo.casefold() == repository_key + ] + if managed_repo_adapters and adapter not in managed_repo_adapters: + repository_names = ", ".join(candidate.pipeline_class for candidate in managed_repo_adapters) + raise ValueError( + "Diffusers audio pipeline identity is inconsistent: managed repository " + f"{tagged_repo.strip()!r} supports {repository_names}, but the pipeline is tagged as " + f"{adapter.pipeline_class}." + ) + if mode is None: + if len(adapter.mode_contracts) != 1: + raise ValueError( + f"Untagged {adapter.pipeline_class} is ambiguous across modes: {', '.join(adapter.modes)}. " + "Reload it with an explicit audio mode." + ) + mode = adapter.modes[0] + contract = adapter.contract_for_mode(str(mode)) + return adapter, contract + + +def _finite_positive_bound(value: Any, *, default: float, label: str, maximum: float | None) -> float: + if isinstance(value, bool): + raise ValueError(f"{label} must be a finite number greater than 0.") + try: + number = float(value_or_default(value, default)) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"{label} must be a finite number greater than 0.") from error + if not isfinite(number) or number <= 0 or (maximum is not None and number > maximum): + maximum_message = f" and at most {maximum:g} seconds" if maximum is not None else "" + raise ValueError(f"{label} must be finite and greater than 0{maximum_message}.") + return number + + +@dataclass(frozen=True) +class AudioInvocation: + adapter: AudioPipelineAdapter + contract: AudioModeContract + duration_seconds: float + extension_seconds: float | None + repaint_interval: tuple[float, float] | None + source: ValidatedAudioInput | None + controls: dict[str, int | float | None] + + +def _preflight_audio_invocation(pipeline: Any, kwargs: dict[str, Any]) -> AudioInvocation: + adapter, contract = _pipeline_audio_contract(pipeline) + if "task_type" not in kwargs: + task_type = contract.task_type + else: + requested_task = kwargs.get("task_type") + if not isinstance(requested_task, str) or not requested_task or requested_task != requested_task.strip(): + raise ValueError("Audio task must be an exact nonblank supported task string.") + task_type = requested_task + if task_type in STALE_ACE_TASK_TYPES: + raise ValueError( + f"ACE-Step task {task_type!r} is recognized but hidden until MoDiff provides its dedicated " + "complete input mode. Rebuild the graph with a supported audio mode." + ) + if task_type != contract.task_type: + raise ValueError( + f"Audio mode {contract.mode} requires task {contract.task_type}; received {task_type}. " + "Refresh or rebuild the stale graph contract." + ) + + source_audio = kwargs.get("source_audio") + reference_audio = kwargs.get("reference_audio") + has_source = _has_audio_input(source_audio) + has_reference = _has_audio_input(reference_audio) + if contract.source_audio == "required" and not has_source: + raise ValueError(f"Audio mode {contract.mode} requires source audio.") + if contract.source_audio == "forbidden" and has_source: + raise ValueError(f"Audio mode {contract.mode} does not accept source audio.") + if contract.reference_audio == "required" and not has_reference: + raise ValueError(f"Audio mode {contract.mode} requires reference audio.") + if contract.reference_audio == "forbidden" and has_reference: + raise ValueError(f"Audio mode {contract.mode} does not accept reference audio.") + + requested_sample_rate = _bounded_int( + kwargs.get("sample_rate"), + default=48000, + label="Audio sample rate", + minimum=min(int(value) for value in AUDIO_SAMPLE_RATE_OPTIONS), + maximum=max(int(value) for value in AUDIO_SAMPLE_RATE_OPTIONS), + ) + if requested_sample_rate not in {int(value) for value in AUDIO_SAMPLE_RATE_OPTIONS}: + supported = ", ".join(AUDIO_SAMPLE_RATE_OPTIONS.values()) + raise ValueError(f"Audio sample rate must be one of: {supported}.") + + source = None + if has_source: + source = _validated_audio_input( + source_audio, + label=f"Audio mode {contract.mode} source", + maximum_duration=contract.max_duration_seconds or ACE_MAX_DURATION_SECONDS, + ) + source = _normalize_source_audio_channels( + source, + adapter=adapter, + label=f"Audio mode {contract.mode} source", + ) + + extension_duration = None + if contract.mode == "audio_continuation": + extension_duration = _finite_positive_bound( + kwargs.get("extension_duration"), + default=ACE_CONTINUATION_DEFAULT_EXTENSION_SECONDS, + label="ACE-Step continuation extension", + maximum=contract.max_extension_seconds, + ) + + repaint_interval = None + if contract.validate_repaint_interval: + repaint_start = _bounded_float( + kwargs.get("repainting_start"), + default=0.0, + label="Audio repaint start", + minimum=0, + maximum=contract.max_duration_seconds or ACE_MAX_DURATION_SECONDS, + ) + repaint_end = _bounded_float( + kwargs.get("repainting_end"), + default=0.0, + label="Audio repaint end", + minimum=0, + maximum=contract.max_duration_seconds or ACE_MAX_DURATION_SECONDS, + ) + if repaint_end <= repaint_start: + raise ValueError("Audio repaint requires a non-negative start and an end strictly greater than the start.") + if source is not None and repaint_end > source.duration_seconds: + raise ValueError( + f"Audio repaint end {repaint_end:g}s exceeds the {source.duration_seconds:g}s source duration." + ) + repaint_interval = (repaint_start, repaint_end) + + if contract.mode == "audio_continuation": + duration = source.duration_seconds + extension_duration + if contract.max_duration_seconds is not None and duration > contract.max_duration_seconds: + raise ValueError( + "ACE-Step continuation source plus extension must be at most " + f"{contract.max_duration_seconds:g} seconds." + ) + elif contract.mode == "audio_repaint": + duration = source.duration_seconds + else: + duration_label = ( + "Stable Audio duration" if adapter.pipeline_class == "StableAudioPipeline" else "ACE-Step duration" + ) + duration = _finite_positive_bound( + kwargs.get("audio_duration"), + default=30.0, + label=duration_label, + maximum=contract.max_duration_seconds, + ) + + seed = _bounded_int( + kwargs.get("seed"), + default=0, + label="Audio seed", + minimum=0, + maximum=4294967295, + ) + if adapter.pipeline_class == "StableAudioPipeline": + controls: dict[str, int | float | None] = { + "seed": seed, + "steps": _bounded_int( + kwargs.get("stable_audio_steps"), + default=100, + label="Stable Audio steps", + minimum=1, + maximum=300, + ), + "guidance": _bounded_float( + kwargs.get("stable_audio_guidance"), + default=7, + label="Stable Audio guidance", + minimum=0, + maximum=20, + ), + "waveforms": _bounded_int( + kwargs.get("num_waveforms"), + default=1, + label="Stable Audio variations", + minimum=1, + maximum=8, + ), + } + else: + raw_bpm = kwargs.get("bpm") + bpm = None + if raw_bpm is not None and not (isinstance(raw_bpm, str) and not raw_bpm.strip()): + bpm_value = _bounded_int( + raw_bpm, + default=0, + label="ACE-Step BPM", + minimum=0, + maximum=400, + ) + bpm = bpm_value or None + controls = { + "seed": seed, + "steps": _bounded_int( + kwargs.get("num_inference_steps"), + default=8, + label="ACE-Step inference steps", + minimum=1, + maximum=100, + ), + "guidance": _bounded_float( + kwargs.get("guidance_scale"), + default=1, + label="ACE-Step guidance", + minimum=0, + maximum=20, + ), + "shift": _bounded_float( + kwargs.get("shift"), + default=3, + label="ACE-Step shift", + minimum=0, + maximum=10, + minimum_inclusive=False, + ), + "lora_scale": _bounded_float( + kwargs.get("lora_scale"), + default=1, + label="ACE-Step LoRA call strength", + minimum=0, + maximum=2, + ), + "cover_strength": ( + _bounded_float( + kwargs.get("audio_cover_strength"), + default=0.85, + label="ACE-Step cover strength", + minimum=0, + maximum=1, + ) + if contract.mode == "audio_variation" + else None + ), + "bpm": bpm, + } + controls["sample_rate"] = requested_sample_rate + return AudioInvocation( + adapter=adapter, + contract=contract, + duration_seconds=duration, + extension_seconds=extension_duration, + repaint_interval=repaint_interval, + source=source, + controls=controls, + ) + + class Generate(NodeBase): """Generate audio with a Diffusers audio pipeline.""" @@ -509,13 +1635,50 @@ class Generate(NodeBase): "display": "input", "type": "audio_diffusion_pipeline", "required": True, + "onSignal": [ + {"action": "value", "target": "audio_contract"}, + {"action": "exec", "data": "update_audio_contract"}, + ], + }, + "audio_contract": { + "label": "Audio Contract", + "type": "object", + "default": DEFAULT_AUDIO_CONTRACT, + "hidden": True, + }, + "task_type": { + "label": "Task", + "type": "string", + "options": ["text2music"], + "default": "text2music", + "fieldOptions": {"noValidation": True}, }, - "task_type": {"label": "Task", "type": "string", "options": ACE_TASK_TYPES, "default": "text2music"}, "prompt": {"label": "Prompt", "display": "textarea", "type": "text", "default": ""}, - "negative_prompt": {"label": "Negative Prompt", "display": "textarea", "type": "text", "default": ""}, + "negative_prompt": { + "label": "Negative Prompt", + "display": "textarea", + "type": "text", + "default": "", + "hidden": True, + }, "lyrics": {"label": "Lyrics", "display": "textarea", "type": "text", "default": ""}, - "audio_duration": {"label": "Duration", "type": "float", "default": 30.0, "min": 1, "max": 240, "step": 0.5}, - "extension_duration": {"label": "Extension", "type": "float", "default": 15.0, "min": 1, "max": 180, "step": 0.5}, + "audio_duration": { + "label": "Duration", + "type": "float", + "default": 30.0, + "min": 1, + "max": ACE_MAX_DURATION_SECONDS, + "step": 0.5, + }, + "extension_duration": { + "label": "Extension", + "type": "float", + "default": ACE_CONTINUATION_DEFAULT_EXTENSION_SECONDS, + "min": 1, + "max": ACE_CONTINUATION_MAX_EXTENSION_SECONDS, + "step": 0.5, + "hidden": True, + }, "vocal_language": {"label": "Language", "type": "string", "default": "en"}, "num_inference_steps": { "label": "Steps", @@ -554,17 +1717,65 @@ class Generate(NodeBase): "Strength and keep this multiplier at 1.0." ), }, - "shift": {"label": "Shift", "display": "slider", "type": "float", "default": 3.0, "min": 0, "max": 10, "step": 0.1}, + "shift": { + "label": "Shift", + "display": "slider", + "type": "float", + "default": 3.0, + "min": 0.1, + "max": 10, + "step": 0.1, + }, "seed": {"label": "Seed", "type": "int", "display": "random", "default": 0, "min": 0, "max": 4294967295}, "bpm": {"label": "BPM", "type": "int", "default": 0, "min": 0, "max": 400}, "keyscale": {"label": "Key", "type": "string", "default": ""}, "timesignature": {"label": "Time", "type": "string", "default": "4"}, - "source_audio": {"label": "Source Audio", "display": "input", "type": ["audio", "str"], "required": False}, - "reference_audio": {"label": "Reference Audio", "display": "input", "type": ["audio", "str"], "required": False}, - "repainting_start": {"label": "Repaint Start", "type": "float", "default": 0.0, "min": 0, "step": 0.01}, - "repainting_end": {"label": "Repaint End", "type": "float", "default": 0.0, "min": 0, "step": 0.01}, - "audio_cover_strength": {"label": "Cover Strength", "display": "slider", "type": "float", "default": 0.85, "min": 0, "max": 1, "step": 0.01}, - "return_continuation_tail": {"label": "Return Tail Only", "type": "bool", "default": True}, + "source_audio": { + "label": "Source Audio", + "display": "input", + "type": ["audio", "str"], + "required": False, + "hidden": True, + }, + "reference_audio": { + "label": "Reference Audio", + "display": "input", + "type": ["audio", "str"], + "required": False, + "hidden": True, + }, + "repainting_start": { + "label": "Repaint Start", + "type": "float", + "default": 0.0, + "min": 0, + "step": 0.01, + "hidden": True, + }, + "repainting_end": { + "label": "Repaint End", + "type": "float", + "default": 0.0, + "min": 0, + "step": 0.01, + "hidden": True, + }, + "audio_cover_strength": { + "label": "Cover Strength", + "display": "slider", + "type": "float", + "default": 0.85, + "min": 0, + "max": 1, + "step": 0.01, + "hidden": True, + }, + "return_continuation_tail": { + "label": "Return Tail Only", + "type": "bool", + "default": True, + "hidden": True, + }, "sample_rate": { "label": "Sample Rate", "type": "int", @@ -577,6 +1788,7 @@ class Generate(NodeBase): "default": 100, "min": 1, "max": 300, + "hidden": True, "description": "StableAudioPipeline-only denoising steps; ignored by ACE-Step.", }, "stable_audio_guidance": { @@ -585,6 +1797,7 @@ class Generate(NodeBase): "default": 7, "min": 0, "max": 20, + "hidden": True, "description": "StableAudioPipeline-only classifier-free guidance; ignored by ACE-Step.", }, "num_waveforms": { @@ -593,6 +1806,7 @@ class Generate(NodeBase): "default": 1, "min": 1, "max": 8, + "hidden": True, "description": "Number of StableAudioPipeline waveforms; ignored by ACE-Step.", }, "audio": {"label": "Audio", "display": "output", "type": "audio"}, @@ -601,98 +1815,134 @@ class Generate(NodeBase): "duration_seconds": {"label": "Duration", "display": "output", "type": "float"}, } - def execute(self, **kwargs): - import torch + def __call__(self, **kwargs): + if "task_type" in kwargs: + task_type = kwargs.get("task_type") + if not isinstance(task_type, str) or not task_type or task_type != task_type.strip(): + raise ValueError("Audio task must be an exact nonblank supported task string.") + for field in ("seed", "bpm", "num_inference_steps", "stable_audio_steps", "num_waveforms", "sample_rate"): + value = kwargs.get(field) + if ( + field not in kwargs + or value is None + or (field == "bpm" and isinstance(value, str) and not value.strip()) + ): + continue + if isinstance(value, bool): + raise ValueError(f"{field} must be an exact finite integer.") + try: + numeric_value = float(value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"{field} must be an exact finite integer.") from error + if not isfinite(numeric_value) or not numeric_value.is_integer(): + raise ValueError(f"{field} must be an exact finite integer.") + return super().__call__(**kwargs) + + def update_audio_contract(self, values, ref): + """Apply the loader's backend-owned contract to this generic audio form.""" + + values = values if isinstance(values, dict) else {} + signal_value = values.get("audio_contract") + if not isinstance(signal_value, dict): + raise ValueError("The connected audio pipeline did not publish a valid task contract.") + adapter = get_audio_pipeline_adapter(signal_value.get("pipelineClass")) + contract = adapter.contract_for_mode(str(signal_value.get("mode") or "")) + expected_signal = contract.signal_value( + adapter.pipeline_class, + str(signal_value.get("repository") or adapter.default_repo), + ) + if signal_value != expected_signal: + raise ValueError("The connected audio pipeline published a stale or mismatched task contract.") + + for field, params in expected_signal["fieldParams"].items(): + self.set_field_params(field, params) + def execute(self, **kwargs): pipeline = kwargs.get("pipeline") if pipeline is None: raise ValueError("Diffusers audio pipeline is required.") + invocation = _preflight_audio_invocation(pipeline, kwargs) + adapter = invocation.adapter + contract = invocation.contract + + if adapter.pipeline_class == "StableAudioPipeline": + return self._execute_stable_audio(pipeline, kwargs, invocation) - if getattr(pipeline, "_modiff_audio_pipeline_class", None) == "StableAudioPipeline": - return self._execute_stable_audio(pipeline, kwargs) + import torch - requested_sample_rate = int(kwargs.get("sample_rate") or 48000) - if requested_sample_rate not in {int(value) for value in AUDIO_SAMPLE_RATE_OPTIONS}: - supported = ", ".join(AUDIO_SAMPLE_RATE_OPTIONS.values()) - raise ValueError(f"Audio sample rate must be one of: {supported}.") + requested_sample_rate = int(invocation.controls["sample_rate"]) # The decoded tensor is produced at the VAE's native rate. Labeling it # with a different UI/export rate changes its duration and can truncate # valid samples, so generation stays at the pipeline's native rate and # the completed audio is resampled to the requested delivery rate. sample_rate = int(getattr(pipeline, "sample_rate", None) or 48000) - task_type = str(kwargs.get("task_type") or "text2music") - source_audio = kwargs.get("source_audio") - reference_audio = kwargs.get("reference_audio") - source_duration = 0.0 + task_type = contract.task_type + source = invocation.source + source_duration = source.duration_seconds if source is not None else 0.0 device = getattr(pipeline, "_execution_device", None) or getattr(pipeline, "device", None) or "cpu" try: - generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed", 0))) + generator = torch.Generator(device=device).manual_seed(int(invocation.controls["seed"])) except Exception: - generator = torch.Generator(device="cpu").manual_seed(int(kwargs.get("seed", 0))) + generator = torch.Generator(device="cpu").manual_seed(int(invocation.controls["seed"])) - call_task_type = "repaint" if task_type == "continuation" else task_type - audio_duration = float(kwargs.get("audio_duration") or 30.0) - if source_audio not in (None, ""): - source_array, source_sample_rate = audio_to_numpy(source_audio) - source_duration = float(source_array.shape[-1] / source_sample_rate) if source_sample_rate else 0.0 - if task_type == "continuation": - audio_duration = source_duration + float(kwargs.get("extension_duration") or 15.0) + call_task_type = contract.upstream_task_type + audio_duration = invocation.duration_seconds call_kwargs = { "prompt": str(kwargs.get("prompt") or ""), "lyrics": str(kwargs.get("lyrics") or ""), "audio_duration": audio_duration, "vocal_language": str(kwargs.get("vocal_language") or "en"), - "num_inference_steps": int(kwargs.get("num_inference_steps") or 8), - "guidance_scale": float(value_or_default(kwargs.get("guidance_scale"), 1.0)), - "shift": float(value_or_default(kwargs.get("shift"), 3.0)), + "num_inference_steps": int(invocation.controls["steps"]), + "guidance_scale": float(invocation.controls["guidance"]), + "shift": float(invocation.controls["shift"]), "generator": generator, "output_type": "pt", "return_dict": True, "task_type": call_task_type, } if supports_arg(pipeline, "attention_kwargs"): - call_kwargs["attention_kwargs"] = {"scale": float(kwargs.get("lora_scale", 1.0))} - for optional in ("bpm", "keyscale", "timesignature"): + call_kwargs["attention_kwargs"] = {"scale": float(invocation.controls["lora_scale"])} + for optional in ("keyscale", "timesignature"): value = none_if_blank(kwargs.get(optional)) - if optional == "bpm" and value is not None: - try: - value = int(float(value)) - except (TypeError, ValueError) as exc: - raise ValueError(f"BPM must be numeric; received {value!r}.") from exc - if value <= 0: - value = None if value is not None and supports_arg(pipeline, optional): call_kwargs[optional] = value + if invocation.controls["bpm"] is not None and supports_arg(pipeline, "bpm"): + call_kwargs["bpm"] = int(invocation.controls["bpm"]) - if source_audio not in (None, ""): - tensor = audio_to_tensor(source_audio, device=device, target_sample_rate=sample_rate) + if source is not None: + tensor = _audio_array_to_tensor( + source.samples, + source.sample_rate, + device=device, + target_sample_rate=sample_rate, + ) if task_type == "continuation": target_samples = int(round(audio_duration * sample_rate)) if tensor.shape[-1] < target_samples: tensor = torch.nn.functional.pad(tensor, (0, target_samples - tensor.shape[-1])) - # ACE-Step cover/variation treats the supplied track as a timbre - # and style reference. Sending it as src_audio instead asks the - # pipeline for semantic-code cover conditioning, which requires - # optional audio tokenizer/detokenizer modules that the official - # Diffusers artifact does not publish. Repaint and continuation, - # by contrast, need src_audio so the VAE can preserve the source - # waveform outside the edited interval. - if task_type == "cover" and supports_arg(pipeline, "reference_audio"): + # P0 exposes one variation input. Route that required source as the + # sole upstream timbre/style reference: src_audio cover conditioning + # needs optional tokenizer modules that the reviewed artifact does + # not publish. A separate reference input remains forbidden until a + # true two-input adapter has an explicit merge contract. + if task_type == "cover": + if not supports_arg(pipeline, "reference_audio"): + raise ValueError( + "The selected ACE-Step pipeline cannot accept the required variation source as " + "reference_audio." + ) call_kwargs["reference_audio"] = tensor - elif supports_arg(pipeline, "src_audio"): + else: + if not supports_arg(pipeline, "src_audio"): + raise ValueError( + f"The selected ACE-Step pipeline cannot accept required {contract.mode} source audio." + ) call_kwargs["src_audio"] = tensor - if reference_audio not in (None, "") and supports_arg(pipeline, "reference_audio"): - call_kwargs["reference_audio"] = audio_to_tensor( - reference_audio, - device=device, - target_sample_rate=sample_rate, - ) if task_type in ("repaint", "continuation"): - start = float(kwargs.get("repainting_start") or 0.0) - end = float(kwargs.get("repainting_end") or 0.0) + start, end = invocation.repaint_interval or (0.0, 0.0) if task_type == "continuation": start = source_duration end = audio_duration @@ -701,9 +1951,7 @@ def execute(self, **kwargs): if supports_arg(pipeline, "repainting_end"): call_kwargs["repainting_end"] = end if task_type == "cover" and supports_arg(pipeline, "audio_cover_strength"): - call_kwargs["audio_cover_strength"] = float( - value_or_default(kwargs.get("audio_cover_strength"), 0.85) - ) + call_kwargs["audio_cover_strength"] = float(invocation.controls["cover_strength"]) if supports_arg(pipeline, "callback_on_step_end"): call_kwargs["callback_on_step_end"] = self.pipe_callback @@ -720,7 +1968,7 @@ def execute(self, **kwargs): result = pipeline(**call_kwargs) audio = output_to_audio_object(result, sample_rate=sample_rate) if task_type == "continuation" and kwargs.get("return_continuation_tail", True): - audio = crop_tail(audio, source_duration, float(kwargs.get("extension_duration") or 15.0)) + audio = crop_tail(audio, source_duration, invocation.extension_seconds) else: # Diffusion audio decoders may emit a frame-aligned tail beyond the # requested duration. Keep the shared node contract exact without @@ -735,33 +1983,28 @@ def execute(self, **kwargs): "duration_seconds": float(audio.get("duration_seconds") or 0.0), } - def _execute_stable_audio(self, pipeline, kwargs): + def _execute_stable_audio(self, pipeline, kwargs, invocation: AudioInvocation): import torch - if kwargs.get("source_audio") not in (None, "") or kwargs.get("reference_audio") not in (None, ""): - raise ValueError("Stable Audio supports text-to-audio only and does not accept source audio.") - duration = float(kwargs.get("audio_duration") or 30) - if not 0 < duration <= 47: - raise ValueError("Stable Audio duration must be greater than 0 and at most 47 seconds.") device = getattr(pipeline, "_execution_device", None) or getattr(pipeline, "device", None) or "cpu" try: - generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed") or 0)) + generator = torch.Generator(device=device).manual_seed(int(invocation.controls["seed"])) except Exception: - generator = torch.Generator(device="cpu").manual_seed(int(kwargs.get("seed") or 0)) + generator = torch.Generator(device="cpu").manual_seed(int(invocation.controls["seed"])) def callback(step, timestep, latents): if not hasattr(pipeline, "_num_timesteps"): - pipeline._num_timesteps = int(kwargs.get("stable_audio_steps") or 100) + pipeline._num_timesteps = int(invocation.controls["steps"]) self.pipe_callback(pipeline, step, timestep, {"latents": latents}) result = pipeline( prompt=str(kwargs.get("prompt") or ""), negative_prompt=none_if_blank(kwargs.get("negative_prompt")), audio_start_in_s=0, - audio_end_in_s=duration, - num_inference_steps=int(kwargs.get("stable_audio_steps") or 100), - guidance_scale=float(value_or_default(kwargs.get("stable_audio_guidance"), 7)), - num_waveforms_per_prompt=int(kwargs.get("num_waveforms") or 1), + audio_end_in_s=invocation.duration_seconds, + num_inference_steps=int(invocation.controls["steps"]), + guidance_scale=float(invocation.controls["guidance"]), + num_waveforms_per_prompt=int(invocation.controls["waveforms"]), generator=generator, callback=callback, callback_steps=1, @@ -772,9 +2015,9 @@ def callback(step, timestep, latents): vae_config = getattr(vae, "config", None) configured_rate = vae_config.get("sampling_rate") if hasattr(vae_config, "get") else None sample_rate = int(getattr(vae, "sampling_rate", None) or configured_rate or 44100) - requested_sample_rate = int(kwargs.get("sample_rate") or 48000) + requested_sample_rate = int(invocation.controls["sample_rate"]) audio_variations = [ - resample_audio_object(crop_tail(audio, 0, duration), requested_sample_rate) + resample_audio_object(crop_tail(audio, 0, invocation.duration_seconds), requested_sample_rate) for audio in output_to_audio_objects(result, sample_rate) ] audio = audio_variations[0] diff --git a/modules/DiffusersImage/main.py b/modules/DiffusersImage/main.py index a0494b7..8708c94 100644 --- a/modules/DiffusersImage/main.py +++ b/modules/DiffusersImage/main.py @@ -1,9 +1,13 @@ -import inspect import hashlib +import inspect import logging +import math +import sys from dataclasses import dataclass +from pathlib import Path from typing import Any +import numpy as np from PIL import Image, ImageColor, ImageDraw, ImageFilter from modiff.NodeBase import NodeBase @@ -17,26 +21,88 @@ normalize_offload_mode, offload_mode_param, ) -from modiff.model_artifact_catalog import require_catalog_revision, resolve_model_revision -from utils.huggingface import local_files_only +from modiff.model_artifact_catalog import ( + IMMUTABLE_HUB_REVISION, + catalog_repository_pin, + catalog_revision, + require_catalog_revision, +) +from utils.huggingface import ( + cached_file_path, + local_files_only, + resolve_managed_hf_cache_file, + validate_hf_repo_id, +) from utils.torch_utils import DEFAULT_DEVICE, DEVICE_LIST, str_to_dtype logger = logging.getLogger("modiff") FLUX_SCHNELL_REPO = "black-forest-labs/FLUX.1-schnell" FLUX_DEV_REPO = "black-forest-labs/FLUX.1-dev" +FLUX_DEV_FP8_REPO = "black-forest-labs/FLUX.1-dev-FP8" +FLUX_KREA_REPO = "black-forest-labs/FLUX.1-Krea-dev" +FLUX_KONTEXT_REPO = "black-forest-labs/FLUX.1-Kontext-dev" +FLUX_KONTEXT_NVFP4_REPO = "black-forest-labs/FLUX.1-Kontext-dev-NVFP4" +FLUX_FILL_REPO = "black-forest-labs/FLUX.1-Fill-dev" +FLUX_DEPTH_REPO = "black-forest-labs/FLUX.1-Depth-dev" +FLUX_CANNY_REPO = "black-forest-labs/FLUX.1-Canny-dev" +FLUX_CANNY_REPAIR_REPO = "fuliucansheng/FLUX.1-Canny-dev-diffusers" +FLUX_REDUX_REPO = "black-forest-labs/FLUX.1-Redux-dev" +FLUX2_KLEIN_REPO = "black-forest-labs/FLUX.2-klein-4B" +Z_IMAGE_REPO = "Tongyi-MAI/Z-Image-Turbo" +SDXL_BASE_REPO = "stabilityai/stable-diffusion-xl-base-1.0" QWEN_IMAGE_2512_REPO = "Qwen/Qwen-Image-2512" QWEN_IMAGE_2512_PREQUANTIZED_REPO = "unsloth/Qwen-Image-2512-unsloth-bnb-4bit" +QWEN_IMAGE_EDIT_REPO = "Qwen/Qwen-Image-Edit" +QWEN_IMAGE_EDIT_PLUS_REPO = "Qwen/Qwen-Image-Edit-2511" +QWEN_IMAGE_EDIT_PREQUANTIZED_REPO = "ovedrive/qwen-image-edit-4bit" DEVICE_OPTIONS = list(DEVICE_LIST.keys()) +_IMAGE_MODE_ORDER = ( + "text_to_image", + "edit_image", + "multi_image_reference_edit", + "inpaint", + "outpaint", + "control_image", +) +_IMAGE_MODEL_SOURCES = frozenset({"hub", "local"}) +_MAX_IMAGE_INPUT_DIMENSION = 8192 +_MAX_IMAGE_INPUT_PIXELS = 16 * 1024 * 1024 @dataclass(frozen=True) class ImagePipelineAdapter: pipeline_class: str modes: frozenset[str] - default_repo: str = FLUX_SCHNELL_REPO + default_repo: str + compatible_repos: frozenset[str] = frozenset() + artifact_pipeline_classes: tuple[str, ...] = () + runtime_pipeline_classes: tuple[str, ...] = () guidance_parameter: str = "guidance_scale" multi_image_strategy: str = "list" + max_sequence_length: int = 512 + max_reference_images: int = 1 + max_reference_pixels: int = _MAX_IMAGE_INPUT_PIXELS + + @property + def managed_repos(self) -> frozenset[str]: + """Reviewed repositories that remain valid when this adapter is selected.""" + + return frozenset((self.default_repo, *self.compatible_repos)) + + @property + def model_filter_classes(self) -> tuple[str, ...]: + return self.artifact_pipeline_classes or (self.pipeline_class,) + + @property + def allowed_runtime_classes(self) -> tuple[str, ...]: + return self.runtime_pipeline_classes or (self.pipeline_class,) + + @property + def mode_options(self) -> tuple[str, ...]: + """Expose stable UI/default ordering while preserving the set-valued contract.""" + + return tuple(mode for mode in _IMAGE_MODE_ORDER if mode in self.modes) def apply_generation_parameters(self, pipeline: Any, values: dict[str, Any], target: dict[str, Any]) -> None: aliases = { @@ -66,42 +132,151 @@ def apply_generation_parameters(self, pipeline: Any, values: dict[str, Any], tar "QwenImagePipeline": ImagePipelineAdapter( "QwenImagePipeline", frozenset({"text_to_image"}), - default_repo=QWEN_IMAGE_2512_REPO, + QWEN_IMAGE_2512_REPO, + compatible_repos=frozenset({QWEN_IMAGE_2512_PREQUANTIZED_REPO}), + guidance_parameter="true_cfg_scale", + ), + "ZImagePipeline": ImagePipelineAdapter("ZImagePipeline", frozenset({"text_to_image"}), Z_IMAGE_REPO), + "ZImageImg2ImgPipeline": ImagePipelineAdapter( + "ZImageImg2ImgPipeline", + frozenset({"edit_image"}), + Z_IMAGE_REPO, + artifact_pipeline_classes=("ZImagePipeline", "ZImageImg2ImgPipeline"), + ), + "ZImageInpaintPipeline": ImagePipelineAdapter( + "ZImageInpaintPipeline", + frozenset({"inpaint", "outpaint"}), + Z_IMAGE_REPO, + artifact_pipeline_classes=("ZImagePipeline", "ZImageInpaintPipeline"), + ), + "StableDiffusionXLPipeline": ImagePipelineAdapter( + "StableDiffusionXLPipeline", + frozenset({"text_to_image"}), + SDXL_BASE_REPO, + ), + "StableDiffusionXLImg2ImgPipeline": ImagePipelineAdapter( + "StableDiffusionXLImg2ImgPipeline", + frozenset({"edit_image"}), + SDXL_BASE_REPO, + artifact_pipeline_classes=("StableDiffusionXLPipeline", "StableDiffusionXLImg2ImgPipeline"), + ), + "StableDiffusionXLInpaintPipeline": ImagePipelineAdapter( + "StableDiffusionXLInpaintPipeline", + frozenset({"inpaint", "outpaint"}), + SDXL_BASE_REPO, + artifact_pipeline_classes=("StableDiffusionXLPipeline", "StableDiffusionXLInpaintPipeline"), + ), + "FluxPipeline": ImagePipelineAdapter( + "FluxPipeline", + frozenset({"text_to_image"}), + FLUX_SCHNELL_REPO, + compatible_repos=frozenset({FLUX_DEV_REPO, FLUX_DEV_FP8_REPO, FLUX_KREA_REPO}), guidance_parameter="true_cfg_scale", ), - "ZImagePipeline": ImagePipelineAdapter("ZImagePipeline", frozenset({"text_to_image"})), - "FluxPipeline": ImagePipelineAdapter("FluxPipeline", frozenset({"text_to_image"})), "Flux2KleinPipeline": ImagePipelineAdapter( - "Flux2KleinPipeline", frozenset({"text_to_image", "edit_image", "multi_image_reference_edit"}) + "Flux2KleinPipeline", + frozenset({"text_to_image", "edit_image", "multi_image_reference_edit"}), + FLUX2_KLEIN_REPO, + max_reference_images=8, + ), + "Flux2KleinInpaintPipeline": ImagePipelineAdapter( + "Flux2KleinInpaintPipeline", + frozenset({"inpaint", "outpaint"}), + FLUX2_KLEIN_REPO, + artifact_pipeline_classes=("Flux2KleinPipeline", "Flux2KleinInpaintPipeline"), ), "FluxImg2ImgPipeline": ImagePipelineAdapter( - "FluxImg2ImgPipeline", frozenset({"edit_image", "multi_image_reference_edit"}) + "FluxImg2ImgPipeline", + frozenset({"edit_image"}), + FLUX_DEV_REPO, + compatible_repos=frozenset({FLUX_DEV_FP8_REPO}), + artifact_pipeline_classes=("FluxPipeline", "FluxImg2ImgPipeline"), + guidance_parameter="true_cfg_scale", + ), + "FluxInpaintPipeline": ImagePipelineAdapter( + "FluxInpaintPipeline", + frozenset({"inpaint"}), + FLUX_DEV_REPO, + compatible_repos=frozenset({FLUX_DEV_FP8_REPO}), + artifact_pipeline_classes=("FluxPipeline", "FluxInpaintPipeline"), + guidance_parameter="true_cfg_scale", + ), + "FluxFillPipeline": ImagePipelineAdapter("FluxFillPipeline", frozenset({"inpaint", "outpaint"}), FLUX_FILL_REPO), + "FluxControlPipeline": ImagePipelineAdapter( + "FluxControlPipeline", + frozenset({"control_image"}), + FLUX_DEPTH_REPO, + compatible_repos=frozenset({FLUX_CANNY_REPO, FLUX_CANNY_REPAIR_REPO}), ), - "FluxInpaintPipeline": ImagePipelineAdapter("FluxInpaintPipeline", frozenset({"inpaint"})), - "FluxFillPipeline": ImagePipelineAdapter("FluxFillPipeline", frozenset({"inpaint", "outpaint"})), - "FluxControlPipeline": ImagePipelineAdapter("FluxControlPipeline", frozenset({"control_image"})), - "FluxControlNetPipeline": ImagePipelineAdapter("FluxControlNetPipeline", frozenset({"control_image"})), "FluxKontextPipeline": ImagePipelineAdapter( "FluxKontextPipeline", frozenset({"edit_image", "multi_image_reference_edit"}), + FLUX_KONTEXT_REPO, + compatible_repos=frozenset({FLUX_KONTEXT_NVFP4_REPO}), + guidance_parameter="true_cfg_scale", multi_image_strategy="stitch_horizontal", + max_reference_images=8, + ), + "FluxKontextInpaintPipeline": ImagePipelineAdapter( + "FluxKontextInpaintPipeline", + frozenset({"inpaint", "outpaint"}), + FLUX_KONTEXT_REPO, + compatible_repos=frozenset({FLUX_KONTEXT_NVFP4_REPO}), + artifact_pipeline_classes=("FluxKontextPipeline", "FluxKontextInpaintPipeline"), + guidance_parameter="true_cfg_scale", ), # Virtual adapter class: FLUX Redux is a prior that supplies embeddings to # a base FLUX pipeline, not a standalone img2img checkpoint. "FluxReduxPipeline": ImagePipelineAdapter( "FluxReduxPipeline", frozenset({"edit_image", "multi_image_reference_edit"}), + FLUX_REDUX_REPO, + artifact_pipeline_classes=("FluxPriorReduxPipeline",), + runtime_pipeline_classes=("FluxReduxPipelineBundle",), # Current Diffusers performs the documented per-reference scaling and # weighted sum inside FluxPriorReduxPipeline. Keep the references as a # list and delegate the conditioning math to the upstream pipeline. multi_image_strategy="upstream_weighted_sum", + max_reference_images=8, ), "QwenImageEditInpaintPipeline": ImagePipelineAdapter( "QwenImageEditInpaintPipeline", frozenset({"inpaint", "outpaint"}), - default_repo="Qwen/Qwen-Image-Edit", + QWEN_IMAGE_EDIT_REPO, + compatible_repos=frozenset({QWEN_IMAGE_EDIT_PREQUANTIZED_REPO}), + artifact_pipeline_classes=("QwenImageEditPipeline", "QwenImageEditInpaintPipeline"), + guidance_parameter="true_cfg_scale", + ), + "QwenImageImg2ImgPipeline": ImagePipelineAdapter( + "QwenImageImg2ImgPipeline", + frozenset({"edit_image"}), + QWEN_IMAGE_2512_REPO, + compatible_repos=frozenset({QWEN_IMAGE_2512_PREQUANTIZED_REPO}), + artifact_pipeline_classes=("QwenImagePipeline", "QwenImageImg2ImgPipeline"), + guidance_parameter="true_cfg_scale", + ), + "QwenImageInpaintPipeline": ImagePipelineAdapter( + "QwenImageInpaintPipeline", + frozenset({"inpaint", "outpaint"}), + QWEN_IMAGE_2512_REPO, + compatible_repos=frozenset({QWEN_IMAGE_2512_PREQUANTIZED_REPO}), + artifact_pipeline_classes=("QwenImagePipeline", "QwenImageInpaintPipeline"), + guidance_parameter="true_cfg_scale", + ), + "QwenImageEditPipeline": ImagePipelineAdapter( + "QwenImageEditPipeline", + frozenset({"edit_image"}), + QWEN_IMAGE_EDIT_REPO, + compatible_repos=frozenset({QWEN_IMAGE_EDIT_PREQUANTIZED_REPO}), guidance_parameter="true_cfg_scale", ), + "QwenImageEditPlusPipeline": ImagePipelineAdapter( + "QwenImageEditPlusPipeline", + frozenset({"edit_image", "multi_image_reference_edit"}), + QWEN_IMAGE_EDIT_PLUS_REPO, + guidance_parameter="true_cfg_scale", + max_reference_images=8, + ), } IMAGE_PIPELINE_CLASSES = list(IMAGE_PIPELINE_ADAPTERS) IMAGE_PIPELINE_MODES = {name: set(adapter.modes) for name, adapter in IMAGE_PIPELINE_ADAPTERS.items()} @@ -122,6 +297,182 @@ def apply_generation_parameters(self, pipeline: Any, values: dict[str, Any], tar ] QUANT_COMPONENTS = ["transformer", "transformer_2", "text_encoder", "text_encoder_2", "vae"] +IMAGE_ACTION_MODES = { + "Generate": ("text_to_image",), + "Edit": ("edit_image", "multi_image_reference_edit"), + "Inpaint": ("inpaint", "outpaint"), + "ControlGenerate": ("control_image",), +} + +_IMAGE_CONTRACT_VISIBILITY_FIELDS = ( + "negative_prompt", + "width", + "height", + "guidance_scale", + "strength", + "padding_mask_crop", + "max_sequence_length", + "reference_strength", +) + + +@dataclass(frozen=True) +class ImageModeFieldContract: + visible_fields: tuple[str, ...] + + def __post_init__(self) -> None: + if len(set(self.visible_fields)) != len(self.visible_fields) or any( + field not in _IMAGE_CONTRACT_VISIBILITY_FIELDS for field in self.visible_fields + ): + raise ValueError("Image mode contracts must declare unique reviewed visibility fields.") + + def field_param_overlay(self) -> dict[str, dict[str, bool]]: + return {field: {"hidden": field not in self.visible_fields} for field in _IMAGE_CONTRACT_VISIBILITY_FIELDS} + + +def _image_field_contract(*visible_fields: str) -> ImageModeFieldContract: + return ImageModeFieldContract(visible_fields=visible_fields) + + +_NEGATIVE_SIZE_GUIDANCE_SEQUENCE = ( + "negative_prompt", + "width", + "height", + "guidance_scale", + "max_sequence_length", +) +_SIZE_GUIDANCE_SEQUENCE = ("width", "height", "guidance_scale", "max_sequence_length") +_NEGATIVE_SIZE_GUIDANCE_STRENGTH_SEQUENCE = ( + "negative_prompt", + "width", + "height", + "guidance_scale", + "strength", + "max_sequence_length", +) +_SIZE_GUIDANCE_STRENGTH_SEQUENCE = ( + "width", + "height", + "guidance_scale", + "strength", + "max_sequence_length", +) +_NEGATIVE_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE = ( + "negative_prompt", + "width", + "height", + "guidance_scale", + "strength", + "padding_mask_crop", + "max_sequence_length", +) +_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE = ( + "width", + "height", + "guidance_scale", + "strength", + "padding_mask_crop", + "max_sequence_length", +) + +IMAGE_MODE_FIELD_CONTRACTS = { + "QwenImagePipeline": { + "text_to_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE), + }, + "ZImagePipeline": { + "text_to_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE), + }, + "ZImageImg2ImgPipeline": { + "edit_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_SEQUENCE), + }, + "ZImageInpaintPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_SEQUENCE) for mode in ("inpaint", "outpaint") + }, + "StableDiffusionXLPipeline": { + "text_to_image": _image_field_contract("negative_prompt", "width", "height", "guidance_scale"), + }, + "StableDiffusionXLImg2ImgPipeline": { + "edit_image": _image_field_contract("negative_prompt", "guidance_scale", "strength"), + }, + "StableDiffusionXLInpaintPipeline": { + mode: _image_field_contract( + "negative_prompt", "width", "height", "guidance_scale", "strength", "padding_mask_crop" + ) + for mode in ("inpaint", "outpaint") + }, + "FluxPipeline": { + "text_to_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE), + }, + "Flux2KleinPipeline": { + mode: _image_field_contract(*_SIZE_GUIDANCE_SEQUENCE) + for mode in ("text_to_image", "edit_image", "multi_image_reference_edit") + }, + "Flux2KleinInpaintPipeline": { + mode: _image_field_contract(*_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE) for mode in ("inpaint", "outpaint") + }, + "FluxImg2ImgPipeline": { + "edit_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_SEQUENCE), + }, + "FluxInpaintPipeline": { + "inpaint": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE), + }, + "FluxFillPipeline": { + mode: _image_field_contract(*_SIZE_GUIDANCE_STRENGTH_SEQUENCE) for mode in ("inpaint", "outpaint") + }, + "FluxControlPipeline": { + "control_image": _image_field_contract(*_SIZE_GUIDANCE_SEQUENCE), + }, + "FluxKontextPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE) + for mode in ("edit_image", "multi_image_reference_edit") + }, + "FluxKontextInpaintPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE) + for mode in ("inpaint", "outpaint") + }, + "FluxReduxPipeline": { + "edit_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE), + "multi_image_reference_edit": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE, "reference_strength"), + }, + "QwenImageEditInpaintPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE) + for mode in ("inpaint", "outpaint") + }, + "QwenImageImg2ImgPipeline": { + "edit_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_SEQUENCE), + }, + "QwenImageInpaintPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_STRENGTH_CROP_SEQUENCE) + for mode in ("inpaint", "outpaint") + }, + "QwenImageEditPipeline": { + "edit_image": _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE), + }, + "QwenImageEditPlusPipeline": { + mode: _image_field_contract(*_NEGATIVE_SIZE_GUIDANCE_SEQUENCE) + for mode in ("edit_image", "multi_image_reference_edit") + }, +} + + +def get_image_mode_field_contract(adapter: ImagePipelineAdapter, mode: str) -> ImageModeFieldContract: + contracts = IMAGE_MODE_FIELD_CONTRACTS.get(adapter.pipeline_class) + if contracts is None or tuple(contracts) != adapter.mode_options: + raise RuntimeError(f"Image adapter {adapter.pipeline_class} has an incomplete reviewed field contract.") + contract = contracts.get(mode) + if contract is None: + raise ValueError(f"{adapter.pipeline_class} does not support image mode {mode}.") + return contract + + +_REMOVED_IMAGE_PIPELINE_ERRORS = { + "FluxControlNetPipeline": ( + "FluxControlNetPipeline requires a separately loaded FluxControlNetModel, but the generic Diffusers image " + "loader does not yet expose that component-assembly contract. Use FluxControlPipeline for the self-contained " + "FLUX Depth/Canny checkpoints until generic ControlNet assembly is available." + ) +} + def repo_value(value: Any) -> str: if isinstance(value, dict): @@ -129,14 +480,564 @@ def repo_value(value: Any) -> str: return str(value or "") -def none_if_blank(value: Any): +def get_image_pipeline_adapter(name: Any) -> ImagePipelineAdapter: + if not isinstance(name, str) or not name or name != name.strip(): + raise ValueError("A registered Diffusers image pipeline class is required.") + removed_reason = _REMOVED_IMAGE_PIPELINE_ERRORS.get(name) + if removed_reason: + raise ValueError(removed_reason) + adapter = IMAGE_PIPELINE_ADAPTERS.get(name) + if adapter is None: + supported = ", ".join(IMAGE_PIPELINE_CLASSES) + raise ValueError(f"Unsupported Diffusers image pipeline class {name!r}. Supported classes: {supported}.") + return adapter + + +def _loader_image_pipeline_adapter(values: Any) -> ImagePipelineAdapter: + if not isinstance(values, dict): + raise ValueError("Diffusers image loader values must be an object.") + return get_image_pipeline_adapter(values.get("pipeline_class")) + + +def _loader_image_mode(values: Any, adapter: ImagePipelineAdapter) -> str: + mode = values.get("mode") if isinstance(values, dict) else None + if not isinstance(mode, str) or not mode or mode != mode.strip(): + raise ValueError("A registered Diffusers image mode is required.") + if mode not in adapter.modes: + supported = ", ".join(adapter.mode_options) + raise ValueError(f"{adapter.pipeline_class} does not support {mode}. Supported modes: {supported}.") + return mode + + +def _canonical_image_model_source(source: Any, *, label: str = "Diffusers image model") -> str: + if not isinstance(source, str) or not source or source != source.strip(): + raise ValueError(f"{label} source must be exactly hub or local.") + normalized = source.casefold() + if normalized not in _IMAGE_MODEL_SOURCES: + raise ValueError(f"{label} source must be exactly hub or local.") + return normalized + + +def _managed_image_repositories() -> dict[str, str]: + return { + repository.casefold(): repository + for registered_adapter in IMAGE_PIPELINE_ADAPTERS.values() + for repository in registered_adapter.managed_repos + } + + +def _validated_image_hub_repository(value: str, *, label: str) -> str: + if value.count("/") != 1: + raise ValueError(f"{label} must use an exact Hugging Face namespace/repository ID.") + try: + validate_hf_repo_id(value) + except ValueError as error: + raise ValueError(f"{label} must use an exact Hugging Face namespace/repository ID.") from error + try: + resolves_locally = Path(value).expanduser().exists() + except (OSError, RuntimeError) as error: + raise ValueError(f"{label} could not be validated as a Hugging Face repository ID.") from error + if resolves_locally: + raise ValueError( + f"{label} resolves to an existing local filesystem target. Select source=local for local models." + ) + return value + + +def resolve_image_model_selection(adapter: ImagePipelineAdapter, value: Any): + """Return one canonical Hub/local selection for the chosen adapter.""" + + if value is None or (isinstance(value, str) and not value.strip()): + return {"source": "hub", "value": adapter.default_repo} + if isinstance(value, dict): + source = _canonical_image_model_source(value.get("source")) + raw_selected = value.get("value") + if not isinstance(raw_selected, str): + raise ValueError("Diffusers image model value must be a repository ID or local path string.") + selected = raw_selected.strip() + if not selected: + if source == "hub": + return {"source": "hub", "value": adapter.default_repo} + raise ValueError("A local Diffusers image model path is required.") + elif isinstance(value, str): + source = "hub" + selected = value.strip() + else: + raise ValueError("Diffusers image model selection must be a repository ID or a hub/local selection object.") + + if source == "local": + return {"source": "local", "value": selected} + + selected = _validated_image_hub_repository(selected, label="Diffusers image model repository") + managed_repos = _managed_image_repositories() + selected_key = selected.casefold() + compatible_repos = {repo.casefold() for repo in adapter.managed_repos} + if selected_key in managed_repos and selected_key not in compatible_repos: + return {"source": "hub", "value": adapter.default_repo} + return {"source": "hub", "value": managed_repos.get(selected_key, selected)} + + +def _normalize_image_revision(value: Any) -> str: if value is None: - return None - if isinstance(value, str) and value.strip() == "": - return None + return "" + if not isinstance(value, str) or value != value.strip(): + raise ValueError("Diffusers image revision must be an exact trimmed string.") return value +def resolve_image_pipeline_revision(model_selection: Any, revision: Any) -> str: + """Resolve one immutable Hub revision or reject unsupported local execution.""" + + if not isinstance(model_selection, dict): + raise ValueError("Diffusers image model selection must be normalized before revision resolution.") + source = _canonical_image_model_source(model_selection.get("source")) + model_id = model_selection.get("value") + if not isinstance(model_id, str) or not model_id or model_id != model_id.strip(): + raise ValueError("Diffusers image model value must be a nonblank canonical string.") + requested = _normalize_image_revision(revision) + if source == "local": + raise ValueError( + "Local standard Diffusers pipeline loading is contract-only until MoDiff has a reviewed local " + "pipeline-directory index. Install or select a pinned Hub pipeline through Model Manager." + ) + + pin = catalog_repository_pin(model_id) + if pin is not None: + expected = catalog_revision(model_id) + if requested and requested != expected: + raise ValueError( + f"Cataloged Diffusers image repository {model_id!r} must use its reviewed commit {expected}; " + f"received {requested!r}." + ) + return str(expected) + if not requested: + raise ValueError( + f"Custom Diffusers image repository {model_id!r} requires an explicit lowercase 40-character commit." + ) + if requested != requested.lower() or not IMMUTABLE_HUB_REVISION.fullmatch(requested): + raise ValueError("A custom Diffusers image repository revision must be a lowercase 40-character commit.") + return requested + + +def image_model_field_options(adapter: ImagePipelineAdapter) -> dict[str, Any]: + classes = list(adapter.model_filter_classes) + return { + "noValidation": True, + "sources": ["hub", "local"], + "filter": { + "hub": {"className": classes}, + "local": {"className": classes}, + }, + } + + +def image_pipeline_contract(adapter: ImagePipelineAdapter, mode: str) -> dict[str, Any]: + field_contract = get_image_mode_field_contract(adapter, mode) + actions = { + action: [candidate for candidate in adapter.mode_options if candidate in accepted_modes] + for action, accepted_modes in IMAGE_ACTION_MODES.items() + } + return { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "image", + "pipelineClass": adapter.pipeline_class, + "mode": mode, + "modes": list(adapter.mode_options), + "actions": {action: modes for action, modes in actions.items() if modes}, + "fieldParams": field_contract.field_param_overlay(), + } + + +DEFAULT_IMAGE_PIPELINE_CONTRACT = image_pipeline_contract( + IMAGE_PIPELINE_ADAPTERS["FluxPipeline"], + "text_to_image", +) + + +def _tag_image_pipeline( + pipeline: Any, + adapter: ImagePipelineAdapter, + mode: str, + repo: str, + source: str, + revision: str | None, +) -> None: + setattr(pipeline, "_modiff_image_adapter", adapter) + setattr(pipeline, "_modiff_image_pipeline_class", adapter.pipeline_class) + setattr(pipeline, "_modiff_image_mode", mode) + setattr(pipeline, "_modiff_image_repo", repo) + setattr(pipeline, "_modiff_image_source", source) + setattr(pipeline, "_modiff_image_revision", revision) + + +def _image_pipeline_adapter(pipeline: Any) -> ImagePipelineAdapter: + tag_names = ( + "_modiff_image_pipeline_class", + "_modiff_image_mode", + "_modiff_image_repo", + "_modiff_image_source", + "_modiff_image_revision", + ) + has_any_tag = any(hasattr(pipeline, name) for name in (*tag_names, "_modiff_image_adapter")) + has_all_tags = all(hasattr(pipeline, name) for name in tag_names) + runtime_name = type(pipeline).__name__ + adapter_hint = getattr(pipeline, "_modiff_image_adapter", None) + if has_any_tag: + if not has_all_tags: + missing = ", ".join( + name.removeprefix("_modiff_image_") for name in tag_names if not hasattr(pipeline, name) + ) + raise ValueError( + f"Diffusers image pipeline identity is incomplete; missing canonical tags: {missing}. " + "Reconnect it through Load Diffusers Image Pipeline." + ) + adapter = get_image_pipeline_adapter(getattr(pipeline, "_modiff_image_pipeline_class")) + hinted_name = getattr(adapter_hint, "pipeline_class", None) + if adapter_hint is not None and hinted_name != adapter.pipeline_class: + raise ValueError( + "Diffusers image pipeline identity is inconsistent: adapter hint and pipeline-class tag disagree." + ) + if runtime_name not in adapter.allowed_runtime_classes: + allowed = ", ".join(adapter.allowed_runtime_classes) + raise ValueError( + f"Diffusers image pipeline identity is inconsistent: runtime class {runtime_name!r} is tagged as " + f"{adapter.pipeline_class}; allowed runtime classes: {allowed}." + ) + + mode = getattr(pipeline, "_modiff_image_mode") + if not isinstance(mode, str) or not mode or mode != mode.strip() or mode not in adapter.modes: + supported = ", ".join(adapter.mode_options) + raise ValueError( + f"{adapter.pipeline_class} carries invalid image mode {mode!r}. Supported modes: {supported}." + ) + source = getattr(pipeline, "_modiff_image_source") + if source not in _IMAGE_MODEL_SOURCES: + raise ValueError("Diffusers image pipeline source tag must be canonical hub or local.") + repository = getattr(pipeline, "_modiff_image_repo") + if not isinstance(repository, str) or not repository or repository != repository.strip(): + raise ValueError("Diffusers image pipeline repository tag must be a nonblank canonical string.") + revision = getattr(pipeline, "_modiff_image_revision") + if source == "hub": + if ( + not isinstance(revision, str) + or revision != revision.lower() + or not IMMUTABLE_HUB_REVISION.fullmatch(revision) + ): + raise ValueError("Diffusers image Hub pipeline revision tag must be a lowercase 40-character commit.") + pin = catalog_repository_pin(repository) + if pin is not None and revision != catalog_revision(repository): + raise ValueError( + f"Diffusers image pipeline revision tag does not match the reviewed pin for {repository!r}." + ) + managed = _managed_image_repositories() + canonical_repository = managed.get(repository.casefold()) + if canonical_repository is not None: + if repository != canonical_repository: + raise ValueError("Diffusers image pipeline repository tag is not canonically spelled.") + if repository not in adapter.managed_repos: + raise ValueError( + f"Diffusers image pipeline repository {repository!r} is not compatible with " + f"{adapter.pipeline_class}." + ) + elif revision not in (None, ""): + raise ValueError("A local Diffusers image pipeline must not carry a Hub revision tag.") + return adapter + + candidates = [ + adapter for adapter in IMAGE_PIPELINE_ADAPTERS.values() if runtime_name in adapter.allowed_runtime_classes + ] + if len(candidates) == 1: + return candidates[0] + if not candidates: + raise ValueError( + f"Cannot recover an exact Diffusers image adapter from untagged runtime class {runtime_name!r}. " + "Reconnect the pipeline through Load Diffusers Image Pipeline." + ) + candidate_names = ", ".join(sorted(adapter.pipeline_class for adapter in candidates)) + raise ValueError( + f"Cannot recover one exact Diffusers image adapter from untagged runtime class {runtime_name!r}: " + f"{candidate_names}. Reconnect it through Load Diffusers Image Pipeline." + ) + + +def validate_image_action(pipeline: Any, action: str) -> ImagePipelineAdapter: + accepted_modes = IMAGE_ACTION_MODES[action] + adapter = _image_pipeline_adapter(pipeline) + raw_mode = getattr(pipeline, "_modiff_image_mode", None) + mode = raw_mode if isinstance(raw_mode, str) else "" + + if not mode: + # Pipelines resident before mode tagging can be recovered only when all + # adapter modes map to this one task node. Flux2 spans Generate and Edit, + # so allowing either without its exact loader mode would be ambiguous. + if set(adapter.modes).issubset(accepted_modes): + return adapter + supported_actions = [ + candidate + for candidate, candidate_modes in IMAGE_ACTION_MODES.items() + if set(adapter.modes).issubset(candidate_modes) + ] + suffix = f" Safe legacy action: {supported_actions[0]}." if len(supported_actions) == 1 else "" + raise ValueError( + f"{adapter.pipeline_class} is missing its exact loaded image mode, so {action} cannot run safely. " + f"Reconnect it through Load Diffusers Image Pipeline.{suffix}" + ) + if mode not in adapter.modes: + supported = ", ".join(adapter.mode_options) + raise ValueError( + f"{adapter.pipeline_class} carries invalid image mode {mode!r}. Supported modes: {supported}." + ) + if mode not in accepted_modes: + required = ", ".join(accepted_modes) + raise ValueError( + f"{adapter.pipeline_class} was loaded for image mode {mode!r}; {action} requires one of: {required}." + ) + return adapter + + +def _bounded_image_int( + value: Any, + *, + field: str, + default: int, + minimum: int, + maximum: int, + step: int | None = None, +) -> int: + raw = default if value is None else value + if isinstance(raw, bool): + raise ValueError(f"Diffusers image {field} must be an integer.") + if isinstance(raw, int): + parsed = raw + else: + try: + numeric = float(raw) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"Diffusers image {field} must be an integer.") from error + if not math.isfinite(numeric) or not numeric.is_integer(): + raise ValueError(f"Diffusers image {field} must be a finite integer.") + parsed = int(numeric) + if parsed < minimum or parsed > maximum: + raise ValueError(f"Diffusers image {field} must be between {minimum} and {maximum}; received {parsed}.") + if step is not None and (parsed - minimum) % step: + raise ValueError(f"Diffusers image {field} must use increments of {step} from {minimum}; received {parsed}.") + return parsed + + +def _bounded_image_float( + value: Any, + *, + field: str, + default: float, + minimum: float, + maximum: float, +) -> float: + raw = default if value is None else value + if isinstance(raw, bool): + raise ValueError(f"Diffusers image {field} must be a number.") + try: + parsed = float(raw) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"Diffusers image {field} must be a number.") from error + if not math.isfinite(parsed) or parsed < minimum or parsed > maximum: + raise ValueError( + f"Diffusers image {field} must be finite and between {minimum} and {maximum}; received {raw!r}." + ) + return parsed + + +def _normalized_image_prompt(value: Any, *, field: str) -> str | list[str]: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (list, tuple)): + prompts = list(value) + if not prompts or len(prompts) > 64 or any(not isinstance(item, str) for item in prompts): + raise ValueError(f"Diffusers image {field} must be a string or a list of 1 to 64 strings.") + return prompts + raise ValueError(f"Diffusers image {field} must be a string or a list of 1 to 64 strings.") + + +def _image_media_extent(value: Any, *, field: str, require_pil: bool) -> tuple[int, int]: + if isinstance(value, Image.Image): + width, height = value.size + sample_count = 1 + else: + if require_pil: + raise ValueError(f"Diffusers image {field} must be a PIL image loaded by an Image node.") + torch_module = sys.modules.get("torch") + tensor_type = getattr(torch_module, "Tensor", ()) if torch_module is not None else () + if not isinstance(value, np.ndarray) and not (tensor_type and isinstance(value, tensor_type)): + raise ValueError(f"Diffusers image {field} must be a PIL image, NumPy array, or Torch tensor.") + shape_value = getattr(value, "shape", None) + try: + shape = tuple(int(dimension) for dimension in shape_value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"Diffusers image {field} must be a PIL image, NumPy array, or Torch tensor.") from error + if not shape or any(dimension <= 0 for dimension in shape): + raise ValueError(f"Diffusers image {field} dimensions must be positive and nonempty.") + sample_count = shape[0] if len(shape) == 4 else 1 + image_shape = shape[1:] if len(shape) == 4 else shape + if len(image_shape) == 2: + height, width = image_shape + elif len(image_shape) == 3 and image_shape[0] in {1, 3, 4}: + _channels, height, width = image_shape + elif len(image_shape) == 3 and image_shape[-1] in {1, 3, 4}: + height, width, _channels = image_shape + else: + raise ValueError(f"Diffusers image {field} must have HW, CHW, HWC, NCHW, or NHWC image dimensions.") + if width <= 0 or height <= 0: + raise ValueError(f"Diffusers image {field} dimensions must be positive and nonempty.") + if width > _MAX_IMAGE_INPUT_DIMENSION or height > _MAX_IMAGE_INPUT_DIMENSION: + raise ValueError( + f"Diffusers image {field} dimensions cannot exceed {_MAX_IMAGE_INPUT_DIMENSION} pixels per edge." + ) + return sample_count, sample_count * width * height + + +def _validate_image_media( + value: Any, + *, + field: str, + max_items: int, + max_pixels: int, + require_pil: bool = False, + require_single_value: bool = False, +) -> None: + if value is None: + raise ValueError(f"Diffusers image {field} is required.") + if isinstance(value, (list, tuple)): + if require_single_value: + raise ValueError(f"Diffusers image {field} requires one PIL image, not a list.") + items = list(value) + if not items: + raise ValueError(f"Diffusers image {field} cannot be an empty image list.") + else: + items = [value] + + total_items = 0 + total_pixels = 0 + for index, item in enumerate(items): + sample_count, pixels = _image_media_extent( + item, + field=f"{field}[{index}]" if len(items) > 1 else field, + require_pil=require_pil, + ) + total_items += sample_count + total_pixels += pixels + if total_items > max_items: + raise ValueError(f"Diffusers image {field} accepts at most {max_items} image(s); received {total_items}.") + if total_pixels > max_pixels: + raise ValueError(f"Diffusers image {field} exceeds the {max_pixels}-pixel cumulative input limit.") + + +def preflight_image_action( + pipeline: Any, + action: str, + kwargs: dict[str, Any], +) -> tuple[ImagePipelineAdapter, dict[str, Any]]: + """Validate the generic image contract before importing Torch or calling Diffusers.""" + + if pipeline is None: + raise ValueError("Diffusers image pipeline is required.") + adapter = validate_image_action(pipeline, action) + values = dict(kwargs) + values["pipeline"] = pipeline + values["prompt"] = _normalized_image_prompt(values.get("prompt"), field="prompt") + values["negative_prompt"] = _normalized_image_prompt( + values.get("negative_prompt"), + field="negative_prompt", + ) + values["width"] = _bounded_image_int( + values.get("width"), field="width", default=1024, minimum=16, maximum=2048, step=16 + ) + values["height"] = _bounded_image_int( + values.get("height"), field="height", default=1024, minimum=16, maximum=2048, step=16 + ) + values["seed"] = _bounded_image_int(values.get("seed"), field="seed", default=0, minimum=0, maximum=4294967295) + values["num_inference_steps"] = _bounded_image_int( + values.get("num_inference_steps"), + field="num_inference_steps", + default=4, + minimum=1, + maximum=100, + ) + values["guidance_scale"] = _bounded_image_float( + values.get("guidance_scale"), field="guidance_scale", default=0.0, minimum=0.0, maximum=20.0 + ) + values["strength"] = _bounded_image_float( + values.get("strength"), field="strength", default=0.8, minimum=0.0, maximum=1.0 + ) + values["padding_mask_crop"] = _bounded_image_int( + values.get("padding_mask_crop"), + field="padding_mask_crop", + default=0, + minimum=0, + maximum=512, + step=8, + ) + values["max_sequence_length"] = _bounded_image_int( + values.get("max_sequence_length"), + field="max_sequence_length", + default=256, + minimum=1, + maximum=adapter.max_sequence_length, + ) + values["reference_strength"] = _bounded_image_float( + values.get("reference_strength"), + field="reference_strength", + default=1.0, + minimum=0.0, + maximum=1.0, + ) + + output_type = ( + "pil" if "output_type" not in values or values.get("output_type") is None else values.get("output_type") + ) + allowed_output_types = {"pil"} if action == "Inpaint" else {"pil", "np", "pt"} + if not isinstance(output_type, str) or output_type not in allowed_output_types: + allowed = ", ".join(sorted(allowed_output_types)) + raise ValueError(f"Diffusers image {action} output_type must be exactly one of: {allowed}.") + values["output_type"] = output_type + + if action == "Edit": + mode = getattr(pipeline, "_modiff_image_mode", None) + max_references = adapter.max_reference_images if mode in (None, "multi_image_reference_edit") else 1 + _validate_image_media( + values.get("image"), + field="Edit image", + max_items=max_references, + max_pixels=adapter.max_reference_pixels, + ) + elif action == "Inpaint": + _validate_image_media( + values.get("image"), + field="Inpaint source", + max_items=1, + max_pixels=adapter.max_reference_pixels, + require_pil=True, + require_single_value=True, + ) + _validate_image_media( + values.get("mask_image"), + field="Inpaint mask", + max_items=1, + max_pixels=adapter.max_reference_pixels, + require_pil=True, + require_single_value=True, + ) + elif action == "ControlGenerate": + _validate_image_media( + values.get("control_image"), + field="Control image", + max_items=1, + max_pixels=adapter.max_reference_pixels, + ) + return adapter, values + + def output_image_dimensions(images: Any, output_type: str = "pil") -> tuple[int | None, int | None]: """Return width/height for Diffusers PIL, NumPy, or Torch outputs.""" @@ -568,7 +1469,6 @@ class FluxReduxPipelineBundle: def __init__(self, prior: Any, base: Any): self.prior = prior self.base = base - self._modiff_image_adapter = IMAGE_PIPELINE_ADAPTERS["FluxReduxPipeline"] @property def device(self): @@ -643,13 +1543,30 @@ class LoadPipeline(NodeBase): # from_pretrained(), so changing it must not force another 13-minute load. cache_ignored_params = frozenset({"mode"}) params = { - "pipeline": {"label": "Pipeline", "display": "output", "type": "image_diffusion_pipeline"}, + "pipeline": { + "label": "Pipeline", + "display": "output", + "type": "image_diffusion_pipeline", + "signal": { + "direction": "output", + "origin": "pipeline_class", + "value": DEFAULT_IMAGE_PIPELINE_CONTRACT, + }, + }, "model_id": { "label": "Model", "display": "modelselect", "type": "string", "value": {"source": "hub", "value": FLUX_SCHNELL_REPO}, - "fieldOptions": {"noValidation": True, "sources": ["hub", "local"]}, + "fieldOptions": { + "noValidation": True, + "sources": ["hub", "local"], + "filter": { + "hub": {"className": ["FluxPipeline"]}, + "local": {"className": ["FluxPipeline"]}, + }, + }, + "onChange": "update_pipeline_contract", }, "pipeline_class": { "label": "Pipeline Class", @@ -657,12 +1574,15 @@ class LoadPipeline(NodeBase): "options": IMAGE_PIPELINE_CLASSES, "default": "FluxPipeline", "fieldOptions": {"noValidation": True}, + "onChange": "update_pipeline_contract", }, "mode": { "label": "Mode", "type": "string", "options": IMAGE_PIPELINE_MODE_OPTIONS, "default": "text_to_image", + "fieldOptions": {"noValidation": True}, + "onChange": "update_pipeline_contract", }, "revision": {"label": "Revision", "type": "string", "default": ""}, "dtype": { @@ -714,18 +1634,105 @@ class LoadPipeline(NodeBase): } @staticmethod - def _validate_mode(pipeline_class_name: str, requested_mode: str): - adapter = IMAGE_PIPELINE_ADAPTERS.get(pipeline_class_name) - if adapter is None or requested_mode not in adapter.modes: - supported = ", ".join(sorted(adapter.modes if adapter else [])) or "none" - raise ValueError(f"{pipeline_class_name} does not support {requested_mode}. Supported modes: {supported}.") - return adapter + def _validate_mode(pipeline_class_name: str, requested_mode: Any): + adapter = get_image_pipeline_adapter(pipeline_class_name) + mode = _loader_image_mode({"mode": requested_mode}, adapter) + return adapter, mode def __call__(self, **kwargs): - pipeline_class_name = str(kwargs.get("pipeline_class") or "FluxPipeline") - requested_mode = str(kwargs.get("mode") or "text_to_image") - self._validate_mode(pipeline_class_name, requested_mode) - return super().__call__(**kwargs) + adapter = _loader_image_pipeline_adapter(kwargs) + requested_mode = _loader_image_mode(kwargs, adapter) + values = dict(kwargs) + values["pipeline_class"] = adapter.pipeline_class + values["mode"] = requested_mode + values["model_id"] = resolve_image_model_selection(adapter, values.get("model_id")) + values["revision"] = resolve_image_pipeline_revision(values["model_id"], values.get("revision")) + result = super().__call__(**values) + pipeline = result.get("pipeline") if isinstance(result, dict) else None + if pipeline is not None: + _tag_image_pipeline( + pipeline, + adapter, + requested_mode, + repo_value(values["model_id"]), + values["model_id"]["source"], + values["revision"], + ) + return result + + def update_pipeline_contract(self, values, ref): + if not isinstance(values, dict): + raise ValueError("Diffusers image loader values must be an object.") + adapter = _loader_image_pipeline_adapter(values) + requested_mode = values.get("mode") + if not isinstance(requested_mode, str) or not requested_mode or requested_mode != requested_mode.strip(): + raise ValueError("A registered Diffusers image mode is required.") + signal_origin = ref.get("key") if isinstance(ref, dict) else ref + if requested_mode in adapter.modes: + selected_mode = requested_mode + elif signal_origin == "pipeline_class" and requested_mode in IMAGE_PIPELINE_MODE_OPTIONS: + selected_mode = adapter.mode_options[0] + else: + _loader_image_mode(values, adapter) + raise AssertionError("unreachable") + current_selection = values.get("model_id") + resolved_selection = resolve_image_model_selection(adapter, current_selection) + raw_revision = _normalize_image_revision(values.get("revision")) + if resolved_selection.get("source") == "local": + resolved_revision = "" + else: + selected_repo = resolved_selection["value"] + reviewed_revision = catalog_revision(selected_repo) + if reviewed_revision is not None: + # A managed repository always owns its catalog pin. This also + # replaces a stale custom pin when a class change selects the + # class's reviewed default repository. + resolved_revision = reviewed_revision + elif signal_origin == "model_id": + # A field action has no trustworthy previous-repository value, + # so a custom repository selection cannot inherit the visible + # pin from whichever repository was selected before it. + resolved_revision = "" + else: + selection_was_replaced = False + if isinstance(current_selection, dict): + raw_source = current_selection.get("source") + raw_repo = current_selection.get("value") + selection_was_replaced = not ( + isinstance(raw_source, str) + and raw_source.strip().casefold() == resolved_selection["source"] + and isinstance(raw_repo, str) + and raw_repo.strip().casefold() == selected_repo.casefold() + ) + elif isinstance(current_selection, str): + selection_was_replaced = current_selection.strip().casefold() != selected_repo.casefold() + else: + selection_was_replaced = True + resolved_revision = ( + "" if selection_was_replaced else resolve_image_pipeline_revision(resolved_selection, raw_revision) + ) + + self.set_field_params( + "mode", + {"options": list(adapter.mode_options), "default": adapter.mode_options[0]}, + ) + self.set_field_params("model_id", {"fieldOptions": image_model_field_options(adapter)}) + if selected_mode != requested_mode: + self.set_field_value({"mode": selected_mode}) + if resolved_selection != current_selection: + self.set_field_value({"model_id": resolved_selection}) + if resolved_revision != raw_revision: + self.set_field_value({"revision": resolved_revision}) + self.set_field_params( + "pipeline", + { + "signal": { + "direction": "output", + "origin": str(signal_origin or "pipeline_class"), + "value": image_pipeline_contract(adapter, selected_mode), + } + }, + ) def prepare_for_workflow_reuse(self): """Restore a resident image pipeline before another graph adopts it.""" @@ -746,20 +1753,15 @@ def execute(self, **kwargs): execution_recipe = {} if not isinstance(execution_recipe, dict): raise TypeError("Execution Recipe must come from a Diffusers Execution Recipe node.") - pipeline_class_name = str(kwargs.get("pipeline_class") or "FluxPipeline") - requested_mode = str(kwargs.get("mode") or "text_to_image") - adapter = self._validate_mode(pipeline_class_name, requested_mode) - model_selection = kwargs.get("model_id") - selected_model_id = repo_value(model_selection) - model_id = selected_model_id or adapter.default_repo - model_source = model_selection.get("source") if selected_model_id and isinstance(model_selection, dict) else "hub" + adapter = _loader_image_pipeline_adapter(kwargs) + pipeline_class_name = adapter.pipeline_class + requested_mode = _loader_image_mode(kwargs, adapter) + model_selection = resolve_image_model_selection(adapter, kwargs.get("model_id")) + model_id = repo_value(model_selection) + model_source = model_selection["source"] dtype = str_to_dtype(kwargs.get("dtype") or "bfloat16") device = execution_recipe.get("device") or kwargs.get("device") or DEFAULT_DEVICE - revision = resolve_model_revision( - model_id, - none_if_blank(kwargs.get("revision")), - source=model_source, - ) + revision = resolve_image_pipeline_revision(model_selection, kwargs.get("revision")) auto_offload = bool(kwargs.get("auto_offload", True)) recipe_offload = execution_recipe.get("offload_mode") if recipe_offload is not None: @@ -856,15 +1858,11 @@ def execute(self, **kwargs): pipeline_class = pipeline_class_from_name(pipeline_class_name) with self.diffusers_loading_progress(): pipeline = pipeline_class.from_pretrained(model_id, **load_kwargs) - pipeline._modiff_image_adapter = adapter + _tag_image_pipeline(pipeline, adapter, requested_mode, model_id, model_source, revision) runtime_recipe = { **execution_recipe, - "vae_slicing": bool( - execution_recipe.get("vae_slicing", kwargs.get("enable_vae_slicing", True)) - ), - "vae_tiling": bool( - execution_recipe.get("vae_tiling", kwargs.get("enable_vae_tiling", True)) - ), + "vae_slicing": bool(execution_recipe.get("vae_slicing", kwargs.get("enable_vae_slicing", True))), + "vae_tiling": bool(execution_recipe.get("vae_tiling", kwargs.get("enable_vae_tiling", True))), } runtime_owner = pipeline.base if isinstance(pipeline, FluxReduxPipelineBundle) else pipeline pipeline._modiff_runtime_config = apply_execution_recipe_to_pipeline(runtime_owner, runtime_recipe) @@ -897,7 +1895,22 @@ class Generate(NodeBase): category = "Diffusers Image" resizable = True params = { - "pipeline": {"label": "Pipeline", "display": "input", "type": "image_diffusion_pipeline", "required": True}, + "pipeline": { + "label": "Pipeline", + "display": "input", + "type": "image_diffusion_pipeline", + "required": True, + "onSignal": [ + {"action": "value", "target": "image_contract"}, + {"action": "exec", "data": "update_image_contract"}, + ], + }, + "image_contract": { + "label": "Image Contract", + "type": "object", + "default": DEFAULT_IMAGE_PIPELINE_CONTRACT, + "hidden": True, + }, "prompt": {"label": "Prompt", "display": "textarea", "type": "text", "default": ""}, "negative_prompt": {"label": "Negative Prompt", "display": "textarea", "type": "text", "default": ""}, "width": {"label": "Width", "type": "int", "default": 1024, "min": 16, "max": 2048, "step": 16}, @@ -928,6 +1941,7 @@ class Generate(NodeBase): "min": 0, "max": 1, "step": 0.01, + "hidden": True, }, "padding_mask_crop": { "label": "Padding Mask Crop", @@ -936,39 +1950,67 @@ class Generate(NodeBase): "min": 0, "max": 512, "step": 8, + "hidden": True, }, - "max_sequence_length": {"label": "Max Sequence Length", "type": "int", "default": 256, "min": 1, "max": 2048}, + "max_sequence_length": {"label": "Max Sequence Length", "type": "int", "default": 256, "min": 1, "max": 512}, "output_type": {"label": "Output type", "type": "string", "options": ["pil", "np", "pt"], "default": "pil"}, "images": {"label": "Images", "display": "output", "type": "image"}, "width_out": {"label": "Width", "display": "output", "type": "int"}, "height_out": {"label": "Height", "display": "output", "type": "int"}, } + def __call__(self, **kwargs): + # Validate the raw graph payload before NodeBase can coerce booleans, + # blank strings, or container values into apparently valid numbers. + # The concrete registered class owns the action/mode check, so this + # remains one generic facade for Generate/Edit/Inpaint/ControlGenerate. + action = self.class_name + if action not in {"Generate", "Edit", "Inpaint", "ControlGenerate"}: + raise ValueError(f"Unsupported Diffusers image action {action!r}.") + _adapter, values = preflight_image_action(kwargs.get("pipeline"), action, kwargs) + return super().__call__(**values) + + def update_image_contract(self, values, ref): + """Apply the loader's backend-owned contract to this generic image form.""" + + values = values if isinstance(values, dict) else {} + signal_value = values.get("image_contract") + if not isinstance(signal_value, dict): + raise ValueError("The connected image pipeline did not publish a valid task contract.") + adapter = get_image_pipeline_adapter(signal_value.get("pipelineClass")) + mode = str(signal_value.get("mode") or "") + expected_signal = image_pipeline_contract(adapter, mode) + if signal_value != expected_signal: + raise ValueError("The connected image pipeline published a stale or mismatched task contract.") + if mode not in expected_signal["actions"].get(self.class_name, ()): + raise ValueError("The connected image pipeline does not support this generic image action.") + + for field, params in expected_signal["fieldParams"].items(): + if field in self.__class__.params: + self.set_field_params(field, params) + def execute(self, **kwargs): + pipeline = kwargs.get("pipeline") + adapter, values = preflight_image_action(pipeline, "Generate", kwargs) + import torch - pipeline = kwargs.get("pipeline") - if pipeline is None: - raise ValueError("Diffusers image pipeline is required.") device = getattr(pipeline, "_execution_device", None) or getattr(pipeline, "device", None) or "cpu" try: - generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed", 0))) + generator = torch.Generator(device=device).manual_seed(values["seed"]) except Exception: - generator = torch.Generator(device="cpu").manual_seed(int(kwargs.get("seed", 0))) - steps = int(kwargs.get("num_inference_steps") or 4) + generator = torch.Generator(device="cpu").manual_seed(values["seed"]) + steps = values["num_inference_steps"] call_kwargs = { - "prompt": kwargs.get("prompt") or "", - "width": int(kwargs.get("width") or 1024), - "height": int(kwargs.get("height") or 1024), + "prompt": values.get("prompt") or "", + "width": values["width"], + "height": values["height"], "num_inference_steps": steps, "generator": generator, - "output_type": kwargs.get("output_type") or "pil", + "output_type": values["output_type"], "return_dict": True, } - adapter = getattr(pipeline, "_modiff_image_adapter", None) or ImagePipelineAdapter( - type(pipeline).__name__, frozenset() - ) - adapter.apply_generation_parameters(pipeline, kwargs, call_kwargs) + adapter.apply_generation_parameters(pipeline, values, call_kwargs) add_progress_callback(self, pipeline, call_kwargs, steps) self._active_pipeline = pipeline try: @@ -1000,45 +2042,37 @@ class Edit(Generate): "min": 0.0, "max": 1.0, "step": 0.05, + "hidden": True, "description": "Relative influence of every reference after the first composition anchor, when supported by the selected adapter.", }, } def execute(self, **kwargs): - if kwargs.get("image") is None: - raise ValueError("Diffusers Image Edit needs an input image.") pipeline = kwargs.get("pipeline") - adapter = getattr(pipeline, "_modiff_image_adapter", None) or ImagePipelineAdapter( - type(pipeline).__name__ if pipeline is not None else "unknown", frozenset() - ) - image = prepare_reference_images(kwargs.get("image"), adapter) - return self._execute_conditioned(kwargs, {"image": image}) + adapter, values = preflight_image_action(pipeline, "Edit", kwargs) + image = prepare_reference_images(values.get("image"), adapter) + return self._execute_conditioned(values, {"image": image}, adapter=adapter) + + def _execute_conditioned(self, values, extra_kwargs, *, adapter): + pipeline = values["pipeline"] - def _execute_conditioned(self, kwargs, extra_kwargs): import torch - pipeline = kwargs.get("pipeline") - if pipeline is None: - raise ValueError("Diffusers image pipeline is required.") device = getattr(pipeline, "_execution_device", None) or getattr(pipeline, "device", None) or "cpu" try: - generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed", 0))) + generator = torch.Generator(device=device).manual_seed(values["seed"]) except Exception: - generator = torch.Generator(device="cpu").manual_seed(int(kwargs.get("seed", 0))) - steps = int(kwargs.get("num_inference_steps") or 4) + generator = torch.Generator(device="cpu").manual_seed(values["seed"]) + steps = values["num_inference_steps"] call_kwargs = { - "prompt": kwargs.get("prompt") or "", + "prompt": values.get("prompt") or "", "num_inference_steps": steps, "generator": generator, - "output_type": kwargs.get("output_type") or "pil", + "output_type": values["output_type"], "return_dict": True, **extra_kwargs, } - adapter = getattr(pipeline, "_modiff_image_adapter", None) - if adapter is None: - guidance_parameter = "true_cfg_scale" if supports_arg(pipeline, "true_cfg_scale") else "guidance_scale" - adapter = ImagePipelineAdapter(type(pipeline).__name__, frozenset(), guidance_parameter=guidance_parameter) - adapter.apply_generation_parameters(pipeline, kwargs, call_kwargs) + adapter.apply_generation_parameters(pipeline, values, call_kwargs) add_progress_callback(self, pipeline, call_kwargs, steps) self._active_pipeline = pipeline try: @@ -1049,8 +2083,8 @@ def _execute_conditioned(self, kwargs, extra_kwargs): actual_width, actual_height = output_image_dimensions(images, call_kwargs["output_type"]) return { "images": images, - "width_out": actual_width if actual_width is not None else int(kwargs.get("width") or 0), - "height_out": actual_height if actual_height is not None else int(kwargs.get("height") or 0), + "width_out": actual_width if actual_width is not None else values["width"], + "height_out": actual_height if actual_height is not None else values["height"], } @@ -1066,18 +2100,17 @@ class Inpaint(Edit): } def execute(self, **kwargs): - if kwargs.get("image") is None or kwargs.get("mask_image") is None: - raise ValueError("Diffusers Image Inpaint needs image and mask_image inputs.") - if kwargs.get("output_type", "pil") != "pil": - raise ValueError("Diffusers Image Inpaint requires output_type='pil' for mask-safe compositing.") + pipeline = kwargs.get("pipeline") + adapter, values = preflight_image_action(pipeline, "Inpaint", kwargs) result = self._execute_conditioned( - kwargs, - {"image": kwargs.get("image"), "mask_image": kwargs.get("mask_image")}, + values, + {"image": values["image"], "mask_image": values["mask_image"]}, + adapter=adapter, ) result["images"] = composite_masked_pil_outputs( result.get("images"), - kwargs.get("image"), - kwargs.get("mask_image"), + values["image"], + values["mask_image"], ) return result @@ -1093,9 +2126,13 @@ class ControlGenerate(Edit): } def execute(self, **kwargs): - if kwargs.get("control_image") is None: - raise ValueError("Diffusers Control Generate needs a control_image input.") - return self._execute_conditioned(kwargs, {"control_image": kwargs.get("control_image")}) + pipeline = kwargs.get("pipeline") + adapter, values = preflight_image_action(pipeline, "ControlGenerate", kwargs) + return self._execute_conditioned( + values, + {"control_image": values["control_image"]}, + adapter=adapter, + ) class LoadAdapter(NodeBase): @@ -1113,11 +2150,17 @@ class LoadAdapter(NodeBase): "fieldOptions": {"noValidation": True, "sources": ["hub", "local"]}, }, "weight_name": {"label": "Weight name", "type": "string", "default": ""}, + "revision": { + "label": "Revision", + "type": "string", + "default": "", + "description": "Required immutable Hub commit for the selected adapter repository.", + }, "expected_sha256": { "label": "Expected SHA-256", "type": "string", "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required SHA-256 for Hub adapter weights; optional for local weights.", }, "adapter_name": {"label": "Adapter name", "type": "string", "default": "default"}, "replace_existing": { @@ -1138,63 +2181,221 @@ class LoadAdapter(NodeBase): "output": {"label": "Pipeline", "display": "output", "type": "image_diffusion_pipeline"}, } - def execute(self, **kwargs): + @staticmethod + def _normalized_request(kwargs: dict[str, Any]) -> dict[str, Any]: + """Normalize the raw adapter contract without touching a pipeline.""" + + if not isinstance(kwargs, dict): + raise ValueError("Diffusers image adapter values must be an object.") pipeline = kwargs.get("pipeline") if pipeline is None: raise ValueError("LoadAdapter needs a pipeline input.") - adapter_selection = kwargs.get("adapter_path") - adapter_path = repo_value(adapter_selection) - if not adapter_path: - return {"output": pipeline} - if not hasattr(pipeline, "load_lora_weights"): + if not callable(getattr(pipeline, "load_lora_weights", None)): raise ValueError("This pipeline does not expose load_lora_weights().") - load_kwargs = { - "adapter_name": kwargs.get("adapter_name") or "default", - } - weight_name = none_if_blank(kwargs.get("weight_name")) - source = adapter_selection.get("source") if isinstance(adapter_selection, dict) else "hub" + + selection = kwargs.get("adapter_path") + if selection is None or (isinstance(selection, str) and not selection.strip()): + source = None + adapter_path = "" + normalized_selection: str | dict[str, str] = "" + elif isinstance(selection, dict): + source = _canonical_image_model_source(selection.get("source"), label="Diffusers image adapter") + raw_adapter_path = selection.get("value") + if not isinstance(raw_adapter_path, str): + raise ValueError("Diffusers image adapter value must be a repository ID or local path string.") + adapter_path = raw_adapter_path.strip() + if not adapter_path: + source = None + normalized_selection = "" + else: + normalized_selection = {"source": source, "value": adapter_path} + elif isinstance(selection, str): + source = "hub" + adapter_path = selection.strip() + normalized_selection = {"source": source, "value": adapter_path} + else: + raise ValueError( + "Diffusers image adapter selection must be a repository ID or a hub/local selection object." + ) + + raw_weight_name = kwargs.get("weight_name") + if raw_weight_name is not None and not isinstance(raw_weight_name, str): + raise ValueError("Diffusers image adapter weight_name must be a string.") + weight_name = str(raw_weight_name or "").strip() + + raw_revision = kwargs.get("revision") + if raw_revision is not None and not isinstance(raw_revision, str): + raise ValueError("Diffusers image adapter revision must be a string.") + revision = str(raw_revision or "").strip() + if isinstance(raw_revision, str) and raw_revision != revision: + raise ValueError("Diffusers image adapter revision must be an exact trimmed string.") + + raw_expected_sha256 = kwargs.get("expected_sha256") + if raw_expected_sha256 is not None and not isinstance(raw_expected_sha256, str): + raise ValueError("Diffusers image adapter expected_sha256 must be a string.") + expected_sha256 = str(raw_expected_sha256 or "").strip().lower().removeprefix("sha256:") + if expected_sha256 and ( + len(expected_sha256) != 64 or any(character not in "0123456789abcdef" for character in expected_sha256) + ): + raise ValueError("Diffusers image adapter expected_sha256 must contain exactly 64 hexadecimal digits.") + + raw_adapter_name = kwargs.get("adapter_name") + if raw_adapter_name is not None and not isinstance(raw_adapter_name, str): + raise ValueError("Diffusers image adapter name must be a string.") + adapter_name = str(raw_adapter_name or "default").strip() + if not adapter_name: + raise ValueError("Diffusers image adapter name cannot be blank.") + scale = _bounded_image_float( + kwargs.get("scale"), field="adapter scale", default=1.0, minimum=-2.0, maximum=2.0 + ) + raw_replace_existing = kwargs.get("replace_existing") + if raw_replace_existing is None: + replace_existing = True + elif type(raw_replace_existing) is not bool: + raise ValueError("Diffusers image adapter replace_existing must be a boolean.") + else: + replace_existing = raw_replace_existing + if source == "hub": - from pathlib import Path - from utils.huggingface import cached_file_path + adapter_path = _validated_image_hub_repository(adapter_path, label="Diffusers image adapter repository") + normalized_selection = {"source": "hub", "value": adapter_path} + if not weight_name: + raise ValueError("A Hub adapter requires an exact safetensors weight_name.") + weight_parts = weight_name.split("/") + if ( + "\\" in weight_name + or weight_name.startswith("/") + or any(part in {"", ".", ".."} for part in weight_parts) + or not weight_name.endswith(".safetensors") + ): + raise ValueError("A Hub adapter weight_name must be a contained .safetensors repository file.") + if revision != revision.lower() or not revision or not IMMUTABLE_HUB_REVISION.fullmatch(revision): + raise ValueError("A Hub Diffusers image adapter requires a lowercase 40-character commit revision.") + if not expected_sha256: + raise ValueError("A Hub Diffusers image adapter requires an expected SHA-256 hash.") + elif source == "local": + if revision: + raise ValueError("A local Diffusers image adapter cannot carry a Hub revision.") + elif revision or expected_sha256 or weight_name: + raise ValueError("Adapter weight, revision, and hash values require a selected adapter.") + + values = dict(kwargs) + values.update( + { + "pipeline": pipeline, + "adapter_path": normalized_selection, + "weight_name": weight_name, + "revision": revision, + "expected_sha256": expected_sha256, + "adapter_name": adapter_name, + "replace_existing": replace_existing, + "scale": scale, + } + ) + return values + + def __call__(self, **kwargs): + # This facade-local pass prevents NodeBase's permissive primitive casts + # from turning malformed security fields into another request. + return super().__call__(**self._normalized_request(kwargs)) + def execute(self, **kwargs): + values = self._normalized_request(kwargs) + pipeline = values["pipeline"] + selection = values["adapter_path"] + if not selection: + return {"output": pipeline} + source = selection["source"] + adapter_path = selection["value"] + weight_name = values["weight_name"] or None + resolved_weight: Path + load_adapter_path: Path + load_weight_name: str + if source == "hub": repo_id = adapter_path - if not weight_name: - parts = adapter_path.split("/") - if len(parts) >= 3: - repo_id, weight_name = "/".join(parts[:2]), "/".join(parts[2:]) - if not weight_name: - raise ValueError("A Hub adapter requires a pinned weight_name for app-managed installation.") - cached = cached_file_path(repo_id, weight_name) + cached = cached_file_path(repo_id, weight_name, revision=values["revision"]) if not cached: raise FileNotFoundError( f"Adapter {repo_id}/{weight_name} is not installed. Install the pinned file through Model Manager first." ) - cached_path = Path(cached) - adapter_path = str(cached_path.parent) - weight_name = cached_path.name - expected_sha256 = str(kwargs.get("expected_sha256") or "").strip().lower().removeprefix("sha256:") - if expected_sha256: - digest = hashlib.sha256() - with cached_path.open("rb") as handle: - for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): - digest.update(chunk) - if digest.hexdigest() != expected_sha256: - raise ValueError( - f"Adapter {repo_id}/{weight_name} failed its pinned SHA-256 verification. " - "Repair the adapter through Model Manager before running this graph." - ) - if weight_name: - load_kwargs["weight_name"] = weight_name - replace_existing = bool(kwargs.get("replace_existing", True)) + cached_alias = Path(cached).expanduser() + if not cached_alias.name.endswith(".safetensors"): + raise ValueError( + "The installed Hub adapter snapshot entry must have a lowercase .safetensors filename." + ) + try: + resolved_weight = resolve_managed_hf_cache_file(cached) + except (FileNotFoundError, ValueError) as error: + raise FileNotFoundError( + f"Installed adapter cache entry does not exist: {repo_id}/{weight_name}. " + "Repair it through Model Manager." + ) from error + # Hugging Face snapshot entries normally symlink to extensionless + # blob files. Hash the resolved blob, but retain the validated + # snapshot alias so Diffusers selects its safetensors-only branch. + load_adapter_path = cached_alias.parent + load_weight_name = cached_alias.name + else: + try: + local_target = Path(adapter_path).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"Diffusers image adapter path does not exist: {adapter_path}") from error + if local_target.is_file(): + if weight_name is not None and Path(weight_name).name != local_target.name: + raise ValueError("Local adapter file selection and weight_name refer to different files.") + resolved_weight = local_target + elif local_target.is_dir(): + if weight_name is None: + raise ValueError("A local Diffusers image adapter directory requires an exact weight_name.") + requested_weight = Path(weight_name) + if requested_weight.is_absolute(): + raise ValueError("Local Diffusers image adapter weight_name must stay inside its selected folder.") + try: + resolved_weight = (local_target / requested_weight).resolve(strict=True) + resolved_weight.relative_to(local_target) + except (OSError, RuntimeError, ValueError) as error: + raise FileNotFoundError( + f"Local Diffusers image adapter weight does not exist inside the selected folder: {weight_name}" + ) from error + if not resolved_weight.is_file(): + raise FileNotFoundError("The selected local Diffusers image adapter weight is not a file.") + else: + raise FileNotFoundError(f"Diffusers image adapter target is not a file or folder: {local_target}") + + if not resolved_weight.name.endswith(".safetensors"): + raise ValueError("A local Diffusers image adapter must select a lowercase .safetensors file.") + load_adapter_path = resolved_weight.parent + load_weight_name = resolved_weight.name + + expected_sha256 = values["expected_sha256"] + if expected_sha256: + digest = hashlib.sha256() + with resolved_weight.open("rb") as handle: + for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != expected_sha256: + raise ValueError( + "The Diffusers image adapter failed its pinned SHA-256 verification. " + "Repair or reselect it before running this graph." + ) + + adapter_name = values["adapter_name"] + scale = values["scale"] + load_kwargs = { + "adapter_name": adapter_name, + "weight_name": load_weight_name, + "use_safetensors": True, + } + adapter_path = str(load_adapter_path) + replace_existing = values["replace_existing"] adapter_scales = dict(getattr(pipeline, "_modiff_adapter_scales", {}) or {}) - if replace_existing and hasattr(pipeline, "unload_lora_weights"): - pipeline.unload_lora_weights() + unload_lora_weights = getattr(pipeline, "unload_lora_weights", None) + if replace_existing and callable(unload_lora_weights): + unload_lora_weights() adapter_scales.clear() pipeline.load_lora_weights(adapter_path, **load_kwargs) - raw_scale = kwargs.get("scale") - scale = 1.0 if raw_scale is None else float(raw_scale) - adapter_scales[load_kwargs["adapter_name"]] = scale - if hasattr(pipeline, "set_adapters"): + adapter_scales[adapter_name] = scale + if callable(getattr(pipeline, "set_adapters", None)): pipeline.set_adapters(list(adapter_scales), list(adapter_scales.values())) pipeline._modiff_adapter_scales = adapter_scales return {"output": pipeline} diff --git a/modules/DiffusersVideo/main.py b/modules/DiffusersVideo/main.py index 0ca6453..f71bc1f 100644 --- a/modules/DiffusersVideo/main.py +++ b/modules/DiffusersVideo/main.py @@ -9,13 +9,19 @@ from functools import wraps import json import logging +from math import isfinite +from pathlib import Path from typing import Any +import numpy as np +from PIL import Image + from modiff.config import CONFIG from modiff.NodeBase import NodeBase from modiff.diffusers_offload import OFFLOAD_MODE_MODEL_CPU, apply_pipeline_offload -from modiff.model_artifact_catalog import require_catalog_revision, resolve_model_revision +from modiff.model_artifact_catalog import IMMUTABLE_HUB_REVISION, catalog_revision, require_catalog_revision from modules.DiffusersVideo.wan_vace import ( + WAN_VACE_NATIVE_CHUNK_FRAMES, WanVACEGenerate, WanVACELoadPipeline, callback_tensor_inputs, @@ -26,8 +32,9 @@ normalize_num_frames, parse_json_object, repo_value, + validate_dimensions, ) -from utils.huggingface import local_files_only +from utils.huggingface import local_files_only, validate_hf_repo_id from utils.torch_utils import DEFAULT_DEVICE, str_to_dtype logger = logging.getLogger("modiff") @@ -38,6 +45,10 @@ LTX_DISTILLED_TIMESTEPS = [1000, 900, 700, 500, 300, 200, 100, 40] FRAMEPACK_BASE_REPO = "hunyuanvideo-community/HunyuanVideo" FRAMEPACK_VISION_REPO = "lllyasviel/flux_redux_bfl" +WAN_VACE_MAX_SEQUENCE_LENGTH = 512 +WAN_VACE_MAX_SEED = 4294967295 +WAN_VACE_MAX_REFERENCE_IMAGES = 8 +WAN_VACE_MAX_REFERENCE_PIXELS = 16 * 1024 * 1024 def _value_or_default(mapping: dict[str, Any], key: str, default: Any): @@ -58,6 +69,107 @@ class VideoPipelineAdapter: default_audio_sample_rate: int | None = None +@dataclass(frozen=True) +class VideoModeMediaContract: + video: str + mask: str + reference_images: str + + +_VIDEO_DYNAMIC_FIELDS = ( + "video", + "mask", + "reference_images", + "conditioning_scale", + "strength", + "denoise_strength", + "frame_rate", + "last_image", + "framepack_sampling", + "latent_window_size", + "true_cfg_scale", + "secondary_guidance_scale", + "scheduler_flow_shift", + "guidance_scale_2", + "use_guidance_scale_2", + "pose_video", + "face_video", + "background_video", + "segment_frame_length", + "previous_conditioning_frames", + "motion_encode_batch_size", + "temporal_tile_size", + "temporal_overlap", + "temporal_overlap_condition_strength", + "adain_factor", + "prompt_segments_json", +) +_VIDEO_INPUT_FIELDS = frozenset( + { + "video", + "mask", + "reference_images", + "last_image", + "pose_video", + "face_video", + "background_video", + } +) + + +def _studio_identity_binding(form_field: str) -> dict[str, Any]: + groups = { + "strength": "video-strength", + "conditioningScale": "video-conditioning-scale", + } + if form_field not in groups: + raise ValueError("Video field bindings must target a reviewed Studio form field.") + return { + "schemaVersion": 1, + "group": groups[form_field], + "formFields": [form_field], + "transform": "identity", + } + + +@dataclass(frozen=True) +class VideoModeFieldContract: + visible_fields: tuple[str, ...] = () + required_fields: tuple[str, ...] = () + strength_form_field: str = "strength" + + def __post_init__(self) -> None: + visible = set(self.visible_fields) + required = set(self.required_fields) + allowed = set(_VIDEO_DYNAMIC_FIELDS) + if len(visible) != len(self.visible_fields) or not visible.issubset(allowed): + raise ValueError("Video mode contracts must declare unique reviewed visibility fields.") + if len(required) != len(self.required_fields) or not required.issubset(visible & _VIDEO_INPUT_FIELDS): + raise ValueError("Video mode contracts may require only visible reviewed input fields.") + if self.strength_form_field not in {"strength", "conditioningScale"}: + raise ValueError("Video strength bindings must target a reviewed Studio form field.") + + def field_param_overlay(self) -> dict[str, dict[str, Any]]: + visible = set(self.visible_fields) + required = set(self.required_fields) + overlay = {field: {"hidden": field not in visible} for field in _VIDEO_DYNAMIC_FIELDS} + for field in _VIDEO_INPUT_FIELDS: + overlay[field]["required"] = field in required + overlay["strength"]["fieldOptions"] = {"studioBinding": _studio_identity_binding(self.strength_form_field)} + return overlay + + +WAN_VACE_MODE_MEDIA_CONTRACTS = { + "text_to_video": VideoModeMediaContract("forbidden", "forbidden", "forbidden"), + "video_to_video": VideoModeMediaContract("required", "forbidden", "optional"), + "video_inpaint": VideoModeMediaContract("required", "required", "optional"), + "video_outpaint": VideoModeMediaContract("required", "required", "optional"), + "reference_to_video": VideoModeMediaContract("forbidden", "forbidden", "required"), + "control_to_video": VideoModeMediaContract("required", "forbidden", "optional"), + "video_color_edit": VideoModeMediaContract("required", "forbidden", "optional"), +} + + VIDEO_PIPELINE_ADAPTERS = { "WanVACEPipeline": VideoPipelineAdapter( id="wan-vace", @@ -66,7 +178,6 @@ class VideoPipelineAdapter: default_repo="Wan-AI/Wan2.1-VACE-1.3B-diffusers", modes=( "text_to_video", - "image_to_video", "video_to_video", "video_inpaint", "video_outpaint", @@ -157,42 +268,708 @@ class VideoPipelineAdapter: } +def _video_field_contract( + *visible_fields: str, + required_fields: tuple[str, ...] = (), + strength_form_field: str = "strength", +) -> VideoModeFieldContract: + return VideoModeFieldContract( + visible_fields=visible_fields, + required_fields=required_fields, + strength_form_field=strength_form_field, + ) + + +_VACE_GUIDANCE_FIELDS = ("conditioning_scale", "guidance_scale_2", "use_guidance_scale_2") +_ANIMATE_FIELDS = ( + "reference_images", + "pose_video", + "face_video", + "segment_frame_length", + "previous_conditioning_frames", + "motion_encode_batch_size", +) +_LTX_LONG_FIELDS = ( + "reference_images", + "strength", + "frame_rate", + "temporal_tile_size", + "temporal_overlap", + "temporal_overlap_condition_strength", + "adain_factor", + "prompt_segments_json", +) +VIDEO_MODE_FIELD_CONTRACTS = { + "WanVACEPipeline": { + "text_to_video": _video_field_contract(*_VACE_GUIDANCE_FIELDS), + "video_to_video": _video_field_contract( + "video", "reference_images", *_VACE_GUIDANCE_FIELDS, required_fields=("video",) + ), + "video_inpaint": _video_field_contract( + "video", + "mask", + "reference_images", + *_VACE_GUIDANCE_FIELDS, + required_fields=("video", "mask"), + ), + "video_outpaint": _video_field_contract( + "video", + "mask", + "reference_images", + *_VACE_GUIDANCE_FIELDS, + required_fields=("video", "mask"), + ), + "reference_to_video": _video_field_contract( + "reference_images", + *_VACE_GUIDANCE_FIELDS, + required_fields=("reference_images",), + ), + "control_to_video": _video_field_contract( + "video", "reference_images", *_VACE_GUIDANCE_FIELDS, required_fields=("video",) + ), + "video_color_edit": _video_field_contract( + "video", "reference_images", *_VACE_GUIDANCE_FIELDS, required_fields=("video",) + ), + }, + "WanVideoToVideoPipeline": { + mode: _video_field_contract("video", "strength", required_fields=("video",)) + for mode in ("video_to_video", "video_color_edit") + }, + "WanPipeline": {"text_to_video": _video_field_contract("scheduler_flow_shift")}, + "Wan22Pipeline": {"text_to_video": _video_field_contract("scheduler_flow_shift")}, + "WanTI2VPipeline": {"text_to_video": _video_field_contract("scheduler_flow_shift")}, + "WanImageToVideoPipeline": { + "image_to_video": _video_field_contract( + "reference_images", + "last_image", + "secondary_guidance_scale", + required_fields=("reference_images",), + ) + }, + "WanAnimatePipeline": { + "character_animate": _video_field_contract( + *_ANIMATE_FIELDS, + required_fields=("reference_images", "pose_video", "face_video"), + ), + "character_replace": _video_field_contract( + *_ANIMATE_FIELDS, + "background_video", + "mask", + required_fields=("reference_images", "pose_video", "face_video", "background_video", "mask"), + ), + }, + "LTXConditionPipeline": { + "text_to_video": _video_field_contract("frame_rate"), + "image_to_video": _video_field_contract( + "reference_images", "strength", "frame_rate", required_fields=("reference_images",) + ), + "video_to_video": _video_field_contract( + "video", + "strength", + "denoise_strength", + "frame_rate", + required_fields=("video",), + strength_form_field="conditioningScale", + ), + "reference_to_video": _video_field_contract( + "reference_images", "strength", "frame_rate", required_fields=("reference_images",) + ), + }, + "LTXI2VLongMultiPromptPipeline": { + "image_to_video": _video_field_contract(*_LTX_LONG_FIELDS, required_fields=("reference_images",)) + }, + "LTX2ConditionPipeline": { + "text_to_video": _video_field_contract("frame_rate"), + "image_to_video": _video_field_contract( + "reference_images", "strength", "frame_rate", required_fields=("reference_images",) + ), + "video_to_video": _video_field_contract( + "video", "strength", "frame_rate", required_fields=("video",), strength_form_field="conditioningScale" + ), + "reference_to_video": _video_field_contract( + "reference_images", "strength", "frame_rate", required_fields=("reference_images",) + ), + }, + "HunyuanVideoFramepackPipeline": { + "image_to_video": _video_field_contract( + "reference_images", + "last_image", + "framepack_sampling", + "latent_window_size", + "true_cfg_scale", + required_fields=("reference_images",), + ) + }, +} + + +def get_video_mode_field_contract(adapter: VideoPipelineAdapter, mode: str) -> VideoModeFieldContract: + contracts = VIDEO_MODE_FIELD_CONTRACTS.get(adapter.pipeline_class) + if contracts is None or tuple(contracts) != adapter.modes: + raise RuntimeError(f"Video adapter {adapter.pipeline_class} has an incomplete reviewed field contract.") + contract = contracts.get(mode) + if contract is None: + raise ValueError(f"{adapter.pipeline_class} does not support video mode {mode}.") + return contract + + +VIDEO_PIPELINE_LOAD_HANDLERS = { + "WanVACEPipeline": "_load_wan_vace", + "WanVideoToVideoPipeline": "_load_wan_video_to_video", + "WanPipeline": "_load_wan_text_to_video", + "Wan22Pipeline": "_load_wan_text_to_video", + "WanTI2VPipeline": "_load_wan_text_to_video", + "WanImageToVideoPipeline": "_load_wan_image_to_video", + "WanAnimatePipeline": "_load_wan_animate", + "LTXConditionPipeline": "_load_ltx", + "LTXI2VLongMultiPromptPipeline": "_load_ltx_long", + "LTX2ConditionPipeline": "_load_ltx2", + "HunyuanVideoFramepackPipeline": "_load_framepack", +} + + +VIDEO_PIPELINE_EXECUTE_HANDLERS = { + "WanVACEPipeline": "_execute_wan_vace", + "WanVideoToVideoPipeline": "_execute_wan_video_to_video", + "WanPipeline": "_execute_wan_text_to_video", + "Wan22Pipeline": "_execute_wan_text_to_video", + "WanTI2VPipeline": "_execute_wan_text_to_video", + "WanImageToVideoPipeline": "_execute_wan_image_to_video", + "WanAnimatePipeline": "_execute_wan_animate", + "LTXConditionPipeline": "_execute_ltx", + "LTXI2VLongMultiPromptPipeline": "_execute_ltx_long", + "LTX2ConditionPipeline": "_execute_ltx2", + "HunyuanVideoFramepackPipeline": "_execute_framepack", +} + + def get_video_pipeline_adapter(name: Any) -> VideoPipelineAdapter: - key = str(name or "WanVACEPipeline") - adapter = VIDEO_PIPELINE_ADAPTERS.get(key) + if not isinstance(name, str) or not name or name != name.strip(): + raise ValueError("A registered Diffusers video pipeline class is required.") + adapter = VIDEO_PIPELINE_ADAPTERS.get(name) if adapter is None: supported = ", ".join(sorted(VIDEO_PIPELINE_ADAPTERS)) - raise ValueError(f"Unsupported Diffusers video pipeline class {key}. Supported classes: {supported}.") + raise ValueError(f"Unsupported Diffusers video pipeline class {name}. Supported classes: {supported}.") return adapter +def _canonical_video_model_source(source: Any) -> str: + if not isinstance(source, str) or not source or source != source.strip(): + raise ValueError("Diffusers video model source must be exactly hub or local.") + normalized = source.casefold() + if normalized not in {"hub", "local"}: + raise ValueError("Diffusers video model source must be exactly hub or local.") + return normalized + + +def _validated_video_hub_repository(value: str) -> str: + if value.count("/") != 1: + raise ValueError("Diffusers video Hub models must use an exact namespace/repository ID.") + try: + validate_hf_repo_id(value) + except ValueError as error: + raise ValueError("Diffusers video Hub models must use an exact namespace/repository ID.") from error + try: + resolves_locally = Path(value).expanduser().exists() + except (OSError, RuntimeError) as error: + raise ValueError("Diffusers video Hub model identity could not be validated.") from error + if resolves_locally: + raise ValueError( + "Diffusers video Hub model resolves to an existing local filesystem target. " + "Select source=local for local models." + ) + return value + + +def _validated_video_local_model_directory(value: str) -> str: + try: + resolved = Path(value).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError(f"Local Diffusers video model directory does not exist: {value}") from error + if not resolved.is_dir(): + raise ValueError(f"Local Diffusers video model selection must be a directory: {value}") + return str(resolved) + + def _resolve_adapter_model_selection(adapter: VideoPipelineAdapter, value: Any): - """Replace only the inherited Wan VACE default for non-VACE adapters. + """Replace managed adapter defaults while preserving custom selections. - ``LoadPipeline`` intentionally inherits the mature Wan loader contract, so - its model selector also inherits Wan's persisted default value. A graph - that changes only ``pipeline_class`` must resolve to that adapter's model; - an explicitly selected local or Hub artifact must remain untouched. + The generic loader inherits one persisted model selector. A class-only + change can therefore leave any previous adapter's managed default behind. + Registered Hub defaults follow the adapter selection, while an explicit + custom Hub repository or any local selection remains untouched. """ - selected = repo_value(value) - inherited_vace_default = VIDEO_PIPELINE_ADAPTERS["WanVACEPipeline"].default_repo - if not selected or (adapter.pipeline_class != "WanVACEPipeline" and selected == inherited_vace_default): + if value is None or (isinstance(value, str) and not value.strip()): + source = "hub" + selected = adapter.default_repo + elif isinstance(value, dict): + source = _canonical_video_model_source(value.get("source")) + raw_selected = value.get("value") + if not isinstance(raw_selected, str): + raise ValueError("Diffusers video model value must be a repository ID or local path string.") + selected = raw_selected.strip() + if not selected: + if source == "hub": + selected = adapter.default_repo + else: + raise ValueError("A local Diffusers video model path is required.") + elif isinstance(value, str): + source = "hub" + selected = value.strip() + else: + raise ValueError("Diffusers video model selection must be a repository ID or a hub/local selection object.") + + if source == "hub": + selected = _validated_video_hub_repository(selected) + else: + selected = _validated_video_local_model_directory(selected) + managed_defaults = {candidate.default_repo.casefold() for candidate in VIDEO_PIPELINE_ADAPTERS.values()} + selected_key = selected.casefold() + if source == "hub" and selected_key in managed_defaults: return {"source": "hub", "value": adapter.default_repo} - return value + return {"source": source, "value": selected} def _resolve_loader_revision(model_selection: Any, model_id: str, revision: Any) -> str | None: source = model_selection.get("source") if isinstance(model_selection, dict) else None - return resolve_model_revision(model_id, none_if_blank(revision), source=source) + if source == "local": + return None + + catalog_pin = catalog_revision(model_id) + if revision is None or revision == "": + if catalog_pin is not None: + return catalog_pin + raise ValueError( + f"Custom Hugging Face video repository {model_id!r} requires an explicit immutable " + "lowercase 40-character commit SHA revision." + ) + if ( + not isinstance(revision, str) + or revision != revision.strip() + or revision != revision.lower() + or not IMMUTABLE_HUB_REVISION.fullmatch(revision) + ): + raise ValueError("Hugging Face video revision must be an exact lowercase 40-character commit SHA.") + if catalog_pin is not None and revision != catalog_pin: + raise ValueError( + f"Cataloged Hugging Face video repository {model_id!r} is pinned to {catalog_pin}; " + f"the requested revision {revision} does not match." + ) + return revision + + +_MISSING_VIDEO_PIPELINE_TAG = object() def _pipeline_adapter(pipeline: Any) -> VideoPipelineAdapter: - adapter_name = getattr(pipeline, "_modiff_video_pipeline_class", None) - if adapter_name: - return get_video_pipeline_adapter(adapter_name) - # Compatibility for pipelines loaded before adapter tagging existed. - return get_video_pipeline_adapter("WanVACEPipeline") + runtime_class = type(pipeline).__name__ + runtime_candidates = [ + adapter + for adapter in VIDEO_PIPELINE_ADAPTERS.values() + if runtime_class in {adapter.pipeline_class, adapter.diffusers_class} + ] + adapter_name = getattr(pipeline, "_modiff_video_pipeline_class", _MISSING_VIDEO_PIPELINE_TAG) + if adapter_name is not _MISSING_VIDEO_PIPELINE_TAG: + adapter = get_video_pipeline_adapter(adapter_name) + if runtime_candidates and adapter not in runtime_candidates: + runtime_names = ", ".join(sorted(candidate.pipeline_class for candidate in runtime_candidates)) + raise ValueError( + f"Diffusers video pipeline identity is inconsistent: runtime class {runtime_class} supports " + f"{runtime_names}, but the pipeline is tagged as {adapter.pipeline_class}." + ) + tagged_repo = getattr(pipeline, "_modiff_video_repo", None) + if isinstance(tagged_repo, str) and tagged_repo.strip(): + repository_key = tagged_repo.strip().casefold() + managed_repo_adapters = [ + candidate + for candidate in VIDEO_PIPELINE_ADAPTERS.values() + if candidate.default_repo.casefold() == repository_key + ] + if managed_repo_adapters and adapter not in managed_repo_adapters: + repository_names = ", ".join(sorted(candidate.pipeline_class for candidate in managed_repo_adapters)) + raise ValueError( + "Diffusers video pipeline identity is inconsistent: managed repository " + f"{tagged_repo.strip()!r} supports {repository_names}, but the pipeline is tagged as " + f"{adapter.pipeline_class}." + ) + return adapter + + if not runtime_candidates: + raise ValueError( + f"Cannot recover a registered Diffusers video adapter from untagged runtime class {runtime_class}." + ) + + reviewed_repo = getattr(pipeline, "_modiff_video_repo", None) + if not isinstance(reviewed_repo, str) or not reviewed_repo.strip(): + raise ValueError( + f"Cannot recover untagged Diffusers video runtime class {runtime_class} without an exact reviewed " + "repository identity. Reload it through the generic video loader." + ) + candidates = [adapter for adapter in runtime_candidates if adapter.default_repo == reviewed_repo.strip()] + if len(candidates) == 1: + return candidates[0] + if not candidates: + raise ValueError( + f"Cannot recover untagged Diffusers video runtime class {runtime_class} from unreviewed repository " + f"{reviewed_repo!r}. Reload it through the generic video loader." + ) + candidate_names = ", ".join(sorted(adapter.pipeline_class for adapter in candidates)) + raise ValueError( + f"Untagged Diffusers video runtime class {runtime_class} is ambiguous across adapters: {candidate_names}. " + "Reload it through the generic video loader so its exact adapter is tagged." + ) + + +def _adapter_signal(adapter: VideoPipelineAdapter) -> dict[str, Any]: + return { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "video", + "pipelineClass": adapter.pipeline_class, + "modes": list(adapter.modes), + } + + +def _media_frame_container_family(frame: Any, *, field_name: str, index: int) -> str: + label = f"{field_name} frame {index + 1}" + if isinstance(frame, Image.Image): + return "pil" + if isinstance(frame, np.ndarray): + return "numpy" + + frame_type = type(frame) + module_name = str(getattr(frame_type, "__module__", "")) + is_torch_like = ( + module_name.startswith("torch") + and callable(getattr(frame, "detach", None)) + and hasattr(frame, "device") + and hasattr(frame, "dtype") + and hasattr(frame, "shape") + ) + if is_torch_like: + return "torch" + raise ValueError(f"Wan VACE {label} must be a PIL image, NumPy array, or Torch tensor-like image.") + + +def _media_frame_spatial_size(frame: Any, *, field_name: str, index: int) -> tuple[int, int]: + """Validate one image-like frame without importing a heavyweight runtime.""" + + family = _media_frame_container_family(frame, field_name=field_name, index=index) + size = getattr(frame, "size", None) + + label = f"{field_name} frame {index + 1}" + if family == "pil": + try: + width, height = (int(value) for value in size) + except (TypeError, ValueError) as error: + raise ValueError(f"Wan VACE {label} has an invalid PIL spatial size.") from error + else: + try: + shape = tuple(int(value) for value in frame.shape) + except (AttributeError, TypeError, ValueError) as error: + raise ValueError(f"Wan VACE {label} has an invalid array/tensor shape.") from error + if len(shape) == 2: + height, width = shape + elif len(shape) == 3: + # Diffusers' image/video processors use NumPy HWC and Torch CHW. + # Accepting the opposite layout here is unsafe for masked VACE + # modes: neutralization broadcasts along those canonical channel + # axes before the upstream processor runs. + if family == "numpy": + if shape[-1] not in {1, 3, 4}: + raise ValueError(f"Wan VACE {label} NumPy images must use HWC layout with 1, 3, or 4 channels.") + height, width = shape[0], shape[1] + else: + if shape[0] not in {1, 3, 4}: + raise ValueError( + f"Wan VACE {label} Torch tensor-like images must use CHW layout with 1, 3, or 4 channels." + ) + height, width = shape[1], shape[2] + else: + raise ValueError(f"Wan VACE {label} must be a 2D or 3D image frame; received shape {shape}.") + + if width <= 0 or height <= 0: + raise ValueError(f"Wan VACE {label} must have positive spatial dimensions; received {width}x{height}.") + return width, height + + +def _validate_media_sequence( + frames: list[Any] | None, + *, + field_name: str, + uniform_spatial_size: bool, +) -> list[tuple[int, int]]: + if frames is None: + return [] + families = [ + _media_frame_container_family(frame, field_name=field_name, index=index) for index, frame in enumerate(frames) + ] + if any(family != families[0] for family in families[1:]): + received = ", ".join(dict.fromkeys(families)) + raise ValueError(f"Wan VACE {field_name} frames must use one container family; received {received}.") + sizes = [ + _media_frame_spatial_size(frame, field_name=field_name, index=index) for index, frame in enumerate(frames) + ] + if uniform_spatial_size and any(size != sizes[0] for size in sizes[1:]): + raise ValueError(f"Wan VACE {field_name} frames must all have the same spatial dimensions.") + return sizes + + +def _normalize_wan_vace_reference_images(value: Any) -> list[Image.Image] | None: + references = ensure_reference_images(value) + if references is None: + return None + + nested = [item for item in references if isinstance(item, (list, tuple))] + if nested: + if len(references) != 1: + raise ValueError("Wan VACE reference images support one flat list or one nested batch only.") + references = list(nested[0]) + if not references: + return None + if not all(isinstance(reference, Image.Image) for reference in references): + raise ValueError("Wan VACE reference images must be actual PIL images.") + if len(references) > WAN_VACE_MAX_REFERENCE_IMAGES: + raise ValueError(f"Wan VACE accepts at most {WAN_VACE_MAX_REFERENCE_IMAGES} reference images per video.") + return references + + +def _bounded_wan_vace_num_frames(value: Any) -> int: + if isinstance(value, bool): + raise ValueError("Wan VACE num_frames must be a finite integer from 1 through 241.") + try: + number = float(81 if value is None else value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError("Wan VACE num_frames must be a finite integer from 1 through 241.") from error + if not isfinite(number) or not number.is_integer() or number < 1 or number > 241: + raise ValueError("Wan VACE num_frames must be a finite integer from 1 through 241.") + return int(number) + + +def _bounded_wan_vace_int( + value: Any, + *, + default: int, + label: str, + minimum: int, + maximum: int, +) -> int: + if isinstance(value, bool): + raise ValueError(f"Wan VACE {label} must be a finite integer from {minimum} through {maximum}.") + try: + number = float(default if value is None else value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"Wan VACE {label} must be a finite integer from {minimum} through {maximum}.") from error + if not isfinite(number) or not number.is_integer() or number < minimum or number > maximum: + raise ValueError(f"Wan VACE {label} must be a finite integer from {minimum} through {maximum}.") + return int(number) + + +def _bounded_wan_vace_float( + value: Any, + *, + default: float, + label: str, + minimum: float, + maximum: float, +) -> float: + if isinstance(value, bool): + raise ValueError(f"Wan VACE {label} must be finite and from {minimum:g} through {maximum:g}.") + try: + number = float(default if value is None else value) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError(f"Wan VACE {label} must be finite and from {minimum:g} through {maximum:g}.") from error + if not isfinite(number) or number < minimum or number > maximum: + raise ValueError(f"Wan VACE {label} must be finite and from {minimum:g} through {maximum:g}.") + return number + + +def _normalize_wan_vace_scalar_contract(kwargs: dict[str, Any]) -> dict[str, Any]: + """Validate graph-controlled VACE resource values before Torch/upstream.""" + + pipeline = kwargs.get("pipeline") + width = _bounded_wan_vace_int( + kwargs.get("width"), + default=832, + label="width", + minimum=16, + maximum=2048, + ) + height = _bounded_wan_vace_int( + kwargs.get("height"), + default=480, + label="height", + minimum=16, + maximum=2048, + ) + validate_dimensions(width, height, pipeline) + + requested_num_frames = _bounded_wan_vace_num_frames(kwargs.get("num_frames")) + normalized_num_frames = normalize_num_frames(requested_num_frames, pipeline) + if normalized_num_frames > 241: + raise ValueError( + "Wan VACE num_frames normalization exceeds the supported maximum of 241; " + f"received {requested_num_frames}, normalized to {normalized_num_frames}." + ) + + output_type = kwargs.get("output_type") + output_type = "pil" if output_type is None else output_type + if not isinstance(output_type, str) or output_type not in {"pil", "np", "pt"}: + raise ValueError("Wan VACE output_type must be exactly one of: pil, np, pt.") + + return { + "width": width, + "height": height, + "num_frames": normalized_num_frames, + "num_inference_steps": _bounded_wan_vace_int( + kwargs.get("num_inference_steps"), + default=30, + label="inference steps", + minimum=1, + maximum=100, + ), + "guidance_scale": _bounded_wan_vace_float( + kwargs.get("guidance_scale"), + default=5.0, + label="guidance scale", + minimum=0, + maximum=20, + ), + "guidance_scale_2": _bounded_wan_vace_float( + kwargs.get("guidance_scale_2"), + default=0.0, + label="secondary guidance scale", + minimum=0, + maximum=20, + ), + "conditioning_scale": _bounded_wan_vace_float( + kwargs.get("conditioning_scale"), + default=1.0, + label="conditioning scale", + minimum=0, + maximum=2, + ), + "seed": _bounded_wan_vace_int( + kwargs.get("seed"), + default=0, + label="seed", + minimum=0, + maximum=WAN_VACE_MAX_SEED, + ), + "num_videos_per_prompt": _bounded_wan_vace_int( + kwargs.get("num_videos_per_prompt"), + default=1, + label="videos per prompt", + minimum=1, + maximum=1, + ), + "output_type": output_type, + "max_sequence_length": _bounded_wan_vace_int( + kwargs.get("max_sequence_length"), + default=WAN_VACE_MAX_SEQUENCE_LENGTH, + label="max sequence length", + minimum=1, + maximum=WAN_VACE_MAX_SEQUENCE_LENGTH, + ), + } + + +def _validate_wan_vace_media_contract(mode: str, kwargs: dict[str, Any]) -> dict[str, Any]: + contract = WAN_VACE_MODE_MEDIA_CONTRACTS.get(mode) + if contract is None: + raise ValueError(f"WanVACEPipeline does not have a media contract for video mode {mode}.") + + media = { + "video": ensure_video_list(kwargs.get("video"), "video"), + "mask": ensure_video_list(kwargs.get("mask"), "mask"), + "reference_images": _normalize_wan_vace_reference_images(kwargs.get("reference_images")), + } + labels = { + "video": "source/control video", + "mask": "mask video", + "reference_images": "reference images", + } + for field in ("video", "mask", "reference_images"): + requirement = getattr(contract, field) + if requirement not in {"required", "optional", "forbidden"}: + raise RuntimeError(f"Wan VACE {mode} has an invalid {field} media requirement {requirement!r}.") + present = media[field] is not None + if requirement == "required" and not present: + raise ValueError(f"Wan VACE {mode} requires {labels[field]}.") + if requirement == "forbidden" and present: + raise ValueError(f"Wan VACE {mode} does not accept {labels[field]}.") + + video_sizes = _validate_media_sequence( + media["video"], field_name="source/control video", uniform_spatial_size=True + ) + mask_sizes = _validate_media_sequence(media["mask"], field_name="mask video", uniform_spatial_size=True) + reference_sizes = _validate_media_sequence( + media["reference_images"], + field_name="reference image", + uniform_spatial_size=False, + ) + reference_pixels = sum(width * height for width, height in reference_sizes) + if reference_pixels > WAN_VACE_MAX_REFERENCE_PIXELS: + raise ValueError( + f"Wan VACE reference images exceed the {WAN_VACE_MAX_REFERENCE_PIXELS}-pixel cumulative input limit." + ) + + if media["video"] is not None and media["mask"] is not None: + if len(media["video"]) != len(media["mask"]): + raise ValueError( + f"Wan VACE video/mask frame count mismatch: {len(media['video'])} vs {len(media['mask'])}." + ) + if any(video_size != mask_size for video_size, mask_size in zip(video_sizes, mask_sizes)): + raise ValueError("Wan VACE source/control video and mask frames must have matching spatial dimensions.") + for index, (video_frame, mask_frame) in enumerate(zip(media["video"], media["mask"])): + video_family = _media_frame_container_family( + video_frame, + field_name="source/control video", + index=index, + ) + mask_family = _media_frame_container_family(mask_frame, field_name="mask video", index=index) + if video_family != mask_family: + raise ValueError( + "Wan VACE source/control video and mask frame " + f"{index + 1} must use the same container family; received {video_family} and {mask_family}." + ) + if video_family in {"numpy", "torch"}: + video_ndim = len(tuple(video_frame.shape)) + mask_ndim = len(tuple(mask_frame.shape)) + if video_ndim == 2 and mask_ndim != 2: + raise ValueError( + "Wan VACE 2D source/control video frames require 2D mask frames; " + f"frame {index + 1} received a {mask_ndim}D mask." + ) + + scalar_values = _normalize_wan_vace_scalar_contract(kwargs) + normalized_num_frames = scalar_values["num_frames"] + if media["video"] is not None and len(media["video"]) != normalized_num_frames: + raise ValueError( + f"Wan VACE {mode} received {len(media['video'])} conditioned video frames, but normalized " + f"num_frames is {normalized_num_frames}." + ) + if ( + media["video"] is not None + and normalized_num_frames > WAN_VACE_NATIVE_CHUNK_FRAMES + and scalar_values["output_type"] != "pil" + ): + raise ValueError( + "Segmented Wan VACE conditioned video currently requires output_type=pil; " + "NumPy and Torch chunk concatenation are not supported." + ) + + return {**media, **scalar_values} + + +DEFAULT_VIDEO_CONTRACT = _adapter_signal(VIDEO_PIPELINE_ADAPTERS["WanVACEPipeline"]) + + +def _require_video_mode(value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("An exact non-empty Diffusers video mode is required.") + return value def _normalize_ltx_frames(value: int) -> int: @@ -270,50 +1047,98 @@ class LoadPipeline(WanVACELoadPipeline): category = "Diffusers Video" params = { **WanVACELoadPipeline.params, - "pipeline": {"label": "Pipeline", "display": "output", "type": "video_diffusion_pipeline"}, + "model_id": { + **WanVACELoadPipeline.params["model_id"], + "onChange": "select_adapter", + }, + "pipeline": { + "label": "Pipeline", + "display": "output", + "type": "video_diffusion_pipeline", + "signal": { + "direction": "output", + "origin": "pipeline_class", + "value": DEFAULT_VIDEO_CONTRACT, + }, + }, "pipeline_class": { "label": "Pipeline Class", "type": "string", "options": list(VIDEO_PIPELINE_ADAPTERS), "default": "WanVACEPipeline", + "fieldOptions": {"noValidation": True}, + "onChange": "select_adapter", }, "resolved_artifact": {"label": "Resolved Artifact", "display": "output", "type": "string"}, } + def __call__(self, **kwargs): + # A missing, null, or malformed class is not distinguishable from a + # stale imported graph. New graphs persist the declared default + # explicitly, so execution can require exact identity here. + adapter = get_video_pipeline_adapter(kwargs.get("pipeline_class")) + values = dict(kwargs) + values["model_id"] = _resolve_adapter_model_selection(adapter, values.get("model_id")) + model_id = repo_value(values["model_id"]) + values["revision"] = _resolve_loader_revision(values["model_id"], model_id, values.get("revision")) + return super().__call__(**values) + def execute(self, **kwargs): adapter = get_video_pipeline_adapter(kwargs.get("pipeline_class")) values = dict(kwargs) values["model_id"] = _resolve_adapter_model_selection(adapter, values.get("model_id")) - if adapter.pipeline_class == "WanVACEPipeline": - result = super().execute(**values) - pipeline = result["pipeline"] - elif adapter.pipeline_class == "WanVideoToVideoPipeline": - pipeline = self._load_wan_video_to_video(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class in {"WanPipeline", "Wan22Pipeline", "WanTI2VPipeline"}: - pipeline = self._load_wan_text_to_video(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class == "WanImageToVideoPipeline": - pipeline = self._load_wan_image_to_video(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class == "LTXConditionPipeline": - pipeline = self._load_ltx(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class == "LTXI2VLongMultiPromptPipeline": - pipeline = self._load_ltx_long(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class == "LTX2ConditionPipeline": - pipeline = self._load_ltx2(adapter, values) - result = {"pipeline": pipeline} - elif adapter.pipeline_class == "WanAnimatePipeline": - pipeline = self._load_wan_animate(adapter, values) - result = {"pipeline": pipeline} - else: - pipeline = self._load_framepack(adapter, values) - result = {"pipeline": pipeline} + model_id = repo_value(values["model_id"]) + values["revision"] = _resolve_loader_revision(values["model_id"], model_id, values.get("revision")) + handler_name = VIDEO_PIPELINE_LOAD_HANDLERS.get(adapter.pipeline_class) + handler = getattr(self, handler_name, None) if handler_name else None + if not callable(handler): + raise RuntimeError(f"No loader handler is registered for video adapter {adapter.pipeline_class}.") + pipeline = handler(adapter, values) setattr(pipeline, "_modiff_video_pipeline_class", adapter.pipeline_class) - setattr(pipeline, "_modiff_video_repo", repo_value(values.get("model_id")) or adapter.default_repo) - return {**result, "resolved_artifact": values.get("model_id")} + setattr(pipeline, "_modiff_video_repo", model_id or adapter.default_repo) + setattr(pipeline, "_modiff_video_revision", values["revision"]) + return { + "pipeline": pipeline, + "resolved_artifact": model_id or adapter.default_repo, + } + + def select_adapter(self, values, ref): + values = values if isinstance(values, dict) else {} + adapter = get_video_pipeline_adapter(values.get("pipeline_class")) + selected = values.get("model_id") + resolved = _resolve_adapter_model_selection(adapter, selected) + signal_origin = ref.get("key") if isinstance(ref, dict) else ref + field_values = {} + if resolved != selected: + field_values["model_id"] = resolved + if resolved["source"] == "local": + field_values["revision"] = "" + else: + managed_revision = catalog_revision(resolved["value"]) + if managed_revision is not None: + field_values["revision"] = managed_revision + elif signal_origin == "model_id": + # A custom repository selected by this action must not inherit + # the commit belonging to the previously visible repository. + field_values["revision"] = "" + elif values.get("revision") not in (None, ""): + _resolve_loader_revision(resolved, resolved["value"], values.get("revision")) + if field_values: + self.set_field_value(field_values) + self.set_field_params( + "pipeline", + { + "signal": { + "direction": "output", + "origin": str(signal_origin or "pipeline_class"), + "value": _adapter_signal(adapter), + } + }, + ) + + def _load_wan_vace(self, adapter: VideoPipelineAdapter, kwargs: dict[str, Any]): + result = super().execute(**kwargs) + return result["pipeline"] def _load_ltx(self, adapter: VideoPipelineAdapter, kwargs: dict[str, Any]): from diffusers import LTXConditionPipeline @@ -663,15 +1488,40 @@ class Generate(WanVACEGenerate): "min": 0, "max": 9007199254740991, }, - "pipeline": {"label": "Pipeline", "display": "input", "type": "video_diffusion_pipeline", "required": True}, + "pipeline": { + "label": "Pipeline", + "display": "input", + "type": "video_diffusion_pipeline", + "required": True, + "onSignal": [ + {"action": "value", "target": "video_contract"}, + {"action": "exec", "data": "update_adapter_modes"}, + ], + }, + "video_contract": { + "label": "Video Contract", + "type": "object", + "default": DEFAULT_VIDEO_CONTRACT, + "hidden": True, + }, "mode": { "label": "Mode", "type": "string", "options": sorted({mode for adapter in VIDEO_PIPELINE_ADAPTERS.values() for mode in adapter.modes}), "default": "text_to_video", + "fieldOptions": {"noValidation": True}, + "onChange": "update_adapter_modes", }, "frame_rate": {"label": "Frame rate", "type": "int", "default": 25, "min": 1, "max": 60}, - "strength": {"label": "Condition strength", "type": "float", "default": 1.0, "min": 0, "max": 1, "step": 0.05}, + "strength": { + "label": "Condition strength", + "type": "float", + "default": 1.0, + "min": 0, + "max": 1, + "step": 0.05, + "fieldOptions": {"studioBinding": _studio_identity_binding("strength")}, + }, "denoise_strength": { "label": "Denoise strength", "type": "float", @@ -707,7 +1557,12 @@ class Generate(WanVACEGenerate): }, "pose_video": {"label": "Pose Video", "display": "input", "type": ["video", "str"], "required": False}, "face_video": {"label": "Face Video", "display": "input", "type": ["video", "str"], "required": False}, - "background_video": {"label": "Background Video", "display": "input", "type": ["video", "str"], "required": False}, + "background_video": { + "label": "Background Video", + "display": "input", + "type": ["video", "str"], + "required": False, + }, "segment_frame_length": {"label": "Segment Frames", "type": "int", "default": 77, "min": 5, "max": 241}, "previous_conditioning_frames": {"label": "Previous Frames", "type": "int", "default": 1, "min": 1, "max": 16}, "motion_encode_batch_size": {"label": "Motion Batch", "type": "int", "default": 1, "min": 1, "max": 32}, @@ -737,20 +1592,58 @@ class Generate(WanVACEGenerate): }, } + def __call__(self, **kwargs): + values = dict(kwargs) + values["mode"] = _require_video_mode(values.get("mode")) + pipeline = values.get("pipeline") + if pipeline is not None: + try: + adapter = _pipeline_adapter(pipeline) + except ValueError: + # Preserve the established NodeBase error wrapping for stale or + # inconsistent pipeline identities. Execution validates it + # authoritatively before dispatch. + adapter = None + if adapter is not None and adapter.pipeline_class == "WanVACEPipeline": + values.update(_normalize_wan_vace_scalar_contract(values)) + return super().__call__(**values) + def execute(self, **kwargs): _adapter, result = self._execute_with_adapter(**kwargs) result.pop("_audio", None) return result + def update_adapter_modes(self, values, ref): + values = values if isinstance(values, dict) else {} + signal = values.get("video_contract") + if not isinstance(signal, dict): + raise ValueError("The connected video pipeline did not publish a valid adapter contract.") + adapter = get_video_pipeline_adapter(signal.get("pipelineClass")) + if signal != _adapter_signal(adapter): + raise ValueError("The connected video pipeline published a stale or mismatched adapter contract.") + current_mode = values.get("mode") + selected_mode = current_mode if current_mode in adapter.modes else adapter.modes[0] + field_contract = get_video_mode_field_contract(adapter, selected_mode) + self.set_field_params( + "mode", + {"options": list(adapter.modes), "default": adapter.modes[0], "value": selected_mode}, + ) + for field, params in field_contract.field_param_overlay().items(): + self.set_field_params(field, params) + def _execute_with_adapter(self, **kwargs): pipeline = kwargs.get("pipeline") if pipeline is None: raise ValueError("A Diffusers video pipeline is required.") adapter = _pipeline_adapter(pipeline) - mode = str(kwargs.get("mode") or "text_to_video") + mode = _require_video_mode(kwargs.get("mode")) if mode not in adapter.modes: raise ValueError(f"{adapter.pipeline_class} does not support video mode {mode}.") - return adapter, self._execute_adapter(pipeline, adapter, mode, kwargs) + values = dict(kwargs) + values["mode"] = mode + if adapter.pipeline_class == "WanVACEPipeline": + values.update(_validate_wan_vace_media_contract(mode, values)) + return adapter, self._execute_adapter(pipeline, adapter, mode, values) def _execute_adapter( self, @@ -759,27 +1652,22 @@ def _execute_adapter( mode: str, kwargs: dict[str, Any], ): - if adapter.pipeline_class == "WanVACEPipeline": - return super().execute(**kwargs) - if adapter.pipeline_class == "WanVideoToVideoPipeline": - return self._execute_wan_video_to_video(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class in {"WanPipeline", "Wan22Pipeline", "WanTI2VPipeline"}: - return self._execute_wan_text_to_video(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class == "WanImageToVideoPipeline": - return self._execute_wan_image_to_video(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class == "LTXConditionPipeline": - return self._execute_ltx(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class == "LTXI2VLongMultiPromptPipeline": - return self._execute_ltx_long(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class == "LTX2ConditionPipeline": - return self._execute_ltx2(pipeline, adapter, mode, kwargs) - if adapter.pipeline_class == "WanAnimatePipeline": - return self._execute_wan_animate(pipeline, adapter, mode, kwargs) - return self._execute_framepack(pipeline, adapter, mode, kwargs) + handler_name = VIDEO_PIPELINE_EXECUTE_HANDLERS.get(adapter.pipeline_class) + handler = getattr(self, handler_name, None) if handler_name else None + if not callable(handler): + raise RuntimeError(f"No execution handler is registered for video adapter {adapter.pipeline_class}.") + return handler(pipeline, adapter, mode, kwargs) - def _execute_wan_animate(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, kwargs: dict[str, Any]): - import torch + def _execute_wan_vace( + self, + pipeline: Any, + adapter: VideoPipelineAdapter, + mode: str, + kwargs: dict[str, Any], + ): + return super().execute(**kwargs) + def _execute_wan_animate(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, kwargs: dict[str, Any]): references = ensure_reference_images(kwargs.get("reference_images")) if not references or len(references) != 1: raise ValueError("Wan Animate needs exactly one character reference image.") @@ -796,6 +1684,8 @@ def _execute_wan_animate(self, pipeline: Any, adapter: VideoPipelineAdapter, mod raise ValueError("Wan character replacement needs background and mask videos.") if call_mode == "animate" and (background is not None or mask is not None): raise ValueError("Wan character animation does not accept background or mask videos.") + import torch + device = getattr(pipeline, "_execution_device", None) or "cpu" generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed") or 0)) height = int(kwargs.get("height") or 720) @@ -841,8 +1731,6 @@ def _execute_wan_animate(self, pipeline: Any, adapter: VideoPipelineAdapter, mod } def _execute_framepack(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, kwargs: dict[str, Any]): - import torch - if mode != "image_to_video": raise ValueError("FramePack supports image_to_video generation only.") if ensure_video_list(kwargs.get("video"), "video") is not None: @@ -864,6 +1752,8 @@ def _execute_framepack(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: prompt = ensure_single_prompt(none_if_blank(kwargs.get("prompt")), "prompt") negative_prompt = ensure_single_prompt(none_if_blank(kwargs.get("negative_prompt")), "negative prompt") _validate_prompt_token_limit(pipeline, prompt, "prompt", adapter.max_prompt_tokens) + import torch + device = getattr(pipeline, "_execution_device", None) or "cpu" generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed") or 0)) call_kwargs = { @@ -912,8 +1802,6 @@ def _execute_wan_text_to_video( mode: str, kwargs: dict[str, Any], ): - import torch - if mode != "text_to_video": raise ValueError(f"{adapter.pipeline_class} does not support video mode {mode}.") if ensure_video_list(kwargs.get("video"), "video") is not None: @@ -927,6 +1815,8 @@ def _execute_wan_text_to_video( negative_prompt = ensure_single_prompt(none_if_blank(kwargs.get("negative_prompt")), "negative prompt") _validate_prompt_token_limit(pipeline, prompt, "prompt", adapter.max_prompt_tokens) _validate_prompt_token_limit(pipeline, negative_prompt, "negative prompt", adapter.max_prompt_tokens) + import torch + is_ti2v = adapter.pipeline_class == "WanTI2VPipeline" width = int(kwargs.get("width") or (1280 if is_ti2v else 832)) height = int(kwargs.get("height") or (704 if is_ti2v else 480)) @@ -989,8 +1879,6 @@ def _execute_wan_video_to_video( mode: str, kwargs: dict[str, Any], ): - import torch - prompt = ensure_single_prompt(none_if_blank(kwargs.get("prompt")), "prompt") negative_prompt = ensure_single_prompt(none_if_blank(kwargs.get("negative_prompt")), "negative prompt") video = ensure_video_list(kwargs.get("video"), "video") @@ -1004,6 +1892,8 @@ def _execute_wan_video_to_video( strength = float(kwargs.get("strength", 0.8)) if not 0 < strength <= 1: raise ValueError(f"Wan {mode} strength must be greater than 0 and at most 1; received {strength}.") + import torch + width = int(kwargs.get("width", 832)) height = int(kwargs.get("height", 480)) device = getattr(pipeline, "_execution_device", None) or "cpu" @@ -1053,8 +1943,6 @@ def _execute_wan_image_to_video( mode: str, kwargs: dict[str, Any], ): - import torch - if mode not in {"image_to_video", "reference_to_video"}: raise ValueError(f"{adapter.pipeline_class} does not support video mode {mode}.") if ensure_video_list(kwargs.get("video"), "video") is not None: @@ -1083,6 +1971,8 @@ def _execute_wan_image_to_video( num_frames = normalize_num_frames(int(kwargs.get("num_frames") or 81), pipeline) if num_frames < 81: raise ValueError("Quality-first Wan shots need at least 81 frames (about five seconds at 16 fps).") + import torch + device = getattr(pipeline, "_execution_device", None) or "cpu" generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed") or 0)) call_kwargs = { @@ -1126,9 +2016,6 @@ def _execute_wan_image_to_video( } def _execute_ltx(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, kwargs: dict[str, Any]): - import torch - from diffusers.pipelines.ltx.pipeline_ltx_condition import LTXVideoCondition - prompt = ensure_single_prompt(none_if_blank(kwargs.get("prompt")), "prompt") negative_prompt = ensure_single_prompt(none_if_blank(kwargs.get("negative_prompt")), "negative prompt") video = ensure_video_list(kwargs.get("video"), "video") @@ -1152,6 +2039,9 @@ def _execute_ltx(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, height = int(kwargs.get("height", 480)) _validate_ltx_dimensions(width, height) num_frames = _normalize_ltx_frames(int(kwargs.get("num_frames", 97))) + import torch + from diffusers.pipelines.ltx.pipeline_ltx_condition import LTXVideoCondition + device = getattr(pipeline, "_execution_device", None) or "cpu" generator = torch.Generator(device=device).manual_seed(int(kwargs.get("seed", 0))) call_kwargs = { @@ -1325,9 +2215,6 @@ def _execute_ltx_long(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: } def _execute_ltx2(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, kwargs: dict[str, Any]): - import torch - from diffusers.pipelines.ltx2.pipeline_ltx2_condition import LTX2VideoCondition - prompt = ensure_single_prompt(none_if_blank(kwargs.get("prompt")), "prompt") negative_prompt = ensure_single_prompt(none_if_blank(kwargs.get("negative_prompt")), "negative prompt") video = ensure_video_list(kwargs.get("video"), "video") @@ -1343,6 +2230,9 @@ def _execute_ltx2(self, pipeline: Any, adapter: VideoPipelineAdapter, mode: str, _validate_ltx_dimensions(width, height) num_frames = _normalize_ltx_frames(int(kwargs.get("num_frames") or 121)) strength = float(_value_or_default(kwargs, "strength", 1)) + import torch + from diffusers.pipelines.ltx2.pipeline_ltx2_condition import LTX2VideoCondition + conditions = None if references: conditions = [ @@ -1406,6 +2296,11 @@ class GenerateVideoAudio(NodeBase): "duration_seconds": {"label": "Audio Duration", "display": "output", "type": "float"}, } + def __call__(self, **kwargs): + values = dict(kwargs) + values["mode"] = _require_video_mode(values.get("mode")) + return super().__call__(**values) + def execute(self, **kwargs): from modules.DiffusersAudio.main import output_to_audio_object @@ -1451,7 +2346,12 @@ class BuildShotJobs(NodeBase): params = { "shots": {"label": "Shot Plan", "display": "input", "type": "collection"}, "opening_images": {"label": "Opening Keyframes", "display": "input", "type": "image"}, - "ending_images": {"label": "Optional Ending Keyframes", "display": "input", "type": "image", "required": False}, + "ending_images": { + "label": "Optional Ending Keyframes", + "display": "input", + "type": "image", + "required": False, + }, "mode": { "label": "Shot Mode", "type": "string", @@ -1554,9 +2454,7 @@ def execute(self, **kwargs): "height": int(kwargs.get("height") or 480), "steps": int(kwargs.get("steps") or 40), "guidance_scale": float(_value_or_default(kwargs, "guidance_scale", 3.5)), - "secondary_guidance_scale": float( - _value_or_default(kwargs, "secondary_guidance_scale", 3.5) - ), + "secondary_guidance_scale": float(_value_or_default(kwargs, "secondary_guidance_scale", 3.5)), "conditioning_strength": float( _value_or_default( shot, @@ -1704,10 +2602,7 @@ def sequence_pipe_callback(pipe, step_index, timestep, callback_kwargs, *, shot_ self.progress( int(completed_steps / total_steps * 100), phase="denoising", - message=( - f"Shot {shot_index + 1}/{len(shots)}: " - f"denoising {completed_in_shot}/{shot_steps}" - ), + message=(f"Shot {shot_index + 1}/{len(shots)}: denoising {completed_in_shot}/{shot_steps}"), current_step=completed_steps, total_steps=total_steps, ) diff --git a/modules/DiffusersVideo/wan_vace.py b/modules/DiffusersVideo/wan_vace.py index 2bccaae..f994f35 100644 --- a/modules/DiffusersVideo/wan_vace.py +++ b/modules/DiffusersVideo/wan_vace.py @@ -37,24 +37,28 @@ def none_if_blank(value: Any): def ensure_single_prompt(prompt: Any, field_name: str): if isinstance(prompt, list): - raise ValueError(f"Wan VACE currently supports one {field_name}; prompt lists are not supported by this pipeline.") + raise ValueError( + f"Wan VACE currently supports one {field_name}; prompt lists are not supported by this pipeline." + ) return prompt def ensure_video_list(value: Any, field_name: str): - if value in (None, ""): + if value is None or (isinstance(value, str) and value == ""): return None - if isinstance(value, list): - return value + if isinstance(value, (list, tuple)): + normalized = list(value) + return normalized or None raise ValueError(f"{field_name} must be a video frame list or be left empty.") def ensure_reference_images(value: Any): - if value in (None, ""): + if value is None or (isinstance(value, str) and value == ""): return None - if not isinstance(value, list): - return [value] - return value + if isinstance(value, (list, tuple)): + normalized = list(value) + return normalized or None + return [value] def parse_json_object(value: Any, field_name: str): @@ -127,10 +131,12 @@ def _neutralize_masked_region(frame: Any, mask: Any): if isinstance(frame, torch.Tensor) and isinstance(mask, torch.Tensor): mask_values = mask + if frame.ndim == mask_values.ndim == 3 and mask_values.shape[0] in {1, 3, 4}: + mask_values = mask_values[0] threshold = 0.5 if mask_values.is_floating_point() and float(mask_values.max()) <= 1 else 127 generate = mask_values > threshold while generate.ndim < frame.ndim: - generate = generate.unsqueeze(-1) + generate = generate.unsqueeze(0) if generate.shape != frame.shape: generate = torch.broadcast_to(generate, frame.shape) if frame.is_floating_point(): @@ -147,7 +153,7 @@ def _neutralize_masked_region(frame: Any, mask: Any): import numpy as np if isinstance(frame, np.ndarray) and isinstance(mask, np.ndarray): - mask_values = mask[..., 0] if mask.ndim == frame.ndim else mask + mask_values = mask[..., 0] if frame.ndim == mask.ndim == 3 else mask threshold = 0.5 if np.issubdtype(mask_values.dtype, np.floating) and float(mask_values.max()) <= 1 else 127 generate = mask_values > threshold while generate.ndim < frame.ndim: @@ -169,9 +175,7 @@ def _neutralize_masked_region(frame: Any, mask: Any): neutral_color = 127 if len(bands) == 1 else tuple(255 if band == "A" else 127 for band in bands) neutral = Image.new(frame.mode, frame.size, neutral_color) return Image.composite(neutral, frame, mask.convert("L")) - raise TypeError( - f"Wan VACE cannot neutralize {type(frame).__name__} frames with {type(mask).__name__} masks." - ) + raise TypeError(f"Wan VACE cannot neutralize {type(frame).__name__} frames with {type(mask).__name__} masks.") def validate_dimensions(width: int, height: int, pipeline: Any): @@ -184,8 +188,7 @@ def validate_dimensions(width: int, height: int, pipeline: Any): height_multiple = spatial_scale * int(patch_height) if width % width_multiple != 0 or height % height_multiple != 0: raise ValueError( - f"Wan VACE size must be divisible by {width_multiple}x{height_multiple}; " - f"received {width}x{height}." + f"Wan VACE size must be divisible by {width_multiple}x{height_multiple}; received {width}x{height}." ) @@ -235,7 +238,9 @@ def execute(self, **kwargs): model_selection = kwargs.get("model_id") selected_model_id = repo_value(model_selection) model_id = selected_model_id or WAN_VACE_DEFAULT_REPO - model_source = model_selection.get("source") if selected_model_id and isinstance(model_selection, dict) else "hub" + model_source = ( + model_selection.get("source") if selected_model_id and isinstance(model_selection, dict) else "hub" + ) dtype = str_to_dtype(kwargs.get("dtype", "bfloat16")) revision = resolve_model_revision( model_id, @@ -301,21 +306,62 @@ class WanVACEGenerate(NodeBase): "video": {"label": "Source/control video", "display": "input", "type": "video", "required": False}, "mask": {"label": "Mask video", "display": "input", "type": "video", "required": False}, "reference_images": {"label": "Reference images", "display": "input", "type": "image", "required": False}, - "conditioning_scale": {"label": "Conditioning Scale", "display": "slider", "type": "float", "min": 0, "max": 2, "step": 0.05, "default": 1.0}, + "conditioning_scale": { + "label": "Conditioning Scale", + "display": "slider", + "type": "float", + "min": 0, + "max": 2, + "step": 0.05, + "default": 1.0, + }, "width": {"label": "Width", "type": "int", "default": 832, "min": 16, "max": 2048, "step": 16}, "height": {"label": "Height", "type": "int", "default": 480, "min": 16, "max": 2048, "step": 16}, "num_frames": {"label": "Frames", "type": "int", "default": 81, "min": 1, "max": 241, "step": 4}, - "num_inference_steps": {"label": "Steps", "display": "slider", "type": "int", "default": 30, "min": 1, "max": 100}, - "guidance_scale": {"label": "Guidance", "display": "slider", "type": "float", "default": 5.0, "min": 0, "max": 20, "step": 0.1}, - "guidance_scale_2": {"label": "Guidance 2", "display": "slider", "type": "float", "default": 0.0, "min": 0, "max": 20, "step": 0.1}, + "num_inference_steps": { + "label": "Steps", + "display": "slider", + "type": "int", + "default": 30, + "min": 1, + "max": 100, + }, + "guidance_scale": { + "label": "Guidance", + "display": "slider", + "type": "float", + "default": 5.0, + "min": 0, + "max": 20, + "step": 0.1, + }, + "guidance_scale_2": { + "label": "Guidance 2", + "display": "slider", + "type": "float", + "default": 0.0, + "min": 0, + "max": 20, + "step": 0.1, + }, "use_guidance_scale_2": {"label": "Use guidance 2", "type": "bool", "default": False}, "num_videos_per_prompt": {"label": "Videos per prompt", "type": "int", "default": 1, "min": 1, "max": 1}, "seed": {"label": "Seed", "type": "int", "display": "random", "default": 0, "min": 0, "max": 4294967295}, "latents": {"label": "Latents", "display": "input", "type": "tensor", "required": False}, "prompt_embeds": {"label": "Prompt embeds", "display": "input", "type": "tensor", "required": False}, - "negative_prompt_embeds": {"label": "Negative prompt embeds", "display": "input", "type": "tensor", "required": False}, + "negative_prompt_embeds": { + "label": "Negative prompt embeds", + "display": "input", + "type": "tensor", + "required": False, + }, "output_type": {"label": "Output type", "type": "string", "options": ["pil", "np", "pt"], "default": "pil"}, - "attention_kwargs_json": {"label": "Attention kwargs JSON", "display": "textarea", "type": "text", "default": ""}, + "attention_kwargs_json": { + "label": "Attention kwargs JSON", + "display": "textarea", + "type": "text", + "default": "", + }, "callback_on_step_end_tensor_inputs": {"label": "Callback tensors", "type": "string", "default": "latents"}, "max_sequence_length": {"label": "Max sequence length", "type": "int", "default": 512, "min": 1, "max": 2048}, "video_out": {"label": "Video frames", "display": "output", "type": "video"}, @@ -377,7 +423,9 @@ def execute(self, **kwargs): "return_dict": True, "attention_kwargs": parse_json_object(kwargs.get("attention_kwargs_json"), "attention kwargs"), "callback_on_step_end": self.pipe_callback, - "callback_on_step_end_tensor_inputs": callback_tensor_inputs(kwargs.get("callback_on_step_end_tensor_inputs")), + "callback_on_step_end_tensor_inputs": callback_tensor_inputs( + kwargs.get("callback_on_step_end_tensor_inputs") + ), "max_sequence_length": int(kwargs.get("max_sequence_length", 512)), } diff --git a/modules/ModularDiffusers/README.md b/modules/ModularDiffusers/README.md index 5459759..e8ab9ae 100644 --- a/modules/ModularDiffusers/README.md +++ b/modules/ModularDiffusers/README.md @@ -5,14 +5,14 @@ MoDiff integrates the experimental [Diffusers Modular Pipelines](https://huggingface.co/docs/diffusers/main/en/modular_diffusers/overview) APIs with its node graph. A small set of dynamic nodes can expose different model pipelines without creating a separate hardcoded node class for every model family. > [!WARNING] -> Modular Diffusers APIs and compatible Hub repositories are still evolving. A visible node contract is not proof that every model/revision will load or fit on the current hardware. Custom blocks and `trust_remote_code` can execute repository-supplied Python; use only reviewed, revision-pinned sources and read [SECURITY.md](../../SECURITY.md). +> Modular Diffusers APIs and compatible Hub repositories are still evolving. A visible node contract is not proof that every model/revision will load or fit on the current hardware. Custom repository contracts are preview-only in this release; repository-code execution and `trust_remote_code` are fail-closed. Read [SECURITY.md](../../SECURITY.md). ## Concepts - **Dynamic node contracts:** node fields adapt to the selected pipeline configuration. - **Composable workflows:** model loading, prompt encoding, denoising, and decoding can remain separate or be combined into a custom block. - **Shared components:** compatible nodes can reuse components from the package-level `ComponentsManager` instead of loading duplicate models. -- **Hub-backed blocks:** supported repositories can provide Modular Diffusers configuration/code used to construct a node interface. +- **Hub-backed contract previews:** exact cached Hub commits can provide bounded declarative metadata used to construct a node interface; custom execution remains disabled. - **Resource controls:** loaders expose supported quantization and offload modes, subject to package, model, and hardware compatibility. MoDiff adapts Diffusers' Mellon node-metadata helper to supply MoDiff dynamic fields and configuration names; the @@ -44,9 +44,9 @@ Open the workflow library in the left sidebar and expand `modular_diffusers`. Th - `image_to_image` — prompt plus reference-image conditioning. - `multiple_image_edit` — multiple-image editing inputs. - `quantization` — an example with an explicit quantization configuration. -- `dynamic_node` — a Hub-backed dynamic block example. +- `dynamic_node` — a historical Hub-backed contract-preview graph; it is not runnable in this release. -Drag a graph onto the canvas, inspect its selected model and required inputs, then update the graph before running. Models are not bundled with these JSON files; MoDiff may need to download them, and gated repositories may require accepted terms plus a Hugging Face read token. +Drag a runnable built-in graph onto the canvas, inspect its selected model and required inputs, then update the graph before running. Models are not bundled with these JSON files; MoDiff may need to download them, and gated repositories may require accepted terms plus a Hugging Face read token. The historical `dynamic_node` graph remains only as migration input; its selected repository cannot produce a current MoDiff contract preview. [Watch the bundled workflow browser demo (MP4)](https://github.com/user-attachments/assets/a4d0604f-80ea-4470-80e6-53a73e584ca3) @@ -62,12 +62,90 @@ The bundled `text_to_image` graph illustrates five stages: The exact fields and defaults come from the live registry. For example, Flux, Qwen Image, Z-Image, and Wan pipelines do not share one universal guidance, prompt, or step contract. Refresh or recreate a graph when a model's dynamic definition changes. +Reviewed built-in pipeline metadata also declares any additional component that +**Load Models** must load and publish. Wan I2V currently declares its +`image_encoder` this way; the loader no longer selects that requirement from a +pipeline-class branch. The same bounded field in an untrusted custom sidecar is +descriptive only and cannot authorize custom execution. + +The **Layers** node likewise derives its selectable transformer-stack paths +from reviewed built-in pipeline metadata. Both dynamic field creation and graph +execution require the connected model signal and reject an absent, unknown, or +unlisted block path. A custom sidecar cannot extend this executable allowlist. + +The **Guider** node narrows its class selector from the same reviewed pipeline +metadata and validates the connected model signal again at execution. Pipelines +without an upstream Guider component expose no executable choice, and guiders +that consume layer stacks are offered only when that pipeline has a reviewed +Layers allowlist. + +The **Scheduler** replacement selector follows the pinned upstream compatibility +contract. SDXL and Wan expose only scheduler classes compatible with their +expected Euler or UniPC component; flow-matching pipelines expose no legacy +replacement choices. Dynamic field refresh and execution require the connected +reviewed pipeline identity, and execution also verifies the live scheduler +component class before replacement. + +The **Denoise** node also uses reviewed pipeline metadata for the narrow legacy +case where hidden `height` and `width` values must remain available alongside +image latents. Other and unknown pipelines discard those stale dimensions, and +custom sidecar metadata cannot authorize a built-in execution exception. + [Watch a separated Modular Diffusers workflow demo (MP4)](https://github.com/user-attachments/assets/4bbf74ac-404e-46bb-ae51-a84e65c25235) Type a prompt, confirm model readiness, and use **Run**. A queued task response only confirms submission; watch Queue and WebSocket progress for completion or structured failure details. [Watch a workflow execution demo (MP4)](https://github.com/user-attachments/assets/e563eeb0-4f9e-4a27-8304-49fd15b87550) +### Opaque state routes + +Some reviewed built-in Qwen, SDXL, and Wan I2V action contracts dynamically +expose `Route State` connectors. Wan I2V uses **Image Embeddings** → **Encode +Image** → **Denoise** → **Decode Latents**, with image embeddings, image +condition latents, and denoised latents retained on their exact typed edges. +Keep the ordinary typed connections as well as the route connection. The route is a +process-local capability that binds the exact Models Loader execution, +component roles and resident processors, generator state, routed geometry, +crop/overlay state, and paired tensors; +it is not model data and cannot be serialized, copied between loader runs, or +restored from an imported workflow value. Rerun the loader and upstream action +when a route is missing or stale. + +Studio switches to the native mask/overlay path only after the complete route +chain is present. A partial dynamic definition remains pending instead of +guessing a fallback topology. The generic Qwen path and the internal SDXL base +inpaint path carry masks and masked-image latents on their typed graph edges. +SDXL inpaint remains unadvertised, unprofiled, and unqualified. Its internal +VAE route may be combined with the generic SDXL ControlNet bundle only when +the Load Model output is a current, exact `ControlNetModel` or +`ControlNetUnionModel` publication and the selected ordinary/Union variant +matches that class. Selecting Union reveals one bounded numeric control-type +index; Denoise also requires that index to exist in the resident model's +declared `num_control_type` contract. The same resident component must survive +cache validation, pipeline initialization, component installation, and the +upstream call. The internal generic **IP-Adapter Embeddings** action can add one +reviewed standard SDXL adapter to that same resident UNet and pass its exact +positive/negative embeddings to Denoise. It accepts only +`h94/IP-Adapter@018e402774aeeddd60609b4ecdb7e298259dc729` and +`sdxl_models/ip-adapter_sdxl.safetensors`, verifies the cataloged byte size and +SHA-256 from the local Hub cache, and never downloads during graph execution. +Its image encoder also loads locally from the pinned repository revision and +must match the reviewed CLIP ViT-H geometry. The process-local receipt binds the +loader execution, UNet mutation, adapter parameters/scale, encoder, processor, +Guider, source pixels, and embedding tensors through cache and Denoise +boundaries. Re-running Models Loader removes only that current owned mutation +before issuing a new loader receipt. This single-adapter path is contract-only: +it is not a public mode or template, requires the optional Transformers runtime +to have been installed explicitly, and has no live output qualification. +Multiple adapters and Multi-ControlNet remain disabled. Wan +first/last-frame topology remains unadvertised, but its official artifact is +reviewed at an immutable revision. The generic Models Loader accepts that exact +repository variant, and Image Embeddings plus Encode Image require the selected +I2V/FLF workflow to match the loader publication before initializing blocks. +The distinct FLF processor and transformer contracts are then revalidated by +the existing route-state boundary; changing only `last_image`, repository, or +revision fails closed. + ## Reusing a loaded model Compatible tasks can share components from one `Load Models` node. For example, an image-edit path can add image encoding/conditioning nodes while reusing the model components already loaded for text-to-image. @@ -78,11 +156,13 @@ Component reuse depends on compatible pipeline contracts and current cache state ## Dynamic Block -`Dynamic Block` combines a compatible Modular Diffusers block configuration into one graph node. Enter a supported repository ID, load its definition, inspect the generated fields, and connect any required shared components or media inputs. +`Dynamic Block` previews the declarative node contract from a compatible Modular Diffusers repository. Enter a repository ID and an exact 40-character commit, cache that revision through Model Manager, then inspect its sanitized generated fields. Previewing reads only the local Hub cache, performs no network fetch, and does not construct or execute the upstream pipeline. + +The legacy `diffusers/FLUX.2-klein-4B-modular` selector and bundled graph are not valid MoDiff examples because that reviewed revision does not publish `modiff_pipeline_config.json`. They remain migration debt tracked by roadmap segment P1.1 and are not runnable. -The shipped example uses `diffusers/FLUX.2-klein-4B-modular` at the immutable revision recorded in `data/model-artifact-catalog.json`. Repository availability and code can change; custom repositories still require an explicitly reviewed 40-character commit revision. +Dynamic blocks are not arbitrary no-code plugins. Sidecars must use MoDiff's bounded declarative schema, and executable callbacks are rejected. -Dynamic blocks are not arbitrary no-code plugins. They must expose a structure understood by the current Diffusers/MoDiff integration, may require remote Python code, and can fail when upstream APIs or model files change. +`Dynamic Block` is `contract_only` in this release. MoDiff can preview its sanitized fields, but execution fails before Diffusers can import any repository-selected component library. ## Combining workflows @@ -90,19 +170,19 @@ Multiple Modular Diffusers paths can coexist on one canvas and share compatible Only nodes connected to the submitted graph path execute, but shared component state still consumes memory. Inspect Queue, loader diagnostics, and GPU-process information when a combined graph exceeds available resources. -## Custom Hub blocks +## Custom Hub contract previews -MoDiff can load compatible custom blocks from the Hugging Face Hub. This is a trust-sensitive feature: +MoDiff can inspect compatible custom contracts from an exact locally cached Hugging Face Hub commit. This is a bounded, no-network preview path, not an executable custom-pipeline feature: Custom block repositories must publish MoDiff's current `modiff_pipeline_config.json` schema. The loader does not fall back to earlier extension schemas or filenames. 1. Review the repository, owner, dependencies, license, and exact commit. 2. Enter the reviewed 40-character commit revision; moving branches and tags are rejected. -3. Enable `trust_remote_code` only when the repository requires it and you accept that its Python executes with backend-process permissions. -4. Test on a dedicated local environment without sensitive files in `work_dir`. +3. Keep `trust_remote_code` off. The backend rejects it before identity issuance, cache reuse, or model construction. +4. Use the preview only to inspect the sanitized node contract. Executable custom repository code is deferred until MoDiff has a reviewed component dependency contract and isolated, task-scoped authorization. -[Watch the custom prompt block demo (MP4)](https://github.com/user-attachments/assets/d68bc8c1-1b1c-478a-b94b-1e498c60a4fc) +[Watch the historical custom prompt block demo (MP4)](https://github.com/user-attachments/assets/d68bc8c1-1b1c-478a-b94b-1e498c60a4fc). It predates the current fail-closed execution boundary and is not current qualification evidence. ## Additional nodes diff --git a/modules/ModularDiffusers/__init__.py b/modules/ModularDiffusers/__init__.py index a6ffcd4..0cb90f0 100644 --- a/modules/ModularDiffusers/__init__.py +++ b/modules/ModularDiffusers/__init__.py @@ -9,7 +9,15 @@ offload_mode_param, ) -from .modular_utils import ModiffPipelineRegistry +from .modular_utils import ( + FLUX_LAYER_BLOCK_OPTIONS, + QWEN_IMAGE_LAYER_BLOCK_OPTIONS, + SDXL_LAYER_BLOCK_OPTIONS, + ModiffPipelineRegistry, + get_modular_guider_options, + get_modular_layer_block_options, + get_modular_scheduler_options, +) MESSAGE_DURATION = 5000 @@ -25,31 +33,21 @@ "denoise", "embeddings", "guiders", + "ip_adapter", "latents", "loaders", "schedulers", "dynamic_node", ] -SDXL_BLOCKS = [ - "down_blocks.1.attentions.0.transformer_blocks", - "down_blocks.1.attentions.1.transformer_blocks", - "down_blocks.2.attentions.0.transformer_blocks", - "down_blocks.2.attentions.1.transformer_blocks", - "mid_block.attentions.0.transformer_blocks", - "up_blocks.0.attentions.0.transformer_blocks", - "up_blocks.0.attentions.1.transformer_blocks", - "up_blocks.0.attentions.2.transformer_blocks", - "up_blocks.1.attentions.0.transformer_blocks", - "up_blocks.1.attentions.1.transformer_blocks", - "up_blocks.1.attentions.2.transformer_blocks", -] - -QWEN_IMAGE_BLOCKS = ["transformer_blocks"] - -FLUX_BLOCKS = ["transformer_blocks", "single_transformer_blocks"] +SDXL_BLOCKS = list(SDXL_LAYER_BLOCK_OPTIONS) +QWEN_IMAGE_BLOCKS = list(QWEN_IMAGE_LAYER_BLOCK_OPTIONS) +FLUX_BLOCKS = list(FLUX_LAYER_BLOCK_OPTIONS) +MODULAR_LAYER_BLOCK_OPTIONS = get_modular_layer_block_options() +MODULAR_GUIDER_OPTIONS = get_modular_guider_options() +MODULAR_SCHEDULER_OPTIONS = get_modular_scheduler_options() # The static node-registry parser resolves schema constants against this -# package object. Export the Guider options so the public /nodes contract -# contains the actual mapping instead of the unresolved identifier string. +# package object. Export reviewed dynamic options so the public /nodes +# contract contains mappings instead of unresolved identifier strings. from .guiders import GUIDER_OPTIONS as GUIDER_OPTIONS # noqa: E402,F401 diff --git a/modules/ModularDiffusers/adapters.py b/modules/ModularDiffusers/adapters.py index 10d1c17..2214a0d 100644 --- a/modules/ModularDiffusers/adapters.py +++ b/modules/ModularDiffusers/adapters.py @@ -1,10 +1,8 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import json -import hashlib -import os -from pathlib import Path +from pathlib import PurePosixPath from modiff.NodeBase import NodeBase +from modiff.auxiliary_lora import build_lora_descriptor class Lora(NodeBase): @@ -20,21 +18,23 @@ class Lora(NodeBase): "fieldOptions": { "noValidation": True, "sources": ["hub", "local"], - "filter": { - "hub": {"className": [""]}, - "local": {"className": [""]}, - }, }, }, "weight_name": { "label": "Weight Name", "type": "string", }, + "revision": { + "label": "Revision", + "type": "string", + "default": "", + "description": "Required exact commit for a Hub LoRA; unused for a local Safetensors file.", + }, "expected_sha256": { "label": "Expected SHA-256", "type": "string", "default": "", - "description": "Optional immutable hash for the selected adapter weight file.", + "description": "Required for Hub weights; local weights are hashed when this descriptor is created.", }, "scale": { "label": "Scale", @@ -68,68 +68,25 @@ def execute( model, scale, weight_name=None, + revision="", expected_sha256="", scheduler_class="", scheduler_config="{}", ): - if isinstance(model, dict): - lora_path = model.get("value") - if not lora_path: - raise ValueError("A LoRA model is required.") - filename = os.path.splitext(os.path.basename(lora_path))[0] - if model.get("source") == "hub" and lora_path: - from utils.huggingface import cached_file_path - - repo_id = lora_path - if not weight_name: - parts = lora_path.split("/") - if len(parts) >= 3: - repo_id, weight_name = "/".join(parts[:2]), "/".join(parts[2:]) - if not weight_name: - raise ValueError("A Hub LoRA requires a pinned weight_name for app-managed installation.") - cached = cached_file_path(repo_id, weight_name) - if not cached: - raise FileNotFoundError( - f"LoRA {repo_id}/{weight_name} is not installed. Install the pinned file through Model Manager first." - ) - cached_path = Path(cached) - lora_path = str(cached_path.parent) - weight_name = cached_path.name - expected_sha256 = str(expected_sha256 or "").strip().lower().removeprefix("sha256:") - if expected_sha256: - digest = hashlib.sha256() - with cached_path.open("rb") as handle: - for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""): - digest.update(chunk) - if digest.hexdigest() != expected_sha256: - raise ValueError( - f"LoRA {repo_id}/{weight_name} failed its pinned SHA-256 verification. " - "Repair the adapter through Model Manager before running this graph." - ) - else: - lora_path = None - filename = "" - - adapter_name = f"{filename}_{self.node_id}" - - if isinstance(scheduler_config, str): - try: - scheduler_config = json.loads(scheduler_config or "{}") - except json.JSONDecodeError as exc: - raise ValueError(f"LoRA scheduler config must be valid JSON: {exc}") from exc - if not isinstance(scheduler_config, dict): - raise TypeError("LoRA scheduler config must decode to a JSON object.") - - # Return the LoRA configuration directly, including optional generic - # inference metadata for distilled adapters. Models without that - # metadata continue to use the repository scheduler unchanged. - return { - "lora": { - "lora_path": lora_path, - "weight_name": weight_name, - "adapter_name": adapter_name, - "scale": scale, - "scheduler_class": scheduler_class or None, - "scheduler_config": scheduler_config, - } - } + if not isinstance(model, dict) or not model.get("value"): + raise ValueError("A LoRA model is required and must explicitly select a hub or local source.") + requested_weight = str(weight_name or "") + name_seed = PurePosixPath(requested_weight.replace("\\", "/")).stem + if not name_seed: + name_seed = PurePosixPath(str(model.get("value") or "").replace("\\", "/")).stem or "lora" + descriptor = build_lora_descriptor( + selection=model, + weight_name=weight_name, + revision=revision, + expected_sha256=expected_sha256, + adapter_name=f"{name_seed}_{self.node_id}", + scale=scale, + scheduler_class=scheduler_class, + scheduler_config=scheduler_config, + ) + return {"lora": descriptor} diff --git a/modules/ModularDiffusers/controlnet.py b/modules/ModularDiffusers/controlnet.py index db452ec..97e2a9c 100644 --- a/modules/ModularDiffusers/controlnet.py +++ b/modules/ModularDiffusers/controlnet.py @@ -1,20 +1,55 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import importlib import logging from modiff.NodeBase import NodeBase from . import components from .modular_utils import ( - DummyCustomPipeline, + modular_generator_from_seed, + normalize_modular_runtime_params, + normalize_modular_seed, + pipeline_class_from_model_type, pipeline_class_from_runtime_inputs, - pipeline_class_to_modiff_node_config, + reject_undeclared_modular_generator, + require_modiff_node_contract, +) +from .route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + SDXL_UNION_CONTROL_MODE_LIMIT, + consume_controlnet_input_route_state, + issue_controlnet_route_state, + reject_route_reserved_inputs, + reject_route_reserved_inputs_before_identity_resolution, + require_component_binding, + require_route_state_current_publication, + require_route_state_shape_before_identity_resolution, + require_sdxl_controlnet_component_binding, + require_standalone_component_binding, + route_cache_params_equal, + route_contract_for_model_type, + validate_controlnet_input_route_state, + validate_route_field_contract, ) from .utils import collect_model_ids logger = logging.getLogger("modiff") +_SDXL_CONTROLNET_VARIANTS = frozenset({"ordinary", "union"}) + + +def _sdxl_controlnet_selection(values): + variant = values.get("controlnet_variant", "ordinary") + if type(variant) is not str or variant not in _SDXL_CONTROLNET_VARIANTS: + raise ValueError("SDXL ControlNet variant must be exactly 'ordinary' or 'union'.") + if variant == "ordinary": + return False, None + control_mode = values.get("control_mode") + if type(control_mode) is not int or not 0 <= control_mode < SDXL_UNION_CONTROL_MODE_LIMIT: + raise ValueError("SDXL ControlNet Union requires one bounded control-type index.") + return True, control_mode + class ControlnetUnion(NodeBase): label = "ControlNet Union" @@ -158,19 +193,7 @@ class Controlnet(NodeBase): "display": "output", "type": "custom_controlnet", "onSignal": [ - { - "action": "value", - "target": "model_type", - "data": { - "StableDiffusionXLModularPipeline": "StableDiffusionXLModularPipeline", - "QwenImageModularPipeline": "QwenImageModularPipeline", - "QwenImageEditModularPipeline": "QwenImageEditModularPipeline", - "QwenImageEditPlusModularPipeline": "QwenImageEditPlusModularPipeline", - "FluxModularPipeline": "FluxModularPipeline", - "FluxKontextModularPipeline": "FluxKontextModularPipeline", - "DummyCustomPipeline": "DummyCustomPipeline", - }, - }, + {"action": "value", "target": "model_type"}, {"action": "exec", "data": "update_node"}, ], }, @@ -194,19 +217,7 @@ def update_node(self, values, ref): "display": "output", "type": "custom_controlnet", "onSignal": [ - { - "action": "value", - "target": "model_type", - "data": { - "StableDiffusionXLModularPipeline": "StableDiffusionXLModularPipeline", - "QwenImageModularPipeline": "QwenImageModularPipeline", - "QwenImageEditModularPipeline": "QwenImageEditModularPipeline", - "QwenImageEditPlusModularPipeline": "QwenImageEditPlusModularPipeline", - "FluxModularPipeline": "FluxModularPipeline", - "FluxKontextModularPipeline": "FluxKontextModularPipeline", - "DummyCustomPipeline": "DummyCustomPipeline", - }, - }, + {"action": "value", "target": "model_type"}, {"action": "exec", "data": "update_node"}, ], }, @@ -214,22 +225,39 @@ def update_node(self, values, ref): model_type = values.get("model_type", "") - if model_type == "" or self._model_type == model_type: + if self._model_type == model_type: + if not model_type or self._pipeline_class is None: + return None + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + require_blocks=False, + resolve_blocks=False, + ) + node_params_to_update = dict(node_config["params"]) + node_params_to_update.pop("controlnet_bundle", None) + self.send_node_definition({**node_params, **node_params_to_update}) + return None + if model_type == "": + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) return None - self._model_type = model_type - if model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - - # Not supported for this pipeline - if node_config is None: + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + require_blocks=False, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + raise + self._model_type = model_type node_params_to_update = node_config["params"] node_params_to_update.pop("controlnet_bundle", None) @@ -237,15 +265,121 @@ def update_node(self, values, ref): node_params.update(**node_params_to_update) self.send_node_definition(node_params) + def _cache_params_equal(self, previous, current): + equal = route_cache_params_equal(previous, current, fallback=super()._cache_params_equal) + if not equal or not isinstance(current, dict): + return equal + + route_state = current.get(ROUTE_STATE_INPUT) + vae = current.get("vae") + if route_contract_for_model_type(self._model_type) == "sdxl": + require_route_state_shape_before_identity_resolution(current) + reject_undeclared_modular_generator(current) + reject_route_reserved_inputs_before_identity_resolution(current) + reject_route_reserved_inputs(current, model_type=self._model_type, action=self.node_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + require_blocks=False, + resolve_blocks=False, + ) + validate_route_field_contract(current, node_config) + union, _control_mode = _sdxl_controlnet_selection(current) + if {"control_type", "control_type_idx"}.intersection(current): + raise ValueError("SDXL ControlNet Union output fields are backend-managed.") + require_sdxl_controlnet_component_binding(current.get("controlnet"), union=union) + return True + routed_action = route_state is not None or self._model_type == "QwenImageModularPipeline" + routed_action = routed_action or ( + isinstance(vae, dict) and vae.get("model_type") == "QwenImageModularPipeline" + ) + if not routed_action: + return True + + require_route_state_shape_before_identity_resolution(current) + reject_undeclared_modular_generator(current) + reject_route_reserved_inputs_before_identity_resolution(current) + reject_route_reserved_inputs(current) + require_route_state_current_publication(route_state, label="ControlNet input route") + seed = normalize_modular_seed(current.get("seed")) + binding = require_component_binding( + vae, + label="ControlNet VAE", + expected_model_type="QwenImageModularPipeline", + expected_role="vae", + ) + require_standalone_component_binding( + current.get("controlnet"), + label="ControlNet model", + expected_kind="controlnet", + ) + if route_state is not None: + validate_controlnet_input_route_state( + route_state, + binding=binding, + model_type="QwenImageModularPipeline", + seed=seed, + ) + return True + def execute(self, **kwargs): kwargs = dict(kwargs) - self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_undeclared_modular_generator(kwargs) + reject_route_reserved_inputs_before_identity_resolution(kwargs) + identity_kwargs = {name: value for name, value in kwargs.items() if name != ROUTE_STATE_INPUT} + self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, identity_kwargs) + self._model_type = getattr(self._pipeline_class, "__name__", "") # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - denoise_blocks, _ = pipeline_class_to_modiff_node_config(self._pipeline_class, "denoise") - if denoise_blocks is None: - return + blocks, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + require_blocks=False, + ) + denoise_blocks, _ = require_modiff_node_contract(self._pipeline_class, "denoise") + validate_route_field_contract(kwargs, node_config) + if route_contract_for_model_type(self._model_type) == "sdxl": + union, control_mode = _sdxl_controlnet_selection(kwargs) + if {"control_type", "control_type_idx"}.intersection(kwargs): + raise ValueError("SDXL ControlNet Union output fields are backend-managed.") + require_sdxl_controlnet_component_binding(kwargs.get("controlnet"), union=union) + kwargs.pop("controlnet_variant", None) + kwargs.pop("control_mode", None) + if union: + kwargs["control_mode"] = control_mode + + route_output_declared = ROUTE_STATE_OUTPUT in node_config["output_names"] + route_state = kwargs.get(ROUTE_STATE_INPUT) + route_binding = None + controlnet_binding = None + if route_output_declared: + if blocks is None or "generator" not in blocks.input_names: + raise ValueError("The routed Modular ControlNet block does not expose its required generator input.") + kwargs = normalize_modular_runtime_params(kwargs, node_config) + reject_route_reserved_inputs(kwargs) + if kwargs.get("seed") is None: + raise ValueError("A routed Modular ControlNet action requires its originating seed.") + route_binding = require_component_binding( + kwargs.get("vae"), + label="ControlNet VAE", + expected_model_type=self._model_type, + expected_role="vae", + ) + controlnet_binding = require_standalone_component_binding( + kwargs.get("controlnet"), + label="ControlNet model", + expected_kind="controlnet", + ) + if route_state is not None: + validate_controlnet_input_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + ) + elif route_state is not None: + raise ValueError(f"Pipeline '{self._model_type}' does not support opaque Modular route state.") # 2. Cast parameters to the types expected by the modular pipeline. # Preserve the graph compatibility cast until the upstream schema exposes exact types. @@ -262,6 +396,28 @@ def execute(self, **kwargs): # 3. Create pipeline self._pipeline = blocks.init_pipeline(components_manager=components) + def revalidate_route_components(): + if not route_output_declared: + return + require_component_binding( + kwargs.get("vae"), + label="ControlNet VAE", + expected_model_type=self._model_type, + expected_token=route_binding, + expected_role="vae", + ) + require_standalone_component_binding( + kwargs.get("controlnet"), + label="ControlNet model", + expected_kind="controlnet", + expected_binding=controlnet_binding, + ) + + # Pipeline initialization and component-manager activity are + # extension boundaries. Recheck both publications before manager + # lookup and again before the upstream block can execute. + revalidate_route_components() + # 4. Update components expected_component_names = blocks.component_names model_input_names = node_config["model_input_names"] @@ -276,6 +432,20 @@ def execute(self, **kwargs): if components_to_update: self._pipeline.update_components(**components_to_update) + revalidate_route_components() + + if route_output_declared: + if route_state is None: + route_generator = modular_generator_from_seed(kwargs["seed"], self._pipeline) + else: + route_generator = consume_controlnet_input_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + execution_device=self._pipeline._execution_device, + ) + # 5. Compile runtime inputs from kwargs based on node_config.inputs node_kwargs = {} input_names = node_config["input_names"] @@ -285,6 +455,9 @@ def execute(self, **kwargs): continue value = kwargs.get(name) + if name in {ROUTE_STATE_INPUT, "seed"}: + continue + if isinstance(value, dict) and name not in blocks.input_names: for k, v in value.items(): if k in blocks.input_names: @@ -292,8 +465,16 @@ def execute(self, **kwargs): elif name in blocks.input_names: node_kwargs[name] = value + if route_output_declared: + node_kwargs["generator"] = route_generator + # 6. Run the pipeline + revalidate_route_components() node_output = self._pipeline(**node_kwargs).values + # The upstream block call is also an extension boundary. Do not + # publish route state if it changed either reviewed component + # payload while producing the control latents. + revalidate_route_components() # 7. Prepare controlnet output for Denoise node # use the denoise blocks to know what inputs it expects @@ -310,4 +491,14 @@ def execute(self, **kwargs): **controlnet_inputs, } - return {"controlnet_bundle": controlnet_out} + outputs = {"controlnet_bundle": controlnet_out} + if route_output_declared: + outputs[ROUTE_STATE_OUTPUT] = issue_controlnet_route_state( + route_state, + binding=route_binding, + controlnet_component=kwargs.get("controlnet"), + seed=kwargs["seed"], + generator=node_kwargs["generator"], + control_image_latents=node_output.get("control_image_latents") if node_output else None, + ) + return outputs diff --git a/modules/ModularDiffusers/custom_pipeline.py b/modules/ModularDiffusers/custom_pipeline.py new file mode 100644 index 0000000..69443b7 --- /dev/null +++ b/modules/ModularDiffusers/custom_pipeline.py @@ -0,0 +1,379 @@ +"""Versioned drift checksums and contract-only custom Modular bindings.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +import threading +from collections import OrderedDict +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from .pipeline_schema import MoDiffPipelineConfig as PipelineConfig +from .pipeline_schema import MAX_CUSTOM_PIPELINE_REPOSITORY_CHARS +from .pipeline_schema import VerifiedMoDiffPipelineConfig + + +CUSTOM_PIPELINE_MODEL_TYPE = "DummyCustomPipeline" +CUSTOM_PIPELINE_EXECUTION_STATUS = "contract_only" +CUSTOM_PIPELINE_IDENTITY_SCHEMA = "modiff.custom-pipeline-identity.v2" +CUSTOM_PIPELINE_CONFIG_FILENAME = PipelineConfig.config_name +CUSTOM_PIPELINE_IDENTITY_FIELD = "modiff_pipeline_identity" + +_COMMIT_REVISION = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_EXECUTION_ID = re.compile(r"^sha256:[0-9a-f]{64}$") +_IDENTITY_KEYS = { + "schema", + "execution_id", + "model_type", + "source", + "repo_id", + "revision", + "trust_remote_code", + "config_filename", + "config_sha256", + "executable_manifest_sha256", +} + +# Exact sidecars are individually capped at 1 MiB. Keep worst-case retained +# sidecar bytes near 32 MiB; correctness never depends on cache residency. +_BINDING_CACHE_LIMIT = 32 + + +def _canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +def _identity_body( + *, + source: str, + repo_id: str, + revision: str | None, + trust_remote_code: bool, + config_sha256: str, + executable_manifest_sha256: str, +) -> dict[str, Any]: + return { + "schema": CUSTOM_PIPELINE_IDENTITY_SCHEMA, + "model_type": CUSTOM_PIPELINE_MODEL_TYPE, + "source": source, + "repo_id": repo_id, + "revision": revision, + "trust_remote_code": trust_remote_code, + "config_filename": CUSTOM_PIPELINE_CONFIG_FILENAME, + "config_sha256": config_sha256, + "executable_manifest_sha256": executable_manifest_sha256, + } + + +def _execution_id_for_body(body: Mapping[str, Any]) -> str: + return "sha256:" + hashlib.sha256(_canonical_json(body)).hexdigest() + + +@dataclass(frozen=True, slots=True) +class CustomPipelineExecutionIdentity: + """A versioned, source-bound checksum; never an execution authorization.""" + + source: str + repo_id: str + revision: str | None + trust_remote_code: bool + config_sha256: str + executable_manifest_sha256: str + execution_id: str + + @classmethod + def create( + cls, + *, + source: str, + repo_id: str, + revision: str | None, + trust_remote_code: bool, + config_sha256: str, + executable_manifest_sha256: str, + ) -> "CustomPipelineExecutionIdentity": + if not isinstance(source, str) or source not in {"hub", "local"}: + raise ValueError("Custom Modular Diffusers identity source must be exactly 'hub' or 'local'.") + if not isinstance(repo_id, str) or not repo_id or repo_id != repo_id.strip(): + raise ValueError("Custom Modular Diffusers identity requires a normalized non-empty repository.") + if len(repo_id) > MAX_CUSTOM_PIPELINE_REPOSITORY_CHARS: + raise ValueError("Custom Modular Diffusers identity repository exceeds the 4096-character boundary.") + if type(trust_remote_code) is not bool: + raise TypeError("Custom Modular Diffusers trust_remote_code must be a JSON boolean.") + if trust_remote_code: + raise ValueError( + "Custom Modular Diffusers repository code is disabled until MoDiff provides a reviewed, " + "task-scoped authorization and isolated content-addressed execution path." + ) + if source == "hub": + if not isinstance(revision, str) or _COMMIT_REVISION.fullmatch(revision) is None: + raise ValueError("Custom Hub pipeline identity requires a lowercase 40-character commit revision.") + else: + if revision is not None: + raise ValueError("Local custom pipeline identities must use a null revision.") + if not isinstance(config_sha256, str) or _SHA256.fullmatch(config_sha256) is None: + raise ValueError("Custom Modular Diffusers identity requires a lowercase SHA-256 sidecar digest.") + if ( + not isinstance(executable_manifest_sha256, str) + or _SHA256.fullmatch(executable_manifest_sha256) is None + ): + raise ValueError( + "Custom Modular Diffusers identity requires a lowercase SHA-256 executable-manifest digest." + ) + + body = _identity_body( + source=source, + repo_id=repo_id, + revision=revision, + trust_remote_code=trust_remote_code, + config_sha256=config_sha256, + executable_manifest_sha256=executable_manifest_sha256, + ) + execution_id = _execution_id_for_body(body) + return cls( + source=source, + repo_id=repo_id, + revision=revision, + trust_remote_code=trust_remote_code, + config_sha256=config_sha256, + executable_manifest_sha256=executable_manifest_sha256, + execution_id=execution_id, + ) + + @classmethod + def from_value(cls, value: Any) -> "CustomPipelineExecutionIdentity": + if not isinstance(value, Mapping): + raise ValueError("Custom Modular Diffusers contract identity must be a JSON object.") + raw_keys = list(value.keys()) + if any(not isinstance(key, str) for key in raw_keys): + raise ValueError("Custom Modular Diffusers contract identity keys must be JSON strings.") + keys = set(raw_keys) + if keys != _IDENTITY_KEYS: + missing = sorted(_IDENTITY_KEYS - keys) + unknown = sorted(keys - _IDENTITY_KEYS) + detail = [] + if missing: + detail.append("missing " + ", ".join(missing)) + if unknown: + detail.append("unknown " + ", ".join(unknown)) + raise ValueError("Malformed custom Modular Diffusers contract identity: " + "; ".join(detail)) + if value.get("schema") != CUSTOM_PIPELINE_IDENTITY_SCHEMA: + raise ValueError( + f"Unsupported custom Modular Diffusers identity schema {value.get('schema')!r}; " + f"expected {CUSTOM_PIPELINE_IDENTITY_SCHEMA!r}." + ) + if value.get("model_type") != CUSTOM_PIPELINE_MODEL_TYPE: + raise ValueError("Custom Modular Diffusers identity has an incompatible model_type.") + if value.get("config_filename") != CUSTOM_PIPELINE_CONFIG_FILENAME: + raise ValueError(f"Custom Modular Diffusers identity must bind {CUSTOM_PIPELINE_CONFIG_FILENAME!r}.") + execution_id = value.get("execution_id") + if not isinstance(execution_id, str) or _EXECUTION_ID.fullmatch(execution_id) is None: + raise ValueError("Custom Modular Diffusers identity has an invalid execution_id.") + identity = cls.create( + source=value.get("source"), + repo_id=value.get("repo_id"), + revision=value.get("revision"), + trust_remote_code=value.get("trust_remote_code"), + config_sha256=value.get("config_sha256"), + executable_manifest_sha256=value.get("executable_manifest_sha256"), + ) + if not hmac.compare_digest(identity.execution_id, execution_id): + raise ValueError("Custom Modular Diffusers contract checksum does not match its identity fields.") + return identity + + def to_dict(self) -> dict[str, Any]: + return { + **_identity_body( + source=self.source, + repo_id=self.repo_id, + revision=self.revision, + trust_remote_code=self.trust_remote_code, + config_sha256=self.config_sha256, + executable_manifest_sha256=self.executable_manifest_sha256, + ), + "execution_id": self.execution_id, + } + + def selector_tuple(self) -> tuple[str, str, str | None, bool]: + return self.source, self.repo_id, self.revision, self.trust_remote_code + + +@dataclass(frozen=True, slots=True) +class CustomPipelineBinding: + """Contract-only pipeline-class substitute bound to verified drift checksums.""" + + identity: CustomPipelineExecutionIdentity + _config_bytes: bytes = field(repr=False, compare=False) + _repository_path: str = field(repr=False, compare=False) + + @property + def __name__(self) -> str: + return CUSTOM_PIPELINE_MODEL_TYPE + + @property + def execution_status(self) -> str: + return CUSTOM_PIPELINE_EXECUTION_STATUS + + @property + def repo_id(self) -> str: + return self.identity.repo_id + + @property + def revision(self) -> str | None: + return self.identity.revision + + @property + def trust_remote_code(self) -> bool: + return self.identity.trust_remote_code + + @property + def repository_path(self) -> str: + return self._repository_path + + def pipeline_config(self) -> PipelineConfig: + return PipelineConfig.from_json_bytes( + self._config_bytes, + source_label=f"{self.identity.execution_id}:{CUSTOM_PIPELINE_CONFIG_FILENAME}", + ) + + def __call__(self): + # Upstream currently imports the repository-controlled library named in + # each component type_hint even with trust_remote_code=False. Reconcile + # drift, then fail closed until MoDiff has a reviewed component contract. + resolve_custom_pipeline_identity(self.identity.to_dict()) + raise RuntimeError( + "Custom Modular Diffusers execution is disabled until MoDiff validates every component type_hint " + "against its reviewed executable dependency contract. Contract preview remains available." + ) + + +_binding_cache: "OrderedDict[tuple[str, str, str | None, bool, str, str], CustomPipelineBinding]" = OrderedDict() +_binding_cache_lock = threading.RLock() + + +def _binding_from_verified( + verified: VerifiedMoDiffPipelineConfig, + *, + trust_remote_code: bool, + expected_identity: Mapping[str, Any] | CustomPipelineExecutionIdentity | None = None, + allow_selector_change: bool = False, +) -> CustomPipelineBinding: + identity = CustomPipelineExecutionIdentity.create( + source=verified.source, + repo_id=verified.repo_id, + revision=verified.revision, + trust_remote_code=trust_remote_code, + config_sha256=verified.sha256, + executable_manifest_sha256=verified.executable_manifest_sha256, + ) + if expected_identity is not None: + expected = ( + expected_identity + if isinstance(expected_identity, CustomPipelineExecutionIdentity) + else CustomPipelineExecutionIdentity.from_value(expected_identity) + ) + selector_changed = expected.selector_tuple() != identity.selector_tuple() + if expected != identity and not (allow_selector_change and selector_changed): + if expected.config_sha256 != identity.config_sha256 and not selector_changed: + raise ValueError( + f"Cached {CUSTOM_PIPELINE_CONFIG_FILENAME} no longer matches the persisted custom pipeline " + "identity. Review the exact sidecar and explicitly refresh the custom contract before running." + ) + if ( + expected.executable_manifest_sha256 != identity.executable_manifest_sha256 + and not selector_changed + ): + raise ValueError( + "Cached custom pipeline executable metadata no longer matches the persisted contract identity. " + "Review the exact cached files and explicitly refresh the custom contract before running." + ) + raise ValueError( + "The selected custom Modular Diffusers source does not match its persisted contract identity. " + "Refresh the loader contract after changing repository, source, revision, or trust." + ) + + # Keep the exact verified bytes. Parsing a fresh object for every caller + # isolates mutable ``node_params`` without making the binding depend on a + # second serialization (or on cache residency). + config_bytes = verified.raw_bytes + key = ( + identity.source, + identity.repo_id, + identity.revision, + identity.trust_remote_code, + identity.config_sha256, + identity.executable_manifest_sha256, + ) + with _binding_cache_lock: + existing = _binding_cache.get(key) + if ( + existing is not None + and existing._config_bytes == config_bytes + and existing.repository_path == verified.repository_path + ): + _binding_cache.move_to_end(key) + return existing + binding = CustomPipelineBinding( + identity=identity, + _config_bytes=config_bytes, + _repository_path=verified.repository_path, + ) + _binding_cache[key] = binding + _binding_cache.move_to_end(key) + while len(_binding_cache) > _BINDING_CACHE_LIMIT: + _binding_cache.popitem(last=False) + return binding + + +def resolve_custom_pipeline_binding( + *, + source: str, + repo_id: str, + revision: str | None, + trust_remote_code: bool, + expected_identity: Mapping[str, Any] | CustomPipelineExecutionIdentity | None = None, + allow_selector_change: bool = False, +) -> CustomPipelineBinding: + """Verify a locally available sidecar/manifest and return a contract-only binding.""" + + if type(trust_remote_code) is not bool: + raise TypeError("Custom Modular Diffusers trust_remote_code must be a JSON boolean.") + if trust_remote_code: + raise ValueError( + "Custom Modular Diffusers repository code is disabled until MoDiff provides a reviewed, task-scoped " + "authorization and isolated content-addressed execution path." + ) + verified = PipelineConfig.load_verified( + repo_id, + source=source, + revision=revision, + ) + return _binding_from_verified( + verified, + trust_remote_code=trust_remote_code, + expected_identity=expected_identity, + allow_selector_change=allow_selector_change, + ) + + +def resolve_custom_pipeline_identity(value: Any) -> CustomPipelineBinding: + """Recover and re-verify a persisted/runtime identity without network access.""" + + identity = CustomPipelineExecutionIdentity.from_value(value) + return resolve_custom_pipeline_binding( + source=identity.source, + repo_id=identity.repo_id, + revision=identity.revision, + trust_remote_code=identity.trust_remote_code, + expected_identity=identity, + ) + + +def _clear_custom_pipeline_binding_cache_for_tests() -> None: + with _binding_cache_lock: + _binding_cache.clear() diff --git a/modules/ModularDiffusers/denoise.py b/modules/ModularDiffusers/denoise.py index 3bc2a71..3954e4b 100644 --- a/modules/ModularDiffusers/denoise.py +++ b/modules/ModularDiffusers/denoise.py @@ -1,8 +1,8 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import importlib import inspect import logging import time +from collections.abc import Mapping from copy import deepcopy from typing import Any, List, Tuple @@ -14,9 +14,41 @@ from . import MESSAGE_DURATION, components from .modular_utils import ( - DummyCustomPipeline, + get_model_type_metadata, + normalize_modular_runtime_params, + normalize_modular_seed, + pipeline_class_from_model_type, pipeline_class_from_runtime_inputs, - pipeline_class_to_modiff_node_config, + reject_undeclared_modular_generator, + require_modiff_node_contract, +) +from .route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + SDXL_UNION_CONTROL_MODE_LIMIT, + consume_decode_route_state, + SUPPORTED_ROUTE_MODEL_TYPES, + consume_denoise_route_state, + effective_modular_block_input, + issue_decode_route_state, + issue_normal_decode_route_state, + reject_route_reserved_inputs, + reject_route_reserved_inputs_before_identity_resolution, + resolve_managed_component_by_id, + require_component_binding, + require_matching_token_bearers, + require_route_state_shape_before_identity_resolution, + require_sdxl_ip_adapter_bundle, + require_sdxl_controlnet_component_binding, + route_contract_for_model_type, + route_requires_controlnet_state, + route_cache_params_equal, + route_uses_hidden_denoise_mask, + sdxl_vae_geometry_from_component, + validate_denoise_route_state, + validate_route_field_contract, + wan_transformer_contract_from_component, + wan_vae_geometry_from_component, ) from .utils import collect_model_ids @@ -29,6 +61,27 @@ "Update or recreate the Studio graph after the model fields finish refreshing." ) +_DENOISE_IMAGE_LATENT_DIMENSIONS = ("height", "width") + + +def _apply_image_latent_dimension_contract(model_type, node_kwargs): + """Drop legacy dimensions unless reviewed pipeline metadata retains them.""" + + if node_kwargs.get("image_latents") is None: + return + metadata = get_model_type_metadata(model_type) + retained = metadata.get("denoise_image_latent_dimensions") if isinstance(metadata, dict) else None + if ( + not isinstance(retained, list) + or len(retained) > len(_DENOISE_IMAGE_LATENT_DIMENSIONS) + or any(not isinstance(name, str) or name not in _DENOISE_IMAGE_LATENT_DIMENSIONS for name in retained) + or len(retained) != len(set(retained)) + ): + raise RuntimeError("The registered Modular pipeline has an invalid image-latent dimension contract.") + for name in _DENOISE_IMAGE_LATENT_DIMENSIONS: + if name not in retained: + node_kwargs.pop(name, None) + def embeddings_missing_error(error): return bool(error.args and error.args[0] == "embeddings") or "embeddings" in str(error) @@ -138,23 +191,38 @@ def update_node(self, values, ref): model_type = self.get_signal_value("unet") if self._model_type == model_type: + if not model_type or self._pipeline_class is None: + return None + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + node_params_to_update = dict(node_config["params"]) + node_params_to_update.pop("unet", None) + self.send_node_definition(node_params_to_update) return None - if model_type is None or model_type == "" or model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - self._model_type = model_type - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - # not support this node type - if node_config is None: + if model_type is None or model_type == "": + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + return None + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) + raise + self._model_type = model_type - node_params_to_update = node_config["params"] + node_params_to_update = dict(node_config["params"]) node_params_to_update.pop("unet", None) node_params.update(**node_params_to_update) @@ -164,6 +232,343 @@ def __init__(self, node_id=None): super().__init__(node_id) self._model_type = "" self._pipeline_class = None + self._route_cache_node_input_names = () + self._route_cache_block_input_names = () + self._route_cache_component_names = () + self._route_cache_model_input_names = () + + def _require_route_component_inputs(self, current, *, model_input_names, bundle_names): + binding = require_component_binding( + current.get("unet"), + label="denoise model", + expected_model_type=self._model_type, + expected_role="denoiser", + ) + require_component_binding( + current.get("scheduler"), + label="scheduler", + expected_model_type=self._model_type, + expected_token=binding, + expected_role="scheduler", + ) + if "vae" in model_input_names: + require_component_binding( + current.get("vae"), + label="Denoise VAE", + expected_model_type=self._model_type, + expected_token=binding, + expected_role="vae", + ) + for bundle_name in bundle_names: + require_matching_token_bearers(current[bundle_name], binding, label=bundle_name) + return binding + + def _require_installed_route_components(self, current, component_updates, *, model_input_names): + """Prove required connected components were installed, not ambient manager picks.""" + + if "vae" not in model_input_names: + return None + if route_contract_for_model_type(self._model_type) == "wan_i2v": + vae = resolve_managed_component_by_id(components, current.get("vae"), label="Denoise VAE") + wan_vae_geometry_from_component(vae) + return vae + expected_vae = component_updates.get("vae") + if expected_vae is None: + raise ValueError("The connected Denoise VAE could not be resolved from its exact managed component ID.") + if getattr(self._pipeline, "vae", None) is not expected_vae: + raise ValueError("The SDXL Denoise pipeline did not install the exact connected VAE component.") + return expected_vae + + def _resolve_wan_route_components(self, current, *, require_resident_pipeline): + vae = resolve_managed_component_by_id(components, current.get("vae"), label="Denoise VAE") + transformer = resolve_managed_component_by_id( + components, + current.get("unet"), + label="Denoise transformer", + ) + wan_vae_geometry_from_component(vae) + wan_transformer_contract_from_component(transformer) + if require_resident_pipeline and ( + getattr(self, "_pipeline", None) is None + or getattr(self._pipeline, "transformer", None) is not transformer + ): + raise ValueError("The resident Wan Denoise pipeline does not hold the exact connected transformer.") + return vae, transformer + + def _resolve_sdxl_route_vae(self, current, *, require_resident_pipeline): + vae = resolve_managed_component_by_id(components, current.get("vae"), label="Denoise VAE") + if require_resident_pipeline and ( + getattr(self, "_pipeline", None) is None or getattr(self._pipeline, "vae", None) is not vae + ): + raise ValueError("The resident SDXL Denoise pipeline does not hold the exact connected VAE component.") + return vae, sdxl_vae_geometry_from_component(vae) + + def _resolve_sdxl_route_unet(self, current, *, require_resident_pipeline): + component_input = current.get("unet") + if not isinstance(component_input, Mapping) or not isinstance(component_input.get("model_id"), str): + raise ValueError("Denoise UNet metadata must contain one managed component ID.") + try: + resolved = components.get_components_by_ids( + ids=[component_input["model_id"]], + return_dict_with_names=True, + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("Denoise UNet could not be resolved from its exact managed component ID.") from error + unet = resolved.get("unet") if isinstance(resolved, Mapping) else None + if unet is None: + raise ValueError("Denoise UNet could not be resolved from its exact managed component ID.") + if require_resident_pipeline and ( + getattr(self, "_pipeline", None) is None or getattr(self._pipeline, "unet", None) is not unet + ): + raise ValueError("The resident SDXL Denoise pipeline does not hold the exact connected UNet.") + return unet + + def _resolve_sdxl_controlnet(self, component_input, *, control_mode, require_resident_pipeline): + union = control_mode is not None + require_sdxl_controlnet_component_binding(component_input, union=union) + controlnet = resolve_managed_component_by_id( + components, + component_input, + label="Denoise ControlNet", + ) + if union: + control_type_count = getattr(getattr(controlnet, "config", None), "num_control_type", None) + if type(control_type_count) is not int or not 1 <= control_type_count <= SDXL_UNION_CONTROL_MODE_LIMIT: + raise ValueError("The connected SDXL ControlNet Union has an invalid control-type contract.") + if ( + type(control_mode) is not int + or not 0 <= control_mode < SDXL_UNION_CONTROL_MODE_LIMIT + or control_mode >= control_type_count + ): + raise ValueError("The selected SDXL ControlNet Union mode is outside the resident model contract.") + if require_resident_pipeline and ( + getattr(self, "_pipeline", None) is None + or getattr(self._pipeline, "controlnet", None) is not controlnet + ): + raise ValueError("The resident SDXL Denoise pipeline does not hold the exact connected ControlNet.") + return controlnet + + def _validate_route_cache_inputs(self, current): + route_state = current.get(ROUTE_STATE_INPUT) + supported_route_model = self._model_type in SUPPORTED_ROUTE_MODEL_TYPES + if route_state is None and not supported_route_model: + return True + if not self._route_cache_node_input_names or not self._route_cache_block_input_names: + return False + + route_contract = route_contract_for_model_type(self._model_type) + if route_contract == "wan_i2v": + _blocks, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + current = normalize_modular_runtime_params(dict(current), node_config) + route_state = current.get(ROUTE_STATE_INPUT) + + block_input_names = self._route_cache_block_input_names + node_input_names = self._route_cache_node_input_names + component_names = self._route_cache_component_names + bundle_names = [ + name + for name in node_input_names + if isinstance(current.get(name), dict) and name not in block_input_names + ] + if route_contract == "sdxl": + for bundle_name in bundle_names: + ip_fields = {"ip_adapter_embeds", "negative_ip_adapter_embeds"}.intersection( + current[bundle_name] + ) + if ip_fields and bundle_name != "ip_adapter": + raise ValueError( + f"SDXL IP-Adapter fields require the exact adapter input: {', '.join(sorted(ip_fields))}." + ) + union_fields = {"control_type", "control_type_idx"}.intersection( + current[bundle_name] + ) + if union_fields: + raise ValueError( + f"SDXL ControlNet Union fields are not enabled: {', '.join(sorted(union_fields))}." + ) + require_route_state_shape_before_identity_resolution(current) + reject_undeclared_modular_generator(current) + reject_route_reserved_inputs_before_identity_resolution( + current, + allowed_direct_inputs={"mask", "masked_image_latents"}, + ) + reject_route_reserved_inputs( + current, + bundle_names=bundle_names, + model_type=self._model_type, + action="denoise", + ) + binding = self._require_route_component_inputs( + current, + model_input_names=self._route_cache_model_input_names, + bundle_names=bundle_names, + ) + resident_vae = None + resident_geometry = (None, None) + resident_transformer = None + if route_contract == "sdxl": + resident_unet = self._resolve_sdxl_route_unet(current, require_resident_pipeline=True) + require_sdxl_ip_adapter_bundle( + current.get("ip_adapter"), + binding=binding, + unet=resident_unet, + guider=getattr(self._pipeline, "guider", None), + ) + resident_vae, resident_geometry = self._resolve_sdxl_route_vae( + current, + require_resident_pipeline=True, + ) + cached_decode_route = self.output.get(ROUTE_STATE_OUTPUT) + if cached_decode_route is None: + return False + consume_decode_route_state( + cached_decode_route, + binding=binding, + model_type=self._model_type, + latents=self.output.get("latents"), + vae_component=resident_vae, + vae_latent_channels=resident_geometry[0], + vae_scale_factor=resident_geometry[1], + materialize_overlay=False, + ) + elif route_contract == "wan_i2v": + resident_vae, resident_transformer = self._resolve_wan_route_components( + current, + require_resident_pipeline=True, + ) + cached_decode_route = self.output.get(ROUTE_STATE_OUTPUT) + if cached_decode_route is None: + return False + consume_decode_route_state( + cached_decode_route, + binding=binding, + model_type=self._model_type, + latents=self.output.get("latents"), + vae_component=resident_vae, + materialize_overlay=False, + ) + + image_latents = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="image_latents", + ) + mask = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="mask", + ) + masked_image_latents = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="masked_image_latents", + ) + control_image_latents = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="control_image_latents", + ) + controlnet_component = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=tuple(block_input_names) + tuple(component_names), + target_name="controlnet", + ) + control_mode = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="control_mode", + ) + controlnet_bundle_present = current.get("controlnet_bundle") is not None + controlnet_state_present = ( + controlnet_bundle_present + or control_image_latents is not None + or controlnet_component is not None + or control_mode is not None + ) + if route_contract == "sdxl" and controlnet_state_present: + if ( + not controlnet_bundle_present + or controlnet_component is None + or control_image_latents is not None + ): + raise ValueError("SDXL ControlNet requires one exact connected component bundle.") + self._resolve_sdxl_controlnet( + controlnet_component, + control_mode=control_mode, + require_resident_pipeline=True, + ) + image_embeds = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="image_embeds", + ) + image_condition_latents = effective_modular_block_input( + current, + node_input_names=node_input_names, + block_input_names=block_input_names, + target_name="image_condition_latents", + ) + if route_state is None: + if route_contract == "wan_i2v": + raise ValueError("Wan image-to-video Denoise requires its preceding Image Encode route.") + if image_latents is not None or mask is not None or masked_image_latents is not None or any( + current.get(name) is not None for name in ("image_latents", "image_latents_with_strength") + ): + raise ValueError( + "Connected Modular image or inpaint latents require the opaque route state emitted by the " + "matching VAE encoder." + ) + if route_requires_controlnet_state(self._model_type) and ( + controlnet_bundle_present + or control_image_latents is not None + or controlnet_component is not None + ): + raise ValueError( + "Connected Qwen ControlNet inputs require the opaque route state emitted by the matching " + "ControlNet action." + ) + return True + validate_denoise_route_state( + route_state, + binding=binding, + model_type=self._model_type, + seed=normalize_modular_seed(current.get("seed")), + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + vae_component=resident_vae, + vae_latent_channels=resident_geometry[0], + vae_scale_factor=resident_geometry[1], + control_image_latents=control_image_latents, + controlnet_component=controlnet_component, + control_mode=control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=current.get("ip_adapter") is not None, + image_embeds=image_embeds, + image_condition_latents=image_condition_latents, + height=current.get("height"), + width=current.get("width"), + num_frames=current.get("num_frames"), + transformer_component=resident_transformer, + ) + return True + + def _cache_params_equal(self, previous, current): + equal = route_cache_params_equal(previous, current, fallback=super()._cache_params_equal) + if equal and isinstance(current, dict) and not self._validate_route_cache_inputs(current): + return False + return equal def _raise_if_interrupted(self): if self._interrupt: @@ -185,7 +590,15 @@ def _publish_initial_denoise_progress(self, num_inference_steps: int): def execute(self, **kwargs): kwargs = dict(kwargs) - self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_undeclared_modular_generator(kwargs) + reject_route_reserved_inputs_before_identity_resolution( + kwargs, + allowed_direct_inputs={"mask", "masked_image_latents"}, + ) + identity_kwargs = {name: value for name, value in kwargs.items() if name != ROUTE_STATE_INPUT} + self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, identity_kwargs) + self._model_type = getattr(self._pipeline_class, "__name__", "") if not ((unet := kwargs.get("unet")) and isinstance(unet, dict)): self.notify( @@ -197,7 +610,213 @@ def execute(self, **kwargs): return None # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + validate_route_field_contract(kwargs, node_config) + + route_state = kwargs.get(ROUTE_STATE_INPUT) + route_binding = None + supported_route_model = self._model_type in SUPPORTED_ROUTE_MODEL_TYPES + route_contract = route_contract_for_model_type(self._model_type) + model_input_names = node_config["model_input_names"] + bundle_names = [ + name + for name in node_config["input_names"] + if isinstance(kwargs.get(name), dict) and name not in blocks.input_names + ] + if route_contract == "sdxl": + for bundle_name in bundle_names: + ip_fields = {"ip_adapter_embeds", "negative_ip_adapter_embeds"}.intersection( + kwargs[bundle_name] + ) + if ip_fields and bundle_name != "ip_adapter": + raise ValueError( + f"SDXL IP-Adapter fields require the exact adapter input: {', '.join(sorted(ip_fields))}." + ) + union_fields = {"control_type", "control_type_idx"}.intersection( + kwargs[bundle_name] + ) + if union_fields: + raise ValueError( + f"SDXL ControlNet Union fields are not enabled: {', '.join(sorted(union_fields))}." + ) + effective_image_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="image_latents", + ) + effective_strength_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="image_latents_with_strength", + ) + effective_mask = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="mask", + ) + effective_masked_image_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="masked_image_latents", + ) + effective_control_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="control_image_latents", + ) + effective_image_embeds = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="image_embeds", + ) + effective_image_condition_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="image_condition_latents", + ) + component_names = getattr(blocks, "component_names", ()) + if not isinstance(component_names, (list, tuple, set, frozenset)): + component_names = () + self._route_cache_node_input_names = tuple(node_config["input_names"]) + self._route_cache_block_input_names = tuple(blocks.input_names) + self._route_cache_component_names = tuple(component_names) + self._route_cache_model_input_names = tuple(node_config["model_input_names"]) + effective_controlnet_component = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=tuple(blocks.input_names) + tuple(component_names), + target_name="controlnet", + ) + effective_control_mode = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="control_mode", + ) + controlnet_bundle_present = kwargs.get("controlnet_bundle") is not None + routed_latent_present = any( + kwargs.get(name) is not None for name in ("image_latents", "image_latents_with_strength") + ) + routed_latent_present = routed_latent_present or any( + value is not None + for value in ( + effective_image_latents, + effective_strength_latents, + effective_mask, + effective_masked_image_latents, + ) + ) + if ( + route_state is None + and supported_route_model + and routed_latent_present + ): + raise ValueError( + "Connected Modular image or inpaint latents require the opaque route state emitted by the matching " + "VAE encoder." + ) + if route_state is None and route_contract == "wan_i2v": + raise ValueError("Wan image-to-video Denoise requires its preceding Image Encode route.") + controlnet_state_present = ( + controlnet_bundle_present + or effective_control_latents is not None + or effective_controlnet_component is not None + or effective_control_mode is not None + ) + if route_state is None and route_requires_controlnet_state(self._model_type) and controlnet_state_present: + raise ValueError( + "Connected Qwen ControlNet inputs require the opaque route state emitted by the matching " + "ControlNet action." + ) + if route_state is not None: + # Route-enabled actions use the strict backend schema before any + # block deepcopy or pipeline initialization. In particular, do not + # let the compatibility int() cast normalize bools or fractions. + kwargs = normalize_modular_runtime_params(kwargs, node_config) + if route_state is not None and not supported_route_model: + raise ValueError(f"Pipeline '{self._model_type}' does not support opaque Modular route state.") + + if supported_route_model: + reject_route_reserved_inputs( + kwargs, + bundle_names=bundle_names, + model_type=self._model_type, + action="denoise", + ) + route_binding = self._require_route_component_inputs( + kwargs, + model_input_names=node_config["model_input_names"], + bundle_names=bundle_names, + ) + preinit_vae = None + preinit_geometry = (None, None) + preinit_transformer = None + preinit_controlnet = None + preinit_unet = None + ip_adapter_state = None + if route_contract == "sdxl": + preinit_unet = self._resolve_sdxl_route_unet(kwargs, require_resident_pipeline=False) + ip_adapter_state = require_sdxl_ip_adapter_bundle( + kwargs.get("ip_adapter"), + binding=route_binding, + unet=preinit_unet, + guider=kwargs.get("guider"), + ) + preinit_vae, preinit_geometry = self._resolve_sdxl_route_vae( + kwargs, + require_resident_pipeline=False, + ) + if controlnet_state_present: + if ( + not controlnet_bundle_present + or effective_controlnet_component is None + or effective_control_latents is not None + ): + raise ValueError("SDXL ControlNet requires one exact connected component bundle.") + preinit_controlnet = self._resolve_sdxl_controlnet( + effective_controlnet_component, + control_mode=effective_control_mode, + require_resident_pipeline=False, + ) + elif route_contract == "wan_i2v": + preinit_vae, preinit_transformer = self._resolve_wan_route_components( + kwargs, + require_resident_pipeline=False, + ) + + if route_state is not None: + if kwargs.get("seed") is None: + raise ValueError("A Modular VAE-to-Denoise route requires its originating seed.") + validate_denoise_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + image_latents=effective_image_latents, + mask=effective_mask, + masked_image_latents=effective_masked_image_latents, + vae_component=preinit_vae, + vae_latent_channels=preinit_geometry[0], + vae_scale_factor=preinit_geometry[1], + control_image_latents=effective_control_latents, + controlnet_component=effective_controlnet_component, + control_mode=effective_control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=kwargs.get("ip_adapter") is not None, + image_embeds=effective_image_embeds, + image_condition_latents=effective_image_condition_latents, + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + transformer_component=preinit_transformer, + ) if "embeddings" in node_config["input_names"]: embeddings = kwargs.get("embeddings") @@ -211,8 +830,6 @@ def execute(self, **kwargs): return None # 2. create pipeline - repo_id = unet.get("repo_id", None) - num_inference_steps = int(kwargs.get("num_inference_steps") or 0) progress_started_at = time.monotonic() @@ -244,7 +861,17 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): runtime_blocks = deepcopy(blocks) insert_preview_block(runtime_blocks, preview_callback) - self._pipeline = runtime_blocks.init_pipeline(repo_id, components_manager=components) + # The selected installed blocks and connected managed components are + # already the reviewed execution contract. Passing the repository here + # would make upstream reload its mutable config and resolve type hints a + # second time, outside ModelsLoader's exact-index validation. + self._pipeline = runtime_blocks.init_pipeline(components_manager=components) + if supported_route_model: + self._require_route_component_inputs( + kwargs, + model_input_names=model_input_names, + bundle_names=bundle_names, + ) # Preserve the graph compatibility cast until the upstream schema exposes exact types. for param_name, param_config in node_config["params"].items(): @@ -258,9 +885,14 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): # 3. update components expected_component_names = blocks.component_names model_input_names = node_config["model_input_names"] + install_model_input_names = model_input_names + if route_contract == "wan_i2v": + # Validation/provenance-only: pinned split Wan Denoise has no VAE + # component slot, so never inject this graph port upstream. + install_model_input_names = [name for name in model_input_names if name != "vae"] model_ids = collect_model_ids( kwargs, - target_key_names=model_input_names, + target_key_names=install_model_input_names, target_model_names=expected_component_names, ) @@ -289,8 +921,91 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): if component_updates: self._pipeline.update_components(**component_updates) + route_geometry = None + route_transformer = None + route_controlnet = None + if supported_route_model: + self._require_route_component_inputs( + kwargs, + model_input_names=model_input_names, + bundle_names=bundle_names, + ) + installed_vae = self._require_installed_route_components( + kwargs, + component_updates, + model_input_names=model_input_names, + ) + if route_contract == "sdxl": + if component_updates.get("unet") is not preinit_unet or getattr(self._pipeline, "unet", None) is not preinit_unet: + raise ValueError("The connected Denoise UNet changed during pipeline initialization.") + if require_sdxl_ip_adapter_bundle( + kwargs.get("ip_adapter"), + binding=route_binding, + unet=preinit_unet, + guider=getattr(self._pipeline, "guider", None), + ) is not ip_adapter_state: + raise ValueError("The SDXL IP-Adapter publication changed during pipeline initialization.") + live_vae, live_geometry = self._resolve_sdxl_route_vae( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae: + raise ValueError("The connected Denoise VAE changed during component installation.") + if installed_vae is not preinit_vae: + raise ValueError("The connected Denoise VAE changed during pipeline initialization.") + route_geometry = live_geometry + if route_geometry != preinit_geometry: + raise ValueError("The connected Denoise VAE geometry changed during pipeline initialization.") + if preinit_controlnet is not None: + installed_controlnet = component_updates.get("controlnet") + if installed_controlnet is None: + raise ValueError( + "The connected Denoise ControlNet could not be resolved from its exact managed component ID." + ) + route_controlnet = self._resolve_sdxl_controlnet( + effective_controlnet_component, + control_mode=effective_control_mode, + require_resident_pipeline=True, + ) + if route_controlnet is not installed_controlnet or route_controlnet is not preinit_controlnet: + raise ValueError("The connected Denoise ControlNet changed during pipeline initialization.") + elif route_contract == "wan_i2v": + live_vae, route_transformer = self._resolve_wan_route_components( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae or live_vae is not preinit_vae: + raise ValueError("The connected Wan Denoise VAE changed during pipeline initialization.") + if route_transformer is not preinit_transformer: + raise ValueError("The connected Wan transformer changed during pipeline initialization.") device = self._pipeline._execution_device + route_runtime_inputs = None + if route_state is not None: + route_runtime_inputs = consume_denoise_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + execution_device=device, + image_latents=effective_image_latents, + mask=effective_mask, + masked_image_latents=effective_masked_image_latents, + vae_component=installed_vae, + vae_latent_channels=(route_geometry[0] if route_geometry is not None else None), + vae_scale_factor=(route_geometry[1] if route_geometry is not None else None), + control_image_latents=effective_control_latents, + controlnet_component=effective_controlnet_component, + control_mode=effective_control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=kwargs.get("ip_adapter") is not None, + image_embeds=effective_image_embeds, + image_condition_latents=effective_image_condition_latents, + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + transformer_component=route_transformer, + ) # 4. compile a dict of runtime inputs from kwargs based on node_config["input_names"] node_kwargs = {} @@ -301,10 +1016,14 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): if value is None: continue + if name == ROUTE_STATE_INPUT: + continue + # special case #1: `seed` -> always create a `generator` if name == "seed": - generator = torch.Generator(device=device).manual_seed(value) - node_kwargs["generator"] = generator + if route_runtime_inputs is None: + generator = torch.Generator(device=device).manual_seed(value) + node_kwargs["generator"] = generator # special case #2: passed `guidance_scale` but pipeline does not accept it # -> potentially create a new guider if pipeline support it @@ -331,20 +1050,27 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): else: node_kwargs[name] = value - # Compatibility workaround: hidden height/width values may still be passed by older graphs. - edit_models = [ - "Flux2KleinModularPipeline", - "QwenImageEditModularPipeline", - "QwenImageEditPlusModularPipeline", - "FluxKontextModularPipeline", - ] - if ( - "image_latents" in node_kwargs - and node_kwargs["image_latents"] is not None - and self._model_type not in edit_models - ): - node_kwargs.pop("height", None) - node_kwargs.pop("width", None) + if route_runtime_inputs is not None: + node_kwargs["generator"] = route_runtime_inputs["generator"] + if route_runtime_inputs["processed_mask_image"] is not None: + if "processed_mask_image" not in blocks.input_names: + raise ValueError("The selected Modular denoiser does not expose the routed mask input.") + node_kwargs["processed_mask_image"] = route_runtime_inputs["processed_mask_image"] + for name in ("mask", "masked_image_latents", "crops_coords"): + value = route_runtime_inputs.get(name) + if value is None: + continue + if name not in blocks.input_names: + raise ValueError(f"The selected Modular denoiser does not expose routed SDXL input '{name}'.") + node_kwargs[name] = value + if route_contract == "wan_i2v": + for name in ("height", "width", "num_frames"): + if name not in blocks.input_names: + raise ValueError(f"The selected Wan denoiser does not expose routed input '{name}'.") + node_kwargs[name] = route_runtime_inputs[name] + + # Hidden dimensions from older graphs survive only where reviewed metadata requires them. + _apply_image_latent_dimension_contract(self._model_type, node_kwargs) # 5. figure out the outputs to return based on node_config["output_names"] outputs = {} @@ -353,6 +1079,78 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): if "doc" in output_names: output_names.remove("doc") outputs["doc"] = self._pipeline.blocks.doc + route_output_declared = ROUTE_STATE_OUTPUT in output_names + if route_output_declared: + output_names.remove(ROUTE_STATE_OUTPUT) + outputs[ROUTE_STATE_OUTPUT] = None + pipeline_output_names = list(output_names) + if route_uses_hidden_denoise_mask(self._model_type) and "mask" not in pipeline_output_names: + pipeline_output_names.append("mask") + + if supported_route_model: + self._require_route_component_inputs( + kwargs, + model_input_names=model_input_names, + bundle_names=bundle_names, + ) + installed_vae = self._require_installed_route_components( + kwargs, + component_updates, + model_input_names=model_input_names, + ) + if route_geometry is not None: + if require_sdxl_ip_adapter_bundle( + kwargs.get("ip_adapter"), + binding=route_binding, + unet=preinit_unet, + guider=getattr(self._pipeline, "guider", None), + ) is not ip_adapter_state: + raise ValueError("The SDXL IP-Adapter publication changed before upstream execution.") + live_vae, live_geometry = self._resolve_sdxl_route_vae( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae or live_geometry != route_geometry: + raise ValueError("The connected Denoise VAE changed before upstream execution.") + if route_controlnet is not None and self._resolve_sdxl_controlnet( + effective_controlnet_component, + control_mode=effective_control_mode, + require_resident_pipeline=True, + ) is not route_controlnet: + raise ValueError("The connected Denoise ControlNet changed before upstream execution.") + elif route_contract == "wan_i2v": + live_vae, live_transformer = self._resolve_wan_route_components( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae or live_vae is not preinit_vae: + raise ValueError("The connected Wan Denoise VAE changed before upstream execution.") + if live_transformer is not route_transformer or live_transformer is not preinit_transformer: + raise ValueError("The connected Wan transformer changed before upstream execution.") + if route_state is not None: + validate_denoise_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + image_latents=effective_image_latents, + mask=effective_mask, + masked_image_latents=effective_masked_image_latents, + vae_component=installed_vae, + vae_latent_channels=(route_geometry[0] if route_geometry is not None else None), + vae_scale_factor=(route_geometry[1] if route_geometry is not None else None), + control_image_latents=effective_control_latents, + controlnet_component=effective_controlnet_component, + control_mode=effective_control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=kwargs.get("ip_adapter") is not None, + image_embeds=effective_image_embeds, + image_condition_latents=effective_image_condition_latents, + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + transformer_component=route_transformer, + ) # 6. run the pipeline and update the outputs dict with the pipeline outputs transformer = getattr(self._pipeline, "transformer", None) @@ -360,7 +1158,7 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): self._active_pipeline = self._pipeline self._publish_initial_denoise_progress(num_inference_steps) try: - node_outputs = self._pipeline(**node_kwargs, output=output_names) + node_outputs = self._pipeline(**node_kwargs, output=pipeline_output_names) except ValueError as e: self.notify(str(e), variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) raise @@ -392,6 +1190,97 @@ def preview_callback(_latents, step_index: int, scheduler_order: int): self._active_pipeline = None reset_wrapped_forward_signature(signature_state) - outputs.update(node_outputs) + for name in output_names: + outputs[name] = node_outputs.get(name) + if supported_route_model: + self._require_route_component_inputs( + kwargs, + model_input_names=model_input_names, + bundle_names=bundle_names, + ) + installed_vae = self._require_installed_route_components( + kwargs, + component_updates, + model_input_names=model_input_names, + ) + if route_geometry is not None: + if require_sdxl_ip_adapter_bundle( + kwargs.get("ip_adapter"), + binding=route_binding, + unet=preinit_unet, + guider=getattr(self._pipeline, "guider", None), + ) is not ip_adapter_state: + raise ValueError("The SDXL IP-Adapter publication changed during upstream execution.") + live_vae, live_geometry = self._resolve_sdxl_route_vae( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae or live_geometry != route_geometry: + raise ValueError("The connected Denoise VAE changed during upstream execution.") + if route_controlnet is not None and self._resolve_sdxl_controlnet( + effective_controlnet_component, + control_mode=effective_control_mode, + require_resident_pipeline=True, + ) is not route_controlnet: + raise ValueError("The connected Denoise ControlNet changed during upstream execution.") + elif route_contract == "wan_i2v": + live_vae, live_transformer = self._resolve_wan_route_components( + kwargs, + require_resident_pipeline=True, + ) + if live_vae is not installed_vae or live_vae is not preinit_vae: + raise ValueError("The connected Wan Denoise VAE changed during upstream execution.") + if live_transformer is not route_transformer or live_transformer is not preinit_transformer: + raise ValueError("The connected Wan transformer changed during upstream execution.") + if route_state is not None: + validate_denoise_route_state( + route_state, + binding=route_binding, + model_type=self._model_type, + seed=kwargs["seed"], + image_latents=effective_image_latents, + mask=effective_mask, + masked_image_latents=effective_masked_image_latents, + vae_component=installed_vae, + vae_latent_channels=(route_geometry[0] if route_geometry is not None else None), + vae_scale_factor=(route_geometry[1] if route_geometry is not None else None), + control_image_latents=effective_control_latents, + controlnet_component=effective_controlnet_component, + control_mode=effective_control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=kwargs.get("ip_adapter") is not None, + image_embeds=effective_image_embeds, + image_condition_latents=effective_image_condition_latents, + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + transformer_component=route_transformer, + ) + if not route_output_declared: + raise ValueError("The backend Modular route contract is missing its Denoise route output.") + if route_state is not None: + outputs[ROUTE_STATE_OUTPUT] = issue_decode_route_state( + route_state, + binding=route_binding, + actual_mask=( + node_outputs.get("mask") if route_uses_hidden_denoise_mask(self._model_type) else None + ), + latents=outputs.get("latents"), + vae_component=(installed_vae if route_contract in {"sdxl", "wan_i2v"} else None), + transformer_component=(route_transformer if route_contract == "wan_i2v" else None), + execution_device=(device if route_contract == "wan_i2v" else None), + ) + else: + if route_uses_hidden_denoise_mask(self._model_type) and node_outputs.get("mask") is not None: + raise ValueError( + "A normal Modular Denoise route unexpectedly returned inpaint mask state." + ) + outputs[ROUTE_STATE_OUTPUT] = issue_normal_decode_route_state( + binding=route_binding, + latents=outputs.get("latents"), + vae_component=(installed_vae if route_geometry is not None else None), + vae_latent_channels=(route_geometry[0] if route_geometry is not None else None), + vae_scale_factor=(route_geometry[1] if route_geometry is not None else None), + ) return outputs diff --git a/modules/ModularDiffusers/dynamic_node.py b/modules/ModularDiffusers/dynamic_node.py index 0c8ac59..a799ebb 100644 --- a/modules/ModularDiffusers/dynamic_node.py +++ b/modules/ModularDiffusers/dynamic_node.py @@ -1,35 +1,155 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import logging +from collections.abc import Mapping +from copy import deepcopy -from diffusers import ModularPipeline +from .pipeline_schema import PROTOTYPE_SENSITIVE_FIELD_NAMES from .pipeline_schema import MoDiffPipelineConfig as PipelineConfig from modiff.NodeBase import NodeBase from modiff.diffusers_offload import ( - DEFAULT_GROUP_COMPONENTS, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK, OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_NONE, - apply_component_group_offload, - configure_components_manager_offload, - normalize_offload_mode, offload_mode_param, ) from modiff.model_artifact_catalog import resolve_model_revision -from utils.torch_utils import DEFAULT_DEVICE, DEVICE_LIST, str_to_dtype +from utils.torch_utils import DEFAULT_DEVICE, DEVICE_LIST -from . import MESSAGE_DURATION, components -from .loaders import record_pipeline_component_runtime_policy, reusable_component_ids -from .utils import collect_model_ids -from .modular_utils import pin_modular_component_revisions, require_immutable_hub_revision +from . import components +from .modular_utils import require_immutable_hub_revision -logger = logging.getLogger("modiff") +_DECLARATIVE_SIDECAR_ACTIONS = {"show", "hide", "value", "signal"} +_EXECUTION_UNSUPPORTED_MESSAGE = ( + "Dynamic Block Node is contract-preview only in this release. Upstream Modular configs can direct imports of " + "installed libraries even when trust_remote_code is disabled, so execution remains unavailable until MoDiff " + "has a reviewed component-library allowlist. Use the built-in generic Modular Diffusers nodes to run models." +) + + +def _require_json_boolean_trust(value): + if type(value) is not bool: + raise TypeError("Dynamic Block trust_remote_code must be a JSON boolean.") + return value + + +def _validate_sidecar_field_action(value, *, field_name, event_name, field_definitions): + """Reject code callbacks and mutations outside the published field contract.""" + + if value is None: + return + if isinstance(value, str): + raise ValueError( + f"Dynamic Block sidecar field {field_name!r} must not define server-executing {event_name} metadata." + ) + if isinstance(value, list): + for item in value: + _validate_sidecar_field_action( + item, + field_name=field_name, + event_name=event_name, + field_definitions=field_definitions, + ) + return + if not isinstance(value, Mapping): + raise ValueError(f"Dynamic Block sidecar field {field_name!r} has malformed {event_name} metadata.") + + action = value.get("action") + if action in {"exec", "create"}: + raise ValueError(f"Dynamic Block sidecar field {field_name!r} must not define {event_name} action {action!r}.") + if action is not None and action not in _DECLARATIVE_SIDECAR_ACTIONS: + raise ValueError(f"Dynamic Block sidecar field {field_name!r} has unsupported {event_name} action {action!r}.") + + allowed_fields = set(field_definitions) + + def validate_visibility_map(mapping): + if not isinstance(mapping, Mapping): + raise ValueError( + f"Dynamic Block sidecar field {field_name!r} has malformed {event_name} visibility data." + ) + for targets in mapping.values(): + target_names = targets if isinstance(targets, list) else [targets] + if any(not isinstance(target, str) or target not in allowed_fields for target in target_names): + raise ValueError( + f"Dynamic Block sidecar field {field_name!r} targets an unknown contract field." + ) + + if action is None: + validate_visibility_map(value) + elif action in {"show", "hide"}: + validate_visibility_map(value.get("data", {})) + else: + target = value.get("target") + if not isinstance(target, str) or target not in allowed_fields: + raise ValueError(f"Dynamic Block sidecar field {field_name!r} targets an unknown contract field.") + if action == "value": + prop = value.get("prop", "value") + if prop not in {"value", "hidden", "disabled", "options", "fieldOptions", "display"}: + raise ValueError( + f"Dynamic Block sidecar field {field_name!r} defines unsupported value property {prop!r}." + ) + else: + target_definition = field_definitions.get(target) + target_display = target_definition.get("display") if isinstance(target_definition, Mapping) else None + if target_display not in {"input", "output"}: + raise ValueError( + f"Dynamic Block sidecar field {field_name!r} signal target must be an input or output field." + ) + + +def _custom_node_contract(custom_config): + """Return an isolated, declarative-only DynamicBlock node contract.""" + + node_params = custom_config.node_params + if not isinstance(node_params, Mapping): + raise ValueError("Dynamic Block sidecar node_params must be a JSON object.") + raw_contract = node_params.get("custom") + if not isinstance(raw_contract, Mapping): + raise ValueError("Dynamic Block sidecar must define a 'custom' node contract object.") + + contract = deepcopy(dict(raw_contract)) + raw_params = contract.get("params") + if not isinstance(raw_params, Mapping): + raise ValueError("Dynamic Block sidecar custom.params must be a JSON object.") + field_definitions = dict(raw_params) + + sanitized_params = {} + for field_name, raw_field in raw_params.items(): + if ( + not isinstance(field_name, str) + or field_name in PROTOTYPE_SENSITIVE_FIELD_NAMES + or not isinstance(raw_field, Mapping) + ): + raise ValueError("Dynamic Block sidecar fields must map string names to JSON objects.") + field = deepcopy(dict(raw_field)) + for event_name in ("onChange", "onSignal"): + if event_name in field: + _validate_sidecar_field_action( + field[event_name], + field_name=field_name, + event_name=event_name, + field_definitions=field_definitions, + ) + sanitized_params[field_name] = field + contract["params"] = sanitized_params + + for names_key in ("model_input_names", "input_names", "output_names"): + names = contract.get(names_key, []) + if not isinstance(names, list) or any(not isinstance(name, str) for name in names): + raise ValueError(f"Dynamic Block sidecar custom.{names_key} must be a JSON string array.") + contract[names_key] = list(names) + + for text_key in ("label", "color"): + value = contract.get(text_key) + if value is not None and not isinstance(value, str): + raise ValueError(f"Dynamic Block sidecar custom.{text_key} must be a string when provided.") + return contract def _server(): from modiff.server import server + return server @@ -79,15 +199,22 @@ def send_node_definition_with_meta(self, params, label=None, header_color=None): "fieldOptions": {"noValidation": True}, }, "load_block_button": { - "label": "Load Custom Block", + "label": "Preview Custom Block Contract", "display": "ui_button", "value": False, "onChange": "update_node", }, "device": {"label": "Device", "type": "string", "value": DEFAULT_DEVICE, "options": DEVICE_LIST}, "auto_offload": {"label": "Enable Auto Offload", "type": "boolean", "value": False}, - "offload_mode": offload_mode_param(modes=[OFFLOAD_MODE_NONE, OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK]), - "trust_remote_code": {"label": "Trust Remote Code", "type": "boolean", "value": False}, + "offload_mode": offload_mode_param( + modes=[OFFLOAD_MODE_NONE, OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK] + ), + "trust_remote_code": { + "label": "Trust Remote Code", + "type": "boolean", + "value": False, + "description": "Execution is disabled on this contract-preview-only legacy node.", + }, "revision": { "label": "Revision", "type": "string", @@ -111,21 +238,35 @@ def __del__(self): components.remove_from_collection(comp_id, self.node_id) super().__del__() - def _get_custom_config(self, repo_id, revision=None): + def _get_verified_custom_config(self, repo_id, revision=None): revision = resolve_model_revision(repo_id, revision) revision = require_immutable_hub_revision(repo_id, revision, required=True) - custom_config = PipelineConfig.load(repo_id, revision=revision) - return custom_config + return PipelineConfig.load_verified( + repo_id, + source="hub", + revision=revision, + ) + + def _get_custom_config(self, repo_id, revision=None): + """Compatibility wrapper returning the verified sidecar configuration.""" + + return self._get_verified_custom_config(repo_id, revision).config def update_node(self, values, ref): if not values.get("repo_id", ""): self.send_node_definition({}) return + trust_remote_code = _require_json_boolean_trust(values.get("trust_remote_code", False)) + if trust_remote_code: + raise ValueError( + "Dynamic Block contract preview requires Trust Remote Code off; repository code is not " + "authorized by this legacy node." + ) repo_id = values.get("repo_id", "") revision = values.get("revision") - custom_config = self._get_custom_config(repo_id, revision) - node_config = custom_config.node_params["custom"] + verified_config = self._get_verified_custom_config(repo_id, revision) + node_config = _custom_node_contract(verified_config.config) custom_params = node_config["params"] self._model_input_names = node_config.get("model_input_names", []) @@ -152,155 +293,5 @@ def execute( revision=None, **kwargs, ): - revision = resolve_model_revision(repo_id, revision) - revision = require_immutable_hub_revision(repo_id, revision, required=True) - offload_mode = normalize_offload_mode(offload_mode, auto_offload=auto_offload, device=device) - if offload_mode not in [OFFLOAD_MODE_NONE, OFFLOAD_MODE_MODEL_CPU, OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK]: - self.notify( - f"Dynamic Modular Diffusers blocks do not support {offload_mode} offload.", - variant="error", - persist=False, - autoHideDuration=MESSAGE_DURATION, - ) - return None - logger.debug(f"Dynamic Block Node ({self.node_id}) received parameters:") - logger.debug(f" repo_id: '{repo_id}'") - logger.debug(f" device: '{device}'") - logger.debug(f" auto_offload: '{auto_offload}'") - logger.debug(f" offload_mode: '{offload_mode}'") - logger.debug(f" trust_remote_code: '{trust_remote_code}'") - - try: - pipeline = ModularPipeline.from_pretrained( - repo_id, - trust_remote_code=bool(trust_remote_code), - revision=revision, - components_manager=components, - collection=self.node_id, - local_files_only=True, - ) - except ValueError as e: - self.notify(f"{str(e)}", variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) - raise e - except ModuleNotFoundError as e: - self.notify( - f"{str(e)}. This likely means the custom code is trying to import a library that is not installed in MoDiff. Please check the error message for which module is missing and install it in your MoDiff environment.", - variant="error", - persist=False, - autoHideDuration=MESSAGE_DURATION, - ) - raise e - - pin_modular_component_revisions(pipeline, repo_id, revision) - - # Load config to get input/output names and dtype - custom_config = self._get_custom_config(repo_id, revision) - node_config = custom_config.node_params["custom"] - - # Get dtype from config - default_dtype = custom_config.default_dtype - if not default_dtype: - default_dtype = "bfloat16" - torch_dtype = str_to_dtype(default_dtype) - - use_group_offload = offload_mode in [OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK] - - # Configure component-manager residency before component load. CPU and - # MPS execution deliberately bypass accelerator offload hooks. - configure_components_manager_offload(components, mode=offload_mode, device=device) - - # Cast parameters to the types expected by the modular pipeline. - for param_name, param_config in node_config["params"].items(): - if param_name in kwargs and kwargs[param_name] is not None: - param_type = param_config.get("type", None) - if param_type == "float": - kwargs[param_name] = float(kwargs[param_name]) - elif param_type == "int": - kwargs[param_name] = int(kwargs[param_name]) - - # Handle components - collect from connected inputs (Load Models) and config - model_input_names = node_config.get("model_input_names", []) - expected_component_names = pipeline.pretrained_component_names - - model_ids = collect_model_ids( - kwargs, - target_key_names=model_input_names, - target_model_names=expected_component_names, - ) - - components_update_dict = {} - if model_ids: - components_update_dict = components.get_components_by_ids(ids=model_ids, return_dict_with_names=True) - - # Check which components need to be loaded vs reused - components_to_load = [] - for comp_name in pipeline.pretrained_component_names: - if comp_name in components_update_dict: - continue # Already provided externally - - comp_spec = pipeline.get_component_spec(comp_name) - comp_ids_to_reuse = reusable_component_ids( - components, - name=comp_name, - load_id=comp_spec.load_id, - dtype=torch_dtype, - requested_quantization=None, - offload_mode=offload_mode, - device=device, - node_id=self.node_id, - ) - if comp_ids_to_reuse: - # Reuse existing component - comp_id = comp_ids_to_reuse[0] - components_update_dict[comp_name] = components.get_one(component_id=comp_id) - else: - components_to_load.append(comp_name) - - pipeline.update_components(**components_update_dict) - pipeline.load_components(names=components_to_load, torch_dtype=torch_dtype) - - if use_group_offload: - try: - offload_result = apply_component_group_offload( - pipeline, - component_names=DEFAULT_GROUP_COMPONENTS, - device=device, - mode=offload_mode, - node_id=self.node_id, - scope="dynamic-modular", - ) - if not offload_result.applied: - raise RuntimeError("No compatible custom Modular Diffusers component was available to offload.") - logger.debug(f"Dynamic Block Node: applied {offload_mode} to {offload_result.components}") - except RuntimeError as exc: - self.notify(str(exc), variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) - raise - elif offload_mode == "none": - pipeline.to(device) - - record_pipeline_component_runtime_policy( - pipeline, - offload_mode=offload_mode, - device=device, - node_id=self.node_id, - ) - - # Build inputs dict - inputs_dict = {} - for input_name in node_config["input_names"]: - if input_name in kwargs: - inputs_dict[input_name] = kwargs.pop(input_name) - - # Execute pipeline - strip out_ prefix for pipeline call - node_output_names = node_config["output_names"] - pipeline_output_names = [name[4:] if name.startswith("out_") else name for name in node_output_names] - pipeline_outputs = pipeline(**inputs_dict, output=pipeline_output_names) - - # Map pipeline outputs back to node names (with the out_ prefix). - final_outputs = {} - for node_name, pipeline_name in zip(node_output_names, pipeline_output_names): - if pipeline_name in pipeline_outputs: - final_outputs[node_name] = pipeline_outputs[pipeline_name] - - final_outputs["doc"] = pipeline.blocks.doc - return final_outputs + _require_json_boolean_trust(trust_remote_code) + raise ValueError(_EXECUTION_UNSUPPORTED_MESSAGE) diff --git a/modules/ModularDiffusers/embeddings.py b/modules/ModularDiffusers/embeddings.py index a610afc..8d9609f 100644 --- a/modules/ModularDiffusers/embeddings.py +++ b/modules/ModularDiffusers/embeddings.py @@ -1,5 +1,4 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import importlib import logging from diffusers import ComponentSpec @@ -8,9 +7,27 @@ from . import MESSAGE_DURATION, components from .modular_utils import ( - DummyCustomPipeline, + normalize_modular_runtime_params, + pipeline_class_from_model_type, pipeline_class_from_runtime_inputs, - pipeline_class_to_modiff_node_config, + require_modiff_node_contract, + reject_undeclared_modular_generator, +) +from .route_state import ( + ROUTE_STATE_OUTPUT, + issue_wan_image_encoder_route_state, + preflight_wan_image_encoder_inputs, + reject_route_reserved_inputs_before_identity_resolution, + resolve_managed_component_by_id, + require_cataloged_wan_action_source, + require_component_binding, + require_route_state_shape_before_identity_resolution, + route_contract_for_model_type, + snapshot_wan_source_media, + validate_route_field_contract, + validate_wan_image_encoder_route_state, + wan_image_encoder_contract_from_component, + wan_image_processor_config_seal, ) from .utils import collect_model_ids @@ -82,19 +99,24 @@ def update_node(self, values, ref): if self._model_type == model_type: return None - if model_type is None or model_type == "" or model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - self._model_type = model_type - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - # not support this node type - if node_config is None: + if model_type is None or model_type == "": + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) + return None + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + raise + self._model_type = model_type node_params_to_update = node_config["params"] node_params_to_update.pop("text_encoders", None) @@ -112,7 +134,7 @@ def execute(self, **kwargs): kwargs = dict(kwargs) self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) # 2. create pipeline repo_id = None @@ -128,16 +150,13 @@ def execute(self, **kwargs): ) return None - self._pipeline = blocks.init_pipeline(repo_id, components_manager=components) + # Enforce the backend-issued action schema before initializing blocks. + kwargs = normalize_modular_runtime_params(kwargs, node_config) - # Preserve the graph compatibility cast until the upstream schema exposes exact types. - for param_name, param_config in node_config["params"].items(): - if param_name in kwargs and kwargs[param_name] is not None: - param_type = param_config.get("type", None) - if param_type == "float": - kwargs[param_name] = float(kwargs[param_name]) - elif param_type == "int": - kwargs[param_name] = int(kwargs[param_name]) + # Components came from the reviewed ModelsLoader contract. Re-reading + # repository config here would let a later cache mutation choose fresh + # component type hints outside that validation boundary. + self._pipeline = blocks.init_pipeline(components_manager=components) # 3. update components expected_component_names = blocks.component_names @@ -232,26 +251,90 @@ def __init__(self, node_id=None): self._model_type = "" self._pipeline_class = None + def _cache_params_equal(self, previous, current): + equal = super()._cache_params_equal(previous, current) + if not equal or not isinstance(current, dict) or self._pipeline_class is None: + return equal + model_type = getattr(self._pipeline_class, "__name__", "") + if route_contract_for_model_type(model_type) != "wan_i2v": + return True + binding = require_component_binding( + current.get("image_encoder"), + label="image encoder", + expected_model_type=model_type, + expected_role="image_encoder", + ) + require_cataloged_wan_action_source( + image=current.get("image"), + last_image=current.get("last_image"), + binding=binding, + ) + _blocks, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + current = normalize_modular_runtime_params(dict(current), node_config) + route_state = self.output.get(ROUTE_STATE_OUTPUT) + if route_state is None or getattr(self, "_pipeline", None) is None: + return False + resident_image_encoder = resolve_managed_component_by_id( + components, + current.get("image_encoder"), + label="Image Embeddings image encoder", + ) + if getattr(self._pipeline, "image_encoder", None) is not resident_image_encoder: + raise ValueError("The resident Wan image pipeline does not hold the exact connected image encoder.") + validate_wan_image_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + image=current.get("image"), + last_image=current.get("last_image"), + height=current.get("height"), + width=current.get("width"), + image_embeds=self.output.get("image_embeds"), + image_encoder=resident_image_encoder, + image_processor=getattr(self._pipeline, "image_processor", None), + execution_device=getattr(self._pipeline, "_execution_device", None), + ) + return True + def update_node(self, values, ref): node_params = {} model_type = self.get_signal_value("image_encoder") if self._model_type == model_type: + if not model_type or self._pipeline_class is None: + return None + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + node_params_to_update = dict(node_config["params"]) + node_params_to_update.pop("image_encoder", None) + self.send_node_definition(node_params_to_update) return None - if model_type is None or model_type == "" or model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - self._model_type = model_type - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - - if node_config is None: + if model_type is None or model_type == "": + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) + return None + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + raise + self._model_type = model_type node_params_to_update = node_config["params"] node_params_to_update.pop("image_encoder", None) @@ -260,10 +343,46 @@ def update_node(self, values, ref): def execute(self, **kwargs): kwargs = dict(kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_undeclared_modular_generator(kwargs) + reject_route_reserved_inputs_before_identity_resolution(kwargs) self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) + model_type = getattr(self._pipeline_class, "__name__", "") + wan_route = route_contract_for_model_type(model_type) == "wan_i2v" + route_binding = None + if wan_route: + route_binding = require_component_binding( + kwargs.get("image_encoder"), + label="image encoder", + expected_model_type=model_type, + expected_role="image_encoder", + ) + require_cataloged_wan_action_source( + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + binding=route_binding, + ) # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + validate_route_field_contract(kwargs, node_config) + kwargs = normalize_modular_runtime_params(kwargs, node_config) + source_snapshot = None + image_preflight = None + if wan_route: + source_snapshot = snapshot_wan_source_media(kwargs.get("image"), kwargs.get("last_image")) + image_preflight = preflight_wan_image_encoder_inputs( + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + ) + preinit_image_encoder = resolve_managed_component_by_id( + components, + kwargs.get("image_encoder"), + label="Image Embeddings image encoder", + ) + preinit_image_encoder_config_seal = wan_image_encoder_contract_from_component(preinit_image_encoder) # 2. Create pipeline repo_id = None @@ -281,18 +400,9 @@ def execute(self, **kwargs): ) return None - self._pipeline = blocks.init_pipeline(repo_id, components_manager=components) + self._pipeline = blocks.init_pipeline(components_manager=components) - # 3. Cast parameters to the types expected by the modular pipeline. - for param_name, param_config in node_config["params"].items(): - if param_name in kwargs and kwargs[param_name] is not None: - param_type = param_config.get("type", None) - if param_type == "float": - kwargs[param_name] = float(kwargs[param_name]) - elif param_type == "int": - kwargs[param_name] = int(kwargs[param_name]) - - # 4. Update components + # 3. Update components expected_component_names = blocks.component_names model_input_names = node_config["model_input_names"] model_ids = collect_model_ids( @@ -304,8 +414,11 @@ def execute(self, **kwargs): # The image encoder contract does not currently expose its processor as # a model input, so load the matching Diffusers component explicitly. # Network writes remain owned by the app's download flow. + from transformers import CLIPImageProcessor + spec = ComponentSpec( name="image_processor", + type_hint=CLIPImageProcessor, repo=repo_id, subfolder="image_processor", variant="", @@ -315,11 +428,36 @@ def execute(self, **kwargs): comp_id = components.add("image_processor", comp, collection=self.node_id) model_ids.append(comp_id) + components_to_update = {} if model_ids: components_to_update = components.get_components_by_ids(ids=model_ids, return_dict_with_names=True) if components_to_update: self._pipeline.update_components(**components_to_update) + installed_image_encoder = getattr(self._pipeline, "image_encoder", None) + installed_image_processor = getattr(self._pipeline, "image_processor", None) + image_processor_config_seal = None + if wan_route: + require_component_binding( + kwargs.get("image_encoder"), + label="image encoder", + expected_model_type=model_type, + expected_token=route_binding, + expected_role="image_encoder", + ) + if components_to_update.get("image_encoder") is not installed_image_encoder: + raise ValueError("The Wan image pipeline did not install the exact connected image encoder.") + if components_to_update.get("image_processor") is not installed_image_processor: + raise ValueError("The Wan image pipeline did not install the exact locally resolved image processor.") + if installed_image_encoder is not preinit_image_encoder: + raise ValueError("The connected Wan image encoder changed during pipeline initialization.") + if wan_image_encoder_contract_from_component(installed_image_encoder) != preinit_image_encoder_config_seal: + raise ValueError("The connected Wan image encoder contract changed during pipeline initialization.") + image_processor_config_seal = wan_image_processor_config_seal( + installed_image_processor, + workflow=image_preflight[0], + ) + # 5. Compile runtime inputs from kwargs based on node_config["input_names"] node_kwargs = {} input_names = node_config["input_names"] @@ -337,18 +475,83 @@ def execute(self, **kwargs): node_kwargs[name] = value # 6. Run the pipeline + if wan_route: + live_image_encoder = resolve_managed_component_by_id( + components, + kwargs.get("image_encoder"), + label="Image Embeddings image encoder", + ) + if live_image_encoder is not installed_image_encoder: + raise ValueError("The connected Wan image encoder changed before upstream execution.") + if ( + getattr(self._pipeline, "image_encoder", None) is not installed_image_encoder + or getattr(self._pipeline, "image_processor", None) is not installed_image_processor + ): + raise ValueError("Wan image encoder components changed before upstream execution.") + if ( + wan_image_processor_config_seal(installed_image_processor, workflow=image_preflight[0]) + != image_processor_config_seal + ): + raise ValueError("The Wan image processor configuration changed before upstream execution.") + if wan_image_encoder_contract_from_component(installed_image_encoder) != preinit_image_encoder_config_seal: + raise ValueError("The connected Wan image encoder contract changed before upstream execution.") try: node_output_state = self._pipeline(**node_kwargs) except ValueError as e: self.notify(str(e), variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) return None + if wan_route: + require_component_binding( + kwargs.get("image_encoder"), + label="image encoder", + expected_model_type=model_type, + expected_token=route_binding, + expected_role="image_encoder", + ) + if ( + getattr(self._pipeline, "image_encoder", None) is not installed_image_encoder + or getattr(self._pipeline, "image_processor", None) is not installed_image_processor + ): + raise ValueError("Wan image encoder components changed during upstream execution.") + if resolve_managed_component_by_id( + components, + kwargs.get("image_encoder"), + label="Image Embeddings image encoder", + ) is not installed_image_encoder: + raise ValueError("The connected Wan image encoder changed during upstream execution.") + if ( + wan_image_processor_config_seal(installed_image_processor, workflow=image_preflight[0]) + != image_processor_config_seal + ): + raise ValueError("The Wan image processor configuration changed during upstream execution.") + if wan_image_encoder_contract_from_component(installed_image_encoder) != preinit_image_encoder_config_seal: + raise ValueError("The connected Wan image encoder contract changed during upstream execution.") + # 7. Prepare outputs based on node_config["output_names"] output_names = node_config["output_names"].copy() outputs = {} for name in output_names: if name == "doc": outputs["doc"] = self._pipeline.blocks.doc + elif name == ROUTE_STATE_OUTPUT: + if not wan_route or route_binding is None or source_snapshot is None or image_preflight is None: + raise ValueError("The Wan Image Embeddings route is missing its backend binding.") + outputs[name] = issue_wan_image_encoder_route_state( + binding=route_binding, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + image_embeds=node_output_state.get("image_embeds"), + image_encoder=installed_image_encoder, + image_processor=installed_image_processor, + resized_image=node_output_state.get("resized_image"), + resized_last_image=node_output_state.get("resized_last_image"), + execution_device=self._pipeline._execution_device, + source_snapshot=source_snapshot, + preflight_geometry=image_preflight, + ) else: outputs[name] = node_output_state.get(name) diff --git a/modules/ModularDiffusers/guiders.py b/modules/ModularDiffusers/guiders.py index fd24c57..cecdb13 100644 --- a/modules/ModularDiffusers/guiders.py +++ b/modules/ModularDiffusers/guiders.py @@ -1,11 +1,13 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. import logging +from collections.abc import Mapping from diffusers import LayerSkipConfig, SmoothedEnergyGuidanceConfig from modiff.NodeBase import NodeBase -from . import FLUX_BLOCKS, QWEN_IMAGE_BLOCKS, SDXL_BLOCKS +from . import MODULAR_GUIDER_OPTIONS, MODULAR_LAYER_BLOCK_OPTIONS +from .pipeline_schema import MAX_GUIDER_OPTIONS, MAX_LAYER_BLOCK_OPTIONS logger = logging.getLogger("modiff") @@ -14,15 +16,18 @@ "SkipLayerGuidance": "skip_layer_config", "AutoGuidance": "auto_guidance_config", "SmoothedEnergyGuidance": "seg_guidance_config", + "PerturbedAttentionGuidance": "perturbed_guidance_config", } GUIDER_OPTIONS = { "ClassifierFreeGuidance": "Classifier Free Guidance", "SkipLayerGuidance": "Skip Layer Guidance", "AdaptiveProjectedGuidance": "Adaptive Projected Guidance", + "AdaptiveProjectedMixGuidance": "Adaptive Projected Mix Guidance", "ClassifierFreeZeroStarGuidance": "Classifier Free Zero Star Guidance", "AutoGuidance": "Auto Guidance", "SmoothedEnergyGuidance": "Smoothed Energy Guidance", + "PerturbedAttentionGuidance": "Perturbed Attention Guidance", "TangentialClassifierFreeGuidance": "Tangential Classifier Free Guidance", "FrequencyDecoupledGuidance": "Frequency Decoupled Guidance", } @@ -72,6 +77,42 @@ "max": 100.0, }, }, + "AdaptiveProjectedMixGuidance": { + "adaptive_projected_guidance_scale": { + "label": "Adaptive Projected Guidance Scale", + "type": "float", + "value": 10.0, + "min": 0.0, + "max": 100.0, + }, + "adaptive_projected_guidance_momentum": { + "label": "Adaptive Projected Guidance Momentum", + "type": "float", + "value": -0.5, + "min": -1.0, + "max": 1.0, + "step": 0.01, + }, + "adaptive_projected_guidance_rescale": { + "label": "Adaptive Projected Guidance Rescale", + "type": "float", + "value": 10.0, + "min": 0.0, + "max": 100.0, + }, + "eta": { + "label": "Eta", + "type": "float", + "value": 0.0, + "step": 0.01, + }, + "adaptive_projected_guidance_start_step": { + "label": "Adaptive Projected Guidance Start Step", + "type": "int", + "value": 5, + "min": 0, + }, + }, "ClassifierFreeZeroStarGuidance": { "zero_init_steps": { "label": "Zero Init Steps", @@ -89,8 +130,38 @@ "step": 0.01, } }, + "PerturbedAttentionGuidance": { + "perturbed_guidance_scale": { + "label": "Perturbed Guidance Scale", + "type": "float", + "value": 2.8, + "min": 0.0, + "max": 10.0, + }, + "perturbed_guidance_start": { + "label": "Perturbed Guidance Start", + "type": "float", + "display": "slider", + "value": 0.01, + "min": 0.0, + "max": 1.0, + "step": 0.01, + }, + "perturbed_guidance_stop": { + "label": "Perturbed Guidance Stop", + "type": "float", + "display": "slider", + "value": 0.2, + "min": 0.0, + "max": 1.0, + "step": 0.01, + }, + }, } +_BOOLEAN_GUIDER_ARGUMENTS = frozenset({"enabled", "use_original_formulation"}) +_INTEGER_GUIDER_ARGUMENTS = frozenset({"adaptive_projected_guidance_start_step", "zero_init_steps"}) + class Guider(NodeBase): label = "Guider" @@ -110,6 +181,7 @@ class Guider(NodeBase): "SkipLayerGuidance": ["layers_config"], "AutoGuidance": ["layers_config"], "SmoothedEnergyGuidance": ["layers_config"], + "PerturbedAttentionGuidance": ["layers_config"], }, ], }, @@ -136,6 +208,11 @@ class Guider(NodeBase): "type": "boolean", "value": False, }, + "enabled": { + "label": "Enabled", + "type": "boolean", + "value": True, + }, "start": { "label": "Start", "type": "float", @@ -158,16 +235,34 @@ class Guider(NodeBase): "label": "Guider", "display": "output", "type": "custom_guider", - "onSignal": { - "action": "signal", - "target": "layers_config", - }, + "onSignal": [ + { + "action": "value", + "target": "guider", + "prop": "options", + "data": MODULAR_GUIDER_OPTIONS, + }, + {"action": "signal", "target": "layers_config"}, + ], }, "layers_config": {"label": "Layers", "type": "layers_config", "display": "input"}, } + def _selected_guider(self, guider): + model_type = self.get_signal_value("guider_out") + allowed = MODULAR_GUIDER_OPTIONS.get(model_type) if isinstance(model_type, str) else None + if ( + not isinstance(guider, str) + or not isinstance(allowed, list) + or len(allowed) > MAX_GUIDER_OPTIONS + or guider not in GUIDER_OPTIONS + or guider not in allowed + ): + raise ValueError("Guider requires a class allowed by the connected reviewed Modular pipeline.") + return guider + def updateNode(self, values, ref): - value = values.get("guider") + value = self._selected_guider(values.get("guider")) params = GUIDER_CONFIGS.get(value, {}) self.send_node_definition(params) @@ -180,15 +275,21 @@ def execute(self, guider, layers_config=None, **kwargs): guider_options = {} for key, value in kwargs.items(): - if key == "use_original_formulation": + if key in _BOOLEAN_GUIDER_ARGUMENTS: + if not isinstance(value, bool): + raise TypeError(f"{key} must be a boolean.") guider_options[key] = value + elif key in _INTEGER_GUIDER_ARGUMENTS: + numeric_value = float(value) + if isinstance(value, bool) or not numeric_value.is_integer(): + raise ValueError(f"{key} must be an integer.") + guider_options[key] = int(numeric_value) else: guider_options[key] = float(value) logger.debug(f" - guider options: {guider_options}") - if guider not in GUIDER_OPTIONS: - raise ValueError(f"Unsupported Diffusers guider: {guider!r}.") + guider = self._selected_guider(guider) guider_cls = getattr(__import__("diffusers", fromlist=[guider]), guider) @@ -204,6 +305,8 @@ def execute(self, guider, layers_config=None, **kwargs): if isinstance(layers_config, dict): layers_config = [layers_config] + elif isinstance(layers_config, (LayerSkipConfig, SmoothedEnergyGuidanceConfig)): + layers_config = [layers_config] if isinstance(layers_config, list): layer_configs = [] @@ -217,6 +320,35 @@ def execute(self, guider, layers_config=None, **kwargs): raise TypeError( f"{guider} layer entries must be mappings or {expected_type.__name__} instances." ) + indices = getattr(config_dict, "indices", None) + fqn = getattr(config_dict, "fqn", None) + if ( + not isinstance(indices, list) + or not indices + or any( + isinstance(index, bool) or not isinstance(index, int) or index < 0 for index in indices + ) + ): + raise ValueError( + f"{guider} layer indices must be a non-empty list of non-negative integers." + ) + if not isinstance(fqn, str) or not fqn or fqn != fqn.strip(): + raise ValueError( + f"{guider} requires a non-empty layer FQN without surrounding whitespace." + ) + if guider == "PerturbedAttentionGuidance": + layer_config_values = config_dict.to_dict() + if float(layer_config_values.get("dropout", 1.0)) != 1.0: + raise ValueError( + "PerturbedAttentionGuidance requires Layers dropout to be 1.0 because it " + "perturbs attention scores." + ) + layer_config_values.update( + skip_attention=False, + skip_attention_scores=True, + skip_ff=False, + ) + config_dict = LayerSkipConfig(**layer_config_values) layer_configs.append(config_dict) continue @@ -234,7 +366,19 @@ def execute(self, guider, layers_config=None, **kwargs): if guider == "SmoothedEnergyGuidance": layer_config = SmoothedEnergyGuidanceConfig(indices=indices, fqn=fqn) else: - layer_config = LayerSkipConfig(**config_dict) + layer_config_values = dict(config_dict) + if guider == "PerturbedAttentionGuidance": + if float(layer_config_values.get("dropout", 1.0)) != 1.0: + raise ValueError( + "PerturbedAttentionGuidance requires Layers dropout to be 1.0 because it " + "perturbs attention scores." + ) + layer_config_values.update( + skip_attention=False, + skip_attention_scores=True, + skip_ff=False, + ) + layer_config = LayerSkipConfig(**layer_config_values) layer_configs.append(layer_config) @@ -285,20 +429,33 @@ class Layers(NodeBase): "action": "value", "target": "blocks_select", "prop": "options", - "data": { - "StableDiffusionXLModularPipeline": SDXL_BLOCKS, - "QwenImageModularPipeline": QWEN_IMAGE_BLOCKS, - "QwenImageEditModularPipeline": QWEN_IMAGE_BLOCKS, - "QwenImageEditPlusModularPipeline": QWEN_IMAGE_BLOCKS, - "FluxModularPipeline": FLUX_BLOCKS, - "FluxKontextModularPipeline": FLUX_BLOCKS, - }, + "data": MODULAR_LAYER_BLOCK_OPTIONS, }, }, } - def set_blocks(self, values, ref): + def _selected_blocks(self, values): + if not isinstance(values, Mapping): + raise TypeError("Layers values must be a mapping.") blocks_select = values.get("blocks_select", []) + if ( + not isinstance(blocks_select, list) + or len(blocks_select) > MAX_LAYER_BLOCK_OPTIONS + or any(not isinstance(block, str) or not block or block != block.strip() for block in blocks_select) + or len(blocks_select) != len(set(blocks_select)) + ): + raise ValueError("Layers requires a bounded list of unique block names.") + if not blocks_select: + return () + + model_type = self.get_signal_value("layers_config") + allowed_blocks = MODULAR_LAYER_BLOCK_OPTIONS.get(model_type) if isinstance(model_type, str) else None + if not isinstance(allowed_blocks, list) or any(block not in allowed_blocks for block in blocks_select): + raise ValueError("Layers requires block names allowed by the connected reviewed Modular pipeline.") + return tuple(blocks_select) + + def set_blocks(self, values, ref): + blocks_select = self._selected_blocks(values) params = {} @@ -312,15 +469,13 @@ def set_blocks(self, values, ref): def execute(self, **kwargs): layer_configs = [] + blocks_select = self._selected_blocks(kwargs) + supplied_blocks = {block for block in kwargs if block != "blocks_select"} + if supplied_blocks != set(blocks_select): + raise ValueError("Layers inputs must exactly match the reviewed selected block names.") - for block in kwargs: - if block == "blocks_select": - continue - + for block in blocks_select: config = kwargs.get(block, {}) - - if not isinstance(block, str) or not block or block != block.strip(): - raise ValueError("Layer block names must be non-empty FQNs without surrounding whitespace.") if not isinstance(config, dict): raise TypeError(f"Layer configuration for {block!r} must be a mapping.") diff --git a/modules/ModularDiffusers/ip_adapter.py b/modules/ModularDiffusers/ip_adapter.py new file mode 100644 index 0000000..bfc642c --- /dev/null +++ b/modules/ModularDiffusers/ip_adapter.py @@ -0,0 +1,235 @@ +"""Generic, exact SDXL IP-Adapter encoding action.""" + +import torch +from diffusers import BaseGuidance, ComponentSpec + +from modiff.NodeBase import NodeBase +from modiff.auxiliary_ip_adapter import resolve_reviewed_sdxl_ip_adapter + +from . import components +from .modular_utils import ( + normalize_modular_runtime_params, + pipeline_class_from_model_type, + pipeline_class_from_runtime_inputs, + require_modiff_node_contract, +) +from .route_state import ( + clear_sdxl_ip_adapter_state, + issue_sdxl_ip_adapter_bundle, + prepare_sdxl_ip_adapter_unet, + reject_route_reserved_inputs_before_identity_resolution, + require_component_binding, + require_route_state_shape_before_identity_resolution, + require_sdxl_ip_adapter_bundle, + resolve_managed_component_by_id, + sdxl_ip_adapter_feature_extractor_contract, + sdxl_ip_adapter_image_encoder_contract, + sdxl_ip_adapter_unet_contract, +) + + +class IPAdapter(NodeBase): + label = "IP-Adapter Embeddings" + category = "embedding" + resizable = True + skipParamsCheck = True + node_type = "ip_adapter" + params = { + "unet": { + "label": "Denoise Model *", + "display": "input", + "type": "diffusers_auto_model", + "required": True, + "onSignal": "update_node", + }, + } + + def __init__(self, node_id=None): + super().__init__(node_id) + self._model_type = "" + self._pipeline_class = None + self._pipeline = None + self._image_encoder = None + self._image_encoder_identity = None + + def update_node(self, values, ref): + model_type = self.get_signal_value("unet") + if not model_type: + self._model_type = "" + self._pipeline_class = None + self.send_node_definition({}) + return None + try: + pipeline_class = pipeline_class_from_model_type(model_type) + _blocks, node_config = require_modiff_node_contract( + pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None + self.send_node_definition({}) + raise + self._model_type = model_type + self._pipeline_class = pipeline_class + node_params = dict(node_config["params"]) + node_params.pop("unet", None) + self.send_node_definition(node_params) + return None + + def _cache_params_equal(self, previous, current): + equal = super()._cache_params_equal(previous, current) + if not equal or not isinstance(current, dict): + return equal + self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, current) + model_type = getattr(self._pipeline_class, "__name__", "") + binding = require_component_binding( + current.get("unet"), + label="IP-Adapter UNet", + expected_model_type=model_type, + expected_role="denoiser", + ) + unet = resolve_managed_component_by_id(components, current.get("unet"), label="IP-Adapter UNet") + guider = current.get("guider") + if not isinstance(guider, BaseGuidance): + raise TypeError("SDXL IP-Adapter requires a connected Diffusers Guider.") + require_sdxl_ip_adapter_bundle( + self.output.get("ip_adapter"), + binding=binding, + unet=unet, + guider=guider, + ) + return True + + def _load_image_encoder(self, artifact, *, dtype, device): + identity = ( + artifact.repository, + artifact.revision, + artifact.image_encoder_subfolder, + artifact.image_encoder_class, + str(dtype), + str(device), + ) + if self._image_encoder is not None and self._image_encoder_identity == identity: + sdxl_ip_adapter_image_encoder_contract(self._image_encoder) + return self._image_encoder + + from transformers import CLIPVisionModelWithProjection + + if artifact.image_encoder_class != CLIPVisionModelWithProjection.__name__: + raise ValueError("The installed Transformers runtime does not match the reviewed image-encoder class.") + spec = ComponentSpec( + name="image_encoder", + type_hint=CLIPVisionModelWithProjection, + repo=artifact.repository, + subfolder=artifact.image_encoder_subfolder, + revision=artifact.revision, + ) + image_encoder = spec.load(local_files_only=True, torch_dtype=dtype) + image_encoder.to(device=device) + sdxl_ip_adapter_image_encoder_contract(image_encoder) + self._image_encoder = image_encoder + self._image_encoder_identity = identity + return image_encoder + + def execute(self, **kwargs): + kwargs = dict(kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_route_reserved_inputs_before_identity_resolution(kwargs) + self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) + self._model_type = getattr(self._pipeline_class, "__name__", "") + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + if self._model_type != "StableDiffusionXLModularPipeline": + raise ValueError("The generic IP-Adapter action currently supports only its reviewed SDXL contract.") + kwargs = normalize_modular_runtime_params(kwargs, node_config) + binding = require_component_binding( + kwargs.get("unet"), + label="IP-Adapter UNet", + expected_model_type=self._model_type, + expected_role="denoiser", + ) + unet = resolve_managed_component_by_id(components, kwargs.get("unet"), label="IP-Adapter UNet") + guider = kwargs.get("guider") + if not isinstance(guider, BaseGuidance): + raise TypeError("SDXL IP-Adapter requires a connected Diffusers Guider.") + artifact = resolve_reviewed_sdxl_ip_adapter( + selection=kwargs.get("adapter_model"), + revision=kwargs.get("adapter_revision"), + weight_name=kwargs.get("adapter_weight_name"), + ) + previous_state = prepare_sdxl_ip_adapter_unet( + unet, + binding=binding, + ) + pipeline = blocks.init_pipeline(components_manager=components) + image_encoder = self._load_image_encoder( + artifact, + dtype=getattr(unet, "dtype", torch.float32), + device=pipeline._execution_device, + ) + pipeline.update_components(unet=unet, image_encoder=image_encoder, guider=guider) + if ( + getattr(pipeline, "unet", None) is not unet + or getattr(pipeline, "image_encoder", None) is not image_encoder + or getattr(pipeline, "guider", None) is not guider + ): + raise ValueError("The SDXL IP-Adapter pipeline did not install its exact connected components.") + feature_extractor = getattr(pipeline, "feature_extractor", None) + sdxl_ip_adapter_feature_extractor_contract(feature_extractor) + + mutated = False + try: + if previous_state is not None and previous_state._artifact_identity != artifact.identity: + pipeline.unload_ip_adapter() + clear_sdxl_ip_adapter_state(unet, expected_state=previous_state) + previous_state = None + if previous_state is None: + mutated = True + pipeline.load_ip_adapter( + str(artifact.load_directory), + subfolder="", + weight_name=artifact.weight_name, + local_files_only=True, + ) + mutated = True + pipeline.set_ip_adapter_scale(kwargs["adapter_scale"]) + sdxl_ip_adapter_unet_contract(unet, scale=kwargs["adapter_scale"]) + output_state = pipeline(ip_adapter_image=kwargs["ip_adapter_image"]) + if ( + getattr(pipeline, "unet", None) is not unet + or getattr(pipeline, "image_encoder", None) is not image_encoder + or getattr(pipeline, "guider", None) is not guider + ): + raise ValueError("SDXL IP-Adapter components changed during upstream encoding.") + if resolve_managed_component_by_id( + components, + kwargs.get("unet"), + label="IP-Adapter UNet", + ) is not unet: + raise ValueError("The connected SDXL UNet changed during IP-Adapter encoding.") + sdxl_ip_adapter_image_encoder_contract(image_encoder) + sdxl_ip_adapter_feature_extractor_contract(feature_extractor) + sdxl_ip_adapter_unet_contract(unet, scale=kwargs["adapter_scale"]) + bundle = issue_sdxl_ip_adapter_bundle( + binding=binding, + unet=unet, + artifact_identity=artifact.identity, + image_encoder=image_encoder, + feature_extractor=feature_extractor, + guider=guider, + scale=kwargs["adapter_scale"], + image=kwargs["ip_adapter_image"], + ip_adapter_embeds=output_state.get("ip_adapter_embeds"), + negative_ip_adapter_embeds=output_state.get("negative_ip_adapter_embeds"), + ) + self._pipeline = pipeline + except Exception: + if mutated: + try: + pipeline.unload_ip_adapter() + finally: + if previous_state is not None: + clear_sdxl_ip_adapter_state(unet, expected_state=previous_state) + raise + return {"ip_adapter": bundle, "doc": pipeline.blocks.doc} diff --git a/modules/ModularDiffusers/latents.py b/modules/ModularDiffusers/latents.py index 88b03c2..59c19e1 100644 --- a/modules/ModularDiffusers/latents.py +++ b/modules/ModularDiffusers/latents.py @@ -1,19 +1,49 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. -import importlib import json import logging import time import torch from PIL import Image +from diffusers.modular_pipelines import PipelineState from modiff.NodeBase import NodeBase from . import MESSAGE_DURATION, components from .modular_utils import ( - DummyCustomPipeline, + modular_generator_from_seed, + normalize_modular_runtime_params, + normalize_modular_seed, + pipeline_class_from_model_type, pipeline_class_from_runtime_inputs, - pipeline_class_to_modiff_node_config, + reject_undeclared_modular_generator, + require_modiff_node_contract, +) +from .route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + SUPPORTED_ROUTE_MODEL_TYPES, + consume_decode_route_state, + consume_wan_image_encoder_route_state, + effective_modular_block_input, + issue_encoder_route_state, + issue_wan_vae_route_state, + preflight_wan_vae_route_state, + reject_route_reserved_inputs_before_identity_resolution, + reject_route_reserved_inputs, + resolve_managed_component_by_id, + require_cataloged_wan_action_source, + require_component_binding, + require_route_state_shape_before_identity_resolution, + route_contract_for_model_type, + route_cache_params_equal, + require_wan_video_processor, + sdxl_vae_geometry_from_component, + validate_sdxl_crop_overlay_inputs, + validate_encoder_route_state, + validate_wan_post_vae_route_state, + validate_route_field_contract, + wan_video_processor_config_seal, ) from .utils import collect_model_ids @@ -21,6 +51,28 @@ logger = logging.getLogger("modiff") +def require_exact_installed_component(pipeline, component_updates, expected_component_names, name): + """Reject ambient ComponentsManager selection for an explicitly connected port.""" + + if name not in expected_component_names: + return None + expected = component_updates.get(name) + if expected is None: + raise ValueError(f"The connected Modular {name} could not be resolved by its exact managed component ID.") + if getattr(pipeline, name, None) is not expected: + raise ValueError(f"The Modular pipeline did not install the exact connected {name} component.") + return expected + + +def require_exact_resident_component(pipeline, component_input, name, *, label): + """Resolve a cache-time component and reject ambient or replaced residents.""" + + resolved = resolve_managed_component_by_id(components, component_input, label=label) + if pipeline is None or getattr(pipeline, name, None) is not resolved: + raise ValueError(f"The resident Modular pipeline does not hold the exact connected {name} component.") + return resolved + + def sanitized_tensor_summary(value): """Return JSON-safe tensor metadata without retaining or serializing data.""" if isinstance(value, torch.Tensor): @@ -131,39 +183,132 @@ def __init__(self, node_id=None): super().__init__(node_id) self._model_type = "" self._pipeline_class = None + self._wan_decode_video_processor = None + self._wan_decode_video_processor_config_seal = None + + def _cache_params_equal(self, previous, current): + equal = route_cache_params_equal(previous, current, fallback=super()._cache_params_equal) + if not equal or not isinstance(current, dict): + return equal + route_state = current.get(ROUTE_STATE_INPUT) + if self._pipeline_class is None: + return route_state is None + model_type = getattr(self._pipeline_class, "__name__", "") + if model_type not in SUPPORTED_ROUTE_MODEL_TYPES: + return True + if route_state is None: + return False + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + require_route_state_shape_before_identity_resolution(current) + reject_route_reserved_inputs_before_identity_resolution(current) + reject_route_reserved_inputs(current, model_type=model_type, action="decoder") + binding = require_component_binding( + current.get("vae"), + label="VAE", + expected_model_type=model_type, + expected_role="vae", + ) + resident_vae = None + resident_geometry = (None, None) + route_contract = route_contract_for_model_type(model_type) + resident_video_processor = None + if route_contract in {"sdxl", "wan_i2v"}: + resident_vae = require_exact_resident_component( + getattr(self, "_pipeline", None), + current.get("vae"), + "vae", + label="Decode VAE", + ) + if route_contract == "sdxl": + resident_geometry = sdxl_vae_geometry_from_component(resident_vae) + else: + resident_video_processor = require_wan_video_processor( + getattr(getattr(self, "_pipeline", None), "video_processor", None) + ) + if resident_video_processor is not self._wan_decode_video_processor: + return False + if ( + wan_video_processor_config_seal(resident_video_processor) + != self._wan_decode_video_processor_config_seal + ): + raise ValueError( + "The Wan Decode video processor configuration changed after cached output publication." + ) + effective_latents = effective_modular_block_input( + current, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="latents", + ) + consume_decode_route_state( + route_state, + binding=binding, + model_type=model_type, + latents=effective_latents, + vae_component=resident_vae, + vae_latent_channels=resident_geometry[0], + vae_scale_factor=resident_geometry[1], + video_processor=resident_video_processor, + execution_device=( + getattr(getattr(self, "_pipeline", None), "_execution_device", None) + if route_contract == "wan_i2v" + else None + ), + materialize_overlay=False, + ) + return True def update_node(self, values, ref): node_params = {} model_type = self.get_signal_value("vae") if self._model_type == model_type: + if not model_type or self._pipeline_class is None: + return None + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + node_params_to_update = dict(node_config["params"]) + node_params_to_update.pop("vae", None) + self.send_node_definition(node_params_to_update) return None - if model_type is None or model_type == "" or model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - self._model_type = model_type - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - - if node_config is None: + if model_type is None or model_type == "": + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) + return None + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + raise + self._model_type = model_type - node_params_to_update = node_config["params"] + node_params_to_update = dict(node_config["params"]) node_params_to_update.pop("vae", None) node_params.update(**node_params_to_update) self.send_node_definition(node_params) def execute(self, **kwargs): kwargs = dict(kwargs) - self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_route_reserved_inputs_before_identity_resolution(kwargs) + identity_kwargs = {name: value for name, value in kwargs.items() if name != ROUTE_STATE_INPUT} + self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, identity_kwargs) # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + validate_route_field_contract(kwargs, node_config) # 2. Create pipeline repo_id = None @@ -179,7 +324,56 @@ def execute(self, **kwargs): ) return None - self._pipeline = blocks.init_pipeline(repo_id, components_manager=components) + route_state = kwargs.get(ROUTE_STATE_INPUT) + if ( + getattr(self._pipeline_class, "__name__", "") in SUPPORTED_ROUTE_MODEL_TYPES + and route_state is None + ): + raise ValueError( + "Modular Decode requires the opaque route state emitted by its matching Denoise action." + ) + route_values = None + preinit_vae = None + preinit_geometry = (None, None) + decode_video_processor = None + decode_video_processor_config_seal = None + if route_state is not None: + reject_route_reserved_inputs( + kwargs, + model_type=getattr(self._pipeline_class, "__name__", ""), + action="decoder", + ) + effective_latents = effective_modular_block_input( + kwargs, + node_input_names=node_config["input_names"], + block_input_names=blocks.input_names, + target_name="latents", + ) + binding = require_component_binding( + vae, + label="VAE", + expected_model_type=getattr(self._pipeline_class, "__name__", ""), + expected_role="vae", + ) + decode_contract = route_contract_for_model_type(getattr(self._pipeline_class, "__name__", "")) + if decode_contract in {"sdxl", "wan_i2v"}: + preinit_vae = resolve_managed_component_by_id(components, vae, label="Decode VAE") + if decode_contract == "sdxl": + preinit_geometry = sdxl_vae_geometry_from_component(preinit_vae) + route_values = consume_decode_route_state( + route_state, + binding=binding, + model_type=getattr(self._pipeline_class, "__name__", ""), + latents=effective_latents, + vae_component=preinit_vae, + vae_latent_channels=preinit_geometry[0], + vae_scale_factor=preinit_geometry[1], + materialize_overlay=False, + ) + + # Use the installed block contract and inject the already-managed VAE; + # never re-read repository component hints in a downstream action. + self._pipeline = blocks.init_pipeline(components_manager=components) # 3. Cast parameters to the types expected by the modular pipeline. for param_name, param_config in node_config["params"].items(): @@ -199,16 +393,73 @@ def execute(self, **kwargs): target_model_names=expected_component_names, ) + components_to_update = {} if model_ids: components_to_update = components.get_components_by_ids(ids=model_ids, return_dict_with_names=True) if components_to_update: self._pipeline.update_components(**components_to_update) + if route_state is not None: + require_component_binding( + vae, + label="VAE", + expected_model_type=getattr(self._pipeline_class, "__name__", ""), + expected_token=binding, + expected_role="vae", + ) + installed_vae = require_exact_installed_component( + self._pipeline, + components_to_update, + expected_component_names, + "vae", + ) + live_vae = installed_vae + if installed_vae is not None: + live_vae = require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Decode VAE", + ) + if live_vae is not installed_vae: + raise ValueError("The connected Decode VAE changed during component installation.") + if decode_contract == "sdxl": + if installed_vae is not preinit_vae: + raise ValueError("The connected Decode VAE changed during pipeline initialization.") + vae_latent_channels, vae_scale_factor = sdxl_vae_geometry_from_component(installed_vae) + route_values = consume_decode_route_state( + route_state, + binding=binding, + model_type=getattr(self._pipeline_class, "__name__", ""), + latents=effective_latents, + vae_component=installed_vae, + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ) + elif decode_contract == "wan_i2v": + if installed_vae is not preinit_vae: + raise ValueError("The connected Wan Decode VAE changed during pipeline initialization.") + decode_video_processor = require_wan_video_processor( + getattr(self._pipeline, "video_processor", None) + ) + decode_video_processor_config_seal = wan_video_processor_config_seal(decode_video_processor) + route_values = consume_decode_route_state( + route_state, + binding=binding, + model_type=getattr(self._pipeline_class, "__name__", ""), + latents=effective_latents, + vae_component=installed_vae, + video_processor=decode_video_processor, + execution_device=self._pipeline._execution_device, + materialize_overlay=False, + ) # 5. Compile runtime inputs from kwargs based on node_config["input_names"] node_kwargs = {} input_names = node_config["input_names"] for name in input_names: + if name == ROUTE_STATE_INPUT: + continue if name not in kwargs: continue value = kwargs.get(name) @@ -220,13 +471,106 @@ def execute(self, **kwargs): elif name in blocks.input_names: node_kwargs[name] = value + if route_values is not None and route_values["mask_overlay_kwargs"] is not None: + if "mask_overlay_kwargs" not in blocks.input_names: + raise ValueError("The selected Modular decoder does not expose the routed mask overlay input.") + node_kwargs["mask_overlay_kwargs"] = route_values["mask_overlay_kwargs"] + if route_values is not None and route_values["decode_inputs"] is not None: + for name, value in route_values["decode_inputs"].items(): + if name not in blocks.input_names: + raise ValueError(f"The selected Modular decoder does not expose routed SDXL input '{name}'.") + node_kwargs[name] = value + # 6. Run the pipeline + if route_state is not None and route_values["contract"] == "wan_i2v": + if require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Decode VAE", + ) is not installed_vae: + raise ValueError("The connected Wan Decode VAE changed before upstream execution.") + if getattr(self._pipeline, "video_processor", None) is not decode_video_processor: + raise ValueError("The Wan Decode video processor changed before upstream execution.") + if wan_video_processor_config_seal(decode_video_processor) != decode_video_processor_config_seal: + raise ValueError("The Wan Decode video processor configuration changed before upstream execution.") + consume_decode_route_state( + route_state, + binding=binding, + model_type=getattr(self._pipeline_class, "__name__", ""), + latents=effective_latents, + vae_component=installed_vae, + video_processor=decode_video_processor, + execution_device=self._pipeline._execution_device, + materialize_overlay=False, + ) try: - node_output_state = self._pipeline(**node_kwargs) + if route_values is not None and route_values["contract"] == "qwen" and route_values["inpaint"]: + node_output_state = self._pipeline( + state=PipelineState(values={"mask": True}), + **node_kwargs, + ) + else: + node_output_state = self._pipeline(**node_kwargs) except ValueError as e: self.notify(str(e), variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) raise + if route_state is not None: + require_component_binding( + vae, + label="VAE", + expected_model_type=getattr(self._pipeline_class, "__name__", ""), + expected_token=binding, + expected_role="vae", + ) + installed_vae = require_exact_installed_component( + self._pipeline, + components_to_update, + expected_component_names, + "vae", + ) + live_vae = installed_vae + if installed_vae is not None: + live_vae = require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Decode VAE", + ) + if live_vae is not installed_vae: + raise ValueError("The connected Decode VAE changed during upstream execution.") + post_geometry = (None, None) + if route_values["contract"] == "sdxl": + post_latent_channels, post_scale_factor = sdxl_vae_geometry_from_component(installed_vae) + if (post_latent_channels, post_scale_factor) != (vae_latent_channels, vae_scale_factor): + raise ValueError("The connected Decode VAE geometry changed during upstream execution.") + post_geometry = (post_latent_channels, post_scale_factor) + elif route_values["contract"] == "wan_i2v": + if getattr(self._pipeline, "video_processor", None) is not decode_video_processor: + raise ValueError("The Wan Decode video processor changed during upstream execution.") + if wan_video_processor_config_seal(decode_video_processor) != decode_video_processor_config_seal: + raise ValueError("The Wan Decode video processor configuration changed during upstream execution.") + consume_decode_route_state( + route_state, + binding=binding, + model_type=getattr(self._pipeline_class, "__name__", ""), + latents=effective_latents, + vae_component=( + installed_vae if route_values["contract"] in {"sdxl", "wan_i2v"} else None + ), + vae_latent_channels=post_geometry[0], + vae_scale_factor=post_geometry[1], + video_processor=(decode_video_processor if route_values["contract"] == "wan_i2v" else None), + execution_device=( + self._pipeline._execution_device if route_values["contract"] == "wan_i2v" else None + ), + materialize_overlay=False, + ) + if route_values["contract"] == "wan_i2v": + self._wan_decode_video_processor = decode_video_processor + self._wan_decode_video_processor_config_seal = decode_video_processor_config_seal + # 7. Prepare outputs based on node_config["output_names"] outputs = {} output_names = node_config["output_names"].copy() @@ -273,28 +617,136 @@ def __init__(self, node_id=None): self._model_type = "" self._pipeline_class = None + def _cache_params_equal(self, previous, current): + equal = route_cache_params_equal(previous, current, fallback=super()._cache_params_equal) + if not equal or not isinstance(current, dict) or self._pipeline_class is None: + return equal + model_type = getattr(self._pipeline_class, "__name__", "") + route_contract = route_contract_for_model_type(model_type) + if route_contract == "wan_i2v": + binding = require_component_binding( + current.get("vae"), + label="VAE", + expected_model_type=model_type, + expected_role="vae", + ) + require_cataloged_wan_action_source( + image=current.get("image"), + last_image=current.get("last_image"), + binding=binding, + ) + _blocks, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + current = normalize_modular_runtime_params(dict(current), node_config) + route_input = current.get(ROUTE_STATE_INPUT) + route_output = self.output.get(ROUTE_STATE_OUTPUT) + if route_input is None or route_output is None: + return False + resident_vae = require_exact_resident_component( + getattr(self, "_pipeline", None), + current.get("vae"), + "vae", + label="Encode VAE", + ) + video_processor = getattr(getattr(self, "_pipeline", None), "video_processor", None) + require_wan_video_processor(video_processor) + preflight_wan_vae_route_state( + route_input, + binding=binding, + model_type=model_type, + image=current.get("image"), + last_image=current.get("last_image"), + height=current.get("height"), + width=current.get("width"), + num_frames=current.get("num_frames"), + vae_component=resident_vae, + ) + validate_wan_post_vae_route_state( + route_output, + binding=binding, + model_type=model_type, + seed=normalize_modular_seed(current.get("seed")), + image_condition_latents=self.output.get("image_condition_latents"), + height=current.get("height"), + width=current.get("width"), + num_frames=current.get("num_frames"), + vae_component=resident_vae, + video_processor=video_processor, + producer_execution_device=getattr(self._pipeline, "_execution_device", None), + ) + return True + if route_contract != "sdxl": + return True + binding = require_component_binding( + current.get("vae"), + label="VAE", + expected_model_type=model_type, + expected_role="vae", + ) + resident_vae = require_exact_resident_component( + getattr(self, "_pipeline", None), + current.get("vae"), + "vae", + label="Encode VAE", + ) + route_state = self.output.get(ROUTE_STATE_OUTPUT) + if route_state is None: + return False + resident_geometry = sdxl_vae_geometry_from_component(resident_vae) + validate_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=normalize_modular_seed(current.get("seed")), + image_latents=self.output.get("image_latents"), + mask=self.output.get("mask"), + masked_image_latents=self.output.get("masked_image_latents"), + vae_component=resident_vae, + vae_latent_channels=resident_geometry[0], + vae_scale_factor=resident_geometry[1], + ) + return True + def update_node(self, values, ref): node_params = {} model_type = self.get_signal_value("vae") if self._model_type == model_type: + if not model_type or self._pipeline_class is None: + return None + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + node_params_to_update = dict(node_config["params"]) + node_params_to_update.pop("vae", None) + self.send_node_definition(node_params_to_update) return None - if model_type is None or model_type == "" or model_type == "DummyCustomPipeline": - self._pipeline_class = DummyCustomPipeline - else: - diffusers_module = importlib.import_module("diffusers") - self._pipeline_class = getattr(diffusers_module, model_type) - - self._model_type = model_type - - _, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) - - if node_config is None: + if model_type is None or model_type == "": + self._model_type = "" + self._pipeline_class = None self.send_node_definition(node_params) - return + return None + try: + self._pipeline_class = pipeline_class_from_model_type(model_type) + _, node_config = require_modiff_node_contract( + self._pipeline_class, + self.node_type, + resolve_blocks=False, + ) + except ValueError: + self._model_type = "" + self._pipeline_class = None + self.send_node_definition(node_params) + raise + self._model_type = model_type - node_params_to_update = node_config["params"] + node_params_to_update = dict(node_config["params"]) node_params_to_update.pop("vae", None) node_params.update(**node_params_to_update) self.send_node_definition(node_params) @@ -310,10 +762,28 @@ def execute(self, **kwargs): elapsed_seconds=0.0, ) kwargs = dict(kwargs) + require_route_state_shape_before_identity_resolution(kwargs) + reject_undeclared_modular_generator(kwargs) + reject_route_reserved_inputs_before_identity_resolution(kwargs) self._pipeline_class = pipeline_class_from_runtime_inputs(self._pipeline_class, kwargs) - + model_type = getattr(self._pipeline_class, "__name__", "") + route_contract = route_contract_for_model_type(model_type) + route_binding = None + if route_contract == "wan_i2v": + route_binding = require_component_binding( + kwargs.get("vae"), + label="VAE", + expected_model_type=model_type, + expected_role="vae", + ) + require_cataloged_wan_action_source( + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + binding=route_binding, + ) # 1. Get node config - blocks, node_config = pipeline_class_to_modiff_node_config(self._pipeline_class, self.node_type) + blocks, node_config = require_modiff_node_contract(self._pipeline_class, self.node_type) + validate_route_field_contract(kwargs, node_config) # 2. Create pipeline repo_id = None @@ -329,16 +799,65 @@ def execute(self, **kwargs): ) return None - self._pipeline = blocks.init_pipeline(repo_id, components_manager=components) - - # 3. Cast parameters to the types expected by the modular pipeline. - for param_name, param_config in node_config["params"].items(): - if param_name in kwargs and kwargs[param_name] is not None: - param_type = param_config.get("type", None) - if param_type == "float": - kwargs[param_name] = float(kwargs[param_name]) - elif param_type == "int": - kwargs[param_name] = int(kwargs[param_name]) + # 3. Enforce the backend-issued action schema before initializing blocks. + kwargs = normalize_modular_runtime_params(kwargs, node_config) + if route_contract == "sdxl": + validate_sdxl_crop_overlay_inputs( + kwargs.get("padding_mask_crop"), + kwargs.get("image"), + kwargs.get("mask_image"), + ) + if "seed" in node_config["input_names"] and "generator" not in blocks.input_names: + raise ValueError( + "The backend-issued Modular Diffusers Encode Image contract declares a seed, but its installed " + "VAE encoder block does not expose the required generator input." + ) + preinit_vae = None + preinit_geometry = None + wan_preflight = None + wan_route_values = None + video_processor_config_seal = None + route_state = kwargs.get(ROUTE_STATE_INPUT) + if ROUTE_STATE_OUTPUT in node_config["output_names"]: + if kwargs.get("seed") is None: + raise ValueError("A Modular VAE route requires a seed before pipeline initialization.") + if route_binding is None: + route_binding = require_component_binding( + vae, + label="VAE", + expected_model_type=model_type, + expected_role="vae", + ) + if route_contract == "sdxl": + preinit_vae = resolve_managed_component_by_id(components, vae, label="Encode VAE") + preinit_geometry = sdxl_vae_geometry_from_component(preinit_vae) + elif route_contract == "wan_i2v": + if route_state is None: + raise ValueError( + "Wan Image Encode requires the opaque route emitted by Image Embeddings." + ) + preinit_vae = resolve_managed_component_by_id(components, vae, label="Encode VAE") + wan_preflight = preflight_wan_vae_route_state( + route_state, + binding=route_binding, + model_type=model_type, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + vae_component=preinit_vae, + ) + wan_route_values = consume_wan_image_encoder_route_state( + route_state, + binding=route_binding, + model_type=model_type, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + ) + self._pipeline = blocks.init_pipeline(components_manager=components) # 4. Update components expected_component_names = blocks.component_names @@ -349,13 +868,64 @@ def execute(self, **kwargs): target_model_names=expected_component_names, ) + components_to_update = {} if model_ids: components_to_update = components.get_components_by_ids(ids=model_ids, return_dict_with_names=True) if components_to_update: self._pipeline.update_components(**components_to_update) + route_geometry = None + if route_binding is not None: + require_component_binding( + vae, + label="VAE", + expected_model_type=model_type, + expected_token=route_binding, + expected_role="vae", + ) + installed_vae = require_exact_installed_component( + self._pipeline, + components_to_update, + expected_component_names, + "vae", + ) + live_vae = installed_vae + if installed_vae is not None: + live_vae = require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Encode VAE", + ) + if live_vae is not installed_vae: + raise ValueError("The connected Encode VAE changed during component installation.") + if route_contract == "sdxl": + if live_vae is not preinit_vae: + raise ValueError("The connected Encode VAE changed during pipeline initialization.") + route_geometry = sdxl_vae_geometry_from_component(live_vae) + if route_geometry != preinit_geometry: + raise ValueError("The connected Encode VAE geometry changed during pipeline initialization.") + elif route_contract == "wan_i2v": + if live_vae is not preinit_vae: + raise ValueError("The connected Wan Encode VAE changed during pipeline initialization.") + video_processor = require_wan_video_processor(getattr(self._pipeline, "video_processor", None)) + video_processor_config_seal = wan_video_processor_config_seal(video_processor) + installed_preflight = preflight_wan_vae_route_state( + route_state, + binding=route_binding, + model_type=model_type, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + vae_component=live_vae, + ) + if installed_preflight != wan_preflight: + raise ValueError("Wan VAE geometry changed during pipeline initialization.") # 5. Compile runtime inputs from kwargs based on node_config["input_names"] node_kwargs = {} + encoder_generator = None input_names = node_config["input_names"] for name in input_names: @@ -363,22 +933,101 @@ def execute(self, **kwargs): continue value = kwargs.get(name) - if isinstance(value, dict) and name not in blocks.input_names: + if name == "seed": + if value is not None: + encoder_generator = modular_generator_from_seed(value, self._pipeline) + node_kwargs["generator"] = encoder_generator + elif isinstance(value, dict) and name not in blocks.input_names: for k, v in value.items(): if k in blocks.input_names: node_kwargs[k] = v elif name in blocks.input_names: node_kwargs[name] = value + if wan_route_values is not None: + # Preserve the raw requested values only in kwargs/cache identity. + # The second upstream resize must receive the first-pass geometry. + node_kwargs["height"] = wan_route_values["height"] + node_kwargs["width"] = wan_route_values["width"] + if "image" in node_kwargs: node_kwargs["image"] = prepare_image_for_vae_pipeline(node_kwargs["image"], self._pipeline_class) # 6. Run the pipeline + if route_contract == "wan_i2v": + if require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Encode VAE", + ) is not installed_vae: + raise ValueError("The connected Wan Encode VAE changed before upstream execution.") + if getattr(self._pipeline, "video_processor", None) is not video_processor: + raise ValueError("The Wan VAE video processor changed before upstream execution.") + if wan_video_processor_config_seal(video_processor) != video_processor_config_seal: + raise ValueError("The Wan VAE video processor configuration changed before upstream execution.") + if preflight_wan_vae_route_state( + route_state, + binding=route_binding, + model_type=model_type, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + vae_component=installed_vae, + ) != wan_preflight: + raise ValueError("Wan VAE geometry changed before upstream execution.") try: node_output_state = self._pipeline(**node_kwargs) except ValueError as e: self.notify(str(e), variant="error", persist=False, autoHideDuration=MESSAGE_DURATION) raise + if route_binding is not None: + require_component_binding( + vae, + label="VAE", + expected_model_type=model_type, + expected_token=route_binding, + expected_role="vae", + ) + installed_vae = require_exact_installed_component( + self._pipeline, + components_to_update, + expected_component_names, + "vae", + ) + live_vae = installed_vae + if installed_vae is not None: + live_vae = require_exact_resident_component( + self._pipeline, + vae, + "vae", + label="Encode VAE", + ) + if live_vae is not installed_vae: + raise ValueError("The connected Encode VAE changed during upstream execution.") + if route_geometry is not None and sdxl_vae_geometry_from_component(installed_vae) != route_geometry: + raise ValueError("The connected Encode VAE geometry changed during upstream execution.") + if route_contract == "wan_i2v": + if live_vae is not preinit_vae: + raise ValueError("The connected Wan Encode VAE changed during upstream execution.") + if getattr(self._pipeline, "video_processor", None) is not video_processor: + raise ValueError("The Wan VAE video processor changed during upstream execution.") + if wan_video_processor_config_seal(video_processor) != video_processor_config_seal: + raise ValueError("The Wan VAE video processor configuration changed during upstream execution.") + if preflight_wan_vae_route_state( + route_state, + binding=route_binding, + model_type=model_type, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + vae_component=live_vae, + ) != wan_preflight: + raise ValueError("Wan VAE geometry changed during upstream execution.") # 7. Prepare outputs based on node_config["output_names"] output_names = node_config["output_names"].copy() @@ -386,6 +1035,54 @@ def execute(self, **kwargs): for name in output_names: if name == "doc": outputs["doc"] = self._pipeline.blocks.doc + elif name == ROUTE_STATE_OUTPUT: + if encoder_generator is None or route_binding is None: + raise ValueError("The Modular VAE route is missing its validated generator or loader binding.") + if route_contract == "wan_i2v": + raw_name = "first_last_frame_latents" if wan_preflight[0] == "flf2v" else "first_frame_latents" + outputs[name] = issue_wan_vae_route_state( + route_state, + binding=route_binding, + seed=kwargs["seed"], + generator=encoder_generator, + image=kwargs.get("image"), + last_image=kwargs.get("last_image"), + height=kwargs.get("height"), + width=kwargs.get("width"), + num_frames=kwargs.get("num_frames"), + image_condition_latents=node_output_state.get("image_condition_latents"), + raw_frame_latents=node_output_state.get(raw_name), + vae_component=installed_vae, + video_processor=video_processor, + resized_image=node_output_state.get("resized_image"), + resized_last_image=node_output_state.get("resized_last_image"), + execution_device=self._pipeline._execution_device, + preflight_geometry=wan_preflight, + ) + continue + route_kwargs = { + "binding": route_binding, + "seed": kwargs["seed"], + "generator": encoder_generator, + "image_latents": node_output_state.get("image_latents"), + "processed_mask_image": node_output_state.get("processed_mask_image"), + "mask_overlay_kwargs": node_output_state.get("mask_overlay_kwargs"), + } + if route_contract == "sdxl": + route_kwargs.update( + mask=node_output_state.get("mask"), + masked_image_latents=node_output_state.get("masked_image_latents"), + padding_mask_crop=kwargs.get("padding_mask_crop"), + crops_coords=node_output_state.get("crops_coords"), + original_image=node_kwargs.get("image"), + original_mask=node_kwargs.get("mask_image"), + vae_component=installed_vae, + vae_latent_channels=route_geometry[0], + vae_scale_factor=route_geometry[1], + ) + outputs[name] = issue_encoder_route_state( + **route_kwargs, + ) else: outputs[name] = node_output_state.get(name) diff --git a/modules/ModularDiffusers/loaders.py b/modules/ModularDiffusers/loaders.py index cc5b622..fae0a5e 100644 --- a/modules/ModularDiffusers/loaders.py +++ b/modules/ModularDiffusers/loaders.py @@ -1,14 +1,30 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. import logging +import hashlib +import json +import importlib +import re +import threading import traceback from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path import torch from diffusers import ComponentSpec, ModularPipeline from diffusers.utils import logging as diffusers_logging -from .pipeline_schema import MoDiffPipelineConfig as PipelineConfig +from huggingface_hub import get_hf_file_metadata, hf_hub_download, hf_hub_url +from huggingface_hub.utils import EntryNotFoundError, HfHubHTTPError, LocalEntryNotFoundError from modiff.NodeBase import NodeBase +from modiff.auxiliary_lora import ( + ResolvedLoraDescriptor, + reviewed_scheduler_effective_config, + resolve_lora_descriptors, + scheduler_override_contract, +) from modiff.diffusers_offload import ( DEFAULT_GROUP_COMPONENTS, OFFLOAD_MODE_GROUP_CPU, @@ -21,18 +37,37 @@ normalize_offload_mode, offload_mode_param, ) -from modiff.model_artifact_catalog import resolve_model_revision +from modiff.model_artifact_catalog import require_catalog_revision, resolve_model_revision +from modiff.modular_workflow_contracts import ( + PINNED_MODULAR_REPOSITORY_LOAD_COMPONENT_TYPES, + PINNED_MODULAR_REPOSITORY_COMPONENT_TYPES, + PINNED_MODULAR_REPOSITORY_VARIANTS, +) from utils.torch_utils import DEFAULT_DEVICE, DEVICE_LIST, str_to_dtype -from . import MESSAGE_DURATION, MODULAR_REGISTRY, components +from . import MESSAGE_DURATION, components +from .custom_pipeline import ( + CUSTOM_PIPELINE_IDENTITY_FIELD, + CUSTOM_PIPELINE_MODEL_TYPE, + CustomPipelineExecutionIdentity, + resolve_custom_pipeline_binding, +) from .modular_utils import ( - DUMMY_CUSTOM_PIPELINE_CONFIG, - DummyCustomPipeline, get_all_model_types, get_model_type_metadata, pin_modular_component_revisions, + pipeline_class_from_model_type, require_immutable_hub_revision, ) +from .route_state import ( + bind_loader_outputs, + bind_standalone_component_output, + issue_pipeline_instance_token, + issue_standalone_component_issuer, + reset_owned_sdxl_ip_adapter_for_loader, + require_standalone_component_binding, + standalone_component_reuse_is_bound, +) logger = logging.getLogger("modiff") @@ -41,6 +76,609 @@ QWEN_LOW_VRAM_COMPONENT = "qwen_low_vram" QWEN_LOW_RESOURCE_COMPONENTS = {"transformer", "text_encoder"} GROUP_OFFLOAD_COMPONENTS = set(DEFAULT_GROUP_COMPONENTS) +MODELS_LOADER_IDENTITY_OUTPUTS = ("text_encoders", "unet_out", "vae_out", "scheduler", "image_encoder") +MODELS_LOADER_COMPONENT_OUTPUTS = frozenset({"image_encoder"}) +MAX_REVIEWED_PIPELINE_INDEX_BYTES = 1024 * 1024 +_REVIEWED_PIPELINE_INDEX_FILENAMES = ("modular_model_index.json", "model_index.json") +MAX_REVIEWED_COMPONENT_CONFIG_BYTES = 1024 * 1024 +MAX_REVIEWED_JSON_DEPTH = 64 +MAX_REVIEWED_JSON_ITEMS = 100_000 +_EXACT_HUB_REVISION = re.compile(r"^[0-9a-f]{40}$") +_COMPONENT_CONFIG_CLASS_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_DIFFUSERS_COMPONENT_CATEGORY_MODULES = { + "unet": "unets.", + "transformer": "transformers.", + "vae": "autoencoders.", + "controlnet": "controlnets.", +} +# This pinned Diffusers export inherits torch.nn.Module directly rather than +# ModelMixin, so it does not implement the reviewed standalone loading contract. +_DIFFUSERS_COMPONENT_EXPORT_EXCLUSIONS = frozenset({"DualTransformer2DModel"}) + + +def _reviewed_loader_component_outputs(model_type): + metadata = get_model_type_metadata(model_type) + outputs = metadata.get("loader_component_outputs") if isinstance(metadata, Mapping) else None + if not isinstance(outputs, list) or any(name not in MODELS_LOADER_COMPONENT_OUTPUTS for name in outputs): + raise RuntimeError("The registered Modular pipeline has an invalid loader component output contract.") + return tuple(outputs) + + +def _reject_duplicate_pipeline_index_keys(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError(f"Duplicate JSON key {key!r} is not allowed") + value[key] = item + return value + + +def _reject_nonfinite_pipeline_index_number(value): + raise ValueError(f"Non-finite JSON number {value!r} is not allowed") + + +def _validate_reviewed_json_shape(document, *, description): + """Bound nesting and aggregate values after the finite-size JSON parse.""" + + pending = [(document, 0)] + item_count = 0 + while pending: + value, depth = pending.pop() + item_count += 1 + if item_count > MAX_REVIEWED_JSON_ITEMS: + raise EnvironmentError( + f"{description} exceeds the {MAX_REVIEWED_JSON_ITEMS}-item JSON limit." + ) + if depth > MAX_REVIEWED_JSON_DEPTH: + raise EnvironmentError( + f"{description} exceeds the {MAX_REVIEWED_JSON_DEPTH}-level JSON depth limit." + ) + if isinstance(value, dict): + pending.extend((item, depth + 1) for item in value.values()) + elif isinstance(value, list): + pending.extend((item, depth + 1) for item in value) + + +def _reviewed_json_fingerprint(document): + canonical = json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def _read_bounded_reviewed_json(read_path, *, byte_limit, description): + try: + file_size = read_path.stat().st_size + if file_size > byte_limit: + raise EnvironmentError(f"{description} exceeds the {byte_limit}-byte limit.") + with read_path.open("rb") as reader: + raw_bytes = reader.read(byte_limit + 1) + except OSError as error: + raise EnvironmentError(f"Could not read {description}: {error}") from error + if len(raw_bytes) > byte_limit: + raise EnvironmentError(f"{description} exceeds the {byte_limit}-byte limit.") + try: + document = json.loads( + raw_bytes.decode("utf-8"), + object_pairs_hook=_reject_duplicate_pipeline_index_keys, + parse_constant=_reject_nonfinite_pipeline_index_number, + ) + except (UnicodeDecodeError, ValueError, RecursionError) as error: + raise EnvironmentError(f"{description} is not unambiguous UTF-8 JSON: {error}") from error + if not isinstance(document, dict): + raise EnvironmentError(f"{description} must be a JSON object.") + _validate_reviewed_json_shape(document, description=description) + return document + + +def _path_is_link(path): + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +@lru_cache(maxsize=1) +def _reviewed_diffusers_component_exports(): + """Return the pinned Diffusers model export map without importing model classes.""" + + from diffusers import models as diffusers_models + + import_structure = getattr(diffusers_models, "_import_structure", None) + if not isinstance(import_structure, dict): + raise RuntimeError("The pinned Diffusers models export contract is unavailable.") + categories = {} + for category, module_prefix in _DIFFUSERS_COMPONENT_CATEGORY_MODULES.items(): + exports = {} + for relative_module, names in import_structure.items(): + if not isinstance(relative_module, str) or not relative_module.startswith(module_prefix): + continue + if not isinstance(names, (list, tuple)): + raise RuntimeError( + f"The pinned Diffusers export contract for {relative_module!r} is malformed." + ) + for name in names: + if not isinstance(name, str) or not _COMPONENT_CONFIG_CLASS_NAME.fullmatch(name): + raise RuntimeError( + f"The pinned Diffusers export contract for {relative_module!r} has an invalid class name." + ) + if name in _DIFFUSERS_COMPONENT_EXPORT_EXCLUSIONS: + continue + previous = exports.setdefault(name, relative_module) + if previous != relative_module: + raise RuntimeError( + f"The pinned Diffusers component {name!r} is exported by multiple model modules." + ) + categories[category] = tuple(sorted(exports.items())) + return tuple(sorted(categories.items())) + + +def reviewed_diffusers_component_class_names(category): + """Expose generic Hub filters from the same backend category allowlist.""" + + exports = dict(_reviewed_diffusers_component_exports()) + if not isinstance(category, str) or category not in exports: + return [] + return [name for name, _module in exports[category]] + + +def _resolve_reviewed_diffusers_component_class(category, class_name): + """Resolve one prevalidated class only from its pinned Diffusers models module.""" + + category_exports = dict(dict(_reviewed_diffusers_component_exports()).get(category, ())) + relative_module = category_exports.get(class_name) + if relative_module is None: + raise ValueError( + f"Diffusers class {class_name!r} is not an approved {category!r} component in the pinned runtime." + ) + + # The repository selects only an allowlisted export name. The installed, + # pinned Diffusers package supplies the module path and class object. + module = importlib.import_module(f"diffusers.models.{relative_module}") + component_class = getattr(module, class_name, None) + from diffusers import ModelMixin + + expected_prefix = f"diffusers.models.{_DIFFUSERS_COMPONENT_CATEGORY_MODULES[category]}" + if ( + not isinstance(component_class, type) + or not issubclass(component_class, ModelMixin) + or not component_class.__module__.startswith(expected_prefix) + or not callable(getattr(component_class, "from_pretrained", None)) + ): + raise ValueError( + f"Installed Diffusers class {class_name!r} does not satisfy the reviewed {category!r} " + "ModelMixin loading contract." + ) + return component_class + + +def _normalize_reviewed_component_subfolder(subfolder): + if subfolder is None or subfolder == "": + return None + if not isinstance(subfolder, str): + raise TypeError("AutoModelLoader subfolder must be a string.") + if len(subfolder) > 512 or "\\" in subfolder or "\x00" in subfolder: + raise ValueError("AutoModelLoader subfolder must be a short relative POSIX path.") + parts = subfolder.split("/") + if subfolder.startswith("/") or any(part in ("", ".", "..") for part in parts): + raise ValueError("AutoModelLoader subfolder must be a traversal-free relative path.") + if any(":" in part for part in parts): + raise ValueError("AutoModelLoader subfolder must not contain a drive or URI scheme.") + return "/".join(parts) + + +def _reviewed_local_component_config(repository, subfolder): + raw_root = Path(repository).expanduser().absolute() + if not raw_root.exists() or not raw_root.is_dir() or _path_is_link(raw_root): + raise ValueError("AutoModelLoader local source must be an existing non-linked directory.") + try: + root = raw_root.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError("AutoModelLoader could not resolve the selected local directory.") from error + + current = root + for part in subfolder.split("/") if subfolder else (): + current = current / part + if not current.exists() or not current.is_dir() or _path_is_link(current): + raise ValueError("AutoModelLoader local subfolder must stay inside non-linked directories.") + config_path = current / "config.json" + if not config_path.exists() or not config_path.is_file() or _path_is_link(config_path): + raise ValueError("AutoModelLoader requires a non-linked config.json in the selected component directory.") + try: + config_path.resolve(strict=True).relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError("AutoModelLoader component config escapes the selected local directory.") from error + return config_path + + +def _reviewed_hub_component_config_path(path, *, repository, revision): + config_path = Path(path).absolute() + snapshot_path = None + for parent in config_path.parents: + if parent.name == revision and parent.parent.name == "snapshots": + snapshot_path = parent + break + if snapshot_path is None: + raise EnvironmentError( + f"The cached component config for {repository}@{revision} is not in its exact Hub snapshot." + ) + try: + relative_path = config_path.relative_to(snapshot_path) + except ValueError as error: + raise EnvironmentError( + f"The cached component config for {repository}@{revision} escapes its exact Hub snapshot." + ) from error + + repo_cache_path = snapshot_path.parent.parent + for directory in (repo_cache_path, snapshot_path.parent, snapshot_path): + if _path_is_link(directory): + raise EnvironmentError(f"The cached component snapshot boundary must not be linked: '{directory}'.") + current = snapshot_path + for part in relative_path.parts[:-1]: + current = current / part + if _path_is_link(current): + raise EnvironmentError(f"The cached component config parent must not be linked: '{current}'.") + + read_path = config_path + if config_path.is_symlink(): + try: + blob_path = repo_cache_path / "blobs" + if _path_is_link(blob_path): + raise ValueError("the repository blobs directory is linked") + read_path = config_path.resolve(strict=True) + read_path.relative_to(blob_path.resolve(strict=True)) + except (OSError, RuntimeError, ValueError) as error: + raise EnvironmentError( + f"The cached component config for {repository}@{revision} does not resolve inside its repository cache." + ) from error + if not read_path.is_file(): + raise EnvironmentError(f"The cached component config for {repository}@{revision} is not a regular file.") + return read_path + + +def _load_reviewed_component_config(source, repository, subfolder, revision): + """Load only a bounded component config; Hub downloads use an exact commit.""" + + if source == "local": + config_path = _reviewed_local_component_config(repository, subfolder) + description = f"AutoModelLoader component config '{config_path}'" + return _read_bounded_reviewed_json( + config_path, + byte_limit=MAX_REVIEWED_COMPONENT_CONFIG_BYTES, + description=description, + ) + + download_kwargs = { + "repo_id": repository, + "filename": "config.json", + "revision": revision, + "subfolder": subfolder, + } + try: + config_path = hf_hub_download(**download_kwargs, local_files_only=True) + except (EntryNotFoundError, LocalEntryNotFoundError): + url = hf_hub_url( + repository, + filename="config.json", + subfolder=subfolder, + revision=revision, + ) + try: + metadata = get_hf_file_metadata(url) + except (HfHubHTTPError, ValueError) as error: + raise EnvironmentError( + f"Could not inspect the exact component config for {repository}@{revision}: {error}" + ) from error + if metadata.commit_hash and metadata.commit_hash.lower() != revision: + raise EnvironmentError( + f"The Hub resolved {repository}@{revision} to a different commit; refusing the component config." + ) + if metadata.size is None or metadata.size > MAX_REVIEWED_COMPONENT_CONFIG_BYTES: + raise EnvironmentError( + f"The component config for {repository}@{revision} has no bounded size or exceeds the " + f"{MAX_REVIEWED_COMPONENT_CONFIG_BYTES}-byte limit." + ) + try: + config_path = hf_hub_download(**download_kwargs) + except (EntryNotFoundError, HfHubHTTPError, ValueError) as error: + raise EnvironmentError( + f"Could not download the exact component config for {repository}@{revision}: {error}" + ) from error + + read_path = _reviewed_hub_component_config_path( + config_path, + repository=repository, + revision=revision, + ) + return _read_bounded_reviewed_json( + read_path, + byte_limit=MAX_REVIEWED_COMPONENT_CONFIG_BYTES, + description=f"cached component config for {repository}@{revision}", + ) + + +def _preflight_reviewed_diffusers_component(model_type, model_id, subfolder, revision): + """Bind an untrusted selection to one installed Diffusers component export.""" + + if not isinstance(model_type, str) or model_type not in _DIFFUSERS_COMPONENT_CATEGORY_MODULES: + raise ValueError( + "AutoModelLoader requires a component type of unet, transformer, vae, or controlnet; " + f"received {model_type!r}. Rebuild or repair the managed graph before loading model weights." + ) + if not isinstance(model_id, Mapping): + raise TypeError("AutoModelLoader model_id must be a model-selector JSON object.") + source = model_id.get("source") + repository = model_id.get("value") + if not isinstance(source, str) or source not in {"hub", "local"}: + raise ValueError("AutoModelLoader model source must be exactly 'hub' or 'local'.") + if not isinstance(repository, str) or not repository.strip(): + raise ValueError("AutoModelLoader requires a non-empty repository or local directory.") + repository = repository.strip() + if len(repository) > 4096 or "\x00" in repository: + raise ValueError("AutoModelLoader repository selection is too long or contains a null byte.") + subfolder = _normalize_reviewed_component_subfolder(subfolder) + + if revision is not None and not isinstance(revision, str): + raise TypeError("AutoModelLoader revision must be a string when provided.") + explicit_revision = str(revision or "").strip() or None + if source == "hub": + resolved_revision = resolve_model_revision(repository, explicit_revision, source="hub") + resolved_revision = require_immutable_hub_revision(repository, resolved_revision, required=True) + if not isinstance(resolved_revision, str) or not _EXACT_HUB_REVISION.fullmatch(resolved_revision): + raise ValueError( + "AutoModelLoader Hub components require an exact lowercase 40-character commit revision." + ) + else: + if explicit_revision is not None: + raise ValueError("AutoModelLoader local components must not carry a Hub revision.") + resolved_revision = None + raw_root = Path(repository).expanduser().absolute() + if _path_is_link(raw_root): + raise ValueError("AutoModelLoader local source must be an existing non-linked directory.") + try: + repository = str(raw_root.resolve(strict=True)) + except (OSError, RuntimeError) as error: + raise ValueError("AutoModelLoader could not resolve the selected local directory.") from error + + document = _load_reviewed_component_config( + source, + repository, + subfolder, + resolved_revision, + ) + if "auto_map" in document: + raise ValueError("AutoModelLoader component configs must not declare remote-code auto_map entries.") + class_name = document.get("_class_name") + if not isinstance(class_name, str) or not _COMPONENT_CONFIG_CLASS_NAME.fullmatch(class_name): + if "model_type" in document: + raise ValueError( + "AutoModelLoader does not accept Transformers model_type-only configs; select a Diffusers component." + ) + raise ValueError("AutoModelLoader component config requires a simple Diffusers _class_name.") + category_exports = dict(dict(_reviewed_diffusers_component_exports())[model_type]) + if class_name not in category_exports: + raise ValueError( + f"Diffusers class {class_name!r} is not an approved {model_type!r} component in the pinned runtime." + ) + return source, repository, resolved_revision, subfolder, class_name, _reviewed_json_fingerprint(document) + + +def _read_reviewed_pipeline_index(path, *, repository, revision): + """Read one exact cached Hub index through a finite, duplicate-free boundary.""" + + index_path = Path(path).absolute() + snapshot_path = index_path.parent + if snapshot_path.name != revision or snapshot_path.parent.name != "snapshots": + raise EnvironmentError( + f"The cached pipeline index for {repository}@{revision} is not in its exact Hub snapshot." + ) + repo_cache_path = snapshot_path.parent.parent + for directory in (repo_cache_path, snapshot_path.parent, snapshot_path): + is_junction = bool(getattr(directory, "is_junction", lambda: False)()) + if directory.is_symlink() or is_junction: + raise EnvironmentError(f"The cached pipeline snapshot boundary must not be linked: '{directory}'.") + + read_path = index_path + if index_path.is_symlink(): + try: + blob_path = repo_cache_path / "blobs" + if blob_path.is_symlink() or bool(getattr(blob_path, "is_junction", lambda: False)()): + raise ValueError("the repository blobs directory is linked") + read_path = index_path.resolve(strict=True) + read_path.relative_to(blob_path.resolve(strict=True)) + except (OSError, RuntimeError, ValueError) as error: + raise EnvironmentError( + f"The cached pipeline index for {repository}@{revision} does not resolve inside its repository cache." + ) from error + if not read_path.is_file(): + raise EnvironmentError(f"The cached pipeline index for {repository}@{revision} is not a regular file.") + + try: + file_size = read_path.stat().st_size + if file_size > MAX_REVIEWED_PIPELINE_INDEX_BYTES: + raise EnvironmentError( + f"The cached pipeline index for {repository}@{revision} exceeds the " + f"{MAX_REVIEWED_PIPELINE_INDEX_BYTES}-byte limit." + ) + with read_path.open("rb") as reader: + raw_bytes = reader.read(MAX_REVIEWED_PIPELINE_INDEX_BYTES + 1) + except OSError as error: + raise EnvironmentError( + f"Could not read the cached pipeline index for {repository}@{revision}: {error}" + ) from error + if len(raw_bytes) > MAX_REVIEWED_PIPELINE_INDEX_BYTES: + raise EnvironmentError( + f"The cached pipeline index for {repository}@{revision} exceeds the " + f"{MAX_REVIEWED_PIPELINE_INDEX_BYTES}-byte limit." + ) + try: + document = json.loads( + raw_bytes.decode("utf-8"), + object_pairs_hook=_reject_duplicate_pipeline_index_keys, + parse_constant=_reject_nonfinite_pipeline_index_number, + ) + except (UnicodeDecodeError, ValueError, RecursionError) as error: + raise EnvironmentError( + f"The cached pipeline index for {repository}@{revision} is not unambiguous UTF-8 JSON: {error}" + ) from error + if not isinstance(document, dict): + raise EnvironmentError(f"The cached pipeline index for {repository}@{revision} must be a JSON object.") + _validate_reviewed_json_shape( + document, + description=f"cached pipeline index for {repository}@{revision}", + ) + return document + + +def _load_reviewed_pipeline_index(repository, revision): + """Resolve only an immutable cached Hub index; this function never downloads.""" + + failures = [] + for filename in _REVIEWED_PIPELINE_INDEX_FILENAMES: + try: + index_path = hf_hub_download( + repository, + filename=filename, + revision=revision, + local_files_only=True, + ) + except (EntryNotFoundError, LocalEntryNotFoundError, HfHubHTTPError, ValueError) as error: + failures.append(error) + continue + return filename, _read_reviewed_pipeline_index( + index_path, + repository=repository, + revision=revision, + ) + raise EnvironmentError( + f"No cached modular_model_index.json or model_index.json was found for {repository}@{revision}. " + "Install that exact reviewed revision through Model Manager before running the pipeline." + ) from (failures[-1] if failures else None) + + +def _validate_reviewed_pipeline_index(model_type, repository, revision): + """Bind repo metadata to the installed registered pipeline component contract.""" + + from diffusers.modular_pipelines.modular_pipeline import MODULAR_PIPELINE_MAPPING, _create_default_map_fn + from diffusers.pipelines.auto_pipeline import _get_model + from diffusers.pipelines.pipeline_loading_utils import _fetch_class_library_tuple, _get_pipeline_class + + pipeline_class = pipeline_class_from_model_type(model_type) + filename, document = _load_reviewed_pipeline_index(repository, revision) + if filename == ModularPipeline.config_name: + resolved_pipeline_class = _get_pipeline_class(ModularPipeline, config=document) + else: + standard_pipeline_class = _get_pipeline_class(ModularPipeline, config=document) + model_name = _get_model(standard_pipeline_class.__name__) + map_fn = MODULAR_PIPELINE_MAPPING.get(model_name, _create_default_map_fn("ModularPipeline")) + resolved_pipeline_class = getattr(__import__("diffusers"), map_fn(document)) + if resolved_pipeline_class is not pipeline_class: + raise ValueError( + f"The cached index for reviewed Modular pipeline {model_type!r} declares incompatible pipeline class " + f"{resolved_pipeline_class.__name__!r}. Repair the exact reviewed model revision before running." + ) + + installed_pipeline = pipeline_class() + expected_blocks_class_name = installed_pipeline.config.get("_blocks_class_name") + observed_blocks_class_name = document.get("_blocks_class_name") + if filename == ModularPipeline.config_name and observed_blocks_class_name != expected_blocks_class_name: + raise ValueError( + f"The cached index for reviewed Modular pipeline {model_type!r} declares blocks class " + f"{observed_blocks_class_name!r}, but the installed pipeline requires " + f"{expected_blocks_class_name!r}. Repair the exact reviewed model revision before running." + ) + if filename != ModularPipeline.config_name and observed_blocks_class_name not in { + None, + expected_blocks_class_name, + }: + raise ValueError( + f"The cached standard index for reviewed Modular pipeline {model_type!r} declares incompatible " + f"blocks class {observed_blocks_class_name!r}." + ) + + expected_component_names = set(installed_pipeline._component_specs) + for name, raw_value in document.items(): + if name in expected_component_names or not isinstance(raw_value, list): + continue + if filename == ModularPipeline.config_name and len(raw_value) == 3: + spec_dict = raw_value[2] + if isinstance(spec_dict, dict) and spec_dict.get("type_hint") not in (None, [None, None]): + raise ValueError( + f"The cached reviewed pipeline index declares unexpected executable component {name!r}." + ) + elif filename != ModularPipeline.config_name and len(raw_value) == 2: + if raw_value != [None, None]: + raise ValueError( + f"The cached reviewed pipeline index declares unexpected executable component {name!r}." + ) + + for component_name, component_spec in installed_pipeline._component_specs.items(): + raw_component = document.get(component_name) + if raw_component is None: + continue + if filename == ModularPipeline.config_name: + if not isinstance(raw_component, list) or len(raw_component) != 3 or not isinstance(raw_component[2], dict): + raise ValueError( + f"The cached reviewed pipeline index has a malformed {component_name!r} component contract." + ) + outer_library, outer_class_name = raw_component[:2] + if any(item is not None and not isinstance(item, str) for item in (outer_library, outer_class_name)): + raise ValueError( + f"The cached reviewed pipeline index has malformed outer metadata for {component_name!r}." + ) + observed_type_hint = raw_component[2].get("type_hint") + else: + if not isinstance(raw_component, list) or len(raw_component) != 2: + raise ValueError( + f"The cached reviewed pipeline index has a malformed {component_name!r} component contract." + ) + observed_type_hint = raw_component + if not isinstance(observed_type_hint, list) or len(observed_type_hint) != 2 or any( + not isinstance(item, str) or not item for item in observed_type_hint + ): + raise ValueError( + f"The cached reviewed pipeline index has an invalid {component_name!r} component type hint." + ) + expected_type_hint = list(_fetch_class_library_tuple(component_spec.type_hint)) + reviewed_concrete_type = PINNED_MODULAR_REPOSITORY_COMPONENT_TYPES.get(repository, {}).get(component_name) + if observed_type_hint != expected_type_hint and tuple(observed_type_hint) != reviewed_concrete_type: + raise ValueError( + f"The cached reviewed pipeline index maps component {component_name!r} to " + f"{observed_type_hint!r}, but registered pipeline {model_type!r} requires {expected_type_hint!r}. " + "Repair the exact reviewed model revision before running." + ) + return filename, deepcopy(document) + + +def _instantiate_reviewed_builtin_pipeline( + model_type, + repository, + *, + index_filename, + index_document, + components_manager, + collection, +): + """Construct installed registered blocks from one already-validated index.""" + + pipeline_class = pipeline_class_from_model_type(model_type) + installed_pipeline = pipeline_class() + load_document = deepcopy(index_document) + for component_name, type_hint in PINNED_MODULAR_REPOSITORY_LOAD_COMPONENT_TYPES.get(repository, {}).items(): + load_document[component_name] = list(type_hint) + config_kwargs = ( + {"modular_config_dict": load_document} + if index_filename == ModularPipeline.config_name + else {"config_dict": load_document} + ) + return pipeline_class( + blocks=installed_pipeline.blocks, + pretrained_model_name_or_path=repository, + components_manager=components_manager, + collection=collection, + **config_kwargs, + ) def node_get_component_info(node_id=None, manager=None, name=None): @@ -64,6 +702,36 @@ def component_quant_config_summary(config): return {name: quant_config_to_info(value) for name, value in config.items()} +def annotate_modular_loader_outputs( + loaded_components, + *, + repo_id, + repo_source, + model_type, + revision, + trust_remote_code, + custom_identity=None, + pipeline_instance_token=None, +): + """Attach enough verified identity metadata for runtime-only recovery.""" + + for value in loaded_components.values(): + if not isinstance(value, dict): + continue + value["repo_id"] = repo_id + value["repo_source"] = repo_source + value["model_type"] = model_type + value["revision"] = revision + value["trust_remote_code"] = trust_remote_code + if custom_identity is not None: + value[CUSTOM_PIPELINE_IDENTITY_FIELD] = deepcopy(custom_identity) + else: + value.pop(CUSTOM_PIPELINE_IDENTITY_FIELD, None) + if pipeline_instance_token is not None: + bind_loader_outputs(loaded_components, pipeline_instance_token) + return loaded_components + + def should_incrementally_group_offload(*, use_group_offload, quant_config): """Select the low-peak loader path from component capabilities, not a pipeline name.""" return bool( @@ -401,67 +1069,111 @@ def normalize_quant_config_input(config): ) +@dataclass(frozen=True) +class _PreparedLoraAdapters: + pipeline: object + resolved: tuple[ResolvedLoraDescriptor, ...] + scheduler: object | None + + def update_lora_adapters(lora_node, lora_list): - """ - Update LoRA adapters based on the provided list of LoRAs. + """Replace Modular LoRAs only after every identity and file header is revalidated. - Args: - lora_node: ModularPipeline node containing LoRA functionality - lora_list: List of dictionaries or single dictionary containing LoRA configurations with: - {'lora_path': str, 'weight_name': str, 'adapter_name': str, 'scale': float} + Diffusers still owns model/component key compatibility. A later upstream + compatibility failure cannot be made transactional by this lifecycle API. """ - # Convert single lora to list if needed - if not isinstance(lora_list, list): - lora_list = [lora_list] - - # Get currently loaded adapters - loaded_adapters = list(set().union(*lora_node.get_list_adapters().values())) - - # Determine which adapters to set and remove - to_set = [lora["adapter_name"] for lora in lora_list] - to_remove = [adapter for adapter in loaded_adapters if adapter not in to_set] - - # Remove unused adapters first - for adapter_name in to_remove: - lora_node.delete_adapters(adapter_name) - - # Load new LoRAs and set their scales - scales = {} - for lora in lora_list: - adapter_name = lora["adapter_name"] - if adapter_name not in loaded_adapters: - lora_node.load_lora_weights( - lora["lora_path"], - weight_name=lora["weight_name"], - adapter_name=adapter_name, - ) - scales[adapter_name] = lora["scale"] - # Set adapter scales - if scales: - lora_node.set_adapters(list(scales.keys()), list(scales.values())) + resolved = resolve_lora_descriptors(lora_list) + get_adapters = getattr(lora_node, "get_list_adapters", None) + load = getattr(lora_node, "load_lora_weights", None) + activate = getattr(lora_node, "set_adapters", None) + if not callable(get_adapters) or not callable(load) or not callable(activate): + raise ValueError("This Modular pipeline does not expose the reviewed Diffusers LoRA lifecycle API.") + + listed = get_adapters() or {} + if not isinstance(listed, Mapping): + raise ValueError("The Modular pipeline returned an invalid loaded-adapter inventory.") + loaded_adapters = { + str(adapter) + for adapters in listed.values() + for adapter in (adapters or []) + } + unload = getattr(lora_node, "unload_lora_weights", None) + delete = getattr(lora_node, "delete_adapters", None) + if loaded_adapters and not callable(unload) and not callable(delete): + raise ValueError("The Modular pipeline cannot safely replace its existing LoRA adapters.") + + # Scheduler construction is also a preflight: an invalid/incompatible + # override must not unload a currently working adapter set. + scheduler = _prepare_lora_scheduler_override(lora_node, resolved) + + if loaded_adapters: + if callable(unload): + unload() + else: + for adapter_name in sorted(loaded_adapters): + delete(adapter_name) + + for item in resolved: + load( + str(item.load_directory), + weight_name=item.weight_name, + adapter_name=item.adapter_name, + use_safetensors=True, + ) + activate( + [item.adapter_name for item in resolved], + [item.scale for item in resolved], + ) + lora_node._modiff_lora_identities = { + item.adapter_name: item.descriptor_sha256 for item in resolved + } + return _PreparedLoraAdapters( + pipeline=lora_node, + resolved=tuple(resolved), + scheduler=scheduler, + ) -def apply_lora_scheduler_override(pipeline, lora_list): - """Apply one explicit scheduler contract supplied by distilled LoRAs.""" - if not isinstance(lora_list, list): - lora_list = [lora_list] - overrides = [ - (item.get("scheduler_class"), item.get("scheduler_config") or {}) - for item in lora_list - if item.get("scheduler_class") - ] - if not overrides: +def _prepare_lora_scheduler_override( + pipeline, + resolved: list[ResolvedLoraDescriptor], +): + override = scheduler_override_contract(resolved) + if override is None: return None - if any(override != overrides[0] for override in overrides[1:]): - raise ValueError("Connected LoRAs declare incompatible scheduler contracts.") - - scheduler_class_name, scheduler_config = overrides[0] - scheduler_class = getattr(__import__("diffusers", fromlist=[scheduler_class_name]), scheduler_class_name) + scheduler_class, scheduler_config = override current_scheduler = getattr(pipeline, "scheduler", None) if current_scheduler is None: raise ValueError("The selected LoRA requires a scheduler, but the pipeline does not expose one.") - scheduler = scheduler_class.from_config(current_scheduler.config, **scheduler_config) + if not callable(getattr(pipeline, "update_components", None)): + raise ValueError("The selected LoRA requires a pipeline that can update its scheduler component.") + effective_config = reviewed_scheduler_effective_config( + scheduler_class, + current_scheduler.config, + scheduler_config, + ) + scheduler = scheduler_class.from_config(effective_config) + if type(scheduler) is not scheduler_class: + raise ValueError( + f"Diffusers scheduler {scheduler_class.__name__!r} did not construct an exact scheduler instance." + ) + return scheduler + + +def apply_lora_scheduler_override(pipeline, lora_list=None, *, prepared=None): + """Apply one explicit scheduler contract supplied by distilled LoRAs.""" + if prepared is not None: + if lora_list is not None or not isinstance(prepared, _PreparedLoraAdapters): + raise TypeError("Prepared LoRA scheduler state must come directly from update_lora_adapters().") + if prepared.pipeline is not pipeline: + raise ValueError("Prepared LoRA scheduler state belongs to a different pipeline.") + scheduler = prepared.scheduler + else: + resolved = resolve_lora_descriptors(lora_list) + scheduler = _prepare_lora_scheduler_override(pipeline, resolved) + if scheduler is None: + return None pipeline.update_components(scheduler=scheduler) return scheduler @@ -781,12 +1493,17 @@ class AutoModelLoader(NodeBase): }, "subfolder": {"label": "Subfolder", "type": "string", "value": ""}, "variant": {"type": "string", "value": "", "options": ["", "fp16", "bf16"]}, - "trust_remote_code": {"label": "Trust Remote Code", "type": "boolean", "value": False}, + "trust_remote_code": { + "label": "Trust Remote Code", + "type": "boolean", + "value": False, + "description": "Repository code execution is disabled for standalone components.", + }, "revision": { "label": "Revision", "type": "string", "value": "", - "description": "Required 40-character commit hash when Trust Remote Code is enabled.", + "description": "Required exact lowercase 40-character commit hash for every Hub component.", }, "device": {"label": "Device", "type": "string", "value": DEFAULT_DEVICE, "options": DEVICE_LIST}, "auto_offload": {"label": "Enable Auto Offload", "type": "boolean", "value": True}, @@ -794,6 +1511,48 @@ class AutoModelLoader(NodeBase): "model": {"label": "Model", "display": "output", "type": "diffusers_auto_model"}, } + def __init__(self, node_id=None): + super().__init__(node_id) + self._standalone_component_issuer = issue_standalone_component_issuer() + + def _cache_params_equal(self, previous, current): + if not super()._cache_params_equal(previous, current): + return False + cached_model = self.output.get("model") if isinstance(self.output, dict) else None + if cached_model is None: + return True + require_standalone_component_binding( + cached_model, + label="cached model", + expected_kind=current.get("model_type"), + expected_issuer=self._standalone_component_issuer, + expected_reviewed_identity=current.get("_reviewed_component_identity"), + ) + return True + + def __call__(self, **kwargs): + """Validate the exact Diffusers component before cache reuse.""" + + trust_remote_code = kwargs.get("trust_remote_code", False) + if type(trust_remote_code) is not bool: + raise TypeError("AutoModelLoader trust_remote_code must be a JSON boolean.") + if trust_remote_code: + raise ValueError( + "AutoModelLoader repository code is disabled until MoDiff provides a reviewed, task-scoped " + "authorization and isolated content-addressed execution path." + ) + reviewed_identity = _preflight_reviewed_diffusers_component( + kwargs.get("model_type"), + kwargs.get("model_id"), + kwargs.get("subfolder"), + kwargs.get("revision"), + ) + # NodeBase includes this backend-derived, content-addressed value in + # its cache comparison. Graph input cannot spoof it because we replace + # any supplied value after local verification. + kwargs["_reviewed_component_identity"] = reviewed_identity + return super().__call__(**kwargs) + def __del__(self): node_comp_ids = components._lookup_ids(collection=self.node_id) for comp_id in node_comp_ids: @@ -802,21 +1561,15 @@ def __del__(self): def set_filters(self, values, ref): model_type = values.get("model_type", "") - - filters = [] - - if model_type == "unet": - filters = ["UNet2DConditionModel"] - self.set_field_params("subfolder", {"value": "unet"}) - elif model_type == "transformer": - filters = ["QwenImageTransformer2DModel", "FluxTransformer2DModel", "SD3Transformer2DModel"] - self.set_field_params("subfolder", {"value": "transformer"}) - elif model_type == "vae": - filters = ["AutoencoderKL", "AutoencoderKLQwenImage"] - self.set_field_params("subfolder", {"value": "vae"}) - elif model_type == "controlnet": - filters = ["ControlNetModel", "QwenImageControlNetModel", "FluxControlNetModel"] - self.set_field_params("subfolder", {"value": ""}) + filters = reviewed_diffusers_component_class_names(model_type) + default_subfolders = { + "unet": "unet", + "transformer": "transformer", + "vae": "vae", + "controlnet": "", + } + if model_type in default_subfolders: + self.set_field_params("subfolder", {"value": default_subfolders[model_type]}) self.set_field_params( "model_id", @@ -824,6 +1577,7 @@ def set_filters(self, values, ref): "fieldOptions": { "filter": { "hub": {"className": filters}, + "local": {"className": filters}, }, }, }, @@ -841,6 +1595,7 @@ def execute( variant=None, subfolder=None, revision=None, + _reviewed_component_identity=None, ): logger.debug(f"AutoModelLoader ({self.node_id}) received parameters:") logger.debug(f" model_type: '{model_type}'") @@ -853,38 +1608,33 @@ def execute( logger.debug(f" auto_offload: '{auto_offload}'") logger.debug(f" offload_mode: '{offload_mode}'") - supported_model_types = {"unet", "transformer", "vae", "controlnet"} - if model_type not in supported_model_types: + if type(trust_remote_code) is not bool: + raise TypeError("AutoModelLoader trust_remote_code must be a JSON boolean.") + if trust_remote_code: raise ValueError( - "AutoModelLoader requires a component type of unet, transformer, vae, or controlnet; " - f"received {model_type!r}. Rebuild or repair the managed graph before loading model weights." + "AutoModelLoader repository code is disabled until MoDiff provides a reviewed, task-scoped " + "authorization and isolated content-addressed execution path." ) - if isinstance(model_id, dict): - real_model_id = model_id.get("value", model_id) - _source = model_id.get("source", "hub") - else: - real_model_id = "" - - if real_model_id == "": - self.notify( - "Please provide a valid Repository ID.", - variant="error", - persist=False, - autoHideDuration=MESSAGE_DURATION, - ) - return None - - revision = resolve_model_revision(real_model_id, revision, source=_source) - revision = require_immutable_hub_revision( - real_model_id, + reviewed_identity = _preflight_reviewed_diffusers_component( + model_type, + model_id, + subfolder, revision, - required=bool(trust_remote_code), ) + if _reviewed_component_identity is not None and tuple(_reviewed_component_identity) != reviewed_identity: + raise ValueError("AutoModelLoader component config changed after cache validation; retry the run.") + _source, real_model_id, revision, subfolder, class_name, _config_fingerprint = reviewed_identity + component_class = _resolve_reviewed_diffusers_component_class(model_type, class_name) # Normalize parameters variant = None if variant == "" else variant - subfolder = None if subfolder == "" else subfolder + if variant is not None and ( + not isinstance(variant, str) + or len(variant) > 128 + or not re.fullmatch(r"[A-Za-z0-9_.-]+", variant) + ): + raise ValueError("AutoModelLoader variant must be a short filename-safe identifier.") normalized_offload_mode = normalize_offload_mode( offload_mode, @@ -893,7 +1643,8 @@ def execute( ) spec = ComponentSpec( name=model_type, - repo=real_model_id, + type_hint=component_class, + pretrained_model_name_or_path=real_model_id, subfolder=subfolder, variant=variant, revision=revision, @@ -907,6 +1658,12 @@ def execute( device=device, node_id=self.node_id, ) + if reusable and not standalone_component_reuse_is_bound( + manager_model_id=reusable[0], + component_kind=model_type, + reviewed_identity=reviewed_identity, + ): + reusable = None if reusable: _existing_id, model = reusable self.progress( @@ -922,7 +1679,7 @@ def execute( message=f"Loading {model_type} weights from {real_model_id}", ) with self.diffusers_loading_progress(): - model = spec.load(torch_dtype=dtype, trust_remote_code=trust_remote_code) + model = spec.load(torch_dtype=dtype) self.progress( 99, phase="component_placement", @@ -953,8 +1710,15 @@ def execute( model = components.get_model_info(comp_id) model["repo_id"] = real_model_id + model["repo_source"] = _source model["revision"] = revision - model["trust_remote_code"] = bool(trust_remote_code) + model["trust_remote_code"] = False + bind_standalone_component_output( + model, + issuer=self._standalone_component_issuer, + component_kind=model_type, + reviewed_identity=reviewed_identity, + ) return {"model": model} @@ -971,13 +1735,7 @@ class ModelsLoader(NodeBase): "options": { "": "", }, - "onChange": [ - "set_filters", - {"action": "signal", "target": "unet_out"}, - {"action": "signal", "target": "text_encoders"}, - {"action": "signal", "target": "vae_out"}, - {"action": "signal", "target": "image_encoder"}, - ], + "onChange": "set_filters", }, "repo_id": { "label": "Repository ID", @@ -992,6 +1750,7 @@ class ModelsLoader(NodeBase): "local": {"className": [""]}, }, }, + "onChange": "refresh_pipeline_identity", }, "dtype": { "label": "dtype", @@ -1000,12 +1759,32 @@ class ModelsLoader(NodeBase): "postProcess": str_to_dtype, }, "device": {"label": "Device", "type": "string", "value": DEFAULT_DEVICE, "options": DEVICE_LIST}, - "trust_remote_code": {"label": "Trust Remote Code", "type": "boolean", "value": False}, + "trust_remote_code": { + "label": "Trust Remote Code", + "type": "boolean", + "value": False, + "description": "Repository code execution is disabled; keep this off for contract preview.", + "onChange": "refresh_pipeline_identity", + }, "revision": { "label": "Revision", "type": "string", "value": "", - "description": "Required 40-character commit hash for custom or trusted remote code.", + "description": "Required exact 40-character commit hash for Hub custom contracts.", + "onChange": "refresh_pipeline_identity", + }, + "modiff_pipeline_identity": { + "label": "Custom Pipeline Identity", + "type": "object", + "value": None, + "hidden": True, + }, + "refresh_pipeline_identity_button": { + "label": "Review and Refresh Custom Contract", + "display": "ui_button", + "value": False, + "hidden": True, + "onChange": "refresh_pipeline_identity", }, "auto_offload": {"label": "Enable Auto Offload", "type": "boolean", "value": True}, "offload_mode": offload_mode_param( @@ -1026,6 +1805,59 @@ def __init__(self, node_id=None): super().__init__(node_id) self.loader = None self.model_types_loaded = False + self._pipeline_identity_generation = 0 + self._pipeline_identity_lock = threading.Lock() + + def __call__(self, **kwargs): + """Enforce custom identity and remote-code policy before cache reuse.""" + + model_type = kwargs.get("model_type") + trust_remote_code = kwargs.get("trust_remote_code", False) + if type(trust_remote_code) is not bool: + raise TypeError("ModelsLoader trust_remote_code must be a JSON boolean.") + if trust_remote_code: + scope = "custom" if model_type == CUSTOM_PIPELINE_MODEL_TYPE else "built-in" + raise ValueError( + f"Repository code is disabled for {scope} Modular Diffusers loaders until MoDiff provides a " + "reviewed, task-scoped authorization and isolated content-addressed execution path." + ) + if model_type == CUSTOM_PIPELINE_MODEL_TYPE: + identity_value = kwargs.get(CUSTOM_PIPELINE_IDENTITY_FIELD) + if not isinstance(identity_value, Mapping): + raise ValueError( + "Custom Modular Diffusers execution requires a complete backend-issued identity. " + "Use Review and Refresh Custom Contract before running this loader." + ) + identity = CustomPipelineExecutionIdentity.from_value(identity_value) + if identity.trust_remote_code: + raise ValueError( + "Custom Modular Diffusers repository code is disabled; refresh the contract with Trust Remote " + "Code off." + ) + raise RuntimeError( + "Custom Modular Diffusers is contract_only in this release. Contract preview is available, but " + "execution is disabled pending the P1.1 reviewed component dependency contract." + ) + reviewed_selection = self._preflight_reviewed_builtin_selection( + model_type=model_type, + repo_id=kwargs.get("repo_id"), + revision=kwargs.get("revision"), + ) + source, repository, reviewed_revision, index_filename, index_document = reviewed_selection + kwargs["_reviewed_builtin_identity"] = ( + source, + repository, + reviewed_revision, + index_filename, + _reviewed_json_fingerprint(index_document), + ) + return super().__call__(**kwargs) + + def prepare_for_workflow_reuse(self): + """Invalidate stale in-flight field-action publications on node reuse.""" + + with self._pipeline_identity_lock: + self._pipeline_identity_generation += 1 def __del__(self): node_comp_ids = components._lookup_ids(collection=self.node_id) @@ -1034,6 +1866,235 @@ def __del__(self): self.loader = None super().__del__() + def _begin_pipeline_identity_refresh(self): + with self._pipeline_identity_lock: + self._pipeline_identity_generation += 1 + return self._pipeline_identity_generation + + def _publish_pipeline_identity( + self, + generation, + *, + persisted_identity, + signal_value, + show_refresh, + dtype=None, + update_persisted_identity=True, + clear_revision=False, + ): + """Publish one coherent loader contract if this refresh is still current.""" + + with self._pipeline_identity_lock: + if generation != self._pipeline_identity_generation: + return False + field_values = {} + if update_persisted_identity: + field_values[CUSTOM_PIPELINE_IDENTITY_FIELD] = deepcopy(persisted_identity) + if clear_revision: + field_values["revision"] = "" + if field_values: + self.set_field_value(field_values) + self.set_field_visibility({"refresh_pipeline_identity_button": bool(show_refresh)}) + if dtype: + self.set_field_params("dtype", {"value": dtype}) + for output_name in MODELS_LOADER_IDENTITY_OUTPUTS: + self.set_field_params( + output_name, + { + "signal": { + "direction": "output", + "origin": CUSTOM_PIPELINE_IDENTITY_FIELD, + "value": deepcopy(signal_value), + } + }, + ) + return True + + @staticmethod + def _selected_repository(repo_id, *, custom): + if not isinstance(repo_id, Mapping): + if custom: + raise ValueError( + "Custom Modular Diffusers repositories require an explicit Hub or Local source selection." + ) + return "hub", str(repo_id or "").strip() + source = repo_id.get("source") + repository = repo_id.get("value") + if not isinstance(source, str) or source not in {"hub", "local"}: + raise ValueError("The repository source must be exactly 'hub' or 'local'.") + if not isinstance(repository, str): + raise ValueError("The selected Modular Diffusers repository must be a string.") + return source, repository.strip() + + @classmethod + def _reviewed_builtin_selection(cls, *, model_type, repo_id, revision): + metadata = get_model_type_metadata(model_type) + if not isinstance(metadata, Mapping) or model_type == CUSTOM_PIPELINE_MODEL_TYPE: + raise ValueError( + "ModelsLoader execution requires a registered built-in Modular Diffusers pipeline type. " + "Use the contract-preview flow for custom pipelines." + ) + default_repository = metadata.get("default_repo") + if not isinstance(default_repository, str) or not default_repository: + raise ValueError(f"Registered Modular pipeline {model_type!r} has no reviewed default repository.") + source, selected_repository = cls._selected_repository(repo_id, custom=False) + if source != "hub": + raise ValueError( + "Registered built-in Modular Diffusers pipelines execute only from their reviewed immutable Hub " + "artifact; local or alternate repository selections are contract-preview only." + ) + reviewed_repositories = PINNED_MODULAR_REPOSITORY_VARIANTS.get(model_type, (default_repository,)) + if selected_repository not in reviewed_repositories: + raise ValueError( + f"Registered Modular pipeline {model_type!r} requires reviewed repository selection." + ) + reviewed_revision = require_catalog_revision(selected_repository, model_type=model_type) + if revision is not None and not isinstance(revision, str): + raise ValueError("A built-in Modular Diffusers revision must be a string when provided.") + selected_revision = str(revision or "").strip() + if selected_revision and selected_revision != reviewed_revision: + raise ValueError( + f"Registered Modular pipeline {model_type!r} requires reviewed revision selection." + ) + return source, selected_repository, reviewed_revision + + @classmethod + def _preflight_reviewed_builtin_selection(cls, *, model_type, repo_id, revision): + selection = cls._reviewed_builtin_selection( + model_type=model_type, + repo_id=repo_id, + revision=revision, + ) + _source, repository, reviewed_revision = selection + index_filename, index_document = _validate_reviewed_pipeline_index( + model_type, + repository, + reviewed_revision, + ) + return *selection, index_filename, index_document + + def refresh_pipeline_identity(self, values, ref): + """Verify and publish a backend-owned pipeline identity without loading model code.""" + + generation = self._begin_pipeline_identity_refresh() + values = values if isinstance(values, Mapping) else {} + model_type = str(values.get("model_type") or "").strip() + if not model_type: + self._publish_pipeline_identity( + generation, + persisted_identity=None, + signal_value="", + show_refresh=False, + ) + return None + + if model_type != CUSTOM_PIPELINE_MODEL_TYPE: + # Do not publish an unknown class name as a runnable capability. + pipeline_class_from_model_type(model_type) + trust_remote_code = values.get("trust_remote_code", False) + selected_repo = values.get("repo_id") + clear_revision = ( + isinstance(selected_repo, Mapping) + and selected_repo.get("source") == "local" + and bool(str(values.get("revision") or "").strip()) + ) + if type(trust_remote_code) is not bool: + self._publish_pipeline_identity( + generation, + persisted_identity=None, + signal_value="", + show_refresh=False, + clear_revision=clear_revision, + ) + raise TypeError("ModelsLoader trust_remote_code must be a JSON boolean.") + self._publish_pipeline_identity( + generation, + persisted_identity=None, + signal_value="" if trust_remote_code else model_type, + show_refresh=False, + clear_revision=clear_revision, + ) + if trust_remote_code: + raise ValueError( + "Built-in Modular Diffusers repository code is disabled. Disable Trust Remote Code; official " + "registered pipeline types do not require it." + ) + return None + + clear_revision = False + try: + source, repository = self._selected_repository(values.get("repo_id"), custom=True) + selected_revision = str(values.get("revision") or "").strip() or None + clear_revision = source == "local" and selected_revision is not None + revision = None if source == "local" else selected_revision + trust_remote_code = values.get("trust_remote_code", False) + if type(trust_remote_code) is not bool: + raise TypeError("Custom Modular Diffusers trust_remote_code must be a JSON boolean.") + explicit_refresh = isinstance(ref, Mapping) and ref.get("key") == "refresh_pipeline_identity_button" + + # Empty selectors are an incomplete form, not an executable custom + # identity. A malformed non-empty selector remains an error. + if not repository or (source == "hub" and revision is None): + self._publish_pipeline_identity( + generation, + persisted_identity=None, + signal_value="", + show_refresh=True, + clear_revision=clear_revision, + ) + return None + + if trust_remote_code: + raise ValueError( + "Custom Modular Diffusers repository code is disabled until MoDiff provides a reviewed, " + "task-scoped authorization and isolated content-addressed execution path. Disable Trust Remote " + "Code to review this declarative contract." + ) + + previous_identity = values.get(CUSTOM_PIPELINE_IDENTITY_FIELD) + if previous_identity in (None, ""): + previous_identity = None + elif not isinstance(previous_identity, Mapping): + raise ValueError("The persisted custom Modular Diffusers contract identity is malformed.") + + binding = resolve_custom_pipeline_binding( + source=source, + repo_id=repository, + revision=revision, + trust_remote_code=trust_remote_code, + expected_identity=None if explicit_refresh else previous_identity, + allow_selector_change=not explicit_refresh, + ) + identity_value = binding.identity.to_dict() + config = binding.pipeline_config() + self._publish_pipeline_identity( + generation, + persisted_identity=identity_value, + signal_value=identity_value, + show_refresh=True, + dtype=config.default_dtype, + clear_revision=clear_revision, + ) + except Exception: + # Never leave a stale contract advertised to connected dynamic + # nodes. Preserve a trust-false identity so same-selector drift + # still needs explicit review; clear it when remote code was set. + published = self._publish_pipeline_identity( + generation, + persisted_identity=None, + signal_value="", + show_refresh=True, + update_persisted_identity=type(values.get("trust_remote_code", False)) is not bool + or values.get("trust_remote_code") is True, + clear_revision=clear_revision, + ) + if not published: + # A newer field action owns the visible state and its response; + # an obsolete background failure must not surface over it. + return None + raise + return None + def set_filters(self, values, ref): # first time dynamically load the model_type options if not self.model_types_loaded: @@ -1059,6 +2120,7 @@ def set_filters(self, values, ref): }, ) self.set_field_params("dtype", {"value": default_dtype}) + return self.refresh_pipeline_identity(values, ref) def execute( self, @@ -1074,7 +2136,49 @@ def execute( offload_mode=OFFLOAD_MODE_MODEL_CPU, quant_config=None, revision=None, + modiff_pipeline_identity=None, + refresh_pipeline_identity_button=False, + _reviewed_builtin_identity=None, ): + if type(trust_remote_code) is not bool: + raise TypeError("ModelsLoader trust_remote_code must be a JSON boolean.") + if trust_remote_code: + raise ValueError( + "Modular Diffusers repository code is disabled until MoDiff provides a reviewed, task-scoped " + "authorization and isolated content-addressed execution path." + ) + is_custom_pipeline = model_type == CUSTOM_PIPELINE_MODEL_TYPE + if is_custom_pipeline: + _source, real_repo_id = self._selected_repository(repo_id, custom=True) + reviewed_index_filename = None + reviewed_index_document = None + loader_component_outputs = () + else: + ( + _source, + real_repo_id, + revision, + reviewed_index_filename, + reviewed_index_document, + ) = self._preflight_reviewed_builtin_selection( + model_type=model_type, + repo_id=repo_id, + revision=revision, + ) + current_reviewed_identity = ( + _source, + real_repo_id, + revision, + reviewed_index_filename, + _reviewed_json_fingerprint(reviewed_index_document), + ) + if ( + _reviewed_builtin_identity is not None + and tuple(_reviewed_builtin_identity) != current_reviewed_identity + ): + raise ValueError("The reviewed Modular pipeline index changed after cache validation; retry the run.") + loader_component_outputs = _reviewed_loader_component_outputs(model_type) + requested_offload_mode = offload_mode offload_mode = normalize_offload_mode( offload_mode, @@ -1152,12 +2256,6 @@ def execute( components.get_components_by_ids(ids=[vae["model_id"]], return_dict_with_names=True) ) - if isinstance(repo_id, dict): - real_repo_id = repo_id.get("value", repo_id) - _source = repo_id.get("source", "hub") - else: - real_repo_id = "" - if real_repo_id == "": self.notify( "Please provide a valid Repository ID.", @@ -1167,30 +2265,49 @@ def execute( ) return None self._loader_diagnostics["repo_id"] = real_repo_id - revision = resolve_model_revision( - real_repo_id, - revision, - model_type=model_type, - source=_source, - ) - revision = require_immutable_hub_revision( - real_repo_id, - revision, - required=bool(trust_remote_code) or model_type == "DummyCustomPipeline", - ) + custom_identity = None + pipeline_load_path = real_repo_id + if is_custom_pipeline: + expected_identity = modiff_pipeline_identity + if not isinstance(expected_identity, Mapping): + raise ValueError( + "Custom Modular Diffusers execution requires a complete backend-issued identity. " + "Use Review and Refresh Custom Contract before running this loader." + ) + parsed_identity = CustomPipelineExecutionIdentity.from_value(expected_identity) + if parsed_identity.trust_remote_code != trust_remote_code: + raise ValueError( + "The selected custom pipeline trust setting does not match its backend-issued identity." + ) + custom_binding = resolve_custom_pipeline_binding( + source=_source, + repo_id=real_repo_id, + revision=str(revision or "").strip() or None, + trust_remote_code=trust_remote_code, + expected_identity=expected_identity, + ) + custom_identity = custom_binding.identity.to_dict() + real_repo_id = custom_binding.identity.repo_id + revision = custom_binding.identity.revision + pipeline_load_path = custom_binding.repository_path + raise RuntimeError( + "Custom Modular Diffusers is contract_only in this release. Upstream can import repository-named " + "component libraries even with Trust Remote Code off, so execution is disabled pending the P1.1 " + "reviewed component dependency contract." + ) self._loader_diagnostics["revision"] = revision use_group_offload = offload_mode in [OFFLOAD_MODE_GROUP_CPU, OFFLOAD_MODE_GROUP_DISK] configure_components_manager_offload(components, mode=offload_mode, device=device) - self.loader = ModularPipeline.from_pretrained( - real_repo_id, + self.loader = _instantiate_reviewed_builtin_pipeline( + model_type, + pipeline_load_path, + index_filename=reviewed_index_filename, + index_document=reviewed_index_document, components_manager=components, collection=self.node_id, - trust_remote_code=trust_remote_code, - revision=revision, - local_files_only=True, ) self._loader_diagnostics["component_revision_pins"] = pin_modular_component_revisions( self.loader, @@ -1198,27 +2315,11 @@ def execute( revision, ) - if model_type == "DummyCustomPipeline": - # update node param - custom_config = PipelineConfig.load(real_repo_id, revision=revision, local_files_only=True) - custom_config.label = "Custom" - - # update repo_id for DummyCustomPipeline - DummyCustomPipeline.repo_id = real_repo_id - DummyCustomPipeline.revision = revision - DummyCustomPipeline.trust_remote_code = bool(trust_remote_code) - # register DummyCustomPipeline to MODULAR_REGISTRY - MODULAR_REGISTRY.register(DummyCustomPipeline, custom_config) - - else: - DummyCustomPipeline.repo_id = None - DummyCustomPipeline.revision = None - DummyCustomPipeline.trust_remote_code = False - MODULAR_REGISTRY.register(DummyCustomPipeline, DUMMY_CUSTOM_PIPELINE_CONFIG) - ALL_COMPONENTS = self.loader.pretrained_component_names - text_node = self.loader.blocks.sub_blocks["text_encoder"].init_pipeline(real_repo_id) + # The already-reviewed installed block contract is authoritative. Passing + # the repository here would make upstream parse its index a second time. + text_node = self.loader.blocks.sub_blocks["text_encoder"].init_pipeline() text_encoder_names = text_node.pretrained_component_names components_to_load = [c for c in ALL_COMPONENTS if c not in components_to_update] @@ -1254,8 +2355,7 @@ def execute( ) required_components = {denoiser_name, "vae", "scheduler", *text_encoder_names} - if model_type == "WanImage2VideoModularPipeline": - required_components.add("image_encoder") + required_components.update(loader_component_outputs) required_components = {name for name in required_components if name} self._loader_diagnostics["required_components"] = sorted(required_components) self._loader_diagnostics["components_to_load"] = list(components_to_reload) @@ -1290,6 +2390,9 @@ def execute( ) self.loader.update_components(**components_to_update) + if model_type == "StableDiffusionXLModularPipeline": + reset_owned_sdxl_ip_adapter_for_loader(self.loader) + if use_group_offload: try: offload_result = apply_component_group_offload( @@ -1358,11 +2461,11 @@ def execute( print(f" ModelsLoader: reloaded components: {components_to_reload}") print(f" ModelsLoader: updated components: {components_to_update.keys()}") - if hasattr(self.loader, "unload_lora_weights"): + if lora_list is not None: + prepared_loras = update_lora_adapters(self.loader, lora_list) + apply_lora_scheduler_override(self.loader, prepared=prepared_loras) + elif hasattr(self.loader, "unload_lora_weights"): self.loader.unload_lora_weights() - if lora_list: - update_lora_adapters(self.loader, lora_list) - apply_lora_scheduler_override(self.loader, lora_list) # Construct loaded_components at the end after all modifications try: @@ -1376,10 +2479,12 @@ def execute( "scheduler": node_get_component_info(node_id=self.node_id, manager=components, name="scheduler"), } - if model_type == "WanImage2VideoModularPipeline": - loaded_components["image_encoder"] = node_get_component_info( - node_id=self.node_id, manager=components, name="image_encoder" - ) + loaded_components.update( + { + name: node_get_component_info(node_id=self.node_id, manager=components, name=name) + for name in loader_component_outputs + } + ) except ValueError as e: self.notify( f" ModelsLoader: Error retrieving component info: {e}", @@ -1396,14 +2501,28 @@ def execute( ) raise RuntimeError(f"ModelsLoader could not retrieve required component info: {e}") from e + # Mint only after every loading and component-info step succeeded. A + # cache hit keeps these dictionaries (and this identity token), while + # each successful re-execution receives a new token. + pipeline_instance_token = issue_pipeline_instance_token( + model_type=model_type, + repo_id=real_repo_id, + repo_source=_source, + revision=revision, + ) + # Make every connected output self-describing. Runtime cleanup may # recreate downstream nodes without replaying their dynamic UI signal. - for k, v in loaded_components.items(): - if isinstance(v, dict): - v["repo_id"] = real_repo_id - v["model_type"] = model_type - v["revision"] = revision - v["trust_remote_code"] = bool(trust_remote_code) + annotate_modular_loader_outputs( + loaded_components, + repo_id=real_repo_id, + repo_source=_source, + model_type=model_type, + revision=revision, + trust_remote_code=bool(trust_remote_code), + custom_identity=custom_identity, + pipeline_instance_token=pipeline_instance_token, + ) logger.debug(f" ModelsLoader: Final component_manager state: {components}") diff --git a/modules/ModularDiffusers/modular_utils.py b/modules/ModularDiffusers/modular_utils.py index d9078f3..c492b4b 100644 --- a/modules/ModularDiffusers/modular_utils.py +++ b/modules/ModularDiffusers/modular_utils.py @@ -1,18 +1,179 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. import logging +import math import re import threading +from collections.abc import Mapping +from copy import deepcopy from pathlib import Path from typing import Any, Dict, Optional +import torch from diffusers import Flux2KleinModularPipeline from modiff.model_artifact_catalog import resolve_model_revision +from modiff.modular_workflow_contracts import WAN_I2V_REPOSITORY from .pipeline_schema import MoDiffParam as PipelineParam from .pipeline_schema import MoDiffPipelineConfig as PipelineConfig +from .custom_pipeline import ( + CUSTOM_PIPELINE_EXECUTION_STATUS, + CUSTOM_PIPELINE_IDENTITY_FIELD, + CUSTOM_PIPELINE_MODEL_TYPE, + CustomPipelineBinding, + resolve_custom_pipeline_identity, +) logger = logging.getLogger("modiff") +_CANONICAL_INTEGER_TEXT = re.compile(r"^(?:0|-?[1-9][0-9]*)$") +_MIN_MODULAR_SEED = 0 +_MAX_MODULAR_SEED = 4294967295 +SDXL_LAYER_BLOCK_OPTIONS = ( + "down_blocks.1.attentions.0.transformer_blocks", + "down_blocks.1.attentions.1.transformer_blocks", + "down_blocks.2.attentions.0.transformer_blocks", + "down_blocks.2.attentions.1.transformer_blocks", + "mid_block.attentions.0.transformer_blocks", + "up_blocks.0.attentions.0.transformer_blocks", + "up_blocks.0.attentions.1.transformer_blocks", + "up_blocks.0.attentions.2.transformer_blocks", + "up_blocks.1.attentions.0.transformer_blocks", + "up_blocks.1.attentions.1.transformer_blocks", + "up_blocks.1.attentions.2.transformer_blocks", +) +QWEN_IMAGE_LAYER_BLOCK_OPTIONS = ("transformer_blocks",) +FLUX_LAYER_BLOCK_OPTIONS = ("transformer_blocks", "single_transformer_blocks") +IMAGE_LATENT_DIMENSIONS = ("height", "width") +ALL_GUIDER_OPTIONS = ( + "ClassifierFreeGuidance", + "SkipLayerGuidance", + "AdaptiveProjectedGuidance", + "AdaptiveProjectedMixGuidance", + "ClassifierFreeZeroStarGuidance", + "AutoGuidance", + "SmoothedEnergyGuidance", + "PerturbedAttentionGuidance", + "TangentialClassifierFreeGuidance", + "FrequencyDecoupledGuidance", +) +LAYER_GUIDER_OPTIONS = frozenset( + {"SkipLayerGuidance", "AutoGuidance", "SmoothedEnergyGuidance", "PerturbedAttentionGuidance"} +) +NON_LAYER_GUIDER_OPTIONS = tuple(name for name in ALL_GUIDER_OPTIONS if name not in LAYER_GUIDER_OPTIONS) +COMPATIBLE_SCHEDULER_OPTIONS = ( + "DDIMScheduler", + "DDPMScheduler", + "DEISMultistepScheduler", + "DPMSolverMultistepScheduler", + "DPMSolverSinglestepScheduler", + "DPMSolverSDEScheduler", + "EulerDiscreteScheduler", + "EulerAncestralDiscreteScheduler", + "HeunDiscreteScheduler", + "KDPM2DiscreteScheduler", + "KDPM2AncestralDiscreteScheduler", + "LMSDiscreteScheduler", + "PNDMScheduler", + "UniPCMultistepScheduler", +) + + +def _normalize_modular_integer(value): + """Accept graph integers without silently truncating another JSON type.""" + + if isinstance(value, bool): + raise ValueError("expected an integer, not a boolean") + if isinstance(value, int): + return value + if isinstance(value, str) and _CANONICAL_INTEGER_TEXT.fullmatch(value): + return int(value) + raise ValueError("expected an integer or canonical integer string") + + +def reject_undeclared_modular_generator(kwargs, declared_params=()): + """Reject graph-supplied Torch generator state outside a declared field contract.""" + + if "generator" in kwargs and "generator" not in declared_params: + raise ValueError( + "Direct Modular Diffusers 'generator' values are not accepted by this graph action. " + "Use its backend-issued seed field so MoDiff can construct the generator on the execution device." + ) + + +def normalize_modular_runtime_params(kwargs, node_config): + """Cast and enforce scalar constraints from a backend action schema.""" + + normalized = dict(kwargs) + declared_params = node_config.get("params", {}) + reject_undeclared_modular_generator(normalized, declared_params) + + for param_name, param_config in declared_params.items(): + if param_name not in normalized or normalized[param_name] is None: + continue + value = normalized[param_name] + param_type = param_config.get("type") + try: + if param_type == "float": + if isinstance(value, bool): + raise ValueError("expected a finite number, not a boolean") + value = float(value) + if not math.isfinite(value): + raise ValueError("expected a finite number") + elif param_type == "int": + value = _normalize_modular_integer(value) + elif param_type == "boolean" and not isinstance(value, bool): + raise ValueError("expected a JSON boolean") + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"Invalid value for modular parameter '{param_name}': expected {param_type}.") from exc + + options = param_config.get("options") + if isinstance(options, list) and value not in options: + raise ValueError( + f"Invalid value for modular parameter '{param_name}': expected one of {options}." + ) + minimum = param_config.get("min") + maximum = param_config.get("max") + if minimum is not None and value < minimum: + raise ValueError( + f"Invalid value for modular parameter '{param_name}': expected a value greater than or equal to " + f"{minimum}." + ) + if maximum is not None and value > maximum: + raise ValueError( + f"Invalid value for modular parameter '{param_name}': expected a value less than or equal to " + f"{maximum}." + ) + normalized[param_name] = value + return normalized + + +def modular_generator_from_seed(seed, pipeline): + """Build a deterministic Torch generator on a Modular pipeline's execution device.""" + + normalized_seed = normalize_modular_seed(seed) + + execution_device = getattr(pipeline, "_execution_device", None) + if execution_device is None: + raise RuntimeError( + "The Modular Diffusers encoder could not resolve its execution device for deterministic sampling." + ) + return torch.Generator(device=execution_device).manual_seed(normalized_seed) + + +def normalize_modular_seed(seed): + """Return one bounded canonical seed without accepting JSON lookalikes.""" + + try: + normalized_seed = _normalize_modular_integer(seed) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("Invalid Modular Diffusers seed: expected a finite integer.") from exc + if not _MIN_MODULAR_SEED <= normalized_seed <= _MAX_MODULAR_SEED: + raise ValueError( + f"Invalid Modular Diffusers seed: expected {_MIN_MODULAR_SEED} through {_MAX_MODULAR_SEED}." + ) + return normalized_seed + + IMMUTABLE_HUB_REVISION = re.compile(r"^[0-9a-fA-F]{40}$") @@ -67,8 +228,76 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): SDXL_NODE_SPECS = { + "ip_adapter": { + "inputs": [ + PipelineParam( + name="ip_adapter_image", + label="IP-Adapter Image", + type="image", + display="input", + ), + PipelineParam( + name="adapter_model", + label="IP-Adapter Model", + type="string", + display="modelselect", + value={"source": "hub", "value": "h94/IP-Adapter"}, + fieldOptions={"noValidation": True, "sources": ["hub"]}, + ), + PipelineParam( + name="adapter_revision", + label="Adapter Revision", + type="string", + value="018e402774aeeddd60609b4ecdb7e298259dc729", + ), + PipelineParam( + name="adapter_weight_name", + label="Adapter Weight", + type="string", + value="sdxl_models/ip-adapter_sdxl.safetensors", + ), + PipelineParam( + name="adapter_scale", + label="IP-Adapter Scale", + type="float", + display="slider", + value=1.0, + min=0.0, + max=2.0, + step=0.05, + ), + ], + "model_inputs": [ + PipelineParam.unet(), + PipelineParam.guider(display="input"), + ], + "outputs": [ + PipelineParam.ip_adapter(display="output"), + PipelineParam.doc(), + ], + "required_inputs": ["ip_adapter_image", "adapter_model", "adapter_revision", "adapter_weight_name"], + "required_model_inputs": ["unet", "guider"], + "block_name": "ip_adapter", + }, "controlnet": { "inputs": [ + PipelineParam( + name="controlnet_variant", + label="ControlNet Variant", + type="string", + options=["ordinary", "union"], + value="ordinary", + onChange={"union": ["control_mode"]}, + ), + PipelineParam( + name="control_mode", + label="Union Control Type Index", + type="int", + min=0, + max=31, + step=1, + value=0, + ), PipelineParam.control_image(), PipelineParam.controlnet_conditioning_scale(), PipelineParam.control_guidance_start(), @@ -97,11 +326,15 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.guidance_scale(), PipelineParam.image_latents_with_strength(), PipelineParam.strength(), + PipelineParam.mask(), + PipelineParam.masked_image_latents(), PipelineParam.controlnet_bundle(display="input"), PipelineParam.ip_adapter(), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.unet(), + PipelineParam.vae(), PipelineParam.guider(), PipelineParam.scheduler(), PipelineParam.controlnet_bundle(display="input"), @@ -109,21 +342,28 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "outputs": [ PipelineParam.latents(display="output"), PipelineParam.latents_preview(), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["embeddings"], - "required_model_inputs": ["unet", "scheduler"], + "required_model_inputs": ["unet", "vae", "scheduler"], "block_name": "denoise", }, "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.mask_image(), + PipelineParam.padding_mask_crop(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), ], "outputs": [ PipelineParam.image_latents(display="output"), + PipelineParam.mask(display="output"), + PipelineParam.masked_image_latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -149,6 +389,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "decoder": { "inputs": [ PipelineParam.latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.vae(), @@ -168,6 +409,9 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Stable Diffusion XL", default_repo="stabilityai/stable-diffusion-xl-base-1.0", default_dtype="float16", + layer_block_options=SDXL_LAYER_BLOCK_OPTIONS, + guider_options=ALL_GUIDER_OPTIONS, + scheduler_options=COMPATIBLE_SCHEDULER_OPTIONS, ) @@ -184,6 +428,8 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.control_guidance_end(), PipelineParam.height(), PipelineParam.width(), + PipelineParam.seed(), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.controlnet(), @@ -191,6 +437,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "outputs": [ PipelineParam.controlnet_bundle(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["control_image"], @@ -208,6 +455,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.image_latents_with_strength(), PipelineParam.strength(), PipelineParam.controlnet_bundle(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.unet(), @@ -217,6 +465,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "outputs": [ PipelineParam.latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["embeddings"], @@ -226,12 +475,18 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.mask_image(), + PipelineParam.padding_mask_crop(), + PipelineParam.height(), + PipelineParam.width(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), ], "outputs": [ PipelineParam.image_latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -257,6 +512,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "decoder": { "inputs": [ PipelineParam.latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.vae(), @@ -276,6 +532,8 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Qwen-Image-2512", default_repo="Qwen/Qwen-Image-2512", default_dtype="bfloat16", + layer_block_options=QWEN_IMAGE_LAYER_BLOCK_OPTIONS, + guider_options=ALL_GUIDER_OPTIONS, ) @@ -292,6 +550,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.num_inference_steps(40), PipelineParam.guidance_scale(4.0), PipelineParam.image_latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.unet(), @@ -300,6 +559,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "outputs": [ PipelineParam.latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["embeddings", "image_latents"], @@ -309,12 +569,16 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.mask_image(), + PipelineParam.padding_mask_crop(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), ], "outputs": [ PipelineParam.image_latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -341,6 +605,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "decoder": { "inputs": [ PipelineParam.latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.vae(), @@ -360,6 +625,9 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Qwen-Image-Edit", default_repo="Qwen/Qwen-Image-Edit", default_dtype="bfloat16", + layer_block_options=QWEN_IMAGE_LAYER_BLOCK_OPTIONS, + guider_options=ALL_GUIDER_OPTIONS, + denoise_image_latent_dimensions=IMAGE_LATENT_DIMENSIONS, ) @@ -376,6 +644,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.num_inference_steps(40), PipelineParam.guidance_scale(4.0), PipelineParam.image_latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.unet(), @@ -384,6 +653,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "outputs": [ PipelineParam.latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["embeddings", "image_latents"], @@ -393,12 +663,14 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), ], "outputs": [ PipelineParam.image_latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -425,6 +697,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "decoder": { "inputs": [ PipelineParam.latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.vae(), @@ -444,12 +717,62 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Qwen-Image-Edit-2511", default_repo="Qwen/Qwen-Image-Edit-2511", default_dtype="bfloat16", + layer_block_options=QWEN_IMAGE_LAYER_BLOCK_OPTIONS, + guider_options=ALL_GUIDER_OPTIONS, + denoise_image_latent_dimensions=IMAGE_LATENT_DIMENSIONS, ) # ============================================================================= # Qwen Image Layered # ============================================================================= + +def _qwen_image_layered_resolution_param(): + """Return the shared, backend-owned Layered source-resolution contract.""" + + return PipelineParam( + name="resolution", + label="Source Resolution", + type="int", + default=640, + options=[640, 1024], + fieldOptions={ + "controlTier": "advanced", + "studioBinding": { + "schemaVersion": 1, + "group": "source-resolution", + "formFields": ["width", "height"], + "transform": "nearest-option-to-long-edge", + }, + }, + required_block_params=["resolution"], + ) + + +def _qwen_image_layered_max_sequence_length_param(): + """Return the Layered prompt-length contract and its generic form binding.""" + + return PipelineParam( + name="max_sequence_length", + label="Maximum Sequence Length", + type="int", + default=1024, + min=1, + max=1024, + step=1, + fieldOptions={ + "controlTier": "advanced", + "studioBinding": { + "schemaVersion": 1, + "group": "maximum-sequence-length", + "formFields": ["maxSequenceLength"], + "transform": "identity", + }, + }, + required_block_params=["max_sequence_length"], + ) + + QWEN_IMAGE_LAYERED_NODE_SPECS = { "controlnet": None, "denoise": { @@ -477,6 +800,8 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + _qwen_image_layered_resolution_param(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), @@ -494,6 +819,16 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): PipelineParam.prompt(), PipelineParam.negative_prompt(), PipelineParam.image(), + _qwen_image_layered_resolution_param(), + PipelineParam( + name="use_en_prompt", + label="Use English Prompt Template", + type="boolean", + default=False, + fieldOptions={"controlTier": "advanced"}, + required_block_params=["use_en_prompt"], + ), + _qwen_image_layered_max_sequence_length_param(), ], "model_inputs": [ PipelineParam.text_encoders(), @@ -528,6 +863,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Qwen-Image-Layered", default_repo="Qwen/Qwen-Image-Layered", default_dtype="bfloat16", + guider_options=NON_LAYER_GUIDER_OPTIONS, ) # ============================================================================= @@ -549,7 +885,6 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "model_inputs": [ PipelineParam.unet(), - PipelineParam.guider(), PipelineParam.scheduler(), ], "outputs": [ @@ -563,6 +898,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), @@ -613,6 +949,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Flux", default_repo="black-forest-labs/FLUX.1-dev", default_dtype="bfloat16", + layer_block_options=FLUX_LAYER_BLOCK_OPTIONS, ) @@ -632,7 +969,6 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "model_inputs": [ PipelineParam.unet(), - PipelineParam.guider(), PipelineParam.scheduler(), ], "outputs": [ @@ -646,6 +982,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), @@ -696,6 +1033,8 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Flux Kontext", default_repo="black-forest-labs/FLUX.1-Kontext-dev", default_dtype="bfloat16", + layer_block_options=FLUX_LAYER_BLOCK_OPTIONS, + denoise_image_latent_dimensions=IMAGE_LATENT_DIMENSIONS, ) # ============================================================================= @@ -716,7 +1055,6 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "model_inputs": [ PipelineParam.unet(), - PipelineParam.guider(), PipelineParam.scheduler(), ], "outputs": [ @@ -730,6 +1068,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), @@ -779,6 +1118,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Flux 2 Klein Distilled", default_repo="black-forest-labs/FLUX.2-klein-4B", default_dtype="bfloat16", + denoise_image_latent_dimensions=IMAGE_LATENT_DIMENSIONS, ) @@ -815,6 +1155,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.seed(), ], "model_inputs": [ PipelineParam.vae(), @@ -865,6 +1206,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="Z-Image", default_repo="Tongyi-MAI/Z-Image-Turbo", default_dtype="bfloat16", + guider_options=NON_LAYER_GUIDER_OPTIONS, ) # ============================================================================= @@ -885,6 +1227,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): ], "model_inputs": [ PipelineParam.unet(), + PipelineParam.guider(), PipelineParam.scheduler(), ], "outputs": [ @@ -934,6 +1277,8 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): label="WAN2 T2V", default_repo="Wan-AI/Wan2.1-T2V-1.3B-Diffusers", default_dtype="bfloat16", + guider_options=NON_LAYER_GUIDER_OPTIONS, + scheduler_options=COMPATIBLE_SCHEDULER_OPTIONS, ) WAN_I2V_NODE_SPECS = { @@ -941,36 +1286,49 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "denoise": { "inputs": [ PipelineParam.embeddings(display="input"), - PipelineParam.width(832), - PipelineParam.height(480), + PipelineParam.width(832, max=8192), + PipelineParam.height(480, max=8192), PipelineParam.seed(), PipelineParam.num_inference_steps(50), PipelineParam.guidance_scale(5.0), PipelineParam.num_frames(81), PipelineParam.image_embeds(display="input"), - PipelineParam(name="image_condition_latents", label="Image Latents", type="latents", display="input"), + PipelineParam.image_condition_latents(display="input"), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.unet(), + # Provenance-only: the pinned split denoise block does not consume + # VAE weights, but its latent defaults must match the encoder VAE. + PipelineParam.vae(), + PipelineParam.guider(), PipelineParam.scheduler(), ], "outputs": [ PipelineParam.latents(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["embeddings", "image_embeds", "image_condition_latents"], - "required_model_inputs": ["unet", "scheduler"], + "required_model_inputs": ["unet", "vae", "scheduler"], "block_name": "denoise", }, "vae_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.last_image(), + PipelineParam.height(480, max=8192), + PipelineParam.width(832, max=8192), + PipelineParam.num_frames(81), + PipelineParam.seed(), + PipelineParam.route_state_in(), ], "model_inputs": [ PipelineParam.vae(), ], "outputs": [ - PipelineParam(name="image_condition_latents", label="Image Latents", type="latents", display="output"), + PipelineParam.image_condition_latents(), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -980,12 +1338,16 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "image_encoder": { "inputs": [ PipelineParam.image(), + PipelineParam.last_image(), + PipelineParam.height(480, max=8192), + PipelineParam.width(832, max=8192), ], "model_inputs": [ PipelineParam.image_encoder(), ], "outputs": [ PipelineParam.image_embeds(display="output"), + PipelineParam.route_state_out(), PipelineParam.doc(), ], "required_inputs": ["image"], @@ -1011,6 +1373,7 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): "decoder": { "inputs": [ PipelineParam.latents(display="input"), + PipelineParam.route_state_in(), PipelineParam( name="output_type", label="Output Type", type="dropdown", options=["np", "pil"], default="pil" ), @@ -1031,33 +1394,48 @@ def pin_modular_component_revisions(pipeline, primary_repo, primary_revision): WAN_I2V_PIPELINE_CONFIG = PipelineConfig( node_specs=WAN_I2V_NODE_SPECS, label="WAN2 I2V", - default_repo="Wan-AI/Wan2.1-I2V-14B-480P-Diffusers", + default_repo=WAN_I2V_REPOSITORY, default_dtype="bfloat16", + guider_options=NON_LAYER_GUIDER_OPTIONS, + scheduler_options=COMPATIBLE_SCHEDULER_OPTIONS, + loader_component_outputs=("image_encoder",), ) class DummyCustomPipeline: - """Placeholder class used as registry key for custom pipelines.""" - - repo_id = None - revision = None - trust_remote_code = False + """Unbound registry marker for the custom pipeline choice.""" def __new__(cls): - from diffusers import ModularPipeline + raise ValueError( + "The Custom Modular Diffusers choice is contract_only and is not an executable pipeline. " + "Select a source, repository, immutable Hub revision when applicable, and trust setting so the backend " + "can issue a verified contract checksum for preview." + ) + - revision = require_immutable_hub_revision( - cls.repo_id, - cls.revision, - required=bool(cls.trust_remote_code), +def pipeline_class_from_model_type(model_type): + """Resolve a selected Modular Diffusers model type with an actionable error.""" + + if isinstance(model_type, Mapping): + return resolve_custom_pipeline_identity(model_type) + normalized_model_type = str(model_type or "").strip() + if not normalized_model_type: + return None + if normalized_model_type == CUSTOM_PIPELINE_MODEL_TYPE: + raise ValueError( + "The custom Modular Diffusers selection is missing its backend-issued contract identity. " + "Refresh the Models Loader after selecting its source, repository, revision, and trust setting." ) - kwargs = { - "trust_remote_code": bool(cls.trust_remote_code), - "local_files_only": True, - } - if revision: - kwargs["revision"] = revision - return ModularPipeline.from_pretrained(cls.repo_id, **kwargs) + + import diffusers as diffusers_module + + pipeline_class = getattr(diffusers_module, normalized_model_type, None) + if pipeline_class is None: + raise ValueError( + f"Unknown Diffusers modular pipeline class '{normalized_model_type}'. " + "Install a Diffusers version that provides this model type, then refresh the node definition." + ) + return pipeline_class def pipeline_class_from_runtime_inputs(current_pipeline_class, *runtime_values): @@ -1068,72 +1446,89 @@ def pipeline_class_from_runtime_inputs(current_pipeline_class, *runtime_values): ``model_type`` to each component payload, allowing downstream nodes to restore the same pipeline contract from their graph inputs. """ - if current_pipeline_class is not None: - return current_pipeline_class - model_types = set() - custom_repositories = set() - custom_revisions = set() - custom_trust_values = set() + custom_identities = [] + custom_markers = 0 + custom_markers_without_identity = 0 visited = set() - - def collect(value): + pending = [(runtime_value, 0) for runtime_value in runtime_values] + value_count = 0 + while pending: + value, depth = pending.pop() + value_count += 1 + if value_count > 4096 or depth > 32: + raise ValueError("Connected Modular Diffusers inputs exceed the safe nested-value limit.") if isinstance(value, dict): value_id = id(value) if value_id in visited: - return + continue visited.add(value_id) + if len(value) > 1024: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") model_type = value.get("model_type") if isinstance(model_type, str) and model_type.strip(): model_types.add(model_type.strip()) - if model_type.strip() == "DummyCustomPipeline": - repository = value.get("repo_id") - revision = value.get("revision") - if isinstance(repository, str) and repository.strip(): - custom_repositories.add(repository.strip()) - if isinstance(revision, str) and revision.strip(): - custom_revisions.add(revision.strip()) - custom_trust_values.add(bool(value.get("trust_remote_code"))) - for nested_value in value.values(): - collect(nested_value) + if model_type.strip() == CUSTOM_PIPELINE_MODEL_TYPE: + custom_markers += 1 + identity = value.get(CUSTOM_PIPELINE_IDENTITY_FIELD) + if isinstance(identity, Mapping): + custom_identities.append(identity) + else: + custom_markers_without_identity += 1 + pending.extend( + (nested_value, depth + 1) + for key, nested_value in value.items() + if key != CUSTOM_PIPELINE_IDENTITY_FIELD + ) elif isinstance(value, (list, tuple)): - for nested_value in value: - collect(nested_value) - - for runtime_value in runtime_values: - collect(runtime_value) + if len(value) > 1024: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") + pending.extend((nested_value, depth + 1) for nested_value in value) if not model_types: - return None + return current_pipeline_class if len(model_types) > 1: raise ValueError( "Connected modular model inputs use incompatible pipeline classes: " + ", ".join(sorted(model_types)) ) model_type = next(iter(model_types)) - if model_type == "DummyCustomPipeline": - if len(custom_repositories) != 1 or len(custom_revisions) != 1 or len(custom_trust_values) != 1: - raise ValueError( - "Connected custom Modular Diffusers inputs have incomplete or conflicting trust metadata." - ) - repository = next(iter(custom_repositories)) - revision = next(iter(custom_revisions)) - trust_remote_code = next(iter(custom_trust_values)) - require_immutable_hub_revision(repository, revision, required=True) - DummyCustomPipeline.repo_id = repository - DummyCustomPipeline.revision = revision - DummyCustomPipeline.trust_remote_code = trust_remote_code - return DummyCustomPipeline - - import diffusers as diffusers_module - - pipeline_class = getattr(diffusers_module, model_type, None) - if pipeline_class is None: + current_model_type = getattr(current_pipeline_class, "__name__", None) + if current_pipeline_class is not None and current_model_type != model_type: + selected_name = current_model_type or type(current_pipeline_class).__name__ raise ValueError( - f"Unknown Diffusers modular pipeline class '{model_type}'. " - "Install a Diffusers version that provides this model type." + f"The Modular Diffusers node is configured for pipeline class '{selected_name}', but its connected " + f"model inputs identify '{model_type}'. Reconnect components from one Models Loader or update the " + "node so the selected and connected pipeline classes match." ) - return pipeline_class + + if model_type == CUSTOM_PIPELINE_MODEL_TYPE: + if custom_markers == 0 or custom_markers_without_identity or not custom_identities: + raise ValueError( + "Connected custom Modular Diffusers inputs are missing their backend-issued contract identity. " + "Refresh and rerun the Models Loader before executing this node." + ) + bindings = [resolve_custom_pipeline_identity(identity) for identity in custom_identities] + binding = bindings[0] + if any(resolved.identity != binding.identity for resolved in bindings[1:]): + raise ValueError("Connected custom Modular Diffusers inputs use incompatible contract identities.") + + if current_pipeline_class is not None: + if not isinstance(current_pipeline_class, CustomPipelineBinding): + raise ValueError( + "The Modular Diffusers node is configured with an unverified custom pipeline marker. " + "Refresh its backend-issued contract identity before running." + ) + if current_pipeline_class.identity != binding.identity: + raise ValueError( + "The Modular Diffusers node is configured for a different custom contract identity than its " + "connected model inputs. Reconnect components from one Models Loader or refresh the node." + ) + return current_pipeline_class + return binding + + pipeline_class = pipeline_class_from_model_type(model_type) + return current_pipeline_class or pipeline_class DUMMY_CUSTOM_PIPELINE_CONFIG = PipelineConfig(node_specs={}, label="Custom", default_repo="", default_dtype="bfloat16") @@ -1146,26 +1541,27 @@ class ModiffPipelineRegistry: def __init__(self): self._registry: Dict[type, PipelineConfig] = {} self._initialized = False - # Lock to prevent concurrent initialization races - self._init_lock = threading.Lock() + # Registration occurs while initialization already owns this lock. + self._init_lock = threading.RLock() def register(self, pipeline_cls: type, config: PipelineConfig): """Register a pipeline class with its config.""" - self._registry[pipeline_cls] = config + with self._init_lock: + self._registry[pipeline_cls] = config def get(self, pipeline_cls: type) -> Optional[PipelineConfig]: # Ensure only one thread/coroutine initializes the registry with self._init_lock: if not self._initialized: _initialize_registry(self) - return self._registry.get(pipeline_cls, None) + return self._registry.get(pipeline_cls, None) def get_all(self) -> Dict[type, PipelineConfig]: # Ensure only one thread/coroutine initializes the registry with self._init_lock: if not self._initialized: _initialize_registry(self) - return self._registry + return dict(self._registry) def _initialize_registry(registry: ModiffPipelineRegistry): @@ -1289,24 +1685,66 @@ def get_all_model_types() -> Dict[str, str]: def get_model_type_metadata(model_type: str) -> Optional[Dict[str, Any]]: """Get metadata for a model type. - Returns dict with model_type, label, default_repo, default_dtype, node_params. + Returns model_type, label, default_repo, default_dtype, and node_params. + The custom preview marker also declares ``execution_status=contract_only``. """ registry = _get_registry_instance().get_all() for pipeline_cls, config in registry.items(): if pipeline_cls.__name__ == model_type: - return { + metadata = { "model_type": model_type, "label": config.label, "default_repo": config.default_repo, "default_dtype": config.default_dtype, + "loader_component_outputs": list(config.loader_component_outputs), + "layer_block_options": list(config.layer_block_options), + "guider_options": list(config.guider_options), + "scheduler_options": list(config.scheduler_options), + "denoise_image_latent_dimensions": list(config.denoise_image_latent_dimensions), "node_params": config.node_params, } + if model_type == CUSTOM_PIPELINE_MODEL_TYPE: + metadata["execution_status"] = CUSTOM_PIPELINE_EXECUTION_STATUS + return metadata return None -def pipeline_class_to_modiff_node_config(pipeline_class, node_type=None): +def get_modular_layer_block_options() -> Dict[str, list[str]]: + """Return exact model-type-to-transformer-block choices from reviewed configs.""" + + return { + pipeline_cls.__name__: list(config.layer_block_options) + for pipeline_cls, config in _get_registry_instance().get_all().items() + if config.layer_block_options + } + + +def get_modular_guider_options() -> Dict[str, list[str]]: + """Return exact model-type-to-guider choices from reviewed configs.""" + + return { + pipeline_cls.__name__: list(config.guider_options) + for pipeline_cls, config in _get_registry_instance().get_all().items() + if config.guider_options + } + + +def get_modular_scheduler_options() -> Dict[str, list[str]]: + """Return exact model-type-to-scheduler replacements from reviewed configs.""" + + return { + pipeline_cls.__name__: list(config.scheduler_options) + for pipeline_cls, config in _get_registry_instance().get_all().items() + if config.scheduler_options + } + + +def pipeline_class_to_modiff_node_config(pipeline_class, node_type=None, *, resolve_blocks=True): """Get the block and MoDiff node parameters for a pipeline class and node type.""" - config = _get_registry_instance().get(pipeline_class) + if isinstance(pipeline_class, CustomPipelineBinding): + config = pipeline_class.pipeline_config() + else: + config = _get_registry_instance().get(pipeline_class) if config is None: logger.debug(f"Failed to load config for {pipeline_class}") return None, None @@ -1314,7 +1752,7 @@ def pipeline_class_to_modiff_node_config(pipeline_class, node_type=None): node_params = config.node_params.get(node_type) node_type_blocks = None - if node_params is not None and node_params.get("block_name"): + if resolve_blocks and node_params is not None and node_params.get("block_name"): # patch to use only distilled klein blocks if pipeline_class == Flux2KleinModularPipeline: pipeline = pipeline_class(config_dict={"is_distilled": True}) @@ -1324,3 +1762,54 @@ def pipeline_class_to_modiff_node_config(pipeline_class, node_type=None): node_type_blocks = pipeline.blocks.sub_blocks[node_params["block_name"]] return node_type_blocks, node_params + + +_MODIFF_NODE_ACTION_LABELS = { + "text_encoder": "Encode Prompt", + "image_encoder": "Image Embeddings", + "vae_encoder": "Encode Image", + "denoise": "Denoise", + "decoder": "Decode Latents", + "controlnet": "ControlNet", + "ip_adapter": "IP-Adapter Embeddings", +} + + +def require_modiff_node_contract(pipeline_class, node_type, *, require_blocks=True, resolve_blocks=True): + """Resolve an isolated Modular node-action contract or fail before execution. + + Registry-backed custom configurations retain their deserialized ``node_params`` + dictionary. Dynamic node definitions remove their own connector from the + returned parameter set, so every caller must receive a deep copy rather than + the registry-owned object. Bundle-only actions such as SDXL ControlNet may + opt out of executable-block validation with ``require_blocks=False``. + UI-only refreshes use ``resolve_blocks=False`` so selecting a custom + contract cannot import repository Python before graph execution. + """ + + action_label = _MODIFF_NODE_ACTION_LABELS.get(node_type, str(node_type).replace("_", " ").title()) + if pipeline_class is None: + raise ValueError( + f"The generic Modular Diffusers {action_label} node requires connected model inputs that identify a " + "supported Modular pipeline. Reconnect the Models Loader output and update the node before running." + ) + + blocks, node_config = pipeline_class_to_modiff_node_config( + pipeline_class, + node_type, + resolve_blocks=resolve_blocks, + ) + pipeline_name = getattr(pipeline_class, "__name__", type(pipeline_class).__name__) + if node_config is None: + raise ValueError( + f"Modular Diffusers pipeline '{pipeline_name}' does not support the generic {action_label} node " + f"(action '{node_type}'). Select a pipeline with a registered {action_label} contract or remove the " + "unsupported node from this workflow." + ) + if resolve_blocks and require_blocks and blocks is None: + raise ValueError( + f"Modular Diffusers pipeline '{pipeline_name}' has an incomplete {action_label} contract " + f"(action '{node_type}'): its registered block could not be resolved. Repair the pipeline config " + "before running this workflow." + ) + return blocks, deepcopy(node_config) diff --git a/modules/ModularDiffusers/pipeline_schema.py b/modules/ModularDiffusers/pipeline_schema.py index 9db32cd..df920cf 100644 --- a/modules/ModularDiffusers/pipeline_schema.py +++ b/modules/ModularDiffusers/pipeline_schema.py @@ -11,12 +11,16 @@ """ import copy +import hashlib import json import logging import os +import re +from collections.abc import Mapping # Simple typed wrapper for parameter overrides from dataclasses import asdict, dataclass +from pathlib import Path from typing import Any from huggingface_hub import create_repo, hf_hub_download, upload_file @@ -25,6 +29,7 @@ HfHubHTTPError, RepositoryNotFoundError, RevisionNotFoundError, + validate_repo_id, ) from diffusers.utils import HUGGINGFACE_CO_RESOLVE_ENDPOINT @@ -34,6 +39,577 @@ logger = logging.getLogger(__name__) +MAX_MODIFF_PIPELINE_CONFIG_BYTES = 1024 * 1024 +MAX_CUSTOM_PIPELINE_REPOSITORY_CHARS = 4096 +MAX_LOCAL_EXECUTABLE_MANIFEST_ENTRIES = 16384 +MAX_LOCAL_EXECUTABLE_MANIFEST_FILES = 256 +MAX_LOCAL_EXECUTABLE_FILE_BYTES = 2 * 1024 * 1024 +MAX_LOCAL_EXECUTABLE_MANIFEST_BYTES = 16 * 1024 * 1024 +MAX_LOCAL_EXECUTABLE_MANIFEST_DEPTH = 16 +MAX_CUSTOM_PIPELINE_JSON_DEPTH = 16 +MAX_CUSTOM_PIPELINE_JSON_VALUES = 16_384 +MAX_CUSTOM_PIPELINE_JSON_CONTAINER_ITEMS = 2_048 +MAX_CUSTOM_PIPELINE_JSON_STRING_CHARS = 16_384 +MAX_CUSTOM_PIPELINE_ACTIONS = 128 +MAX_CUSTOM_PIPELINE_PARAMS_PER_ACTION = 256 +MAX_LOADER_COMPONENT_OUTPUTS = 16 +MAX_LAYER_BLOCK_OPTIONS = 64 +MAX_GUIDER_OPTIONS = 16 +MAX_SCHEDULER_OPTIONS = 32 +MAX_DENOISE_IMAGE_LATENT_DIMENSIONS = 2 +SUPPORTED_DENOISE_IMAGE_LATENT_DIMENSIONS = frozenset({"height", "width"}) +PROTOTYPE_SENSITIVE_FIELD_NAMES = frozenset({"__proto__", "prototype", "constructor"}) +_IMMUTABLE_HUB_REVISION = re.compile(r"^[0-9a-f]{40}$") +_LOCAL_EXECUTABLE_CONFIG_FILES = { + "config.json", + "model_index.json", + "modular_config.json", + "modular_model_index.json", +} + + +class DuplicateConfigKeyError(ValueError): + """Raised when a JSON object contains an ambiguous duplicate key.""" + + +def _reject_duplicate_config_keys(pairs): + value = {} + for key, item in pairs: + if key in value: + raise DuplicateConfigKeyError(f"Duplicate JSON key {key!r} is not allowed") + value[key] = item + return value + + +def _reject_nonfinite_config_constant(value): + raise ValueError(f"Non-finite JSON number {value!r} is not allowed") + + +def _decode_pipeline_config_bytes(raw_bytes: bytes, *, source_label: str) -> dict[str, Any]: + try: + decoded = raw_bytes.decode("utf-8") + data = json.loads( + decoded, + object_pairs_hook=_reject_duplicate_config_keys, + parse_constant=_reject_nonfinite_config_constant, + ) + except (UnicodeDecodeError, ValueError, RecursionError) as error: + raise EnvironmentError( + f"The config file at '{source_label}' is not valid unambiguous UTF-8 JSON: {error}" + ) from error + if not isinstance(data, dict): + raise EnvironmentError(f"The config file at '{source_label}' must contain a JSON object at its root.") + return data + + +def _validate_bounded_json_structure(data: dict[str, Any], *, source_label: str) -> None: + """Bound nested metadata before it can become client-visible node state.""" + + pending = [(data, 0)] + value_count = 0 + while pending: + value, depth = pending.pop() + value_count += 1 + if value_count > MAX_CUSTOM_PIPELINE_JSON_VALUES: + raise EnvironmentError( + f"The config file at '{source_label}' exceeds the " + f"{MAX_CUSTOM_PIPELINE_JSON_VALUES}-value structural limit." + ) + if depth > MAX_CUSTOM_PIPELINE_JSON_DEPTH: + raise EnvironmentError( + f"The config file at '{source_label}' exceeds the " + f"{MAX_CUSTOM_PIPELINE_JSON_DEPTH}-level structural depth limit." + ) + if isinstance(value, dict): + if len(value) > MAX_CUSTOM_PIPELINE_JSON_CONTAINER_ITEMS: + raise EnvironmentError( + f"The config file at '{source_label}' contains an object larger than the " + f"{MAX_CUSTOM_PIPELINE_JSON_CONTAINER_ITEMS}-item limit." + ) + for key, item in value.items(): + if len(key) > 256: + raise EnvironmentError( + f"The config file at '{source_label}' contains a JSON key longer than 256 characters." + ) + pending.append((item, depth + 1)) + elif isinstance(value, list): + if len(value) > MAX_CUSTOM_PIPELINE_JSON_CONTAINER_ITEMS: + raise EnvironmentError( + f"The config file at '{source_label}' contains a list larger than the " + f"{MAX_CUSTOM_PIPELINE_JSON_CONTAINER_ITEMS}-item limit." + ) + pending.extend((item, depth + 1) for item in value) + elif isinstance(value, str) and len(value) > MAX_CUSTOM_PIPELINE_JSON_STRING_CHARS: + raise EnvironmentError( + f"The config file at '{source_label}' contains a string longer than the " + f"{MAX_CUSTOM_PIPELINE_JSON_STRING_CHARS}-character limit." + ) + + +def _validate_declarative_field_action( + value: Any, + *, + source_label: str, + field_path: str, + field_definitions: Mapping[str, Any], +) -> None: + """Reject callbacks that execute code or mutate fields outside their contract.""" + + if isinstance(value, str) or not isinstance(value, (dict, list)): + raise EnvironmentError( + f"The config file at '{source_label}' requires '{field_path}' to be a declarative JSON object or list; " + "string callbacks are not allowed in custom pipeline sidecars." + ) + + allowed_fields = set(field_definitions) + + def validate_visibility_map(mapping: Any) -> None: + if not isinstance(mapping, dict): + raise EnvironmentError( + f"The config file at '{source_label}' requires visibility data in '{field_path}' to be an object." + ) + for targets in mapping.values(): + target_names = targets if isinstance(targets, list) else [targets] + if any(not isinstance(target, str) or target not in allowed_fields for target in target_names): + raise EnvironmentError( + f"The config file at '{source_label}' contains an unknown field target in '{field_path}'." + ) + + pending = [value] + while pending: + item = pending.pop() + if isinstance(item, list): + for descriptor in item: + if not isinstance(descriptor, (dict, list)): + raise EnvironmentError( + f"The config file at '{source_label}' contains a non-declarative callback item in " + f"'{field_path}'; string callbacks are not allowed." + ) + pending.append(descriptor) + elif isinstance(item, dict): + if "action" in item: + action = item["action"] + if not isinstance(action, str) or action not in {"show", "hide", "value", "signal"}: + raise EnvironmentError( + f"The config file at '{source_label}' contains prohibited field action " + f"{action!r} in '{field_path}'. Custom sidecars may only declare show, hide, value, or signal." + ) + if action in {"show", "hide"}: + validate_visibility_map(item.get("data", {})) + else: + target = item.get("target") + if not isinstance(target, str) or target not in allowed_fields: + raise EnvironmentError( + f"The config file at '{source_label}' contains an unknown field target in '{field_path}'." + ) + if action == "value": + prop = item.get("prop", "value") + if prop not in {"value", "hidden", "disabled", "options", "fieldOptions", "display"}: + raise EnvironmentError( + f"The config file at '{source_label}' contains unsupported value property " + f"{prop!r} in '{field_path}'." + ) + else: + target_definition = field_definitions.get(target) + target_display = ( + target_definition.get("display") if isinstance(target_definition, Mapping) else None + ) + if target_display in {"input", "output"}: + continue + raise EnvironmentError( + f"The config file at '{source_label}' requires signal target {target!r} in " + f"'{field_path}' to be an input or output field." + ) + else: + # An action-less object is the client's visibility map. + validate_visibility_map(item) + + +def _validate_pipeline_config_document(data: dict[str, Any], *, source_label: str) -> None: + """Validate structural fields consumed before the richer P1 schema gate.""" + + _validate_bounded_json_structure(data, source_label=source_label) + for field_name in ("label", "default_repo", "default_dtype"): + field_value = data.get(field_name, "") + if not isinstance(field_value, str): + raise EnvironmentError( + f"The config file at '{source_label}' requires string field '{field_name}'." + ) + loader_component_outputs = data.get("loader_component_outputs", []) + if not isinstance(loader_component_outputs, list) or len(loader_component_outputs) > MAX_LOADER_COMPONENT_OUTPUTS: + raise EnvironmentError( + f"The config file at '{source_label}' requires at most {MAX_LOADER_COMPONENT_OUTPUTS} " + "loader component output names." + ) + invalid_loader_component_output = any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in loader_component_outputs + ) + if invalid_loader_component_output or len(loader_component_outputs) != len(set(loader_component_outputs)): + raise EnvironmentError( + f"The config file at '{source_label}' contains invalid or duplicate loader component output names." + ) + layer_block_options = data.get("layer_block_options", []) + if not isinstance(layer_block_options, list) or len(layer_block_options) > MAX_LAYER_BLOCK_OPTIONS: + raise EnvironmentError( + f"The config file at '{source_label}' requires at most {MAX_LAYER_BLOCK_OPTIONS} layer block names." + ) + invalid_layer_block_option = any( + not isinstance(name, str) + or not name.strip() + or len(name) > 256 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in layer_block_options + ) + if invalid_layer_block_option or len(layer_block_options) != len(set(layer_block_options)): + raise EnvironmentError( + f"The config file at '{source_label}' contains invalid or duplicate layer block names." + ) + guider_options = data.get("guider_options", []) + if not isinstance(guider_options, list) or len(guider_options) > MAX_GUIDER_OPTIONS: + raise EnvironmentError( + f"The config file at '{source_label}' requires at most {MAX_GUIDER_OPTIONS} guider class names." + ) + invalid_guider_option = any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in guider_options + ) + if invalid_guider_option or len(guider_options) != len(set(guider_options)): + raise EnvironmentError( + f"The config file at '{source_label}' contains invalid or duplicate guider class names." + ) + scheduler_options = data.get("scheduler_options", []) + if not isinstance(scheduler_options, list) or len(scheduler_options) > MAX_SCHEDULER_OPTIONS: + raise EnvironmentError( + f"The config file at '{source_label}' requires at most {MAX_SCHEDULER_OPTIONS} scheduler class names." + ) + invalid_scheduler_option = any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in scheduler_options + ) + if invalid_scheduler_option or len(scheduler_options) != len(set(scheduler_options)): + raise EnvironmentError( + f"The config file at '{source_label}' contains invalid or duplicate scheduler class names." + ) + denoise_image_latent_dimensions = data.get("denoise_image_latent_dimensions", []) + if ( + not isinstance(denoise_image_latent_dimensions, list) + or len(denoise_image_latent_dimensions) > MAX_DENOISE_IMAGE_LATENT_DIMENSIONS + ): + raise EnvironmentError( + f"The config file at '{source_label}' requires at most " + f"{MAX_DENOISE_IMAGE_LATENT_DIMENSIONS} denoise image-latent dimension names." + ) + if ( + any( + not isinstance(name, str) or name not in SUPPORTED_DENOISE_IMAGE_LATENT_DIMENSIONS + for name in denoise_image_latent_dimensions + ) + or len(denoise_image_latent_dimensions) != len(set(denoise_image_latent_dimensions)) + ): + raise EnvironmentError( + f"The config file at '{source_label}' contains invalid or duplicate denoise image-latent dimension names." + ) + if "node_params" not in data or not isinstance(data["node_params"], dict) or not data["node_params"]: + raise EnvironmentError( + f"The config file at '{source_label}' requires a non-empty 'node_params' JSON object." + ) + if len(data["node_params"]) > MAX_CUSTOM_PIPELINE_ACTIONS: + raise EnvironmentError( + f"The config file at '{source_label}' exceeds the {MAX_CUSTOM_PIPELINE_ACTIONS}-action limit." + ) + + for action_name, action in data["node_params"].items(): + if ( + not isinstance(action_name, str) + or not action_name.strip() + or len(action_name) > 128 + or action_name in PROTOTYPE_SENSITIVE_FIELD_NAMES + ): + raise EnvironmentError( + f"The config file at '{source_label}' contains an invalid node action name." + ) + if action is None: + continue + if not isinstance(action, dict): + raise EnvironmentError( + f"The config file at '{source_label}' requires action '{action_name}' to be a JSON object or null." + ) + params = action.get("params") + if not isinstance(params, dict): + raise EnvironmentError( + f"The config file at '{source_label}' requires action '{action_name}.params' to be a JSON object." + ) + if len(params) > MAX_CUSTOM_PIPELINE_PARAMS_PER_ACTION: + raise EnvironmentError( + f"The config file at '{source_label}' action '{action_name}' exceeds the " + f"{MAX_CUSTOM_PIPELINE_PARAMS_PER_ACTION}-parameter limit." + ) + for param_name, param in params.items(): + if ( + not isinstance(param_name, str) + or not param_name.strip() + or len(param_name) > 128 + or param_name in PROTOTYPE_SENSITIVE_FIELD_NAMES + ): + raise EnvironmentError( + f"The config file at '{source_label}' contains an invalid parameter name in '{action_name}'." + ) + if not isinstance(param, dict): + raise EnvironmentError( + f"The config file at '{source_label}' requires parameter '{action_name}.{param_name}' " + "to be a JSON object." + ) + for callback_name in ("onChange", "onSignal"): + if callback_name in param: + _validate_declarative_field_action( + param[callback_name], + source_label=source_label, + field_path=f"{action_name}.{param_name}.{callback_name}", + field_definitions=params, + ) + for names_field in ("input_names", "model_input_names", "output_names"): + names = action.get(names_field) + if not isinstance(names, list) or any( + not isinstance(name, str) or not name.strip() or len(name) > 128 for name in names + ): + raise EnvironmentError( + f"The config file at '{source_label}' requires '{action_name}.{names_field}' " + "to be a list of non-empty strings." + ) + block_name = action.get("block_name") + if block_name is not None and ( + not isinstance(block_name, str) or not block_name.strip() or len(block_name) > 256 + ): + raise EnvironmentError( + f"The config file at '{source_label}' requires '{action_name}.block_name' " + "to be a non-empty string or null." + ) + for optional_string in ("node_type", "label", "color"): + if optional_string in action and not isinstance(action[optional_string], str): + raise EnvironmentError( + f"The config file at '{source_label}' requires '{action_name}.{optional_string}' " + "to be a string." + ) + + +def _read_pipeline_config_bytes(config_path: Path) -> bytes: + try: + size = config_path.stat().st_size + except OSError as error: + raise EnvironmentError(f"Could not inspect Modular Diffusers config file '{config_path}': {error}") from error + if size > MAX_MODIFF_PIPELINE_CONFIG_BYTES: + raise EnvironmentError( + f"The Modular Diffusers config file at '{config_path}' is larger than the " + f"{MAX_MODIFF_PIPELINE_CONFIG_BYTES}-byte limit." + ) + try: + with config_path.open("rb") as reader: + raw_bytes = reader.read(MAX_MODIFF_PIPELINE_CONFIG_BYTES + 1) + except OSError as error: + raise EnvironmentError(f"Could not read Modular Diffusers config file '{config_path}': {error}") from error + if len(raw_bytes) > MAX_MODIFF_PIPELINE_CONFIG_BYTES: + raise EnvironmentError( + f"The Modular Diffusers config file at '{config_path}' is larger than the " + f"{MAX_MODIFF_PIPELINE_CONFIG_BYTES}-byte limit." + ) + return raw_bytes + + +def _is_executable_manifest_file(path: Path, relative_name: str) -> bool: + return relative_name in _LOCAL_EXECUTABLE_CONFIG_FILES or path.suffix.lower() == ".py" + + +def _hub_snapshot_blob_root(repository_path: Path) -> Path: + return repository_path.parent.parent / "blobs" + + +def _is_linked_directory(path: Path) -> bool: + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +def _validate_hub_snapshot_config_path(config_path: Path, *, revision: str) -> None: + repository_path = config_path.parent + if ( + config_path.name != MoDiffPipelineConfig.config_name + or repository_path.name != revision + or repository_path.parent.name != "snapshots" + ): + raise EnvironmentError( + f"The cached Hub result for revision {revision} is not contained in its exact snapshot directory." + ) + repo_cache_root = repository_path.parent.parent + for directory, label in ( + (repo_cache_root, "repository cache"), + (repository_path.parent, "snapshots directory"), + (repository_path, "exact snapshot"), + ): + if _is_linked_directory(directory): + raise EnvironmentError(f"The cached Hub {label} must not be a symlink or junction: '{directory}'.") + if config_path.is_symlink(): + try: + blob_root = _hub_snapshot_blob_root(repository_path) + if _is_linked_directory(blob_root): + raise ValueError("the repository blobs directory is linked") + target = config_path.resolve(strict=True) + target.relative_to(blob_root.resolve(strict=True)) + except (OSError, RuntimeError, ValueError) as error: + raise EnvironmentError( + f"The cached Hub {MoDiffPipelineConfig.config_name} symlink does not resolve inside this " + "repository cache's blobs directory." + ) from error + + +def _executable_manifest_sha256(repository_path: Path, *, source: str) -> str: + """Detect bounded loader/control metadata drift, not weights or atomic code.""" + + candidates = [] + pending = [(repository_path, 0)] + scanned_entries = 0 + while pending: + current_directory, depth = pending.pop() + try: + entries = sorted(os.scandir(current_directory), key=lambda entry: entry.name) + except OSError as error: + raise EnvironmentError( + f"Could not inspect custom pipeline directory '{current_directory}': {error}" + ) from error + for entry in entries: + scanned_entries += 1 + if scanned_entries > MAX_LOCAL_EXECUTABLE_MANIFEST_ENTRIES: + raise EnvironmentError( + "Custom pipeline repository exceeds the " + f"{MAX_LOCAL_EXECUTABLE_MANIFEST_ENTRIES}-entry executable-manifest scan limit." + ) + entry_path = Path(entry.path) + relative_name = entry_path.relative_to(repository_path).as_posix() + manifest_file = _is_executable_manifest_file(entry_path, relative_name) + is_junction = bool(getattr(entry_path, "is_junction", lambda: False)()) + if entry.is_symlink() or is_junction: + if entry.is_dir(follow_symlinks=True) or is_junction: + raise EnvironmentError( + f"Custom pipeline executable manifest does not allow linked directory '{entry_path}'." + ) + if manifest_file: + if source == "local": + raise EnvironmentError( + f"Local custom pipeline executable manifest does not allow linked file '{entry_path}'." + ) + try: + blob_root = _hub_snapshot_blob_root(repository_path) + if _is_linked_directory(blob_root): + raise ValueError("the repository blobs directory is linked") + resolved_path = entry_path.resolve(strict=True) + resolved_path.relative_to(blob_root.resolve(strict=True)) + if not resolved_path.is_file(): + raise ValueError("linked executable is not a regular file") + except (OSError, RuntimeError, ValueError) as error: + raise EnvironmentError( + f"Cached Hub executable file '{entry_path}' does not resolve inside this repository " + "cache's blobs directory." + ) from error + candidates.append((entry_path, resolved_path)) + continue + if entry.is_dir(follow_symlinks=False): + if depth >= MAX_LOCAL_EXECUTABLE_MANIFEST_DEPTH: + raise EnvironmentError( + "Custom pipeline repository exceeds the " + f"{MAX_LOCAL_EXECUTABLE_MANIFEST_DEPTH}-level executable-manifest depth limit." + ) + pending.append((entry_path, depth + 1)) + elif entry.is_file(follow_symlinks=False) and manifest_file: + candidates.append((entry_path, entry_path)) + + if len(candidates) > MAX_LOCAL_EXECUTABLE_MANIFEST_FILES: + raise EnvironmentError( + "Custom pipeline repository exceeds the " + f"{MAX_LOCAL_EXECUTABLE_MANIFEST_FILES}-file executable-manifest limit." + ) + + manifest_entries = [] + total_bytes = 0 + for display_path, resolved_path in candidates: + relative_name = display_path.relative_to(repository_path).as_posix() + if len(relative_name) > MAX_CUSTOM_PIPELINE_REPOSITORY_CHARS: + raise EnvironmentError("Custom pipeline executable manifest path exceeds 4096 characters.") + try: + file_size = resolved_path.stat().st_size + except OSError as error: + raise EnvironmentError( + f"Could not inspect custom pipeline executable file '{display_path}': {error}" + ) from error + if file_size > MAX_LOCAL_EXECUTABLE_FILE_BYTES: + raise EnvironmentError( + f"Custom pipeline executable file '{display_path}' exceeds the " + f"{MAX_LOCAL_EXECUTABLE_FILE_BYTES}-byte limit." + ) + try: + with resolved_path.open("rb") as reader: + raw_bytes = reader.read(MAX_LOCAL_EXECUTABLE_FILE_BYTES + 1) + except OSError as error: + raise EnvironmentError( + f"Could not read custom pipeline executable file '{display_path}': {error}" + ) from error + if len(raw_bytes) > MAX_LOCAL_EXECUTABLE_FILE_BYTES: + raise EnvironmentError( + f"Custom pipeline executable file '{display_path}' exceeds the " + f"{MAX_LOCAL_EXECUTABLE_FILE_BYTES}-byte limit." + ) + if len(raw_bytes) != file_size: + raise EnvironmentError( + f"Custom pipeline loader metadata '{display_path}' changed while its manifest was being read. " + "Retry after the repository cache is stable." + ) + total_bytes += len(raw_bytes) + if total_bytes > MAX_LOCAL_EXECUTABLE_MANIFEST_BYTES: + raise EnvironmentError( + "Custom pipeline executable manifest exceeds the " + f"{MAX_LOCAL_EXECUTABLE_MANIFEST_BYTES}-byte total limit." + ) + manifest_entries.append((relative_name, raw_bytes)) + + digest = hashlib.sha256(b"modiff-executable-manifest-v2\0") + present_names = {relative_name for relative_name, _raw_bytes in manifest_entries} + for config_name in sorted(_LOCAL_EXECUTABLE_CONFIG_FILES): + name_bytes = config_name.encode("utf-8") + digest.update(b"fixed-config\0") + digest.update(len(name_bytes).to_bytes(4, "big")) + digest.update(name_bytes) + digest.update(b"present\0" if config_name in present_names else b"absent\0") + for relative_name, raw_bytes in sorted(manifest_entries): + name_bytes = relative_name.encode("utf-8") + digest.update(b"file\0") + digest.update(len(name_bytes).to_bytes(4, "big")) + digest.update(name_bytes) + digest.update(len(raw_bytes).to_bytes(8, "big")) + digest.update(raw_bytes) + return digest.hexdigest() + + +@dataclass(frozen=True) +class VerifiedMoDiffPipelineConfig: + """A bounded sidecar plus loader-metadata drift checksum; model weights are not hashed.""" + + config: "MoDiffPipelineConfig" + raw_bytes: bytes + sha256: str + source: str + repo_id: str + revision: str | None + executable_manifest_sha256: str + config_path: str + repository_path: str + + def _name_to_label(name: str) -> str: """Convert snake_case name to Title Case label.""" return name.replace("_", " ").title() @@ -43,6 +619,12 @@ def _name_to_label(name: str) -> str: MODIFF_PARAM_TEMPLATES = { # Image I/O "image": {"label": "Image", "type": "image", "display": "input", "required_block_params": ["image"]}, + "last_image": { + "label": "Last Image", + "type": "image", + "display": "input", + "required_block_params": ["last_image"], + }, "images": {"label": "Images", "type": "image", "display": "output", "required_block_params": ["images"]}, "control_image": { "label": "Control Image", @@ -50,6 +632,12 @@ def _name_to_label(name: str) -> str: "display": "input", "required_block_params": ["control_image"], }, + "mask_image": { + "label": "Mask Image", + "type": "image", + "display": "input", + "required_block_params": ["mask_image"], + }, # Latents "latents": {"label": "Latents", "type": "latents", "display": "input", "required_block_params": ["latents"]}, "image_latents": { @@ -58,12 +646,30 @@ def _name_to_label(name: str) -> str: "display": "input", "required_block_params": ["image_latents"], }, + "mask": { + "label": "Latent Mask", + "type": "latent_mask", + "display": "input", + "required_block_params": ["mask"], + }, + "masked_image_latents": { + "label": "Masked Image Latents", + "type": "masked_latents", + "display": "input", + "required_block_params": ["masked_image_latents"], + }, "first_frame_latents": { "label": "First Frame Latents", "type": "latents", "display": "input", "required_block_params": ["first_frame_latents"], }, + "image_condition_latents": { + "label": "Image Condition Latents", + "type": "video_condition_latents", + "display": "output", + "required_block_params": ["image_condition_latents"], + }, "latents_preview": {"label": "Latents Preview", "type": "latent", "display": "output"}, # Image Latents with Strength "image_latents_with_strength": { @@ -141,6 +747,14 @@ def _name_to_label(name: str) -> str: "display": "random", "required_block_params": ["generator"], }, + "padding_mask_crop": { + "label": "Mask Crop Padding", + "type": "int", + "min": 0, + "max": 8192, + "step": 1, + "required_block_params": ["padding_mask_crop"], + }, "num_inference_steps": { "label": "Steps", "type": "int", @@ -239,8 +853,19 @@ def _name_to_label(name: str) -> str: "type": "custom_guider", "display": "input", "onChange": {False: ["guidance_scale"], True: []}, + "required_block_params": ["guider"], }, "doc": {"label": "Doc", "type": "string", "display": "output"}, + "route_state_in": { + "label": "Route State", + "type": "modular_route_state", + "display": "input", + }, + "route_state_out": { + "label": "Route State", + "type": "modular_route_state", + "display": "output", + }, } @@ -537,6 +1162,7 @@ def output_param_to_modiff_param(output_param: "OutputParam") -> MoDiffParam: "vae_encoder": { "inputs": [ MoDiffParam.image(), + MoDiffParam.seed(), ], "model_inputs": [ MoDiffParam.vae(), @@ -752,6 +1378,11 @@ def __init__( label: str = "", default_repo: str = "", default_dtype: str = "", + loader_component_outputs: tuple[str, ...] = (), + layer_block_options: tuple[str, ...] = (), + guider_options: tuple[str, ...] = (), + scheduler_options: tuple[str, ...] = (), + denoise_image_latent_dimensions: tuple[str, ...] = (), ): """ Args: @@ -761,6 +1392,11 @@ def __init__( label: Human-readable label for the pipeline default_repo: Default HuggingFace repo for this pipeline default_dtype: Default dtype (e.g., "float16", "bfloat16") + loader_component_outputs: Additional required component names that ModelsLoader publishes. + layer_block_options: Exact installed transformer block paths accepted by the Layers node. + guider_options: Exact Diffusers guider classes accepted for this pipeline. + scheduler_options: Exact Diffusers scheduler replacements accepted for this pipeline. + denoise_image_latent_dimensions: Legacy dimension inputs retained when image latents are supplied. """ # Convert all node specs to MoDiff format immediately self.node_specs = node_specs @@ -768,6 +1404,83 @@ def __init__( self.label = label self.default_repo = default_repo self.default_dtype = default_dtype + if not isinstance(loader_component_outputs, (list, tuple)): + raise ValueError("loader_component_outputs requires a list or tuple of component names.") + normalized_loader_outputs = tuple(loader_component_outputs) + if ( + len(normalized_loader_outputs) > MAX_LOADER_COMPONENT_OUTPUTS + or any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in normalized_loader_outputs + ) + or len(normalized_loader_outputs) != len(set(normalized_loader_outputs)) + ): + raise ValueError("loader_component_outputs requires bounded, unique component names.") + self.loader_component_outputs = normalized_loader_outputs + if not isinstance(layer_block_options, (list, tuple)): + raise ValueError("layer_block_options requires a list or tuple of block names.") + normalized_layer_blocks = tuple(layer_block_options) + if ( + len(normalized_layer_blocks) > MAX_LAYER_BLOCK_OPTIONS + or any( + not isinstance(name, str) + or not name.strip() + or len(name) > 256 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in normalized_layer_blocks + ) + or len(normalized_layer_blocks) != len(set(normalized_layer_blocks)) + ): + raise ValueError("layer_block_options requires bounded, unique block names.") + self.layer_block_options = normalized_layer_blocks + if not isinstance(guider_options, (list, tuple)): + raise ValueError("guider_options requires a list or tuple of guider class names.") + normalized_guider_options = tuple(guider_options) + if ( + len(normalized_guider_options) > MAX_GUIDER_OPTIONS + or any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in normalized_guider_options + ) + or len(normalized_guider_options) != len(set(normalized_guider_options)) + ): + raise ValueError("guider_options requires bounded, unique guider class names.") + self.guider_options = normalized_guider_options + if not isinstance(scheduler_options, (list, tuple)): + raise ValueError("scheduler_options requires a list or tuple of scheduler class names.") + normalized_scheduler_options = tuple(scheduler_options) + if ( + len(normalized_scheduler_options) > MAX_SCHEDULER_OPTIONS + or any( + not isinstance(name, str) + or not name.strip() + or len(name) > 128 + or name in PROTOTYPE_SENSITIVE_FIELD_NAMES + for name in normalized_scheduler_options + ) + or len(normalized_scheduler_options) != len(set(normalized_scheduler_options)) + ): + raise ValueError("scheduler_options requires bounded, unique scheduler class names.") + self.scheduler_options = normalized_scheduler_options + if not isinstance(denoise_image_latent_dimensions, (list, tuple)): + raise ValueError("denoise_image_latent_dimensions requires a list or tuple of dimension names.") + normalized_denoise_dimensions = tuple(denoise_image_latent_dimensions) + if ( + len(normalized_denoise_dimensions) > MAX_DENOISE_IMAGE_LATENT_DIMENSIONS + or any( + not isinstance(name, str) or name not in SUPPORTED_DENOISE_IMAGE_LATENT_DIMENSIONS + for name in normalized_denoise_dimensions + ) + or len(normalized_denoise_dimensions) != len(set(normalized_denoise_dimensions)) + ): + raise ValueError("denoise_image_latent_dimensions requires bounded, unique supported dimension names.") + self.denoise_image_latent_dimensions = normalized_denoise_dimensions @property def node_params(self) -> dict[str, Any]: @@ -806,6 +1519,11 @@ def to_dict(self) -> dict[str, Any]: "label": self.label, "default_repo": self.default_repo, "default_dtype": self.default_dtype, + "loader_component_outputs": list(self.loader_component_outputs), + "layer_block_options": list(self.layer_block_options), + "guider_options": list(self.guider_options), + "scheduler_options": list(self.scheduler_options), + "denoise_image_latent_dimensions": list(self.denoise_image_latent_dimensions), "node_params": self.node_params, } @@ -822,6 +1540,11 @@ def from_dict(cls, data: dict[str, Any]) -> "MoDiffPipelineConfig": instance.label = data.get("label", "") instance.default_repo = data.get("default_repo", "") instance.default_dtype = data.get("default_dtype", "") + instance.loader_component_outputs = tuple(data.get("loader_component_outputs", ())) + instance.layer_block_options = tuple(data.get("layer_block_options", ())) + instance.guider_options = tuple(data.get("guider_options", ())) + instance.scheduler_options = tuple(data.get("scheduler_options", ())) + instance.denoise_image_latent_dimensions = tuple(data.get("denoise_image_latent_dimensions", ())) return instance def to_json_string(self) -> str: @@ -833,12 +1556,25 @@ def to_json_file(self, json_file_path: str | os.PathLike): with open(json_file_path, "w", encoding="utf-8") as writer: writer.write(self.to_json_string()) + @classmethod + def from_json_bytes(cls, raw_bytes: bytes, *, source_label: str = "") -> "MoDiffPipelineConfig": + """Load one bounded, duplicate-free JSON object from an exact byte sequence.""" + + if len(raw_bytes) > MAX_MODIFF_PIPELINE_CONFIG_BYTES: + raise EnvironmentError( + f"The Modular Diffusers config at '{source_label}' is larger than the " + f"{MAX_MODIFF_PIPELINE_CONFIG_BYTES}-byte limit." + ) + data = _decode_pipeline_config_bytes(raw_bytes, source_label=source_label) + _validate_pipeline_config_document(data, source_label=source_label) + return cls.from_dict(data) + @classmethod def from_json_file(cls, json_file_path: str | os.PathLike) -> "MoDiffPipelineConfig": """Load from a JSON file.""" - with open(json_file_path, "r", encoding="utf-8") as reader: - data = json.load(reader) - return cls.from_dict(data) + config_path = Path(json_file_path) + raw_bytes = _read_pipeline_config_bytes(config_path) + return cls.from_json_bytes(raw_bytes, source_label=str(config_path)) def save(self, save_directory: str | os.PathLike, push_to_hub: bool = False, **kwargs): """Save the modiff pipeline config to a directory.""" @@ -868,6 +1604,114 @@ def save(self, save_directory: str | os.PathLike, push_to_hub: bool = False, **k ) logger.info(f"Pipeline config pushed to hub: {repo_id}") + @classmethod + def load_verified( + cls, + pretrained_model_name_or_path: str | os.PathLike, + *, + source: str, + revision: str | None = None, + cache_dir: str | os.PathLike | None = None, + token: bool | str | None = None, + ) -> VerifiedMoDiffPipelineConfig: + """Read a source-explicit sidecar and loader metadata without network access. + + ``source='hub'`` never falls back to a same-named local directory. It + resolves only an exact cached Hub commit. ``source='local'`` never calls + the Hub and rejects a sidecar symlink that escapes the selected model + directory. The executable manifest detects reviewed config/Python drift; + it is not an atomic code authorization or a model-weight proof. + """ + + if not isinstance(source, str) or source not in {"hub", "local"}: + raise ValueError("Custom Modular Diffusers repositories must declare source as 'hub' or 'local'.") + + repository = str(pretrained_model_name_or_path or "").strip() + if not repository: + raise ValueError("Custom Modular Diffusers repositories require a non-empty repository or local path.") + if len(repository) > MAX_CUSTOM_PIPELINE_REPOSITORY_CHARS: + raise ValueError("Custom Modular Diffusers repository or local path exceeds 4096 characters.") + + normalized_revision = str(revision or "").strip() or None + if source == "hub": + try: + validate_repo_id(repository) + except ValueError as error: + raise ValueError(f"Invalid Hugging Face repository ID {repository!r}: {error}") from error + if normalized_revision is None or _IMMUTABLE_HUB_REVISION.fullmatch(normalized_revision) is None: + raise ValueError( + "Custom Modular Diffusers Hub repositories require an immutable lowercase 40-character commit " + "revision before their MoDiff sidecar can be read." + ) + try: + config_file = hf_hub_download( + repository, + filename=cls.config_name, + cache_dir=cache_dir, + local_files_only=True, + token=token, + revision=normalized_revision, + ) + except ( + RepositoryNotFoundError, + RevisionNotFoundError, + EntryNotFoundError, + HfHubHTTPError, + ValueError, + ) as error: + raise EnvironmentError( + f"Could not resolve cached {cls.config_name} for {repository}@{normalized_revision}. " + "Install that exact revision through Model Manager before refreshing the custom pipeline contract." + ) from error + config_path = Path(config_file).absolute() + if not config_path.is_file(): + raise EnvironmentError( + f"The cached Hub snapshot for {repository}@{normalized_revision} has no {cls.config_name}." + ) + repository_path = config_path.parent + _validate_hub_snapshot_config_path(config_path, revision=normalized_revision) + normalized_repository = repository + executable_manifest_sha256 = _executable_manifest_sha256(repository_path, source="hub") + else: + if normalized_revision is not None: + raise ValueError( + "Local custom Modular Diffusers directories are mutable and must not claim a Hub revision. " + "Clear Revision or select the Hub source." + ) + try: + repository_path = Path(repository).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise EnvironmentError( + f"Local custom Modular Diffusers directory '{repository}' does not exist." + ) from error + if not repository_path.is_dir(): + raise EnvironmentError(f"Local custom Modular Diffusers source '{repository}' must be a directory.") + try: + config_path = (repository_path / cls.config_name).resolve(strict=True) + config_path.relative_to(repository_path) + except (OSError, RuntimeError, ValueError) as error: + raise EnvironmentError( + f"Local {cls.config_name} must be a regular file contained by '{repository_path}'." + ) from error + if not config_path.is_file(): + raise EnvironmentError(f"No file named {cls.config_name} found in {repository_path}") + normalized_repository = str(repository_path) + executable_manifest_sha256 = _executable_manifest_sha256(repository_path, source="local") + + raw_bytes = _read_pipeline_config_bytes(config_path) + config = cls.from_json_bytes(raw_bytes, source_label=str(config_path)) + return VerifiedMoDiffPipelineConfig( + config=config, + raw_bytes=raw_bytes, + sha256=hashlib.sha256(raw_bytes).hexdigest(), + source=source, + repo_id=normalized_repository, + revision=normalized_revision, + executable_manifest_sha256=executable_manifest_sha256, + config_path=str(config_path), + repository_path=str(repository_path), + ) + @classmethod def load( cls, diff --git a/modules/ModularDiffusers/route_state.py b/modules/ModularDiffusers/route_state.py new file mode 100644 index 0000000..284b4f7 --- /dev/null +++ b/modules/ModularDiffusers/route_state.py @@ -0,0 +1,4345 @@ +"""Opaque, process-local state for multi-action Modular Diffusers routes. + +Large graph values such as latents remain on their existing typed edges. This +module carries only the small pieces of upstream routing state that cannot be +reconstructed safely between the VAE, denoise, and decode actions. Neither +loader bindings nor route states are serializable graph data. +""" + +from __future__ import annotations + +import hashlib +import math +import threading +import weakref +from collections.abc import Mapping +from enum import Enum + +import torch +from PIL import Image + +from modiff.model_artifact_catalog import require_catalog_revision +from modiff.modular_workflow_contracts import WAN_WORKFLOW_REPOSITORIES + + +_QWEN_ROUTE_CONTRACT = "qwen" +_SDXL_ROUTE_CONTRACT = "sdxl" +_WAN_ROUTE_CONTRACT = "wan_i2v" +_ROUTE_CONTRACT_BY_MODEL_TYPE = { + "QwenImageModularPipeline": _QWEN_ROUTE_CONTRACT, + "QwenImageEditModularPipeline": _QWEN_ROUTE_CONTRACT, + "QwenImageEditPlusModularPipeline": _QWEN_ROUTE_CONTRACT, + "StableDiffusionXLModularPipeline": _SDXL_ROUTE_CONTRACT, + "WanImage2VideoModularPipeline": _WAN_ROUTE_CONTRACT, +} +SUPPORTED_ROUTE_MODEL_TYPES = frozenset(_ROUTE_CONTRACT_BY_MODEL_TYPE) +ROUTE_STATE_INPUT = "route_state_in" +ROUTE_STATE_OUTPUT = "route_state_out" +SDXL_UNION_CONTROL_MODE_LIMIT = 32 +ROUTE_RESERVED_PIPELINE_INPUTS = frozenset( + { + "generator", + "processed_mask_image", + "mask_overlay_kwargs", + "mask", + } +) + +_ENCODE_TO_DENOISE = "encode_to_denoise" +_IMAGE_EMBED_TO_VAE = "image_embed_to_vae" +_CONTROLNET_TO_DENOISE = "controlnet_to_denoise" +_IP_ADAPTER_TO_DENOISE = "ip_adapter_to_denoise" +_DENOISE_TO_DECODE = "denoise_to_decode" +_MAX_PAIRED_LATENT_TENSORS = 64 +_MAX_MASK_CROP_PADDING = 8192 +_MAX_OVERLAY_EDGE_PIXELS = 8192 +_MAX_OVERLAY_AGGREGATE_PIXELS = 16 * 1024 * 1024 +_MAX_WAN_SOURCE_BYTES = 64 * 1024 * 1024 +_MAX_WAN_REQUEST_AREA = 16 * 1024 * 1024 +_MAX_WAN_VIDEO_TENSOR_BYTES = 512 * 1024 * 1024 +_WAN_VIDEO_PIXEL_BYTES = 3 * 4 +_MAX_WAN_DIMENSION = 8192 +_MAX_WAN_FRAMES = 480 +_MAX_WAN_PROCESSOR_NORMALIZATION = 16.0 +_MAX_WAN_VAE_CONFIG_MAGNITUDE = 1024.0 +_MIN_WAN_POSITIVE_SCALE = 1e-6 +_WAN_SPATIAL_SCALE = 8 +_WAN_TEMPORAL_SCALE = 4 +_WAN_LATENT_CHANNELS = 16 +_WAN_TRANSFORMER_PATCH_SIZE = (1, 2, 2) +_WAN_PATCH_SIZE_SPATIAL = 2 +_WAN_TRANSFORMER_INPUT_CHANNELS = 36 +_WAN_TRANSFORMER_OUTPUT_CHANNELS = 16 +_WAN_IMAGE_SIZE = 224 +_WAN_IMAGE_EMBED_DIM = 1280 +_WAN_IMAGE_EMBED_TOKENS = 257 +_WAN_IMAGE_ENCODER_PATCH_SIZE = 14 +_WAN_IMAGE_ENCODER_PROJECTION_DIM = 1024 +_WAN_IMAGE_ENCODER_LAYERS = 32 +_WAN_IMAGE_ENCODER_HEADS = 16 +_WAN_CLIP_IMAGE_MEAN = (0.48145466, 0.4578275, 0.40821073) +_WAN_CLIP_IMAGE_STD = (0.26862954, 0.26130258, 0.27577711) +_SDXL_IP_ADAPTER_IMAGE_SIZE = 224 +_SDXL_IP_ADAPTER_HIDDEN_SIZE = 1280 +_SDXL_IP_ADAPTER_PATCH_SIZE = 14 +_SDXL_IP_ADAPTER_PROJECTION_DIM = 1024 +_SDXL_IP_ADAPTER_LAYERS = 32 +_SDXL_IP_ADAPTER_HEADS = 16 +_SDXL_IP_ADAPTER_IMAGE_MEAN = (0.48145466, 0.4578275, 0.40821073) +_SDXL_IP_ADAPTER_IMAGE_STD = (0.26862954, 0.26130258, 0.27577711) +_MAX_SDXL_IP_ADAPTER_DIMENSION = 8192 +_MAX_SDXL_IP_ADAPTER_PIXELS = 16 * 1024 * 1024 +_MAX_SDXL_IP_ADAPTER_SOURCE_BYTES = 64 * 1024 * 1024 +_WAN_VAE_LATENTS_MEAN = ( + -0.7571, + -0.7089, + -0.9113, + 0.1075, + -0.1745, + 0.9653, + -0.1517, + 1.5508, + 0.4134, + -0.0715, + 0.5517, + -0.3632, + -0.1922, + -0.9497, + 0.2503, + -0.2921, +) +_WAN_VAE_LATENTS_STD = ( + 2.8184, + 1.4541, + 2.3275, + 2.6558, + 1.2196, + 1.7708, + 2.6052, + 2.0743, + 3.2687, + 2.1526, + 2.8652, + 1.5579, + 1.6382, + 1.1253, + 2.8251, + 1.916, +) +_WAN_I2V_WORKFLOW = "image2video" +_WAN_FLF_WORKFLOW = "flf2v" +_WAN_CLIP_PROCESSOR_FIELDS = ( + "do_resize", + "size", + "resample", + "do_center_crop", + "crop_size", + "do_rescale", + "rescale_factor", + "do_normalize", + "image_mean", + "image_std", + "do_convert_rgb", + "do_pad", + "pad_size", + "disable_grouping", +) +_WAN_VIDEO_PROCESSOR_FIELDS = ( + "do_resize", + "vae_scale_factor", + "vae_latent_channels", + "resample", + "reducing_gap", + "do_normalize", + "do_binarize", + "do_convert_rgb", + "do_convert_grayscale", +) +_WAN_VIDEO_PROCESSOR_DEFAULTS = ( + ("do_resize", True), + ("vae_scale_factor", 8), + ("vae_latent_channels", 4), + ("resample", "lanczos"), + ("reducing_gap", None), + ("do_normalize", True), + ("do_binarize", False), + ("do_convert_rgb", False), + ("do_convert_grayscale", False), +) +_ISSUER_SEAL = object() +_NOT_PROVIDED = object() +_REGISTRY_LOCK = threading.RLock() + + +class _PipelineBindingKey: + """Identity-only, non-string component-dictionary key.""" + + __slots__ = () + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Modular pipeline bindings cannot be serialized.") + + +_PIPELINE_BINDING_KEY = _PipelineBindingKey() + + +class _IPAdapterStateKey: + """Identity-only key for the process-local IP-Adapter bundle receipt.""" + + __slots__ = () + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("SDXL IP-Adapter state cannot be serialized.") + + +_IP_ADAPTER_STATE_KEY = _IPAdapterStateKey() + + +class _StandaloneComponentBindingKey: + """Identity-only, non-string standalone-component dictionary key.""" + + __slots__ = () + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Standalone component bindings cannot be serialized.") + + +_STANDALONE_COMPONENT_BINDING_KEY = _StandaloneComponentBindingKey() + + +class _PipelineInstanceToken: + """One successful ModelsLoader execution, compared only by identity.""" + + __slots__ = ( + "_model_type", + "_repo_id", + "_repo_source", + "_revision", + "_sealed", + "__weakref__", + ) + + def __init__(self, seal, *, model_type, repo_id, repo_source, revision): + if seal is not _ISSUER_SEAL: + raise TypeError("Pipeline instance tokens are issued only by ModelsLoader.") + object.__setattr__(self, "_model_type", model_type) + object.__setattr__(self, "_repo_id", repo_id) + object.__setattr__(self, "_repo_source", repo_source) + object.__setattr__(self, "_revision", revision) + object.__setattr__(self, "_sealed", True) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Pipeline instance tokens are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Modular pipeline bindings cannot be serialized.") + + +class _ComponentBinding: + """Sealed role and component-id inventory for one loader output port.""" + + __slots__ = ("_pipeline_token", "_role", "_component_ids", "_sealed", "__weakref__") + + def __init__(self, seal, *, pipeline_token, role, component_ids): + if seal is not _ISSUER_SEAL: + raise TypeError("Component bindings are issued only by ModelsLoader.") + object.__setattr__(self, "_pipeline_token", pipeline_token) + object.__setattr__(self, "_role", role) + object.__setattr__(self, "_component_ids", component_ids) + object.__setattr__(self, "_sealed", True) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Component bindings are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Modular component bindings cannot be serialized.") + + +class _StandaloneComponentIssuer: + """One AutoModelLoader instance, compared only by identity.""" + + __slots__ = ("_sealed", "__weakref__") + + def __init__(self, seal): + if seal is not _ISSUER_SEAL: + raise TypeError("Standalone component issuers are created only by AutoModelLoader.") + object.__setattr__(self, "_sealed", True) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Standalone component issuers are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Standalone component issuers cannot be serialized.") + + +class _StandaloneComponentBinding: + """Sealed provenance and publication identity for one standalone component.""" + + __slots__ = ( + "_issuer", + "_component_kind", + "_manager_model_id", + "_repo_source", + "_repo_id", + "_revision", + "_subfolder", + "_class_name", + "_config_fingerprint", + "_sealed", + "__weakref__", + ) + + def __init__( + self, + seal, + *, + issuer, + component_kind, + manager_model_id, + repo_source, + repo_id, + revision, + subfolder, + class_name, + config_fingerprint, + ): + if seal is not _ISSUER_SEAL: + raise TypeError("Standalone component bindings are issued only by AutoModelLoader.") + object.__setattr__(self, "_issuer", issuer) + object.__setattr__(self, "_component_kind", component_kind) + object.__setattr__(self, "_manager_model_id", manager_model_id) + object.__setattr__(self, "_repo_source", repo_source) + object.__setattr__(self, "_repo_id", repo_id) + object.__setattr__(self, "_revision", revision) + object.__setattr__(self, "_subfolder", subfolder) + object.__setattr__(self, "_class_name", class_name) + object.__setattr__(self, "_config_fingerprint", config_fingerprint) + object.__setattr__(self, "_sealed", True) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Standalone component bindings are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Standalone component bindings cannot be serialized.") + + +class _SealedRoutePayload: + """Immutable base for one reviewed pipeline-family route payload.""" + + __slots__ = ("_sealed",) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Modular route payloads are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Modular route payloads cannot be serialized.") + + +class _QwenRoutePayload(_SealedRoutePayload): + """Pinned Qwen generator, mask/overlay, and tensor-pairing state.""" + + __slots__ = ( + "_generator_snapshot", + "_processed_mask_image", + "_mask_overlay_kwargs", + "_inpaint", + "_paired_latents_ref", + "_paired_control_latents_ref", + "_standalone_controlnet_binding", + ) + + def __init__( + self, + *, + generator_snapshot, + processed_mask_image, + mask_overlay_kwargs, + inpaint, + paired_latents_ref, + paired_control_latents_ref=None, + standalone_controlnet_binding=None, + ): + object.__setattr__(self, "_generator_snapshot", generator_snapshot) + object.__setattr__(self, "_processed_mask_image", processed_mask_image) + object.__setattr__(self, "_mask_overlay_kwargs", mask_overlay_kwargs) + object.__setattr__(self, "_inpaint", inpaint) + object.__setattr__(self, "_paired_latents_ref", paired_latents_ref) + object.__setattr__(self, "_paired_control_latents_ref", paired_control_latents_ref) + object.__setattr__(self, "_standalone_controlnet_binding", standalone_controlnet_binding) + object.__setattr__(self, "_sealed", True) + + +class _SdxlRoutePayload(_SealedRoutePayload): + """Pinned SDXL generator, typed latent, and optional crop-overlay state.""" + + __slots__ = ( + "_generator_snapshot", + "_inpaint", + "_paired_latents_ref", + "_mask_ref", + "_masked_image_latents_ref", + "_padding_mask_crop", + "_crops_coords", + "_original_image_snapshot", + "_original_mask_snapshot", + "_vae_ref", + "_vae_latent_channels", + "_vae_scale_factor", + ) + + def __init__( + self, + *, + generator_snapshot, + inpaint, + paired_latents_ref, + mask_ref=None, + masked_image_latents_ref=None, + padding_mask_crop=None, + crops_coords=None, + original_image_snapshot=None, + original_mask_snapshot=None, + vae_ref=None, + vae_latent_channels=None, + vae_scale_factor=None, + ): + object.__setattr__(self, "_generator_snapshot", generator_snapshot) + object.__setattr__(self, "_inpaint", inpaint) + object.__setattr__(self, "_paired_latents_ref", paired_latents_ref) + object.__setattr__(self, "_mask_ref", mask_ref) + object.__setattr__(self, "_masked_image_latents_ref", masked_image_latents_ref) + object.__setattr__(self, "_padding_mask_crop", padding_mask_crop) + object.__setattr__(self, "_crops_coords", crops_coords) + object.__setattr__(self, "_original_image_snapshot", original_image_snapshot) + object.__setattr__(self, "_original_mask_snapshot", original_mask_snapshot) + object.__setattr__(self, "_vae_ref", vae_ref) + object.__setattr__(self, "_vae_latent_channels", vae_latent_channels) + object.__setattr__(self, "_vae_scale_factor", vae_scale_factor) + object.__setattr__(self, "_sealed", True) + + +class _WanRoutePayload(_SealedRoutePayload): + """Pinned Wan split-route media, geometry, tensor, and component state.""" + + __slots__ = ( + "_generator_snapshot", + "_inpaint", + "_paired_latents_ref", + "_image_embeds_ref", + "_image_condition_latents_ref", + "_source_image_ref", + "_source_image_seal", + "_last_image_ref", + "_last_image_seal", + "_workflow", + "_requested_height", + "_requested_width", + "_first_height", + "_first_width", + "_second_height", + "_second_width", + "_num_frames", + "_image_encoder_ref", + "_image_encoder_config_seal", + "_image_processor_ref", + "_image_processor_config_seal", + "_image_encoder_execution_device", + "_vae_ref", + "_video_processor_ref", + "_video_processor_config_seal", + "_vae_config_seal", + "_vae_execution_device", + "_transformer_ref", + "_transformer_config_seal", + ) + + def __init__( + self, + *, + generator_snapshot, + paired_latents_ref, + image_embeds_ref, + image_condition_latents_ref=None, + source_image_ref, + source_image_seal, + last_image_ref, + last_image_seal, + workflow, + requested_height, + requested_width, + first_height, + first_width, + second_height=None, + second_width=None, + num_frames=None, + image_encoder_ref=None, + image_encoder_config_seal=None, + image_processor_ref=None, + image_processor_config_seal=None, + image_encoder_execution_device=None, + vae_ref=None, + video_processor_ref=None, + video_processor_config_seal=None, + vae_config_seal=None, + vae_execution_device=None, + transformer_ref=None, + transformer_config_seal=None, + ): + object.__setattr__(self, "_generator_snapshot", generator_snapshot) + object.__setattr__(self, "_inpaint", False) + object.__setattr__(self, "_paired_latents_ref", paired_latents_ref) + object.__setattr__(self, "_image_embeds_ref", image_embeds_ref) + object.__setattr__(self, "_image_condition_latents_ref", image_condition_latents_ref) + object.__setattr__(self, "_source_image_ref", source_image_ref) + object.__setattr__(self, "_source_image_seal", source_image_seal) + object.__setattr__(self, "_last_image_ref", last_image_ref) + object.__setattr__(self, "_last_image_seal", last_image_seal) + object.__setattr__(self, "_workflow", workflow) + object.__setattr__(self, "_requested_height", requested_height) + object.__setattr__(self, "_requested_width", requested_width) + object.__setattr__(self, "_first_height", first_height) + object.__setattr__(self, "_first_width", first_width) + object.__setattr__(self, "_second_height", second_height) + object.__setattr__(self, "_second_width", second_width) + object.__setattr__(self, "_num_frames", num_frames) + object.__setattr__(self, "_image_encoder_ref", image_encoder_ref) + object.__setattr__(self, "_image_encoder_config_seal", image_encoder_config_seal) + object.__setattr__(self, "_image_processor_ref", image_processor_ref) + object.__setattr__(self, "_image_processor_config_seal", image_processor_config_seal) + object.__setattr__(self, "_image_encoder_execution_device", image_encoder_execution_device) + object.__setattr__(self, "_vae_ref", vae_ref) + object.__setattr__(self, "_video_processor_ref", video_processor_ref) + object.__setattr__(self, "_video_processor_config_seal", video_processor_config_seal) + object.__setattr__(self, "_vae_config_seal", vae_config_seal) + object.__setattr__(self, "_vae_execution_device", vae_execution_device) + object.__setattr__(self, "_transformer_ref", transformer_ref) + object.__setattr__(self, "_transformer_config_seal", transformer_config_seal) + object.__setattr__(self, "_sealed", True) + + +class _ModularRouteState: + """Sealed route envelope with a contract-dispatched opaque payload.""" + + __slots__ = ("_stage", "_binding", "_seed", "_contract", "_payload", "_sealed", "__weakref__") + + def __init__(self, seal, *, stage, binding, seed, contract, payload): + if seal is not _ISSUER_SEAL: + raise TypeError("Modular route states are issued only by the backend runtime.") + expected_payload_type = { + _QWEN_ROUTE_CONTRACT: _QwenRoutePayload, + _SDXL_ROUTE_CONTRACT: _SdxlRoutePayload, + _WAN_ROUTE_CONTRACT: _WanRoutePayload, + }.get(contract) + if expected_payload_type is None or type(payload) is not expected_payload_type: + raise TypeError("Modular route state payload does not match its reviewed contract.") + object.__setattr__(self, "_stage", stage) + object.__setattr__(self, "_binding", binding) + object.__setattr__(self, "_seed", seed) + object.__setattr__(self, "_contract", contract) + object.__setattr__(self, "_payload", payload) + object.__setattr__(self, "_sealed", True) + + @property + def _generator_snapshot(self): + return self._payload._generator_snapshot + + @property + def _processed_mask_image(self): + return getattr(self._payload, "_processed_mask_image", None) + + @property + def _mask_overlay_kwargs(self): + return getattr(self._payload, "_mask_overlay_kwargs", None) + + @property + def _inpaint(self): + return self._payload._inpaint + + @property + def _paired_latents_ref(self): + return self._payload._paired_latents_ref + + @property + def _paired_control_latents_ref(self): + return getattr(self._payload, "_paired_control_latents_ref", None) + + @property + def _standalone_controlnet_binding(self): + return getattr(self._payload, "_standalone_controlnet_binding", None) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("Modular route states are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("Modular route states cannot be serialized.") + + +class _SDXLIPAdapterState: + """Sealed receipt for one exact adapted UNet and its encoded tensors.""" + + __slots__ = ( + "_stage", + "_binding", + "_unet_ref", + "_artifact_identity", + "_image_encoder_ref", + "_image_encoder_seal", + "_feature_extractor_ref", + "_feature_extractor_seal", + "_guider_ref", + "_guider_conditions", + "_unet_seal", + "_scale", + "_image_ref", + "_image_seal", + "_embedding_refs", + "_negative_embedding_refs", + "_sealed", + "__weakref__", + ) + + def __init__( + self, + seal, + *, + binding, + unet, + artifact_identity, + image_encoder, + image_encoder_seal, + feature_extractor, + feature_extractor_seal, + guider, + guider_conditions, + unet_seal, + scale, + image, + image_seal, + embedding_refs, + negative_embedding_refs, + ): + if seal is not _ISSUER_SEAL: + raise TypeError("SDXL IP-Adapter states are issued only by the backend runtime.") + object.__setattr__(self, "_stage", _IP_ADAPTER_TO_DENOISE) + object.__setattr__(self, "_binding", binding) + object.__setattr__(self, "_unet_ref", weakref.ref(unet)) + object.__setattr__(self, "_artifact_identity", artifact_identity) + object.__setattr__(self, "_image_encoder_ref", weakref.ref(image_encoder)) + object.__setattr__(self, "_image_encoder_seal", image_encoder_seal) + object.__setattr__(self, "_feature_extractor_ref", weakref.ref(feature_extractor)) + object.__setattr__(self, "_feature_extractor_seal", feature_extractor_seal) + object.__setattr__(self, "_guider_ref", weakref.ref(guider)) + object.__setattr__(self, "_guider_conditions", guider_conditions) + object.__setattr__(self, "_unet_seal", unet_seal) + object.__setattr__(self, "_scale", scale) + object.__setattr__(self, "_image_ref", weakref.ref(image)) + object.__setattr__(self, "_image_seal", image_seal) + object.__setattr__(self, "_embedding_refs", embedding_refs) + object.__setattr__(self, "_negative_embedding_refs", negative_embedding_refs) + object.__setattr__(self, "_sealed", True) + + def __setattr__(self, _name, _value): + if getattr(self, "_sealed", False): + raise AttributeError("SDXL IP-Adapter states are immutable.") + object.__setattr__(self, _name, _value) + + def __copy__(self): + return self + + def __deepcopy__(self, _memo): + return self + + def __reduce_ex__(self, _protocol): + raise TypeError("SDXL IP-Adapter states cannot be serialized.") + + +_ISSUED_BINDINGS = weakref.WeakSet() +_ISSUED_COMPONENT_BINDINGS = weakref.WeakSet() +_ISSUED_ROUTE_STATES = weakref.WeakSet() +_ISSUED_IP_ADAPTER_STATES = weakref.WeakSet() +_ISSUED_STANDALONE_COMPONENT_ISSUERS = weakref.WeakSet() +_ISSUED_STANDALONE_COMPONENT_BINDINGS = weakref.WeakSet() +_CURRENT_STANDALONE_COMPONENT_PUBLICATIONS = weakref.WeakKeyDictionary() +_CURRENT_STANDALONE_MANAGER_PUBLICATIONS = {} +_CURRENT_SDXL_IP_ADAPTER_STATES = weakref.WeakKeyDictionary() + +_STANDALONE_COMPONENT_KINDS = frozenset({"unet", "transformer", "vae", "controlnet"}) + +_LOADER_OUTPUT_ROLES = { + "unet_out": "denoiser", + "vae_out": "vae", + "text_encoders": "text_encoders", + "scheduler": "scheduler", + "image_encoder": "image_encoder", +} + + +def route_contract_for_model_type(model_type): + """Return the reviewed opaque-state contract for one pipeline class name.""" + + return _ROUTE_CONTRACT_BY_MODEL_TYPE.get(model_type) + + +def route_requires_controlnet_state(model_type): + """Whether generic ControlNet inputs require a preceding opaque Control route.""" + + return route_contract_for_model_type(model_type) == _QWEN_ROUTE_CONTRACT + + +def route_uses_hidden_denoise_mask(model_type): + """Whether Denoise must request Qwen's hidden mask result for route advancement.""" + + return route_contract_for_model_type(model_type) == _QWEN_ROUTE_CONTRACT + + +def _is_issued_binding(value): + with _REGISTRY_LOCK: + return type(value) is _PipelineInstanceToken and value in _ISSUED_BINDINGS + + +def _is_issued_route_state(value): + with _REGISTRY_LOCK: + return type(value) is _ModularRouteState and value in _ISSUED_ROUTE_STATES + + +def _is_issued_component_binding(value): + with _REGISTRY_LOCK: + return type(value) is _ComponentBinding and value in _ISSUED_COMPONENT_BINDINGS + + +def _is_issued_standalone_component_issuer(value): + with _REGISTRY_LOCK: + return type(value) is _StandaloneComponentIssuer and value in _ISSUED_STANDALONE_COMPONENT_ISSUERS + + +def _is_current_standalone_component_binding(value): + with _REGISTRY_LOCK: + if ( + type(value) is not _StandaloneComponentBinding + or value not in _ISSUED_STANDALONE_COMPONENT_BINDINGS + or value._issuer not in _ISSUED_STANDALONE_COMPONENT_ISSUERS + ): + return False + current_ref = _CURRENT_STANDALONE_COMPONENT_PUBLICATIONS.get(value._issuer) + return current_ref is not None and current_ref() is value + + +def _is_lower_hex(value, length): + return type(value) is str and len(value) == length and all(character in "0123456789abcdef" for character in value) + + +def _normalize_standalone_reviewed_identity(reviewed_identity): + if type(reviewed_identity) is not tuple or len(reviewed_identity) != 6: + raise ValueError("A standalone component binding requires one exact reviewed identity tuple.") + repo_source, repo_id, revision, subfolder, class_name, config_fingerprint = reviewed_identity + if type(repo_source) is not str or repo_source not in {"hub", "local"}: + raise ValueError("A standalone component binding requires a reviewed repository source.") + if ( + type(repo_id) is not str + or not repo_id + or repo_id != repo_id.strip() + or len(repo_id) > 4096 + or "\x00" in repo_id + ): + raise ValueError("A standalone component binding requires a reviewed repository identity.") + if repo_source == "hub": + if not _is_lower_hex(revision, 40): + raise ValueError("A standalone Hub component binding requires an immutable revision.") + elif revision is not None: + raise ValueError("A standalone local component binding cannot carry a Hub revision.") + if subfolder is not None: + if type(subfolder) is not str or len(subfolder) > 512 or "\\" in subfolder or "\x00" in subfolder: + raise ValueError("A standalone component binding requires a normalized subfolder.") + parts = subfolder.split("/") + if not subfolder or subfolder.startswith("/") or any(part in ("", ".", "..") or ":" in part for part in parts): + raise ValueError("A standalone component binding requires a normalized subfolder.") + if type(class_name) is not str or not class_name or not class_name.isascii() or not class_name.isidentifier(): + raise ValueError("A standalone component binding requires a reviewed Diffusers class name.") + if not _is_lower_hex(config_fingerprint, 64): + raise ValueError("A standalone component binding requires a reviewed config fingerprint.") + return reviewed_identity + + +def _standalone_binding_reviewed_identity(binding): + return ( + binding._repo_source, + binding._repo_id, + binding._revision, + binding._subfolder, + binding._class_name, + binding._config_fingerprint, + ) + + +def _forget_standalone_manager_publication(manager_model_id, binding_ref): + with _REGISTRY_LOCK: + if _CURRENT_STANDALONE_MANAGER_PUBLICATIONS.get(manager_model_id) is binding_ref: + _CURRENT_STANDALONE_MANAGER_PUBLICATIONS.pop(manager_model_id, None) + + +def _validate_standalone_component_metadata(component, binding, *, label): + expected = { + "model_id": binding._manager_model_id, + "repo_source": binding._repo_source, + "repo_id": binding._repo_id, + "revision": binding._revision, + "class_name": binding._class_name, + } + for field, expected_value in expected.items(): + if ( + field not in component + or component.get(field) != expected_value + or type(component.get(field)) is not type(expected_value) + ): + raise ValueError( + f"Connected standalone Diffusers {label} does not match its backend-issued provenance binding." + ) + if component.get("trust_remote_code") is not False: + raise ValueError( + f"Connected standalone Diffusers {label} does not match its backend-issued provenance binding." + ) + + +def issue_standalone_component_issuer(): + """Mint one process-local publication issuer for an AutoModelLoader instance.""" + + issuer = _StandaloneComponentIssuer(_ISSUER_SEAL) + with _REGISTRY_LOCK: + _ISSUED_STANDALONE_COMPONENT_ISSUERS.add(issuer) + return issuer + + +def standalone_component_reuse_is_bound(*, manager_model_id, component_kind, reviewed_identity): + """Return whether a resident manager entry has this exact reviewed provenance.""" + + if type(manager_model_id) is not str or not manager_model_id: + return False + if type(component_kind) is not str or component_kind not in _STANDALONE_COMPONENT_KINDS: + return False + reviewed_identity = _normalize_standalone_reviewed_identity(reviewed_identity) + with _REGISTRY_LOCK: + binding_ref = _CURRENT_STANDALONE_MANAGER_PUBLICATIONS.get(manager_model_id) + binding = binding_ref() if binding_ref is not None else None + if type(binding) is not _StandaloneComponentBinding or binding not in _ISSUED_STANDALONE_COMPONENT_BINDINGS: + if binding_ref is not None: + _CURRENT_STANDALONE_MANAGER_PUBLICATIONS.pop(manager_model_id, None) + return False + return ( + binding._manager_model_id == manager_model_id + and binding._component_kind == component_kind + and _standalone_binding_reviewed_identity(binding) == reviewed_identity + ) + + +def bind_standalone_component_output( + component, + *, + issuer, + component_kind, + reviewed_identity, +): + """Publish and seal one reviewed standalone ComponentsManager payload.""" + + if not _is_issued_standalone_component_issuer(issuer): + raise ValueError("AutoModelLoader received an invalid standalone component issuer.") + if type(component) is not dict: + raise ValueError("AutoModelLoader can bind only an exact ComponentsManager metadata dictionary.") + if _STANDALONE_COMPONENT_BINDING_KEY in component: + raise ValueError("AutoModelLoader cannot republish an already-bound component payload.") + if type(component_kind) is not str or component_kind not in _STANDALONE_COMPONENT_KINDS: + raise ValueError("AutoModelLoader received an invalid standalone component kind.") + reviewed_identity = _normalize_standalone_reviewed_identity(reviewed_identity) + repo_source, repo_id, revision, subfolder, class_name, config_fingerprint = reviewed_identity + manager_model_id = component.get("model_id") + if ( + type(manager_model_id) is not str + or not manager_model_id + or len(manager_model_id) > 4096 + or "\x00" in manager_model_id + ): + raise ValueError("AutoModelLoader publication is missing its managed component identity.") + + expected_metadata = { + "repo_source": repo_source, + "repo_id": repo_id, + "revision": revision, + "class_name": class_name, + } + for field, expected_value in expected_metadata.items(): + if ( + field not in component + or component.get(field) != expected_value + or type(component.get(field)) is not type(expected_value) + ): + raise ValueError("AutoModelLoader publication does not match its reviewed component identity.") + if component.get("trust_remote_code") is not False: + raise ValueError("AutoModelLoader publication must keep repository code disabled.") + + with _REGISTRY_LOCK: + if issuer not in _ISSUED_STANDALONE_COMPONENT_ISSUERS: + raise ValueError("AutoModelLoader standalone component issuer is no longer valid.") + resident_ref = _CURRENT_STANDALONE_MANAGER_PUBLICATIONS.get(manager_model_id) + resident_binding = resident_ref() if resident_ref is not None else None + if resident_binding is not None and ( + resident_binding._component_kind != component_kind + or _standalone_binding_reviewed_identity(resident_binding) != reviewed_identity + ): + raise ValueError( + "AutoModelLoader cannot republish a resident component under a different reviewed identity." + ) + binding = _StandaloneComponentBinding( + _ISSUER_SEAL, + issuer=issuer, + component_kind=component_kind, + manager_model_id=manager_model_id, + repo_source=repo_source, + repo_id=repo_id, + revision=revision, + subfolder=subfolder, + class_name=class_name, + config_fingerprint=config_fingerprint, + ) + _ISSUED_STANDALONE_COMPONENT_BINDINGS.add(binding) + component[_STANDALONE_COMPONENT_BINDING_KEY] = binding + _CURRENT_STANDALONE_COMPONENT_PUBLICATIONS[issuer] = weakref.ref(binding) + binding_ref = weakref.ref( + binding, + lambda dead_ref, model_id=manager_model_id: _forget_standalone_manager_publication( + model_id, + dead_ref, + ), + ) + _CURRENT_STANDALONE_MANAGER_PUBLICATIONS[manager_model_id] = binding_ref + return component + + +def require_standalone_component_binding( + component, + *, + label, + expected_kind=None, + expected_binding=None, + expected_issuer=None, + expected_reviewed_identity=None, +): + """Require one current, exact AutoModelLoader component publication.""" + + if type(component) is not dict: + raise ValueError(f"Connected standalone Diffusers {label} is not a managed component payload.") + binding = component.get(_STANDALONE_COMPONENT_BINDING_KEY) + if type(binding) is not _StandaloneComponentBinding: + raise ValueError( + f"Connected standalone Diffusers {label} is missing its process-local AutoModelLoader binding. " + "Rerun the Load Model node and reconnect the component." + ) + with _REGISTRY_LOCK: + issued = ( + binding in _ISSUED_STANDALONE_COMPONENT_BINDINGS + and binding._issuer in _ISSUED_STANDALONE_COMPONENT_ISSUERS + ) + if not issued: + raise ValueError( + f"Connected standalone Diffusers {label} is missing its process-local AutoModelLoader binding. " + "Rerun the Load Model node and reconnect the component." + ) + if not _is_current_standalone_component_binding(binding): + raise ValueError( + f"Connected standalone Diffusers {label} is no longer the current AutoModelLoader publication. " + "Rerun the Load Model node and reconnect the component." + ) + + _validate_standalone_component_metadata(component, binding, label=label) + if expected_kind is not None and binding._component_kind != expected_kind: + raise ValueError( + f"Connected standalone Diffusers {label} is a '{binding._component_kind}' component, " + f"not '{expected_kind}'." + ) + if expected_binding is not None and binding is not expected_binding: + raise ValueError(f"Connected standalone Diffusers {label} comes from a different component publication.") + if expected_issuer is not None and binding._issuer is not expected_issuer: + raise ValueError(f"Connected standalone Diffusers {label} comes from a different Load Model node.") + if expected_reviewed_identity is not None: + expected_reviewed_identity = _normalize_standalone_reviewed_identity(expected_reviewed_identity) + if _standalone_binding_reviewed_identity(binding) != expected_reviewed_identity: + raise ValueError( + f"Connected standalone Diffusers {label} does not match the current reviewed component identity." + ) + return binding + + +def require_sdxl_controlnet_component_binding(component, *, union=False, expected_binding=None): + """Require one exact ordinary or Union SDXL ControlNet contract. + + ControlNet Union uses the same generic graph port, so the process-local + reviewed class identity is the authority that keeps the still-dormant + Union route from being admitted through the ordinary ControlNet path. + """ + + binding = require_standalone_component_binding( + component, + label="ControlNet model", + expected_kind="controlnet", + expected_binding=expected_binding, + ) + if type(union) is not bool: + raise TypeError("SDXL ControlNet variant selection must be a boolean.") + expected_class = "ControlNetUnionModel" if union else "ControlNetModel" + if binding._class_name != expected_class: + variant = "Union" if union else "ordinary" + raise ValueError(f"SDXL {variant} ControlNet execution requires an exact {expected_class} component.") + return binding + + +def _component_id_inventory(component): + pending = [(component, "", 0)] + visited = set() + values = [] + value_count = 0 + while pending: + value, path, depth = pending.pop() + value_count += 1 + if value_count > 2048 or depth > 32: + raise ValueError("A ModelsLoader component payload exceeds the safe nested-value limit.") + if isinstance(value, dict): + value_id = id(value) + if value_id in visited: + continue + visited.add(value_id) + if len(value) > 512: + raise ValueError("A ModelsLoader component payload exceeds the safe container-size limit.") + for key, nested in value.items(): + if key is _PIPELINE_BINDING_KEY or key is _STANDALONE_COMPONENT_BINDING_KEY: + continue + nested_path = f"{path}.{key}" if path else str(key) + if key == "model_id": + if not isinstance(nested, str) or not nested: + raise ValueError("A ModelsLoader component payload contains an invalid model_id.") + values.append((nested_path, nested)) + elif isinstance(nested, (dict, list, tuple)): + pending.append((nested, nested_path, depth + 1)) + elif isinstance(value, (list, tuple)): + if len(value) > 512: + raise ValueError("A ModelsLoader component payload exceeds the safe container-size limit.") + pending.extend((nested, f"{path}[{index}]", depth + 1) for index, nested in enumerate(value)) + if not values: + raise ValueError("A ModelsLoader component payload is missing its managed model_id.") + return tuple(sorted(values)) + + +def issue_pipeline_instance_token(*, model_type, repo_id, repo_source, revision): + """Mint the identity token for one successful ModelsLoader execution.""" + + if not isinstance(model_type, str) or not model_type: + raise ValueError("A pipeline instance binding requires a model type.") + if not isinstance(repo_id, str) or not repo_id: + raise ValueError("A pipeline instance binding requires a repository identity.") + if not isinstance(repo_source, str) or not repo_source: + raise ValueError("A pipeline instance binding requires a repository source.") + if revision is not None and not isinstance(revision, str): + raise ValueError("A pipeline instance binding revision must be a string or null.") + token = _PipelineInstanceToken( + _ISSUER_SEAL, + model_type=model_type, + repo_id=repo_id, + repo_source=repo_source, + revision=revision, + ) + with _REGISTRY_LOCK: + _ISSUED_BINDINGS.add(token) + return token + + +def bind_loader_outputs(loaded_components, token): + """Attach one private binding to every top-level ModelsLoader output.""" + + if not _is_issued_binding(token): + raise ValueError("ModelsLoader received an invalid pipeline instance binding.") + for output_name, value in loaded_components.items(): + if isinstance(value, dict): + role = _LOADER_OUTPUT_ROLES.get(output_name) + if role is None: + raise ValueError(f"ModelsLoader cannot bind unknown component output '{output_name}'.") + component_binding = _ComponentBinding( + _ISSUER_SEAL, + pipeline_token=token, + role=role, + component_ids=_component_id_inventory(value), + ) + with _REGISTRY_LOCK: + _ISSUED_COMPONENT_BINDINGS.add(component_binding) + value[_PIPELINE_BINDING_KEY] = component_binding + return loaded_components + + +def _validate_component_metadata(component, token, *, label): + expected = { + "model_type": token._model_type, + "repo_id": token._repo_id, + "repo_source": token._repo_source, + "revision": token._revision, + } + for field, expected_value in expected.items(): + if component.get(field) != expected_value: + raise ValueError(f"Connected Modular Diffusers {label} does not match its backend-issued loader binding.") + + +def require_component_binding( + component, + *, + label, + expected_model_type=None, + expected_token=None, + expected_role=None, +): + """Validate one authoritative component dictionary and return its token.""" + + if not isinstance(component, dict): + raise ValueError(f"Connected Modular Diffusers {label} is not a managed component payload.") + component_binding = component.get(_PIPELINE_BINDING_KEY) + if not _is_issued_component_binding(component_binding): + raise ValueError( + f"Connected Modular Diffusers {label} is missing its process-local ModelsLoader binding. " + "Rerun the Models Loader and reconnect the component." + ) + token = component_binding._pipeline_token + if not _is_issued_binding(token): + raise ValueError(f"Connected Modular Diffusers {label} has an invalid loader execution binding.") + _validate_component_metadata(component, token, label=label) + if _component_id_inventory(component) != component_binding._component_ids: + raise ValueError(f"Connected Modular Diffusers {label} model identity changed after ModelsLoader publication.") + if expected_role is not None and component_binding._role != expected_role: + raise ValueError( + f"Connected Modular Diffusers {label} came from loader role '{component_binding._role}', " + f"not '{expected_role}'." + ) + if expected_model_type is not None and token._model_type != expected_model_type: + raise ValueError( + f"Connected Modular Diffusers {label} belongs to '{token._model_type}', not '{expected_model_type}'." + ) + if expected_token is not None and token is not expected_token: + raise ValueError(f"Connected Modular Diffusers {label} comes from a different Models Loader execution.") + return token + + +def require_matching_token_bearers(value, expected_token, *, label): + """Reject every nested managed component issued by another loader run.""" + + max_depth = 32 + max_values = 2048 + max_container_items = 512 + visited = set() + pending = [(value, 0)] + value_count = 0 + if not _is_issued_binding(expected_token): + raise ValueError("The authoritative Modular Diffusers loader binding is invalid.") + while pending: + item, depth = pending.pop() + value_count += 1 + if value_count > max_values or depth > max_depth: + raise ValueError(f"Connected Modular Diffusers {label} exceeds the safe nested-value limit.") + if isinstance(item, dict): + item_id = id(item) + if item_id in visited: + continue + visited.add(item_id) + if len(item) > max_container_items: + raise ValueError(f"Connected Modular Diffusers {label} exceeds the safe container-size limit.") + if _PIPELINE_BINDING_KEY in item: + require_component_binding( + item, + label=label, + expected_model_type=expected_token._model_type, + expected_token=expected_token, + ) + for key, nested in item.items(): + if key is not _PIPELINE_BINDING_KEY and key is not _STANDALONE_COMPONENT_BINDING_KEY: + pending.append((nested, depth + 1)) + elif isinstance(item, (list, tuple)): + if len(item) > max_container_items: + raise ValueError(f"Connected Modular Diffusers {label} exceeds the safe container-size limit.") + pending.extend((nested, depth + 1) for nested in item) + + +def require_route_state_shape_before_identity_resolution(kwargs): + """Reject serialized or misplaced route values before model identity scanning.""" + + route_value = kwargs.get(ROUTE_STATE_INPUT) + if route_value is not None and type(route_value) is not _ModularRouteState: + raise ValueError( + "Modular route state must be the opaque value emitted by the preceding backend action; " + "serialized mappings and user-provided values are not accepted." + ) + + pending = [(value, 0) for name, value in kwargs.items() if name != ROUTE_STATE_INPUT] + visited = set() + value_count = 0 + while pending: + value, depth = pending.pop() + value_count += 1 + if value_count > 2048 or depth > 32: + raise ValueError("Connected Modular Diffusers inputs exceed the safe nested-value limit.") + if type(value) is _ModularRouteState: + raise ValueError("A Modular route state was connected to an undeclared graph field.") + if isinstance(value, Mapping): + value_id = id(value) + if value_id in visited: + continue + visited.add(value_id) + if len(value) > 512: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") + pending.extend((nested, depth + 1) for nested in value.values()) + elif isinstance(value, (list, tuple)): + if len(value) > 512: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") + pending.extend((nested, depth + 1) for nested in value) + + +def reject_route_reserved_inputs_before_identity_resolution(kwargs, *, allowed_direct_inputs=()): + """Bound and reject route-owned names before recursive model recovery.""" + + allowed_direct_inputs = frozenset(allowed_direct_inputs) + pending = [(kwargs, 0)] + visited = set() + value_count = 0 + while pending: + value, depth = pending.pop() + value_count += 1 + if value_count > 2048 or depth > 32: + raise ValueError("Connected Modular Diffusers inputs exceed the safe nested-value limit.") + if isinstance(value, Mapping): + value_id = id(value) + if value_id in visited: + continue + visited.add(value_id) + if len(value) > 512: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") + collision = ROUTE_RESERVED_PIPELINE_INPUTS.intersection(value) + if depth == 0: + collision -= allowed_direct_inputs + if collision: + raise ValueError( + "Modular route fields are backend-managed and cannot participate in model identity: " + + ", ".join(sorted(collision)) + ) + for nested in value.values(): + if type(nested) is not _ModularRouteState: + pending.append((nested, depth + 1)) + elif isinstance(value, (list, tuple)): + if len(value) > 512: + raise ValueError("Connected Modular Diffusers inputs exceed the safe container-size limit.") + pending.extend((nested, depth + 1) for nested in value) + + +def validate_route_field_contract(kwargs, node_config): + """Enforce the selected backend schema after identity recovery.""" + + route_value = kwargs.get(ROUTE_STATE_INPUT) + declared = ROUTE_STATE_INPUT in node_config.get("input_names", ()) + if route_value is not None and not declared: + raise ValueError( + "The selected Modular Diffusers action does not declare route state. " + "Remove the stale route-state edge and reconnect the current model workflow." + ) + if route_value is not None and not _is_issued_route_state(route_value): + raise ValueError("The Modular route state was not issued by this backend process.") + + +def _route_reserved_inputs(*, model_type, action): + contract = route_contract_for_model_type(model_type) + if contract != _SDXL_ROUTE_CONTRACT: + return ROUTE_RESERVED_PIPELINE_INPUTS, ROUTE_RESERVED_PIPELINE_INPUTS + common = frozenset({"generator", "processed_mask_image", "mask_overlay_kwargs"}) + if action == "denoise": + return common | {"crops_coords"}, common | {"crops_coords", "mask", "masked_image_latents"} + if action == "decoder": + decode_owned = {"image", "mask_image", "padding_mask_crop", "crops_coords"} + return common | decode_owned, common | decode_owned + return common, common + + +def reject_route_reserved_inputs(kwargs, *, bundle_names=(), model_type=None, action=None): + """Prevent graph values or generic bundles from overwriting sealed route data.""" + + direct_reserved, bundle_reserved = _route_reserved_inputs(model_type=model_type, action=action) + for name in direct_reserved: + if kwargs.get(name) is not None: + raise ValueError(f"Modular route input '{name}' is backend-managed and cannot be supplied directly.") + for bundle_name in bundle_names: + bundle = kwargs.get(bundle_name) + if not isinstance(bundle, Mapping): + continue + collision = bundle_reserved.intersection(bundle) + if collision: + raise ValueError( + f"Modular input bundle '{bundle_name}' cannot overwrite backend-managed route fields: " + + ", ".join(sorted(collision)) + ) + + +def route_cache_params_equal(previous, current, *, fallback): + """Compare route-bearing cache inputs without value-comparing tensors. + + A route authenticates exact tensor objects, while the default node cache + deliberately treats equal-valued tensors as unchanged. Check tensor + identity first whenever either input set carries a route, then retain the + established comparison for every other value. Rewrapped builtin lists + containing the same tensor objects remain cacheable. + """ + + if not isinstance(previous, dict) or not isinstance(current, dict): + return fallback(previous, current) + if ROUTE_STATE_INPUT not in previous and ROUTE_STATE_INPUT not in current: + return fallback(previous, current) + + pending = [(previous, current, 0)] + visited = set() + value_count = 0 + while pending: + left, right, depth = pending.pop() + value_count += 1 + if value_count > 2048 or depth > 32: + return False + if type(left) is not type(right): + return False + if type(left) is torch.Tensor: + if left is not right: + return False + continue + if type(left) is dict: + pair = (id(left), id(right)) + if pair in visited: + continue + visited.add(pair) + if len(left) > 512 or len(right) > 512 or set(left) != set(right): + return False + pending.extend((left[key], right[key], depth + 1) for key in left) + elif type(left) in (list, tuple): + pair = (id(left), id(right)) + if pair in visited: + continue + visited.add(pair) + if len(left) > 512 or len(left) != len(right): + return False + pending.extend((left_item, right_item, depth + 1) for left_item, right_item in zip(left, right)) + + return fallback(previous, current) + + +def effective_modular_block_input(kwargs, *, node_input_names, block_input_names, target_name): + """Resolve one effective input using the action's generic bundle-flattening rules.""" + + block_input_names = set(block_input_names) + effective_value = None + for input_name in node_input_names: + if input_name not in kwargs: + continue + value = kwargs.get(input_name) + if isinstance(value, dict) and input_name not in block_input_names: + if target_name in block_input_names and target_name in value: + effective_value = value[target_name] + elif input_name == target_name and input_name in block_input_names: + effective_value = value + return effective_value + + +def _clone_generator(generator): + if not isinstance(generator, torch.Generator): + raise TypeError("Modular route generation requires a Torch generator.") + clone_state = getattr(generator, "clone_state", None) + if callable(clone_state): + return clone_state() + clone = torch.Generator(device=generator.device) + clone.set_state(generator.get_state().clone()) + return clone + + +def _devices_compatible(left, right): + left_device = torch.device(left) + right_device = torch.device(right) + if left_device.type != right_device.type: + return False + if left_device.type == "cpu": + return True + return left_device.index is None or right_device.index is None or left_device.index == right_device.index + + +def _wan_execution_device(value, *, label): + if value is None: + raise ValueError(f"Wan {label} execution device is unavailable.") + try: + device = torch.device(value) + except (TypeError, RuntimeError) as error: + raise ValueError(f"Wan {label} execution device is invalid.") from error + if device.type == "meta": + raise ValueError(f"Wan {label} execution device must be resident.") + return device + + +def _require_wan_tensor_device(tensor, execution_device, *, label): + device = _wan_execution_device(execution_device, label=label) + if type(tensor) is not torch.Tensor or not _devices_compatible(tensor.device, device): + raise ValueError(f"{label} must be resident on the producing Wan execution device.") + return device + + +def _require_wan_reference_device(reference, execution_device, *, label): + device = _wan_execution_device(execution_device, label=label) + _issued_kind, issued_refs = reference + for issued_ref, issued_seal in issued_refs: + issued_item = issued_ref() + if issued_item is None: + raise ValueError(f"The {label} paired with this Modular route state is no longer resident.") + _require_tensor_seal(issued_item, issued_seal, label=label) + if not _devices_compatible(issued_item.device, device): + raise ValueError(f"{label} must remain on its producing Wan execution device.") + return device + + +def _new_route_state(*, stage, binding, seed, contract, payload): + state = _ModularRouteState( + _ISSUER_SEAL, + stage=stage, + binding=binding, + seed=seed, + contract=contract, + payload=payload, + ) + with _REGISTRY_LOCK: + _ISSUED_ROUTE_STATES.add(state) + return state + + +def _validate_sdxl_overlay_media_pair(original_image, original_mask): + for label, value in (("SDXL original image", original_image), ("SDXL original mask", original_mask)): + if not isinstance(value, Image.Image): + raise TypeError(f"{label} must be a PIL image when mask crop overlay is enabled.") + width, height = value.size + if ( + type(width) is not int + or type(height) is not int + or width <= 0 + or height <= 0 + or width > _MAX_OVERLAY_EDGE_PIXELS + or height > _MAX_OVERLAY_EDGE_PIXELS + ): + raise ValueError( + f"{label} dimensions must be positive and no larger than {_MAX_OVERLAY_EDGE_PIXELS} pixels per edge." + ) + if original_image.size != original_mask.size: + raise ValueError("SDXL crop overlay image and mask dimensions must match exactly.") + aggregate_pixels = original_image.width * original_image.height + original_mask.width * original_mask.height + if aggregate_pixels > _MAX_OVERLAY_AGGREGATE_PIXELS: + raise ValueError( + "SDXL crop overlay image and mask exceed the cumulative 16-Mi-pixel snapshot limit." + ) + + +def validate_sdxl_crop_overlay_inputs(padding_mask_crop, original_image, original_mask): + """Fail closed on untrusted crop media before any SDXL block is initialized.""" + + if padding_mask_crop is None: + return + if ( + type(padding_mask_crop) is not int + or padding_mask_crop < 0 + or padding_mask_crop > _MAX_MASK_CROP_PADDING + ): + raise ValueError( + f"SDXL mask crop padding must be a canonical integer from 0 through {_MAX_MASK_CROP_PADDING}, or null." + ) + if original_mask is None: + raise ValueError("SDXL mask crop padding requires a mask image.") + _validate_sdxl_overlay_media_pair(original_image, original_mask) + + +def _freeze_sdxl_overlay_media(original_image, original_mask, *, crops_coords): + """Validate pinned PIL overlay geometry and copy within one cumulative limit.""" + + _validate_sdxl_overlay_media_pair(original_image, original_mask) + x1, y1, x2, y2 = crops_coords + if not (0 <= x1 < x2 <= original_image.width and 0 <= y1 < y2 <= original_image.height): + raise ValueError("SDXL crop coordinates must describe a nonempty region within the original image and mask.") + return ("pil", original_image.copy()), ("pil", original_mask.copy()) + + +def _materialize_overlay_media(snapshot): + kind, value = snapshot + if kind == "pil": + return value.copy() + raise ValueError("The sealed Modular overlay snapshot has an invalid media kind.") + + +def sdxl_vae_geometry_from_component(vae): + """Read the bounded latent geometry used by pinned SDXL blocks.""" + + config = getattr(vae, "config", None) + latent_channels = getattr(config, "latent_channels", None) + block_out_channels = getattr(config, "block_out_channels", None) + if type(latent_channels) is not int or latent_channels != 4: + raise ValueError("The connected SDXL VAE must declare the pinned four-channel latent contract.") + if ( + type(block_out_channels) not in (list, tuple) + or not block_out_channels + or len(block_out_channels) > 16 + ): + raise ValueError("The connected SDXL VAE has an invalid scale-factor contract.") + scale_factor = 2 ** (len(block_out_channels) - 1) + return latent_channels, scale_factor + + +def _canonical_wan_dimension(value, *, label): + if type(value) is not int or not 1 <= value <= _MAX_WAN_DIMENSION: + raise ValueError( + f"Wan {label} must be a canonical integer from 1 through {_MAX_WAN_DIMENSION}." + ) + return value + + +def _validate_wan_requested_dimensions(height, width): + height = _canonical_wan_dimension(height, label="height") + width = _canonical_wan_dimension(width, label="width") + if height * width > _MAX_WAN_REQUEST_AREA: + raise ValueError("Wan requested dimensions exceed the bounded 16-Mi-pixel area budget.") + return height, width + + +def _validate_wan_source_pair(image, last_image): + values = (("source image", image),) if last_image is None else ( + ("source image", image), + ("last image", last_image), + ) + aggregate_pixels = 0 + for label, value in values: + if type(value) is not Image.Image: + raise TypeError(f"Wan {label} must be one exact PIL Image value.") + width, height = value.size + if ( + type(width) is not int + or type(height) is not int + or width <= 0 + or height <= 0 + or width > _MAX_WAN_DIMENSION + or height > _MAX_WAN_DIMENSION + ): + raise ValueError( + f"Wan {label} dimensions must be positive and no larger than {_MAX_WAN_DIMENSION} pixels per edge." + ) + bands = value.getbands() + if type(bands) is not tuple or not 1 <= len(bands) <= 4: + raise ValueError(f"Wan {label} has an unsupported pixel-band contract.") + if width * height * 4 > _MAX_WAN_SOURCE_BYTES: + raise ValueError(f"Wan {label} exceeds the bounded 64-MiB pixel snapshot budget.") + aggregate_pixels += width * height + if aggregate_pixels > _MAX_OVERLAY_AGGREGATE_PIXELS: + raise ValueError("Wan source and last images exceed the cumulative 16-Mi-pixel route budget.") + return _WAN_FLF_WORKFLOW if last_image is not None else _WAN_I2V_WORKFLOW + + +def _wan_image_seal(image, *, label): + try: + image.load() + # Hash a bounded canonical rendering so palette/transparency changes + # cannot preserve the same index bytes while changing decoded pixels. + pixels = image.convert("RGBA").tobytes() + except Exception as error: + raise ValueError(f"Wan {label} pixels could not be sealed before execution.") from error + if len(pixels) > _MAX_WAN_SOURCE_BYTES: + raise ValueError(f"Wan {label} exceeds the bounded 64-MiB pixel snapshot budget.") + return (image.mode, image.size, image.getbands(), hashlib.sha256(pixels).digest()) + + +def snapshot_wan_source_media(image, last_image=None): + """Seal bounded exact source objects before a split Wan image action.""" + + workflow = _validate_wan_source_pair(image, last_image) + source_ref = _component_identity_reference(image, label="Wan source image") + last_ref = ( + _component_identity_reference(last_image, label="Wan last image") + if last_image is not None + else None + ) + return ( + workflow, + source_ref, + _wan_image_seal(image, label="source image"), + last_ref, + _wan_image_seal(last_image, label="last image") if last_image is not None else None, + ) + + +def _require_wan_media_snapshot(snapshot, image, last_image): + if type(snapshot) is not tuple or len(snapshot) != 5: + raise ValueError("Wan source media is missing its bounded pre-execution snapshot.") + workflow, source_ref, source_seal, last_ref, last_seal = snapshot + if workflow != _validate_wan_source_pair(image, last_image): + raise ValueError("Wan source media changed between I2V and FLF routing semantics.") + issued_source = source_ref() if isinstance(source_ref, weakref.ReferenceType) else None + if issued_source is not image: + raise ValueError("Wan source image is not the exact object sealed for this route.") + if _wan_image_seal(image, label="source image") != source_seal: + raise ValueError("Wan source image pixels changed after route snapshotting.") + if last_image is None: + if last_ref is not None or last_seal is not None: + raise ValueError("Wan I2V source state unexpectedly contains a last image.") + else: + issued_last = last_ref() if isinstance(last_ref, weakref.ReferenceType) else None + if issued_last is not last_image: + raise ValueError("Wan last image is not the exact object sealed for this route.") + if _wan_image_seal(last_image, label="last image") != last_seal: + raise ValueError("Wan last-image pixels changed after route snapshotting.") + return snapshot + + +def _wan_payload_media_snapshot(payload): + return ( + payload._workflow, + payload._source_image_ref, + payload._source_image_seal, + payload._last_image_ref, + payload._last_image_seal, + ) + + +def _validate_wan_resized_media( + *, + workflow, + resized_image, + resized_last_image, + height, + width, + expected_last_size, + stage, +): + if type(resized_image) is not Image.Image or resized_image.size != (width, height): + raise ValueError(f"Pinned Wan {stage} did not publish the exact resized source geometry.") + if workflow == _WAN_FLF_WORKFLOW: + if ( + type(expected_last_size) is not tuple + or len(expected_last_size) != 2 + or type(resized_last_image) is not Image.Image + or resized_last_image.size != expected_last_size + ): + raise ValueError(f"Pinned Wan FLF {stage} did not publish the exact resized last-image geometry.") + elif expected_last_size is not None or resized_last_image is not None: + raise ValueError(f"Pinned Wan I2V {stage} unexpectedly published a resized last image.") + + +def _validate_wan_intermediate_dimensions(height, width, *, label): + if ( + type(height) is not int + or type(width) is not int + or height <= 0 + or width <= 0 + or height > _MAX_WAN_DIMENSION + or width > _MAX_WAN_DIMENSION + ): + raise ValueError( + f"Wan {label} must resolve to positive edges no larger than {_MAX_WAN_DIMENSION} pixels." + ) + if height * width > _MAX_OVERLAY_AGGREGATE_PIXELS: + raise ValueError(f"Wan {label} exceeds the bounded 16-Mi-pixel intermediate budget.") + return height, width + + +def _wan_clip_resize_dimensions(height, width, *, workflow): + _validate_wan_intermediate_dimensions(height, width, label="CLIP source intermediate") + if workflow == _WAN_FLF_WORKFLOW: + # The distinct official FLF artifact uses shortest_edge=224. Keep its + # preparatory state contract exact even though that artifact is not an + # executable/cataloged MoDiff dependency yet. + short_edge = min(height, width) + long_edge = max(height, width) + resized_short = _WAN_IMAGE_SIZE + resized_long = int(_WAN_IMAGE_SIZE * long_edge / short_edge) + if height <= width: + resized_height, resized_width = resized_short, resized_long + else: + resized_height, resized_width = resized_long, resized_short + return _validate_wan_intermediate_dimensions( + resized_height, + resized_width, + label="FLF CLIP resize intermediate", + ) + if workflow != _WAN_I2V_WORKFLOW: + raise ValueError("Wan CLIP resize received an unknown workflow contract.") + # The cataloged I2V repository publishes explicit height/width dimensions, + # so Transformers resizes directly to 224x224. + return _validate_wan_intermediate_dimensions( + _WAN_IMAGE_SIZE, + _WAN_IMAGE_SIZE, + label="CLIP resize intermediate", + ) + + +def _wan_last_image_intermediate_dimensions(last_image, *, height, width, stage): + if last_image is None: + return None + resize_ratio = max(width / last_image.width, height / last_image.height) + resized_width = round(last_image.width * resize_ratio) + resized_height = round(last_image.height * resize_ratio) + _validate_wan_intermediate_dimensions( + resized_height, + resized_width, + label=f"FLF {stage} last-image intermediate", + ) + return resized_height, resized_width + + +def wan_area_budget_dimensions(image, height, width): + """Return the exact pinned Wan resize result for one area-budget pass.""" + + _validate_wan_source_pair(image, None) + height, width = _validate_wan_requested_dimensions(height, width) + aspect_ratio = image.height / image.width + mod_value = _WAN_SPATIAL_SCALE * _WAN_PATCH_SIZE_SPATIAL + resolved_height = round(math.sqrt(height * width * aspect_ratio)) // mod_value * mod_value + resolved_width = round(math.sqrt(height * width / aspect_ratio)) // mod_value * mod_value + if resolved_height <= 0 or resolved_width <= 0: + raise ValueError("Wan area-budget resize resolved to an empty dimension.") + return _validate_wan_intermediate_dimensions( + resolved_height, + resolved_width, + label="area-budget resize", + ) + + +def preflight_wan_image_encoder_inputs(*, image, last_image, height, width): + """Bound both first-pass Wan/FLF resizes before pipeline initialization.""" + + workflow = _validate_wan_source_pair(image, last_image) + requested_height, requested_width = _validate_wan_requested_dimensions(height, width) + first_height, first_width = wan_area_budget_dimensions(image, requested_height, requested_width) + clip_height, clip_width = _wan_clip_resize_dimensions( + first_height, + first_width, + workflow=workflow, + ) + last_intermediate = _wan_last_image_intermediate_dimensions( + last_image, + height=first_height, + width=first_width, + stage="image-encoder", + ) + return ( + workflow, + requested_height, + requested_width, + first_height, + first_width, + clip_height, + clip_width, + last_intermediate, + ) + + +def require_cataloged_wan_action_source(*, image, last_image, binding): + """Bind the selected Wan route to its exact reviewed loader artifact.""" + + workflow = _validate_wan_source_pair(image, last_image) + if not _is_issued_binding(binding) or binding._model_type != "WanImage2VideoModularPipeline": + raise ValueError("Wan image execution requires the current Models Loader publication.") + expected_repository = dict(WAN_WORKFLOW_REPOSITORIES).get(workflow) + if expected_repository is None: + raise ValueError("Wan image execution selected an unknown workflow contract.") + expected_revision = require_catalog_revision( + expected_repository, + model_type="WanImage2VideoModularPipeline", + ) + if ( + binding._repo_source != "hub" + or binding._repo_id != expected_repository + or binding._revision != expected_revision + ): + raise ValueError( + "Wan image execution does not match the reviewed immutable artifact for the selected workflow." + ) + return workflow + + +def wan_vae_geometry_from_component(vae): + """Validate the exact VAE defaults assumed by the pinned split Wan blocks.""" + + config = getattr(vae, "config", None) + z_dim = getattr(config, "z_dim", None) + temporal_downsample = getattr(vae, "temperal_downsample", None) + if type(z_dim) is not int or z_dim != _WAN_LATENT_CHANNELS: + raise ValueError("The connected Wan VAE must declare the pinned z_dim 16 contract.") + if ( + type(temporal_downsample) not in (list, tuple) + or tuple(temporal_downsample) != (False, True, True) + ): + raise ValueError("The connected Wan VAE must use temporal_downsample (False, True, True).") + spatial_scale = 2 ** len(temporal_downsample) + temporal_scale = 2 ** sum(temporal_downsample) + if (spatial_scale, temporal_scale) != (_WAN_SPATIAL_SCALE, _WAN_TEMPORAL_SCALE): + raise ValueError("The connected Wan VAE must use spatial scale 8 and temporal scale 4.") + expected_config = { + "in_channels": 3, + "out_channels": 3, + "patch_size": None, + "scale_factor_spatial": _WAN_SPATIAL_SCALE, + "scale_factor_temporal": _WAN_TEMPORAL_SCALE, + } + for name, expected in expected_config.items(): + value = getattr(config, name, _NOT_PROVIDED) + if value != expected or (expected is not None and type(value) is not type(expected)): + raise ValueError(f"The connected Wan VAE must declare pinned {name}={expected!r}.") + return z_dim, spatial_scale, temporal_scale + + +def _wan_vae_config_seal(vae): + z_dim, spatial_scale, temporal_scale = wan_vae_geometry_from_component(vae) + config = getattr(vae, "config", None) + + def finite_vector(name, *, nonzero=False): + value = getattr(config, name, None) + if type(value) not in (list, tuple) or len(value) != z_dim: + raise ValueError(f"The connected Wan VAE must declare exactly {z_dim} {name} values.") + canonical = [] + for item in value: + if ( + type(item) not in (int, float) + or not math.isfinite(item) + or abs(item) > _MAX_WAN_VAE_CONFIG_MAGNITUDE + ): + raise ValueError(f"The connected Wan VAE {name} values must be finite numbers.") + if nonzero and not _MIN_WAN_POSITIVE_SCALE <= item <= _MAX_WAN_VAE_CONFIG_MAGNITUDE: + raise ValueError( + f"The connected Wan VAE {name} values must be strictly positive bounded scales." + ) + canonical.append(item) + return tuple(canonical) + + latents_mean = finite_vector("latents_mean") + latents_std = finite_vector("latents_std", nonzero=True) + if latents_mean != _WAN_VAE_LATENTS_MEAN or latents_std != _WAN_VAE_LATENTS_STD: + raise ValueError("The connected Wan VAE must retain the pinned latent mean and standard deviation.") + return ( + z_dim, + spatial_scale, + temporal_scale, + tuple(vae.temperal_downsample), + latents_mean, + latents_std, + ) + + +def wan_transformer_contract_from_component(transformer, *, workflow=None): + """Validate the exact patch/channel contract assumed by split Wan Denoise.""" + + config = getattr(transformer, "config", None) + patch_size = getattr(config, "patch_size", None) + in_channels = getattr(config, "in_channels", None) + out_channels = getattr(config, "out_channels", None) + image_dim = getattr(config, "image_dim", None) + if ( + type(patch_size) not in (list, tuple) + or len(patch_size) != len(_WAN_TRANSFORMER_PATCH_SIZE) + or any(type(value) is not int for value in patch_size) + ): + raise ValueError("The connected Wan transformer has an invalid patch-size contract.") + if tuple(patch_size) != _WAN_TRANSFORMER_PATCH_SIZE: + raise ValueError("The connected Wan transformer must use pinned patch size (1, 2, 2).") + if type(in_channels) is not int or in_channels != _WAN_TRANSFORMER_INPUT_CHANNELS: + raise ValueError("The connected Wan transformer must use the pinned 36-channel I2V input contract.") + if type(out_channels) is not int or out_channels != _WAN_TRANSFORMER_OUTPUT_CHANNELS: + raise ValueError("The connected Wan transformer must use the pinned 16-channel output contract.") + if type(image_dim) is not int or image_dim != _WAN_IMAGE_EMBED_DIM: + raise ValueError("The connected Wan transformer must use the pinned 1280-wide image dimension.") + pos_embed_seq_len = getattr(config, "pos_embed_seq_len", None) + if workflow == _WAN_I2V_WORKFLOW: + if pos_embed_seq_len is not None: + raise ValueError("The cataloged Wan I2V transformer must not declare FLF positional embeddings.") + elif workflow == _WAN_FLF_WORKFLOW: + if type(pos_embed_seq_len) is not int or pos_embed_seq_len != 514: + raise ValueError("The preparatory Wan FLF transformer contract requires pos_embed_seq_len 514.") + elif workflow is not None: + raise ValueError("Wan transformer validation received an unknown workflow contract.") + elif pos_embed_seq_len is not None and (type(pos_embed_seq_len) is not int or pos_embed_seq_len != 514): + raise ValueError("The connected Wan transformer has an unreviewed positional-embedding contract.") + return tuple(patch_size), in_channels, out_channels, image_dim, pos_embed_seq_len + + +def _canonical_processor_scalar(value, *, label): + if value is None or type(value) in (bool, int, str): + if type(value) is str and len(value) > 256: + raise ValueError(f"{label} exceeds the bounded processor string limit.") + return value + if type(value) is float: + if not math.isfinite(value): + raise ValueError(f"{label} must be finite.") + return value + if isinstance(value, Enum): + return ( + "enum", + type(value).__module__, + type(value).__qualname__, + value.name, + _canonical_processor_scalar(value.value, label=label), + ) + if type(value) in (list, tuple): + if len(value) > 32: + raise ValueError(f"{label} exceeds the bounded processor sequence limit.") + return tuple( + _canonical_processor_scalar(item, label=f"{label}[{index}]") + for index, item in enumerate(value) + ) + raise ValueError(f"{label} has an unsupported effective processor value.") + + +def _canonical_clip_size(value, *, label): + if isinstance(value, Mapping): + getter = value.get + else: + getter = lambda name: getattr(value, name, _NOT_PROVIDED) + fields = ("height", "width", "longest_edge", "shortest_edge", "max_height", "max_width") + canonical = [] + for name in fields: + field_value = getter(name) + if field_value is _NOT_PROVIDED: + raise ValueError(f"{label} is missing its bounded '{name}' field.") + if field_value is not None and ( + type(field_value) is not int or not 1 <= field_value <= _MAX_WAN_DIMENSION + ): + raise ValueError( + f"{label}.{name} must be null or a canonical positive integer no larger than " + f"{_MAX_WAN_DIMENSION}." + ) + canonical.append((name, field_value)) + if not any(value is not None for _name, value in canonical): + raise ValueError(f"{label} must declare at least one bounded effective dimension.") + return tuple(canonical) + + +def wan_image_processor_config_seal(image_processor, *, workflow=_WAN_I2V_WORKFLOW): + """Seal the bounded effective CLIP preprocessing fields used by Wan.""" + + if image_processor is None: + raise ValueError("The pinned Wan action is missing its CLIP image processor.") + values = [] + for name in _WAN_CLIP_PROCESSOR_FIELDS: + value = getattr(image_processor, name, _NOT_PROVIDED) + if value is _NOT_PROVIDED: + raise ValueError(f"The pinned Wan image processor is missing effective field '{name}'.") + if name in {"size", "crop_size", "pad_size"}: + if name == "pad_size" and value is None: + pass + else: + value = _canonical_clip_size(value, label=f"Wan image processor {name}") + elif name in { + "do_resize", + "do_center_crop", + "do_rescale", + "do_normalize", + "do_convert_rgb", + }: + if type(value) is not bool: + raise ValueError(f"Wan image processor {name} must be an exact boolean.") + expected = workflow == _WAN_FLF_WORKFLOW if name == "do_center_crop" else True + if value is not expected: + raise ValueError(f"Wan image processor {name} does not match the reviewed setting.") + elif name in {"do_pad", "disable_grouping"}: + if value is not None and type(value) is not bool: + raise ValueError(f"Wan image processor {name} must be null or an exact boolean.") + elif name == "resample": + if not ( + (type(value) is int and value == int(Image.Resampling.BICUBIC)) + or (type(value) is Image.Resampling and value is Image.Resampling.BICUBIC) + ): + raise ValueError("Wan image processor resample must be the reviewed PIL bicubic value.") + value = int(Image.Resampling.BICUBIC) + elif name == "rescale_factor": + if ( + type(value) not in (int, float) + or not math.isfinite(value) + or not _MIN_WAN_POSITIVE_SCALE <= value <= 1.0 + ): + raise ValueError("Wan image processor rescale_factor must be a finite bounded positive scale.") + elif name in {"image_mean", "image_std"}: + if type(value) not in (list, tuple) or len(value) != 3: + raise ValueError(f"Wan image processor {name} must contain exactly three numeric values.") + canonical_vector = [] + for item in value: + if ( + type(item) not in (int, float) + or not math.isfinite(item) + or abs(item) > _MAX_WAN_PROCESSOR_NORMALIZATION + ): + raise ValueError(f"Wan image processor {name} values must be finite and bounded.") + if name == "image_std" and not _MIN_WAN_POSITIVE_SCALE <= item: + raise ValueError("Wan image processor image_std values must be strictly positive bounded scales.") + canonical_vector.append(item) + value = tuple(canonical_vector) + else: + value = _canonical_processor_scalar(value, label=f"Wan image processor {name}") + values.append((name, value)) + values = tuple(values) + effective = dict(values) + if workflow == _WAN_I2V_WORKFLOW: + expected_size = ( + ("height", _WAN_IMAGE_SIZE), + ("width", _WAN_IMAGE_SIZE), + ("longest_edge", None), + ("shortest_edge", None), + ("max_height", None), + ("max_width", None), + ) + elif workflow == _WAN_FLF_WORKFLOW: + expected_size = ( + ("height", None), + ("width", None), + ("longest_edge", None), + ("shortest_edge", _WAN_IMAGE_SIZE), + ("max_height", None), + ("max_width", None), + ) + else: + raise ValueError("Wan image processor validation received an unknown workflow contract.") + expected_crop = ( + ("height", _WAN_IMAGE_SIZE), + ("width", _WAN_IMAGE_SIZE), + ("longest_edge", None), + ("shortest_edge", None), + ("max_height", None), + ("max_width", None), + ) + if effective["size"] != expected_size or effective["crop_size"] != expected_crop: + raise ValueError("Wan image processor must retain the reviewed 224-pixel CLIP resize/crop contract.") + if effective["rescale_factor"] != 1 / 255: + raise ValueError("Wan image processor must retain the reviewed 1/255 rescale factor.") + if effective["do_pad"] not in {None, False} or effective["pad_size"] is not None: + raise ValueError("Wan image processor padding must remain disabled with no pad size.") + if effective["image_mean"] != _WAN_CLIP_IMAGE_MEAN or effective["image_std"] != _WAN_CLIP_IMAGE_STD: + raise ValueError("Wan image processor must retain the pinned CLIP image mean and standard deviation.") + return (type(image_processor).__module__, type(image_processor).__qualname__, values) + + +def wan_image_encoder_contract_from_component(image_encoder): + """Validate the CLIP vision geometry consumed by pinned Wan I2V blocks.""" + + config = getattr(image_encoder, "config", None) + image_size = getattr(config, "image_size", None) + hidden_size = getattr(config, "hidden_size", None) + patch_size = getattr(config, "patch_size", None) + num_channels = getattr(config, "num_channels", None) + num_hidden_layers = getattr(config, "num_hidden_layers", None) + num_attention_heads = getattr(config, "num_attention_heads", None) + projection_dim = getattr(config, "projection_dim", None) + if type(image_size) is not int or image_size != _WAN_IMAGE_SIZE: + raise ValueError("The connected Wan image encoder must use the pinned 224-pixel image size.") + if type(hidden_size) is not int or hidden_size != _WAN_IMAGE_EMBED_DIM: + raise ValueError("The connected Wan image encoder must use the pinned 1280-wide hidden state.") + if type(patch_size) is not int or patch_size != _WAN_IMAGE_ENCODER_PATCH_SIZE: + raise ValueError("The connected Wan image encoder must use the pinned patch size 14.") + if type(num_channels) is not int or num_channels != 3: + raise ValueError("The connected Wan image encoder must consume exactly three channels.") + if type(num_hidden_layers) is not int or num_hidden_layers != _WAN_IMAGE_ENCODER_LAYERS: + raise ValueError("The connected Wan image encoder must expose the pinned 32 hidden layers.") + if type(num_attention_heads) is not int or num_attention_heads != _WAN_IMAGE_ENCODER_HEADS: + raise ValueError("The connected Wan image encoder must expose the pinned 16 attention heads.") + if type(projection_dim) is not int or projection_dim != _WAN_IMAGE_ENCODER_PROJECTION_DIM: + raise ValueError("The connected Wan image encoder must use the pinned projection dimension 1024.") + return ( + image_size, + hidden_size, + patch_size, + num_channels, + num_hidden_layers, + num_attention_heads, + projection_dim, + ) + + +def _effective_video_processor_value(video_processor, name): + config = getattr(video_processor, "config", None) + if isinstance(config, Mapping) and name in config: + return config[name] + return getattr(video_processor, name, _NOT_PROVIDED) + + +def wan_video_processor_config_seal(video_processor): + """Validate and seal the pinned from-config Wan VideoProcessor defaults.""" + + if video_processor is None: + raise ValueError("The pinned Wan action is missing its video processor.") + values = [] + for name in _WAN_VIDEO_PROCESSOR_FIELDS: + value = _effective_video_processor_value(video_processor, name) + if value is _NOT_PROVIDED: + raise ValueError(f"The pinned Wan video processor is missing effective field '{name}'.") + values.append((name, _canonical_processor_scalar(value, label=f"Wan video processor {name}"))) + values = tuple(values) + if values != _WAN_VIDEO_PROCESSOR_DEFAULTS: + raise ValueError("The pinned Wan video processor does not match its reviewed from-config defaults.") + return (type(video_processor).__module__, type(video_processor).__qualname__, values) + + +def require_wan_video_processor(video_processor): + """Validate the reviewed effective processor used by Wan VAE/decode steps.""" + + wan_video_processor_config_seal(video_processor) + return video_processor + + +def _require_component_reference(reference, component, *, label, require_connected=True): + issued = reference() if isinstance(reference, weakref.ReferenceType) else None + if issued is None: + raise ValueError(f"The {label} paired with this Modular route state is no longer resident.") + if require_connected and issued is not component: + raise ValueError(f"The connected {label} is not the exact component paired with this route state.") + return issued + + +def _validate_wan_image_embeds(image_embeds, *, workflow): + if type(image_embeds) is not torch.Tensor: + raise TypeError("Wan image embeddings must be an exact Torch tensor.") + if image_embeds.layout != torch.strided or image_embeds.device.type == "meta" or image_embeds.ndim != 3: + raise ValueError("Wan image embeddings must be a resident strided rank-3 tensor.") + expected_batch = 2 if workflow == _WAN_FLF_WORKFLOW else 1 + if tuple(image_embeds.shape) != (expected_batch, _WAN_IMAGE_EMBED_TOKENS, _WAN_IMAGE_EMBED_DIM): + raise ValueError("Wan image embeddings do not match the selected I2V/FLF image batch contract.") + if not image_embeds.dtype.is_floating_point: + raise ValueError("Wan image embeddings must use a floating-point dtype.") + + +def _validate_wan_image_embed_dimension(image_embeds, *, image_dim): + if type(image_dim) is not int or image_dim <= 0 or image_embeds.shape[-1] != image_dim: + raise ValueError("Wan image embeddings do not match the connected transformer's image dimension.") + + +def _validate_wan_frames(num_frames, *, workflow, temporal_scale=_WAN_TEMPORAL_SCALE): + if type(num_frames) is not int or not 1 <= num_frames <= _MAX_WAN_FRAMES: + raise ValueError(f"Wan num_frames must be a canonical integer from 1 through {_MAX_WAN_FRAMES}.") + if (num_frames - 1) % temporal_scale != 0: + raise ValueError( + f"Wan num_frames must satisfy (num_frames - 1) % {temporal_scale} == 0." + ) + if workflow == _WAN_FLF_WORKFLOW and num_frames < temporal_scale + 1: + raise ValueError("Wan FLF requires at least 5 frames; one-frame FLF is invalid upstream.") + + +def _validate_wan_video_tensor( + tensor, + *, + label, + channels, + num_frames, + height, + width, + spatial_scale, + temporal_scale, +): + if type(tensor) is not torch.Tensor: + raise TypeError(f"{label} must be an exact Torch tensor.") + if tensor.layout != torch.strided or tensor.device.type == "meta" or tensor.ndim != 5: + raise ValueError(f"{label} must be a resident strided rank-5 tensor.") + expected_shape = ( + 1, + channels, + (num_frames - 1) // temporal_scale + 1, + height // spatial_scale, + width // spatial_scale, + ) + if tuple(tensor.shape) != expected_shape: + raise ValueError(f"{label} does not match the sealed Wan frame and spatial geometry.") + if not tensor.dtype.is_floating_point: + raise ValueError(f"{label} must use a floating-point dtype.") + + +def resolve_managed_component_by_id(component_manager, component_input, *, label): + """Resolve exactly one connected managed component without initializing a block.""" + + if not isinstance(component_input, Mapping): + raise TypeError(f"{label} metadata must be a mapping.") + model_id = component_input.get("model_id") + if type(model_id) is not str or not model_id: + raise ValueError(f"{label} metadata must contain one non-empty managed component ID.") + try: + resolved = component_manager.get_components_by_ids( + ids=[model_id], + return_dict_with_names=False, + ) + except (KeyError, TypeError, ValueError) as error: + raise ValueError(f"{label} could not be resolved from its exact managed component ID.") from error + if type(resolved) is not dict or set(resolved) != {model_id} or resolved[model_id] is None: + raise ValueError(f"{label} could not be resolved from its exact managed component ID.") + return resolved[model_id] + + +def _component_identity_reference(component, *, label): + try: + reference = weakref.ref(component) + except TypeError as error: + raise TypeError(f"{label} must support process-local weak identity provenance.") from error + if reference() is not component: + raise ValueError(f"{label} could not be sealed by exact process identity.") + return reference + + +def _sdxl_ip_adapter_image_seal(image): + if type(image) is not Image.Image: + raise TypeError("SDXL IP-Adapter currently accepts one exact PIL image.") + width, height = image.size + if ( + type(width) is not int + or type(height) is not int + or width <= 0 + or height <= 0 + or width > _MAX_SDXL_IP_ADAPTER_DIMENSION + or height > _MAX_SDXL_IP_ADAPTER_DIMENSION + or width * height > _MAX_SDXL_IP_ADAPTER_PIXELS + ): + raise ValueError("SDXL IP-Adapter image dimensions exceed the bounded image contract.") + try: + image.load() + pixels = image.convert("RGBA").tobytes() + except Exception as error: + raise ValueError("SDXL IP-Adapter image pixels could not be sealed before execution.") from error + if len(pixels) > _MAX_SDXL_IP_ADAPTER_SOURCE_BYTES: + raise ValueError("SDXL IP-Adapter image exceeds the bounded 64-MiB pixel budget.") + return image.mode, image.size, image.getbands(), hashlib.sha256(pixels).digest() + + +def sdxl_ip_adapter_image_encoder_contract(image_encoder): + """Validate the exact CLIP ViT-H geometry used by the reviewed SDXL adapter.""" + + config = getattr(image_encoder, "config", None) + expected = { + "image_size": _SDXL_IP_ADAPTER_IMAGE_SIZE, + "hidden_size": _SDXL_IP_ADAPTER_HIDDEN_SIZE, + "patch_size": _SDXL_IP_ADAPTER_PATCH_SIZE, + "num_channels": 3, + "num_hidden_layers": _SDXL_IP_ADAPTER_LAYERS, + "num_attention_heads": _SDXL_IP_ADAPTER_HEADS, + "projection_dim": _SDXL_IP_ADAPTER_PROJECTION_DIM, + } + values = [] + for name, expected_value in expected.items(): + value = getattr(config, name, None) + if type(value) is not int or value != expected_value: + raise ValueError( + f"The reviewed SDXL IP-Adapter image encoder must declare {name}={expected_value}." + ) + values.append(value) + return tuple(values) + + +def sdxl_ip_adapter_feature_extractor_contract(feature_extractor): + """Seal the from-config CLIP preprocessing used by the pinned upstream block.""" + + if feature_extractor is None: + raise ValueError("The reviewed SDXL IP-Adapter is missing its CLIP image processor.") + size = _canonical_clip_size(getattr(feature_extractor, "size", None), label="IP-Adapter processor size") + crop_size = _canonical_clip_size( + getattr(feature_extractor, "crop_size", None), + label="IP-Adapter processor crop size", + ) + expected_size = ( + ("height", None), + ("width", None), + ("longest_edge", None), + ("shortest_edge", _SDXL_IP_ADAPTER_IMAGE_SIZE), + ("max_height", None), + ("max_width", None), + ) + expected_crop = ( + ("height", _SDXL_IP_ADAPTER_IMAGE_SIZE), + ("width", _SDXL_IP_ADAPTER_IMAGE_SIZE), + ("longest_edge", None), + ("shortest_edge", None), + ("max_height", None), + ("max_width", None), + ) + if size != expected_size or crop_size != expected_crop: + raise ValueError("The reviewed SDXL IP-Adapter processor must retain its 224-pixel resize/crop contract.") + expected_scalars = { + "do_convert_rgb": True, + "do_resize": True, + "do_rescale": True, + "rescale_factor": 1 / 255, + "do_normalize": True, + "do_center_crop": True, + } + scalars = [] + for name, expected_value in expected_scalars.items(): + value = getattr(feature_extractor, name, _NOT_PROVIDED) + if value != expected_value or type(value) is not type(expected_value): + raise ValueError(f"The reviewed SDXL IP-Adapter processor has an invalid {name} setting.") + scalars.append((name, value)) + image_mean = tuple(getattr(feature_extractor, "image_mean", ())) + image_std = tuple(getattr(feature_extractor, "image_std", ())) + if image_mean != _SDXL_IP_ADAPTER_IMAGE_MEAN or image_std != _SDXL_IP_ADAPTER_IMAGE_STD: + raise ValueError("The reviewed SDXL IP-Adapter processor has invalid CLIP normalization values.") + return type(feature_extractor).__module__, type(feature_extractor).__qualname__, size, crop_size, tuple(scalars) + + +def _sdxl_ip_adapter_parameter_seal(modules): + values = [] + for module_index, module in enumerate(modules): + named_parameters = getattr(module, "named_parameters", None) + if not callable(named_parameters): + raise ValueError("The resident SDXL IP-Adapter module does not expose Torch parameters.") + for name, parameter in named_parameters(): + if len(values) >= 4096: + raise ValueError("The resident SDXL IP-Adapter exceeds the bounded parameter inventory.") + if not isinstance(name, str) or len(name) > 512 or type(parameter) is not torch.nn.Parameter: + raise ValueError("The resident SDXL IP-Adapter has an invalid parameter inventory.") + values.append( + ( + module_index, + name, + id(parameter), + int(getattr(parameter, "_version", -1)), + tuple(parameter.shape), + str(parameter.dtype), + str(parameter.device), + ) + ) + if not values: + raise ValueError("The resident SDXL IP-Adapter has no sealed adapter parameters.") + return tuple(values) + + +def sdxl_ip_adapter_unet_contract(unet, *, scale): + """Seal one loaded adapter projection plus its exact attention processors.""" + + if isinstance(scale, bool) or not isinstance(scale, (int, float)) or not math.isfinite(float(scale)): + raise ValueError("SDXL IP-Adapter scale must be a finite number.") + scale = float(scale) + if not 0.0 <= scale <= 2.0: + raise ValueError("SDXL IP-Adapter scale must be between 0 and 2.") + from diffusers.models import ImageProjection + from diffusers.models.attention_processor import ( + IPAdapterAttnProcessor, + IPAdapterAttnProcessor2_0, + IPAdapterXFormersAttnProcessor, + ) + + projection = getattr(unet, "encoder_hid_proj", None) + layers = getattr(projection, "image_projection_layers", None) + if not isinstance(layers, torch.nn.ModuleList) or len(layers) != 1 or type(layers[0]) is not ImageProjection: + raise ValueError("The resident SDXL UNet must contain exactly one reviewed standard IP-Adapter projection.") + processors = getattr(unet, "attn_processors", None) + if not isinstance(processors, Mapping) or not processors or len(processors) > 512: + raise ValueError("The resident SDXL UNet has an invalid attention-processor inventory.") + adapter_types = (IPAdapterAttnProcessor, IPAdapterAttnProcessor2_0, IPAdapterXFormersAttnProcessor) + adapter_processors = [] + processor_seal = [] + for name, processor in sorted(processors.items()): + if not isinstance(name, str) or not name or len(name) > 512: + raise ValueError("The resident SDXL UNet has an invalid attention-processor name.") + if isinstance(processor, adapter_types): + scales = getattr(processor, "scale", None) + if type(scales) is not list or len(scales) != 1 or float(scales[0]) != scale: + raise ValueError("The resident SDXL IP-Adapter attention scale no longer matches its receipt.") + adapter_processors.append(processor) + processor_seal.append((name, type(processor).__module__, type(processor).__qualname__, id(processor))) + if not adapter_processors: + raise ValueError("The resident SDXL UNet has no IP-Adapter attention processors.") + config = getattr(unet, "config", None) + if getattr(config, "encoder_hid_dim_type", None) != "ip_image_proj": + raise ValueError("The resident SDXL UNet does not advertise the reviewed IP image projection contract.") + return ( + id(projection), + id(layers[0]), + tuple(processor_seal), + _sdxl_ip_adapter_parameter_seal([layers[0], *adapter_processors]), + ) + + +def _sdxl_ip_adapter_tensor_refs(values, *, label, required): + if values is None and not required: + return () + if type(values) is not list or len(values) != 1 or type(values[0]) is not torch.Tensor: + raise ValueError(f"SDXL IP-Adapter {label} must contain exactly one Torch tensor.") + tensor = values[0] + if tensor.ndim != 3 or tensor.shape[0] != 1 or not 1 <= tensor.shape[1] <= 64 or tensor.shape[2] != 1024: + raise ValueError(f"SDXL IP-Adapter {label} has an invalid standard projection shape.") + return (weakref.ref(tensor),) + + +def _require_sdxl_ip_adapter_tensor_refs(refs, values, *, label, required): + current_refs = _sdxl_ip_adapter_tensor_refs(values, label=label, required=required) + if len(refs) != len(current_refs) or any( + reference() is not current() for reference, current in zip(refs, current_refs) + ): + raise ValueError(f"SDXL IP-Adapter {label} changed after backend publication.") + + +def _is_issued_ip_adapter_state(value): + with _REGISTRY_LOCK: + return type(value) is _SDXLIPAdapterState and value in _ISSUED_IP_ADAPTER_STATES + + +def _current_sdxl_ip_adapter_state(unet): + with _REGISTRY_LOCK: + try: + state = _CURRENT_SDXL_IP_ADAPTER_STATES.get(unet) + except TypeError: + return None + return state if type(state) is _SDXLIPAdapterState else None + + +def _validate_sdxl_ip_adapter_resident_state(state, *, binding, unet): + if not _is_issued_ip_adapter_state(state) or state._stage != _IP_ADAPTER_TO_DENOISE: + raise ValueError("SDXL IP-Adapter requires a current backend-issued state receipt.") + if state._binding is not binding or binding._model_type != "StableDiffusionXLModularPipeline": + raise ValueError("SDXL IP-Adapter state belongs to a different Models Loader execution.") + if state._unet_ref() is not unet: + raise ValueError("SDXL IP-Adapter state belongs to a different resident UNet.") + if _current_sdxl_ip_adapter_state(unet) is not state: + raise ValueError("SDXL IP-Adapter state is no longer the current resident publication.") + if sdxl_ip_adapter_unet_contract(unet, scale=state._scale) != state._unet_seal: + raise ValueError("The resident SDXL IP-Adapter UNet state changed after publication.") + return state + + +def _validate_sdxl_ip_adapter_state(state, *, binding, unet, guider=None, bundle=None): + _validate_sdxl_ip_adapter_resident_state(state, binding=binding, unet=unet) + image_encoder = state._image_encoder_ref() + feature_extractor = state._feature_extractor_ref() + if image_encoder is None or feature_extractor is None: + raise ValueError("The resident SDXL IP-Adapter encoder components are no longer available.") + if sdxl_ip_adapter_image_encoder_contract(image_encoder) != state._image_encoder_seal: + raise ValueError("The resident SDXL IP-Adapter image encoder changed after publication.") + if sdxl_ip_adapter_feature_extractor_contract(feature_extractor) != state._feature_extractor_seal: + raise ValueError("The resident SDXL IP-Adapter processor changed after publication.") + source_image = state._image_ref() + if source_image is None or _sdxl_ip_adapter_image_seal(source_image) != state._image_seal: + raise ValueError("The SDXL IP-Adapter source image changed after encoding.") + state_guider = state._guider_ref() + if bundle is not None and state_guider is not guider: + raise ValueError("SDXL IP-Adapter and Denoise must use the exact same Guider publication.") + if state_guider is None or getattr(state_guider, "num_conditions", None) != state._guider_conditions: + raise ValueError("The SDXL IP-Adapter Guider contract changed after encoding.") + if bundle is not None: + if type(bundle) is not dict or set(bundle) != { + "ip_adapter_embeds", + "negative_ip_adapter_embeds", + _IP_ADAPTER_STATE_KEY, + }: + raise ValueError("SDXL IP-Adapter bundle fields do not match the backend-issued contract.") + if bundle.get(_IP_ADAPTER_STATE_KEY) is not state: + raise ValueError("SDXL IP-Adapter bundle carries a different state receipt.") + _require_sdxl_ip_adapter_tensor_refs( + state._embedding_refs, + bundle.get("ip_adapter_embeds"), + label="embeddings", + required=True, + ) + _require_sdxl_ip_adapter_tensor_refs( + state._negative_embedding_refs, + bundle.get("negative_ip_adapter_embeds"), + label="negative embeddings", + required=state._guider_conditions > 1, + ) + return state + + +def _sdxl_unet_has_ip_adapter_structure(unet): + projection = getattr(unet, "encoder_hid_proj", None) + if bool(getattr(projection, "image_projection_layers", None)): + return True + processors = getattr(unet, "attn_processors", None) + if not isinstance(processors, Mapping): + return False + from diffusers.models.attention_processor import ( + IPAdapterAttnProcessor, + IPAdapterAttnProcessor2_0, + IPAdapterXFormersAttnProcessor, + ) + + return any( + isinstance( + processor, + (IPAdapterAttnProcessor, IPAdapterAttnProcessor2_0, IPAdapterXFormersAttnProcessor), + ) + for processor in processors.values() + ) + + +def prepare_sdxl_ip_adapter_unet(unet, *, binding): + """Return current owned state or reject an unreceipted UNet mutation.""" + + state = _current_sdxl_ip_adapter_state(unet) + if state is None: + if _sdxl_unet_has_ip_adapter_structure(unet): + raise ValueError("The connected SDXL UNet contains unreceipted IP-Adapter state.") + return None + _validate_sdxl_ip_adapter_resident_state(state, binding=binding, unet=unet) + return state + + +def clear_sdxl_ip_adapter_state(unet, *, expected_state): + with _REGISTRY_LOCK: + current = _current_sdxl_ip_adapter_state(unet) + if current is not expected_state: + raise ValueError("The resident SDXL IP-Adapter publication changed before cleanup.") + _CURRENT_SDXL_IP_ADAPTER_STATES.pop(unet, None) + + +def reset_owned_sdxl_ip_adapter_for_loader(pipeline): + """Remove an exact owned adapter before Models Loader publishes a new receipt.""" + + unet = getattr(pipeline, "unet", None) + if unet is None: + return False + state = _current_sdxl_ip_adapter_state(unet) + if state is None: + if _sdxl_unet_has_ip_adapter_structure(unet): + raise ValueError("Models Loader found unreceipted IP-Adapter state on its resident SDXL UNet.") + return False + _validate_sdxl_ip_adapter_resident_state(state, binding=state._binding, unet=unet) + unload = getattr(pipeline, "unload_ip_adapter", None) + if not callable(unload): + raise ValueError("The reviewed SDXL pipeline cannot remove its resident IP-Adapter state.") + unload() + if _sdxl_unet_has_ip_adapter_structure(unet): + raise ValueError("The reviewed SDXL pipeline did not fully remove its resident IP-Adapter state.") + clear_sdxl_ip_adapter_state(unet, expected_state=state) + return True + + +def issue_sdxl_ip_adapter_bundle( + *, + binding, + unet, + artifact_identity, + image_encoder, + feature_extractor, + guider, + scale, + image, + ip_adapter_embeds, + negative_ip_adapter_embeds, +): + """Publish one exact process-local adapter/embedding receipt for Denoise.""" + + if not _is_issued_binding(binding) or binding._model_type != "StableDiffusionXLModularPipeline": + raise ValueError("SDXL IP-Adapter requires the current SDXL Models Loader binding.") + if type(artifact_identity) is not tuple or len(artifact_identity) != 7: + raise ValueError("SDXL IP-Adapter artifact identity is malformed.") + if any(not isinstance(value, (str, int)) or isinstance(value, bool) for value in artifact_identity): + raise ValueError("SDXL IP-Adapter artifact identity contains an invalid value.") + guider_conditions = getattr(guider, "num_conditions", None) + if type(guider_conditions) is not int or not 1 <= guider_conditions <= 4: + raise ValueError("SDXL IP-Adapter Guider must declare a bounded condition count.") + scale = float(scale) + state = _SDXLIPAdapterState( + _ISSUER_SEAL, + binding=binding, + unet=unet, + artifact_identity=artifact_identity, + image_encoder=image_encoder, + image_encoder_seal=sdxl_ip_adapter_image_encoder_contract(image_encoder), + feature_extractor=feature_extractor, + feature_extractor_seal=sdxl_ip_adapter_feature_extractor_contract(feature_extractor), + guider=guider, + guider_conditions=guider_conditions, + unet_seal=sdxl_ip_adapter_unet_contract(unet, scale=scale), + scale=scale, + image=image, + image_seal=_sdxl_ip_adapter_image_seal(image), + embedding_refs=_sdxl_ip_adapter_tensor_refs(ip_adapter_embeds, label="embeddings", required=True), + negative_embedding_refs=_sdxl_ip_adapter_tensor_refs( + negative_ip_adapter_embeds, + label="negative embeddings", + required=guider_conditions > 1, + ), + ) + with _REGISTRY_LOCK: + _ISSUED_IP_ADAPTER_STATES.add(state) + # The resident UNet keeps this receipt current even if the action node + # is deleted; the weak key releases it when that UNet leaves memory. + _CURRENT_SDXL_IP_ADAPTER_STATES[unet] = state + return { + "ip_adapter_embeds": ip_adapter_embeds, + "negative_ip_adapter_embeds": negative_ip_adapter_embeds, + _IP_ADAPTER_STATE_KEY: state, + } + + +def require_sdxl_ip_adapter_bundle(bundle, *, binding, unet, guider): + """Validate an adapter bundle and all resident mutation state before Denoise.""" + + if bundle is None: + state = _current_sdxl_ip_adapter_state(unet) + if state is not None or _sdxl_unet_has_ip_adapter_structure(unet): + raise ValueError( + "The resident SDXL UNet has IP-Adapter state but Denoise has no matching adapter bundle. " + "Reconnect the adapter or rerun Models Loader with a clean component." + ) + return None + if type(bundle) is not dict: + raise ValueError("SDXL IP-Adapter input must be the exact backend-issued bundle.") + state = bundle.get(_IP_ADAPTER_STATE_KEY) + return _validate_sdxl_ip_adapter_state( + state, + binding=binding, + unet=unet, + guider=guider, + bundle=bundle, + ) + + +def _require_sdxl_vae_provenance(payload, vae_component): + if vae_component is None: + raise ValueError("The exact connected SDXL VAE component is required for route validation.") + issued_vae = payload._vae_ref() if isinstance(payload._vae_ref, weakref.ReferenceType) else None + if issued_vae is None: + raise ValueError("The SDXL VAE paired with this Modular route state is no longer resident.") + if issued_vae is not vae_component: + raise ValueError("The connected SDXL VAE is not the exact component paired with this route state.") + geometry = sdxl_vae_geometry_from_component(issued_vae) + if geometry != (payload._vae_latent_channels, payload._vae_scale_factor): + raise ValueError("The SDXL VAE geometry changed after this Modular route state was issued.") + return geometry + + +def validate_sdxl_route_vae_provenance(route_state, *, binding, model_type, vae_component): + """Validate a cached SDXL route against its exact loader and resident VAE.""" + + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._binding is not binding or binding._model_type != model_type: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + if route_state._contract != _SDXL_ROUTE_CONTRACT or route_contract_for_model_type(model_type) != _SDXL_ROUTE_CONTRACT: + raise ValueError("The Modular route state does not carry the SDXL VAE contract.") + payload = route_state._payload + geometry = _require_sdxl_vae_provenance(payload, vae_component) + _require_paired_latents_resident(payload._paired_latents_ref, label="routed latents") + _require_paired_latents_resident(payload._mask_ref, label="VAE latent mask") + _require_paired_latents_resident(payload._masked_image_latents_ref, label="VAE masked-image latents") + return geometry + + +def _validate_sdxl_latent_tensor(tensor, *, label, latent_channels, scale_factor): + if type(tensor) is not torch.Tensor: + raise TypeError(f"{label} must be an exact Torch tensor.") + if tensor.layout != torch.strided or tensor.device.type == "meta" or tensor.ndim != 4: + raise ValueError(f"{label} must be a resident strided rank-4 tensor.") + batch, channels, height, width = tensor.shape + if batch <= 0 or height <= 0 or width <= 0: + raise ValueError(f"{label} batch and spatial dimensions must be positive.") + if channels != latent_channels: + raise ValueError(f"{label} channels do not match the connected SDXL VAE latent contract.") + if not tensor.dtype.is_floating_point: + raise ValueError(f"{label} must use a floating-point dtype.") + decoded_pixels = batch * height * width * scale_factor * scale_factor + if decoded_pixels > _MAX_OVERLAY_AGGREGATE_PIXELS: + raise ValueError(f"{label} exceeds the bounded SDXL decoded-pixel budget.") + + +def _validate_sdxl_encoder_tensors( + image_latents, + mask, + masked_image_latents, + *, + latent_channels, + scale_factor, +): + _validate_sdxl_latent_tensor( + image_latents, + label="SDXL VAE image latents", + latent_channels=latent_channels, + scale_factor=scale_factor, + ) + if mask is None and masked_image_latents is None: + return + _validate_sdxl_latent_tensor( + masked_image_latents, + label="SDXL VAE masked-image latents", + latent_channels=latent_channels, + scale_factor=scale_factor, + ) + if type(mask) is not torch.Tensor: + raise TypeError("SDXL VAE latent mask must be an exact Torch tensor.") + if mask.layout != torch.strided or mask.device.type == "meta" or mask.ndim != 4: + raise ValueError("SDXL VAE latent mask must be a resident strided rank-4 tensor.") + if mask.shape[1] != 1: + raise ValueError("SDXL VAE latent mask must have exactly one channel.") + if mask.shape[0] <= 0 or mask.shape[2] <= 0 or mask.shape[3] <= 0: + raise ValueError("SDXL VAE latent mask batch and spatial dimensions must be positive.") + if (mask.shape[0], mask.shape[2], mask.shape[3]) != ( + image_latents.shape[0], + image_latents.shape[2], + image_latents.shape[3], + ): + raise ValueError("SDXL VAE latent mask batch and spatial dimensions must match image latents.") + if ( + masked_image_latents.shape[0] != image_latents.shape[0] + or masked_image_latents.shape[2:] != image_latents.shape[2:] + ): + raise ValueError("SDXL VAE masked-image latent batch and spatial dimensions must match image latents.") + if mask.dtype != image_latents.dtype or masked_image_latents.dtype != image_latents.dtype: + raise ValueError("SDXL VAE image, mask, and masked-image latents must use one exact dtype.") + if mask.device != image_latents.device or masked_image_latents.device != image_latents.device: + raise ValueError("SDXL VAE image, mask, and masked-image latents must use one exact device.") + + +def _paired_latent_items(latents, *, label, allow_list): + if type(latents) is torch.Tensor: + return "tensor", (latents,) + if not allow_list or type(latents) is not list: + suffix = " or a bounded nonempty list of exact Torch tensors" if allow_list else "" + raise TypeError(f"{label} must be an exact Torch tensor{suffix}.") + if not latents: + raise ValueError(f"{label} latent lists must not be empty.") + if len(latents) > _MAX_PAIRED_LATENT_TENSORS: + raise ValueError(f"{label} latent lists cannot contain more than {_MAX_PAIRED_LATENT_TENSORS} tensors.") + for index, item in enumerate(latents): + if type(item) is not torch.Tensor: + raise TypeError(f"{label}[{index}] must be an exact Torch tensor.") + return "list", tuple(latents) + + +def _paired_latents_reference(latents, *, label, allow_list=False): + kind, items = _paired_latent_items(latents, label=label, allow_list=allow_list) + return kind, tuple((weakref.ref(item), _tensor_identity_seal(item)) for item in items) + + +def _tensor_identity_seal(tensor): + try: + version = tensor._version + except RuntimeError as error: + raise ValueError("Modular route tensors must expose a mutation version counter.") from error + if tensor.layout != torch.strided or tensor.device.type == "meta": + raise ValueError("Modular route tensors must be resident strided tensors.") + try: + data_ptr = tensor.data_ptr() + except RuntimeError as error: + raise ValueError("Modular route tensors must expose stable resident storage.") from error + stride = tuple(tensor.stride()) + storage_offset = tensor.storage_offset() + return ( + tuple(tensor.shape), + tensor.dtype, + tensor.device, + tensor.layout, + stride, + storage_offset, + data_ptr, + version, + ) + + +def _require_tensor_seal(tensor, seal, *, label): + if _tensor_identity_seal(tensor) != seal: + raise ValueError(f"The exact {label} paired with this Modular route state was mutated or rebound.") + + +def _require_paired_latents(route_state, latents, *, label): + _require_paired_latents_reference(route_state._paired_latents_ref, latents, label=label) + + +def _require_paired_latents_reference(reference, latents, *, label): + issued_kind, issued_refs = reference + connected_kind, connected_items = _paired_latent_items( + latents, + label=f"Connected {label}", + allow_list=issued_kind == "list", + ) + if connected_kind != issued_kind or len(connected_items) != len(issued_refs): + raise ValueError(f"Connected {label} does not match the exact latent output paired with this route state.") + for (issued_ref, issued_seal), connected_item in zip(issued_refs, connected_items): + issued_item = issued_ref() + if issued_item is None: + raise ValueError(f"The {label} paired with this Modular route state is no longer resident.") + if issued_item is not connected_item: + raise ValueError(f"Connected {label} does not match the exact latent output paired with this route state.") + _require_tensor_seal(issued_item, issued_seal, label=label) + + +def _require_optional_paired_latents(reference, latents, *, label): + if reference is None: + if latents is not None: + raise ValueError(f"Connected {label} was not part of this Modular route state.") + return + _require_paired_latents_reference(reference, latents, label=label) + + +def _require_paired_latents_resident(reference, *, label): + """Require every exact tensor paired with a sealed route to remain alive.""" + + if reference is None: + return + _issued_kind, issued_refs = reference + for issued_ref, issued_seal in issued_refs: + issued_item = issued_ref() + if issued_item is None: + raise ValueError(f"The {label} paired with this Modular route state is no longer resident.") + _require_tensor_seal(issued_item, issued_seal, label=label) + + +def require_route_state_current_publication(route_state, *, label, controlnet_component=_NOT_PROVIDED): + """Reject a cached route whose standalone component publication was superseded.""" + + if route_state is None: + return None + if not _is_issued_route_state(route_state): + raise ValueError(f"Connected Modular {label} was not issued by this backend process.") + controlnet_binding = route_state._standalone_controlnet_binding + if controlnet_binding is not None and not _is_current_standalone_component_binding(controlnet_binding): + raise ValueError( + f"Connected Modular {label} references a superseded AutoModelLoader ControlNet publication. " + "Rerun Load Model and ControlNet, then reconnect the route." + ) + if controlnet_binding is not None and controlnet_component is not _NOT_PROVIDED: + require_standalone_component_binding( + controlnet_component, + label="ControlNet model", + expected_kind="controlnet", + expected_binding=controlnet_binding, + ) + return route_state + + +def issue_wan_image_encoder_route_state( + *, + binding, + image, + last_image, + height, + width, + image_embeds, + image_encoder, + image_processor, + resized_image, + resized_last_image, + execution_device, + source_snapshot, + preflight_geometry, +): + """Issue the first opaque Wan edge after the pinned image-encoder pass.""" + + if not _is_issued_binding(binding) or binding._model_type != "WanImage2VideoModularPipeline": + raise ValueError("Cannot issue a Wan image route for an invalid ModelsLoader binding.") + _require_wan_media_snapshot(source_snapshot, image, last_image) + workflow = source_snapshot[0] + current_preflight = preflight_wan_image_encoder_inputs( + image=image, + last_image=last_image, + height=height, + width=width, + ) + if current_preflight != preflight_geometry: + raise ValueError("Wan image-encoder preflight geometry changed before route publication.") + ( + _workflow, + requested_height, + requested_width, + first_height, + first_width, + _clip_height, + _clip_width, + last_intermediate, + ) = current_preflight + _validate_wan_resized_media( + workflow=workflow, + resized_image=resized_image, + resized_last_image=resized_last_image, + height=first_height, + width=first_width, + expected_last_size=last_intermediate, + stage="image encoding", + ) + _validate_wan_image_embeds(image_embeds, workflow=workflow) + image_encoder_execution_device = _require_wan_tensor_device( + image_embeds, + execution_device, + label="Wan image embeddings", + ) + image_encoder_config_seal = wan_image_encoder_contract_from_component(image_encoder) + _validate_wan_image_embed_dimension(image_embeds, image_dim=image_encoder_config_seal[1]) + image_encoder_ref = _component_identity_reference(image_encoder, label="Connected Wan image encoder") + image_processor_ref = _component_identity_reference(image_processor, label="Connected Wan image processor") + return _new_route_state( + stage=_IMAGE_EMBED_TO_VAE, + binding=binding, + seed=None, + contract=_WAN_ROUTE_CONTRACT, + payload=_WanRoutePayload( + generator_snapshot=None, + paired_latents_ref=_paired_latents_reference(image_embeds, label="Wan image embeddings"), + image_embeds_ref=_paired_latents_reference(image_embeds, label="Wan image embeddings"), + source_image_ref=source_snapshot[1], + source_image_seal=source_snapshot[2], + last_image_ref=source_snapshot[3], + last_image_seal=source_snapshot[4], + workflow=workflow, + requested_height=requested_height, + requested_width=requested_width, + first_height=first_height, + first_width=first_width, + image_encoder_ref=image_encoder_ref, + image_encoder_config_seal=image_encoder_config_seal, + image_processor_ref=image_processor_ref, + image_processor_config_seal=wan_image_processor_config_seal( + image_processor, + workflow=workflow, + ), + image_encoder_execution_device=image_encoder_execution_device, + ), + ) + + +def validate_wan_image_encoder_route_state( + route_state, + *, + binding, + model_type, + image, + last_image, + height, + width, + image_embeds=None, + image_encoder=_NOT_PROVIDED, + image_processor=_NOT_PROVIDED, + execution_device=_NOT_PROVIDED, +): + """Validate image-to-VAE state without trusting visible resize fields.""" + + if not _is_issued_route_state(route_state) or route_state._stage != _IMAGE_EMBED_TO_VAE: + raise ValueError("The Wan image route state is connected to the wrong action stage.") + if route_state._binding is not binding or binding._model_type != model_type: + raise ValueError("The Wan image route comes from a different Models Loader execution.") + if route_state._contract != _WAN_ROUTE_CONTRACT or route_contract_for_model_type(model_type) != _WAN_ROUTE_CONTRACT: + raise ValueError("The Wan image route belongs to a different pipeline state contract.") + payload = route_state._payload + preflight_geometry = preflight_wan_image_encoder_inputs( + image=image, + last_image=last_image, + height=height, + width=width, + ) + requested = preflight_geometry[1:3] + if requested != (payload._requested_height, payload._requested_width): + raise ValueError("Wan requested dimensions changed after the image-embedding action.") + _require_wan_media_snapshot(_wan_payload_media_snapshot(payload), image, last_image) + expected_first = preflight_geometry[3:5] + if expected_first != (payload._first_height, payload._first_width): + raise ValueError("Wan first-pass image dimensions no longer match the sealed route.") + if image_embeds is None: + _require_paired_latents_resident(payload._image_embeds_ref, label="Wan image embeddings") + else: + _validate_wan_image_embeds(image_embeds, workflow=payload._workflow) + _require_paired_latents_reference(payload._image_embeds_ref, image_embeds, label="Wan image embeddings") + sealed_execution_device = _wan_execution_device( + payload._image_encoder_execution_device, + label="image-encoder producer", + ) + if execution_device is not _NOT_PROVIDED and not _devices_compatible( + sealed_execution_device, + _wan_execution_device(execution_device, label="image-encoder producer"), + ): + raise ValueError("Wan Image Embeddings execution device changed after route publication.") + _require_wan_reference_device( + payload._image_embeds_ref, + sealed_execution_device, + label="Wan image embeddings", + ) + image_encoder = _require_component_reference( + payload._image_encoder_ref, + None if image_encoder is _NOT_PROVIDED else image_encoder, + label="Wan image encoder", + require_connected=image_encoder is not _NOT_PROVIDED, + ) + image_encoder_config_seal = wan_image_encoder_contract_from_component(image_encoder) + if image_encoder_config_seal != payload._image_encoder_config_seal: + raise ValueError("The Wan image encoder contract changed after route publication.") + if image_embeds is not None: + _validate_wan_image_embed_dimension(image_embeds, image_dim=image_encoder_config_seal[1]) + processor = _require_component_reference( + payload._image_processor_ref, + None if image_processor is _NOT_PROVIDED else image_processor, + label="Wan image processor", + require_connected=image_processor is not _NOT_PROVIDED, + ) + if ( + wan_image_processor_config_seal(processor, workflow=payload._workflow) + != payload._image_processor_config_seal + ): + raise ValueError("The Wan image processor configuration changed after route publication.") + return route_state + + +def consume_wan_image_encoder_route_state(route_state, **kwargs): + """Return only the backend-owned first-pass dimensions for Wan VAE.""" + + validate_wan_image_encoder_route_state(route_state, **kwargs) + payload = route_state._payload + return { + "workflow": payload._workflow, + "height": payload._first_height, + "width": payload._first_width, + } + + +def preflight_wan_vae_route_state( + route_state, + *, + binding, + model_type, + image, + last_image, + height, + width, + num_frames, + vae_component, +): + """Validate Wan VAE geometry/resource bounds before pipeline init.""" + + validate_wan_image_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + image=image, + last_image=last_image, + height=height, + width=width, + ) + payload = route_state._payload + z_dim, spatial_scale, temporal_scale = wan_vae_geometry_from_component(vae_component) + _validate_wan_frames(num_frames, workflow=payload._workflow, temporal_scale=temporal_scale) + second_height, second_width = wan_area_budget_dimensions( + image, + payload._first_height, + payload._first_width, + ) + second_last_intermediate = _wan_last_image_intermediate_dimensions( + last_image, + height=second_height, + width=second_width, + stage="VAE", + ) + video_tensor_bytes = second_height * second_width * num_frames * _WAN_VIDEO_PIXEL_BYTES + if video_tensor_bytes > _MAX_WAN_VIDEO_TENSOR_BYTES: + raise ValueError("Wan resolved dimensions and frame count exceed the bounded 512-MiB video-tensor budget.") + return ( + payload._workflow, + payload._first_height, + payload._first_width, + second_height, + second_width, + num_frames, + z_dim, + spatial_scale, + temporal_scale, + _wan_vae_config_seal(vae_component), + second_last_intermediate, + video_tensor_bytes, + ) + + +def issue_wan_vae_route_state( + route_state, + *, + binding, + seed, + generator, + image, + last_image, + height, + width, + num_frames, + image_condition_latents, + raw_frame_latents, + vae_component, + video_processor, + resized_image, + resized_last_image, + execution_device, + preflight_geometry, +): + """Advance the Wan route after the second resize and deterministic VAE.""" + + validate_wan_image_encoder_route_state( + route_state, + binding=binding, + model_type=binding._model_type, + image=image, + last_image=last_image, + height=height, + width=width, + ) + if type(seed) is not int or not 0 <= seed <= 4294967295: + raise ValueError("A Wan route seed must be a canonical integer from 0 through 4294967295.") + if not isinstance(generator, torch.Generator) or generator.initial_seed() != seed: + raise ValueError("The post-VAE Wan generator does not match the validated route seed.") + input_payload = route_state._payload + current_preflight = preflight_wan_vae_route_state( + route_state, + binding=binding, + model_type=binding._model_type, + image=image, + last_image=last_image, + height=height, + width=width, + num_frames=num_frames, + vae_component=vae_component, + ) + if current_preflight != preflight_geometry: + raise ValueError("Wan VAE preflight geometry changed before route publication.") + ( + _workflow, + _first_height, + _first_width, + second_height, + second_width, + _preflight_num_frames, + z_dim, + spatial_scale, + temporal_scale, + vae_config_seal, + second_last_intermediate, + _video_tensor_bytes, + ) = current_preflight + video_processor_config_seal = wan_video_processor_config_seal(video_processor) + _validate_wan_resized_media( + workflow=input_payload._workflow, + resized_image=resized_image, + resized_last_image=resized_last_image, + height=second_height, + width=second_width, + expected_last_size=second_last_intermediate, + stage="VAE encoding", + ) + _validate_wan_video_tensor( + raw_frame_latents, + label="Wan raw frame latents", + channels=z_dim, + num_frames=num_frames, + height=second_height, + width=second_width, + spatial_scale=spatial_scale, + temporal_scale=temporal_scale, + ) + _validate_wan_video_tensor( + image_condition_latents, + label="Wan image condition latents", + channels=z_dim + temporal_scale, + num_frames=num_frames, + height=second_height, + width=second_width, + spatial_scale=spatial_scale, + temporal_scale=temporal_scale, + ) + vae_execution_device = _require_wan_tensor_device( + raw_frame_latents, + execution_device, + label="Wan raw frame latents", + ) + _require_wan_tensor_device( + image_condition_latents, + vae_execution_device, + label="Wan image condition latents", + ) + if not _devices_compatible(generator.device, vae_execution_device): + raise ValueError("The post-VAE Wan generator must remain on the producing execution device.") + _require_wan_media_snapshot(_wan_payload_media_snapshot(input_payload), image, last_image) + condition_ref = _paired_latents_reference( + image_condition_latents, + label="Wan image condition latents", + ) + return _new_route_state( + stage=_ENCODE_TO_DENOISE, + binding=binding, + seed=seed, + contract=_WAN_ROUTE_CONTRACT, + payload=_WanRoutePayload( + generator_snapshot=_clone_generator(generator), + paired_latents_ref=condition_ref, + image_embeds_ref=input_payload._image_embeds_ref, + image_condition_latents_ref=condition_ref, + source_image_ref=input_payload._source_image_ref, + source_image_seal=input_payload._source_image_seal, + last_image_ref=input_payload._last_image_ref, + last_image_seal=input_payload._last_image_seal, + workflow=input_payload._workflow, + requested_height=input_payload._requested_height, + requested_width=input_payload._requested_width, + first_height=input_payload._first_height, + first_width=input_payload._first_width, + second_height=second_height, + second_width=second_width, + num_frames=num_frames, + image_encoder_ref=input_payload._image_encoder_ref, + image_encoder_config_seal=input_payload._image_encoder_config_seal, + image_processor_ref=input_payload._image_processor_ref, + image_processor_config_seal=input_payload._image_processor_config_seal, + image_encoder_execution_device=input_payload._image_encoder_execution_device, + vae_ref=_component_identity_reference(vae_component, label="Connected Wan VAE"), + video_processor_ref=_component_identity_reference( + video_processor, + label="Connected Wan video processor", + ), + video_processor_config_seal=video_processor_config_seal, + vae_config_seal=vae_config_seal, + vae_execution_device=vae_execution_device, + ), + ) + + +def _require_wan_vae_provenance(payload, vae_component, video_processor=_NOT_PROVIDED): + vae = _require_component_reference(payload._vae_ref, vae_component, label="Wan VAE") + processor = _require_component_reference( + payload._video_processor_ref, + None if video_processor is _NOT_PROVIDED else video_processor, + label="Wan VAE video processor", + require_connected=video_processor is not _NOT_PROVIDED, + ) + if wan_video_processor_config_seal(processor) != payload._video_processor_config_seal: + raise ValueError("The Wan VAE video processor configuration changed after route publication.") + if _wan_vae_config_seal(vae) != payload._vae_config_seal: + raise ValueError("The Wan VAE geometry changed after this Modular route state was issued.") + return wan_vae_geometry_from_component(vae) + + +def validate_wan_vae_route_state( + route_state, + *, + binding, + model_type, + seed, + image_embeds, + image_condition_latents, + height, + width, + num_frames, + vae_component, + video_processor=_NOT_PROVIDED, + transformer_component=_NOT_PROVIDED, + producer_execution_device=_NOT_PROVIDED, +): + """Validate the exact Wan VAE-to-Denoise route and typed tensor edges.""" + + if not _is_issued_route_state(route_state) or route_state._stage != _ENCODE_TO_DENOISE: + raise ValueError("The Wan VAE route state is connected to the wrong action stage.") + if route_state._binding is not binding or binding._model_type != model_type: + raise ValueError("The Wan VAE route comes from a different Models Loader execution.") + if route_state._contract != _WAN_ROUTE_CONTRACT or route_contract_for_model_type(model_type) != _WAN_ROUTE_CONTRACT: + raise ValueError("The Wan VAE route belongs to a different pipeline state contract.") + payload = route_state._payload + if type(seed) is not int or seed != route_state._seed: + raise ValueError("Denoise seed must match the seed used by the preceding Wan VAE route.") + generator = payload._generator_snapshot + if not isinstance(generator, torch.Generator) or generator.initial_seed() != seed: + raise ValueError("The Wan route generator snapshot no longer matches its originating seed.") + requested = _validate_wan_requested_dimensions(height, width) + if requested != (payload._requested_height, payload._requested_width): + raise ValueError("Wan requested dimensions changed after VAE encoding.") + _validate_wan_frames(num_frames, workflow=payload._workflow) + if num_frames != payload._num_frames: + raise ValueError("Wan num_frames changed after VAE encoding.") + _require_wan_media_snapshot( + _wan_payload_media_snapshot(payload), + payload._source_image_ref(), + payload._last_image_ref() if payload._last_image_ref is not None else None, + ) + if image_embeds is _NOT_PROVIDED: + _require_paired_latents_resident(payload._image_embeds_ref, label="Wan image embeddings") + else: + _validate_wan_image_embeds(image_embeds, workflow=payload._workflow) + _require_paired_latents_reference(payload._image_embeds_ref, image_embeds, label="Wan image embeddings") + z_dim, spatial_scale, temporal_scale = _require_wan_vae_provenance( + payload, + vae_component, + video_processor, + ) + _validate_wan_video_tensor( + image_condition_latents, + label="Wan image condition latents", + channels=z_dim + temporal_scale, + num_frames=num_frames, + height=payload._second_height, + width=payload._second_width, + spatial_scale=spatial_scale, + temporal_scale=temporal_scale, + ) + _require_paired_latents_reference( + payload._image_condition_latents_ref, + image_condition_latents, + label="Wan image condition latents", + ) + _require_wan_reference_device( + payload._image_embeds_ref, + payload._image_encoder_execution_device, + label="Wan image embeddings", + ) + sealed_vae_execution_device = _require_wan_reference_device( + payload._image_condition_latents_ref, + payload._vae_execution_device, + label="Wan image condition latents", + ) + if producer_execution_device is not _NOT_PROVIDED and not _devices_compatible( + sealed_vae_execution_device, + _wan_execution_device(producer_execution_device, label="VAE producer"), + ): + raise ValueError("Wan Image Encode execution device changed after route publication.") + image_encoder = _require_component_reference( + payload._image_encoder_ref, + None, + label="Wan image encoder", + require_connected=False, + ) + if wan_image_encoder_contract_from_component(image_encoder) != payload._image_encoder_config_seal: + raise ValueError("The Wan image encoder contract changed after route publication.") + image_processor = _require_component_reference( + payload._image_processor_ref, + None, + label="Wan image processor", + require_connected=False, + ) + if ( + wan_image_processor_config_seal(image_processor, workflow=payload._workflow) + != payload._image_processor_config_seal + ): + raise ValueError("The Wan image processor configuration changed after route publication.") + if transformer_component is not _NOT_PROVIDED: + transformer_seal = wan_transformer_contract_from_component( + transformer_component, + workflow=payload._workflow, + ) + if image_embeds is _NOT_PROVIDED: + raise ValueError("Wan transformer validation requires the exact connected image embeddings.") + _validate_wan_image_embed_dimension(image_embeds, image_dim=transformer_seal[3]) + if payload._transformer_config_seal is not None and transformer_seal != payload._transformer_config_seal: + raise ValueError("The Wan transformer contract changed after Denoise route publication.") + if payload._transformer_ref is not None: + _require_component_reference(payload._transformer_ref, transformer_component, label="Wan transformer") + return route_state + + +def consume_wan_vae_route_state(route_state, *, execution_device, **kwargs): + """Materialize retry-safe Wan generator state plus routed dimensions.""" + + validate_wan_vae_route_state(route_state, **kwargs) + payload = route_state._payload + if not _devices_compatible(payload._generator_snapshot.device, execution_device): + raise ValueError("The Wan route generator device is incompatible with Denoise execution.") + for label, tensor in ( + ("image embeddings", kwargs.get("image_embeds")), + ("image condition latents", kwargs.get("image_condition_latents")), + ): + if type(tensor) is not torch.Tensor or not _devices_compatible(tensor.device, execution_device): + raise ValueError(f"Wan {label} must be resident on the Denoise execution device.") + return { + "generator": _clone_generator(payload._generator_snapshot), + "height": payload._second_height, + "width": payload._second_width, + "num_frames": payload._num_frames, + "processed_mask_image": None, + } + + +def validate_wan_post_vae_route_state(route_state, **kwargs): + """Validate a cached Wan VAE result before a transformer is in scope.""" + + return validate_wan_vae_route_state( + route_state, + image_embeds=_NOT_PROVIDED, + transformer_component=_NOT_PROVIDED, + **kwargs, + ) + + +def issue_encoder_route_state( + *, + binding, + seed, + generator, + image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + mask=None, + masked_image_latents=None, + padding_mask_crop=None, + crops_coords=None, + original_image=None, + original_mask=None, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, +): + """Seal post-VAE generator state and mask routing values for Denoise.""" + + if not _is_issued_binding(binding): + raise ValueError("Cannot issue route state for an invalid ModelsLoader binding.") + if binding._model_type not in SUPPORTED_ROUTE_MODEL_TYPES: + raise ValueError(f"Pipeline '{binding._model_type}' does not declare opaque route-state support.") + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 4294967295: + raise ValueError("A Modular route seed must be a canonical integer from 0 through 4294967295.") + if not isinstance(generator, torch.Generator) or generator.initial_seed() != seed: + raise ValueError("The post-VAE generator does not match the validated Modular route seed.") + contract = route_contract_for_model_type(binding._model_type) + if contract == _SDXL_ROUTE_CONTRACT: + if processed_mask_image is not None or mask_overlay_kwargs is not None: + raise ValueError("SDXL route state cannot contain Qwen processed-mask or overlay fields.") + inpaint = mask is not None or masked_image_latents is not None + if (mask is None) != (masked_image_latents is None): + raise ValueError( + "The SDXL VAE route must provide both mask and masked-image latents for inpainting, or neither." + ) + if ( + type(vae_latent_channels) is not int + or not 1 <= vae_latent_channels <= 64 + or type(vae_scale_factor) is not int + or not 1 <= vae_scale_factor <= 32768 + ): + raise ValueError("SDXL route state requires the exact connected VAE latent geometry.") + if sdxl_vae_geometry_from_component(vae_component) != ( + vae_latent_channels, + vae_scale_factor, + ): + raise ValueError("SDXL route state geometry does not match the exact connected VAE component.") + _validate_sdxl_encoder_tensors( + image_latents, + mask, + masked_image_latents, + latent_channels=vae_latent_channels, + scale_factor=vae_scale_factor, + ) + if inpaint: + mask_ref = _paired_latents_reference(mask, label="VAE latent mask") + masked_image_latents_ref = _paired_latents_reference( + masked_image_latents, + label="VAE masked-image latents", + ) + else: + mask_ref = None + masked_image_latents_ref = None + if padding_mask_crop is not None and ( + type(padding_mask_crop) is not int + or padding_mask_crop < 0 + or padding_mask_crop > _MAX_MASK_CROP_PADDING + ): + raise ValueError( + f"SDXL mask crop padding must be a canonical integer from 0 through {_MAX_MASK_CROP_PADDING}, or null." + ) + if padding_mask_crop is None: + if crops_coords is not None: + raise ValueError("SDXL crop coordinates require non-null mask crop padding.") + original_image_snapshot = None + original_mask_snapshot = None + else: + if not inpaint: + raise ValueError("SDXL mask crop padding requires an inpaint mask and masked-image latents.") + if ( + type(crops_coords) is not tuple + or len(crops_coords) != 4 + or any(type(item) is not int for item in crops_coords) + ): + raise ValueError("SDXL crop coordinates must be an exact tuple of four canonical integers.") + if original_image is None or original_mask is None: + raise ValueError("SDXL crop overlay requires the original image and mask.") + original_image_snapshot, original_mask_snapshot = _freeze_sdxl_overlay_media( + original_image, + original_mask, + crops_coords=crops_coords, + ) + return _new_route_state( + stage=_ENCODE_TO_DENOISE, + binding=binding, + seed=seed, + contract=contract, + payload=_SdxlRoutePayload( + generator_snapshot=_clone_generator(generator), + inpaint=inpaint, + paired_latents_ref=_paired_latents_reference(image_latents, label="VAE image latents"), + mask_ref=mask_ref, + masked_image_latents_ref=masked_image_latents_ref, + padding_mask_crop=padding_mask_crop, + crops_coords=crops_coords, + original_image_snapshot=original_image_snapshot, + original_mask_snapshot=original_mask_snapshot, + vae_ref=_component_identity_reference(vae_component, label="Connected SDXL VAE"), + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ), + ) + + if any( + value is not None + for value in ( + mask, + masked_image_latents, + padding_mask_crop, + crops_coords, + original_image, + original_mask, + vae_component, + vae_latent_channels, + vae_scale_factor, + ) + ): + raise ValueError("Qwen route state cannot contain SDXL typed mask or crop fields.") + if binding._model_type == "QwenImageEditPlusModularPipeline" and ( + processed_mask_image is not None or mask_overlay_kwargs is not None + ): + raise ValueError("Qwen Image Edit Plus supports generator-only route state; inpaint state is not enabled.") + if mask_overlay_kwargs is not None and type(mask_overlay_kwargs) is not dict: + raise TypeError("Modular mask overlay state must be an exact dictionary or null.") + if (processed_mask_image is None) != (mask_overlay_kwargs is None): + raise ValueError( + "The Modular VAE route must provide both processed mask and overlay state for inpainting, " + "or neither for a normal image route." + ) + if processed_mask_image is not None: + if type(processed_mask_image) is not torch.Tensor: + raise TypeError("The processed Modular inpaint mask must be a Torch tensor.") + expected_overlay_keys = {"crops_coords", "original_image", "original_mask"} + if set(mask_overlay_kwargs) != expected_overlay_keys: + raise ValueError("The Modular inpaint overlay state does not match the pinned Qwen contract.") + crops_coords = mask_overlay_kwargs["crops_coords"] + original_image = mask_overlay_kwargs["original_image"] + original_mask = mask_overlay_kwargs["original_mask"] + if crops_coords is None: + if original_image is not None or original_mask is not None: + raise ValueError("Original image and mask require non-null Modular inpaint crop coordinates.") + else: + if ( + type(crops_coords) is not tuple + or len(crops_coords) != 4 + or any(isinstance(item, bool) or not isinstance(item, int) for item in crops_coords) + or original_image is None + or original_mask is None + ): + raise ValueError( + "Modular inpaint crop coordinates require four integers plus original image and mask." + ) + return _new_route_state( + stage=_ENCODE_TO_DENOISE, + binding=binding, + seed=seed, + contract=contract, + payload=_QwenRoutePayload( + generator_snapshot=_clone_generator(generator), + processed_mask_image=processed_mask_image, + mask_overlay_kwargs=dict(mask_overlay_kwargs) if mask_overlay_kwargs is not None else None, + inpaint=processed_mask_image is not None, + paired_latents_ref=_paired_latents_reference( + image_latents, + label="VAE image latents", + allow_list=binding._model_type == "QwenImageEditPlusModularPipeline", + ), + ), + ) + + +def validate_encoder_route_state( + route_state, + *, + binding, + model_type, + seed, + image_latents, + mask=None, + masked_image_latents=None, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, +): + """Reject wrong-stage, cross-loader, or seed-mismatched state before pipeline init.""" + + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 4294967295: + raise ValueError("A Modular route seed must be a canonical integer from 0 through 4294967295.") + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._stage != _ENCODE_TO_DENOISE: + raise ValueError("The Modular route state is connected to the wrong action stage.") + if route_state._binding is not binding: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + if binding._model_type != model_type: + raise ValueError("The Modular route state belongs to a different pipeline class.") + if route_state._contract != route_contract_for_model_type(model_type): + raise ValueError("The Modular route state belongs to a different pipeline state contract.") + if seed != route_state._seed: + raise ValueError("Denoise seed must match the seed used by the preceding VAE encoder route.") + generator = route_state._generator_snapshot + if not isinstance(generator, torch.Generator) or generator.initial_seed() != route_state._seed: + raise ValueError("The Modular route generator snapshot no longer matches its originating seed.") + _require_paired_latents(route_state, image_latents, label="VAE image latents") + if route_state._contract == _SDXL_ROUTE_CONTRACT: + payload = route_state._payload + _require_sdxl_vae_provenance(payload, vae_component) + _require_optional_paired_latents(route_state._payload._mask_ref, mask, label="VAE latent mask") + _require_optional_paired_latents( + route_state._payload._masked_image_latents_ref, + masked_image_latents, + label="VAE masked-image latents", + ) + _validate_sdxl_encoder_tensors( + image_latents, + mask, + masked_image_latents, + latent_channels=payload._vae_latent_channels, + scale_factor=payload._vae_scale_factor, + ) + if vae_latent_channels is not None or vae_scale_factor is not None: + if (vae_latent_channels, vae_scale_factor) != ( + payload._vae_latent_channels, + payload._vae_scale_factor, + ): + raise ValueError("The connected Denoise VAE geometry does not match the originating VAE route.") + + +def consume_encoder_route_state( + route_state, + *, + binding, + model_type, + seed, + execution_device, + image_latents, + mask=None, + masked_image_latents=None, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, +): + """Materialize a fresh post-VAE generator after pre-init route validation.""" + + validate_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + vae_component=vae_component, + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ) + generator = route_state._generator_snapshot + if not _devices_compatible(generator.device, execution_device): + raise ValueError( + "The Modular route generator device is incompatible with the Denoise execution device; " + "rerun the connected Models Loader and VAE encoder on one execution path." + ) + values = { + "generator": _clone_generator(generator), + "processed_mask_image": route_state._processed_mask_image, + } + if route_state._contract == _SDXL_ROUTE_CONTRACT: + values.update( + mask=mask, + masked_image_latents=masked_image_latents, + crops_coords=route_state._payload._crops_coords, + ) + return values + + +def issue_controlnet_route_state( + route_state, + *, + binding, + controlnet_component, + seed, + generator, + control_image_latents, +): + """Seal the exact post-ControlNet VAE generator and latent pairing for Denoise.""" + + if not _is_issued_binding(binding) or binding._model_type != "QwenImageModularPipeline": + raise ValueError("Qwen ControlNet route state requires a matching ModelsLoader pipeline binding.") + controlnet_binding = require_standalone_component_binding( + controlnet_component, + label="ControlNet model", + expected_kind="controlnet", + ) + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 4294967295: + raise ValueError("A Modular ControlNet route seed must be a canonical integer from 0 through 4294967295.") + if not isinstance(generator, torch.Generator) or generator.initial_seed() != seed: + raise ValueError("The post-ControlNet generator does not match the validated Modular route seed.") + + if route_state is None: + paired_latents_ref = None + processed_mask_image = None + mask_overlay_kwargs = None + inpaint = False + else: + validate_controlnet_input_route_state( + route_state, + binding=binding, + model_type=binding._model_type, + seed=seed, + ) + paired_latents_ref = route_state._paired_latents_ref + processed_mask_image = route_state._processed_mask_image + mask_overlay_kwargs = route_state._mask_overlay_kwargs + inpaint = route_state._inpaint + + return _new_route_state( + stage=_CONTROLNET_TO_DENOISE, + binding=binding, + seed=seed, + contract=_QWEN_ROUTE_CONTRACT, + payload=_QwenRoutePayload( + generator_snapshot=_clone_generator(generator), + processed_mask_image=processed_mask_image, + mask_overlay_kwargs=dict(mask_overlay_kwargs) if mask_overlay_kwargs is not None else None, + inpaint=inpaint, + paired_latents_ref=paired_latents_ref, + paired_control_latents_ref=_paired_latents_reference( + control_image_latents, + label="ControlNet image latents", + allow_list=True, + ), + standalone_controlnet_binding=controlnet_binding, + ), + ) + + +def validate_controlnet_input_route_state(route_state, *, binding, model_type, seed): + """Validate an optional ImageEncode route without receiving its large latent edge. + + Image latents remain directly connected from ImageEncode to Denoise. This + stage authenticates the route provenance and keeps its exact weak pairing + alive so Denoise can compare the typed edge after ControlNet has run. + """ + + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 4294967295: + raise ValueError("A Modular route seed must be a canonical integer from 0 through 4294967295.") + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._stage != _ENCODE_TO_DENOISE: + raise ValueError("The Modular route state is connected to the wrong action stage.") + if route_state._binding is not binding: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + if binding._model_type != model_type: + raise ValueError("The Modular route state belongs to a different pipeline class.") + if seed != route_state._seed: + raise ValueError("ControlNet seed must match the seed used by the preceding VAE encoder route.") + generator = route_state._generator_snapshot + if not isinstance(generator, torch.Generator) or generator.initial_seed() != route_state._seed: + raise ValueError("The Modular route generator snapshot no longer matches its originating seed.") + _require_paired_latents_resident(route_state._paired_latents_ref, label="VAE image latents") + + +def consume_controlnet_input_route_state( + route_state, + *, + binding, + model_type, + seed, + execution_device, +): + """Materialize a retry-safe post-ImageEncode generator for ControlNet.""" + + validate_controlnet_input_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + ) + generator = route_state._generator_snapshot + if not _devices_compatible(generator.device, execution_device): + raise ValueError( + "The Modular route generator device is incompatible with the ControlNet execution device; " + "rerun the connected model actions on one execution path." + ) + return _clone_generator(generator) + + +def validate_controlnet_route_state( + route_state, + *, + binding, + model_type, + seed, + image_latents, + control_image_latents, + controlnet_component, +): + """Validate the ControlNet-to-Denoise stage before pipeline initialization.""" + + if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 4294967295: + raise ValueError("A Modular route seed must be a canonical integer from 0 through 4294967295.") + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._stage != _CONTROLNET_TO_DENOISE: + raise ValueError("The Modular route state is connected to the wrong action stage.") + if route_state._binding is not binding: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + if binding._model_type != model_type: + raise ValueError("The Modular route state belongs to a different pipeline class.") + if seed != route_state._seed: + raise ValueError("Denoise seed must match the seed used by the preceding ControlNet route.") + generator = route_state._generator_snapshot + if not isinstance(generator, torch.Generator) or generator.initial_seed() != route_state._seed: + raise ValueError("The Modular route generator snapshot no longer matches its originating seed.") + require_route_state_current_publication(route_state, label="ControlNet route state") + require_standalone_component_binding( + controlnet_component, + label="ControlNet model", + expected_kind="controlnet", + expected_binding=route_state._standalone_controlnet_binding, + ) + _require_optional_paired_latents( + route_state._paired_latents_ref, + image_latents, + label="VAE image latents", + ) + _require_paired_latents_reference( + route_state._paired_control_latents_ref, + control_image_latents, + label="ControlNet image latents", + ) + + +def validate_denoise_route_state( + route_state, + *, + binding, + model_type, + seed, + image_latents, + mask=None, + masked_image_latents=None, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, + control_image_latents=None, + controlnet_component=None, + control_mode=None, + controlnet_bundle_present=False, + ip_adapter_present=False, + image_embeds=None, + image_condition_latents=None, + height=None, + width=None, + num_frames=None, + transformer_component=None, +): + """Validate either the direct VAE or ControlNet route accepted by Denoise.""" + + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._contract == _WAN_ROUTE_CONTRACT: + if any( + value is not None + for value in ( + image_latents, + mask, + masked_image_latents, + control_image_latents, + controlnet_component, + ) + ) or controlnet_bundle_present or ip_adapter_present: + raise ValueError("Combined Wan I2V route and image/inpaint/control adapter state is not enabled.") + return validate_wan_vae_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_embeds=image_embeds, + image_condition_latents=image_condition_latents, + height=height, + width=width, + num_frames=num_frames, + vae_component=vae_component, + transformer_component=transformer_component, + ) + if route_state._contract == _SDXL_ROUTE_CONTRACT: + if route_state._stage != _ENCODE_TO_DENOISE: + raise ValueError("The SDXL route state is connected to the wrong action stage.") + if control_image_latents is not None: + raise ValueError("SDXL ControlNet does not accept prepared Qwen ControlNet latents.") + if controlnet_bundle_present != (controlnet_component is not None): + raise ValueError("SDXL ControlNet requires one exact connected component bundle.") + if controlnet_component is not None: + if control_mode is not None and ( + type(control_mode) is not int or not 0 <= control_mode < SDXL_UNION_CONTROL_MODE_LIMIT + ): + raise ValueError("SDXL ControlNet Union mode must be one bounded canonical integer.") + require_sdxl_controlnet_component_binding( + controlnet_component, + union=control_mode is not None, + ) + elif control_mode is not None: + raise ValueError("SDXL ControlNet Union mode requires one exact connected component bundle.") + return validate_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + vae_component=vae_component, + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ) + if route_state._stage == _CONTROLNET_TO_DENOISE: + return validate_controlnet_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_latents=image_latents, + control_image_latents=control_image_latents, + controlnet_component=controlnet_component, + ) + if controlnet_bundle_present or control_image_latents is not None or controlnet_component is not None: + raise ValueError("A Qwen ControlNet bundle requires its matching ControlNet route state.") + return validate_encoder_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + vae_component=vae_component, + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ) + + +def consume_denoise_route_state( + route_state, + *, + binding, + model_type, + seed, + execution_device, + image_latents, + mask=None, + masked_image_latents=None, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, + control_image_latents=None, + controlnet_component=None, + control_mode=None, + controlnet_bundle_present=False, + ip_adapter_present=False, + image_embeds=None, + image_condition_latents=None, + height=None, + width=None, + num_frames=None, + transformer_component=None, +): + """Materialize a fresh generator from either accepted pre-Denoise route stage.""" + + if _is_issued_route_state(route_state) and route_state._contract == _WAN_ROUTE_CONTRACT: + return consume_wan_vae_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + execution_device=execution_device, + image_embeds=image_embeds, + image_condition_latents=image_condition_latents, + height=height, + width=width, + num_frames=num_frames, + vae_component=vae_component, + transformer_component=transformer_component, + ) + + validate_denoise_route_state( + route_state, + binding=binding, + model_type=model_type, + seed=seed, + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + vae_component=vae_component, + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + control_image_latents=control_image_latents, + controlnet_component=controlnet_component, + control_mode=control_mode, + controlnet_bundle_present=controlnet_bundle_present, + ip_adapter_present=ip_adapter_present, + ) + generator = route_state._generator_snapshot + if not _devices_compatible(generator.device, execution_device): + raise ValueError( + "The Modular route generator device is incompatible with the Denoise execution device; " + "rerun the connected model actions on one execution path." + ) + values = { + "generator": _clone_generator(generator), + "processed_mask_image": route_state._processed_mask_image, + } + if route_state._contract == _SDXL_ROUTE_CONTRACT: + values.update( + mask=mask, + masked_image_latents=masked_image_latents, + crops_coords=route_state._payload._crops_coords, + ) + return values + + +def issue_decode_route_state( + route_state, + *, + binding, + actual_mask=None, + latents, + vae_component=None, + transformer_component=None, + execution_device=None, +): + """Advance one validated encoder route using the mask returned by Denoise.""" + + if not _is_issued_route_state(route_state) or route_state._stage not in { + _ENCODE_TO_DENOISE, + _CONTROLNET_TO_DENOISE, + }: + raise ValueError("Only a valid pre-Denoise route can advance to Decode.") + if route_state._binding is not binding: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + require_route_state_current_publication(route_state, label="Denoise input route") + if route_state._contract == _QWEN_ROUTE_CONTRACT: + if actual_mask is not None and type(actual_mask) is not torch.Tensor: + raise TypeError("The Denoise inpaint mask must be a Torch tensor.") + actual_inpaint = actual_mask is not None + if actual_inpaint != route_state._inpaint: + raise ValueError("The Denoise mask result does not match the VAE encoder route.") + payload = _QwenRoutePayload( + generator_snapshot=None, + processed_mask_image=None, + mask_overlay_kwargs=route_state._mask_overlay_kwargs, + inpaint=actual_inpaint, + paired_latents_ref=_paired_latents_reference(latents, label="Denoise latents"), + ) + elif route_state._contract == _SDXL_ROUTE_CONTRACT: + if actual_mask is not None: + raise ValueError("SDXL Denoise must not publish hidden Qwen mask state.") + _require_sdxl_vae_provenance(route_state._payload, vae_component) + _validate_sdxl_latent_tensor( + latents, + label="SDXL Denoise latents", + latent_channels=route_state._payload._vae_latent_channels, + scale_factor=route_state._payload._vae_scale_factor, + ) + payload = _SdxlRoutePayload( + generator_snapshot=None, + inpaint=route_state._inpaint, + paired_latents_ref=_paired_latents_reference(latents, label="Denoise latents"), + padding_mask_crop=route_state._payload._padding_mask_crop, + crops_coords=route_state._payload._crops_coords, + original_image_snapshot=route_state._payload._original_image_snapshot, + original_mask_snapshot=route_state._payload._original_mask_snapshot, + vae_ref=route_state._payload._vae_ref, + vae_latent_channels=route_state._payload._vae_latent_channels, + vae_scale_factor=route_state._payload._vae_scale_factor, + ) + else: + if route_state._contract != _WAN_ROUTE_CONTRACT: + raise ValueError("The Denoise route carries an unknown state contract.") + if actual_mask is not None: + raise ValueError("Wan Denoise must not publish hidden mask state.") + input_payload = route_state._payload + z_dim, spatial_scale, temporal_scale = _require_wan_vae_provenance(input_payload, vae_component) + transformer_seal = wan_transformer_contract_from_component( + transformer_component, + workflow=input_payload._workflow, + ) + _validate_wan_video_tensor( + latents, + label="Wan Denoise latents", + channels=z_dim, + num_frames=input_payload._num_frames, + height=input_payload._second_height, + width=input_payload._second_width, + spatial_scale=spatial_scale, + temporal_scale=temporal_scale, + ) + if execution_device is None or not _devices_compatible(latents.device, execution_device): + raise ValueError("Wan Denoise output latents must be resident on the Denoise execution device.") + source_image = input_payload._source_image_ref() + last_image = input_payload._last_image_ref() if input_payload._last_image_ref is not None else None + if source_image is None or (input_payload._last_image_ref is not None and last_image is None): + raise ValueError("Wan source media paired with this route is no longer resident.") + _require_wan_media_snapshot(_wan_payload_media_snapshot(input_payload), source_image, last_image) + _require_paired_latents_resident(input_payload._image_embeds_ref, label="Wan image embeddings") + _require_paired_latents_resident( + input_payload._image_condition_latents_ref, + label="Wan image condition latents", + ) + payload = _WanRoutePayload( + generator_snapshot=None, + paired_latents_ref=_paired_latents_reference(latents, label="Wan Denoise latents"), + image_embeds_ref=input_payload._image_embeds_ref, + image_condition_latents_ref=input_payload._image_condition_latents_ref, + source_image_ref=input_payload._source_image_ref, + source_image_seal=input_payload._source_image_seal, + last_image_ref=input_payload._last_image_ref, + last_image_seal=input_payload._last_image_seal, + workflow=input_payload._workflow, + requested_height=input_payload._requested_height, + requested_width=input_payload._requested_width, + first_height=input_payload._first_height, + first_width=input_payload._first_width, + second_height=input_payload._second_height, + second_width=input_payload._second_width, + num_frames=input_payload._num_frames, + image_encoder_ref=input_payload._image_encoder_ref, + image_encoder_config_seal=input_payload._image_encoder_config_seal, + image_processor_ref=input_payload._image_processor_ref, + image_processor_config_seal=input_payload._image_processor_config_seal, + image_encoder_execution_device=input_payload._image_encoder_execution_device, + vae_ref=input_payload._vae_ref, + video_processor_ref=input_payload._video_processor_ref, + video_processor_config_seal=input_payload._video_processor_config_seal, + vae_config_seal=input_payload._vae_config_seal, + vae_execution_device=input_payload._vae_execution_device, + transformer_ref=_component_identity_reference( + transformer_component, + label="Connected Wan transformer", + ), + transformer_config_seal=transformer_seal, + ) + return _new_route_state( + stage=_DENOISE_TO_DECODE, + binding=binding, + seed=route_state._seed, + contract=route_state._contract, + payload=payload, + ) + + +def issue_normal_decode_route_state( + *, + binding, + latents, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, +): + """Bind a text/control Denoise result to normal Decode semantics.""" + + if not _is_issued_binding(binding): + raise ValueError("Cannot issue Decode route state for an invalid ModelsLoader binding.") + if binding._model_type not in SUPPORTED_ROUTE_MODEL_TYPES: + raise ValueError(f"Pipeline '{binding._model_type}' does not declare opaque route-state support.") + contract = route_contract_for_model_type(binding._model_type) + if contract == _WAN_ROUTE_CONTRACT: + raise ValueError("Wan image-to-video Denoise requires its preceding image/VAE route state.") + if contract == _SDXL_ROUTE_CONTRACT: + if vae_latent_channels is None or vae_scale_factor is None: + raise ValueError("A normal SDXL Decode route requires the exact connected VAE geometry.") + if sdxl_vae_geometry_from_component(vae_component) != ( + vae_latent_channels, + vae_scale_factor, + ): + raise ValueError("A normal SDXL Decode route must match the exact connected VAE component.") + _validate_sdxl_latent_tensor( + latents, + label="SDXL Denoise latents", + latent_channels=vae_latent_channels, + scale_factor=vae_scale_factor, + ) + return _new_route_state( + stage=_DENOISE_TO_DECODE, + binding=binding, + seed=None, + contract=contract, + payload=( + _SdxlRoutePayload( + generator_snapshot=None, + inpaint=False, + paired_latents_ref=_paired_latents_reference(latents, label="Denoise latents"), + vae_ref=_component_identity_reference(vae_component, label="Connected SDXL VAE"), + vae_latent_channels=vae_latent_channels, + vae_scale_factor=vae_scale_factor, + ) + if contract == _SDXL_ROUTE_CONTRACT + else _QwenRoutePayload( + generator_snapshot=None, + processed_mask_image=None, + mask_overlay_kwargs=None, + inpaint=False, + paired_latents_ref=_paired_latents_reference(latents, label="Denoise latents"), + ) + ), + ) + + +def consume_decode_route_state( + route_state, + *, + binding, + model_type, + latents, + vae_component=None, + vae_latent_channels=None, + vae_scale_factor=None, + video_processor=None, + execution_device=None, + materialize_overlay=True, +): + """Validate Denoise-to-Decode state and return only normal decode kwargs.""" + + if not _is_issued_route_state(route_state): + raise ValueError("The Modular route state was not issued by this backend process.") + if route_state._stage != _DENOISE_TO_DECODE: + raise ValueError("The Modular route state is connected to the wrong action stage.") + if route_state._binding is not binding: + raise ValueError("The Modular route state comes from a different Models Loader execution.") + if binding._model_type != model_type: + raise ValueError("The Modular route state belongs to a different pipeline class.") + if route_state._contract != route_contract_for_model_type(model_type): + raise ValueError("The Modular route state belongs to a different pipeline state contract.") + _require_paired_latents(route_state, latents, label="Denoise latents") + if route_state._contract == _SDXL_ROUTE_CONTRACT: + payload = route_state._payload + _require_sdxl_vae_provenance(payload, vae_component) + _validate_sdxl_latent_tensor( + latents, + label="SDXL Denoise latents", + latent_channels=payload._vae_latent_channels, + scale_factor=payload._vae_scale_factor, + ) + if vae_latent_channels is not None or vae_scale_factor is not None: + if (vae_latent_channels, vae_scale_factor) != ( + payload._vae_latent_channels, + payload._vae_scale_factor, + ): + raise ValueError("The connected Decode VAE geometry does not match the Denoise route.") + decode_inputs = None + if payload._padding_mask_crop is not None: + if ( + payload._crops_coords is None + or payload._original_image_snapshot is None + or payload._original_mask_snapshot is None + ): + raise ValueError("The sealed SDXL crop-overlay route is incomplete.") + if materialize_overlay: + decode_inputs = { + "image": _materialize_overlay_media(payload._original_image_snapshot), + "mask_image": _materialize_overlay_media(payload._original_mask_snapshot), + "padding_mask_crop": payload._padding_mask_crop, + "crops_coords": payload._crops_coords, + } + return { + "contract": _SDXL_ROUTE_CONTRACT, + "inpaint": route_state._inpaint, + "decode_inputs": decode_inputs, + "mask_overlay_kwargs": None, + } + if route_state._contract == _WAN_ROUTE_CONTRACT: + payload = route_state._payload + z_dim, spatial_scale, temporal_scale = _require_wan_vae_provenance(payload, vae_component) + if video_processor is not None: + require_wan_video_processor(video_processor) + if execution_device is not None and not _devices_compatible(latents.device, execution_device): + raise ValueError("Wan Denoise latents must be resident on the Decode execution device.") + source_image = payload._source_image_ref() + last_image = payload._last_image_ref() if payload._last_image_ref is not None else None + if source_image is None or (payload._last_image_ref is not None and last_image is None): + raise ValueError("Wan source media paired with this route is no longer resident.") + _require_wan_media_snapshot(_wan_payload_media_snapshot(payload), source_image, last_image) + _require_paired_latents_resident(payload._image_embeds_ref, label="Wan image embeddings") + _require_paired_latents_resident( + payload._image_condition_latents_ref, + label="Wan image condition latents", + ) + transformer = _require_component_reference( + payload._transformer_ref, + None, + label="Wan transformer", + require_connected=False, + ) + if ( + wan_transformer_contract_from_component(transformer, workflow=payload._workflow) + != payload._transformer_config_seal + ): + raise ValueError("The Wan transformer contract changed after Denoise execution.") + _validate_wan_video_tensor( + latents, + label="Wan Denoise latents", + channels=z_dim, + num_frames=payload._num_frames, + height=payload._second_height, + width=payload._second_width, + spatial_scale=spatial_scale, + temporal_scale=temporal_scale, + ) + return { + "contract": _WAN_ROUTE_CONTRACT, + "inpaint": False, + "decode_inputs": None, + "mask_overlay_kwargs": None, + } + return { + "contract": _QWEN_ROUTE_CONTRACT, + "inpaint": route_state._inpaint, + "decode_inputs": None, + "mask_overlay_kwargs": ( + dict(route_state._mask_overlay_kwargs) if route_state._mask_overlay_kwargs is not None else None + ), + } diff --git a/modules/ModularDiffusers/schedulers.py b/modules/ModularDiffusers/schedulers.py index b068281..79a8fc8 100644 --- a/modules/ModularDiffusers/schedulers.py +++ b/modules/ModularDiffusers/schedulers.py @@ -1,11 +1,12 @@ # Derived from cubiq/Mellon@5fd242921d13bff9fb03f4de405fdd39c2335e1f; modified by MoDiff. import logging -from diffusers import ComponentSpec +from diffusers import ComponentSpec, SchedulerMixin from modiff.NodeBase import NodeBase -from . import components +from . import MODULAR_SCHEDULER_OPTIONS, components +from .pipeline_schema import MAX_SCHEDULER_OPTIONS logger = logging.getLogger("modiff") @@ -295,7 +296,17 @@ class Scheduler(NodeBase): resizable = True skipParamsCheck = True params = { - "scheduler_in": {"label": "Scheduler", "display": "input", "type": "diffusers_auto_model"}, + "scheduler_in": { + "label": "Scheduler", + "display": "input", + "type": "diffusers_auto_model", + "onSignal": { + "action": "value", + "target": "scheduler", + "prop": "options", + "data": MODULAR_SCHEDULER_OPTIONS, + }, + }, "scheduler": { "label": "Scheduler", "fieldOptions": {"loading": True}, @@ -332,8 +343,21 @@ class Scheduler(NodeBase): }, } + def _selected_scheduler(self, scheduler): + model_type = self.get_signal_value("scheduler_in") + allowed = MODULAR_SCHEDULER_OPTIONS.get(model_type) if isinstance(model_type, str) else None + if ( + not isinstance(scheduler, str) + or not isinstance(allowed, list) + or len(allowed) > MAX_SCHEDULER_OPTIONS + or scheduler not in SCHEDULER_CONFIGS + or scheduler not in allowed + ): + raise ValueError("Scheduler requires a class allowed by the connected reviewed Modular pipeline.") + return scheduler + def updateNode(self, values, ref): - value = values.get("scheduler") + value = self._selected_scheduler(values.get("scheduler")) params = SCHEDULER_CONFIGS.get(value, {}) self.send_node_definition(params) @@ -344,8 +368,16 @@ def execute(self, scheduler_in, scheduler, **kwargs): logger.debug(f" - scheduler: {scheduler}") logger.debug(f" - kwargs: {kwargs}") + scheduler = self._selected_scheduler(scheduler) scheduler_component = components.get_one(scheduler_in["model_id"]) scheduler_cls = getattr(__import__("diffusers", fromlist=[scheduler]), scheduler) + current_scheduler_cls = type(scheduler_component) + compatible = set(getattr(current_scheduler_cls, "_compatibles", ())) + if ( + not issubclass(scheduler_cls, SchedulerMixin) + or (scheduler_cls is not current_scheduler_cls and scheduler not in compatible) + ): + raise ValueError("Scheduler replacement is incompatible with the connected scheduler component.") scheduler_options = {} for key, value in kwargs.items(): @@ -366,6 +398,8 @@ def execute(self, scheduler_in, scheduler, **kwargs): default_creation_method="from_config", ) new_scheduler = schedule_spec.create(**scheduler_options) + if type(new_scheduler) is not scheduler_cls: + raise ValueError("Scheduler replacement did not construct the exact reviewed scheduler class.") comp_id = components.add(name="scheduler", component=new_scheduler, collection=self.node_id) logger.debug(f" Scheduler: new_scheduler: {new_scheduler}") diff --git a/modules/Spandrel/main.py b/modules/Spandrel/main.py index 7df57ea..2de2b31 100644 --- a/modules/Spandrel/main.py +++ b/modules/Spandrel/main.py @@ -30,7 +30,14 @@ def execute(self, **kwargs): if not model_path: raise ValueError("Model ID is required") - if model_source == 'hub': + exact_artifact = isinstance(model_id, dict) and any( + model_id.get(key) not in (None, '') for key in ('revision', 'sha256', 'byteSize') + ) + if exact_artifact: + from modiff.controlled_artifacts import resolve_upscaler_artifact + + model_path = str(resolve_upscaler_artifact(model_id).path) + elif model_source == 'hub': from utils.huggingface import cached_file_path if model_path.endswith((".safetensors", ".pt", ".pth", ".ckpt", ".pkl", ".bin")): diff --git a/scripts/qualify_optional_runtime.py b/scripts/qualify_optional_runtime.py new file mode 100644 index 0000000..028043d --- /dev/null +++ b/scripts/qualify_optional_runtime.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Run the portable, fail-closed optional-runtime qualification workload. + +This tool is deliberately outside the product API. It temporarily projects the +future qualified profile in memory, operates only in a newly-created temporary +managed root, and never changes the source-controlled action/cutover flags. +Run it from a clean prospective base where every staged distribution is absent. +""" + +from __future__ import annotations + +import argparse +from dataclasses import replace +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import stat +import subprocess +import sys +import tempfile +import time +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE_ID = "huggingface-transformers-peft-5.14.1-0.20.0" +MAX_EVIDENCE_BYTES = 256 * 1024 + +_PREFLIGHT_SCRIPT = r""" +from dataclasses import replace +from importlib import metadata +import json +from pathlib import Path +import sys + +root = Path(sys.argv[1]).resolve(strict=True) +sys.path.insert(0, str(root)) + +import modiff.optional_runtimes as optional_runtimes +import modiff.optimization_packages as optimization_packages + +profile_id = "huggingface-transformers-peft-5.14.1-0.20.0" +candidate = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[profile_id] +plan = optimization_packages._artifact_install_plan(candidate) +present = [] +for package in candidate.packages: + try: + version = metadata.version(package.distribution) + except metadata.PackageNotFoundError: + continue + present.append({"distribution": package.distribution, "version": str(version)[:128]}) +print(json.dumps({"plan": plan, "present": present}, sort_keys=True)) +""" + +_WORKLOAD_SCRIPT = r""" +from dataclasses import replace +import json +import math +import os +from pathlib import Path +import sys + +root = Path(sys.argv[1]).resolve(strict=True) +sys.path.insert(0, str(root)) + +import modiff.optional_runtimes as optional_runtimes + +profile_id = "huggingface-transformers-peft-5.14.1-0.20.0" +candidate = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[profile_id] +qualified = replace( + candidate, + contract_state="qualified", + cutover_ready=True, + install_action_available=True, + activation_available=True, +) +optional_runtimes.OPTIONAL_RUNTIME_PROFILES = {profile_id: qualified} + +import modiff.optimization_packages as optimization_packages + +optimization_packages.OPTIONAL_RUNTIME_PROFILES = {profile_id: qualified} +environment_id = optimization_packages.activate_runtime_overlay() +if not environment_id or os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS") != "active": + raise RuntimeError("the qualified child did not activate the reviewed overlay") + +import torch +from diffusers.utils import USE_PEFT_BACKEND +from peft import LoraConfig, get_peft_model +import peft +import transformers +from transformers import CLIPTextConfig, CLIPTextModel + +torch.manual_seed(7) +config = CLIPTextConfig( + vocab_size=32, + hidden_size=16, + intermediate_size=32, + projection_dim=16, + num_hidden_layers=1, + num_attention_heads=4, + max_position_embeddings=8, + bos_token_id=0, + eos_token_id=2, + pad_token_id=1, +) +model = CLIPTextModel(config) +model = get_peft_model( + model, + LoraConfig(r=2, lora_alpha=4, target_modules=["q_proj", "v_proj"]), +) +model.eval() +with torch.no_grad(): + output = model(input_ids=torch.tensor([[0, 3, 4, 2]], dtype=torch.long)).last_hidden_state +finite = bool(torch.isfinite(output).all().item()) +trainable = [name for name, value in model.named_parameters() if value.requires_grad] +if not finite or list(output.shape) != [1, 4, 16] or len(trainable) != 4 or USE_PEFT_BACKEND is not True: + raise RuntimeError("the no-weight Transformers/PEFT workload failed its invariant") + +print(json.dumps({ + "status": "passed", + "environmentId": environment_id, + "transformersVersion": transformers.__version__, + "peftVersion": peft.__version__, + "torchVersion": torch.__version__, + "shape": list(output.shape), + "finite": finite, + "trainableAdapterParameters": len(trainable), + "diffusersPeftBackend": bool(USE_PEFT_BACKEND), +}, sort_keys=True)) +""" + +_BASE_SCRIPT = r""" +from importlib import metadata +import json +import os +from pathlib import Path +import sys + +root = Path(sys.argv[1]).resolve(strict=True) +sys.path.insert(0, str(root)) +import modiff.optimization_packages as optimization_packages + +active = optimization_packages.activate_runtime_overlay() +present = [] +for name in json.loads(sys.argv[2]): + try: + metadata.version(name) + except metadata.PackageNotFoundError: + continue + present.append(name) +if active is not None or os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS") != "base" or present: + raise RuntimeError("rollback did not restore the clean base process") +print(json.dumps({"status": "passed", "activeEnvironment": None, "stagedPackagesPresent": present})) +""" + + +def _platform_name() -> str: + if sys.platform.startswith("win"): + return "windows" + if sys.platform == "darwin": + return "macos" + return "linux" + + +def _machine_name() -> str: + value = platform.machine().strip().lower().replace("-", "_") + return {"amd64": "x86_64", "aarch64": "arm64"}.get(value, value) + + +def _sha256(path: Path, *, maximum_bytes: int = 128 * 1024**2) -> tuple[str, int]: + details = path.lstat() + if not stat.S_ISREG(details.st_mode) or path.is_symlink() or details.st_size > maximum_bytes: + raise RuntimeError("the managed uv executable is not a bounded regular file") + digest = hashlib.sha256() + observed = 0 + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + observed += len(chunk) + digest.update(chunk) + if observed != details.st_size: + raise RuntimeError("the managed uv executable changed while it was read") + return digest.hexdigest(), observed + + +def _read_json_object(path: Path, *, maximum_bytes: int = 64 * 1024) -> dict[str, Any]: + details = path.lstat() + if not stat.S_ISREG(details.st_mode) or path.is_symlink() or details.st_size > maximum_bytes: + raise RuntimeError("the managed uv receipt is not a bounded regular file") + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise RuntimeError("the managed uv receipt is invalid") + return value + + +def copy_verified_uv(source_managed_root: Path, target_managed_root: Path) -> dict[str, str]: + """Copy only the reviewed uv executable and exact receipt into the isolated root.""" + + sys.path.insert(0, str(ROOT)) + from modiff.tool_locks import UV_TOOL_LOCKS + + lock = UV_TOOL_LOCKS.get((_platform_name(), _machine_name())) + if lock is None: + raise RuntimeError("this platform has no reviewed immutable uv executable") + source = source_managed_root.resolve(strict=True) / "tools" / "uv" + receipt = _read_json_object(source / "receipt.json") + relative = receipt.get("executable") + if not isinstance(relative, str) or not relative or len(relative) > 256: + raise RuntimeError("the managed uv receipt has no bounded executable") + relative_path = Path(relative) + if relative_path.is_absolute() or ".." in relative_path.parts: + raise RuntimeError("the managed uv receipt escapes its tool directory") + executable = (source / relative_path).resolve(strict=True) + executable.relative_to(source) + digest, _size = _sha256(executable) + if ( + receipt.get("schemaVersion") != 1 + or receipt.get("archiveSha256") != lock["archiveSha256"] + or receipt.get("executableSha256") != lock["executableSha256"] + or digest != lock["executableSha256"] + ): + raise RuntimeError("the managed uv executable or receipt failed its reviewed identity") + target = target_managed_root / "tools" / "uv" + target_executable = target / relative_path + target_executable.parent.mkdir(parents=True, exist_ok=False) + shutil.copy2(executable, target_executable, follow_symlinks=False) + copied_digest, _copied_size = _sha256(target_executable) + if copied_digest != digest: + raise RuntimeError("the isolated uv copy failed its identity check") + (target / "receipt.json").write_text( + json.dumps(receipt, indent=2, sort_keys=True, allow_nan=False) + "\n", + encoding="utf-8", + ) + return { + "archiveSha256": str(lock["archiveSha256"]), + "executableSha256": digest, + } + + +def _source_revision() -> dict[str, Any]: + try: + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + timeout=10, + ).stdout.strip() + dirty = bool( + subprocess.run( + ["git", "status", "--porcelain"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + timeout=10, + ).stdout.strip() + ) + except (OSError, subprocess.SubprocessError): + commit, dirty = "unavailable", True + return {"commit": commit, "dirty": dirty} + + +def _future_profile(): + sys.path.insert(0, str(ROOT)) + import modiff.optional_runtimes as optional_runtimes + + candidate = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[PROFILE_ID] + if ( + candidate.contract_state != "candidate_unqualified" + or candidate.cutover_ready + or candidate.install_action_available + or candidate.activation_available + ): + raise RuntimeError("qualification requires the production profile to remain dormant") + return candidate, replace( + candidate, + contract_state="qualified", + cutover_ready=True, + install_action_available=True, + activation_available=True, + ) + + +def qualification_preflight() -> dict[str, Any]: + candidate, qualified = _future_profile() + probe = _json_process(_PREFLIGHT_SCRIPT, str(ROOT), timeout=60) + plan = probe.get("plan") + present = probe.get("present") + if not isinstance(plan, list) or not isinstance(present, list): + raise RuntimeError("the qualification preflight returned an invalid contract") + uv_ready = False + try: + with tempfile.TemporaryDirectory(prefix="modiff-uv-preflight-") as temporary: + copy_verified_uv(ROOT / ".modiff", Path(temporary) / "managed") + uv_ready = True + except (OSError, RuntimeError, TypeError, ValueError): + pass + artifact_body = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode("utf-8") + return { + "schemaVersion": 1, + "status": "ready" if not present and uv_ready and sys.version_info[:2] == (3, 12) else "not_ready", + "platform": _platform_name(), + "machine": _machine_name(), + "pythonVersion": platform.python_version(), + "source": _source_revision(), + "profileId": candidate.id, + "candidateSpecDigest": candidate.spec_digest, + "qualificationSpecDigest": qualified.spec_digest, + "sourceFlagsDormant": True, + "cleanBase": not present, + "stagedPackagesPresent": present, + "managedUvReceiptPresent": uv_ready, + "artifactCount": len(plan), + "artifactBytes": sum(int(item["byteSize"]) for item in plan), + "artifactPlanDigest": "sha256:" + hashlib.sha256(artifact_body).hexdigest(), + } + + +def _json_process(script: str, *arguments: str, timeout: int = 300) -> dict[str, Any]: + environment = { + key: value + for key, value in os.environ.items() + if key.upper() not in {"PYTHONHOME", "PYTHONPATH"} + } + environment.update( + { + "HF_HUB_OFFLINE": "1", + "TRANSFORMERS_OFFLINE": "1", + "DIFFUSERS_OFFLINE": "1", + "HF_HUB_DISABLE_TELEMETRY": "1", + "DO_NOT_TRACK": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "TOKENIZERS_PARALLELISM": "false", + "CUDA_VISIBLE_DEVICES": "-1", + } + ) + result = subprocess.run( + [sys.executable, "-I", "-s", "-B", "-c", script, *arguments], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + raise RuntimeError("an isolated qualification process failed") + try: + value = json.loads(result.stdout.strip().splitlines()[-1]) + except (IndexError, json.JSONDecodeError) as exc: + raise RuntimeError("an isolated qualification process returned invalid evidence") from exc + if not isinstance(value, dict): + raise RuntimeError("an isolated qualification process returned an invalid contract") + return value + + +def _child(script: str, *arguments: str, timeout: int = 300) -> dict[str, Any]: + value = _json_process(script, *arguments, timeout=timeout) + if value.get("status") != "passed": + raise RuntimeError("an isolated qualification process did not pass") + return value + + +def run_qualification(*, consent: bool) -> dict[str, Any]: + if consent is not True: + raise RuntimeError("explicit --consent is required") + if "modiff.optimization_packages" in sys.modules or "modiff.runtime_overlays" in sys.modules: + raise RuntimeError("qualification must start in a fresh Python process") + preflight = qualification_preflight() + if preflight["status"] != "ready": + raise RuntimeError("the host is not a clean, installer-ready qualification base") + candidate, qualified = _future_profile() + progress: list[str] = [] + started = time.monotonic() + previous_managed_root = os.environ.get("MODIFF_MANAGED_ROOT") + try: + with tempfile.TemporaryDirectory(prefix="modiff-optional-runtime-qualification-") as temporary: + managed_root = Path(temporary).resolve(strict=True) / "managed" + managed_root.mkdir() + copy_verified_uv(ROOT / ".modiff", managed_root) + os.environ["MODIFF_MANAGED_ROOT"] = str(managed_root) + + import modiff.optional_runtimes as optional_runtimes + import modiff.optimization_packages as optimization_packages + + profiles = {PROFILE_ID: qualified} + optional_runtimes.OPTIONAL_RUNTIME_PROFILES = profiles + optimization_packages.OPTIONAL_RUNTIME_PROFILES = profiles + install = optimization_packages.install_optional_runtime( + PROFILE_ID, + qualified.spec_digest, + consent=True, + progress=lambda update: progress.append(str(update.get("phase") or "")), + ) + environment_id = install["environmentId"] + activation = optimization_packages.activate_optional_runtime_environment( + environment_id, + PROFILE_ID, + qualified.spec_digest, + consent=True, + ) + if activation.get("restartRequired") is not True: + raise RuntimeError("qualification activation did not require a fresh process") + workload = _child(_WORKLOAD_SCRIPT, str(ROOT), timeout=600) + rollback = optimization_packages.rollback_optional_runtime_environment(consent=True) + if rollback.get("restartRequired") is not True: + raise RuntimeError("qualification rollback did not require a fresh process") + base = _child( + _BASE_SCRIPT, + str(ROOT), + json.dumps([package.distribution for package in candidate.packages]), + ) + finally: + if previous_managed_root is None: + os.environ.pop("MODIFF_MANAGED_ROOT", None) + else: + os.environ["MODIFF_MANAGED_ROOT"] = previous_managed_root + return { + **preflight, + "status": "passed", + "elapsedSeconds": round(time.monotonic() - started, 3), + "install": { + "validationStatus": install.get("validation", {}).get("status"), + "requiresActivation": install.get("requiresActivation"), + "progressPhases": list(dict.fromkeys(progress)), + }, + "activation": {"restartRequired": activation.get("restartRequired")}, + "workload": {key: value for key, value in workload.items() if key != "environmentId"}, + "rollback": { + "restartRequired": rollback.get("restartRequired"), + "baseProcess": base, + }, + "sourceFlagsChanged": False, + "managedStateRetained": False, + } + + +def _write_evidence(path: Path, value: dict[str, Any]) -> None: + body = (json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n").encode("utf-8") + if len(body) > MAX_EVIDENCE_BYTES: + raise RuntimeError("the qualification evidence exceeds its safe bound") + path = path.absolute() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as output: + output.write(body) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--consent", action="store_true", help="Allow the networked temporary qualification run.") + parser.add_argument("--preflight-only", action="store_true", help="Inspect readiness without network or mutation.") + parser.add_argument("--evidence", type=Path, help="Create a bounded JSON evidence file (must not already exist).") + args = parser.parse_args(argv) + try: + result = qualification_preflight() if args.preflight_only else run_qualification(consent=args.consent) + if args.evidence: + _write_evidence(args.evidence, result) + print(json.dumps(result, indent=2, sort_keys=True, allow_nan=False)) + return 0 if result["status"] == "passed" or args.preflight_only else 1 + except Exception as exc: # keep public failure evidence bounded and path-free + failure = { + "schemaVersion": 1, + "status": "failed", + "errorCode": type(exc).__name__[:64], + "message": ( + "Optional-runtime qualification failed; inspect the local command output " + "and retry from a clean base." + ), + } + if args.evidence: + try: + _write_evidence(args.evidence, failure) + except Exception: + pass + print(json.dumps(failure, indent=2, sort_keys=True), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_app_managed_auxiliary_models.py b/tests/test_app_managed_auxiliary_models.py index 1cd5f6d..7c6ff66 100644 --- a/tests/test_app_managed_auxiliary_models.py +++ b/tests/test_app_managed_auxiliary_models.py @@ -1,15 +1,28 @@ +import hashlib +import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +import numpy as np import torch from diffusers import FlowMatchEulerDiscreteScheduler +from safetensors.numpy import save_file from modules.ModularDiffusers.adapters import Lora from modules.ModularDiffusers.loaders import apply_lora_scheduler_override from modules.Spandrel import MODULE_MAP as SPANDREL_MODULE_MAP from modules.Spandrel.main import Upscaler -from utils.huggingface import local_files_only +from utils.huggingface import CONFIG, local_files_only + + +LORA_REVISION = "a" * 40 + + +def _write_tiny_safetensors(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + save_file({"lora.weight": np.asarray([1.0], dtype=np.float32)}, str(path)) class AppManagedAuxiliaryModelTests(unittest.TestCase): @@ -27,32 +40,46 @@ def test_modular_lora_rejects_an_empty_selection(self): Lora("empty-lora").execute({"source": "hub", "value": ""}, 1.0) def test_modular_lora_resolves_a_hub_weight_only_from_app_cache(self): - cached_weight = Path("/cache/revision/style.safetensors") - with patch( - "utils.huggingface.cached_file_path", - return_value=str(cached_weight), - ): - result = Lora("cached-lora").execute( - {"source": "hub", "value": "example/style"}, - 0.75, - weight_name="style.safetensors", - )["lora"] - - self.assertEqual(result["lora_path"], str(cached_weight.parent)) - self.assertEqual(result["weight_name"], "style.safetensors") + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) + cached_weight = ( + cache_root + / "models--example--style" + / "snapshots" + / LORA_REVISION + / "style.safetensors" + ) + _write_tiny_safetensors(cached_weight) + digest = hashlib.sha256(cached_weight.read_bytes()).hexdigest() + with patch.dict(CONFIG.hf, {"cache_dir": str(cache_root)}): + with patch("utils.huggingface.cached_file_path", return_value=str(cached_weight)) as cached: + result = Lora("cached-lora").execute( + {"source": "hub", "value": "example/style"}, + 0.75, + weight_name="style.safetensors", + revision=LORA_REVISION, + expected_sha256=digest, + )["lora"] + + self.assertEqual(result["artifact"]["repository"], "example/style") + self.assertEqual(result["artifact"]["revision"], LORA_REVISION) + self.assertEqual(result["artifact"]["weight_name"], "style.safetensors") + self.assertEqual(cached.call_args.kwargs["revision"], LORA_REVISION) def test_modular_lora_carries_a_generic_scheduler_contract(self): - with patch("utils.huggingface.cached_file_path", return_value="/cache/revision/lightning.safetensors"): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) result = Lora("lightning-lora").execute( - {"source": "hub", "value": "example/lightning"}, + {"source": "local", "value": str(weight)}, 1.0, weight_name="lightning.safetensors", scheduler_class="FlowMatchEulerDiscreteScheduler", scheduler_config='{"base_shift": 1.0986122886681098, "shift_terminal": null}', )["lora"] - self.assertEqual(result["scheduler_class"], "FlowMatchEulerDiscreteScheduler") - self.assertIsNone(result["scheduler_config"]["shift_terminal"]) + self.assertEqual(result["scheduler"]["class_name"], "FlowMatchEulerDiscreteScheduler") + self.assertIsNone(result["scheduler"]["config"]["shift_terminal"]) def test_loader_applies_explicit_lora_scheduler_contract(self): class FakePipeline: @@ -63,14 +90,18 @@ def update_components(self, **components): for name, component in components.items(): setattr(self, name, component) - pipeline = FakePipeline() - scheduler = apply_lora_scheduler_override( - pipeline, - { - "scheduler_class": "FlowMatchEulerDiscreteScheduler", - "scheduler_config": {"base_shift": 1.0986122886681098, "shift_terminal": None}, - }, - ) + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) + descriptor = Lora("lightning-lora").execute( + {"source": "local", "value": str(weight)}, + 1.0, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.0986122886681098, "shift_terminal": None}, + )["lora"] + pipeline = FakePipeline() + scheduler = apply_lora_scheduler_override(pipeline, descriptor) self.assertIs(pipeline.scheduler, scheduler) self.assertAlmostEqual(scheduler.config.base_shift, 1.0986122886681098) @@ -83,8 +114,15 @@ def test_modular_lora_fails_if_model_manager_has_not_installed_weight(self): {"source": "hub", "value": "example/style"}, 1.0, weight_name="style.safetensors", + revision=LORA_REVISION, + expected_sha256="b" * 64, ) + def test_modular_lora_model_selection_does_not_publish_a_blank_class_filter(self): + options = Lora.params["model"]["fieldOptions"] + self.assertEqual(options["sources"], ["hub", "local"]) + self.assertNotIn("filter", options) + def test_hub_upscaler_requires_a_pinned_filename(self): with self.assertRaisesRegex(ValueError, "pinned filename"): Upscaler("unpinned-upscaler").execute( @@ -104,6 +142,32 @@ def test_hub_upscaler_missing_from_app_cache_fails_before_model_load(self): ) loader.assert_not_called() + def test_exact_upscaler_selection_is_revalidated_before_model_load(self): + selection = { + "source": "hub", + "value": "nateraw/real-esrgan/RealESRGAN_x2plus.pth", + "revision": "42efb9c3eeed1f5c0c8a626cf5f7f4481dfbb094", + "sha256": "4" * 64, + "byteSize": 123, + } + node = Upscaler("exact-upscaler") + model = MagicMock() + model.eval.return_value = model + node.mm_add = MagicMock() + node.mm_exec = MagicMock(return_value=[]) + with ( + patch( + "modiff.controlled_artifacts.resolve_upscaler_artifact", + return_value=SimpleNamespace(path=Path("C:/managed/exact.pth")), + ) as resolve, + patch("modules.Spandrel.main.ModelLoader") as loader, + ): + loader.return_value.load_from_file.return_value = model + self.assertEqual(node.execute(image=object(), model_id=selection, device="cpu"), {"output": []}) + + resolve.assert_called_once_with(selection) + loader.return_value.load_from_file.assert_called_once_with("C:\\managed\\exact.pth") + def test_upscaler_tiles_and_stitches_model_agnostic_integer_scale(self): class FakeUpscaler: device = "cpu" diff --git a/tests/test_audio_operations.py b/tests/test_audio_operations.py index b99d99e..a19a9f1 100644 --- a/tests/test_audio_operations.py +++ b/tests/test_audio_operations.py @@ -2,6 +2,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch import numpy as np from scipy.io import wavfile @@ -9,10 +10,140 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from modules.Audio.main import Export, FitDuration, Join, MatchLoudness, _atempo_factors, _audio_to_numpy, _read_wav +from modules.Audio.main import ( + Export, + FitDuration, + Join, + MatchLoudness, + TrimPad, + _atempo_factors, + _audio_to_numpy, + _read_wav, +) class AudioExportTests(unittest.TestCase): + def test_explicit_sample_layout_preserves_square_stereo_payloads(self): + square = np.asarray([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + + frames_first, _ = _audio_to_numpy( + { + "samples": square, + "sample_rate": 48000, + "channels": 2, + "sample_layout": "frames_first", + } + ) + channels_first, _ = _audio_to_numpy( + { + "samples": square, + "sample_rate": 48000, + "channels": 2, + "sample_layout": "channels_first", + } + ) + + np.testing.assert_array_equal(frames_first, square) + np.testing.assert_array_equal(channels_first, square.T) + + def test_trim_pad_consumes_a_square_diffusers_channels_first_audio_object(self): + channels_first = np.asarray([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + + result = TrimPad().execute( + audio={ + "samples": channels_first, + "sample_layout": "channels_first", + "sample_rate": 48000, + "channels": 2, + "duration_seconds": 2 / 48000, + }, + target_sample_rate=48000, + ) + + self.assertEqual(result["output"]["sample_layout"], "frames_first") + self.assertEqual(result["output"]["channels"], 2) + np.testing.assert_array_equal(result["output"]["samples"], channels_first.T) + + def test_trim_pad_rejects_contradictory_layout_and_channel_metadata(self): + cases = ( + ( + { + "samples": np.zeros((2, 3), dtype=np.float32), + "sample_layout": "frames_first", + "sample_rate": 48000, + "channels": 2, + }, + "frames_first identifies 3", + ), + ( + { + "samples": np.zeros((3, 2), dtype=np.float32), + "sample_layout": "channels_first", + "sample_rate": 48000, + "channels": 2, + }, + "channels_first identifies 3", + ), + ) + for payload, message in cases: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + TrimPad().execute(audio=payload, target_sample_rate=48000) + + for channels in (True, 0, -1, 1.5, float("nan"), float("inf"), "two"): + with self.subTest(channels=channels): + with self.assertRaisesRegex(ValueError, "channels metadata must be a positive integer"): + TrimPad().execute( + audio={ + "samples": np.zeros((3, 2), dtype=np.float32), + "sample_layout": "frames_first", + "sample_rate": 48000, + "channels": channels, + }, + target_sample_rate=48000, + ) + + def test_trim_pad_uses_decoder_provenance_for_path_backed_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "two-channel.wav" + wavfile.write(path, 48000, np.zeros((3, 2), dtype=np.int16)) + + with patch("modules.Audio.main._read_wav") as decoder: + with self.assertRaisesRegex(ValueError, "Decoded audio files use frames_first"): + TrimPad().execute( + audio={ + "samples": str(path), + "sample_layout": "channels_first", + "sample_rate": 48000, + "channels": 2, + }, + target_sample_rate=48000, + ) + decoder.assert_not_called() + + with self.assertRaisesRegex(ValueError, "declares 1 channels, but the decoded file has 2"): + TrimPad().execute( + audio={ + "samples": str(path), + "sample_layout": "frames_first", + "sample_rate": 48000, + "channels": 1, + }, + target_sample_rate=48000, + ) + + result = TrimPad().execute( + audio={ + "samples": str(path), + "sample_layout": "frames_first", + "sample_rate": 48000, + "channels": 2, + }, + target_sample_rate=48000, + ) + self.assertEqual(result["output"]["samples"].shape, (3, 2)) + self.assertEqual(result["output"]["sample_layout"], "frames_first") + def test_unsigned_pcm_midpoint_is_silence_for_arrays_and_wav_files(self): source = np.asarray([0, 128, 255], dtype=np.uint8) converted, _sample_rate = _audio_to_numpy({"samples": source, "sample_rate": 8000}) diff --git a/tests/test_auto_resource.py b/tests/test_auto_resource.py index 69fe1e4..d39d6ce 100644 --- a/tests/test_auto_resource.py +++ b/tests/test_auto_resource.py @@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from modiff.auto_resource import ( # noqa: E402 + AUTO_HISTORY_VERSION, AUTO_MODEL_REQUIREMENTS, FLUX_KONTEXT_NVFP4_REPO, QWEN_IMAGE_EDIT_PREQUANTIZED_REPO, @@ -16,6 +17,8 @@ READY_PROOF_STATUSES, WAN_VACE_REPO, Z_IMAGE_REPO, + _apply_history_to_candidates, + _auto_requirements_for_pair, _candidate_history_signature, _requirements_missing_for_dict, _requirements_missing, @@ -23,11 +26,13 @@ _validate_snapshot_shards, auto_resource_history_key, build_auto_resource_plan, + matching_auto_resource_success_history, record_auto_resource_failure, record_auto_resource_success, ) from modiff.diffusers_profiles import ( # noqa: E402 ACE_STEP_REPO, + DIFFUSERS_EXECUTION_PROFILES, FLUX_KREA_REPO, FLUX_SCHNELL_REPO, LTX_VIDEO_REPO, @@ -328,6 +333,19 @@ def test_qwen_on_constrained_cuda_prefers_prequantized_diffusers_artifact(self): self.assertFalse(selected["requiresLocalProbe"]) self.assertEqual(plan["readiness"], "ready") self.assertEqual(plan["schemaVersion"], 2) + self.assertTrue(plan["candidates"]) + for candidate in plan["candidates"]: + self.assertEqual(candidate["autoResourceSchemaVersion"], plan["schemaVersion"]) + self.assertEqual(candidate["executionProfileId"], "qwen-image:t2i-direct") + self.assertEqual( + candidate["studioExecutionSpecContract"], + { + "schemaVersion": 1, + "id": "qwen-image-2512:text-to-image:v1", + "contentHash": "studio-spec-v1-f53ab380", + "executionProfileId": "qwen-image:t2i-direct", + }, + ) self.assertEqual(plan["compatibility"]["state"], "ready") self.assertEqual(plan["compatibility"]["source"], "backend_auto_planner") @@ -363,6 +381,151 @@ def test_constrained_auto_selects_only_compatible_recipe_and_explains_every_reje ) self.assertTrue(explanation, candidate["id"]) + def test_unknown_model_task_pair_is_expert_only_even_with_an_installed_artifact_and_history(self): + payload = { + "form": { + "modelType": "BrandNewPipeline", + "mode": "text_to_image", + "modelRepo": "org/new-model", + "executionPath": "modular-diffusers", + } + } + hardware = self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120) + + with tempfile.TemporaryDirectory() as data_dir: + plan = self._plan( + payload, + repos=["org/new-model"], + hardware=hardware, + data_dir=data_dir, + ) + candidate = plan["candidates"][0] + recorded = record_auto_resource_success( + data_dir, + runtime_fingerprint={"resourceFingerprint": hardware["runtimeFingerprint"]}, + runtime_hints={"resourceMode": "auto", "autoResourcePlan": candidate}, + ) + replayed = self._plan( + payload, + repos=["org/new-model"], + hardware=hardware, + data_dir=data_dir, + ) + + self.assertIsNone(recorded) + for result in (plan, replayed): + self.assertFalse(result["exactPairDeclared"]) + self.assertFalse(result["canAutoRun"]) + self.assertIsNone(result["selectedCandidate"]) + self.assertIsNone(result["selectedInstallTarget"]) + self.assertEqual(result["readiness"], "manual_only") + self.assertEqual(result["compatibility"]["state"], "expert_only") + self.assertEqual(result["compatibility"]["action"]["type"], "switch_to_expert") + self.assertEqual(result["candidates"][0]["proof"]["status"], "manual_only") + self.assertFalse(result["candidates"][0]["exactPairDeclared"]) + self.assertIn("BrandNewPipeline:text_to_image", result["blockingReason"]) + + def test_known_model_with_unsupported_mode_is_expert_only(self): + plan = self._plan( + { + "form": { + "modelType": "FluxSchnellPipeline", + "mode": "audio_repaint", + "modelRepo": FLUX_SCHNELL_REPO, + } + }, + repos=[FLUX_SCHNELL_REPO], + hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), + ) + + self.assertFalse(plan["exactPairDeclared"]) + self.assertFalse(plan["canAutoRun"]) + self.assertIsNone(plan["selectedCandidate"]) + self.assertEqual(plan["readiness"], "manual_only") + self.assertEqual(plan["healthBadge"], "Expert only") + self.assertEqual(plan["compatibility"]["state"], "expert_only") + self.assertIn("FluxSchnellPipeline:audio_repaint", plan["blockingReason"]) + self.assertIn("text_to_image", plan["blockingReason"]) + + def test_removed_false_auto_modes_cannot_be_promoted_by_history(self): + cases = ( + ("QwenImageEditPlusModularPipeline", "inpaint"), + ("FluxReduxPipeline", "multi_image_reference_edit"), + ) + hardware = self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120) + + for model_type, mode in cases: + with self.subTest(model_type=model_type, mode=mode), tempfile.TemporaryDirectory() as data_dir: + requirements = AUTO_MODEL_REQUIREMENTS[model_type] + self.assertNotIn(mode, requirements["supportedTasks"]) + repo = requirements["defaultRepo"] + plan = self._plan( + { + "form": { + "modelType": model_type, + "mode": mode, + "modelRepo": repo, + } + }, + repos=[repo], + hardware=hardware, + data_dir=data_dir, + ) + candidate = plan["candidates"][0] + recorded = record_auto_resource_success( + data_dir, + runtime_fingerprint={"resourceFingerprint": hardware["runtimeFingerprint"]}, + runtime_hints={"resourceMode": "auto", "autoResourcePlan": candidate}, + ) + + self.assertIsNone(recorded) + self.assertFalse(plan["exactPairDeclared"]) + self.assertFalse(plan["canAutoRun"]) + self.assertIsNone(plan["selectedCandidate"]) + self.assertEqual(plan["readiness"], "manual_only") + self.assertEqual(plan["compatibility"]["state"], "expert_only") + self.assertIn(f"{model_type}:{mode}", plan["blockingReason"]) + + def test_auto_requirement_without_an_execution_profile_still_fails_closed(self): + requirements = AUTO_MODEL_REQUIREMENTS["FluxSchnellPipeline"] + inconsistent = { + **requirements, + "supportedTasks": [*requirements["supportedTasks"], "audio_repaint"], + } + with patch.dict(AUTO_MODEL_REQUIREMENTS, {"FluxSchnellPipeline": inconsistent}): + plan = self._plan( + { + "form": { + "modelType": "FluxSchnellPipeline", + "mode": "audio_repaint", + "modelRepo": FLUX_SCHNELL_REPO, + } + }, + repos=[FLUX_SCHNELL_REPO], + hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), + ) + + self.assertFalse(plan["exactPairDeclared"]) + self.assertFalse(plan["canAutoRun"]) + self.assertEqual(plan["readiness"], "manual_only") + + def test_profile_only_mode_without_an_auto_requirement_remains_expert_only(self): + plan = self._plan( + { + "form": { + "modelType": "FluxKontextPipeline", + "mode": "multi_image_reference_edit", + "modelRepo": AUTO_MODEL_REQUIREMENTS["FluxKontextPipeline"]["defaultRepo"], + } + }, + hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), + ) + + self.assertFalse(plan["exactPairDeclared"]) + self.assertFalse(plan["canAutoRun"]) + self.assertEqual(plan["readiness"], "manual_only") + self.assertEqual(plan["compatibility"]["state"], "expert_only") + def test_wan_uses_minimum_for_admission_and_keeps_recommended_metadata(self): plan = self._plan( {"form": {"modelType": "WanVACEPipeline", "mode": "text_to_video"}}, @@ -450,7 +613,7 @@ def test_nominal_capacity_tiers_allow_small_reported_total_shortfalls(self): self.assertEqual(missing, []) - def test_qwen_edit_prefers_apache_prequantized_install_on_nominal_16gb_cuda(self): + def test_qwen_edit_modular_does_not_offer_unprofiled_direct_prequantized_install(self): plan = self._plan( {"form": {"modelType": "QwenImageEditModularPipeline", "mode": "edit_image"}}, runtime=self._runtime(vram_gib=15.99, free_gib=14), @@ -458,12 +621,27 @@ def test_qwen_edit_prefers_apache_prequantized_install_on_nominal_16gb_cuda(self ) self.assertEqual(plan["status"], "needs_setup") - self.assertEqual(plan["selectedInstallTarget"]["repo"], QWEN_IMAGE_EDIT_PREQUANTIZED_REPO) - self.assertEqual(plan["candidates"][0]["resolvedArtifact"], QWEN_IMAGE_EDIT_PREQUANTIZED_REPO) - self.assertEqual(plan["compatibility"]["state"], "needs_model") - self.assertEqual(plan["compatibility"]["action"]["repo"], QWEN_IMAGE_EDIT_PREQUANTIZED_REPO) + self.assertIsNone(plan["selectedInstallTarget"]) + community = next( + candidate + for candidate in plan["candidates"] + if candidate["resolvedArtifact"] == QWEN_IMAGE_EDIT_PREQUANTIZED_REPO + ) + self.assertEqual(community["proof"]["status"], "manual_only") + self.assertEqual(community["loaderModule"], "modules.ModularDiffusers") + self.assertEqual(community["executionPath"], "modular-diffusers") + for candidate in plan["candidates"]: + self.assertEqual( + candidate["studioExecutionSpecContract"], + { + "schemaVersion": 1, + "id": "qwen-image-edit:edit-image:v1", + "contentHash": "studio-spec-v1-ae6a6ce8", + "executionProfileId": "qwen-edit:modular", + }, + ) - def test_qwen_edit_community_artifact_requires_explicit_workflow_confirmation(self): + def test_qwen_edit_unprofiled_community_artifact_cannot_become_auto_ready(self): hardware = self._hardware(vram_gib=15.99, free_gib=14, system_ram_gib=31.8) unconfirmed = self._plan( {"form": {"modelType": "QwenImageEditModularPipeline", "mode": "edit_image"}}, @@ -491,8 +669,12 @@ def test_qwen_edit_community_artifact_requires_explicit_workflow_confirmation(se repos=[QWEN_IMAGE_EDIT_PREQUANTIZED_REPO], hardware=hardware, ) - self.assertEqual(confirmed["selectedCandidate"]["resolvedArtifact"], QWEN_IMAGE_EDIT_PREQUANTIZED_REPO) - self.assertEqual(confirmed["selectedCandidate"]["proof"]["source"], "user_community_confirmation") + confirmed_candidate = next( + item for item in confirmed["candidates"] + if item["resolvedArtifact"] == QWEN_IMAGE_EDIT_PREQUANTIZED_REPO + ) + self.assertIsNone(confirmed["selectedCandidate"]) + self.assertEqual(confirmed_candidate["proof"]["status"], "manual_only") def test_normalized_runtime_mps_snapshot_satisfies_cuda_or_mps(self): plan = self._plan( @@ -553,6 +735,19 @@ def test_z_image_auto_uses_intel_xpu_without_cuda_offload_hooks(self): self.assertEqual(selected["offloadMode"], "none") self.assertFalse(selected["autoOffload"]) self.assertIsNone(selected["deviceMap"]) + self.assertEqual(selected["loaderModule"], "modules.DiffusersImage") + self.assertEqual(selected["loaderAction"], "LoadPipeline") + self.assertEqual(selected["executionPath"], "direct-diffusers-image") + self.assertEqual(selected["pipelineClass"], "ZImagePipeline") + self.assertEqual( + selected["studioExecutionSpecContract"], + { + "schemaVersion": 1, + "id": "z-image:text-to-image:v1", + "contentHash": "studio-spec-v1-0d3c1205", + "executionProfileId": "z-image:auto", + }, + ) def test_qwen_official_bf16_is_not_auto_ready_on_constrained_cuda_without_prequantized_artifact(self): plan = self._plan( @@ -611,19 +806,28 @@ def test_qwen_official_bf16_stays_on_device_when_vram_has_headroom(self): self.assertEqual(plan["selectedCandidate"]["offloadMode"], "none") self.assertEqual(plan["selectedCandidate"]["deviceMap"], "cuda") - def test_declared_qwen_edit_plus_profile_uses_generic_full_residency_metadata(self): + def test_declared_qwen_edit_plus_profiles_use_generic_full_residency_metadata(self): repo = "Qwen/Qwen-Image-Edit-2511" - plan = self._plan( - {"form": {"modelType": "QwenImageEditPlusModularPipeline", "mode": "edit_image", "offloadMode": "model_cpu"}}, - runtime=self._runtime(vram_gib=98, free_gib=96), - repos=[repo], - hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), - ) + for mode in ("edit_image", "multi_image_reference_edit"): + plan = self._plan( + { + "form": { + "modelType": "QwenImageEditPlusModularPipeline", + "mode": mode, + "offloadMode": "model_cpu", + } + }, + runtime=self._runtime(vram_gib=98, free_gib=96), + repos=[repo], + hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), + ) - self.assertEqual(plan["status"], "ready") - self.assertEqual(plan["selectedCandidate"]["resolvedArtifact"], repo) - self.assertEqual(plan["selectedCandidate"]["offloadMode"], "none") - self.assertEqual(plan["selectedCandidate"]["deviceMap"], "cuda") + self.assertEqual(plan["status"], "ready") + selected = plan["selectedCandidate"] + self.assertEqual(selected["resolvedArtifact"], repo) + self.assertEqual(selected["offloadMode"], "none") + self.assertEqual(selected["deviceMap"], "cuda") + self.assertEqual(selected["studioExecutionSpecContract"]["executionProfileId"], "qwen-edit-plus:modular") def test_qwen_control_uses_native_residency_on_98_gib(self): plan = self._plan( @@ -644,6 +848,39 @@ def test_qwen_control_uses_native_residency_on_98_gib(self): self.assertEqual(selected["resolvedArtifact"], QWEN_IMAGE_2512_REPO) self.assertEqual(selected["offloadMode"], "none") self.assertEqual(selected["deviceMap"], "cuda") + self.assertEqual(selected["studioExecutionSpecContract"]["executionProfileId"], "qwen-image:modular") + self.assertEqual(selected["studioExecutionSpecContract"]["id"], "qwen-image-2512:control-image:v1") + self.assertEqual( + selected["modelDependencies"], + [ + { + "id": "qwen-controlnet-union", + "kind": "controlnet", + "repo": "InstantX/Qwen-Image-ControlNet-Union", + "revision": "b13036f066d6dee7c20513e263d3d673055e9de8", + } + ], + ) + + def test_flux_redux_candidate_binds_the_reviewed_base_pipeline_revision(self): + plan = self._plan( + {"form": {"modelType": "FluxReduxPipeline", "mode": "edit_image"}}, + runtime=self._runtime(vram_gib=98, free_gib=96), + repos=[AUTO_MODEL_REQUIREMENTS["FluxReduxPipeline"]["defaultRepo"]], + hardware=self._hardware(vram_gib=98, free_gib=96, system_ram_gib=120), + ) + + self.assertEqual( + plan["candidates"][0]["modelDependencies"], + [ + { + "id": "flux-redux-base", + "kind": "base", + "repo": "black-forest-labs/FLUX.1-dev", + "revision": "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + } + ], + ) def test_qwen_control_lower_memory_runtime_keeps_model_cpu_offload(self): plan = self._plan( @@ -851,6 +1088,30 @@ def test_every_declared_high_memory_profile_can_disable_unnecessary_offload(self self.assertEqual(plan["selectedCandidate"]["offloadMode"], "none") self.assertEqual(plan["selectedCandidate"]["deviceMap"], "cuda") + def test_wan_i2v_auto_plan_uses_the_exact_direct_profile(self): + requirements = AUTO_MODEL_REQUIREMENTS["WanImageToVideoPipeline"] + plan = self._plan( + { + "form": { + "modelType": "WanImageToVideoPipeline", + "mode": "image_to_video", + "offloadMode": "model_cpu", + }, + }, + runtime=self._runtime(vram_gib=2, free_gib=1.5), + repos=[requirements["defaultRepo"]], + hardware=self._shared_rocm_hardware(accessible_gib=128, disk_free_gib=256), + ) + + self.assertEqual(plan["status"], "ready") + candidate = plan["selectedCandidate"] + self.assertEqual(candidate["loaderModule"], "modules.DiffusersVideo") + self.assertEqual(candidate["loaderAction"], "LoadPipeline") + self.assertEqual(candidate["executionPath"], "direct-diffusers-video") + self.assertEqual(candidate["pipelineClass"], "WanImageToVideoPipeline") + self.assertEqual(candidate["quantizedComponents"], []) + self.assertEqual(candidate["offloadMode"], "model_cpu") + def test_generic_auto_ignores_a_stale_expert_no_offload_value_on_constrained_hardware(self): requirements = AUTO_MODEL_REQUIREMENTS["AceStepAudioPipeline"] plan = self._plan( @@ -961,6 +1222,7 @@ def test_qwen_layered_on_high_memory_prefers_native_bf16_without_offload(self): self.assertEqual(selected["qualityTier"], "native-bf16-high-memory") self.assertEqual(selected["quantizationMode"], "none") self.assertEqual(selected["offloadMode"], "none") + self.assertEqual(selected["studioExecutionSpecContract"]["executionProfileId"], "qwen-layered:modular") def test_corrupt_wrong_size_and_active_artifact_requires_repair(self): plan = self._plan( @@ -1195,11 +1457,34 @@ def test_legacy_audio_receipt_with_irrelevant_video_fields_is_reused_only_for_ma def test_auto_history_key_is_exact_optimization_recipe_specific(self): runtime = self._runtime() base = { + "autoResourceSchemaVersion": 2, + "executionProfileId": "z-image:auto", "modelType": "ZImageModularPipeline", "mode": "text_to_image", "resolvedArtifact": Z_IMAGE_REPO, "dtype": "bfloat16", "offloadMode": "none", + "loaderModule": "modules.DiffusersImage", + "loaderAction": "LoadPipeline", + "executionPath": "direct-diffusers-image", + "optionalRuntimeProfileIds": ["huggingface-transformers-peft-5.14.1-0.20.0"], + "optionalRuntimeRequirement": { + "schemaVersion": 1, + "delivery": "base", + "requiredNow": False, + "profileIds": ["huggingface-transformers-peft-5.14.1-0.20.0"], + "executionProfileIds": ["z-image:auto"], + "state": "base_satisfied", + "reason": "base_runtime_contract", + }, + "studioExecutionSpecContract": { + "schemaVersion": 1, + "id": "z-image:text-to-image:v1", + "contentHash": "studio-spec-v1-00000000", + "executionProfileId": "z-image:auto", + }, + "modelDependencies": [], + "controlledArtifacts": [], "attentionBackend": "auto", "regionalCompile": False, "denoiserCache": "none", @@ -1213,6 +1498,61 @@ def test_auto_history_key_is_exact_optimization_recipe_specific(self): ("denoiserCache", "first_block"), ("channelsLast", True), ("layerwiseCasting", True), + ("autoResourceSchemaVersion", 3), + ("executionProfileId", "z-image:replacement"), + ("loaderModule", "modules.ModularDiffusers"), + ("loaderAction", "ModelsLoader"), + ("executionPath", "modular-diffusers"), + ("optionalRuntimeProfileIds", []), + ( + "optionalRuntimeRequirement", + { + **base["optionalRuntimeRequirement"], + "delivery": "optional_overlay", + "requiredNow": True, + }, + ), + ( + "studioExecutionSpecContract", + { + **base["studioExecutionSpecContract"], + "contentHash": "studio-spec-v1-11111111", + }, + ), + ( + "modelDependencies", + [ + { + "id": "replacement", + "kind": "base", + "repo": "example/replacement", + "revision": "0123456789abcdef", + } + ], + ), + ( + "controlledArtifacts", + [ + { + "schemaVersion": 1, + "kind": "diffusers_lora", + "module": "modules.DiffusersImage", + "action": "LoadAdapter", + "artifact": { + "source": "hub", + "repository": "example/style", + "revision": "a" * 40, + "weightName": "style.safetensors", + "sha256": "b" * 64, + }, + "adapterName": "style", + "scale": 0.75, + "scheduler": None, + "replaceExisting": True, + "descriptorSha256": "c" * 64, + } + ], + ), ): self.assertNotEqual( baseline, @@ -1220,6 +1560,159 @@ def test_auto_history_key_is_exact_optimization_recipe_specific(self): field, ) + def test_controlled_artifact_history_is_reusable_only_for_the_exact_receipt(self): + runtime = self._runtime() + receipt = { + "schemaVersion": 1, + "kind": "diffusers_lora", + "module": "modules.DiffusersImage", + "action": "LoadAdapter", + "artifact": { + "source": "hub", + "repository": "example/style", + "revision": "a" * 40, + "weightName": "style.safetensors", + "sha256": "b" * 64, + }, + "adapterName": "style", + "scale": 0.75, + "scheduler": None, + "replaceExisting": True, + "descriptorSha256": "c" * 64, + } + candidate = { + "id": "flux-style", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "artifact": FLUX_SCHNELL_REPO, + "controlledArtifacts": [receipt], + } + with tempfile.TemporaryDirectory() as data_dir: + record_auto_resource_success( + data_dir, + runtime_fingerprint=runtime, + runtime_hints={"resourceMode": "auto", "autoResourcePlan": candidate}, + ) + exact = matching_auto_resource_success_history( + data_dir, + candidate=candidate, + runtime_fingerprint=runtime, + ) + changed = json.loads(json.dumps(candidate)) + changed["controlledArtifacts"][0]["scale"] = 1.0 + changed["controlledArtifacts"][0]["descriptorSha256"] = "d" * 64 + stale = matching_auto_resource_success_history( + data_dir, + candidate=changed, + runtime_fingerprint=runtime, + ) + base_only = matching_auto_resource_success_history( + data_dir, + candidate={key: value for key, value in candidate.items() if key != "controlledArtifacts"}, + runtime_fingerprint=runtime, + ) + + self.assertIsNotNone(exact) + self.assertIsNone(stale) + self.assertIsNone(base_only) + + def test_auto_history_rejects_stale_profile_and_schema_receipts(self): + hardware = self._hardware() + current = { + "id": "z-image-native", + "autoResourceSchemaVersion": 2, + "executionProfileId": "z-image:auto", + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + "resolvedArtifact": Z_IMAGE_REPO, + "dtype": "bfloat16", + "quantizationMode": "none", + "quantizedComponents": [], + "offloadMode": "none", + "loaderModule": "modules.DiffusersImage", + "loaderAction": "LoadPipeline", + "executionPath": "direct-diffusers-image", + "pipelineClass": "ZImagePipeline", + "optionalRuntimeProfileIds": ["huggingface-transformers-peft-5.14.1-0.20.0"], + "optionalRuntimeRequirement": { + "schemaVersion": 1, + "delivery": "base", + "requiredNow": False, + "profileIds": ["huggingface-transformers-peft-5.14.1-0.20.0"], + "executionProfileIds": ["z-image:auto"], + }, + "studioExecutionSpecContract": { + "schemaVersion": 1, + "id": "z-image:text-to-image:v1", + "contentHash": "studio-spec-v1-00000000", + "executionProfileId": "z-image:auto", + }, + "modelDependencies": [], + "generation": {"width": 1024, "height": 1024, "steps": 8}, + "installed": True, + "requirementsMissing": [], + "proof": {"status": "declared_safe"}, + } + for changed, history_schema_version in ( + ({**current, "executionProfileId": "z-image:replacement"}, AUTO_HISTORY_VERSION), + ({**current, "autoResourceSchemaVersion": 3}, AUTO_HISTORY_VERSION), + ({**current, "optionalRuntimeProfileIds": []}, AUTO_HISTORY_VERSION), + ( + { + **current, + "optionalRuntimeRequirement": { + **current["optionalRuntimeRequirement"], + "executionProfileIds": ["z-image:replacement"], + }, + }, + AUTO_HISTORY_VERSION, + ), + ( + { + **current, + "studioExecutionSpecContract": { + **current["studioExecutionSpecContract"], + "contentHash": "studio-spec-v1-11111111", + }, + }, + AUTO_HISTORY_VERSION, + ), + ( + { + **current, + "modelDependencies": [ + { + "id": "replacement", + "kind": "base", + "repo": "example/replacement", + "revision": "0123456789abcdef", + } + ], + }, + AUTO_HISTORY_VERSION, + ), + (current, AUTO_HISTORY_VERSION - 1), + ): + stale_signature = _candidate_history_signature(changed, hardware=hardware) + stale_signature["historySchemaVersion"] = history_schema_version + output = _apply_history_to_candidates( + [current], + history={ + "version": 2, + "entries": { + "stale": { + "signature": stale_signature, + "candidate": changed, + "successCount": 1, + "lastSuccessAt": 1, + } + }, + }, + hardware=hardware, + )[0] + self.assertNotEqual(output["proof"]["status"], "live_proven") + self.assertIsNone(output["successHistory"]) + def test_each_current_studio_model_has_requirements_metadata(self): expected = { "ZImageModularPipeline", @@ -1231,6 +1724,8 @@ def test_each_current_studio_model_has_requirements_metadata(self): "WanVACEPipeline", "WanVideoPipeline", "WanVideoPipeline:text_to_video", + "WanImageToVideoPipeline", + "WanTI2VPipeline", "LTXVideoPipeline", "AceStepAudioPipeline", "FluxSchnellPipeline", @@ -1250,6 +1745,143 @@ def test_each_current_studio_model_has_requirements_metadata(self): if entry.get("manualOnlyReason"): self.assertIn("Auto", entry["manualOnlyReason"]) + def test_every_auto_supported_task_has_an_execution_profile(self): + profile_pairs = { + (profile.model_type, mode) + for profile in DIFFUSERS_EXECUTION_PROFILES.values() + for mode in profile.modes + } + + for key, requirements in AUTO_MODEL_REQUIREMENTS.items(): + model_type = key.split(":", 1)[0] + for mode in requirements.get("supportedTasks") or []: + with self.subTest(model_type=model_type, mode=mode): + self.assertIn((model_type, mode), profile_pairs) + + def test_every_effective_auto_specification_has_one_canonical_target(self): + checked = set() + for key, requirements in AUTO_MODEL_REQUIREMENTS.items(): + model_type = key.split(":", 1)[0] + for mode in requirements.get("supportedTasks") or []: + with self.subTest(model_type=model_type, mode=mode): + specification = _auto_requirements_for_pair(model_type, mode) + self.assertIsNotNone(specification) + profiles = [ + profile + for profile in DIFFUSERS_EXECUTION_PROFILES.values() + if profile.model_type == model_type and mode in profile.modes + ] + self.assertEqual(len(profiles), 1) + profile = profiles[0] + self.assertEqual(specification["loaderModule"], profile.loader_module) + self.assertEqual(specification["loaderAction"], profile.loader_action) + self.assertEqual(specification["executionPath"], profile.execution_path) + self.assertEqual(specification["pipelineClass"], profile.pipeline_class) + checked.add((model_type, mode)) + + self.assertTrue(checked) + + def test_public_model_requirements_are_exact_pair_specifications(self): + plan = self._plan( + { + "form": { + "modelType": "QwenImageEditModularPipeline", + "mode": "edit_image", + } + } + ) + requirements = plan["modelRequirements"] + + self.assertNotIn("QwenImageEditModularPipeline", requirements) + edit = requirements["QwenImageEditModularPipeline:edit_image"] + self.assertEqual(edit["loaderModule"], "modules.ModularDiffusers") + self.assertEqual(edit["loaderAction"], "ModelsLoader") + self.assertEqual(edit["executionPath"], "modular-diffusers") + self.assertEqual( + requirements["QwenImageModularPipeline:control_image"]["modelDependencies"][0]["revision"], + "b13036f066d6dee7c20513e263d3d673055e9de8", + ) + self.assertEqual( + requirements["FluxReduxPipeline:edit_image"]["modelDependencies"][0]["revision"], + "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + ) + self.assertEqual(requirements["FluxSchnellPipeline:text_to_image"]["modelDependencies"], []) + for key, specification in requirements.items(): + with self.subTest(key=key): + self.assertIn(":", key) + self.assertEqual(specification["supportedTasks"], [key.split(":", 1)[1]]) + self.assertTrue(specification["loaderModule"]) + self.assertTrue(specification["loaderAction"]) + self.assertTrue(specification["executionPath"]) + + def test_qwen_effective_targets_follow_exact_mode_profiles(self): + expected = { + ("QwenImageModularPipeline", "text_to_image"): ( + "modules.DiffusersImage", + "LoadPipeline", + "direct-diffusers-image", + ), + ("QwenImageModularPipeline", "control_image"): ( + "modules.ModularDiffusers", + "ModelsLoader", + "modular-diffusers", + ), + ("QwenImageEditModularPipeline", "edit_image"): ( + "modules.ModularDiffusers", + "ModelsLoader", + "modular-diffusers", + ), + ("QwenImageEditModularPipeline", "inpaint"): ( + "modules.DiffusersImage", + "LoadPipeline", + "direct-diffusers-image", + ), + ("QwenImageEditPlusModularPipeline", "edit_image"): ( + "modules.ModularDiffusers", + "ModelsLoader", + "modular-diffusers", + ), + ("QwenImageLayeredModularPipeline", "layer_decomposition"): ( + "modules.ModularDiffusers", + "ModelsLoader", + "modular-diffusers", + ), + } + for pair, target in expected.items(): + with self.subTest(pair=pair): + specification = _auto_requirements_for_pair(*pair) + self.assertEqual( + ( + specification["loaderModule"], + specification["loaderAction"], + specification["executionPath"], + ), + target, + ) + + edit_specification = _auto_requirements_for_pair( + "QwenImageEditModularPipeline", + "edit_image", + ) + self.assertNotIn("preferredLowerMemoryRepo", edit_specification) + + def test_wan_modular_remains_auto_undeclared_without_a_profile_target(self): + self.assertIsNone(_auto_requirements_for_pair("WanModularPipeline", "text_to_video")) + plan = self._plan( + { + "form": { + "modelType": "WanModularPipeline", + "mode": "text_to_video", + "pipelineClass": "WanModularPipeline", + "executionPath": "modular-diffusers", + } + } + ) + self.assertFalse(plan["exactPairDeclared"]) + self.assertIsNone(plan["selectedCandidate"]) + self.assertIsNone(plan["candidates"][0]["loaderModule"]) + self.assertIsNone(plan["candidates"][0]["loaderAction"]) + def test_resource_planner_accepts_supported_ram_vram_os_matrix(self): ram_tiers = (8, 16, 32, 64, 96) vram_tiers = (None, 8, 16, 24, 32, 48, 96) diff --git a/tests/test_auxiliary_ip_adapter_contract.py b/tests/test_auxiliary_ip_adapter_contract.py new file mode 100644 index 0000000..733165b --- /dev/null +++ b/tests/test_auxiliary_ip_adapter_contract.py @@ -0,0 +1,129 @@ +import hashlib +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from huggingface_hub.utils import LocalEntryNotFoundError + +from modiff.auxiliary_ip_adapter import resolve_reviewed_sdxl_ip_adapter +from modiff.model_artifact_catalog import catalog_repository_pin + + +REPOSITORY = "h94/IP-Adapter" +REVISION = "018e402774aeeddd60609b4ecdb7e298259dc729" +WEIGHT = "sdxl_models/ip-adapter_sdxl.safetensors" + + +def _pin(payload=b"reviewed-ip-adapter"): + return { + "repo": REPOSITORY, + "revision": REVISION, + "purpose": "sdxl-ip-adapter", + "weightName": WEIGHT, + "sha256": hashlib.sha256(payload).hexdigest(), + "byteSize": len(payload), + "imageEncoderSubfolder": "models/image_encoder", + "imageEncoderClass": "CLIPVisionModelWithProjection", + } + + +class AuxiliaryIPAdapterContractTests(unittest.TestCase): + def test_catalog_declares_the_exact_reviewed_single_adapter(self): + pin = catalog_repository_pin(REPOSITORY) + self.assertEqual(pin["kind"], "auxiliary") + self.assertEqual(pin["revision"], REVISION) + self.assertEqual(pin["purpose"], "sdxl-ip-adapter") + self.assertEqual(pin["weightName"], WEIGHT) + self.assertEqual(pin["sha256"], "ba1002529e783604c5f326d49f0122025392d1d20ac8d573b3eeb3e6dea4ebb6") + self.assertEqual(pin["byteSize"], 702585376) + self.assertEqual(pin["imageEncoderSubfolder"], "models/image_encoder") + self.assertEqual(pin["imageEncoderClass"], "CLIPVisionModelWithProjection") + + def test_resolution_is_local_only_and_content_addressed(self): + payload = b"reviewed-ip-adapter" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ip-adapter_sdxl.safetensors" + path.write_bytes(payload) + with ( + patch("modiff.auxiliary_ip_adapter.catalog_repository_pin", return_value=_pin(payload)), + patch("modiff.auxiliary_ip_adapter.hf_hub_download", return_value=str(path)) as download, + ): + resolved = resolve_reviewed_sdxl_ip_adapter( + selection={"source": "hub", "value": REPOSITORY}, + revision=REVISION, + weight_name=WEIGHT, + ) + + download.assert_called_once_with( + repo_id=REPOSITORY, + revision=REVISION, + filename=WEIGHT, + local_files_only=True, + ) + self.assertEqual(resolved.repository, REPOSITORY) + self.assertEqual(resolved.revision, REVISION) + self.assertEqual(resolved.weight_name, "ip-adapter_sdxl.safetensors") + self.assertEqual(resolved.content_sha256, hashlib.sha256(payload).hexdigest()) + self.assertEqual(resolved.byte_size, len(payload)) + + def test_unreviewed_selectors_and_mutable_identity_fail_before_cache_access(self): + cases = ( + (None, REVISION, WEIGHT, _pin(), "model-selector object"), + ({"source": "local", "value": REPOSITORY}, REVISION, WEIGHT, _pin(), "only reviewed immutable Hub"), + ({"source": "hub", "value": "other/repo"}, REVISION, WEIGHT, None, "not a reviewed"), + ({"source": "hub", "value": REPOSITORY}, "main", WEIGHT, _pin(), "immutable repository revision"), + ({"source": "hub", "value": REPOSITORY}, REVISION, "../adapter.bin", _pin(), "traversal-free"), + ) + for selection, revision, weight, pin, message in cases: + with ( + self.subTest(message=message), + patch("modiff.auxiliary_ip_adapter.catalog_repository_pin", return_value=pin), + patch("modiff.auxiliary_ip_adapter.hf_hub_download") as download, + self.assertRaisesRegex((TypeError, ValueError), message), + ): + resolve_reviewed_sdxl_ip_adapter( + selection=selection, + revision=revision, + weight_name=weight, + ) + download.assert_not_called() + + def test_missing_size_or_digest_mismatch_never_falls_back_to_network(self): + with ( + patch("modiff.auxiliary_ip_adapter.catalog_repository_pin", return_value=_pin()), + patch( + "modiff.auxiliary_ip_adapter.hf_hub_download", + side_effect=LocalEntryNotFoundError("private-cache-marker"), + ) as download, + self.assertRaisesRegex(FileNotFoundError, "never downloads"), + ): + resolve_reviewed_sdxl_ip_adapter( + selection={"source": "hub", "value": REPOSITORY}, + revision=REVISION, + weight_name=WEIGHT, + ) + self.assertTrue(download.call_args.kwargs["local_files_only"]) + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "ip-adapter_sdxl.safetensors" + path.write_bytes(b"tampered") + for pin, message in ( + ({**_pin(b"tampered"), "byteSize": 99}, "byte size"), + ({**_pin(b"tampered"), "sha256": "0" * 64}, "SHA-256"), + ): + with ( + self.subTest(message=message), + patch("modiff.auxiliary_ip_adapter.catalog_repository_pin", return_value=pin), + patch("modiff.auxiliary_ip_adapter.hf_hub_download", return_value=str(path)), + self.assertRaisesRegex(ValueError, message), + ): + resolve_reviewed_sdxl_ip_adapter( + selection={"source": "hub", "value": REPOSITORY}, + revision=REVISION, + weight_name=WEIGHT, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auxiliary_lora_contract.py b/tests/test_auxiliary_lora_contract.py new file mode 100644 index 0000000..f1e095f --- /dev/null +++ b/tests/test_auxiliary_lora_contract.py @@ -0,0 +1,643 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import numpy as np +from safetensors.numpy import save_file + +from modiff.auxiliary_lora import ( + LORA_DESCRIPTOR_SCHEMA, + build_lora_descriptor, + controlled_lora_receipts_from_graph, + resolve_lora_descriptor, +) +from modules.ModularDiffusers.adapters import Lora +from modules.ModularDiffusers.loaders import apply_lora_scheduler_override, update_lora_adapters +from utils.huggingface import CONFIG + + +REVISION = "a" * 40 + + +def _write_tiny_safetensors(path: Path, marker: float = 1.0) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + save_file({"lora.weight": np.asarray([marker], dtype=np.float32)}, str(path)) + + +class MutationTrackingPipeline: + def __init__(self): + self.adapters = {"transformer": ["working"]} + self.events = [] + self.scheduler = None + + def get_list_adapters(self): + return self.adapters + + def unload_lora_weights(self): + self.events.append("unload") + self.adapters = {"transformer": []} + + def delete_adapters(self, name): + self.events.append(("delete", name)) + + def load_lora_weights(self, path, **kwargs): + self.events.append(("load", path, kwargs)) + + def set_adapters(self, names, weights): + self.events.append(("set", names, weights)) + + +def _local_descriptor(path: Path, *, node_id: str = "local", scale: float = 1.0): + return Lora(node_id).execute( + {"source": "local", "value": str(path)}, + scale, + weight_name=path.name, + )["lora"] + + +def _resign_descriptor(descriptor): + payload = {key: value for key, value in descriptor.items() if key != "descriptor_sha256"} + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + descriptor["descriptor_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + return descriptor + + +class AuxiliaryLoraContractTests(unittest.TestCase): + def test_executable_graph_receipts_bind_order_and_ignore_disconnected_adapters(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.safetensors" + second = root / "second.safetensors" + disconnected = root / "disconnected.safetensors" + _write_tiny_safetensors(first, 1) + _write_tiny_safetensors(second, 2) + _write_tiny_safetensors(disconnected, 3) + + def direct_node(path, name, scale, replace_existing): + return { + "module": "modules.DiffusersImage", + "action": "LoadAdapter", + "params": { + "adapter_path": {"value": {"source": "local", "value": str(path)}}, + "weight_name": {"value": path.name}, + "adapter_name": {"value": name}, + "scale": {"value": scale}, + "replace_existing": {"value": replace_existing}, + }, + } + + graph = { + "nodes": { + "pipeline": {"module": "unit", "action": "Pipeline", "params": {}}, + "first": direct_node(first, "first", 0.5, True), + "second": direct_node(second, "second", 0.25, False), + "disconnected": direct_node(disconnected, "unused", 1, True), + "generate": {"module": "unit", "action": "Generate", "params": {}}, + }, + "paths": [["pipeline", "first", "second", "generate"]], + } + receipts = controlled_lora_receipts_from_graph(graph) + reversed_receipts = controlled_lora_receipts_from_graph( + {**graph, "paths": [["pipeline", "second", "first", "generate"]]} + ) + expected_first_digest = hashlib.sha256(first.read_bytes()).hexdigest() + + self.assertEqual([item["adapterName"] for item in receipts], ["first", "second"]) + self.assertEqual([item["replaceExisting"] for item in receipts], [True, False]) + self.assertEqual(receipts[0]["artifact"]["sha256"], expected_first_digest) + self.assertNotIn(str(root), json.dumps(receipts)) + self.assertEqual([item["adapterName"] for item in reversed_receipts], ["second", "first"]) + + def test_modular_graph_receipt_matches_the_runtime_descriptor_identity(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + graph = { + "nodes": { + "adapter": { + "module": "modules.ModularDiffusers", + "action": "Lora", + "params": { + "model": {"value": {"source": "local", "value": str(weight)}}, + "weight_name": {"value": weight.name}, + "scale": {"value": 0.75}, + }, + } + }, + "paths": [["adapter"]], + } + receipt = controlled_lora_receipts_from_graph(graph)[0] + descriptor = Lora("adapter").execute( + {"source": "local", "value": str(weight)}, + 0.75, + weight_name=weight.name, + )["lora"] + + self.assertEqual(receipt["descriptorSha256"], descriptor["descriptor_sha256"]) + self.assertEqual(receipt["adapterName"], "style_adapter") + self.assertIsNone(receipt["replaceExisting"]) + + def test_empty_direct_image_adapter_is_the_same_noop_as_the_loader(self): + graph = { + "nodes": { + "adapter": { + "module": "modules.DiffusersImage", + "action": "LoadAdapter", + "params": { + "adapter_path": {"value": {"source": "hub", "value": ""}}, + }, + } + }, + "paths": [["adapter"]], + } + self.assertEqual(controlled_lora_receipts_from_graph(graph), []) + + def test_local_file_produces_one_versioned_content_identity(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + expected_digest = hashlib.sha256(weight.read_bytes()).hexdigest() + + descriptor = _local_descriptor(weight) + resolved = resolve_lora_descriptor(descriptor) + + self.assertEqual(descriptor["schema"], LORA_DESCRIPTOR_SCHEMA) + self.assertEqual(descriptor["artifact"]["source"], "local") + self.assertEqual(descriptor["artifact"]["sha256"], expected_digest) + self.assertEqual(resolved.weight_name, "style.safetensors") + self.assertEqual(resolved.load_directory, weight.parent.resolve()) + + def test_local_directory_requires_a_contained_literal_lowercase_safetensors_alias(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "adapter.bin").write_bytes(b"bin") + (root / "adapter.SAFETENSORS").write_bytes(b"upper") + for weight_name in ("adapter.bin", "adapter.SAFETENSORS", "../outside.safetensors"): + with self.subTest(weight_name=weight_name): + with self.assertRaisesRegex(ValueError, "lowercase .safetensors|stay inside"): + build_lora_descriptor( + selection={"source": "local", "value": str(root)}, + weight_name=weight_name, + revision="", + expected_sha256="", + adapter_name="adapter", + scale=1, + ) + + def test_raw_and_source_inferred_selections_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + with self.assertRaisesRegex(TypeError, "explicitly provide source and value"): + build_lora_descriptor( + selection=str(weight), + weight_name=weight.name, + revision="", + expected_sha256="", + adapter_name="adapter", + scale=1, + ) + with self.assertRaisesRegex(ValueError, "exactly 'hub' or 'local'"): + build_lora_descriptor( + selection={"source": "", "value": str(weight)}, + weight_name=weight.name, + revision="", + expected_sha256="", + adapter_name="adapter", + scale=1, + ) + + def test_hub_identity_is_revision_aware_and_preserves_the_snapshot_alias(self): + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) + repo_root = cache_root / "models--example--style" + alias = repo_root / "snapshots" / REVISION / "weights" / "style.safetensors" + blob = repo_root / "blobs" / ("b" * 64) + alias.parent.mkdir(parents=True) + blob.parent.mkdir(parents=True) + _write_tiny_safetensors(alias) + blob.write_bytes(alias.read_bytes()) + digest = hashlib.sha256(alias.read_bytes()).hexdigest() + + with patch.dict(CONFIG.hf, {"cache_dir": str(cache_root)}): + with patch("utils.huggingface.cached_file_path", return_value=str(alias)) as cached: + with patch("utils.huggingface.resolve_managed_hf_cache_file", return_value=blob): + descriptor = build_lora_descriptor( + selection={"source": "hub", "value": "example/style"}, + weight_name="weights/style.safetensors", + revision=REVISION, + expected_sha256=digest, + adapter_name="style", + scale=0.5, + ) + resolved = resolve_lora_descriptor(descriptor) + + self.assertEqual(resolved.load_directory, alias.parent.resolve()) + self.assertEqual(resolved.weight_name, "style.safetensors") + self.assertNotEqual(resolved.load_directory, blob.parent) + self.assertEqual(cached.call_args.kwargs["revision"], REVISION) + + def test_hub_requires_exact_repository_revision_hash_and_alias(self): + cases = ( + ({"source": "hub", "value": "single-name"}, REVISION, "a" * 64, "style.safetensors", "namespace/repository"), + ({"source": "hub", "value": "example/style"}, "main", "a" * 64, "style.safetensors", "40-character"), + ({"source": "hub", "value": "example/style"}, REVISION.upper(), "a" * 64, "style.safetensors", "40-character"), + ({"source": "hub", "value": "example/style"}, REVISION, "", "style.safetensors", "64 lowercase"), + ({"source": "hub", "value": "example/style"}, REVISION, "a" * 64, "style.bin", "lowercase .safetensors"), + ) + for selection, revision, digest, weight_name, message in cases: + with self.subTest(revision=revision, weight_name=weight_name): + with self.assertRaisesRegex(ValueError, message): + build_lora_descriptor( + selection=selection, + weight_name=weight_name, + revision=revision, + expected_sha256=digest, + adapter_name="style", + scale=1, + ) + + def test_hub_cache_hit_must_use_the_exact_repository_snapshot_lexical_path(self): + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) + wrong_alias = ( + cache_root + / "models--other--style" + / "snapshots" + / REVISION + / "style.safetensors" + ) + wrong_alias.parent.mkdir(parents=True) + wrong_alias.write_bytes(b"same bytes") + digest = hashlib.sha256(wrong_alias.read_bytes()).hexdigest() + with patch.dict(CONFIG.hf, {"cache_dir": str(cache_root)}): + with patch("utils.huggingface.cached_file_path", return_value=str(wrong_alias)): + with self.assertRaisesRegex(ValueError, "exact repository snapshot path"): + build_lora_descriptor( + selection={"source": "hub", "value": "example/style"}, + weight_name="style.safetensors", + revision=REVISION, + expected_sha256=digest, + adapter_name="style", + scale=1, + ) + + def test_hub_resolved_blob_must_remain_in_the_selected_repository_cache(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cache_root = root / "hub" + alias = ( + cache_root + / "models--example--style" + / "snapshots" + / REVISION + / "style.safetensors" + ) + outside = root / "outside" / "blob" + alias.parent.mkdir(parents=True) + outside.parent.mkdir(parents=True) + alias.write_bytes(b"same bytes") + outside.write_bytes(b"same bytes") + digest = hashlib.sha256(alias.read_bytes()).hexdigest() + with patch.dict(CONFIG.hf, {"cache_dir": str(cache_root)}): + with patch("utils.huggingface.cached_file_path", return_value=str(alias)): + with patch("utils.huggingface.resolve_managed_hf_cache_file", return_value=outside): + with self.assertRaisesRegex(ValueError, "outside its managed repository cache"): + build_lora_descriptor( + selection={"source": "hub", "value": "example/style"}, + weight_name="style.safetensors", + revision=REVISION, + expected_sha256=digest, + adapter_name="style", + scale=1, + ) + + def test_consumer_rejects_legacy_partial_extra_and_tampered_descriptors_without_mutation(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + descriptor = _local_descriptor(weight) + values = [ + "", + [], + {}, + {"lora_path": str(weight.parent), "weight_name": weight.name, "adapter_name": "legacy"}, + {key: value for key, value in descriptor.items() if key != "descriptor_sha256"}, + {**descriptor, "unexpected": True}, + {**descriptor, "scale": 0.25}, + ] + for value in values: + pipeline = MutationTrackingPipeline() + with self.subTest(value=value): + with self.assertRaises((TypeError, ValueError)): + update_lora_adapters(pipeline, value) + self.assertEqual(pipeline.events, []) + + def test_consumer_rehashes_a_to_b_file_mutation_before_any_pipeline_mutation(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight, 1) + descriptor = _local_descriptor(weight) + _write_tiny_safetensors(weight, 2) + pipeline = MutationTrackingPipeline() + + with self.assertRaisesRegex(ValueError, "no longer matches"): + update_lora_adapters(pipeline, descriptor) + + self.assertEqual(pipeline.events, []) + + def test_whole_list_is_validated_before_an_existing_adapter_is_unloaded(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "first.safetensors" + second = root / "second.safetensors" + _write_tiny_safetensors(first, 1) + _write_tiny_safetensors(second, 2) + first_descriptor = _local_descriptor(first, node_id="first") + second_descriptor = _local_descriptor(second, node_id="second") + _write_tiny_safetensors(second, 3) + pipeline = MutationTrackingPipeline() + + with self.assertRaisesRegex(ValueError, "no longer matches"): + update_lora_adapters(pipeline, [first_descriptor, second_descriptor]) + + self.assertEqual(pipeline.events, []) + + def test_malformed_or_empty_safetensors_fail_before_modular_pipeline_mutation(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + for label in ("malformed", "empty"): + _write_tiny_safetensors(weight) + descriptor = _local_descriptor(weight) + if label == "malformed": + weight.write_bytes(b"not-a-safetensors-file") + message = "valid Safetensors" + else: + save_file({}, str(weight)) + message = "at least one tensor" + descriptor["artifact"]["sha256"] = hashlib.sha256(weight.read_bytes()).hexdigest() + _resign_descriptor(descriptor) + pipeline = MutationTrackingPipeline() + + with self.subTest(label=label): + with self.assertRaisesRegex(ValueError, message): + update_lora_adapters(pipeline, descriptor) + self.assertEqual(pipeline.events, []) + + def test_scheduler_contract_rejects_dynamic_imports_duplicate_keys_and_oversized_json(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + selection = {"source": "local", "value": str(weight)} + cases = ( + ("os.system", "{}", "simple Diffusers export"), + ("DiffusionPipeline", "{}", "reviewed scheduler contract"), + ("SchedulerMixin", "{}", "reviewed scheduler contract"), + ("EulerDiscreteScheduler", "{}", "reviewed scheduler contract"), + ("FlowMatchEulerDiscreteScheduler", '{"shift":1,"shift":2}', "Duplicate scheduler JSON key"), + ("FlowMatchEulerDiscreteScheduler", '{"value":"' + ("x" * 17000) + '"}', "byte limit"), + ( + "FlowMatchEulerDiscreteScheduler", + '{"pretrained_model_name_or_path":"attacker/scheduler"}', + "unsupported constructor parameters", + ), + ( + "FlowMatchEulerDiscreteScheduler", + '{"return_unused_kwargs":true}', + "unsupported constructor parameters", + ), + ( + "FlowMatchEulerDiscreteScheduler", + '{"nested":' + ("[" * 1100) + "0" + ("]" * 1100) + "}", + "nesting limit", + ), + ) + for scheduler_class, config, message in cases: + with self.subTest(scheduler_class=scheduler_class, message=message): + with self.assertRaisesRegex(ValueError, message): + build_lora_descriptor( + selection=selection, + weight_name=weight.name, + revision="", + expected_sha256="", + adapter_name="style", + scale=1, + scheduler_class=scheduler_class, + scheduler_config=config, + ) + + def test_consumer_rejects_diffusers_config_controls_without_config_io_or_pipeline_mutation(self): + from diffusers import FlowMatchEulerDiscreteScheduler + + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) + descriptor = Lora("lightning").execute( + {"source": "local", "value": str(weight)}, + 1, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.25}, + )["lora"] + + for config_key, config_value in ( + ("pretrained_model_name_or_path", "attacker/scheduler"), + ("return_unused_kwargs", True), + ): + tampered = copy.deepcopy(descriptor) + tampered["scheduler"]["config"] = {config_key: config_value} + _resign_descriptor(tampered) + pipeline = MutationTrackingPipeline() + pipeline.scheduler = FlowMatchEulerDiscreteScheduler() + pipeline.update_components = lambda **_components: pipeline.events.append("update") + with self.subTest(config_key=config_key): + with patch.object( + FlowMatchEulerDiscreteScheduler, + "load_config", + side_effect=AssertionError("scheduler config I/O must stay unreachable"), + ) as load_config: + with self.assertRaisesRegex(ValueError, "unsupported constructor parameters"): + update_lora_adapters(pipeline, tampered) + load_config.assert_not_called() + self.assertEqual(pipeline.events, []) + + def test_flow_match_scheduler_resource_values_are_bounded_before_construction_or_mutation(self): + from diffusers import FlowMatchEulerDiscreteScheduler + + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) + with self.assertRaisesRegex(ValueError, "num_train_timesteps"): + Lora("oversized-scheduler").execute( + {"source": "local", "value": str(weight)}, + 1, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"num_train_timesteps": 1_000_000_000}, + ) + + descriptor = Lora("bounded-scheduler").execute( + {"source": "local", "value": str(weight)}, + 1, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.25}, + )["lora"] + pipeline = MutationTrackingPipeline() + current_config = dict(FlowMatchEulerDiscreteScheduler().config) + current_config["num_train_timesteps"] = 1_000_000_000 + pipeline.scheduler = type("CurrentScheduler", (), {"config": current_config})() + pipeline.update_components = lambda **_components: pipeline.events.append("update") + + with patch( + "diffusers.schedulers.scheduling_flow_match_euler_discrete.np.linspace", + side_effect=AssertionError("oversized scheduler allocation must stay unreachable"), + ) as linspace: + with self.assertRaisesRegex(ValueError, "num_train_timesteps"): + update_lora_adapters(pipeline, descriptor) + linspace.assert_not_called() + + self.assertEqual(pipeline.events, []) + + def test_hostile_descriptor_containers_are_bounded_before_copy_or_pipeline_mutation(self): + class HostileDict(dict): + def __deepcopy__(self, _memo): + raise AssertionError("hostile descriptor must not reach deepcopy") + + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + descriptor = _local_descriptor(weight) + cases = [] + + deeply_nested = copy.deepcopy(descriptor) + nested = {} + for _ in range(600): + nested = {"nested": nested} + deeply_nested["scheduler"] = { + "class_name": "FlowMatchEulerDiscreteScheduler", + "config": nested, + } + cases.append(("deep", deeply_nested, "nesting limit")) + + cyclic = copy.deepcopy(descriptor) + cycle = {} + cycle["cycle"] = cycle + cyclic["scheduler"] = { + "class_name": "FlowMatchEulerDiscreteScheduler", + "config": cycle, + } + cases.append(("cycle", cyclic, "nesting limit|value limit")) + + custom = copy.deepcopy(descriptor) + custom["scheduler"] = { + "class_name": "FlowMatchEulerDiscreteScheduler", + "config": HostileDict(base_shift=1.25), + } + cases.append(("custom", custom, "unsupported value type HostileDict")) + + oversized_top_level = {f"field_{index}": index for index in range(257)} + cases.append(("oversized-top-level", oversized_top_level, "item object limit")) + + for label, value, message in cases: + pipeline = MutationTrackingPipeline() + with self.subTest(label=label): + with self.assertRaisesRegex((TypeError, ValueError), message): + update_lora_adapters(pipeline, value) + self.assertEqual(pipeline.events, []) + + def test_scheduler_wrong_return_type_fails_before_any_pipeline_mutation(self): + from diffusers import FlowMatchEulerDiscreteScheduler + + class SchedulerPipeline(MutationTrackingPipeline): + def __init__(self): + super().__init__() + self.scheduler = FlowMatchEulerDiscreteScheduler() + + def update_components(self, **components): + self.events.append(("update_components", components)) + + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) + descriptor = Lora("lightning").execute( + {"source": "local", "value": str(weight)}, + 1, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.25}, + )["lora"] + pipeline = SchedulerPipeline() + + with patch.object( + FlowMatchEulerDiscreteScheduler, + "from_config", + return_value=(FlowMatchEulerDiscreteScheduler(), {}), + ): + with self.assertRaisesRegex(ValueError, "exact scheduler instance"): + update_lora_adapters(pipeline, descriptor) + + self.assertEqual(pipeline.events, []) + + def test_modular_scheduler_is_preconstructed_and_bound_to_the_same_pipeline(self): + from diffusers import FlowMatchEulerDiscreteScheduler + + class SchedulerPipeline(MutationTrackingPipeline): + def __init__(self): + super().__init__() + self.scheduler = FlowMatchEulerDiscreteScheduler() + + def update_components(self, **components): + self.events.append(("update_components", components)) + self.scheduler = components["scheduler"] + + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "lightning.safetensors" + _write_tiny_safetensors(weight) + descriptor = Lora("lightning").execute( + {"source": "local", "value": str(weight)}, + 1, + weight_name=weight.name, + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.25}, + )["lora"] + + no_scheduler = MutationTrackingPipeline() + with self.assertRaisesRegex(ValueError, "does not expose one"): + update_lora_adapters(no_scheduler, descriptor) + self.assertEqual(no_scheduler.events, []) + + pipeline = SchedulerPipeline() + prepared = update_lora_adapters(pipeline, descriptor) + other = SchedulerPipeline() + with self.assertRaisesRegex(ValueError, "different pipeline"): + apply_lora_scheduler_override(other, prepared=prepared) + self.assertEqual(other.events, []) + + scheduler = apply_lora_scheduler_override(pipeline, prepared=prepared) + + load_event = next(event for event in pipeline.events if isinstance(event, tuple) and event[0] == "load") + self.assertTrue(load_event[2]["use_safetensors"]) + self.assertEqual(load_event[2]["weight_name"], "lightning.safetensors") + self.assertIs(pipeline.scheduler, scheduler) + self.assertAlmostEqual(scheduler.config.base_shift, 1.25) + + def test_descriptor_digest_cannot_be_recomputed_around_an_invalid_artifact_shape(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "style.safetensors" + _write_tiny_safetensors(weight) + descriptor = _local_descriptor(weight) + tampered = copy.deepcopy(descriptor) + tampered["artifact"]["repository"] = "example/style" + _resign_descriptor(tampered) + + with self.assertRaisesRegex(ValueError, "Local LoRA artifact fields"): + resolve_lora_descriptor(tampered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_controlled_artifacts.py b/tests/test_controlled_artifacts.py new file mode 100644 index 0000000..330b42f --- /dev/null +++ b/tests/test_controlled_artifacts.py @@ -0,0 +1,226 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from modiff.auto_resource import auto_resource_history_key +from modiff.controlled_artifacts import ( + controlled_artifact_receipts_from_graph, + resolve_upscaler_artifact, +) +from utils.huggingface import CONFIG + + +UPSCALER_REVISION = "42efb9c3eeed1f5c0c8a626cf5f7f4481dfbb094" +ACE_REVISION = "200ba991ae448051e14b0183157e35c2d27c9fb0" +LTX_REVISION = "7c64400e1861cc0d7b98d570a1926d5408ec60cd" + + +def _node(module, action, **params): + return { + "module": module, + "action": action, + "params": {key: {"value": value} for key, value in params.items()}, + } + + +class ControlledArtifactReceiptTests(unittest.TestCase): + def test_executable_upscaler_is_rehashed_and_disconnected_copy_is_ignored(self): + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) + weight = ( + cache_root + / "models--nateraw--real-esrgan" + / "snapshots" + / UPSCALER_REVISION + / "RealESRGAN_x2plus.pth" + ) + weight.parent.mkdir(parents=True) + weight.write_bytes(b"reviewed-upscaler") + digest = hashlib.sha256(weight.read_bytes()).hexdigest() + selection = { + "source": "hub", + "value": "nateraw/real-esrgan/RealESRGAN_x2plus.pth", + "revision": UPSCALER_REVISION, + "sha256": digest, + "byteSize": weight.stat().st_size, + } + graph = { + "nodes": { + "pipeline": _node( + "modules.DiffusersVideo", + "LoadPipeline", + model_id={"source": "hub", "value": "Lightricks/LTX-Video-0.9.8-13B-distilled"}, + revision=LTX_REVISION, + pipeline_class="LTXConditionPipeline", + ), + "upscale": _node("modules.Spandrel", "Upscaler", model_id=selection), + "disconnected": _node("modules.Spandrel", "Upscaler", model_id=selection), + "export": _node("modules.Video", "Export", fps=16), + }, + "paths": [["pipeline", "upscale", "export"]], + } + primary = { + "loaderModule": "modules.DiffusersVideo", + "loaderAction": "LoadPipeline", + "pipelineClass": "LTXConditionPipeline", + } + with patch.dict(CONFIG.hf, {"cache_dir": str(cache_root)}): + receipts = controlled_artifact_receipts_from_graph(graph, primary_candidate=primary) + + self.assertEqual(len(receipts), 1) + receipt = receipts[0] + self.assertEqual(receipt["kind"], "spandrel_upscaler") + self.assertEqual(receipt["artifact"]["revision"], UPSCALER_REVISION) + self.assertEqual(receipt["artifact"]["sha256"], digest) + self.assertNotIn(str(cache_root), json.dumps(receipt)) + + def test_local_upscaler_receipt_never_publishes_its_absolute_root(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "custom.pth" + weight.write_bytes(b"local-upscaler") + resolved = resolve_upscaler_artifact({"source": "local", "value": str(weight)}) + + self.assertEqual(resolved.receipt["artifact"]["source"], "local") + self.assertEqual(resolved.receipt["artifact"]["weightName"], "custom.pth") + self.assertNotIn(directory, json.dumps(resolved.receipt)) + + def test_declared_upscaler_digest_mismatch_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + weight = Path(directory) / "tampered.pth" + weight.write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "declared SHA-256"): + resolve_upscaler_artifact( + {"source": "local", "value": str(weight), "sha256": "0" * 64} + ) + + def test_soundtrack_and_lyric_receipts_exclude_the_primary_pipeline(self): + video_primary = _node( + "modules.DiffusersVideo", + "LoadPipeline", + model_id={"source": "hub", "value": "Wan-AI/Wan2.2-TI2V-5B-Diffusers"}, + revision="b8fff7315c768468a5333511427288870b2e9635", + pipeline_class="WanTI2VPipeline", + ) + soundtrack = _node( + "modules.DiffusersAudio", + "LoadPipeline", + model_id={"source": "hub", "value": "ACE-Step/acestep-v15-xl-turbo-diffusers"}, + revision=ACE_REVISION, + pipeline_class="AceStepPipeline", + ) + soundtrack_graph = { + "nodes": {"video": video_primary, "audio": soundtrack, "mux": _node("modules.Video", "ExportWithAudio")}, + "paths": [["video", "audio", "mux"]], + } + soundtrack_receipts = controlled_artifact_receipts_from_graph( + soundtrack_graph, + primary_candidate={ + "loaderModule": "modules.DiffusersVideo", + "loaderAction": "LoadPipeline", + "pipelineClass": "WanTI2VPipeline", + }, + ) + + audio_primary = copy.deepcopy(soundtrack) + lyric_video = _node( + "modules.DiffusersVideo", + "LoadPipeline", + model_id={"source": "hub", "value": "Lightricks/LTX-Video-0.9.8-13B-distilled"}, + pipeline_class="LTXConditionPipeline", + ) + lyric_graph = { + "nodes": {"audio": audio_primary, "video": lyric_video, "mux": _node("modules.Video", "ExportWithAudio")}, + "paths": [["audio", "video", "mux"]], + } + lyric_receipts = controlled_artifact_receipts_from_graph( + lyric_graph, + primary_candidate={ + "loaderModule": "modules.DiffusersAudio", + "loaderAction": "LoadPipeline", + "pipelineClass": "AceStepPipeline", + }, + ) + + self.assertEqual([item["kind"] for item in soundtrack_receipts], ["diffusers_pipeline"]) + self.assertEqual(soundtrack_receipts[0]["artifact"]["revision"], ACE_REVISION) + self.assertEqual(soundtrack_receipts[0]["pipelineClass"], "AceStepPipeline") + self.assertEqual([item["kind"] for item in lyric_receipts], ["diffusers_pipeline"]) + self.assertEqual(lyric_receipts[0]["artifact"]["revision"], LTX_REVISION) + self.assertEqual(lyric_receipts[0]["pipelineClass"], "LTXConditionPipeline") + + def test_base_only_pipeline_and_disconnected_auxiliary_pipeline_add_no_receipt(self): + primary = _node( + "modules.DiffusersVideo", + "LoadPipeline", + model_id={"source": "hub", "value": "Lightricks/LTX-Video-0.9.8-13B-distilled"}, + revision=LTX_REVISION, + pipeline_class="LTXConditionPipeline", + ) + disconnected = _node( + "modules.DiffusersAudio", + "LoadPipeline", + model_id={"source": "hub", "value": "ACE-Step/acestep-v15-xl-turbo-diffusers"}, + revision=ACE_REVISION, + pipeline_class="AceStepPipeline", + ) + graph = {"nodes": {"primary": primary, "unused": disconnected}, "paths": [["primary"]]} + self.assertEqual( + controlled_artifact_receipts_from_graph( + graph, + primary_candidate={ + "loaderModule": "modules.DiffusersVideo", + "loaderAction": "LoadPipeline", + "pipelineClass": "LTXConditionPipeline", + }, + ), + [], + ) + + def test_pipeline_identity_changes_auto_history_key_and_malformed_digest_is_rejected(self): + graph = { + "nodes": { + "video": _node( + "modules.DiffusersVideo", + "LoadPipeline", + model_id={"source": "hub", "value": "Lightricks/LTX-Video-0.9.8-13B-distilled"}, + pipeline_class="LTXConditionPipeline", + ), + "audio": _node( + "modules.DiffusersAudio", + "LoadPipeline", + model_id={"source": "hub", "value": "ACE-Step/acestep-v15-xl-turbo-diffusers"}, + revision=ACE_REVISION, + pipeline_class="AceStepPipeline", + ), + }, + "paths": [["video", "audio"]], + } + primary = { + "loaderModule": "modules.DiffusersVideo", + "loaderAction": "LoadPipeline", + "pipelineClass": "LTXConditionPipeline", + } + receipt = controlled_artifact_receipts_from_graph(graph, primary_candidate=primary)[0] + candidate = { + "id": "unit", + "modelType": "LTXVideoPipeline", + "mode": "text_to_video", + "controlledArtifacts": [receipt], + } + changed = copy.deepcopy(candidate) + changed["controlledArtifacts"][0]["pipelineClass"] = "StableAudioPipeline" + self.assertNotEqual(auto_resource_history_key(candidate), auto_resource_history_key(changed)) + + malformed = copy.deepcopy(candidate) + malformed["controlledArtifacts"][0]["descriptorSha256"] = "0" * 64 + # Malformed receipts normalize differently from the exact candidate; + # they cannot reuse the exact artifact history identity. + self.assertNotEqual(auto_resource_history_key(candidate), auto_resource_history_key(malformed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_modular_identity.py b/tests/test_custom_modular_identity.py new file mode 100644 index 0000000..1df429c --- /dev/null +++ b/tests/test_custom_modular_identity.py @@ -0,0 +1,1797 @@ +import hashlib +import json +import os +import sys +import tempfile +import threading +import unittest +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager, nullcontext +from copy import deepcopy +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock, patch + +import torch +from huggingface_hub.errors import LocalEntryNotFoundError +from modiff.model_artifact_catalog import require_catalog_revision + +from modules.ModularDiffusers.custom_pipeline import ( + _BINDING_CACHE_LIMIT, + CUSTOM_PIPELINE_IDENTITY_FIELD, + CUSTOM_PIPELINE_MODEL_TYPE, + CustomPipelineExecutionIdentity, + _clear_custom_pipeline_binding_cache_for_tests, + resolve_custom_pipeline_binding, + resolve_custom_pipeline_identity, +) +from modules.ModularDiffusers.loaders import ( + AutoModelLoader, + MODELS_LOADER_IDENTITY_OUTPUTS, + ModelsLoader, + _instantiate_reviewed_builtin_pipeline, + _validate_reviewed_pipeline_index, + annotate_modular_loader_outputs, + load_components_strict, +) +from modules.ModularDiffusers.modular_utils import ( + DummyCustomPipeline, + _get_registry_instance, + get_model_type_metadata, + pipeline_class_from_model_type, + pipeline_class_from_runtime_inputs, + require_modiff_node_contract, +) +from modules.ModularDiffusers.pipeline_schema import ( + MAX_MODIFF_PIPELINE_CONFIG_BYTES, + MoDiffPipelineConfig, +) +from modules.ModularDiffusers.route_state import bind_standalone_component_output + + +def _config_bytes(label="Custom fixture", *, dtype="float16", steps=4): + config = MoDiffPipelineConfig.from_dict( + { + "label": label, + "default_dtype": dtype, + "node_params": { + "denoise": { + "block_name": "denoise", + "params": { + "unet": {"label": "Denoiser", "type": "diffusers_auto_model"}, + "steps": {"label": "Steps", "type": "int", "default": steps}, + }, + "input_names": ["steps"], + "model_input_names": ["unet"], + "output_names": ["latents"], + } + }, + } + ) + return config.to_json_string().encode("utf-8") + b"\n" + + +def _write_sidecar(directory, raw_bytes=None): + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + raw_bytes = _config_bytes() if raw_bytes is None else raw_bytes + (directory / MoDiffPipelineConfig.config_name).write_bytes(raw_bytes) + return raw_bytes + + +class _SymlinkDirEntryProxy: + """Deterministic DirEntry symlink view for Windows hosts without symlink rights.""" + + def __init__(self, entry): + self._entry = entry + self.name = entry.name + self.path = entry.path + + def is_symlink(self): + return True + + def is_dir(self, *, follow_symlinks=True): + return False + + def is_file(self, *, follow_symlinks=True): + return self._entry.is_file(follow_symlinks=follow_symlinks) + + +@contextmanager +def _working_directory(path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +class VerifiedPipelineSidecarTests(unittest.TestCase): + def test_local_sidecar_hashes_the_exact_bounded_bytes(self): + with tempfile.TemporaryDirectory() as directory: + raw_bytes = _write_sidecar(directory) + + verified = MoDiffPipelineConfig.load_verified(directory, source="local") + + self.assertEqual(verified.raw_bytes, raw_bytes) + self.assertEqual(verified.sha256, hashlib.sha256(raw_bytes).hexdigest()) + self.assertEqual(verified.source, "local") + self.assertIsNone(verified.revision) + self.assertEqual(verified.repo_id, str(Path(directory).resolve())) + + with self.assertRaisesRegex(ValueError, "exceeds 4096"): + MoDiffPipelineConfig.load_verified("x" * 4097, source="local") + + def test_sidecar_rejects_oversize_duplicate_keys_and_non_object_roots(self): + invalid_documents = { + "larger": b"{" + (b" " * MAX_MODIFF_PIPELINE_CONFIG_BYTES) + b"}", + "Duplicate": b'{"node_params":{"denoise":{"block_name":"one","block_name":"two"}}}', + "JSON object": b"[]", + "Non-finite": b'{"default_dtype":NaN}', + } + for expected_message, raw_bytes in invalid_documents.items(): + with self.subTest(expected_message=expected_message), tempfile.TemporaryDirectory() as directory: + _write_sidecar(directory, raw_bytes) + with self.assertRaisesRegex(EnvironmentError, expected_message): + MoDiffPipelineConfig.load_verified(directory, source="local") + + def test_local_sidecar_rejects_revision_and_symlink_escape_without_hub_lookup(self): + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory, "repository") + outside = Path(directory, "outside.json") + repository.mkdir() + outside.write_bytes(_config_bytes()) + try: + Path(repository, MoDiffPipelineConfig.config_name).symlink_to(outside) + containment_context = nullcontext() + except OSError: + # Windows may deny symlink creation without Developer Mode. + # Simulate the two canonical resolutions to exercise the same + # containment decision deterministically in that environment. + containment_context = patch.object( + Path, + "resolve", + side_effect=[repository.resolve(), outside.resolve()], + ) + + with patch("modules.ModularDiffusers.pipeline_schema.hf_hub_download") as hub_download: + with containment_context: + with self.assertRaisesRegex(EnvironmentError, "contained"): + MoDiffPipelineConfig.load_verified(repository, source="local") + with self.assertRaisesRegex(ValueError, "must not claim a Hub revision"): + MoDiffPipelineConfig.load_verified(repository, source="local", revision="a" * 40) + hub_download.assert_not_called() + + def test_hub_source_uses_only_exact_cached_commit_and_never_local_shadow(self): + revision = "a" * 40 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cached_snapshot = root / "cache" / "snapshots" / revision + config_path = cached_snapshot / MoDiffPipelineConfig.config_name + _write_sidecar(cached_snapshot) + _write_sidecar(root / "owner" / "pipeline", _config_bytes("shadow")) + + with ( + _working_directory(root), + patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(config_path), + ) as hub_download, + ): + verified = MoDiffPipelineConfig.load_verified( + "owner/pipeline", + source="hub", + revision=revision, + ) + + hub_download.assert_called_once_with( + "owner/pipeline", + filename=MoDiffPipelineConfig.config_name, + cache_dir=None, + local_files_only=True, + token=None, + revision=revision, + ) + self.assertEqual(verified.source, "hub") + self.assertEqual(verified.repository_path, str(cached_snapshot.absolute())) + self.assertEqual(verified.config.label, "Custom fixture") + + def test_hub_source_rejects_mutable_revision_and_missing_cache(self): + with patch("modules.ModularDiffusers.pipeline_schema.hf_hub_download") as hub_download: + with self.assertRaisesRegex(ValueError, "lowercase 40-character"): + MoDiffPipelineConfig.load_verified("owner/pipeline", source="hub", revision="main") + hub_download.assert_not_called() + + with patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + side_effect=LocalEntryNotFoundError("not cached"), + ) as hub_download: + with self.assertRaisesRegex(EnvironmentError, "Install that exact revision"): + MoDiffPipelineConfig.load_verified("owner/pipeline", source="hub", revision="b" * 40) + hub_download.assert_called_once() + + def test_sidecar_rejects_hostile_shapes_callbacks_and_excessive_nesting(self): + valid = json.loads(_config_bytes()) + invalid_documents = ( + ("non-empty 'node_params'", {}), + ("non-empty 'node_params'", {"node_params": []}), + ("non-empty 'node_params'", {"node_params": "denoise"}), + ("JSON object or null", {**valid, "node_params": {"denoise": []}}), + ("denoise.params", {**valid, "node_params": {"denoise": {"params": []}}}), + ("block_name", { + **valid, + "node_params": { + "denoise": {**valid["node_params"]["denoise"], "block_name": ["denoise"]} + }, + }), + ("input_names", { + **valid, + "node_params": { + "denoise": {**valid["node_params"]["denoise"], "input_names": "steps"} + }, + }), + ("at most 16 loader component", {**valid, "loader_component_outputs": "image_encoder"}), + ("invalid or duplicate loader component", {**valid, "loader_component_outputs": [{}]}), + ( + "invalid or duplicate loader component", + {**valid, "loader_component_outputs": ["image_encoder", "image_encoder"]}, + ), + ("at most 64 layer block", {**valid, "layer_block_options": "transformer_blocks"}), + ("invalid or duplicate layer block", {**valid, "layer_block_options": [{}]}), + ( + "invalid or duplicate layer block", + {**valid, "layer_block_options": ["transformer_blocks", "transformer_blocks"]}, + ), + ("at most 16 guider class", {**valid, "guider_options": "ClassifierFreeGuidance"}), + ("invalid or duplicate guider class", {**valid, "guider_options": [{}]}), + ( + "invalid or duplicate guider class", + {**valid, "guider_options": ["ClassifierFreeGuidance", "ClassifierFreeGuidance"]}, + ), + ("at most 32 scheduler class", {**valid, "scheduler_options": "EulerDiscreteScheduler"}), + ("invalid or duplicate scheduler class", {**valid, "scheduler_options": [{}]}), + ( + "invalid or duplicate scheduler class", + {**valid, "scheduler_options": ["EulerDiscreteScheduler", "EulerDiscreteScheduler"]}, + ), + ( + "at most 2 denoise image-latent dimension", + {**valid, "denoise_image_latent_dimensions": "height"}, + ), + ( + "invalid or duplicate denoise image-latent dimension", + {**valid, "denoise_image_latent_dimensions": [{}]}, + ), + ( + "invalid or duplicate denoise image-latent dimension", + {**valid, "denoise_image_latent_dimensions": ["height", "height"]}, + ), + ( + "invalid or duplicate denoise image-latent dimension", + {**valid, "denoise_image_latent_dimensions": ["depth"]}, + ), + ) + for expected_message, document in invalid_documents: + with self.subTest(expected_message=expected_message), tempfile.TemporaryDirectory() as directory: + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + with self.assertRaisesRegex(EnvironmentError, expected_message): + MoDiffPipelineConfig.load_verified(directory, source="local") + + for callback in ( + "set_filters", + ["update_node"], + [{"action": "show", "data": {"true": ["steps"]}}, "update_node"], + {"action": "exec", "data": "set_filters"}, + {"action": "create", "data": {}}, + ): + with self.subTest(callback=callback), tempfile.TemporaryDirectory() as directory: + document = json.loads(_config_bytes()) + document["node_params"]["denoise"]["params"]["steps"]["onChange"] = callback + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + with self.assertRaisesRegex(EnvironmentError, "callback|prohibited field action"): + MoDiffPipelineConfig.load_verified(directory, source="local") + + with tempfile.TemporaryDirectory() as directory: + document = json.loads(_config_bytes()) + document["node_params"]["denoise"]["params"]["steps"]["onChange"] = { + "false": ["steps"], + "true": [], + } + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + self.assertEqual( + MoDiffPipelineConfig.load_verified(directory, source="local").config.label, + "Custom fixture", + ) + + for callback in ( + {"true": ["ghost"]}, + {"action": "value", "target": "modiff_pipeline_identity"}, + {"action": "signal", "target": "steps"}, + ): + with self.subTest(callback=callback), tempfile.TemporaryDirectory() as directory: + document = json.loads(_config_bytes()) + document["node_params"]["denoise"]["params"]["steps"]["onChange"] = callback + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + with self.assertRaisesRegex(EnvironmentError, "unknown field target|input or output"): + MoDiffPipelineConfig.load_verified(directory, source="local") + + for reserved_name in ("__proto__", "prototype", "constructor"): + with self.subTest(reserved_name=reserved_name), tempfile.TemporaryDirectory() as directory: + document = json.loads(_config_bytes()) + document["node_params"]["denoise"]["params"][reserved_name] = {"type": "string"} + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + with self.assertRaisesRegex(EnvironmentError, "invalid parameter name"): + MoDiffPipelineConfig.load_verified(directory, source="local") + + nested = "leaf" + for _index in range(32): + nested = {"nested": nested} + document = json.loads(_config_bytes()) + document["future_metadata"] = nested + with tempfile.TemporaryDirectory() as directory: + _write_sidecar(directory, json.dumps(document).encode("utf-8")) + with self.assertRaisesRegex(EnvironmentError, "structural depth"): + MoDiffPipelineConfig.load_verified(directory, source="local") + + def test_manifest_detects_loader_metadata_and_python_drift_but_not_weights(self): + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + _write_sidecar(repository) + (repository / "pipeline_a.py").write_text("class PipelineA: pass\n", encoding="utf-8") + (repository / "pipeline_b.py").write_text("class PipelineB: pass\n", encoding="utf-8") + config_path = repository / "config.json" + config_path.write_text( + json.dumps({"auto_map": {"AutoPipeline": "pipeline_a.PipelineA"}}), encoding="utf-8" + ) + weights_path = repository / "weights.safetensors" + weights_path.write_bytes(b"weights-a") + + original = MoDiffPipelineConfig.load_verified(repository, source="local") + weights_path.write_bytes(b"weights-b") + weights_changed = MoDiffPipelineConfig.load_verified(repository, source="local") + self.assertEqual( + weights_changed.executable_manifest_sha256, + original.executable_manifest_sha256, + "P0 manifest intentionally does not prove model-weight bytes; full artifact proof is P0.4.", + ) + + config_path.write_text( + json.dumps({"auto_map": {"AutoPipeline": "pipeline_b.PipelineB"}}), encoding="utf-8" + ) + config_changed = MoDiffPipelineConfig.load_verified(repository, source="local") + self.assertNotEqual(config_changed.executable_manifest_sha256, original.executable_manifest_sha256) + + (repository / "pipeline_b.py").unlink() + python_removed = MoDiffPipelineConfig.load_verified(repository, source="local") + self.assertNotEqual( + python_removed.executable_manifest_sha256, + config_changed.executable_manifest_sha256, + ) + + def test_manifest_enforces_file_count_size_and_local_symlink_boundaries(self): + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + _write_sidecar(repository) + (repository / "a.py").write_bytes(b"123456789") + (repository / "b.py").write_bytes(b"pass\n") + with patch("modules.ModularDiffusers.pipeline_schema.MAX_LOCAL_EXECUTABLE_MANIFEST_FILES", 1): + with self.assertRaisesRegex(EnvironmentError, "file executable-manifest limit"): + MoDiffPipelineConfig.load_verified(repository, source="local") + with patch("modules.ModularDiffusers.pipeline_schema.MAX_LOCAL_EXECUTABLE_FILE_BYTES", 8): + with self.assertRaisesRegex(EnvironmentError, "exceeds the 8-byte limit"): + MoDiffPipelineConfig.load_verified(repository, source="local") + + real_scandir = os.scandir + + def linked_scandir(path): + with real_scandir(path) as scanner: + entries = list(scanner) + if Path(path) == repository: + return [ + _SymlinkDirEntryProxy(entry) if entry.name == "b.py" else entry + for entry in entries + ] + return entries + + with patch("modules.ModularDiffusers.pipeline_schema.os.scandir", side_effect=linked_scandir): + with self.assertRaisesRegex(EnvironmentError, "does not allow linked file"): + MoDiffPipelineConfig.load_verified(repository, source="local") + + def test_hub_manifest_allows_only_this_repository_blob_symlinks(self): + revision = "c" * 40 + with tempfile.TemporaryDirectory() as directory: + repo_cache = Path(directory, "models--owner--pipeline") + blobs = repo_cache / "blobs" + snapshot = repo_cache / "snapshots" / revision + blobs.mkdir(parents=True) + snapshot.mkdir(parents=True) + sidecar_blob = blobs / "sidecar" + sidecar_blob.write_bytes(_config_bytes()) + python_blob = blobs / "python" + python_blob.write_text("class CachedPipeline: pass\n", encoding="utf-8") + sidecar_path = snapshot / MoDiffPipelineConfig.config_name + python_path = snapshot / "pipeline.py" + sidecar_path.write_bytes(sidecar_blob.read_bytes()) + python_path.write_bytes(python_blob.read_bytes()) + real_scandir = os.scandir + real_resolve = Path.resolve + real_is_symlink = Path.is_symlink + + def linked_scandir(path): + with real_scandir(path) as scanner: + entries = list(scanner) + if Path(path) == snapshot: + return [ + _SymlinkDirEntryProxy(entry) if entry.name == "pipeline.py" else entry + for entry in entries + ] + return entries + + def linked_resolve(path, strict=False): + candidate = Path(path) + if candidate == sidecar_path: + return sidecar_blob + if candidate == python_path: + return python_blob + return real_resolve(candidate, strict=strict) + + def linked_is_symlink(path): + candidate = Path(path) + return candidate == sidecar_path or real_is_symlink(candidate) + + with ( + patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(sidecar_path), + ), + patch("modules.ModularDiffusers.pipeline_schema.os.scandir", side_effect=linked_scandir), + patch.object(Path, "resolve", new=linked_resolve), + patch.object(Path, "is_symlink", new=linked_is_symlink), + ): + verified = MoDiffPipelineConfig.load_verified( + "owner/pipeline", source="hub", revision=revision + ) + self.assertRegex(verified.executable_manifest_sha256, r"^[0-9a-f]{64}$") + + outside = Path(directory, "outside.py") + outside.write_text("raise RuntimeError\n", encoding="utf-8") + def escaping_resolve(path, strict=False): + candidate = Path(path) + if candidate == sidecar_path: + return sidecar_blob + if candidate == python_path: + return outside + return real_resolve(candidate, strict=strict) + + with ( + patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(sidecar_path), + ), + patch("modules.ModularDiffusers.pipeline_schema.os.scandir", side_effect=linked_scandir), + patch.object(Path, "resolve", new=escaping_resolve), + patch.object(Path, "is_symlink", new=linked_is_symlink), + ): + with self.assertRaisesRegex(EnvironmentError, "blobs directory"): + MoDiffPipelineConfig.load_verified("owner/pipeline", source="hub", revision=revision) + + def test_hub_snapshot_rejects_linked_snapshot_ancestor(self): + revision = "d" * 40 + with tempfile.TemporaryDirectory() as directory: + snapshot = Path(directory, "cache", "snapshots", revision) + config_path = snapshot / MoDiffPipelineConfig.config_name + _write_sidecar(snapshot) + with ( + patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(config_path), + ), + patch( + "modules.ModularDiffusers.pipeline_schema._is_linked_directory", + side_effect=lambda path: path == snapshot, + ), + ): + with self.assertRaisesRegex(EnvironmentError, "symlink or junction"): + MoDiffPipelineConfig.load_verified("owner/pipeline", source="hub", revision=revision) + + +class CustomPipelineBindingTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + root = Path(self.temporary_directory.name) + self.pipeline_a = root / "pipeline-a" + self.pipeline_b = root / "pipeline-b" + _write_sidecar(self.pipeline_a, _config_bytes("Pipeline A", steps=4)) + _write_sidecar(self.pipeline_b, _config_bytes("Pipeline B", steps=8)) + _clear_custom_pipeline_binding_cache_for_tests() + + def tearDown(self): + _clear_custom_pipeline_binding_cache_for_tests() + self.temporary_directory.cleanup() + + def _resolve(self, repository, *, expected_identity=None, allow_selector_change=False, trust=False): + return resolve_custom_pipeline_binding( + source="local", + repo_id=str(repository), + revision=None, + trust_remote_code=trust, + expected_identity=expected_identity, + allow_selector_change=allow_selector_change, + ) + + @staticmethod + def _component_payload(binding): + return { + "model_type": CUSTOM_PIPELINE_MODEL_TYPE, + CUSTOM_PIPELINE_IDENTITY_FIELD: binding.identity.to_dict(), + } + + def test_binding_is_callable_immutable_and_config_reads_are_isolated(self): + binding = self._resolve(self.pipeline_a) + first_config = binding.pipeline_config() + first_config.node_params["denoise"]["params"]["steps"]["default"] = 99 + + self.assertEqual(binding.pipeline_config().node_params["denoise"]["params"]["steps"]["default"], 4) + self.assertEqual(binding.__name__, CUSTOM_PIPELINE_MODEL_TYPE) + self.assertEqual(binding.execution_status, "contract_only") + with patch("diffusers.ModularPipeline.from_pretrained") as loader: + with self.assertRaisesRegex(RuntimeError, "Contract preview remains available"): + binding() + loader.assert_not_called() + + def test_custom_a_standard_b_custom_a_has_no_global_registry_mutation(self): + registry = _get_registry_instance() + before = registry.get_all() + binding_a = self._resolve(self.pipeline_a) + + recovered_a = pipeline_class_from_runtime_inputs(None, self._component_payload(binding_a)) + standard_b = pipeline_class_from_model_type("FluxModularPipeline") + recovered_a_again = pipeline_class_from_runtime_inputs(None, self._component_payload(binding_a)) + + self.assertIs(recovered_a, binding_a) + self.assertEqual(standard_b.__name__, "FluxModularPipeline") + self.assertIs(recovered_a_again, binding_a) + self.assertEqual(registry.get_all(), before) + self.assertNotIn("repo_id", DummyCustomPipeline.__dict__) + + def test_interleaved_custom_bindings_keep_distinct_contracts_and_identities(self): + binding_a = self._resolve(self.pipeline_a) + binding_b = self._resolve(self.pipeline_b) + + with ThreadPoolExecutor(max_workers=4) as executor: + recovered = list( + executor.map( + lambda binding: pipeline_class_from_runtime_inputs(None, self._component_payload(binding)), + [binding_a, binding_b, binding_a, binding_b], + ) + ) + + self.assertEqual( + [item.identity for item in recovered], + [ + binding_a.identity, + binding_b.identity, + binding_a.identity, + binding_b.identity, + ], + ) + self.assertEqual(recovered[0].pipeline_config().label, "Pipeline A") + self.assertEqual(recovered[1].pipeline_config().label, "Pipeline B") + with self.assertRaisesRegex(ValueError, "different custom contract identity"): + pipeline_class_from_runtime_inputs(binding_a, self._component_payload(binding_b)) + + def test_restart_recovery_is_local_only_and_does_not_depend_on_cache_residency(self): + original = self._resolve(self.pipeline_a) + identity_value = original.identity.to_dict() + _clear_custom_pipeline_binding_cache_for_tests() + + with patch("modules.ModularDiffusers.pipeline_schema.hf_hub_download") as hub_download: + recovered = resolve_custom_pipeline_identity(identity_value) + + self.assertIsNot(recovered, original) + self.assertEqual(recovered.identity, original.identity) + self.assertEqual(recovered.pipeline_config().label, "Pipeline A") + hub_download.assert_not_called() + + def test_same_selector_sidecar_drift_fails_until_explicit_refresh(self): + original = self._resolve(self.pipeline_a) + _write_sidecar(self.pipeline_a, _config_bytes("Pipeline A revised", steps=12)) + + with self.assertRaisesRegex(ValueError, "no longer matches the persisted"): + resolve_custom_pipeline_identity(original.identity.to_dict()) + with patch("diffusers.ModularPipeline.from_pretrained") as pipeline_loader: + with self.assertRaisesRegex(ValueError, "no longer matches the persisted"): + original() + pipeline_loader.assert_not_called() + + refreshed = self._resolve(self.pipeline_a) + self.assertNotEqual(refreshed.identity, original.identity) + self.assertEqual(refreshed.pipeline_config().label, "Pipeline A revised") + + def test_executable_manifest_drift_rejects_old_identity_before_upstream(self): + (self.pipeline_a / "pipeline_a.py").write_text("class PipelineA: pass\n", encoding="utf-8") + (self.pipeline_a / "pipeline_b.py").write_text("class PipelineB: pass\n", encoding="utf-8") + index_path = self.pipeline_a / "modular_model_index.json" + index_path.write_text( + json.dumps({"auto_map": {"ModularPipeline": "pipeline_a.PipelineA"}}), encoding="utf-8" + ) + original = self._resolve(self.pipeline_a) + index_path.write_text( + json.dumps({"auto_map": {"ModularPipeline": "pipeline_b.PipelineB"}}), encoding="utf-8" + ) + + with patch("diffusers.ModularPipeline.from_pretrained") as pipeline_loader: + with self.assertRaisesRegex(ValueError, "executable metadata no longer matches"): + resolve_custom_pipeline_identity(original.identity.to_dict()) + with self.assertRaisesRegex(ValueError, "executable metadata no longer matches"): + original() + pipeline_loader.assert_not_called() + + def test_cached_hub_python_drift_rejects_old_identity_before_upstream(self): + revision = "e" * 40 + repo_cache = Path(self.temporary_directory.name, "models--owner--pipeline") + snapshot = repo_cache / "snapshots" / revision + sidecar_path = snapshot / MoDiffPipelineConfig.config_name + _write_sidecar(snapshot) + python_path = snapshot / "pipeline.py" + python_path.write_text("class PipelineA: pass\n", encoding="utf-8") + with patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(sidecar_path), + ): + original = resolve_custom_pipeline_binding( + source="hub", + repo_id="owner/pipeline", + revision=revision, + trust_remote_code=False, + ) + + python_path.write_text("class PipelineB: pass\n", encoding="utf-8") + with ( + patch( + "modules.ModularDiffusers.pipeline_schema.hf_hub_download", + return_value=str(sidecar_path), + ), + patch("diffusers.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(ValueError, "executable metadata no longer matches"): + resolve_custom_pipeline_identity(original.identity.to_dict()) + pipeline_loader.assert_not_called() + + def test_identity_parser_rejects_tampering_and_malformed_container_fields(self): + identity = self._resolve(self.pipeline_a).identity.to_dict() + invalid_fields = { + "source": [], + "repo_id": {}, + "revision": "a" * 40, + "trust_remote_code": "false", + "config_sha256": [], + "executable_manifest_sha256": {}, + "execution_id": "sha256:" + ("0" * 64), + } + for field_name, invalid_value in invalid_fields.items(): + with self.subTest(field_name=field_name): + malformed = deepcopy(identity) + malformed[field_name] = invalid_value + with self.assertRaises((TypeError, ValueError)): + CustomPipelineExecutionIdentity.from_value(malformed) + + malformed = {**identity, "unknown": True} + with self.assertRaisesRegex(ValueError, "unknown unknown"): + CustomPipelineExecutionIdentity.from_value(malformed) + + oversized_repository = deepcopy(identity) + oversized_repository["repo_id"] = "x" * 4097 + with self.assertRaisesRegex(ValueError, "4096-character"): + CustomPipelineExecutionIdentity.from_value(oversized_repository) + + remote_code = deepcopy(identity) + remote_code["trust_remote_code"] = True + with self.assertRaisesRegex(ValueError, "repository code is disabled"): + CustomPipelineExecutionIdentity.from_value(remote_code) + + def test_runtime_rejects_missing_and_mixed_custom_identities(self): + binding_a = self._resolve(self.pipeline_a) + binding_b = self._resolve(self.pipeline_b) + with self.assertRaisesRegex(ValueError, "backend-issued contract identity"): + pipeline_class_from_runtime_inputs(None, {"model_type": CUSTOM_PIPELINE_MODEL_TYPE}) + with self.assertRaisesRegex(ValueError, "incompatible contract identities"): + pipeline_class_from_runtime_inputs( + None, + self._component_payload(binding_a), + self._component_payload(binding_b), + ) + malformed = self._component_payload(binding_a) + malformed[CUSTOM_PIPELINE_IDENTITY_FIELD]["source"] = [] + with self.assertRaisesRegex(ValueError, "source must be exactly"): + pipeline_class_from_runtime_inputs(None, malformed) + + def test_concurrent_same_identity_resolution_converges_without_registry_exposure(self): + initial_registry = _get_registry_instance().get_all() + with ThreadPoolExecutor(max_workers=12) as executor: + bindings = list(executor.map(lambda _index: self._resolve(self.pipeline_a), range(32))) + + self.assertTrue(all(binding is bindings[0] for binding in bindings)) + self.assertEqual(_get_registry_instance().get_all(), initial_registry) + + def test_binding_cache_is_bounded_and_eviction_does_not_break_recovery(self): + first = self._resolve(self.pipeline_a) + root = Path(self.temporary_directory.name) + for index in range(_BINDING_CACHE_LIMIT): + repository = root / f"eviction-{index}" + _write_sidecar(repository, _config_bytes(f"Eviction {index}")) + self._resolve(repository) + + recovered = resolve_custom_pipeline_identity(first.identity.to_dict()) + self.assertIsNot(recovered, first) + self.assertEqual(recovered.identity, first.identity) + + def test_ui_contract_lookup_does_not_instantiate_custom_code_and_execution_is_contract_only(self): + binding = self._resolve(self.pipeline_a) + runtime_block = object() + fake_pipeline = SimpleNamespace(blocks=SimpleNamespace(sub_blocks={"denoise": runtime_block})) + + with patch("diffusers.ModularPipeline.from_pretrained", return_value=fake_pipeline) as loader: + blocks, node_config = require_modiff_node_contract(binding, "denoise", resolve_blocks=False) + loader.assert_not_called() + self.assertIsNone(blocks) + self.assertEqual(node_config["params"]["steps"]["default"], 4) + + with self.assertRaisesRegex(RuntimeError, "component type_hint"): + require_modiff_node_contract(binding, "denoise") + + loader.assert_not_called() + + def test_attacker_component_library_is_never_imported_by_binding(self): + (self.pipeline_a / "modular_model_index.json").write_text( + json.dumps( + { + "transformer": { + "type_hint": ["attacker_package", "Payload"], + "pretrained_model_name_or_path": "owner/payload", + } + } + ), + encoding="utf-8", + ) + binding = self._resolve(self.pipeline_a) + real_import = __import__ + + def guarded_import(name, *args, **kwargs): + if name == "attacker_package": + raise AssertionError("repository-controlled package import was reached") + return real_import(name, *args, **kwargs) + + with ( + patch("builtins.__import__", side_effect=guarded_import), + patch("diffusers.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(RuntimeError, "Contract preview remains available"): + binding() + pipeline_loader.assert_not_called() + + def test_trust_true_binding_is_rejected_before_sidecar_or_upstream_load(self): + with ( + patch("modules.ModularDiffusers.custom_pipeline.PipelineConfig.load_verified") as sidecar_loader, + patch("diffusers.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(ValueError, "repository code is disabled"): + resolve_custom_pipeline_binding( + source="hub", + repo_id="owner/pipeline", + revision="f" * 40, + trust_remote_code=True, + ) + sidecar_loader.assert_not_called() + pipeline_loader.assert_not_called() + + +class ModelsLoaderCustomIdentityTests(unittest.TestCase): + def setUp(self): + self.temporary_directory = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary_directory.name, "pipeline") + _write_sidecar(self.repository) + _clear_custom_pipeline_binding_cache_for_tests() + + def tearDown(self): + _clear_custom_pipeline_binding_cache_for_tests() + self.temporary_directory.cleanup() + + def _values(self, identity=None): + return { + "model_type": CUSTOM_PIPELINE_MODEL_TYPE, + "repo_id": {"source": "local", "value": str(self.repository)}, + "revision": "", + "trust_remote_code": False, + CUSTOM_PIPELINE_IDENTITY_FIELD: identity, + } + + @staticmethod + def _capture_node_messages(node): + node.set_field_value = Mock() + node.set_field_visibility = Mock() + node.set_field_params = Mock() + + def test_loader_declares_object_identity_and_refresh_actions(self): + self.assertEqual(ModelsLoader.params[CUSTOM_PIPELINE_IDENTITY_FIELD]["type"], "object") + self.assertTrue(ModelsLoader.params[CUSTOM_PIPELINE_IDENTITY_FIELD]["hidden"]) + self.assertEqual(ModelsLoader.params["repo_id"]["onChange"], "refresh_pipeline_identity") + self.assertEqual(ModelsLoader.params["revision"]["onChange"], "refresh_pipeline_identity") + self.assertEqual(ModelsLoader.params["trust_remote_code"]["onChange"], "refresh_pipeline_identity") + self.assertEqual( + get_model_type_metadata(CUSTOM_PIPELINE_MODEL_TYPE)["execution_status"], + "contract_only", + ) + + def test_field_action_persists_identity_and_publishes_structured_output_signals(self): + node = ModelsLoader("identity-field-action") + self._capture_node_messages(node) + + node.refresh_pipeline_identity(self._values(), {"key": "repo_id"}) + + persisted = node.set_field_value.call_args.args[0][CUSTOM_PIPELINE_IDENTITY_FIELD] + parsed = CustomPipelineExecutionIdentity.from_value(persisted) + self.assertEqual(parsed.source, "local") + signal_calls = { + call.args[0]: call.args[1]["signal"] + for call in node.set_field_params.call_args_list + if "signal" in call.args[1] + } + self.assertEqual(set(signal_calls), set(MODELS_LOADER_IDENTITY_OUTPUTS)) + for signal in signal_calls.values(): + self.assertEqual(signal["direction"], "output") + self.assertEqual(signal["origin"], CUSTOM_PIPELINE_IDENTITY_FIELD) + self.assertEqual(signal["value"], persisted) + + def test_field_action_clears_stale_hub_revision_when_source_is_local(self): + node = ModelsLoader("identity-local-revision-normalization") + self._capture_node_messages(node) + values = self._values() + values["revision"] = "a" * 40 + + node.refresh_pipeline_identity(values, {"key": "repo_id"}) + + published_values = node.set_field_value.call_args.args[0] + self.assertEqual(published_values["revision"], "") + identity = CustomPipelineExecutionIdentity.from_value( + published_values[CUSTOM_PIPELINE_IDENTITY_FIELD] + ) + self.assertIsNone(identity.revision) + + def test_trust_true_field_actions_cannot_mint_or_advertise_a_contract(self): + for ref_key in ("trust_remote_code", "refresh_pipeline_identity_button"): + with self.subTest(ref_key=ref_key): + node = ModelsLoader(f"identity-trust-disabled-{ref_key}") + self._capture_node_messages(node) + values = self._values() + values["trust_remote_code"] = True + with patch( + "modules.ModularDiffusers.loaders.resolve_custom_pipeline_binding" + ) as resolver: + with self.assertRaisesRegex(ValueError, "(?i)repository code is disabled"): + node.refresh_pipeline_identity(values, {"key": ref_key}) + resolver.assert_not_called() + self.assertIsNone( + node.set_field_value.call_args.args[0][CUSTOM_PIPELINE_IDENTITY_FIELD] + ) + signals = [ + call.args[1]["signal"]["value"] + for call in node.set_field_params.call_args_list + if "signal" in call.args[1] + ] + self.assertEqual(signals, [""] * len(MODELS_LOADER_IDENTITY_OUTPUTS)) + + def test_same_selector_drift_clears_signal_and_requires_explicit_refresh(self): + node = ModelsLoader("identity-explicit-refresh") + self._capture_node_messages(node) + node.refresh_pipeline_identity(self._values(), {"key": "repo_id"}) + old_identity = node.set_field_value.call_args.args[0][CUSTOM_PIPELINE_IDENTITY_FIELD] + node.set_field_value.reset_mock() + node.set_field_params.reset_mock() + _write_sidecar(self.repository, _config_bytes("reviewed replacement", steps=6)) + + with self.assertRaisesRegex(ValueError, "explicitly refresh"): + node.refresh_pipeline_identity(self._values(old_identity), {"key": "revision"}) + node.set_field_value.assert_not_called() + stale_signals = [ + call.args[1]["signal"]["value"] + for call in node.set_field_params.call_args_list + if "signal" in call.args[1] + ] + self.assertEqual(stale_signals, [""] * len(MODELS_LOADER_IDENTITY_OUTPUTS)) + + node.set_field_params.reset_mock() + node.refresh_pipeline_identity( + self._values(old_identity), + {"key": "refresh_pipeline_identity_button"}, + ) + new_identity = node.set_field_value.call_args.args[0][CUSTOM_PIPELINE_IDENTITY_FIELD] + self.assertNotEqual(new_identity, old_identity) + + def test_generation_guard_prevents_stale_field_action_publication(self): + node = ModelsLoader("identity-generation") + self._capture_node_messages(node) + binding = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ) + entered = threading.Event() + release = threading.Event() + + def slow_resolve(**_kwargs): + entered.set() + release.wait(timeout=5) + return binding + + with patch("modules.ModularDiffusers.loaders.resolve_custom_pipeline_binding", side_effect=slow_resolve): + worker = threading.Thread( + target=node.refresh_pipeline_identity, + args=(self._values(), {"key": "repo_id"}), + ) + worker.start() + self.assertTrue(entered.wait(timeout=2)) + node.refresh_pipeline_identity( + {"model_type": "FluxModularPipeline"}, + {"key": "model_type"}, + ) + release.set() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(node.set_field_value.call_count, 1) + self.assertIsNone(node.set_field_value.call_args.args[0][CUSTOM_PIPELINE_IDENTITY_FIELD]) + signals = [ + call.args[1]["signal"]["value"] + for call in node.set_field_params.call_args_list + if "signal" in call.args[1] + ] + self.assertEqual(signals, ["FluxModularPipeline"] * len(MODELS_LOADER_IDENTITY_OUTPUTS)) + + def test_generation_guard_suppresses_failure_from_obsolete_field_action(self): + node = ModelsLoader("identity-generation-failure") + self._capture_node_messages(node) + entered = threading.Event() + release = threading.Event() + worker_errors = [] + + def slow_failure(**_kwargs): + entered.set() + release.wait(timeout=5) + raise ValueError("obsolete custom selection failed") + + def run_obsolete_action(): + try: + node.refresh_pipeline_identity(self._values(), {"key": "repo_id"}) + except Exception as error: # pragma: no cover - asserted through worker_errors + worker_errors.append(error) + + with patch("modules.ModularDiffusers.loaders.resolve_custom_pipeline_binding", side_effect=slow_failure): + worker = threading.Thread(target=run_obsolete_action) + worker.start() + self.assertTrue(entered.wait(timeout=2)) + node.refresh_pipeline_identity( + {"model_type": "FluxModularPipeline"}, + {"key": "model_type"}, + ) + release.set() + worker.join(timeout=5) + + self.assertFalse(worker.is_alive()) + self.assertEqual(worker_errors, []) + signals = [ + call.args[1]["signal"]["value"] + for call in node.set_field_params.call_args_list + if "signal" in call.args[1] + ] + self.assertEqual(signals, ["FluxModularPipeline"] * len(MODELS_LOADER_IDENTITY_OUTPUTS)) + + def test_execute_requires_persisted_identity_before_modular_pipeline_load(self): + node = ModelsLoader("identity-required") + with patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader: + with self.assertRaisesRegex(ValueError, "backend-issued identity"): + node.execute( + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + repo_id={"source": "local", "value": str(self.repository)}, + device="cpu", + dtype=torch.float32, + auto_offload=False, + offload_mode="none", + modiff_pipeline_identity=None, + ) + pipeline_loader.assert_not_called() + + def test_execute_reverifies_hash_before_modular_pipeline_load(self): + binding = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ) + _write_sidecar(self.repository, _config_bytes("changed after issue")) + node = ModelsLoader("identity-reverify") + with patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader: + with self.assertRaisesRegex(ValueError, "no longer matches"): + node.execute( + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + repo_id={"source": "local", "value": str(self.repository)}, + device="cpu", + dtype=torch.float32, + auto_offload=False, + offload_mode="none", + modiff_pipeline_identity=binding.identity.to_dict(), + ) + pipeline_loader.assert_not_called() + + def test_execute_rejects_hand_edited_local_revision_before_pipeline_load(self): + binding = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ) + node = ModelsLoader("identity-local-revision-rejection") + with patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader: + with self.assertRaisesRegex(ValueError, "must not claim a Hub revision"): + node.execute( + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + repo_id={"source": "local", "value": str(self.repository)}, + device="cpu", + dtype=torch.float32, + auto_offload=False, + offload_mode="none", + revision="a" * 40, + modiff_pipeline_identity=binding.identity.to_dict(), + ) + pipeline_loader.assert_not_called() + + def test_execute_custom_is_contract_only_before_any_upstream_constructor(self): + (self.repository / "modular_model_index.json").write_text( + json.dumps( + { + "transformer": { + "type_hint": ["attacker_package", "Payload"], + "pretrained_model_name_or_path": "owner/payload", + } + } + ), + encoding="utf-8", + ) + binding = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ) + node = ModelsLoader("identity-preflight") + real_import = __import__ + + def guarded_import(name, *args, **kwargs): + if name == "attacker_package": + raise AssertionError("repository-controlled package import was reached") + return real_import(name, *args, **kwargs) + + with ( + patch("builtins.__import__", side_effect=guarded_import), + patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(RuntimeError, "contract_only"): + node.execute( + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + repo_id={"source": "local", "value": str(self.repository)}, + device="cpu", + dtype=torch.float32, + auto_offload=False, + offload_mode="none", + modiff_pipeline_identity=binding.identity.to_dict(), + ) + pipeline_loader.assert_not_called() + + def test_self_describing_outputs_copy_the_exact_custom_identity(self): + identity = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ).identity.to_dict() + outputs = {"unet_out": {"model_id": "one"}, "text_encoders": {"text_encoder": {}}} + + annotate_modular_loader_outputs( + outputs, + repo_id=str(self.repository.resolve()), + repo_source="local", + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + revision=None, + trust_remote_code=False, + custom_identity=identity, + ) + identity["repo_id"] = "tampered after publication" + + for value in outputs.values(): + recovered = CustomPipelineExecutionIdentity.from_value(value[CUSTOM_PIPELINE_IDENTITY_FIELD]) + self.assertEqual(recovered.repo_id, str(self.repository.resolve())) + self.assertEqual(value["repo_source"], "local") + + annotate_modular_loader_outputs( + outputs, + repo_id="owner/standard", + repo_source="hub", + model_type="FluxModularPipeline", + revision="a" * 40, + trust_remote_code=False, + ) + self.assertTrue( + all(CUSTOM_PIPELINE_IDENTITY_FIELD not in value for value in outputs.values()) + ) + + def test_standard_component_load_does_not_force_local_only(self): + spec = SimpleNamespace( + pretrained_model_name_or_path="owner/component", + load=Mock(return_value=object()), + ) + pipeline = SimpleNamespace( + _component_specs={"transformer": spec}, + _pretrained_model_name_or_path="owner/component", + register_components=Mock(), + ) + diagnostics = {} + + load_components_strict( + pipeline, + ["transformer"], + required_names={"transformer"}, + model_id="owner/component", + dtype="float32", + offload_mode="none", + quant_config=None, + diagnostics=diagnostics, + component_load_kwargs={"torch_dtype": torch.float32}, + ) + + spec.load.assert_called_once_with(torch_dtype=torch.float32) + self.assertEqual(diagnostics["components_loaded"], ["transformer"]) + + def test_auto_model_trust_false_preserves_existing_download_behavior(self): + node = AutoModelLoader("auto-model-download-contract") + node.diffusers_loading_progress = Mock(return_value=nullcontext()) + revision = "a" * 40 + + class ApprovedTransformer: + pass + + spec = SimpleNamespace( + load_id="owner/component", + load=Mock(side_effect=RuntimeError("stop after load kwargs")), + ) + with ( + patch("modules.ModularDiffusers.loaders.ComponentSpec", return_value=spec) as component_spec, + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + return_value=( + "hub", + "owner/component", + revision, + "transformer", + "ApprovedTransformer", + "f" * 64, + ), + ), + patch( + "modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class", + return_value=ApprovedTransformer, + ), + patch("modules.ModularDiffusers.loaders.reusable_standalone_component", return_value=None), + ): + with self.assertRaisesRegex(RuntimeError, "stop after load kwargs"): + node.execute( + model_type="transformer", + model_id={"source": "hub", "value": "owner/component"}, + dtype=torch.float32, + trust_remote_code=False, + ) + component_spec.assert_called_once_with( + name="transformer", + type_hint=ApprovedTransformer, + pretrained_model_name_or_path="owner/component", + subfolder="transformer", + variant=None, + revision=revision, + ) + spec.load.assert_called_once_with(torch_dtype=torch.float32) + + def test_auto_model_selector_spoofing_fails_before_node_cache(self): + node = AutoModelLoader("auto-model-selector-cache-guard") + common = { + "model_type": "transformer", + "trust_remote_code": False, + "revision": "a" * 40, + } + invalid_selectors = ( + "owner/component", + {"source": "api", "value": "owner/component"}, + {"source": [], "value": "owner/component"}, + ) + with patch("modiff.NodeBase.NodeBase.__call__") as base_call: + for selector in invalid_selectors: + with self.subTest(selector=selector): + with self.assertRaises((TypeError, ValueError)): + node(model_id=selector, **common) + with self.assertRaisesRegex(ValueError, "requires a component type"): + node(model_type=[], model_id={"source": "hub", "value": "owner/component"}, **{ + key: value for key, value in common.items() if key != "model_type" + }) + base_call.assert_not_called() + + def test_auto_model_derived_config_identity_participates_in_node_cache(self): + node = AutoModelLoader("auto-model-derived-cache-key") + identity_a = ("hub", "owner/component", "a" * 40, "transformer", "ClassA", "1" * 64) + identity_b = ("hub", "owner/component", "a" * 40, "transformer", "ClassA", "2" * 64) + + def publish(**kwargs): + identity = kwargs["_reviewed_component_identity"] + source, repo_id, revision, _subfolder, class_name, fingerprint = identity + model = { + "model_id": "transformer-resident-a" if fingerprint == "1" * 64 else "transformer-resident-b", + "class_name": class_name, + "class": "A" if fingerprint == "1" * 64 else "B", + "repo_id": repo_id, + "repo_source": source, + "revision": revision, + "trust_remote_code": False, + } + bind_standalone_component_output( + model, + issuer=node._standalone_component_issuer, + component_kind="transformer", + reviewed_identity=identity, + ) + return {"model": model} + + node.execute = Mock(side_effect=publish) + inputs = { + "model_type": "transformer", + "model_id": {"source": "hub", "value": "owner/component"}, + "dtype": "float32", + "subfolder": "transformer", + "variant": "", + "trust_remote_code": False, + "revision": "a" * 40, + "device": "cpu", + "auto_offload": False, + "offload_mode": "none", + } + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + side_effect=(identity_a, identity_b, identity_b), + ), + patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True), + ): + first = node(**inputs) + second = node(**inputs) + third = node(**inputs) + + self.assertEqual(first["model"]["class"], "A") + self.assertEqual(second["model"]["class"], "B") + self.assertEqual(third["model"]["class"], "B") + self.assertEqual(node.execute.call_count, 2) + + def test_auto_model_preflight_accepts_only_diffusers_category_config(self): + from modules.ModularDiffusers.loaders import _preflight_reviewed_diffusers_component + + revision = "a" * 40 + valid_config = {"_class_name": "FluxTransformer2DModel"} + with ( + patch( + "modules.ModularDiffusers.loaders._load_reviewed_component_config", + return_value=valid_config, + ) as load_config, + patch("modules.ModularDiffusers.loaders.importlib.import_module") as class_import, + ): + result = _preflight_reviewed_diffusers_component( + "transformer", + {"source": "hub", "value": "owner/component"}, + "transformer", + revision, + ) + self.assertEqual( + result, + ( + "hub", + "owner/component", + revision, + "transformer", + "FluxTransformer2DModel", + hashlib.sha256(b'{"_class_name":"FluxTransformer2DModel"}').hexdigest(), + ), + ) + load_config.assert_called_once_with("hub", "owner/component", "transformer", revision) + class_import.assert_not_called() + + hostile_configs = ( + {"_class_name": "FluxTransformer2DModel", "auto_map": {"x": "attacker.Payload"}}, + {"model_type": "attacker_transformer"}, + {"_class_name": "AutoencoderKL"}, + {"_class_name": "AttackerPipeline"}, + ) + for config in hostile_configs: + with ( + self.subTest(config=config), + patch( + "modules.ModularDiffusers.loaders._load_reviewed_component_config", + return_value=config, + ), + ): + with self.assertRaises(ValueError): + _preflight_reviewed_diffusers_component( + "transformer", + {"source": "hub", "value": "owner/component"}, + "transformer", + revision, + ) + + def test_curated_qwen_controlnet_preflight_uses_the_catalog_commit_without_network(self): + from modules.ModularDiffusers.loaders import _preflight_reviewed_diffusers_component + + revision = "b13036f066d6dee7c20513e263d3d673055e9de8" + with patch( + "modules.ModularDiffusers.loaders._load_reviewed_component_config", + return_value={"_class_name": "QwenImageControlNetModel"}, + ) as load_config: + result = _preflight_reviewed_diffusers_component( + "controlnet", + {"source": "hub", "value": "InstantX/Qwen-Image-ControlNet-Union"}, + "", + "", + ) + + self.assertEqual( + result[0:5], + ( + "hub", + "InstantX/Qwen-Image-ControlNet-Union", + revision, + None, + "QwenImageControlNetModel", + ), + ) + load_config.assert_called_once_with( + "hub", + "InstantX/Qwen-Image-ControlNet-Union", + None, + revision, + ) + + def test_auto_model_local_root_mapping_cannot_select_an_installed_library(self): + from diffusers import FluxTransformer2DModel + from modules.ModularDiffusers.loaders import _preflight_reviewed_diffusers_component + + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + component_directory = repository / "transformer" + component_directory.mkdir() + (repository / "model_index.json").write_text( + json.dumps({"transformer": ["attacker_package", "Payload"]}), + encoding="utf-8", + ) + (component_directory / "config.json").write_text( + json.dumps({"_class_name": "FluxTransformer2DModel"}), + encoding="utf-8", + ) + relative_repository = os.path.relpath(repository, Path.cwd()) + preflight = _preflight_reviewed_diffusers_component( + "transformer", + {"source": "local", "value": relative_repository}, + "transformer", + None, + ) + self.assertEqual(preflight[1], str(repository.resolve())) + + attacker_module = ModuleType("attacker_package") + attacker_module.Payload = SimpleNamespace(from_pretrained=Mock()) + node = AutoModelLoader("auto-model-installed-library-guard") + node.diffusers_loading_progress = Mock(return_value=nullcontext()) + with ( + patch.dict(sys.modules, {"attacker_package": attacker_module}), + patch.object( + FluxTransformer2DModel, + "from_pretrained", + side_effect=RuntimeError("approved loader stop"), + ) as approved_load, + patch("modules.ModularDiffusers.loaders.reusable_standalone_component", return_value=None), + ): + with self.assertRaisesRegex(ValueError, "approved loader stop"): + node.execute( + model_type="transformer", + model_id={"source": "local", "value": relative_repository}, + dtype=torch.float32, + trust_remote_code=False, + subfolder="transformer", + device="cpu", + auto_offload=False, + offload_mode="none", + ) + + approved_load.assert_called_once() + self.assertEqual(approved_load.call_args.args[0], str(repository.resolve())) + attacker_module.Payload.from_pretrained.assert_not_called() + + def test_auto_model_hub_revision_and_subfolder_fail_closed_before_config_access(self): + from modules.ModularDiffusers.loaders import _preflight_reviewed_diffusers_component + + cases = ( + (None, "transformer", "immutable"), + ("A" * 40, "transformer", "lowercase"), + ("a" * 40, "../transformer", "traversal"), + ) + with patch("modules.ModularDiffusers.loaders._load_reviewed_component_config") as load_config: + for revision, subfolder, message in cases: + with self.subTest(revision=revision, subfolder=subfolder): + with self.assertRaisesRegex(ValueError, message): + _preflight_reviewed_diffusers_component( + "transformer", + {"source": "hub", "value": "owner/component"}, + subfolder, + revision, + ) + load_config.assert_not_called() + + def test_remote_code_and_non_boolean_bypasses_fail_before_node_cache_or_loaders(self): + models_loader = ModelsLoader("remote-code-cache-guard") + auto_loader = AutoModelLoader("auto-remote-code-cache-guard") + with patch("modiff.NodeBase.NodeBase.__call__") as base_call: + for node, model_type in ( + (models_loader, "FluxModularPipeline"), + (models_loader, CUSTOM_PIPELINE_MODEL_TYPE), + (auto_loader, "transformer"), + ): + with self.subTest(node=node.__class__.__name__, model_type=model_type): + with self.assertRaisesRegex(ValueError, "(?i)repository code is disabled"): + node(model_type=model_type, trust_remote_code=True) + with self.assertRaisesRegex(TypeError, "JSON boolean"): + node(model_type=model_type, trust_remote_code="false") + custom_identity = resolve_custom_pipeline_binding( + source="local", + repo_id=str(self.repository), + revision=None, + trust_remote_code=False, + ).identity.to_dict() + with self.assertRaisesRegex(RuntimeError, "contract_only"): + models_loader( + model_type=CUSTOM_PIPELINE_MODEL_TYPE, + trust_remote_code=False, + modiff_pipeline_identity=custom_identity, + ) + base_call.assert_not_called() + + with ( + patch("modules.ModularDiffusers.loaders.ComponentSpec") as component_spec, + patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(ValueError, "repository code is disabled"): + auto_loader.execute( + model_type="transformer", + model_id={"source": "hub", "value": "owner/component"}, + dtype=torch.float32, + trust_remote_code=True, + ) + with self.assertRaisesRegex(TypeError, "JSON boolean"): + auto_loader.execute( + model_type="transformer", + model_id={"source": "hub", "value": "owner/component"}, + dtype=torch.float32, + trust_remote_code="false", + ) + with self.assertRaisesRegex(ValueError, "repository code is disabled"): + models_loader.execute( + model_type="FluxModularPipeline", + repo_id={"source": "hub", "value": "owner/pipeline"}, + device="cpu", + dtype=torch.float32, + trust_remote_code=True, + auto_offload=False, + offload_mode="none", + ) + component_spec.assert_not_called() + pipeline_loader.assert_not_called() + + def test_every_registered_builtin_pipeline_has_an_exact_reviewed_execution_binding(self): + registry = _get_registry_instance().get_all() + for pipeline_class, config in registry.items(): + model_type = pipeline_class.__name__ + if model_type == CUSTOM_PIPELINE_MODEL_TYPE: + continue + with self.subTest(model_type=model_type): + expected_revision = require_catalog_revision(config.default_repo, model_type=model_type) + self.assertEqual( + ModelsLoader._reviewed_builtin_selection( + model_type=model_type, + repo_id={"source": "hub", "value": config.default_repo}, + revision=None, + ), + ("hub", config.default_repo, expected_revision), + ) + self.assertEqual( + ModelsLoader._reviewed_builtin_selection( + model_type=model_type, + repo_id={"source": "hub", "value": config.default_repo}, + revision=expected_revision, + ), + ("hub", config.default_repo, expected_revision), + ) + + wan_revision = require_catalog_revision( + "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", + model_type="WanImage2VideoModularPipeline", + ) + self.assertEqual( + ModelsLoader._reviewed_builtin_selection( + model_type="WanImage2VideoModularPipeline", + repo_id={"source": "hub", "value": "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers"}, + revision=wan_revision, + ), + ("hub", "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", wan_revision), + ) + + def test_builtin_pipeline_rejects_alternate_artifacts_before_node_cache_reuse(self): + node = ModelsLoader("reviewed-builtin-cache-guard") + node.params = {"model_type": "FluxModularPipeline"} + node.output = {name: object() for name in MODELS_LOADER_IDENTITY_OUTPUTS} + attacks = ( + ( + {"source": "local", "value": str(self.repository)}, + None, + "reviewed immutable Hub artifact", + ), + ({"source": "hub", "value": "attacker/pipeline"}, None, "requires reviewed repository"), + ( + {"source": "hub", "value": "black-forest-labs/FLUX.1-dev"}, + "f" * 40, + "requires reviewed revision", + ), + ) + with ( + patch("modiff.NodeBase.NodeBase.__call__") as base_call, + patch("modules.ModularDiffusers.loaders._validate_reviewed_pipeline_index") as index_validator, + patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader, + ): + for repo_id, revision, message in attacks: + with self.subTest(repo_id=repo_id, revision=revision): + with self.assertRaisesRegex(ValueError, message): + node( + model_type="FluxModularPipeline", + repo_id=repo_id, + revision=revision, + trust_remote_code=False, + ) + base_call.assert_not_called() + index_validator.assert_not_called() + pipeline_loader.assert_not_called() + + def test_wan_standard_indexes_accept_only_exact_reviewed_concrete_component_types(self): + base_document = { + "_class_name": "WanImageToVideoPipeline", + "_diffusers_version": "0.34.0.dev0", + "image_encoder": ["transformers", "CLIPVisionModelWithProjection"], + "scheduler": ["diffusers", "UniPCMultistepScheduler"], + "text_encoder": ["transformers", "UMT5EncoderModel"], + "tokenizer": ["transformers", "T5TokenizerFast"], + "transformer": ["diffusers", "WanTransformer3DModel"], + "vae": ["diffusers", "AutoencoderKLWan"], + } + cases = ( + ( + "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers", + "b184e23a8a16b20f108f727c902e769e873ffc73", + ["transformers", "CLIPImageProcessor"], + ), + ( + "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", + "17c30769b1e0b5dcaa1799b117bf20a9c31f59d7", + ["transformers", "CLIPProcessor"], + ), + ) + for repository, revision, processor_type in cases: + document = {**base_document, "image_processor": processor_type} + with self.subTest(repository=repository), patch( + "modules.ModularDiffusers.loaders._load_reviewed_pipeline_index", + return_value=("model_index.json", document), + ): + filename, validated = _validate_reviewed_pipeline_index( + "WanImage2VideoModularPipeline", + repository, + revision, + ) + self.assertEqual(filename, "model_index.json") + self.assertEqual(validated, document) + + tampered = {**base_document, "image_processor": ["transformers", "AutoProcessor"]} + with patch( + "modules.ModularDiffusers.loaders._load_reviewed_pipeline_index", + return_value=("model_index.json", tampered), + ), self.assertRaisesRegex(ValueError, "AutoProcessor"): + _validate_reviewed_pipeline_index( + "WanImage2VideoModularPipeline", + "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", + "17c30769b1e0b5dcaa1799b117bf20a9c31f59d7", + ) + + def test_wan_flf_loads_reviewed_image_only_processor_after_index_validation(self): + from diffusers.pipelines.pipeline_loading_utils import _fetch_class_library_tuple + + document = { + "_class_name": "WanImageToVideoPipeline", + "_diffusers_version": "0.34.0.dev0", + "image_processor": ["transformers", "CLIPProcessor"], + "image_encoder": ["transformers", "CLIPVisionModelWithProjection"], + "scheduler": ["diffusers", "UniPCMultistepScheduler"], + "text_encoder": ["transformers", "UMT5EncoderModel"], + "tokenizer": ["transformers", "T5TokenizerFast"], + "transformer": ["diffusers", "WanTransformer3DModel"], + "vae": ["diffusers", "AutoencoderKLWan"], + } + pipeline = _instantiate_reviewed_builtin_pipeline( + "WanImage2VideoModularPipeline", + "Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers", + index_filename="model_index.json", + index_document=document, + components_manager=None, + collection="wan-flf-load-contract", + ) + + self.assertEqual(document["image_processor"], ["transformers", "CLIPProcessor"]) + self.assertEqual( + _fetch_class_library_tuple(pipeline.get_component_spec("image_processor").type_hint), + ("transformers", "CLIPImageProcessor"), + ) + + def test_builtin_pipeline_rejects_cache_mutated_component_library_before_upstream(self): + document = { + "_class_name": "FluxModularPipeline", + "_blocks_class_name": "FluxAutoBlocks", + "transformer": [ + None, + None, + { + "type_hint": ["attacker_package", "Payload"], + "pretrained_model_name_or_path": "attacker/payload", + }, + ], + } + node = ModelsLoader("reviewed-builtin-index-guard") + with ( + patch( + "modules.ModularDiffusers.loaders._load_reviewed_pipeline_index", + return_value=("modular_model_index.json", document), + ), + patch("modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained") as pipeline_loader, + ): + with self.assertRaisesRegex(ValueError, "attacker_package"): + node.execute( + model_type="FluxModularPipeline", + repo_id={"source": "hub", "value": "black-forest-labs/FLUX.1-dev"}, + revision="3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + device="cpu", + dtype=torch.float32, + trust_remote_code=False, + auto_offload=False, + offload_mode="none", + ) + pipeline_loader.assert_not_called() + + def test_builtin_reviewed_index_fingerprint_participates_in_node_cache(self): + node = ModelsLoader("reviewed-builtin-derived-cache-key") + revision = "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21" + base_selection = ( + "hub", + "black-forest-labs/FLUX.1-dev", + revision, + "modular_model_index.json", + ) + selection_a = (*base_selection, {"_class_name": "FluxModularPipeline", "guidance": 1}) + selection_b = (*base_selection, {"_class_name": "FluxModularPipeline", "guidance": 2}) + outputs_a = {name: {"version": "A"} for name in MODELS_LOADER_IDENTITY_OUTPUTS} + outputs_b = {name: {"version": "B"} for name in MODELS_LOADER_IDENTITY_OUTPUTS} + node.execute = Mock(side_effect=(outputs_a, outputs_b)) + inputs = { + "model_type": "FluxModularPipeline", + "repo_id": {"source": "hub", "value": "black-forest-labs/FLUX.1-dev"}, + "device": "cpu", + "dtype": "float32", + "unet": None, + "vae": None, + "lora_list": None, + "trust_remote_code": False, + "auto_offload": False, + "offload_mode": "none", + "quant_config": None, + "revision": revision, + "modiff_pipeline_identity": None, + "refresh_pipeline_identity_button": False, + } + with ( + patch.object( + node, + "_preflight_reviewed_builtin_selection", + side_effect=(selection_a, selection_b, selection_b), + ), + patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True), + ): + first = node(**inputs) + second = node(**inputs) + third = node(**inputs) + + self.assertEqual(first["scheduler"]["version"], "A") + self.assertEqual(second["scheduler"]["version"], "B") + self.assertEqual(third["scheduler"]["version"], "B") + self.assertEqual(node.execute.call_count, 2) + + def test_builtin_pipeline_rejects_cache_selected_blocks_before_construction(self): + document = { + "_class_name": "FluxModularPipeline", + "_blocks_class_name": "StableDiffusionXLAutoBlocks", + "controlnet": [ + None, + None, + {"type_hint": ["attacker_package", "Payload"]}, + ], + } + node = ModelsLoader("reviewed-builtin-blocks-guard") + with ( + patch( + "modules.ModularDiffusers.loaders._load_reviewed_pipeline_index", + return_value=("modular_model_index.json", document), + ), + patch("modules.ModularDiffusers.loaders._instantiate_reviewed_builtin_pipeline") as constructor, + ): + with self.assertRaisesRegex(ValueError, "blocks class"): + node.execute( + model_type="FluxModularPipeline", + repo_id={"source": "hub", "value": "black-forest-labs/FLUX.1-dev"}, + revision="3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + device="cpu", + dtype=torch.float32, + trust_remote_code=False, + auto_offload=False, + offload_mode="none", + ) + constructor.assert_not_called() + + def test_registered_pipeline_component_contracts_accept_only_installed_expected_type_hints(self): + from diffusers.pipelines.pipeline_loading_utils import _fetch_class_library_tuple + from modules.ModularDiffusers.loaders import _validate_reviewed_pipeline_index + + registry = _get_registry_instance().get_all() + for pipeline_class, config in registry.items(): + model_type = pipeline_class.__name__ + if model_type == CUSTOM_PIPELINE_MODEL_TYPE: + continue + installed_pipeline = pipeline_class() + document = { + "_class_name": model_type, + "_blocks_class_name": installed_pipeline.config.get("_blocks_class_name"), + } + for component_name, component_spec in installed_pipeline._component_specs.items(): + document[component_name] = [ + None, + None, + {"type_hint": list(_fetch_class_library_tuple(component_spec.type_hint))}, + ] + with ( + self.subTest(model_type=model_type), + patch( + "modules.ModularDiffusers.loaders._load_reviewed_pipeline_index", + return_value=("modular_model_index.json", document), + ), + ): + _validate_reviewed_pipeline_index( + model_type, + config.default_repo, + require_catalog_revision(config.default_repo, model_type=model_type), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diffusers_adapters.py b/tests/test_diffusers_adapters.py index a13cd8c..14f6077 100644 --- a/tests/test_diffusers_adapters.py +++ b/tests/test_diffusers_adapters.py @@ -1,11 +1,18 @@ -import unittest +import hashlib +import json import tempfile +import unittest from pathlib import Path +import numpy as np +from safetensors.numpy import save_file + +from modiff.auxiliary_lora import build_lora_descriptor from modules.DiffusersAdapters.main import ( LoRAComparisonJobs, LoRAFuseUnfuse, LoRAHotswap, + LoRAInspectValidate, LoRAMergeArtifact, LoRAUnloadReset, apply_lora_mix, @@ -59,27 +66,158 @@ def save_pretrained(self, directory, **kwargs): self.saves.append((directory, kwargs)) -def adapter(name, scale=1.0): - return {"lora_path": f"/{name}", "weight_name": f"{name}.safetensors", "adapter_name": name, "scale": scale} +def adapter(directory, name, scale=1.0, *, scheduler_class="", scheduler_config=None): + path = Path(directory) / f"{name}.safetensors" + if not path.exists(): + save_file({"lora.weight": np.asarray([1.0], dtype=np.float32)}, str(path)) + return build_lora_descriptor( + selection={"source": "local", "value": str(path)}, + weight_name=path.name, + revision="", + expected_sha256="", + adapter_name=name, + scale=scale, + scheduler_class=scheduler_class, + scheduler_config=scheduler_config or {}, + ) + + +def resign_descriptor(descriptor): + payload = {key: value for key, value in descriptor.items() if key != "descriptor_sha256"} + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + descriptor["descriptor_sha256"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return descriptor class DiffusersAdapterTests(unittest.TestCase): + def test_inspection_consumes_the_same_explicit_versioned_descriptor(self): + self.assertEqual(LoRAInspectValidate.params["adapter"]["type"], "custom_lora") + self.assertTrue(LoRAInspectValidate.params["adapter"]["required"]) + self.assertNotIn("weight_name", LoRAInspectValidate.params) + with self.assertRaisesRegex(TypeError, "versioned descriptor"): + LoRAInspectValidate().execute(adapter="/path/inferred/from/existence") + def test_stack_loads_missing_adapters_and_activates_independent_weights(self): - pipeline = FakePipeline() - result = apply_lora_mix(pipeline, [adapter("existing", 0.25), adapter("style", 0.75)]) + with tempfile.TemporaryDirectory() as directory: + pipeline = FakePipeline() + result = apply_lora_mix( + pipeline, + [adapter(directory, "existing", 0.25), adapter(directory, "style", 0.75)], + ) - self.assertEqual([item[1]["adapter_name"] for item in pipeline.loaded], ["style"]) + self.assertEqual([item[1]["adapter_name"] for item in pipeline.loaded], ["existing", "style"]) + self.assertEqual(pipeline.deleted, ["existing"]) + self.assertTrue(all(item[1]["use_safetensors"] for item in pipeline.loaded)) + self.assertTrue(all(item[1]["weight_name"].endswith(".safetensors") for item in pipeline.loaded)) self.assertEqual(pipeline.active, (["existing", "style"], [0.25, 0.75])) self.assertEqual(result["adapter_names"], ["existing", "style"]) def test_hotswap_requires_an_existing_slot_and_uses_in_place_api(self): - pipeline = FakePipeline() - LoRAHotswap().execute(pipeline=pipeline, replacement=adapter("replacement", 0.6), slot_name="existing") + with tempfile.TemporaryDirectory() as directory: + pipeline = FakePipeline() + LoRAHotswap().execute( + pipeline=pipeline, + replacement=adapter(directory, "replacement", 0.6), + slot_name="existing", + ) self.assertTrue(pipeline.loaded[0][1]["hotswap"]) + self.assertTrue(pipeline.loaded[0][1]["use_safetensors"]) + self.assertEqual(pipeline.loaded[0][1]["weight_name"], "replacement.safetensors") self.assertEqual(pipeline.loaded[0][1]["adapter_name"], "existing") self.assertEqual(pipeline.active, (["existing"], [0.6])) + def test_stack_and_hotswap_reject_partial_or_tampered_descriptors_before_mutation(self): + with tempfile.TemporaryDirectory() as directory: + valid = adapter(directory, "style") + tampered = {**valid, "scale": 0.25} + legacy = { + "lora_path": directory, + "weight_name": "style.safetensors", + "adapter_name": "style", + } + for operation, value in ( + ("stack-legacy", legacy), + ("stack-tampered", tampered), + ("hotswap-legacy", legacy), + ("hotswap-tampered", tampered), + ): + pipeline = FakePipeline() + with self.subTest(operation=operation): + with self.assertRaises((TypeError, ValueError)): + if operation.startswith("stack"): + apply_lora_mix(pipeline, value) + else: + LoRAHotswap().execute( + pipeline=pipeline, + replacement=value, + slot_name="existing", + ) + self.assertEqual(pipeline.loaded, []) + self.assertEqual(pipeline.deleted, []) + self.assertIsNone(pipeline.active) + + def test_stack_revalidates_the_whole_list_before_deleting_an_existing_slot(self): + with tempfile.TemporaryDirectory() as directory: + first = adapter(directory, "existing") + second = adapter(directory, "style") + (Path(directory) / "style.safetensors").write_bytes(b"mutated-style") + pipeline = FakePipeline() + + with self.assertRaisesRegex(ValueError, "no longer matches"): + apply_lora_mix(pipeline, [first, second]) + + self.assertEqual(pipeline.loaded, []) + self.assertEqual(pipeline.deleted, []) + self.assertIsNone(pipeline.active) + + def test_stack_rejects_malformed_or_empty_safetensors_before_replacing_an_existing_slot(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "existing.safetensors" + for label in ("malformed", "empty"): + save_file({"lora.weight": np.asarray([1.0], dtype=np.float32)}, str(path)) + descriptor = adapter(directory, "existing") + if label == "malformed": + path.write_bytes(b"not-a-safetensors-file") + message = "valid Safetensors" + else: + save_file({}, str(path)) + message = "at least one tensor" + descriptor["artifact"]["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + resign_descriptor(descriptor) + pipeline = FakePipeline() + + with self.subTest(label=label): + with self.assertRaisesRegex(ValueError, message): + apply_lora_mix(pipeline, descriptor) + self.assertEqual(pipeline.loaded, []) + self.assertEqual(pipeline.deleted, []) + self.assertIsNone(pipeline.active) + + def test_stack_and_hotswap_fail_closed_on_scheduler_bearing_descriptors(self): + with tempfile.TemporaryDirectory() as directory: + value = adapter( + directory, + "lightning", + scheduler_class="FlowMatchEulerDiscreteScheduler", + scheduler_config={"base_shift": 1.0}, + ) + for operation in ("stack", "hotswap"): + pipeline = FakePipeline() + with self.subTest(operation=operation): + with self.assertRaisesRegex(ValueError, "scheduler-bearing"): + if operation == "stack": + apply_lora_mix(pipeline, value) + else: + LoRAHotswap().execute( + pipeline=pipeline, + replacement=value, + slot_name="existing", + ) + self.assertEqual(pipeline.loaded, []) + self.assertEqual(pipeline.deleted, []) + self.assertIsNone(pipeline.active) + def test_fuse_reset_and_comparison_jobs_preserve_explicit_user_choices(self): pipeline = FakePipeline() LoRAFuseUnfuse().execute( diff --git a/tests/test_diffusers_audio.py b/tests/test_diffusers_audio.py index 1235166..c4d2f08 100644 --- a/tests/test_diffusers_audio.py +++ b/tests/test_diffusers_audio.py @@ -1,30 +1,47 @@ +import hashlib import sys import tempfile import unittest +from contextlib import chdir from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import Mock, patch import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import modules as module_registry # noqa: E402 from modules.DiffusersAudio.main import ( # noqa: E402 + ACE_CONTINUATION_MAX_EXTENSION_SECONDS, + ACE_MAX_DURATION_SECONDS, + ACE_STEP_DEFAULT_REPO, + AUDIO_PIPELINE_ADAPTERS, AUDIO_SAMPLE_RATE_OPTIONS, + STABLE_AUDIO_DEFAULT_REPO, FuseAdapters, Generate, LoadAdapter, LoadPipeline, SetAdapters, + _resolve_audio_model_selection, + _resolve_audio_loader_revision, + _preflight_audio_invocation, audio_to_numpy, audio_to_tensor, crop_tail, + get_audio_pipeline_adapter, ) -from modiff.server import to_bytes # noqa: E402 +from modiff.config import CONFIG # noqa: E402 +from modiff.diffusers_profiles import public_execution_profiles # noqa: E402 +from modiff.path_identifiers import resolve_runtime_input_path # noqa: E402 +from modiff.server import WebServer, to_bytes # noqa: E402 class FakeAceStepPipeline: + _modiff_audio_pipeline_class = "AceStepPipeline" + _modiff_audio_mode = "text_to_audio" device = "cpu" sample_rate = 48000 @@ -37,6 +54,8 @@ def __call__(self, bpm=None, **kwargs): class FakeSourceConditionedAceStepPipeline: + _modiff_audio_pipeline_class = "AceStepPipeline" + _modiff_audio_mode = "audio_variation" device = "cpu" sample_rate = 48000 @@ -52,6 +71,9 @@ def __call__( src_audio=None, reference_audio=None, audio_cover_strength=None, + attention_kwargs=None, + repainting_start=None, + repainting_end=None, **kwargs, ): self.call_kwargs = { @@ -63,11 +85,1221 @@ def __call__( "src_audio": src_audio, "reference_audio": reference_audio, "audio_cover_strength": audio_cover_strength, + "attention_kwargs": attention_kwargs, + "repainting_start": repainting_start, + "repainting_end": repainting_end, } return SimpleNamespace(audios=np.zeros((1, 480), dtype=np.float32)) class DiffusersAudioGenerateTests(unittest.TestCase): + def test_audio_adapters_declare_ordered_mode_task_and_input_contracts(self): + ace = AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"] + stable = AUDIO_PIPELINE_ADAPTERS["StableAudioPipeline"] + + self.assertEqual( + ace.modes, + ("text_to_audio", "audio_variation", "audio_continuation", "audio_repaint"), + ) + self.assertEqual( + [contract.task_type for contract in ace.mode_contracts], + ["text2music", "cover", "continuation", "repaint"], + ) + self.assertEqual( + [(contract.source_audio, contract.reference_audio) for contract in ace.mode_contracts], + [ + ("forbidden", "forbidden"), + ("required", "forbidden"), + ("required", "forbidden"), + ("required", "forbidden"), + ], + ) + self.assertTrue( + all(contract.max_duration_seconds == ACE_MAX_DURATION_SECONDS for contract in ace.mode_contracts) + ) + self.assertEqual( + ace.contract_for_mode("audio_continuation").max_extension_seconds, + ACE_CONTINUATION_MAX_EXTENSION_SECONDS, + ) + self.assertEqual(ace.source_audio_channels, 2) + self.assertTrue(ace.duplicate_mono_source) + self.assertEqual(stable.modes, ("text_to_audio",)) + self.assertIsNone(stable.source_audio_channels) + self.assertEqual(stable.mode_contracts[0].task_type, "text2audio") + self.assertEqual(stable.mode_contracts[0].max_duration_seconds, 47) + self.assertNotIn("extract", Generate.params["task_type"]["options"]) + self.assertNotIn("lego", Generate.params["task_type"]["options"]) + self.assertNotIn("complete", Generate.params["task_type"]["options"]) + + def test_every_direct_audio_profile_has_an_exact_adapter_contract(self): + profiles = [ + profile + for profile in public_execution_profiles() + if profile["backend_path"] == "modules.DiffusersAudio.LoadPipeline" + ] + self.assertTrue(profiles) + for profile in profiles: + with self.subTest(profile=profile["id"]): + adapter = AUDIO_PIPELINE_ADAPTERS[profile["pipeline_class"]] + self.assertEqual(tuple(profile["modes"]), adapter.modes) + self.assertEqual(profile["default_repo"], adapter.default_repo) + + def test_adapter_identity_and_real_loader_inputs_are_strict(self): + for invalid in (None, "", " AceStepPipeline", "AceStepPipeline ", False, 0, {}, []): + with self.subTest(invalid=repr(invalid)): + with self.assertRaisesRegex(ValueError, "registered Diffusers audio pipeline class is required"): + get_audio_pipeline_adapter(invalid) + + invalid_loader_values = ( + {"mode": "text_to_audio"}, + { + "model_id": {"source": "hub", "value": STABLE_AUDIO_DEFAULT_REPO}, + "mode": "text_to_audio", + }, + {"pipeline_class": None, "mode": "text_to_audio"}, + {"pipeline_class": False, "mode": "text_to_audio"}, + {"pipeline_class": "AceStepPipeline"}, + {"pipeline_class": "AceStepPipeline", "mode": None}, + {"pipeline_class": "AceStepPipeline", "mode": False}, + {"pipeline_class": "AceStepPipeline", "mode": 0}, + {"pipeline_class": "AceStepPipeline", "mode": []}, + {"pipeline_class": "AceStepPipeline", "mode": {}}, + {"pipeline_class": "AceStepPipeline", "mode": ""}, + {"pipeline_class": "AceStepPipeline", "mode": " text_to_audio"}, + {"pipeline_class": "AceStepPipeline", "mode": "text_to_audio "}, + ) + for values in invalid_loader_values: + node = LoadPipeline("audio-loader-invalid") + node.execute = Mock() + with self.subTest(values=values): + with self.assertRaisesRegex( + ValueError, "registered Diffusers audio (pipeline class|mode) is required" + ): + node(**values) + node.execute.assert_not_called() + + pipeline = SimpleNamespace() + node = LoadPipeline("audio-loader-explicit") + node.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": ACE_STEP_DEFAULT_REPO}) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + node( + model_id={"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + ) + self.assertEqual(node.execute.call_args.kwargs["pipeline_class"], "AceStepPipeline") + self.assertEqual( + node.execute.call_args.kwargs["revision"], + "200ba991ae448051e14b0183157e35c2d27c9fb0", + ) + self.assertEqual(pipeline._modiff_audio_pipeline_class, "AceStepPipeline") + self.assertEqual(pipeline._modiff_audio_revision, "200ba991ae448051e14b0183157e35c2d27c9fb0") + + def test_hub_pipeline_revisions_fail_closed_before_nodebase_or_upstream(self): + custom_revision = "0123456789abcdef0123456789abcdef01234567" + custom_selection = {"source": "hub", "value": "organization/custom-audio"} + base_values = { + "model_id": custom_selection, + "pipeline_class": "AceStepPipeline", + "mode": "text_to_audio", + } + invalid_revisions = ( + None, + "", + "main", + custom_revision.upper(), + f" {custom_revision}", + custom_revision[:-1], + 123, + False, + ) + node = LoadPipeline("strict-audio-hub-revision") + node.execute = Mock(side_effect=AssertionError("upstream must not run")) + for revision in invalid_revisions: + with self.subTest(revision=revision): + with self.assertRaisesRegex(ValueError, "immutable lowercase|exact lowercase"): + node(**base_values, revision=revision) + node.execute.assert_not_called() + + curated_mismatch = LoadPipeline("strict-audio-curated-mismatch") + curated_mismatch.execute = Mock(side_effect=AssertionError("upstream must not run")) + with self.assertRaisesRegex(ValueError, "pinned to .* does not match"): + curated_mismatch( + model_id={"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + revision="0000000000000000000000000000000000000000", + ) + curated_mismatch.execute.assert_not_called() + + pipeline = SimpleNamespace() + valid = LoadPipeline("strict-audio-custom-valid") + valid.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": custom_selection["value"]}) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + valid(**base_values, revision=custom_revision) + self.assertEqual(valid.execute.call_args.kwargs["revision"], custom_revision) + self.assertEqual(pipeline._modiff_audio_revision, custom_revision) + + def test_case_insensitive_local_model_source_is_never_replaced_by_a_managed_default(self): + adapter = AUDIO_PIPELINE_ADAPTERS["StableAudioPipeline"] + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / ACE_STEP_DEFAULT_REPO + local_model.mkdir(parents=True) + with chdir(temporary): + for source in ("local", "LOCAL", "Local"): + selection = {"source": source, "value": ACE_STEP_DEFAULT_REPO} + with self.subTest(source=source): + self.assertEqual( + _resolve_audio_model_selection(adapter, selection), + {"source": "local", "value": str(local_model.resolve())}, + ) + + self.assertEqual( + _resolve_audio_model_selection( + adapter, + {"source": "local", "value": str(local_model)}, + ), + {"source": "local", "value": str(local_model.resolve())}, + ) + + for invalid in ("organization/not-a-local-model", str(Path(temporary) / "missing")): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "directory does not exist"): + _resolve_audio_model_selection( + adapter, + {"source": "local", "value": invalid}, + ) + + node = LoadPipeline("missing-local-audio-boundary") + node.execute = Mock(side_effect=AssertionError("upstream must not run")) + with self.assertRaisesRegex(ValueError, "directory does not exist"): + node( + model_id={"source": "local", "value": invalid}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + revision="main", + ) + node.execute.assert_not_called() + + def test_model_selection_source_is_canonical_and_cannot_bypass_the_catalog_pin(self): + adapter = AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"] + for source in ("hub", "HUB", "Hub"): + with self.subTest(source=source): + self.assertEqual( + _resolve_audio_model_selection( + adapter, + {"source": source, "value": ACE_STEP_DEFAULT_REPO}, + ), + {"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + ) + + self.assertEqual( + _resolve_audio_model_selection( + adapter, + {"source": "HUB", "value": ACE_STEP_DEFAULT_REPO.upper()}, + ), + {"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + ) + + for source in (None, "", " hub", "hub ", "remote", False, 0, {}, []): + node = LoadPipeline("audio-source-boundary") + node.execute = Mock() + with self.subTest(source=repr(source)): + with self.assertRaisesRegex(ValueError, "source must be exactly hub or local"): + node( + model_id={"source": source, "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + ) + node.execute.assert_not_called() + + def test_hub_model_source_cannot_resolve_as_a_local_directory(self): + revision = "0123456789abcdef0123456789abcdef01234567" + node = LoadPipeline("audio-hub-local-path-boundary") + node.execute = Mock(side_effect=AssertionError("upstream must not run")) + + with tempfile.TemporaryDirectory() as temporary: + local_repo = Path(temporary) / "organization" / "local-audio" + local_repo.mkdir(parents=True) + with chdir(temporary): + invalid_hub_values = ( + "organization/local-audio", + str(local_repo), + local_repo.as_uri(), + "../local-audio", + "single-component", + ) + for value in invalid_hub_values: + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "namespace/repository|local filesystem"): + node( + model_id={"source": "hub", "value": value}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + revision=revision, + ) + + node.execute.assert_not_called() + + def test_default_hub_model_cannot_resolve_as_a_local_directory(self): + adapter = AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"] + node = LoadPipeline("audio-default-hub-local-path-boundary") + node.execute = Mock(side_effect=AssertionError("upstream must not run")) + + with tempfile.TemporaryDirectory() as temporary: + (Path(temporary) / adapter.default_repo).mkdir(parents=True) + with chdir(temporary): + for selection in (None, "", {"source": "hub", "value": ""}): + with self.subTest(selection=selection): + with self.assertRaisesRegex(ValueError, "local filesystem"): + node( + model_id=selection, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + ) + + node.execute.assert_not_called() + + def test_local_audio_model_drops_any_hub_revision(self): + pipeline = SimpleNamespace() + node = LoadPipeline("canonical-local-audio-cache") + node.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": "local-audio"}) + + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / "models" / "local-audio" + local_model.mkdir(parents=True) + with chdir(temporary), patch("modiff.NodeBase.modelstore.is_local_cached", return_value=True): + first = node( + model_id={"source": "local", "value": "models/local-audio"}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + revision="main", + ) + second = node( + model_id={"source": "local", "value": str(local_model)}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + revision=None, + ) + + self.assertIs(first, second) + node.execute.assert_called_once() + self.assertEqual( + node.execute.call_args.kwargs["model_id"], + {"source": "local", "value": str(local_model.resolve())}, + ) + # NodeBase serializes the optional field default as an empty string; + # the facade resolves it to None again immediately inside execute. + self.assertEqual(node.execute.call_args.kwargs["revision"], "") + self.assertIsNone( + _resolve_audio_loader_revision( + node.execute.call_args.kwargs["model_id"], + str(local_model.resolve()), + "main", + ) + ) + self.assertIsNone(pipeline._modiff_audio_revision) + + def test_loader_actions_update_class_modes_repository_and_output_signal(self): + node = LoadPipeline("audio-contract-action") + node.set_field_params = Mock() + node.set_field_value = Mock() + + node.update_audio_contract( + { + "pipeline_class": "StableAudioPipeline", + "mode": "audio_variation", + "model_id": {"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + }, + {"key": "pipeline_class"}, + ) + + mode_update = next(call.args[1] for call in node.set_field_params.call_args_list if call.args[0] == "mode") + self.assertEqual(mode_update["options"], ["text_to_audio"]) + self.assertEqual(mode_update["default"], "text_to_audio") + published = node.set_field_value.call_args.args[0] + self.assertEqual(published["model_id"], {"source": "hub", "value": STABLE_AUDIO_DEFAULT_REPO}) + self.assertEqual(published["revision"], "f21265c1e2710b3bd2386596943f0007f55f802e") + self.assertEqual(published["audio_contract"]["taskType"], "text2audio") + pipeline_signal = next( + call.args[1]["signal"] for call in node.set_field_params.call_args_list if call.args[0] == "pipeline" + ) + self.assertEqual(pipeline_signal["value"], published["audio_contract"]) + + def test_audio_model_action_couples_repository_and_revision_before_real_execution(self): + stale_revision = "0" * 40 + replacement_revision = "1234567890abcdef1234567890abcdef12345678" + replacement = {"source": "hub", "value": "organization/replacement-audio"} + node = LoadPipeline("audio-model-identity-action") + node._sid = "audio-browser-session" + messages = [] + current_server = SimpleNamespace( + _current_dynamic_message_identity_payload=lambda: {}, + queue_message=lambda message, sid=None: messages.append((message, sid)), + ) + + with patch("modiff.NodeBase._server", return_value=current_server): + node.update_audio_contract( + { + "pipeline_class": "AceStepPipeline", + "mode": "text_to_audio", + "model_id": replacement, + "revision": stale_revision, + }, + {"key": "model_id"}, + ) + + value_message = next(message for message, _sid in messages if message["type"] == "set_field_value") + self.assertEqual(value_message["fields"]["model_id"], replacement) + self.assertEqual(value_message["fields"]["revision"], "") + self.assertEqual( + next(sid for message, sid in messages if message["type"] == "set_field_value"), + "audio-browser-session", + ) + + for ref_key in ("pipeline_class", "mode"): + preserving = LoadPipeline(f"audio-custom-pin-{ref_key}") + preserving.set_field_params = Mock() + preserving.set_field_value = Mock() + preserving.update_audio_contract( + { + "pipeline_class": "AceStepPipeline", + "mode": "text_to_audio", + "model_id": replacement, + "revision": replacement_revision, + }, + {"key": ref_key}, + ) + with self.subTest(ref_key=ref_key): + self.assertNotIn("revision", preserving.set_field_value.call_args.args[0]) + + upstream_calls = [] + + class FakePipelineClass: + @classmethod + def from_pretrained(cls, repository, **kwargs): + upstream_calls.append((repository, kwargs["revision"])) + return SimpleNamespace() + + executing = LoadPipeline("audio-replacement-execution") + executing.mm_add = Mock() + with ( + patch("modules.DiffusersAudio.main.pipeline_class_from_name", return_value=FakePipelineClass), + patch("modules.DiffusersAudio.main.local_files_only", return_value=True), + patch("modules.DiffusersAudio.main.apply_pipeline_offload"), + ): + result = executing.execute( + pipeline_class="AceStepPipeline", + mode="text_to_audio", + model_id=replacement, + revision=replacement_revision, + ) + + self.assertEqual(upstream_calls, [(replacement["value"], replacement_revision)]) + self.assertEqual(result["pipeline"]._modiff_audio_repo, replacement["value"]) + self.assertEqual(result["pipeline"]._modiff_audio_revision, replacement_revision) + + def test_audio_model_action_publishes_catalog_pin_and_clears_local_revision(self): + cataloged = LoadPipeline("audio-catalog-pin-action") + cataloged.set_field_params = Mock() + cataloged.set_field_value = Mock() + cataloged.update_audio_contract( + { + "pipeline_class": "StableAudioPipeline", + "mode": "text_to_audio", + "model_id": {"source": "hub", "value": STABLE_AUDIO_DEFAULT_REPO}, + "revision": "0" * 40, + }, + {"key": "model_id"}, + ) + self.assertEqual( + cataloged.set_field_value.call_args.args[0]["revision"], + "f21265c1e2710b3bd2386596943f0007f55f802e", + ) + + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / "local-audio" + local_model.mkdir() + local = LoadPipeline("audio-local-revision-action") + local.set_field_params = Mock() + local.set_field_value = Mock() + local.update_audio_contract( + { + "pipeline_class": "AceStepPipeline", + "mode": "text_to_audio", + "model_id": {"source": "local", "value": str(local_model)}, + "revision": "0" * 40, + }, + {"key": "model_id"}, + ) + self.assertEqual(local.set_field_value.call_args.args[0]["revision"], "") + + def test_generate_contract_signal_sets_task_and_audio_input_form_contract(self): + node = Generate("audio-generate-contract") + node.set_field_params = Mock() + contract = ( + AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"] + .contract_for_mode("audio_variation") + .signal_value("AceStepPipeline", ACE_STEP_DEFAULT_REPO) + ) + + node.update_audio_contract({"audio_contract": contract}, {"key": "pipeline"}) + + updates = {call.args[0]: call.args[1] for call in node.set_field_params.call_args_list} + self.assertEqual(updates, contract["fieldParams"]) + self.assertEqual(updates["task_type"]["options"], ["cover"]) + self.assertEqual(updates["task_type"]["default"], "cover") + self.assertTrue(updates["source_audio"]["required"]) + self.assertFalse(updates["source_audio"]["hidden"]) + self.assertFalse(updates["reference_audio"]["required"]) + self.assertTrue(updates["reference_audio"]["hidden"]) + self.assertFalse(updates["audio_cover_strength"]["hidden"]) + self.assertTrue(updates["repainting_start"]["hidden"]) + variation_duration_visibility = next( + call.args[1]["hidden"] + for call in node.set_field_params.call_args_list + if call.args[0] == "audio_duration" and "hidden" in call.args[1] + ) + self.assertFalse(variation_duration_visibility) + + node.set_field_params.reset_mock() + tampered_contract = { + **contract, + "fieldParams": { + **contract["fieldParams"], + "lyrics": {"hidden": True}, + }, + } + with self.assertRaisesRegex(ValueError, "stale or mismatched task contract"): + node.update_audio_contract({"audio_contract": tampered_contract}, {"key": "pipeline"}) + node.set_field_params.assert_not_called() + + for mode in ("audio_continuation", "audio_repaint"): + node.set_field_params.reset_mock() + derived_contract = ( + AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"] + .contract_for_mode(mode) + .signal_value("AceStepPipeline", ACE_STEP_DEFAULT_REPO) + ) + node.update_audio_contract({"audio_contract": derived_contract}, {"key": "pipeline"}) + duration_visibility = next( + call.args[1]["hidden"] + for call in node.set_field_params.call_args_list + if call.args[0] == "audio_duration" and "hidden" in call.args[1] + ) + with self.subTest(mode=mode): + self.assertTrue(duration_visibility) + + for adapter in AUDIO_PIPELINE_ADAPTERS.values(): + for mode_contract in adapter.mode_contracts: + node.set_field_params.reset_mock() + signal = mode_contract.signal_value(adapter.pipeline_class, adapter.default_repo) + node.update_audio_contract({"audio_contract": signal}, {"key": "pipeline"}) + updates = {call.args[0]: call.args[1] for call in node.set_field_params.call_args_list} + with self.subTest(pipeline=adapter.pipeline_class, mode=mode_contract.mode): + self.assertEqual(updates, signal["fieldParams"]) + + def test_loader_mode_cache_hit_is_retagged_without_reloading(self): + pipeline = SimpleNamespace() + node = LoadPipeline("audio-mode-cache") + node.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": ACE_STEP_DEFAULT_REPO}) + + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + node( + model_id={"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + ) + node( + model_id={"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="audio_repaint", + ) + + self.assertEqual(node.execute.call_count, 1) + self.assertEqual(pipeline._modiff_audio_pipeline_class, "AceStepPipeline") + self.assertEqual(pipeline._modiff_audio_mode, "audio_repaint") + self.assertEqual(pipeline._modiff_audio_repo, ACE_STEP_DEFAULT_REPO) + + def test_loader_mode_retag_invalidates_cached_audio_descendant_contract(self): + class Pipeline(FakeSourceConditionedAceStepPipeline): + def __init__(self): + super().__init__() + self.calls = 0 + + def __call__( + self, + src_audio=None, + repainting_start=None, + repainting_end=None, + **kwargs, + ): + self.calls += 1 + self.call_kwargs = { + **kwargs, + "src_audio": src_audio, + "repainting_start": repainting_start, + "repainting_end": repainting_end, + } + return SimpleNamespace(audios=np.zeros((1, 960), dtype=np.float32)) + + pipeline = Pipeline() + loader = LoadPipeline("audio-cache-loader") + loader.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": ACE_STEP_DEFAULT_REPO}) + task = Generate("audio-cache-task") + task.progress = lambda *args, **kwargs: None + server = object.__new__(WebServer) + server.modules = module_registry.MODULE_MAP + server.node_cache = {"loader": loader, "task": task} + server.current_task = None + server.queue_message = lambda *args, **kwargs: None + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + graph_node = { + "module": "modules.DiffusersAudio", + "action": "Generate", + "params": { + "pipeline": {"sourceId": "loader", "sourceKey": "pipeline"}, + "source_audio": {"value": source}, + "extension_duration": {"value": 0.01}, + "sample_rate": {"value": 48000}, + }, + } + loader_values = { + "model_id": {"source": "hub", "value": ACE_STEP_DEFAULT_REPO}, + "pipeline_class": "AceStepPipeline", + } + + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + loader(mode="audio_continuation", **loader_values) + server.execute_node("task", graph_node, "test", quiet=True) + loader(mode="audio_repaint", **loader_values) + + self.assertTrue(loader._has_changed) + self.assertEqual(loader.execute.call_count, 1) + with self.assertRaisesRegex(RuntimeError, "end strictly greater than the start"): + server.execute_node("task", graph_node, "test", quiet=True) + self.assertEqual(pipeline.calls, 1) + + def test_ace_mode_task_mismatch_and_hidden_stale_tasks_fail_explicitly(self): + pipeline = FakeAceStepPipeline() + node = Generate("ace-task-contract") + + with self.assertRaisesRegex(ValueError, "text_to_audio requires task text2music"): + node.execute(pipeline=pipeline, task_type="cover") + for stale_task in ("extract", "lego", "complete"): + with self.subTest(task=stale_task): + with self.assertRaisesRegex(ValueError, "recognized but hidden"): + node.execute(pipeline=pipeline, task_type=stale_task) + + self.assertIsNone(pipeline.call_kwargs) + + def test_task_may_derive_only_when_absent_and_supplied_malformed_values_fail(self): + pipeline = FakeAceStepPipeline() + node = Generate("audio-task-raw-contract") + node.execute = Mock(return_value={}) + for invalid in (None, "", " text2music", "text2music ", False, 0, {}, []): + with self.subTest(invalid=repr(invalid)): + with self.assertRaisesRegex(ValueError, "exact nonblank supported task string"): + node(pipeline=pipeline, task_type=invalid) + node.execute.assert_not_called() + + direct = Generate() + direct.progress = lambda *args, **kwargs: None + direct.execute(pipeline=pipeline, audio_duration=0.01, sample_rate=48000) + self.assertEqual(pipeline.call_kwargs["task_type"], "text2music") + + def test_required_audio_inputs_are_enforced_per_mode(self): + required_modes = ("audio_variation", "audio_continuation", "audio_repaint") + for mode in required_modes: + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = mode + task = { + "audio_variation": "cover", + "audio_continuation": "continuation", + "audio_repaint": "repaint", + }[mode] + with self.subTest(mode=mode, condition="missing-source"): + with self.assertRaisesRegex(ValueError, "requires source audio"): + Generate().execute( + pipeline=pipeline, + task_type=task, + repainting_start=0, + repainting_end=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_required_source_media_is_validated_before_upstream(self): + invalid_sources = ( + {"samples": np.zeros((1, 0), dtype=np.float32), "sample_rate": 48000}, + {"samples": np.zeros((0, 480), dtype=np.float32), "sample_rate": 48000}, + {"samples": np.zeros((1, 1, 10), dtype=np.float32), "sample_rate": 48000}, + {"samples": np.asarray([[float("nan")]], dtype=np.float32), "sample_rate": 48000}, + {"samples": np.asarray([[float("inf")]], dtype=np.float32), "sample_rate": 48000}, + {"samples": np.asarray([[float("-inf")]], dtype=np.float32), "sample_rate": 48000}, + {"samples": np.zeros((1, 10), dtype=np.float32), "sample_rate": 0}, + {"samples": np.zeros((1, 10), dtype=np.float32), "sample_rate": -1}, + {"samples": np.zeros((1, 10), dtype=np.float32), "sample_rate": 48000.5}, + {"samples": np.zeros((1, 241), dtype=np.float32), "sample_rate": 1}, + ) + for source in invalid_sources: + pipeline = FakeSourceConditionedAceStepPipeline() + with self.subTest(shape=np.asarray(source["samples"]).shape, rate=source["sample_rate"]): + with self.assertRaisesRegex( + ValueError, + "(channel and one audio frame|mono or multichannel|samples must all be finite|sample rate|at most 240)", + ): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_source_audio_file_variants_use_the_shared_resolver_and_decoder_provenance(self): + from scipy.io import wavfile + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + work = root / "work" + data = root / "data" + work.mkdir() + data.mkdir() + source_path = work / "short-stereo.wav" + wavfile.write( + source_path, + 48000, + np.asarray([[100, 1000], [200, 2000], [300, 3000]], dtype=np.int16), + ) + sources = ( + "short-stereo.wav", + {"samples": "short-stereo.wav", "channels": 2}, + {"audio": "short-stereo.wav", "channels": 2}, + {"array": "short-stereo.wav", "channels": 2}, + {"path": "short-stereo.wav", "channels": 2}, + {"file": "short-stereo.wav", "channels": 2}, + ) + + with ( + patch.dict(CONFIG.paths, {"work_dir": str(work), "data": str(data)}), + patch( + "modules.DiffusersAudio.main.resolve_runtime_input_path", + wraps=resolve_runtime_input_path, + ) as resolver, + ): + for source in sources: + with self.subTest(source=source): + invocation = _preflight_audio_invocation( + FakeSourceConditionedAceStepPipeline(), + { + "task_type": "cover", + "source_audio": source, + "audio_duration": 0.01, + }, + ) + self.assertEqual(invocation.source.samples.shape, (2, 3)) + resolver.assert_called_once() + resolver.reset_mock() + + def test_source_audio_paths_cannot_traverse_or_escape_managed_roots_before_decoder_or_torch(self): + from scipy.io import wavfile + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + work = root / "work" + data = root / "data" + work.mkdir() + data.mkdir() + outside = root / "outside.wav" + wavfile.write(outside, 48000, np.zeros((3, 2), dtype=np.int16)) + invalid_sources = ( + "../outside.wav", + str(outside), + "@data/../outside.wav", + {"samples": "../outside.wav", "channels": 2}, + {"audio": str(outside), "channels": 2}, + {"array": "@data/../outside.wav", "channels": 2}, + {"path": str(outside), "channels": 2}, + {"file": "../outside.wav", "channels": 2}, + ) + + with ( + patch.dict(CONFIG.paths, {"work_dir": str(work), "data": str(data)}), + patch.dict(sys.modules, {"torch": None}), + patch("scipy.io.wavfile.read") as decoder, + ): + for source in invalid_sources: + pipeline = FakeSourceConditionedAceStepPipeline() + with self.subTest(source=source): + with self.assertRaisesRegex( + ValueError, + "path identifier is invalid|must stay inside the configured MoDiff work or data directory", + ): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + decoder.assert_not_called() + + def test_short_audio_orientation_uses_channels_metadata_and_rejects_guessing_before_torch(self): + frame_first = np.asarray( + [[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]], + dtype=np.float32, + ) + channel_first = frame_first.T + square = np.asarray([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + accepted_sources = ( + ({"samples": frame_first, "sample_rate": 48000, "channels": 2}, channel_first), + ({"samples": channel_first, "sample_rate": 48000, "channels": 2}, channel_first), + ( + { + "samples": square, + "sample_layout": "frames_first", + "sample_rate": 48000, + "channels": 2, + }, + square.T, + ), + ( + { + "samples": square, + "sample_layout": "channels_first", + "sample_rate": 48000, + "channels": 2, + }, + square, + ), + ) + for source, expected in accepted_sources: + with self.subTest(shape=source["samples"].shape): + invocation = _preflight_audio_invocation( + FakeSourceConditionedAceStepPipeline(), + { + "task_type": "cover", + "source_audio": source, + "audio_duration": 0.01, + }, + ) + self.assertEqual(invocation.source.samples.shape, expected.shape) + np.testing.assert_allclose(invocation.source.samples, expected) + + mono = _preflight_audio_invocation( + FakeSourceConditionedAceStepPipeline(), + { + "task_type": "cover", + "source_audio": { + "samples": frame_first[:, :1], + "sample_rate": 48000, + "channels": 1, + }, + "audio_duration": 0.01, + }, + ).source.samples + self.assertEqual(mono.shape, (2, 3)) + np.testing.assert_array_equal(mono[0], mono[1]) + + from modules.Audio.main import Load as LoadAudio + from scipy.io import wavfile + + with tempfile.TemporaryDirectory() as temporary: + work = Path(temporary) / "work" + data = Path(temporary) / "data" + work.mkdir() + data.mkdir() + wavfile.write( + work / "two-frame-stereo.wav", + 48000, + np.asarray([[100, 1000], [200, 2000]], dtype=np.int16), + ) + with patch.dict(CONFIG.paths, {"work_dir": str(work), "data": str(data)}): + audio_node_payload = LoadAudio().execute(file="two-frame-stereo.wav")["audio"] + invocation = _preflight_audio_invocation( + FakeSourceConditionedAceStepPipeline(), + { + "task_type": "cover", + "source_audio": audio_node_payload, + "audio_duration": 0.01, + }, + ) + self.assertEqual(audio_node_payload["sample_layout"], "frames_first") + self.assertEqual(invocation.source.samples.shape, (2, 2)) + + with patch.dict(sys.modules, {"torch": None}): + for shape in ((2, 2), (3, 2), (4, 2), (3, 1)): + pipeline = FakeSourceConditionedAceStepPipeline() + with self.subTest(ambiguous_shape=shape): + with self.assertRaisesRegex(ValueError, "ambiguous channel orientation"): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio={ + "samples": np.zeros(shape, dtype=np.float32), + "sample_rate": 48000, + }, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + for source, message in ( + ( + { + "samples": frame_first, + "sample_rate": 48000, + "channels": 2, + "sample_layout": "time_major", + }, + "sample_layout metadata must be exactly", + ), + ( + { + "samples": frame_first, + "sample_rate": 48000, + "channels": 2, + "sample_layout": "channels_first", + }, + "identifies 3", + ), + ): + pipeline = FakeSourceConditionedAceStepPipeline() + with self.subTest(source=source): + with self.assertRaisesRegex(ValueError, message): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + pipeline = FakeSourceConditionedAceStepPipeline() + with self.assertRaisesRegex(ValueError, "exactly 2 channels.*received 3"): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio={ + "samples": np.zeros((3, 2), dtype=np.float32), + "sample_rate": 48000, + "channels": 3, + }, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_ace_source_channel_adapter_duplicates_mono_and_rejects_ambiguous_multichannel(self): + mono = {"samples": np.zeros((1, 480), dtype=np.float32), "sample_rate": 48000} + cases = ( + ("audio_variation", "cover", "reference_audio", {"audio_duration": 0.01}), + ("audio_continuation", "continuation", "src_audio", {"extension_duration": 0.01}), + ( + "audio_repaint", + "repaint", + "src_audio", + {"repainting_start": 0, "repainting_end": 0.01}, + ), + ) + for mode, task, upstream_field, values in cases: + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = mode + node = Generate(f"mono-{mode}") + node.progress = lambda *args, **kwargs: None + + node.execute( + pipeline=pipeline, + task_type=task, + source_audio=mono, + sample_rate=48000, + **values, + ) + + conditioned = pipeline.call_kwargs[upstream_field] + with self.subTest(mode=mode): + expected_samples = 960 if mode == "audio_continuation" else 480 + self.assertEqual(tuple(conditioned.shape), (2, expected_samples)) + np.testing.assert_array_equal(conditioned[0].cpu().numpy(), conditioned[1].cpu().numpy()) + + for channels in (3, 6): + pipeline = FakeSourceConditionedAceStepPipeline() + source = { + "samples": np.zeros((channels, 480), dtype=np.float32), + "sample_rate": 48000, + } + with self.subTest(channels=channels): + with self.assertRaisesRegex(ValueError, "exactly 2 channels.*multichannel downmixing"): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + audio_duration=0.01, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_variation_rejects_a_distinct_reference_instead_of_overriding_its_required_source(self): + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + reference = {"samples": np.ones((1, 480), dtype=np.float32), "sample_rate": 48000} + self.assertIsNot(source, reference) + pipeline = FakeSourceConditionedAceStepPipeline() + + with patch.dict(sys.modules, {"torch": None}): + with self.assertRaisesRegex(ValueError, "audio_variation does not accept reference audio"): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + reference_audio=reference, + ) + + self.assertIsNone(pipeline.call_kwargs) + + def test_forbidden_audio_inputs_are_enforced_per_mode(self): + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + forbidden_cases = ( + ("text_to_audio", "text2music", "source_audio"), + ("text_to_audio", "text2music", "reference_audio"), + ("audio_variation", "cover", "reference_audio"), + ("audio_continuation", "continuation", "reference_audio"), + ("audio_repaint", "repaint", "reference_audio"), + ) + for mode, task, forbidden_key in forbidden_cases: + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = mode + values = {"pipeline": pipeline, "task_type": task, forbidden_key: source} + if mode != "text_to_audio": + values["source_audio"] = source + if mode == "audio_repaint": + values.update(repainting_start=0, repainting_end=0.01) + with self.subTest(mode=mode, forbidden=forbidden_key): + with self.assertRaisesRegex(ValueError, "does not accept"): + Generate().execute(**values) + self.assertIsNone(pipeline.call_kwargs) + + class StableAudioFixture: + _modiff_audio_pipeline_class = "StableAudioPipeline" + + def __init__(self): + self.call_kwargs = None + + def __call__(self, **kwargs): + self.call_kwargs = kwargs + return SimpleNamespace(audios=np.zeros((1, 1, 480), dtype=np.float32)) + + for forbidden_key in ("source_audio", "reference_audio"): + pipeline = StableAudioFixture() + with self.subTest(pipeline="stable", forbidden=forbidden_key): + with self.assertRaisesRegex(ValueError, "does not accept"): + Generate().execute(pipeline=pipeline, **{forbidden_key: source}) + self.assertIsNone(pipeline.call_kwargs) + + def test_stable_duration_bounds_are_preflighted(self): + pipeline = SimpleNamespace(_modiff_audio_pipeline_class="StableAudioPipeline") + for duration in (0, -1, float("nan"), float("inf"), float("-inf"), 47.01): + with self.subTest(duration=duration): + with self.assertRaisesRegex(ValueError, "greater than 0 and at most 47"): + Generate().execute(pipeline=pipeline, audio_duration=duration) + + def test_ace_duration_and_continuation_extension_bounds_are_preflighted(self): + invalid_durations = (0, -1, float("nan"), float("inf"), float("-inf"), 240.01) + for duration in invalid_durations: + pipeline = FakeAceStepPipeline() + with self.subTest(kind="duration", value=duration): + with self.assertRaisesRegex(ValueError, "greater than 0 and at most 240"): + Generate().execute(pipeline=pipeline, audio_duration=duration) + self.assertIsNone(pipeline.call_kwargs) + + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + invalid_extensions = (0, -1, float("nan"), float("inf"), float("-inf"), 180.01) + for extension in invalid_extensions: + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_continuation" + with self.subTest(kind="extension", value=extension): + with self.assertRaisesRegex(ValueError, "continuation extension.*greater than 0 and at most 180"): + Generate().execute( + pipeline=pipeline, + task_type="continuation", + source_audio=source, + extension_duration=extension, + ) + self.assertIsNone(pipeline.call_kwargs) + + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_continuation" + long_source = {"samples": np.zeros((2, 61), dtype=np.float32), "sample_rate": 1} + with self.assertRaisesRegex(ValueError, "source plus extension must be at most 240"): + Generate().execute( + pipeline=pipeline, + task_type="continuation", + source_audio=long_source, + extension_duration=180, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_continuation_and_repaint_derive_duration_from_validated_source(self): + source = {"samples": np.zeros((2, 960), dtype=np.float32), "sample_rate": 48000} + + continuation = FakeSourceConditionedAceStepPipeline() + continuation._modiff_audio_mode = "audio_continuation" + continuation_node = Generate("audio-derived-continuation") + continuation_node.progress = lambda *args, **kwargs: None + continuation_node.execute( + pipeline=continuation, + task_type="continuation", + source_audio=source, + audio_duration=float("inf"), + extension_duration=0.01, + sample_rate=48000, + ) + self.assertAlmostEqual(continuation.call_kwargs["audio_duration"], 0.03) + self.assertAlmostEqual(continuation.call_kwargs["repainting_start"], 0.02) + self.assertAlmostEqual(continuation.call_kwargs["repainting_end"], 0.03) + + class RepaintPipeline(FakeSourceConditionedAceStepPipeline): + _modiff_audio_mode = "audio_repaint" + + def __call__(self, src_audio=None, **kwargs): + self.call_kwargs = {**kwargs, "src_audio": src_audio} + return SimpleNamespace(audios=np.zeros((1, 1, src_audio.shape[-1]), dtype=np.float32)) + + repaint = RepaintPipeline() + repaint_node = Generate("audio-derived-repaint") + repaint_node.progress = lambda *args, **kwargs: None + result = repaint_node.execute( + pipeline=repaint, + task_type="repaint", + source_audio=source, + audio_duration=-1, + repainting_start=0.01, + repainting_end=0.02, + sample_rate=48000, + ) + self.assertAlmostEqual(repaint.call_kwargs["audio_duration"], 0.02) + self.assertAlmostEqual(result["duration_seconds"], 0.02) + + def test_valid_ace_modes_route_to_their_declared_upstream_tasks(self): + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + cases = ( + ("text_to_audio", "text2music", "text2music"), + ("audio_variation", "cover", "cover"), + ("audio_continuation", "continuation", "repaint"), + ("audio_repaint", "repaint", "repaint"), + ) + for mode, task, upstream_task in cases: + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = mode + node = Generate(f"valid-{mode}") + node.progress = lambda *args, **kwargs: None + values = { + "pipeline": pipeline, + "task_type": task, + "audio_duration": 0.01, + "sample_rate": 48000, + } + if mode != "text_to_audio": + values["source_audio"] = source + if mode == "audio_continuation": + values["extension_duration"] = 0.01 + if mode == "audio_repaint": + values.update(repainting_start=0, repainting_end=0.01) + + node.execute(**values) + + with self.subTest(mode=mode): + self.assertEqual(pipeline.call_kwargs["task_type"], upstream_task) + if mode in {"audio_continuation", "audio_repaint"}: + self.assertIsNotNone(pipeline.call_kwargs["src_audio"]) + if mode == "audio_variation": + self.assertIsNotNone(pipeline.call_kwargs["reference_audio"]) + + def test_repaint_interval_is_validated_before_generation(self): + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + for start, end, message in ((-0.1, 0.01, "at least 0"), (0.01, 0.01, "strictly greater")): + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_repaint" + with self.subTest(start=start, end=end): + with self.assertRaisesRegex(ValueError, message): + Generate().execute( + pipeline=pipeline, + task_type="repaint", + source_audio=source, + repainting_start=start, + repainting_end=end, + ) + self.assertIsNone(pipeline.call_kwargs) + + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_repaint" + with self.assertRaisesRegex(ValueError, "exceeds.*source duration"): + Generate().execute( + pipeline=pipeline, + task_type="repaint", + source_audio=source, + repainting_start=0, + repainting_end=1, + ) + self.assertIsNone(pipeline.call_kwargs) + + def test_invalid_contract_fails_before_torch_import_or_upstream_call(self): + pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_continuation" + with patch.dict(sys.modules, {"torch": None}): + with self.assertRaisesRegex(ValueError, "requires source audio"): + Generate().execute(pipeline=pipeline, task_type="continuation") + self.assertIsNone(pipeline.call_kwargs) + + def test_untagged_pipeline_recovery_is_exact_and_only_safe_for_single_mode(self): + AceStepPipeline = type("AceStepPipeline", (), {"__call__": lambda self, **kwargs: None}) + with self.assertRaisesRegex(ValueError, "ambiguous across modes"): + Generate().execute(pipeline=AceStepPipeline()) + + class StableAudioPipeline: + device = "cpu" + vae = SimpleNamespace(config={"sampling_rate": 44100}) + + def __init__(self): + self.call_kwargs = None + + def __call__(self, **kwargs): + self.call_kwargs = kwargs + return SimpleNamespace(audios=np.zeros((1, 1, 441), dtype=np.float32)) + + pipeline = StableAudioPipeline() + result = Generate().execute( + pipeline=pipeline, + audio_duration=0.01, + sample_rate=44100, + stable_audio_steps=2, + ) + self.assertEqual(pipeline.call_kwargs["audio_end_in_s"], 0.01) + self.assertEqual(result["sample_rate_out"], 44100) + + class StableAudioSubclass(StableAudioPipeline): + pass + + with self.assertRaisesRegex(ValueError, "not an exact supported"): + Generate().execute(pipeline=StableAudioSubclass(), audio_duration=0.01) + + def test_tagged_audio_pipeline_rejects_runtime_class_and_managed_repository_mismatches(self): + StableAudioPipeline = type( + "StableAudioPipeline", + (), + { + "_modiff_audio_pipeline_class": "AceStepPipeline", + "_modiff_audio_mode": "text_to_audio", + }, + ) + with self.assertRaisesRegex(ValueError, "runtime class StableAudioPipeline.*tagged as AceStepPipeline"): + Generate().execute(pipeline=StableAudioPipeline(), audio_duration=0.01) + + mismatched_repo = SimpleNamespace( + _modiff_audio_pipeline_class="StableAudioPipeline", + _modiff_audio_mode="text_to_audio", + _modiff_audio_repo=ACE_STEP_DEFAULT_REPO, + ) + with self.assertRaisesRegex(ValueError, "managed repository.*supports AceStepPipeline.*StableAudioPipeline"): + Generate().execute(pipeline=mismatched_repo, audio_duration=0.01) + def test_graph_contract_distinguishes_required_and_optional_audio_inputs(self): for node_class in (LoadAdapter, SetAdapters, FuseAdapters, Generate): with self.subTest(node=node_class.__name__): @@ -75,11 +1307,25 @@ def test_graph_contract_distinguishes_required_and_optional_audio_inputs(self): self.assertFalse(Generate.params["source_audio"]["required"]) self.assertFalse(Generate.params["reference_audio"]["required"]) + default_overlay = AUDIO_PIPELINE_ADAPTERS["AceStepPipeline"].contract_for_mode( + "text_to_audio" + ).field_param_overlay() + for field, params in default_overlay.items(): + for key in ("hidden", "required", "max"): + if key in params: + with self.subTest(field=field, key=key): + value = Generate.params[field].get(key, False) if key == "hidden" else Generate.params[field][key] + self.assertEqual(value, params[key]) self.assertTrue(Generate.params["lora_scale"]["hidden"]) self.assertIn("per-call multiplier", Generate.params["lora_scale"]["description"]) self.assertIn("ignored by ACE-Step", Generate.params["stable_audio_steps"]["description"]) self.assertIn("ignored by ACE-Step", Generate.params["stable_audio_guidance"]["description"]) self.assertIn("ignored by ACE-Step", Generate.params["num_waveforms"]["description"]) + self.assertTrue(LoadPipeline.params["pipeline_class"]["fieldOptions"]["noValidation"]) + self.assertTrue(LoadPipeline.params["mode"]["fieldOptions"]["noValidation"]) + self.assertTrue(Generate.params["task_type"]["fieldOptions"]["noValidation"]) + self.assertEqual(LoadAdapter.params["weight_name"]["default"], "adapter_model.safetensors") + self.assertIn("literal lowercase .safetensors", LoadAdapter.params["weight_name"]["description"]) def test_ace_step_lora_load_set_and_fuse_contracts(self): events = [] @@ -100,12 +1346,15 @@ def fuse_lora(self, **kwargs): events.append(("fuse", kwargs)) pipeline = Pipeline() - LoadAdapter("audio-lora").execute( - pipeline=pipeline, - adapter_path={"source": "local", "value": "/models/audio-style"}, - adapter_name="style", - scale=0.6, - ) + with tempfile.TemporaryDirectory() as temporary: + adapter_file = Path(temporary) / "adapter_model.safetensors" + adapter_file.write_bytes(b"audio-lora-fixture") + LoadAdapter("audio-lora").execute( + pipeline=pipeline, + adapter_path={"source": "local", "value": str(adapter_file)}, + adapter_name="style", + scale=0.6, + ) SetAdapters("audio-blend").execute( pipeline=pipeline, adapter_names="style", @@ -115,10 +1364,133 @@ def fuse_lora(self, **kwargs): self.assertEqual(events[0], "unload") self.assertEqual(events[1][0], "load") + self.assertEqual(events[1][2]["weight_name"], "adapter_model.safetensors") + self.assertTrue(events[1][2]["use_safetensors"]) self.assertEqual(events[2], ("set", ["style"], [0.6])) self.assertEqual(events[3], ("set", ["style"], [0.4])) self.assertEqual(events[4], ("fuse", {"safe_fusing": True})) + def test_audio_lora_requires_a_literal_lowercase_safetensors_filename_before_cache_or_mutation(self): + class Pipeline: + _modiff_audio_pipeline_class = "AceStepPipeline" + + def __init__(self): + self.events = [] + + def unload_lora_weights(self): + self.events.append("unload") + + def load_lora_weights(self, *args, **kwargs): + self.events.append(("load", args, kwargs)) + + revision = "0123456789abcdef0123456789abcdef01234567" + digest = "a" * 64 + invalid_names = ( + "weights.bin", + "weights.BIN", + "weights.SAFETENSORS", + "weights.SafeTensors", + "weights.safetensors.bin", + ) + for weight_name in invalid_names: + pipeline = Pipeline() + with self.subTest(source="hub", weight_name=weight_name): + with ( + patch("utils.huggingface.cached_file_path") as cache_lookup, + self.assertRaisesRegex(ValueError, "literal lowercase \\.safetensors suffix"), + ): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path={"source": "hub", "value": "org/audio-lora"}, + weight_name=weight_name, + revision=revision, + expected_sha256=digest, + ) + cache_lookup.assert_not_called() + self.assertEqual(pipeline.events, []) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + invalid_files = [] + for weight_name in invalid_names: + path = root / weight_name + path.write_bytes(b"unsafe-format-fixture") + invalid_files.append(path) + + for path in invalid_files: + pipeline = Pipeline() + with self.subTest(source="local-file", weight_name=path.name): + with self.assertRaisesRegex(ValueError, "literal lowercase \\.safetensors suffix"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path={"source": "local", "value": str(path)}, + ) + self.assertEqual(pipeline.events, []) + + local_folder = root / "folder" + local_folder.mkdir() + for weight_name in ("weights.bin", "weights.SAFETENSORS"): + pipeline = Pipeline() + with self.subTest(source="local-folder", weight_name=weight_name): + with self.assertRaisesRegex(ValueError, "literal lowercase \\.safetensors suffix"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path={"source": "local", "value": str(local_folder)}, + weight_name=weight_name, + ) + self.assertEqual(pipeline.events, []) + + lowercase_file = root / "accepted.safetensors" + lowercase_file.write_bytes(b"safe-format-fixture") + valid_pipeline = Pipeline() + LoadAdapter().execute( + pipeline=valid_pipeline, + adapter_path={"source": "local", "value": str(lowercase_file)}, + replace_existing=False, + ) + self.assertEqual(valid_pipeline.events[0][0], "load") + self.assertEqual(valid_pipeline.events[0][2]["weight_name"], "accepted.safetensors") + self.assertTrue(valid_pipeline.events[0][2]["use_safetensors"]) + + for cached_name in ("cached.bin", "cached.SAFETENSORS"): + cached_path = root / cached_name + cached_path.write_bytes(b"unsafe-cache-alias") + pipeline = Pipeline() + with self.subTest(source="hub-cache", cached_name=cached_name): + with ( + patch("utils.huggingface.cached_file_path", return_value=str(cached_path)), + patch("utils.huggingface.resolve_managed_hf_cache_file") as resolve_cached, + self.assertRaisesRegex(ValueError, "literal lowercase \\.safetensors suffix"), + ): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path={"source": "hub", "value": "org/audio-lora"}, + weight_name="weights.safetensors", + revision=revision, + expected_sha256=digest, + ) + resolve_cached.assert_not_called() + self.assertEqual(pipeline.events, []) + + preflight = LoadAdapter("audio-lora-format-preflight") + preflight.execute = Mock(side_effect=AssertionError("LoRA execution must not run")) + for selection, weight_name in ( + ({"source": "hub", "value": "org/audio-lora"}, "weights.SAFETENSORS"), + ({"source": "local", "value": str(root / "weights.SAFETENSORS")}, None), + ): + with self.subTest(source="nodebase-preflight", weight_name=weight_name): + values = { + "pipeline": Pipeline(), + "adapter_path": selection, + "revision": revision, + "expected_sha256": digest, + } + if weight_name is not None: + values["weight_name"] = weight_name + with self.assertRaisesRegex(ValueError, "literal lowercase \\.safetensors suffix"): + preflight(**values) + preflight.execute.assert_not_called() + def test_audio_lora_rejects_non_ace_pipeline(self): with self.assertRaisesRegex(ValueError, "AceStepPipeline"): LoadAdapter("wrong-audio-lora").execute( @@ -126,6 +1498,276 @@ def test_audio_lora_rejects_non_ace_pipeline(self): adapter_path={"source": "local", "value": "/models/audio-style"}, ) + def test_audio_lora_source_and_local_path_boundaries_fail_before_pipeline_calls(self): + class Pipeline: + _modiff_audio_pipeline_class = "AceStepPipeline" + + def __init__(self): + self.calls = [] + + def load_lora_weights(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + for source in (None, "", " hub", "hub ", "remote", False, 0, {}, []): + pipeline = Pipeline() + with self.subTest(source=repr(source)): + with self.assertRaisesRegex(ValueError, "source must be exactly hub or local"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path={"source": source, "value": "org/not-installed"}, + replace_existing=False, + ) + self.assertEqual(pipeline.calls, []) + + pipeline = Pipeline() + for selection in (None, "", " ", {"source": "local", "value": ""}, {"source": "hub", "value": " "}): + with self.subTest(selection=repr(selection)): + with self.assertRaisesRegex(ValueError, "Audio LoRA (selection|repository ID or local path)"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path=selection, + replace_existing=False, + ) + self.assertEqual(pipeline.calls, []) + + with self.assertRaisesRegex(FileNotFoundError, "path does not exist"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path="org/not-installed", + replace_existing=False, + ) + self.assertEqual(pipeline.calls, []) + + with tempfile.TemporaryDirectory() as temporary: + temporary_path = Path(temporary) + adapter_file = temporary_path / "installed.safetensors" + adapter_file.write_bytes(b"installed-audio-lora") + + hub_pipeline = Pipeline() + hub_revision = "0123456789abcdef0123456789abcdef01234567" + expected_sha256 = hashlib.sha256(adapter_file.read_bytes()).hexdigest() + with ( + patch("utils.huggingface.cached_file_path", return_value=str(adapter_file)) as cached, + patch( + "utils.huggingface.resolve_managed_hf_cache_file", + return_value=adapter_file, + ) as resolve_cached, + ): + LoadAdapter().execute( + pipeline=hub_pipeline, + adapter_path={"source": "HUB", "value": "org/installed"}, + weight_name="installed.safetensors", + revision=hub_revision, + expected_sha256=expected_sha256, + replace_existing=False, + ) + cached.assert_called_once_with( + "org/installed", + "installed.safetensors", + revision=hub_revision, + ) + resolve_cached.assert_called_once_with(str(adapter_file)) + self.assertEqual(len(hub_pipeline.calls), 1) + + hash_mismatch_pipeline = Pipeline() + with ( + patch("utils.huggingface.cached_file_path", return_value=str(adapter_file)), + patch("utils.huggingface.resolve_managed_hf_cache_file", return_value=adapter_file), + ): + with self.assertRaisesRegex(ValueError, "pinned SHA-256 verification"): + LoadAdapter().execute( + pipeline=hash_mismatch_pipeline, + adapter_path={"source": "hub", "value": "org/installed"}, + weight_name="installed.safetensors", + revision=hub_revision, + expected_sha256="0" * 64, + replace_existing=False, + ) + self.assertEqual(hash_mismatch_pipeline.calls, []) + + escaped_cache_pipeline = Pipeline() + managed_cache = temporary_path / "managed-cache" + managed_cache.mkdir() + with ( + patch("utils.huggingface.cached_file_path", return_value=str(adapter_file)), + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(managed_cache)}), + ): + with self.assertRaisesRegex(ValueError, "outside the managed cache root"): + LoadAdapter().execute( + pipeline=escaped_cache_pipeline, + adapter_path={"source": "hub", "value": "org/installed"}, + weight_name="installed.safetensors", + revision=hub_revision, + expected_sha256=expected_sha256, + replace_existing=False, + ) + self.assertEqual(escaped_cache_pipeline.calls, []) + + for revision, expected_hash in ( + (None, expected_sha256), + ("main", expected_sha256), + (hub_revision.upper(), expected_sha256), + (hub_revision, ""), + (hub_revision, "not-a-sha256"), + ): + rejected_pipeline = Pipeline() + with self.subTest(hub_revision=revision, expected_hash=expected_hash): + with patch("utils.huggingface.cached_file_path") as rejected_cache: + with self.assertRaisesRegex(ValueError, "immutable lowercase|exact lowercase|SHA-256"): + LoadAdapter().execute( + pipeline=rejected_pipeline, + adapter_path={"source": "hub", "value": "org/installed"}, + weight_name="installed.safetensors", + revision=revision, + expected_sha256=expected_hash, + replace_existing=False, + ) + rejected_cache.assert_not_called() + self.assertEqual(rejected_pipeline.calls, []) + + for weight_name in ( + "../installed.safetensors", + "/installed.safetensors", + "nested\\..\\installed.safetensors", + "nested//installed.safetensors", + ): + rejected_pipeline = Pipeline() + with self.subTest(hub_weight_name=weight_name): + with patch("utils.huggingface.cached_file_path") as rejected_cache: + with self.assertRaisesRegex(ValueError, "relative Hub file path without traversal"): + LoadAdapter().execute( + pipeline=rejected_pipeline, + adapter_path={"source": "hub", "value": "org/installed"}, + weight_name=weight_name, + revision=hub_revision, + expected_sha256=expected_sha256, + replace_existing=False, + ) + rejected_cache.assert_not_called() + self.assertEqual(rejected_pipeline.calls, []) + + local_pipeline = Pipeline() + LoadAdapter().execute( + pipeline=local_pipeline, + adapter_path=str(adapter_file), + replace_existing=False, + ) + self.assertEqual(len(local_pipeline.calls), 1) + self.assertEqual(local_pipeline.calls[0][1]["weight_name"], "installed.safetensors") + + from utils.huggingface import cached_file_path + + with patch("utils.huggingface.try_to_load_from_cache", return_value=str(adapter_file)) as cache_lookup: + self.assertEqual( + cached_file_path("org/installed", "installed.safetensors", revision=hub_revision), + str(adapter_file), + ) + self.assertEqual(cache_lookup.call_args.kwargs["revision"], hub_revision) + + real_node_pipeline = Pipeline() + real_node = LoadAdapter("audio-lora-real-node-local") + real_node.execute = Mock(return_value={"output": real_node_pipeline}) + with chdir(temporary), patch("modiff.NodeBase.modelstore.is_local_cached", return_value=True): + first = real_node( + pipeline=real_node_pipeline, + adapter_path=adapter_file.name, + replace_existing=False, + ) + second = real_node( + pipeline=real_node_pipeline, + adapter_path=str(adapter_file), + replace_existing=False, + ) + self.assertIs(first, second) + real_node.execute.assert_called_once() + self.assertEqual( + real_node.execute.call_args.kwargs["adapter_path"], + {"source": "local", "value": str(adapter_file)}, + ) + + invalid_real_node = LoadAdapter("audio-lora-real-node-invalid") + invalid_real_node.execute = Mock() + for invalid in (None, "", {"source": "", "value": str(adapter_file)}): + with self.subTest(real_node_selection=repr(invalid)): + with self.assertRaises(ValueError): + invalid_real_node( + pipeline=real_node_pipeline, + adapter_path=invalid, + replace_existing=False, + ) + invalid_real_node.execute.assert_not_called() + + adapter_folder = temporary_path / "folder" + adapter_folder.mkdir() + traversal_pipeline = Pipeline() + with self.assertRaisesRegex(FileNotFoundError, "inside the selected folder"): + LoadAdapter().execute( + pipeline=traversal_pipeline, + adapter_path={"source": "local", "value": str(adapter_folder)}, + weight_name="../installed.safetensors", + replace_existing=False, + ) + self.assertEqual(traversal_pipeline.calls, []) + + def test_audio_lora_identity_is_validated_before_real_nodebase_execution(self): + pipeline = SimpleNamespace(_modiff_audio_pipeline_class="AceStepPipeline") + selection = {"source": "hub", "value": "organization/custom-audio-lora"} + revision = "0123456789abcdef0123456789abcdef01234567" + digest = "a" * 64 + invalid = LoadAdapter("strict-audio-lora-identity") + invalid.execute = Mock(side_effect=AssertionError("LoRA upstream must not run")) + + for values in ( + {"revision": None, "expected_sha256": digest}, + {"revision": "main", "expected_sha256": digest}, + {"revision": revision.upper(), "expected_sha256": digest}, + {"revision": revision, "expected_sha256": ""}, + {"revision": revision, "expected_sha256": "not-a-digest"}, + ): + with self.subTest(values=values): + with self.assertRaisesRegex(ValueError, "immutable lowercase|exact lowercase|SHA-256"): + invalid( + pipeline=pipeline, + adapter_path=selection, + replace_existing=False, + **values, + ) + invalid.execute.assert_not_called() + + valid = LoadAdapter("strict-audio-lora-valid") + valid.execute = Mock(return_value={"output": pipeline}) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + valid( + pipeline=pipeline, + adapter_path=selection, + revision=revision, + expected_sha256=f"SHA256:{digest.upper()}", + replace_existing=False, + ) + self.assertEqual(valid.execute.call_args.kwargs["revision"], revision) + self.assertEqual(valid.execute.call_args.kwargs["expected_sha256"], digest) + + def test_hub_audio_lora_cannot_resolve_as_a_local_directory(self): + pipeline = SimpleNamespace(_modiff_audio_pipeline_class="AceStepPipeline") + node = LoadAdapter("audio-lora-hub-local-path-boundary") + node.execute = Mock(side_effect=AssertionError("LoRA upstream must not run")) + revision = "0123456789abcdef0123456789abcdef01234567" + + with tempfile.TemporaryDirectory() as temporary: + local_repo = Path(temporary) / "organization" / "local-audio-lora" + local_repo.mkdir(parents=True) + with chdir(temporary): + with self.assertRaisesRegex(ValueError, "local filesystem"): + node( + pipeline=pipeline, + adapter_path={"source": "hub", "value": "organization/local-audio-lora"}, + revision=revision, + expected_sha256="a" * 64, + replace_existing=False, + ) + + node.execute.assert_not_called() + def test_stable_audio_uses_native_generation_rate_and_requested_delivery_rate(self): class FakeStableAudio: _modiff_audio_pipeline_class = "StableAudioPipeline" @@ -162,7 +1804,7 @@ def __call__(self, **kwargs): self.assertTrue(all(item["samples"].shape == (2, 48000) for item in result["audio_variations"])) self.assertIs(result["audio"], result["audio_variations"][0]) - def test_explicit_zero_audio_controls_are_forwarded(self): + def test_explicit_zero_audio_controls_are_preserved_only_where_upstream_allows_them(self): pipeline = FakeSourceConditionedAceStepPipeline() node = Generate("ace-zero-values-test") node.progress = lambda *args, **kwargs: None @@ -175,15 +1817,117 @@ def test_explicit_zero_audio_controls_are_forwarded(self): prompt="A silent control fixture", audio_duration=0.01, guidance_scale=0, - shift=0, + shift=0.1, + lora_scale=0, audio_cover_strength=0, ) self.assertEqual(pipeline.call_kwargs["guidance_scale"], 0) - self.assertEqual(pipeline.call_kwargs["shift"], 0) + self.assertEqual(pipeline.call_kwargs["shift"], 0.1) + self.assertEqual(pipeline.call_kwargs["attention_kwargs"]["scale"], 0) self.assertEqual(pipeline.call_kwargs["audio_cover_strength"], 0) self.assertEqual(result["audio_variations"], [result["audio"]]) + def test_audio_numeric_resource_bounds_fail_before_upstream(self): + raw_node = Generate("audio-raw-integer-bounds") + raw_node.execute = Mock() + for field, value in (("num_inference_steps", 1.5), ("stable_audio_steps", True), ("num_waveforms", "1.5")): + with self.subTest(raw_field=field, raw_value=value): + with self.assertRaisesRegex(ValueError, "exact finite integer"): + raw_node(pipeline=FakeAceStepPipeline(), **{field: value}) + raw_node.execute.assert_not_called() + + ace_cases = ( + ({"num_inference_steps": 0}, "inference steps"), + ({"num_inference_steps": 101}, "inference steps"), + ({"guidance_scale": -0.1}, "guidance"), + ({"guidance_scale": float("nan")}, "guidance"), + ({"shift": 0}, "shift"), + ({"shift": 10.1}, "shift"), + ({"lora_scale": -0.1}, "LoRA call strength"), + ({"lora_scale": float("inf")}, "LoRA call strength"), + ({"bpm": 401}, "BPM"), + ({"bpm": 170.5}, "BPM"), + ({"seed": -1}, "seed"), + ) + for values, message in ace_cases: + pipeline = FakeAceStepPipeline() + with self.subTest(values=values): + with self.assertRaisesRegex(ValueError, message): + Generate().execute(pipeline=pipeline, audio_duration=0.01, **values) + self.assertIsNone(pipeline.call_kwargs) + + source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} + for strength in (-0.1, 1.1, float("nan")): + pipeline = FakeSourceConditionedAceStepPipeline() + with self.subTest(cover_strength=strength): + with self.assertRaisesRegex(ValueError, "cover strength"): + Generate().execute( + pipeline=pipeline, + task_type="cover", + source_audio=source, + audio_duration=0.01, + audio_cover_strength=strength, + ) + self.assertIsNone(pipeline.call_kwargs) + + stable_cases = ( + ({"stable_audio_steps": 0}, "Stable Audio steps"), + ({"stable_audio_steps": 301}, "Stable Audio steps"), + ({"stable_audio_guidance": -0.1}, "Stable Audio guidance"), + ({"stable_audio_guidance": float("inf")}, "Stable Audio guidance"), + ({"num_waveforms": 0}, "Stable Audio variations"), + ({"num_waveforms": 9}, "Stable Audio variations"), + ) + for values, message in stable_cases: + pipeline = SimpleNamespace(_modiff_audio_pipeline_class="StableAudioPipeline") + with self.subTest(values=values): + with self.assertRaisesRegex(ValueError, message): + Generate().execute(pipeline=pipeline, audio_duration=0.01, **values) + + self.assertEqual(Generate.params["shift"]["min"], 0.1) + + def test_audio_lora_scales_are_finite_and_bounded_before_mutation(self): + class Pipeline: + _modiff_audio_pipeline_class = "AceStepPipeline" + + def __init__(self): + self.events = [] + + def unload_lora_weights(self): + self.events.append("unload") + + def load_lora_weights(self, *args, **kwargs): + self.events.append("load") + + def set_adapters(self, *args, **kwargs): + self.events.append("set") + + with tempfile.TemporaryDirectory() as temporary: + adapter_file = Path(temporary) / "adapter_model.safetensors" + adapter_file.write_bytes(b"bounded-audio-lora") + for scale in (-0.1, 2.1, float("nan"), float("inf")): + pipeline = Pipeline() + with self.subTest(scale=scale): + with self.assertRaisesRegex(ValueError, "Audio LoRA strength"): + LoadAdapter().execute( + pipeline=pipeline, + adapter_path=str(adapter_file), + scale=scale, + ) + self.assertEqual(pipeline.events, []) + + pipeline = Pipeline() + for weights in ("nan", "inf", "-0.1", "2.1"): + with self.subTest(weights=weights): + with self.assertRaisesRegex(ValueError, "finite values from 0 through 2"): + SetAdapters().execute( + pipeline=pipeline, + adapter_names="style", + adapter_weights=weights, + ) + self.assertEqual(pipeline.events, []) + def test_unsigned_pcm_midpoint_normalizes_to_zero(self): samples, _sample_rate = audio_to_numpy( {"samples": np.asarray([0, 128, 255], dtype=np.uint8), "sample_rate": 8000} @@ -239,6 +1983,7 @@ def from_pretrained(cls, repo, **kwargs): model_id="org/ace-step", pipeline_class="AceStepPipeline", mode="text_to_audio", + revision="0123456789abcdef0123456789abcdef01234567", device="cuda:0", auto_offload=False, offload_mode="none", @@ -246,7 +1991,7 @@ def from_pretrained(cls, repo, **kwargs): self.assertEqual(loaded["repo"], "org/ace-step") self.assertEqual(loaded["kwargs"]["device_map"], "cuda") - self.assertIsNone(loaded["kwargs"]["revision"]) + self.assertEqual(loaded["kwargs"]["revision"], "0123456789abcdef0123456789abcdef01234567") def test_curated_audio_pipeline_uses_catalog_revision(self): loaded = {} @@ -260,20 +2005,25 @@ def from_pretrained(cls, repo, **kwargs): node = LoadPipeline("ace-revision-test") node.progress = lambda *args, **kwargs: None node.mm_add = lambda *args, **kwargs: None - with ( - patch("modules.DiffusersAudio.main.pipeline_class_from_name", return_value=FakePipeline), - patch("modules.DiffusersAudio.main.apply_pipeline_offload"), - ): - node.execute( - model_id="ACE-Step/acestep-v15-xl-turbo-diffusers", - pipeline_class="AceStepPipeline", - mode="text_to_audio", - device="cpu", - auto_offload=False, - offload_mode="none", - ) - - self.assertEqual(loaded["kwargs"]["revision"], "200ba991ae448051e14b0183157e35c2d27c9fb0") + for source in ("hub", "HUB", "Hub"): + with self.subTest(source=source): + with ( + patch("modules.DiffusersAudio.main.pipeline_class_from_name", return_value=FakePipeline), + patch("modules.DiffusersAudio.main.apply_pipeline_offload"), + ): + node.execute( + model_id={"source": source, "value": ACE_STEP_DEFAULT_REPO}, + pipeline_class="AceStepPipeline", + mode="text_to_audio", + device="cpu", + auto_offload=False, + offload_mode="none", + ) + + self.assertEqual( + loaded["kwargs"]["revision"], + "200ba991ae448051e14b0183157e35c2d27c9fb0", + ) def test_xl_turbo_schema_uses_distilled_defaults(self): steps = Generate.params["num_inference_steps"] @@ -381,6 +2131,7 @@ def test_cover_routes_source_track_to_reference_audio_without_optional_audio_cod def test_repaint_routes_source_track_to_src_audio_without_implicit_timbre_reference(self): pipeline = FakeSourceConditionedAceStepPipeline() + pipeline._modiff_audio_mode = "audio_repaint" node = Generate("ace-repaint-source-test") node.progress = lambda *args, **kwargs: None source = {"samples": np.zeros((2, 480), dtype=np.float32), "sample_rate": 48000} diff --git a/tests/test_diffusers_image_registry.py b/tests/test_diffusers_image_registry.py index 111f59c..b84923c 100644 --- a/tests/test_diffusers_image_registry.py +++ b/tests/test_diffusers_image_registry.py @@ -1,5 +1,8 @@ import hashlib +import inspect import json +import os +import sys import tempfile import unittest from pathlib import Path @@ -10,16 +13,51 @@ from PIL import Image import modules as module_registry +from modiff.model_artifact_catalog import catalog_revision from modiff.server import WebServer -from modules.DiffusersImage import ControlGenerate, Edit, Inpaint, LoadAdapter, LoadPipeline, MODULE_MAP +from modules.DiffusersImage import ControlGenerate, Edit, Generate, Inpaint, LoadAdapter, LoadPipeline, MODULE_MAP from modules.DiffusersImage.main import ( + FLUX2_KLEIN_REPO, + FLUX_CANNY_REPO, + FLUX_DEPTH_REPO, FLUX_DEV_REPO, + FLUX_FILL_REPO, + FLUX_KONTEXT_REPO, + FLUX_KREA_REPO, + FLUX_SCHNELL_REPO, + IMAGE_MODE_FIELD_CONTRACTS, + IMAGE_PIPELINE_CLASSES, + QWEN_IMAGE_2512_REPO, + QWEN_IMAGE_EDIT_PLUS_REPO, + QWEN_IMAGE_EDIT_REPO, + SDXL_BASE_REPO, + Z_IMAGE_REPO, FluxReduxPipelineBundle, + ImageModeFieldContract, + _tag_image_pipeline, add_progress_callback, + image_pipeline_contract, output_image_dimensions, + pipeline_class_from_name, quant_config_for, + resolve_image_model_selection, + resolve_image_pipeline_revision, + validate_image_action, ) from modules.DiffusersImage.main import IMAGE_PIPELINE_ADAPTERS +from utils.huggingface import resolve_managed_hf_cache_file + +CUSTOM_IMAGE_REVISION = "a" * 40 +HUB_ADAPTER_REVISION = "b" * 40 + + +def tag_test_image_pipeline(pipeline, pipeline_class, mode, *, repo=None, revision=None): + adapter = IMAGE_PIPELINE_ADAPTERS[pipeline_class] + pipeline.__class__.__name__ = adapter.allowed_runtime_classes[0] + repository = repo or adapter.default_repo + resolved_revision = revision or catalog_revision(repository) or CUSTOM_IMAGE_REVISION + _tag_image_pipeline(pipeline, adapter, mode, repository, "hub", resolved_revision) + return pipeline class DiffusersImageRegistryTests(unittest.TestCase): @@ -150,6 +188,589 @@ def test_registered_classes_can_be_constructed(self): self.assertEqual(node.node_id, "registry-probe") self.assertTrue(node.resizable) + def test_loader_rejects_missing_null_malformed_and_noncanonical_class_or_mode_before_nodebase(self): + invalid_values = ( + ("class-absent", {"mode": "text_to_image"}), + ("class-null", {"pipeline_class": None, "mode": "text_to_image"}), + ("class-false", {"pipeline_class": False, "mode": "text_to_image"}), + ("class-zero", {"pipeline_class": 0, "mode": "text_to_image"}), + ("class-object", {"pipeline_class": {}, "mode": "text_to_image"}), + ("class-array", {"pipeline_class": [], "mode": "text_to_image"}), + ("class-blank", {"pipeline_class": "", "mode": "text_to_image"}), + ("class-spaced", {"pipeline_class": " FluxPipeline ", "mode": "text_to_image"}), + ("mode-absent", {"pipeline_class": "FluxPipeline"}), + ("mode-null", {"pipeline_class": "FluxPipeline", "mode": None}), + ("mode-false", {"pipeline_class": "FluxPipeline", "mode": False}), + ("mode-zero", {"pipeline_class": "FluxPipeline", "mode": 0}), + ("mode-object", {"pipeline_class": "FluxPipeline", "mode": {}}), + ("mode-array", {"pipeline_class": "FluxPipeline", "mode": []}), + ("mode-blank", {"pipeline_class": "FluxPipeline", "mode": ""}), + ("mode-spaced", {"pipeline_class": "FluxPipeline", "mode": " text_to_image "}), + ) + for label, values in invalid_values: + with self.subTest(case=label): + node = LoadPipeline(f"strict-{label}") + node.execute = Mock() + with self.assertRaises(ValueError): + node( + model_id={"source": "hub", "value": FLUX_SCHNELL_REPO}, + **values, + ) + node.execute.assert_not_called() + self.assertEqual(node.params, {}) + + def test_loader_canonicalizes_complete_identity_before_real_nodebase_cache(self): + class FluxPipeline: + pass + + pipeline = FluxPipeline() + node = LoadPipeline("canonical-image-loader") + node.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": FLUX_SCHNELL_REPO}) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + result = node( + model_id={"source": "HUB", "value": " BLACK-FOREST-LABS/FLUX.1-SCHNELL "}, + pipeline_class="FluxPipeline", + mode="text_to_image", + revision=None, + ) + + expected_revision = catalog_revision(FLUX_SCHNELL_REPO) + self.assertEqual( + node.params, + { + "model_id": {"source": "hub", "value": FLUX_SCHNELL_REPO}, + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "revision": expected_revision, + }, + ) + self.assertIs(result["pipeline"], pipeline) + self.assertEqual(pipeline._modiff_image_pipeline_class, "FluxPipeline") + self.assertEqual(pipeline._modiff_image_mode, "text_to_image") + self.assertEqual(pipeline._modiff_image_repo, FLUX_SCHNELL_REPO) + self.assertEqual(pipeline._modiff_image_source, "hub") + self.assertEqual(pipeline._modiff_image_revision, expected_revision) + + def test_model_selection_source_value_and_catalog_spelling_are_canonical_or_rejected(self): + adapter = IMAGE_PIPELINE_ADAPTERS["FluxPipeline"] + accepted = ( + (None, {"source": "hub", "value": FLUX_SCHNELL_REPO}), + ("", {"source": "hub", "value": FLUX_SCHNELL_REPO}), + ( + {"source": "HUB", "value": " BLACK-FOREST-LABS/FLUX.1-SCHNELL "}, + {"source": "hub", "value": FLUX_SCHNELL_REPO}, + ), + ({"source": "Local", "value": " models/custom "}, {"source": "local", "value": "models/custom"}), + (" org/custom ", {"source": "hub", "value": "org/custom"}), + ) + for value, expected in accepted: + with self.subTest(value=value): + self.assertEqual(resolve_image_model_selection(adapter, value), expected) + + rejected = ( + {"source": "local", "value": ""}, + {"source": " hub ", "value": FLUX_SCHNELL_REPO}, + {"source": "remote", "value": FLUX_SCHNELL_REPO}, + {"value": FLUX_SCHNELL_REPO}, + {"source": None, "value": FLUX_SCHNELL_REPO}, + {"source": 7, "value": FLUX_SCHNELL_REPO}, + {"source": "hub", "value": [FLUX_SCHNELL_REPO]}, + [], + ) + for value in rejected: + with self.subTest(value=value), self.assertRaises(ValueError): + resolve_image_model_selection(adapter, value) + + with self.assertRaisesRegex(ValueError, "existing local filesystem target"): + resolve_image_model_selection( + adapter, + {"source": "hub", "value": "modules/DiffusersImage"}, + ) + + def test_loader_revision_shape_local_boundary_and_custom_commit_fail_closed_before_execute(self): + managed = {"source": "hub", "value": FLUX_SCHNELL_REPO} + expected_pin = catalog_revision(FLUX_SCHNELL_REPO) + for value in (None, "", expected_pin): + with self.subTest(valid_revision=value): + self.assertEqual(resolve_image_pipeline_revision(managed, value), expected_pin) + + for value in (" ", False, 0, {}, [], "main", "A" * 40): + with self.subTest(invalid_revision=value), self.assertRaises(ValueError): + resolve_image_pipeline_revision(managed, value) + + custom = {"source": "hub", "value": "org/custom-image"} + self.assertEqual(resolve_image_pipeline_revision(custom, CUSTOM_IMAGE_REVISION), CUSTOM_IMAGE_REVISION) + for value in (None, "", "main", "A" * 40, "a" * 39): + with self.subTest(custom_revision=value), self.assertRaises(ValueError): + resolve_image_pipeline_revision(custom, value) + + node = LoadPipeline("local-image-contract-only") + node.execute = Mock() + with self.assertRaisesRegex(ValueError, "reviewed local pipeline-directory index"): + node( + model_id={"source": "local", "value": "models/local-pipeline"}, + pipeline_class="FluxPipeline", + mode="text_to_image", + revision="", + ) + node.execute.assert_not_called() + + def test_class_only_changes_replace_the_inherited_managed_repository(self): + cases = { + "ZImagePipeline": ("text_to_image", Z_IMAGE_REPO), + "Flux2KleinPipeline": ("text_to_image", FLUX2_KLEIN_REPO), + "FluxFillPipeline": ("inpaint", FLUX_FILL_REPO), + "FluxControlPipeline": ("control_image", FLUX_DEPTH_REPO), + "FluxKontextPipeline": ("edit_image", FLUX_KONTEXT_REPO), + } + loaded = [] + + class FakePipeline: + @classmethod + def from_pretrained(cls, repo, **_kwargs): + loaded.append(repo) + return cls() + + node = LoadPipeline("class-only-default-probe") + node.progress = lambda *args, **kwargs: None + node.mm_add = lambda *args, **kwargs: None + with ( + patch("modules.DiffusersImage.main.pipeline_class_from_name", return_value=FakePipeline), + patch("modules.DiffusersImage.main.apply_pipeline_offload"), + ): + for pipeline_class, (mode, expected_repo) in cases.items(): + with self.subTest(pipeline_class=pipeline_class): + result = node.execute( + model_id={"source": "hub", "value": FLUX_SCHNELL_REPO}, + pipeline_class=pipeline_class, + mode=mode, + auto_offload=False, + offload_mode="none", + ) + self.assertEqual(result["resolved_artifact"], expected_repo) + self.assertEqual(result["pipeline"]._modiff_image_mode, mode) + + self.assertEqual(loaded, [expected for _mode, expected in cases.values()]) + + def test_model_resolution_preserves_explicit_and_shared_compatible_repositories(self): + flux2 = IMAGE_PIPELINE_ADAPTERS["Flux2KleinPipeline"] + custom_hub = {"source": "hub", "value": "example/custom-flux2-compatible"} + local = {"source": "local", "value": FLUX_SCHNELL_REPO} + self.assertEqual(resolve_image_model_selection(flux2, custom_hub), custom_hub) + self.assertEqual(resolve_image_model_selection(flux2, local), local) + + flux = IMAGE_PIPELINE_ADAPTERS["FluxPipeline"] + for repo in (FLUX_SCHNELL_REPO, FLUX_DEV_REPO, FLUX_KREA_REPO): + with self.subTest(repo=repo): + selection = {"source": "hub", "value": repo} + self.assertEqual(resolve_image_model_selection(flux, selection), selection) + + control = IMAGE_PIPELINE_ADAPTERS["FluxControlPipeline"] + canny = {"source": "hub", "value": FLUX_CANNY_REPO} + self.assertEqual(resolve_image_model_selection(control, canny), canny) + + def test_every_image_adapter_owns_a_default_and_all_reviewed_compatible_repositories(self): + all_managed_repos = {repo for adapter in IMAGE_PIPELINE_ADAPTERS.values() for repo in adapter.managed_repos} + for pipeline_class, adapter in IMAGE_PIPELINE_ADAPTERS.items(): + with self.subTest(pipeline_class=pipeline_class): + self.assertTrue(adapter.default_repo) + contract = image_pipeline_contract(adapter, adapter.mode_options[0]) + implemented_modes = {mode for action_modes in contract["actions"].values() for mode in action_modes} + self.assertEqual(implemented_modes, adapter.modes) + self.assertEqual( + resolve_image_model_selection(adapter, None), + {"source": "hub", "value": adapter.default_repo}, + ) + for repo in adapter.managed_repos: + selection = {"source": "hub", "value": repo} + self.assertEqual(resolve_image_model_selection(adapter, selection), selection) + self.assertEqual( + resolve_image_pipeline_revision(selection, ""), + catalog_revision(repo), + ) + + incompatible = next(repo for repo in all_managed_repos if repo not in adapter.managed_repos) + self.assertEqual( + resolve_image_model_selection( + adapter, + {"source": "hub", "value": incompatible}, + ), + {"source": "hub", "value": adapter.default_repo}, + ) + + def test_image_field_contracts_cover_every_adapter_mode_and_selected_values(self): + self.assertEqual(set(IMAGE_MODE_FIELD_CONTRACTS), set(IMAGE_PIPELINE_ADAPTERS)) + expected_fields = { + "negative_prompt", + "width", + "height", + "guidance_scale", + "strength", + "padding_mask_crop", + "max_sequence_length", + "reference_strength", + } + for pipeline_class, adapter in IMAGE_PIPELINE_ADAPTERS.items(): + with self.subTest(pipeline_class=pipeline_class): + self.assertEqual(tuple(IMAGE_MODE_FIELD_CONTRACTS[pipeline_class]), adapter.mode_options) + for mode in adapter.mode_options: + self.assertEqual( + set(image_pipeline_contract(adapter, mode)["fieldParams"]), + expected_fields, + ) + + flux_text = image_pipeline_contract(IMAGE_PIPELINE_ADAPTERS["FluxPipeline"], "text_to_image") + self.assertTrue(flux_text["fieldParams"]["strength"]["hidden"]) + self.assertTrue(flux_text["fieldParams"]["padding_mask_crop"]["hidden"]) + sdxl_edit = image_pipeline_contract(IMAGE_PIPELINE_ADAPTERS["StableDiffusionXLImg2ImgPipeline"], "edit_image") + self.assertFalse(sdxl_edit["fieldParams"]["strength"]["hidden"]) + self.assertTrue(sdxl_edit["fieldParams"]["width"]["hidden"]) + redux_multi = image_pipeline_contract( + IMAGE_PIPELINE_ADAPTERS["FluxReduxPipeline"], "multi_image_reference_edit" + ) + self.assertFalse(redux_multi["fieldParams"]["reference_strength"]["hidden"]) + qwen_edit = image_pipeline_contract( + IMAGE_PIPELINE_ADAPTERS["QwenImageEditPlusPipeline"], "multi_image_reference_edit" + ) + self.assertTrue(qwen_edit["fieldParams"]["strength"]["hidden"]) + self.assertTrue(qwen_edit["fieldParams"]["reference_strength"]["hidden"]) + + def test_image_field_contract_rejects_unknown_or_duplicate_visibility_fields(self): + for fields in (("unknown",), ("strength", "strength")): + with self.subTest(fields=fields), self.assertRaisesRegex(ValueError, "unique reviewed"): + ImageModeFieldContract(fields) + + def test_new_standard_image_adapters_match_pinned_generic_action_signatures(self): + expected = { + "StableDiffusionXLPipeline": ({"text_to_image"}, SDXL_BASE_REPO, {"prompt"}), + "StableDiffusionXLImg2ImgPipeline": ({"edit_image"}, SDXL_BASE_REPO, {"prompt", "image"}), + "StableDiffusionXLInpaintPipeline": ( + {"inpaint", "outpaint"}, + SDXL_BASE_REPO, + {"prompt", "image", "mask_image"}, + ), + "QwenImageImg2ImgPipeline": ({"edit_image"}, QWEN_IMAGE_2512_REPO, {"prompt", "image"}), + "QwenImageInpaintPipeline": ( + {"inpaint", "outpaint"}, + QWEN_IMAGE_2512_REPO, + {"prompt", "image", "mask_image"}, + ), + "QwenImageEditPipeline": ({"edit_image"}, QWEN_IMAGE_EDIT_REPO, {"prompt", "image"}), + "QwenImageEditPlusPipeline": ( + {"edit_image", "multi_image_reference_edit"}, + QWEN_IMAGE_EDIT_PLUS_REPO, + {"prompt", "image"}, + ), + "ZImageImg2ImgPipeline": ({"edit_image"}, Z_IMAGE_REPO, {"prompt", "image"}), + "ZImageInpaintPipeline": ( + {"inpaint", "outpaint"}, + Z_IMAGE_REPO, + {"prompt", "image", "mask_image"}, + ), + "FluxKontextInpaintPipeline": ( + {"inpaint", "outpaint"}, + FLUX_KONTEXT_REPO, + {"prompt", "image", "mask_image"}, + ), + "Flux2KleinInpaintPipeline": ( + {"inpaint", "outpaint"}, + FLUX2_KLEIN_REPO, + {"prompt", "image", "mask_image"}, + ), + } + for pipeline_name, (modes, repository, required_inputs) in expected.items(): + with self.subTest(pipeline=pipeline_name): + adapter = IMAGE_PIPELINE_ADAPTERS[pipeline_name] + parameters = set(inspect.signature(pipeline_class_from_name(pipeline_name).__call__).parameters) + self.assertEqual(adapter.modes, frozenset(modes)) + self.assertEqual(adapter.default_repo, repository) + self.assertTrue(required_inputs.issubset(parameters)) + self.assertIn("num_inference_steps", parameters) + self.assertIn("generator", parameters) + self.assertIn("output_type", parameters) + self.assertIn(adapter.guidance_parameter, parameters) + + for deferred in ( + "Flux2Pipeline", + "Flux2KleinKVPipeline", + "FluxControlImg2ImgPipeline", + "FluxControlInpaintPipeline", + "FluxControlNetPipeline", + "FluxControlNetImg2ImgPipeline", + "FluxControlNetInpaintPipeline", + "QwenImageControlNetPipeline", + "QwenImageControlNetInpaintPipeline", + "QwenImageLayeredPipeline", + "ZImageControlNetPipeline", + "ZImageControlNetInpaintPipeline", + "ZImageOmniPipeline", + "StableDiffusionXLInstructPix2PixPipeline", + ): + with self.subTest(deferred=deferred): + self.assertNotIn(deferred, IMAGE_PIPELINE_ADAPTERS) + + def test_new_standard_image_adapters_execute_only_their_pinned_signature(self): + image = Image.new("RGB", (16, 16), "black") + mask = Image.new("L", (16, 16), "white") + cases = ( + ("StableDiffusionXLPipeline", "text_to_image", Generate, {}), + ("StableDiffusionXLImg2ImgPipeline", "edit_image", Edit, {"image": image}), + ("StableDiffusionXLInpaintPipeline", "inpaint", Inpaint, {"image": image, "mask_image": mask}), + ("QwenImageImg2ImgPipeline", "edit_image", Edit, {"image": image}), + ("QwenImageInpaintPipeline", "inpaint", Inpaint, {"image": image, "mask_image": mask}), + ("QwenImageEditPipeline", "edit_image", Edit, {"image": image}), + ("QwenImageEditPlusPipeline", "edit_image", Edit, {"image": image}), + ("ZImageImg2ImgPipeline", "edit_image", Edit, {"image": image}), + ("ZImageInpaintPipeline", "inpaint", Inpaint, {"image": image, "mask_image": mask}), + ("FluxKontextInpaintPipeline", "inpaint", Inpaint, {"image": image, "mask_image": mask}), + ("Flux2KleinInpaintPipeline", "inpaint", Inpaint, {"image": image, "mask_image": mask}), + ) + aliases = { + "negative_prompt": "negative_prompt", + "width": "width", + "height": "height", + "max_sequence_length": "max_sequence_length", + "strength": "strength", + "padding_mask_crop": "padding_mask_crop", + "reference_strength": "reference_strength", + } + for pipeline_name, mode, action_class, action_inputs in cases: + with self.subTest(pipeline=pipeline_name): + upstream_signature = inspect.signature(pipeline_class_from_name(pipeline_name).__call__) + upstream_parameters = set(upstream_signature.parameters) + received = {} + + def call(_self, **kwargs): + received.update(kwargs) + return SimpleNamespace(images=[Image.new("RGB", (16, 16), "white")]) + + call.__signature__ = upstream_signature + fake_type = type( + pipeline_name, + (), + {"_execution_device": "cpu", "__call__": call}, + ) + pipeline = tag_test_image_pipeline(fake_type(), pipeline_name, mode) + values = { + "pipeline": pipeline, + "prompt": "render the reviewed fixture", + "negative_prompt": "artifact", + "width": 32, + "height": 32, + "num_inference_steps": 2, + "guidance_scale": 4.0, + "strength": 0.75, + "padding_mask_crop": 16, + "max_sequence_length": 128, + "output_type": "pil", + **action_inputs, + } + initial = { + "prompt", + "num_inference_steps", + "generator", + "output_type", + "return_dict", + *action_inputs.keys(), + } + if action_class is Generate: + initial.update({"width", "height"}) + adapter = IMAGE_PIPELINE_ADAPTERS[pipeline_name] + expected_keys = initial | { + destination + for source, destination in aliases.items() + if values.get(source) is not None and destination in upstream_parameters + } + expected_keys.add(adapter.guidance_parameter) + + with patch("modules.DiffusersImage.main.add_progress_callback"): + action_class(f"signature-{pipeline_name}").execute(**values) + + self.assertEqual(set(received), expected_keys) + self.assertEqual(received[adapter.guidance_parameter], 4.0) + if "negative_prompt" in upstream_parameters: + self.assertEqual(received["negative_prompt"], "artifact") + + def test_modern_flux_true_cfg_and_negative_prompt_use_the_reviewed_parameters(self): + class ModernFlux: + def __call__( + self, + *, + negative_prompt=None, + true_cfg_scale=1.0, + guidance_scale=3.5, + ): + return None + + for pipeline_name in ( + "FluxPipeline", + "FluxImg2ImgPipeline", + "FluxInpaintPipeline", + "FluxKontextPipeline", + "FluxKontextInpaintPipeline", + ): + with self.subTest(pipeline=pipeline_name): + target = {} + IMAGE_PIPELINE_ADAPTERS[pipeline_name].apply_generation_parameters( + ModernFlux(), + {"negative_prompt": "artifact", "guidance_scale": 5.0}, + target, + ) + self.assertEqual( + target, + {"negative_prompt": "artifact", "true_cfg_scale": 5.0}, + ) + + def test_flux_controlnet_is_not_advertised_without_component_assembly(self): + self.assertNotIn("FluxControlNetPipeline", IMAGE_PIPELINE_CLASSES) + node = LoadPipeline("removed-controlnet-probe") + with ( + patch("modules.DiffusersImage.main.pipeline_class_from_name") as resolve_pipeline, + self.assertRaisesRegex(ValueError, "requires a separately loaded FluxControlNetModel"), + ): + node( + model_id=FLUX_DEV_REPO, + pipeline_class="FluxControlNetPipeline", + mode="control_image", + ) + resolve_pipeline.assert_not_called() + + def test_pipeline_field_action_publishes_backend_owned_options_defaults_and_signal(self): + self.assertEqual(LoadPipeline.params["model_id"]["onChange"], "update_pipeline_contract") + self.assertEqual( + LoadPipeline.params["pipeline"]["signal"]["value"], + image_pipeline_contract(IMAGE_PIPELINE_ADAPTERS["FluxPipeline"], "text_to_image"), + ) + node = LoadPipeline("image-contract-action") + node.set_field_params = Mock() + node.set_field_value = Mock() + + node.update_pipeline_contract( + { + "pipeline_class": "FluxKontextPipeline", + "mode": "control_image", + "model_id": {"source": "hub", "value": FLUX_SCHNELL_REPO}, + }, + {"key": "pipeline_class"}, + ) + + node.set_field_value.assert_any_call({"mode": "edit_image"}) + node.set_field_value.assert_any_call({"model_id": {"source": "hub", "value": FLUX_KONTEXT_REPO}}) + node.set_field_value.assert_any_call({"revision": catalog_revision(FLUX_KONTEXT_REPO)}) + mode_update = next(call for call in node.set_field_params.call_args_list if call.args[0] == "mode") + self.assertEqual(mode_update.args[1]["options"], ["edit_image", "multi_image_reference_edit"]) + self.assertEqual(mode_update.args[1]["default"], "edit_image") + model_update = next(call for call in node.set_field_params.call_args_list if call.args[0] == "model_id") + self.assertEqual( + model_update.args[1]["fieldOptions"]["filter"]["hub"]["className"], + ["FluxKontextPipeline"], + ) + signal_update = next(call for call in node.set_field_params.call_args_list if call.args[0] == "pipeline") + signal = signal_update.args[1]["signal"] + self.assertEqual(signal["origin"], "pipeline_class") + self.assertEqual( + signal["value"], + image_pipeline_contract(IMAGE_PIPELINE_ADAPTERS["FluxKontextPipeline"], "edit_image"), + ) + + def test_generate_field_action_applies_only_the_exact_selected_image_contract(self): + self.assertEqual( + Generate.params["pipeline"]["onSignal"], + [ + {"action": "value", "target": "image_contract"}, + {"action": "exec", "data": "update_image_contract"}, + ], + ) + node = Edit("image-generate-contract-action") + node.set_field_params = Mock() + selected = image_pipeline_contract(IMAGE_PIPELINE_ADAPTERS["StableDiffusionXLImg2ImgPipeline"], "edit_image") + + node.update_image_contract({"image_contract": selected}, {"key": "pipeline"}) + + updates = {call.args[0]: call.args[1] for call in node.set_field_params.call_args_list} + self.assertEqual(updates["strength"], {"hidden": False}) + self.assertEqual(updates["width"], {"hidden": True}) + self.assertEqual(updates["max_sequence_length"], {"hidden": True}) + + tampered = {**selected, "fieldParams": {**selected["fieldParams"], "strength": {"hidden": True}}} + with self.assertRaisesRegex(ValueError, "stale or mismatched"): + node.update_image_contract({"image_contract": tampered}, {"key": "pipeline"}) + with self.assertRaisesRegex(ValueError, "does not support this generic image action"): + Generate("wrong-image-action").update_image_contract( + {"image_contract": selected}, + {"key": "pipeline"}, + ) + + def test_model_field_action_couples_repository_and_exact_revision_without_stale_pins(self): + node = LoadPipeline("image-model-revision-action") + node.set_field_params = Mock() + node.set_field_value = Mock() + + node.update_pipeline_contract( + { + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "model_id": {"source": "hub", "value": "org/custom-image"}, + "revision": CUSTOM_IMAGE_REVISION, + }, + {"key": "model_id"}, + ) + node.set_field_value.assert_any_call({"revision": ""}) + + node.set_field_value.reset_mock() + node.update_pipeline_contract( + { + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "model_id": {"source": "hub", "value": FLUX_DEV_REPO}, + "revision": CUSTOM_IMAGE_REVISION, + }, + {"key": "model_id"}, + ) + node.set_field_value.assert_any_call({"revision": catalog_revision(FLUX_DEV_REPO)}) + + node.set_field_value.reset_mock() + node.update_pipeline_contract( + { + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "model_id": {"source": "local", "value": "models/custom"}, + "revision": CUSTOM_IMAGE_REVISION, + }, + {"key": "model_id"}, + ) + node.set_field_value.assert_any_call({"revision": ""}) + + node.set_field_value.reset_mock() + node.update_pipeline_contract( + { + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "model_id": {"source": "hub", "value": "org/custom-image"}, + "revision": CUSTOM_IMAGE_REVISION, + }, + {"key": "mode"}, + ) + self.assertNotIn( + {"revision": ""}, + [call.args[0] for call in node.set_field_value.call_args_list], + ) + + def test_pipeline_field_action_rejects_noncanonical_identity_and_source(self): + node = LoadPipeline("invalid-image-contract-action") + invalid = ( + {"pipeline_class": " FluxPipeline ", "mode": "text_to_image", "model_id": FLUX_SCHNELL_REPO}, + {"pipeline_class": "FluxPipeline", "mode": " text_to_image ", "model_id": FLUX_SCHNELL_REPO}, + { + "pipeline_class": "FluxPipeline", + "mode": "text_to_image", + "model_id": {"source": " hub ", "value": FLUX_SCHNELL_REPO}, + }, + ) + for values in invalid: + with self.subTest(values=values), self.assertRaises(ValueError): + node.update_pipeline_contract(values, {"key": "pipeline_class"}) + def test_unsupported_mode_fails_before_pipeline_resolution(self): node = LoadPipeline("mode-probe") with self.assertRaisesRegex(ValueError, "does not support outpaint"): @@ -178,6 +799,15 @@ def from_pretrained(cls, _repo, **_kwargs): offload_mode="none", ) + def test_flux_img2img_exposes_only_single_image_edit(self): + adapter = IMAGE_PIPELINE_ADAPTERS["FluxImg2ImgPipeline"] + self.assertEqual(adapter.mode_options, ("edit_image",)) + self.assertEqual(adapter.max_reference_images, 1) + self.assertNotIn( + "multi_image_reference_edit", + image_pipeline_contract(adapter, "edit_image")["modes"], + ) + def test_flux2_klein_reuses_one_resident_loader_when_only_mode_changes(self): loaded = [] @@ -202,11 +832,466 @@ def from_pretrained(cls, repo, **_kwargs): ): first = node(mode="text_to_image", **common) second = node(mode="edit_image", **common) + self.assertTrue(node._has_changed) + third = node(mode="edit_image", **common) self.assertIs(first["pipeline"], second["pipeline"]) + self.assertIs(second["pipeline"], third["pipeline"]) self.assertEqual(loaded, ["black-forest-labs/FLUX.2-klein-4B"]) self.assertEqual(node.params["mode"], "edit_image") self.assertFalse(node._has_changed) + self.assertEqual(second["pipeline"]._modiff_image_pipeline_class, "Flux2KleinPipeline") + self.assertEqual(second["pipeline"]._modiff_image_mode, "edit_image") + self.assertEqual(second["pipeline"]._modiff_image_repo, FLUX2_KLEIN_REPO) + + def test_mode_retag_invalidates_cached_generate_edit_and_inpaint_outputs(self): + image = Image.new("RGB", (8, 8), "black") + mask = Image.new("L", (8, 8), "white") + cases = ( + ( + Generate, + "Flux2KleinPipeline", + FLUX2_KLEIN_REPO, + "text_to_image", + "edit_image", + {"prompt": "same prompt"}, + "Generate requires one of: text_to_image", + ), + ( + Edit, + "Flux2KleinPipeline", + FLUX2_KLEIN_REPO, + "edit_image", + "text_to_image", + {"prompt": "same prompt", "image": image}, + "Edit requires one of: edit_image, multi_image_reference_edit", + ), + ( + Inpaint, + "FluxFillPipeline", + FLUX_FILL_REPO, + "inpaint", + "outpaint", + { + "prompt": "same prompt", + "image": image, + "mask_image": mask, + "output_type": "pil", + }, + None, + ), + ) + + for action_class, pipeline_class, repository, first_mode, second_mode, values, error in cases: + with self.subTest(action=action_class.__name__, mode=f"{first_mode}->{second_mode}"): + + class FakePipeline: + _execution_device = "cpu" + + def __init__(self): + self.calls = 0 + + def __call__(self, **_kwargs): + self.calls += 1 + return SimpleNamespace(images=[Image.new("RGB", (8, 8), "white")]) + + FakePipeline.__name__ = IMAGE_PIPELINE_ADAPTERS[pipeline_class].allowed_runtime_classes[0] + pipeline = FakePipeline() + loader = LoadPipeline(f"{action_class.__name__}-loader") + loader.execute = Mock(return_value={"pipeline": pipeline, "resolved_artifact": repository}) + task = action_class(f"{action_class.__name__}-task") + server = object.__new__(WebServer) + server.modules = module_registry.MODULE_MAP + server.node_cache = {"loader": loader, "task": task} + server.current_task = None + server.queue_message = lambda *_args, **_kwargs: None + graph_node = { + "module": "modules.DiffusersImage", + "action": action_class.__name__, + "params": { + "pipeline": {"sourceId": "loader", "sourceKey": "pipeline"}, + **{key: {"value": value} for key, value in values.items()}, + }, + } + common = { + "pipeline_class": pipeline_class, + "model_id": {"source": "hub", "value": repository}, + } + + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + loader(mode=first_mode, **common) + server.execute_node("task", graph_node, "test", quiet=True) + loader(mode=second_mode, **common) + + self.assertTrue(loader._has_changed) + self.assertEqual(loader.execute.call_count, 1) + if error is not None: + with self.assertRaisesRegex(ValueError, error): + server.execute_node("task", graph_node, "test", quiet=True) + self.assertEqual(pipeline.calls, 1) + else: + server.execute_node("task", graph_node, "test", quiet=True) + self.assertTrue(task._has_changed) + self.assertEqual(pipeline.calls, 2) + + def test_task_nodes_reject_a_pipeline_loaded_for_another_mode_before_inference(self): + calls = [] + + class TaggedPipeline: + _execution_device = "cpu" + + def __call__(self, **_kwargs): + calls.append(True) + return type("Result", (), {"images": [Image.new("RGB", (8, 8), "white")]})() + + image = Image.new("RGB", (8, 8), "black") + mask = Image.new("L", (8, 8), "white") + cases = ( + ( + Generate("wrong-generate-mode"), + "Flux2KleinPipeline", + "edit_image", + {"prompt": "test"}, + "Generate requires one of: text_to_image", + ), + ( + Edit("wrong-edit-mode"), + "Flux2KleinPipeline", + "text_to_image", + {"image": image, "prompt": "test"}, + "Edit requires one of: edit_image, multi_image_reference_edit", + ), + ( + Inpaint("wrong-inpaint-mode"), + "FluxControlPipeline", + "control_image", + {"image": image, "mask_image": mask, "prompt": "test"}, + "Inpaint requires one of: inpaint, outpaint", + ), + ( + ControlGenerate("wrong-control-mode"), + "FluxFillPipeline", + "inpaint", + {"control_image": image, "prompt": "test"}, + "ControlGenerate requires one of: control_image", + ), + ) + for node, pipeline_class, mode, kwargs, message in cases: + with self.subTest(node=type(node).__name__): + pipeline = tag_test_image_pipeline(TaggedPipeline(), pipeline_class, mode) + with self.assertRaisesRegex(ValueError, message): + node.execute(pipeline=pipeline, **kwargs) + + self.assertEqual(calls, []) + + def test_untagged_multi_action_pipeline_requires_an_exact_loaded_mode(self): + class Flux2KleinPipeline: + def __call__(self, **_kwargs): + self.fail("inference must not run") + + with self.assertRaisesRegex(ValueError, "missing its exact loaded image mode"): + Generate("untagged-flux2-probe").execute( + pipeline=Flux2KleinPipeline(), + prompt="test", + ) + + def test_exact_untagged_single_action_pipeline_has_safe_legacy_recovery(self): + received = [] + + class FluxPipeline: + _execution_device = "cpu" + + def __call__(self, **kwargs): + received.append(kwargs) + return type("Result", (), {"images": [Image.new("RGB", (8, 8), "white")]})() + + result = Generate("legacy-flux-generate-probe").execute( + pipeline=FluxPipeline(), + prompt="test", + width=16, + height=16, + num_inference_steps=1, + ) + + self.assertEqual(result["images"][0].size, (8, 8)) + self.assertEqual(received[0]["prompt"], "test") + + def test_unknown_untagged_pipeline_is_rejected_before_inference(self): + calls = [] + + class UnknownPipeline: + def __call__(self, **_kwargs): + calls.append(True) + + with self.assertRaisesRegex(ValueError, "Cannot recover an exact Diffusers image adapter"): + Generate("unknown-image-pipeline-probe").execute( + pipeline=UnknownPipeline(), + prompt="test", + ) + + self.assertEqual(calls, []) + + def test_tagged_pipeline_requires_complete_runtime_source_repo_and_revision_consistency(self): + class PartialPipeline: + pass + + partial = PartialPipeline() + partial._modiff_image_pipeline_class = "FluxPipeline" + partial._modiff_image_mode = "text_to_image" + with self.assertRaisesRegex(ValueError, "identity is incomplete"): + validate_image_action(partial, "Generate") + + class FluxPipeline: + pass + + runtime_mismatch = FluxPipeline() + qwen = IMAGE_PIPELINE_ADAPTERS["QwenImagePipeline"] + _tag_image_pipeline( + runtime_mismatch, + qwen, + "text_to_image", + qwen.default_repo, + "hub", + catalog_revision(qwen.default_repo), + ) + with self.assertRaisesRegex(ValueError, "runtime class 'FluxPipeline' is tagged as QwenImagePipeline"): + validate_image_action(runtime_mismatch, "Generate") + + class Flux2KleinPipeline: + pass + + incompatible_repo = Flux2KleinPipeline() + flux2 = IMAGE_PIPELINE_ADAPTERS["Flux2KleinPipeline"] + _tag_image_pipeline( + incompatible_repo, + flux2, + "text_to_image", + FLUX_SCHNELL_REPO, + "hub", + catalog_revision(FLUX_SCHNELL_REPO), + ) + with self.assertRaisesRegex(ValueError, "is not compatible with Flux2KleinPipeline"): + validate_image_action(incompatible_repo, "Generate") + + valid = tag_test_image_pipeline(FluxPipeline(), "FluxPipeline", "text_to_image") + self.assertIs(validate_image_action(valid, "Generate"), IMAGE_PIPELINE_ADAPTERS["FluxPipeline"]) + + bundle = FluxReduxPipelineBundle(SimpleNamespace(), SimpleNamespace()) + tag_test_image_pipeline(bundle, "FluxReduxPipeline", "edit_image") + self.assertIs(validate_image_action(bundle, "Edit"), IMAGE_PIPELINE_ADAPTERS["FluxReduxPipeline"]) + + def test_all_image_actions_validate_numeric_and_media_contracts_before_torch(self): + calls = [] + + def pipeline(runtime_name): + pipeline_type = type(runtime_name, (), {"__call__": lambda _self, **_kwargs: calls.append(runtime_name)}) + return pipeline_type() + + image = Image.new("RGB", (16, 16), "black") + mask = Image.new("L", (16, 16), "white") + cases = ( + (Generate(), pipeline("FluxPipeline"), {"width": 15}, "width"), + (Edit(), pipeline("FluxImg2ImgPipeline"), {"image": image, "height": 31}, "height"), + ( + Inpaint(), + pipeline("FluxFillPipeline"), + {"image": image, "mask_image": mask, "num_inference_steps": 0}, + "num_inference_steps", + ), + ( + ControlGenerate(), + pipeline("FluxControlPipeline"), + {"control_image": image, "guidance_scale": float("nan")}, + "guidance_scale", + ), + (Edit(), pipeline("FluxImg2ImgPipeline"), {"image": []}, "empty image list"), + ( + Inpaint(), + pipeline("FluxFillPipeline"), + {"image": "not-an-image", "mask_image": "not-a-mask"}, + "must be a PIL image", + ), + ( + ControlGenerate(), + pipeline("FluxControlPipeline"), + {"control_image": "not-an-image"}, + "PIL image, NumPy array, or Torch tensor", + ), + ) + for node, selected_pipeline, values, message in cases: + with self.subTest(node=type(node).__name__, message=message): + with ( + patch.dict(sys.modules, {"torch": None}), + self.assertRaisesRegex(ValueError, message) as raised, + ): + node.execute(pipeline=selected_pipeline, **values) + self.assertNotIn("torch halted", str(raised.exception)) + self.assertEqual(calls, []) + + def test_generate_preflight_rejects_every_declared_bound_and_nonfinite_value(self): + class FluxPipeline: + def __call__(self, **_kwargs): + raise AssertionError("inference must not run") + + invalid = ( + ("width", 2064), + ("height", 17), + ("seed", 4294967296), + ("num_inference_steps", 101), + ("guidance_scale", float("inf")), + ("strength", -0.01), + ("padding_mask_crop", 7), + ("max_sequence_length", 513), + ("output_type", "latent"), + ) + for field, value in invalid: + with self.subTest(field=field, value=value), patch.dict(sys.modules, {"torch": None}): + with self.assertRaises(ValueError) as raised: + Generate().execute(pipeline=FluxPipeline(), **{field: value}) + self.assertNotIn("torch halted", str(raised.exception)) + + def test_action_preflight_runs_through_real_nodebase_before_torch_or_upstream(self): + calls = [] + + class FluxPipeline: + def __call__(self, **_kwargs): + calls.append(True) + + node = Generate("real-nodebase-image-preflight") + with patch.dict(sys.modules, {"torch": None}), self.assertRaisesRegex(ValueError, "width") as raised: + node(pipeline=FluxPipeline(), width=15) + + self.assertNotIn("torch halted", str(raised.exception)) + self.assertEqual(calls, []) + + def test_facade_rejects_raw_nodebase_coercion_inputs_before_torch(self): + class FluxPipeline: + def __call__(self, **_kwargs): + raise AssertionError("inference must not run") + + image = Image.new("RGB", (16, 16), "black") + mask = Image.new("L", (16, 16), "white") + cases = ( + (Generate("raw-generate-bool"), FluxPipeline(), {"width": True}), + (Generate("raw-generate-blank"), FluxPipeline(), {"num_inference_steps": ""}), + (Edit("raw-edit-container"), type("FluxImg2ImgPipeline", (), {})(), {"image": image, "height": []}), + ( + Inpaint("raw-inpaint-bool"), + type("FluxFillPipeline", (), {})(), + {"image": image, "mask_image": mask, "padding_mask_crop": False}, + ), + ( + ControlGenerate("raw-control-container"), + type("FluxControlPipeline", (), {})(), + {"control_image": image, "guidance_scale": {}}, + ), + ) + for node, pipeline, values in cases: + with self.subTest(node=node.node_id), patch.dict(sys.modules, {"torch": None}): + with self.assertRaises(ValueError): + node(pipeline=pipeline, **values) + + def test_media_shape_duck_types_are_not_accepted_as_images(self): + class FluxImg2ImgPipeline: + pass + + with ( + patch.dict(sys.modules, {"torch": None}), + self.assertRaisesRegex(ValueError, "PIL image, NumPy array, or Torch tensor"), + ): + Edit().execute( + pipeline=FluxImg2ImgPipeline(), + image=SimpleNamespace(shape=(16, 16, 3)), + ) + + def test_reference_count_and_pixel_limits_fail_before_stitching_or_torch(self): + class FluxImg2ImgPipeline: + def __call__(self, **_kwargs): + raise AssertionError("inference must not run") + + oversized_shape = np.broadcast_to(np.zeros((1, 1, 3), dtype=np.uint8), (8192, 8192, 3)) + with patch.dict(sys.modules, {"torch": None}), self.assertRaisesRegex(ValueError, "cumulative input limit"): + Edit().execute(pipeline=FluxImg2ImgPipeline(), image=oversized_shape) + + references = [Image.new("RGB", (1, 1), "black") for _ in range(2)] + with patch.dict(sys.modules, {"torch": None}), self.assertRaisesRegex(ValueError, "at most 1"): + Edit().execute(pipeline=FluxImg2ImgPipeline(), image=references) + + class Flux2KleinPipeline: + pass + + multi_mode = tag_test_image_pipeline(Flux2KleinPipeline(), "Flux2KleinPipeline", "multi_image_reference_edit") + with patch.dict(sys.modules, {"torch": None}), self.assertRaisesRegex(ValueError, "at most 8"): + Edit().execute( + pipeline=multi_mode, + image=[Image.new("RGB", (1, 1), "black") for _ in range(9)], + ) + + single_mode = tag_test_image_pipeline(Flux2KleinPipeline(), "Flux2KleinPipeline", "edit_image") + with patch.dict(sys.modules, {"torch": None}), self.assertRaisesRegex(ValueError, "at most 1"): + Edit().execute( + pipeline=single_mode, + image=[Image.new("RGB", (1, 1)), Image.new("RGB", (1, 1))], + ) + + def test_generate_preflight_preserves_valid_zero_and_boundary_values(self): + received = {} + + class FluxPipeline: + _execution_device = "cpu" + + def __call__( + self, + *, + prompt, + width, + height, + num_inference_steps, + generator, + output_type, + return_dict, + true_cfg_scale, + strength, + max_sequence_length, + ): + received.update( + width=width, + height=height, + num_inference_steps=num_inference_steps, + output_type=output_type, + true_cfg_scale=true_cfg_scale, + strength=strength, + max_sequence_length=max_sequence_length, + seed=generator.initial_seed(), + ) + return SimpleNamespace(images=[Image.new("RGB", (16, 16), "white")]) + + Generate().execute( + pipeline=FluxPipeline(), + width=16, + height=2048, + seed=4294967295, + num_inference_steps=1, + guidance_scale=0, + strength=0, + padding_mask_crop=0, + max_sequence_length=512, + output_type="pil", + ) + + self.assertEqual( + received, + { + "width": 16, + "height": 2048, + "num_inference_steps": 1, + "output_type": "pil", + "true_cfg_scale": 0.0, + "strength": 0.0, + "max_sequence_length": 512, + "seed": 4294967295, + }, + ) def test_cross_workflow_loader_reuse_removes_residual_lora(self): unloads = [] @@ -240,6 +1325,7 @@ def from_pretrained(cls, repo, **kwargs): model_id=repo, pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, auto_offload=False, offload_mode="none", ) @@ -295,6 +1381,7 @@ def capture_offload(_pipeline, **kwargs): model_id="org/runtime-recipe-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, execution_recipe=recipe, ) recipe["quantization_config"] = None @@ -302,6 +1389,7 @@ def capture_offload(_pipeline, **kwargs): model_id="org/runtime-recipe-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, execution_recipe=recipe, ) @@ -346,6 +1434,7 @@ def from_pretrained(cls, _repo, **_kwargs): model_id="org/direct-image-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, execution_recipe=recipe, enable_vae_slicing=False, enable_vae_tiling=False, @@ -385,6 +1474,7 @@ def from_pretrained(cls, _repo, **_kwargs): model_id="org/direct-image-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, auto_offload=False, offload_mode="none", enable_vae_slicing=False, @@ -418,6 +1508,7 @@ def from_pretrained(cls, repo, **kwargs): model_id="org/native-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, device="cuda:0", device_map="cuda", auto_offload=False, @@ -448,6 +1539,7 @@ def from_pretrained(cls, _repo, **kwargs): model_id="org/native-model", pipeline_class="FluxPipeline", mode="text_to_image", + revision=CUSTOM_IMAGE_REVISION, execution_recipe={"device_map": "none", "offload_mode": "none", "device": "cuda:0"}, device_map="cuda", ) @@ -490,7 +1582,6 @@ def register_modules(self, **kwargs): model_id="black-forest-labs/FLUX.1-Redux-dev", pipeline_class="FluxReduxPipeline", mode="edit_image", - revision="redux-commit", auto_offload=False, offload_mode="none", ) @@ -499,7 +1590,7 @@ def register_modules(self, **kwargs): self.assertEqual(loaded[0][0:2], ("base", FLUX_DEV_REPO)) self.assertEqual(loaded[0][2]["revision"], "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21") self.assertEqual(loaded[1][0:2], ("prior", "black-forest-labs/FLUX.1-Redux-dev")) - self.assertEqual(loaded[1][2]["revision"], "redux-commit") + self.assertEqual(loaded[1][2]["revision"], catalog_revision("black-forest-labs/FLUX.1-Redux-dev")) self.assertEqual(loaded[1][2]["text_encoder"], "clip") self.assertEqual(loaded[1][2]["text_encoder_2"], "t5") self.assertEqual(loaded[1][2]["tokenizer"], "clip-tokenizer") @@ -509,7 +1600,7 @@ def register_modules(self, **kwargs): self.assertIsNone(result["pipeline"].base.text_encoder) self.assertIsNone(result["pipeline"].base.text_encoder_2) - def test_curated_image_loader_uses_catalog_pin_but_preserves_explicit_revision(self): + def test_curated_image_loader_uses_only_its_catalog_pin(self): loaded = [] class FakePipeline: @@ -532,17 +1623,18 @@ def from_pretrained(cls, repo, **kwargs): auto_offload=False, offload_mode="none", ) - node.execute( - model_id="black-forest-labs/FLUX.1-schnell", - pipeline_class="FluxPipeline", - mode="text_to_image", - revision="reviewed-user-revision", - auto_offload=False, - offload_mode="none", - ) + with self.assertRaisesRegex(ValueError, "must use its reviewed commit"): + node.execute( + model_id="black-forest-labs/FLUX.1-schnell", + pipeline_class="FluxPipeline", + mode="text_to_image", + revision="main", + auto_offload=False, + offload_mode="none", + ) self.assertEqual(loaded[0][1]["revision"], "741f7c3ce8b383c54771c7003378a50191e9efe9") - self.assertEqual(loaded[1][1]["revision"], "reviewed-user-revision") + self.assertEqual(len(loaded), 1) def test_flux_redux_bundle_delegates_multiple_reference_fusion_to_diffusers(self): import torch @@ -628,9 +1720,8 @@ def __call__(self, **kwargs): def test_flux_kontext_stitches_multiple_references_through_generic_edit(self): received = {} - class FakeKontextPipeline: + class FluxKontextPipeline: _execution_device = "cpu" - _modiff_image_adapter = IMAGE_PIPELINE_ADAPTERS["FluxKontextPipeline"] def __call__(self, **kwargs): received.update(kwargs) @@ -638,7 +1729,7 @@ def __call__(self, **kwargs): references = [Image.new("RGB", (16, 16), "red"), Image.new("RGB", (8, 16), "blue")] result = Edit("kontext-multi-probe").execute( - pipeline=FakeKontextPipeline(), + pipeline=FluxKontextPipeline(), image=references, prompt="use first for identity and second for style", width=32, @@ -660,6 +1751,8 @@ class FakeResult: class FakeQwenPipeline: _execution_device = "cpu" + _modiff_image_pipeline_class = "QwenImageEditInpaintPipeline" + _modiff_image_mode = "inpaint" def __call__( self, @@ -684,7 +1777,7 @@ def __call__( node = Inpaint("qwen-generic-probe") result = node.execute( - pipeline=FakeQwenPipeline(), + pipeline=tag_test_image_pipeline(FakeQwenPipeline(), "QwenImageEditInpaintPipeline", "inpaint"), image=Image.new("RGB", (16, 16), "black"), mask_image=Image.new("L", (16, 16), "white"), prompt="replace the object", @@ -747,6 +1840,8 @@ class FakeResult: class FakePipeline: _execution_device = "cpu" + _modiff_image_pipeline_class = "FluxFillPipeline" + _modiff_image_mode = "inpaint" def __call__(self, **_kwargs): return FakeResult() @@ -758,7 +1853,7 @@ def __call__(self, **_kwargs): mask.putpixel((x, y), 255) result = Inpaint("mask-contract-probe").execute( - pipeline=FakePipeline(), + pipeline=tag_test_image_pipeline(FakePipeline(), "FluxFillPipeline", "inpaint"), image=source, mask_image=mask, prompt="replace", @@ -803,13 +1898,15 @@ class FakeResult: class FakePipeline: _execution_device = "cpu" + _modiff_image_pipeline_class = "FluxFillPipeline" + _modiff_image_mode = "inpaint" def __call__(self, **_kwargs): observed.append(node._active_pipeline is self) return FakeResult() node.execute( - pipeline=FakePipeline(), + pipeline=tag_test_image_pipeline(FakePipeline(), "FluxFillPipeline", "inpaint"), image=Image.new("RGB", (16, 16), "black"), mask_image=Image.new("L", (16, 16), "white"), prompt="replace", @@ -821,36 +1918,344 @@ def __call__(self, **_kwargs): def test_adapter_uses_only_the_app_managed_cached_weight(self): calls = [] - cached_weight = Path("/cache/revision/adapter.safetensors") class FakePipeline: def load_lora_weights(self, path, **kwargs): calls.append((path, kwargs)) - with patch("utils.huggingface.cached_file_path", return_value=str(cached_weight)): - LoadAdapter("adapter-probe").execute( - pipeline=FakePipeline(), - adapter_path={"source": "hub", "value": "unit/adapter"}, - weight_name="adapter.safetensors", - adapter_name="gallery", - scale=0.8, - ) + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) / "hub" + cache_root.mkdir() + cached_weight = cache_root / "adapter.safetensors" + cached_weight.write_bytes(b"cached adapter") + expected = hashlib.sha256(cached_weight.read_bytes()).hexdigest() + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + return_value=str(cached_weight), + ) as cached, + ): + LoadAdapter("adapter-probe").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256=expected, + adapter_name="gallery", + scale=0.8, + ) + cached.assert_called_once_with("unit/adapter", "adapter.safetensors", revision=HUB_ADAPTER_REVISION) self.assertEqual(calls[0][0], str(cached_weight.parent)) self.assertEqual(calls[0][1]["weight_name"], "adapter.safetensors") + self.assertTrue(calls[0][1]["use_safetensors"]) def test_adapter_missing_from_app_cache_fails_before_pipeline_load(self): class FakePipeline: def load_lora_weights(self, *_args, **_kwargs): raise AssertionError("must not download or load") - with patch("utils.huggingface.cached_file_path", return_value=False): + with patch("modules.DiffusersImage.main.cached_file_path", return_value=False): with self.assertRaisesRegex(FileNotFoundError, "Model Manager"): LoadAdapter("missing-adapter-probe").execute( pipeline=FakePipeline(), adapter_path={"source": "hub", "value": "unit/adapter"}, weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + + def test_adapter_rejects_nonliteral_safetensors_suffixes_before_cache_or_pipeline_mutation(self): + class FakePipeline: + def load_lora_weights(self, *_args, **_kwargs): + raise AssertionError("adapter loading must not run") + + with patch("modules.DiffusersImage.main.cached_file_path") as cached: + for weight_name in ("adapter.bin", "adapter.SAFETENSORS", "adapter.SafeTensors"): + with ( + self.subTest(weight_name=weight_name), + self.assertRaisesRegex(ValueError, "contained \\.safetensors"), + ): + LoadAdapter("unsafe-hub-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name=weight_name, + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + cached.assert_not_called() + + def test_adapter_source_variants_cannot_bypass_the_app_cache_boundary(self): + class FakePipeline: + def __init__(self): + self.calls = [] + + def load_lora_weights(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + for source in ("Hub", "HUB"): + with self.subTest(canonical_source=source): + pipeline = FakePipeline() + with ( + patch("modules.DiffusersImage.main.cached_file_path", return_value=False) as cached, + self.assertRaisesRegex(FileNotFoundError, "Model Manager"), + ): + LoadAdapter("canonical-adapter-source").execute( + pipeline=pipeline, + adapter_path={"source": source, "value": "unit/adapter"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + cached.assert_called_once_with("unit/adapter", "adapter.safetensors", revision=HUB_ADAPTER_REVISION) + self.assertEqual(pipeline.calls, []) + + invalid = (" hub ", "remote", None, 7) + for source in invalid: + with self.subTest(invalid_source=source): + pipeline = FakePipeline() + with self.assertRaisesRegex(ValueError, "source must be exactly hub or local"): + LoadAdapter("invalid-adapter-source").execute( + pipeline=pipeline, + adapter_path={"source": source, "value": "unit/adapter"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + self.assertEqual(pipeline.calls, []) + + def test_adapter_truthy_missing_cache_entry_fails_before_pipeline_mutation(self): + events = [] + + class FakePipeline: + def unload_lora_weights(self): + events.append("unload") + + def load_lora_weights(self, *_args, **_kwargs): + events.append("load") + + with tempfile.TemporaryDirectory() as directory: + missing = Path(directory) / "missing.safetensors" + with ( + patch("modules.DiffusersImage.main.cached_file_path", return_value=str(missing)), + self.assertRaisesRegex(FileNotFoundError, "cache entry does not exist"), + ): + LoadAdapter("truthy-missing-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name=missing.name, + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + self.assertEqual(events, []) + + def test_managed_cache_resolution_accepts_contained_files_and_rejects_escapes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cache_root = root / "hub" + cache_root.mkdir() + contained = cache_root / "blob.safetensors" + contained.write_bytes(b"contained") + outside = root / "outside.safetensors" + outside.write_bytes(b"outside") + with patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}): + self.assertEqual(resolve_managed_hf_cache_file(contained), contained.resolve()) + with self.assertRaisesRegex(ValueError, "outside the managed cache root"): + resolve_managed_hf_cache_file(outside) + + def test_managed_cache_resolution_preserves_snapshot_to_blob_symlinks(self): + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) / "hub" + repository_root = cache_root / "models--unit--adapter" + blob = repository_root / "blobs" / "abc" + blob.parent.mkdir(parents=True) + blob.write_bytes(b"blob") + snapshot_file = repository_root / "snapshots" / HUB_ADAPTER_REVISION / "adapter.safetensors" + snapshot_file.parent.mkdir(parents=True) + try: + os.symlink(blob, snapshot_file) + except OSError as error: + self.skipTest(f"Symlinks are unavailable on this Windows runtime: {error}") + with patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}): + self.assertEqual(resolve_managed_hf_cache_file(snapshot_file), blob.resolve()) + + def test_hub_adapter_load_preserves_safe_snapshot_alias_for_extensionless_blob(self): + calls = [] + + class FakePipeline: + def load_lora_weights(self, path, **kwargs): + calls.append((path, kwargs)) + + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) / "hub" + repository_root = cache_root / "models--unit--adapter" + blob = repository_root / "blobs" / "abc" + blob.parent.mkdir(parents=True) + blob.write_bytes(b"extensionless safetensors blob probe") + snapshot_file = repository_root / "snapshots" / HUB_ADAPTER_REVISION / "adapter.safetensors" + snapshot_file.parent.mkdir(parents=True) + try: + os.symlink(blob, snapshot_file) + except OSError as error: + self.skipTest(f"Symlinks are unavailable on this Windows runtime: {error}") + + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch("modules.DiffusersImage.main.cached_file_path", return_value=str(snapshot_file)), + ): + LoadAdapter("snapshot-alias-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256=hashlib.sha256(blob.read_bytes()).hexdigest(), + ) + + self.assertEqual(calls[0][0], str(snapshot_file.parent)) + self.assertEqual(calls[0][1]["weight_name"], "adapter.safetensors") + self.assertTrue(calls[0][1]["use_safetensors"]) + + def test_hub_adapter_cache_escape_fails_before_pipeline_mutation(self): + events = [] + + class FakePipeline: + def unload_lora_weights(self): + events.append("unload") + + def load_lora_weights(self, *_args, **_kwargs): + events.append("load") + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cache_root = root / "hub" + cache_root.mkdir() + outside = root / "outside.safetensors" + outside.write_bytes(b"outside") + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + return_value=str(outside), + ), + self.assertRaisesRegex(FileNotFoundError, "cache entry does not exist"), + ): + LoadAdapter("escaped-hub-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name=outside.name, + revision=HUB_ADAPTER_REVISION, + expected_sha256=hashlib.sha256(outside.read_bytes()).hexdigest(), ) + self.assertEqual(events, []) + + def test_hub_label_cannot_turn_an_existing_local_path_into_a_repository(self): + class FakePipeline: + def load_lora_weights(self, *_args, **_kwargs): + raise AssertionError("adapter loading must not run") + + with self.assertRaisesRegex(ValueError, "existing local filesystem target"): + LoadAdapter("hub-local-confusion").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "modules/DiffusersImage"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256="0" * 64, + ) + + def test_adapter_facade_rejects_raw_nodebase_coercion_before_cache_or_mutation(self): + class FakePipeline: + def load_lora_weights(self, *_args, **_kwargs): + raise AssertionError("adapter loading must not run") + + base = { + "pipeline": FakePipeline(), + "adapter_path": {"source": "hub", "value": "unit/adapter"}, + "weight_name": "adapter.safetensors", + "revision": HUB_ADAPTER_REVISION, + "expected_sha256": "0" * 64, + } + invalid = ( + {"scale": False}, + {"scale": []}, + {"replace_existing": "false"}, + {"revision": False}, + {"expected_sha256": []}, + {"weight_name": 0}, + {"adapter_path": False}, + ) + with patch("modules.DiffusersImage.main.cached_file_path") as cached: + for index, override in enumerate(invalid): + with self.subTest(override=override), self.assertRaises(ValueError): + LoadAdapter(f"raw-adapter-{index}")(**{**base, **override}) + cached.assert_not_called() + + def test_local_adapter_requires_an_existing_file_or_contained_directory_weight(self): + calls = [] + + class FakePipeline: + def load_lora_weights(self, path, **kwargs): + calls.append((path, kwargs)) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + direct = root / "direct.safetensors" + direct.write_bytes(b"direct") + nested = root / "weights" + nested.mkdir() + nested_weight = nested / "nested.safetensors" + nested_weight.write_bytes(b"nested") + selected = root / "selected" + selected.mkdir() + (root / "outside.safetensors").write_bytes(b"outside") + + LoadAdapter("local-file-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "LOCAL", "value": str(direct)}, + adapter_name="direct", + ) + LoadAdapter("local-folder-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "local", "value": str(root)}, + weight_name="weights/nested.safetensors", + adapter_name="nested", + ) + self.assertEqual(calls[0][0], str(root.resolve())) + self.assertEqual(calls[0][1]["weight_name"], direct.name) + self.assertTrue(calls[0][1]["use_safetensors"]) + self.assertEqual(calls[1][0], str(nested.resolve())) + self.assertEqual(calls[1][1]["weight_name"], nested_weight.name) + self.assertTrue(calls[1][1]["use_safetensors"]) + + before = len(calls) + for value, weight_name in ( + (str(root / "missing.safetensors"), ""), + ("unit/adapter", "adapter.safetensors"), + (str(selected), "../outside.safetensors"), + ): + with ( + self.subTest(value=value, weight_name=weight_name), + self.assertRaises((FileNotFoundError, ValueError)), + ): + LoadAdapter("invalid-local-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "local", "value": value}, + weight_name=weight_name, + ) + self.assertEqual(len(calls), before) + + for unsafe_name in ("unsafe.bin", "unsafe.SAFETENSORS", "unsafe.SafeTensors"): + unsafe = root / unsafe_name + unsafe.write_bytes(b"unsafe format probe") + with ( + self.subTest(unsafe_name=unsafe_name), + self.assertRaisesRegex(ValueError, "lowercase \\.safetensors"), + ): + LoadAdapter("unsafe-local-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "local", "value": str(unsafe)}, + ) + self.assertEqual(len(calls), before) def test_adapter_verifies_pinned_hash_and_replaces_previous_pipeline_adapters(self): events = [] @@ -866,14 +2271,23 @@ def set_adapters(self, names, weights): events.append(("activate", names, weights)) with tempfile.TemporaryDirectory() as directory: - adapter_file = Path(directory) / "adapter.safetensors" + cache_root = Path(directory) / "hub" + cache_root.mkdir() + adapter_file = cache_root / "adapter.safetensors" adapter_file.write_bytes(b"pinned adapter bytes") expected = hashlib.sha256(adapter_file.read_bytes()).hexdigest() - with patch("utils.huggingface.cached_file_path", return_value=str(adapter_file)): + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + return_value=str(adapter_file), + ), + ): LoadAdapter("verified-adapter-probe").execute( pipeline=FakePipeline(), adapter_path={"source": "hub", "value": "unit/adapter"}, weight_name=adapter_file.name, + revision=HUB_ADAPTER_REVISION, expected_sha256=expected, adapter_name="theme", scale=0.75, @@ -898,18 +2312,25 @@ def set_adapters(self, names, weights): pipeline = FakePipeline() with tempfile.TemporaryDirectory() as directory: - first = Path(directory) / "first.safetensors" - second = Path(directory) / "second.safetensors" + cache_root = Path(directory) / "hub" + cache_root.mkdir() + first = cache_root / "first.safetensors" + second = cache_root / "second.safetensors" first.write_bytes(b"first") second.write_bytes(b"second") - with patch( - "utils.huggingface.cached_file_path", - side_effect=[str(first), str(second)], + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + side_effect=[str(first), str(second)], + ), ): LoadAdapter("first-adapter").execute( pipeline=pipeline, adapter_path={"source": "hub", "value": "unit/first"}, weight_name=first.name, + revision=HUB_ADAPTER_REVISION, + expected_sha256=hashlib.sha256(first.read_bytes()).hexdigest(), adapter_name="cinematic", scale=0.8, replace_existing=True, @@ -918,6 +2339,8 @@ def set_adapters(self, names, weights): pipeline=pipeline, adapter_path={"source": "hub", "value": "unit/second"}, weight_name=second.name, + revision=HUB_ADAPTER_REVISION, + expected_sha256=hashlib.sha256(second.read_bytes()).hexdigest(), adapter_name="render_3d", scale=0.18, replace_existing=False, @@ -937,15 +2360,28 @@ def load_lora_weights(self, *_args, **_kwargs): def set_adapters(self, names, weights): activations.append((names, weights)) - with patch("utils.huggingface.cached_file_path", return_value="/cache/adapter.safetensors"): - LoadAdapter("zero-scale-adapter").execute( - pipeline=FakePipeline(), - adapter_path={"source": "hub", "value": "unit/adapter"}, - weight_name="adapter.safetensors", - adapter_name="optional", - scale=0, - replace_existing=False, - ) + with tempfile.TemporaryDirectory() as directory: + cache_root = Path(directory) / "hub" + cache_root.mkdir() + cached_weight = cache_root / "adapter.safetensors" + cached_weight.write_bytes(b"cached adapter") + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + return_value=str(cached_weight), + ), + ): + LoadAdapter("zero-scale-adapter").execute( + pipeline=FakePipeline(), + adapter_path={"source": "hub", "value": "unit/adapter"}, + weight_name="adapter.safetensors", + revision=HUB_ADAPTER_REVISION, + expected_sha256=hashlib.sha256(cached_weight.read_bytes()).hexdigest(), + adapter_name="optional", + scale=0, + replace_existing=False, + ) self.assertEqual(activations, [(["optional"], [0.0])]) @@ -958,14 +2394,23 @@ def load_lora_weights(self, *_args, **_kwargs): raise AssertionError("hash validation must happen before adapter loading") with tempfile.TemporaryDirectory() as directory: - adapter_file = Path(directory) / "adapter.safetensors" + cache_root = Path(directory) / "hub" + cache_root.mkdir() + adapter_file = cache_root / "adapter.safetensors" adapter_file.write_bytes(b"unexpected bytes") - with patch("utils.huggingface.cached_file_path", return_value=str(adapter_file)): + with ( + patch.dict("utils.huggingface.CONFIG.hf", {"cache_dir": str(cache_root)}), + patch( + "modules.DiffusersImage.main.cached_file_path", + return_value=str(adapter_file), + ), + ): with self.assertRaisesRegex(ValueError, "pinned SHA-256"): LoadAdapter("invalid-adapter-probe").execute( pipeline=FakePipeline(), adapter_path={"source": "hub", "value": "unit/adapter"}, weight_name=adapter_file.name, + revision=HUB_ADAPTER_REVISION, expected_sha256="0" * 64, ) diff --git a/tests/test_diffusers_offload.py b/tests/test_diffusers_offload.py index 2c50835..b192d17 100644 --- a/tests/test_diffusers_offload.py +++ b/tests/test_diffusers_offload.py @@ -23,7 +23,15 @@ reset_pipeline_device_map_for_runtime, supports_accelerator_cpu_offload, ) -from modiff.diffusers_profiles import QWEN_IMAGE_2512_PREQUANTIZED_REPO, public_execution_profiles +from modiff.diffusers_profiles import ( + QWEN_IMAGE_2512_PREQUANTIZED_REPO, + execution_profiles_for_execution, + optional_runtime_profile_ids_for_execution, + public_execution_profiles, +) +from modiff.model_artifact_catalog import catalog_revision +from modiff.optional_runtime_execution import optional_runtime_requirement_for_execution +from modiff.studio_execution_specs import studio_execution_spec_for_pair, studio_model_dependencies_for_pair from modules.ModularDiffusers.denoise import embeddings_are_missing, embeddings_missing_error from modules.ModularDiffusers.embeddings import extract_prompt_embeddings from modules.ModularDiffusers.loaders import normalize_quant_config_input @@ -41,6 +49,46 @@ from modules.DiffusersImage.main import build_qwen_pipeline_quantization_config, coerce_pipeline_quantization_config +def resource_plan_target(model_type, mode): + profiles = execution_profiles_for_execution(model_type, mode) + if len(profiles) != 1: + raise AssertionError(f"Expected one execution profile for {model_type}:{mode}, got {len(profiles)}") + profile = profiles[0] + return { + "autoResourceSchemaVersion": 2, + "executionProfileId": profile.id, + "modelType": model_type, + "mode": mode, + "loaderModule": profile.loader_module, + "loaderAction": profile.loader_action, + "executionPath": profile.execution_path, + "pipelineClass": profile.pipeline_class, + } + + +def auto_resource_plan_target(model_type, mode): + target = { + **resource_plan_target(model_type, mode), + "modelDependencies": studio_model_dependencies_for_pair(model_type, mode), + "optionalRuntimeProfileIds": list( + optional_runtime_profile_ids_for_execution(model_type, mode) + ), + "optionalRuntimeRequirement": optional_runtime_requirement_for_execution( + model_type, + mode, + ), + } + specification = studio_execution_spec_for_pair(model_type, mode) + if specification is not None: + target["studioExecutionSpecContract"] = { + "schemaVersion": specification["schemaVersion"], + "id": specification["id"], + "contentHash": specification["contentHash"], + "executionProfileId": specification["executionProfileId"], + } + return target + + class FakePipelineState: def __init__(self, values=None, kwargs_values=None, raise_kwargs=False): self.values = values or {} @@ -146,13 +194,24 @@ def test_generic_component_filter_does_not_assign_an_uninstalled_repository(self model_call = next(call for call in set_field_params.call_args_list if call.args[0] == "model_id") params = model_call.args[1] - self.assertEqual( - params["fieldOptions"]["filter"]["hub"]["className"], - ["ControlNetModel", "QwenImageControlNetModel", "FluxControlNetModel"], - ) + filters = params["fieldOptions"]["filter"] + class_names = filters["hub"]["className"] + self.assertEqual(class_names, sorted(class_names)) + self.assertEqual(filters["local"]["className"], class_names) + self.assertIn("ControlNetModel", class_names) + self.assertIn("QwenImageControlNetModel", class_names) + self.assertIn("FluxControlNetModel", class_names) + self.assertIn("ZImageControlNetModel", class_names) + self.assertNotIn("FluxPipeline", class_names) + self.assertNotIn("AutoencoderKL", class_names) self.assertNotIn("value", params) self.assertNotIn("default", params) + self.assertIn( + "every Hub component", + AutoModelLoader.params["revision"]["description"], + ) + def test_generic_pipeline_filter_preserves_selection_until_user_chooses_an_installed_repository(self): node = ModelsLoader("generic-pipeline-filter") node.model_types_loaded = True @@ -312,13 +371,17 @@ def remove_from_collection(self, *_args, **_kwargs): with ( patch("modules.ModularDiffusers.loaders.components", manager), patch( - "modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained", + "modules.ModularDiffusers.loaders._validate_reviewed_pipeline_index", + return_value=("model_index.json", {"_class_name": "QwenImagePipeline"}), + ), + patch( + "modules.ModularDiffusers.loaders._instantiate_reviewed_builtin_pipeline", side_effect=StopAtModelLoad("model load reached"), ), ): with self.assertRaisesRegex(StopAtModelLoad, "model load reached"): node.execute( - model_type="QwenImagePipeline", + model_type="QwenImageModularPipeline", repo_id={"source": "hub", "value": "Qwen/Qwen-Image-2512"}, device="cpu:0", dtype=torch.float32, @@ -906,6 +969,7 @@ def test_native_auto_plan_applies_direct_cuda_device_map_to_image_loader(self): "action": "LoadPipeline", "params": { "model_id": {"value": {"source": "hub", "value": "old-model"}}, + "revision": {"value": "a" * 40}, "pipeline_class": {"value": "QwenImagePipeline"}, "device_map": {"value": "none"}, "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, @@ -920,8 +984,7 @@ def test_native_auto_plan_applies_direct_cuda_device_map_to_image_loader(self): server, graph, { - "executionPath": "direct-diffusers-image", - "pipelineClass": "QwenImagePipeline", + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), "modelRepo": "Qwen/Qwen-Image-2512", "offloadMode": OFFLOAD_MODE_NONE, "deviceMap": "cuda", @@ -933,6 +996,8 @@ def test_native_auto_plan_applies_direct_cuda_device_map_to_image_loader(self): self.assertEqual(params["device_map"]["value"], "cuda") self.assertEqual(params["offload_mode"]["value"], OFFLOAD_MODE_NONE) self.assertFalse(params["auto_offload"]["value"]) + self.assertEqual(params["model_id"]["value"]["value"], "Qwen/Qwen-Image-2512") + self.assertEqual(params["revision"]["value"], catalog_revision("Qwen/Qwen-Image-2512")) self.assertEqual(graph["nodes"]["recipe"]["params"]["device_map"]["value"], "cuda") self.assertEqual(graph["nodes"]["recipe"]["params"]["offload_mode"]["value"], OFFLOAD_MODE_NONE) @@ -955,6 +1020,7 @@ def test_structured_audio_plan_does_not_rewrite_independent_video_loader(self): "action": "LoadPipeline", "params": { "model_id": {"value": {"source": "hub", "value": "old-audio"}}, + "revision": {"value": "a" * 40}, "pipeline_class": {"value": "AceStepPipeline"}, "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, "auto_offload": {"value": True}, @@ -986,8 +1052,8 @@ def test_structured_audio_plan_does_not_rewrite_independent_video_loader(self): server, graph, { + **resource_plan_target("AceStepAudioPipeline", "text_to_audio"), "modelRepo": "ACE-Step/acestep-v15-xl-turbo-diffusers", - "pipelineClass": "AceStepPipeline", "offloadMode": OFFLOAD_MODE_NONE, "deviceMap": "cuda", "generation": {"audioDuration": 24, "steps": 8}, @@ -1001,6 +1067,10 @@ def test_structured_audio_plan_does_not_rewrite_independent_video_loader(self): graph["nodes"]["audio-loader"]["params"]["model_id"]["value"]["value"], "ACE-Step/acestep-v15-xl-turbo-diffusers", ) + self.assertEqual( + graph["nodes"]["audio-loader"]["params"]["revision"]["value"], + catalog_revision("ACE-Step/acestep-v15-xl-turbo-diffusers"), + ) # Auto retry plans own runtime configuration only; creative/generation # controls remain exactly as the user configured them. self.assertEqual(graph["nodes"]["audio-generate"]["params"]["audio_duration"]["value"], 12) @@ -1013,6 +1083,627 @@ def test_structured_audio_plan_does_not_rewrite_independent_video_loader(self): "LTXConditionPipeline", ) + def test_auto_repo_mutation_uses_custom_plan_revision_and_changes_identity_atomically(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + revision = "b" * 40 + graph = { + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "local", "value": "old/repo"}}, + "revision": {"value": "a" * 40}, + "pipeline_class": {"value": "FluxPipeline"}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "modelRepo": "custom/new-repo", + "artifactRevision": revision, + }, + ) + + params = graph["nodes"]["loader"]["params"] + self.assertEqual(updated, ["loader"]) + self.assertEqual(params["model_id"]["value"], {"source": "hub", "value": "custom/new-repo"}) + self.assertEqual(params["revision"]["value"], revision) + + def test_z_image_auto_plan_updates_the_client_shaped_direct_loader(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + target_repo = "Tongyi-MAI/Z-Image-Turbo" + graph = { + "paths": [["z-image-loader", "independent-modular-loader"]], + "nodes": { + "z-image-loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "hub", "value": "custom/old-repo"}}, + "revision": {"value": "a" * 40}, + "pipeline_class": {"value": "ZImagePipeline"}, + }, + }, + "independent-modular-loader": { + "module": "modules.ModularDiffusers", + "action": "ModelsLoader", + "params": { + "repo_id": {"value": {"source": "hub", "value": "custom/modular-repo"}}, + "revision": {"value": "c" * 40}, + "model_type": {"value": "ZImageModularPipeline"}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("ZImageModularPipeline", "text_to_image"), + "modelRepo": target_repo, + }, + ) + + params = graph["nodes"]["z-image-loader"]["params"] + self.assertEqual(updated, ["z-image-loader"]) + self.assertEqual(params["model_id"]["value"], {"source": "hub", "value": target_repo}) + self.assertEqual(params["revision"]["value"], catalog_revision(target_repo)) + self.assertEqual( + graph["nodes"]["independent-modular-loader"]["params"]["repo_id"]["value"]["value"], + "custom/modular-repo", + ) + + def test_auto_repo_mutation_rejects_stale_pinned_revision_without_partial_change(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + old_selection = {"source": "hub", "value": "custom/old-repo"} + stale_revision = "a" * 40 + graph = { + "runtimeHints": { + "autoFieldOverrides": [ + {"schemaVersion": 1, "nodeId": "loader", "fieldKey": "revision"}, + ], + }, + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": dict(old_selection)}, + "revision": {"value": stale_revision}, + "pipeline_class": {"value": "QwenImagePipeline"}, + }, + }, + }, + } + + with self.assertRaisesRegex(RuntimeError, "revision override is pinned"): + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "modelRepo": "Qwen/Qwen-Image-2512", + }, + ) + + self.assertEqual(graph["nodes"]["loader"]["params"]["model_id"]["value"], old_selection) + self.assertEqual(graph["nodes"]["loader"]["params"]["revision"]["value"], stale_revision) + + def test_auto_repo_mutation_error_does_not_echo_untrusted_graph_identity(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + marker = "GRAPH_SECRET_MARKER_" + "x" * 1024 + graph = { + "runtimeHints": { + "autoFieldOverrides": [ + {"schemaVersion": 1, "nodeId": "loader", "fieldKey": "revision"}, + ], + }, + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "hub", "value": marker}}, + "revision": {"value": "a" * 40}, + "pipeline_class": {"value": "QwenImagePipeline"}, + }, + }, + }, + } + + with self.assertRaises(RuntimeError) as raised: + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "modelRepo": "Qwen/Qwen-Image-2512", + }, + ) + + self.assertNotIn("GRAPH_SECRET_MARKER_", str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_target_mismatch") + + def test_auto_repo_mutation_preserves_a_pinned_repo_and_its_revision(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + selection = {"source": "hub", "value": "custom/pinned-repo"} + revision = "c" * 40 + graph = { + "runtimeHints": { + "autoFieldOverrides": [ + {"schemaVersion": 1, "nodeId": "loader", "fieldKey": "model_id"}, + ], + }, + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": dict(selection)}, + "revision": {"value": revision}, + "pipeline_class": {"value": "FluxPipeline"}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "modelRepo": "custom/new-repo", + "artifactRevision": "d" * 40, + }, + ) + + self.assertEqual(updated, []) + self.assertEqual(graph["nodes"]["loader"]["params"]["model_id"]["value"], selection) + self.assertEqual(graph["nodes"]["loader"]["params"]["revision"]["value"], revision) + + def test_auto_repo_mutation_preserves_pinned_revision_when_repository_is_unchanged(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + repository = "custom/same-repo" + pinned_revision = "c" * 40 + graph = { + "runtimeHints": { + "autoFieldOverrides": [ + {"schemaVersion": 1, "nodeId": "loader", "fieldKey": "revision"}, + ], + }, + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "hub", "value": repository}}, + "revision": {"value": pinned_revision}, + "pipeline_class": {"value": "FluxPipeline"}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "modelRepo": repository, + "artifactRevision": "d" * 40, + }, + ) + + self.assertEqual(updated, []) + self.assertEqual(graph["nodes"]["loader"]["params"]["revision"]["value"], pinned_revision) + + def test_auto_repo_mutation_fails_closed_when_loader_has_no_revision_contract(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "legacy-loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "hub", "value": "custom/old-repo"}}, + "pipeline_class": {"value": "QwenImagePipeline"}, + }, + }, + }, + } + + with self.assertRaisesRegex(RuntimeError, "without a revision field"): + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "modelRepo": "Qwen/Qwen-Image-2512", + }, + ) + self.assertEqual( + graph["nodes"]["legacy-loader"]["params"]["model_id"]["value"]["value"], + "custom/old-repo", + ) + + def test_auto_repo_mutation_rejects_plan_revision_that_disagrees_with_catalog(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "model_id": {"value": {"source": "hub", "value": "custom/old-repo"}}, + "revision": {"value": "a" * 40}, + "pipeline_class": {"value": "QwenImagePipeline"}, + }, + }, + }, + } + + with self.assertRaisesRegex(RuntimeError, "does not match the reviewed catalog commit"): + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "modelRepo": "Qwen/Qwen-Image-2512", + "artifactRevision": "b" * 40, + }, + ) + self.assertEqual( + graph["nodes"]["loader"]["params"]["model_id"]["value"]["value"], + "custom/old-repo", + ) + + def test_exact_modular_plans_update_only_the_matching_models_loader(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + cases = ( + ("QwenImageModularPipeline", "control_image"), + ("QwenImageEditModularPipeline", "edit_image"), + ("QwenImageEditPlusModularPipeline", "edit_image"), + ("QwenImageLayeredModularPipeline", "layer_decomposition"), + ) + for model_type, mode in cases: + with self.subTest(model_type=model_type, mode=mode): + profile = execution_profiles_for_execution(model_type, mode)[0] + graph = { + "nodes": { + "target": { + "module": "modules.ModularDiffusers", + "action": "ModelsLoader", + "params": { + "model_type": {"value": model_type}, + "repo_id": {"value": {"source": "hub", "value": "custom/old"}}, + "revision": {"value": "a" * 40}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + "other-modular": { + "module": "modules.ModularDiffusers", + "action": "ModelsLoader", + "params": { + "model_type": {"value": "ZImageModularPipeline"}, + "repo_id": {"value": {"source": "hub", "value": "custom/other"}}, + "revision": {"value": "c" * 40}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + "direct-image": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "QwenImagePipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target(model_type, mode), + "modelRepo": profile.default_repo, + "offloadMode": OFFLOAD_MODE_NONE, + }, + ) + + self.assertEqual(updated, ["target"]) + self.assertEqual( + graph["nodes"]["target"]["params"]["repo_id"]["value"]["value"], + profile.default_repo, + ) + self.assertEqual( + graph["nodes"]["target"]["params"]["revision"]["value"], + catalog_revision(profile.default_repo), + ) + self.assertEqual( + graph["nodes"]["other-modular"]["params"]["repo_id"]["value"]["value"], + "custom/other", + ) + self.assertEqual( + graph["nodes"]["direct-image"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + + def test_same_module_action_different_pipeline_class_is_not_repurposed(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "flux": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "FluxPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + "qwen": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "QwenImagePipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + }, + ) + + self.assertEqual(updated, ["flux"]) + self.assertEqual( + graph["nodes"]["flux"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_GROUP_DISK, + ) + self.assertEqual( + graph["nodes"]["qwen"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + self.assertEqual(graph["nodes"]["qwen"]["params"]["pipeline_class"]["value"], "QwenImagePipeline") + + def test_plan_with_zero_exact_loader_identities_fails_closed(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "video": { + "module": "modules.DiffusersVideo", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "LTXConditionPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + + with self.assertRaisesRegex(RuntimeError, "matched zero exact loader identities") as raised: + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("AceStepAudioPipeline", "text_to_audio"), + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + }, + ) + + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_target_mismatch") + self.assertEqual( + graph["nodes"]["video"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + + def test_exact_idempotent_loader_plan_is_valid_but_target_only_plan_is_not(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "flux": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "FluxPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_GROUP_DISK}, + "auto_offload": {"value": True}, + }, + }, + }, + } + target = resource_plan_target("FluxSchnellPipeline", "text_to_image") + + self.assertEqual( + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + {**target, "offloadMode": OFFLOAD_MODE_GROUP_DISK}, + ), + [], + ) + with self.assertRaisesRegex(RuntimeError, "none exposes an applicable plan field"): + WebServer._apply_resource_retry_plan_to_graph(server, graph, target) + + def test_disconnected_matching_loader_does_not_satisfy_plan_target(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "paths": [["qwen"]], + "nodes": { + "disconnected-flux": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "FluxPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + "qwen": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "QwenImagePipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + + with self.assertRaisesRegex(RuntimeError, "matched zero exact loader identities"): + WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + }, + ) + self.assertEqual( + graph["nodes"]["disconnected-flux"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + + def test_disconnected_duplicate_identity_is_not_mutated_with_executable_target(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "paths": [["active-flux"]], + "nodes": { + "active-flux": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "FluxPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + "disconnected-flux": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "FluxPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + + updated = WebServer._apply_resource_retry_plan_to_graph( + server, + graph, + { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + }, + ) + + self.assertEqual(updated, ["active-flux"]) + self.assertEqual( + graph["nodes"]["disconnected-flux"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + + def test_wan_modular_plan_cannot_be_class_or_path_routed(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + graph = { + "nodes": { + "wan": { + "module": "modules.ModularDiffusers", + "action": "ModelsLoader", + "params": { + "model_type": {"value": "WanModularPipeline"}, + "offload_mode": {"value": OFFLOAD_MODE_MODEL_CPU}, + }, + }, + }, + } + plan = { + "modelType": "WanModularPipeline", + "mode": "text_to_video", + "loaderModule": "modules.ModularDiffusers", + "loaderAction": "ModelsLoader", + "executionPath": "modular-diffusers", + "pipelineClass": "WanModularPipeline", + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + } + + with self.assertRaisesRegex(RuntimeError, "does not resolve to one exact execution profile"): + WebServer._apply_resource_retry_plan_to_graph(server, graph, plan) + self.assertEqual( + graph["nodes"]["wan"]["params"]["offload_mode"]["value"], + OFFLOAD_MODE_MODEL_CPU, + ) + + def test_retry_plan_sanitizer_preserves_exact_candidate_and_loader_target(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + plan = { + **resource_plan_target("QwenImageEditPlusModularPipeline", "edit_image"), + "candidateId": "qwen-edit-plus-retry", + "id": "qwen-edit-plus-retry", + "modelType": "QwenImageEditPlusModularPipeline", + "mode": "edit_image", + "offloadMode": OFFLOAD_MODE_GROUP_DISK, + } + + sanitized = WebServer._sanitize_retry_plan_for_hints(server, plan) + + for key in ( + "candidateId", + "id", + "modelType", + "mode", + "loaderModule", + "loaderAction", + "executionPath", + "pipelineClass", + ): + self.assertEqual(sanitized[key], plan[key]) + def test_auto_retry_preserves_pinned_fields_and_requires_an_unpinned_change(self): from modiff.server import WebServer @@ -1041,11 +1732,13 @@ def test_auto_retry_preserves_pinned_fields_and_requires_an_unpinned_change(self "params": { "dtype": {"value": "float16"}, "offload_mode": {"value": OFFLOAD_MODE_NONE}, + "pipeline_class": {"value": "FluxPipeline"}, }, }, }, } plan = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), "dtype": "bfloat16", "offloadMode": OFFLOAD_MODE_MODEL_CPU, "onCategories": ["oom"], @@ -1098,18 +1791,21 @@ def execute_node(node_id, node, sid): "paths": [["loader-node"]], "deterministicMode": {"enabled": True, "strict": False, "seed": 17}, "runtimeHints": { + **resource_plan_target("ZImageModularPipeline", "text_to_image"), "source": "studio", + "resourceMode": "expert", "device": "cuda:0", "offloadMode": OFFLOAD_MODE_GROUP_CPU, "resourceRetryModes": [OFFLOAD_MODE_GROUP_DISK], }, "nodes": { "loader-node": { - "module": "modules.ModularDiffusers", - "action": "ModelsLoader", + "module": "modules.DiffusersImage", + "action": "LoadPipeline", "params": { "offload_mode": {"value": OFFLOAD_MODE_GROUP_CPU}, "auto_offload": {"value": True}, + "pipeline_class": {"value": "ZImagePipeline"}, }, }, }, @@ -1124,6 +1820,48 @@ def execute_node(node_id, node, sid): self.assertEqual(completed["deterministicMode"]["application"], 2) self.assertEqual(graph["nodes"]["loader-node"]["params"]["offload_mode"]["value"], OFFLOAD_MODE_GROUP_DISK) + def test_auto_retry_modes_come_from_exact_profile_and_expert_modes_remain_explicit(self): + from modiff.server import WebServer + + server = object.__new__(WebServer) + auto_hints = { + **auto_resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "resourceMode": "auto", + "offloadMode": OFFLOAD_MODE_MODEL_CPU, + "resourceRetryModes": [OFFLOAD_MODE_GROUP_CPU], + } + + self.assertEqual( + WebServer._resource_retry_modes(server, auto_hints), + [OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK], + ) + self.assertEqual( + [plan["offloadMode"] for plan in WebServer._coerce_retry_plan_list(server, auto_hints)], + [OFFLOAD_MODE_SEQUENTIAL_CPU, OFFLOAD_MODE_GROUP_DISK], + ) + self.assertEqual( + WebServer._resource_retry_modes( + server, + { + **auto_resource_plan_target("ZImageModularPipeline", "text_to_image"), + "resourceMode": "auto", + "offloadMode": OFFLOAD_MODE_GROUP_CPU, + }, + ), + [OFFLOAD_MODE_GROUP_DISK], + ) + self.assertEqual( + WebServer._resource_retry_modes( + server, + { + "resourceMode": "expert", + "offloadMode": OFFLOAD_MODE_MODEL_CPU, + "resourceRetryModes": [OFFLOAD_MODE_GROUP_CPU], + }, + ), + [OFFLOAD_MODE_GROUP_CPU], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_diffusers_profiles.py b/tests/test_diffusers_profiles.py index 6659547..8c076aa 100644 --- a/tests/test_diffusers_profiles.py +++ b/tests/test_diffusers_profiles.py @@ -1,9 +1,50 @@ +from dataclasses import replace import unittest -from modiff.diffusers_profiles import DIFFUSERS_EXECUTION_PROFILES +from modiff.diffusers_profiles import ( + ACE_STEP_LORA_BASE_REPO, + DIFFUSERS_EXECUTION_PROFILES, + ExpertCudaPolicy, + ExpertMpsPolicy, + ExpertQuantizationPolicy, + MPS_EXPERIMENTAL_POLICY, + MPS_UNQUALIFIED_POLICY, + MPS_UNQUALIFIED_WITH_Z_IMAGE_FALLBACK_POLICY, + QWEN_EXPERT_CUDA_POLICY, + QWEN_EXPERT_QUANTIZATION_POLICY, +) +from modiff.model_artifact_catalog import catalog_revision +from modules.DiffusersAudio.main import AUDIO_PIPELINE_ADAPTERS +from modules.DiffusersImage.main import IMAGE_PIPELINE_ADAPTERS +from modules.DiffusersVideo.main import VIDEO_PIPELINE_ADAPTERS class DiffusersExecutionProfileTests(unittest.TestCase): + def test_every_profile_declares_one_explicit_loader_and_execution_path(self): + expected_targets = { + "modular-diffusers": ("modules.ModularDiffusers", "ModelsLoader"), + "direct-diffusers-image": ("modules.DiffusersImage", "LoadPipeline"), + "direct-diffusers-video": ("modules.DiffusersVideo", "LoadPipeline"), + "direct-wan-vace": ("modules.DiffusersVideo", "LoadPipeline"), + "direct-diffusers-audio": ("modules.DiffusersAudio", "LoadPipeline"), + } + + for profile in DIFFUSERS_EXECUTION_PROFILES.values(): + with self.subTest(profile=profile.id): + self.assertEqual( + (profile.loader_module, profile.loader_action), + expected_targets[profile.execution_path], + ) + self.assertEqual( + profile.backend_path, + f"{profile.loader_module}.{profile.loader_action}", + ) + public = profile.to_public_dict() + self.assertEqual(public["loader_module"], profile.loader_module) + self.assertEqual(public["loader_action"], profile.loader_action) + self.assertEqual(public["execution_path"], profile.execution_path) + self.assertEqual(public["backend_path"], profile.backend_path) + def test_every_supported_studio_model_has_an_execution_profile(self): expected = { "ZImageModularPipeline", @@ -46,6 +87,240 @@ def test_video_profile_uses_generic_facade(self): self.assertEqual(ltx_profile.backend_path, "modules.DiffusersVideo.LoadPipeline") self.assertEqual(ltx_profile.pipeline_class, "LTXConditionPipeline") + def test_z_image_auto_profile_uses_the_registered_direct_image_facade(self): + profile = DIFFUSERS_EXECUTION_PROFILES["z-image:auto"] + + self.assertEqual(profile.model_type, "ZImageModularPipeline") + self.assertEqual(profile.backend_path, "modules.DiffusersImage.LoadPipeline") + self.assertEqual(profile.execution_path, "direct-diffusers-image") + self.assertEqual(profile.pipeline_class, "ZImagePipeline") + + def test_qwen_profiles_publish_reviewed_expert_resource_policies(self): + qwen_profile_ids = { + "qwen-image:t2i-direct", + "qwen-image:modular", + "qwen-edit:direct-inpaint", + "qwen-edit:modular", + "qwen-edit-plus:modular", + "qwen-layered:modular", + } + + for profile_id, profile in DIFFUSERS_EXECUTION_PROFILES.items(): + with self.subTest(profile=profile_id): + self.assertIs( + profile.expert_cuda_policy, + QWEN_EXPERT_CUDA_POLICY if profile_id in qwen_profile_ids else None, + ) + self.assertIs( + profile.expert_quantization_policy, + QWEN_EXPERT_QUANTIZATION_POLICY if profile_id in qwen_profile_ids else None, + ) + self.assertEqual( + "expert_cuda_policy" in profile.to_public_dict(), + profile_id in qwen_profile_ids, + ) + self.assertEqual( + "expert_quantization_policy" in profile.to_public_dict(), + profile_id in qwen_profile_ids, + ) + + public = DIFFUSERS_EXECUTION_PROFILES["qwen-image:t2i-direct"].to_public_dict() + self.assertEqual( + public["expert_cuda_policy"], + { + "schema_version": 1, + "blocked_dtypes": ["float32"], + "recommended_dtype": "bfloat16", + "offloaded_vram_bytes": 10 * 1024**3, + "resident_vram_bytes": 80 * 1024**3, + "quantized_resident_vram_bytes": [["bnb_4bit", 24 * 1024**3]], + }, + ) + self.assertEqual( + public["expert_quantization_policy"], + { + "schema_version": 1, + "quantization_mode": "bnb_4bit", + "offload_mode": "model_cpu", + "modular_node": "modules.ModularDiffusers.QuantizationConfigNode", + "subfolder": "transformer", + "component": "qwen_low_vram", + "four_bit_quant_type": "nf4", + "compute_dtype": "bfloat16", + "double_quant": True, + }, + ) + self.assertEqual(public["expert_quantization_modes"], ["bnb_4bit"]) + self.assertEqual( + public["expert_mps_policy"], + { + "schema_version": 1, + "qualification": "unqualified", + "fallback_action": "switch_to_z_image", + }, + ) + + def test_profiles_publish_only_the_reviewed_expert_mps_policies(self): + unqualified = { + "qwen-image:modular", + "qwen-edit:direct-inpaint", + "qwen-edit:modular", + "qwen-edit-plus:modular", + "qwen-layered:modular", + "wan-vace:direct", + "wan-22-image-to-video:direct", + "wan-22-ti2v-5b:direct", + "wan-text-to-video:direct", + "wan-video-to-video:direct", + "ltx-video:direct", + } + for profile_id, profile in DIFFUSERS_EXECUTION_PROFILES.items(): + with self.subTest(profile=profile_id): + expected = ( + MPS_UNQUALIFIED_WITH_Z_IMAGE_FALLBACK_POLICY + if profile_id == "qwen-image:t2i-direct" + else MPS_EXPERIMENTAL_POLICY + if profile_id == "z-image:auto" + else MPS_UNQUALIFIED_POLICY + if profile_id in unqualified + else None + ) + self.assertIs(profile.expert_mps_policy, expected) + self.assertEqual("expert_mps_policy" in profile.to_public_dict(), expected is not None) + + def test_only_reviewed_image_profiles_publish_expert_quantization_choices(self): + flux_modes = ("bnb_4bit", "bnb_8bit", "quanto_float8", "torchao_float8") + qwen_ids = { + "qwen-image:t2i-direct", + "qwen-image:modular", + "qwen-edit:direct-inpaint", + "qwen-edit:modular", + "qwen-edit-plus:modular", + "qwen-layered:modular", + } + for profile_id, profile in DIFFUSERS_EXECUTION_PROFILES.items(): + expected = ("bnb_4bit",) if profile_id in qwen_ids else flux_modes if profile.model_type.startswith("Flux") else () + with self.subTest(profile=profile_id): + self.assertEqual(profile.expert_quantization_modes, expected) + self.assertEqual( + profile.to_public_dict().get("expert_quantization_modes"), + list(expected) if expected else None, + ) + + def test_execution_profile_rejects_unreviewed_expert_quantization_choices(self): + profile = DIFFUSERS_EXECUTION_PROFILES["z-image:auto"] + for modes in (("unknown",), ("bnb_4bit", "bnb_4bit")): + with self.subTest(modes=modes), self.assertRaisesRegex(ValueError, "invalid Expert quantization modes"): + replace(profile, expert_quantization_modes=modes) + + def test_expert_cuda_policy_rejects_unreviewed_or_unbounded_values(self): + cases = ( + {"schema_version": 2}, + {"blocked_dtypes": ("float32", "float32")}, + {"blocked_dtypes": ("unknown",)}, + {"recommended_dtype": "float32"}, + {"offloaded_vram_bytes": 0}, + {"resident_vram_bytes": 1025 * 1024**3}, + {"quantized_resident_vram_bytes": (("unknown", 24 * 1024**3),)}, + ) + values = { + "schema_version": 1, + "blocked_dtypes": ("float32",), + "recommended_dtype": "bfloat16", + "offloaded_vram_bytes": 10 * 1024**3, + "resident_vram_bytes": 80 * 1024**3, + "quantized_resident_vram_bytes": (("bnb_4bit", 24 * 1024**3),), + } + + for update in cases: + with self.subTest(update=update): + with self.assertRaisesRegex(ValueError, "Invalid reviewed Expert CUDA policy"): + ExpertCudaPolicy(**{**values, **update}) + + def test_expert_quantization_policy_rejects_unreviewed_values(self): + cases = ( + {"schema_version": 2}, + {"quantization_mode": "bnb_8bit"}, + {"offload_mode": "none"}, + {"modular_node": "../../unsafe"}, + {"subfolder": "../transformer"}, + {"component": "qwen/unsafe"}, + {"four_bit_quant_type": "int4"}, + {"compute_dtype": "float64"}, + {"double_quant": 1}, + ) + values = { + "schema_version": 1, + "quantization_mode": "bnb_4bit", + "offload_mode": "model_cpu", + "modular_node": "modules.ModularDiffusers.QuantizationConfigNode", + "subfolder": "transformer", + "component": "qwen_low_vram", + "four_bit_quant_type": "nf4", + "compute_dtype": "bfloat16", + "double_quant": True, + } + + for update in cases: + with self.subTest(update=update): + with self.assertRaisesRegex(ValueError, "Invalid reviewed Expert quantization policy"): + ExpertQuantizationPolicy(**{**values, **update}) + + def test_expert_mps_policy_rejects_unreviewed_values(self): + values = {"schema_version": 1, "qualification": "unqualified", "fallback_action": "open_setup"} + for update in ( + {"schema_version": 2}, + {"qualification": "certified"}, + {"fallback_action": "run_anyway"}, + ): + with self.subTest(update=update): + with self.assertRaisesRegex(ValueError, "Invalid reviewed Expert MPS policy"): + ExpertMpsPolicy(**{**values, **update}) + + def test_ace_lora_template_base_is_an_exact_reviewed_compatible_artifact(self): + profile = DIFFUSERS_EXECUTION_PROFILES["ace-step-audio:direct"] + + self.assertEqual(profile.compatible_repos, (ACE_STEP_LORA_BASE_REPO,)) + self.assertEqual( + catalog_revision(ACE_STEP_LORA_BASE_REPO), + "be23effe449c5957947f3020fd63bee23c64abe4", + ) + + def test_every_direct_profile_is_accepted_by_its_registered_adapter(self): + """Profiles may be a supported subset; Expert-only adapters need no Auto profile.""" + + registries = { + "modules.DiffusersImage.LoadPipeline": IMAGE_PIPELINE_ADAPTERS, + "modules.DiffusersVideo.LoadPipeline": VIDEO_PIPELINE_ADAPTERS, + "modules.DiffusersAudio.LoadPipeline": AUDIO_PIPELINE_ADAPTERS, + } + direct_profiles = [ + profile + for profile in DIFFUSERS_EXECUTION_PROFILES.values() + if profile.backend_path in registries + ] + self.assertTrue(direct_profiles) + + for profile in direct_profiles: + with self.subTest(profile=profile.id): + adapter = registries[profile.backend_path].get(profile.pipeline_class) + self.assertIsNotNone( + adapter, + f"{profile.id} names unregistered adapter {profile.pipeline_class}", + ) + self.assertTrue( + set(profile.modes).issubset(adapter.modes), + f"{profile.id} modes {profile.modes} are outside {adapter.modes}", + ) + + if profile.backend_path == "modules.DiffusersImage.LoadPipeline": + managed_repos = {repo.casefold() for repo in adapter.managed_repos} + self.assertIn(profile.default_repo.casefold(), managed_repos) + if profile.fallback_repo: + self.assertIn(profile.fallback_repo.casefold(), managed_repos) + else: + self.assertEqual(profile.default_repo.casefold(), adapter.default_repo.casefold()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_diffusers_video_registry.py b/tests/test_diffusers_video_registry.py index bc2b68e..5e65a27 100644 --- a/tests/test_diffusers_video_registry.py +++ b/tests/test_diffusers_video_registry.py @@ -1,9 +1,14 @@ import inspect +import sys +import tempfile import unittest +from contextlib import chdir +from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import numpy as np +from PIL import Image import modules as module_registry from modules.DiffusersVideo import ( @@ -20,7 +25,14 @@ FRAMEPACK_BASE_REPO, FRAMEPACK_VISION_REPO, LTX_DISTILLED_TIMESTEPS, + VIDEO_PIPELINE_ADAPTERS, + VIDEO_PIPELINE_EXECUTE_HANDLERS, + VIDEO_PIPELINE_LOAD_HANDLERS, + VIDEO_MODE_FIELD_CONTRACTS, + WAN_VACE_MODE_MEDIA_CONTRACTS, + _pipeline_adapter, _resolve_adapter_model_selection, + _resolve_loader_revision, get_video_pipeline_adapter, ) @@ -45,7 +57,11 @@ def test_quality_shot_jobs_pair_six_keyframes_with_six_five_second_shots(self): def test_quality_shot_jobs_allow_a_per_shot_conditioning_strength_override(self): result = BuildShotJobs().execute( shots=[ - {"prompt": "Preserve the parked car while the station door opens.", "duration_seconds": 5, "conditioning_strength": 0.8}, + { + "prompt": "Preserve the parked car while the station door opens.", + "duration_seconds": 5, + "conditioning_strength": 0.8, + }, {"prompt": "The same car drives away through snow.", "duration_seconds": 5}, ], opening_images=[object(), object()], @@ -194,6 +210,1096 @@ def test_unknown_pipeline_is_rejected_before_model_load(self): with self.assertRaisesRegex(ValueError, "Unsupported Diffusers video pipeline"): get_video_pipeline_adapter("UnknownVideoPipeline") + def test_missing_null_and_malformed_pipeline_class_fail_closed_before_loading(self): + node = LoadPipeline("video-loader-identity") + node._load_wan_vace = MagicMock(side_effect=AssertionError("loader must not run")) + + for values in ( + {}, + {"pipeline_class": None}, + {"pipeline_class": ""}, + {"pipeline_class": " WanVACEPipeline "}, + {"pipeline_class": []}, + {"pipeline_class": {}}, + ): + with self.subTest(values=values): + with self.assertRaisesRegex(ValueError, "registered Diffusers video pipeline class is required"): + node(**values) + node._load_wan_vace.assert_not_called() + + def test_unknown_pipeline_class_survives_node_normalization_for_an_actionable_error(self): + node = LoadPipeline("video-loader-unknown") + node._load_wan_vace = MagicMock(side_effect=AssertionError("loader must not run")) + + with self.assertRaisesRegex(ValueError, "Unsupported Diffusers video pipeline class UnknownVideoPipeline"): + node(pipeline_class="UnknownVideoPipeline") + node._load_wan_vace.assert_not_called() + + def test_registered_adapter_without_a_dispatch_handler_fails_before_loading_or_execution(self): + pipeline = SimpleNamespace(_modiff_video_pipeline_class="HunyuanVideoFramepackPipeline") + loader = LoadPipeline() + loader._load_framepack = MagicMock(side_effect=AssertionError("FramePack loader must not run")) + generator = Generate() + generator._execute_framepack = MagicMock(side_effect=AssertionError("FramePack generator must not run")) + + with patch.dict(VIDEO_PIPELINE_LOAD_HANDLERS, {"HunyuanVideoFramepackPipeline": None}): + with self.assertRaisesRegex(RuntimeError, "No loader handler.*HunyuanVideoFramepackPipeline"): + loader.execute(pipeline_class="HunyuanVideoFramepackPipeline") + with patch.dict(VIDEO_PIPELINE_EXECUTE_HANDLERS, {"HunyuanVideoFramepackPipeline": None}): + with self.assertRaisesRegex(RuntimeError, "No execution handler.*HunyuanVideoFramepackPipeline"): + generator.execute(pipeline=pipeline, mode="image_to_video") + + loader._load_framepack.assert_not_called() + generator._execute_framepack.assert_not_called() + + def test_framepack_has_explicit_load_and_execute_dispatch(self): + self.assertEqual(set(VIDEO_PIPELINE_LOAD_HANDLERS), set(VIDEO_PIPELINE_ADAPTERS)) + self.assertEqual(set(VIDEO_PIPELINE_EXECUTE_HANDLERS), set(VIDEO_PIPELINE_ADAPTERS)) + self.assertEqual( + VIDEO_PIPELINE_LOAD_HANDLERS["HunyuanVideoFramepackPipeline"], + "_load_framepack", + ) + self.assertEqual( + VIDEO_PIPELINE_EXECUTE_HANDLERS["HunyuanVideoFramepackPipeline"], + "_execute_framepack", + ) + + pipeline = SimpleNamespace() + loader = LoadPipeline() + with patch.object(loader, "_load_framepack", return_value=pipeline) as load_framepack: + result = loader.execute(pipeline_class="HunyuanVideoFramepackPipeline") + load_framepack.assert_called_once() + self.assertIs(result["pipeline"], pipeline) + self.assertEqual(pipeline._modiff_video_pipeline_class, "HunyuanVideoFramepackPipeline") + self.assertEqual(result["resolved_artifact"], "lllyasviel/FramePackI2V_HY") + + def test_real_loader_normalizes_a_stale_managed_repo_before_caching(self): + pipeline = SimpleNamespace() + node = LoadPipeline("normalized-video-loader-cache") + node._load_framepack = MagicMock(return_value=pipeline) + selected_class = "HunyuanVideoFramepackPipeline" + stale = {"source": "hub", "value": "Wan-AI/Wan2.1-VACE-1.3B-diffusers"} + corrected = {"source": "hub", "value": "lllyasviel/FramePackI2V_HY"} + case_variant = {"source": "HUB", "value": "LLLYASVIEL/FRAMEPACKI2V_HY"} + + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + first = node(pipeline_class=selected_class, model_id=stale) + second = node(pipeline_class=selected_class, model_id=corrected) + third = node(pipeline_class=selected_class, model_id=case_variant) + + self.assertIs(first, second) + self.assertIs(first, third) + self.assertEqual(node.params["model_id"], corrected) + self.assertEqual(first["resolved_artifact"], corrected["value"]) + self.assertEqual(first["pipeline"]._modiff_video_revision, "86cef4396041b6002c957852daac4c91aaa47c79") + node._load_framepack.assert_called_once() + + def test_hub_pipeline_revisions_fail_closed_before_nodebase_or_loader(self): + custom_revision = "0123456789abcdef0123456789abcdef01234567" + custom_selection = {"source": "hub", "value": "organization/custom-framepack"} + invalid_revisions = ( + None, + "", + "main", + custom_revision.upper(), + f" {custom_revision}", + custom_revision[:-1], + 123, + False, + ) + invalid = LoadPipeline("strict-video-hub-revision") + invalid._load_framepack = MagicMock(side_effect=AssertionError("loader must not run")) + for revision in invalid_revisions: + with self.subTest(revision=revision): + with self.assertRaisesRegex(ValueError, "immutable lowercase|exact lowercase"): + invalid( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id=custom_selection, + revision=revision, + ) + invalid._load_framepack.assert_not_called() + + curated_mismatch = LoadPipeline("strict-video-curated-mismatch") + curated_mismatch._load_framepack = MagicMock(side_effect=AssertionError("loader must not run")) + with self.assertRaisesRegex(ValueError, "pinned to .* does not match"): + curated_mismatch( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id={"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, + revision="0000000000000000000000000000000000000000", + ) + curated_mismatch._load_framepack.assert_not_called() + + custom_pipeline = SimpleNamespace() + valid_custom = LoadPipeline("strict-video-custom-valid") + valid_custom._load_framepack = MagicMock(return_value=custom_pipeline) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + custom_result = valid_custom( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id=custom_selection, + revision=custom_revision, + ) + self.assertEqual(custom_result["pipeline"]._modiff_video_revision, custom_revision) + self.assertEqual(valid_custom._load_framepack.call_args.args[1]["revision"], custom_revision) + + curated_pipeline = SimpleNamespace() + valid_curated = LoadPipeline("strict-video-curated-valid") + valid_curated._load_framepack = MagicMock(return_value=curated_pipeline) + with patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True): + curated_result = valid_curated( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id={"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, + ) + self.assertEqual( + curated_result["pipeline"]._modiff_video_revision, + "86cef4396041b6002c957852daac4c91aaa47c79", + ) + + def test_tagged_pipeline_recovers_only_its_exact_registered_adapter(self): + tagged = SimpleNamespace(_modiff_video_pipeline_class="LTXConditionPipeline") + self.assertEqual(_pipeline_adapter(tagged).pipeline_class, "LTXConditionPipeline") + + tagged._modiff_video_pipeline_class = "UnknownVideoPipeline" + with self.assertRaisesRegex(ValueError, "Unsupported Diffusers video pipeline"): + _pipeline_adapter(tagged) + + def test_real_node_rejects_null_or_runtime_inconsistent_pipeline_tags(self): + null_tagged = type( + "LTXConditionPipeline", + (), + { + "_modiff_video_pipeline_class": None, + "_modiff_video_repo": "Lightricks/LTX-Video-0.9.8-13B-distilled", + }, + )() + inconsistent = type( + "WanVACEPipeline", + (), + { + "_modiff_video_pipeline_class": "HunyuanVideoFramepackPipeline", + "_modiff_video_repo": "Wan-AI/Wan2.1-VACE-1.3B-diffusers", + }, + )() + managed_repo_inconsistent = type( + "WanPipeline", + (), + { + "_modiff_video_pipeline_class": "WanTI2VPipeline", + "_modiff_video_repo": "WAN-AI/WAN2.1-T2V-1.3B-DIFFUSERS", + }, + )() + + for pipeline, message in ( + (null_tagged, "registered Diffusers video pipeline class is required"), + (inconsistent, "identity is inconsistent.*tagged as HunyuanVideoFramepackPipeline"), + (managed_repo_inconsistent, "identity is inconsistent.*tagged as WanTI2VPipeline"), + ): + with self.subTest(message=message): + node = Generate("strict-video-tag") + node._execute_ltx = MagicMock(side_effect=AssertionError("LTX handler must not run")) + node._execute_framepack = MagicMock(side_effect=AssertionError("FramePack handler must not run")) + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + node._execute_wan_text_to_video = MagicMock( + side_effect=AssertionError("Wan text handler must not run") + ) + with self.assertRaisesRegex(RuntimeError, message): + node( + pipeline=pipeline, + mode="image_to_video" if pipeline is inconsistent else "text_to_video", + reference_images=[Image.new("RGB", (4, 4))] if pipeline is inconsistent else None, + ) + node._execute_ltx.assert_not_called() + node._execute_framepack.assert_not_called() + node._execute_wan_vace.assert_not_called() + node._execute_wan_text_to_video.assert_not_called() + + def test_known_shared_runtime_accepts_a_consistent_tag_and_custom_repo(self): + pipeline = type( + "WanPipeline", + (), + { + "_modiff_video_pipeline_class": "WanTI2VPipeline", + "_modiff_video_repo": "organization/custom-wan-ti2v", + }, + )() + node = Generate("consistent-video-tag") + node._execute_wan_text_to_video = MagicMock( + return_value={"video_out": [], "width_out": 1, "height_out": 1, "frames_out": 0} + ) + + result = node(pipeline=pipeline, mode="text_to_video") + + self.assertEqual(result["frames_out"], 0) + node._execute_wan_text_to_video.assert_called_once() + + def test_untagged_unique_runtime_class_requires_an_exact_reviewed_repo(self): + pipeline = type("LTXConditionPipeline", (), {})() + with self.assertRaisesRegex(ValueError, "without an exact reviewed repository"): + _pipeline_adapter(pipeline) + + pipeline._modiff_video_repo = "organization/custom-ltx" + with self.assertRaisesRegex(ValueError, "unreviewed repository"): + _pipeline_adapter(pipeline) + + pipeline._modiff_video_repo = "Lightricks/LTX-Video-0.9.8-13B-distilled" + self.assertEqual(_pipeline_adapter(pipeline).pipeline_class, "LTXConditionPipeline") + + def test_tagged_loader_output_keeps_custom_repository_support(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="LTXConditionPipeline", + _modiff_video_repo="organization/custom-ltx", + ) + self.assertEqual(_pipeline_adapter(pipeline).pipeline_class, "LTXConditionPipeline") + + def test_untagged_unknown_runtime_class_fails_closed(self): + pipeline = type("UnreviewedVideoPipeline", (), {})() + with self.assertRaisesRegex(ValueError, "Cannot recover.*UnreviewedVideoPipeline"): + _pipeline_adapter(pipeline) + + def test_untagged_shared_wan_runtime_is_ambiguous_without_an_exact_reviewed_repo(self): + pipeline = type("WanPipeline", (), {})() + with self.assertRaisesRegex(ValueError, "without an exact reviewed repository"): + _pipeline_adapter(pipeline) + + pipeline._modiff_video_repo = "organization/custom-wan" + with self.assertRaisesRegex(ValueError, "unreviewed repository"): + _pipeline_adapter(pipeline) + + def test_exact_reviewed_repo_disambiguates_a_shared_wan_runtime(self): + pipeline = type( + "WanPipeline", + (), + {"_modiff_video_repo": "Wan-AI/Wan2.2-TI2V-5B-Diffusers"}, + )() + self.assertEqual(_pipeline_adapter(pipeline).pipeline_class, "WanTI2VPipeline") + + def test_pipeline_signal_publishes_backend_owned_exact_adapter_modes(self): + loader = LoadPipeline("loader") + loader.set_field_value = MagicMock() + loader.set_field_params = MagicMock() + loader.select_adapter( + { + "pipeline_class": "HunyuanVideoFramepackPipeline", + "model_id": {"source": "hub", "value": "Wan-AI/Wan2.1-VACE-1.3B-diffusers"}, + }, + None, + ) + + loader.set_field_value.assert_called_once_with( + { + "model_id": {"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, + "revision": "86cef4396041b6002c957852daac4c91aaa47c79", + } + ) + signal = loader.set_field_params.call_args.args[1]["signal"] + self.assertEqual( + signal["value"], + { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "video", + "pipelineClass": "HunyuanVideoFramepackPipeline", + "modes": ["image_to_video"], + }, + ) + + generator = Generate("generator") + generator.set_field_params = MagicMock() + generator.update_adapter_modes( + { + "mode": "text_to_video", + "video_contract": { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "video", + "pipelineClass": "HunyuanVideoFramepackPipeline", + "modes": ["image_to_video"], + }, + }, + None, + ) + updates = {call.args[0]: call.args[1] for call in generator.set_field_params.call_args_list} + self.assertEqual( + updates["mode"], + {"options": ["image_to_video"], "default": "image_to_video", "value": "image_to_video"}, + ) + self.assertEqual(updates["reference_images"], {"hidden": False, "required": True}) + self.assertEqual(updates["video"], {"hidden": True, "required": False}) + self.assertEqual(updates["framepack_sampling"], {"hidden": False}) + self.assertEqual( + updates["strength"]["fieldOptions"]["studioBinding"], + { + "schemaVersion": 1, + "group": "video-strength", + "formFields": ["strength"], + "transform": "identity", + }, + ) + self.assertEqual(LoadPipeline.params["pipeline_class"]["onChange"], "select_adapter") + self.assertEqual(LoadPipeline.params["model_id"]["onChange"], "select_adapter") + self.assertEqual(Generate.params["mode"]["onChange"], "update_adapter_modes") + self.assertEqual( + Generate.params["pipeline"]["onSignal"], + [ + {"action": "value", "target": "video_contract"}, + {"action": "exec", "data": "update_adapter_modes"}, + ], + ) + self.assertEqual( + LoadPipeline.params["pipeline"]["signal"]["value"], + { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "video", + "pipelineClass": "WanVACEPipeline", + "modes": list(VIDEO_PIPELINE_ADAPTERS["WanVACEPipeline"].modes), + }, + ) + + def test_video_model_action_couples_repository_and_revision_before_real_execution(self): + stale_revision = "0" * 40 + replacement_revision = "1234567890abcdef1234567890abcdef12345678" + replacement = {"source": "hub", "value": "organization/replacement-video"} + loader = LoadPipeline("video-model-identity-action") + loader._sid = "video-browser-session" + messages = [] + current_server = SimpleNamespace( + _current_dynamic_message_identity_payload=lambda: {}, + queue_message=lambda message, sid=None: messages.append((message, sid)), + ) + + with patch("modiff.NodeBase._server", return_value=current_server): + loader.select_adapter( + { + "pipeline_class": "LTXConditionPipeline", + "model_id": replacement, + "revision": stale_revision, + }, + {"key": "model_id"}, + ) + + value_message = next(message for message, _sid in messages if message["type"] == "set_field_value") + self.assertEqual(value_message["fields"], {"revision": ""}) + self.assertEqual( + next(sid for message, sid in messages if message["type"] == "set_field_value"), + "video-browser-session", + ) + + preserving = LoadPipeline("video-custom-pin-class-action") + preserving.set_field_params = MagicMock() + preserving.set_field_value = MagicMock() + preserving.select_adapter( + { + "pipeline_class": "LTXConditionPipeline", + "model_id": replacement, + "revision": replacement_revision, + }, + {"key": "pipeline_class"}, + ) + preserving.set_field_value.assert_not_called() + + upstream_calls = [] + + class FakePipelineClass: + @classmethod + def from_pretrained(cls, repository, **kwargs): + upstream_calls.append((repository, kwargs["revision"])) + return SimpleNamespace() + + executing = LoadPipeline("video-replacement-execution") + executing.mm_add = MagicMock() + with ( + patch("diffusers.LTXConditionPipeline", FakePipelineClass), + patch("modules.DiffusersVideo.main.local_files_only", return_value=True), + patch("modules.DiffusersVideo.main.apply_pipeline_offload"), + patch("modules.DiffusersRuntime.main.apply_execution_recipe_to_pipeline"), + ): + result = executing.execute( + pipeline_class="LTXConditionPipeline", + model_id=replacement, + revision=replacement_revision, + ) + + self.assertEqual(upstream_calls, [(replacement["value"], replacement_revision)]) + self.assertEqual(result["pipeline"]._modiff_video_repo, replacement["value"]) + self.assertEqual(result["pipeline"]._modiff_video_revision, replacement_revision) + + def test_video_model_action_publishes_catalog_pin_and_clears_local_revision(self): + cataloged = LoadPipeline("video-catalog-pin-action") + cataloged.set_field_params = MagicMock() + cataloged.set_field_value = MagicMock() + cataloged.select_adapter( + { + "pipeline_class": "LTXConditionPipeline", + "model_id": { + "source": "hub", + "value": "Lightricks/LTX-Video-0.9.8-13B-distilled", + }, + "revision": "0" * 40, + }, + {"key": "model_id"}, + ) + self.assertEqual( + cataloged.set_field_value.call_args.args[0]["revision"], + "7c64400e1861cc0d7b98d570a1926d5408ec60cd", + ) + + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / "local-video" + local_model.mkdir() + local = LoadPipeline("video-local-revision-action") + local.set_field_params = MagicMock() + local.set_field_value = MagicMock() + local.select_adapter( + { + "pipeline_class": "LTXConditionPipeline", + "model_id": {"source": "local", "value": str(local_model)}, + "revision": "0" * 40, + }, + {"key": "model_id"}, + ) + self.assertEqual(local.set_field_value.call_args.args[0]["revision"], "") + + def test_mode_action_rejects_a_stale_or_unknown_video_contract(self): + generator = Generate("generator") + generator.set_field_params = MagicMock() + with self.assertRaisesRegex(ValueError, "valid adapter contract"): + generator.update_adapter_modes({}, None) + with self.assertRaisesRegex(ValueError, "Unsupported Diffusers video pipeline"): + generator.update_adapter_modes( + { + "video_contract": { + "pipelineClass": "UnknownVideoPipeline", + "modes": ["text_to_video"], + } + }, + None, + ) + with self.assertRaisesRegex(ValueError, "stale or mismatched"): + generator.update_adapter_modes( + { + "video_contract": { + "pipelineClass": "HunyuanVideoFramepackPipeline", + "modes": ["text_to_video"], + } + }, + None, + ) + with self.assertRaisesRegex(ValueError, "stale or mismatched"): + generator.update_adapter_modes( + { + "video_contract": { + "schemaVersion": 999, + "library": "unreviewed", + "mediaKind": "audio", + "pipelineClass": "HunyuanVideoFramepackPipeline", + "modes": ["image_to_video"], + } + }, + None, + ) + generator.set_field_params.assert_not_called() + + def test_video_field_contracts_cover_every_adapter_mode_and_update_selected_fields(self): + self.assertEqual(set(VIDEO_MODE_FIELD_CONTRACTS), set(VIDEO_PIPELINE_ADAPTERS)) + for pipeline_class, adapter in VIDEO_PIPELINE_ADAPTERS.items(): + with self.subTest(pipeline_class=pipeline_class): + self.assertEqual(tuple(VIDEO_MODE_FIELD_CONTRACTS[pipeline_class]), adapter.modes) + + cases = ( + ( + "WanVACEPipeline", + "video_inpaint", + { + "video": {"hidden": False, "required": True}, + "mask": {"hidden": False, "required": True}, + "reference_images": {"hidden": False, "required": False}, + "framepack_sampling": {"hidden": True}, + }, + "strength", + ), + ( + "LTXConditionPipeline", + "video_to_video", + { + "video": {"hidden": False, "required": True}, + "reference_images": {"hidden": True, "required": False}, + "strength": {"hidden": False}, + "denoise_strength": {"hidden": False}, + }, + "conditioningScale", + ), + ( + "WanAnimatePipeline", + "character_replace", + { + "reference_images": {"hidden": False, "required": True}, + "pose_video": {"hidden": False, "required": True}, + "face_video": {"hidden": False, "required": True}, + "background_video": {"hidden": False, "required": True}, + "mask": {"hidden": False, "required": True}, + "strength": {"hidden": True}, + }, + "strength", + ), + ) + for pipeline_class, mode, expected_fields, strength_form_field in cases: + with self.subTest(pipeline_class=pipeline_class, mode=mode): + adapter = VIDEO_PIPELINE_ADAPTERS[pipeline_class] + node = Generate(f"field-contract-{pipeline_class}-{mode}") + node.set_field_params = MagicMock() + node.update_adapter_modes( + { + "mode": mode, + "video_contract": { + "schemaVersion": 1, + "library": "diffusers", + "mediaKind": "video", + "pipelineClass": pipeline_class, + "modes": list(adapter.modes), + }, + }, + {"key": "mode"}, + ) + updates = {call.args[0]: call.args[1] for call in node.set_field_params.call_args_list} + for field, expected in expected_fields.items(): + self.assertEqual( + {key: updates[field][key] for key in expected}, + expected, + ) + strength_binding = updates["strength"]["fieldOptions"]["studioBinding"] + self.assertEqual(strength_binding["formFields"], [strength_form_field]) + self.assertEqual( + strength_binding["group"], + "video-conditioning-scale" if strength_form_field == "conditioningScale" else "video-strength", + ) + + def test_wan_vace_declares_the_reviewed_mode_media_matrix(self): + adapter = VIDEO_PIPELINE_ADAPTERS["WanVACEPipeline"] + expected = { + "text_to_video": ("forbidden", "forbidden", "forbidden"), + "video_to_video": ("required", "forbidden", "optional"), + "video_inpaint": ("required", "required", "optional"), + "video_outpaint": ("required", "required", "optional"), + "reference_to_video": ("forbidden", "forbidden", "required"), + "control_to_video": ("required", "forbidden", "optional"), + "video_color_edit": ("required", "forbidden", "optional"), + } + + self.assertEqual(tuple(WAN_VACE_MODE_MEDIA_CONTRACTS), adapter.modes) + self.assertEqual(set(WAN_VACE_MODE_MEDIA_CONTRACTS), set(expected)) + self.assertNotIn("image_to_video", adapter.modes) + for mode, requirements in expected.items(): + with self.subTest(mode=mode): + contract = WAN_VACE_MODE_MEDIA_CONTRACTS[mode] + self.assertEqual((contract.video, contract.mask, contract.reference_images), requirements) + + def test_wan_vace_every_required_and_forbidden_media_rule_preflights_before_torch(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=1, + ) + media = { + "video": [np.zeros((4, 4, 3), dtype=np.uint8)], + "mask": [np.zeros((4, 4), dtype=np.uint8)], + "reference_images": [Image.new("RGB", (4, 4))], + } + + with patch.dict(sys.modules, {"torch": None}): + for mode, contract in WAN_VACE_MODE_MEDIA_CONTRACTS.items(): + requirements = { + "video": contract.video, + "mask": contract.mask, + "reference_images": contract.reference_images, + } + valid = { + field: media[field] for field, requirement in requirements.items() if requirement == "required" + } + for field, requirement in requirements.items(): + if requirement == "required": + values = {key: value for key, value in valid.items() if key != field} + node = Generate() + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.subTest(mode=mode, missing=field): + with self.assertRaisesRegex(ValueError, "requires"): + node.execute(pipeline=pipeline, mode=mode, num_frames=1, **values) + node._execute_wan_vace.assert_not_called() + elif requirement == "forbidden": + node = Generate() + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.subTest(mode=mode, forbidden=field): + with self.assertRaisesRegex(ValueError, "does not accept"): + node.execute( + pipeline=pipeline, + mode=mode, + num_frames=1, + **valid, + **{field: media[field]}, + ) + node._execute_wan_vace.assert_not_called() + + def test_wan_vace_optional_references_and_empty_media_are_normalized_before_dispatch(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=1, + ) + video = [np.zeros((4, 4, 3), dtype=np.uint8)] + mask = [np.zeros((4, 4), dtype=np.uint8)] + reference = Image.new("RGB", (6, 5)) + output = {"video_out": [], "width_out": 4, "height_out": 4, "frames_out": 0} + + node = Generate() + node._execute_wan_vace = MagicMock(return_value=output) + for mode, contract in WAN_VACE_MODE_MEDIA_CONTRACTS.items(): + values = {"video": [], "mask": [], "reference_images": [], "num_frames": 1} + if contract.video == "required": + values["video"] = video + if contract.mask == "required": + values["mask"] = mask + if contract.reference_images in {"required", "optional"}: + values["reference_images"] = [reference] + + with self.subTest(mode=mode): + node.execute(pipeline=pipeline, mode=mode, **values) + dispatched = node._execute_wan_vace.call_args.args[3] + if contract.video == "forbidden": + self.assertIsNone(dispatched["video"]) + else: + self.assertEqual(len(dispatched["video"]), 1) + self.assertIs(dispatched["video"][0], video[0]) + if contract.mask == "forbidden": + self.assertIsNone(dispatched["mask"]) + else: + self.assertEqual(len(dispatched["mask"]), 1) + self.assertIs(dispatched["mask"][0], mask[0]) + if contract.reference_images == "forbidden": + self.assertIsNone(dispatched["reference_images"]) + else: + self.assertEqual(dispatched["reference_images"], [reference]) + self.assertEqual(dispatched["num_frames"], 1) + node._execute_wan_vace.reset_mock() + + def test_real_video_node_preserves_unknown_mode_for_an_actionable_failure(self): + pipeline = SimpleNamespace(_modiff_video_pipeline_class="WanVACEPipeline") + node = Generate("strict-video-mode") + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + + self.assertTrue(Generate.params["mode"]["fieldOptions"]["noValidation"]) + for value in (None, [], {}): + with self.subTest(mode=value): + with self.assertRaisesRegex(ValueError, "exact non-empty Diffusers video mode"): + node(pipeline=pipeline, mode=value) + with self.assertRaisesRegex(ValueError, "exact non-empty Diffusers video mode"): + node(pipeline=pipeline) + with self.assertRaisesRegex(RuntimeError, "does not support video mode future_video_mode"): + node(pipeline=pipeline, mode="future_video_mode") + with self.assertRaisesRegex(RuntimeError, "does not support video mode text_to_video "): + node(pipeline=pipeline, mode=" text_to_video ") + with self.assertRaisesRegex(RuntimeError, "does not support video mode image_to_video"): + node(pipeline=pipeline, mode="image_to_video") + node._execute_wan_vace.assert_not_called() + + def test_real_video_node_passes_normalized_media_to_the_wan_handler(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=1, + ) + frame = np.zeros((4, 4, 3), dtype=np.uint8) + node = Generate("normalized-wan-media") + node._execute_wan_vace = MagicMock( + return_value={"video_out": [], "width_out": 4, "height_out": 4, "frames_out": 0} + ) + + result = node( + pipeline=pipeline, + mode="video_to_video", + video=(frame,), + mask=[], + reference_images=[], + num_frames=1, + ) + + self.assertEqual(result["frames_out"], 0) + dispatched = node._execute_wan_vace.call_args.args[3] + self.assertEqual(len(dispatched["video"]), 1) + np.testing.assert_array_equal(dispatched["video"][0], frame) + self.assertIsNone(dispatched["mask"]) + self.assertIsNone(dispatched["reference_images"]) + self.assertEqual(dispatched["num_frames"], 1) + + def test_wan_vace_media_types_shapes_counts_and_requested_length_preflight_before_torch(self): + class TorchLikeFrame: + __module__ = "torch" + shape = (3, 4, 4) + dtype = "float32" + device = "cpu" + + def detach(self): + return self + + class HWCTorchLikeFrame: + __module__ = "torch" + shape = (8, 8, 3) + dtype = "float32" + device = "cpu" + + def detach(self): + return self + + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=1, + ) + output = {"video_out": [], "width_out": 4, "height_out": 4, "frames_out": 0} + valid_frames = ( + Image.new("RGB", (4, 4)), + np.zeros((4, 4, 3), dtype=np.uint8), + TorchLikeFrame(), + ) + + with patch.dict(sys.modules, {"torch": None}): + for frame in valid_frames: + with self.subTest(valid_type=type(frame).__name__): + node = Generate() + node._execute_wan_vace = MagicMock(return_value=output) + node.execute( + pipeline=pipeline, + mode="video_to_video", + video=[frame], + num_frames=1, + ) + node._execute_wan_vace.assert_called_once() + + reference = Image.new("RGB", (6, 5)) + for selection in (reference, [reference], [[reference]], ((reference,),)): + with self.subTest(reference_shape=type(selection).__name__, selection=selection): + node = Generate() + node._execute_wan_vace = MagicMock(return_value=output) + node.execute( + pipeline=pipeline, + mode="reference_to_video", + reference_images=selection, + num_frames=1, + ) + dispatched_references = node._execute_wan_vace.call_args.args[3]["reference_images"] + self.assertEqual(dispatched_references, [reference]) + + frame4 = np.zeros((4, 4, 3), dtype=np.uint8) + frame5 = np.zeros((5, 4, 3), dtype=np.uint8) + mask4 = np.zeros((4, 4), dtype=np.uint8) + mask5 = np.zeros((5, 4), dtype=np.uint8) + cases = ( + ( + {"mode": "video_to_video", "video": [object()], "num_frames": 1}, + "must be a PIL image, NumPy array, or Torch tensor-like image", + ), + ( + { + "mode": "video_to_video", + "video": [frame4], + "reference_images": [object()], + "num_frames": 1, + }, + "reference images must be actual PIL images", + ), + ( + { + "mode": "reference_to_video", + "reference_images": [np.zeros((4, 4, 3), dtype=np.uint8)], + "num_frames": 1, + }, + "reference images must be actual PIL images", + ), + ( + { + "mode": "reference_to_video", + "reference_images": [[Image.new("RGB", (4, 4))], [Image.new("RGB", (4, 4))]], + "num_frames": 1, + }, + "one flat list or one nested batch", + ), + ( + {"mode": "video_inpaint", "video": [frame4], "mask": [object()], "num_frames": 1}, + "mask video frame 1 must be a PIL image", + ), + ( + { + "mode": "video_inpaint", + "video": [frame4, frame4], + "mask": [mask4], + "num_frames": 2, + }, + "video/mask frame count mismatch: 2 vs 1", + ), + ( + {"mode": "video_inpaint", "video": [frame4], "mask": [mask5], "num_frames": 1}, + "must have matching spatial dimensions", + ), + ( + { + "mode": "video_inpaint", + "video": [frame4], + "mask": [Image.new("L", (4, 4))], + "num_frames": 1, + }, + "must use the same container family", + ), + ( + { + "mode": "video_inpaint", + "video": [Image.new("RGB", (4, 4))], + "mask": [mask4], + "num_frames": 1, + }, + "must use the same container family", + ), + ( + { + "mode": "video_inpaint", + "video": [np.zeros((8, 8), dtype=np.uint8)], + "mask": [np.zeros((8, 8, 1), dtype=np.uint8)], + "num_frames": 1, + }, + "2D source/control video frames require 2D mask frames", + ), + ( + {"mode": "video_to_video", "video": [frame4, frame5], "num_frames": 2}, + "frames must all have the same spatial dimensions", + ), + ( + { + "mode": "video_to_video", + "video": [Image.new("RGB", (4, 4)), frame4], + "num_frames": 2, + }, + "source/control video frames must use one container family", + ), + ( + { + "mode": "video_inpaint", + "video": [Image.new("RGB", (4, 4)), Image.new("RGB", (4, 4))], + "mask": [Image.new("L", (4, 4)), mask4], + "num_frames": 2, + }, + "mask video frames must use one container family", + ), + ( + { + "mode": "reference_to_video", + "reference_images": [Image.new("RGB", (4, 4)) for _ in range(9)], + "num_frames": 1, + }, + "accepts at most 8 reference images", + ), + ( + { + "mode": "reference_to_video", + "reference_images": [Image.new("1", (4097, 4097))], + "num_frames": 1, + }, + "16777216-pixel cumulative input limit", + ), + ( + { + "mode": "video_to_video", + "video": [frame4] * 82, + "num_frames": 82, + "output_type": "np", + }, + "conditioned video currently requires output_type=pil", + ), + ( + { + "mode": "video_to_video", + "video": [frame4] * 82, + "num_frames": 82, + "output_type": "pt", + }, + "conditioned video currently requires output_type=pil", + ), + ( + { + "mode": "video_to_video", + "video": [np.zeros((3, 8, 8), dtype=np.uint8)], + "num_frames": 1, + }, + "NumPy images must use HWC layout", + ), + ( + { + "mode": "video_to_video", + "video": [HWCTorchLikeFrame()], + "num_frames": 1, + }, + "Torch tensor-like images must use CHW layout", + ), + ( + {"mode": "video_to_video", "video": [frame4], "num_frames": 2}, + "received 1 conditioned video frames, but normalized num_frames is 2", + ), + ) + for values, message in cases: + with self.subTest(message=message): + node = Generate() + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.assertRaisesRegex(ValueError, message): + node.execute(pipeline=pipeline, **values) + node._execute_wan_vace.assert_not_called() + + def test_wan_vace_numeric_resource_contract_preflights_before_torch_and_dispatch(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=1, + vae_scale_factor_spatial=8, + transformer=SimpleNamespace(config=SimpleNamespace(patch_size=(1, 2, 2))), + ) + invalid = ( + ("width", False, "width.*finite integer"), + ("width", 15, "width.*16 through 2048"), + ("width", 2049, "width.*16 through 2048"), + ("width", 16.5, "width.*finite integer"), + ("height", 15, "height.*16 through 2048"), + ("height", 2049, "height.*16 through 2048"), + ("height", 16.5, "height.*finite integer"), + ("height", float("nan"), "height.*finite integer"), + ("num_inference_steps", 0, "inference steps.*1 through 100"), + ("num_inference_steps", 101, "inference steps.*1 through 100"), + ("num_inference_steps", 1.5, "inference steps.*finite integer"), + ("guidance_scale", -0.1, "guidance scale.*0 through 20"), + ("guidance_scale", float("nan"), "guidance scale.*finite"), + ("guidance_scale_2", 20.1, "secondary guidance scale.*0 through 20"), + ("guidance_scale_2", float("inf"), "secondary guidance scale.*finite"), + ("conditioning_scale", -0.1, "conditioning scale.*0 through 2"), + ("conditioning_scale", float("nan"), "conditioning scale.*finite"), + ("seed", -1, "seed.*0 through 4294967295"), + ("seed", 4294967296, "seed.*0 through 4294967295"), + ("seed", 1.5, "seed.*finite integer"), + ("num_videos_per_prompt", 0, "videos per prompt.*1 through 1"), + ("num_videos_per_prompt", 2, "videos per prompt.*1 through 1"), + ("num_videos_per_prompt", True, "videos per prompt.*finite integer"), + ("output_type", "tensor", "output_type must be exactly"), + ("output_type", " pt ", "output_type must be exactly"), + ("max_sequence_length", 0, "max sequence length.*1 through 512"), + ("max_sequence_length", 513, "max sequence length.*1 through 512"), + ("max_sequence_length", 1.5, "max sequence length.*finite integer"), + ) + + with patch.dict(sys.modules, {"torch": None}): + for index, (field, value, message) in enumerate(invalid): + node = Generate(f"strict-wan-scalar-{index}") + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + values = { + "pipeline": pipeline, + "mode": "text_to_video", + "num_frames": 1, + "width": 16, + "height": 16, + field: value, + } + with self.subTest(field=field, value=value): + with self.assertRaisesRegex(ValueError, message): + node(**values) + node._execute_wan_vace.assert_not_called() + + misaligned = Generate("strict-wan-alignment") + misaligned._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.assertRaisesRegex(ValueError, "size must be divisible by 16x16"): + misaligned( + pipeline=pipeline, + mode="text_to_video", + width=24, + height=16, + num_frames=1, + ) + misaligned._execute_wan_vace.assert_not_called() + + boundary = Generate("strict-wan-scalar-boundaries") + boundary._execute_wan_vace = MagicMock( + return_value={"video_out": [], "width_out": 16, "height_out": 16, "frames_out": 0} + ) + result = boundary( + pipeline=pipeline, + mode="text_to_video", + width=16, + height=16, + num_frames=1, + num_inference_steps=1, + guidance_scale=0, + guidance_scale_2=0, + conditioning_scale=0, + seed=4294967295, + num_videos_per_prompt=1, + output_type="pt", + max_sequence_length=512, + ) + + self.assertEqual(result["frames_out"], 0) + dispatched = boundary._execute_wan_vace.call_args.args[3] + self.assertEqual(dispatched["width"], 16) + self.assertEqual(dispatched["height"], 16) + self.assertEqual(dispatched["num_inference_steps"], 1) + self.assertEqual(dispatched["guidance_scale"], 0) + self.assertEqual(dispatched["guidance_scale_2"], 0) + self.assertEqual(dispatched["conditioning_scale"], 0) + self.assertEqual(dispatched["seed"], 4294967295) + self.assertEqual(dispatched["num_videos_per_prompt"], 1) + self.assertEqual(dispatched["output_type"], "pt") + self.assertEqual(dispatched["max_sequence_length"], 512) + + def test_real_wan_vace_node_rejects_invalid_reference_pairing_and_frame_bounds_before_dispatch(self): + pipeline = SimpleNamespace( + _modiff_video_pipeline_class="WanVACEPipeline", + vae_scale_factor_temporal=4, + ) + invalid_media = ( + { + "mode": "reference_to_video", + "reference_images": [np.zeros((4, 4, 3), dtype=np.uint8)], + "num_frames": 1, + }, + { + "mode": "video_inpaint", + "video": [np.zeros((4, 4, 3), dtype=np.uint8)], + "mask": [Image.new("L", (4, 4))], + "num_frames": 1, + }, + ) + with patch.dict(sys.modules, {"torch": None}): + for index, values in enumerate(invalid_media): + with self.subTest(values=values): + node = Generate(f"strict-wan-media-{index}") + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.assertRaisesRegex(RuntimeError, "actual PIL images|same container family"): + node(pipeline=pipeline, **values) + node._execute_wan_vace.assert_not_called() + + invalid_frame_counts = (False, 0, -1, 1.5, "1.5", 242, float("inf"), float("nan"), object()) + for index, num_frames in enumerate(invalid_frame_counts): + with self.subTest(num_frames=num_frames): + node = Generate(f"strict-wan-frames-{index}") + node._execute_wan_vace = MagicMock(side_effect=AssertionError("VACE handler must not run")) + with self.assertRaisesRegex(ValueError, "finite integer from 1 through 241"): + node(pipeline=pipeline, mode="text_to_video", num_frames=num_frames) + node._execute_wan_vace.assert_not_called() + + node = Generate("strict-wan-frames-boundary") + node._execute_wan_vace = MagicMock( + return_value={ + "video_out": [], + "width_out": 4, + "height_out": 4, + "frames_out": 0, + } + ) + result = node(pipeline=pipeline, mode="text_to_video", num_frames=241.0) + self.assertEqual(result["frames_out"], 0) + self.assertEqual(node._execute_wan_vace.call_args.args[3]["num_frames"], 241) + + def test_invalid_non_vace_media_contract_fails_before_torch_import(self): + pipeline = SimpleNamespace(_modiff_video_pipeline_class="WanPipeline") + with patch.dict(sys.modules, {"torch": None}): + with self.assertRaisesRegex(ValueError, "text_to_video does not accept a source video"): + Generate().execute(pipeline=pipeline, mode="text_to_video", video=[object()]) + def test_sequence_generator_reuses_one_generic_pipeline_for_multiple_shots(self): class FakePipeline: _modiff_video_pipeline_class = "LTXConditionPipeline" @@ -703,7 +1809,7 @@ def __call__(self, **kwargs): self.assertEqual(pipeline.calls[0]["num_frames"], 481) self.assertEqual(pipeline.calls[0]["sampling_type"], "inverted_anti_drifting") - def test_non_wan_adapter_replaces_only_the_inherited_legacy_model_default(self): + def test_adapter_replaces_any_managed_default_but_preserves_custom_hub_and_local_selections(self): adapter = get_video_pipeline_adapter("HunyuanVideoFramepackPipeline") inherited = {"source": "hub", "value": "Wan-AI/Wan2.1-VACE-1.3B-diffusers"} self.assertEqual( @@ -711,8 +1817,201 @@ def test_non_wan_adapter_replaces_only_the_inherited_legacy_model_default(self): {"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, ) - explicit = {"source": "local", "value": "/models/custom-framepack"} - self.assertIs(_resolve_adapter_model_selection(adapter, explicit), explicit) + other_managed = {"source": "hub", "value": "Lightricks/LTX-2"} + self.assertEqual( + _resolve_adapter_model_selection(adapter, other_managed), + {"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, + ) + + same_managed_with_noncanonical_case = {"source": "HUB", "value": "LLLYASVIEL/FRAMEPACKI2V_HY"} + self.assertEqual( + _resolve_adapter_model_selection(adapter, same_managed_with_noncanonical_case), + {"source": "hub", "value": "lllyasviel/FramePackI2V_HY"}, + ) + + custom_hub = {"source": "Hub", "value": "organization/custom-framepack"} + self.assertEqual( + _resolve_adapter_model_selection(adapter, custom_hub), + {"source": "hub", "value": "organization/custom-framepack"}, + ) + + with tempfile.TemporaryDirectory() as temporary: + explicit_path = Path(temporary) / "models" / "custom-framepack" + explicit_path.mkdir(parents=True) + managed_path = Path(temporary) / "Lightricks" / "LTX-2" + managed_path.mkdir(parents=True) + explicit = {"source": "LOCAL", "value": str(explicit_path)} + self.assertEqual( + _resolve_adapter_model_selection(adapter, explicit), + {"source": "local", "value": str(explicit_path.resolve())}, + ) + + with chdir(temporary): + local_managed_name = {"source": "Local", "value": "Lightricks/LTX-2"} + self.assertEqual( + _resolve_adapter_model_selection(adapter, local_managed_name), + {"source": "local", "value": str(managed_path.resolve())}, + ) + + for invalid in ("organization/not-a-local-model", str(Path(temporary) / "missing")): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "directory does not exist"): + _resolve_adapter_model_selection( + adapter, + {"source": "local", "value": invalid}, + ) + + node = LoadPipeline("missing-local-video-boundary") + node._load_framepack = MagicMock(side_effect=AssertionError("upstream must not run")) + with self.assertRaisesRegex(ValueError, "directory does not exist"): + node( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id={"source": "local", "value": invalid}, + revision="main", + ) + node._load_framepack.assert_not_called() + self.assertEqual( + _resolve_adapter_model_selection(adapter, "organization/legacy-framepack"), + {"source": "hub", "value": "organization/legacy-framepack"}, + ) + + def test_video_model_selection_defaults_and_source_boundary_fail_before_revision_or_loader(self): + adapter = get_video_pipeline_adapter("HunyuanVideoFramepackPipeline") + expected_default = {"source": "hub", "value": adapter.default_repo} + for selection in (None, "", " ", {"source": "Hub", "value": ""}): + with self.subTest(default_selection=selection): + self.assertEqual(_resolve_adapter_model_selection(adapter, selection), expected_default) + + invalid_selections = ( + {"value": "organization/model"}, + {"source": None, "value": "organization/model"}, + {"source": "", "value": "organization/model"}, + {"source": " hub ", "value": "organization/model"}, + {"source": "remote", "value": "organization/model"}, + {"source": [], "value": "organization/model"}, + {"source": {}, "value": "organization/model"}, + {"source": "hub"}, + {"source": "hub", "value": []}, + {"source": "local", "value": ""}, + {"source": "local", "value": " "}, + [], + (), + ) + node = LoadPipeline("strict-video-model-selection") + node._load_framepack = MagicMock(side_effect=AssertionError("FramePack loader must not run")) + with patch("modules.DiffusersVideo.main.catalog_revision") as resolve_revision: + for selection in invalid_selections: + with self.subTest(invalid_selection=selection): + with self.assertRaisesRegex( + ValueError, + "source must be exactly hub or local|value must be a repository ID|local Diffusers video " + "model path is required|model selection must be a repository ID", + ): + node( + pipeline_class="HunyuanVideoFramepackPipeline", + model_id=selection, + ) + resolve_revision.assert_not_called() + node._load_framepack.assert_not_called() + + def test_video_hub_source_cannot_resolve_as_a_local_directory(self): + revision = "0123456789abcdef0123456789abcdef01234567" + node = LoadPipeline("video-hub-local-path-boundary") + node._load_ltx = MagicMock(side_effect=AssertionError("upstream must not run")) + + with tempfile.TemporaryDirectory() as temporary: + local_repo = Path(temporary) / "organization" / "local-video" + local_repo.mkdir(parents=True) + with chdir(temporary): + invalid_hub_values = ( + "organization/local-video", + str(local_repo), + local_repo.as_uri(), + "../local-video", + "single-component", + ) + for value in invalid_hub_values: + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "namespace/repository|local filesystem"): + node( + pipeline_class="LTXConditionPipeline", + model_id={"source": "hub", "value": value}, + revision=revision, + ) + + node._load_ltx.assert_not_called() + + def test_default_video_hub_model_cannot_resolve_as_a_local_directory(self): + adapter = get_video_pipeline_adapter("LTXConditionPipeline") + node = LoadPipeline("video-default-hub-local-path-boundary") + node._load_ltx = MagicMock(side_effect=AssertionError("upstream must not run")) + + with tempfile.TemporaryDirectory() as temporary: + (Path(temporary) / adapter.default_repo).mkdir(parents=True) + with chdir(temporary): + for selection in (None, "", {"source": "hub", "value": ""}): + with self.subTest(selection=selection): + with self.assertRaisesRegex(ValueError, "local filesystem"): + node( + pipeline_class="LTXConditionPipeline", + model_id=selection, + ) + + node._load_ltx.assert_not_called() + + def test_local_video_model_is_canonical_before_cache_and_drops_hub_revision(self): + pipeline = SimpleNamespace() + node = LoadPipeline("canonical-local-video-cache") + node._load_ltx = MagicMock(return_value=pipeline) + + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / "models" / "local-video" + local_model.mkdir(parents=True) + with chdir(temporary), patch("modiff.NodeBase.modelstore.is_local_cached", return_value=True): + first = node( + pipeline_class="LTXConditionPipeline", + model_id={"source": "local", "value": "models/local-video"}, + revision="main", + ) + second = node( + pipeline_class="LTXConditionPipeline", + model_id={"source": "local", "value": str(local_model)}, + revision=None, + ) + + self.assertIs(first, second) + node._load_ltx.assert_called_once() + dispatched = node._load_ltx.call_args.args[1] + self.assertEqual( + dispatched["model_id"], + {"source": "local", "value": str(local_model.resolve())}, + ) + self.assertIsNone(dispatched["revision"]) + self.assertIsNone(pipeline._modiff_video_revision) + + def test_curated_video_hub_default_keeps_catalog_revision_while_local_selection_does_not(self): + adapter = get_video_pipeline_adapter("HunyuanVideoFramepackPipeline") + default_selection = _resolve_adapter_model_selection( + adapter, + {"source": "Hub", "value": ""}, + ) + self.assertEqual(default_selection, {"source": "hub", "value": adapter.default_repo}) + self.assertEqual( + _resolve_loader_revision(default_selection, adapter.default_repo, None), + "86cef4396041b6002c957852daac4c91aaa47c79", + ) + + with tempfile.TemporaryDirectory() as temporary: + local_model = Path(temporary) / adapter.default_repo + local_model.mkdir(parents=True) + with chdir(temporary): + local_selection = _resolve_adapter_model_selection( + adapter, + {"source": "LOCAL", "value": adapter.default_repo}, + ) + self.assertEqual(local_selection, {"source": "local", "value": str(local_model.resolve())}) + self.assertIsNone(_resolve_loader_revision(local_selection, local_selection["value"], None)) + self.assertIsNone(_resolve_loader_revision(local_selection, local_selection["value"], "main")) def test_framepack_loader_composes_the_official_transformer_base_and_vision_repositories(self): adapter = get_video_pipeline_adapter("HunyuanVideoFramepackPipeline") diff --git a/tests/test_dynamic_block_security.py b/tests/test_dynamic_block_security.py new file mode 100644 index 0000000..1027130 --- /dev/null +++ b/tests/test_dynamic_block_security.py @@ -0,0 +1,227 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from modules.ModularDiffusers.dynamic_node import DynamicBlockNode +from modules.ModularDiffusers.pipeline_schema import MoDiffPipelineConfig + + +_REVISION = "a" * 40 + + +def _custom_config(params=None): + return MoDiffPipelineConfig.from_dict( + { + "label": "Dynamic fixture", + "default_dtype": "float32", + "node_params": { + "custom": { + "params": {} if params is None else params, + "model_input_names": [], + "input_names": [], + "output_names": [], + "label": "Dynamic fixture", + } + }, + } + ) + + +def _verified(config=None): + return SimpleNamespace( + config=_custom_config() if config is None else config, + revision=_REVISION, + repository_path="C:/hf-cache/models--owner--block/snapshots/" + _REVISION, + ) + + +class DynamicBlockSecurityTests(unittest.TestCase): + def test_imported_trust_and_non_boolean_values_fail_before_any_loader(self): + node = DynamicBlockNode("dynamic-imported-trust") + + cases = ( + (False, ValueError, "contract-preview only"), + (True, ValueError, "contract-preview only"), + ("false", TypeError, "JSON boolean"), + (1, TypeError, "JSON boolean"), + ) + for trust_value, error_type, message in cases: + with ( + self.subTest(trust_value=trust_value), + patch.object( + node, + "_get_verified_custom_config", + ) as verify_config, + patch( + "diffusers.ModularPipeline.from_pretrained", + ) as pipeline_loader, + ): + with self.assertRaisesRegex(error_type, message): + node.execute( + "owner/custom-block", + "cpu", + False, + trust_value, + offload_mode="none", + revision=_REVISION, + ) + + verify_config.assert_not_called() + pipeline_loader.assert_not_called() + + def test_exact_revision_is_used_for_local_only_verified_sidecar_resolution(self): + node = DynamicBlockNode("dynamic-sidecar-revision") + verified = _verified() + with ( + patch("modules.ModularDiffusers.dynamic_node.resolve_model_revision", return_value=_REVISION), + patch( + "modules.ModularDiffusers.dynamic_node.PipelineConfig.load_verified", + return_value=verified, + ) as load_verified, + ): + result = node._get_verified_custom_config("owner/custom-block", "main") + + self.assertIs(result, verified) + load_verified.assert_called_once_with( + "owner/custom-block", + source="hub", + revision=_REVISION, + ) + + def test_hostile_sidecar_actions_are_rejected_before_definition_publication(self): + hostile_actions = ( + {"prompt": {"type": "string", "onChange": "update_node"}}, + {"prompt": {"type": "string", "onSignal": {"action": "exec", "data": "update_node"}}}, + {"prompt": {"type": "string", "onChange": {"action": "create", "data": {}}}}, + ) + + for index, params in enumerate(hostile_actions): + node = DynamicBlockNode(f"dynamic-hostile-action-{index}") + node.send_node_definition_with_meta = MagicMock() + with patch.object( + node, + "_get_verified_custom_config", + return_value=_verified(_custom_config(params)), + ): + with self.assertRaisesRegex(ValueError, "must not define"): + node.update_node( + { + "repo_id": "owner/custom-block", + "revision": _REVISION, + "trust_remote_code": False, + }, + {"key": "load_block_button"}, + ) + + node.send_node_definition_with_meta.assert_not_called() + + def test_trusted_preview_is_rejected_before_sidecar_resolution(self): + node = DynamicBlockNode("dynamic-trusted-preview") + node.send_node_definition_with_meta = MagicMock() + with patch.object(node, "_get_verified_custom_config") as verify_config: + with self.assertRaisesRegex(ValueError, "Trust Remote Code off"): + node.update_node( + { + "repo_id": "owner/custom-block", + "revision": _REVISION, + "trust_remote_code": True, + }, + {"key": "load_block_button"}, + ) + verify_config.assert_not_called() + node.send_node_definition_with_meta.assert_not_called() + + def test_declarative_sidecar_actions_remain_available(self): + params = { + "mode": { + "type": "string", + "onChange": {"image": ["image"], "text": ["prompt"]}, + }, + "image": {"display": "input", "type": "image"}, + "prompt": {"type": "string"}, + "identity": { + "display": "input", + "type": "object", + "onSignal": {"action": "value", "target": "mode"}, + }, + } + node = DynamicBlockNode("dynamic-declarative-actions") + node.send_node_definition_with_meta = MagicMock() + with ( + patch.object( + node, + "_get_verified_custom_config", + return_value=_verified(_custom_config(params)), + ) as verify_config, + patch("modules.ModularDiffusers.dynamic_node.PipelineConfig.load") as network_config_load, + ): + node.update_node( + { + "repo_id": "owner/custom-block", + "revision": _REVISION, + "trust_remote_code": False, + }, + {"key": "load_block_button"}, + ) + + published_params = node.send_node_definition_with_meta.call_args.args[0] + self.assertEqual(published_params, params) + verify_config.assert_called_once_with("owner/custom-block", _REVISION) + network_config_load.assert_not_called() + + def test_declarative_sidecar_cannot_target_unpublished_fields(self): + hostile_targets = ( + {"mode": {"type": "string", "onChange": {"true": ["ghost"]}}}, + { + "mode": { + "type": "string", + "onChange": {"action": "value", "target": "modiff_pipeline_identity"}, + } + }, + { + "mode": {"type": "string", "onChange": {"action": "signal", "target": "prompt"}}, + "prompt": {"type": "string"}, + }, + ) + for index, params in enumerate(hostile_targets): + node = DynamicBlockNode(f"dynamic-hostile-target-{index}") + node.send_node_definition_with_meta = MagicMock() + with patch.object( + node, + "_get_verified_custom_config", + return_value=_verified(_custom_config(params)), + ): + with self.assertRaisesRegex(ValueError, "unknown contract field|input or output"): + node.update_node( + { + "repo_id": "owner/custom-block", + "revision": _REVISION, + "trust_remote_code": False, + }, + {"key": "load_block_button"}, + ) + node.send_node_definition_with_meta.assert_not_called() + + def test_prototype_sensitive_sidecar_field_names_are_rejected(self): + for index, field_name in enumerate(("__proto__", "prototype", "constructor")): + node = DynamicBlockNode(f"dynamic-prototype-field-{index}") + node.send_node_definition_with_meta = MagicMock() + with patch.object( + node, + "_get_verified_custom_config", + return_value=_verified(_custom_config({field_name: {"type": "string"}})), + ): + with self.assertRaisesRegex(ValueError, "fields must map"): + node.update_node( + { + "repo_id": "owner/custom-block", + "revision": _REVISION, + "trust_remote_code": False, + }, + {"key": "load_block_button"}, + ) + node.send_node_definition_with_meta.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graph_catalog_integrity.py b/tests/test_graph_catalog_integrity.py index 62e90e2..1020a85 100644 --- a/tests/test_graph_catalog_integrity.py +++ b/tests/test_graph_catalog_integrity.py @@ -53,11 +53,28 @@ def _is_curated_loader(node): and data.get("action") == "LoadPipeline" ) or ( data.get("module") == "modules.ModularDiffusers" - and data.get("action") in {"ModelsLoader", "DynamicBlockNode"} + and data.get("action") in {"ModelsLoader", "AutoModelLoader", "DynamicBlockNode"} ) class GraphCatalogIntegrityTests(unittest.TestCase): + def test_ace_step_graphs_keep_the_positive_shift_contract(self): + graph_dir = GRAPH_ROOT / "studio" / "ace-step-audio-pipeline" + checked = 0 + for graph_path in sorted(graph_dir.glob("*.json")): + graph = json.loads(graph_path.read_text(encoding="utf-8")) + for node in graph.get("nodes", []): + data = node.get("data", {}) + if data.get("module") != "modules.DiffusersAudio" or data.get("action") != "Generate": + continue + shift = data.get("params", {}).get("shift") + if not isinstance(shift, dict): + continue + checked += 1 + self.assertEqual(shift.get("min"), 0.1, graph_path.name) + self.assertGreater(float(shift.get("value")), 0, graph_path.name) + self.assertEqual(checked, 6) + def test_curated_graphs_exclude_runtime_and_machine_state(self): node_runtime_fields = {"measured", "selected", "dragging"} measured_runtime_fields = {"memoryUsage", "executionTime"} @@ -97,6 +114,108 @@ def test_hugging_face_graph_inputs_use_immutable_resolve_revisions(self): f"{graph_path.relative_to(GRAPH_ROOT)} contains a mutable Hugging Face URL", ) + def test_hub_diffusers_adapter_graphs_pin_revision_weight_and_digest(self): + checked = defaultdict(int) + for graph_path in sorted(GRAPH_ROOT.rglob("*.json")): + graph = json.loads(graph_path.read_text(encoding="utf-8")) + for node in graph.get("nodes", []): + data = node.get("data", {}) + module = data.get("module") + if module not in {"modules.DiffusersAudio", "modules.DiffusersImage"} or data.get("action") != "LoadAdapter": + continue + params = data.get("params", {}) + selection = (params.get("adapter_path") or {}).get("value") + if not isinstance(selection, dict) or str(selection.get("source") or "").casefold() != "hub": + continue + checked[module] += 1 + revision = str((params.get("revision") or {}).get("value") or "") + digest = str((params.get("expected_sha256") or {}).get("value") or "") + weight_name = str((params.get("weight_name") or {}).get("value") or "") + self.assertRegex(revision, r"^[0-9a-f]{40}$", str(graph_path.relative_to(GRAPH_ROOT))) + self.assertRegex(digest, r"^[0-9a-f]{64}$", str(graph_path.relative_to(GRAPH_ROOT))) + self.assertTrue( + weight_name.endswith(".safetensors"), + f"{graph_path.relative_to(GRAPH_ROOT)}: Hub adapter must use safetensors", + ) + self.assertGreater(checked["modules.DiffusersAudio"], 0) + self.assertEqual(checked["modules.DiffusersImage"], 11) + + def test_hub_modular_lora_graphs_pin_the_generic_auxiliary_identity(self): + expected_revisions = { + "lightx2v/Qwen-Image-Edit-2511-Lightning": "d74eba145674fd7e31b949324e148e21e7118abd", + } + manifest = json.loads(WORKFLOW_MANIFEST.read_text(encoding="utf-8")) + manifest_workflows = { + workflow["graphPath"]: workflow + for workflow in [ + *manifest.get("workflows", []), + *manifest.get("experimentalWorkflows", []), + ] + } + checked = 0 + for graph_path in sorted(GRAPH_ROOT.rglob("*.json")): + graph = json.loads(graph_path.read_text(encoding="utf-8")) + for node in graph.get("nodes", []): + data = node.get("data", {}) + if data.get("module") != "modules.ModularDiffusers" or data.get("action") != "Lora": + continue + params = data.get("params", {}) + selection = (params.get("model") or {}).get("value") + if not isinstance(selection, dict) or selection.get("source") != "hub": + continue + checked += 1 + label = str(graph_path.relative_to(GRAPH_ROOT)) + manifest_label = graph_path.relative_to(GRAPH_ROOT).as_posix() + repository = str(selection.get("value") or "") + revision = str((params.get("revision") or {}).get("value") or "") + digest = str((params.get("expected_sha256") or {}).get("value") or "") + weight_name = str((params.get("weight_name") or {}).get("value") or "") + self.assertEqual(revision, expected_revisions[repository], label) + self.assertRegex(digest, r"^[0-9a-f]{64}$", label) + self.assertTrue(weight_name.endswith(".safetensors"), label) + self.assertNotIn("filter", (params.get("model") or {}).get("fieldOptions", {}), label) + self.assertIn(manifest_label, manifest_workflows, label) + self.assertIn( + repository, + manifest_workflows[manifest_label].get("requiredArtifacts", []), + f"{label}: workflow manifest omits its mandatory Hub LoRA", + ) + self.assertEqual(checked, 2) + + def test_hub_modular_component_graphs_pin_and_manifest_their_artifact(self): + manifest = json.loads(WORKFLOW_MANIFEST.read_text(encoding="utf-8")) + manifest_workflows = { + workflow["graphPath"]: workflow + for workflow in [ + *manifest.get("workflows", []), + *manifest.get("experimentalWorkflows", []), + ] + } + checked = 0 + for graph_path in sorted(GRAPH_ROOT.rglob("*.json")): + graph = json.loads(graph_path.read_text(encoding="utf-8")) + for node in graph.get("nodes", []): + data = node.get("data", {}) + if data.get("module") != "modules.ModularDiffusers" or data.get("action") != "AutoModelLoader": + continue + params = data.get("params", {}) + selection = (params.get("model_id") or {}).get("value") + if not isinstance(selection, dict) or selection.get("source") != "hub": + continue + checked += 1 + label = str(graph_path.relative_to(GRAPH_ROOT)) + manifest_label = graph_path.relative_to(GRAPH_ROOT).as_posix() + repository = str(selection.get("value") or "") + revision = str((params.get("revision") or {}).get("value") or "") + self.assertEqual(revision, catalog_revision(repository), label) + self.assertIn(manifest_label, manifest_workflows, label) + self.assertIn( + repository, + manifest_workflows[manifest_label].get("requiredArtifacts", []), + f"{label}: workflow manifest omits its mandatory Hub component", + ) + self.assertEqual(checked, 1) + def test_workflow_manifest_hashes_match_the_canonical_graphs(self): manifest = json.loads(WORKFLOW_MANIFEST.read_text(encoding="utf-8")) workflows = [ diff --git a/tests/test_hf_download_concurrency.py b/tests/test_hf_download_concurrency.py index 442e579..1584cb1 100644 --- a/tests/test_hf_download_concurrency.py +++ b/tests/test_hf_download_concurrency.py @@ -1,6 +1,7 @@ import asyncio import json import unittest +from unittest import mock from modiff.server import WebServer @@ -11,6 +12,34 @@ def __init__(self, **query): class HuggingFaceDownloadConcurrencyTests(unittest.IsolatedAsyncioTestCase): + async def test_app_download_forwards_exact_commit_to_hub_snapshot(self): + server = WebServer(modules={}) + server.loop = asyncio.get_running_loop() + revision = "a" * 40 + entry = { + "task_id": "download-task", + "sids": set(), + "started_at": 1.0, + "repair": False, + "repair_source_repo_id": None, + "requested_files": [], + "revision": revision, + } + + async def run_callback(callback, **_kwargs): + return callback() + + with ( + mock.patch.object(server, "_run_executor_callback", side_effect=run_callback), + mock.patch( + "modiff.server.download_hub_model", + return_value={"repo_id": "unit/exact-model", "complete": True}, + ) as download, + ): + await server._run_hf_download_task("unit/exact-model", entry) + + self.assertEqual(download.call_args.args[-1], revision) + async def test_shared_memory_runtime_serializes_graph_and_download_model_io(self): server = WebServer(modules={}) server.loop = asyncio.get_running_loop() @@ -110,6 +139,93 @@ async def fake_download(repo_id, entry): self.assertIn("text_encoder/model-00004-of-00004.safetensors", captured["requested_files"]) self.assertNotIn("ltxv-13b-0.9.8-dev.safetensors", captured["requested_files"]) + async def test_custom_download_carries_exact_commit_into_app_owned_task(self): + server = WebServer(modules={}) + server.loop = asyncio.get_running_loop() + captured = {} + revision = "a" * 40 + + async def fake_download(repo_id, entry): + captured.update(entry) + return { + "repo_id": repo_id, + "revision": entry["revision"], + "complete": True, + "repair_required": False, + } + + server._run_hf_download_task = fake_download + response = await server.hf_download( + FakeRequest(repo_id="unit/exact-model", revision=revision) + ) + payload = json.loads(response.text) + + self.assertFalse(payload["error"]) + self.assertEqual(captured["revision"], revision) + self.assertEqual(payload["result"]["revision"], revision) + + async def test_cataloged_download_uses_reviewed_commit_when_client_omits_revision(self): + server = WebServer(modules={}) + server.loop = asyncio.get_running_loop() + captured = {} + + async def fake_download(repo_id, entry): + captured.update(entry) + return { + "repo_id": repo_id, + "revision": entry["revision"], + "complete": True, + "repair_required": False, + } + + server._run_hf_download_task = fake_download + response = await server.hf_download( + FakeRequest(repo_id="Wan-AI/Wan2.1-FLF2V-14B-720P-diffusers") + ) + payload = json.loads(response.text) + + self.assertFalse(payload["error"]) + self.assertEqual(captured["revision"], "17c30769b1e0b5dcaa1799b117bf20a9c31f59d7") + self.assertEqual(payload["result"]["revision"], captured["revision"]) + + async def test_uncataloged_download_preserves_user_selected_revision_behavior(self): + server = WebServer(modules={}) + server.loop = asyncio.get_running_loop() + captured = {} + + async def fake_download(repo_id, entry): + captured.update(entry) + return {"repo_id": repo_id, "complete": True, "repair_required": False} + + server._run_hf_download_task = fake_download + response = await server.hf_download(FakeRequest(repo_id="unit/custom-model")) + + self.assertFalse(json.loads(response.text)["error"]) + self.assertIsNone(captured["revision"]) + + async def test_concurrent_download_rejects_a_different_exact_commit(self): + server = WebServer(modules={}) + server.loop = asyncio.get_running_loop() + release = asyncio.Event() + + async def fake_download(repo_id, entry): + await release.wait() + return {"repo_id": repo_id, "complete": True, "repair_required": False} + + server._run_hf_download_task = fake_download + first = asyncio.create_task( + server.hf_download(FakeRequest(repo_id="unit/exact-model", revision="a" * 40)) + ) + await asyncio.sleep(0) + response = await server.hf_download( + FakeRequest(repo_id="unit/exact-model", revision="b" * 40) + ) + + self.assertEqual(response.status, 409) + self.assertIn("immutable snapshot", json.loads(response.text)["error"]) + release.set() + await first + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hf_download_errors.py b/tests/test_hf_download_errors.py index 236d067..04344a4 100644 --- a/tests/test_hf_download_errors.py +++ b/tests/test_hf_download_errors.py @@ -56,6 +56,20 @@ async def test_download_endpoint_rejects_windows_backslash_repo_escapes_before_q self.assertEqual(response.status, 400) self.assertIn(b"invalid_huggingface_repo_id", response.body) + async def test_download_endpoint_rejects_mutable_or_malformed_revisions(self): + server = object.__new__(WebServer) + for revision in ("main", "A" * 40, "a" * 39, " a" * 20): + with self.subTest(revision=revision): + request = SimpleNamespace( + can_read_body=False, + query={"repo_id": "unit/exact-model", "revision": revision}, + ) + + response = await WebServer.hf_download(server, request) + + self.assertEqual(response.status, 400) + self.assertIn(b"invalid_huggingface_revision", response.body) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_main_supervisor.py b/tests/test_main_supervisor.py index 3e6fcda..11666ed 100644 --- a/tests/test_main_supervisor.py +++ b/tests/test_main_supervisor.py @@ -1,6 +1,9 @@ +import asyncio import importlib.util import os import signal +import sys +from types import ModuleType, SimpleNamespace import unittest from pathlib import Path from unittest.mock import Mock, patch @@ -17,6 +20,53 @@ def load_main_module(): class MainSupervisorTests(unittest.TestCase): + def test_importing_supervisor_never_activates_runtime_overlay(self): + optimization_module = ModuleType("modiff.optimization_packages") + activation = Mock(side_effect=AssertionError("supervisor imported an overlay")) + optimization_module.activate_runtime_overlay = activation + with patch.dict( + sys.modules, {"modiff.optimization_packages": optimization_module} + ): + load_main_module() + activation.assert_not_called() + + def test_worker_activates_overlay_before_server_run(self): + module = load_main_module() + events = [] + started = None + + async def scenario(): + nonlocal started + started = asyncio.Event() + optimization_module = ModuleType("modiff.optimization_packages") + optimization_module.activate_runtime_overlay = lambda: events.append( + "overlay" + ) + + async def server_run(): + events.append("server") + started.set() + + async def cleanup(): + events.append("cleanup") + + server_module = ModuleType("modiff.server") + server_module.server = SimpleNamespace(run=server_run, cleanup=cleanup) + with patch.dict( + sys.modules, + { + "modiff.optimization_packages": optimization_module, + "modiff.server": server_module, + }, + ): + task = asyncio.create_task(module.worker_main()) + await asyncio.wait_for(started.wait(), timeout=2) + task.cancel() + await asyncio.wait_for(task, timeout=2) + + asyncio.run(scenario()) + self.assertEqual(events, ["overlay", "server", "cleanup"]) + def test_supervisor_control_plane_ignores_non_loopback_bind_requests(self): module = load_main_module() worker = Mock(wait=Mock(return_value=0)) diff --git a/tests/test_model_capabilities.py b/tests/test_model_capabilities.py index ac25211..d856918 100644 --- a/tests/test_model_capabilities.py +++ b/tests/test_model_capabilities.py @@ -2,7 +2,16 @@ import unittest import modules as module_registry +from modiff.auto_resource import AUTO_MODEL_REQUIREMENTS +from modiff.diffusers_profiles import ( + CONTRACT_ONLY_DIFFUSERS_PIPELINES, + DIFFUSERS_EXECUTION_PROFILES, +) +from modiff.model_artifact_catalog import catalog_revision from modiff.server import WebServer +from modules.DiffusersAudio.main import AUDIO_PIPELINE_ADAPTERS +from modules.DiffusersImage.main import IMAGE_PIPELINE_ADAPTERS +from modules.DiffusersVideo.main import VIDEO_PIPELINE_ADAPTERS class FakeRequest: @@ -14,15 +23,39 @@ async def test_capabilities_publish_normalized_execution_contract(self): response = await WebServer(module_registry.MODULE_MAP).model_capabilities(FakeRequest()) payload = json.loads(response.text) self.assertEqual(payload["schemaVersion"], 2) - self.assertEqual(len(payload["experimentalCapabilities"]), 5) + self.assertEqual(len(payload["experimentalCapabilities"]), 25) self.assertTrue(all(item["supportTier"] == "experimental" for item in payload["experimentalCapabilities"])) experimental = {item["modelType"]: item for item in payload["experimentalCapabilities"]} self.assertNotIn("DiffusionGemmaForBlockDiffusion", experimental) + # Official Hugging Face libraries may back generic task nodes, but the + # removed library/model-specific driver must not return as a parallel path. self.assertNotIn("modules.TransformersMultimodal", module_registry.MODULE_MAP) self.assertEqual( experimental["Flux2KleinModularPipeline"]["runnableModes"], ["text_to_image", "edit_image", "multi_image_reference_edit"], ) + self.assertEqual( + experimental["FluxModularPipeline"]["runnableModes"], + ["text_to_image", "image_to_image"], + ) + self.assertEqual( + experimental["ZImageModularPipeline"]["backendPath"], + "modules.ModularDiffusers.ModelsLoader", + ) + self.assertEqual( + experimental["ZImageModularPipeline"]["pipelineClasses"], + ["ZImageModularPipeline"], + ) + sdxl = experimental["StableDiffusionXLModularPipeline"] + self.assertEqual(sdxl["qualificationStatus"], "contract_only") + self.assertEqual(sdxl["runnableModes"], ["text_to_image", "image_to_image", "control_image", "inpaint"]) + self.assertEqual(sdxl["pipelineClasses"], ["StableDiffusionXLModularPipeline"]) + self.assertEqual(sdxl["backendPath"], "modules.ModularDiffusers.ModelsLoader") + self.assertEqual(sdxl["executionProfiles"], []) + self.assertFalse(sdxl["autoEligible"]) + self.assertFalse(sdxl["templateEligible"]) + self.assertFalse(sdxl["galleryEligible"]) + self.assertNotIn("StableDiffusionXLModularPipeline", AUTO_MODEL_REQUIREMENTS) for capability in payload["experimentalCapabilities"]: self.assertIn("executionProfiles", capability) self.assertIn("inputContracts", capability) @@ -33,6 +66,176 @@ async def test_capabilities_publish_normalized_execution_contract(self): self.assertIn("quantizationSupport", capability) by_model = {item["modelType"]: item for item in payload["capabilities"]} + self.assertEqual(len(payload["studioExecutionSpecs"]), 39) + for model_type in ( + "FluxSchnellPipeline", + "FluxDevPipeline", + "FluxKreaPipeline", + "FluxDepthPipeline", + "FluxCannyPipeline", + "FluxReduxPipeline", + "FluxKontextPipeline", + "FluxFillPipeline", + "Flux2KleinPipeline", + "WanImageToVideoPipeline", + "WanTI2VPipeline", + "WanVideoPipeline", + "LTXVideoPipeline", + "AceStepAudioPipeline", + "ZImageModularPipeline", + "QwenImageModularPipeline", + ): + self.assertEqual( + by_model[model_type]["studioExecutionSpecs"], + [item for item in payload["studioExecutionSpecs"] if item["modelType"] == model_type], + ) + self.assertEqual( + payload["studioExecutionSpecs"][0]["roles"], + payload["studioExecutionSpecs"][1]["roles"], + ) + self.assertEqual( + payload["studioExecutionSpecs"][0]["edges"], + payload["studioExecutionSpecs"][1]["edges"], + ) + self.assertEqual( + payload["studioExecutionSpecs"][0]["roles"], + payload["studioExecutionSpecs"][2]["roles"], + ) + self.assertEqual( + payload["studioExecutionSpecs"][0]["edges"], + payload["studioExecutionSpecs"][2]["edges"], + ) + depth_spec = by_model["FluxDepthPipeline"]["studioExecutionSpecs"][0] + self.assertEqual(by_model["FluxDepthPipeline"]["modes"], ["control_image"]) + self.assertEqual(depth_spec["mode"], "control_image") + self.assertIn("diffusersImageControl", [item[0] for item in depth_spec["roles"]]) + self.assertIn("loadImage", [item[0] for item in depth_spec["roles"]]) + + canny_spec = by_model["FluxCannyPipeline"]["studioExecutionSpecs"][0] + self.assertEqual(by_model["FluxCannyPipeline"]["modes"], ["control_image"]) + self.assertEqual(canny_spec["mode"], "control_image") + self.assertEqual(canny_spec["roles"], depth_spec["roles"]) + self.assertEqual(canny_spec["edges"], depth_spec["edges"]) + + redux_spec = by_model["FluxReduxPipeline"]["studioExecutionSpecs"][0] + self.assertEqual(by_model["FluxReduxPipeline"]["modes"], ["edit_image"]) + self.assertEqual(redux_spec["mode"], "edit_image") + self.assertIn("diffusersImageEdit", [item[0] for item in redux_spec["roles"]]) + redux_requirement = by_model["FluxReduxPipeline"]["modeRequirements"]["edit_image"][ + "modelRequirements" + ][0] + self.assertEqual(redux_requirement["repo"], "black-forest-labs/FLUX.1-dev") + self.assertEqual( + redux_requirement["revision"], + "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + ) + self.assertEqual( + by_model["FluxReduxPipeline"]["additionalRequirements"][0], + redux_requirement, + ) + + kontext_specs = by_model["FluxKontextPipeline"]["studioExecutionSpecs"] + kontext_spec = next(item for item in kontext_specs if item["mode"] == "edit_image") + self.assertEqual(by_model["FluxKontextPipeline"]["modes"], ["edit_image", "multi_image_reference_edit"]) + self.assertEqual(kontext_spec["mode"], "edit_image") + self.assertEqual(kontext_spec["pipelineClass"], "FluxKontextPipeline") + self.assertIn("diffusersImageEdit", [item[0] for item in kontext_spec["roles"]]) + kontext_multi_spec = next(item for item in kontext_specs if item["mode"] == "multi_image_reference_edit") + self.assertEqual(kontext_multi_spec["pipelineClass"], "FluxKontextPipeline") + self.assertNotEqual(kontext_multi_spec["contentHash"], kontext_spec["contentHash"]) + + fill_specs = by_model["FluxFillPipeline"]["studioExecutionSpecs"] + fill_spec = next(item for item in fill_specs if item["mode"] == "inpaint") + fill_outpaint_spec = next(item for item in fill_specs if item["mode"] == "outpaint") + self.assertEqual(by_model["FluxFillPipeline"]["modes"], ["inpaint", "outpaint"]) + self.assertEqual(by_model["FluxFillPipeline"]["studioExecutionSpecModes"], ["inpaint", "outpaint"]) + self.assertEqual(fill_spec["pipelineClass"], "FluxFillPipeline") + self.assertEqual(fill_outpaint_spec["pipelineClass"], "FluxFillPipeline") + self.assertNotEqual(fill_outpaint_spec["contentHash"], fill_spec["contentHash"]) + self.assertIn("diffusersImageInpaint", [item[0] for item in fill_spec["roles"]]) + self.assertIn("loadMask", [item[0] for item in fill_spec["roles"]]) + + klein_specs = by_model["Flux2KleinPipeline"]["studioExecutionSpecs"] + klein_text = next(item for item in klein_specs if item["mode"] == "text_to_image") + klein_edit = next(item for item in klein_specs if item["mode"] == "edit_image") + klein_multi = next(item for item in klein_specs if item["mode"] == "multi_image_reference_edit") + self.assertEqual(klein_text["id"], "flux2-klein:text-to-image:v1") + self.assertEqual(klein_edit["id"], "flux2-klein:edit-image:v1") + self.assertEqual(klein_multi["id"], "flux2-klein:multi-image-reference-edit:v1") + self.assertEqual(klein_text["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(klein_edit["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(klein_multi["pipelineClass"], "Flux2KleinPipeline") + self.assertNotEqual(klein_text["contentHash"], klein_edit["contentHash"]) + self.assertNotEqual(klein_multi["contentHash"], klein_edit["contentHash"]) + self.assertEqual( + by_model["Flux2KleinPipeline"]["studioExecutionSpecModes"], + ["edit_image", "multi_image_reference_edit", "text_to_image"], + ) + + i2v_spec = by_model["WanImageToVideoPipeline"]["studioExecutionSpecs"][0] + self.assertEqual(i2v_spec["mode"], "image_to_video") + self.assertEqual(i2v_spec["pipelineClass"], "WanImageToVideoPipeline") + self.assertIn("loadImage", [item[0] for item in i2v_spec["roles"]]) + + ti2v_spec = by_model["WanTI2VPipeline"]["studioExecutionSpecs"][0] + self.assertEqual(ti2v_spec["mode"], "text_to_video") + self.assertEqual(ti2v_spec["pipelineClass"], "WanTI2VPipeline") + self.assertIn("wanGenerate", [item[0] for item in ti2v_spec["roles"]]) + for model_type in ( + "FluxSchnellPipeline", + "FluxDevPipeline", + "FluxKreaPipeline", + "FluxDepthPipeline", + "FluxCannyPipeline", + "FluxReduxPipeline", + "FluxKontextPipeline", + "FluxFillPipeline", + "Flux2KleinPipeline", + "WanImageToVideoPipeline", + "WanTI2VPipeline", + "WanVideoPipeline", + "LTXVideoPipeline", + "AceStepAudioPipeline", + ): + capability = by_model[model_type] + self.assertEqual( + capability["studioExecutionSpecModes"], + sorted(item["mode"] for item in capability["studioExecutionSpecs"]), + ) + + z_image = by_model["ZImageModularPipeline"] + self.assertEqual(z_image["pipelineClasses"], ["ZImagePipeline"]) + self.assertEqual( + z_image["executionProfiles"][0]["backend_path"], + "modules.DiffusersImage.LoadPipeline", + ) + self.assertEqual(z_image["executionProfiles"][0]["execution_path"], "direct-diffusers-image") + self.assertNotIn("expert_quantization_modes", z_image["executionProfiles"][0]) + self.assertEqual(z_image["studioExecutionSpecModes"], ["text_to_image"]) + self.assertEqual(z_image["studioExecutionSpecs"][0]["id"], "z-image:text-to-image:v1") + self.assertEqual(z_image["studioExecutionSpecs"][0]["pipelineClass"], "ZImagePipeline") + + qwen_image = by_model["QwenImageModularPipeline"] + self.assertTrue( + all(profile["expert_quantization_modes"] == ["bnb_4bit"] for profile in qwen_image["executionProfiles"]) + ) + self.assertEqual( + by_model["FluxSchnellPipeline"]["executionProfiles"][0]["expert_quantization_modes"], + ["bnb_4bit", "bnb_8bit", "quanto_float8", "torchao_float8"], + ) + self.assertEqual(qwen_image["studioExecutionSpecModes"], ["control_image", "text_to_image"]) + qwen_text_spec = next(item for item in qwen_image["studioExecutionSpecs"] if item["mode"] == "text_to_image") + qwen_control_spec = next(item for item in qwen_image["studioExecutionSpecs"] if item["mode"] == "control_image") + self.assertEqual(qwen_text_spec["id"], "qwen-image-2512:text-to-image:v1") + self.assertEqual(qwen_text_spec["pipelineClass"], "QwenImagePipeline") + self.assertEqual(qwen_control_spec["executionProfileId"], "qwen-image:modular") + self.assertEqual(qwen_control_spec["pipelineClass"], "QwenImageModularPipeline") + self.assertIn(["controlnetModel", "revision", "revision"], qwen_control_spec["bindings"]) + self.assertIn( + ["controlnet", "route_state_out", "denoise", "route_state_in"], + qwen_control_spec["edges"], + ) + wan = by_model["WanVACEPipeline"] self.assertEqual(wan["mediaKind"], "video") self.assertEqual(wan["pipelineClasses"], ["WanVACEPipeline"]) @@ -57,6 +260,151 @@ async def test_capabilities_publish_normalized_execution_contract(self): self.assertEqual(wan_v2v["modes"], ["video_to_video", "video_color_edit"]) wan_t2v = next(profile for profile in wan_video["executionProfiles"] if profile["id"] == "wan-text-to-video:direct") self.assertEqual(wan_t2v["modes"], ["text_to_video"]) + self.assertEqual( + wan_video["studioExecutionSpecModes"], + ["text_to_video", "video_color_edit", "video_to_video"], + ) + wan_text_spec = next(item for item in wan_video["studioExecutionSpecs"] if item["mode"] == "text_to_video") + wan_video_spec = next(item for item in wan_video["studioExecutionSpecs"] if item["mode"] == "video_to_video") + wan_color_spec = next(item for item in wan_video["studioExecutionSpecs"] if item["mode"] == "video_color_edit") + self.assertEqual(wan_text_spec["pipelineClass"], "WanPipeline") + self.assertEqual(wan_video_spec["pipelineClass"], "WanVideoToVideoPipeline") + self.assertEqual(wan_color_spec["pipelineClass"], "WanVideoToVideoPipeline") + self.assertIn("loadVideo", [item[0] for item in wan_video_spec["roles"]]) + self.assertIn("normalizeVideo", [item[0] for item in wan_video_spec["roles"]]) + self.assertEqual(wan_color_spec["roles"], wan_video_spec["roles"]) + self.assertEqual(wan_color_spec["edges"], wan_video_spec["edges"]) + self.assertEqual(wan_color_spec["bindings"], wan_video_spec["bindings"]) + ltx = by_model["LTXVideoPipeline"] + self.assertEqual( + ltx["studioExecutionSpecModes"], + ["image_to_video", "reference_to_video", "text_to_video", "video_to_video"], + ) + for ltx_spec in ltx["studioExecutionSpecs"]: + self.assertEqual(ltx_spec["pipelineClass"], "LTXConditionPipeline") + self.assertIn(["diffusersRecipe", "attention_backend", "nativeMath"], ltx_spec["bindings"]) + self.assertNotIn(["wanGenerate", "scheduler_flow_shift", "shift"], ltx_spec["bindings"]) + ltx_image_spec = next(item for item in ltx["studioExecutionSpecs"] if item["mode"] == "image_to_video") + self.assertIn("loadImage", [item[0] for item in ltx_image_spec["roles"]]) + self.assertIn(["loadImage", "image", "wanGenerate", "reference_images"], ltx_image_spec["edges"]) + ltx_video_spec = next(item for item in ltx["studioExecutionSpecs"] if item["mode"] == "video_to_video") + self.assertIn("loadVideo", [item[0] for item in ltx_video_spec["roles"]]) + self.assertIn(["normalizeVideo", "output", "wanGenerate", "video"], ltx_video_spec["edges"]) + self.assertIn(["wanGenerate", "strength", "conditioningScale"], ltx_video_spec["bindings"]) + self.assertIn(["wanGenerate", "denoise_strength", "strength"], ltx_video_spec["bindings"]) + ltx_reference_spec = next(item for item in ltx["studioExecutionSpecs"] if item["mode"] == "reference_to_video") + self.assertEqual(ltx_reference_spec["roles"], ltx_image_spec["roles"]) + self.assertEqual(ltx_reference_spec["edges"], ltx_image_spec["edges"]) + self.assertEqual(ltx_reference_spec["bindings"], ltx_image_spec["bindings"]) + ace = by_model["AceStepAudioPipeline"] + self.assertEqual( + ace["studioExecutionSpecModes"], + ["audio_continuation", "audio_repaint", "audio_variation", "text_to_audio"], + ) + ace_text_spec = next(item for item in ace["studioExecutionSpecs"] if item["mode"] == "text_to_audio") + ace_variation_spec = next(item for item in ace["studioExecutionSpecs"] if item["mode"] == "audio_variation") + ace_continuation_spec = next( + item for item in ace["studioExecutionSpecs"] if item["mode"] == "audio_continuation" + ) + ace_repaint_spec = next(item for item in ace["studioExecutionSpecs"] if item["mode"] == "audio_repaint") + self.assertEqual(ace_text_spec["pipelineClass"], "AceStepPipeline") + self.assertEqual( + [item[0] for item in ace_text_spec["roles"]], + ["diffusersQuantization", "diffusersRecipe", "audioPipeline", "audioGenerate", "audioExport"], + ) + self.assertIn(["audioGenerate", "task_type", "text2music"], ace_text_spec["bindings"]) + self.assertIn(["audioGenerate", "audio", "audioExport", "audio"], ace_text_spec["edges"]) + self.assertEqual(ace_variation_spec["pipelineClass"], "AceStepPipeline") + self.assertIn("loadAudio", [item[0] for item in ace_variation_spec["roles"]]) + self.assertIn(["loadAudio", "file", "sourceAudio"], ace_variation_spec["bindings"]) + self.assertIn(["audioGenerate", "task_type", "cover"], ace_variation_spec["bindings"]) + self.assertIn( + ["loadAudio", "audio", "audioGenerate", "source_audio"], + ace_variation_spec["edges"], + ) + self.assertNotEqual(ace_variation_spec["contentHash"], ace_text_spec["contentHash"]) + self.assertEqual(ace_continuation_spec["pipelineClass"], "AceStepPipeline") + self.assertIn("audioLoudnessMatch", [item[0] for item in ace_continuation_spec["roles"]]) + self.assertIn("audioJoin", [item[0] for item in ace_continuation_spec["roles"]]) + self.assertIn( + ["audioGenerate", "task_type", "continuation"], + ace_continuation_spec["bindings"], + ) + self.assertIn( + ["audioJoin", "output", "audioExport", "audio"], + ace_continuation_spec["edges"], + ) + self.assertNotEqual(ace_continuation_spec["contentHash"], ace_variation_spec["contentHash"]) + self.assertEqual(ace_repaint_spec["roles"], ace_variation_spec["roles"]) + self.assertEqual(ace_repaint_spec["edges"], ace_variation_spec["edges"]) + self.assertIn(["audioGenerate", "task_type", "repaint"], ace_repaint_spec["bindings"]) + self.assertNotEqual(ace_repaint_spec["contentHash"], ace_continuation_spec["contentHash"]) + self.assertEqual( + ace["runnableModes"], + ["audio_continuation", "audio_repaint", "audio_variation", "text_to_audio"], + ) + self.assertEqual( + by_model["FluxKontextPipeline"]["studioExecutionSpecModes"], + ["edit_image", "multi_image_reference_edit"], + ) + qwen_edit = by_model["QwenImageEditModularPipeline"] + self.assertEqual(qwen_edit["studioExecutionSpecModes"], ["edit_image", "inpaint", "outpaint"]) + qwen_inpaint_spec, qwen_outpaint_spec, qwen_edit_spec = qwen_edit["studioExecutionSpecs"] + self.assertEqual(qwen_inpaint_spec["executionProfileId"], "qwen-edit:direct-inpaint") + self.assertEqual(qwen_inpaint_spec["pipelineClass"], "QwenImageEditInpaintPipeline") + self.assertIn("loadMask", [item[0] for item in qwen_inpaint_spec["roles"]]) + self.assertIn( + ["loadMask", "image", "diffusersImageInpaint", "mask_image"], + qwen_inpaint_spec["edges"], + ) + self.assertEqual(qwen_outpaint_spec["executionProfileId"], "qwen-edit:direct-inpaint") + self.assertIn("qwenOutpaintCanvas", [item[0] for item in qwen_outpaint_spec["roles"]]) + self.assertIn( + ["qwenOutpaintCanvas", "mask_image", "diffusersImageInpaint", "mask_image"], + qwen_outpaint_spec["edges"], + ) + self.assertEqual(qwen_edit_spec["executionProfileId"], "qwen-edit:modular") + self.assertEqual(qwen_edit_spec["executionPath"], "modular-diffusers") + self.assertEqual(qwen_edit_spec["pipelineClass"], "QwenImageEditModularPipeline") + self.assertIn("models", [item[0] for item in qwen_edit_spec["roles"]]) + self.assertIn(["imageEncode", "image_latents", "denoise", "image_latents"], qwen_edit_spec["edges"]) + qwen_layered = by_model["QwenImageLayeredModularPipeline"] + self.assertEqual(qwen_layered["studioExecutionSpecModes"], ["layer_decomposition"]) + qwen_layered_spec = qwen_layered["studioExecutionSpecs"][0] + self.assertEqual(qwen_layered_spec["executionProfileId"], "qwen-layered:modular") + self.assertEqual(qwen_layered_spec["executionPath"], "modular-diffusers") + self.assertEqual(qwen_layered_spec["pipelineClass"], "QwenImageLayeredModularPipeline") + self.assertIn(["denoise", "layers", "layers"], qwen_layered_spec["bindings"]) + wan_vace = by_model["WanVACEPipeline"] + self.assertEqual( + wan_vace["studioExecutionSpecModes"], + ["control_to_video", "text_to_video", "video_inpaint", "video_outpaint"], + ) + wan_vace_text_spec, wan_vace_inpaint_spec, wan_vace_outpaint_spec, wan_vace_control_spec = wan_vace[ + "studioExecutionSpecs" + ] + self.assertEqual(wan_vace_control_spec["mode"], "control_to_video") + self.assertIn(["loadControlVideo", "file", "controlVideo"], wan_vace_control_spec["bindings"]) + self.assertIn( + ["normalizeVideo", "output", "wanGenerate", "video"], + wan_vace_control_spec["edges"], + ) + self.assertEqual(wan_vace_text_spec["executionProfileId"], "wan-vace:direct") + self.assertEqual(wan_vace_text_spec["executionPath"], "direct-wan-vace") + self.assertEqual(wan_vace_text_spec["pipelineClass"], "WanVACEPipeline") + self.assertIn(["wanGenerate", "mode", "mode"], wan_vace_text_spec["bindings"]) + self.assertEqual(wan_vace_inpaint_spec["mode"], "video_inpaint") + self.assertIn(["loadMaskVideo", "file", "maskVideo"], wan_vace_inpaint_spec["bindings"]) + self.assertIn( + ["alignMaskVideo", "output", "wanGenerate", "mask"], + wan_vace_inpaint_spec["edges"], + ) + self.assertEqual(wan_vace_outpaint_spec["mode"], "video_outpaint") + self.assertIn( + ["alignMaskVideo", "grow_pixels", "outpaintMaskGrow0"], + wan_vace_outpaint_spec["bindings"], + ) + self.assertNotEqual(wan_vace_outpaint_spec["contentHash"], wan_vace_inpaint_spec["contentHash"]) ltx = by_model["LTXVideoPipeline"] self.assertEqual(ltx["mediaKind"], "video") @@ -97,9 +445,93 @@ async def test_capabilities_publish_normalized_execution_contract(self): qwen_inpaint = by_model["QwenImageEditModularPipeline"] self.assertEqual(qwen_inpaint["inpaintContract"]["source"], "modules.DiffusersImage.Inpaint") self.assertIn("QwenImageEditInpaintPipeline", qwen_inpaint["pipelineClasses"]) + self.assertEqual(qwen_inpaint["modes"], ["edit_image", "inpaint", "outpaint"]) + self.assertEqual(qwen_inpaint["runnableModes"], ["edit_image", "inpaint", "outpaint"]) + + qwen_control = by_model["QwenImageModularPipeline"] + control_requirement = qwen_control["modeRequirements"]["control_image"]["modelRequirements"][0] + self.assertEqual(control_requirement["repo"], "InstantX/Qwen-Image-ControlNet-Union") + self.assertEqual(control_requirement["revision"], "b13036f066d6dee7c20513e263d3d673055e9de8") + self.assertEqual(qwen_control["additionalRequirements"][0], control_requirement) blocked = by_model["QwenImageEditPlusModularPipeline"] + self.assertEqual(blocked["modes"], ["edit_image", "multi_image_reference_edit"]) + self.assertEqual(blocked["runnableModes"], ["edit_image", "multi_image_reference_edit"]) self.assertNotIn("inpaint", blocked["runnableModes"]) + self.assertFalse(blocked["inpaintContract"]["available"]) + self.assertEqual(blocked["inpaintContract"]["status"], "blocked") + self.assertEqual(blocked["studioExecutionSpecModes"], ["edit_image", "multi_image_reference_edit"]) + self.assertEqual( + [item["executionProfileId"] for item in blocked["studioExecutionSpecs"]], + ["qwen-edit-plus:modular", "qwen-edit-plus:modular"], + ) + self.assertTrue(all(item["executionPath"] == "modular-diffusers" for item in blocked["studioExecutionSpecs"])) + + async def test_contract_only_capabilities_close_registered_unprofiled_adapters(self): + response = await WebServer(module_registry.MODULE_MAP).model_capabilities(FakeRequest()) + payload = json.loads(response.text) + published = { + item["modelType"]: item + for item in payload["experimentalCapabilities"] + if item.get("qualificationStatus") == "contract_only" + and item.get("executionKind") == "standard" + } + + profiled_classes = { + profile.pipeline_class for profile in DIFFUSERS_EXECUTION_PROFILES.values() + } + adapters_by_media = { + "image": IMAGE_PIPELINE_ADAPTERS, + "video": VIDEO_PIPELINE_ADAPTERS, + "audio": AUDIO_PIPELINE_ADAPTERS, + } + expected_classes = { + pipeline_class + for adapters in adapters_by_media.values() + for pipeline_class in adapters + if pipeline_class not in profiled_classes + } + declared_classes = { + pipeline_class + for pipeline_class, _media_kind, _repo, _modes in CONTRACT_ONLY_DIFFUSERS_PIPELINES + } + self.assertEqual(declared_classes, expected_classes) + self.assertEqual(set(published), expected_classes) + self.assertTrue(expected_classes.isdisjoint(AUTO_MODEL_REQUIREMENTS)) + self.assertTrue( + expected_classes.isdisjoint( + capability["modelType"] for capability in payload["capabilities"] + ) + ) + + for pipeline_class, media_kind, repository, declared_modes in CONTRACT_ONLY_DIFFUSERS_PIPELINES: + with self.subTest(pipeline_class=pipeline_class): + adapter = adapters_by_media[media_kind][pipeline_class] + adapter_modes = ( + adapter.mode_options + if media_kind == "image" + else adapter.modes + ) + capability = published[pipeline_class] + self.assertEqual(tuple(declared_modes), tuple(adapter_modes)) + self.assertEqual(repository, adapter.default_repo) + self.assertEqual(capability["pipelineClasses"], [pipeline_class]) + self.assertEqual(capability["runnableModes"], list(adapter_modes)) + self.assertEqual(capability["mediaKind"], media_kind) + self.assertEqual( + capability["backendPath"], + f"modules.Diffusers{media_kind.title()}.LoadPipeline", + ) + self.assertEqual(capability["artifactCandidates"], [adapter.default_repo]) + self.assertEqual( + capability["revisionCandidates"], + [catalog_revision(adapter.default_repo)], + ) + self.assertFalse(capability["autoEligible"]) + self.assertFalse(capability["templateEligible"]) + self.assertFalse(capability["galleryEligible"]) + self.assertEqual(capability["executionProfiles"], []) + self.assertNotIn("optionalRuntimeRequirement", capability) if __name__ == "__main__": diff --git a/tests/test_modular_diffusers_upstream_contract.py b/tests/test_modular_diffusers_upstream_contract.py index ffe3c94..cce3efa 100644 --- a/tests/test_modular_diffusers_upstream_contract.py +++ b/tests/test_modular_diffusers_upstream_contract.py @@ -4,21 +4,53 @@ import diffusers import torch -from diffusers import ComponentSpec, ComponentsManager, ModularPipeline +from diffusers import ComponentSpec, ComponentsManager, EulerDiscreteScheduler, ModularPipeline from diffusers.modular_pipelines import InputParam, LoopSequentialPipelineBlocks, ModularPipelineBlocks, OutputParam -from modules.ModularDiffusers.modular_utils import get_all_model_types -from modules.ModularDiffusers import FLUX_BLOCKS, QWEN_IMAGE_BLOCKS, SDXL_BLOCKS -from modules.ModularDiffusers.denoise import Denoise +from modiff.diffusers_profiles import public_execution_profiles, public_experimental_pipelines +from modules import MODULE_MAP +from modules.ModularDiffusers.modular_utils import ( + get_all_model_types, + get_modular_guider_options, + get_modular_layer_block_options, + get_modular_scheduler_options, + get_model_type_metadata, + require_modiff_node_contract, +) +from modules.ModularDiffusers import ( + FLUX_BLOCKS, + MODULAR_GUIDER_OPTIONS, + MODULAR_LAYER_BLOCK_OPTIONS, + MODULAR_SCHEDULER_OPTIONS, + QWEN_IMAGE_BLOCKS, + SDXL_BLOCKS, +) +from modules.ModularDiffusers.controlnet import Controlnet +from modules.ModularDiffusers.denoise import Denoise, _apply_image_latent_dimension_contract from modules.ModularDiffusers.dynamic_node import DynamicBlockNode -from modules.ModularDiffusers.guiders import Guider, Layers -from modules.ModularDiffusers.guiders import GUIDER_OPTIONS -from modules.ModularDiffusers.loaders import AutoModelLoader, ModelsLoader, QuantizationConfigNode +from modules.ModularDiffusers.embeddings import EncodePrompt, ImageEmbeddings +from modules.ModularDiffusers.guiders import GUIDER_CONFIGS, GUIDER_OPTIONS, LAYER_CONFIG_MAPPING, Guider, Layers +from modules.ModularDiffusers.ip_adapter import IPAdapter +from modules.ModularDiffusers.latents import DecodeLatents, ImageEncode +from modules.ModularDiffusers.loaders import ( + AutoModelLoader, + ModelsLoader, + QuantizationConfigNode, + _reviewed_loader_component_outputs, +) from modules.ModularDiffusers.pipeline_schema import ( + MoDiffParam, MoDiffPipelineConfig, input_param_to_modiff_param, output_param_to_modiff_param, ) +from modules.ModularDiffusers.route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + bind_standalone_component_output, + issue_standalone_component_issuer, +) +from modules.ModularDiffusers.schedulers import SCHEDULER_CONFIGS, Scheduler _NO_EXPLICIT_GUIDER = object() @@ -27,6 +59,10 @@ class ModularDiffusersUpstreamContractTests(unittest.TestCase): """Hardware-free checks for the experimental upstream API MoDiff consumes.""" + def _run_guider(self, node, guider, *, model_type="QwenImageModularPipeline", **kwargs): + with patch.object(Guider, "get_signal_value", return_value=model_type): + return node.execute(guider, **kwargs) + def _run_denoise_guider_contract(self, *, guider=_NO_EXPLICIT_GUIDER, pipeline_components=("guider",)): pipeline = MagicMock() pipeline.component_names = list(pipeline_components) @@ -55,7 +91,7 @@ def _run_denoise_guider_contract(self, *, guider=_NO_EXPLICIT_GUIDER, pipeline_c node._pipeline_class = object() with ( patch( - "modules.ModularDiffusers.denoise.pipeline_class_to_modiff_node_config", + "modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(blocks, node_config), ), patch("modules.ModularDiffusers.denoise.deepcopy", return_value=blocks), @@ -91,16 +127,98 @@ def test_public_guider_registry_exposes_resolved_options_mapping(self): self.assertIsInstance(options, dict) self.assertEqual(options, GUIDER_OPTIONS) + def test_pinned_guider_exports_and_constructor_signatures_are_exact(self): + expected_signatures = { + "AdaptiveProjectedMixGuidance": [ + "guidance_scale", + "guidance_rescale", + "adaptive_projected_guidance_scale", + "adaptive_projected_guidance_momentum", + "adaptive_projected_guidance_rescale", + "eta", + "use_original_formulation", + "start", + "stop", + "adaptive_projected_guidance_start_step", + "enabled", + ], + "PerturbedAttentionGuidance": [ + "guidance_scale", + "perturbed_guidance_scale", + "perturbed_guidance_start", + "perturbed_guidance_stop", + "perturbed_guidance_layers", + "perturbed_guidance_config", + "guidance_rescale", + "use_original_formulation", + "start", + "stop", + "enabled", + ], + } + expected_defaults = { + "AdaptiveProjectedMixGuidance": { + "guidance_scale": 3.5, + "adaptive_projected_guidance_scale": 10.0, + "adaptive_projected_guidance_momentum": -0.5, + "adaptive_projected_guidance_rescale": 10.0, + "eta": 0.0, + "adaptive_projected_guidance_start_step": 5, + }, + "PerturbedAttentionGuidance": { + "guidance_scale": 7.5, + "perturbed_guidance_scale": 2.8, + "perturbed_guidance_start": 0.01, + "perturbed_guidance_stop": 0.2, + "perturbed_guidance_layers": None, + "perturbed_guidance_config": None, + }, + } + + for guider_name, expected_parameters in expected_signatures.items(): + with self.subTest(guider=guider_name): + self.assertTrue(hasattr(diffusers, guider_name)) + signature = inspect.signature(getattr(diffusers, guider_name)) + self.assertEqual(list(signature.parameters), expected_parameters) + for parameter_name, default in expected_defaults[guider_name].items(): + self.assertEqual(signature.parameters[parameter_name].default, default) + + self.assertIn("AdaptiveProjectedMixGuidance", GUIDER_OPTIONS) + self.assertIn("PerturbedAttentionGuidance", GUIDER_OPTIONS) + self.assertNotIn("MagnitudeAwareGuidance", GUIDER_OPTIONS) + self.assertFalse(hasattr(diffusers, "MagnitudeAwareGuidance")) + self.assertEqual( + set(GUIDER_CONFIGS["AdaptiveProjectedMixGuidance"]), + { + "adaptive_projected_guidance_scale", + "adaptive_projected_guidance_momentum", + "adaptive_projected_guidance_rescale", + "eta", + "adaptive_projected_guidance_start_step", + }, + ) + self.assertEqual( + set(GUIDER_CONFIGS["PerturbedAttentionGuidance"]), + {"perturbed_guidance_scale", "perturbed_guidance_start", "perturbed_guidance_stop"}, + ) + self.assertEqual( + LAYER_CONFIG_MAPPING["PerturbedAttentionGuidance"], + "perturbed_guidance_config", + ) + def test_reviewed_dynamic_block_resolves_its_catalog_revision(self): node = DynamicBlockNode("dynamic-revision-probe") + verified = MagicMock() + verified.config = object() with patch( - "modules.ModularDiffusers.dynamic_node.PipelineConfig.load", - return_value=object(), - ) as load_config: + "modules.ModularDiffusers.dynamic_node.PipelineConfig.load_verified", + return_value=verified, + ) as load_verified: node._get_custom_config("diffusers/FLUX.2-klein-4B-modular") - load_config.assert_called_once_with( + load_verified.assert_called_once_with( "diffusers/FLUX.2-klein-4B-modular", + source="hub", revision="62ac375aa5308588f111fcd12115f5c54a8b1f4f", ) @@ -109,9 +227,13 @@ def test_models_loader_resolves_known_base_revision(self): with ( patch("modules.ModularDiffusers.loaders.configure_components_manager_offload"), patch( - "modules.ModularDiffusers.loaders.ModularPipeline.from_pretrained", + "modules.ModularDiffusers.loaders._validate_reviewed_pipeline_index", + return_value=("model_index.json", {"_class_name": "ZImagePipeline"}), + ) as validate_index, + patch( + "modules.ModularDiffusers.loaders._instantiate_reviewed_builtin_pipeline", side_effect=RuntimeError("stop after loader call"), - ) as loader, + ), ): with self.assertRaisesRegex(RuntimeError, "stop after loader call"): node.execute( @@ -124,8 +246,9 @@ def test_models_loader_resolves_known_base_revision(self): offload_mode="none", ) - self.assertEqual( - loader.call_args.kwargs["revision"], + validate_index.assert_called_once_with( + "ZImageModularPipeline", + "Tongyi-MAI/Z-Image-Turbo", "f332072aa78be7aecdf3ee76d5c247082da564a6", ) @@ -178,13 +301,117 @@ def test_modiff_schema_round_trip_does_not_require_model_weights(self): label="Contract fixture", default_repo="local/fixture", default_dtype="bfloat16", + loader_component_outputs=("image_encoder",), + layer_block_options=("transformer_blocks",), + guider_options=("ClassifierFreeGuidance",), + scheduler_options=("EulerDiscreteScheduler",), + denoise_image_latent_dimensions=("height", "width"), ) restored = MoDiffPipelineConfig.from_dict(config.to_dict()) self.assertEqual(restored.to_dict(), config.to_dict()) + self.assertEqual(restored.loader_component_outputs, ("image_encoder",)) + self.assertEqual(restored.layer_block_options, ("transformer_blocks",)) + self.assertEqual(restored.guider_options, ("ClassifierFreeGuidance",)) + self.assertEqual(restored.scheduler_options, ("EulerDiscreteScheduler",)) + self.assertEqual(restored.denoise_image_latent_dimensions, ("height", "width")) self.assertEqual(restored.node_params["encode"]["block_name"], "text_encoder") self.assertIn("prompt", restored.node_params["encode"]["params"]) + def test_models_loader_component_outputs_come_from_reviewed_pipeline_metadata(self): + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + expected = {"WanImage2VideoModularPipeline": ("image_encoder",)} + + for model_type in sorted(registered): + with self.subTest(model_type=model_type): + outputs = _reviewed_loader_component_outputs(model_type) + self.assertEqual(outputs, expected.get(model_type, ())) + self.assertEqual(get_model_type_metadata(model_type)["loader_component_outputs"], list(outputs)) + + self.assertNotIn( + 'model_type == "WanImage2VideoModularPipeline"', + inspect.getsource(ModelsLoader.execute), + ) + + def test_models_loader_rejects_malformed_component_output_metadata(self): + with patch( + "modules.ModularDiffusers.loaders.get_model_type_metadata", + return_value={"loader_component_outputs": ["not_a_loader_output"]}, + ): + with self.assertRaisesRegex(RuntimeError, "invalid loader component output contract"): + _reviewed_loader_component_outputs("FixturePipeline") + + with self.assertRaisesRegex(ValueError, "list or tuple"): + MoDiffPipelineConfig(node_specs={}, loader_component_outputs="image_encoder") + with self.assertRaisesRegex(ValueError, "list or tuple"): + MoDiffPipelineConfig(node_specs={}, layer_block_options="transformer_blocks") + with self.assertRaisesRegex(ValueError, "list or tuple"): + MoDiffPipelineConfig(node_specs={}, guider_options="ClassifierFreeGuidance") + with self.assertRaisesRegex(ValueError, "list or tuple"): + MoDiffPipelineConfig(node_specs={}, scheduler_options="EulerDiscreteScheduler") + with self.assertRaisesRegex(ValueError, "list or tuple"): + MoDiffPipelineConfig(node_specs={}, denoise_image_latent_dimensions="height") + + def test_denoise_image_latent_dimensions_come_from_reviewed_pipeline_metadata(self): + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + retained = { + "Flux2KleinModularPipeline", + "FluxKontextModularPipeline", + "QwenImageEditModularPipeline", + "QwenImageEditPlusModularPipeline", + } + + for model_type in sorted(registered): + with self.subTest(model_type=model_type): + expected = ["height", "width"] if model_type in retained else [] + self.assertEqual( + get_model_type_metadata(model_type)["denoise_image_latent_dimensions"], + expected, + ) + node_kwargs = { + "image_latents": object(), + "height": 640, + "width": 768, + } + _apply_image_latent_dimension_contract(model_type, node_kwargs) + self.assertEqual( + {name for name in ("height", "width") if name in node_kwargs}, + set(expected), + ) + + denoise_source = inspect.getsource(Denoise.execute) + for model_type in retained: + self.assertNotIn(model_type, denoise_source) + + def test_denoise_image_latent_dimension_contract_is_bounded_and_fail_closed(self): + node_kwargs = {"image_latents": object(), "height": 640, "width": 768} + with patch( + "modules.ModularDiffusers.denoise.get_model_type_metadata", + return_value={"denoise_image_latent_dimensions": ["height"]}, + ): + _apply_image_latent_dimension_contract("FixturePipeline", node_kwargs) + self.assertEqual(node_kwargs, {"image_latents": node_kwargs["image_latents"], "height": 640}) + + for invalid in ("height", ["depth"], ["height", "height"], ["height", "width", "depth"]): + with ( + self.subTest(invalid=invalid), + patch( + "modules.ModularDiffusers.denoise.get_model_type_metadata", + return_value={"denoise_image_latent_dimensions": invalid}, + ), + ): + with self.assertRaisesRegex(RuntimeError, "invalid image-latent dimension contract"): + _apply_image_latent_dimension_contract( + "FixturePipeline", + {"image_latents": object(), "height": 640, "width": 768}, + ) + + without_latents = {"image_latents": None, "height": 640, "width": 768} + with patch("modules.ModularDiffusers.denoise.get_model_type_metadata") as metadata: + _apply_image_latent_dimension_contract("FixturePipeline", without_latents) + metadata.assert_not_called() + self.assertEqual(without_latents, {"image_latents": None, "height": 640, "width": 768}) + def test_required_pipeline_registry_matches_installed_diffusers(self): required = { "StableDiffusionXLModularPipeline", @@ -205,6 +432,676 @@ def test_required_pipeline_registry_matches_installed_diffusers(self): registered = set(get_all_model_types()) self.assertTrue(required.issubset(registered), f"MoDiff registry is missing: {sorted(required - registered)}") + def test_registered_pipeline_action_matrix_resolves_real_contracts(self): + expected = { + "StableDiffusionXLModularPipeline": { + "controlnet", + "decoder", + "denoise", + "ip_adapter", + "text_encoder", + "vae_encoder", + }, + "QwenImageModularPipeline": {"controlnet", "decoder", "denoise", "text_encoder", "vae_encoder"}, + "QwenImageEditModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "QwenImageEditPlusModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "QwenImageLayeredModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "FluxModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "FluxKontextModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "Flux2KleinModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "ZImageModularPipeline": {"decoder", "denoise", "text_encoder", "vae_encoder"}, + "WanModularPipeline": {"decoder", "denoise", "text_encoder"}, + "WanImage2VideoModularPipeline": { + "decoder", + "denoise", + "image_encoder", + "text_encoder", + "vae_encoder", + }, + } + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + self.assertEqual(registered, set(expected)) + + actions = {"controlnet", "decoder", "denoise", "image_encoder", "ip_adapter", "text_encoder", "vae_encoder"} + for model_type, supported_actions in expected.items(): + pipeline_class = getattr(diffusers, model_type) + metadata = get_model_type_metadata(model_type) + actual_actions = {name for name, config in metadata["node_params"].items() if config is not None} + self.assertEqual(actual_actions, supported_actions) + + for action in actions: + with self.subTest(model_type=model_type, action=action): + if action in supported_actions: + blocks, node_config = require_modiff_node_contract( + pipeline_class, + action, + require_blocks=action != "controlnet", + ) + self.assertIsNotNone(node_config) + if action != "controlnet": + self.assertIsNotNone(blocks) + else: + with self.assertRaisesRegex(ValueError, "does not support the generic"): + require_modiff_node_contract( + pipeline_class, + action, + require_blocks=action != "controlnet", + ) + + def test_qwen_layered_targeted_controls_match_the_pinned_upstream_contract_without_weights(self): + expected_defaults = { + "text_encoder": { + "resolution": 640, + "use_en_prompt": False, + "max_sequence_length": 1024, + }, + "vae_encoder": {"resolution": 640}, + } + expected_modiff_inputs = { + "text_encoder": { + "prompt", + "negative_prompt", + "image", + "resolution", + "use_en_prompt", + "max_sequence_length", + }, + "vae_encoder": {"image", "resolution", "seed"}, + } + runtime_input_aliases = { + "text_encoder": {}, + "vae_encoder": {"seed": "generator"}, + } + + for action, defaults in expected_defaults.items(): + with self.subTest(action=action): + blocks, node_config = require_modiff_node_contract( + diffusers.QwenImageLayeredModularPipeline, + action, + ) + upstream_inputs = {param.name: param for param in blocks.inputs} + aliases = runtime_input_aliases[action] + self.assertEqual( + set(blocks.input_names), + (expected_modiff_inputs[action] - set(aliases)) | set(aliases.values()), + ) + self.assertEqual(set(node_config["input_names"]), expected_modiff_inputs[action]) + for field_name, default in defaults.items(): + self.assertIn(field_name, upstream_inputs) + self.assertEqual(upstream_inputs[field_name].default, default) + + metadata = get_model_type_metadata("QwenImageLayeredModularPipeline") + text_params = metadata["node_params"]["text_encoder"]["params"] + vae_params = metadata["node_params"]["vae_encoder"]["params"] + + for params in (text_params, vae_params): + self.assertEqual( + params["resolution"], + { + "label": "Source Resolution", + "type": "int", + "default": 640, + "options": [640, 1024], + "fieldOptions": { + "controlTier": "advanced", + "studioBinding": { + "schemaVersion": 1, + "group": "source-resolution", + "formFields": ["width", "height"], + "transform": "nearest-option-to-long-edge", + }, + }, + }, + ) + self.assertEqual(text_params["use_en_prompt"]["default"], False) + self.assertEqual(text_params["use_en_prompt"]["type"], "boolean") + self.assertEqual( + vae_params["seed"], + { + "label": "Seed", + "type": "int", + "default": 0, + "min": 0, + "max": 4294967295, + "display": "random", + }, + ) + self.assertEqual( + text_params["max_sequence_length"], + { + "label": "Maximum Sequence Length", + "type": "int", + "default": 1024, + "min": 1, + "max": 1024, + "step": 1, + "fieldOptions": { + "controlTier": "advanced", + "studioBinding": { + "schemaVersion": 1, + "group": "maximum-sequence-length", + "formFields": ["maxSequenceLength"], + "transform": "identity", + }, + }, + }, + ) + + def test_registered_encoder_seed_ports_exactly_match_upstream_generator_inputs(self): + expected_generator_actions = { + ("StableDiffusionXLModularPipeline", "vae_encoder"), + ("QwenImageModularPipeline", "vae_encoder"), + ("QwenImageEditModularPipeline", "vae_encoder"), + ("QwenImageEditPlusModularPipeline", "vae_encoder"), + ("QwenImageLayeredModularPipeline", "vae_encoder"), + ("FluxModularPipeline", "vae_encoder"), + ("FluxKontextModularPipeline", "vae_encoder"), + ("Flux2KleinModularPipeline", "vae_encoder"), + ("ZImageModularPipeline", "vae_encoder"), + ("WanImage2VideoModularPipeline", "vae_encoder"), + } + actual_generator_actions = set() + + for model_type in sorted(set(get_all_model_types()) - {"", "DummyCustomPipeline"}): + metadata = get_model_type_metadata(model_type) + for action in ("vae_encoder", "image_encoder"): + if metadata["node_params"].get(action) is None: + continue + with self.subTest(model_type=model_type, action=action): + blocks, node_config = require_modiff_node_contract(getattr(diffusers, model_type), action) + upstream_has_generator = "generator" in blocks.input_names + graph_has_seed = "seed" in node_config["input_names"] + self.assertEqual(graph_has_seed, upstream_has_generator) + self.assertNotIn("generator", node_config["input_names"]) + self.assertNotIn("generator", node_config["params"]) + if upstream_has_generator: + actual_generator_actions.add((model_type, action)) + self.assertEqual( + node_config["params"]["seed"], + { + "label": "Seed", + "type": "int", + "default": 0, + "min": 0, + "max": 4294967295, + "display": "random", + }, + ) + + self.assertEqual(actual_generator_actions, expected_generator_actions) + + def test_registered_denoise_component_ports_exactly_match_upstream_blocks(self): + port_components = { + "unet": {"transformer", "unet"}, + "vae": {"vae"}, + "scheduler": {"scheduler"}, + "guider": {"guider"}, + "controlnet_bundle": {"controlnet"}, + } + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + provenance_only_ports = {"WanImage2VideoModularPipeline": {"vae"}} + self.assertEqual(MoDiffParam.guider().required_block_params, ["guider"]) + + for model_type in sorted(registered): + with self.subTest(model_type=model_type): + blocks, node_config = require_modiff_node_contract(getattr(diffusers, model_type), "denoise") + upstream_components = set(blocks.component_names) + expected_ports = { + port for port, component_names in port_components.items() if component_names & upstream_components + } + expected_ports.update(provenance_only_ports.get(model_type, ())) + self.assertEqual(set(node_config["model_input_names"]), expected_ports) + + wan_blocks, wan_config = require_modiff_node_contract( + diffusers.WanImage2VideoModularPipeline, + "denoise", + ) + self.assertNotIn("vae", wan_blocks.component_names) + self.assertIn("vae", wan_config["model_input_names"]) + self.assertTrue(wan_config["params"]["vae"]["label"].endswith(" *")) + + self.assertEqual( + set(get_model_type_metadata("FluxModularPipeline")["node_params"]["denoise"]["model_input_names"]), + {"unet", "scheduler"}, + ) + self.assertEqual( + set(get_model_type_metadata("WanModularPipeline")["node_params"]["denoise"]["model_input_names"]), + {"unet", "guider", "scheduler"}, + ) + + def test_advertised_modular_control_modes_have_a_registered_node_contract(self): + advertised_control_models = { + capability["modelType"] + for capability in public_experimental_pipelines() + if "control_image" in capability["runnableModes"] + } + advertised_control_models.update( + profile["model_type"] + for profile in public_execution_profiles() + if "control_image" in profile["modes"] + and profile["backend_path"] == "modules.ModularDiffusers.ModelsLoader" + ) + self.assertTrue(advertised_control_models) + + for model_type in sorted(advertised_control_models): + with self.subTest(model_type=model_type): + metadata = get_model_type_metadata(model_type) + self.assertIsNotNone(metadata, f"{model_type} is advertised but not registered") + self.assertIsNotNone( + metadata["node_params"].get("controlnet"), + f"{model_type}:control_image is advertised without a ControlNet node contract", + ) + + def test_controlnet_model_signal_is_a_generic_passthrough(self): + expected_actions = [ + {"action": "value", "target": "model_type"}, + {"action": "exec", "data": "update_node"}, + ] + self.assertEqual(Controlnet.params["controlnet_bundle"]["onSignal"], expected_actions) + + node = Controlnet("generic-controlnet-signal") + node.send_node_definition = MagicMock() + node.update_node({"model_type": "QwenImageModularPipeline"}, None) + + refreshed = node.send_node_definition.call_args.args[0] + self.assertEqual(refreshed["controlnet_bundle"]["onSignal"], expected_actions) + + def test_generic_modular_fields_refresh_from_each_selected_pipeline_contract(self): + cases = ( + ( + EncodePrompt, + "text_encoder", + "text_encoders", + ("QwenImageLayeredModularPipeline", "FluxModularPipeline"), + ), + ( + Denoise, + "denoise", + "unet", + ("QwenImageLayeredModularPipeline", "FluxModularPipeline"), + ), + ( + ImageEncode, + "vae_encoder", + "vae", + ("QwenImageLayeredModularPipeline", "FluxModularPipeline"), + ), + ( + DecodeLatents, + "decoder", + "vae", + ("QwenImageLayeredModularPipeline", "FluxModularPipeline"), + ), + ( + ImageEmbeddings, + "image_encoder", + "image_encoder", + ("WanImage2VideoModularPipeline",), + ), + ( + IPAdapter, + "ip_adapter", + "unet", + ("StableDiffusionXLModularPipeline",), + ), + ) + + for node_class, action, connector, model_types in cases: + with self.subTest(node=node_class.__name__): + node = node_class(f"selected-contract-{node_class.__name__}") + node.send_node_definition = MagicMock() + node.get_signal_value = MagicMock(side_effect=model_types) + for index, model_type in enumerate(model_types, start=1): + node.update_node({}, None) + expected = dict(get_model_type_metadata(model_type)["node_params"][action]["params"]) + expected.pop(connector, None) + self.assertEqual(node.send_node_definition.call_args.args[0], expected) + self.assertEqual(node.send_node_definition.call_count, index) + + update_source = inspect.getsource(node_class.update_node) + for registered_model_type in set(get_all_model_types()) - {""}: + self.assertNotIn(registered_model_type, update_source) + + def test_controlnet_update_rejects_an_unsupported_modular_pipeline(self): + node = Controlnet("unsupported-controlnet-update") + + for _ in range(2): + with self.assertRaisesRegex( + ValueError, + "FluxModularPipeline.*does not support the generic ControlNet node", + ): + node.update_node({"model_type": "FluxModularPipeline"}, None) + + def test_controlnet_execution_rejects_a_stale_unsupported_graph(self): + node = Controlnet("unsupported-controlnet-execute") + + with self.assertRaisesRegex( + ValueError, + "FluxModularPipeline.*does not support the generic ControlNet node", + ): + node.execute(unet={"model_type": "FluxModularPipeline"}) + + def test_every_generic_modular_action_rejects_unsupported_updates_consistently(self): + cases = ( + (EncodePrompt, "Encode Prompt", False), + (ImageEmbeddings, "Image Embeddings", False), + (ImageEncode, "Encode Image", False), + (Denoise, "Denoise", False), + (DecodeLatents, "Decode Latents", False), + (Controlnet, "ControlNet", True), + (IPAdapter, "IP-Adapter Embeddings", False), + ) + + with patch( + "modules.ModularDiffusers.modular_utils.pipeline_class_to_modiff_node_config", + return_value=(None, None), + ): + for node_class, action_label, uses_explicit_model_type in cases: + with self.subTest(node=node_class.__name__): + node = node_class(f"unsupported-{node_class.__name__}") + node.send_node_definition = MagicMock() + if not uses_explicit_model_type: + node.get_signal_value = MagicMock(return_value="FluxModularPipeline") + + for _ in range(2): + with self.assertRaisesRegex( + ValueError, + f"FluxModularPipeline.*does not support the generic {action_label} node", + ): + node.update_node( + {"model_type": "FluxModularPipeline"} if uses_explicit_model_type else {}, + None, + ) + + self.assertEqual(node._model_type, "") + self.assertIsNone(node._pipeline_class) + self.assertEqual(node.send_node_definition.call_count, 2) + + def test_every_generic_modular_action_rejects_unknown_model_types_during_update(self): + cases = ( + (EncodePrompt, False), + (ImageEmbeddings, False), + (ImageEncode, False), + (Denoise, False), + (DecodeLatents, False), + (Controlnet, True), + (IPAdapter, False), + ) + + for node_class, uses_explicit_model_type in cases: + with self.subTest(node=node_class.__name__): + node = node_class(f"unknown-{node_class.__name__}") + node._model_type = "FluxModularPipeline" + node._pipeline_class = diffusers.FluxModularPipeline + node.send_node_definition = MagicMock() + if not uses_explicit_model_type: + node.get_signal_value = MagicMock(return_value="FutureModularPipeline") + + for _ in range(2): + with self.assertRaisesRegex( + ValueError, + "Unknown Diffusers modular pipeline class 'FutureModularPipeline'.*" + "refresh the node definition", + ): + node.update_node( + {"model_type": "FutureModularPipeline"} if uses_explicit_model_type else {}, + None, + ) + + self.assertEqual(node._model_type, "") + self.assertIsNone(node._pipeline_class) + self.assertEqual(node.send_node_definition.call_count, 2) + + def test_every_generic_modular_action_rejects_unsupported_execution_consistently(self): + cases = ( + (EncodePrompt, "Encode Prompt", "text_encoders"), + (ImageEmbeddings, "Image Embeddings", "image_encoder"), + (ImageEncode, "Encode Image", "vae"), + (Denoise, "Denoise", "unet"), + (DecodeLatents, "Decode Latents", "vae"), + (Controlnet, "ControlNet", "unet"), + (IPAdapter, "IP-Adapter Embeddings", "unet"), + ) + + with patch( + "modules.ModularDiffusers.modular_utils.pipeline_class_to_modiff_node_config", + return_value=(None, None), + ): + for node_class, action_label, connector in cases: + with self.subTest(node=node_class.__name__): + node = node_class(f"stale-{node_class.__name__}") + runtime_component = { + "model_type": "FluxModularPipeline", + "repo_id": "local/fixture", + } + with self.assertRaisesRegex( + ValueError, + f"FluxModularPipeline.*does not support the generic {action_label} node", + ): + node.execute(**{connector: runtime_component}) + + def test_sdxl_controlnet_keeps_its_supported_bundle_only_contract(self): + pipeline_class = diffusers.StableDiffusionXLModularPipeline + blocks, node_config = require_modiff_node_contract( + pipeline_class, + "controlnet", + require_blocks=False, + ) + + self.assertIsNone(blocks) + self.assertIsNotNone(node_config) + self.assertFalse({"seed", ROUTE_STATE_INPUT}.intersection(node_config["input_names"])) + self.assertNotIn(ROUTE_STATE_OUTPUT, node_config["output_names"]) + self.assertEqual( + node_config["params"]["controlnet_variant"], + { + "label": "ControlNet Variant", + "options": ["ordinary", "union"], + "type": "string", + "value": "ordinary", + "onChange": {"union": ["control_mode"]}, + }, + ) + self.assertEqual( + node_config["params"]["control_mode"], + { + "label": "Union Control Type Index", + "type": "int", + "min": 0, + "max": 31, + "step": 1, + "value": 0, + }, + ) + controlnet_component = { + "model_id": "fixture-controlnet-id", + "repo_id": "local/controlnet-fixture", + "repo_source": "hub", + "revision": "a" * 40, + "class_name": "ControlNetModel", + "trust_remote_code": False, + } + issuer = issue_standalone_component_issuer() + bind_standalone_component_output( + controlnet_component, + issuer=issuer, + component_kind="controlnet", + reviewed_identity=( + "hub", + "local/controlnet-fixture", + "a" * 40, + None, + "ControlNetModel", + "b" * 64, + ), + ) + node = Controlnet("sdxl-bundle-only") + result = node.execute( + model_type=pipeline_class.__name__, + controlnet=controlnet_component, + controlnet_variant="ordinary", + control_mode=0, + control_image="fixture-control-image", + controlnet_conditioning_scale=0.75, + control_guidance_start=0.1, + control_guidance_end=0.9, + ) + self.assertEqual( + result, + { + "controlnet_bundle": { + "controlnet": controlnet_component, + "control_image": "fixture-control-image", + "controlnet_conditioning_scale": 0.75, + "control_guidance_start": 0.1, + "control_guidance_end": 0.9, + } + }, + ) + self.assertTrue( + node._cache_params_equal( + { + "controlnet": controlnet_component, + "controlnet_variant": "ordinary", + "control_mode": 0, + }, + { + "controlnet": controlnet_component, + "controlnet_variant": "ordinary", + "control_mode": 0, + }, + ) + ) + with self.assertRaisesRegex(ValueError, "exact ControlNetUnionModel"): + node._cache_params_equal( + { + "controlnet": controlnet_component, + "controlnet_variant": "union", + "control_mode": 0, + }, + { + "controlnet": controlnet_component, + "controlnet_variant": "union", + "control_mode": 0, + }, + ) + with self.assertRaisesRegex(ValueError, "opaque value"): + node._cache_params_equal( + {"controlnet": controlnet_component, ROUTE_STATE_INPUT: {}}, + {"controlnet": controlnet_component, ROUTE_STATE_INPUT: {}}, + ) + with self.assertRaisesRegex(ValueError, "backend-managed"): + Controlnet("sdxl-union-fields-through-ordinary-port").execute( + model_type=pipeline_class.__name__, + controlnet=controlnet_component, + control_image="fixture-control-image", + controlnet_conditioning_scale=0.75, + control_guidance_start=0.1, + control_guidance_end=0.9, + control_type_idx=[0], + ) + + union_component = { + "model_id": "fixture-controlnet-union-id", + "repo_id": "local/controlnet-union-fixture", + "repo_source": "hub", + "revision": "c" * 40, + "class_name": "ControlNetUnionModel", + "trust_remote_code": False, + } + bind_standalone_component_output( + union_component, + issuer=issue_standalone_component_issuer(), + component_kind="controlnet", + reviewed_identity=( + "hub", + "local/controlnet-union-fixture", + "c" * 40, + None, + "ControlNetUnionModel", + "d" * 64, + ), + ) + union_result = Controlnet("sdxl-union-bundle").execute( + model_type=pipeline_class.__name__, + controlnet=union_component, + controlnet_variant="union", + control_mode=1, + control_image="fixture-control-image", + controlnet_conditioning_scale=0.75, + control_guidance_start=0.1, + control_guidance_end=0.9, + ) + self.assertEqual( + union_result, + { + "controlnet_bundle": { + "controlnet": union_component, + "control_mode": 1, + "control_image": "fixture-control-image", + "controlnet_conditioning_scale": 0.75, + "control_guidance_start": 0.1, + "control_guidance_end": 0.9, + } + }, + ) + self.assertTrue( + Controlnet("sdxl-union-cache")._cache_params_equal( + { + "controlnet": union_component, + "controlnet_variant": "union", + "control_mode": 1, + }, + { + "controlnet": union_component, + "controlnet_variant": "union", + "control_mode": 1, + }, + ) + ) + with self.assertRaisesRegex(ValueError, "exact ControlNetModel"): + Controlnet("sdxl-union-through-ordinary-port").execute( + model_type=pipeline_class.__name__, + controlnet=union_component, + control_image="fixture-control-image", + controlnet_conditioning_scale=0.75, + control_guidance_start=0.1, + control_guidance_end=0.9, + ) + with self.assertRaisesRegex(ValueError, "exactly 'ordinary' or 'union'"): + Controlnet("sdxl-invalid-controlnet-variant").execute( + model_type=pipeline_class.__name__, + controlnet=union_component, + controlnet_variant="automatic", + control_mode=0, + control_image="fixture-control-image", + ) + with self.assertRaisesRegex(ValueError, "one bounded control-type index"): + Controlnet("sdxl-invalid-controlnet-mode").execute( + model_type=pipeline_class.__name__, + controlnet=union_component, + controlnet_variant="union", + control_mode="custom", + control_image="fixture-control-image", + ) + + def test_qwen_controlnet_generator_is_closed_by_seed_and_optional_opaque_route(self): + blocks, node_config = require_modiff_node_contract( + diffusers.QwenImageModularPipeline, + "controlnet", + ) + + self.assertIn("generator", blocks.input_names) + self.assertNotIn("seed", blocks.input_names) + self.assertIn("seed", node_config["input_names"]) + self.assertIn(ROUTE_STATE_INPUT, node_config["input_names"]) + self.assertIn(ROUTE_STATE_OUTPUT, node_config["output_names"]) + self.assertFalse(node_config["params"][ROUTE_STATE_INPUT]["label"].endswith("*")) + self.assertFalse( + {"image_latents", "image_latents_with_strength", "strength"}.intersection(node_config["input_names"]) + ) + self.assertIn("control_image_latents", blocks.output_names) + def test_layer_options_identify_module_list_stacks(self): self.assertEqual(QWEN_IMAGE_BLOCKS, ["transformer_blocks"]) self.assertEqual(FLUX_BLOCKS, ["transformer_blocks", "single_transformer_blocks"]) @@ -212,12 +1109,197 @@ def test_layer_options_identify_module_list_stacks(self): self.assertTrue(all(value.endswith(".transformer_blocks") for value in SDXL_BLOCKS)) self.assertTrue(all(value == value.strip() for value in [*SDXL_BLOCKS, *QWEN_IMAGE_BLOCKS, *FLUX_BLOCKS])) + expected = { + "StableDiffusionXLModularPipeline": SDXL_BLOCKS, + "QwenImageModularPipeline": QWEN_IMAGE_BLOCKS, + "QwenImageEditModularPipeline": QWEN_IMAGE_BLOCKS, + "QwenImageEditPlusModularPipeline": QWEN_IMAGE_BLOCKS, + "FluxModularPipeline": FLUX_BLOCKS, + "FluxKontextModularPipeline": FLUX_BLOCKS, + } + self.assertEqual(get_modular_layer_block_options(), expected) + self.assertEqual(MODULAR_LAYER_BLOCK_OPTIONS, expected) + self.assertEqual(Layers.params["layers_config"]["onSignal"]["data"], expected) + self.assertEqual( + MODULE_MAP["modules.ModularDiffusers"]["Layers"]["params"]["layers_config"]["onSignal"]["data"], + expected, + ) + for model_type in set(get_all_model_types()) - {"", "DummyCustomPipeline"}: + self.assertEqual( + get_model_type_metadata(model_type)["layer_block_options"], + expected.get(model_type, []), + ) + layer_source = inspect.getsource(Layers) + self.assertNotIn("QwenImageModularPipeline", layer_source) + self.assertNotIn("FluxModularPipeline", layer_source) + + def test_guider_options_follow_reviewed_pipeline_components_and_layer_contracts(self): + all_options = list(GUIDER_OPTIONS) + layer_guiders = set(LAYER_CONFIG_MAPPING) + non_layer_options = [name for name in all_options if name not in layer_guiders] + full_models = { + "StableDiffusionXLModularPipeline", + "QwenImageModularPipeline", + "QwenImageEditModularPipeline", + "QwenImageEditPlusModularPipeline", + } + non_layer_models = { + "QwenImageLayeredModularPipeline", + "WanImage2VideoModularPipeline", + "WanModularPipeline", + "ZImageModularPipeline", + } + expected = { + **{model_type: all_options for model_type in full_models}, + **{model_type: non_layer_options for model_type in non_layer_models}, + } + + self.assertEqual(get_modular_guider_options(), expected) + self.assertEqual(MODULAR_GUIDER_OPTIONS, expected) + self.assertEqual(Guider.params["guider_out"]["onSignal"][0]["data"], expected) + self.assertEqual( + MODULE_MAP["modules.ModularDiffusers"]["Guider"]["params"]["guider_out"]["onSignal"][0]["data"], + expected, + ) + + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + for model_type in sorted(registered): + with self.subTest(model_type=model_type): + blocks, _ = require_modiff_node_contract(getattr(diffusers, model_type), "denoise") + has_upstream_guider = "guider" in blocks.component_names + self.assertEqual(model_type in expected, has_upstream_guider) + self.assertEqual(get_model_type_metadata(model_type)["guider_options"], expected.get(model_type, [])) + + guider_source = inspect.getsource(Guider) + for model_type in registered: + self.assertNotIn(model_type, guider_source) + + def test_scheduler_options_follow_pinned_upstream_compatibility(self): + expected_choices = [name for name in SCHEDULER_CONFIGS if name not in {"LCMScheduler", "TCDScheduler"}] + expected = { + "StableDiffusionXLModularPipeline": expected_choices, + "WanModularPipeline": expected_choices, + "WanImage2VideoModularPipeline": expected_choices, + } + self.assertEqual(get_modular_scheduler_options(), expected) + self.assertEqual(MODULAR_SCHEDULER_OPTIONS, expected) + self.assertEqual(Scheduler.params["scheduler_in"]["onSignal"]["data"], expected) + self.assertEqual( + MODULE_MAP["modules.ModularDiffusers"]["Scheduler"]["params"]["scheduler_in"]["onSignal"]["data"], + expected, + ) + for scheduler_name in SCHEDULER_CONFIGS: + scheduler_type = getattr(diffusers, scheduler_name) + self.assertTrue(issubclass(scheduler_type, diffusers.SchedulerMixin)) + self.assertTrue(scheduler_type.__module__.startswith("diffusers.")) + + for model_type in set(get_all_model_types()) - {"", "DummyCustomPipeline"}: + pipeline_class = getattr(diffusers, model_type) + with self.subTest(model_type=model_type): + blocks, _node_config = require_modiff_node_contract(pipeline_class, "denoise") + scheduler_spec = next(spec for spec in blocks.expected_components if spec.name == "scheduler") + scheduler_type = scheduler_spec.type_hint + upstream_choices = { + scheduler_type.__name__, + *getattr(scheduler_type, "_compatibles", ()), + } + compatible = [name for name in SCHEDULER_CONFIGS if name in upstream_choices] + self.assertEqual(expected.get(model_type, []), compatible) + self.assertEqual( + get_model_type_metadata(model_type)["scheduler_options"], + compatible, + ) + + scheduler_source = inspect.getsource(Scheduler) + for model_type in set(get_all_model_types()) - {""}: + self.assertNotIn(model_type, scheduler_source) + + def test_scheduler_field_action_and_execution_require_exact_compatible_identity(self): + node = object.__new__(Scheduler) + node.node_id = "scheduler-contract" + node.send_node_definition = MagicMock() + + with patch.object(Scheduler, "get_signal_value", return_value="StableDiffusionXLModularPipeline"): + node.updateNode({"scheduler": "EulerDiscreteScheduler"}, None) + node.send_node_definition.assert_called_once_with(SCHEDULER_CONFIGS["EulerDiscreteScheduler"]) + + invalid_selections = ( + (None, "EulerDiscreteScheduler"), + ({"model": "bad"}, "EulerDiscreteScheduler"), + ("QwenImageModularPipeline", "EulerDiscreteScheduler"), + ("FutureModularPipeline", "EulerDiscreteScheduler"), + ("StableDiffusionXLModularPipeline", "LCMScheduler"), + ("StableDiffusionXLModularPipeline", "TCDScheduler"), + ) + for model_type, scheduler in invalid_selections: + with ( + self.subTest(model_type=model_type, scheduler=scheduler), + patch.object( + Scheduler, + "get_signal_value", + return_value=model_type, + ), + ): + with self.assertRaisesRegex(ValueError, "connected reviewed Modular pipeline"): + node.updateNode({"scheduler": scheduler}, None) + + current = EulerDiscreteScheduler() + with ( + patch.object(Scheduler, "get_signal_value", return_value="StableDiffusionXLModularPipeline"), + patch("modules.ModularDiffusers.schedulers.components.get_one", return_value=current), + patch("modules.ModularDiffusers.schedulers.components.add", return_value="replacement"), + patch( + "modules.ModularDiffusers.schedulers.components.get_model_info", + return_value={"model_id": "replacement"}, + ), + ): + self.assertEqual( + node.execute({"model_id": "current"}, "EulerDiscreteScheduler"), + {"scheduler_out": {"model_id": "replacement"}}, + ) + + from diffusers import FlowMatchEulerDiscreteScheduler + + with ( + patch.object(Scheduler, "get_signal_value", return_value="StableDiffusionXLModularPipeline"), + patch( + "modules.ModularDiffusers.schedulers.components.get_one", + return_value=FlowMatchEulerDiscreteScheduler(), + ), + self.assertRaisesRegex(ValueError, "incompatible with the connected scheduler component"), + ): + node.execute({"model_id": "current"}, "EulerDiscreteScheduler") + + def test_guider_field_action_and_execution_require_the_connected_reviewed_pipeline(self): + node = object.__new__(Guider) + node.node_id = "reviewed-guider-contract" + node.send_node_definition = MagicMock() + + with patch.object(Guider, "get_signal_value", return_value="QwenImageLayeredModularPipeline"): + node.updateNode({"guider": "AdaptiveProjectedMixGuidance"}, None) + node.send_node_definition.assert_called_once() + with self.assertRaisesRegex(ValueError, "connected reviewed Modular pipeline"): + node.updateNode({"guider": "SkipLayerGuidance"}, None) + + for model_type in (None, {}, "FluxModularPipeline", "FutureModularPipeline"): + with ( + self.subTest(model_type=model_type), + patch.object( + Guider, + "get_signal_value", + return_value=model_type, + ), + ): + with self.assertRaisesRegex(ValueError, "connected reviewed Modular pipeline"): + node.execute("ClassifierFreeGuidance") + def test_layers_preserve_exact_stack_fqn_and_validate_indices(self): node = object.__new__(Layers) - output = node.execute( - blocks_select=["transformer_blocks"], - transformer_blocks={"indices": "0, 18", "dropout": 0.5}, - ) + with patch.object(Layers, "get_signal_value", return_value="QwenImageModularPipeline"): + output = node.execute( + blocks_select=["transformer_blocks"], + transformer_blocks={"indices": "0, 18", "dropout": 0.5}, + ) self.assertEqual( output["layers_config"], @@ -232,22 +1314,174 @@ def test_layers_preserve_exact_stack_fqn_and_validate_indices(self): } ], ) - with self.assertRaisesRegex(ValueError, "comma-separated integers"): - node.execute(transformer_blocks={"indices": "zero"}) + with patch.object(Layers, "get_signal_value", return_value="QwenImageModularPipeline"): + with self.assertRaisesRegex(ValueError, "comma-separated integers"): + node.execute(blocks_select=["transformer_blocks"], transformer_blocks={"indices": "zero"}) + + def test_layers_field_action_and_execution_require_the_connected_reviewed_allowlist(self): + node = object.__new__(Layers) + node.send_node_definition = MagicMock() + + with patch.object(Layers, "get_signal_value", return_value="FluxModularPipeline"): + node.set_blocks({"blocks_select": ["single_transformer_blocks"]}, None) + node.send_node_definition.assert_called_once() + with self.assertRaisesRegex(ValueError, "exactly match"): + node.execute( + blocks_select=["single_transformer_blocks"], + single_transformer_blocks={"indices": "1"}, + transformer_blocks={"indices": "2"}, + ) + + for model_type, block in ( + (None, "transformer_blocks"), + ({}, "transformer_blocks"), + ("QwenImageModularPipeline", "single_transformer_blocks"), + ("QwenImageLayeredModularPipeline", "transformer_blocks"), + ): + with self.subTest(model_type=model_type, block=block): + with patch.object(Layers, "get_signal_value", return_value=model_type): + with self.assertRaisesRegex(ValueError, "connected reviewed Modular pipeline"): + node.execute(blocks_select=[block], **{block: {"indices": "0"}}) def test_layer_dependent_guiders_require_an_explicit_nonempty_selection(self): node = object.__new__(Guider) node.node_id = "guider-contract" - for guider in ("SkipLayerGuidance", "AutoGuidance", "SmoothedEnergyGuidance"): + for guider in ( + "SkipLayerGuidance", + "AutoGuidance", + "SmoothedEnergyGuidance", + "PerturbedAttentionGuidance", + ): with self.subTest(guider=guider), self.assertRaisesRegex(ValueError, "non-empty Layers connection"): - node.execute(guider, layers_config=[]) + self._run_guider(node, guider, layers_config=[]) + + def test_adaptive_projected_mix_guider_forwards_exact_typed_arguments(self): + node = object.__new__(Guider) + node.node_id = "adaptive-projected-mix-contract" + with patch.object(diffusers, "AdaptiveProjectedMixGuidance", return_value="configured") as constructor: + result = self._run_guider( + node, + "AdaptiveProjectedMixGuidance", + guidance_scale=3.5, + guidance_rescale=0.25, + adaptive_projected_guidance_scale=10.0, + adaptive_projected_guidance_momentum=-0.5, + adaptive_projected_guidance_rescale=12.0, + eta=0.1, + use_original_formulation=True, + start=0.05, + stop=0.95, + adaptive_projected_guidance_start_step=7, + enabled=True, + ) + + self.assertEqual(result, {"guider_out": "configured"}) + constructor.assert_called_once_with( + guidance_scale=3.5, + guidance_rescale=0.25, + adaptive_projected_guidance_scale=10.0, + adaptive_projected_guidance_momentum=-0.5, + adaptive_projected_guidance_rescale=12.0, + eta=0.1, + use_original_formulation=True, + start=0.05, + stop=0.95, + adaptive_projected_guidance_start_step=7, + enabled=True, + ) + + def test_new_pinned_guiders_construct_without_model_weights(self): + node = object.__new__(Guider) + node.node_id = "new-guider-construction-contract" + + adaptive = self._run_guider(node, "AdaptiveProjectedMixGuidance")["guider_out"] + perturbed = self._run_guider( + node, + "PerturbedAttentionGuidance", + layers_config=[{"indices": [1], "fqn": "transformer_blocks", "dropout": 1.0}], + )["guider_out"] + + self.assertIsInstance(adaptive, diffusers.AdaptiveProjectedMixGuidance) + self.assertEqual(adaptive.adaptive_projected_guidance_start_step, 5) + self.assertIsInstance(perturbed, diffusers.PerturbedAttentionGuidance) + self.assertEqual(perturbed.skip_layer_config[0].indices, [1]) + self.assertTrue(perturbed.skip_layer_config[0].skip_attention_scores) + + def test_adaptive_projected_mix_rejects_fractional_start_step_before_construction(self): + node = object.__new__(Guider) + node.node_id = "adaptive-projected-mix-invalid-start" + + with patch.object(diffusers, "AdaptiveProjectedMixGuidance") as constructor: + with self.assertRaisesRegex(ValueError, "adaptive_projected_guidance_start_step must be an integer"): + self._run_guider( + node, + "AdaptiveProjectedMixGuidance", + adaptive_projected_guidance_start_step=2.5, + ) + constructor.assert_not_called() + + def test_perturbed_attention_guider_normalizes_the_generic_layers_contract(self): + node = object.__new__(Guider) + node.node_id = "perturbed-attention-contract" + with patch.object(diffusers, "PerturbedAttentionGuidance", return_value="configured") as constructor: + result = self._run_guider( + node, + "PerturbedAttentionGuidance", + layers_config=[ + { + "indices": [2, 7], + "fqn": "transformer_blocks", + "dropout": 1.0, + "skip_attention": True, + "skip_attention_scores": False, + "skip_ff": True, + } + ], + guidance_scale=7.5, + perturbed_guidance_scale=2.8, + perturbed_guidance_start=0.01, + perturbed_guidance_stop=0.2, + ) + + self.assertEqual(result, {"guider_out": "configured"}) + config = constructor.call_args.kwargs["perturbed_guidance_config"][0] + self.assertEqual(config.indices, [2, 7]) + self.assertEqual(config.fqn, "transformer_blocks") + self.assertFalse(config.skip_attention) + self.assertTrue(config.skip_attention_scores) + self.assertFalse(config.skip_ff) + self.assertNotIn("perturbed_guidance_layers", constructor.call_args.kwargs) + + def test_perturbed_attention_rejects_invalid_layers_before_construction(self): + invalid_layers = ( + None, + [], + [{"indices": "2", "fqn": "transformer_blocks"}], + [{"indices": [-1], "fqn": "transformer_blocks"}], + [{"indices": [2], "fqn": " transformer_blocks"}], + [{"indices": [2], "fqn": "transformer_blocks", "dropout": 0.5}], + ["transformer_blocks.2"], + [diffusers.LayerSkipConfig(indices=[2], fqn="transformer_blocks", dropout=0.5)], + [diffusers.LayerSkipConfig(indices=[-1], fqn="transformer_blocks")], + diffusers.LayerSkipConfig(indices=[2], fqn="transformer_blocks", dropout=0.5), + ) + node = object.__new__(Guider) + node.node_id = "perturbed-attention-invalid-layers" + + for layers_config in invalid_layers: + with self.subTest(layers_config=layers_config): + with patch.object(diffusers, "PerturbedAttentionGuidance") as constructor: + with self.assertRaises((TypeError, ValueError)): + self._run_guider(node, "PerturbedAttentionGuidance", layers_config=layers_config) + constructor.assert_not_called() def test_guider_converts_validated_layer_mapping_to_upstream_config(self): node = object.__new__(Guider) node.node_id = "guider-contract" with patch.object(diffusers, "SkipLayerGuidance", return_value="configured") as constructor: - result = node.execute( + result = self._run_guider( + node, "SkipLayerGuidance", layers_config=[ { @@ -270,7 +1504,7 @@ def test_frequency_decoupled_guider_uses_upstream_plural_scale_argument(self): node = object.__new__(Guider) node.node_id = "frequency-guider-contract" with patch.object(diffusers, "FrequencyDecoupledGuidance", return_value="configured") as constructor: - result = node.execute("FrequencyDecoupledGuidance", guidance_scale=4.5) + result = self._run_guider(node, "FrequencyDecoupledGuidance", guidance_scale=4.5) self.assertEqual(result, {"guider_out": "configured"}) self.assertEqual(constructor.call_args.kwargs["guidance_scales"], [4.5]) diff --git a/tests/test_modular_image_outputs.py b/tests/test_modular_image_outputs.py index ed67cde..08c3b1c 100644 --- a/tests/test_modular_image_outputs.py +++ b/tests/test_modular_image_outputs.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import patch +import torch from PIL import Image @@ -15,6 +16,7 @@ flatten_pil_images, prepare_image_for_vae_pipeline, ) +from modules.ModularDiffusers.embeddings import EncodePrompt # noqa: E402 class ModularImageOutputTests(unittest.TestCase): @@ -63,8 +65,7 @@ class FakeBlocks: input_names = ["image"] @staticmethod - def init_pipeline(repo_id, components_manager): - self.assertEqual(repo_id, "fixture/layered") + def init_pipeline(*, components_manager): return FakePipeline() node_config = { @@ -78,7 +79,7 @@ def init_pipeline(repo_id, components_manager): node._pipeline_class = QwenImageLayeredModularPipeline with patch( - "modules.ModularDiffusers.latents.pipeline_class_to_modiff_node_config", + "modules.ModularDiffusers.latents.require_modiff_node_contract", return_value=(FakeBlocks(), node_config), ): result = node.execute(vae={"repo_id": "fixture/layered"}, image=source) @@ -92,6 +93,349 @@ def init_pipeline(repo_id, components_manager): self.assertEqual(observed["image"].getpixel((0, 0)), (255, 0, 0, 255)) self.assertEqual(source.mode, "RGB") + def test_layered_prompt_controls_forward_exactly_without_loading_weights(self): + observed = {} + + class FakeState: + @staticmethod + def get_by_kwargs(_name): + return {"prompt_embeds": "encoded"} + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture-doc"})() + + def __call__(self, **kwargs): + observed.update(kwargs) + return FakeState() + + @staticmethod + def update_components(**_kwargs): + return None + + class FakeBlocks: + component_names = [] + input_names = [ + "image", + "resolution", + "prompt", + "use_en_prompt", + "negative_prompt", + "max_sequence_length", + ] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + node_config = { + "params": { + "resolution": {"type": "int", "options": [640, 1024]}, + "use_en_prompt": {"type": "boolean"}, + "max_sequence_length": {"type": "int", "min": 1, "max": 1024}, + }, + "model_input_names": [], + "input_names": [ + "prompt", + "negative_prompt", + "image", + "resolution", + "use_en_prompt", + "max_sequence_length", + ], + "output_names": ["embeddings", "doc"], + } + node = EncodePrompt() + node._pipeline_class = type("OpaqueLayeredPipeline", (), {}) + + with patch( + "modules.ModularDiffusers.embeddings.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ): + result = node.execute( + text_encoders={"repo_id": "fixture/layered"}, + prompt="separate the subject", + negative_prompt="", + image="fixture-image", + resolution="1024", + use_en_prompt=True, + max_sequence_length="768", + ) + + self.assertEqual( + observed, + { + "prompt": "separate the subject", + "negative_prompt": "", + "image": "fixture-image", + "resolution": 1024, + "use_en_prompt": True, + "max_sequence_length": 768, + }, + ) + self.assertEqual(result, {"embeddings": {"prompt_embeds": "encoded"}, "doc": "fixture-doc"}) + + invalid_controls = ( + ( + {"resolution": "768", "use_en_prompt": False, "max_sequence_length": "768"}, + "resolution.*one of", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": "0"}, + "max_sequence_length.*greater than", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": "1025"}, + "max_sequence_length.*less than", + ), + ( + {"resolution": "640", "use_en_prompt": "false", "max_sequence_length": "768"}, + "use_en_prompt.*boolean", + ), + ( + {"resolution": 1024.9, "use_en_prompt": False, "max_sequence_length": "768"}, + "resolution.*expected int", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": 1024.9}, + "max_sequence_length.*expected int", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": True}, + "max_sequence_length.*expected int", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": float("nan")}, + "max_sequence_length.*expected int", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": "01024"}, + "max_sequence_length.*expected int", + ), + ( + {"resolution": "640", "use_en_prompt": False, "max_sequence_length": "-0"}, + "max_sequence_length.*expected int", + ), + ) + for overrides, message in invalid_controls: + with self.subTest(overrides=overrides), patch( + "modules.ModularDiffusers.embeddings.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ): + observed.clear() + with self.assertRaisesRegex(ValueError, message): + node.execute( + text_encoders={"repo_id": "fixture/layered"}, + prompt="separate the subject", + negative_prompt="", + image="fixture-image", + **overrides, + ) + self.assertEqual(observed, {}) + + def test_layered_vae_resolution_forwards_exactly_without_loading_weights(self): + observed = {} + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture-doc"})() + + def __call__(self, **kwargs): + observed.update(kwargs) + return {"image_latents": "encoded"} + + @staticmethod + def update_components(**_kwargs): + return None + + class FakeBlocks: + component_names = [] + input_names = ["image", "resolution"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + node_config = { + "params": {"resolution": {"type": "int", "options": [640, 1024]}}, + "model_input_names": [], + "input_names": ["image", "resolution"], + "output_names": ["image_latents", "doc"], + } + source = Image.new("RGB", (8, 8), "red") + node = ImageEncode() + node._pipeline_class = type("OpaqueLayeredPipeline", (), {}) + + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ): + result = node.execute( + vae={"repo_id": "fixture/layered"}, + image=source, + resolution="1024", + ) + + self.assertIs(observed["image"], source) + self.assertEqual(observed["resolution"], 1024) + self.assertEqual(result["image_latents"], "encoded") + self.assertEqual(result["doc"], "fixture-doc") + + observed.clear() + for invalid_resolution, message in ( + ("768", "resolution.*one of"), + (1024.9, "resolution.*expected int"), + (float("inf"), "resolution.*expected int"), + (True, "resolution.*expected int"), + ("01024", "resolution.*expected int"), + ("-0", "resolution.*expected int"), + ): + with self.subTest(resolution=invalid_resolution), patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ), self.assertRaisesRegex(ValueError, message): + node.execute( + vae={"repo_id": "fixture/layered"}, + image=source, + resolution=invalid_resolution, + ) + self.assertEqual(observed, {}) + + def test_vae_seed_becomes_an_execution_device_generator_without_forwarding_raw_seed(self): + observed = [] + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture-doc"})() + _execution_device = torch.device("cpu") + + def __call__(self, **kwargs): + observed.append(kwargs) + return {"image_latents": "encoded"} + + @staticmethod + def update_components(**_kwargs): + return None + + class FakeBlocks: + component_names = [] + input_names = ["image", "resolution", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + node_config = { + "params": { + "resolution": {"type": "int", "options": [640, 1024]}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + }, + "model_input_names": [], + "input_names": ["image", "resolution", "seed"], + "output_names": ["image_latents", "doc"], + } + node = ImageEncode() + node._pipeline_class = type("OpaqueLayeredPipeline", (), {}) + + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ): + for seed in (0, 4294967295): + with self.subTest(seed=seed): + result = node.execute( + vae={"repo_id": "fixture/layered"}, + image="fixture-image", + resolution=640, + seed=str(seed), + ) + call = observed[-1] + self.assertNotIn("seed", call) + self.assertEqual(call["generator"].initial_seed(), seed) + self.assertEqual(call["generator"].device, torch.device("cpu")) + self.assertEqual(result["image_latents"], "encoded") + + self.assertEqual(len(observed), 2) + + def test_invalid_or_undeclared_vae_generator_state_fails_before_pipeline_or_torch_init(self): + initialization_count = 0 + + class FakeBlocks: + component_names = [] + input_names = ["image", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + nonlocal initialization_count + initialization_count += 1 + raise AssertionError("invalid seed state must fail before pipeline initialization") + + node_config = { + "params": {"seed": {"type": "int", "min": 0, "max": 4294967295}}, + "model_input_names": [], + "input_names": ["image", "seed"], + "output_names": ["image_latents"], + } + node = ImageEncode() + node._pipeline_class = type("OpaqueLayeredPipeline", (), {}) + invalid_values = (True, False, 1.5, float("nan"), float("inf"), "01", "-0", -1, 4294967296) + + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ) as contract_resolver, patch( + "modules.ModularDiffusers.modular_utils.torch.Generator" + ) as generator_constructor: + for seed in invalid_values: + with self.subTest(seed=seed), self.assertRaises(ValueError): + node.execute(vae={"repo_id": "fixture/layered"}, image="fixture-image", seed=seed) + + resolver_calls_before_direct_inputs = contract_resolver.call_count + for direct_kwargs in ({"generator": object()}, {"seed": 7, "generator": object()}): + with self.subTest(direct_kwargs=direct_kwargs), self.assertRaisesRegex( + ValueError, + "Direct Modular Diffusers 'generator' values are not accepted", + ): + node.execute( + vae={"repo_id": "fixture/layered"}, + image="fixture-image", + **direct_kwargs, + ) + self.assertEqual(contract_resolver.call_count, resolver_calls_before_direct_inputs) + + generator_constructor.assert_not_called() + + self.assertEqual(initialization_count, 0) + + def test_vae_seed_contract_mismatch_fails_before_pipeline_initialization(self): + initialization_count = 0 + + class FakeBlocks: + component_names = [] + input_names = ["image"] + + @staticmethod + def init_pipeline(*, components_manager): + nonlocal initialization_count + initialization_count += 1 + raise AssertionError("a mismatched action contract must fail before initialization") + + node_config = { + "params": {"seed": {"type": "int", "min": 0, "max": 4294967295}}, + "model_input_names": [], + "input_names": ["image", "seed"], + "output_names": ["image_latents"], + } + node = ImageEncode() + node._pipeline_class = type("OpaquePipeline", (), {}) + + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), node_config), + ), patch("modules.ModularDiffusers.modular_utils.torch.Generator") as generator_constructor: + with self.assertRaisesRegex(ValueError, "does not expose the required generator input"): + node.execute(vae={"repo_id": "fixture/vae"}, image="fixture-image", seed=0) + generator_constructor.assert_not_called() + + self.assertEqual(initialization_count, 0) + def test_flattens_nested_layered_diffusers_pil_batches(self): first = Image.new("RGB", (8, 8), "red") second = Image.new("RGBA", (8, 8), "blue") diff --git a/tests/test_modular_ip_adapter.py b/tests/test_modular_ip_adapter.py new file mode 100644 index 0000000..3bc7a3a --- /dev/null +++ b/tests/test_modular_ip_adapter.py @@ -0,0 +1,376 @@ +import inspect +import gc +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import torch +from diffusers import ClassifierFreeGuidance, StableDiffusionXLModularPipeline +from diffusers.models import ImageProjection +from diffusers.models.attention_processor import IPAdapterAttnProcessor +from PIL import Image +from transformers import CLIPImageProcessor + +from modiff.auxiliary_ip_adapter import ResolvedSDXLIPAdapter +from modules.ModularDiffusers.ip_adapter import IPAdapter +from modules.ModularDiffusers.denoise import Denoise +from modules.ModularDiffusers.loaders import ModelsLoader, annotate_modular_loader_outputs +from modules.ModularDiffusers.modular_utils import require_modiff_node_contract +from modules.ModularDiffusers.route_state import ( + ROUTE_STATE_OUTPUT, + issue_pipeline_instance_token, + require_sdxl_ip_adapter_bundle, + reset_owned_sdxl_ip_adapter_for_loader, +) + + +SDXL = "StableDiffusionXLModularPipeline" + + +class FixtureEncoder(torch.nn.Module): + def __init__(self): + super().__init__() + self.config = type( + "FixtureEncoderConfig", + (), + { + "image_size": 224, + "hidden_size": 1280, + "patch_size": 14, + "num_channels": 3, + "num_hidden_layers": 32, + "num_attention_heads": 16, + "projection_dim": 1024, + }, + )() + + +class FixtureUNet(torch.nn.Module): + def __init__(self): + super().__init__() + self.dtype = torch.float32 + self.encoder_hid_proj = None + self.attn_processors = {"base.processor": torch.nn.Identity()} + self.config = type("FixtureUNetConfig", (), {"encoder_hid_dim_type": None})() + + +def _loader_output(unet_id="fixture-unet"): + token = issue_pipeline_instance_token( + model_type=SDXL, + repo_id="stabilityai/stable-diffusion-xl-base-1.0", + repo_source="hub", + revision="a" * 40, + ) + outputs = { + "unet_out": {"model_id": unet_id}, + "vae_out": {"model_id": "fixture-vae"}, + "text_encoders": {"text_encoder": {"model_id": "fixture-text"}}, + "scheduler": {"model_id": "fixture-scheduler"}, + } + annotate_modular_loader_outputs( + outputs, + repo_id="stabilityai/stable-diffusion-xl-base-1.0", + repo_source="hub", + model_type=SDXL, + revision="a" * 40, + trust_remote_code=False, + pipeline_instance_token=token, + ) + return token, outputs + + +class FixturePipeline: + def __init__(self, *, swap_manager=None, fail_call=False): + self._execution_device = torch.device("cpu") + self.blocks = type("FixtureBlocksDocument", (), {"doc": "fixture"})() + self.feature_extractor = CLIPImageProcessor(size=224, crop_size=224) + self.swap_manager = swap_manager + self.fail_call = fail_call + self.load_calls = [] + self.unload_calls = 0 + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def load_ip_adapter(self, path, **kwargs): + self.load_calls.append((path, dict(kwargs))) + self.unet.encoder_hid_proj = type("FixtureProjection", (), {})() + self.unet.encoder_hid_proj.image_projection_layers = torch.nn.ModuleList( + [ImageProjection(image_embed_dim=1024, cross_attention_dim=8, num_image_text_embeds=4)] + ) + self.unet.attn_processors = { + "down_blocks.0.attentions.0.transformer_blocks.0.attn2.processor": IPAdapterAttnProcessor( + hidden_size=8, + cross_attention_dim=8, + num_tokens=(4,), + scale=1.0, + ) + } + self.unet.config.encoder_hid_dim_type = "ip_image_proj" + + def unload_ip_adapter(self): + self.unload_calls += 1 + self.unet.encoder_hid_proj = None + self.unet.attn_processors = {"base.processor": torch.nn.Identity()} + self.unet.config.encoder_hid_dim_type = None + + def set_ip_adapter_scale(self, scale): + for processor in self.unet.attn_processors.values(): + if isinstance(processor, IPAdapterAttnProcessor): + processor.scale = [float(scale)] + + def __call__(self, **kwargs): + if self.swap_manager is not None: + self.swap_manager() + if self.fail_call: + raise RuntimeError("fixture encoder failure") + self.call_kwargs = dict(kwargs) + return { + "ip_adapter_embeds": [torch.zeros((1, 1, 1024))], + "negative_ip_adapter_embeds": [torch.ones((1, 1, 1024))], + } + + +class FixtureBlocks: + def __init__(self, pipeline): + self.pipeline = pipeline + + def init_pipeline(self, *, components_manager): + self.components_manager = components_manager + return self.pipeline + + +class ModularIPAdapterTests(unittest.TestCase): + def _execute(self, *, pipeline=None, manager_swap=False, fail_call=False): + token, outputs = _loader_output() + unet = FixtureUNet() + other_unet = FixtureUNet() + current = {"value": unet} + encoder = FixtureEncoder() + guider = ClassifierFreeGuidance(guidance_scale=7.5) + image = Image.new("RGB", (32, 32), "green") + with tempfile.TemporaryDirectory() as directory: + artifact = ResolvedSDXLIPAdapter( + repository="h94/IP-Adapter", + revision="0" * 40, + weight_name="ip-adapter_sdxl.safetensors", + content_sha256="1" * 64, + byte_size=1, + image_encoder_subfolder="models/image_encoder", + image_encoder_class="CLIPVisionModelWithProjection", + load_directory=Path(directory), + ) + + def manager(*, ids, return_dict_with_names=False): + self.assertFalse(return_dict_with_names) + self.assertEqual(ids, [outputs["unet_out"]["model_id"]]) + return {ids[0]: current["value"]} + + def swap(): + if manager_swap: + current["value"] = other_unet + + pipeline = pipeline or FixturePipeline() + pipeline.swap_manager = swap + pipeline.fail_call = fail_call + _real_blocks, config = require_modiff_node_contract( + StableDiffusionXLModularPipeline, + "ip_adapter", + resolve_blocks=False, + ) + node = IPAdapter("fixture-ip-adapter") + with ( + patch( + "modules.ModularDiffusers.ip_adapter.pipeline_class_from_runtime_inputs", + return_value=StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.ip_adapter.require_modiff_node_contract", + return_value=(FixtureBlocks(pipeline), config), + ), + patch("modules.ModularDiffusers.ip_adapter.components.get_components_by_ids", side_effect=manager), + patch("modules.ModularDiffusers.ip_adapter.resolve_reviewed_sdxl_ip_adapter", return_value=artifact), + patch.object(node, "_load_image_encoder", return_value=encoder), + ): + result = node.execute( + unet=outputs["unet_out"], + guider=guider, + ip_adapter_image=image, + adapter_model={"source": "hub", "value": "h94/IP-Adapter"}, + adapter_revision="0" * 40, + adapter_weight_name="sdxl_models/ip-adapter_sdxl.safetensors", + adapter_scale=0.75, + ) + return { + "token": token, + "outputs": outputs, + "unet": unet, + "other_unet": other_unet, + "encoder": encoder, + "guider": guider, + "image": image, + "pipeline": pipeline, + "result": result, + } + + def test_action_loads_one_exact_local_adapter_and_publishes_the_bound_bundle(self): + fixture = self._execute() + pipeline = fixture["pipeline"] + self.assertEqual(len(pipeline.load_calls), 1) + _path, kwargs = pipeline.load_calls[0] + self.assertEqual(kwargs["subfolder"], "") + self.assertEqual(kwargs["weight_name"], "ip-adapter_sdxl.safetensors") + self.assertIs(kwargs["local_files_only"], True) + self.assertIs(pipeline.call_kwargs["ip_adapter_image"], fixture["image"]) + self.assertIsNotNone( + require_sdxl_ip_adapter_bundle( + fixture["result"]["ip_adapter"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + ) + + def test_manager_swap_or_encoding_failure_unloads_partial_mutation_and_never_publishes(self): + for option in ("manager-swap", "call-failure"): + pipeline = FixturePipeline() + with self.subTest(option=option): + with self.assertRaisesRegex( + (ValueError, RuntimeError), + "changed during IP-Adapter encoding|fixture encoder failure", + ): + self._execute( + pipeline=pipeline, + manager_swap=option == "manager-swap", + fail_call=option == "call-failure", + ) + self.assertEqual(pipeline.unload_calls, 1) + self.assertIsNone(pipeline.unet.encoder_hid_proj) + + def test_loader_reset_removes_only_current_owned_adapter_state(self): + fixture = self._execute() + self.assertTrue(reset_owned_sdxl_ip_adapter_for_loader(fixture["pipeline"])) + self.assertEqual(fixture["pipeline"].unload_calls, 1) + self.assertIsNone( + require_sdxl_ip_adapter_bundle( + None, + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + ) + self.assertIn("reset_owned_sdxl_ip_adapter_for_loader(self.loader)", inspect.getsource(ModelsLoader.execute)) + + def test_denoise_flattens_only_the_exact_adapter_bundle_and_revalidates_resident_components(self): + fixture = self._execute() + + class FixtureVAE: + config = type( + "FixtureVAEConfig", + (), + {"latent_channels": 4, "block_out_channels": [32, 64, 128, 256]}, + )() + + vae = FixtureVAE() + scheduler = object() + calls = [] + real_blocks, config = require_modiff_node_contract(StableDiffusionXLModularPipeline, "denoise") + + class FixtureDenoisePipeline: + _execution_device = torch.device("cpu") + component_names = list(real_blocks.component_names) + blocks = type("FixtureDenoiseDocument", (), {"doc": "fixture"})() + transformer = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + calls.append(dict(kwargs)) + return {"latents": torch.zeros((1, 4, 8, 8))} + + pipeline = FixtureDenoisePipeline() + + class FixtureDenoiseBlocks: + component_names = list(real_blocks.component_names) + input_names = list(real_blocks.input_names) + + def __deepcopy__(self, memo): + return self + + @staticmethod + def init_pipeline(*, components_manager): + return pipeline + + manager_values = { + fixture["outputs"]["unet_out"]["model_id"]: ("unet", fixture["unet"]), + fixture["outputs"]["vae_out"]["model_id"]: ("vae", vae), + fixture["outputs"]["scheduler"]["model_id"]: ("scheduler", scheduler), + } + + def manager(*, ids, return_dict_with_names=False): + selected = [manager_values[model_id] for model_id in ids] + if return_dict_with_names: + return {name: value for name, value in selected} + return {model_id: manager_values[model_id][1] for model_id in ids} + + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FixtureDenoiseBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.components.get_components_by_ids", side_effect=manager), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + result = Denoise("fixture-ip-denoise").execute( + unet=fixture["outputs"]["unet_out"], + vae=fixture["outputs"]["vae_out"], + scheduler=fixture["outputs"]["scheduler"], + guider=fixture["guider"], + embeddings={"prompt_embeds": torch.zeros((1, 1, 2048))}, + ip_adapter=fixture["result"]["ip_adapter"], + seed=7, + num_inference_steps=1, + ) + + self.assertEqual(len(calls), 1) + self.assertIs(calls[0]["ip_adapter_embeds"], fixture["result"]["ip_adapter"]["ip_adapter_embeds"]) + self.assertIs( + calls[0]["negative_ip_adapter_embeds"], + fixture["result"]["ip_adapter"]["negative_ip_adapter_embeds"], + ) + self.assertNotIn("ip_adapter", calls[0]) + self.assertNotIn("_IPAdapterStateKey", repr(calls[0])) + self.assertIsNotNone(result[ROUTE_STATE_OUTPUT]) + + def test_loader_reset_rejects_unreceipted_adapter_structure(self): + unet = FixtureUNet() + pipeline = FixturePipeline() + pipeline.update_components(unet=unet) + pipeline.load_ip_adapter("fixture", local_files_only=True) + with self.assertRaisesRegex(ValueError, "unreceipted IP-Adapter state"): + reset_owned_sdxl_ip_adapter_for_loader(pipeline) + self.assertEqual(pipeline.unload_calls, 0) + + def test_loader_can_reset_owned_state_after_the_adapter_action_is_deleted(self): + fixture = self._execute() + unet = fixture["unet"] + reset_pipeline = FixturePipeline() + reset_pipeline.update_components(unet=unet) + del fixture + gc.collect() + + self.assertTrue(reset_owned_sdxl_ip_adapter_for_loader(reset_pipeline)) + self.assertEqual(reset_pipeline.unload_calls, 1) + self.assertIsNone(unet.encoder_hid_proj) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_modular_pipeline_recovery.py b/tests/test_modular_pipeline_recovery.py index 19075d8..851ac04 100644 --- a/tests/test_modular_pipeline_recovery.py +++ b/tests/test_modular_pipeline_recovery.py @@ -2,7 +2,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -from diffusers import QwenImageEditPlusModularPipeline +from diffusers import FluxModularPipeline, QwenImageEditPlusModularPipeline from modules.ModularDiffusers.modular_utils import ( DummyCustomPipeline, @@ -11,6 +11,7 @@ require_immutable_hub_revision, ) from modules.ModularDiffusers.denoise import Denoise +from modules.ModularDiffusers.embeddings import EncodePrompt class ModularPipelineRecoveryTests(unittest.TestCase): @@ -58,11 +59,6 @@ def test_modular_component_specs_accept_explicit_auxiliary_commit(self): self.assertEqual(auxiliary.revision, explicit_revision) self.assertEqual(applied, {"transformer": "d" * 40, "auxiliary": explicit_revision}) - def tearDown(self): - DummyCustomPipeline.repo_id = None - DummyCustomPipeline.revision = None - DummyCustomPipeline.trust_remote_code = False - def test_remote_code_requires_an_immutable_commit_revision(self): with self.assertRaisesRegex(ValueError, "40-character"): require_immutable_hub_revision("owner/custom-pipeline", "main", required=True) @@ -72,36 +68,12 @@ def test_remote_code_requires_an_immutable_commit_revision(self): revision, ) - def test_dummy_custom_pipeline_never_silently_enables_remote_code(self): - DummyCustomPipeline.repo_id = "owner/custom-pipeline" - with patch("diffusers.ModularPipeline.from_pretrained", return_value="pipeline") as loader: - self.assertEqual(DummyCustomPipeline(), "pipeline") - - loader.assert_called_once_with( - "owner/custom-pipeline", - trust_remote_code=False, - local_files_only=True, - ) - - def test_dummy_custom_pipeline_propagates_explicit_trust_and_revision(self): - DummyCustomPipeline.repo_id = "owner/custom-pipeline" - DummyCustomPipeline.trust_remote_code = True + def test_dummy_custom_pipeline_is_an_unbound_non_executable_registry_marker(self): with patch("diffusers.ModularPipeline.from_pretrained") as loader: - with self.assertRaisesRegex(ValueError, "40-character"): + with self.assertRaisesRegex(ValueError, "contract_only.*verified contract checksum"): DummyCustomPipeline() loader.assert_not_called() - DummyCustomPipeline.revision = "b" * 40 - loader.return_value = "pipeline" - self.assertEqual(DummyCustomPipeline(), "pipeline") - - loader.assert_called_once_with( - "owner/custom-pipeline", - trust_remote_code=True, - local_files_only=True, - revision="b" * 40, - ) - def test_dynamic_denoise_declares_its_stable_model_input_as_required(self): self.assertTrue(Denoise.params["unet"]["required"]) @@ -135,6 +107,88 @@ def test_preserves_pipeline_class_set_by_dynamic_signal(self): QwenImageEditPlusModularPipeline, ) + def test_selected_pipeline_class_must_match_connected_runtime_components(self): + with self.assertRaisesRegex( + ValueError, + "configured for pipeline class 'QwenImageEditPlusModularPipeline'.*identify 'FluxModularPipeline'", + ): + pipeline_class_from_runtime_inputs( + QwenImageEditPlusModularPipeline, + {"model_type": "FluxModularPipeline"}, + ) + + def test_matching_selected_and_runtime_pipeline_class_is_preserved(self): + self.assertIs( + pipeline_class_from_runtime_inputs( + QwenImageEditPlusModularPipeline, + {"model_type": "QwenImageEditPlusModularPipeline"}, + ), + QwenImageEditPlusModularPipeline, + ) + + def test_denoise_synchronizes_model_type_after_runtime_recovery(self): + node = Denoise("runtime-model-type") + with patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + side_effect=RuntimeError("stop after recovery"), + ): + with self.assertRaisesRegex(RuntimeError, "stop after recovery"): + node.execute( + unet={ + "model_type": FluxModularPipeline.__name__, + "repo_id": "local/fixture", + } + ) + + self.assertIs(node._pipeline_class, FluxModularPipeline) + self.assertEqual(node._model_type, FluxModularPipeline.__name__) + + def test_downstream_blocks_do_not_reload_repository_config_before_component_injection(self): + node = EncodePrompt("reviewed-block-construction") + pipeline = Mock() + state = Mock() + state.get_by_kwargs.return_value = {"prompt_embeds": "encoded"} + pipeline.return_value = state + blocks = Mock() + blocks.component_names = ["text_encoder"] + blocks.input_names = ["prompt"] + + def init_without_repository(*args, **kwargs): + self.assertEqual(args, (), "a repository argument would make upstream reload model config") + self.assertIn("components_manager", kwargs) + return pipeline + + blocks.init_pipeline.side_effect = init_without_repository + node_config = { + "params": {}, + "model_input_names": ["text_encoders"], + "input_names": ["prompt"], + "output_names": ["embeddings"], + } + managed_component = object() + + with ( + patch( + "modules.ModularDiffusers.embeddings.require_modiff_node_contract", + return_value=(blocks, node_config), + ), + patch("modules.ModularDiffusers.embeddings.collect_model_ids", return_value=["text-encoder-id"]), + patch( + "modules.ModularDiffusers.embeddings.components.get_components_by_ids", + return_value={"text_encoder": managed_component}, + ), + ): + outputs = node.execute( + text_encoders={ + "repo_id": "attacker/reloaded-config", + "model_type": FluxModularPipeline.__name__, + }, + prompt="test prompt", + ) + + self.assertEqual(outputs, {"embeddings": {"prompt_embeds": "encoded"}}) + pipeline.update_components.assert_called_once_with(text_encoder=managed_component) + def test_recovers_pipeline_class_from_nested_loader_output(self): runtime_inputs = { "text_encoders": { @@ -148,8 +202,8 @@ def test_recovers_pipeline_class_from_nested_loader_output(self): QwenImageEditPlusModularPipeline, ) - def test_recovers_custom_pipeline_marker(self): - self.assertIs( + def test_legacy_custom_pipeline_marker_is_not_treated_as_an_execution_identity(self): + with self.assertRaisesRegex(ValueError, "backend-issued contract identity"): pipeline_class_from_runtime_inputs( None, { @@ -158,15 +212,10 @@ def test_recovers_custom_pipeline_marker(self): "revision": "c" * 40, "trust_remote_code": True, }, - ), - DummyCustomPipeline, - ) - self.assertEqual(DummyCustomPipeline.repo_id, "owner/custom-pipeline") - self.assertEqual(DummyCustomPipeline.revision, "c" * 40) - self.assertTrue(DummyCustomPipeline.trust_remote_code) + ) def test_custom_pipeline_recovery_rejects_missing_trust_metadata(self): - with self.assertRaisesRegex(ValueError, "trust metadata"): + with self.assertRaisesRegex(ValueError, "backend-issued contract identity"): pipeline_class_from_runtime_inputs(None, {"model_type": "DummyCustomPipeline"}) def test_rejects_mixed_model_inputs_before_loading(self): diff --git a/tests/test_modular_route_state.py b/tests/test_modular_route_state.py new file mode 100644 index 0000000..bfbdac8 --- /dev/null +++ b/tests/test_modular_route_state.py @@ -0,0 +1,5303 @@ +import gc +import json +import pickle +import unittest +import weakref +from copy import deepcopy +from unittest.mock import Mock, patch + +import diffusers +import numpy as np +import torch +from PIL import Image + +from modiff.NodeBase import deep_equal +from modiff.modular_workflow_contracts import PINNED_MODULAR_WORKFLOW_TRUTH +from modules.ModularDiffusers.controlnet import Controlnet +from modules.ModularDiffusers.denoise import Denoise +from modules.ModularDiffusers.latents import DecodeLatents, ImageEncode +from modules.ModularDiffusers.loaders import AutoModelLoader, ModelsLoader, annotate_modular_loader_outputs +from modules.ModularDiffusers.modular_utils import ( + get_model_type_metadata, + pipeline_class_from_runtime_inputs, +) +from modules.ModularDiffusers.route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + bind_loader_outputs, + bind_standalone_component_output, + consume_controlnet_input_route_state, + consume_decode_route_state, + consume_denoise_route_state, + consume_encoder_route_state, + issue_controlnet_route_state, + issue_decode_route_state, + issue_encoder_route_state, + issue_normal_decode_route_state, + issue_pipeline_instance_token, + issue_sdxl_ip_adapter_bundle, + issue_standalone_component_issuer, + reject_route_reserved_inputs, + require_component_binding, + require_matching_token_bearers, + require_route_state_current_publication, + require_sdxl_ip_adapter_bundle, + require_standalone_component_binding, + validate_controlnet_input_route_state, + validate_controlnet_route_state, + validate_denoise_route_state, + validate_encoder_route_state, +) + + +QWEN_IMAGE = "QwenImageModularPipeline" +QWEN_EDIT = "QwenImageEditModularPipeline" +QWEN_EDIT_PLUS = "QwenImageEditPlusModularPipeline" +SDXL = "StableDiffusionXLModularPipeline" + + +class _FixtureSdxlVae: + def __init__(self, *, latent_channels=4, scale_depth=4): + self.config = type( + "FixtureVaeConfig", + (), + { + "latent_channels": latent_channels, + "block_out_channels": [32] * scale_depth, + }, + )() + + +def _sdxl_encoder_route(*, seed=7, crop=False, advance_generator=True): + token, outputs = _bound_outputs(SDXL) + vae = _FixtureSdxlVae() + image_latents = torch.full((1, 4, 8, 8), float(seed % 17)) + mask = torch.zeros((1, 1, 8, 8)) + masked_image_latents = torch.ones((1, 4, 8, 8)) + generator = torch.Generator(device="cpu").manual_seed(seed) + if advance_generator: + torch.rand((), generator=generator) + original_image = Image.new("RGB", (64, 64), "red") + original_mask = Image.new("L", (64, 64), 255) + route = issue_encoder_route_state( + binding=token, + seed=seed, + generator=generator, + image_latents=image_latents, + mask=mask, + masked_image_latents=masked_image_latents, + padding_mask_crop=(0 if crop else None), + crops_coords=((4, 5, 60, 61) if crop else None), + original_image=(original_image if crop else None), + original_mask=(original_mask if crop else None), + vae_component=vae, + vae_latent_channels=4, + vae_scale_factor=8, + ) + return { + "token": token, + "outputs": outputs, + "vae": vae, + "route": route, + "image_latents": image_latents, + "mask": mask, + "masked_image_latents": masked_image_latents, + "generator": generator, + "original_image": original_image, + "original_mask": original_mask, + } + + +def _standalone_identity( + *, + repo_id="fixture/component", + revision="a" * 40, + subfolder=None, + class_name="FixtureControlNetModel", + fingerprint="1" * 64, +): + return "hub", repo_id, revision, subfolder, class_name, fingerprint + + +def _standalone_payload(identity, *, manager_model_id="controlnet-resident"): + repo_source, repo_id, revision, _subfolder, class_name, _fingerprint = identity + return { + "model_id": manager_model_id, + "class_name": class_name, + "repo_id": repo_id, + "repo_source": repo_source, + "revision": revision, + "trust_remote_code": False, + } + + +def _publish_standalone( + identity=None, + *, + issuer=None, + manager_model_id="controlnet-resident", + component_kind="controlnet", +): + identity = identity or _standalone_identity() + issuer = issuer or issue_standalone_component_issuer() + payload = _standalone_payload(identity, manager_model_id=manager_model_id) + bind_standalone_component_output( + payload, + issuer=issuer, + component_kind=component_kind, + reviewed_identity=identity, + ) + return issuer, payload + + +def _bound_outputs(model_type=QWEN_EDIT, *, suffix="a", model_id="shared-model"): + token = issue_pipeline_instance_token( + model_type=model_type, + repo_id=f"fixture/{model_type}", + repo_source="hub", + revision=(suffix[0] * 40), + ) + outputs = { + "unet_out": {"model_id": model_id}, + "vae_out": {"model_id": model_id}, + "text_encoders": {"text_encoder": {"model_id": model_id}}, + "scheduler": {"model_id": model_id}, + } + annotate_modular_loader_outputs( + outputs, + repo_id=f"fixture/{model_type}", + repo_source="hub", + model_type=model_type, + revision=(suffix[0] * 40), + trust_remote_code=False, + pipeline_instance_token=token, + ) + return token, outputs + + +def _normal_encoder_route(token, *, seed=7, advance_generator=True): + image_latents = torch.full((1, 1, 2, 2), float(seed % 17)) + generator = torch.Generator(device="cpu").manual_seed(seed) + if advance_generator: + torch.rand((), generator=generator) + return ( + issue_encoder_route_state( + binding=token, + seed=seed, + generator=generator, + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ), + image_latents, + ) + + +def _sdxl_ip_adapter_fixture(*, suffix="a", scale=0.75): + from diffusers import ClassifierFreeGuidance + from diffusers.models import ImageProjection + from diffusers.models.attention_processor import IPAdapterAttnProcessor + from transformers import CLIPImageProcessor + + class FixtureEncoder(torch.nn.Module): + def __init__(self): + super().__init__() + self.config = type( + "FixtureIPAdapterEncoderConfig", + (), + { + "image_size": 224, + "hidden_size": 1280, + "patch_size": 14, + "num_channels": 3, + "num_hidden_layers": 32, + "num_attention_heads": 16, + "projection_dim": 1024, + }, + )() + + class FixtureUNet(torch.nn.Module): + def __init__(self): + super().__init__() + self.dtype = torch.float32 + self.encoder_hid_proj = type("FixtureProjection", (), {})() + self.encoder_hid_proj.image_projection_layers = torch.nn.ModuleList( + [ImageProjection(image_embed_dim=1024, cross_attention_dim=8, num_image_text_embeds=4)] + ) + self.attn_processors = { + "down_blocks.0.attentions.0.transformer_blocks.0.attn2.processor": IPAdapterAttnProcessor( + hidden_size=8, + cross_attention_dim=8, + num_tokens=(4,), + scale=scale, + ) + } + self.config = type("FixtureIPAdapterUNetConfig", (), {"encoder_hid_dim_type": "ip_image_proj"})() + + token, outputs = _bound_outputs(SDXL, suffix=suffix, model_id=f"ip-unet-{suffix}") + unet = FixtureUNet() + encoder = FixtureEncoder() + processor = CLIPImageProcessor(size=224, crop_size=224) + guider = ClassifierFreeGuidance(guidance_scale=7.5) + image = Image.new("RGB", (32, 24), "purple") + embeddings = [torch.zeros((1, 1, 1024))] + negative_embeddings = [torch.ones((1, 1, 1024))] + bundle = issue_sdxl_ip_adapter_bundle( + binding=token, + unet=unet, + artifact_identity=( + "h94/IP-Adapter", + "0" * 40, + "sdxl_models/ip-adapter_sdxl.safetensors", + "1" * 64, + 1, + "models/image_encoder", + "CLIPVisionModelWithProjection", + ), + image_encoder=encoder, + feature_extractor=processor, + guider=guider, + scale=scale, + image=image, + ip_adapter_embeds=embeddings, + negative_ip_adapter_embeds=negative_embeddings, + ) + return { + "token": token, + "outputs": outputs, + "unet": unet, + "encoder": encoder, + "processor": processor, + "guider": guider, + "image": image, + "embeddings": embeddings, + "negative_embeddings": negative_embeddings, + "bundle": bundle, + "scale": scale, + } + + +def _route_node_config(*, route=True, control_bundle=False): + inputs = ["embeddings", "image_latents", "seed"] + if control_bundle: + inputs.append("controlnet_bundle") + if route: + inputs.append(ROUTE_STATE_INPUT) + return { + "params": { + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "embeddings": {"type": "embeddings"}, + "image_latents": {"type": "latents"}, + **({"controlnet_bundle": {"type": "custom_controlnet"}} if control_bundle else {}), + **({ROUTE_STATE_INPUT: {"type": "modular_route_state"}} if route else {}), + }, + "model_input_names": ["unet", "scheduler"], + "input_names": inputs, + "output_names": ["latents", *([ROUTE_STATE_OUTPUT] if route else [])], + } + + +def _controlnet_node_config(): + return { + "params": { + "control_image": {"type": "image"}, + "controlnet_conditioning_scale": {"type": "float", "min": 0.0, "max": 1.0}, + "control_guidance_start": {"type": "float", "min": 0.0, "max": 1.0}, + "control_guidance_end": {"type": "float", "min": 0.0, "max": 1.0}, + "height": {"type": "int", "min": 64, "max": 2048}, + "width": {"type": "int", "min": 64, "max": 2048}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["controlnet", "vae"], + "input_names": [ + "control_image", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + "height", + "width", + "seed", + ROUTE_STATE_INPUT, + ], + "output_names": ["controlnet_bundle", ROUTE_STATE_OUTPUT], + } + + +class OpaqueBindingTests(unittest.TestCase): + def test_binding_key_and_token_survive_deepcopy_but_reject_json_and_pickle(self): + token, outputs = _bound_outputs() + copied = deepcopy(outputs["unet_out"]) + + self.assertTrue(deep_equal(outputs["unet_out"], copied)) + self.assertIs(require_component_binding(copied, label="copy"), token) + with self.assertRaises(TypeError): + json.dumps(outputs["unet_out"]) + with self.assertRaises(TypeError): + pickle.dumps(token) + with self.assertRaises(TypeError): + pickle.dumps(outputs["unet_out"]) + + def test_one_loader_execution_shares_one_token_and_reexecution_rotates_it(self): + first_token, first = _bound_outputs(suffix="a") + for payload in first.values(): + self.assertIs(require_component_binding(payload, label="loader output"), first_token) + + second_token = issue_pipeline_instance_token( + model_type=QWEN_EDIT, + repo_id=f"fixture/{QWEN_EDIT}", + repo_source="hub", + revision="a" * 40, + ) + annotate_modular_loader_outputs( + first, + repo_id=f"fixture/{QWEN_EDIT}", + repo_source="hub", + model_type=QWEN_EDIT, + revision="a" * 40, + trust_remote_code=False, + pipeline_instance_token=second_token, + ) + self.assertIsNot(first_token, second_token) + self.assertIs(require_component_binding(first["vae_out"], label="reloaded VAE"), second_token) + + def test_separate_loaders_stay_distinct_even_for_the_same_resident_component_id(self): + token_a, outputs_a = _bound_outputs(suffix="a", model_id="resident-shared") + token_b, outputs_b = _bound_outputs(suffix="b", model_id="resident-shared") + + self.assertIsNot(token_a, token_b) + with self.assertRaisesRegex(ValueError, "different Models Loader"): + require_component_binding( + outputs_b["scheduler"], + label="scheduler", + expected_token=token_a, + ) + + def test_component_metadata_tampering_and_unissued_tokens_are_rejected(self): + token, outputs = _bound_outputs() + outputs["vae_out"]["repo_id"] = "attacker/repository" + with self.assertRaisesRegex(ValueError, "does not match"): + require_component_binding(outputs["vae_out"], label="VAE") + + forged = object.__new__(type(token)) + with self.assertRaisesRegex(ValueError, "invalid pipeline instance"): + bind_loader_outputs({"vae_out": {}}, forged) + + def test_component_role_swaps_and_post_publication_model_id_mutation_are_rejected(self): + _token, outputs = _bound_outputs() + with self.assertRaisesRegex(ValueError, "loader role 'scheduler'.*not 'vae'"): + require_component_binding( + outputs["scheduler"], + label="VAE", + expected_role="vae", + ) + + copied_unet = deepcopy(outputs["unet_out"]) + copied_unet["model_id"] = "foreign-model-id" + with self.assertRaisesRegex(ValueError, "model identity changed"): + require_component_binding( + copied_unet, + label="denoise model", + expected_role="denoiser", + ) + + def test_prepare_for_workflow_reuse_preserves_resident_output_binding(self): + token, outputs = _bound_outputs() + node = ModelsLoader("resident-binding-adoption") + + node.prepare_for_workflow_reuse() + + self.assertIs(require_component_binding(outputs["unet_out"], label="resident model"), token) + + def test_failed_loader_preflight_cannot_mint_or_publish_a_token(self): + node = ModelsLoader() + with patch("modules.ModularDiffusers.loaders.issue_pipeline_instance_token") as issuer: + with self.assertRaisesRegex(ValueError, "repository code is disabled"): + node.execute( + model_type=QWEN_EDIT, + repo_id={"source": "hub", "value": "fixture/model"}, + device="cpu", + dtype=torch.float32, + trust_remote_code=True, + ) + issuer.assert_not_called() + + +class StandaloneComponentBindingTests(unittest.TestCase): + def test_binding_is_opaque_deepcopy_stable_unpickleable_and_weakly_registered(self): + issuer, payload = _publish_standalone() + binding = require_standalone_component_binding( + payload, + label="ControlNet", + expected_kind="controlnet", + expected_issuer=issuer, + expected_reviewed_identity=_standalone_identity(), + ) + copied = deepcopy(payload) + + self.assertTrue(deep_equal(payload, copied)) + self.assertIs( + require_standalone_component_binding(copied, label="copied ControlNet"), + binding, + ) + self.assertIs(deepcopy(binding), binding) + with self.assertRaises(AttributeError): + binding._repo_id = "attacker/component" + with self.assertRaises(TypeError): + json.dumps(payload) + for value in (issuer, binding, payload): + with self.subTest(value=type(value).__name__), self.assertRaises(TypeError): + pickle.dumps(value) + del value + + binding_ref = weakref.ref(binding) + del binding + del copied + del payload + del issuer + gc.collect() + self.assertIsNone(binding_ref()) + + def test_strict_helper_rejects_tampering_forgery_and_cross_publication_copies(self): + identity_a = _standalone_identity() + issuer_a, payload_a = _publish_standalone(identity_a) + binding_a = require_standalone_component_binding(payload_a, label="ControlNet A") + + tampered_values = { + "model_id": "foreign-manager-id", + "class_name": "DifferentControlNetModel", + "repo_id": "attacker/component", + "repo_source": "local", + "revision": "b" * 40, + "trust_remote_code": True, + } + for field, value in tampered_values.items(): + tampered = deepcopy(payload_a) + tampered[field] = value + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "provenance binding"): + require_standalone_component_binding(tampered, label="tampered ControlNet") + + local_identity = ("local", "C:\\models\\controlnet", None, None, "FixtureControlNetModel", "1" * 64) + local_issuer, local_payload = _publish_standalone( + local_identity, + manager_model_id="local-controlnet-resident", + ) + del local_payload["revision"] + with self.assertRaisesRegex(ValueError, "provenance binding"): + require_standalone_component_binding( + local_payload, + label="local ControlNet without revision field", + expected_issuer=local_issuer, + ) + + with self.assertRaisesRegex(ValueError, "not 'vae'"): + require_standalone_component_binding(payload_a, label="ControlNet A", expected_kind="vae") + with self.assertRaisesRegex(ValueError, "different Load Model node"): + require_standalone_component_binding( + payload_a, + label="ControlNet A", + expected_issuer=issue_standalone_component_issuer(), + ) + changed_identity = (*identity_a[:-1], "2" * 64) + with self.assertRaisesRegex(ValueError, "reviewed component identity"): + require_standalone_component_binding( + payload_a, + label="ControlNet A", + expected_reviewed_identity=changed_identity, + ) + changed_subfolder_identity = (*identity_a[:3], "controlnet", *identity_a[4:]) + with self.assertRaisesRegex(ValueError, "reviewed component identity"): + require_standalone_component_binding( + payload_a, + label="ControlNet A", + expected_reviewed_identity=changed_subfolder_identity, + ) + + binding_key = next(key for key in payload_a if type(key) is not str) + forged = deepcopy(payload_a) + forged[binding_key] = object.__new__(type(binding_a)) + with self.assertRaisesRegex(ValueError, "missing its process-local"): + require_standalone_component_binding(forged, label="forged ControlNet") + + identity_b = _standalone_identity( + repo_id="fixture/other-component", + revision="b" * 40, + class_name="OtherControlNetModel", + fingerprint="2" * 64, + ) + _issuer_b, payload_b = _publish_standalone( + identity_b, + manager_model_id="other-controlnet-resident", + ) + binding_b = require_standalone_component_binding(payload_b, label="ControlNet B") + with self.assertRaisesRegex(ValueError, "different component publication"): + require_standalone_component_binding( + payload_a, + label="ControlNet A", + expected_binding=binding_b, + ) + cross_publication = deepcopy(payload_b) + cross_publication[binding_key] = binding_a + with self.assertRaisesRegex(ValueError, "provenance binding"): + require_standalone_component_binding(cross_publication, label="cross-publication ControlNet") + + def test_new_publication_revokes_old_payload_even_while_it_remains_resident(self): + issuer = issue_standalone_component_issuer() + identity_a = _standalone_identity(class_name="FixtureControlNetModel", fingerprint="1" * 64) + _issuer, payload_a = _publish_standalone(identity_a, issuer=issuer) + binding_a = require_standalone_component_binding(payload_a, label="ControlNet A") + + identity_b = _standalone_identity(class_name="FixtureControlNetModel", fingerprint="2" * 64) + _issuer, payload_b = _publish_standalone( + identity_b, + issuer=issuer, + manager_model_id="controlnet-resident-b", + ) + binding_b = require_standalone_component_binding(payload_b, label="ControlNet B") + + self.assertIsNot(binding_a, binding_b) + self.assertNotEqual(payload_a["model_id"], payload_b["model_id"]) + with self.assertRaisesRegex(ValueError, "no longer the current"): + require_standalone_component_binding(payload_a, label="resident ControlNet A") + self.assertIs(require_standalone_component_binding(payload_b, label="ControlNet B"), binding_b) + + def test_same_manager_identity_can_republish_but_cannot_be_relabeled(self): + identity_a = _standalone_identity() + issuer, payload_a = _publish_standalone( + identity_a, + manager_model_id="same-manager-controlnet", + ) + binding_a = require_standalone_component_binding(payload_a, label="first publication") + payload_a2 = _standalone_payload(identity_a, manager_model_id="same-manager-controlnet") + bind_standalone_component_output( + payload_a2, + issuer=issuer, + component_kind="controlnet", + reviewed_identity=identity_a, + ) + binding_a2 = require_standalone_component_binding(payload_a2, label="second publication") + + self.assertIsNot(binding_a, binding_a2) + with self.assertRaisesRegex(ValueError, "no longer the current"): + require_standalone_component_binding(payload_a, label="first publication") + + identity_b = (*identity_a[:-1], "2" * 64) + payload_b = _standalone_payload(identity_b, manager_model_id="same-manager-controlnet") + with self.assertRaisesRegex(ValueError, "resident component under a different reviewed identity"): + bind_standalone_component_output( + payload_b, + issuer=issuer, + component_kind="controlnet", + reviewed_identity=identity_b, + ) + self.assertIs( + require_standalone_component_binding(payload_a2, label="still-current publication"), + binding_a2, + ) + + def test_failed_publication_mints_nothing_and_does_not_revoke_current_payload(self): + issuer, current_payload = _publish_standalone() + invalid_payload = _standalone_payload(_standalone_identity()) + invalid_payload["class_name"] = "UnreviewedControlNetModel" + + with patch("modules.ModularDiffusers.route_state._StandaloneComponentBinding") as binding_type: + with self.assertRaisesRegex(ValueError, "reviewed component identity"): + bind_standalone_component_output( + invalid_payload, + issuer=issuer, + component_kind="controlnet", + reviewed_identity=_standalone_identity(), + ) + binding_type.assert_not_called() + require_standalone_component_binding(current_payload, label="current ControlNet") + + def test_auto_model_execute_binds_only_after_reviewed_manager_publication(self): + node = AutoModelLoader() + identity = _standalone_identity( + repo_id="fixture/transformer", + subfolder="transformer", + class_name="FixtureTransformerModel", + ) + manager_payload = _standalone_payload(identity, manager_model_id="transformer-resident") + + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + return_value=identity, + ), + patch( + "modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class", + return_value=type("FixtureTransformerModel", (), {}), + ), + patch("modules.ModularDiffusers.loaders.ComponentSpec") as component_spec, + patch( + "modules.ModularDiffusers.loaders.reusable_standalone_component", + return_value=("transformer-resident", object()), + ), + patch( + "modules.ModularDiffusers.loaders.standalone_component_reuse_is_bound", + return_value=True, + ), + patch("modules.ModularDiffusers.loaders.components.add", return_value="transformer-resident"), + patch( + "modules.ModularDiffusers.loaders.components.get_model_info", + return_value=manager_payload, + ), + patch.object(node, "progress"), + ): + output = node.execute( + model_type="transformer", + model_id={"source": "hub", "value": "fixture/transformer"}, + dtype=torch.float32, + trust_remote_code=False, + device="cpu", + auto_offload=False, + offload_mode="none", + variant=None, + subfolder="transformer", + revision="a" * 40, + _reviewed_component_identity=identity, + ) + + component_spec.assert_called_once() + require_standalone_component_binding( + output["model"], + label="published transformer", + expected_kind="transformer", + expected_issuer=node._standalone_component_issuer, + expected_reviewed_identity=identity, + ) + + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + return_value=identity, + ), + patch( + "modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class", + return_value=type("FixtureTransformerModel", (), {}), + ), + patch("modules.ModularDiffusers.loaders.ComponentSpec"), + patch( + "modules.ModularDiffusers.loaders.reusable_standalone_component", + return_value=("transformer-resident", object()), + ), + patch( + "modules.ModularDiffusers.loaders.standalone_component_reuse_is_bound", + return_value=True, + ), + patch("modules.ModularDiffusers.loaders.components.add", return_value="transformer-resident"), + patch( + "modules.ModularDiffusers.loaders.components.get_model_info", + side_effect=ValueError("publication failed"), + ), + patch("modules.ModularDiffusers.loaders.bind_standalone_component_output") as bind_output, + patch.object(node, "progress"), + ): + with self.assertRaisesRegex(ValueError, "publication failed"): + node.execute( + model_type="transformer", + model_id={"source": "hub", "value": "fixture/transformer"}, + dtype=torch.float32, + trust_remote_code=False, + device="cpu", + auto_offload=False, + offload_mode="none", + variant=None, + subfolder="transformer", + revision="a" * 40, + _reviewed_component_identity=identity, + ) + bind_output.assert_not_called() + + def test_auto_model_fingerprint_change_cannot_republish_a_resident_model(self): + node = AutoModelLoader() + identity_a = _standalone_identity( + repo_id="fixture/transformer", + subfolder="transformer", + class_name="FixtureTransformerModel", + fingerprint="1" * 64, + ) + resident_payload = _standalone_payload(identity_a, manager_model_id="transformer-resident-a") + bind_standalone_component_output( + resident_payload, + issuer=node._standalone_component_issuer, + component_kind="transformer", + reviewed_identity=identity_a, + ) + identity_b = (*identity_a[:-1], "2" * 64) + published_payload = _standalone_payload(identity_b, manager_model_id="transformer-resident-b") + resident_model = torch.nn.Linear(1, 1) + loaded_model = torch.nn.Linear(1, 1) + + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + return_value=identity_b, + ), + patch( + "modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class", + return_value=type("FixtureTransformerModel", (), {}), + ), + patch("modules.ModularDiffusers.loaders.ComponentSpec") as component_spec, + patch( + "modules.ModularDiffusers.loaders.reusable_standalone_component", + return_value=("transformer-resident-a", resident_model), + ) as reusable, + patch("modules.ModularDiffusers.loaders.apply_model_offload") as apply_offload, + patch( + "modules.ModularDiffusers.loaders.components.add", + return_value="transformer-resident-b", + ) as manager_add, + patch( + "modules.ModularDiffusers.loaders.components.get_model_info", + return_value=published_payload, + ), + patch.object(node, "progress"), + patch.object(node, "diffusers_loading_progress") as loading_progress, + ): + component_spec.return_value.load.return_value = loaded_model + apply_offload.return_value = Mock( + mode="none", + method="fixture", + components=["transformer"], + ) + loading_progress.return_value.__enter__.return_value = None + output = node.execute( + model_type="transformer", + model_id={"source": "hub", "value": "fixture/transformer"}, + dtype=torch.float32, + trust_remote_code=False, + device="cpu", + auto_offload=False, + offload_mode="none", + variant=None, + subfolder="transformer", + revision="a" * 40, + _reviewed_component_identity=identity_b, + ) + + reusable.assert_called_once() + component_spec.return_value.load.assert_called_once_with(torch_dtype=torch.float32) + manager_add.assert_called_once_with("transformer", loaded_model, collection=None) + require_standalone_component_binding( + output["model"], + label="reloaded transformer B", + expected_reviewed_identity=identity_b, + ) + with self.assertRaisesRegex(ValueError, "no longer the current"): + require_standalone_component_binding(resident_payload, label="resident transformer A") + + def test_auto_model_toctou_mismatch_fails_before_class_import_or_initialization(self): + node = AutoModelLoader() + identity_a = _standalone_identity( + repo_id="fixture/transformer", + subfolder="transformer", + class_name="FixtureTransformerModel", + fingerprint="1" * 64, + ) + identity_b = (*identity_a[:-1], "2" * 64) + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + return_value=identity_b, + ), + patch("modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class") as resolver, + patch("modules.ModularDiffusers.loaders.ComponentSpec") as component_spec, + patch("modules.ModularDiffusers.loaders.reusable_standalone_component") as reusable, + patch("modules.ModularDiffusers.loaders.components.add") as manager_add, + patch("modules.ModularDiffusers.loaders.bind_standalone_component_output") as bind_output, + ): + with self.assertRaisesRegex(ValueError, "config changed after cache validation"): + node.execute( + model_type="transformer", + model_id={"source": "hub", "value": "fixture/transformer"}, + dtype=torch.float32, + trust_remote_code=False, + device="cpu", + auto_offload=False, + offload_mode="none", + variant=None, + subfolder="transformer", + revision="a" * 40, + _reviewed_component_identity=identity_a, + ) + resolver.assert_not_called() + component_spec.assert_not_called() + reusable.assert_not_called() + manager_add.assert_not_called() + bind_output.assert_not_called() + + def test_auto_model_cache_reuses_current_binding_rotates_on_content_and_rejects_tampering(self): + node = AutoModelLoader("standalone-publication-cache") + identity_a = _standalone_identity( + repo_id="fixture/transformer", + subfolder="transformer", + class_name="FixtureTransformerModel", + fingerprint="1" * 64, + ) + identity_b = _standalone_identity( + repo_id="fixture/transformer", + subfolder="transformer", + class_name="FixtureTransformerModel", + fingerprint="2" * 64, + ) + + def publish(**kwargs): + identity = kwargs["_reviewed_component_identity"] + manager_model_id = "transformer-resident-a" if identity[-1] == "1" * 64 else "transformer-resident-b" + payload = _standalone_payload(identity, manager_model_id=manager_model_id) + bind_standalone_component_output( + payload, + issuer=node._standalone_component_issuer, + component_kind="transformer", + reviewed_identity=identity, + ) + return {"model": payload} + + node.execute = Mock(side_effect=publish) + inputs = { + "model_type": "transformer", + "model_id": {"source": "hub", "value": "fixture/transformer"}, + "dtype": "float32", + "subfolder": "transformer", + "variant": "", + "trust_remote_code": False, + "revision": "a" * 40, + "device": "cpu", + "auto_offload": False, + "offload_mode": "none", + } + with ( + patch( + "modules.ModularDiffusers.loaders._preflight_reviewed_diffusers_component", + side_effect=(identity_a, identity_b, identity_b, identity_b), + ), + patch("modiff.NodeBase.modelstore.is_hf_cached", return_value=True), + patch("modules.ModularDiffusers.loaders._resolve_reviewed_diffusers_component_class") as resolver, + ): + first = node(**inputs) + first_binding = require_standalone_component_binding(first["model"], label="first model") + second = node(**inputs) + second_binding = require_standalone_component_binding(second["model"], label="second model") + third = node(**inputs) + self.assertIs(third, second) + self.assertIs( + require_standalone_component_binding(third["model"], label="cached model"), + second_binding, + ) + with self.assertRaisesRegex(ValueError, "no longer the current"): + require_standalone_component_binding(first["model"], label="old resident model") + + third["model"]["repo_id"] = "attacker/component" + with self.assertRaisesRegex(ValueError, "provenance binding"): + node(**inputs) + + self.assertIsNot(first_binding, second_binding) + self.assertEqual(node.execute.call_count, 2) + resolver.assert_not_called() + + +class OpaqueRoutePrimitiveTests(unittest.TestCase): + def test_route_is_identity_cached_and_nonserializable(self): + token, _outputs = _bound_outputs() + route, image_latents = _normal_encoder_route(token) + + self.assertIs(deepcopy(route), route) + self.assertTrue(deep_equal(route, deepcopy(route))) + with self.assertRaises(TypeError): + pickle.dumps(route) + with self.assertRaises(TypeError): + json.dumps({"route": route}) + + forged = object.__new__(type(route)) + with self.assertRaisesRegex(ValueError, "not issued"): + validate_encoder_route_state( + forged, + binding=token, + model_type=QWEN_EDIT, + seed=7, + image_latents=image_latents, + ) + + def test_generator_snapshot_is_post_vae_and_fresh_for_each_denoise_retry(self): + token, _outputs = _bound_outputs() + seed = 19 + fresh = torch.Generator(device="cpu").manual_seed(seed) + fresh_state = fresh.get_state().clone() + advanced = torch.Generator(device="cpu").manual_seed(seed) + torch.rand((4,), generator=advanced) + image_latents = torch.zeros((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=seed, + generator=advanced, + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + first = consume_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT, + seed=seed, + execution_device="cpu", + image_latents=image_latents, + )["generator"] + second = consume_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT, + seed=seed, + execution_device="cpu:0", + image_latents=image_latents, + )["generator"] + + self.assertTrue(torch.equal(first.get_state(), second.get_state())) + self.assertFalse(torch.equal(first.get_state(), fresh_state)) + self.assertTrue( + torch.equal( + torch.rand((8,), generator=first), + torch.rand((8,), generator=second), + ) + ) + + def test_sdxl_route_continues_post_vae_generator_and_exact_typed_inpaint_state(self): + fixture = _sdxl_encoder_route(seed=19) + first = consume_denoise_route_state( + fixture["route"], + binding=fixture["token"], + model_type=SDXL, + seed=19, + execution_device="cpu", + image_latents=fixture["image_latents"], + mask=fixture["mask"], + masked_image_latents=fixture["masked_image_latents"], + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + ) + second = consume_denoise_route_state( + fixture["route"], + binding=fixture["token"], + model_type=SDXL, + seed=19, + execution_device="cpu:0", + image_latents=fixture["image_latents"], + mask=fixture["mask"], + masked_image_latents=fixture["masked_image_latents"], + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + ) + + fresh = torch.Generator(device="cpu").manual_seed(19) + self.assertFalse(torch.equal(first["generator"].get_state(), fresh.get_state())) + self.assertTrue(torch.equal(first["generator"].get_state(), second["generator"].get_state())) + self.assertIs(first["mask"], fixture["mask"]) + self.assertIs(first["masked_image_latents"], fixture["masked_image_latents"]) + self.assertIsNone(first["crops_coords"]) + + def test_sdxl_crop_route_snapshots_bounded_pil_media_and_materializes_once_requested(self): + fixture = _sdxl_encoder_route(crop=True) + fixture["original_image"].putpixel((0, 0), (0, 0, 255)) + fixture["original_mask"].putpixel((0, 0), 0) + denoised = torch.zeros((1, 4, 8, 8)) + decode_route = issue_decode_route_state( + fixture["route"], + binding=fixture["token"], + latents=denoised, + vae_component=fixture["vae"], + ) + + validate_only = consume_decode_route_state( + decode_route, + binding=fixture["token"], + model_type=SDXL, + latents=denoised, + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + materialize_overlay=False, + ) + materialized = consume_decode_route_state( + decode_route, + binding=fixture["token"], + model_type=SDXL, + latents=denoised, + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + )["decode_inputs"] + + self.assertIsNone(validate_only["decode_inputs"]) + self.assertEqual(materialized["padding_mask_crop"], 0) + self.assertEqual(materialized["crops_coords"], (4, 5, 60, 61)) + self.assertEqual(materialized["image"].getpixel((0, 0)), (255, 0, 0)) + self.assertEqual(materialized["mask_image"].getpixel((0, 0)), 255) + self.assertIsNot(materialized["image"], fixture["original_image"]) + self.assertIsNot(materialized["mask_image"], fixture["original_mask"]) + + def test_sdxl_route_accepts_exact_ordinary_controlnet_and_ip_adapter_composition(self): + fixture = _sdxl_encoder_route() + common = { + "binding": fixture["token"], + "model_type": SDXL, + "seed": 7, + "image_latents": fixture["image_latents"], + "mask": fixture["mask"], + "masked_image_latents": fixture["masked_image_latents"], + "vae_latent_channels": 4, + "vae_scale_factor": 8, + } + with self.assertRaisesRegex(ValueError, "exact component paired"): + validate_denoise_route_state( + fixture["route"], + vae_component=_FixtureSdxlVae(), + **common, + ) + fixture["vae"].config.latent_channels = 5 + with self.assertRaisesRegex(ValueError, "four-channel|geometry changed"): + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + **common, + ) + fixture["vae"].config.latent_channels = 4 + ordinary_identity = _standalone_identity(class_name="ControlNetModel") + _issuer, ordinary_controlnet = _publish_standalone(ordinary_identity) + self.assertIsNone( + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=ordinary_controlnet, + **common, + ) + ) + consumed = consume_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=ordinary_controlnet, + execution_device="cpu", + **common, + ) + self.assertTrue(torch.equal(consumed["generator"].get_state(), fixture["generator"].get_state())) + + for values, message in ( + ({"controlnet_bundle_present": True}, "exact connected component bundle"), + ({"controlnet_component": ordinary_controlnet}, "exact connected component bundle"), + ( + { + "controlnet_bundle_present": True, + "controlnet_component": ordinary_controlnet, + "control_image_latents": torch.zeros((1, 4, 8, 8)), + }, + "does not accept prepared Qwen", + ), + ): + with self.subTest(values=tuple(values)), self.assertRaisesRegex(ValueError, message): + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + **common, + **values, + ) + self.assertIsNone( + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + ip_adapter_present=True, + **common, + ) + ) + + _union_issuer, union_controlnet = _publish_standalone( + _standalone_identity(class_name="ControlNetUnionModel"), + manager_model_id="controlnet-union-resident", + ) + self.assertIsNone( + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=union_controlnet, + control_mode=3, + **common, + ) + ) + union_consumed = consume_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=union_controlnet, + control_mode=3, + execution_device="cpu", + **common, + ) + self.assertTrue(torch.equal(union_consumed["generator"].get_state(), fixture["generator"].get_state())) + with self.assertRaisesRegex(ValueError, "exact ControlNetModel"): + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=union_controlnet, + **common, + ) + with self.assertRaisesRegex(ValueError, "bounded canonical integer"): + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=union_controlnet, + control_mode=32, + **common, + ) + + _replacement_issuer, replacement_controlnet = _publish_standalone( + ordinary_identity, + issuer=_issuer, + manager_model_id="controlnet-resident", + ) + with self.assertRaisesRegex(ValueError, "no longer the current"): + validate_denoise_route_state( + fixture["route"], + vae_component=fixture["vae"], + controlnet_bundle_present=True, + controlnet_component=ordinary_controlnet, + **common, + ) + self.assertIsNotNone(replacement_controlnet) + + dead_fixture = _sdxl_encoder_route() + departed = weakref.ref(dead_fixture.pop("vae")) + gc.collect() + self.assertIsNone(departed()) + with self.assertRaisesRegex(ValueError, "no longer resident"): + validate_encoder_route_state( + dead_fixture["route"], + binding=dead_fixture["token"], + model_type=SDXL, + seed=7, + image_latents=dead_fixture["image_latents"], + mask=dead_fixture["mask"], + masked_image_latents=dead_fixture["masked_image_latents"], + vae_component=_FixtureSdxlVae(), + vae_latent_channels=4, + vae_scale_factor=8, + ) + + def test_route_tensor_version_seals_reject_in_place_encoder_and_decode_mutation(self): + for field in ("image_latents", "mask", "masked_image_latents"): + with self.subTest(contract="sdxl", field=field): + fixture = _sdxl_encoder_route() + fixture[field].add_(1) + with self.assertRaisesRegex(ValueError, "mutated or rebound"): + validate_encoder_route_state( + fixture["route"], + binding=fixture["token"], + model_type=SDXL, + seed=7, + image_latents=fixture["image_latents"], + mask=fixture["mask"], + masked_image_latents=fixture["masked_image_latents"], + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + ) + + qwen_token, _outputs = _bound_outputs(QWEN_EDIT) + qwen_route, qwen_latents = _normal_encoder_route(qwen_token) + qwen_latents.add_(1) + with self.assertRaisesRegex(ValueError, "mutated or rebound"): + validate_encoder_route_state( + qwen_route, + binding=qwen_token, + model_type=QWEN_EDIT, + seed=7, + image_latents=qwen_latents, + ) + + fixture = _sdxl_encoder_route() + denoised = torch.zeros((1, 4, 8, 8)) + decode_route = issue_decode_route_state( + fixture["route"], + binding=fixture["token"], + latents=denoised, + vae_component=fixture["vae"], + ) + denoised.add_(1) + with self.assertRaisesRegex(ValueError, "mutated or rebound"): + consume_decode_route_state( + decode_route, + binding=fixture["token"], + model_type=SDXL, + latents=denoised, + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + ) + + def test_sdxl_route_rejects_malformed_latent_structure_and_crop_geometry(self): + token, _outputs = _bound_outputs(SDXL) + vae = _FixtureSdxlVae() + base = { + "binding": token, + "seed": 7, + "generator": torch.Generator(device="cpu").manual_seed(7), + "image_latents": torch.zeros((1, 4, 8, 8)), + "mask": torch.zeros((1, 1, 8, 8)), + "masked_image_latents": torch.zeros((1, 4, 8, 8)), + "vae_component": vae, + "vae_latent_channels": 4, + "vae_scale_factor": 8, + } + invalid_tensors = ( + ("image_latents", torch.zeros((1, 4, 8)), "rank-4"), + ("image_latents", torch.zeros((1, 5, 8, 8)), "channels"), + ("mask", torch.zeros((1, 2, 8, 8)), "one channel"), + ("mask", torch.zeros((2, 1, 8, 8)), "batch and spatial"), + ("masked_image_latents", torch.zeros((1, 4, 7, 8)), "batch and spatial"), + ("mask", torch.zeros((1, 1, 8, 8), dtype=torch.float64), "one exact dtype"), + ("image_latents", torch.zeros((1, 4, 513, 513)), "decoded-pixel budget"), + ) + for field, value, message in invalid_tensors: + with self.subTest(field=field, shape=tuple(value.shape)), self.assertRaisesRegex(ValueError, message): + issue_encoder_route_state(**{**base, field: value}) + + with self.assertRaisesRegex(ValueError, "pinned four-channel"): + issue_encoder_route_state( + **{ + **base, + "vae_component": _FixtureSdxlVae(latent_channels=5), + "vae_latent_channels": 5, + "image_latents": torch.zeros((1, 5, 8, 8)), + "masked_image_latents": torch.zeros((1, 5, 8, 8)), + } + ) + + image = Image.new("RGB", (64, 64), "red") + mask = Image.new("L", (64, 64), 255) + invalid_crops = ( + ([0, 0, 8, 8], "exact tuple"), + ((True, 0, 8, 8), "exact tuple"), + ((0, 0, 0, 8), "nonempty region"), + ((0, 0, 65, 8), "within the original"), + ) + for coords, message in invalid_crops: + with self.subTest(coords=coords), self.assertRaisesRegex(ValueError, message): + issue_encoder_route_state( + **base, + padding_mask_crop=0, + crops_coords=coords, + original_image=image, + original_mask=mask, + ) + + def test_route_issuance_requires_exact_typed_latent_outputs(self): + token, _outputs = _bound_outputs(QWEN_EDIT_PLUS) + generator = torch.Generator(device="cpu").manual_seed(7) + with self.assertRaisesRegex(TypeError, "VAE image latents must be an exact Torch tensor"): + issue_encoder_route_state( + binding=token, + seed=7, + generator=generator, + image_latents=object(), + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + with self.assertRaisesRegex(TypeError, "Denoise latents must be an exact Torch tensor"): + issue_normal_decode_route_state(binding=token, latents=object()) + + tensor = torch.zeros((1, 1, 2, 2)) + + class TensorList(list): + pass + + class CustomTensorSequence: + def __init__(self, values): + self.values = values + + def __iter__(self): + return iter(self.values) + + def __len__(self): + return len(self.values) + + invalid_sequences = ( + (tuple([tensor]), TypeError, "exact Torch tensor or a bounded nonempty list"), + (TensorList([tensor]), TypeError, "exact Torch tensor or a bounded nonempty list"), + (CustomTensorSequence([tensor]), TypeError, "exact Torch tensor or a bounded nonempty list"), + ([], ValueError, "must not be empty"), + ([tensor, object()], TypeError, r"VAE image latents\[1\].*exact Torch tensor"), + ([torch.zeros(()) for _index in range(65)], ValueError, "more than 64"), + ) + for latent_value, error_type, message in invalid_sequences: + with self.subTest(latent_value=type(latent_value).__name__), self.assertRaisesRegex(error_type, message): + issue_encoder_route_state( + binding=token, + seed=7, + generator=generator, + image_latents=latent_value, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + def test_multi_latent_route_preserves_list_kind_length_order_and_weak_identity(self): + token, _outputs = _bound_outputs(QWEN_EDIT_PLUS) + first = torch.zeros((1, 1, 2, 2)) + second = torch.ones((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=[first, second], + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + # Rewrapping is harmless, but the exact list kind and tensor identities, + # length, and order remain authoritative. + validate_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT_PLUS, + seed=7, + image_latents=[first, second], + ) + for connected, error_type, message in ( + ((first, second), TypeError, "bounded nonempty list"), + ([first], ValueError, "exact latent output paired"), + ([second, first], ValueError, "exact latent output paired"), + ([first, second.clone()], ValueError, "exact latent output paired"), + ): + with ( + self.subTest(connected=type(connected).__name__, length=len(connected)), + self.assertRaisesRegex(error_type, message), + ): + validate_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT_PLUS, + seed=7, + image_latents=connected, + ) + + def issue_without_retaining_latents(): + transient_latents = [torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))] + return issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=transient_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + dead_route = issue_without_retaining_latents() + gc.collect() + with self.assertRaisesRegex(ValueError, "no longer resident"): + validate_encoder_route_state( + dead_route, + binding=token, + model_type=QWEN_EDIT_PLUS, + seed=7, + image_latents=[torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))], + ) + + def test_single_image_qwen_route_issuance_rejects_latent_lists(self): + for model_type in (QWEN_IMAGE, QWEN_EDIT): + with self.subTest(model_type=model_type): + token, _outputs = _bound_outputs(model_type) + with self.assertRaisesRegex(TypeError, "VAE image latents must be an exact Torch tensor"): + issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=[torch.zeros((1, 1, 2, 2))], + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + def test_zero_and_max_seed_work_while_noncanonical_seed_values_fail(self): + token, _outputs = _bound_outputs() + for seed in (0, 4294967295): + with self.subTest(seed=seed): + route, image_latents = _normal_encoder_route(token, seed=seed, advance_generator=False) + validate_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT, + seed=seed, + image_latents=image_latents, + ) + + route, image_latents = _normal_encoder_route(token, seed=7) + for invalid in (True, 7.0, "07", "-0", None): + with self.subTest(seed=invalid), self.assertRaisesRegex(ValueError, "seed"): + validate_encoder_route_state( + route, + binding=token, + model_type=QWEN_EDIT, + seed=invalid, + image_latents=image_latents, + ) + + def test_route_rejects_cross_loader_cross_model_and_wrong_stage(self): + token_a, _outputs_a = _bound_outputs(suffix="a") + token_b, _outputs_b = _bound_outputs(suffix="b") + route, image_latents = _normal_encoder_route(token_a) + with self.assertRaisesRegex(ValueError, "different Models Loader"): + validate_encoder_route_state( + route, + binding=token_b, + model_type=QWEN_EDIT, + seed=7, + image_latents=image_latents, + ) + with self.assertRaisesRegex(ValueError, "different pipeline"): + validate_encoder_route_state( + route, + binding=token_a, + model_type="QwenImageModularPipeline", + seed=7, + image_latents=image_latents, + ) + denoised_latents = torch.zeros((1, 1, 2, 2)) + decode_route = issue_decode_route_state( + route, + binding=token_a, + actual_mask=None, + latents=denoised_latents, + ) + with self.assertRaisesRegex(ValueError, "wrong action stage"): + validate_encoder_route_state( + decode_route, + binding=token_a, + model_type=QWEN_EDIT, + seed=7, + image_latents=image_latents, + ) + with self.assertRaisesRegex(ValueError, "wrong action stage"): + consume_decode_route_state( + route, + binding=token_a, + model_type=QWEN_EDIT, + latents=denoised_latents, + ) + + def test_inpaint_mask_and_overlay_are_an_atomic_route_contract(self): + token, _outputs = _bound_outputs() + generator = torch.Generator(device="cpu").manual_seed(7) + image_latents = torch.zeros((1, 1, 2, 2)) + for processed_mask, overlay in ((object(), None), (None, {"crop": (0, 0, 8, 8)})): + with ( + self.subTest(processed_mask=processed_mask, overlay=overlay), + self.assertRaisesRegex(ValueError, "both processed mask and overlay"), + ): + issue_encoder_route_state( + binding=token, + seed=7, + generator=generator, + image_latents=image_latents, + processed_mask_image=processed_mask, + mask_overlay_kwargs=overlay, + ) + + inpaint_route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=torch.ones((1, 1, 8, 8)), + mask_overlay_kwargs={ + "crops_coords": (0, 0, 8, 8), + "original_image": object(), + "original_mask": object(), + }, + ) + denoised_latents = torch.zeros((1, 1, 2, 2)) + decode_route = issue_decode_route_state( + inpaint_route, + binding=token, + actual_mask=torch.ones((1, 1, 8, 8)), + latents=denoised_latents, + ) + decoded = consume_decode_route_state( + decode_route, + binding=token, + model_type=QWEN_EDIT, + latents=denoised_latents, + ) + self.assertTrue(decoded["inpaint"]) + self.assertEqual(decoded["mask_overlay_kwargs"]["crops_coords"], (0, 0, 8, 8)) + + malformed_overlays = ( + ({"crops_coords": None, "original_image": None}, "pinned Qwen contract"), + ( + {"crops_coords": None, "original_image": object(), "original_mask": None}, + "require non-null", + ), + ( + {"crops_coords": [0, 0, 8, 8], "original_image": object(), "original_mask": object()}, + "four integers", + ), + ) + for overlay, message in malformed_overlays: + with self.subTest(overlay=overlay), self.assertRaisesRegex(ValueError, message): + issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=torch.ones((1, 1, 8, 8)), + mask_overlay_kwargs=overlay, + ) + with self.assertRaisesRegex(TypeError, "processed Modular inpaint mask"): + issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=object(), + mask_overlay_kwargs={ + "crops_coords": None, + "original_image": None, + "original_mask": None, + }, + ) + with self.assertRaisesRegex(TypeError, "Denoise inpaint mask"): + issue_decode_route_state( + inpaint_route, + binding=token, + actual_mask=object(), + latents=denoised_latents, + ) + + def test_controlnet_route_seals_post_control_generator_and_retries_from_fresh_clones(self): + token, _outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-generator-route") + seed = 29 + generator = torch.Generator(device="cpu").manual_seed(seed) + torch.rand((5,), generator=generator) + post_control_state = generator.get_state().clone() + control_latents = torch.zeros((1, 1, 2, 2)) + route = issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet, + seed=seed, + generator=generator, + control_image_latents=control_latents, + ) + + first = consume_denoise_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + image_latents=None, + control_image_latents=control_latents, + controlnet_component=controlnet, + )["generator"] + second = consume_denoise_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu:0", + image_latents=None, + control_image_latents=control_latents, + controlnet_component=controlnet, + )["generator"] + + self.assertTrue(torch.equal(first.get_state(), post_control_state)) + self.assertTrue(torch.equal(first.get_state(), second.get_state())) + self.assertTrue(torch.equal(torch.rand((8,), generator=first), torch.rand((8,), generator=second))) + + def test_controlnet_stage_carries_image_pairing_and_inpaint_state_without_a_large_input(self): + token, _outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-image-route") + seed = 31 + image_latents = torch.zeros((1, 1, 2, 2)) + processed_mask = torch.ones((1, 1, 8, 8)) + overlay = { + "crops_coords": None, + "original_image": None, + "original_mask": None, + } + encoder_generator = torch.Generator(device="cpu").manual_seed(seed) + torch.rand((3,), generator=encoder_generator) + encoder_route = issue_encoder_route_state( + binding=token, + seed=seed, + generator=encoder_generator, + image_latents=image_latents, + processed_mask_image=processed_mask, + mask_overlay_kwargs=overlay, + ) + + first_control_generator = consume_controlnet_input_route_state( + encoder_route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + ) + retry_control_generator = consume_controlnet_input_route_state( + encoder_route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + ) + self.assertTrue(torch.equal(first_control_generator.get_state(), retry_control_generator.get_state())) + torch.rand((4,), generator=first_control_generator) + control_latents = torch.ones((1, 1, 2, 2)) + control_route = issue_controlnet_route_state( + encoder_route, + binding=token, + controlnet_component=controlnet, + seed=seed, + generator=first_control_generator, + control_image_latents=control_latents, + ) + + consumed = consume_denoise_route_state( + control_route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + image_latents=image_latents, + control_image_latents=control_latents, + controlnet_component=controlnet, + ) + self.assertIs(consumed["processed_mask_image"], processed_mask) + with self.assertRaisesRegex(ValueError, "exact latent output paired"): + validate_denoise_route_state( + control_route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + image_latents=image_latents.clone(), + control_image_latents=control_latents, + controlnet_component=controlnet, + ) + with self.assertRaisesRegex(ValueError, "exact latent output paired"): + validate_denoise_route_state( + control_route, + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + image_latents=image_latents, + control_image_latents=control_latents.clone(), + controlnet_component=controlnet, + ) + + denoised_latents = torch.full((1, 1, 2, 2), 2.0) + decode_route = issue_decode_route_state( + control_route, + binding=token, + actual_mask=processed_mask, + latents=denoised_latents, + ) + decoded = consume_decode_route_state( + decode_route, + binding=token, + model_type=QWEN_IMAGE, + latents=denoised_latents, + ) + self.assertTrue(decoded["inpaint"]) + self.assertEqual(decoded["mask_overlay_kwargs"], overlay) + + def test_controlnet_latent_carrier_is_exact_bounded_and_weak(self): + token, _outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-list-route") + generator = torch.Generator(device="cpu").manual_seed(7) + first = torch.zeros((1, 1, 2, 2)) + second = torch.ones((1, 1, 2, 2)) + route = issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet, + seed=7, + generator=generator, + control_image_latents=[first, second], + ) + validate_controlnet_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=7, + image_latents=None, + control_image_latents=[first, second], + controlnet_component=controlnet, + ) + + class TensorList(list): + pass + + class CustomTensorSequence: + def __iter__(self): + return iter((first,)) + + def __len__(self): + return 1 + + invalid_issuance = ( + ((first,), TypeError, "bounded nonempty list"), + (TensorList([first]), TypeError, "bounded nonempty list"), + (CustomTensorSequence(), TypeError, "bounded nonempty list"), + ([], ValueError, "must not be empty"), + ([first, object()], TypeError, r"ControlNet image latents\[1\].*exact Torch tensor"), + ([torch.zeros(()) for _index in range(65)], ValueError, "more than 64"), + ) + for value, error_type, message in invalid_issuance: + with self.subTest(value=type(value).__name__), self.assertRaisesRegex(error_type, message): + issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet, + seed=7, + generator=generator, + control_image_latents=value, + ) + + for connected in ([second, first], [first], [first, second.clone()]): + with self.subTest(length=len(connected)), self.assertRaisesRegex(ValueError, "exact latent output paired"): + validate_controlnet_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=7, + image_latents=None, + control_image_latents=connected, + controlnet_component=controlnet, + ) + + def issue_without_retaining_control_latents(): + transient = torch.zeros((1, 1, 2, 2)) + return issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet, + seed=7, + generator=generator, + control_image_latents=transient, + ) + + dead_route = issue_without_retaining_control_latents() + gc.collect() + with self.assertRaisesRegex(ValueError, "no longer resident"): + validate_controlnet_route_state( + dead_route, + binding=token, + model_type=QWEN_IMAGE, + seed=7, + image_latents=None, + control_image_latents=torch.zeros((1, 1, 2, 2)), + controlnet_component=controlnet, + ) + + def test_controlnet_route_requires_exact_current_controlnet_kind_and_publication(self): + token, _outputs = _bound_outputs(QWEN_IMAGE) + identity_a = _standalone_identity(fingerprint="a" * 64) + issuer, controlnet_a = _publish_standalone( + identity_a, + manager_model_id="controlnet-revocation-route", + ) + control_latents = torch.zeros((1, 1, 2, 2)) + route = issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet_a, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + control_image_latents=control_latents, + ) + _other_issuer, wrong_kind = _publish_standalone( + manager_model_id="standalone-vae-route", + component_kind="vae", + ) + with self.assertRaisesRegex(ValueError, "not 'controlnet'"): + issue_controlnet_route_state( + None, + binding=token, + controlnet_component=wrong_kind, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + control_image_latents=control_latents, + ) + + _issuer, _controlnet_b = _publish_standalone( + identity_a, + issuer=issuer, + manager_model_id="controlnet-revocation-route", + ) + with self.assertRaisesRegex(ValueError, "superseded"): + require_route_state_current_publication(route, label="ControlNet route") + with self.assertRaisesRegex(ValueError, "superseded"): + validate_controlnet_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=7, + image_latents=None, + control_image_latents=control_latents, + controlnet_component=controlnet_a, + ) + + def test_controlnet_input_route_rejects_dead_inherited_latents_without_receiving_typed_edge(self): + token, _outputs = _bound_outputs(QWEN_IMAGE) + + def issue_without_retaining_image_latents(): + transient = torch.zeros((1, 1, 2, 2)) + return issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=transient, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + route = issue_without_retaining_image_latents() + gc.collect() + with self.assertRaisesRegex(ValueError, "no longer resident"): + validate_controlnet_input_route_state( + route, + binding=token, + model_type=QWEN_IMAGE, + seed=7, + ) + + def test_nested_token_scan_is_bounded_and_cycle_safe(self): + token, _outputs = _bound_outputs() + cyclic = {} + cyclic["self"] = cyclic + require_matching_token_bearers(cyclic, token, label="cyclic bundle") + + nested = {} + cursor = nested + for _index in range(40): + cursor["next"] = {} + cursor = cursor["next"] + with self.assertRaisesRegex(ValueError, "safe nested-value limit"): + require_matching_token_bearers(nested, token, label="deep bundle") + + def test_reserved_route_values_cannot_be_overwritten_by_a_bundle(self): + for field in ("generator", "processed_mask_image", "mask_overlay_kwargs", "mask"): + with self.subTest(field=field), self.assertRaisesRegex(ValueError, "cannot overwrite"): + reject_route_reserved_inputs( + {"embeddings": {field: object()}}, + bundle_names=["embeddings"], + ) + + +class SDXLIPAdapterReceiptTests(unittest.TestCase): + def test_exact_backend_publication_validates_and_cannot_serialize(self): + fixture = _sdxl_ip_adapter_fixture() + self.assertIs( + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + )._binding, + fixture["token"], + ) + with self.assertRaises(TypeError): + json.dumps(fixture["bundle"]) + with self.assertRaises(TypeError): + pickle.dumps(fixture["bundle"]) + + def test_wrong_loader_unet_guider_or_embedding_identity_fails_closed(self): + fixture = _sdxl_ip_adapter_fixture() + other = _sdxl_ip_adapter_fixture(suffix="b") + cases = ( + ({"binding": other["token"]}, "different Models Loader"), + ({"unet": other["unet"]}, "different resident UNet"), + ({"guider": other["guider"]}, "exact same Guider"), + ) + for override, message in cases: + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=override.get("binding", fixture["token"]), + unet=override.get("unet", fixture["unet"]), + guider=override.get("guider", fixture["guider"]), + ) + + fixture["bundle"]["ip_adapter_embeds"] = [torch.zeros((1, 1, 1024))] + with self.assertRaisesRegex(ValueError, "changed after backend publication"): + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + + def test_unet_image_and_latest_publication_tampering_fails_closed(self): + fixture = _sdxl_ip_adapter_fixture() + with torch.no_grad(): + next(iter(fixture["unet"].encoder_hid_proj.image_projection_layers[0].parameters())).add_(1) + with self.assertRaisesRegex(ValueError, "UNet state changed"): + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + + fixture = _sdxl_ip_adapter_fixture() + fixture["image"].putpixel((0, 0), (1, 2, 3)) + with self.assertRaisesRegex(ValueError, "source image changed"): + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + + fixture = _sdxl_ip_adapter_fixture() + replacement = issue_sdxl_ip_adapter_bundle( + binding=fixture["token"], + unet=fixture["unet"], + artifact_identity=("h94/IP-Adapter", "0" * 40, "weight", "2" * 64, 1, "encoder", "class"), + image_encoder=fixture["encoder"], + feature_extractor=fixture["processor"], + guider=fixture["guider"], + scale=fixture["scale"], + image=fixture["image"], + ip_adapter_embeds=fixture["embeddings"], + negative_ip_adapter_embeds=fixture["negative_embeddings"], + ) + with self.assertRaisesRegex(ValueError, "no longer the current"): + require_sdxl_ip_adapter_bundle( + fixture["bundle"], + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + self.assertIsNotNone( + require_sdxl_ip_adapter_bundle( + replacement, + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + ) + + def test_resident_adapter_requires_its_exact_bundle(self): + fixture = _sdxl_ip_adapter_fixture() + with self.assertRaisesRegex(ValueError, "no matching adapter bundle"): + require_sdxl_ip_adapter_bundle( + None, + binding=fixture["token"], + unet=fixture["unet"], + guider=fixture["guider"], + ) + + +class RouteRuntimeBoundaryTests(unittest.TestCase): + def test_sdxl_crop_media_preflight_rejects_untrusted_or_oversize_inputs_before_init(self): + _token, outputs = _bound_outputs(SDXL) + init_pipeline = Mock() + blocks = type( + "SdxlEncodeBlocks", + (), + { + "input_names": ["image", "mask_image", "padding_mask_crop", "generator"], + "component_names": ["vae"], + "init_pipeline": init_pipeline, + }, + )() + config = { + "params": { + "image": {"type": "image"}, + "mask_image": {"type": "image"}, + "padding_mask_crop": {"type": "int", "min": 0, "max": 8192}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + }, + "model_input_names": ["vae"], + "input_names": ["image", "mask_image", "padding_mask_crop", "seed"], + "output_names": ["image_latents", "mask", "masked_image_latents", ROUTE_STATE_OUTPUT], + } + valid_image = Image.new("RGB", (64, 64), "red") + valid_mask = Image.new("L", (64, 64), 255) + invalid_cases = ( + (torch.zeros((1, 3, 64, 64)), valid_mask, "must be a PIL image"), + (np.zeros((64, 64, 3), dtype=np.uint8), valid_mask, "must be a PIL image"), + ([valid_image], valid_mask, "must be a PIL image"), + (valid_image, None, "requires a mask image"), + (valid_image, Image.new("L", (32, 64), 255), "dimensions must match"), + (Image.new("L", (8193, 1), 0), Image.new("L", (8193, 1), 0), "no larger than 8192"), + ( + Image.new("L", (4097, 2048), 0), + Image.new("L", (4097, 2048), 0), + "16-Mi-pixel", + ), + ) + node = ImageEncode("sdxl-crop-preflight") + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(blocks, config), + ), + ): + for image, mask_image, message in invalid_cases: + with self.subTest(message=message), self.assertRaisesRegex((TypeError, ValueError), message): + node.execute( + vae=outputs["vae_out"], + image=image, + mask_image=mask_image, + padding_mask_crop=0, + seed=7, + ) + + init_pipeline.assert_not_called() + + def test_sdxl_cache_hits_revalidate_exact_resident_vae_and_geometry_for_all_three_actions(self): + fixture = _sdxl_encoder_route() + model_id = fixture["outputs"]["vae_out"]["model_id"] + resident = fixture["vae"] + resident_unet = type("ResidentUnet", (), {})() + resident_state = {model_id: resident} + + def manager_result(*, ids, return_dict_with_names=True): + if return_dict_with_names: + return {"unet": resident_unet, "vae": resident_state[model_id], "scheduler": object()} + return {requested: resident_state[requested] for requested in ids} + + encoder = ImageEncode("sdxl-encoder-cache-provenance") + encoder._pipeline_class = diffusers.StableDiffusionXLModularPipeline + encoder._model_type = SDXL + encoder._pipeline = type("ResidentEncodePipeline", (), {"vae": resident})() + encoder.output = { + "image_latents": fixture["image_latents"], + "mask": fixture["mask"], + "masked_image_latents": fixture["masked_image_latents"], + ROUTE_STATE_OUTPUT: fixture["route"], + } + encode_params = {"vae": fixture["outputs"]["vae_out"], "seed": 7} + + denoised = torch.zeros((1, 4, 8, 8)) + normal_route = issue_normal_decode_route_state( + binding=fixture["token"], + latents=denoised, + vae_component=resident, + vae_latent_channels=4, + vae_scale_factor=8, + ) + denoise = Denoise("sdxl-denoise-cache-provenance") + denoise._pipeline_class = diffusers.StableDiffusionXLModularPipeline + denoise._model_type = SDXL + denoise._pipeline = type("ResidentDenoisePipeline", (), {"unet": resident_unet, "vae": resident})() + denoise._route_cache_node_input_names = ("seed", ROUTE_STATE_INPUT) + denoise._route_cache_block_input_names = ("generator",) + denoise._route_cache_component_names = ("unet", "vae", "scheduler") + denoise._route_cache_model_input_names = ("unet", "vae", "scheduler") + denoise.output = {"latents": denoised, ROUTE_STATE_OUTPUT: normal_route} + denoise_params = { + "unet": fixture["outputs"]["unet_out"], + "vae": fixture["outputs"]["vae_out"], + "scheduler": fixture["outputs"]["scheduler"], + "seed": 7, + } + + decode = DecodeLatents("sdxl-decode-cache-provenance") + decode._pipeline_class = diffusers.StableDiffusionXLModularPipeline + decode._model_type = SDXL + decode._pipeline = type("ResidentDecodePipeline", (), {"vae": resident})() + decode_params = { + "vae": fixture["outputs"]["vae_out"], + "latents": denoised, + ROUTE_STATE_INPUT: normal_route, + } + decode_blocks = type("DecodeBlocks", (), {"input_names": ["latents"]})() + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + + with ( + patch( + "modules.ModularDiffusers.latents.components.get_components_by_ids", + side_effect=manager_result, + ), + patch( + "modules.ModularDiffusers.denoise.components.get_components_by_ids", + side_effect=manager_result, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(decode_blocks, decode_config), + ), + ): + self.assertTrue(encoder._cache_params_equal(encode_params, encode_params)) + string_seed_params = {**encode_params, "seed": "7"} + self.assertTrue(encoder._cache_params_equal(string_seed_params, string_seed_params)) + for invalid_seed in (True, 7.5, "07"): + invalid_params = {**encode_params, "seed": invalid_seed} + with self.subTest(invalid_seed=invalid_seed), self.assertRaisesRegex( + ValueError, + "Invalid Modular Diffusers seed", + ): + encoder._cache_params_equal(invalid_params, invalid_params) + self.assertTrue(denoise._cache_params_equal(denoise_params, denoise_params)) + self.assertTrue(decode._cache_params_equal(decode_params, decode_params)) + + resident.config.latent_channels = 5 + for action, check, params in ( + ("encoder", encoder._cache_params_equal, encode_params), + ("denoise", denoise._cache_params_equal, denoise_params), + ("decode", decode._cache_params_equal, decode_params), + ): + with self.subTest(action=action), self.assertRaisesRegex(ValueError, "four-channel|geometry changed"): + check(params, params) + + resident.config.latent_channels = 4 + replacement = _FixtureSdxlVae() + resident_state[model_id] = replacement + with self.assertRaisesRegex(ValueError, "resident Modular pipeline"): + encoder._cache_params_equal(encode_params, encode_params) + with self.assertRaisesRegex(ValueError, "resident SDXL Denoise pipeline"): + denoise._cache_params_equal(denoise_params, denoise_params) + with self.assertRaisesRegex(ValueError, "resident Modular pipeline"): + decode._cache_params_equal(decode_params, decode_params) + + def test_sdxl_base_inpaint_fake_actions_preserve_exact_state_and_overlay_semantics(self): + _token, outputs = _bound_outputs(SDXL) + vae = _FixtureSdxlVae() + unet_component = object() + scheduler_component = object() + trace = [] + captured = {} + image_latents = torch.zeros((1, 4, 8, 8)) + mask = torch.ones((1, 1, 8, 8)) + masked_image_latents = torch.full((1, 4, 8, 8), 2.0) + denoised = torch.full((1, 4, 8, 8), 3.0) + + class FakeEncodePipeline: + _execution_device = torch.device("cpu") + blocks = type("PipelineBlocks", (), {"doc": "encode"})() + + def __init__(self): + self.vae = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + trace.append("vae_encoder") + captured["encode"] = dict(kwargs) + torch.rand((3,), generator=kwargs["generator"]) + captured["post_vae_generator"] = kwargs["generator"].get_state().clone() + return { + "image_latents": image_latents, + "mask": mask, + "masked_image_latents": masked_image_latents, + "crops_coords": (4, 5, 60, 61), + } + + class FakeEncodeBlocks: + component_names = ["vae"] + input_names = ["image", "mask_image", "padding_mask_crop", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeEncodePipeline() + + encode_config = { + "params": { + "image": {"type": "image"}, + "mask_image": {"type": "image"}, + "padding_mask_crop": {"type": "int", "min": 0, "max": 8192}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + }, + "model_input_names": ["vae"], + "input_names": ["image", "mask_image", "padding_mask_crop", "seed"], + "output_names": ["image_latents", "mask", "masked_image_latents", ROUTE_STATE_OUTPUT], + } + + class FakeDenoisePipeline: + _execution_device = torch.device("cpu") + component_names = ["unet", "vae", "scheduler"] + blocks = type("PipelineBlocks", (), {"doc": "denoise"})() + transformer = None + + def __init__(self): + self.unet = None + self.vae = None + self.scheduler = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + trace.append("denoise") + captured["denoise"] = dict(kwargs) + captured["pre_denoise_generator"] = kwargs["generator"].get_state().clone() + return {"latents": denoised} + + class FakeDenoiseBlocks: + component_names = ["unet", "vae", "scheduler"] + input_names = [ + "prompt_embeds", + "image_latents", + "mask", + "masked_image_latents", + "crops_coords", + "generator", + ] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeDenoisePipeline() + + denoise_config = { + "params": { + "embeddings": {"type": "embeddings"}, + "image_latents": {"type": "latents"}, + "mask": {"type": "latent_mask"}, + "masked_image_latents": {"type": "masked_latents"}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["unet", "vae", "scheduler"], + "input_names": [ + "embeddings", + "image_latents", + "mask", + "masked_image_latents", + "seed", + ROUTE_STATE_INPUT, + ], + "output_names": ["latents", ROUTE_STATE_OUTPUT], + } + + class FakeDecodePipeline: + blocks = type("PipelineBlocks", (), {"doc": "decode"})() + + def __init__(self): + self.vae = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + trace.append("decoder") + captured["decode"] = dict(kwargs) + return {"images": "decoded-sdxl-inpaint"} + + class FakeDecodeBlocks: + component_names = ["vae"] + input_names = ["latents", "image", "mask_image", "padding_mask_crop", "crops_coords"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeDecodePipeline() + + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + + def managed_components(*, ids, return_dict_with_names=True): + if return_dict_with_names: + return { + "unet": unet_component, + "vae": vae, + "scheduler": scheduler_component, + } + return {model_id: vae for model_id in set(ids)} + + original_image = Image.new("RGB", (64, 64), "red") + original_mask = Image.new("L", (64, 64), 255) + with ( + patch( + "modules.ModularDiffusers.latents.components.get_components_by_ids", + side_effect=managed_components, + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=["shared-model"]), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=["shared-model"]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + ): + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeEncodeBlocks(), encode_config), + ): + encoded = ImageEncode().execute( + vae=outputs["vae_out"], + image=original_image, + mask_image=original_mask, + padding_mask_crop=0, + seed=7, + ) + with patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeDenoiseBlocks(), denoise_config), + ): + denoised_outputs = Denoise().execute( + unet=outputs["unet_out"], + vae=outputs["vae_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=encoded["image_latents"], + mask=encoded["mask"], + masked_image_latents=encoded["masked_image_latents"], + seed=7, + route_state_in=encoded[ROUTE_STATE_OUTPUT], + ) + with patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeDecodeBlocks(), decode_config), + ): + decoded = DecodeLatents().execute( + vae=outputs["vae_out"], + latents=denoised_outputs["latents"], + route_state_in=denoised_outputs[ROUTE_STATE_OUTPUT], + ) + + self.assertEqual(trace, ["vae_encoder", "denoise", "decoder"]) + self.assertTrue(torch.equal(captured["post_vae_generator"], captured["pre_denoise_generator"])) + self.assertIs(captured["denoise"]["image_latents"], image_latents) + self.assertIs(captured["denoise"]["mask"], mask) + self.assertIs(captured["denoise"]["masked_image_latents"], masked_image_latents) + self.assertEqual(captured["denoise"]["crops_coords"], (4, 5, 60, 61)) + self.assertEqual(captured["denoise"]["output"], ["latents"]) + self.assertNotIn("state", captured["decode"]) + self.assertEqual(captured["decode"]["padding_mask_crop"], 0) + self.assertEqual(captured["decode"]["crops_coords"], (4, 5, 60, 61)) + self.assertEqual(captured["decode"]["image"].getpixel((0, 0)), (255, 0, 0)) + self.assertEqual(captured["decode"]["mask_image"].getpixel((0, 0)), 255) + self.assertEqual(decoded["images"], "decoded-sdxl-inpaint") + + def test_sdxl_inpaint_controlnet_installs_exact_resident_component_and_closes_swaps(self): + for case in ("ordinary", "union", "init-swap", "call-swap"): + with self.subTest(case=case): + union = case == "union" + swap_phase = case.removesuffix("-swap") if case.endswith("-swap") else None + fixture = _sdxl_encoder_route() + outputs = fixture["outputs"] + unet_component = object() + scheduler_component = object() + first_controlnet = ( + type( + "FixtureControlNetUnion", + (), + {"config": type("FixtureControlNetUnionConfig", (), {"num_control_type": 8})()}, + )() + if union + else object() + ) + replacement_controlnet = object() + resident = {"controlnet": first_controlnet} + _issuer, controlnet_payload = _publish_standalone( + _standalone_identity(class_name="ControlNetUnionModel" if union else "ControlNetModel"), + manager_model_id=f"sdxl-controlnet-{case}", + ) + denoised = torch.full((1, 4, 8, 8), 3.0) + pipeline_calls = [] + pipelines = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["unet", "vae", "scheduler", "controlnet"] + blocks = type("PipelineBlocks", (), {"doc": "sdxl-controlnet-denoise"})() + transformer = None + + def __init__(self): + self.unet = None + self.vae = None + self.scheduler = None + self.controlnet = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + if swap_phase == "call": + resident["controlnet"] = replacement_controlnet + return {"latents": denoised} + + class FakeBlocks: + component_names = ["unet", "vae", "scheduler", "controlnet"] + input_names = [ + "prompt_embeds", + "image_latents", + "mask", + "masked_image_latents", + "generator", + "control_mode", + "control_image", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + ] + + @staticmethod + def init_pipeline(*, components_manager): + if swap_phase == "init": + resident["controlnet"] = replacement_controlnet + pipeline = FakePipeline() + pipelines.append(pipeline) + return pipeline + + config = { + "params": { + "embeddings": {"type": "embeddings"}, + "image_latents": {"type": "latents"}, + "mask": {"type": "latent_mask"}, + "masked_image_latents": {"type": "masked_latents"}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "controlnet_bundle": {"type": "custom_controlnet"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["unet", "vae", "scheduler", "controlnet_bundle"], + "input_names": [ + "embeddings", + "image_latents", + "mask", + "masked_image_latents", + "seed", + "controlnet_bundle", + ROUTE_STATE_INPUT, + ], + "output_names": ["latents", ROUTE_STATE_OUTPUT], + } + controlnet_bundle = { + "controlnet": controlnet_payload, + "control_image": "control-image", + "controlnet_conditioning_scale": 0.75, + "control_guidance_start": 0.1, + "control_guidance_end": 0.9, + } + if union: + controlnet_bundle["control_mode"] = 3 + + def managed_components(*, ids, return_dict_with_names=True): + if return_dict_with_names: + result = { + "unet": unet_component, + "vae": fixture["vae"], + "scheduler": scheduler_component, + } + if controlnet_payload["model_id"] in ids: + result["controlnet"] = resident["controlnet"] + return result + available = { + outputs["vae_out"]["model_id"]: fixture["vae"], + controlnet_payload["model_id"]: resident["controlnet"], + } + return {model_id: available[model_id] for model_id in set(ids)} + + kwargs = { + "unet": outputs["unet_out"], + "vae": outputs["vae_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": object()}, + "image_latents": fixture["image_latents"], + "mask": fixture["mask"], + "masked_image_latents": fixture["masked_image_latents"], + "controlnet_bundle": controlnet_bundle, + "seed": 7, + ROUTE_STATE_INPUT: fixture["route"], + } + denoise_node = Denoise() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + patch( + "modules.ModularDiffusers.denoise.components.get_components_by_ids", + side_effect=managed_components, + ), + ): + if swap_phase is not None: + with self.assertRaisesRegex(ValueError, "ControlNet changed|does not hold"): + denoise_node.execute(**kwargs) + continue + result = denoise_node.execute(**kwargs) + denoise_node.output = result + self.assertTrue(denoise_node._cache_params_equal(kwargs, kwargs)) + if union: + first_controlnet.config.num_control_type = 3 + with self.assertRaisesRegex(ValueError, "outside the resident model contract"): + denoise_node._cache_params_equal(kwargs, kwargs) + first_controlnet.config.num_control_type = 8 + + self.assertIs(result["latents"], denoised) + self.assertEqual(len(pipeline_calls), 1) + self.assertNotIn("controlnet", pipeline_calls[0]) + self.assertIs(pipelines[0].controlnet, first_controlnet) + if union: + self.assertEqual(pipeline_calls[0]["control_mode"], 3) + else: + self.assertNotIn("control_mode", pipeline_calls[0]) + self.assertIs(pipeline_calls[0]["image_latents"], fixture["image_latents"]) + self.assertIs(pipeline_calls[0]["mask"], fixture["mask"]) + self.assertIs( + pipeline_calls[0]["masked_image_latents"], + fixture["masked_image_latents"], + ) + consume_decode_route_state( + result[ROUTE_STATE_OUTPUT], + binding=fixture["token"], + model_type=SDXL, + latents=denoised, + vae_component=fixture["vae"], + vae_latent_channels=4, + vae_scale_factor=8, + materialize_overlay=False, + ) + + def test_sdxl_route_less_text_and_legacy_control_emit_normal_decode_routes_while_ip_mismatches_fail_closed(self): + token, outputs = _bound_outputs(SDXL) + _other_token, other_outputs = _bound_outputs(SDXL, suffix="b") + vae = _FixtureSdxlVae() + unet_component = object() + scheduler_component = object() + controlnet_component = object() + controlnet_issuer, controlnet_payload = _publish_standalone( + _standalone_identity(class_name="ControlNetModel") + ) + init_calls = [] + pipeline_calls = [] + pipelines = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["unet", "vae", "scheduler", "controlnet"] + blocks = type("PipelineBlocks", (), {"doc": "denoise"})() + transformer = None + + def __init__(self): + self.unet = None + self.vae = None + self.scheduler = None + self.controlnet = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + return {"latents": torch.zeros((1, 4, 8, 8))} + + class FakeBlocks: + component_names = ["unet", "vae", "scheduler", "controlnet"] + input_names = [ + "prompt_embeds", + "generator", + "control_mode", + "control_image", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + ] + + @staticmethod + def init_pipeline(*, components_manager): + init_calls.append(components_manager) + pipeline = FakePipeline() + pipelines.append(pipeline) + return pipeline + + config = { + "params": { + "embeddings": {"type": "embeddings"}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "controlnet_bundle": {"type": "custom_controlnet"}, + "ip_adapter": {"type": "custom_ip_adapter"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["unet", "vae", "scheduler", "controlnet_bundle"], + "input_names": ["embeddings", "seed", "controlnet_bundle", "ip_adapter", ROUTE_STATE_INPUT], + "output_names": ["latents", ROUTE_STATE_OUTPUT], + } + + def managed_components(*, ids, return_dict_with_names=True): + if return_dict_with_names: + result = {"unet": unet_component, "vae": vae, "scheduler": scheduler_component} + if controlnet_payload["model_id"] in ids: + result["controlnet"] = controlnet_component + return result + available = { + outputs["vae_out"]["model_id"]: vae, + controlnet_payload["model_id"]: controlnet_component, + } + return {model_id: available[model_id] for model_id in set(ids)} + + base = { + "unet": outputs["unet_out"], + "vae": outputs["vae_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": object()}, + "seed": 7, + } + legacy_control = { + "controlnet": controlnet_payload, + "control_image": "control-image", + "controlnet_conditioning_scale": 0.75, + "control_guidance_start": 0.1, + "control_guidance_end": 0.9, + } + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + patch( + "modules.ModularDiffusers.denoise.components.get_components_by_ids", + side_effect=managed_components, + ), + ): + for controlled in (False, True): + kwargs = dict(base) + if controlled: + kwargs["controlnet_bundle"] = legacy_control + result = Denoise().execute(**kwargs) + consume_decode_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=SDXL, + latents=result["latents"], + vae_component=vae, + vae_latent_channels=4, + vae_scale_factor=8, + materialize_overlay=False, + ) + + self.assertNotIn("controlnet", pipeline_calls[0]) + self.assertNotIn("controlnet", pipeline_calls[1]) + self.assertIsNone(pipelines[0].controlnet) + self.assertIs(pipelines[1].controlnet, controlnet_component) + self.assertEqual(pipeline_calls[1]["control_image"], "control-image") + + invalid_cases = ( + ({"ip_adapter": object()}, "exact backend-issued bundle"), + ({"embeddings": {"prompt_embeds": object(), "ip_adapter_embeds": object()}}, "IP-Adapter fields"), + ( + {"controlnet_bundle": {**legacy_control, "control_mode": 1}}, + "exact ControlNetUnionModel", + ), + ({"vae": other_outputs["vae_out"]}, "different Models Loader"), + ) + init_before = len(init_calls) + for override, message in invalid_cases: + with self.subTest(override=tuple(override)), self.assertRaisesRegex(ValueError, message): + Denoise().execute(**{**base, **override}) + self.assertEqual(len(init_calls), init_before) + + self.assertEqual(len(pipeline_calls), 2) + self.assertIsNotNone(controlnet_issuer) + + def test_sdxl_live_manager_vae_swap_during_init_or_call_never_publishes_route_outputs(self): + token, outputs = _bound_outputs(SDXL) + model_id = outputs["vae_out"]["model_id"] + + encode_config = { + "params": { + "image": {"type": "image"}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + }, + "model_input_names": ["vae"], + "input_names": ["image", "seed"], + "output_names": ["image_latents", "mask", "masked_image_latents", ROUTE_STATE_OUTPUT], + } + denoise_config = { + "params": { + "embeddings": {"type": "embeddings"}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["unet", "vae", "scheduler"], + "input_names": ["embeddings", "seed", ROUTE_STATE_INPUT], + "output_names": ["latents", ROUTE_STATE_OUTPUT], + } + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + + for action in ("encode", "denoise", "decode"): + for phase in ("init", "call"): + with self.subTest(action=action, phase=phase): + original_vae = _FixtureSdxlVae() + replacement_vae = _FixtureSdxlVae() + resident = {model_id: original_vae} + pipeline_calls = [] + unet_component = object() + scheduler_component = object() + + def managed_components(*, ids, return_dict_with_names=True): + if return_dict_with_names: + return { + "unet": unet_component, + "vae": resident[model_id], + "scheduler": scheduler_component, + } + return {requested: resident[requested] for requested in set(ids)} + + if action == "encode": + class FakePipeline: + _execution_device = torch.device("cpu") + blocks = type("PipelineBlocks", (), {"doc": "encode"})() + + def __init__(self): + self.vae = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **_kwargs): + pipeline_calls.append(True) + if phase == "call": + resident[model_id] = replacement_vae + return { + "image_latents": torch.zeros((1, 4, 8, 8)), + "mask": None, + "masked_image_latents": None, + } + + class FakeBlocks: + component_names = ["vae"] + input_names = ["image", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + if phase == "init": + resident[model_id] = replacement_vae + return FakePipeline() + + node = ImageEncode() + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), encode_config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[model_id]), + patch( + "modules.ModularDiffusers.latents.components.get_components_by_ids", + side_effect=managed_components, + ), + patch("modules.ModularDiffusers.latents.issue_encoder_route_state") as issue_route, + self.assertRaisesRegex(ValueError, "changed during|resident Modular pipeline"), + ): + node.execute( + vae=outputs["vae_out"], + image=Image.new("RGB", (64, 64), "red"), + seed=7, + ) + issue_route.assert_not_called() + + elif action == "denoise": + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["unet", "vae", "scheduler"] + blocks = type("PipelineBlocks", (), {"doc": "denoise"})() + transformer = None + + def __init__(self): + self.vae = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **_kwargs): + pipeline_calls.append(True) + if phase == "call": + resident[model_id] = replacement_vae + return {"latents": torch.zeros((1, 4, 8, 8))} + + class FakeBlocks: + component_names = ["unet", "vae", "scheduler"] + input_names = ["prompt_embeds", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + if phase == "init": + resident[model_id] = replacement_vae + return FakePipeline() + + node = Denoise() + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), denoise_config), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[model_id]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + patch( + "modules.ModularDiffusers.denoise.components.get_components_by_ids", + side_effect=managed_components, + ), + patch("modules.ModularDiffusers.denoise.issue_normal_decode_route_state") as issue_route, + self.assertRaisesRegex(ValueError, "changed during|resident SDXL Denoise pipeline"), + ): + node.execute( + unet=outputs["unet_out"], + vae=outputs["vae_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + seed=7, + ) + issue_route.assert_not_called() + + else: + denoised = torch.zeros((1, 4, 8, 8)) + route = issue_normal_decode_route_state( + binding=token, + latents=denoised, + vae_component=original_vae, + vae_latent_channels=4, + vae_scale_factor=8, + ) + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "decode"})() + + def __init__(self): + self.vae = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **_kwargs): + pipeline_calls.append(True) + if phase == "call": + resident[model_id] = replacement_vae + return {"images": "must-not-publish"} + + class FakeBlocks: + component_names = ["vae"] + input_names = ["latents"] + + @staticmethod + def init_pipeline(*, components_manager): + if phase == "init": + resident[model_id] = replacement_vae + return FakePipeline() + + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.StableDiffusionXLModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), decode_config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[model_id]), + patch( + "modules.ModularDiffusers.latents.components.get_components_by_ids", + side_effect=managed_components, + ), + self.assertRaisesRegex(ValueError, "changed during|resident Modular pipeline"), + ): + DecodeLatents().execute( + vae=outputs["vae_out"], + latents=denoised, + route_state_in=route, + ) + + self.assertEqual(len(pipeline_calls), 0 if phase == "init" else 1) + + def test_image_encode_seals_the_post_vae_generator_state(self): + token, outputs = _bound_outputs() + pipeline_class = type(QWEN_EDIT, (), {}) + observed = {} + encoded_latents = torch.zeros((1, 1, 2, 2)) + + class FakePipeline: + _execution_device = torch.device("cpu") + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + self.assert_generator(kwargs) + return { + "image_latents": encoded_latents, + "processed_mask_image": None, + "mask_overlay_kwargs": None, + } + + @staticmethod + def assert_generator(kwargs): + observed["kwargs"] = dict(kwargs) + torch.rand((5,), generator=kwargs["generator"]) + observed["post_vae_state"] = kwargs["generator"].get_state().clone() + + class FakeBlocks: + component_names = [] + input_names = ["image", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + config = { + "params": {"seed": {"type": "int", "min": 0, "max": 4294967295}}, + "model_input_names": ["vae"], + "input_names": ["image", "seed"], + "output_names": ["image_latents", ROUTE_STATE_OUTPUT], + } + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + result = ImageEncode().execute(vae=outputs["vae_out"], image="source", seed="7") + + self.assertIs(result["image_latents"], encoded_latents) + self.assertNotIn("seed", observed["kwargs"]) + routed_generator = consume_encoder_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_EDIT, + seed=7, + execution_device="cpu", + image_latents=result["image_latents"], + )["generator"] + self.assertTrue(torch.equal(routed_generator.get_state(), observed["post_vae_state"])) + + def test_advertised_edit_plus_multi_image_encode_seals_a_list_route(self): + token, outputs = _bound_outputs(QWEN_EDIT_PLUS) + seed = 23 + source_images = [object(), object()] + encoded_latents = [torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))] + observed = {} + + self.assertIsNotNone(PINNED_MODULAR_WORKFLOW_TRUTH[QWEN_EDIT_PLUS].mode("multi_image_reference_edit")) + + class FakePipeline: + _execution_device = torch.device("cpu") + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + observed["image"] = kwargs["image"] + torch.rand((4,), generator=kwargs["generator"]) + observed["post_vae_state"] = kwargs["generator"].get_state().clone() + return { + "image_latents": encoded_latents, + "processed_mask_image": None, + "mask_overlay_kwargs": None, + } + + class FakeBlocks: + component_names = [] + input_names = ["image", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + config = { + "params": {"seed": {"type": "int", "min": 0, "max": 4294967295}}, + "model_input_names": ["vae"], + "input_names": ["image", "seed"], + "output_names": ["image_latents", ROUTE_STATE_OUTPUT], + } + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageEditPlusModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + result = ImageEncode().execute( + vae=outputs["vae_out"], + image=source_images, + seed=str(seed), + ) + + self.assertIs(observed["image"], source_images) + self.assertIs(result["image_latents"], encoded_latents) + routed = consume_encoder_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_EDIT_PLUS, + seed=seed, + execution_device="cpu", + image_latents=result["image_latents"], + ) + self.assertTrue(torch.equal(observed["post_vae_state"], routed["generator"].get_state())) + + def test_controlnet_emits_post_control_route_for_text_and_optional_image_routes(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-runtime-route") + seed = 23 + control_latents = [ + torch.zeros((1, 1, 2, 2)), + torch.ones((1, 1, 2, 2)), + ] + observed = [] + + class FakeControlOutput: + def __init__(self, values): + self.values = values + + class FakeControlPipeline: + _execution_device = torch.device("cpu") + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + observed.append( + { + "kwargs": dict(kwargs), + "pre_state": kwargs["generator"].get_state().clone(), + } + ) + torch.rand((4,), generator=kwargs["generator"]) + observed[-1]["post_state"] = kwargs["generator"].get_state().clone() + return FakeControlOutput({"control_image_latents": control_latents[len(observed) - 1]}) + + class FakeControlBlocks: + component_names = ["vae", "controlnet"] + input_names = ["control_image", "height", "width", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeControlPipeline() + + class FakeDenoiseBlocks: + component_names = ["controlnet"] + input_names = [ + "control_image_latents", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + ] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return FakeControlBlocks(), _controlnet_node_config() + return FakeDenoiseBlocks(), {"input_names": FakeDenoiseBlocks.input_names} + + common = { + "controlnet": controlnet, + "vae": outputs["vae_out"], + "control_image": object(), + "controlnet_conditioning_scale": "0.5", + "control_guidance_start": "0.0", + "control_guidance_end": "1.0", + "height": "512", + "width": 512, + "seed": str(seed), + } + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + patch("modules.ModularDiffusers.controlnet.collect_model_ids", return_value=[]), + ): + text_result = Controlnet().execute(**common) + + image_latents = torch.full((1, 1, 2, 2), 3.0) + image_generator = torch.Generator(device="cpu").manual_seed(seed) + torch.rand((2,), generator=image_generator) + image_route = issue_encoder_route_state( + binding=token, + seed=seed, + generator=image_generator, + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + image_result = Controlnet().execute(**common, route_state_in=image_route) + + self.assertEqual(observed[0]["kwargs"]["height"], 512) + self.assertEqual(observed[0]["kwargs"]["width"], 512) + self.assertNotIn("seed", observed[0]["kwargs"]) + self.assertNotIn(ROUTE_STATE_INPUT, observed[0]["kwargs"]) + self.assertNotIn("image_latents", observed[1]["kwargs"]) + self.assertIs(text_result["controlnet_bundle"]["control_image_latents"], control_latents[0]) + self.assertIs(text_result["controlnet_bundle"]["controlnet"], controlnet) + self.assertIs(image_result["controlnet_bundle"]["control_image_latents"], control_latents[1]) + + fresh = torch.Generator(device="cpu").manual_seed(seed) + self.assertTrue(torch.equal(observed[0]["pre_state"], fresh.get_state())) + self.assertTrue(torch.equal(observed[1]["pre_state"], image_generator.get_state())) + text_runtime = consume_denoise_route_state( + text_result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + image_latents=None, + control_image_latents=control_latents[0], + controlnet_component=controlnet, + ) + image_runtime = consume_denoise_route_state( + image_result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_IMAGE, + seed=seed, + execution_device="cpu", + image_latents=image_latents, + control_image_latents=control_latents[1], + controlnet_component=controlnet, + ) + self.assertTrue(torch.equal(text_runtime["generator"].get_state(), observed[0]["post_state"])) + self.assertTrue(torch.equal(image_runtime["generator"].get_state(), observed[1]["post_state"])) + + def test_controlnet_retry_restarts_from_the_same_inherited_generator_snapshot(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-runtime-retry") + image_latents = torch.zeros((1, 1, 2, 2)) + generator = torch.Generator(device="cpu").manual_seed(17) + torch.rand((3,), generator=generator) + image_route = issue_encoder_route_state( + binding=token, + seed=17, + generator=generator, + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + control_latents = torch.ones((1, 1, 2, 2)) + pre_states = [] + post_states = [] + + class FakeOutput: + values = {"control_image_latents": control_latents} + + class FakePipeline: + _execution_device = torch.device("cpu") + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pre_states.append(kwargs["generator"].get_state().clone()) + torch.rand((5,), generator=kwargs["generator"]) + post_states.append(kwargs["generator"].get_state().clone()) + if len(pre_states) == 1: + raise RuntimeError("fixture control failure") + return FakeOutput() + + class FakeControlBlocks: + component_names = ["vae", "controlnet"] + input_names = ["control_image", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + class FakeDenoiseBlocks: + component_names = ["controlnet"] + input_names = ["control_image_latents"] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return FakeControlBlocks(), _controlnet_node_config() + return FakeDenoiseBlocks(), {"input_names": FakeDenoiseBlocks.input_names} + + kwargs = { + "controlnet": controlnet, + "vae": outputs["vae_out"], + "control_image": object(), + "seed": 17, + ROUTE_STATE_INPUT: image_route, + } + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + patch("modules.ModularDiffusers.controlnet.collect_model_ids", return_value=[]), + ): + with self.assertRaisesRegex(RuntimeError, "fixture control failure"): + Controlnet().execute(**kwargs) + result = Controlnet().execute(**kwargs) + + self.assertTrue(torch.equal(pre_states[0], pre_states[1])) + self.assertTrue(torch.equal(post_states[0], post_states[1])) + consumed = consume_denoise_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_IMAGE, + seed=17, + execution_device="cpu", + image_latents=image_latents, + control_image_latents=control_latents, + controlnet_component=controlnet, + ) + self.assertTrue(torch.equal(consumed["generator"].get_state(), post_states[1])) + + def test_controlnet_revalidates_vae_and_standalone_publications_before_route_publication(self): + for case in ( + "standalone_republish", + "vae_metadata_mutation", + "vae_metadata_mutation_during_call", + ): + with self.subTest(case=case): + _token, outputs = _bound_outputs(QWEN_IMAGE, suffix=case[0], model_id=f"vae-{case}") + identity = _standalone_identity(fingerprint=("5" if case == "standalone_republish" else "6") * 64) + issuer, controlnet = _publish_standalone( + identity, + manager_model_id=f"controlnet-init-toctou-{case}", + ) + retained_publications = [] + init_calls = [] + pipeline_calls = [] + + def mutate_during_init(): + if case == "standalone_republish": + retained_publications.append( + _publish_standalone( + identity, + issuer=issuer, + manager_model_id=f"controlnet-init-toctou-{case}", + )[1] + ) + elif case == "vae_metadata_mutation": + outputs["vae_out"]["repo_id"] = "fixture/tampered-during-controlnet-init" + + class FakeOutput: + values = {"control_image_latents": torch.zeros((1, 1, 2, 2))} + + class FakePipeline: + _execution_device = torch.device("cpu") + + def update_components(self, **_kwargs): + return None + + def __call__(self, **_kwargs): + pipeline_calls.append(True) + if case == "vae_metadata_mutation_during_call": + outputs["vae_out"]["repo_id"] = "fixture/tampered-during-controlnet-call" + return FakeOutput() + + class FakeControlBlocks: + component_names = ["vae", "controlnet"] + input_names = ["control_image", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + mutate_during_init() + return FakePipeline() + + class FakeDenoiseBlocks: + component_names = ["controlnet"] + input_names = ["control_image_latents"] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return FakeControlBlocks(), _controlnet_node_config() + return FakeDenoiseBlocks(), {"input_names": FakeDenoiseBlocks.input_names} + + model_id_scan = Mock(return_value=[]) + route_issuer = Mock(wraps=issue_controlnet_route_state) + expected_message = "no longer the current" if case == "standalone_republish" else "does not match" + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + patch("modules.ModularDiffusers.controlnet.collect_model_ids", model_id_scan), + patch("modules.ModularDiffusers.controlnet.issue_controlnet_route_state", route_issuer), + self.assertRaisesRegex(ValueError, expected_message), + ): + Controlnet().execute( + controlnet=controlnet, + vae=outputs["vae_out"], + control_image=object(), + seed=7, + ) + + self.assertEqual(len(init_calls), 1) + route_issuer.assert_not_called() + if case == "vae_metadata_mutation_during_call": + self.assertEqual(pipeline_calls, [True]) + model_id_scan.assert_called_once() + else: + self.assertEqual(pipeline_calls, []) + model_id_scan.assert_not_called() + + def test_decode_materializes_only_minimal_inpaint_state_and_routed_overlay(self): + token, outputs = _bound_outputs() + overlay = { + "crops_coords": (0, 0, 8, 8), + "original_image": object(), + "original_mask": object(), + } + image_latents = torch.zeros((1, 1, 2, 2)) + inpaint_latents = torch.ones((1, 1, 2, 2)) + normal_latents = torch.full((1, 1, 2, 2), 2.0) + encoder_route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=torch.ones((1, 1, 8, 8)), + mask_overlay_kwargs=overlay, + ) + inpaint_route = issue_decode_route_state( + encoder_route, + binding=token, + actual_mask=torch.ones((1, 1, 8, 8)), + latents=inpaint_latents, + ) + normal_route = issue_normal_decode_route_state(binding=token, latents=normal_latents) + pipeline_class = type(QWEN_EDIT, (), {}) + calls = [] + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + calls.append(kwargs) + return {"images": "decoded-image"} + + class FakeBlocks: + component_names = [] + input_names = ["latents", "mask_overlay_kwargs"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + inpaint_result = DecodeLatents().execute( + vae=outputs["vae_out"], + latents=inpaint_latents, + route_state_in=inpaint_route, + ) + normal_result = DecodeLatents().execute( + vae=outputs["vae_out"], + latents=normal_latents, + route_state_in=normal_route, + ) + + self.assertEqual(inpaint_result["images"], "decoded-image") + self.assertEqual(normal_result["images"], "decoded-image") + self.assertEqual(calls[0]["state"].values, {"mask": True}) + self.assertIs(calls[0]["latents"], inpaint_latents) + self.assertEqual(calls[0]["mask_overlay_kwargs"], overlay) + self.assertNotIn("state", calls[1]) + self.assertNotIn("mask_overlay_kwargs", calls[1]) + + def test_qwen_reviewed_state_flows_execute_the_exact_fake_action_sequence(self): + expected_flows = { + "image2image": (False, False), + "inpainting": (True, False), + "controlnet_image2image": (False, True), + "controlnet_inpainting": (True, True), + } + truth = PINNED_MODULAR_WORKFLOW_TRUTH[QWEN_IMAGE] + self.assertEqual(set(dict(truth.state_flows)), set(expected_flows)) + + for index, (flow_name, state_flow) in enumerate(truth.state_flows): + inpaint, controlled = expected_flows[flow_name] + with self.subTest(state_flow=flow_name): + seed = 100 + index + _token, outputs = _bound_outputs(QWEN_IMAGE, suffix=chr(ord("a") + index)) + trace = ["text_encoder"] + runtime = {} + image_latents = torch.full((1, 1, 2, 2), float(index + 1)) + processed_mask = torch.ones((1, 1, 8, 8)) if inpaint else None + overlay = ( + { + "crops_coords": (0, 0, 8, 8), + "original_image": object(), + "original_mask": object(), + } + if inpaint + else None + ) + + class FakeEncodePipeline: + _execution_device = torch.device("cpu") + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + trace.append("vae_encoder") + runtime["vae"] = dict(kwargs) + runtime["post_vae_generator"] = kwargs["generator"].get_state().clone() + return { + "image_latents": image_latents, + "processed_mask_image": processed_mask, + "mask_overlay_kwargs": overlay, + } + + class FakeEncodeBlocks: + component_names = [] + input_names = ["image", "mask_image", "height", "width", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeEncodePipeline() + + encode_config = { + "params": { + "image": {"type": "image"}, + "mask_image": {"type": "image"}, + "height": {"type": "int", "min": 64, "max": 2048}, + "width": {"type": "int", "min": 64, "max": 2048}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + }, + "model_input_names": ["vae"], + "input_names": ["image", "mask_image", "height", "width", "seed"], + "output_names": ["image_latents", ROUTE_STATE_OUTPUT], + } + encode_kwargs = { + "vae": outputs["vae_out"], + "image": object(), + "height": 512, + "width": 512, + "seed": seed, + } + if inpaint: + encode_kwargs["mask_image"] = object() + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeEncodeBlocks(), encode_config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + encoded = ImageEncode().execute(**encode_kwargs) + + self.assertIs(encoded["image_latents"], image_latents) + self.assertEqual("mask_image" in runtime["vae"], inpaint) + image_route = encoded[ROUTE_STATE_OUTPUT] + route_to_denoise = image_route + control_bundle = None + control_latents = None + controlnet = None + + if controlled: + _issuer, controlnet = _publish_standalone( + manager_model_id=f"controlnet-state-flow-{index}" + ) + control_latents = torch.full((1, 1, 2, 2), float(index + 11)) + control_source = object() + + class FakeControlOutput: + values = {"control_image_latents": control_latents} + + class FakeControlPipeline: + _execution_device = torch.device("cpu") + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + trace.append("controlnet") + runtime["control"] = dict(kwargs) + runtime["pre_control_generator"] = kwargs["generator"].get_state().clone() + torch.rand((4,), generator=kwargs["generator"]) + runtime["post_control_generator"] = kwargs["generator"].get_state().clone() + return FakeControlOutput() + + class FakeControlBlocks: + component_names = ["vae", "controlnet"] + input_names = ["control_image", "height", "width", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeControlPipeline() + + class FakeControlDenoiseBlocks: + component_names = ["controlnet"] + input_names = [ + "control_image_latents", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + ] + + def control_contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return FakeControlBlocks(), _controlnet_node_config() + return FakeControlDenoiseBlocks(), {"input_names": FakeControlDenoiseBlocks.input_names} + + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.controlnet.require_modiff_node_contract", + side_effect=control_contract, + ), + patch("modules.ModularDiffusers.controlnet.collect_model_ids", return_value=[]), + ): + controlled_output = Controlnet().execute( + controlnet=controlnet, + vae=outputs["vae_out"], + control_image=control_source, + controlnet_conditioning_scale=0.5, + control_guidance_start=0.0, + control_guidance_end=1.0, + height=512, + width=512, + seed=seed, + route_state_in=image_route, + ) + + self.assertIs(runtime["control"]["control_image"], control_source) + self.assertTrue( + torch.equal(runtime["pre_control_generator"], runtime["post_vae_generator"]) + ) + self.assertIs( + controlled_output["controlnet_bundle"]["control_image_latents"], + control_latents, + ) + control_bundle = controlled_output["controlnet_bundle"] + route_to_denoise = controlled_output[ROUTE_STATE_OUTPUT] + + denoised_latents = torch.full((1, 1, 2, 2), float(index + 21)) + + class FakeDenoisePipeline: + _execution_device = torch.device("cpu") + component_names = ["controlnet"] if controlled else [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + trace.append("denoise") + runtime["denoise"] = dict(kwargs) + runtime["pre_denoise_generator"] = kwargs["generator"].get_state().clone() + torch.rand((3,), generator=kwargs["generator"]) + return {"latents": denoised_latents, "mask": processed_mask} + + class FakeDenoiseBlocks: + component_names = ["controlnet"] if controlled else [] + input_names = [ + "prompt_embeds", + "image_latents", + "processed_mask_image", + "control_image_latents", + "controlnet_conditioning_scale", + "control_guidance_start", + "control_guidance_end", + "strength", + "generator", + ] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeDenoisePipeline() + + denoise_config = _route_node_config(control_bundle=controlled) + denoise_config["params"]["strength"] = {"type": "float", "min": 0.0, "max": 1.0} + denoise_config["input_names"].append("strength") + denoise_kwargs = { + "unet": outputs["unet_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": f"prompt-{index}"}, + "image_latents": image_latents, + "strength": 0.6, + "seed": seed, + "route_state_in": route_to_denoise, + } + if controlled: + denoise_kwargs["controlnet_bundle"] = control_bundle + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeDenoiseBlocks(), denoise_config), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + denoised = Denoise().execute(**denoise_kwargs) + + self.assertIs(runtime["denoise"]["image_latents"], image_latents) + self.assertEqual("processed_mask_image" in runtime["denoise"], inpaint) + if inpaint: + self.assertIs(runtime["denoise"]["processed_mask_image"], processed_mask) + self.assertEqual("control_image_latents" in runtime["denoise"], controlled) + if controlled: + self.assertIs(runtime["denoise"]["control_image_latents"], control_latents) + self.assertTrue( + torch.equal(runtime["pre_denoise_generator"], runtime["post_control_generator"]) + ) + else: + self.assertTrue( + torch.equal(runtime["pre_denoise_generator"], runtime["post_vae_generator"]) + ) + + class FakeDecodePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + trace.append("decoder") + runtime["decode"] = dict(kwargs) + return {"images": f"decoded-{flow_name}"} + + class FakeDecodeBlocks: + component_names = [] + input_names = ["latents", "mask_overlay_kwargs"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakeDecodePipeline() + + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeDecodeBlocks(), decode_config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + decoded = DecodeLatents().execute( + vae=outputs["vae_out"], + latents=denoised["latents"], + route_state_in=denoised[ROUTE_STATE_OUTPUT], + ) + + self.assertEqual(decoded["images"], f"decoded-{flow_name}") + self.assertEqual(tuple(trace), state_flow.action_sequence) + self.assertEqual("state" in runtime["decode"], inpaint) + self.assertEqual("mask_overlay_kwargs" in runtime["decode"], inpaint) + if inpaint: + self.assertEqual(runtime["decode"]["state"].values, {"mask": True}) + self.assertEqual(runtime["decode"]["mask_overlay_kwargs"], overlay) + + def test_forged_route_and_reserved_identity_injection_fail_before_resolver(self): + token, _outputs = _bound_outputs() + nested_route, _image_latents = _normal_encoder_route(token) + cases = ( + (Controlnet, {ROUTE_STATE_INPUT: {"model_type": QWEN_IMAGE}}), + (Controlnet, {"control_image": {"generator": object()}}), + (Denoise, {ROUTE_STATE_INPUT: {"model_type": QWEN_EDIT}}), + (DecodeLatents, {ROUTE_STATE_INPUT: {"model_type": QWEN_EDIT}}), + (ImageEncode, {"processed_mask_image": {"model_type": QWEN_EDIT}}), + (Denoise, {"embeddings": {"mask_overlay_kwargs": {"model_type": QWEN_EDIT}}}), + (Denoise, {"embeddings": {"prompt_embeds": nested_route}}), + (DecodeLatents, {"mask": {"model_type": QWEN_EDIT}}), + ) + for node_class, hostile in cases: + with self.subTest(node=node_class.__name__, hostile=hostile): + node = node_class() + resolver_path = f"{node_class.__module__}.pipeline_class_from_runtime_inputs" + with patch(resolver_path) as resolver, self.assertRaises(ValueError): + node.execute(**hostile) + resolver.assert_not_called() + + def test_pipeline_identity_recovery_is_iterative_bounded_and_cycle_safe(self): + cyclic = {"model_type": QWEN_EDIT} + cyclic["self"] = cyclic + self.assertIs( + pipeline_class_from_runtime_inputs(None, cyclic), + diffusers.QwenImageEditModularPipeline, + ) + nested = {} + cursor = nested + for _index in range(40): + cursor["next"] = {} + cursor = cursor["next"] + with self.assertRaisesRegex(ValueError, "safe nested-value limit"): + pipeline_class_from_runtime_inputs(None, nested) + + def test_qwen_latents_without_route_fail_before_pipeline_initialization(self): + for model_type in ("QwenImageModularPipeline", QWEN_EDIT, QWEN_EDIT_PLUS): + with self.subTest(model_type=model_type): + node = Denoise() + pipeline_class = type(model_type, (), {}) + blocks = Mock() + blocks.input_names = ["image_latents", "generator"] + config = _route_node_config() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=pipeline_class, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(blocks, config) + ), + self.assertRaisesRegex(ValueError, "latents require the opaque route state"), + ): + node.execute(unet={"repo_id": "fixture/model"}, image_latents=object()) + blocks.init_pipeline.assert_not_called() + + def test_flattenable_bundle_latents_without_route_fail_before_init(self): + node = Denoise() + pipeline_class = type(QWEN_EDIT, (), {}) + blocks = Mock() + blocks.input_names = ["prompt_embeds", "image_latents", "generator"] + config = _route_node_config() + config["input_names"] = ["embeddings", "seed", ROUTE_STATE_INPUT] + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch("modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(blocks, config)), + self.assertRaisesRegex(ValueError, "latents require the opaque route state"), + ): + node.execute( + unet={"repo_id": "fixture/model"}, + embeddings={"prompt_embeds": object(), "image_latents": object()}, + seed=7, + ) + blocks.init_pipeline.assert_not_called() + + def test_supported_qwen_decode_requires_route_before_init(self): + _token, outputs = _bound_outputs() + node = DecodeLatents() + pipeline_class = type(QWEN_EDIT, (), {}) + blocks = Mock() + config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch("modules.ModularDiffusers.latents.require_modiff_node_contract", return_value=(blocks, config)), + self.assertRaisesRegex(ValueError, "Decode requires the opaque route state"), + ): + node.execute(vae=outputs["vae_out"], latents=object()) + blocks.init_pipeline.assert_not_called() + + def test_text_denoise_emits_normal_decode_binding(self): + token, outputs = _bound_outputs(model_type="QwenImageModularPipeline") + pipeline_class = type("QwenImageModularPipeline", (), {}) + received = {} + unexpected_mask = False + text_latents = torch.zeros((1, 1, 2, 2)) + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + received.update(kwargs) + return { + "latents": text_latents, + **({"mask": torch.ones((1, 1, 1, 1))} if unexpected_mask else {}), + } + + class FakeBlocks: + component_names = [] + input_names = ["prompt_embeds", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + config = _route_node_config() + node = Denoise() + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(FakeBlocks(), config) + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + ): + result = node.execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + seed=5, + ) + + self.assertIs(result["latents"], text_latents) + self.assertEqual(received["generator"].initial_seed(), 5) + decoded = consume_decode_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type="QwenImageModularPipeline", + latents=result["latents"], + ) + self.assertFalse(decoded["inpaint"]) + self.assertIsNone(decoded["mask_overlay_kwargs"]) + + unexpected_mask = True + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(FakeBlocks(), config) + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + self.assertRaisesRegex(ValueError, "unexpectedly returned inpaint mask"), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + seed=5, + ) + + def test_denoise_resolves_exact_controlnet_bundle_component_before_init(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-denoise-runtime") + control_latents = torch.zeros((1, 1, 2, 2)) + control_generator = torch.Generator(device="cpu").manual_seed(7) + torch.rand((4,), generator=control_generator) + control_route = issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet, + seed=7, + generator=control_generator, + control_image_latents=control_latents, + ) + denoised_latents = torch.ones((1, 1, 2, 2)) + observed = {"validated": False} + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["controlnet"] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + observed["runtime"] = kwargs + return {"latents": denoised_latents, "mask": None} + + class FakeBlocks: + component_names = ["controlnet"] + input_names = ["prompt_embeds", "image_latents", "control_image_latents", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + if not observed["validated"]: + raise AssertionError("ControlNet provenance was not validated before pipeline initialization") + return FakePipeline() + + config = _route_node_config(control_bundle=True) + + def validate_before_init(*args, **kwargs): + self.assertIs(kwargs["controlnet_component"], controlnet) + self.assertIs(kwargs["control_image_latents"], control_latents) + result = validate_denoise_route_state(*args, **kwargs) + observed["validated"] = True + return result + + bundle = { + "controlnet": controlnet, + "control_image_latents": control_latents, + "controlnet_conditioning_scale": 0.5, + } + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.validate_denoise_route_state", side_effect=validate_before_init), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + result = Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + controlnet_bundle=bundle, + seed=7, + route_state_in=control_route, + ) + + self.assertIs(result["latents"], denoised_latents) + self.assertIs(observed["runtime"]["control_image_latents"], control_latents) + self.assertTrue(torch.equal(observed["runtime"]["generator"].get_state(), control_generator.get_state())) + + _other_issuer, other_controlnet = _publish_standalone( + _standalone_identity(repo_id="fixture/other-controlnet", fingerprint="2" * 64), + manager_model_id="other-controlnet-denoise-runtime", + ) + tampered = deepcopy(controlnet) + tampered["repo_id"] = "fixture/tampered" + invalid_bundles = ( + ({"control_image_latents": control_latents}, "managed component payload"), + ( + {"control_image_latents": control_latents, "nested": {"controlnet": controlnet}}, + "managed component payload", + ), + ( + {"control_image_latents": control_latents, "controlnet": other_controlnet}, + "different component publication", + ), + ( + {"control_image_latents": control_latents, "controlnet": tampered}, + "provenance binding", + ), + ) + for invalid_bundle, message in invalid_bundles: + blocks = Mock() + blocks.input_names = FakeBlocks.input_names + blocks.component_names = FakeBlocks.component_names + with ( + self.subTest(message=message), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(ValueError, message), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + controlnet_bundle=invalid_bundle, + seed=7, + route_state_in=control_route, + ) + blocks.init_pipeline.assert_not_called() + + def test_qwen_denoise_rejects_any_control_bundle_without_its_control_route(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + image_route, image_latents = _normal_encoder_route(token) + blocks = Mock() + blocks.input_names = ["prompt_embeds", "image_latents", "control_image_latents", "generator"] + blocks.component_names = ["controlnet"] + config = _route_node_config(control_bundle=True) + cases = ( + ({}, None, None), + ({"controlnet_conditioning_scale": 0.5}, image_route, image_latents), + ) + for bundle, route, latents in cases: + blocks.init_pipeline.reset_mock() + with ( + self.subTest(bundle=bundle, route=route is not None), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(ValueError, "ControlNet.*route state"), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + image_latents=latents, + controlnet_bundle=bundle, + seed=7, + route_state_in=route, + ) + blocks.init_pipeline.assert_not_called() + + def test_control_route_rejects_cross_loader_denoiser_and_scheduler_before_init(self): + token_a, outputs_a = _bound_outputs(QWEN_IMAGE, suffix="a", model_id="control-denoise-a") + _token_b, outputs_b = _bound_outputs(QWEN_IMAGE, suffix="b", model_id="control-denoise-b") + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-cross-loader-denoise") + control_latents = torch.zeros((1, 1, 2, 2)) + control_route = issue_controlnet_route_state( + None, + binding=token_a, + controlnet_component=controlnet, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + control_image_latents=control_latents, + ) + bundle = {"controlnet": controlnet, "control_image_latents": control_latents} + blocks = Mock() + blocks.input_names = ["prompt_embeds", "control_image_latents", "generator"] + blocks.component_names = ["controlnet"] + config = _route_node_config(control_bundle=True) + cases = ( + (outputs_a["unet_out"], outputs_b["scheduler"]), + (outputs_b["unet_out"], outputs_b["scheduler"]), + ) + for unet, scheduler in cases: + blocks.init_pipeline.reset_mock() + with ( + self.subTest(unet=unet["model_id"], scheduler=scheduler["model_id"]), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(ValueError, "different Models Loader"), + ): + Denoise().execute( + unet=unet, + scheduler=scheduler, + embeddings={"prompt_embeds": "encoded"}, + controlnet_bundle=bundle, + seed=7, + route_state_in=control_route, + ) + blocks.init_pipeline.assert_not_called() + + def test_stale_qwen_route_is_rejected_by_a_nonroute_pipeline_before_init(self): + token, _outputs = _bound_outputs() + route, _image_latents = _normal_encoder_route(token) + for node_class, model_type, model_input in ( + (Denoise, "FluxModularPipeline", "unet"), + (DecodeLatents, "FluxModularPipeline", "vae"), + ): + with self.subTest(node=node_class.__name__): + node = node_class() + pipeline_class = type(model_type, (), {}) + blocks = Mock() + config = { + "params": {}, + "model_input_names": [model_input], + "input_names": [], + "output_names": [], + } + with ( + patch(f"{node_class.__module__}.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch(f"{node_class.__module__}.require_modiff_node_contract", return_value=(blocks, config)), + self.assertRaisesRegex(ValueError, "does not declare route state"), + ): + node.execute(**{model_input: {"repo_id": "fixture/flux"}, ROUTE_STATE_INPUT: route}) + blocks.init_pipeline.assert_not_called() + + def test_same_class_components_from_different_loaders_fail_before_init(self): + token_a, outputs_a = _bound_outputs(suffix="a") + _token_b, outputs_b = _bound_outputs(suffix="b") + route, image_latents = _normal_encoder_route(token_a) + blocks = Mock() + blocks.input_names = ["prompt_embeds", "image_latents", "generator", "processed_mask_image"] + config = _route_node_config() + node = Denoise() + pipeline_class = type(QWEN_EDIT, (), {}) + + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch("modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(blocks, config)), + self.assertRaisesRegex(ValueError, "different Models Loader"), + ): + node.execute( + unet=outputs_a["unet_out"], + scheduler=outputs_b["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=image_latents, + seed=7, + route_state_in=route, + ) + blocks.init_pipeline.assert_not_called() + + def test_same_loader_cross_paired_latents_fail_before_pipeline_initialization(self): + token, outputs = _bound_outputs() + image_latents_a = torch.zeros((1, 1, 2, 2)) + image_latents_b = torch.zeros((1, 1, 2, 2)) + route_b = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents_b, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + pipeline_class = type(QWEN_EDIT, (), {}) + denoise_blocks = Mock() + denoise_blocks.input_names = ["prompt_embeds", "image_latents", "generator"] + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(denoise_blocks, _route_node_config()), + ), + self.assertRaisesRegex(ValueError, "exact latent output paired"), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=image_latents_a, + seed=7, + route_state_in=route_b, + ) + denoise_blocks.init_pipeline.assert_not_called() + + denoised_latents_a = torch.ones((1, 1, 2, 2)) + denoised_latents_b = torch.ones((1, 1, 2, 2)) + decode_route_b = issue_normal_decode_route_state( + binding=token, + latents=denoised_latents_b, + ) + decode_blocks = Mock() + decode_blocks.input_names = ["latents"] + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(decode_blocks, decode_config), + ), + self.assertRaisesRegex(ValueError, "exact latent output paired"), + ): + DecodeLatents().execute( + vae=outputs["vae_out"], + latents=denoised_latents_a, + route_state_in=decode_route_b, + ) + decode_blocks.init_pipeline.assert_not_called() + + def test_edit_plus_list_cross_pair_and_dead_refs_fail_before_denoise_initialization(self): + token, outputs = _bound_outputs(QWEN_EDIT_PLUS) + first = torch.zeros((1, 1, 2, 2)) + second = torch.ones((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=[first, second], + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + blocks = Mock() + blocks.input_names = ["prompt_embeds", "image_latents", "generator"] + config = _route_node_config() + + for connected, error_type, message in ( + ([second, first], ValueError, "exact latent output paired"), + ((first, second), TypeError, "bounded nonempty list"), + ): + with ( + self.subTest(connected=type(connected).__name__), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageEditPlusModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(error_type, message), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=connected, + seed=7, + route_state_in=route, + ) + blocks.init_pipeline.assert_not_called() + + def issue_without_retaining_latents(): + transient_latents = [torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))] + return issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=transient_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + dead_route = issue_without_retaining_latents() + gc.collect() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageEditPlusModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(ValueError, "no longer resident"), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=[torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))], + seed=7, + route_state_in=dead_route, + ) + blocks.init_pipeline.assert_not_called() + + def test_single_image_qwen_denoise_rejects_latent_lists_before_initialization(self): + for model_type in (QWEN_IMAGE, QWEN_EDIT): + with self.subTest(model_type=model_type): + token, outputs = _bound_outputs(model_type) + exact_latents = torch.zeros((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=exact_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + blocks = Mock() + blocks.input_names = ["prompt_embeds", "image_latents", "generator"] + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=getattr(diffusers, model_type), + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(blocks, _route_node_config()), + ), + self.assertRaisesRegex(TypeError, "Connected VAE image latents must be an exact Torch tensor"), + ): + Denoise().execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=[exact_latents], + seed=7, + route_state_in=route, + ) + blocks.init_pipeline.assert_not_called() + + def test_runtime_component_role_swaps_fail_before_init(self): + token, outputs = _bound_outputs() + encoder_route, image_latents = _normal_encoder_route(token) + denoised_latents = torch.zeros((1, 1, 2, 2)) + decode_route = issue_decode_route_state( + encoder_route, + binding=token, + actual_mask=None, + latents=denoised_latents, + ) + pipeline_class = type(QWEN_EDIT, (), {}) + + denoise_blocks = Mock() + denoise_blocks.input_names = ["generator", "image_latents"] + denoise_config = _route_node_config() + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(denoise_blocks, denoise_config), + ), + self.assertRaisesRegex(ValueError, "loader role 'vae'.*not 'denoiser'"), + ): + Denoise().execute( + unet=outputs["vae_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=image_latents, + seed=7, + route_state_in=encoder_route, + ) + denoise_blocks.init_pipeline.assert_not_called() + + decode_blocks = Mock() + decode_blocks.input_names = ["latents"] + decode_config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(decode_blocks, decode_config), + ), + self.assertRaisesRegex(ValueError, "loader role 'scheduler'.*not 'vae'"), + ): + DecodeLatents().execute( + vae=outputs["scheduler"], + latents=denoised_latents, + route_state_in=decode_route, + ) + decode_blocks.init_pipeline.assert_not_called() + + def test_invalid_route_seed_and_missing_encode_seed_fail_before_init(self): + token, outputs = _bound_outputs() + route, image_latents = _normal_encoder_route(token) + blocks = Mock() + blocks.input_names = ["generator", "image_latents"] + config = _route_node_config() + pipeline_class = type(QWEN_EDIT, (), {}) + for invalid_seed in (True, 7.5, "07", "-0"): + node = Denoise() + with ( + self.subTest(seed=invalid_seed), + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class + ), + patch("modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(blocks, config)), + self.assertRaises(ValueError), + ): + node.execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": object()}, + image_latents=image_latents, + seed=invalid_seed, + route_state_in=route, + ) + blocks.init_pipeline.assert_not_called() + + encode_blocks = Mock() + encode_blocks.input_names = ["image", "generator"] + encode_config = { + "params": {"seed": {"type": "int", "min": 0, "max": 4294967295}}, + "model_input_names": ["vae"], + "input_names": ["image", "seed"], + "output_names": ["image_latents", ROUTE_STATE_OUTPUT], + } + encode_node = ImageEncode() + with ( + patch("modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(encode_blocks, encode_config), + ), + self.assertRaisesRegex(ValueError, "requires a seed before pipeline initialization"), + ): + encode_node.execute(vae=outputs["vae_out"], image=object()) + encode_blocks.init_pipeline.assert_not_called() + + def test_qwen_controlnet_rejects_noncanonical_scalars_before_init(self): + _token, outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-strict-scalars") + control_blocks = Mock() + control_blocks.component_names = ["vae", "controlnet"] + control_blocks.input_names = ["control_image", "height", "width", "generator"] + denoise_blocks = Mock() + denoise_blocks.component_names = ["controlnet"] + denoise_blocks.input_names = ["control_image_latents"] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return control_blocks, _controlnet_node_config() + return denoise_blocks, {"input_names": denoise_blocks.input_names} + + base = { + "controlnet": controlnet, + "vae": outputs["vae_out"], + "control_image": object(), + "controlnet_conditioning_scale": 0.5, + "control_guidance_start": 0.0, + "control_guidance_end": 1.0, + "height": 512, + "width": 512, + "seed": 7, + } + invalid_values = ( + ("seed", True), + ("seed", 7.5), + ("height", True), + ("height", 512.5), + ("width", float("inf")), + ("controlnet_conditioning_scale", True), + ("controlnet_conditioning_scale", float("nan")), + ("control_guidance_start", float("-inf")), + ("control_guidance_end", 1.5), + ) + for field, value in invalid_values: + control_blocks.init_pipeline.reset_mock() + with ( + self.subTest(field=field, value=value), + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + self.assertRaisesRegex(ValueError, f"{field}|modular parameter"), + ): + Controlnet().execute(**{**base, field: value}) + control_blocks.init_pipeline.assert_not_called() + + def test_controlnet_cross_loader_and_standalone_provenance_fail_before_init(self): + token_a, outputs_a = _bound_outputs(QWEN_IMAGE, suffix="a", model_id="control-route-a") + _token_b, outputs_b = _bound_outputs(QWEN_IMAGE, suffix="b", model_id="control-route-b") + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-preinit-provenance") + image_latents = torch.zeros((1, 1, 2, 2)) + image_route = issue_encoder_route_state( + binding=token_a, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + blocks = Mock() + blocks.component_names = ["vae", "controlnet"] + blocks.input_names = ["control_image", "generator"] + denoise_blocks = Mock() + denoise_blocks.component_names = ["controlnet"] + denoise_blocks.input_names = ["control_image_latents"] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return blocks, _controlnet_node_config() + return denoise_blocks, {"input_names": denoise_blocks.input_names} + + base = { + "vae": outputs_b["vae_out"], + "controlnet": controlnet, + "control_image": object(), + "seed": 7, + ROUTE_STATE_INPUT: image_route, + } + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + self.assertRaisesRegex(ValueError, "different Models Loader"), + ): + Controlnet().execute(**base) + blocks.init_pipeline.assert_not_called() + + unbound_controlnet = { + key: value + for key, value in controlnet.items() + if isinstance(key, str) + } + blocks.init_pipeline.reset_mock() + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + self.assertRaisesRegex(ValueError, "missing its process-local"), + ): + Controlnet().execute( + **{ + **base, + "vae": outputs_a["vae_out"], + "controlnet": unbound_controlnet, + ROUTE_STATE_INPUT: None, + } + ) + blocks.init_pipeline.assert_not_called() + + def test_qwen_edit_plus_multi_image_generator_route_reaches_denoise_and_normal_decode(self): + token, outputs = _bound_outputs(QWEN_EDIT_PLUS) + seed = 11 + generator = torch.Generator(device="cpu").manual_seed(seed) + torch.rand((3,), generator=generator) + advanced_state = generator.get_state().clone() + source_latents = [torch.zeros((1, 1, 2, 2)), torch.full((1, 1, 2, 2), 2.0)] + denoised_latents = torch.ones((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=seed, + generator=generator, + image_latents=source_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + received = {} + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + received.update(kwargs) + return {"latents": denoised_latents, "mask": None} + + class FakeBlocks: + component_names = [] + input_names = ["prompt_embeds", "image_latents", "generator"] + + @staticmethod + def init_pipeline(*, components_manager): + return FakePipeline() + + config = _route_node_config() + pipeline_class = diffusers.QwenImageEditPlusModularPipeline + node = Denoise() + with ( + patch("modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", return_value=pipeline_class), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", return_value=(FakeBlocks(), config) + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + ): + result = node.execute( + unet=outputs["unet_out"], + scheduler=outputs["scheduler"], + embeddings={"prompt_embeds": "encoded"}, + image_latents=source_latents, + seed=seed, + num_inference_steps=2, + route_state_in=route, + ) + + self.assertTrue(torch.equal(received["generator"].get_state(), advanced_state)) + self.assertIs(received["image_latents"], source_latents) + self.assertNotIn("seed", received) + self.assertNotIn("processed_mask_image", received) + self.assertIs(result["latents"], denoised_latents) + decode_values = consume_decode_route_state( + result[ROUTE_STATE_OUTPUT], + binding=token, + model_type=QWEN_EDIT_PLUS, + latents=result["latents"], + ) + self.assertFalse(decode_values["inpaint"]) + self.assertIsNone(decode_values["mask_overlay_kwargs"]) + + with self.assertRaisesRegex(ValueError, "generator-only route state"): + issue_encoder_route_state( + binding=token, + seed=seed, + generator=torch.Generator(device="cpu").manual_seed(seed), + image_latents=source_latents, + processed_mask_image=torch.ones((1, 1, 8, 8)), + mask_overlay_kwargs={ + "crops_coords": None, + "original_image": None, + "original_mask": None, + }, + ) + + def test_route_cache_requires_exact_single_and_multi_latent_tensor_identities(self): + for model_type, source_latents in ( + (QWEN_EDIT, torch.zeros((1, 1, 2, 2))), + ( + QWEN_EDIT_PLUS, + [torch.zeros((1, 1, 2, 2)), torch.ones((1, 1, 2, 2))], + ), + ): + with self.subTest(model_type=model_type): + token, outputs = _bound_outputs(model_type) + route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=source_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + pipeline_calls = [] + init_calls = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + return {"latents": torch.ones((1, 1, 2, 2)), "mask": None} + + class FakeBlocks: + component_names = [] + input_names = ["prompt_embeds", "image_latents", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + pipeline_class = getattr(diffusers, model_type) + node = Denoise(f"route-cache-{model_type}") + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=pipeline_class, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), _route_node_config()), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + common = { + "unet": outputs["unet_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": "encoded"}, + "seed": 7, + "num_inference_steps": 2, + ROUTE_STATE_INPUT: route, + } + first = node(**common, image_latents=source_latents) + rewrapped = list(source_latents) if type(source_latents) is list else source_latents + second = node(**common, image_latents=rewrapped) + + self.assertIs(first, second) + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + cloned = ( + [latent.clone() for latent in source_latents] + if type(source_latents) is list + else source_latents.clone() + ) + with self.assertRaisesRegex(RuntimeError, "exact latent output paired"): + node(**common, image_latents=cloned) + + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + def test_controlnet_cache_revalidates_current_standalone_publication_before_hit(self): + _token, outputs = _bound_outputs(QWEN_IMAGE) + identity = _standalone_identity(fingerprint="3" * 64) + issuer, controlnet_a = _publish_standalone( + identity, + manager_model_id="controlnet-node-cache-publication", + ) + control_latents = torch.zeros((1, 1, 2, 2)) + init_calls = [] + pipeline_calls = [] + + class FakeControlOutput: + values = {"control_image_latents": control_latents} + + class FakePipeline: + _execution_device = torch.device("cpu") + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + torch.rand((), generator=kwargs["generator"]) + return FakeControlOutput() + + class FakeControlBlocks: + component_names = ["vae", "controlnet"] + input_names = ["control_image", "height", "width", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + class FakeDenoiseBlocks: + input_names = ["control_image_latents"] + component_names = ["controlnet"] + + def contract(_pipeline_class, node_type, **_kwargs): + if node_type == "controlnet": + return FakeControlBlocks(), _controlnet_node_config() + return FakeDenoiseBlocks(), {"input_names": FakeDenoiseBlocks.input_names} + + control_image_alias = {"pixels": object()} + common = { + "controlnet": controlnet_a, + "vae": outputs["vae_out"], + "control_image": control_image_alias, + "height": 512, + "width": 512, + "seed": 7, + } + node = Controlnet("controlnet-route-cache-publication") + with ( + patch( + "modules.ModularDiffusers.controlnet.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch("modules.ModularDiffusers.controlnet.require_modiff_node_contract", side_effect=contract), + patch("modules.ModularDiffusers.controlnet.collect_model_ids", return_value=[]), + ): + first = node(**common) + self.assertIs(node(**common), first) + self.assertEqual(len(init_calls), 1) + + control_image_alias["generator"] = object() + with self.assertRaisesRegex(ValueError, "backend-managed"): + node(**common) + control_image_alias.pop("generator") + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + _issuer, controlnet_b = _publish_standalone( + identity, + issuer=issuer, + manager_model_id="controlnet-node-cache-publication", + ) + with self.assertRaisesRegex(ValueError, "no longer the current"): + node(**common) + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + second = node(**{**common, "controlnet": controlnet_b}) + self.assertIsNot(second, first) + + self.assertEqual(len(init_calls), 2) + self.assertEqual(len(pipeline_calls), 2) + + def test_controlnet_cache_revalidates_the_inherited_route_before_hit(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + _issuer, controlnet = _publish_standalone(manager_model_id="controlnet-cache-input-route") + + def route_without_retaining_image_latents(): + transient = torch.zeros((1, 1, 2, 2)) + return issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=transient, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + + route = route_without_retaining_image_latents() + gc.collect() + params = { + "controlnet": controlnet, + "vae": outputs["vae_out"], + "control_image": object(), + "seed": 7, + ROUTE_STATE_INPUT: route, + } + node = Controlnet() + node._model_type = QWEN_IMAGE + with self.assertRaisesRegex(ValueError, "no longer resident"): + node._cache_params_equal(params, params) + + def test_denoise_cache_rejects_control_route_after_publication_is_superseded(self): + token, outputs = _bound_outputs(QWEN_IMAGE) + identity = _standalone_identity(fingerprint="4" * 64) + issuer, controlnet_a = _publish_standalone( + identity, + manager_model_id="controlnet-denoise-cache-publication", + ) + control_latents = torch.zeros((1, 1, 2, 2)) + control_route = issue_controlnet_route_state( + None, + binding=token, + controlnet_component=controlnet_a, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + control_image_latents=control_latents, + ) + init_calls = [] + pipeline_calls = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["controlnet"] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + return {"latents": torch.ones((1, 1, 2, 2)), "mask": None} + + class FakeBlocks: + component_names = ["controlnet"] + input_names = ["prompt_embeds", "control_image_latents", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + bundle = {"controlnet": controlnet_a, "control_image_latents": control_latents} + common = { + "unet": outputs["unet_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": "encoded"}, + "controlnet_bundle": bundle, + "seed": 7, + ROUTE_STATE_INPUT: control_route, + } + node = Denoise("denoise-controlnet-route-cache-publication") + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), _route_node_config(control_bundle=True)), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + first = node(**common) + self.assertIs(node(**common), first) + self.assertEqual(len(init_calls), 1) + + bundle["control_image_latents"] = control_latents.clone() + with self.assertRaisesRegex(ValueError, "exact latent output paired"): + node(**common) + bundle["control_image_latents"] = control_latents + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + bundle["generator"] = object() + with self.assertRaisesRegex(ValueError, "backend-managed"): + node(**common) + bundle.pop("generator") + self.assertEqual(len(init_calls), 1) + + original_repo_id = controlnet_a["repo_id"] + controlnet_a["repo_id"] = "fixture/tampered-after-cache" + with self.assertRaisesRegex(ValueError, "provenance binding"): + node(**common) + controlnet_a["repo_id"] = original_repo_id + self.assertEqual(len(init_calls), 1) + + _issuer, _controlnet_b = _publish_standalone( + identity, + issuer=issuer, + manager_model_id="controlnet-denoise-cache-publication", + ) + with self.assertRaisesRegex(ValueError, "superseded"): + node(**common) + + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + def test_denoise_cache_revalidates_same_object_image_latent_bundle_aliases(self): + token, outputs = _bound_outputs(QWEN_EDIT) + image_latents = torch.zeros((1, 1, 2, 2)) + route = issue_encoder_route_state( + binding=token, + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image_latents=image_latents, + processed_mask_image=None, + mask_overlay_kwargs=None, + ) + latent_bundle = {"image_latents": image_latents} + denoised_latents = torch.ones((1, 1, 2, 2)) + init_calls = [] + pipeline_calls = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + return {"latents": denoised_latents, "mask": None} + + class FakeBlocks: + component_names = [] + input_names = ["prompt_embeds", "image_latents", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + config = _route_node_config() + config["params"].pop("image_latents") + config["params"]["image_latents_with_strength"] = {"type": "latents"} + config["input_names"] = ["embeddings", "image_latents_with_strength", "seed", ROUTE_STATE_INPUT] + common = { + "unet": outputs["unet_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": "encoded"}, + "image_latents_with_strength": latent_bundle, + "seed": 7, + ROUTE_STATE_INPUT: route, + } + node = Denoise("denoise-image-bundle-alias-cache") + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageEditModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + first = node(**common) + self.assertIs(node(**common), first) + latent_bundle["image_latents"] = image_latents.clone() + with self.assertRaisesRegex(ValueError, "exact latent output paired"): + node(**common) + + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + def test_route_less_qwen_denoise_cache_revalidates_bound_model_metadata(self): + _token, outputs = _bound_outputs(QWEN_IMAGE) + denoised_latents = torch.ones((1, 1, 2, 2)) + init_calls = [] + pipeline_calls = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = [] + transformer = None + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + return {"latents": denoised_latents, "mask": None} + + class FakeBlocks: + component_names = [] + input_names = ["prompt_embeds", "generator"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + common = { + "unet": outputs["unet_out"], + "scheduler": outputs["scheduler"], + "embeddings": {"prompt_embeds": "encoded"}, + "seed": 7, + } + node = Denoise("denoise-text-model-alias-cache") + node.progress = Mock() + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), _route_node_config()), + ), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=[]), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + first = node(**common) + self.assertIs(node(**common), first) + original_repo_id = outputs["unet_out"]["repo_id"] + outputs["unet_out"]["repo_id"] = "fixture/tampered-denoiser-cache" + with self.assertRaisesRegex(ValueError, "does not match"): + node(**common) + outputs["unet_out"]["repo_id"] = original_repo_id + + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + def test_decode_route_cache_requires_exact_denoised_latent_identity(self): + token, outputs = _bound_outputs(QWEN_EDIT) + latents = torch.zeros((1, 1, 2, 2)) + route = issue_normal_decode_route_state(binding=token, latents=latents) + pipeline_calls = [] + init_calls = [] + + class FakePipeline: + blocks = type("PipelineBlocks", (), {"doc": "fixture"})() + + def update_components(self, **_kwargs): + return None + + def __call__(self, **kwargs): + pipeline_calls.append(kwargs) + return {"images": "decoded"} + + class FakeBlocks: + component_names = [] + input_names = ["latents"] + + def init_pipeline(self, *, components_manager): + init_calls.append(components_manager) + return FakePipeline() + + config = { + "params": {ROUTE_STATE_INPUT: {"type": "modular_route_state"}}, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT], + "output_names": ["images"], + } + node = DecodeLatents("decode-route-cache") + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.QwenImageEditModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=[]), + ): + common = { + "vae": outputs["vae_out"], + ROUTE_STATE_INPUT: route, + } + first = node(**common, latents=latents) + second = node(**common, latents=latents) + self.assertIs(first, second) + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + with self.assertRaisesRegex(RuntimeError, "exact latent output paired"): + node(**common, latents=latents.clone()) + + self.assertEqual(len(init_calls), 1) + self.assertEqual(len(pipeline_calls), 1) + + def test_same_type_dynamic_resync_reemits_authoritative_route_definition(self): + cases = ( + (ImageEncode, "vae_encoder", "vae"), + (Denoise, "denoise", "unet"), + (DecodeLatents, "decoder", "vae"), + ) + pipeline_class = type(QWEN_EDIT, (), {}) + for node_class, node_type, base_field in cases: + with self.subTest(node=node_class.__name__): + node = node_class("same-node-id") + node._model_type = QWEN_EDIT + node._pipeline_class = pipeline_class + node.get_signal_value = Mock(return_value=QWEN_EDIT) + node.send_node_definition = Mock() + config = { + "params": { + base_field: {"type": "diffusers_auto_model"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + ROUTE_STATE_OUTPUT: {"type": "modular_route_state"}, + } + } + with patch( + f"{node_class.__module__}.require_modiff_node_contract", + return_value=(None, config), + ) as contract: + node.update_node({}, None) + contract.assert_called_once_with(pipeline_class, node_type, resolve_blocks=False) + sent = node.send_node_definition.call_args.args[0] + self.assertNotIn(base_field, sent) + self.assertTrue({ROUTE_STATE_INPUT, ROUTE_STATE_OUTPUT}.issubset(sent)) + + def test_controlnet_same_type_dynamic_resync_reemits_authoritative_route_definition(self): + pipeline_class = type(QWEN_IMAGE, (), {}) + node = Controlnet("same-controlnet-node-id") + node._model_type = QWEN_IMAGE + node._pipeline_class = pipeline_class + node.send_node_definition = Mock() + config = { + "params": { + "controlnet": {"type": "diffusers_auto_model"}, + "controlnet_bundle": {"type": "custom_controlnet"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + ROUTE_STATE_OUTPUT: {"type": "modular_route_state"}, + } + } + with patch( + "modules.ModularDiffusers.controlnet.require_modiff_node_contract", + return_value=(None, config), + ) as contract: + node.update_node({"model_type": QWEN_IMAGE}, None) + + contract.assert_called_once_with( + pipeline_class, + "controlnet", + require_blocks=False, + resolve_blocks=False, + ) + sent = node.send_node_definition.call_args.args[0] + self.assertTrue({"controlnet", "controlnet_bundle", ROUTE_STATE_INPUT, ROUTE_STATE_OUTPUT}.issubset(sent)) + + +class PinnedRouteSchemaTests(unittest.TestCase): + def test_qwen_control_truth_binds_control_to_denoise_without_claiming_direct_vae_route(self): + mode = PINNED_MODULAR_WORKFLOW_TRUTH["QwenImageModularPipeline"].mode("control_image") + edges = { + (edge.producer_action, edge.producer_output, edge.consumer_action, edge.consumer_input) + for edge in mode.state_edges + } + self.assertIn(("controlnet", ROUTE_STATE_OUTPUT, "denoise", ROUTE_STATE_INPUT), edges) + self.assertIn(("denoise", ROUTE_STATE_OUTPUT, "decoder", ROUTE_STATE_INPUT), edges) + self.assertNotIn(("vae_encoder", ROUTE_STATE_OUTPUT, "denoise", ROUTE_STATE_INPUT), edges) + + def test_only_reviewed_image_routes_expose_opaque_handles(self): + supported = { + "QwenImageModularPipeline", + QWEN_EDIT, + QWEN_EDIT_PLUS, + SDXL, + "WanImage2VideoModularPipeline", + } + for model_type in PINNED_MODULAR_WORKFLOW_TRUTH: + metadata = get_model_type_metadata(model_type) + actions = metadata["node_params"] + for action in ("vae_encoder", "denoise", "decoder"): + config = actions.get(action) + if config is None: + continue + names = set(config["input_names"] + config["output_names"]) + if model_type in supported: + expected = { + "vae_encoder": {ROUTE_STATE_OUTPUT}, + "denoise": {ROUTE_STATE_INPUT, ROUTE_STATE_OUTPUT}, + "decoder": {ROUTE_STATE_INPUT}, + }[action] + self.assertTrue(expected.issubset(names), (model_type, action)) + else: + self.assertFalse({ROUTE_STATE_INPUT, ROUTE_STATE_OUTPUT} & names, (model_type, action)) + + def test_qwen_controlnet_declares_optional_route_and_seed_without_a_large_image_latent_edge(self): + qwen_control = get_model_type_metadata(QWEN_IMAGE)["node_params"]["controlnet"] + self.assertIn("seed", qwen_control["input_names"]) + self.assertIn(ROUTE_STATE_INPUT, qwen_control["input_names"]) + self.assertIn(ROUTE_STATE_OUTPUT, qwen_control["output_names"]) + self.assertFalse(qwen_control["params"][ROUTE_STATE_INPUT]["label"].endswith("*")) + self.assertFalse( + {"image_latents", "image_latents_with_strength", "strength"}.intersection(qwen_control["input_names"]) + ) + + sdxl_control = get_model_type_metadata("StableDiffusionXLModularPipeline")["node_params"]["controlnet"] + self.assertFalse({"seed", ROUTE_STATE_INPUT}.intersection(sdxl_control["input_names"])) + self.assertNotIn(ROUTE_STATE_OUTPUT, sdxl_control["output_names"]) + + def test_qwen_vae_route_inputs_and_edit_decoder_anomaly_are_pinned_exactly(self): + image = get_model_type_metadata("QwenImageModularPipeline")["node_params"]["vae_encoder"] + edit = get_model_type_metadata(QWEN_EDIT)["node_params"]["vae_encoder"] + self.assertTrue( + {"image", "mask_image", "padding_mask_crop", "height", "width", "seed"}.issubset(image["input_names"]) + ) + self.assertTrue({"image", "mask_image", "padding_mask_crop", "seed"}.issubset(edit["input_names"])) + + upstream_decoder = diffusers.QwenImageEditModularPipeline().blocks.sub_blocks["decode"] + self.assertEqual(upstream_decoder.input_names, ["latents", "output_type", "mask_overlay_kwargs"]) + self.assertEqual( + upstream_decoder.output_names, + ["latents"], + "Pinned Qwen Edit declares anomalous decoder metadata; MoDiff must not infer graph image output from it.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_modular_wan_route.py b/tests/test_modular_wan_route.py new file mode 100644 index 0000000..df63692 --- /dev/null +++ b/tests/test_modular_wan_route.py @@ -0,0 +1,1723 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import diffusers +import torch +from PIL import Image + +from modiff.modular_workflow_contracts import PINNED_MODULAR_WORKFLOW_TRUTH +from modiff.modular_workflow_contracts import WAN_FLF_REPOSITORY, WAN_I2V_REPOSITORY +from modules.ModularDiffusers.denoise import Denoise +from modules.ModularDiffusers.embeddings import ImageEmbeddings +from modules.ModularDiffusers.latents import DecodeLatents, ImageEncode +from modules.ModularDiffusers.loaders import annotate_modular_loader_outputs +from modules.ModularDiffusers.modular_utils import get_model_type_metadata +from modules.ModularDiffusers.route_state import ( + ROUTE_STATE_INPUT, + ROUTE_STATE_OUTPUT, + consume_decode_route_state, + consume_wan_vae_route_state, + issue_decode_route_state, + issue_pipeline_instance_token, + issue_wan_image_encoder_route_state, + issue_wan_vae_route_state, + preflight_wan_image_encoder_inputs, + preflight_wan_vae_route_state, + require_cataloged_wan_action_source, + snapshot_wan_source_media, + validate_wan_image_encoder_route_state, + validate_wan_post_vae_route_state, + wan_area_budget_dimensions, + wan_image_processor_config_seal, + wan_transformer_contract_from_component, + wan_video_processor_config_seal, +) + + +WAN_I2V = "WanImage2VideoModularPipeline" +WAN_LATENTS_MEAN = ( + -0.7571, + -0.7089, + -0.9113, + 0.1075, + -0.1745, + 0.9653, + -0.1517, + 1.5508, + 0.4134, + -0.0715, + 0.5517, + -0.3632, + -0.1922, + -0.9497, + 0.2503, + -0.2921, +) +WAN_LATENTS_STD = ( + 2.8184, + 1.4541, + 2.3275, + 2.6558, + 1.2196, + 1.7708, + 2.6052, + 2.0743, + 3.2687, + 2.1526, + 2.8652, + 1.5579, + 1.6382, + 1.1253, + 2.8251, + 1.9160, +) + + +class _Size: + def __init__( + self, + *, + height=None, + width=None, + longest_edge=None, + shortest_edge=None, + max_height=None, + max_width=None, + ): + self.height = height + self.width = width + self.longest_edge = longest_edge + self.shortest_edge = shortest_edge + self.max_height = max_height + self.max_width = max_width + + +class _ImageProcessor: + def __init__(self): + self.do_resize = True + self.size = _Size(height=224, width=224) + self.resample = 3 + self.do_center_crop = False + self.crop_size = _Size(height=224, width=224) + self.do_rescale = True + self.rescale_factor = 1 / 255 + self.do_normalize = True + self.image_mean = (0.48145466, 0.4578275, 0.40821073) + self.image_std = (0.26862954, 0.26130258, 0.27577711) + self.do_convert_rgb = True + self.do_pad = None + self.pad_size = None + self.disable_grouping = None + + +class _FlfImageProcessor(_ImageProcessor): + def __init__(self): + super().__init__() + self.size = _Size(shortest_edge=224) + self.do_center_crop = True + + +class _ImageEncoder: + def __init__(self): + self.config = SimpleNamespace( + image_size=224, + hidden_size=1280, + patch_size=14, + projection_dim=1024, + num_channels=3, + num_hidden_layers=32, + num_attention_heads=16, + ) + + +class _WanVae: + def __init__(self): + self.temperal_downsample = [False, True, True] + self.config = SimpleNamespace( + z_dim=16, + latents_mean=list(WAN_LATENTS_MEAN), + latents_std=list(WAN_LATENTS_STD), + in_channels=3, + out_channels=3, + patch_size=None, + scale_factor_spatial=8, + scale_factor_temporal=4, + ) + + +class _VideoProcessor: + def __init__(self): + self.config = { + "do_resize": True, + "vae_scale_factor": 8, + "vae_latent_channels": 4, + "resample": "lanczos", + "reducing_gap": None, + "do_normalize": True, + "do_binarize": False, + "do_convert_rgb": False, + "do_convert_grayscale": False, + } + + +class _Transformer: + def __init__(self): + self.config = SimpleNamespace( + patch_size=(1, 2, 2), + in_channels=36, + out_channels=16, + image_dim=1280, + ) + + +class _FlfTransformer(_Transformer): + def __init__(self): + super().__init__() + self.config.pos_embed_seq_len = 514 + + +WAN_I2V_REVISION = "b184e23a8a16b20f108f727c902e769e873ffc73" +WAN_FLF_REVISION = "17c30769b1e0b5dcaa1799b117bf20a9c31f59d7" + + +def _bound_outputs(*, repository=WAN_I2V_REPOSITORY, revision=WAN_I2V_REVISION): + token = issue_pipeline_instance_token( + model_type=WAN_I2V, + repo_id=repository, + repo_source="hub", + revision=revision, + ) + outputs = { + "unet_out": {"model_id": "wan-transformer"}, + "vae_out": {"model_id": "wan-vae"}, + "scheduler": {"model_id": "wan-scheduler"}, + "image_encoder": {"model_id": "wan-image-encoder"}, + "text_encoders": {"text_encoder": {"model_id": "wan-text-encoder"}}, + } + annotate_modular_loader_outputs( + outputs, + repo_id=repository, + repo_source="hub", + model_type=WAN_I2V, + revision=revision, + trust_remote_code=False, + pipeline_instance_token=token, + ) + return token, outputs + + +def _issue_image_route( + *, + image=None, + last_image=None, + height=64, + width=64, + image_processor=None, + image_encoder=None, +): + token, outputs = _bound_outputs( + repository=WAN_FLF_REPOSITORY if last_image is not None else WAN_I2V_REPOSITORY, + revision=WAN_FLF_REVISION if last_image is not None else WAN_I2V_REVISION, + ) + image = image or Image.new("RGB", (64, 64), "red") + image_processor = image_processor or (_FlfImageProcessor() if last_image is not None else _ImageProcessor()) + image_encoder = image_encoder or _ImageEncoder() + preflight = preflight_wan_image_encoder_inputs( + image=image, + last_image=last_image, + height=height, + width=width, + ) + first_height, first_width = preflight[3:5] + workflow = preflight[0] + image_embeds = torch.zeros((2 if workflow == "flf2v" else 1, 257, 1280)) + route = issue_wan_image_encoder_route_state( + binding=token, + image=image, + last_image=last_image, + height=height, + width=width, + image_embeds=image_embeds, + image_encoder=image_encoder, + image_processor=image_processor, + resized_image=Image.new("L", (first_width, first_height)), + resized_last_image=(Image.new("L", preflight[7]) if last_image is not None else None), + execution_device="cpu", + source_snapshot=snapshot_wan_source_media(image, last_image), + preflight_geometry=preflight, + ) + return { + "binding": token, + "outputs": outputs, + "image": image, + "last_image": last_image, + "height": height, + "width": width, + "image_embeds": image_embeds, + "image_encoder": image_encoder, + "image_processor": image_processor, + "preflight": preflight, + "route": route, + } + + +def _issue_vae_route(*, num_frames=5, seed=7, **image_kwargs): + values = _issue_image_route(**image_kwargs) + vae = _WanVae() + video_processor = _VideoProcessor() + preflight = preflight_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + image=values["image"], + last_image=values["last_image"], + height=values["height"], + width=values["width"], + num_frames=num_frames, + vae_component=vae, + ) + second_height, second_width = preflight[3:5] + temporal_frames = (num_frames - 1) // 4 + 1 + raw_frame_latents = torch.zeros((1, 16, temporal_frames, second_height // 8, second_width // 8)) + image_condition_latents = torch.zeros((1, 20, temporal_frames, second_height // 8, second_width // 8)) + generator = torch.Generator(device="cpu").manual_seed(seed) + route = issue_wan_vae_route_state( + values["route"], + binding=values["binding"], + seed=seed, + generator=generator, + image=values["image"], + last_image=values["last_image"], + height=values["height"], + width=values["width"], + num_frames=num_frames, + image_condition_latents=image_condition_latents, + raw_frame_latents=raw_frame_latents, + vae_component=vae, + video_processor=video_processor, + resized_image=Image.new("L", (second_width, second_height)), + resized_last_image=(Image.new("L", preflight[10]) if values["last_image"] is not None else None), + execution_device="cpu", + preflight_geometry=preflight, + ) + values.update( + num_frames=num_frames, + seed=seed, + vae=vae, + video_processor=video_processor, + vae_preflight=preflight, + raw_frame_latents=raw_frame_latents, + image_condition_latents=image_condition_latents, + generator=generator, + route=route, + ) + return values + + +def _vae_validation(values, **overrides): + kwargs = { + "binding": values["binding"], + "model_type": WAN_I2V, + "seed": values["seed"], + "image_condition_latents": values["image_condition_latents"], + "height": values["height"], + "width": values["width"], + "num_frames": values["num_frames"], + "vae_component": values["vae"], + "video_processor": values["video_processor"], + } + kwargs.update(overrides) + return validate_wan_post_vae_route_state(values["route"], **kwargs) + + +def _issue_decode_values(): + values = _issue_vae_route() + transformer = _Transformer() + latents = torch.zeros_like(values["raw_frame_latents"]) + route = issue_decode_route_state( + values["route"], + binding=values["binding"], + latents=latents, + vae_component=values["vae"], + transformer_component=transformer, + execution_device="cpu", + ) + values.update(transformer=transformer, latents=latents, route=route) + return values + + +class WanRouteStateTests(unittest.TestCase): + def test_each_workflow_requires_its_exact_reviewed_loader_artifact(self): + image = Image.new("RGB", (64, 64)) + last_image = Image.new("RGB", (64, 64)) + i2v_token, _outputs = _bound_outputs() + flf_token, _outputs = _bound_outputs( + repository=WAN_FLF_REPOSITORY, + revision=WAN_FLF_REVISION, + ) + self.assertEqual( + require_cataloged_wan_action_source(image=image, last_image=None, binding=i2v_token), + "image2video", + ) + self.assertEqual( + require_cataloged_wan_action_source(image=image, last_image=last_image, binding=flf_token), + "flf2v", + ) + for binding, ending in ((i2v_token, last_image), (flf_token, None)): + with self.subTest(repository=binding._repo_id), self.assertRaisesRegex(ValueError, "reviewed immutable"): + require_cataloged_wan_action_source(image=image, last_image=ending, binding=binding) + + def test_exact_two_pass_geometry_examples(self): + portrait = Image.new("RGB", (100, 200)) + landscape = Image.new("RGB", (1200, 600)) + self.assertEqual(wan_area_budget_dimensions(portrait, 480, 832), (880, 432)) + self.assertEqual(wan_area_budget_dimensions(portrait, 880, 432), (864, 432)) + self.assertEqual(wan_area_budget_dimensions(landscape, 480, 832), (432, 880)) + self.assertEqual(wan_area_budget_dimensions(landscape, 432, 880), (432, 864)) + + def test_i2v_and_internal_flf_chain_end_to_end_without_raw_latent_edge(self): + for last_image, num_frames in ((None, 1), (Image.new("RGB", (64, 64), "blue"), 5)): + with self.subTest(flf=last_image is not None): + values = _issue_vae_route(last_image=last_image, num_frames=num_frames) + transformer = _FlfTransformer() if last_image is not None else _Transformer() + runtime_a = consume_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + seed=values["seed"], + execution_device="cpu", + image_embeds=values["image_embeds"], + image_condition_latents=values["image_condition_latents"], + height=values["height"], + width=values["width"], + num_frames=num_frames, + vae_component=values["vae"], + transformer_component=transformer, + ) + runtime_b = consume_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + seed=values["seed"], + execution_device="cpu", + image_embeds=values["image_embeds"], + image_condition_latents=values["image_condition_latents"], + height=values["height"], + width=values["width"], + num_frames=num_frames, + vae_component=values["vae"], + transformer_component=transformer, + ) + self.assertTrue(torch.equal(runtime_a["generator"].get_state(), runtime_b["generator"].get_state())) + self.assertEqual( + (runtime_a["height"], runtime_a["width"]), + values["vae_preflight"][3:5], + ) + denoised = torch.zeros_like(values["raw_frame_latents"]) + decode_route = issue_decode_route_state( + values["route"], + binding=values["binding"], + latents=denoised, + vae_component=values["vae"], + transformer_component=transformer, + execution_device="cpu", + ) + decode = consume_decode_route_state( + decode_route, + binding=values["binding"], + model_type=WAN_I2V, + latents=denoised, + vae_component=values["vae"], + video_processor=_VideoProcessor(), + execution_device="cpu", + materialize_overlay=False, + ) + self.assertEqual(decode["contract"], "wan_i2v") + self.assertFalse(hasattr(decode_route._payload, "_raw_frame_latents_ref")) + + def test_frame_rules_and_default_video_budget(self): + image_values = _issue_image_route(image=Image.new("RGB", (832, 480)), height=480, width=832) + vae = _WanVae() + default = preflight_wan_vae_route_state( + image_values["route"], + binding=image_values["binding"], + model_type=WAN_I2V, + image=image_values["image"], + last_image=None, + height=480, + width=832, + num_frames=81, + vae_component=vae, + ) + self.assertLessEqual(default[-1], 512 * 1024 * 1024) + for valid_frames in (1, 5): + preflight_wan_vae_route_state( + image_values["route"], + binding=image_values["binding"], + model_type=WAN_I2V, + image=image_values["image"], + last_image=None, + height=480, + width=832, + num_frames=valid_frames, + vae_component=vae, + ) + small_values = _issue_image_route() + preflight_wan_vae_route_state( + small_values["route"], + binding=small_values["binding"], + model_type=WAN_I2V, + image=small_values["image"], + last_image=None, + height=64, + width=64, + num_frames=477, + vae_component=vae, + ) + for invalid_frames in (0, 2, 480): + with self.subTest(invalid_frames=invalid_frames), self.assertRaises(ValueError): + preflight_wan_vae_route_state( + image_values["route"], + binding=image_values["binding"], + model_type=WAN_I2V, + image=image_values["image"], + last_image=None, + height=480, + width=832, + num_frames=invalid_frames, + vae_component=vae, + ) + flf_values = _issue_image_route(last_image=Image.new("RGB", (64, 64))) + with self.assertRaisesRegex(ValueError, "at least 5"): + preflight_wan_vae_route_state( + flf_values["route"], + binding=flf_values["binding"], + model_type=WAN_I2V, + image=flf_values["image"], + last_image=flf_values["last_image"], + height=64, + width=64, + num_frames=1, + vae_component=vae, + ) + + def test_palette_source_mutation_and_exact_tensor_mutations_are_rejected(self): + palette_image = Image.new("P", (64, 64)) + palette_image.putpalette([0, 0, 0] * 256) + image_values = _issue_image_route(image=palette_image) + palette_image.putpalette([255, 0, 0] * 256) + with self.assertRaisesRegex(ValueError, "pixels changed"): + validate_wan_image_encoder_route_state( + image_values["route"], + binding=image_values["binding"], + model_type=WAN_I2V, + image=palette_image, + last_image=None, + height=64, + width=64, + image_embeds=image_values["image_embeds"], + image_encoder=image_values["image_encoder"], + image_processor=image_values["image_processor"], + ) + + for field in ("image_embeds", "image_condition_latents"): + with self.subTest(field=field): + values = _issue_vae_route() + values[field].add_(1) + with self.assertRaisesRegex(ValueError, "mutated or rebound"): + _vae_validation(values) + + def test_component_and_processor_config_mutations_are_rejected(self): + mutations = ( + ("image_processor", lambda values: setattr(values["image_processor"], "do_normalize", False)), + ("image_encoder", lambda values: setattr(values["image_encoder"].config, "image_size", 336)), + ("video_processor", lambda values: values["video_processor"].config.__setitem__("do_normalize", False)), + ("vae_mean", lambda values: values["vae"].config.latents_mean.__setitem__(0, 0.5)), + ("vae_std", lambda values: values["vae"].config.latents_std.__setitem__(0, 0.5)), + ) + for label, mutate in mutations: + with self.subTest(label=label): + values = _issue_vae_route() + mutate(values) + with self.assertRaises(ValueError): + _vae_validation(values) + + def test_transformer_contract_and_image_dimension_are_exact(self): + for field, value in ( + ("patch_size", (1, 2, 1)), + ("in_channels", 16), + ("out_channels", 15), + ): + transformer = _Transformer() + setattr(transformer.config, field, value) + with self.subTest(field=field), self.assertRaises(ValueError): + wan_transformer_contract_from_component(transformer) + + values = _issue_vae_route() + transformer = _Transformer() + transformer.config.image_dim = 1024 + with self.assertRaises(ValueError): + consume_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + seed=values["seed"], + execution_device="cpu", + image_embeds=values["image_embeds"], + image_condition_latents=values["image_condition_latents"], + height=values["height"], + width=values["width"], + num_frames=values["num_frames"], + vae_component=values["vae"], + transformer_component=transformer, + ) + + def test_wrong_execution_devices_are_rejected_at_producer_and_consumer(self): + values = _issue_vae_route() + transformer = _Transformer() + with self.assertRaisesRegex(ValueError, "generator device"): + consume_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + seed=values["seed"], + execution_device="cuda", + image_embeds=values["image_embeds"], + image_condition_latents=values["image_condition_latents"], + height=values["height"], + width=values["width"], + num_frames=values["num_frames"], + vae_component=values["vae"], + transformer_component=transformer, + ) + with self.assertRaisesRegex(ValueError, "Denoise execution device"): + issue_decode_route_state( + values["route"], + binding=values["binding"], + latents=torch.zeros_like(values["raw_frame_latents"]), + vae_component=values["vae"], + transformer_component=transformer, + execution_device="cuda", + ) + + def test_processor_configs_are_bounded_before_use(self): + processor = _ImageProcessor() + wan_image_processor_config_seal(processor) + for field, value in ( + ("do_resize", "yes"), + ("do_center_crop", True), + ("rescale_factor", 1e20), + ("image_std", (1.0, 0.0, 1.0)), + ("pad_size", _Size(height=9000, width=1)), + ): + invalid = _ImageProcessor() + setattr(invalid, field, value) + with self.subTest(field=field), self.assertRaises(ValueError): + wan_image_processor_config_seal(invalid) + video_processor = _VideoProcessor() + wan_video_processor_config_seal(video_processor) + video_processor.config["vae_latent_channels"] = 16 + with self.assertRaises(ValueError): + wan_video_processor_config_seal(video_processor) + + def test_image_embeddings_require_exact_pinned_token_geometry(self): + values = _issue_image_route() + for wrong_tokens in (1, 256, 258): + with self.subTest(tokens=wrong_tokens), self.assertRaisesRegex(ValueError, "batch contract"): + issue_wan_image_encoder_route_state( + binding=values["binding"], + image=values["image"], + last_image=None, + height=values["height"], + width=values["width"], + image_embeds=torch.zeros((1, wrong_tokens, 1280)), + image_encoder=values["image_encoder"], + image_processor=values["image_processor"], + resized_image=Image.new("L", (values["preflight"][4], values["preflight"][3])), + resized_last_image=None, + execution_device="cpu", + source_snapshot=snapshot_wan_source_media(values["image"], None), + preflight_geometry=values["preflight"], + ) + + def test_resized_last_image_outputs_are_mandatory_only_for_flf(self): + last_image = Image.new("RGB", (64, 64)) + token, _outputs = _bound_outputs() + image = Image.new("RGB", (64, 64)) + processor = _FlfImageProcessor() + encoder = _ImageEncoder() + preflight = preflight_wan_image_encoder_inputs(image=image, last_image=last_image, height=64, width=64) + embeds = torch.zeros((2, 257, 1280)) + base = dict( + binding=token, + image=image, + last_image=last_image, + height=64, + width=64, + image_embeds=embeds, + image_encoder=encoder, + image_processor=processor, + resized_image=Image.new("L", (64, 64)), + execution_device="cpu", + source_snapshot=snapshot_wan_source_media(image, last_image), + preflight_geometry=preflight, + ) + for bad_last in (None, object(), Image.new("L", (32, 64))): + with self.subTest(bad_last=type(bad_last).__name__), self.assertRaises(ValueError): + issue_wan_image_encoder_route_state(**base, resized_last_image=bad_last) + + values = _issue_image_route(image=image, last_image=last_image) + vae = _WanVae() + video_processor = _VideoProcessor() + vae_preflight = preflight_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + image=image, + last_image=last_image, + height=64, + width=64, + num_frames=5, + vae_component=vae, + ) + second_height, second_width = vae_preflight[3:5] + vae_base = dict( + route_state=values["route"], + binding=values["binding"], + seed=7, + generator=torch.Generator(device="cpu").manual_seed(7), + image=image, + last_image=last_image, + height=64, + width=64, + num_frames=5, + image_condition_latents=torch.zeros( + (1, 20, 2, second_height // 8, second_width // 8) + ), + raw_frame_latents=torch.zeros((1, 16, 2, second_height // 8, second_width // 8)), + vae_component=vae, + video_processor=video_processor, + resized_image=Image.new("L", (second_width, second_height)), + execution_device="cpu", + preflight_geometry=vae_preflight, + ) + for bad_last in (None, object(), Image.new("L", (32, 64))): + with self.subTest(vae_bad_last=type(bad_last).__name__), self.assertRaises(ValueError): + issue_wan_vae_route_state(**vae_base, resized_last_image=bad_last) + + def test_flf_crop_resize_preflight_matches_pinned_torchvision_axis_order(self): + from torchvision.transforms.functional import center_crop + + image = Image.new("RGB", (1200, 600)) + last_image = Image.new("RGB", (400, 300)) + preflight = preflight_wan_image_encoder_inputs( + image=image, + last_image=last_image, + height=480, + width=832, + ) + self.assertEqual(preflight[3:5], (432, 880)) + self.assertEqual(preflight[7], (660, 880)) + # Pinned Wan passes [computed_width, computed_height] to torchvision, + # whose API interprets the pair as [height, width]. + actual = center_crop(last_image, [880, 660]) + self.assertEqual(actual.size, preflight[7]) + values = _issue_image_route( + image=image, + last_image=last_image, + height=480, + width=832, + ) + self.assertEqual(values["preflight"][7], actual.size) + second = preflight_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + image=image, + last_image=last_image, + height=480, + width=832, + num_frames=5, + vae_component=_WanVae(), + ) + self.assertEqual(second[3:5], (432, 864)) + self.assertEqual(second[10], (648, 864)) + actual_second = center_crop(last_image, [864, 648]) + self.assertEqual(actual_second.size, second[10]) + + +class WanSchemaTruthTests(unittest.TestCase): + def test_public_i2v_and_internal_flf_share_the_exact_generic_route(self): + truth = PINNED_MODULAR_WORKFLOW_TRUTH[WAN_I2V] + self.assertEqual([name for name, _mode in truth.modes], ["image_to_video"]) + self.assertEqual([name for name, _flow in truth.state_flows], ["flf2v"]) + mode = truth.mode("image_to_video") + flow = truth.state_flow("flf2v") + self.assertEqual(mode.action_sequence, ("text_encoder", "image_encoder", "vae_encoder", "denoise", "decoder")) + self.assertEqual(flow.action_sequence, mode.action_sequence) + edges = { + (edge.producer_action, edge.producer_output, edge.consumer_action, edge.consumer_input) + for edge in mode.state_edges + } + self.assertEqual( + edges, + { + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("image_encoder", "image_embeds", "denoise", "image_embeds"), + ("image_encoder", ROUTE_STATE_OUTPUT, "vae_encoder", ROUTE_STATE_INPUT), + ("vae_encoder", "image_condition_latents", "denoise", "image_condition_latents"), + ("vae_encoder", ROUTE_STATE_OUTPUT, "denoise", ROUTE_STATE_INPUT), + ("denoise", "latents", "decoder", "latents"), + ("denoise", ROUTE_STATE_OUTPUT, "decoder", ROUTE_STATE_INPUT), + }, + ) + self.assertEqual(flow.state_edges, mode.state_edges) + self.assertEqual(len(mode.upstream_block_sequence), 12) + self.assertEqual(len(flow.upstream_block_sequence), 14) + + def test_schema_exposes_only_typed_condition_and_opaque_route_outputs(self): + actions = get_model_type_metadata(WAN_I2V)["node_params"] + self.assertEqual(actions["image_encoder"]["params"]["image_embeds"]["type"], "image_embeds") + self.assertEqual( + actions["vae_encoder"]["params"]["image_condition_latents"]["type"], + "video_condition_latents", + ) + self.assertNotIn("first_last_frame_latents", actions["vae_encoder"]["output_names"]) + self.assertEqual(actions["denoise"]["params"]["height"]["max"], 8192) + self.assertEqual(actions["denoise"]["params"]["width"]["max"], 8192) + self.assertIn("vae", actions["denoise"]["model_input_names"]) + + def test_image_embeddings_same_model_signal_resends_authoritative_route_fields(self): + node = ImageEmbeddings("wan-image-resync") + node._model_type = WAN_I2V + node._pipeline_class = diffusers.WanImage2VideoModularPipeline + node.send_node_definition = Mock() + with patch.object(node, "get_signal_value", return_value=WAN_I2V): + node.update_node({}, None) + params = node.send_node_definition.call_args.args[0] + self.assertIn("last_image", params) + self.assertIn("height", params) + self.assertIn("width", params) + self.assertIn(ROUTE_STATE_OUTPUT, params) + + +class WanPreinitResourceTests(unittest.TestCase): + def _image_blocks_and_config(self): + blocks = Mock() + blocks.input_names = ["image", "last_image", "height", "width"] + config = { + "params": { + "image": {"type": "image"}, + "last_image": {"type": "image"}, + "height": {"type": "int", "min": 1, "max": 8192}, + "width": {"type": "int", "min": 1, "max": 8192}, + "image_encoder": {"type": "diffusers_auto_model"}, + }, + "model_input_names": ["image_encoder"], + "input_names": ["image", "last_image", "height", "width", "image_encoder"], + "output_names": ["image_embeds", ROUTE_STATE_OUTPUT], + } + return blocks, config + + def test_extreme_first_resize_and_flf_crop_fail_before_init(self): + _token, outputs = _bound_outputs() + cases = ((Image.new("RGB", (8192, 1)), None, 4096, 4096, "area-budget"),) + for image, last_image, height, width, message in cases: + with self.subTest(message=message): + blocks, config = self._image_blocks_and_config() + with ( + patch( + "modules.ModularDiffusers.embeddings.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch( + "modules.ModularDiffusers.embeddings.require_modiff_node_contract", + return_value=(blocks, config), + ), + self.assertRaisesRegex(ValueError, message), + ): + ImageEmbeddings().execute( + image_encoder=outputs["image_encoder"], + image=image, + last_image=last_image, + height=height, + width=width, + ) + blocks.init_pipeline.assert_not_called() + + with self.assertRaisesRegex(ValueError, "last-image"): + preflight_wan_image_encoder_inputs( + image=Image.new("RGB", (64, 64)), + last_image=Image.new("RGB", (1, 8192)), + height=64, + width=64, + ) + + direct_resize = preflight_wan_image_encoder_inputs( + image=Image.new("RGB", (8192, 16)), + last_image=None, + height=128, + width=1024, + ) + self.assertEqual(direct_resize[5:7], (224, 224)) + + def test_wrong_workflow_artifact_is_rejected_before_contract_resolution_and_cache_reuse(self): + _token, outputs = _bound_outputs() + image = Image.new("RGB", (64, 64)) + last_image = Image.new("RGB", (64, 64)) + structural = _issue_image_route(image=image, last_image=last_image) + image_values = { + "image_encoder": outputs["image_encoder"], + "image": image, + "last_image": last_image, + "height": 64, + "width": 64, + } + vae_values = { + "vae": outputs["vae_out"], + "image": image, + "last_image": last_image, + "height": 64, + "width": 64, + "num_frames": 5, + "seed": 7, + ROUTE_STATE_INPUT: structural["route"], + } + for node, values, module in ( + (ImageEmbeddings(), image_values, "modules.ModularDiffusers.embeddings"), + (ImageEncode(), vae_values, "modules.ModularDiffusers.latents"), + ): + with self.subTest(node=type(node).__name__): + contract_resolver = Mock(side_effect=AssertionError("contract resolver must not run")) + component_resolver = Mock(side_effect=AssertionError("component resolver must not run")) + with ( + patch( + f"{module}.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch(f"{module}.require_modiff_node_contract", contract_resolver), + patch(f"{module}.resolve_managed_component_by_id", component_resolver), + self.assertRaisesRegex(ValueError, "reviewed immutable"), + ): + node.execute(**values) + contract_resolver.assert_not_called() + component_resolver.assert_not_called() + + node._pipeline_class = diffusers.WanImage2VideoModularPipeline + with ( + patch(f"{module}.require_modiff_node_contract", contract_resolver), + patch(f"{module}.resolve_managed_component_by_id", component_resolver), + self.assertRaisesRegex(ValueError, "reviewed immutable"), + ): + node._cache_params_equal(values, dict(values)) + contract_resolver.assert_not_called() + component_resolver.assert_not_called() + + def test_oversized_resolved_video_fails_before_vae_pipeline_init(self): + image_values = _issue_image_route(height=4096, width=4096) + vae = _WanVae() + blocks = Mock() + blocks.input_names = ["image", "height", "width", "num_frames", "generator"] + config = { + "params": { + "image": {"type": "image"}, + "last_image": {"type": "image"}, + "height": {"type": "int", "min": 1, "max": 8192}, + "width": {"type": "int", "min": 1, "max": 8192}, + "num_frames": {"type": "int", "min": 1, "max": 480}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "vae": {"type": "diffusers_auto_model"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["vae"], + "input_names": [ + "image", + "last_image", + "height", + "width", + "num_frames", + "seed", + "vae", + ROUTE_STATE_INPUT, + ], + "output_names": ["image_condition_latents", ROUTE_STATE_OUTPUT], + } + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch("modules.ModularDiffusers.latents.require_modiff_node_contract", return_value=(blocks, config)), + patch("modules.ModularDiffusers.latents.resolve_managed_component_by_id", return_value=vae), + self.assertRaisesRegex(ValueError, "512-MiB"), + ): + ImageEncode().execute( + vae=image_values["outputs"]["vae_out"], + image=image_values["image"], + height=4096, + width=4096, + num_frames=5, + seed=7, + route_state_in=image_values["route"], + ) + blocks.init_pipeline.assert_not_called() + + +class WanActionBoundaryTests(unittest.TestCase): + def _run_image_embeddings( + self, + *, + init_mutation=None, + call_mutation=None, + pipeline_call_mutation=None, + use_cache=False, + execution_device="cpu", + encoder_component=None, + init_observer=None, + overrides=None, + last_image=None, + ): + _token, outputs = _bound_outputs( + repository=WAN_FLF_REPOSITORY if last_image is not None else WAN_I2V_REPOSITORY, + revision=WAN_FLF_REVISION if last_image is not None else WAN_I2V_REVISION, + ) + source = Image.new("RGB", (100, 200), "red") + processor = _FlfImageProcessor() if last_image is not None else _ImageProcessor() + manager = {"encoder": encoder_component or _ImageEncoder()} + pipeline_calls = [] + + class FakePipeline: + blocks = SimpleNamespace(doc="image-embeddings") + + def __init__(self): + self._execution_device = torch.device(execution_device) + self.image_encoder = None + self.image_processor = None + + def update_components(self, **values): + for name, value in values.items(): + setattr(self, name, value) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + if call_mutation is not None: + call_mutation(manager, processor) + if pipeline_call_mutation is not None: + pipeline_call_mutation(self, manager, processor) + preflight = preflight_wan_image_encoder_inputs( + image=source, + last_image=last_image, + height=480, + width=832, + ) + first_height, first_width = preflight[3:5] + return { + "resized_image": Image.new("L", (first_width, first_height)), + "resized_last_image": ( + Image.new("L", preflight[7]) if last_image is not None else None + ), + "image_embeds": torch.zeros((2 if last_image is not None else 1, 257, 1280)), + } + + class FakeBlocks: + component_names = ["image_encoder", "image_processor"] + input_names = ["image", "last_image", "height", "width"] + doc = "image-embeddings" + + @staticmethod + def init_pipeline(*, components_manager): + if init_observer is not None: + init_observer() + if init_mutation is not None: + init_mutation(manager, processor) + return FakePipeline() + + config = { + "params": { + "image": {"type": "image"}, + "last_image": {"type": "image"}, + "height": {"type": "int", "min": 1, "max": 8192}, + "width": {"type": "int", "min": 1, "max": 8192}, + "image_encoder": {"type": "diffusers_auto_model"}, + "image_embeds": {"type": "image_embeds"}, + ROUTE_STATE_OUTPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["image_encoder"], + "input_names": ["image", "last_image", "height", "width", "image_encoder"], + "output_names": ["image_embeds", ROUTE_STATE_OUTPUT, "doc"], + } + + class FakeSpec: + def load(self, *, local_files_only): + self.local_files_only = local_files_only + return processor + + def managed(*, ids, return_dict_with_names=True): + return {"image_encoder": manager["encoder"], "image_processor": processor} + + def resolve(_components, _payload, *, label): + self.assertIn("image encoder", label.lower()) + return manager["encoder"] + + node = ImageEmbeddings("wan-image-action") + processor_spec = Mock(return_value=FakeSpec()) + kwargs = { + "image_encoder": outputs["image_encoder"], + "image": source, + "last_image": last_image, + "height": "480", + "width": "832", + } + kwargs.update(overrides or {}) + with ( + patch( + "modules.ModularDiffusers.embeddings.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch( + "modules.ModularDiffusers.embeddings.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.embeddings.resolve_managed_component_by_id", side_effect=resolve), + patch("modules.ModularDiffusers.embeddings.collect_model_ids", return_value=["wan-image-encoder"]), + patch("modules.ModularDiffusers.embeddings.ComponentSpec", processor_spec), + patch("modules.ModularDiffusers.embeddings.components.add", return_value="wan-image-processor"), + patch("modules.ModularDiffusers.embeddings.components.get_components_by_ids", side_effect=managed), + ): + result = node(**kwargs) if use_cache else node.execute(**kwargs) + if use_cache: + cached = node(**dict(kwargs)) + self.assertIs(result, cached) + self.assertEqual(processor_spec.call_args.kwargs["type_hint"].__name__, "CLIPImageProcessor") + return result, pipeline_calls + + def test_image_embeddings_manager_init_and_call_swaps_publish_no_route(self): + for stage in ("init", "call"): + replacement = _ImageEncoder() + + def swap(manager, _processor): + manager["encoder"] = replacement + + with self.subTest(stage=stage), self.assertRaisesRegex(ValueError, "changed"): + self._run_image_embeddings( + init_mutation=(swap if stage == "init" else None), + call_mutation=(swap if stage == "call" else None), + ) + + def test_image_embeddings_processor_and_encoder_config_mutation_publish_no_route(self): + mutations = ( + lambda _manager, processor: setattr(processor, "do_normalize", False), + lambda manager, _processor: setattr(manager["encoder"].config, "patch_size", 16), + lambda manager, _processor: setattr(manager["encoder"].config, "projection_dim", 768), + ) + for mutation in mutations: + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + self._run_image_embeddings(call_mutation=mutation) + with self.assertRaisesRegex(ValueError, "components changed"): + self._run_image_embeddings( + pipeline_call_mutation=lambda pipeline, _manager, _processor: setattr( + pipeline, + "image_processor", + _ImageProcessor(), + ) + ) + + def test_image_embeddings_canonical_string_replay_hits_cache(self): + result, calls = self._run_image_embeddings(use_cache=True) + self.assertIsNotNone(result[ROUTE_STATE_OUTPUT]) + self.assertEqual(len(calls), 1) + + def test_exact_flf_artifact_executes_image_and_vae_actions(self): + last_image = Image.new("RGB", (100, 200), "blue") + image_result, image_calls = self._run_image_embeddings(last_image=last_image) + self.assertIsNotNone(image_result[ROUTE_STATE_OUTPUT]) + self.assertIs(image_calls[0]["last_image"], last_image) + + values = _issue_image_route( + image=Image.new("RGB", (100, 200), "red"), + last_image=last_image, + height=480, + width=832, + ) + vae_result, vae_calls, _preflight = self._run_image_encode(values=values) + self.assertIsNotNone(vae_result[ROUTE_STATE_OUTPUT]) + self.assertIs(vae_calls[0]["last_image"], last_image) + + def _run_image_encode( + self, + *, + values=None, + init_mutation=None, + call_mutation=None, + pipeline_call_mutation=None, + use_cache=False, + execution_device="cpu", + vae_component=None, + init_observer=None, + overrides=None, + ): + values = values or _issue_image_route(image=Image.new("RGB", (100, 200)), height=480, width=832) + manager = {"vae": vae_component or _WanVae()} + processor = _VideoProcessor() + pipeline_calls = [] + preflight_holder = {} + + class FakePipeline: + blocks = SimpleNamespace(doc="vae") + + def __init__(self): + self._execution_device = torch.device(execution_device) + self.vae = None + self.video_processor = processor + + def update_components(self, **components): + for name, component in components.items(): + setattr(self, name, component) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + preflight = preflight_wan_vae_route_state( + values["route"], + binding=values["binding"], + model_type=WAN_I2V, + image=values["image"], + last_image=values["last_image"], + height=values["height"], + width=values["width"], + num_frames=5, + vae_component=manager["vae"], + ) + preflight_holder["value"] = preflight + second_height, second_width = preflight[3:5] + if call_mutation is not None: + call_mutation(manager, processor) + if pipeline_call_mutation is not None: + pipeline_call_mutation(self, manager, processor) + temporal_frames = 2 + result = { + "resized_image": Image.new("L", (second_width, second_height)), + "resized_last_image": ( + Image.new("L", preflight[10]) + if values["last_image"] is not None + else None + ), + "image_condition_latents": torch.zeros( + (1, 20, temporal_frames, second_height // 8, second_width // 8) + ), + } + result[ + "first_last_frame_latents" if values["last_image"] is not None else "first_frame_latents" + ] = torch.zeros((1, 16, temporal_frames, second_height // 8, second_width // 8)) + return result + + class FakeBlocks: + component_names = ["vae", "video_processor"] + input_names = ["image", "last_image", "height", "width", "num_frames", "generator"] + doc = "vae" + + @staticmethod + def init_pipeline(*, components_manager): + if init_observer is not None: + init_observer() + if init_mutation is not None: + init_mutation(manager, processor) + return FakePipeline() + + config = { + "params": { + "image": {"type": "image"}, + "last_image": {"type": "image"}, + "height": {"type": "int", "min": 1, "max": 8192}, + "width": {"type": "int", "min": 1, "max": 8192}, + "num_frames": {"type": "int", "min": 1, "max": 480}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "vae": {"type": "diffusers_auto_model"}, + "image_condition_latents": {"type": "video_condition_latents"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + ROUTE_STATE_OUTPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["vae"], + "input_names": [ + "image", + "last_image", + "height", + "width", + "num_frames", + "seed", + "vae", + ROUTE_STATE_INPUT, + ], + "output_names": ["image_condition_latents", ROUTE_STATE_OUTPUT, "doc"], + } + + def managed(*, ids, return_dict_with_names=True): + return {"vae": manager["vae"]} + + node = ImageEncode("wan-vae-action") + kwargs = { + "vae": values["outputs"]["vae_out"], + "image": values["image"], + "last_image": values["last_image"], + "height": str(values["height"]), + "width": str(values["width"]), + "num_frames": "5", + "seed": "7", + ROUTE_STATE_INPUT: values["route"], + } + kwargs.update(overrides or {}) + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch( + "modules.ModularDiffusers.latents.resolve_managed_component_by_id", + side_effect=lambda *_args, **_kwargs: manager["vae"], + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=["wan-vae"]), + patch("modules.ModularDiffusers.latents.components.get_components_by_ids", side_effect=managed), + patch( + "modules.ModularDiffusers.latents.modular_generator_from_seed", + side_effect=lambda seed, _pipeline: torch.Generator(device="cpu").manual_seed(seed), + ), + ): + result = node(**kwargs) if use_cache else node.execute(**kwargs) + if use_cache: + cached = node(**dict(kwargs)) + self.assertIs(result, cached) + return result, pipeline_calls, preflight_holder + + def test_vae_routes_first_pass_dimensions_and_cache_replays_strings(self): + result, calls, preflight = self._run_image_encode(use_cache=True) + self.assertIsNotNone(result[ROUTE_STATE_OUTPUT]) + self.assertEqual(len(calls), 1) + self.assertEqual((calls[0]["height"], calls[0]["width"]), (880, 432)) + self.assertEqual(preflight["value"][3:5], (864, 432)) + + def test_vae_manager_processor_and_normalization_mutations_publish_no_route(self): + mutations = ( + ("init-manager", lambda manager, _processor: manager.__setitem__("vae", _WanVae()), "init"), + ("call-manager", lambda manager, _processor: manager.__setitem__("vae", _WanVae()), "call"), + ("processor", lambda _manager, processor: processor.config.__setitem__("do_normalize", False), "call"), + ("mean", lambda manager, _processor: manager["vae"].config.latents_mean.__setitem__(0, 0.0), "call"), + ) + for label, mutation, stage in mutations: + with self.subTest(label=label), self.assertRaises(ValueError): + self._run_image_encode( + init_mutation=(mutation if stage == "init" else None), + call_mutation=(mutation if stage == "call" else None), + ) + with self.assertRaisesRegex(ValueError, "video processor changed"): + self._run_image_encode( + pipeline_call_mutation=lambda pipeline, _manager, _processor: setattr( + pipeline, + "video_processor", + _VideoProcessor(), + ) + ) + + def test_producer_device_mismatch_publishes_no_image_or_vae_route(self): + with self.assertRaisesRegex(ValueError, "producing Wan execution device"): + self._run_image_embeddings(execution_device="cuda") + with self.assertRaisesRegex(ValueError, "producing Wan execution device"): + self._run_image_encode(execution_device="cuda") + + def test_wrong_pinned_component_geometry_fails_before_action_init(self): + encoder_cases = ( + ("patch_size", 16), + ("num_channels", 4), + ("projection_dim", 768), + ) + for field, value in encoder_cases: + encoder = _ImageEncoder() + setattr(encoder.config, field, value) + init_observer = Mock() + with self.subTest(component="image_encoder", field=field), self.assertRaises(ValueError): + self._run_image_embeddings( + encoder_component=encoder, + init_observer=init_observer, + ) + init_observer.assert_not_called() + + vae_cases = ( + ("in_channels", 4, "config"), + ("out_channels", 4, "config"), + ("patch_size", 2, "config"), + ("scale_factor_spatial", 16, "config"), + ("scale_factor_temporal", 8, "config"), + ("temperal_downsample", (True, True, True), "component"), + ("latents_mean", [0.0] * 16, "config"), + ("latents_std", [0.0] * 16, "config"), + ) + for field, value, target in vae_cases: + vae = _WanVae() + setattr(vae if target == "component" else vae.config, field, value) + init_observer = Mock() + with self.subTest(component="vae", field=field), self.assertRaises(ValueError): + self._run_image_encode( + vae_component=vae, + init_observer=init_observer, + ) + init_observer.assert_not_called() + + transformer_cases = ( + ("patch_size", (1, 2, 3)), + ("out_channels", 15), + ("image_dim", 1024), + ("pos_embed_seq_len", 514), + ) + for field, value in transformer_cases: + transformer = _Transformer() + setattr(transformer.config, field, value) + init_observer = Mock() + with self.subTest(component="transformer", field=field), self.assertRaises(ValueError): + self._run_denoise( + transformer_component=transformer, + init_observer=init_observer, + ) + init_observer.assert_not_called() + + def test_image_and_vae_route_scalars_reject_noncanonical_forms(self): + for invalid in (True, "480.0", "0480"): + with self.subTest(action="image_embeddings", invalid=invalid), self.assertRaisesRegex( + ValueError, + "height", + ): + self._run_image_embeddings(overrides={"height": invalid}) + for field, canonical in (("height", "480"), ("num_frames", "5"), ("seed", "7")): + for invalid in (True, f"{canonical}.0", f"0{canonical}"): + with self.subTest(action="vae", field=field, invalid=invalid), self.assertRaisesRegex( + ValueError, + field, + ): + self._run_image_encode(overrides={field: invalid}) + + def _run_denoise( + self, + *, + call_mutation=None, + init_mutation=None, + use_cache=False, + overrides=None, + transformer_component=None, + init_observer=None, + ): + values = _issue_vae_route(image=Image.new("RGB", (100, 200)), height=480, width=832) + manager = { + "vae": values["vae"], + "transformer": transformer_component or _Transformer(), + } + scheduler = object() + pipeline_calls = [] + + class FakePipeline: + _execution_device = torch.device("cpu") + component_names = ["transformer", "scheduler", "guider"] + blocks = SimpleNamespace(doc="denoise") + + def __init__(self): + self.transformer = None + self.scheduler = None + self.guider = None + + def update_components(self, **components): + for name, component in components.items(): + setattr(self, name, component) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + if call_mutation is not None: + call_mutation(manager, values) + return {"latents": torch.zeros_like(values["raw_frame_latents"])} + + class FakeBlocks: + component_names = ["transformer", "scheduler", "guider"] + input_names = [ + "prompt_embeds", + "height", + "width", + "num_frames", + "image_embeds", + "image_condition_latents", + "generator", + "num_inference_steps", + ] + + @staticmethod + def init_pipeline(*, components_manager): + if init_observer is not None: + init_observer() + if init_mutation is not None: + init_mutation(manager, values) + return FakePipeline() + + config = { + "params": { + "embeddings": {"type": "embeddings"}, + "height": {"type": "int", "min": 1, "max": 8192}, + "width": {"type": "int", "min": 1, "max": 8192}, + "num_frames": {"type": "int", "min": 1, "max": 480}, + "seed": {"type": "int", "min": 0, "max": 4294967295}, + "num_inference_steps": {"type": "int", "min": 1, "max": 1000}, + "image_embeds": {"type": "image_embeds"}, + "image_condition_latents": {"type": "video_condition_latents"}, + "unet": {"type": "diffusers_auto_model"}, + "vae": {"type": "diffusers_auto_model"}, + "scheduler": {"type": "diffusers_scheduler"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + ROUTE_STATE_OUTPUT: {"type": "modular_route_state"}, + }, + "model_input_names": ["unet", "vae", "guider", "scheduler"], + "input_names": [ + "embeddings", + "height", + "width", + "num_frames", + "seed", + "num_inference_steps", + "image_embeds", + "image_condition_latents", + "unet", + "vae", + "scheduler", + ROUTE_STATE_INPUT, + ], + "output_names": ["latents", ROUTE_STATE_OUTPUT, "doc"], + } + + def managed(*, ids, return_dict_with_names=True): + return {"transformer": manager["transformer"], "scheduler": scheduler} + + def resolve(_components, _payload, *, label): + return manager["vae"] if "VAE" in label else manager["transformer"] + + node = Denoise("wan-denoise-action") + node.progress = Mock() + kwargs = { + "unet": values["outputs"]["unet_out"], + "vae": values["outputs"]["vae_out"], + "scheduler": values["outputs"]["scheduler"], + "embeddings": {"prompt_embeds": torch.zeros((1, 4, 8))}, + "height": "480", + "width": "832", + "num_frames": "5", + "seed": "7", + "num_inference_steps": "2", + "image_embeds": values["image_embeds"], + "image_condition_latents": values["image_condition_latents"], + ROUTE_STATE_INPUT: values["route"], + } + kwargs.update(overrides or {}) + with ( + patch( + "modules.ModularDiffusers.denoise.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch( + "modules.ModularDiffusers.denoise.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch("modules.ModularDiffusers.denoise.resolve_managed_component_by_id", side_effect=resolve), + patch("modules.ModularDiffusers.denoise.collect_model_ids", return_value=["wan-transformer", "wan-scheduler"]), + patch("modules.ModularDiffusers.denoise.components.get_components_by_ids", side_effect=managed), + patch("modules.ModularDiffusers.denoise.insert_preview_block"), + ): + result = node(**kwargs) if use_cache else node.execute(**kwargs) + if use_cache: + cached = node(**dict(kwargs)) + self.assertIs(result, cached) + return result, pipeline_calls, values + + def test_denoise_uses_second_pass_dimensions_and_replays_canonical_strings(self): + result, calls, values = self._run_denoise(use_cache=True) + self.assertEqual(len(calls), 1) + self.assertEqual((calls[0]["height"], calls[0]["width"]), (864, 432)) + self.assertEqual(calls[0]["num_frames"], 5) + self.assertIs(calls[0]["image_embeds"], values["image_embeds"]) + self.assertIs(calls[0]["image_condition_latents"], values["image_condition_latents"]) + self.assertIsNotNone(result[ROUTE_STATE_OUTPUT]) + + def test_denoise_init_call_manager_config_and_tensor_mutations_publish_no_route(self): + mutations = ( + ("init-vae", lambda manager, _values: manager.__setitem__("vae", _WanVae()), "init"), + ("call-vae", lambda manager, _values: manager.__setitem__("vae", _WanVae()), "call"), + ("transformer", lambda manager, _values: setattr(manager["transformer"].config, "out_channels", 15), "call"), + ("condition", lambda _manager, values: values["image_condition_latents"].add_(1), "call"), + ("embeds", lambda _manager, values: values["image_embeds"].add_(1), "call"), + ) + for label, mutation, stage in mutations: + with self.subTest(label=label), self.assertRaises(ValueError): + self._run_denoise( + init_mutation=(mutation if stage == "init" else None), + call_mutation=(mutation if stage == "call" else None), + ) + + def _run_decode( + self, + *, + init_mutation=None, + call_mutation=None, + use_cache=False, + cache_processor_swap=False, + precall_processor_swap=False, + execution_device="cpu", + ): + values = _issue_decode_values() + manager = {"vae": values["vae"]} + processor = _VideoProcessor() + pipeline_calls = [] + + class FakePipeline: + blocks = SimpleNamespace(doc="decode") + + def __init__(self): + self._execution_device = torch.device(execution_device) + self.vae = None + self.video_processor = processor + + def update_components(self, **components): + for name, component in components.items(): + setattr(self, name, component) + + def __call__(self, **kwargs): + pipeline_calls.append(dict(kwargs)) + if call_mutation is not None: + call_mutation(self, manager, processor) + return {"videos": [[Image.new("RGB", (8, 8))]]} + + class FakeBlocks: + component_names = ["vae", "video_processor"] + input_names = ["latents", "output_type"] + doc = "decode" + + @staticmethod + def init_pipeline(*, components_manager): + if init_mutation is not None: + init_mutation(manager, processor) + return FakePipeline() + + config = { + "params": { + "latents": {"type": "latents"}, + "output_type": {"type": "dropdown", "default": "pil", "options": ["np", "pil"]}, + "vae": {"type": "diffusers_auto_model"}, + ROUTE_STATE_INPUT: {"type": "modular_route_state"}, + "videos": {"type": "video"}, + }, + "model_input_names": ["vae"], + "input_names": ["latents", ROUTE_STATE_INPUT, "output_type", "vae"], + "output_names": ["videos", "doc"], + } + + def managed(*, ids, return_dict_with_names=True): + return {"vae": manager["vae"]} + + node = DecodeLatents("wan-decode-action") + kwargs = { + "vae": values["outputs"]["vae_out"], + "latents": values["latents"], + "output_type": "pil", + ROUTE_STATE_INPUT: values["route"], + } + cache_equal = None + real_consume_decode = consume_decode_route_state + consume_calls = 0 + + def consume_with_optional_swap(*args, **kwargs): + nonlocal consume_calls + result = real_consume_decode(*args, **kwargs) + consume_calls += 1 + if precall_processor_swap and consume_calls == 2: + node._pipeline.video_processor = _VideoProcessor() + return result + + with ( + patch( + "modules.ModularDiffusers.latents.pipeline_class_from_runtime_inputs", + return_value=diffusers.WanImage2VideoModularPipeline, + ), + patch( + "modules.ModularDiffusers.latents.require_modiff_node_contract", + return_value=(FakeBlocks(), config), + ), + patch( + "modules.ModularDiffusers.latents.resolve_managed_component_by_id", + side_effect=lambda *_args, **_kwargs: manager["vae"], + ), + patch("modules.ModularDiffusers.latents.collect_model_ids", return_value=["wan-vae"]), + patch("modules.ModularDiffusers.latents.components.get_components_by_ids", side_effect=managed), + patch( + "modules.ModularDiffusers.latents.consume_decode_route_state", + side_effect=consume_with_optional_swap, + ), + ): + result = node(**kwargs) if use_cache else node.execute(**kwargs) + if use_cache: + if cache_processor_swap: + node._pipeline.video_processor = _VideoProcessor() + cache_equal = node._cache_params_equal(kwargs, dict(kwargs)) + else: + cached = node(**dict(kwargs)) + self.assertIs(result, cached) + return result, pipeline_calls, node, cache_equal + + def test_decode_cache_replay_binds_exact_output_processor_identity(self): + result, calls, _node, _cache_equal = self._run_decode(use_cache=True) + self.assertIsNotNone(result["videos"]) + self.assertEqual(len(calls), 1) + + _result, calls, _node, cache_equal = self._run_decode( + use_cache=True, + cache_processor_swap=True, + ) + self.assertFalse(cache_equal) + self.assertEqual(len(calls), 1) + + def test_decode_init_call_processor_vae_and_device_swaps_publish_no_output(self): + mutations = ( + ("init-vae", lambda manager, _processor: manager.__setitem__("vae", _WanVae()), "init"), + ( + "call-vae", + lambda _pipeline, manager, _processor: manager.__setitem__("vae", _WanVae()), + "call", + ), + ( + "processor-identity", + lambda pipeline, _manager, _processor: setattr(pipeline, "video_processor", _VideoProcessor()), + "call", + ), + ( + "processor-config", + lambda _pipeline, _manager, processor: processor.config.__setitem__("do_normalize", False), + "call", + ), + ( + "vae-config", + lambda _pipeline, manager, _processor: manager["vae"].config.latents_std.__setitem__(0, 1.0), + "call", + ), + ) + for label, mutation, stage in mutations: + with self.subTest(label=label), self.assertRaises(ValueError): + self._run_decode( + init_mutation=(mutation if stage == "init" else None), + call_mutation=(mutation if stage == "call" else None), + ) + with self.assertRaisesRegex(ValueError, "changed before upstream execution"): + self._run_decode(precall_processor_swap=True) + with self.assertRaisesRegex(ValueError, "Decode execution device"): + self._run_decode(execution_device="cuda") + + def test_route_scalars_reject_bool_fraction_and_leading_zero_before_denoise_call(self): + for invalid in (True, "480.0", "0480"): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "height"): + self._run_denoise(overrides={"height": invalid}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_modular_workflow_truth.py b/tests/test_modular_workflow_truth.py new file mode 100644 index 0000000..bbb60f7 --- /dev/null +++ b/tests/test_modular_workflow_truth.py @@ -0,0 +1,753 @@ +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +import diffusers + +import modules as module_registry +from modiff.diffusers_profiles import public_execution_profiles, public_experimental_pipelines +from modiff.modular_workflow_contracts import ( + PINNED_DIFFUSERS_REVISION, + PINNED_MODULAR_WORKFLOW_TRUTH, +) +from modiff.server import WebServer +from modules.ModularDiffusers.loaders import ModelsLoader +from modules.ModularDiffusers.modular_utils import ( + get_all_model_types, + get_model_type_metadata, + require_modiff_node_contract, +) + + +MODULAR_BACKEND_PATH = "modules.ModularDiffusers.ModelsLoader" + + +def _pipeline_blocks(model_type): + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class = getattr(diffusers, model_type) + constructor_config = dict(truth.constructor_config) + pipeline = pipeline_class(config_dict=constructor_config) if constructor_config else pipeline_class() + return pipeline_class, pipeline.blocks + + +def _advertised_modular_modes(): + advertised = {} + for capability in public_experimental_pipelines(): + if capability.get("executionKind") != "modular": + continue + advertised.setdefault(capability["modelType"], set()).update(capability["runnableModes"]) + for profile in public_execution_profiles(): + if profile["backend_path"] != MODULAR_BACKEND_PATH: + continue + advertised.setdefault(profile["pipeline_class"], set()).update(profile["modes"]) + return advertised + + +class ModularWorkflowTruthTests(unittest.TestCase): + def test_matrix_covers_all_eleven_registered_pipelines(self): + registered = set(get_all_model_types()) - {"", "DummyCustomPipeline"} + self.assertEqual(len(PINNED_MODULAR_WORKFLOW_TRUTH), 11) + self.assertEqual(set(PINNED_MODULAR_WORKFLOW_TRUTH), registered) + self.assertEqual(PINNED_DIFFUSERS_REVISION, "13a7bee4878d62fccc8d25f97e480e68de96fa03") + dependency_contract = Path("pyproject.toml").read_text(encoding="utf-8") + self.assertIn( + f"diffusers.git@{PINNED_DIFFUSERS_REVISION}", + dependency_contract, + "A Diffusers pin update requires an explicit review of the Modular workflow truth matrix.", + ) + + def test_pinned_workflow_maps_and_fixed_sequences_are_exact(self): + for model_type, truth in PINNED_MODULAR_WORKFLOW_TRUTH.items(): + with self.subTest(model_type=model_type): + _pipeline_class, blocks = _pipeline_blocks(model_type) + self.assertEqual(type(blocks).__name__, truth.blocks_class) + + if truth.workflows: + self.assertFalse(truth.fixed_block_sequence) + expected = {workflow.name: workflow.required_inputs for workflow in truth.workflows} + actual_map = blocks._workflow_map + self.assertIsNotNone(actual_map) + actual = { + name: frozenset(input_name for input_name, required in inputs.items() if required) + for name, inputs in actual_map.items() + } + self.assertEqual(actual, expected) + self.assertEqual(set(blocks.available_workflows), set(expected)) + for workflow_name, required_inputs in expected.items(): + workflow = blocks.get_workflow(workflow_name) + self.assertTrue(tuple(workflow.block_names)) + self.assertTrue(set(required_inputs).issubset(workflow.input_names)) + self.assertTrue(workflow.output_names) + else: + self.assertTrue(truth.fixed_block_sequence) + self.assertIsNone(blocks._workflow_map) + with self.assertRaises(NotImplementedError): + _ = blocks.available_workflows + self.assertEqual(tuple(blocks.block_names), truth.fixed_block_sequence) + + def test_flux_qwen_and_wan_advertised_modular_modes_are_exact(self): + expected = { + "StableDiffusionXLModularPipeline": { + "text_to_image", + "image_to_image", + "control_image", + "inpaint", + }, + "QwenImageModularPipeline": {"control_image"}, + "QwenImageEditModularPipeline": {"edit_image"}, + "QwenImageEditPlusModularPipeline": {"edit_image", "multi_image_reference_edit"}, + "QwenImageLayeredModularPipeline": {"layer_decomposition"}, + "FluxModularPipeline": {"text_to_image", "image_to_image"}, + "ZImageModularPipeline": {"text_to_image"}, + "WanModularPipeline": {"text_to_video"}, + "WanImage2VideoModularPipeline": {"image_to_video"}, + } + self.assertEqual(_advertised_modular_modes(), expected) + self.assertEqual( + {model_type: set(dict(truth.modes)) for model_type, truth in PINNED_MODULAR_WORKFLOW_TRUTH.items()}, + { + model_type: expected.get(model_type, set()) + for model_type in PINNED_MODULAR_WORKFLOW_TRUTH + }, + ) + self.assertNotIn("FluxKontextModularPipeline", expected) + self.assertNotIn("Flux2KleinModularPipeline", expected) + + def test_every_advertised_mode_has_a_constructible_action_and_state_contract(self): + for model_type, modes in _advertised_modular_modes().items(): + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class, _blocks = _pipeline_blocks(model_type) + metadata = get_model_type_metadata(model_type) + self.assertIsNotNone(metadata) + + for mode in sorted(modes): + with self.subTest(model_type=model_type, mode=mode): + mode_truth = truth.mode(mode) + self.assertIsNotNone(mode_truth, f"{model_type}:{mode} has no reviewed MoDiff mode contract") + + if mode_truth.upstream_workflow is not None: + workflows = {workflow.name: workflow for workflow in truth.workflows} + self.assertIn(mode_truth.upstream_workflow, workflows) + self.assertEqual( + mode_truth.required_upstream_inputs, + workflows[mode_truth.upstream_workflow].required_inputs, + ) + else: + self.assertTrue(truth.fixed_block_sequence) + + action_contracts = {} + for action in mode_truth.action_sequence: + action_contract = metadata["node_params"].get(action) + self.assertIsNotNone(action_contract, f"{model_type}:{mode} lacks action {action}") + action_contracts[action] = action_contract + blocks, resolved_contract = require_modiff_node_contract( + pipeline_class, + action, + require_blocks=not ( + action == "controlnet" + and action_contract.get("block_name") is None + ), + ) + self.assertIsNotNone(resolved_contract) + if action_contract.get("block_name") is not None: + self.assertIsNotNone(blocks) + + action_inputs = { + input_name + for contract in action_contracts.values() + for input_name in contract["input_names"] + } + self.assertTrue(mode_truth.required_upstream_inputs.issubset(action_inputs)) + + for edge in mode_truth.state_edges: + self.assertIn(edge.producer_action, action_contracts) + self.assertIn(edge.consumer_action, action_contracts) + self.assertIn( + edge.producer_output, + action_contracts[edge.producer_action]["output_names"], + ) + self.assertIn( + edge.consumer_input, + action_contracts[edge.consumer_action]["input_names"], + ) + + def test_sdxl_public_mode_edges_are_route_aware_and_exact(self): + truth = PINNED_MODULAR_WORKFLOW_TRUTH["StableDiffusionXLModularPipeline"] + expected_edges = { + "text_to_image": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "image_to_image": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "control_image": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "inpaint": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "mask", "denoise", "mask"), + ("vae_encoder", "masked_image_latents", "denoise", "masked_image_latents"), + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + } + + self.assertEqual(set(dict(truth.modes)), set(expected_edges)) + for name, mode in truth.modes: + with self.subTest(mode=name): + actual_edges = tuple( + ( + edge.producer_action, + edge.producer_output, + edge.consumer_action, + edge.consumer_input, + ) + for edge in mode.state_edges + ) + self.assertEqual(actual_edges, expected_edges[name]) + + def test_sdxl_base_inpaint_state_flow_is_exact_constructible_and_contract_only( + self, + ): + model_type = "StableDiffusionXLModularPipeline" + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class, blocks = _pipeline_blocks(model_type) + metadata = get_model_type_metadata(model_type) + workflows = {workflow.name: workflow for workflow in truth.workflows} + state_flow = truth.state_flow("inpainting") + + self.assertEqual( + set(dict(truth.state_flows)), + { + "inpainting", + "controlnet_image2image", + "controlnet_inpainting", + "controlnet_union_image2image", + "controlnet_union_inpainting", + "ip_adapter_text2image", + "ip_adapter_image2image", + "ip_adapter_inpainting", + "ip_adapter_controlnet_text2image", + "ip_adapter_controlnet_image2image", + "ip_adapter_controlnet_inpainting", + "ip_adapter_controlnet_union_text2image", + "ip_adapter_controlnet_union_image2image", + "ip_adapter_controlnet_union_inpainting", + }, + ) + self.assertEqual( + set(dict(truth.modes)), + {"text_to_image", "image_to_image", "control_image", "inpaint"}, + ) + self.assertEqual( + _advertised_modular_modes()[model_type], + {"text_to_image", "image_to_image", "control_image", "inpaint"}, + ) + inpaint_mode = truth.mode("inpaint") + self.assertIsNotNone(inpaint_mode) + self.assertEqual(inpaint_mode.upstream_workflow, "inpainting") + self.assertEqual(inpaint_mode.required_upstream_inputs, state_flow.required_upstream_inputs) + self.assertEqual(inpaint_mode.upstream_block_sequence, state_flow.upstream_block_sequence) + self.assertEqual(inpaint_mode.action_sequence, state_flow.action_sequence) + self.assertEqual(inpaint_mode.state_edges, state_flow.state_edges) + self.assertIsNotNone(state_flow) + self.assertEqual(state_flow.upstream_workflow, "inpainting") + self.assertEqual( + state_flow.required_upstream_inputs, + frozenset({"mask_image", "image", "prompt"}), + ) + self.assertEqual(state_flow.required_upstream_inputs, workflows["inpainting"].required_inputs) + self.assertEqual( + state_flow.upstream_block_sequence, + ( + "text_encoder", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.denoise", + "decode", + ), + ) + self.assertEqual( + state_flow.action_sequence, + ("text_encoder", "vae_encoder", "denoise", "decoder"), + ) + + workflow = blocks.get_workflow("inpainting") + self.assertEqual(tuple(workflow.block_names), state_flow.upstream_block_sequence) + self.assertTrue(state_flow.required_upstream_inputs.issubset(workflow.input_names)) + for output_name in ( + "image_latents", + "mask", + "masked_image_latents", + "crops_coords", + "latents", + "images", + ): + self.assertIn(output_name, workflow.output_names) + + action_contracts = {} + action_blocks = {} + for action in state_flow.action_sequence: + action_contract = metadata["node_params"].get(action) + self.assertIsNotNone( + action_contract, + f"{model_type}:inpainting lacks action {action}", + ) + action_contracts[action] = action_contract + resolved_blocks, resolved_contract = require_modiff_node_contract( + pipeline_class, + action, + ) + self.assertIsNotNone(resolved_blocks) + self.assertIsNotNone(resolved_contract) + action_blocks[action] = resolved_blocks + + denoise_contract = action_contracts["denoise"] + decoder_contract = action_contracts["decoder"] + self.assertIn("vae", denoise_contract["model_input_names"]) + self.assertIn("vae", action_blocks["denoise"].component_names) + self.assertEqual( + denoise_contract["params"]["vae"]["type"], + decoder_contract["params"]["vae"]["type"], + ) + self.assertEqual( + denoise_contract["params"]["vae"]["type"], + ModelsLoader.params["vae_out"]["type"], + ) + self.assertEqual(denoise_contract["params"]["vae"]["display"], "input") + self.assertEqual(ModelsLoader.params["vae_out"]["display"], "output") + + action_inputs = { + input_name + for contract in action_contracts.values() + for input_name in contract["input_names"] + } + self.assertTrue(state_flow.required_upstream_inputs.issubset(action_inputs)) + + expected_edges = ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "mask", "denoise", "mask"), + ("vae_encoder", "masked_image_latents", "denoise", "masked_image_latents"), + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ) + actual_edges = tuple( + ( + edge.producer_action, + edge.producer_output, + edge.consumer_action, + edge.consumer_input, + ) + for edge in state_flow.state_edges + ) + self.assertEqual(actual_edges, expected_edges) + + for edge in state_flow.state_edges: + self.assertIn( + edge.producer_output, + action_contracts[edge.producer_action]["output_names"], + ) + self.assertIn( + edge.consumer_input, + action_contracts[edge.consumer_action]["input_names"], + ) + + for field_name, field_type in ( + ("mask", "latent_mask"), + ("masked_image_latents", "masked_latents"), + ): + producer_param = action_contracts["vae_encoder"]["params"][field_name] + consumer_param = action_contracts["denoise"]["params"][field_name] + self.assertEqual(producer_param["display"], "output") + self.assertEqual(consumer_param["display"], "input") + self.assertEqual(producer_param["type"], field_type) + self.assertEqual(consumer_param["type"], field_type) + + def test_sdxl_controlnet_vae_state_flows_are_exact_constructible_and_nonadvertised(self): + model_type = "StableDiffusionXLModularPipeline" + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class, blocks = _pipeline_blocks(model_type) + metadata = get_model_type_metadata(model_type) + workflows = {workflow.name: workflow for workflow in truth.workflows} + expected = { + "controlnet_image2image": { + "inputs": frozenset({"control_image", "image", "prompt"}), + "vae_edges": ("image_latents",), + }, + "controlnet_inpainting": { + "inputs": frozenset({"control_image", "mask_image", "image", "prompt"}), + "vae_edges": ("image_latents", "mask", "masked_image_latents"), + }, + "controlnet_union_image2image": { + "inputs": frozenset({"control_image", "control_mode", "image", "prompt"}), + "vae_edges": ("image_latents",), + }, + "controlnet_union_inpainting": { + "inputs": frozenset({"control_image", "control_mode", "mask_image", "image", "prompt"}), + "vae_edges": ("image_latents", "mask", "masked_image_latents"), + }, + } + expected_blocks = ( + "text_encoder", + "vae_encoder", + "denoise.input", + "denoise.before_denoise.set_timesteps", + "denoise.before_denoise.prepare_latents", + "denoise.before_denoise.prepare_add_cond", + "denoise.controlnet_input", + "denoise.denoise", + "decode", + ) + + self.assertEqual( + _advertised_modular_modes()[model_type], + {"text_to_image", "image_to_image", "control_image", "inpaint"}, + ) + for name, contract in expected.items(): + with self.subTest(state_flow=name): + state_flow = truth.state_flow(name) + self.assertIsNotNone(state_flow) + self.assertEqual(state_flow.required_upstream_inputs, contract["inputs"]) + self.assertEqual(state_flow.required_upstream_inputs, workflows[name].required_inputs) + self.assertEqual(state_flow.upstream_block_sequence, expected_blocks) + self.assertEqual( + state_flow.action_sequence, + ("text_encoder", "vae_encoder", "controlnet", "denoise", "decoder"), + ) + workflow = blocks.get_workflow(name) + self.assertEqual(tuple(workflow.block_names), expected_blocks) + self.assertTrue(contract["inputs"].issubset(workflow.input_names)) + + action_contracts = {} + for action in state_flow.action_sequence: + resolved_blocks, resolved_contract = require_modiff_node_contract( + pipeline_class, + action, + require_blocks=(action != "controlnet"), + ) + self.assertIsNotNone(resolved_contract) + self.assertEqual(resolved_contract, metadata["node_params"][action]) + if action == "controlnet": + self.assertIsNone(resolved_blocks) + else: + self.assertIsNotNone(resolved_blocks) + action_contracts[action] = resolved_contract + + edge_names = { + (edge.producer_action, edge.producer_output, edge.consumer_action, edge.consumer_input) + for edge in state_flow.state_edges + } + for vae_output in contract["vae_edges"]: + self.assertIn(("vae_encoder", vae_output, "denoise", vae_output), edge_names) + self.assertIn( + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + edge_names, + ) + self.assertIn( + ("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + edge_names, + ) + for edge in state_flow.state_edges: + self.assertIn( + edge.producer_output, + action_contracts[edge.producer_action]["output_names"], + ) + self.assertIn( + edge.consumer_input, + action_contracts[edge.consumer_action]["input_names"], + ) + + def test_sdxl_ip_adapter_state_flows_match_all_nine_pinned_upstream_compositions(self): + model_type = "StableDiffusionXLModularPipeline" + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class, blocks = _pipeline_blocks(model_type) + metadata = get_model_type_metadata(model_type) + workflows = {workflow.name: workflow for workflow in truth.workflows} + expected_names = { + "ip_adapter_text2image", + "ip_adapter_image2image", + "ip_adapter_inpainting", + "ip_adapter_controlnet_text2image", + "ip_adapter_controlnet_image2image", + "ip_adapter_controlnet_inpainting", + "ip_adapter_controlnet_union_text2image", + "ip_adapter_controlnet_union_image2image", + "ip_adapter_controlnet_union_inpainting", + } + actual = {name: flow for name, flow in truth.state_flows if name.startswith("ip_adapter_")} + self.assertEqual(set(actual), expected_names) + self.assertEqual( + _advertised_modular_modes()[model_type], + {"text_to_image", "image_to_image", "control_image", "inpaint"}, + ) + + for name, state_flow in actual.items(): + with self.subTest(state_flow=name): + self.assertEqual(state_flow.required_upstream_inputs, workflows[name].required_inputs) + workflow = blocks.get_workflow(name) + self.assertEqual(tuple(workflow.block_names), state_flow.upstream_block_sequence) + self.assertIn("ip_adapter", workflow.block_names) + self.assertIn("ip_adapter", state_flow.action_sequence) + + action_contracts = {} + for action in state_flow.action_sequence: + action_contract = metadata["node_params"].get(action) + self.assertIsNotNone(action_contract) + action_contracts[action] = action_contract + resolved_blocks, resolved_contract = require_modiff_node_contract( + pipeline_class, + action, + require_blocks=action != "controlnet", + ) + self.assertIsNotNone(resolved_contract) + if action == "controlnet": + self.assertIsNone(resolved_blocks) + else: + self.assertIsNotNone(resolved_blocks) + + edges = { + (edge.producer_action, edge.producer_output, edge.consumer_action, edge.consumer_input) + for edge in state_flow.state_edges + } + self.assertIn(("ip_adapter", "ip_adapter", "denoise", "ip_adapter"), edges) + if "controlnet" in state_flow.action_sequence: + self.assertIn(("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), edges) + if "vae_encoder" in state_flow.action_sequence: + self.assertIn(("vae_encoder", "route_state_out", "denoise", "route_state_in"), edges) + for edge in state_flow.state_edges: + self.assertIn(edge.producer_output, action_contracts[edge.producer_action]["output_names"]) + self.assertIn(edge.consumer_input, action_contracts[edge.consumer_action]["input_names"]) + + ip_contract = metadata["node_params"]["ip_adapter"] + self.assertEqual(ip_contract["params"]["ip_adapter_image"]["type"], "image") + self.assertEqual(ip_contract["params"]["ip_adapter"]["type"], "custom_ip_adapter") + self.assertEqual(ip_contract["params"]["ip_adapter"]["display"], "output") + self.assertEqual(metadata["node_params"]["denoise"]["params"]["ip_adapter"]["display"], "input") + + def test_qwen_state_flows_are_exact_constructible_and_nonadvertised(self): + model_type = "QwenImageModularPipeline" + truth = PINNED_MODULAR_WORKFLOW_TRUTH[model_type] + pipeline_class, blocks = _pipeline_blocks(model_type) + metadata = get_model_type_metadata(model_type) + workflows = {workflow.name: workflow for workflow in truth.workflows} + expected_names = { + "image2image", + "inpainting", + "controlnet_image2image", + "controlnet_inpainting", + } + + self.assertEqual(set(dict(truth.state_flows)), expected_names) + self.assertEqual(set(dict(truth.modes)), {"control_image"}) + self.assertEqual(_advertised_modular_modes()[model_type], {"control_image"}) + + for name, state_flow in truth.state_flows: + with self.subTest(state_flow=name): + self.assertEqual(name, state_flow.upstream_workflow) + self.assertIn(name, workflows) + self.assertEqual(state_flow.required_upstream_inputs, workflows[name].required_inputs) + + workflow = blocks.get_workflow(name) + self.assertEqual(tuple(workflow.block_names), state_flow.upstream_block_sequence) + self.assertTrue(state_flow.required_upstream_inputs.issubset(workflow.input_names)) + self.assertIn("latents", workflow.output_names) + self.assertIn("images", workflow.output_names) + + action_contracts = {} + for action in state_flow.action_sequence: + action_contract = metadata["node_params"].get(action) + self.assertIsNotNone(action_contract, f"{model_type}:{name} lacks action {action}") + action_contracts[action] = action_contract + resolved_blocks, resolved_contract = require_modiff_node_contract( + pipeline_class, + action, + require_blocks=not (action == "controlnet" and action_contract.get("block_name") is None), + ) + self.assertIsNotNone(resolved_contract) + if action_contract.get("block_name") is not None: + self.assertIsNotNone(resolved_blocks) + + action_inputs = { + input_name + for contract in action_contracts.values() + for input_name in contract["input_names"] + } + self.assertTrue(state_flow.required_upstream_inputs.issubset(action_inputs)) + + for edge in state_flow.state_edges: + self.assertIn(edge.producer_action, action_contracts) + self.assertIn(edge.consumer_action, action_contracts) + self.assertIn(edge.producer_output, action_contracts[edge.producer_action]["output_names"]) + self.assertIn(edge.consumer_input, action_contracts[edge.consumer_action]["input_names"]) + + is_inpaint = name.endswith("inpainting") or name == "inpainting" + is_control = name.startswith("controlnet_") + self.assertEqual("processed_mask_image" in workflow.output_names, is_inpaint) + self.assertEqual("mask_overlay_kwargs" in workflow.output_names, is_inpaint) + self.assertEqual("mask" in workflow.output_names, is_inpaint) + self.assertEqual("control_image_latents" in workflow.output_names, is_control) + + def test_qwen_state_flow_edges_are_exact_and_ordered(self): + truth = PINNED_MODULAR_WORKFLOW_TRUTH["QwenImageModularPipeline"] + expected_edges = { + "image2image": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "inpainting": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "controlnet_image2image": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "route_state_out", "controlnet", "route_state_in"), + ("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + ("controlnet", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + "controlnet_inpainting": ( + ("text_encoder", "embeddings", "denoise", "embeddings"), + ("vae_encoder", "image_latents", "denoise", "image_latents"), + ("vae_encoder", "route_state_out", "controlnet", "route_state_in"), + ("controlnet", "controlnet_bundle", "denoise", "controlnet_bundle"), + ("controlnet", "route_state_out", "denoise", "route_state_in"), + ("denoise", "latents", "decoder", "latents"), + ("denoise", "route_state_out", "decoder", "route_state_in"), + ), + } + + self.assertEqual(len(truth.state_flows), 4) + self.assertEqual( + {name for name, _state_flow in truth.state_flows}, + {"image2image", "inpainting", "controlnet_image2image", "controlnet_inpainting"}, + ) + for name, state_flow in truth.state_flows: + with self.subTest(state_flow=name): + actual_edges = tuple( + ( + edge.producer_action, + edge.producer_output, + edge.consumer_action, + edge.consumer_input, + ) + for edge in state_flow.state_edges + ) + self.assertEqual(actual_edges, expected_edges[name]) + + def test_completed_sdxl_inpaint_is_contract_only_while_false_flux_claims_remain_unsupported(self): + experimental = {item["modelType"]: item for item in public_experimental_pipelines()} + + sdxl = experimental["StableDiffusionXLModularPipeline"] + self.assertIn("inpaint", sdxl["runnableModes"]) + self.assertNotIn("inpaint", sdxl["unsupportedModes"]) + self.assertEqual(sdxl["qualificationStatus"], "contract_only") + self.assertEqual(sdxl["defaultRepo"], "stabilityai/stable-diffusion-xl-base-1.0") + self.assertEqual(sdxl["revisionCandidates"], ["462165984030d82259a11f4367a4eed129e94a7b"]) + self.assertFalse(sdxl["autoEligible"]) + self.assertFalse(sdxl["templateEligible"]) + self.assertFalse(sdxl["galleryEligible"]) + self.assertEqual( + sdxl["inputContracts"]["inpaint"]["requiredImages"], + ["referenceImages", "maskImage"], + ) + + flux = experimental["FluxModularPipeline"] + self.assertNotIn("control_image", flux["runnableModes"]) + self.assertIn("control_image", flux["unsupportedModes"]) + + self.assertEqual(PINNED_MODULAR_WORKFLOW_TRUTH["FluxKontextModularPipeline"].modes, ()) + self.assertEqual(PINNED_MODULAR_WORKFLOW_TRUTH["Flux2KleinModularPipeline"].modes, ()) + + def test_flux2_klein_legacy_model_type_publishes_only_the_standard_execution_path(self): + flux2 = { + item["modelType"]: item for item in public_experimental_pipelines() + }["Flux2KleinModularPipeline"] + + self.assertEqual(flux2["modelType"], "Flux2KleinModularPipeline") + self.assertEqual(flux2["label"], "FLUX.2 Klein (Standard Diffusers)") + self.assertEqual(flux2["executionKind"], "standard") + self.assertEqual(flux2["executionModelType"], "Flux2KleinPipeline") + self.assertEqual(flux2["pipelineClasses"], ["Flux2KleinPipeline"]) + self.assertEqual(flux2["backendPath"], "modules.DiffusersImage.LoadPipeline") + self.assertEqual(len(flux2["executionProfiles"]), 1) + profile = flux2["executionProfiles"][0] + self.assertEqual(profile["id"], "flux2-klein:direct") + self.assertEqual(profile["model_type"], "Flux2KleinPipeline") + self.assertEqual(flux2["runnableModes"], profile["modes"]) + self.assertNotIn("Flux2KleinModularPipeline", flux2["pipelineClasses"]) + + def test_dangling_experimental_profile_reference_fails_closed(self): + invalid = { + "modelType": "LegacyModularPipeline", + "label": "Invalid fixture", + "mediaKind": "image", + "pipelineClasses": ["LegacyModularPipeline"], + "backendPath": MODULAR_BACKEND_PATH, + "executionKind": "standard", + "executionProfileIds": ["missing:profile"], + "runnableModes": ["text_to_image"], + } + with patch("modiff.diffusers_profiles.EXPERIMENTAL_DIFFUSERS_PIPELINES", [invalid]): + capability = public_experimental_pipelines()[0] + + self.assertEqual(capability["executionProfiles"], []) + self.assertEqual(capability["pipelineClasses"], []) + self.assertEqual(capability["runnableModes"], []) + self.assertIsNone(capability["backendPath"]) + self.assertEqual(capability["qualificationStatus"], "invalid_contract") + + +class FakeRequest: + query = {} + + +class ModularWorkflowCapabilitySerializationTests(unittest.IsolatedAsyncioTestCase): + async def test_public_endpoint_serializes_unsupported_and_execution_truth(self): + response = await WebServer(module_registry.MODULE_MAP).model_capabilities(FakeRequest()) + payload = json.loads(response.text) + experimental = {item["modelType"]: item for item in payload["experimentalCapabilities"]} + + self.assertEqual(payload["schemaVersion"], 2) + self.assertNotIn("inpaint", experimental["StableDiffusionXLModularPipeline"]["unsupportedModes"]) + self.assertIn("inpaint", experimental["StableDiffusionXLModularPipeline"]["runnableModes"]) + self.assertEqual( + experimental["StableDiffusionXLModularPipeline"]["qualificationStatus"], + "contract_only", + ) + self.assertEqual( + experimental["Flux2KleinModularPipeline"]["pipelineClasses"], + ["Flux2KleinPipeline"], + ) + self.assertEqual( + experimental["Flux2KleinModularPipeline"]["executionProfiles"][0]["id"], + "flux2-klein:direct", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node_base.py b/tests/test_node_base.py index bd7ad12..c1feb13 100644 --- a/tests/test_node_base.py +++ b/tests/test_node_base.py @@ -172,6 +172,93 @@ def execute(self, pipeline): server.execute_node("generate", graph_node, "test", quiet=True) self.assertEqual(consumer.execution_count, 2) + def test_cache_ignored_semantic_change_reuses_resident_output_and_invalidates_consumer(self): + from modiff.server import WebServer + + class ResidentLoader(NodeBase): + cache_ignored_params = frozenset({"mode"}) + + def __init__(self, node_id): + self.execution_count = 0 + self.pipeline = {"mode": None} + super().__init__(node_id) + + def __call__(self, **kwargs): + result = super().__call__(**kwargs) + result["pipeline"]["mode"] = kwargs["mode"] + return result + + def execute(self, mode): + self.execution_count += 1 + return {"pipeline": self.pipeline} + + class ContractConsumer(NodeBase): + def __init__(self, node_id): + self.execution_count = 0 + super().__init__(node_id) + + def execute(self, pipeline): + self.execution_count += 1 + return {"result": pipeline["mode"]} + + module_name = ".".join(ResidentLoader.__module__.split(".")[:-1]) + definition = { + module_name: { + "ResidentLoader": { + "params": { + "mode": {"type": "string", "default": "generate"}, + "pipeline": {"type": "pipeline", "display": "output"}, + } + }, + "ContractConsumer": { + "params": { + "pipeline": {"type": "pipeline", "required": True}, + "result": {"type": "string", "display": "output"}, + } + }, + } + } + graph_node = { + "module": module_name, + "action": "ContractConsumer", + "params": { + "pipeline": { + "sourceId": "loader", + "sourceKey": "pipeline", + } + }, + } + + with patch("modiff.NodeBase._module_map", return_value=definition): + loader = ResidentLoader("loader") + consumer = ContractConsumer("consumer") + server = object.__new__(WebServer) + server.modules = definition + server.node_cache = {"loader": loader, "consumer": consumer} + + first = loader(mode="generate") + server.execute_node("consumer", graph_node, "test", quiet=True) + self.assertTrue(loader._has_changed) + self.assertEqual(loader.execution_count, 1) + self.assertEqual(consumer.output, {"result": "generate"}) + + second = loader(mode="edit") + server.execute_node("consumer", graph_node, "test", quiet=True) + self.assertIs(first["pipeline"], second["pipeline"]) + self.assertTrue(loader._has_changed) + self.assertEqual(loader.execution_count, 1) + self.assertTrue(consumer._has_changed) + self.assertEqual(consumer.execution_count, 2) + self.assertEqual(consumer.output, {"result": "edit"}) + + third = loader(mode="edit") + server.execute_node("consumer", graph_node, "test", quiet=True) + self.assertIs(second["pipeline"], third["pipeline"]) + self.assertFalse(loader._has_changed) + self.assertEqual(loader.execution_count, 1) + self.assertFalse(consumer._has_changed) + self.assertEqual(consumer.execution_count, 2) + def test_typed_numeric_value_matches_string_keyed_option_contract(self): class NumericOptionNode(NodeBase): def execute(self, sample_rate): diff --git a/tests/test_optimization_packages.py b/tests/test_optimization_packages.py index 192c1ed..42968b5 100644 --- a/tests/test_optimization_packages.py +++ b/tests/test_optimization_packages.py @@ -1,13 +1,20 @@ import json +import os +import subprocess import tempfile +import threading import unittest from pathlib import Path +from types import SimpleNamespace from unittest import mock from modiff import optimization_packages as optimizations +from modiff import runtime_overlays class OptimizationPackageTests(unittest.TestCase): + ACTIVE_ENVIRONMENT_ID = "runtime-9-cafebabe" + def setUp(self): self.temporary = tempfile.TemporaryDirectory() root = Path(self.temporary.name) @@ -15,8 +22,11 @@ def setUp(self): mock.patch.object(optimizations, "OPTIMIZATION_ROOT", root), mock.patch.object(optimizations, "ENVIRONMENTS_DIR", root / "environments"), mock.patch.object(optimizations, "STAGING_DIR", root / "staging"), + mock.patch.object(optimizations, "ARTIFACTS_DIR", root / "artifacts"), + mock.patch.object(optimizations, "MANAGED_ROOT", root), mock.patch.object(optimizations, "STATE_PATH", root / "state.json"), mock.patch.object(optimizations, "RECEIPTS_PATH", root / "receipts.json"), + mock.patch.object(optimizations, "PROMOTION_PATH", root / "promotion.json"), ] for patcher in self.path_patchers: patcher.start() @@ -40,6 +50,16 @@ def create_environment(self, environment_id): ) return root + def set_active_environment(self, environment_id): + state = optimizations._default_state() + state["activeEnvironmentId"] = environment_id + state["activeTrustClass"] = "legacy_optimization" + optimizations._write_state(state) + + @staticmethod + def lease(): + return SimpleNamespace(cancel_event=threading.Event()) + def test_catalog_is_profile_gated_and_disabled_by_default(self): catalog = optimizations.public_catalog( runtime_profile={"installed": "amd-rocm-linux"}, @@ -49,47 +69,441 @@ def test_catalog_is_profile_gated_and_disabled_by_default(self): self.assertTrue(by_id["torchao"]["compatible"]) self.assertFalse(by_id["hub_attention_kernels"]["compatible"]) self.assertFalse(by_id["torchao"]["enabled"]) + self.assertFalse(by_id["torchao"]["canInstall"]) + self.assertFalse(by_id["torchao"]["canEnable"]) + self.assertIn("immutable artifact lock", by_id["torchao"]["disabledReason"]) + + def test_locked_requirements_keep_windows_file_hash_out_of_url_path(self): + wheel = optimizations.OPTIMIZATION_ROOT / "demo_pkg-1.0.0-py3-none-any.whl" + wheel.write_bytes(b"wheel") + digest = "a" * 64 + body = optimizations._locked_requirements_body( + [{"distribution": "demo-pkg", "sha256": digest}], + [wheel], + ).decode("utf-8") + self.assertIn("demo-pkg @ file:///", body) + self.assertIn(f" --hash=sha256:{digest}\n", body) + self.assertNotIn(".whl#sha256=", body) + + def test_overlay_installer_never_links_staged_files_to_a_shared_cache(self): + source = Path(optimizations.__file__).read_text(encoding="utf-8") + command = source[source.index('command = [') : source.index('install_result =', source.index('command = ['))] + self.assertIn('"--link-mode",\n "copy",', command) + + @staticmethod + def promotion_inspection(environment_id, *, environment_root, **_kwargs): + candidate = environment_root / environment_id + if not candidate.is_dir(): + return {"status": "repair_required"} + return { + "status": "ready", + "manifest": {"schemaVersion": 2, "id": environment_id}, + "validation": {"schemaVersion": 2, "environmentId": environment_id}, + } + + def test_prepared_promotion_journal_recovers_exact_staged_environment(self): + environment_id = "runtime-9-cafebabe" + staged = optimizations.STAGING_DIR / environment_id + destination = optimizations.ENVIRONMENTS_DIR / environment_id + (staged / "site-packages").mkdir(parents=True) + optimizations.ENVIRONMENTS_DIR.mkdir() + (staged / "site-packages" / "proof.txt").write_text("reviewed", encoding="utf-8") + inspection = self.promotion_inspection( + environment_id, + environment_root=optimizations.STAGING_DIR, + ) + anchor = optimizations._promotion_anchor(inspection) + optimizations._write_promotion_record( + environment_id, + phase="prepared", + anchor=anchor, + ) + lease = runtime_overlays.InstallLease( + token="recovery", + owner_kind="test", + owner_id=environment_id, + cancel_event=threading.Event(), + ) + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", optimizations.MANAGED_ROOT), + mock.patch.object(runtime_overlays, "_ACTIVE_INSTALL", lease), + mock.patch.object( + optimizations, + "_environment_inspection", + side_effect=self.promotion_inspection, + ), + ): + recovered = optimizations._reconcile_promotion(lease) + self.assertEqual(recovered, environment_id) + self.assertTrue(lease.committed) + self.assertFalse(staged.exists()) + self.assertEqual((destination / "site-packages" / "proof.txt").read_text(), "reviewed") + self.assertFalse(optimizations.PROMOTION_PATH.exists()) + + def test_prepared_journal_acknowledges_crash_after_exact_rename(self): + environment_id = "runtime-9-cafebabe" + destination = optimizations.ENVIRONMENTS_DIR / environment_id + destination.mkdir(parents=True) + inspection = self.promotion_inspection( + environment_id, + environment_root=optimizations.ENVIRONMENTS_DIR, + ) + optimizations._write_promotion_record( + environment_id, + phase="prepared", + anchor=optimizations._promotion_anchor(inspection), + ) + lease = self.lease() + with ( + mock.patch.object( + optimizations, + "_environment_inspection", + side_effect=self.promotion_inspection, + ), + mock.patch.object(optimizations, "promote_staged_environment") as promote, + mock.patch.object(runtime_overlays, "MANAGED_ROOT", optimizations.MANAGED_ROOT), + ): + self.assertEqual(optimizations._reconcile_promotion(lease), environment_id) + promote.assert_not_called() + self.assertTrue(destination.is_dir()) + self.assertFalse(optimizations.PROMOTION_PATH.exists()) + + def test_ambiguous_promotion_journal_fails_closed_without_mutation(self): + environment_id = "runtime-9-cafebabe" + staged = optimizations.STAGING_DIR / environment_id + destination = optimizations.ENVIRONMENTS_DIR / environment_id + staged.mkdir(parents=True) + destination.mkdir(parents=True) + inspection = self.promotion_inspection( + environment_id, + environment_root=optimizations.STAGING_DIR, + ) + optimizations._write_promotion_record( + environment_id, + phase="prepared", + anchor=optimizations._promotion_anchor(inspection), + ) + with self.assertRaisesRegex(RuntimeError, "ambiguous filesystem state"): + optimizations._reconcile_promotion(self.lease()) + self.assertTrue(staged.is_dir()) + self.assertTrue(destination.is_dir()) + self.assertTrue(optimizations.PROMOTION_PATH.is_file()) + + def test_malformed_promotion_journal_never_downgrades_to_no_record(self): + optimizations.OPTIMIZATION_ROOT.mkdir(parents=True, exist_ok=True) + optimizations.PROMOTION_PATH.write_text( + json.dumps( + { + "schemaVersion": 1, + "environmentId": "../escape", + "phase": "prepared", + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(RuntimeError, "promotion journal requires repair"): + optimizations._reconcile_promotion(self.lease()) + self.assertTrue(optimizations.PROMOTION_PATH.is_file()) + + def test_legacy_activation_is_unqualified_and_rollback_deactivates_to_base(self): + self.set_active_environment("runtime-1-deadbeef") + with self.assertRaisesRegex(RuntimeError, "unqualified"): + optimizations.activate_environment("runtime-2-feedface") + with ( + mock.patch.object( + optimizations, "reserve_install", side_effect=lambda *_args: self.lease() + ), + mock.patch.object(optimizations, "release_install"), + ): + rolled_back = optimizations.rollback_environment() + self.assertIsNone(rolled_back["state"]["activeEnvironmentId"]) + self.assertTrue(rolled_back["restartRequired"]) + + def test_startup_never_imports_or_inserts_a_legacy_overlay(self): + self.set_active_environment("runtime-1-deadbeef") + original_path = list(optimizations.sys.path) + with ( + mock.patch.object( + optimizations, "reserve_install", side_effect=lambda *_args: self.lease() + ), + mock.patch.object(optimizations, "release_install"), + mock.patch.object( + optimizations, + "_safe_environment_path", + side_effect=AssertionError("legacy overlay must not be inspected for import"), + ) as safe_path, + mock.patch.dict(os.environ, {}, clear=False), + ): + self.assertIsNone(optimizations.activate_runtime_overlay()) + self.assertEqual( + os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS"), "repair_required" + ) + safe_path.assert_not_called() + self.assertEqual(optimizations.sys.path, original_path) + + def test_schema_one_environment_requires_repair(self): + self.create_environment("legacy-v1") + self.assertEqual( + optimizations._environment_inspection("legacy-v1")["status"], + "repair_required", + ) + + def test_existing_corrupt_state_is_recovery_only_and_explicit_rollback_repairs_it(self): + corrupt_documents = { + "malformed": b"{", + "duplicate": b'{"schemaVersion":2,"schemaVersion":2}', + "oversized": b" " * (32 * 1024 * 1024 + 1), + } + for label, body in corrupt_documents.items(): + with self.subTest(label=label): + optimizations.STATE_PATH.write_bytes(body) + self.assertEqual( + optimizations.read_state()["_storageStatus"], "repair_required" + ) + with ( + mock.patch.object( + optimizations, + "reserve_install", + side_effect=lambda *_args: self.lease(), + ), + mock.patch.object(optimizations, "release_install"), + mock.patch.dict(os.environ, {}, clear=False), + ): + self.assertIsNone(optimizations.activate_runtime_overlay()) + self.assertEqual( + os.environ.get("MODIFF_RUNTIME_OVERLAY_STATUS"), + "repair_required", + ) + repaired = optimizations.rollback_environment() + self.assertIsNone(repaired["state"]["activeEnvironmentId"]) + self.assertEqual(optimizations.read_state()["_storageStatus"], "ok") - def test_activation_and_rollback_only_use_validated_environments(self): - self.create_environment("first") - self.create_environment("second") - first = optimizations.activate_environment("first") - self.assertTrue(first["restartRequired"]) - second = optimizations.activate_environment("second") - self.assertEqual(second["state"]["previousEnvironmentId"], "first") - rolled_back = optimizations.rollback_environment() - self.assertEqual(rolled_back["state"]["activeEnvironmentId"], "first") - with self.assertRaises(ValueError): - optimizations.activate_environment("missing") + def test_semantically_noncanonical_state_is_always_recovery_only(self): + active = "runtime-1-deadbeef" + cases = {} + wrong_schema = optimizations._default_state() + wrong_schema["schemaVersion"] = 999 + cases["wrong schema"] = wrong_schema + extra_key = optimizations._default_state() + extra_key["privatePath"] = "C:/private" + cases["extra key"] = extra_key + invalid_id = optimizations._default_state() + invalid_id.update( + {"activeEnvironmentId": "../escape", "activeTrustClass": "legacy_optimization"} + ) + cases["invalid id"] = invalid_id + duplicate_capability = optimizations._default_state() + duplicate_capability["enabledCapabilities"] = ["regional_compile", "regional_compile"] + cases["duplicate capability"] = duplicate_capability + unknown_capability = optimizations._default_state() + unknown_capability["enabledCapabilities"] = ["unknown_capability"] + cases["unknown capability"] = unknown_capability + too_many = optimizations._default_state() + too_many["enabledCapabilities"] = ["regional_compile"] * 65 + cases["too many capabilities"] = too_many + invalid_time = optimizations._default_state() + invalid_time["updatedAt"] = "C:/private/time" + cases["invalid timestamp"] = invalid_time + same_ids = optimizations._default_state() + same_ids.update( + { + "activeEnvironmentId": active, + "previousEnvironmentId": active, + "activeTrustClass": "artifact_locked_optional", + "previousTrustClass": "artifact_locked_optional", + } + ) + cases["same active and previous"] = same_ids + + for label, document in cases.items(): + with self.subTest(label=label): + optimizations.STATE_PATH.write_text(json.dumps(document), encoding="utf-8") + state = optimizations.read_state() + self.assertEqual(state["_storageStatus"], "repair_required") + with mock.patch.dict(os.environ, {}, clear=False): + catalog = optimizations.public_optional_runtime_catalog() + self.assertEqual( + catalog["overlay"]["processLoadStatus"], "repair_required" + ) + with self.assertRaisesRegex(RuntimeError, "corrupt runtime state"): + optimizations.set_capability_enabled("regional_compile", True) + + def test_state_symlink_reset_never_touches_external_target(self): + external = Path(self.temporary.name) / "outside-state.json" + external.write_text("external-sentinel", encoding="utf-8") + try: + optimizations.STATE_PATH.symlink_to(external) + except OSError as exc: + self.skipTest(f"state symlink unavailable: {exc}") + self.assertEqual(optimizations.read_state()["_storageStatus"], "repair_required") + with ( + mock.patch.object( + optimizations, "reserve_install", side_effect=lambda *_args: self.lease() + ), + mock.patch.object(optimizations, "release_install"), + ): + optimizations.rollback_environment() + self.assertEqual(external.read_text(encoding="utf-8"), "external-sentinel") + self.assertFalse(optimizations.STATE_PATH.is_symlink()) + self.assertEqual(optimizations.read_state()["_storageStatus"], "ok") + + def test_state_hardlink_reset_never_overwrites_external_target(self): + external = Path(self.temporary.name) / "outside-hardlink-state.json" + sentinel = json.dumps(optimizations._default_state(), sort_keys=True) + external.write_text(sentinel, encoding="utf-8") + os.link(external, optimizations.STATE_PATH) + self.assertEqual(optimizations.read_state()["_storageStatus"], "repair_required") + with ( + mock.patch.object( + optimizations, "reserve_install", side_effect=lambda *_args: self.lease() + ), + mock.patch.object(optimizations, "release_install"), + ): + optimizations.rollback_environment() + self.assertEqual(external.read_text(encoding="utf-8"), sentinel) + self.assertEqual(optimizations.read_state()["_storageStatus"], "ok") + + @unittest.skipUnless(os.name == "nt", "Windows junction regression") + def test_optimization_root_junction_is_never_read_or_reset(self): + base = Path(self.temporary.name) / "junction-case" + managed = base / "managed" + outside = base / "outside" + linked = managed / "optimizations" + managed.mkdir(parents=True) + outside.mkdir() + sentinel = json.dumps(optimizations._default_state(), sort_keys=True) + (outside / "state.json").write_text(sentinel, encoding="utf-8") + created = subprocess.run( + ["cmd.exe", "/c", "mklink", "/J", str(linked), str(outside)], + capture_output=True, + text=True, + check=False, + ) + if created.returncode != 0: + self.skipTest(f"junction creation unavailable: {created.stderr}") + try: + with ( + mock.patch.object(optimizations, "MANAGED_ROOT", managed), + mock.patch.object(optimizations, "OPTIMIZATION_ROOT", linked), + mock.patch.object(optimizations, "STATE_PATH", linked / "state.json"), + ): + self.assertEqual( + optimizations.read_state()["_storageStatus"], "repair_required" + ) + with ( + mock.patch.object( + optimizations, + "reserve_install", + side_effect=lambda *_args: self.lease(), + ), + mock.patch.object(optimizations, "release_install"), + self.assertRaises(OSError), + ): + optimizations.rollback_environment() + self.assertEqual((outside / "state.json").read_text(encoding="utf-8"), sentinel) + finally: + os.rmdir(linked) + + def test_catalog_prioritizes_active_and_previous_and_redacts_forged_fields(self): + optimizations.ENVIRONMENTS_DIR.mkdir(parents=True) + active = "runtime-1-deadbeef" + previous = "runtime-2-feedface" + state = optimizations._default_state() + state.update( + { + "activeEnvironmentId": active, + "previousEnvironmentId": previous, + "activeTrustClass": "legacy_optimization", + "previousTrustClass": "legacy_optimization", + } + ) + optimizations._write_state(state) + for index in range(40): + (optimizations.ENVIRONMENTS_DIR / f"runtime-{index + 10}-aaaaaaaa").mkdir() + (optimizations.ENVIRONMENTS_DIR / active).mkdir() + (optimizations.ENVIRONMENTS_DIR / previous).mkdir() + + inspection = { + "status": "recorded", + "manifest": { + "trustClass": "legacy_optimization", + "createdAt": "C:/private/path", + "specs": [{"kind": "optimization", "id": "torchao"}], + }, + "validation": { + "status": "C:/private/status", + "validatedAt": "C:/private/time", + "bindingDigest": "C:/private/digest", + }, + } + with ( + mock.patch.object( + optimizations, "_environment_inspection", return_value=inspection + ) as inspect_environment, + mock.patch.object( + optimizations, + "overlay_file_seal_matches", + side_effect=AssertionError("status GET must not hash an overlay"), + ), + mock.patch.object( + optimizations, + "verify_artifact_anchored_overlay", + side_effect=AssertionError("status GET must not hash wheel archives"), + ), + ): + catalog = optimizations.public_catalog( + runtime_profile={"installed": "amd-rocm-linux"}, + hardware={"torch": {"version": "2.9.1+rocm7.2"}}, + ) + optional_catalog = optimizations.public_optional_runtime_catalog() + self.assertTrue(inspect_environment.call_args_list) + self.assertTrue( + all( + call.kwargs.get("verify_integrity") is False + for call in inspect_environment.call_args_list + ) + ) + by_id = {item["id"]: item for item in catalog["environments"]} + self.assertIn(active, by_id) + self.assertIn(previous, by_id) + self.assertTrue(catalog["environmentScan"]["truncated"]) + self.assertEqual(by_id[active]["status"], "legacy_unqualified") + self.assertIsNone(by_id[active]["createdAt"]) + self.assertIsNone(by_id[active]["validation"]["status"]) + self.assertIsNone(by_id[active]["validation"]["validatedAt"]) + self.assertIsNone(by_id[active]["validation"]["bindingDigest"]) + optional_ids = { + item["id"] for item in optional_catalog["overlay"]["environments"] + } + self.assertIn(active, optional_ids) + self.assertIn(previous, optional_ids) def test_failed_stage_never_changes_active_environment(self): - self.create_environment("active") - optimizations.activate_environment("active") - failed_process = mock.Mock(returncode=1, stdout="", stderr="compiler failed") + self.set_active_environment(self.ACTIVE_ENVIRONMENT_ID) with ( - mock.patch.object(optimizations, "_uv_executable", return_value="/managed/uv"), - mock.patch.object(optimizations.subprocess, "run", return_value=failed_process), - self.assertRaisesRegex(RuntimeError, "compiler failed"), + mock.patch.object(optimizations, "reserve_install") as reserve, + mock.patch.object(optimizations, "_install_reviewed_overlay") as install, + self.assertRaisesRegex(RuntimeError, "immutable artifact lock"), ): optimizations.install_capability( "torchao", runtime_profile={"installed": "amd-rocm-linux"}, hardware={"torch": {"version": "2.9.1+rocm7.2"}}, ) - self.assertEqual(optimizations.read_state()["activeEnvironmentId"], "active") + reserve.assert_not_called() + install.assert_not_called() + self.assertEqual( + optimizations.read_state()["activeEnvironmentId"], self.ACTIVE_ENVIRONMENT_ID + ) - def test_source_package_bootstraps_toolchain_before_abi_install(self): - self.create_environment("active") - optimizations.activate_environment("active") - succeeded = mock.Mock(returncode=0, stdout="installed", stderr="") - validation = {"status": "passed", "detail": {"torch": "2.9.1+rocm7.2"}} + def test_legacy_source_package_rejects_before_staging(self): + self.set_active_environment(self.ACTIVE_ENVIRONMENT_ID) with ( - mock.patch.object(optimizations, "_uv_executable", return_value="/managed/uv"), - mock.patch.object(optimizations, "_normalized_platform", return_value="linux"), - mock.patch.object(optimizations.subprocess, "run", return_value=succeeded) as run, - mock.patch.object(optimizations, "_run_validation", return_value=validation), + mock.patch.object(optimizations, "reserve_install") as reserve, + mock.patch.object(optimizations, "_install_reviewed_overlay") as install, + self.assertRaisesRegex(RuntimeError, "immutable artifact lock"), ): - result = optimizations.install_capability( + optimizations.install_capability( "flash_attention_2", runtime_profile={"installed": "amd-rocm-linux"}, hardware={ @@ -97,21 +511,14 @@ def test_source_package_bootstraps_toolchain_before_abi_install(self): "amd_architectures": ["gfx1151"], }, ) - - self.assertEqual(run.call_count, 2) - build_command, package_command = (call.args[0] for call in run.call_args_list) - self.assertIn("ninja==1.13.0", build_command) - self.assertIn("flash-attn==2.8.3.post1", package_command) - self.assertIn("--no-build-isolation", package_command) - self.assertIn("--no-deps", package_command) - self.assertTrue(result["requiresActivation"]) - self.assertFalse(result["activeRuntimeChanged"]) - self.assertEqual(optimizations.read_state()["activeEnvironmentId"], "active") - self.assertTrue((optimizations.ENVIRONMENTS_DIR / result["environmentId"] / "validation.json").is_file()) + reserve.assert_not_called() + install.assert_not_called() + self.assertEqual( + optimizations.read_state()["activeEnvironmentId"], self.ACTIVE_ENVIRONMENT_ID + ) def test_auto_requires_opt_in_baseline_review_and_exact_runtime(self): - self.create_environment("active") - optimizations.activate_environment("active") + self.set_active_environment(self.ACTIVE_ENVIRONMENT_ID) optimizations.set_capability_enabled("regional_compile", True) runtime = "runtime-a" common = { @@ -154,9 +561,44 @@ def test_import_probe_receipt_never_authorizes_auto(self): self.assertEqual(receipt["status"], "probe_passed") self.assertFalse(receipt["autoEligible"]) + def test_probe_receipt_never_persists_raw_stdout_stderr_or_paths(self): + result = SimpleNamespace( + returncode=0, + stdout=json.dumps( + { + "supported": True, + "compileAvailable": True, + "cudaAvailable": False, + "deviceCount": 0, + "privatePath": "C:/private/stdout", + } + ), + stderr="C:/private/stderr token=secret", + ) + with mock.patch.object(optimizations.subprocess, "run", return_value=result): + receipt = optimizations.probe_capability( + "regional_compile", runtime_fingerprint={"private": "value"} + ) + self.assertEqual(receipt["status"], "probe_passed") + persisted = optimizations.RECEIPTS_PATH.read_text(encoding="utf-8") + self.assertNotIn("C:/private", persisted) + self.assertNotIn("stderr", persisted) + self.assertNotIn("stdout", persisted) + stored_result = optimizations.read_receipts()["receipts"][0]["result"] + self.assertEqual(stored_result["status"], "passed") + self.assertTrue(stored_result["supported"]) + self.assertRegex(stored_result["diagnosticDigest"], r"^[0-9a-f]{64}$") + + def test_unqualified_package_probe_rejects_without_subprocess(self): + with ( + mock.patch.object(optimizations.subprocess, "run") as run, + self.assertRaisesRegex(RuntimeError, "immutable artifact locks"), + ): + optimizations.probe_capability("torchao", runtime_fingerprint={}) + run.assert_not_called() + def test_auto_combines_independently_qualified_capabilities(self): - self.create_environment("active") - optimizations.activate_environment("active") + self.set_active_environment(self.ACTIVE_ENVIRONMENT_ID) for capability in ("regional_compile", "channels_last"): optimizations.set_capability_enabled(capability, True) common = { diff --git a/tests/test_optional_runtime_execution.py b/tests/test_optional_runtime_execution.py new file mode 100644 index 0000000..a2c876a --- /dev/null +++ b/tests/test_optional_runtime_execution.py @@ -0,0 +1,1273 @@ +import asyncio +from contextlib import contextmanager +from dataclasses import replace +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from modiff import server as server_module +from modiff.diffusers_profiles import ( + DIFFUSERS_EXECUTION_PROFILES, + FLUX_CANNY_VERIFIED_REPAIR_REPO, + FLUX_DEV_FP8_REPO, + FLUX_KONTEXT_NVFP4_REPO, + OPTIONAL_RUNTIME_DELIVERY_BASE, + OPTIONAL_RUNTIME_DELIVERY_OVERLAY, + execution_profiles_for_execution, + optional_runtime_requirement_for_profiles as declarative_requirement, + resolve_execution_profiles_for_loader, +) +from modiff.optional_runtime_execution import ( + OptionalRuntimeExecutionBlocked, + graph_optional_runtime_requirement, + loader_optional_runtime_requirement, + optional_runtime_requirement_for_profiles, +) +from modiff.optional_runtimes import ( + OPTIONAL_RUNTIME_PROFILES, + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, +) +from modiff.server import WebServer + + +EXECUTION_PROFILE_ID = "z-image:auto" +OPTIONAL_PROFILE_ID = TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + + +class JsonRequest: + def __init__(self, body, *, path="/graph"): + self.body = body + self.method = "POST" + self.path = path + self.query = {} + self.headers = {} + self.host = "127.0.0.1:8088" + self.remote = "127.0.0.1" + self.content_length = None + + async def json(self): + return self.body + + +def response_json(response): + return json.loads(response.text) + + +def loader_graph(*, runtime_hints=None): + graph = { + "sid": "optional-runtime-test", + "nodes": { + "loader": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "ZImagePipeline"}, + "model_id": { + "value": { + "source": "hub", + "value": "Tongyi-MAI/Z-Image-Turbo", + } + }, + }, + } + }, + "paths": [["loader"]], + } + if runtime_hints is not None: + graph["runtimeHints"] = runtime_hints + return graph + + +def runtime_catalog( + package_status="missing", + *, + process_status="base", + overlay_status="missing", + qualified=False, +): + contract = OPTIONAL_RUNTIME_PROFILES[OPTIONAL_PROFILE_ID] + return { + "schemaVersion": 1, + "profiles": [ + { + **contract.to_spec_dict(), + "specDigest": contract.spec_digest, + "status": package_status, + "overlayStatus": overlay_status, + "contractState": "qualified" if qualified else "candidate_unqualified", + "cutoverReady": qualified, + } + ], + "overlay": {"processLoadStatus": process_status}, + } + + +@contextmanager +def overlay_delivery(profile_id=EXECUTION_PROFILE_ID, **changes): + original = DIFFUSERS_EXECUTION_PROFILES[profile_id] + updated = replace( + original, + optional_runtime_delivery=OPTIONAL_RUNTIME_DELIVERY_OVERLAY, + **changes, + ) + with mock.patch.dict( + DIFFUSERS_EXECUTION_PROFILES, + {profile_id: updated}, + clear=False, + ): + yield updated + + +class OptionalRuntimeRequirementTests(unittest.TestCase): + def test_every_current_profile_is_explicitly_base_delivered(self): + expected_keys = { + "schemaVersion", + "delivery", + "requiredNow", + "profileIds", + "executionProfileIds", + "state", + "reason", + } + self.assertTrue(DIFFUSERS_EXECUTION_PROFILES) + for profile in DIFFUSERS_EXECUTION_PROFILES.values(): + with self.subTest(profile=profile.id): + self.assertEqual( + profile.optional_runtime_delivery, + OPTIONAL_RUNTIME_DELIVERY_BASE, + ) + requirement = profile.to_public_dict()["optionalRuntimeRequirement"] + self.assertEqual(set(requirement), expected_keys) + self.assertEqual(requirement["delivery"], "base") + self.assertFalse(requirement["requiredNow"]) + self.assertEqual(requirement["state"], "base_satisfied") + self.assertEqual(requirement["executionProfileIds"], [profile.id]) + + def test_base_delivery_never_observes_runtime_catalog(self): + profile = DIFFUSERS_EXECUTION_PROFILES[EXECUTION_PROFILE_ID] + resolver = mock.Mock(side_effect=AssertionError("catalog must stay dormant")) + requirement = optional_runtime_requirement_for_profiles( + (profile,), + catalog_resolver=resolver, + ) + self.assertEqual(requirement["state"], "base_satisfied") + resolver.assert_not_called() + + def test_exact_pair_registry_has_atomic_optional_delivery(self): + pairs = {} + for profile in DIFFUSERS_EXECUTION_PROFILES.values(): + for mode in profile.modes: + pairs.setdefault((profile.model_type, mode), []).append(profile) + for pair, profiles in pairs.items(): + with self.subTest(pair=pair): + self.assertEqual( + len({profile.optional_runtime_delivery for profile in profiles}), + 1, + ) + self.assertEqual( + len({profile.optional_runtime_profiles for profile in profiles}), + 1, + ) + self.assertEqual( + tuple(execution_profiles_for_execution(*pair)), + tuple(profiles), + ) + + def test_mixed_cutover_contract_fails_closed_but_pure_diffusers_is_neutral(self): + original = DIFFUSERS_EXECUTION_PROFILES[EXECUTION_PROFILE_ID] + overlay = replace( + original, + id="fixture-overlay:direct", + optional_runtime_delivery=OPTIONAL_RUNTIME_DELIVERY_OVERLAY, + ) + mixed = declarative_requirement((original, overlay)) + self.assertTrue(mixed["requiredNow"]) + self.assertEqual(mixed["state"], "unavailable") + self.assertEqual(mixed["reason"], "execution_profile_contract_invalid") + + pure = replace( + original, + id="fixture-pure:direct", + optional_runtime_profiles=(), + ) + compatible = declarative_requirement((pure, overlay)) + self.assertTrue(compatible["requiredNow"]) + self.assertNotEqual(compatible["reason"], "execution_profile_contract_invalid") + + def test_public_id_bounds_and_malformed_ids_are_contract_invalid(self): + original = DIFFUSERS_EXECUTION_PROFILES[EXECUTION_PROFILE_ID] + malformed = replace( + original, + optional_runtime_delivery=OPTIONAL_RUNTIME_DELIVERY_OVERLAY, + optional_runtime_profiles=("../escape",), + ) + requirement = declarative_requirement((malformed,)) + self.assertEqual(requirement["state"], "unavailable") + self.assertEqual(requirement["reason"], "execution_profile_contract_invalid") + self.assertLessEqual(len(requirement["profileIds"]), 32) + self.assertLessEqual(len(requirement["executionProfileIds"]), 32) + + many = tuple( + replace(original, id=f"fixture-{index}:direct") + for index in range(33) + ) + overflow = declarative_requirement(many) + self.assertEqual(overflow["reason"], "execution_profile_contract_invalid") + self.assertEqual(len(overflow["executionProfileIds"]), 32) + + duplicate = declarative_requirement( + (original, replace(original, model_type="FixturePipeline")) + ) + self.assertEqual(duplicate["state"], "unavailable") + self.assertEqual(duplicate["reason"], "execution_profile_contract_invalid") + + def test_loader_resolution_uses_authoritative_identity_and_structured_hub_repo(self): + profiles, reason = resolve_execution_profiles_for_loader( + "modules.DiffusersImage", + "LoadPipeline", + { + "pipeline_class": "ZImagePipeline", + "model_id": { + "source": "hub", + "value": "Tongyi-MAI/Z-Image-Turbo", + }, + }, + ) + self.assertIsNone(reason) + self.assertEqual([profile.id for profile in profiles], [EXECUTION_PROFILE_ID]) + + cases = { + "black-forest-labs/FLUX.1-dev": "flux-dev:direct", + FLUX_DEV_FP8_REPO: "flux-dev:direct", + "black-forest-labs/FLUX.1-schnell": "flux-schnell:direct", + "black-forest-labs/FLUX.1-Krea-dev": "flux-krea:direct", + } + for repository, expected in cases.items(): + with self.subTest(repository=repository): + profiles, reason = resolve_execution_profiles_for_loader( + "modules.DiffusersImage", + "LoadPipeline", + { + "pipeline_class": "FluxPipeline", + "model_id": {"source": "hub", "value": repository}, + }, + ) + self.assertIsNone(reason) + self.assertEqual([profile.id for profile in profiles], [expected]) + + profiles, reason = resolve_execution_profiles_for_loader( + "modules.DiffusersImage", + "LoadPipeline", + { + "pipeline_class": "FluxControlPipeline", + "model_id": { + "source": "hub", + "value": FLUX_CANNY_VERIFIED_REPAIR_REPO, + }, + }, + ) + self.assertIsNone(reason) + self.assertEqual([profile.id for profile in profiles], ["flux-canny:direct"]) + + profiles, reason = resolve_execution_profiles_for_loader( + "modules.DiffusersImage", + "LoadPipeline", + { + "pipeline_class": "FluxKontextPipeline", + "model_id": { + "source": "hub", + "value": FLUX_KONTEXT_NVFP4_REPO, + }, + }, + ) + self.assertIsNone(reason) + self.assertEqual([profile.id for profile in profiles], ["flux-kontext:direct"]) + self.assertIn(FLUX_KONTEXT_NVFP4_REPO, profiles[0].compatible_repos) + + def test_local_custom_and_malformed_shared_selectors_are_base_tolerated(self): + trap = mock.Mock(side_effect=AssertionError("catalog must stay dormant")) + for selector in ( + {"source": "local", "value": "C:/models/flux"}, + {"source": "custom", "value": "repo"}, + {"source": "hub", "value": 7}, + {"source": "hub", "value": "unknown/repo", "extra": True}, + ): + with self.subTest(selector=selector): + requirement = loader_optional_runtime_requirement( + "modules.DiffusersImage", + "LoadPipeline", + {"pipeline_class": "FluxPipeline", "model_id": selector}, + catalog_resolver=trap, + ) + self.assertEqual(requirement["state"], "base_satisfied") + trap.assert_not_called() + + def test_graph_requirement_uses_only_deduplicated_executable_path_nodes(self): + graph = loader_graph() + graph["nodes"]["base"] = { + "module": "modules.BasicImage", + "action": "PreviewImage", + "params": {}, + } + catalog = mock.Mock(return_value=runtime_catalog("missing")) + with overlay_delivery(): + graph["paths"] = [["base", "base"]] + disconnected = graph_optional_runtime_requirement( + graph, + catalog_resolver=catalog, + ) + self.assertFalse(disconnected["requiredNow"]) + self.assertEqual(disconnected["state"], "base_satisfied") + catalog.assert_not_called() + + graph["paths"] = [["loader", "loader"], ["loader"]] + executable = graph_optional_runtime_requirement( + graph, + catalog_resolver=catalog, + ) + self.assertTrue(executable["requiredNow"]) + self.assertEqual(executable["state"], "missing") + self.assertEqual(executable["executionProfileIds"], [EXECUTION_PROFILE_ID]) + catalog.assert_called_once_with() + + def test_malformed_or_missing_path_references_do_not_authorize_loaders(self): + graph = loader_graph() + graph["paths"] = [["missing", None, {}], "not-a-path"] + catalog = mock.Mock(side_effect=AssertionError("unresolved paths must not scan")) + with overlay_delivery(): + requirement = graph_optional_runtime_requirement( + graph, + catalog_resolver=catalog, + ) + self.assertFalse(requirement["requiredNow"]) + self.assertEqual(requirement["state"], "base_satisfied") + catalog.assert_not_called() + + def test_required_overlay_status_matrix_and_active_qualification(self): + cases = ( + ("missing", "base", "missing", False, "missing"), + ("wrong_version", "base", "missing", False, "wrong_version"), + ( + "present_unqualified", + "base", + "missing", + False, + "present_unqualified", + ), + ("missing", "base", "staged", False, "staged"), + ("missing", "busy_recovery_only", "missing", False, "busy_recovery_only"), + ("missing", "repair_required", "missing", False, "repair_required"), + ("missing", "restart_required", "missing", False, "restart_required"), + ("present_unqualified", "active", "active", True, "active"), + ) + with overlay_delivery() as profile: + for package, process, overlay, qualified, expected in cases: + with self.subTest(expected=expected), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": process}, + ): + requirement = optional_runtime_requirement_for_profiles( + (profile,), + catalog_resolver=lambda: runtime_catalog( + package, + process_status=process, + overlay_status=overlay, + qualified=qualified, + ), + ) + self.assertEqual(requirement["state"], expected) + self.assertEqual(requirement["requiredNow"], True) + + def test_fake_active_catalog_cannot_override_base_worker_status(self): + with overlay_delivery() as profile, mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "base"}, + ): + requirement = optional_runtime_requirement_for_profiles( + (profile,), + catalog_resolver=lambda: runtime_catalog( + "present_unqualified", + process_status="active", + overlay_status="active", + qualified=True, + ), + ) + self.assertEqual(requirement["state"], "unavailable") + self.assertEqual(requirement["reason"], "optional_runtime_process_status_mismatch") + + def test_unknown_profile_and_malformed_catalogs_fail_closed(self): + with overlay_delivery() as profile: + unknown = replace(profile, optional_runtime_profiles=("unknown-runtime",)) + requirement = optional_runtime_requirement_for_profiles( + (unknown,), + catalog_resolver=lambda: runtime_catalog(), + ) + self.assertEqual(requirement["state"], "unavailable") + self.assertEqual(requirement["reason"], "optional_runtime_profile_unknown") + + valid = runtime_catalog() + active_looking = { + "id": OPTIONAL_PROFILE_ID, + "schemaVersion": 1, + "overlayStatus": "active", + "contractState": "qualified", + "cutoverReady": True, + } + malformed_catalogs = [ + {**valid, "schemaVersion": 2}, + {key: value for key, value in valid.items() if key != "schemaVersion"}, + {**valid, "profiles": [*valid["profiles"], valid["profiles"][0]]}, + {**valid, "profiles": [active_looking]}, + { + **valid, + "profiles": [ + { + **valid["profiles"][0], + "schemaVersion": 2, + } + ], + }, + { + **valid, + "profiles": [ + { + **valid["profiles"][0], + "cutoverReady": 1, + } + ], + }, + ] + overflow_profiles = [] + for index in range(33): + item = dict(valid["profiles"][0]) + item["id"] = f"fixture-runtime-{index}" + overflow_profiles.append(item) + malformed_catalogs.append({**valid, "profiles": overflow_profiles}) + + for catalog in malformed_catalogs: + with self.subTest(catalog=list(catalog)): + result = optional_runtime_requirement_for_profiles( + (profile,), + catalog_resolver=lambda catalog=catalog: catalog, + ) + self.assertEqual(result["state"], "unavailable") + + def test_unexpected_catalog_assertion_propagates(self): + with overlay_delivery() as profile: + with self.assertRaisesRegex(AssertionError, "purity sentinel"): + optional_runtime_requirement_for_profiles( + (profile,), + catalog_resolver=mock.Mock( + side_effect=AssertionError("purity sentinel") + ), + ) + + +class OptionalRuntimeExecutionServerTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.environment = mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "base"}, + ) + self.environment.start() + self.server = WebServer( + modules={}, + work_dir=self.temporary.name, + data_dir=self.temporary.name, + ) + + def tearDown(self): + self.environment.stop() + self.temporary.cleanup() + + async def _graph_response(self, graph=None): + with ( + mock.patch.object( + self.server, + "_auto_resource_runtime_block", + return_value=None, + ), + mock.patch.object( + self.server, + "queue_task", + new=mock.AsyncMock(return_value="task-fixture"), + ) as queue, + mock.patch.object( + self.server, + "_studio_preview_slots_for_task", + return_value={"previewSlots": [], "revision": 0}, + ), + ): + response = await self.server.graph(JsonRequest(graph or loader_graph())) + return response, queue + + async def test_current_base_graph_survives_every_persistent_overlay_status(self): + catalog = mock.Mock(side_effect=AssertionError("base graph must not scan catalog")) + with mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ): + for status in ( + "busy_recovery_only", + "repair_required", + "restart_required", + ): + with self.subTest(layer="handler", status=status), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + response, queue = await self._graph_response() + self.assertEqual(response.status, 200) + queue.assert_awaited_once() + + with self.subTest(layer="queue", status=status), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + task_id = await self.server.queue_task( + self.server.execute_graph, + (loader_graph(),), + None, + "sid", + name="Graph execution", + ) + self.assertIn(task_id, self.server.queued_tasks) + self.server.queued_tasks.clear() + self.server.task_graphs.clear() + while not self.server.main_queue.empty(): + self.server.main_queue.get_nowait() + + with self.subTest(layer="middleware", status=status), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + with ( + mock.patch.object( + self.server, + "_auto_resource_runtime_block", + return_value=None, + ), + mock.patch.object( + self.server, + "queue_task", + new=mock.AsyncMock(return_value="task-fixture"), + ), + mock.patch.object( + self.server, + "_studio_preview_slots_for_task", + return_value={"previewSlots": [], "revision": 0}, + ), + ): + response = await self.server._mutation_origin_middleware( + JsonRequest(loader_graph()), + self.server.graph, + ) + self.assertEqual(response.status, 200) + self.assertEqual(self.server._active_nonruntime_mutations, 0) + catalog.assert_not_called() + + async def test_persistent_overlay_status_is_neutral_for_base_mutation_paths(self): + catalog = mock.Mock( + side_effect=AssertionError("unrelated mutations must not scan catalog") + ) + with mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ): + for status in ( + "busy_recovery_only", + "repair_required", + "restart_required", + ): + for method, path in ( + ("POST", "/file"), + ("PUT", "/workflows/workflow-fixture"), + ("POST", "/hf_download"), + ): + with self.subTest(status=status, method=method, path=path), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + request = JsonRequest({}, path=path) + request.method = method + handler = mock.AsyncMock( + return_value=server_module.web.json_response( + {"error": False} + ) + ) + response = await self.server._mutation_origin_middleware( + request, + handler, + ) + self.assertEqual(response.status, 200) + handler.assert_awaited_once_with(request) + self.assertEqual(self.server._active_nonruntime_mutations, 0) + + self.server._runtime_mutation_gate = { + "token": "runtime-gate-fixture", + "kind": "test", + "identifier": "test", + } + blocked_handler = mock.AsyncMock( + side_effect=AssertionError("live gate must remain global") + ) + response = await self.server._mutation_origin_middleware( + JsonRequest({}, path="/file"), + blocked_handler, + ) + self.server._runtime_mutation_gate = None + self.assertEqual(response.status, 409) + self.assertEqual( + response_json(response)["error_code"], + "runtime_mutation_busy", + ) + blocked_handler.assert_not_awaited() + catalog.assert_not_called() + + async def test_persistent_overlay_status_is_neutral_for_unrelated_queued_work(self): + catalog = mock.Mock( + side_effect=AssertionError("unrelated queue work must not scan catalog") + ) + with mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ): + for status in ( + "busy_recovery_only", + "repair_required", + "restart_required", + ): + with self.subTest(status=status), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + task_id = await self.server.queue_task( + mock.Mock(), + (), + None, + "sid-fixture", + name="Hugging Face search", + ) + self.assertIn(task_id, self.server.queued_tasks) + self.server.queued_tasks.clear() + while not self.server.main_queue.empty(): + self.server.main_queue.get_nowait() + catalog.assert_not_called() + + async def test_live_mutation_gate_blocks_before_optional_catalog_scan(self): + self.server._runtime_mutation_gate = { + "token": "runtime-gate-fixture", + "kind": "test", + "identifier": "test", + } + catalog = mock.Mock(side_effect=AssertionError("catalog must not be scanned")) + with overlay_delivery(), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ): + response = await self.server.graph(JsonRequest(loader_graph())) + self.assertEqual(response.status, 409) + self.assertEqual(response_json(response)["error_code"], "runtime_mutation_busy") + catalog.assert_not_called() + + async def test_required_overlay_graph_returns_bounded_blocker_for_all_states(self): + cases = ( + ("missing", "base", "missing", "missing"), + ("wrong_version", "base", "missing", "wrong_version"), + ( + "present_unqualified", + "base", + "missing", + "present_unqualified", + ), + ("missing", "busy_recovery_only", "missing", "busy_recovery_only"), + ("missing", "repair_required", "missing", "repair_required"), + ("missing", "restart_required", "missing", "restart_required"), + ) + installers = ( + mock.patch.object( + server_module, + "install_optional_runtime", + side_effect=AssertionError("install must not run"), + ), + mock.patch.object( + server_module, + "activate_optional_runtime_environment", + side_effect=AssertionError("activation must not run"), + ), + ) + with overlay_delivery(), installers[0], installers[1]: + for package, process, overlay, expected in cases: + with self.subTest(expected=expected), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": process}, + ), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + return_value=runtime_catalog( + package, + process_status=process, + overlay_status=overlay, + ), + ): + response, queue = await self._graph_response( + loader_graph( + runtime_hints={ + "modelType": "UnrelatedSpoofPipeline", + "mode": "text_to_video", + } + ) + ) + body = response_json(response) + self.assertEqual(response.status, 409) + self.assertEqual(body["error_code"], f"optional_runtime_{expected}") + self.assertEqual( + set(body["optionalRuntimeRequirement"]), + { + "schemaVersion", + "delivery", + "requiredNow", + "profileIds", + "executionProfileIds", + "state", + "reason", + }, + ) + self.assertEqual( + body["optionalRuntimeRequirement"]["executionProfileIds"], + [EXECUTION_PROFILE_ID], + ) + queue.assert_not_awaited() + + async def test_worker_rechecks_admission_to_execution_state_change_before_import(self): + active = runtime_catalog( + "present_unqualified", + process_status="active", + overlay_status="active", + qualified=True, + ) + missing = runtime_catalog( + "missing", + process_status="active", + overlay_status="missing", + ) + resolver = mock.Mock(side_effect=[active, missing]) + with overlay_delivery(), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "active"}, + ), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + resolver, + ): + response, queue = await self._graph_response() + self.assertEqual(response.status, 200) + queue.assert_awaited_once() + capture = mock.Mock(side_effect=AssertionError("process capture must not run")) + with mock.patch.object( + self.server, + "_capture_execution_process_state", + capture, + ): + with self.assertRaises(OptionalRuntimeExecutionBlocked): + self.server.execute_graph(loader_graph()) + capture.assert_not_called() + + def test_loader_boundary_blocks_before_module_import(self): + self.server.modules = { + "modules.DiffusersImage": { + "LoadPipeline": {"params": {}}, + } + } + node = loader_graph()["nodes"]["loader"] + importer = mock.Mock(side_effect=AssertionError("loader module must not import")) + with overlay_delivery(), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + return_value=runtime_catalog("missing"), + ), mock.patch.object(server_module, "import_module", importer): + with self.assertRaises(OptionalRuntimeExecutionBlocked): + self.server.execute_node("loader", node, "sid", quiet=True) + importer.assert_not_called() + + def test_worker_optional_runtime_error_projection_is_strictly_redacted(self): + requirement = { + "schemaVersion": 1, + "delivery": "optional_overlay", + "requiredNow": True, + "profileIds": [OPTIONAL_PROFILE_ID], + "executionProfileIds": [EXECUTION_PROFILE_ID], + "state": "missing", + "reason": "optional_runtime_missing", + } + error = OptionalRuntimeExecutionBlocked(requirement) + payload = self.server._exception_payload( + error, + task_id="task-fixture", + sid=r"C:\private\sid-token", + node_id="loader", + node_name="modules.DiffusersImage.LoadPipeline", + traceback_text=r"C:\private\source.py secret-token", + ) + self.assertEqual( + set(payload), + { + "error", + "category", + "error_code", + "message", + "recovery_hint", + "optionalRuntimeRequirement", + "task_id", + "node", + "node_name", + }, + ) + serialized = json.dumps(payload) + self.assertNotIn("private", serialized) + self.assertNotIn("secret-token", serialized) + for forbidden in ( + "traceback", + "exception_type", + "runtime_hints", + "loader_diagnostics", + "gpu_processes", + ): + self.assertNotIn(forbidden, payload) + + canary = "C:\\private\\secret-token\\" + ("x" * 4096) + adversarial = self.server._exception_payload( + error, + task_id=canary, + sid=canary, + node_id=canary, + node_name=canary, + traceback_text=canary, + ) + self.assertEqual( + set(adversarial), + { + "error", + "category", + "error_code", + "message", + "recovery_hint", + "optionalRuntimeRequirement", + }, + ) + serialized = json.dumps(adversarial) + self.assertNotIn("private", serialized) + self.assertNotIn("secret-token", serialized) + self.assertLess(len(serialized), 4096) + + async def test_worker_terminal_optional_blocker_omits_cleanup_error_text(self): + requirement = { + "schemaVersion": 1, + "delivery": "optional_overlay", + "requiredNow": True, + "profileIds": [OPTIONAL_PROFILE_ID], + "executionProfileIds": [EXECUTION_PROFILE_ID], + "state": "missing", + "reason": "optional_runtime_missing", + } + cleanup_canary = r"C:\private\cleanup-secret-token" + cleanup = mock.Mock( + return_value={ + "released": {"nodes": 0}, + "allocatorTrimmed": False, + "errors": [cleanup_canary], + } + ) + messages = [] + + def blocked_task(): + raise OptionalRuntimeExecutionBlocked(requirement) + + self.server.loop = asyncio.get_running_loop() + with ( + mock.patch.object( + self.server, + "_release_runtime_caches_for_retry", + cleanup, + ), + mock.patch.object(self.server, "queue_message", side_effect=messages.append), + mock.patch.object( + self.server, + "_persist_supervisor_queue_state", + ), + ): + await self.server.queue_task( + blocked_task, + (), + None, + "sid-fixture", + name="Field action", + ) + worker = asyncio.create_task(self.server._main_worker()) + await asyncio.wait_for(self.server.main_queue.join(), timeout=3) + self.server._shutdown_event.set() + await asyncio.wait_for(worker, timeout=3) + + cleanup.assert_called_once_with() + failure = next( + message for message in messages if message.get("type") == "task_failed" + ) + self.assertEqual(failure["category"], "optional_runtime") + self.assertNotIn("runtimeCleanup", failure) + serialized = json.dumps(failure) + self.assertNotIn("cleanup-secret-token", serialized) + self.assertNotIn("C:\\private", serialized) + + async def test_model_capabilities_enrich_nested_active_state_with_one_catalog_snapshot(self): + active = runtime_catalog( + "present_unqualified", + process_status="active", + overlay_status="active", + qualified=True, + ) + catalog = mock.Mock(return_value=active) + with overlay_delivery(), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "active"}, + ), mock.patch.object( + server_module, + "public_optional_runtime_catalog", + catalog, + ), mock.patch.object( + server_module, + "validate_studio_execution_specs", + return_value=[], + ): + response = await self.server.model_capabilities( + type("Request", (), {"query": {}})() + ) + body = response_json(response) + capability = next( + item + for item in body["capabilities"] + if item["modelType"] == "ZImageModularPipeline" + ) + nested = next( + item + for item in capability["executionProfiles"] + if item["id"] == EXECUTION_PROFILE_ID + ) + self.assertEqual(nested["optionalRuntimeRequirement"]["state"], "active") + self.assertEqual(capability["optionalRuntimeRequirement"]["state"], "active") + root_profile = next( + item + for item in body["diffusersExecutionProfiles"] + if item["id"] == EXECUTION_PROFILE_ID + ) + self.assertEqual(root_profile["optionalRuntimeRequirement"]["state"], "active") + catalog.assert_called_once_with() + + async def test_listgraphs_snapshots_catalog_once_and_keeps_no_contract_shape_valid(self): + data_dir = Path(self.temporary.name) + graph_dir = data_dir / "graphs" / "studio" + graph_dir.mkdir(parents=True, exist_ok=True) + workflows = [] + for index in range(3): + filename = f"sample-{index}.json" + (graph_dir / filename).write_text("{}", encoding="utf-8") + workflows.append( + { + "graphPath": f"studio/{filename}", + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + } + ) + (graph_dir / "unmanifested.json").write_text("{}", encoding="utf-8") + (data_dir / "workflow-library-manifest.json").write_text( + json.dumps({"workflows": workflows}), + encoding="utf-8", + ) + active = runtime_catalog( + "present_unqualified", + process_status="active", + overlay_status="active", + qualified=True, + ) + catalog = mock.Mock(return_value=active) + with overlay_delivery(), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "active"}, + ), mock.patch.object( + server_module, + "public_optional_runtime_catalog", + catalog, + ): + response = await self.server.listgraphs(object()) + files = [] + pending = list(response_json(response)) + while pending: + item = pending.pop() + if item["isDir"]: + pending.extend(item["children"]) + else: + files.append(item) + manifested = [item for item in files if item["name"].startswith("sample-")] + self.assertEqual( + {item["optionalRuntimeRequirement"]["state"] for item in manifested}, + {"active"}, + ) + no_contract = next(item for item in files if item["name"] == "unmanifested") + self.assertEqual(no_contract["optionalRuntimeRequirement"]["profileIds"], []) + self.assertEqual( + no_contract["optionalRuntimeRequirement"]["executionProfileIds"], + [], + ) + self.assertFalse(no_contract["optionalRuntimeRequirement"]["requiredNow"]) + catalog.assert_called_once_with() + + +class FieldActionOptionalRuntimeTests(unittest.IsolatedAsyncioTestCase): + class FakeNode: + module_name = "modules.DiffusersImage" + class_name = "LoadPipeline" + + def __init__(self): + self.calls = [] + self._sid = None + + def refresh(self, values, ref): + self.calls.append((values, ref)) + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.environment = mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "base"}, + ) + self.environment.start() + modules = { + "modules.DiffusersImage": { + "LoadPipeline": { + "params": { + "trigger": {"onChange": "refresh"}, + } + } + } + } + self.server = WebServer( + modules=modules, + work_dir=self.temporary.name, + data_dir=self.temporary.name, + ) + self.node = self.FakeNode() + self.server.node_cache["loader"] = self.node + + def tearDown(self): + self.environment.stop() + self.temporary.cleanup() + + def request(self, *, queue=False): + return JsonRequest( + { + "node": "loader", + "sid": "sid", + "fn": "refresh", + "fieldKey": "trigger", + "queue": queue, + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "values": { + "pipeline_class": "ZImagePipeline", + "model_id": { + "source": "hub", + "value": "Tongyi-MAI/Z-Image-Turbo", + }, + }, + }, + path="/fields/action", + ) + + async def test_base_direct_and_queued_field_actions_survive_persistent_states(self): + catalog = mock.Mock(side_effect=AssertionError("base action must not scan catalog")) + with mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ): + for status in ( + "busy_recovery_only", + "repair_required", + "restart_required", + ): + for queued in (False, True): + with self.subTest(status=status, queued=queued), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": status}, + ): + response = await self.server._mutation_origin_middleware( + self.request(queue=queued), + self.server.field_action, + ) + self.assertEqual(response.status, 200) + if queued: + task_id = response_json(response)["task_id"] + task = self.server.queued_tasks[task_id] + task["task"](*task["args"]) + self.server.queued_tasks.clear() + while not self.server.main_queue.empty(): + self.server.main_queue.get_nowait() + self.assertEqual(len(self.node.calls), 6) + catalog.assert_not_called() + + async def test_overlay_field_action_blocks_before_import_or_callback(self): + self.server.node_cache.clear() + importer = mock.Mock(side_effect=AssertionError("module import must not run")) + with overlay_delivery(), mock.patch.object( + server_module, + "import_module", + importer, + ), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + return_value=runtime_catalog("missing"), + ): + response = await self.server.field_action(self.request()) + self.assertEqual(response.status, 409) + self.assertEqual(response_json(response)["error_code"], "optional_runtime_missing") + importer.assert_not_called() + + async def test_queued_field_action_rechecks_state_before_callback(self): + active = runtime_catalog( + "present_unqualified", + process_status="active", + overlay_status="active", + qualified=True, + ) + missing = runtime_catalog( + "missing", + process_status="active", + overlay_status="missing", + ) + resolver = mock.Mock(side_effect=[active, missing]) + with overlay_delivery(), mock.patch.dict( + os.environ, + {"MODIFF_RUNTIME_OVERLAY_STATUS": "active"}, + ), mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + resolver, + ): + response = await self.server.field_action(self.request(queue=True)) + self.assertEqual(response.status, 200) + task_id = response_json(response)["task_id"] + task = self.server.queued_tasks[task_id] + with self.assertRaises(OptionalRuntimeExecutionBlocked): + task["task"](*task["args"]) + self.assertEqual(self.node.calls, []) + + async def test_unsupervised_restart_required_releases_gate_and_preserves_base(self): + profile = OPTIONAL_RUNTIME_PROFILES[OPTIONAL_PROFILE_ID] + cases = ( + ( + "runtime_optional_runtime_activate", + "activate_optional_runtime_environment", + { + "environmentId": "runtime-1-deadbeef", + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + }, + ), + ( + "runtime_optional_runtime_rollback", + "rollback_optional_runtime_environment", + {"consent": True}, + ), + ( + "runtime_optimization_activate", + "activate_optimization_environment", + {"environmentId": "runtime-1-deadbeef"}, + ), + ( + "runtime_optimization_rollback", + "rollback_optimization_environment", + {}, + ), + ) + mutation_result = {"state": {}, "restartRequired": True} + catalog = mock.Mock( + side_effect=AssertionError("persistent restart state must short-circuit") + ) + with ( + mock.patch.object( + server_module, + "validate_optional_runtime_activation_request", + return_value={}, + ), + mock.patch.object( + self.server, + "_schedule_optional_runtime_restart", + return_value=False, + ) as restart, + mock.patch( + "modiff.optional_runtime_execution.public_optional_runtime_catalog", + catalog, + ), + ): + for method_name, backend_name, body in cases: + with self.subTest(handler=method_name), mock.patch.object( + server_module, + backend_name, + return_value=mutation_result, + ) as backend: + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"] = "base" + response = await getattr(self.server, method_name)(JsonRequest(body)) + self.assertEqual(response.status, 200) + self.assertFalse(response_json(response)["restarting"]) + self.assertEqual( + os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"], + "restart_required", + ) + self.assertIsNone(self.server._runtime_mutation_gate) + backend.assert_called_once() + + field_response = await self.server.field_action(self.request()) + self.assertEqual(field_response.status, 200) + + with ( + mock.patch.object( + self.server, + "_auto_resource_runtime_block", + return_value=None, + ), + mock.patch.object( + self.server, + "queue_task", + new=mock.AsyncMock(return_value="task-fixture"), + ), + mock.patch.object( + self.server, + "_studio_preview_slots_for_task", + return_value={"previewSlots": [], "revision": 0}, + ), + ): + graph_response = await self.server.graph( + JsonRequest(loader_graph()) + ) + self.assertEqual(graph_response.status, 200) + + with overlay_delivery(): + field_block = await self.server.field_action(self.request()) + self.assertEqual(field_block.status, 409) + self.assertEqual( + response_json(field_block)["error_code"], + "optional_runtime_restart_required", + ) + + graph_block = await self.server.graph( + JsonRequest(loader_graph()) + ) + self.assertEqual(graph_block.status, 409) + self.assertEqual( + response_json(graph_block)["error_code"], + "optional_runtime_restart_required", + ) + self.assertEqual(restart.call_count, len(cases)) + catalog.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_optional_runtime_qualification.py b/tests/test_optional_runtime_qualification.py new file mode 100644 index 0000000..a22a39b --- /dev/null +++ b/tests/test_optional_runtime_qualification.py @@ -0,0 +1,102 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "qualify_optional_runtime.py" +SPEC = importlib.util.spec_from_file_location("modiff_optional_runtime_qualification", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +qualification = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(qualification) + + +class OptionalRuntimeQualificationTests(unittest.TestCase): + def test_preflight_preserves_dormant_profile_and_reports_exact_artifact_plan(self): + import modiff.optional_runtimes as optional_runtimes + + before = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[qualification.PROFILE_ID] + result = qualification.qualification_preflight() + after = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[qualification.PROFILE_ID] + + self.assertIs(before, after) + self.assertEqual(result["candidateSpecDigest"], before.spec_digest) + self.assertNotEqual(result["qualificationSpecDigest"], before.spec_digest) + self.assertTrue(result["sourceFlagsDormant"]) + self.assertEqual(result["artifactCount"], len(before.packages)) + self.assertGreater(result["artifactBytes"], 0) + self.assertRegex(result["artifactPlanDigest"], r"^sha256:[0-9a-f]{64}$") + self.assertIsInstance(result["managedUvReceiptPresent"], bool) + if result["status"] == "ready": + self.assertTrue(result["managedUvReceiptPresent"]) + self.assertIn(result["status"], {"ready", "not_ready"}) + + def test_full_qualification_requires_consent_before_preflight(self): + with mock.patch.object(qualification, "qualification_preflight") as preflight: + with self.assertRaisesRegex(RuntimeError, "explicit --consent"): + qualification.run_qualification(consent=False) + preflight.assert_not_called() + + def test_future_projection_refuses_changed_production_flags(self): + from dataclasses import replace + import modiff.optional_runtimes as optional_runtimes + + candidate = optional_runtimes.OPTIONAL_RUNTIME_PROFILES[qualification.PROFILE_ID] + changed = replace(candidate, install_action_available=True) + with mock.patch.object( + optional_runtimes, + "OPTIONAL_RUNTIME_PROFILES", + {qualification.PROFILE_ID: changed}, + ): + with self.assertRaisesRegex(RuntimeError, "remain dormant"): + qualification._future_profile() + + def test_verified_uv_copy_rejects_a_forged_executable(self): + from modiff.tool_locks import UV_TOOL_LOCKS + + lock = UV_TOOL_LOCKS[(qualification._platform_name(), qualification._machine_name())] + executable_name = "uv.exe" if qualification._platform_name() == "windows" else "uv" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source" / "tools" / "uv" + source.mkdir(parents=True) + (source / executable_name).write_bytes(b"forged") + (source / "receipt.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "archiveSha256": lock["archiveSha256"], + "executableSha256": lock["executableSha256"], + "executable": executable_name, + } + ), + encoding="utf-8", + ) + + target = root / "target" + with self.assertRaisesRegex(RuntimeError, "reviewed identity"): + qualification.copy_verified_uv(root / "source", target) + self.assertFalse(target.exists()) + + def test_evidence_is_bounded_and_never_overwrites(self): + with tempfile.TemporaryDirectory() as temporary: + evidence = Path(temporary) / "evidence.json" + qualification._write_evidence(evidence, {"status": "passed"}) + self.assertEqual(json.loads(evidence.read_text(encoding="utf-8")), {"status": "passed"}) + with self.assertRaises(FileExistsError): + qualification._write_evidence(evidence, {"status": "changed"}) + + oversized = Path(temporary) / "oversized.json" + with self.assertRaisesRegex(RuntimeError, "safe bound"): + qualification._write_evidence( + oversized, + {"value": "x" * qualification.MAX_EVIDENCE_BYTES}, + ) + self.assertFalse(oversized.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_optional_runtime_server.py b/tests/test_optional_runtime_server.py new file mode 100644 index 0000000..7375848 --- /dev/null +++ b/tests/test_optional_runtime_server.py @@ -0,0 +1,875 @@ +import asyncio +import json +import os +from pathlib import Path +import subprocess +import tempfile +import threading +import unittest +from unittest import mock + +from modiff import server as server_module +from modiff.optional_runtimes import ( + OPTIONAL_RUNTIME_PROFILES, + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, +) +from modiff.runtime_overlays import OverlayInstallBusy +from modiff.server import WebServer + + +class RawRequest: + def __init__(self, raw, *, content_length="automatic", path=""): + self.raw = raw + self.content_length = len(raw) if content_length == "automatic" else content_length + self.path = path + self.match_info = {} + self.read_count = 0 + + async def read(self): + self.read_count += 1 + return self.raw + + +class JsonRequest: + def __init__(self, body): + self.body = body + + async def json(self): + return self.body + + +class MiddlewareRequest(JsonRequest): + def __init__(self, body, *, path): + super().__init__(body) + self.method = "POST" + self.path = path + self.query = {} + self.headers = {} + self.host = "127.0.0.1:8088" + self.remote = "127.0.0.1" + + +class BlockingGraphRequest(MiddlewareRequest): + def __init__(self, admitted, release): + super().__init__({"sid": "race"}, path="/graph") + self.admitted = admitted + self.release = release + + async def json(self): + self.admitted.set() + await self.release.wait() + return self.body + + +def response_json(response): + return json.loads(response.text) + + +class OptionalRuntimeServerTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.environment = mock.patch.dict( + os.environ, {"MODIFF_RUNTIME_OVERLAY_STATUS": "base"} + ) + self.environment.start() + self.server = WebServer( + modules={}, + work_dir=self.temporary.name, + data_dir=self.temporary.name, + ) + + def tearDown(self): + self.environment.stop() + self.temporary.cleanup() + + async def test_strict_control_body_rejects_duplicates_and_both_oversize_paths(self): + duplicate = RawRequest(b'{"consent":true,"consent":false}') + with self.assertRaises(ValueError): + await self.server._strict_runtime_control_json( + duplicate, allowed={"consent"}, required={"consent"} + ) + + declared_oversize = RawRequest(b"{}", content_length=4097) + with self.assertRaisesRegex(ValueError, "exceeds 4096 bytes"): + await self.server._strict_runtime_control_json( + declared_oversize, allowed=set() + ) + self.assertEqual(declared_oversize.read_count, 0) + + streamed_oversize = RawRequest(b"{" + (b" " * 4095) + b"}", content_length=None) + with self.assertRaisesRegex(ValueError, "exceeds 4096 bytes"): + await self.server._strict_runtime_control_json( + streamed_oversize, allowed=set() + ) + + async def test_optional_install_progress_crosses_the_worker_thread_boundary(self): + job_id = "optjob-abcdefghijkl" + profile = OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID] + lease = mock.Mock() + gate_token = "runtime-gate" + self.server.optimization_jobs[job_id] = { + "id": job_id, + "kind": "optional_runtime", + "profileId": profile.id, + "specDigest": profile.spec_digest, + "status": "queued", + "progress": {"phase": "queued"}, + "createdAt": 1.0, + "updatedAt": 1.0, + } + self.server._runtime_install_leases[job_id] = lease + self.server._runtime_install_gate_tokens[job_id] = gate_token + phases = [] + original_update = self.server._update_optimization_job + + def record_update(identifier, **updates): + phase = (updates.get("progress") or {}).get("phase") + if phase: + phases.append(phase) + return original_update(identifier, **updates) + + def install(*_args, progress, **_kwargs): + progress({"phase": "installing", "message": "Installing.", "updatedAt": 2.0}) + return {"environmentId": "runtime-1-deadbeef"} + + with ( + mock.patch.object(self.server, "_update_optimization_job", side_effect=record_update), + mock.patch.object(self.server, "_release_worker_runtime_gate") as release_gate, + mock.patch.object(server_module, "release_runtime_install") as release_lease, + mock.patch.object(server_module, "install_optional_runtime", side_effect=install), + ): + await self.server._run_optional_runtime_install_job( + job_id, profile.id, profile.spec_digest, lease, gate_token + ) + await asyncio.sleep(0) + + self.assertIn("installing", phases) + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "ready") + release_lease.assert_called_once_with(lease) + release_gate.assert_called_once_with(gate_token) + + async def test_optimization_install_progress_crosses_the_worker_thread_boundary(self): + job_id = "optjob-bcdefghijklm" + lease = mock.Mock() + gate_token = "optimization-gate" + self.server.optimization_jobs[job_id] = { + "id": job_id, + "kind": "optimization", + "capabilityId": "torchao", + "status": "queued", + "progress": {"phase": "queued"}, + "createdAt": 1.0, + "updatedAt": 1.0, + } + self.server._runtime_install_leases[job_id] = lease + self.server._runtime_install_gate_tokens[job_id] = gate_token + phases = [] + original_update = self.server._update_optimization_job + + def record_update(identifier, **updates): + phase = (updates.get("progress") or {}).get("phase") + if phase: + phases.append(phase) + return original_update(identifier, **updates) + + def install(*_args, progress, **_kwargs): + progress({"phase": "installing", "message": "Installing.", "updatedAt": 2.0}) + return {"environmentId": "runtime-2-deadbeef"} + + with ( + mock.patch.object(self.server, "_update_optimization_job", side_effect=record_update), + mock.patch.object(self.server, "_release_worker_runtime_gate") as release_gate, + mock.patch.object(server_module, "release_runtime_install") as release_lease, + mock.patch.object( + server_module, "install_optimization_capability", side_effect=install + ), + ): + await self.server._run_optimization_install_job( + job_id, "torchao", {}, {}, lease, gate_token + ) + await asyncio.sleep(0) + + self.assertIn("installing", phases) + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "ready") + release_lease.assert_called_once_with(lease) + release_gate.assert_called_once_with(gate_token) + + async def test_install_schema_rejects_unknown_fields_and_nonliteral_consent(self): + profile = OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID] + base = { + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + } + gate = mock.Mock(side_effect=AssertionError("gate must not be reserved")) + with mock.patch.object(self.server, "_reserve_worker_runtime_gate", gate): + cases = ( + {**base, "extra": "unreviewed"}, + {**base, "consent": 1}, + {**base, "consent": "true"}, + ) + for body in cases: + with self.subTest(body=body): + response = await self.server.runtime_optional_runtime_install( + RawRequest(json.dumps(body).encode("utf-8")) + ) + self.assertEqual(response.status, 400) + + gate.assert_not_called() + self.assertEqual(self.server.optimization_jobs, {}) + + async def test_unavailable_candidate_rejects_before_gate_lease_job_or_worker(self): + profile = OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID] + body = json.dumps( + { + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + } + ).encode("utf-8") + gate = mock.Mock(side_effect=AssertionError("gate must not be reserved")) + lease = mock.Mock(side_effect=AssertionError("lease must not be reserved")) + persist = mock.Mock(side_effect=AssertionError("job must not be persisted")) + installer = mock.Mock(side_effect=AssertionError("installer must not run")) + + with ( + mock.patch.object(self.server, "_reserve_worker_runtime_gate", gate), + mock.patch.object(self.server, "_persist_optimization_job", persist), + mock.patch.object(server_module, "reserve_runtime_install", lease), + mock.patch.object(server_module, "install_optional_runtime", installer), + ): + response = await self.server.runtime_optional_runtime_install( + RawRequest(body) + ) + + self.assertEqual(response.status, 409) + self.assertIn("not qualified", response_json(response)["message"]) + gate.assert_not_called() + lease.assert_not_called() + persist.assert_not_called() + installer.assert_not_called() + self.assertEqual(self.server.optimization_jobs, {}) + self.assertEqual(self.server._runtime_install_leases, {}) + self.assertEqual(self.server._runtime_install_gate_tokens, {}) + self.assertIsNone(self.server._runtime_mutation_gate) + + async def test_activate_and_rollback_schemas_reject_before_gate_or_backend(self): + profile = OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID] + valid = { + "environmentId": "runtime-1-deadbeef", + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + } + activate_cases = [ + b"{}", + json.dumps({**valid, "extra": True}).encode(), + json.dumps({**valid, "environmentId": "../escape"}).encode(), + json.dumps({**valid, "profileId": "../profile"}).encode(), + json.dumps({**valid, "specDigest": "sha256:not-a-digest"}).encode(), + json.dumps({**valid, "consent": 1}).encode(), + b'{"consent":true,"consent":false}', + b"{" + (b" " * 4096) + b"}", + ] + gate = mock.Mock(side_effect=AssertionError("gate must not be reserved")) + backend = mock.Mock(side_effect=AssertionError("backend must not run")) + with ( + mock.patch.object(self.server, "_reserve_worker_runtime_gate", gate), + mock.patch.object( + server_module, "activate_optional_runtime_environment", backend + ), + ): + for raw in activate_cases: + with self.subTest(route="activate", raw=raw[:40]): + response = await self.server.runtime_optional_runtime_activate( + RawRequest(raw) + ) + self.assertEqual(response.status, 400) + + rollback_cases = [ + b"{}", + b'{"consent":false}', + b'{"consent":1}', + b'{"consent":true,"extra":1}', + b'{"consent":true,"consent":false}', + b"{" + (b" " * 4096) + b"}", + ] + with ( + mock.patch.object(self.server, "_reserve_worker_runtime_gate", gate), + mock.patch.object( + server_module, "rollback_optional_runtime_environment", backend + ), + ): + for raw in rollback_cases: + with self.subTest(route="rollback", raw=raw[:40]): + response = await self.server.runtime_optional_runtime_rollback( + RawRequest(raw) + ) + self.assertEqual(response.status, 400) + gate.assert_not_called() + backend.assert_not_called() + + async def test_cancel_route_never_crosses_kind_or_inactive_terminal_boundaries(self): + job_id = "optjob-abcdefghijkl" + + def request(raw=b"", *, path="/runtime/optional-runtimes/jobs/x/cancel", identifier=job_id): + value = RawRequest(raw, path=path) + value.match_info = {"job_id": identifier} + return value + + cancel = mock.Mock(side_effect=AssertionError("cancel must not run")) + with mock.patch.object(server_module, "cancel_runtime_install", cancel): + response = await self.server.runtime_optimization_job_cancel( + request(identifier="bad-job-id") + ) + self.assertEqual(response.status, 404) + for raw in ( + b'{"extra":1}', + b'{"extra":1,"extra":2}', + b"{" + (b" " * 4096) + b"}", + ): + with self.subTest(raw=raw[:40]): + response = await self.server.runtime_optimization_job_cancel( + request(raw) + ) + self.assertEqual(response.status, 400) + + self.server.optimization_jobs[job_id] = { + "id": job_id, + "kind": "optimization", + "status": "running", + } + response = await self.server.runtime_optimization_job_cancel(request()) + self.assertEqual(response.status, 409) + + self.server.optimization_jobs[job_id] = { + "id": job_id, + "kind": "optional_runtime", + "status": "ready", + } + response = await self.server.runtime_optimization_job_cancel(request()) + self.assertEqual(response.status, 409) + + self.server.optimization_jobs[job_id]["status"] = "running" + response = await self.server.runtime_optimization_job_cancel(request()) + self.assertEqual(response.status, 409) + cancel.assert_not_called() + + async def test_optional_get_is_truthful_and_redacts_active_install_owner(self): + active = { + "ownerKind": "optional_runtime", + "ownerId": r"C:\private\runtime", + "token": "secret-install-token", + "command": ["uv", "--token", "secret-install-token"], + } + with mock.patch( + "modiff.optimization_packages.active_install", return_value=active + ): + response = await self.server.runtime_optional_runtimes(object()) + body = response_json(response) + profile = body["profiles"][0] + self.assertEqual(len(profile["stagedRequirements"]), 10) + self.assertEqual(len(profile["artifactLocks"]), 60) + self.assertTrue(all(lock["byteSize"] > 0 for lock in profile["artifactLocks"])) + self.assertFalse(profile["installActionAvailable"]) + self.assertFalse(profile["activationAvailable"]) + self.assertFalse(profile["cutoverReady"]) + self.assertEqual( + body["activeInstallJob"], + {"ownerKind": "optional_runtime", "ownerId": None}, + ) + serialized = json.dumps(body) + self.assertNotIn("secret-install-token", serialized) + self.assertNotIn(r"C:\private", serialized) + + async def test_legacy_install_and_enable_reject_unqualified_package_before_mutation(self): + catalog = { + "capabilities": [ + { + "id": "torchao", + "kind": "package", + "compatible": True, + "canInstall": False, + "canEnable": False, + "installed": False, + "disabledReason": "Immutable artifact lock is unavailable.", + } + ] + } + gate = mock.Mock(side_effect=AssertionError("gate must not be reserved")) + lease = mock.Mock(side_effect=AssertionError("lease must not be reserved")) + persist = mock.Mock(side_effect=AssertionError("job must not be persisted")) + mutate = mock.Mock(side_effect=AssertionError("state must not be mutated")) + with ( + mock.patch.object( + self.server, + "_optimization_runtime_context", + return_value=({}, {}, {}), + ), + mock.patch.object( + server_module, "public_optimization_catalog", return_value=catalog + ), + mock.patch.object(self.server, "_reserve_worker_runtime_gate", gate), + mock.patch.object(server_module, "reserve_runtime_install", lease), + mock.patch.object(self.server, "_persist_optimization_job", persist), + mock.patch.object( + server_module, "set_optimization_capability_enabled", mutate + ), + ): + install_response = await self.server.runtime_optimization_install( + RawRequest(b'{"capabilityId":"torchao"}') + ) + enable_response = await self.server.runtime_optimization_enable( + JsonRequest({"capabilityId": "torchao", "enabled": True}) + ) + self.assertEqual(install_response.status, 400) + self.assertEqual(enable_response.status, 400) + gate.assert_not_called() + lease.assert_not_called() + persist.assert_not_called() + mutate.assert_not_called() + self.assertEqual(self.server.optimization_jobs, {}) + + async def test_restart_required_status_is_neutral_for_base_graph(self): + with ( + mock.patch.dict( + os.environ, {"MODIFF_RUNTIME_OVERLAY_STATUS": "restart_required"} + ), + mock.patch.object( + self.server, + "_auto_resource_runtime_block", + return_value=None, + ), + mock.patch.object( + self.server, + "queue_task", + new=mock.AsyncMock(return_value="task-fixture"), + ) as queue, + mock.patch.object( + self.server, + "_studio_preview_slots_for_task", + return_value={"previewSlots": [], "revision": 0}, + ), + ): + response = await self.server.graph(JsonRequest({"sid": "test"})) + + body = response_json(response) + self.assertEqual(response.status, 200) + self.assertEqual(body["task_id"], "task-fixture") + queue.assert_awaited_once() + + async def test_central_gate_rejects_recorded_work_and_blocks_graph_queue(self): + self.server.current_task["active"] = {"name": "Graph execution"} + with self.assertRaises(OverlayInstallBusy): + self.server._reserve_worker_runtime_gate("optional_runtime_install", "profile") + self.assertIsNone(self.server._runtime_mutation_gate) + + self.server.current_task.clear() + self.server.queued_tasks["queued"] = {"name": "Graph execution"} + with self.assertRaises(OverlayInstallBusy): + self.server._reserve_worker_runtime_gate("optional_runtime_install", "profile") + self.server.queued_tasks.clear() + + self.server._active_nonruntime_mutations = 1 + with self.assertRaises(OverlayInstallBusy): + self.server._reserve_worker_runtime_gate("optional_runtime_install", "profile") + self.server._active_nonruntime_mutations = 0 + + token = self.server._reserve_worker_runtime_gate( + "optional_runtime_install", "profile" + ) + response = await self.server.graph(JsonRequest({"sid": "test"})) + self.assertEqual(response.status, 409) + self.assertEqual(response_json(response)["error_code"], "runtime_mutation_busy") + future = asyncio.get_running_loop().create_future() + with self.assertRaises(OverlayInstallBusy): + await self.server.queue_task( + lambda: None, + (), + future, + "session", + name="Graph execution", + ) + self.server._release_worker_runtime_gate(token) + self.assertIsNone(self.server._runtime_mutation_gate) + + async def test_graph_middleware_admission_wins_install_handler_race(self): + admitted = asyncio.Event() + release = asyncio.Event() + graph_request = BlockingGraphRequest(admitted, release) + runtime_block = { + "issue": { + "message": "test graph stopped after admission", + "category": "test", + "code": "test_stop", + }, + "repairAction": {"label": "test", "action": "test"}, + "runtimeProfile": {}, + } + graph_task = None + with mock.patch.object( + self.server, "_auto_resource_runtime_block", return_value=runtime_block + ): + graph_task = asyncio.create_task( + self.server._mutation_origin_middleware( + graph_request, self.server.graph + ) + ) + try: + await asyncio.wait_for(admitted.wait(), timeout=2) + self.assertEqual(self.server._active_nonruntime_mutations, 1) + profile = OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + install_body = json.dumps( + { + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + } + ).encode("utf-8") + lease = mock.Mock( + side_effect=AssertionError("lease must not be reserved") + ) + persist = mock.Mock( + side_effect=AssertionError("job must not be persisted") + ) + with ( + mock.patch.object( + server_module, + "validate_optional_runtime_install_request", + return_value={}, + ) as validate, + mock.patch.object(server_module, "reserve_runtime_install", lease), + mock.patch.object(self.server, "_persist_optimization_job", persist), + ): + install_response = ( + await self.server.runtime_optional_runtime_install( + RawRequest(install_body) + ) + ) + + self.assertEqual(install_response.status, 409) + self.assertEqual( + response_json(install_response)["error_code"], + "optional_runtime_install_busy", + ) + validate.assert_called_once_with( + profile.id, profile.spec_digest, consent=True + ) + lease.assert_not_called() + persist.assert_not_called() + self.assertEqual(self.server.optimization_jobs, {}) + self.assertIsNone(self.server._runtime_mutation_gate) + finally: + release.set() + graph_response = await asyncio.wait_for(graph_task, timeout=2) + + self.assertEqual(graph_response.status, 409) + self.assertEqual(response_json(graph_response)["error_code"], "test_stop") + self.assertEqual(self.server._active_nonruntime_mutations, 0) + + async def test_paused_activation_handler_gate_wins_graph_and_field_action_race(self): + profile = OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID] + environment_id = "runtime-1-deadbeef" + activation_body = json.dumps( + { + "environmentId": environment_id, + "profileId": profile.id, + "specDigest": profile.spec_digest, + "consent": True, + } + ).encode("utf-8") + entered = asyncio.Event() + release = threading.Event() + loop = asyncio.get_running_loop() + + def paused_activation(*_args, **_kwargs): + loop.call_soon_threadsafe(entered.set) + if not release.wait(timeout=5): + raise RuntimeError("test activation barrier timed out") + return { + "environmentId": environment_id, + "state": {}, + "restartRequired": False, + } + + activation_task = None + with ( + mock.patch.object( + server_module, + "validate_optional_runtime_activation_request", + return_value={}, + ), + mock.patch.object( + server_module, + "activate_optional_runtime_environment", + side_effect=paused_activation, + ), + ): + activation_task = asyncio.create_task( + self.server.runtime_optional_runtime_activate( + RawRequest(activation_body) + ) + ) + try: + await asyncio.wait_for(entered.wait(), timeout=2) + self.assertIsNotNone(self.server._runtime_mutation_gate) + + queue_task = mock.AsyncMock( + side_effect=AssertionError("field action must not be queued") + ) + executor = mock.Mock( + side_effect=AssertionError("field action must not reach executor") + ) + self.server.loop = mock.Mock(run_in_executor=executor) + with mock.patch.object(self.server, "queue_task", queue_task): + graph_response = await self.server.graph( + JsonRequest({"sid": "race"}) + ) + direct_field_response = await self.server.field_action( + JsonRequest({"queue": False}) + ) + queued_field_response = await self.server.field_action( + JsonRequest({"queue": True}) + ) + + for response in ( + graph_response, + direct_field_response, + queued_field_response, + ): + self.assertEqual(response.status, 409) + self.assertEqual( + response_json(response)["error_code"], + "runtime_mutation_busy", + ) + queue_task.assert_not_called() + executor.assert_not_called() + self.assertEqual(self.server.queued_tasks, {}) + + middleware_handler = mock.AsyncMock( + side_effect=AssertionError("middleware must reject before handler") + ) + middleware_response = await self.server._mutation_origin_middleware( + MiddlewareRequest({"queue": False}, path="/fields/action"), + middleware_handler, + ) + self.assertEqual(middleware_response.status, 409) + self.assertEqual( + response_json(middleware_response)["error_code"], + "runtime_mutation_busy", + ) + middleware_handler.assert_not_called() + finally: + release.set() + activation_response = await asyncio.wait_for(activation_task, timeout=2) + + self.assertEqual(activation_response.status, 200) + self.assertIsNone(self.server._runtime_mutation_gate) + + def test_public_job_and_receipt_drop_paths_tokens_stderr_and_free_text(self): + secret_token = "hf_secret_token_material" + secret_path = r"C:\Users\operator\private\model.safetensors" + secret_stderr = "installer stderr with credentials" + secret_free_text = "unreviewed operator supplied message" + digest = "sha256:" + ("a" * 64) + job = { + "id": "optjob-abcdefghijkl", + "kind": "optional_runtime", + "profileId": TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + "specDigest": digest, + "status": "failed", + "createdAt": 1.0, + "updatedAt": 2.0, + "progress": { + "phase": "failed", + "message": secret_free_text, + "updatedAt": 2.0, + "path": secret_path, + }, + "error": secret_stderr, + "token": secret_token, + "stderr": secret_stderr, + "command": ["uv", "--token", secret_token], + "result": { + "environmentId": "runtime-1-deadbeef", + "specs": [ + { + "kind": "optional_runtime", + "id": TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + "specDigest": digest, + "path": secret_path, + } + ], + "requiresActivation": True, + "activeRuntimeChanged": False, + "path": secret_path, + "token": secret_token, + "stderr": secret_stderr, + "freeText": secret_free_text, + }, + } + receipt = { + "id": "probe-" + ("b" * 32), + "kind": "compatibility_probe", + "status": "probe_failed", + "capabilityId": "torchao", + "environmentId": "runtime-1-deadbeef", + "createdAt": "2026-08-10T12:00:00Z", + "qualifiedAt": None, + "autoEligible": False, + "result": { + "status": "failed", + "path": secret_path, + "token": secret_token, + "stderr": secret_stderr, + "message": secret_free_text, + }, + "path": secret_path, + "token": secret_token, + "stderr": secret_stderr, + "message": secret_free_text, + } + + public_job = WebServer._public_runtime_job(job) + public_receipt = WebServer._public_optimization_receipt(receipt) + serialized = json.dumps([public_job, public_receipt]) + + self.assertIsNotNone(public_job) + self.assertIsNotNone(public_receipt) + self.assertEqual( + public_job["progress"]["message"], + "Installation failed; the active environment was unchanged.", + ) + self.assertEqual(public_job["error"], "Optional-runtime installation failed.") + for secret in (secret_token, secret_path, secret_stderr, secret_free_text): + self.assertNotIn(secret, serialized) + self.assertIsNone( + WebServer._public_runtime_job({**job, "id": "optjob-invalid"}) + ) + self.assertIsNone( + WebServer._public_optimization_receipt({**receipt, "id": "probe-invalid"}) + ) + + def test_job_fsm_rejects_regression_and_terminal_rewrites(self): + job_id = "optjob-abcdefghijkl" + self.server.optimization_jobs[job_id] = { + "id": job_id, + "kind": "optional_runtime", + "status": "queued", + "createdAt": 1.0, + "updatedAt": 1.0, + } + with mock.patch.object(self.server, "_persist_optimization_job") as persist: + self.server._update_optimization_job(job_id, status="running") + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "running") + self.server._update_optimization_job(job_id, status="queued") + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "running") + self.server._update_optimization_job(job_id, status="ready") + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "ready") + self.server._update_optimization_job(job_id, status="failed") + self.server._update_optimization_job(job_id, status="running") + + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "ready") + self.assertEqual(persist.call_count, 2) + + def test_job_persistence_failure_does_not_publish_new_state(self): + job_id = "optjob-abcdefghijkl" + original = { + "id": job_id, + "kind": "optional_runtime", + "status": "queued", + "createdAt": 1.0, + "updatedAt": 1.0, + } + self.server.optimization_jobs[job_id] = original + + def fail_before_publish(candidate): + self.assertIs(self.server.optimization_jobs[job_id], original) + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "queued") + self.assertEqual(candidate["status"], "running") + raise OSError("simulated durable write failure") + + with mock.patch.object( + self.server, "_persist_optimization_job", side_effect=fail_before_publish + ): + self.server._update_optimization_job(job_id, status="running") + + self.assertIs(self.server.optimization_jobs[job_id], original) + self.assertEqual(self.server.optimization_jobs[job_id]["status"], "queued") + self.assertEqual(os.environ["MODIFF_RUNTIME_OVERLAY_STATUS"], "repair_required") + + def test_constructor_reconciles_interrupted_job_to_durable_failure(self): + data_root = Path(self.temporary.name) / "reconciliation" + job_root = data_root / "runtime" / "optimization-jobs" + job_root.mkdir(parents=True) + job_id = "optjob-abcdefghijkl" + path = job_root / f"{job_id}.json" + path.write_text( + json.dumps( + { + "id": job_id, + "kind": "optional_runtime", + "profileId": TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + "status": "running", + "progress": {"phase": "installing", "updatedAt": 1.0}, + "createdAt": 1.0, + "updatedAt": 1.0, + } + ), + encoding="utf-8", + ) + + restarted = WebServer(modules={}, work_dir=str(data_root), data_dir=str(data_root)) + persisted = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual(restarted.optimization_jobs[job_id]["status"], "failed") + self.assertEqual(persisted["status"], "failed") + self.assertEqual( + persisted["progress"]["message"], + "The prior worker exited before this installation completed.", + ) + + def test_configured_data_root_symlink_is_rejected(self): + parent = Path(self.temporary.name) + target = parent / "real-data" + link = parent / "linked-data" + target.mkdir() + try: + link.symlink_to(target, target_is_directory=True) + except (NotImplementedError, OSError) as exc: + self.skipTest(f"directory symlinks are unavailable: {exc}") + self.server.data_dir = str(link) + + with self.assertRaisesRegex(OSError, "configured runtime data root is unsafe"): + self.server._runtime_job_root(create=False) + + @unittest.skipUnless(os.name == "nt", "Windows junction regression") + def test_configured_data_root_windows_junction_is_rejected(self): + parent = Path(self.temporary.name) + target = parent / "junction-target" + junction = parent / "junction-data" + target.mkdir() + command_processor = os.environ.get("ComSpec") or r"C:\Windows\System32\cmd.exe" + created = subprocess.run( + [command_processor, "/d", "/c", "mklink", "/J", str(junction), str(target)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if created.returncode != 0 or not junction.exists(): + self.skipTest(f"could not create a test junction: {created.stderr.strip()}") + try: + self.server.data_dir = str(junction) + with self.assertRaisesRegex( + OSError, "configured runtime data root is unsafe" + ): + self.server._runtime_job_root(create=False) + finally: + os.rmdir(junction) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_optional_runtimes.py b/tests/test_optional_runtimes.py new file mode 100644 index 0000000..7e25085 --- /dev/null +++ b/tests/test_optional_runtimes.py @@ -0,0 +1,501 @@ +import builtins +import hashlib +from importlib import metadata +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from modiff.auto_resource import build_auto_resource_plan +from modiff.diffusers_profiles import ( + DIFFUSERS_EXECUTION_PROFILES, + optional_runtime_profile_ids_for_execution, + public_execution_profiles, +) +from modiff.optional_runtimes import ( + OPTIONAL_RUNTIME_PROFILES, + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + optional_runtime_requirements, + public_optional_runtime_profiles, +) +from modiff.server import WebServer + + +GIB = 1024**3 +OPTIONAL_STAGE_IMPORTS = { + "transformers", + "peft", + "tokenizers", + "typer", + "annotated_doc", + "rich", + "markdown_it", + "mdurl", + "pygments", + "shellingham", +} + + +def _version_resolver(versions): + def resolve(distribution): + if distribution not in versions: + raise metadata.PackageNotFoundError(distribution) + return versions[distribution] + + return resolve + + +def _walk_graph_files(items): + for item in items: + if item.get("isDir"): + yield from _walk_graph_files(item.get("children") or []) + else: + yield item + + +def _cpu_hardware(): + return { + "runtimeFingerprint": "optional-runtime-test", + "platform": "linux", + "architecture": "x86_64", + "accelerator": { + "kind": "cpu", + "name": "Mock CPU", + "totalBytes": 0, + "freeBytes": 0, + "capability": None, + "band": "cpu", + }, + "systemMemory": { + "totalBytes": 32 * GIB, + "availableBytes": 28 * GIB, + "pageFileTotalBytes": None, + "pageFileAvailableBytes": None, + }, + "offloadDisk": { + "path": "unit-test", + "totalBytes": 256 * GIB, + "freeBytes": 128 * GIB, + }, + } + + +class OptionalRuntimeContractTests(unittest.TestCase): + def test_composite_contract_is_exact_hashed_and_never_claims_cutover_readiness(self): + versions = {"transformers": "5.14.1", "peft": "0.20.0"} + profile = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver(versions), + )[0] + + self.assertEqual(profile["schemaVersion"], 1) + self.assertEqual(profile["contractState"], "candidate_unqualified") + self.assertFalse(profile["cutoverReady"]) + self.assertFalse(profile["installActionAvailable"]) + self.assertFalse(profile["activationAvailable"]) + self.assertEqual(profile["installPolicy"], "explicit_first_use") + self.assertEqual(profile["status"], "present_unqualified") + self.assertEqual( + profile["requirements"], + ["transformers==5.14.1", "peft==0.20.0"], + ) + self.assertEqual(len(profile["artifactLocks"]), 60) + canonical_spec = json.dumps( + OPTIONAL_RUNTIME_PROFILES[TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID].to_spec_dict(), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + self.assertEqual( + profile["specDigest"], + f"sha256:{hashlib.sha256(canonical_spec).hexdigest()}", + ) + self.assertTrue( + all(package["publisher"] == "Hugging Face" for package in profile["packages"]) + ) + self.assertEqual( + [package["projectUrl"] for package in profile["packages"]], + [ + "https://github.com/huggingface/transformers", + "https://github.com/huggingface/peft", + ], + ) + self.assertTrue( + all(package["license"] == "Apache-2.0" for package in profile["packages"]) + ) + self.assertTrue( + all(package["distributionUrl"].startswith("https://pypi.org/project/") for package in profile["packages"]) + ) + self.assertTrue( + all(package["status"] == "present_unqualified" for package in profile["packages"]) + ) + + different_host = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver({"transformers": "0.0.0"}), + )[0] + self.assertEqual(different_host["specDigest"], profile["specDigest"]) + + def test_status_distinguishes_missing_wrong_version_and_present_unqualified(self): + missing_and_wrong = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver({"transformers": "4.49.0"}), + )[0] + self.assertEqual(missing_and_wrong["status"], "missing") + package_status = { + package["distribution"]: package["status"] + for package in missing_and_wrong["packages"] + } + self.assertEqual( + package_status, + {"transformers": "wrong_version", "peft": "missing"}, + ) + + wrong_version = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver( + {"transformers": "5.14.1", "peft": "0.19.0"} + ), + )[0] + self.assertEqual(wrong_version["status"], "wrong_version") + + present = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver( + {"transformers": "5.14.1", "peft": "0.20.0"} + ), + )[0] + self.assertEqual(present["status"], "present_unqualified") + + def test_observed_versions_are_bounded_before_publication(self): + hostile_version = "9." + ("x" * 500) + "\nprivate-path" + profile = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=_version_resolver( + {"transformers": hostile_version, "peft": "0.20.0"} + ), + )[0] + transformers = next( + package + for package in profile["packages"] + if package["distribution"] == "transformers" + ) + self.assertEqual(transformers["status"], "wrong_version") + self.assertLessEqual(len(transformers["installedVersion"]), 128) + self.assertNotIn("\n", transformers["installedVersion"]) + self.assertNotIn("private-path", transformers["installedVersion"]) + + def test_unreadable_distribution_metadata_fails_closed_without_echoing_error(self): + def unreadable_metadata(distribution): + if distribution == "transformers": + raise ValueError("private-path/invalid.dist-info") + return "0.20.0" + + profile = public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=unreadable_metadata, + )[0] + transformers = next( + package + for package in profile["packages"] + if package["distribution"] == "transformers" + ) + self.assertEqual(profile["status"], "wrong_version") + self.assertEqual(transformers["status"], "wrong_version") + self.assertEqual(transformers["metadataState"], "unreadable") + self.assertNotIn("private-path", json.dumps(profile)) + + def test_unexpected_metadata_assertion_propagates_to_purity_trap(self): + def forbidden_import_sentinel(_distribution): + raise AssertionError("optional package import attempted") + + with self.assertRaisesRegex(AssertionError, "optional package import attempted"): + public_optional_runtime_profiles( + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + version_resolver=forbidden_import_sentinel, + ) + + def test_unknown_profile_id_fails_closed(self): + with self.assertRaisesRegex(ValueError, "Unknown optional runtime profile"): + public_optional_runtime_profiles(["unknown-runtime"]) + with self.assertRaisesRegex(ValueError, "Unknown optional runtime profile"): + optional_runtime_requirements(["unknown-runtime"]) + + def test_execution_profiles_reference_the_central_composite(self): + expected = (TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID,) + self.assertTrue(DIFFUSERS_EXECUTION_PROFILES) + for profile in DIFFUSERS_EXECUTION_PROFILES.values(): + with self.subTest(profile=profile.id): + self.assertEqual(profile.optional_runtime_profiles, expected) + for mode in profile.modes: + self.assertIn( + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + optional_runtime_profile_ids_for_execution( + profile.model_type, + mode, + ), + ) + + public_profile = public_execution_profiles()[0] + self.assertEqual( + public_profile["optional_runtime_profiles"], + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + ) + self.assertNotIn("optionalRuntimeProfiles", public_profile) + + def test_optional_metadata_state_does_not_change_auto_readiness(self): + observations = { + "present_unqualified": _version_resolver( + {"transformers": "5.14.1", "peft": "0.20.0"} + ), + "wrong_version": _version_resolver( + {"transformers": "5.14.1", "peft": "0.19.0"} + ), + "missing": _version_resolver({}), + } + outcomes = {} + with tempfile.TemporaryDirectory() as temp_dir: + for expected_status, resolver in observations.items(): + with patch("modiff.optional_runtimes.metadata.version", side_effect=resolver): + plan = build_auto_resource_plan( + { + "form": { + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + }, + "hardwareOverride": _cpu_hardware(), + }, + runtime_fingerprint=None, + local_models=["Tongyi-MAI/Z-Image-Turbo"], + data_dir=temp_dir, + ) + self.assertEqual( + plan["optionalRuntimeProfiles"][0]["status"], + expected_status, + ) + outcomes[expected_status] = ( + plan["status"], + plan["readiness"], + plan["canAutoRun"], + plan["selectedCandidate"]["id"], + ) + + self.assertEqual(len(set(outcomes.values())), 1) + self.assertEqual( + outcomes["missing"][:3], + ("ready", "ready", True), + ) + + def test_cold_clean_base_registry_discovery_does_not_load_optional_packages(self): + script = r''' +import builtins +import importlib.util +import sys + +original_import = builtins.__import__ +original_find_spec = importlib.util.find_spec +attempts = [] + +def clean_base_find_spec(name, *args, **kwargs): + if name.split(".", 1)[0] in __OPTIONAL_IMPORTS__: + return None + return original_find_spec(name, *args, **kwargs) + +def guarded_import(name, *args, **kwargs): + if name.split(".", 1)[0] in __OPTIONAL_IMPORTS__: + attempts.append(name) + raise ImportError(f"forbidden optional import: {name}") + return original_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import +importlib.util.find_spec = clean_base_find_spec +import modules +assert modules.MODULE_MAP +assert not any( + name.split(".", 1)[0] in __OPTIONAL_IMPORTS__ + for name in sys.modules +), attempts +'''.replace("__OPTIONAL_IMPORTS__", repr(OPTIONAL_STAGE_IMPORTS)) + environment = os.environ.copy() + environment.update( + { + "HF_HUB_OFFLINE": "1", + "TRANSFORMERS_OFFLINE": "1", + "DIFFUSERS_OFFLINE": "1", + } + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=Path(__file__).resolve().parents[1], + env=environment, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +class OptionalRuntimePublicationTests(unittest.IsolatedAsyncioTestCase): + async def test_auto_capabilities_and_listgraphs_are_non_installing_and_import_free(self): + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + graph_dir = data_dir / "graphs" / "studio" + graph_dir.mkdir(parents=True) + (graph_dir / "sample.json").write_text("{}", encoding="utf-8") + (data_dir / "workflow-library-manifest.json").write_text( + json.dumps( + { + "workflows": [ + { + "graphPath": "studio/sample.json", + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + "mediaKind": "image", + "supportTier": "supported", + "qualificationStatus": "graph-qualified", + "requiredArtifacts": ["Tongyi-MAI/Z-Image-Turbo"], + } + ] + } + ), + encoding="utf-8", + ) + server = WebServer({}, work_dir=temp_dir, data_dir=temp_dir) + + original_import = builtins.__import__ + + def guarded_import(name, *args, **kwargs): + if name.split(".", 1)[0] in OPTIONAL_STAGE_IMPORTS: + raise AssertionError(f"read-only metadata path imported {name}") + return original_import(name, *args, **kwargs) + + def forbidden_install(*_args, **_kwargs): + raise AssertionError("read-only metadata path called an installer") + + with ( + patch("builtins.__import__", side_effect=guarded_import), + patch( + "modiff.optimization_packages.install_capability", + side_effect=forbidden_install, + ), + patch( + "modiff.server.install_optimization_capability", + side_effect=forbidden_install, + ), + patch( + "modiff.server.install_optional_runtime", + side_effect=forbidden_install, + ), + patch( + "modiff.server.activate_optional_runtime_environment", + side_effect=forbidden_install, + ), + patch( + "modiff.server.rollback_optional_runtime_environment", + side_effect=forbidden_install, + ), + patch( + "modiff.server.reserve_runtime_install", + side_effect=forbidden_install, + ), + patch( + "modiff.runtime_overlays.reserve_install", + side_effect=forbidden_install, + ), + patch( + "modiff.runtime_overlays.cache_locked_artifacts", + side_effect=forbidden_install, + ), + patch( + "modiff.optimization_packages._atomic_json", + side_effect=forbidden_install, + ), + patch("pathlib.Path.mkdir", side_effect=forbidden_install), + patch("pathlib.Path.write_text", side_effect=forbidden_install), + patch("modiff.server.validate_studio_execution_specs", return_value=[]), + patch("urllib.request.urlopen", side_effect=forbidden_install), + patch("subprocess.Popen", side_effect=forbidden_install), + patch("subprocess.run", side_effect=forbidden_install), + ): + plan = build_auto_resource_plan( + { + "form": { + "modelType": "ZImageModularPipeline", + "mode": "text_to_image", + }, + "hardwareOverride": _cpu_hardware(), + }, + runtime_fingerprint=None, + local_models=[], + data_dir=temp_dir, + ) + capabilities_response = await server.model_capabilities( + type("Request", (), {"query": {}})() + ) + listgraphs_response = await server.listgraphs(object()) + template_open_response = await server.fileGet( + type( + "Request", + (), + {"query": {"file": "graphs/studio/sample.json"}}, + )() + ) + optional_runtime_response = await server.runtime_optional_runtimes(object()) + + self.assertEqual( + plan["optionalRuntimeProfileIds"], + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + ) + self.assertEqual( + plan["candidates"][0]["optionalRuntimeProfileIds"], + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + ) + # This metadata-only slice must not affect the existing Auto result. + self.assertEqual(plan["canAutoRun"], bool(plan["selectedCandidate"])) + self.assertEqual( + plan["optionalRuntimeProfiles"][0]["contractState"], + "candidate_unqualified", + ) + self.assertFalse(plan["optionalRuntimeProfiles"][0]["cutoverReady"]) + self.assertEqual(template_open_response.status, 200) + optional_runtime_catalog = json.loads(optional_runtime_response.text) + self.assertEqual( + optional_runtime_catalog["profiles"][0]["contractState"], + "candidate_unqualified", + ) + self.assertFalse(optional_runtime_catalog["profiles"][0]["cutoverReady"]) + + capabilities = json.loads(capabilities_response.text) + self.assertEqual( + capabilities["optionalRuntimeProfiles"][0]["id"], + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID, + ) + z_image = next( + capability + for capability in capabilities["capabilities"] + if capability["modelType"] == "ZImageModularPipeline" + ) + self.assertEqual( + z_image["optionalRuntimeProfileIds"], + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + ) + + graph_tree = json.loads(listgraphs_response.text) + graph_file = next(_walk_graph_files(graph_tree)) + self.assertEqual( + graph_file["optionalRuntimeProfileIds"], + [TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID], + ) + self.assertEqual( + graph_file["optionalRuntimeProfiles"][0]["contractState"], + "candidate_unqualified", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pipeline_schema.py b/tests/test_pipeline_schema.py index 8ebdfd3..68df3d2 100644 --- a/tests/test_pipeline_schema.py +++ b/tests/test_pipeline_schema.py @@ -2,7 +2,9 @@ import unittest from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch +from modules.ModularDiffusers.modular_utils import require_modiff_node_contract from modules.ModularDiffusers.pipeline_schema import MoDiffParam, MoDiffPipelineConfig, input_param_to_modiff_param @@ -64,6 +66,64 @@ def test_config_round_trip_uses_modiff_owned_filename(self): self.assertTrue(config_path.is_file()) self.assertEqual(MoDiffPipelineConfig.load(directory).to_dict(), config.to_dict()) + def test_default_vae_encoder_projects_only_an_upstream_generator_to_a_seed_field(self): + for upstream_inputs, expected_inputs in ( + (["image", "generator"], ["image", "seed"]), + (["image"], ["image"]), + ): + with self.subTest(upstream_inputs=upstream_inputs): + block = SimpleNamespace( + input_names=upstream_inputs, + intermediate_output_names=["image_latents"], + component_names=["vae"], + ) + blocks = SimpleNamespace(sub_blocks={"vae_encoder": block}) + + node_config = MoDiffPipelineConfig.from_blocks(blocks).node_params["vae_encoder"] + + self.assertEqual(node_config["input_names"], expected_inputs) + self.assertNotIn("generator", node_config["params"]) + if "generator" in upstream_inputs: + self.assertEqual(node_config["params"]["seed"]["min"], 0) + self.assertEqual(node_config["params"]["seed"]["max"], 4294967295) + + def test_resolved_node_contract_does_not_mutate_deserialized_custom_config(self): + config = MoDiffPipelineConfig.from_dict( + { + "label": "Custom fixture", + "node_params": { + "denoise": { + "block_name": "denoise", + "params": { + "unet": {"label": "Denoiser", "type": "diffusers_auto_model"}, + "steps": {"label": "Steps", "type": "int", "default": 4}, + }, + "input_names": ["steps"], + "model_input_names": ["unet"], + "output_names": ["latents"], + } + }, + } + ) + block = object() + + class CustomPipeline: + def __init__(self): + self.blocks = SimpleNamespace(sub_blocks={"denoise": block}) + + registry = SimpleNamespace(get=lambda _pipeline_class: config) + with patch("modules.ModularDiffusers.modular_utils._get_registry_instance", return_value=registry): + resolved_blocks, first = require_modiff_node_contract(CustomPipeline, "denoise") + first["params"].pop("unet") + first["params"]["steps"]["default"] = 99 + _, second = require_modiff_node_contract(CustomPipeline, "denoise") + + self.assertIs(resolved_blocks, block) + self.assertIn("unet", config.node_params["denoise"]["params"]) + self.assertEqual(config.node_params["denoise"]["params"]["steps"]["default"], 4) + self.assertIn("unet", second["params"]) + self.assertEqual(second["params"]["steps"]["default"], 4) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_overlays.py b/tests/test_runtime_overlays.py new file mode 100644 index 0000000..47733c5 --- /dev/null +++ b/tests/test_runtime_overlays.py @@ -0,0 +1,1017 @@ +from dataclasses import replace +import base64 +import csv +import hashlib +import io +import os +from pathlib import Path, PurePosixPath +import stat +import subprocess +import sys +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest import mock +import zipfile + +from modiff import optimization_packages +from modiff import runtime_overlays +from modiff import install as modiff_install +from modiff.optional_runtimes import TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + + +class RuntimeOverlayArtifactTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.archive_root = self.root / "artifacts" + self.archive_root.mkdir() + self.site_packages = self.root / "site-packages" + self.site_packages.mkdir() + + def tearDown(self): + self.temporary.cleanup() + + @staticmethod + def _with_complete_record(members, dist_info): + record_name = f"{dist_info}/RECORD" + files = [(name, body, *details) for name, body, *details in members if name != record_name] + rows = [] + for name, body, *_details in files: + digest = base64.urlsafe_b64encode(hashlib.sha256(body).digest()).rstrip(b"=").decode("ascii") + rows.append((name, f"sha256={digest}", str(len(body)))) + rows.append((record_name, "", "")) + output = io.StringIO(newline="") + csv.writer(output, lineterminator="\n").writerows(rows) + return [*files, (record_name, output.getvalue().encode("utf-8"))] + + @staticmethod + def _default_members( + *, + dist_info="demo_pkg-1.0.0.dist-info", + metadata_name="demo-pkg", + metadata_version="1.0.0", + entry_points=False, + ): + members = [ + ("demo_pkg/__init__.py", b"VALUE = 'reviewed'\n"), + ( + f"{dist_info}/METADATA", + ( + "Metadata-Version: 2.1\n" + f"Name: {metadata_name}\n" + f"Version: {metadata_version}\n\n" + ).encode("utf-8"), + ), + ( + f"{dist_info}/WHEEL", + ( + "Wheel-Version: 1.0\n" + "Generator: MoDiff test fixture\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n\n" + ).encode("utf-8"), + ), + ] + if entry_points: + members.append( + ( + f"{dist_info}/entry_points.txt", + b"[console_scripts]\ndemo-tool = demo_pkg:main\n", + ) + ) + return RuntimeOverlayArtifactTests._with_complete_record(members, dist_info) + + @staticmethod + def _write_member(wheel, name, body, *, mode=stat.S_IFREG | 0o644): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = mode << 16 + wheel.writestr(info, body) + + def _create_locked_wheel(self, members=None): + filename = "demo_pkg-1.0.0-py3-none-any.whl" + return self._store_locked_wheel( + distribution="demo-pkg", + version="1.0.0", + filename=filename, + members=members or self._default_members(), + ) + + def _store_locked_wheel(self, *, distribution, version, filename, members): + source = self.root / filename + with zipfile.ZipFile(source, "w", compression=zipfile.ZIP_DEFLATED) as wheel: + for member in members: + if len(member) == 2: + name, body = member + mode = stat.S_IFREG | 0o644 + else: + name, body, mode = member + self._write_member(wheel, name, body, mode=mode) + digest = hashlib.sha256(source.read_bytes()).hexdigest() + artifact = { + "distribution": distribution, + "version": version, + "filename": filename, + "url": f"https://files.example.invalid/{filename}", + "sha256": digest, + "byteSize": source.stat().st_size, + "platform": "any", + "pythonTag": "py3", + "machine": "any", + } + destination = self.archive_root / digest / filename + destination.parent.mkdir() + source.replace(destination) + return artifact, destination + + def _create_distribution_wheel(self, distribution, version): + wheel_name = distribution.replace("-", "_").replace(".", "_") + dist_info = f"{wheel_name}-{version}.dist-info" + members = [ + (f"{wheel_name}/__init__.py", f"NAME = {distribution!r}\n".encode("utf-8")), + ( + f"{dist_info}/METADATA", + ( + "Metadata-Version: 2.1\n" + f"Name: {distribution}\n" + f"Version: {version}\n\n" + ).encode("utf-8"), + ), + ( + f"{dist_info}/WHEEL", + ( + "Wheel-Version: 1.0\n" + "Generator: MoDiff closure fixture\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n\n" + ).encode("utf-8"), + ), + ] + members = self._with_complete_record(members, dist_info) + return self._store_locked_wheel( + distribution=distribution, + version=version, + filename=f"{wheel_name}-{version}-py3-none-any.whl", + members=members, + ) + + def _extract(self, archive): + with zipfile.ZipFile(archive) as wheel: + for info in wheel.infolist(): + if info.is_dir(): + continue + relative = Path(*PurePosixPath(info.filename).parts) + destination = self.site_packages / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(wheel.read(info)) + + def assert_invalid_wheel(self, members): + artifact, _archive = self._create_locked_wheel(members) + with self.assertRaises(RuntimeError): + runtime_overlays.locked_artifact_file_seal([artifact], self.archive_root) + + def test_valid_locked_wheel_verifies_exact_archive_and_extraction(self): + artifact, archive = self._create_locked_wheel() + self._extract(archive) + + expected_seal = runtime_overlays.locked_artifact_file_seal( + [artifact], self.archive_root + ) + anchor = runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + self.assertEqual(anchor["schemaVersion"], 1) + self.assertEqual(anchor["artifacts"], [artifact]) + self.assertEqual(anchor["fileSeal"], expected_seal) + self.assertRegex(anchor["digest"], r"^sha256:[0-9a-f]{64}$") + + def test_complete_candidate_closure_reanchors_retained_cache_before_imports(self): + profile = optimization_packages.OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + expected_versions = { + "transformers": "5.14.1", + "peft": "0.20.0", + "tokenizers": "0.22.2", + "typer": "0.27.1", + "annotated-doc": "0.0.5", + "rich": "15.0.0", + "markdown-it-py": "4.2.0", + "mdurl": "0.1.2", + "pygments": "2.20.0", + "shellingham": "1.5.4", + } + self.assertEqual( + {package.distribution: package.version for package in profile.packages}, + expected_versions, + ) + + artifacts = [] + archives = [] + individual_union = {} + for distribution, version in expected_versions.items(): + artifact, archive = self._create_distribution_wheel(distribution, version) + artifacts.append(artifact) + archives.append(archive) + self._extract(archive) + individual_seal = runtime_overlays.locked_artifact_file_seal( + [artifact], self.archive_root + ) + self.assertTrue(individual_union.keys().isdisjoint(individual_seal)) + individual_union.update(individual_seal) + + closure_seal = runtime_overlays.locked_artifact_file_seal( + artifacts, self.archive_root + ) + self.assertEqual(closure_seal, dict(sorted(individual_union.items()))) + self.assertEqual(len(closure_seal), 4 * len(expected_versions)) + first_anchor = runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, artifacts, self.archive_root + ) + self.assertEqual(first_anchor["fileSeal"], closure_seal) + + lease = runtime_overlays.InstallLease( + token="retained-cache", + owner_kind="test", + owner_id="closure", + cancel_event=threading.Event(), + ) + opener = mock.Mock() + with mock.patch.object(runtime_overlays, "build_opener", return_value=opener): + retained = runtime_overlays.cache_locked_artifacts( + artifacts, self.archive_root, lease=lease + ) + opener.open.assert_not_called() + self.assertEqual(retained, archives) + retained_anchor = runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, artifacts, self.archive_root + ) + self.assertEqual(retained_anchor, first_anchor) + + archives[-1].write_bytes(b"replacement archive") + with ( + mock.patch.object( + runtime_overlays.importlib, + "import_module", + side_effect=AssertionError("archive identity must fail before imports"), + ), + self.assertRaisesRegex(RuntimeError, "catalog identity"), + ): + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, artifacts, self.archive_root + ) + + def test_artifact_filenames_and_hashes_are_part_of_the_profile_spec_digest(self): + profile = optimization_packages.OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + artifacts = [] + for package in profile.packages: + artifact, _archive = self._create_distribution_wheel( + package.distribution, package.version + ) + artifacts.append(artifact) + locked = replace(profile, artifact_locks=tuple(artifacts)) + self.assertEqual(locked.to_spec_dict()["artifactLocks"], artifacts) + self.assertNotEqual(locked.spec_digest, profile.spec_digest) + + changed_artifacts = [dict(item) for item in artifacts] + changed_artifacts[0]["sha256"] = "0" * 64 + changed = replace(locked, artifact_locks=tuple(changed_artifacts)) + self.assertNotEqual(changed.spec_digest, locked.spec_digest) + self.assertNotEqual( + changed.to_spec_dict()["artifactLocks"][0]["sha256"], + locked.to_spec_dict()["artifactLocks"][0]["sha256"], + ) + + def test_optional_runtime_has_one_complete_wheel_closure_for_every_supported_target(self): + from packaging import tags + + profile = optimization_packages.OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + targets = { + ("linux", "x86_64"): "manylinux_2_17_x86_64", + ("linux", "arm64"): "manylinux_2_17_aarch64", + ("macos", "x86_64"): "macosx_10_12_x86_64", + ("macos", "arm64"): "macosx_11_0_arm64", + ("windows", "x86_64"): "win_amd64", + ("windows", "arm64"): "win_arm64", + } + for (platform_name, machine), wheel_platform in targets.items(): + supported = set( + tags.cpython_tags( + python_version=(3, 12), + abis=["cp312"], + platforms=[wheel_platform], + ) + ) | set( + tags.compatible_tags( + python_version=(3, 12), + interpreter="cp312", + platforms=[wheel_platform], + ) + ) + with ( + self.subTest(platform=platform_name, machine=machine), + mock.patch.object(optimization_packages, "_platform_name", return_value=platform_name), + mock.patch.object(optimization_packages, "_machine_name", return_value=machine), + mock.patch.object(tags, "sys_tags", return_value=iter(supported)), + ): + selected = optimization_packages._artifact_install_plan(profile) + self.assertEqual(len(selected), 10) + self.assertEqual( + [item["distribution"] for item in selected], + [package.distribution for package in profile.packages], + ) + self.assertTrue(all(item["byteSize"] > 0 for item in selected)) + + malformed = [dict(item) for item in profile.artifact_locks] + for item in malformed: + if item["platform"] == "windows" and item["machine"] == "x86_64": + item["byteSize"] = 0 + break + windows_tags = set( + tags.cpython_tags( + python_version=(3, 12), abis=["cp312"], platforms=["win_amd64"] + ) + ) | set( + tags.compatible_tags( + python_version=(3, 12), interpreter="cp312", platforms=["win_amd64"] + ) + ) + with ( + mock.patch.object(optimization_packages, "_platform_name", return_value="windows"), + mock.patch.object(optimization_packages, "_machine_name", return_value="x86_64"), + mock.patch.object(tags, "sys_tags", return_value=iter(windows_tags)), + self.assertRaisesRegex(RuntimeError, "artifact lock is invalid"), + ): + optimization_packages._artifact_install_plan(replace(profile, artifact_locks=tuple(malformed))) + + def test_base_installer_records_the_exact_uv_executable_for_overlay_reuse(self): + managed = self.root / "tool-managed" + tool_root = managed / "tools" / "uv" + tool_root.mkdir(parents=True) + executable = tool_root / "uv.exe" + executable.write_bytes(b"reviewed-uv-test-binary") + digest = hashlib.sha256(executable.read_bytes()).hexdigest() + lock = { + "url": "https://github.com/astral-sh/uv/releases/download/test/uv.zip", + "archiveSha256": "a" * 64, + "executable": "uv.exe", + "executableSha256": digest, + } + with ( + mock.patch.object(modiff_install, "MANAGED_ROOT", managed), + mock.patch.object(modiff_install, "UV_TOOL_LOCKS", {("windows", "x86_64"): lock}), + mock.patch.object(modiff_install, "normalized_os", return_value="windows"), + mock.patch.object(modiff_install, "normalized_arch", return_value="x86_64"), + ): + self.assertEqual(Path(modiff_install._ensure_uv()), executable) + receipt = (tool_root / "receipt.json").read_text(encoding="utf-8") + self.assertIn(digest, receipt) + with ( + mock.patch.object(optimization_packages, "MANAGED_ROOT", managed), + mock.patch.object(optimization_packages, "UV_TOOL_LOCKS", {("windows", "x86_64"): lock}), + mock.patch.object(optimization_packages, "_platform_name", return_value="windows"), + mock.patch.object(optimization_packages.platform, "machine", return_value="AMD64"), + ): + self.assertEqual(Path(optimization_packages._verified_uv_executable()), executable) + executable.write_bytes(b"tampered") + with self.assertRaisesRegex(RuntimeError, "integrity check"): + optimization_packages._verified_uv_executable() + + def test_normalization_removes_only_known_receipts_and_generated_scripts(self): + artifact, archive = self._create_locked_wheel( + self._default_members(entry_points=True) + ) + with zipfile.ZipFile(archive) as wheel: + reviewed_record = wheel.read("demo_pkg-1.0.0.dist-info/RECORD") + self._extract(archive) + dist_info = self.site_packages / "demo_pkg-1.0.0.dist-info" + (dist_info / "RECORD").write_bytes(b"installer-rewritten-record\n") + for receipt in ("INSTALLER", "direct_url.json", "REQUESTED", "uv_cache.json"): + (dist_info / receipt).write_bytes(b"installer generated\n") + (self.site_packages / ".lock").write_bytes(b"installer lock\n") + script = self.site_packages / "bin" / "demo-tool" + script.parent.mkdir() + script.write_bytes(b"#!/usr/bin/env python\n") + + runtime_overlays.normalize_locked_wheel_install( + self.site_packages, [artifact], self.archive_root + ) + + self.assertEqual((dist_info / "RECORD").read_bytes(), reviewed_record) + for receipt in ("INSTALLER", "direct_url.json", "REQUESTED", "uv_cache.json"): + self.assertFalse((dist_info / receipt).exists()) + self.assertFalse((self.site_packages / ".lock").exists()) + self.assertFalse(script.exists()) + self.assertFalse(script.parent.exists()) + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + def test_tampered_extracted_file_is_rejected(self): + artifact, archive = self._create_locked_wheel() + self._extract(archive) + (self.site_packages / "demo_pkg" / "__init__.py").write_bytes(b"tampered\n") + + with self.assertRaisesRegex(RuntimeError, "differs from its locked wheel"): + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + def test_self_consistent_forged_installed_record_cannot_replace_archive_anchor(self): + artifact, archive = self._create_locked_wheel() + self._extract(archive) + module = self.site_packages / "demo_pkg" / "__init__.py" + module.write_bytes(b"MALICIOUS = True\n") + record = self.site_packages / "demo_pkg-1.0.0.dist-info" / "RECORD" + rows = [] + for path in sorted(self.site_packages.rglob("*")): + if not path.is_file(): + continue + relative = path.relative_to(self.site_packages).as_posix() + if path == record: + rows.append(f"{relative},,") + continue + body = path.read_bytes() + digest = base64.urlsafe_b64encode(hashlib.sha256(body).digest()).rstrip(b"=") + rows.append(f"{relative},sha256={digest.decode('ascii')},{len(body)}") + record.write_text("\n".join(rows) + "\n", encoding="utf-8") + + with ( + mock.patch.object( + runtime_overlays.importlib, + "import_module", + side_effect=AssertionError("forged overlay must fail before imports"), + ), + self.assertRaisesRegex(RuntimeError, "differs from its locked wheel"), + ): + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + def test_replaced_locked_archive_is_rejected_before_extraction_trust(self): + artifact, archive = self._create_locked_wheel() + archive.write_bytes(b"not the reviewed wheel") + + with self.assertRaisesRegex(RuntimeError, "catalog identity"): + runtime_overlays.locked_artifact_file_seal([artifact], self.archive_root) + + def test_missing_locked_archive_is_rejected(self): + artifact, archive = self._create_locked_wheel() + archive.unlink() + + with self.assertRaises((FileNotFoundError, RuntimeError)): + runtime_overlays.locked_artifact_file_seal([artifact], self.archive_root) + + def test_extra_extracted_file_is_rejected(self): + artifact, archive = self._create_locked_wheel() + self._extract(archive) + (self.site_packages / "demo_pkg" / "unreviewed.py").write_bytes(b"extra\n") + + with self.assertRaisesRegex(RuntimeError, "does not exactly match"): + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + def test_extracted_pth_startup_hook_is_rejected(self): + artifact, archive = self._create_locked_wheel() + self._extract(archive) + (self.site_packages / "unreviewed.pth").write_bytes(b"import unreviewed\n") + + with self.assertRaisesRegex(RuntimeError, "forbidden .pth"): + runtime_overlays.verify_artifact_anchored_overlay( + self.site_packages, [artifact], self.archive_root + ) + + def test_missing_or_duplicate_wheel_identity_documents_are_rejected(self): + defaults = self._default_members() + cases = { + "missing WHEEL": [item for item in defaults if not item[0].endswith("/WHEEL")], + "missing RECORD": [item for item in defaults if not item[0].endswith("/RECORD")], + "duplicate WHEEL": defaults + + [ + ( + "duplicate-1.0.0.dist-info/WHEEL", + b"Wheel-Version: 1.0\nTag: py3-none-any\n\n", + ) + ], + "duplicate RECORD": defaults + + [("duplicate-1.0.0.dist-info/RECORD", b"second-record\n")], + } + for label, members in cases.items(): + with self.subTest(label=label): + self.assert_invalid_wheel(members) + + def test_wheel_record_must_cover_exact_files_hashes_and_sizes(self): + defaults = self._default_members() + record_name = "demo_pkg-1.0.0.dist-info/RECORD" + record = next(body for name, body, *_details in defaults if name == record_name).decode("utf-8") + cases = { + "missing member": record.replace(next(line for line in record.splitlines(True) if line.startswith("demo_pkg/__init__.py,")), ""), + "unknown member": record + "demo_pkg/unknown.py,sha256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,1\n", + "wrong digest": record.replace("sha256=", "sha256=A", 1), + "wrong size": record.replace(",19\n", ",20\n", 1), + "hashed self": record.replace(f"{record_name},,", f"{record_name},sha256=AAAA,1"), + "duplicate path": record + next(line for line in record.splitlines(True) if line.startswith("demo_pkg/__init__.py,")), + } + for label, malformed in cases.items(): + with self.subTest(label=label): + members = [ + (name, malformed.encode("utf-8") if name == record_name else body, *details) + for name, body, *details in defaults + ] + self.assert_invalid_wheel(members) + + def test_dist_info_metadata_project_and_version_must_match_lock(self): + cases = { + "dist-info project": self._default_members( + dist_info="other_pkg-1.0.0.dist-info" + ), + "dist-info version": self._default_members( + dist_info="demo_pkg-2.0.0.dist-info" + ), + "metadata project": self._default_members(metadata_name="other-pkg"), + "metadata version": self._default_members(metadata_version="2.0.0"), + } + for label, members in cases.items(): + with self.subTest(label=label): + self.assert_invalid_wheel(members) + + def test_archive_pth_and_windows_unsafe_paths_are_rejected(self): + cases = { + "startup hook": "demo_pkg/unreviewed.pth", + "alternate data stream": "demo_pkg/payload:stream", + "trailing dot": "demo_pkg/payload.", + "trailing space": "demo_pkg/payload ", + } + for label, unsafe_name in cases.items(): + with self.subTest(label=label): + self.assert_invalid_wheel( + self._default_members() + [(unsafe_name, b"unreviewed\n")] + ) + + def test_unicode_normalization_collision_is_rejected(self): + members = self._default_members() + [ + ("demo_pkg/caf\N{LATIN SMALL LETTER E WITH ACUTE}.py", b"first\n"), + ("demo_pkg/cafe\N{COMBINING ACUTE ACCENT}.py", b"second\n"), + ] + self.assert_invalid_wheel(members) + + def test_symlink_archive_member_is_rejected(self): + members = self._default_members() + [ + ("demo_pkg/link.py", b"outside.py", stat.S_IFLNK | 0o777) + ] + self.assert_invalid_wheel(members) + + def test_pre_cancelled_acquisition_does_not_open_a_url(self): + artifact, archive = self._create_locked_wheel() + archive.unlink() + lease = runtime_overlays.InstallLease( + token="test", + owner_kind="test", + owner_id="test", + cancel_event=threading.Event(), + ) + lease.cancel_event.set() + opener = mock.Mock() + + with ( + mock.patch.object(runtime_overlays, "build_opener", return_value=opener), + self.assertRaises(runtime_overlays.OverlayCancelled), + ): + runtime_overlays.cache_locked_artifacts( + [artifact], self.archive_root, lease=lease + ) + + opener.open.assert_not_called() + + def test_acquisition_polls_cancellation_while_streaming(self): + artifact, archive = self._create_locked_wheel() + archive.unlink() + lease = runtime_overlays.InstallLease( + token="test", + owner_kind="test", + owner_id="test", + cancel_event=threading.Event(), + ) + + class CancellingResponse: + headers = {} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def geturl(self): + return artifact["url"] + + def read(self, _size): + lease.cancel_event.set() + return b"partial" + + opener = SimpleNamespace(open=mock.Mock(return_value=CancellingResponse())) + with ( + mock.patch.object(runtime_overlays, "build_opener", return_value=opener), + self.assertRaises(runtime_overlays.OverlayCancelled), + ): + runtime_overlays.cache_locked_artifacts( + [artifact], self.archive_root, lease=lease + ) + + opener.open.assert_called_once() + self.assertFalse(any(self.archive_root.rglob("*.part"))) + + def test_os_lease_excludes_another_process_and_reacquires_after_release(self): + managed = self.root / "lease-managed" + lock_path = managed / "optimizations" / "install.lock" + environment = {**os.environ, "MODIFF_MANAGED_ROOT": str(managed)} + probe = """ +from modiff.runtime_overlays import OverlayInstallBusy, release_install, reserve_install +try: + lease = reserve_install('test', 'child') +except OverlayInstallBusy: + print('busy') +else: + print('acquired') + release_install(lease) +""" + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + mock.patch.object(runtime_overlays, "INSTALL_LEASE_PATH", lock_path), + ): + lease = runtime_overlays.reserve_install("test", "parent") + try: + blocked = subprocess.run( + [sys.executable, "-c", probe], + cwd=Path(__file__).resolve().parents[1], + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + finally: + runtime_overlays.release_install(lease) + reacquired = subprocess.run( + [sys.executable, "-c", probe], + cwd=Path(__file__).resolve().parents[1], + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + self.assertEqual(blocked.returncode, 0, blocked.stderr) + self.assertEqual(blocked.stdout.strip(), "busy") + self.assertEqual(reacquired.returncode, 0, reacquired.stderr) + self.assertEqual(reacquired.stdout.strip(), "acquired") + + def test_managed_promotion_uses_exact_directory_and_never_replaces_target(self): + managed = self.root / "promotion-managed" + staging = managed / "optimizations" / "staging" + environments = managed / "optimizations" / "environments" + source = staging / "runtime-1-cafebabe" + destination = environments / source.name + (source / "site-packages").mkdir(parents=True) + environments.mkdir(parents=True) + (source / "site-packages" / "proof.txt").write_text("reviewed", encoding="utf-8") + with mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed): + runtime_overlays.promote_managed_directory(source, destination) + self.assertFalse(source.exists()) + self.assertEqual( + (destination / "site-packages" / "proof.txt").read_text(encoding="utf-8"), + "reviewed", + ) + + replacement_source = staging / "runtime-2-deadbeef" + replacement_source.mkdir() + (replacement_source / "canary.txt").write_text("source", encoding="utf-8") + occupied = environments / replacement_source.name + occupied.mkdir() + (occupied / "canary.txt").write_text("destination", encoding="utf-8") + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + self.assertRaises(runtime_overlays.OverlayStorageUnsafe), + ): + runtime_overlays.promote_managed_directory(replacement_source, occupied) + self.assertEqual((replacement_source / "canary.txt").read_text(), "source") + self.assertEqual((occupied / "canary.txt").read_text(), "destination") + + def test_managed_cleanup_quarantines_exact_tree_and_rejects_unsafe_target(self): + managed = self.root / "cleanup-managed" + environments = managed / "optimizations" / "environments" + target = environments / "runtime-1-cafebabe" + (target / "site-packages" / "nested").mkdir(parents=True) + (target / "site-packages" / "nested" / "proof.txt").write_text( + "reviewed", + encoding="utf-8", + ) + with mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed): + self.assertTrue( + runtime_overlays.remove_managed_directory(target, parent=environments) + ) + self.assertFalse( + runtime_overlays.remove_managed_directory(target, parent=environments) + ) + self.assertFalse(target.exists()) + self.assertEqual(list(environments.glob(".cleanup-*")), []) + + unsafe = environments / "runtime-2-deadbeef" + unsafe.write_text("do-not-delete", encoding="utf-8") + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + self.assertRaises(runtime_overlays.OverlayStorageUnsafe), + ): + runtime_overlays.remove_managed_directory(unsafe, parent=environments) + self.assertEqual(unsafe.read_text(encoding="utf-8"), "do-not-delete") + + def test_staging_identity_rejects_directory_replacement_before_promote_or_cleanup(self): + managed = self.root / "identity-managed" + staging = managed / "optimizations" / "staging" + environments = managed / "optimizations" / "environments" + staged = staging / "runtime-1-cafebabe" + displaced = staging / "displaced" + staged.mkdir(parents=True) + environments.mkdir(parents=True) + (staged / "proof.txt").write_text("validated", encoding="utf-8") + with mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed): + identity = runtime_overlays.managed_directory_identity(staged) + staged.replace(displaced) + staged.mkdir() + (staged / "proof.txt").write_text("replacement", encoding="utf-8") + with self.assertRaises(runtime_overlays.OverlayStorageUnsafe): + runtime_overlays.promote_managed_directory( + staged, + environments / staged.name, + expected_identity=identity, + ) + with self.assertRaises(runtime_overlays.OverlayStorageUnsafe): + runtime_overlays.remove_managed_directory( + staged, + parent=staging, + expected_identity=identity, + ) + self.assertEqual((staged / "proof.txt").read_text(), "replacement") + self.assertEqual((displaced / "proof.txt").read_text(), "validated") + self.assertFalse((environments / staged.name).exists()) + + def test_posix_promotion_requires_platform_exclusive_rename_flags(self): + class Operation: + def __init__(self): + self.calls = [] + + def __call__(self, *args): + self.calls.append(args) + return 0 + + linux = Operation() + macos = Operation() + with ( + mock.patch("ctypes.CDLL", return_value=SimpleNamespace(renameat2=linux)), + mock.patch.object(sys, "platform", "linux"), + ): + runtime_overlays._posix_rename_noreplace(3, "source", 4, "target") + self.assertEqual(linux.calls[0][-1], 1) + with ( + mock.patch("ctypes.CDLL", return_value=SimpleNamespace(renameatx_np=macos)), + mock.patch.object(sys, "platform", "darwin"), + ): + runtime_overlays._posix_rename_noreplace(3, "source", 4, "target") + self.assertEqual(macos.calls[0][-1], 0x00000004) + with ( + mock.patch("ctypes.CDLL", return_value=SimpleNamespace()), + mock.patch.object(sys, "platform", "linux"), + self.assertRaises(runtime_overlays.OverlayStorageUnsafe), + ): + runtime_overlays._posix_rename_noreplace(3, "source", 4, "target") + + def test_cancelled_lease_cannot_enter_managed_promotion(self): + lease = runtime_overlays.InstallLease( + token="cancelled", + owner_kind="test", + owner_id="promotion", + cancel_event=threading.Event(), + ) + lease.cancel_event.set() + with ( + mock.patch.object(runtime_overlays, "_ACTIVE_INSTALL", lease), + mock.patch.object(runtime_overlays, "promote_managed_directory") as promote, + self.assertRaises(runtime_overlays.OverlayCancelled), + ): + runtime_overlays.promote_staged_environment( + lease, + self.root / "staged", + self.root / "destination", + ) + promote.assert_not_called() + + def test_cancel_kills_benign_child_and_grandchild_before_escape(self): + managed = self.root / "cancel-managed" + lock_path = managed / "optimizations" / "install.lock" + started = self.root / "cancel-started" + escaped = self.root / "cancel-escaped" + grandchild = ( + "import time; from pathlib import Path; " + f"time.sleep(1.0); Path({str(escaped)!r}).write_text('escaped')" + ) + child = ( + "import subprocess, sys, time; from pathlib import Path; " + f"subprocess.Popen([sys.executable, '-c', {grandchild!r}]); " + f"Path({str(started)!r}).write_text('started'); time.sleep(30)" + ) + outcome = [] + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + mock.patch.object(runtime_overlays, "INSTALL_LEASE_PATH", lock_path), + ): + lease = runtime_overlays.reserve_install("test", "cancel-tree") + + def run_tree(): + try: + runtime_overlays.run_cancellable_command( + [sys.executable, "-I", "-c", child], + environment=os.environ.copy(), + lease=lease, + timeout=30, + cwd=self.root, + ) + except runtime_overlays.OverlayCancelled: + outcome.append("cancelled") + + worker = threading.Thread(target=run_tree, daemon=True) + worker.start() + deadline = time.monotonic() + 10 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(started.exists(), "benign child did not start") + self.assertTrue(runtime_overlays.cancel_install(lease.token)) + worker.join(timeout=10) + runtime_overlays.release_install(lease) + self.assertFalse(worker.is_alive()) + self.assertEqual(outcome, ["cancelled"]) + time.sleep(1.25) + self.assertFalse(escaped.exists()) + + @unittest.skipUnless(os.name == "nt", "Windows Job Object containment") + def test_windows_job_contains_breakaway_descendants(self): + managed = self.root / "breakaway-managed" + lock_path = managed / "optimizations" / "install.lock" + blocked = self.root / "breakaway-blocked" + launched = self.root / "breakaway-launched" + escaped = self.root / "breakaway-escaped" + grandchild = ( + "import time; from pathlib import Path; " + f"time.sleep(0.5); Path({str(escaped)!r}).write_text('escaped')" + ) + child = ( + "import subprocess, sys; from pathlib import Path; " + "flags=subprocess.CREATE_BREAKAWAY_FROM_JOB|subprocess.CREATE_NEW_PROCESS_GROUP; " + "\ntry: subprocess.Popen([sys.executable, '-c', " + repr(grandchild) + "], creationflags=flags); " + "Path(" + repr(str(launched)) + ").write_text('launched')" + "\nexcept OSError: Path(" + repr(str(blocked)) + ").write_text('blocked')" + ) + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + mock.patch.object(runtime_overlays, "INSTALL_LEASE_PATH", lock_path), + ): + lease = runtime_overlays.reserve_install("test", "breakaway") + try: + result = runtime_overlays.run_cancellable_command( + [sys.executable, "-I", "-c", child], + environment=os.environ.copy(), + lease=lease, + timeout=10, + cwd=self.root, + ) + finally: + runtime_overlays.release_install(lease) + self.assertEqual(result["returnCode"], 0, result["stderr"]) + self.assertTrue(blocked.exists() or launched.exists()) + time.sleep(0.75) + self.assertFalse(escaped.exists()) + + def test_parent_death_watchdog_retains_lease_until_tree_is_dead(self): + managed = self.root / "watchdog-managed" + lock_path = managed / "optimizations" / "install.lock" + started = self.root / "watchdog-started" + escaped = self.root / "watchdog-escaped" + grandchild = ( + "import time; from pathlib import Path; " + f"time.sleep(1.5); Path({str(escaped)!r}).write_text('escaped')" + ) + child = ( + "import subprocess, sys, time; from pathlib import Path; " + f"subprocess.Popen([sys.executable, '-c', {grandchild!r}]); " + f"Path({str(started)!r}).write_text('started'); time.sleep(30)" + ) + parent = f""" +import os, sys +from pathlib import Path +from modiff.runtime_overlays import release_install, reserve_install, run_cancellable_command +lease = reserve_install('test', 'orphan-parent') +try: + run_cancellable_command( + [sys.executable, '-I', '-c', {child!r}], + environment=os.environ.copy(), + lease=lease, + timeout=30, + cwd=Path({str(self.root)!r}), + ) +finally: + release_install(lease) +""" + environment = {**os.environ, "MODIFF_MANAGED_ROOT": str(managed)} + process = subprocess.Popen( + [sys.executable, "-c", parent], + cwd=Path(__file__).resolve().parents[1], + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + 10 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(started.exists(), "watchdog child did not start") + process.kill() + process.wait(timeout=10) + + acquired = None + with ( + mock.patch.object(runtime_overlays, "MANAGED_ROOT", managed), + mock.patch.object(runtime_overlays, "INSTALL_LEASE_PATH", lock_path), + ): + deadline = time.monotonic() + 10 + while acquired is None and time.monotonic() < deadline: + try: + acquired = runtime_overlays.reserve_install("test", "replacement") + except runtime_overlays.OverlayInstallBusy: + time.sleep(0.05) + self.assertIsNotNone(acquired, "watchdog did not release the OS lease") + runtime_overlays.release_install(acquired) + time.sleep(1.75) + self.assertFalse(escaped.exists()) + + def test_unqualified_candidate_rejects_before_artifact_selection_or_lease(self): + profile = optimization_packages.OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + selector = mock.Mock(side_effect=AssertionError("selector must not run")) + reserve = mock.Mock(side_effect=AssertionError("lease must not be reserved")) + with ( + mock.patch.object(optimization_packages, "_artifact_install_plan", selector), + mock.patch.object(optimization_packages, "reserve_install", reserve), + mock.patch.object( + optimization_packages, + "current_base_binding", + side_effect=AssertionError("base inspection must not run"), + ), + self.assertRaisesRegex(RuntimeError, "not qualified for installation"), + ): + optimization_packages.validate_optional_runtime_install_request( + profile.id, profile.spec_digest, consent=True + ) + + selector.assert_not_called() + reserve.assert_not_called() + + def test_flag_flip_with_incomplete_locks_still_rejects_before_lease(self): + current = optimization_packages.OPTIONAL_RUNTIME_PROFILES[ + TRANSFORMERS_PEFT_RUNTIME_PROFILE_ID + ] + future = replace(current, install_action_available=True, artifact_locks=()) + self.assertEqual(future.artifact_locks, ()) + selector = mock.Mock(wraps=optimization_packages._artifact_install_plan) + reserve = mock.Mock(side_effect=AssertionError("lease must not be reserved")) + installer = mock.Mock(side_effect=AssertionError("installer must not be selected")) + with ( + mock.patch.object( + optimization_packages, + "OPTIONAL_RUNTIME_PROFILES", + {future.id: future}, + ), + mock.patch.object(optimization_packages, "_artifact_install_plan", selector), + mock.patch.object(optimization_packages, "reserve_install", reserve), + mock.patch.object(optimization_packages, "current_base_binding", return_value={}), + mock.patch.object(optimization_packages, "_verified_uv_executable", installer), + self.assertRaisesRegex(RuntimeError, "artifact lock is incomplete"), + ): + optimization_packages.validate_optional_runtime_install_request( + future.id, future.spec_digest, consent=True + ) + + selector.assert_called_once_with(future) + installer.assert_not_called() + reserve.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_status.py b/tests/test_runtime_status.py index 6ec2834..687551e 100644 --- a/tests/test_runtime_status.py +++ b/tests/test_runtime_status.py @@ -27,13 +27,55 @@ ) from modiff import preflight # noqa: E402 +from modiff.auto_resource import build_auto_resource_plan # noqa: E402 +from modiff.diffusers_profiles import ( # noqa: E402 + execution_profiles_for_execution, + optional_runtime_profile_ids_for_execution, +) +from modiff.optional_runtime_execution import optional_runtime_requirement_for_execution # noqa: E402 from modiff.server import WebServer # noqa: E402 +from modiff.studio_execution_specs import ( # noqa: E402 + studio_execution_spec_for_pair, + studio_model_dependencies_for_pair, +) from aiohttp.web_fileresponse import CONTENT_TYPES as AIOHTTP_CONTENT_TYPES # noqa: E402 GIB = 1024**3 +def resource_plan_target(model_type, mode): + profiles = execution_profiles_for_execution(model_type, mode) + if len(profiles) != 1: + raise AssertionError(f"Expected one execution profile for {model_type}:{mode}, got {len(profiles)}") + profile = profiles[0] + target = { + "autoResourceSchemaVersion": 2, + "executionProfileId": profile.id, + "loaderModule": profile.loader_module, + "loaderAction": profile.loader_action, + "executionPath": profile.execution_path, + "pipelineClass": profile.pipeline_class, + "modelDependencies": studio_model_dependencies_for_pair(model_type, mode), + "optionalRuntimeProfileIds": list( + optional_runtime_profile_ids_for_execution(model_type, mode) + ), + "optionalRuntimeRequirement": optional_runtime_requirement_for_execution( + model_type, + mode, + ), + } + specification = studio_execution_spec_for_pair(model_type, mode) + if specification is not None: + target["studioExecutionSpecContract"] = { + "schemaVersion": specification["schemaVersion"], + "id": specification["id"], + "contentHash": specification["contentHash"], + "executionProfileId": specification["executionProfileId"], + } + return target + + def hardware_snapshot(*, ram_total=32 * GIB, cuda=True): devices = [] if cuda: @@ -628,6 +670,848 @@ def test_auto_pre_run_cleanup_releases_unknown_and_cross_family_caches(self): self.assertEqual(release.call_count, 2) self.assertEqual(self.server._last_auto_model_family, "AceStep") + def test_auto_execution_rejects_an_undeclared_pair_even_if_the_client_marks_it_ready(self): + cases = ( + ("BrandNewPipeline", "text_to_image"), + ("QwenImageEditPlusModularPipeline", "inpaint"), + ("FluxReduxPipeline", "multi_image_reference_edit"), + ) + + for model_type, mode in cases: + with self.subTest(model_type=model_type, mode=mode): + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": model_type, + "mode": mode, + "autoResourcePlan": { + "id": "stale-client-candidate", + "modelType": model_type, + "mode": mode, + "proof": {"status": "declared_safe"}, + }, + } + ) + + with self.assertRaisesRegex(RuntimeError, "no declared execution recipe") as raised: + self.server._assert_auto_resource_candidate_ready(hints) + + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_pair_undeclared") + self.assertEqual(raised.exception.modiff_auto_resource_status, "expert_only") + + def test_auto_execution_rejects_a_stale_declared_plan_for_a_different_requested_pair(self): + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "QwenImageEditPlusModularPipeline", + "mode": "inpaint", + "autoResourcePlan": { + "id": "stale-flux-candidate", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + }, + } + ) + + with self.assertRaisesRegex(RuntimeError, "does not match the requested workflow pair") as raised: + self.server._assert_auto_resource_candidate_ready(hints) + + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_pair_mismatch") + self.assertEqual(raised.exception.modiff_auto_resource_status, "expert_only") + self.assertNotIn("FluxSchnellPipeline:text_to_image", str(raised.exception)) + self.assertNotIn("QwenImageEditPlusModularPipeline:inpaint", str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + + def test_auto_execution_keeps_declared_but_unqualified_recipe_behavior(self): + candidate = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "flux-schnell-contract-only", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "skipped"}, + } + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + } + ) + + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(hints)) + + def test_generated_schema_v2_plan_is_accepted_by_runtime_admission(self): + plan = build_auto_resource_plan( + {"form": {"modelType": "FluxSchnellPipeline", "mode": "text_to_image"}}, + runtime_fingerprint={"resourceFingerprint": "runtime-admission"}, + local_models=[], + data_dir=self.temp_dir.name, + ) + candidate = plan["candidates"][0] + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": plan["candidates"], + } + ) + + self.assertEqual(candidate["autoResourceSchemaVersion"], plan["schemaVersion"]) + self.assertEqual(candidate["executionProfileId"], "flux-schnell:direct") + self.assertEqual( + candidate["optionalRuntimeProfileIds"], + ["huggingface-transformers-peft-5.14.1-0.20.0"], + ) + self.assertEqual( + candidate["optionalRuntimeRequirement"]["executionProfileIds"], + ["flux-schnell:direct"], + ) + self.assertEqual( + candidate["studioExecutionSpecContract"], + { + "schemaVersion": 1, + "id": "flux-schnell:text-to-image:v1", + "contentHash": "studio-spec-v1-9cd1abb5", + "executionProfileId": "flux-schnell:direct", + }, + ) + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(hints)) + + def test_auto_execution_requires_exact_candidate_id_and_list_binding(self): + candidate = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "flux-bound", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + } + cases = ( + { + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + }, + { + "autoResourcePlan": candidate, + "autoResourceCandidateId": candidate["id"], + }, + { + "autoResourcePlan": candidate, + "autoResourceCandidateId": "stale-id", + "autoResourceCandidates": [candidate], + }, + ) + for case in cases: + with self.subTest(keys=sorted(case)): + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + **case, + } + ) + with self.assertRaises(RuntimeError) as raised: + self.server._assert_auto_resource_candidate_ready(hints) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + self.assertLess(len(str(raised.exception)), 256) + + def test_auto_execution_rejects_same_id_with_divergent_proof_recipe(self): + listed = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "same-id", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "skipped"}, + } + selected = copy.deepcopy(listed) + selected["proof"] = {"status": "declared_safe"} + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": "same-id", + "autoResourcePlan": selected, + "autoResourceCandidates": [listed], + } + ) + + with self.assertRaises(RuntimeError) as raised: + self.server._assert_auto_resource_candidate_ready(hints) + + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + + def test_auto_execution_rejects_stale_profile_and_schema_receipts(self): + base = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "stale-receipt", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + } + for candidate in ( + {**base, "executionProfileId": "flux-schnell:replacement"}, + {**base, "autoResourceSchemaVersion": 3}, + {**base, "optionalRuntimeProfileIds": []}, + { + **base, + "optionalRuntimeRequirement": { + **base["optionalRuntimeRequirement"], + "executionProfileIds": ["flux-schnell:replacement"], + }, + }, + { + **base, + "studioExecutionSpecContract": { + **base["studioExecutionSpecContract"], + "contentHash": "studio-spec-v1-00000000", + }, + }, + { + key: value + for key, value in base.items() + if key != "studioExecutionSpecContract" + }, + { + **base, + "studioExecutionSpecContract": { + **base["studioExecutionSpecContract"], + "extra": True, + }, + }, + ): + with self.subTest(candidate=candidate): + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + } + ) + with self.assertRaises(RuntimeError) as raised: + self.server._assert_auto_resource_candidate_ready(hints) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_target_mismatch") + self.assertLess(len(str(raised.exception)), 256) + + def test_auto_execution_binds_exact_model_dependency_receipts(self): + base = { + **resource_plan_target("QwenImageModularPipeline", "control_image"), + "id": "qwen-control", + "modelType": "QwenImageModularPipeline", + "mode": "control_image", + "proof": {"status": "declared_safe"}, + } + valid = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": base["modelType"], + "mode": base["mode"], + "modelDependencies": base["modelDependencies"], + "autoResourceCandidateId": base["id"], + "autoResourcePlan": base, + "autoResourceCandidates": [base], + } + ) + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(valid)) + + stale_dependency = { + **base["modelDependencies"][0], + "revision": "0000000000000000000000000000000000000000", + } + cases = ( + ({key: value for key, value in base.items() if key != "modelDependencies"}, None), + ({**base, "modelDependencies": [stale_dependency]}, None), + ( + { + **base, + "modelDependencies": [{**base["modelDependencies"][0], "extra": True}], + }, + None, + ), + (base, [stale_dependency]), + ) + for candidate, hint_dependencies in cases: + with self.subTest(candidate=candidate, hints=hint_dependencies): + payload = { + "resourceMode": "auto", + "modelType": base["modelType"], + "mode": base["mode"], + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + } + if hint_dependencies is not None: + payload["modelDependencies"] = hint_dependencies + hints = self.server._coerce_runtime_hints(payload) + with self.assertRaises(RuntimeError) as raised: + self.server._assert_auto_resource_candidate_ready(hints) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_target_mismatch") + self.assertLess(len(str(raised.exception)), 256) + + malformed = { + **base["modelDependencies"][0], + "extra": True, + } + with self.assertRaises(RuntimeError) as raised: + self.server._coerce_runtime_hints({"modelDependencies": [malformed]}) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + + def test_controlled_artifact_receipts_are_server_derived_and_candidate_bound(self): + receipt = { + "schemaVersion": 1, + "kind": "diffusers_lora", + "module": "modules.DiffusersImage", + "action": "LoadAdapter", + "artifact": { + "source": "hub", + "repository": "example/style", + "revision": "a" * 40, + "weightName": "style.safetensors", + "sha256": "b" * 64, + }, + "adapterName": "style", + "scale": 0.75, + "scheduler": None, + "replaceExisting": True, + "descriptorSha256": "c" * 64, + } + candidate = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "flux-with-style", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + "controlledArtifacts": [{"untrusted": True}], + } + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": candidate["modelType"], + "mode": candidate["mode"], + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + "controlledArtifacts": [{"untrusted": True}], + } + ) + self.assertNotIn("controlledArtifacts", hints) + self.assertNotIn("controlledArtifacts", hints["autoResourcePlan"]) + self.assertNotIn("controlledArtifacts", hints["autoResourceCandidates"][0]) + baseline_cache_signature = self.server._auto_candidate_cache_signature(hints) + + with patch("modiff.server.controlled_artifact_receipts_from_graph", return_value=[receipt]): + self.assertEqual( + self.server._bind_controlled_artifact_receipts({"nodes": {}, "paths": []}, hints), + [receipt], + ) + self.assertEqual(hints["controlledArtifacts"], [receipt]) + self.assertEqual(hints["autoResourcePlan"]["controlledArtifacts"], [receipt]) + self.assertEqual(hints["autoResourceCandidates"][0]["controlledArtifacts"], [receipt]) + self.assertNotEqual(self.server._auto_candidate_cache_signature(hints), baseline_cache_signature) + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(hints)) + + base_history_hints = copy.deepcopy(hints) + for history_candidate in ( + base_history_hints["autoResourcePlan"], + base_history_hints["autoResourceCandidates"][0], + ): + history_candidate["proof"] = { + "status": "live_proven", + "source": "auto_resource_history", + } + with ( + patch("modiff.server.controlled_artifact_receipts_from_graph", return_value=[receipt]), + patch("modiff.server.matching_auto_resource_success_history", return_value=None), + patch.object(self.server, "_runtime_fingerprint", return_value={"fingerprint": "unit"}), + ): + self.server._bind_controlled_artifact_receipts( + {"nodes": {}, "paths": []}, + base_history_hints, + ) + self.assertEqual(base_history_hints["autoResourcePlan"]["proof"]["status"], "skipped") + self.assertEqual( + base_history_hints["autoResourceCandidates"][0]["proof"]["source"], + "controlled_artifact_history_required", + ) + # Qualification proof is advisory at execution, but base-only history + # can no longer be represented as live proof for this adapter set. + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(base_history_hints)) + + exact_history_hints = copy.deepcopy(hints) + for history_candidate in ( + exact_history_hints["autoResourcePlan"], + exact_history_hints["autoResourceCandidates"][0], + ): + history_candidate["proof"] = { + "status": "live_proven", + "source": "auto_resource_history", + } + with ( + patch("modiff.server.controlled_artifact_receipts_from_graph", return_value=[receipt]), + patch( + "modiff.server.matching_auto_resource_success_history", + return_value={"successCount": 1, "lastSuccessAt": 1}, + ), + patch.object(self.server, "_runtime_fingerprint", return_value={"fingerprint": "unit"}), + ): + self.server._bind_controlled_artifact_receipts( + {"nodes": {}, "paths": []}, + exact_history_hints, + ) + self.assertEqual(exact_history_hints["autoResourcePlan"]["proof"]["status"], "live_proven") + self.assertIsNone(self.server._assert_auto_resource_candidate_ready(exact_history_hints)) + + hints["autoResourceCandidates"][0]["controlledArtifacts"][0]["scale"] = 1.0 + with self.assertRaises(RuntimeError) as raised: + self.server._assert_auto_resource_candidate_ready(hints) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + + def test_controlled_artifact_validation_errors_are_bounded_and_redacted(self): + marker = "CONTROLLED_ARTIFACT_SECRET_" + "x" * 2048 + with patch( + "modiff.server.controlled_artifact_receipts_from_graph", + side_effect=ValueError(marker), + ): + with self.assertRaises(RuntimeError) as raised: + self.server._bind_controlled_artifact_receipts( + {"nodes": {}, "paths": []}, + {"resourceMode": "auto"}, + ) + self.assertEqual(raised.exception.modiff_error_code, "controlled_artifact_mismatch") + self.assertNotIn(marker, str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + + def test_auto_target_errors_do_not_echo_oversized_untrusted_values(self): + marker = "PUBLIC_SECRET_MARKER_" + "x" * 2048 + candidate = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "flux-bound", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "loaderModule": marker, + "proof": {"status": "declared_safe"}, + } + + with self.assertRaises(RuntimeError) as raised: + self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + } + ) + + self.assertNotIn("PUBLIC_SECRET_MARKER_", str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + + def test_auto_candidate_projection_drops_unknown_deep_and_wide_values_but_bounds_contract_fields(self): + deep = {} + cursor = deep + for _ in range(1200): + cursor["next"] = {} + cursor = cursor["next"] + candidate = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "bounded", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + "junk": deep, + "wideJunk": list(range(100000)), + } + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + } + ) + self.assertNotIn("junk", hints["autoResourcePlan"]) + self.assertNotIn("wideJunk", hints["autoResourceCandidates"][0]) + + nested_proof = {"status": "declared_safe"} + cursor = nested_proof + for _ in range(10): + cursor["nested"] = {} + cursor = cursor["nested"] + invalid = {**candidate, "proof": nested_proof} + with self.assertRaises(RuntimeError) as raised: + self.server._coerce_runtime_hints({"autoResourcePlan": invalid}) + self.assertEqual(raised.exception.modiff_error_code, "auto_resource_candidate_mismatch") + + with self.assertRaises(RuntimeError): + self.server._coerce_runtime_hints({"autoResourceCandidates": [candidate] * 65}) + with self.assertRaises(RuntimeError): + self.server._coerce_runtime_hints({"resourceRetryPlans": [{}] * 33}) + + def test_retry_adjacent_runtime_containers_are_bounded_or_worker_owned(self): + marker = "RUNTIME_CONTAINER_SECRET_MARKER" + deep = {"marker": marker} + cursor = deep + for _ in range(1200): + cursor["next"] = {} + cursor = cursor["next"] + + for payload in ( + {"resourcePlan": deep}, + {"compatibilityProbe": deep}, + {"optimizationQualificationForm": deep}, + {"workflowSnapshot": deep}, + {"modelType": deep}, + {"workflowTitle": deep}, + {"deviceMap": deep}, + {"attentionBackend": deep}, + {"denoiserCache": deep}, + {"regionalCompile": deep}, + {"channelsLast": deep}, + {"layerwiseCasting": deep}, + { + "autoFieldOverrides": [ + {"nodeId": "loader", "fieldKey": "dtype", "value": deep}, + ], + }, + ): + with self.subTest(field=next(iter(payload))): + with self.assertRaises(RuntimeError) as raised: + self.server._coerce_runtime_hints(payload) + self.assertNotIn(marker, str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + + hints = self.server._coerce_runtime_hints( + { + "resourceRetryHistory": [deep], + "resourceRetryAttempt": 9, + "resourceRetryLastError": marker, + "resourceRetryLastCode": marker, + "resourcePlan": { + "summary": "bounded", + "activeRetryPlan": {"reason": marker}, + }, + } + ) + self.assertNotIn("resourceRetryHistory", hints) + self.assertNotIn("resourceRetryAttempt", hints) + self.assertNotIn("resourceRetryLastError", hints) + self.assertNotIn("resourceRetryLastCode", hints) + self.assertNotIn("activeRetryPlan", hints["resourcePlan"]) + self.assertNotIn(marker, json.dumps(hints)) + + with self.assertRaises(RuntimeError): + self.server._coerce_runtime_hints({"resourceRetryModes": ["model_cpu"] * 100000}) + + def test_client_model_family_and_low_vram_classifiers_are_not_runtime_authority(self): + hints = self.server._coerce_runtime_hints( + { + "modelFamily": "Qwen Image", + "lowVramMode": True, + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + } + ) + + self.assertEqual(hints["modelType"], "QwenImageModularPipeline") + self.assertEqual(hints["mode"], "text_to_image") + self.assertNotIn("modelFamily", hints) + self.assertNotIn("lowVramMode", hints) + + async def test_graph_queue_replaces_raw_deep_candidate_data_before_copying(self): + deep = {} + cursor = deep + for _ in range(1200): + cursor["next"] = {} + cursor = cursor["next"] + graph = { + "nodes": {}, + "paths": [], + "runtimeHints": { + "autoResourcePlan": { + "id": "queue-candidate", + "junk": deep, + }, + "resourceRetryHistory": [deep], + }, + } + + await self.server.queue_task( + lambda: None, + (graph,), + None, + "sid", + name="Graph execution", + ) + + self.assertNotIn("junk", graph["runtimeHints"]["autoResourcePlan"]) + self.assertNotIn("resourceRetryHistory", graph["runtimeHints"]) + queued_graph = next(iter(self.server.task_graphs.values())) + self.assertNotIn("junk", queued_graph["runtimeHints"]["autoResourcePlan"]) + self.assertNotIn("resourceRetryHistory", queued_graph["runtimeHints"]) + + def test_auto_retry_candidate_is_bound_to_selected_profile_and_supported_values(self): + flux = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "id": "flux", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + } + qwen = { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "id": "qwen", + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + "proof": {"status": "declared_safe"}, + } + cross_branch = { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "candidateId": "qwen", + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + "onCategories": ["oom"], + } + with self.assertRaises(RuntimeError): + self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": "flux", + "autoResourcePlan": flux, + "autoResourceCandidates": [flux, qwen], + "resourceRetryPlans": [cross_branch], + } + ) + + marker = "EVENT_SECRET_MARKER_" + "x" * 1024 + invalid = {**flux, "offloadMode": marker} + invalid_retry = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "candidateId": "flux", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + } + with self.assertRaises(RuntimeError) as raised: + self.server._coerce_runtime_hints( + { + "resourceMode": "auto", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "autoResourceCandidateId": "flux", + "autoResourcePlan": invalid, + "autoResourceCandidates": [invalid], + "resourceRetryPlans": [invalid_retry], + } + ) + self.assertNotIn("EVENT_SECRET_MARKER_", str(raised.exception)) + self.assertLess(len(str(raised.exception)), 256) + + def test_expert_retry_events_drop_untrusted_ids_and_trigger_labels(self): + marker = "RETRY_EVENT_SECRET_MARKER" + raw_plan = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "candidateId": marker, + "id": marker, + "offloadMode": "model_cpu", + "reason": marker, + "onCategories": ["oom", marker], + "onErrorCodes": ["cuda_oom", marker], + } + hints = self.server._coerce_runtime_hints( + { + "resourceMode": "expert", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "resourceRetryPlans": [raw_plan], + } + ) + + plan = self.server._coerce_retry_plan_list(hints)[0] + public = self.server._sanitize_retry_plan_for_hints(plan) + + self.assertNotIn("candidateId", public) + self.assertNotIn("id", public) + self.assertEqual(public["reason"], "retry_plan_1") + self.assertEqual(public["onCategories"], ["oom"]) + self.assertEqual(public["onErrorCodes"], ["cuda_oom"]) + self.assertNotIn(marker, json.dumps(public)) + self.assertNotIn(marker, json.dumps(hints)) + + def test_graph_completed_runtime_hints_never_echo_raw_retry_state(self): + marker = "GRAPH_COMPLETED_RETRY_SECRET_MARKER" + messages = [] + self.server.current_task = {"task_id": "task-redaction", "progress": 0} + self.server.interrupt_flag = False + self.server.queue_message = messages.append + self.server.execute_node = lambda *_args, **_kwargs: None + self.server._runtime_fingerprint = lambda: {"fingerprint": "test"} + self.server._runtime_measurement = lambda **_kwargs: {"elapsedSeconds": 0} + self.server._record_auto_resource_success = lambda *_args, **_kwargs: None + self.server._record_optimization_observations = lambda *_args, **_kwargs: [] + raw_plan = { + **resource_plan_target("FluxSchnellPipeline", "text_to_image"), + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "candidateId": marker, + "id": marker, + "offloadMode": "model_cpu", + "reason": marker, + "onCategories": ["oom", marker], + "onErrorCodes": ["cuda_oom", marker], + } + graph = { + "sid": "sid", + "paths": [["noop"]], + "nodes": { + "noop": { + "module": "modules.Primitive", + "action": "Value", + "params": {}, + }, + }, + "runtimeHints": { + "resourceMode": "expert", + "modelType": "FluxSchnellPipeline", + "mode": "text_to_image", + "resourceRetryPlans": [raw_plan], + "resourceRetryAttempt": 7, + "resourceRetryLastError": marker, + "resourceRetryLastCode": marker, + "resourceRetryHistory": [{"error": marker}], + "resourcePlan": {"activeRetryPlan": {"reason": marker}}, + }, + } + + self.server._execute_graph(graph) + + completed = next(message for message in messages if message.get("type") == "graph_completed") + self.assertNotIn(marker, json.dumps(completed)) + self.assertNotIn(marker, json.dumps(graph["runtimeHints"])) + + def test_qwen_legacy_cuda_kernel_triggers_survive_but_cross_branch_retry_is_rejected(self): + marker = "UNTRUSTED_RETRY_TRIGGER" + raw_plan = { + **resource_plan_target("QwenImageModularPipeline", "text_to_image"), + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + "modelRepo": "Qwen/Qwen-Image-2512", + "resolvedArtifact": "Qwen/Qwen-Image-2512", + "quantizationMode": "bnb_4bit", + "quantizedComponents": ["transformer"], + "bnb4ComputeDtype": "bfloat16", + "offloadMode": "model_cpu", + "generation": {"steps": 50, "guidanceScale": 4.0}, + "onErrorCodes": ["cuda_kernel_unsupported", marker], + } + self.server._normalize_retry_plan_triggers(raw_plan) + self.assertNotIn("onCategories", raw_plan) + self.assertEqual(raw_plan["onErrorCodes"], ["cuda_kernel_unsupported"]) + self.assertTrue( + self.server._retry_plan_matches( + raw_plan, + {"category": "cuda_kernel", "error_code": "cuda_kernel_unsupported"}, + ) + ) + self.assertNotIn(marker, json.dumps(raw_plan)) + + modular_graph = { + "paths": [["loader"]], + "nodes": { + "loader": { + "module": "modules.ModularDiffusers", + "action": "ModelsLoader", + "params": { + "model_type": {"value": "QwenImageModularPipeline"}, + "repo_id": {"value": "Qwen/Qwen-Image-2512"}, + }, + }, + }, + } + with self.assertRaisesRegex(RuntimeError, "matched zero exact loader identities"): + self.server._apply_resource_retry_plan_to_graph(modular_graph, raw_plan) + + missing_identity = { + key: value + for key, value in raw_plan.items() + if key not in {"modelType", "mode", "loaderModule", "loaderAction", "pipelineClass"} + } + with self.assertRaisesRegex(RuntimeError, "exact modelType and mode"): + self.server._coerce_runtime_hints( + { + "resourceMode": "expert", + "modelType": "QwenImageModularPipeline", + "mode": "text_to_image", + "resourceRetryPlans": [missing_identity], + } + ) + + category_plan = copy.deepcopy(raw_plan) + category_plan.pop("onErrorCodes") + category_plan["onCategories"] = ["cuda_kernel", marker] + self.server._normalize_retry_plan_triggers(category_plan) + self.assertEqual(category_plan["onCategories"], ["cuda_kernel"]) + self.assertTrue( + self.server._retry_plan_matches( + category_plan, + {"category": "cuda_kernel", "error_code": "cuda_kernel_unsupported"}, + ) + ) + + def test_auto_plan_requires_the_exact_studio_receipt_before_loader_application(self): + candidate = { + **resource_plan_target("QwenImageModularPipeline", "control_image"), + "id": "qwen-control-unqualified", + "modelType": "QwenImageModularPipeline", + "mode": "control_image", + "offloadMode": "model_cpu", + "proof": {"status": "skipped"}, + } + graph = { + "sid": "sid", + "paths": [["qwen"]], + "runtimeHints": { + "resourceMode": "auto", + "modelType": "QwenImageModularPipeline", + "mode": "control_image", + "autoResourceCandidateId": candidate["id"], + "autoResourcePlan": candidate, + "autoResourceCandidates": [candidate], + }, + "nodes": { + "qwen": { + "module": "modules.DiffusersImage", + "action": "LoadPipeline", + "params": { + "pipeline_class": {"value": "QwenImagePipeline"}, + "offload_mode": {"value": "model_cpu"}, + }, + }, + }, + } + + with ( + patch.object(self.server, "_prepare_auto_runtime_for_graph", return_value=None), + self.assertRaisesRegex(RuntimeError, "Studio execution specification receipt is required"), + ): + self.server._execute_graph(graph) + def test_auto_pre_run_cleanup_preserves_same_family_cache_with_headroom(self): self.server.node_cache = {"cached-node": object()} self.server._last_auto_model_family = "QwenImage" @@ -1292,32 +2176,40 @@ def test_resource_fingerprint_ignores_free_memory_and_deterministic_run_state(se class PreflightHardwareTests(unittest.TestCase): - def test_diffusers_package_status_rejects_an_older_api_contract(self): - old_diffusers = SimpleNamespace( - __version__="0.39.0", - AceStepPipeline=type("AceStepPipeline", (), {}), - ) + def test_diffusers_package_status_does_not_probe_optional_runtime_symbols(self): + class CleanBaseDiffusers: + __version__ = "0.40.0.dev0" + + def __getattr__(self, name): + if name == "AceStepPipeline": + raise AssertionError("optional-dependent pipeline symbol must not be probed") + raise AttributeError(name) + with ( - patch("modiff.preflight.metadata.version", return_value="0.39.0"), - patch("modiff.preflight.importlib.import_module", return_value=old_diffusers), + patch("modiff.preflight.metadata.version", return_value="0.40.0.dev0"), + patch("modiff.preflight.importlib.import_module", return_value=CleanBaseDiffusers()), ): status = preflight.package_status("diffusers", "diffusers") + self.assertTrue(status["available"]) + self.assertNotIn("error", status) + + def test_package_import_failure_cannot_remain_available(self): + with ( + patch("modiff.preflight.metadata.version", return_value="1.0.0"), + patch("modiff.preflight.importlib.import_module", side_effect=RuntimeError("broken import")), + ): + status = preflight.package_status("example", "example") + self.assertFalse(status["available"]) - self.assertEqual( - status["contractMissing"], - [ - "AceStepPipeline.load_lora_weights", - "AceStepPipeline.set_adapters", - "AceStepPipeline.unload_lora_weights", - ], - ) - self.assertIn("Repair the managed environment", status["error"]) + self.assertEqual(status["error"], "broken import") def test_report_adds_hardware_and_preserves_torch_human_summary(self): snapshot = hardware_snapshot() + checks = [] def package_status(module_name, distribution_name, import_check=True): + checks.append((module_name, import_check)) return { "module": module_name, "distribution": distribution_name, @@ -1327,7 +2219,7 @@ def package_status(module_name, distribution_name, import_check=True): "version": "unit-test", } - args = SimpleNamespace(check_port=65534, full=False) + args = SimpleNamespace(check_port=65534, full=True) with ( patch("modiff.preflight.package_status", side_effect=package_status), patch("modiff.preflight.get_hardware_snapshot", return_value=copy.deepcopy(snapshot)), @@ -1343,6 +2235,12 @@ def package_status(module_name, distribution_name, import_check=True): self.assertEqual(report["hardware"], snapshot) self.assertEqual(torch_status["cuda_device_name"], "Mock CUDA") self.assertTrue(torch_status["cuda_available"]) + self.assertEqual( + [item["module"] for item in report["packages"]["optional_runtime"]], + ["transformers", "peft"], + ) + self.assertIn(("transformers", False), checks) + self.assertIn(("peft", False), checks) self.assertEqual( report["namespace"], { diff --git a/tests/test_server_security.py b/tests/test_server_security.py index cbbda2b..ebdc8b7 100644 --- a/tests/test_server_security.py +++ b/tests/test_server_security.py @@ -9,7 +9,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from modiff.server import WebServer, is_hidden_path +from modiff.server import WebServer, is_cache_servable_data_type, is_hidden_path class JsonRequest: @@ -25,6 +25,13 @@ async def json(self): return self._payload +class CacheRequest: + def __init__(self, node, field): + self.match_info = {"node": node, "field": field} + self.query = {} + self.headers = {} + + class WebSocketRequest: def __init__(self, *, origin=None, host="127.0.0.1:8088", remote="127.0.0.1", sid="test-session"): self.headers = {"Origin": origin} if origin else {} @@ -86,9 +93,166 @@ def test_hidden_path_supports_dotfiles_and_windows_attributes(self): self.assertTrue(is_hidden_path(windows_hidden)) self.assertFalse(is_hidden_path(visible)) + def test_cache_served_type_boundary_accepts_only_media_and_text_families(self): + for value in ( + "image", + "audio", + "video", + "text", + "string", + ["audio"], + ("video",), + {"text"}, + ): + with self.subTest(value=value): + self.assertTrue(is_cache_servable_data_type(value)) + for value in ("diffusers_auto_model", "any", "int", ["collection"], {}, None): + with self.subTest(value=value): + self.assertFalse(is_cache_servable_data_type(value)) + def test_mutation_origin_guard_is_registered_centrally(self): self.assertIn(self.server._mutation_origin_middleware, self.server.app.middlewares) + async def test_cache_rejects_undeclared_dynamic_output_before_conversion(self): + class OpaqueCacheValue: + def __str__(self): + raise AssertionError("Opaque cache output was converted to text.") + + def __bytes__(self): + raise AssertionError("Opaque cache output was converted to bytes.") + + def __fspath__(self): + raise AssertionError("Opaque cache output was converted to a path.") + + def __iter__(self): + raise AssertionError("Opaque cache output was inspected as an iterable.") + + def __reduce__(self): + raise AssertionError("Opaque cache output was serialized.") + + module = "modules.ModularDiffusers" + action = "Denoise" + self.server.node_cache["denoise"] = SimpleNamespace( + module_name=module, + class_name=action, + output={"route_state_out": OpaqueCacheValue()}, + params={}, + ) + malformed_registries = { + "invalid registry": [], + "missing module": {}, + "invalid module": {module: []}, + "missing action": {module: {}}, + "invalid action": {module: {action: []}}, + "missing params": {module: {action: {}}}, + "invalid params": {module: {action: {"params": None}}}, + "invalid field definition": { + module: {action: {"params": {"route_state_out": []}}} + }, + "missing field type": { + module: {action: {"params": {"route_state_out": {}}}} + }, + "missing static field": { + module: {action: {"params": {"image": {"type": "image"}}}} + }, + } + + with ( + patch("modiff.server.to_bytes") as convert_media, + patch("modiff.server.web.FileResponse") as file_response, + ): + for label, registry in malformed_registries.items(): + with self.subTest(label=label): + self.server.modules = registry + response = await self.server.cache(CacheRequest("denoise", "route_state_out")) + self.assertEqual(response.status, 400) + self.assertEqual( + response.text, + "Field route_state_out is not declared as a cache-served field for node denoise.", + ) + + convert_media.assert_not_called() + file_response.assert_not_called() + + async def test_cache_preserves_missing_field_404_and_declared_media_serving(self): + module = "modules.StaticMedia" + action = "Preview" + cached_image = object() + self.server.modules = { + module: { + action: { + "params": { + "image": {"type": "image", "fieldOptions": {"quality": 91}}, + } + } + } + } + self.server.node_cache["preview"] = SimpleNamespace( + module_name=module, + class_name=action, + output={"image": cached_image}, + params={}, + ) + + missing = await self.server.cache(CacheRequest("preview", "absent")) + self.assertEqual(missing.status, 404) + self.assertEqual(missing.text, "Field absent not found in node preview cache.") + + with patch("modiff.server.to_bytes", return_value=b"encoded-image") as convert_media: + response = await self.server.cache(CacheRequest("preview", "image")) + + self.assertEqual(response.status, 200) + self.assertEqual(response.body, b"encoded-image") + self.assertEqual(response.content_type, "image/webp") + convert_media.assert_called_once_with( + "image", + cached_image, + {"format": "WEBP", "quality": 100}, + ) + + async def test_cache_rejects_statically_declared_connector_outputs_before_conversion(self): + class OpaqueConnector: + def __fspath__(self): + raise AssertionError("Opaque connector output was converted to a path.") + + def __iter__(self): + raise AssertionError("Opaque connector output was inspected as an iterable.") + + module = "modules.ModularDiffusers" + action = "AutoModelLoader" + self.server.modules = { + module: { + action: { + "params": { + "model": { + "display": "output", + "type": "diffusers_auto_model", + } + } + } + } + } + self.server.node_cache["loader"] = SimpleNamespace( + module_name=module, + class_name=action, + output={"model": OpaqueConnector()}, + params={}, + ) + + with ( + patch("modiff.server.to_bytes") as convert_media, + patch("modiff.server.web.FileResponse") as file_response, + ): + response = await self.server.cache(CacheRequest("loader", "model")) + + self.assertEqual(response.status, 400) + self.assertEqual( + response.text, + "Field model has a type that cannot be served from node cache.", + ) + convert_media.assert_not_called() + file_response.assert_not_called() + def test_template_gallery_route_is_optional_for_remote_asset_builds(self): missing_gallery = Path(self.temporary.name) / "no-local-gallery" with patch("modiff.server.TEMPLATE_GALLERY_ROOT", missing_gallery): diff --git a/tests/test_studio_execution_specs.py b/tests/test_studio_execution_specs.py new file mode 100644 index 0000000..bdacdfe --- /dev/null +++ b/tests/test_studio_execution_specs.py @@ -0,0 +1,838 @@ +from copy import deepcopy +import unittest +from unittest.mock import patch + +import modules as module_registry + +from modiff.auto_resource import AUTO_MODEL_REQUIREMENTS +from modiff.diffusers_profiles import DIFFUSERS_EXECUTION_PROFILES +from modiff.server import STUDIO_MODEL_CAPABILITIES, WebServer +from modiff.studio_execution_specs import ( + STUDIO_EXECUTION_SPEC_DEFINITIONS, + _execution_spec_role_params, + assert_studio_execution_graph, + studio_execution_spec_for_pair, + studio_model_dependencies_for_pair, + studio_model_requirements_for_pair, + validate_studio_execution_specs, +) + + +def executable_graph_for_spec(spec): + nodes = {} + node_ids = {} + for index, (role, node_key, _x, _y) in enumerate(spec["roles"]): + module, action = node_key.rsplit(".", 1) + node_id = f"node-{index}" + node_ids[role] = node_id + definition = module_registry.MODULE_MAP[module][action] + params = { + key: {**deepcopy(value), "value": deepcopy(value.get("default"))} + for key, value in _execution_spec_role_params(spec, node_key, definition).items() + } + nodes[node_id] = {"module": module, "action": action, "params": params} + for source_role, source_handle, target_role, target_handle in spec["edges"]: + nodes[node_ids[target_role]]["params"][target_handle].update( + {"sourceId": node_ids[source_role], "sourceKey": source_handle} + ) + graph = {"nodes": nodes, "paths": [list(node_ids.values())]} + hints = { + "modelType": spec["modelType"], + "mode": spec["mode"], + "studioExecutionSpec": { + "schemaVersion": 1, + "id": spec["id"], + "contentHash": spec["contentHash"], + "nodes": node_ids, + }, + } + return graph, hints + + +class StudioExecutionSpecTests(unittest.TestCase): + def test_model_dependencies_are_pair_specific_immutable_artifact_receipts(self): + qwen = studio_model_dependencies_for_pair( + "QwenImageModularPipeline", + "control_image", + ) + redux = studio_model_dependencies_for_pair("FluxReduxPipeline", "edit_image") + + self.assertEqual( + qwen, + [ + { + "id": "qwen-controlnet-union", + "kind": "controlnet", + "repo": "InstantX/Qwen-Image-ControlNet-Union", + "revision": "b13036f066d6dee7c20513e263d3d673055e9de8", + } + ], + ) + self.assertEqual( + redux, + [ + { + "id": "flux-redux-base", + "kind": "base", + "repo": "black-forest-labs/FLUX.1-dev", + "revision": "3de623fc3c33e44ffbe2bad470d0f45bccf2eb21", + } + ], + ) + self.assertEqual( + studio_model_requirements_for_pair("FluxReduxPipeline", "edit_image")[0]["requiredForModes"], + ["edit_image"], + ) + self.assertEqual(studio_model_dependencies_for_pair("FluxReduxPipeline", "text_to_image"), []) + + def test_flux_registry_owns_profile_capability_and_auto_contracts(self): + specs = validate_studio_execution_specs(module_registry.MODULE_MAP) + self.assertEqual( + [(item["modelType"], item["mode"]) for item in specs], + [ + ("FluxSchnellPipeline", "text_to_image"), + ("FluxDevPipeline", "text_to_image"), + ("FluxKreaPipeline", "text_to_image"), + ("FluxDepthPipeline", "control_image"), + ("FluxCannyPipeline", "control_image"), + ("FluxReduxPipeline", "edit_image"), + ("FluxKontextPipeline", "edit_image"), + ("FluxKontextPipeline", "multi_image_reference_edit"), + ("FluxFillPipeline", "inpaint"), + ("FluxFillPipeline", "outpaint"), + ("Flux2KleinPipeline", "text_to_image"), + ("Flux2KleinPipeline", "edit_image"), + ("Flux2KleinPipeline", "multi_image_reference_edit"), + ("WanImageToVideoPipeline", "image_to_video"), + ("WanTI2VPipeline", "text_to_video"), + ("WanVideoPipeline", "text_to_video"), + ("WanVideoPipeline", "video_to_video"), + ("WanVideoPipeline", "video_color_edit"), + ("LTXVideoPipeline", "text_to_video"), + ("LTXVideoPipeline", "image_to_video"), + ("LTXVideoPipeline", "video_to_video"), + ("LTXVideoPipeline", "reference_to_video"), + ("AceStepAudioPipeline", "text_to_audio"), + ("AceStepAudioPipeline", "audio_variation"), + ("AceStepAudioPipeline", "audio_continuation"), + ("AceStepAudioPipeline", "audio_repaint"), + ("QwenImageEditModularPipeline", "inpaint"), + ("WanVACEPipeline", "text_to_video"), + ("WanVACEPipeline", "video_inpaint"), + ("WanVACEPipeline", "video_outpaint"), + ("WanVACEPipeline", "control_to_video"), + ("QwenImageEditModularPipeline", "outpaint"), + ("ZImageModularPipeline", "text_to_image"), + ("QwenImageModularPipeline", "text_to_image"), + ("QwenImageEditModularPipeline", "edit_image"), + ("QwenImageEditPlusModularPipeline", "edit_image"), + ("QwenImageEditPlusModularPipeline", "multi_image_reference_edit"), + ("QwenImageLayeredModularPipeline", "layer_decomposition"), + ("QwenImageModularPipeline", "control_image"), + ], + ) + self.assertEqual(specs[0]["roles"], specs[1]["roles"]) + self.assertEqual(specs[0]["edges"], specs[1]["edges"]) + self.assertEqual(specs[0]["bindings"], specs[1]["bindings"]) + self.assertEqual(specs[0]["roles"], specs[2]["roles"]) + self.assertEqual(specs[0]["edges"], specs[2]["edges"]) + self.assertEqual(specs[0]["bindings"], specs[2]["bindings"]) + self.assertEqual( + [item[0] for item in specs[3]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "diffusersImagePipeline", + "loadImage", + "diffusersImageControl", + "preview", + ], + ) + self.assertIn(("loadImage", "file", "controlImage"), specs[3]["bindings"]) + self.assertIn( + ("loadImage", "image", "diffusersImageControl", "control_image"), + specs[3]["edges"], + ) + self.assertEqual(specs[3]["roles"], specs[4]["roles"]) + self.assertEqual(specs[3]["edges"], specs[4]["edges"]) + self.assertEqual(specs[3]["bindings"], specs[4]["bindings"]) + self.assertEqual( + [item[0] for item in specs[5]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "diffusersImagePipeline", + "loadImage", + "diffusersImageEdit", + "preview", + ], + ) + self.assertIn(("loadImage", "file", "referenceImages"), specs[5]["bindings"]) + self.assertIn(("diffusersImageEdit", "reference_strength", "conditioningScale"), specs[5]["bindings"]) + self.assertEqual(specs[6]["roles"], specs[5]["roles"]) + self.assertEqual(specs[6]["edges"], specs[5]["edges"]) + self.assertEqual(specs[6]["bindings"], specs[5]["bindings"]) + self.assertEqual(specs[6]["pipelineClass"], "FluxKontextPipeline") + self.assertEqual(specs[7]["roles"], specs[6]["roles"]) + self.assertEqual(specs[7]["edges"], specs[6]["edges"]) + self.assertEqual(specs[7]["bindings"], specs[6]["bindings"]) + self.assertEqual(specs[7]["pipelineClass"], "FluxKontextPipeline") + self.assertEqual( + [item[0] for item in specs[8]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "diffusersImagePipeline", + "loadImage", + "loadMask", + "diffusersImageInpaint", + "preview", + ], + ) + self.assertIn(("loadMask", "file", "maskImage"), specs[8]["bindings"]) + self.assertIn(("loadMask", "alpha_channel", "removeAlpha"), specs[8]["bindings"]) + self.assertIn(("diffusersImageInpaint", "reference_strength", "conditioningScale"), specs[8]["bindings"]) + self.assertIn(("loadMask", "image", "diffusersImageInpaint", "mask_image"), specs[8]["edges"]) + self.assertEqual(specs[8]["pipelineClass"], "FluxFillPipeline") + self.assertEqual(specs[9]["roles"], specs[8]["roles"]) + self.assertEqual(specs[9]["edges"], specs[8]["edges"]) + self.assertEqual(specs[9]["bindings"], specs[8]["bindings"]) + self.assertEqual(specs[9]["pipelineClass"], "FluxFillPipeline") + self.assertNotEqual(specs[9]["contentHash"], specs[8]["contentHash"]) + self.assertEqual(specs[10]["roles"], specs[0]["roles"]) + self.assertEqual(specs[10]["edges"], specs[0]["edges"]) + self.assertEqual(specs[10]["bindings"], specs[0]["bindings"]) + self.assertEqual(specs[10]["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(specs[11]["roles"], specs[5]["roles"]) + self.assertEqual(specs[11]["edges"], specs[5]["edges"]) + self.assertEqual(specs[11]["bindings"], specs[5]["bindings"]) + self.assertEqual(specs[11]["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(specs[12]["roles"], specs[11]["roles"]) + self.assertEqual(specs[12]["edges"], specs[11]["edges"]) + self.assertEqual(specs[12]["bindings"], specs[11]["bindings"]) + self.assertEqual(specs[12]["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual( + [item[0] for item in specs[13]["roles"]], + ["diffusersQuantization", "diffusersRecipe", "wanPipeline", "wanGenerate", "videoExport", "loadImage"], + ) + self.assertIn(("diffusersQuantization", "components", "dualQuantizedComponents"), specs[13]["bindings"]) + self.assertIn(("loadImage", "file", "referenceImages"), specs[13]["bindings"]) + self.assertIn(("loadImage", "image", "wanGenerate", "reference_images"), specs[13]["edges"]) + self.assertNotIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[13]["bindings"]) + self.assertEqual( + DIFFUSERS_EXECUTION_PROFILES[specs[13]["executionProfileId"]].default_quantized_components, + ("transformer", "transformer_2"), + ) + self.assertEqual( + [item[0] for item in specs[14]["roles"]], + ["diffusersQuantization", "diffusersRecipe", "wanPipeline", "wanGenerate", "videoExport"], + ) + self.assertIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[14]["bindings"]) + self.assertIn(("wanGenerate", "video_out", "videoExport", "video"), specs[14]["edges"]) + self.assertEqual(specs[15]["pipelineClass"], "WanPipeline") + self.assertEqual(specs[15]["roles"], specs[14]["roles"]) + self.assertEqual(specs[15]["edges"], specs[14]["edges"]) + self.assertEqual(specs[15]["bindings"], specs[14]["bindings"]) + self.assertEqual( + [item[0] for item in specs[16]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "wanPipeline", + "wanGenerate", + "videoExport", + "loadVideo", + "normalizeVideo", + ], + ) + self.assertIn(("loadVideo", "file", "sourceVideo"), specs[16]["bindings"]) + self.assertIn(("loadVideo", "video", "normalizeVideo", "video"), specs[16]["edges"]) + self.assertIn(("normalizeVideo", "output", "wanGenerate", "video"), specs[16]["edges"]) + self.assertEqual(specs[16]["pipelineClass"], "WanVideoToVideoPipeline") + self.assertEqual(specs[17]["roles"], specs[16]["roles"]) + self.assertEqual(specs[17]["edges"], specs[16]["edges"]) + self.assertEqual(specs[17]["bindings"], specs[16]["bindings"]) + self.assertEqual(specs[17]["pipelineClass"], "WanVideoToVideoPipeline") + self.assertEqual(specs[18]["roles"], specs[15]["roles"]) + self.assertEqual(specs[18]["edges"], specs[15]["edges"]) + self.assertIn(("diffusersRecipe", "attention_backend", "nativeMath"), specs[18]["bindings"]) + self.assertIn(("diffusersRecipe", "attention_components", "empty"), specs[18]["bindings"]) + self.assertNotIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[18]["bindings"]) + self.assertEqual(specs[18]["pipelineClass"], "LTXConditionPipeline") + self.assertEqual(specs[19]["roles"], specs[13]["roles"]) + self.assertEqual(specs[19]["edges"], specs[13]["edges"]) + self.assertIn(("diffusersRecipe", "attention_backend", "nativeMath"), specs[19]["bindings"]) + self.assertIn(("loadImage", "file", "referenceImages"), specs[19]["bindings"]) + self.assertNotIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[19]["bindings"]) + self.assertEqual(specs[19]["pipelineClass"], "LTXConditionPipeline") + self.assertEqual(specs[20]["roles"], specs[16]["roles"]) + self.assertEqual(specs[20]["edges"], specs[16]["edges"]) + self.assertIn(("diffusersRecipe", "attention_backend", "nativeMath"), specs[20]["bindings"]) + self.assertIn(("wanGenerate", "strength", "conditioningScale"), specs[20]["bindings"]) + self.assertIn(("wanGenerate", "denoise_strength", "strength"), specs[20]["bindings"]) + self.assertNotIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[20]["bindings"]) + self.assertEqual(specs[20]["pipelineClass"], "LTXConditionPipeline") + self.assertEqual(specs[21]["roles"], specs[19]["roles"]) + self.assertEqual(specs[21]["edges"], specs[19]["edges"]) + self.assertEqual(specs[21]["bindings"], specs[19]["bindings"]) + self.assertEqual(specs[21]["pipelineClass"], "LTXConditionPipeline") + self.assertEqual( + [item[0] for item in specs[22]["roles"]], + ["diffusersQuantization", "diffusersRecipe", "audioPipeline", "audioGenerate", "audioExport"], + ) + self.assertIn(("audioGenerate", "task_type", "text2music"), specs[22]["bindings"]) + self.assertIn(("audioGenerate", "audio", "audioExport", "audio"), specs[22]["edges"]) + self.assertEqual(specs[22]["pipelineClass"], "AceStepPipeline") + self.assertEqual( + [item[0] for item in specs[23]["roles"]], + ["loadAudio", "diffusersQuantization", "diffusersRecipe", "audioPipeline", "audioGenerate", "audioExport"], + ) + self.assertIn(("loadAudio", "file", "sourceAudio"), specs[23]["bindings"]) + self.assertIn(("audioGenerate", "task_type", "cover"), specs[23]["bindings"]) + self.assertIn(("loadAudio", "audio", "audioGenerate", "source_audio"), specs[23]["edges"]) + self.assertEqual(specs[23]["pipelineClass"], "AceStepPipeline") + self.assertNotEqual(specs[23]["contentHash"], specs[22]["contentHash"]) + self.assertEqual( + [item[0] for item in specs[24]["roles"]], + [ + "loadAudio", + "diffusersQuantization", + "diffusersRecipe", + "audioPipeline", + "audioGenerate", + "audioExport", + "audioLoudnessMatch", + "audioJoin", + ], + ) + self.assertIn(("audioGenerate", "task_type", "continuation"), specs[24]["bindings"]) + self.assertIn(("audioGenerate", "return_continuation_tail", "true"), specs[24]["bindings"]) + self.assertIn(("audioGenerate", "audio", "audioLoudnessMatch", "audio"), specs[24]["edges"]) + self.assertIn(("audioJoin", "output", "audioExport", "audio"), specs[24]["edges"]) + self.assertNotEqual(specs[24]["contentHash"], specs[23]["contentHash"]) + self.assertEqual(specs[25]["roles"], specs[23]["roles"]) + self.assertEqual(specs[25]["edges"], specs[23]["edges"]) + self.assertIn(("audioGenerate", "task_type", "repaint"), specs[25]["bindings"]) + self.assertIn(("loadAudio", "file", "sourceAudio"), specs[25]["bindings"]) + self.assertNotEqual(specs[25]["contentHash"], specs[24]["contentHash"]) + self.assertEqual( + [item[0] for item in specs[26]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "diffusersImagePipeline", + "loadImage", + "loadMask", + "diffusersImageInpaint", + "preview", + ], + ) + self.assertEqual(specs[26]["executionProfileId"], "qwen-edit:direct-inpaint") + self.assertEqual(specs[26]["pipelineClass"], "QwenImageEditInpaintPipeline") + self.assertIn(("loadImage", "file", "referenceImages"), specs[26]["bindings"]) + self.assertIn(("loadMask", "file", "maskImage"), specs[26]["bindings"]) + self.assertIn(("loadMask", "image", "diffusersImageInpaint", "mask_image"), specs[26]["edges"]) + self.assertEqual(specs[27]["roles"], specs[14]["roles"]) + self.assertEqual(specs[27]["edges"], specs[14]["edges"]) + self.assertEqual(specs[27]["executionProfileId"], "wan-vace:direct") + self.assertEqual(specs[27]["pipelineClass"], "WanVACEPipeline") + self.assertIn(("wanPipeline", "revision", "wanVaceRevision"), specs[27]["bindings"]) + self.assertIn(("wanGenerate", "mode", "mode"), specs[27]["bindings"]) + self.assertIn(("wanGenerate", "scheduler_flow_shift", "shift"), specs[27]["bindings"]) + self.assertEqual( + [item[0] for item in specs[28]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "wanPipeline", + "wanGenerate", + "videoExport", + "loadVideo", + "normalizeVideo", + "loadMaskVideo", + "alignMaskVideo", + ], + ) + self.assertIn(("normalizeVideo", "output", "alignMaskVideo", "video"), specs[28]["edges"]) + self.assertIn(("loadMaskVideo", "video", "alignMaskVideo", "mask"), specs[28]["edges"]) + self.assertIn(("alignMaskVideo", "output", "wanGenerate", "mask"), specs[28]["edges"]) + self.assertIn(("loadMaskVideo", "file", "maskVideo"), specs[28]["bindings"]) + self.assertIn(("alignMaskVideo", "threshold", "maskThreshold127"), specs[28]["bindings"]) + self.assertIn(("alignMaskVideo", "grow_pixels", "inpaintMaskGrow96"), specs[28]["bindings"]) + self.assertEqual(specs[29]["roles"], specs[28]["roles"]) + self.assertEqual(specs[29]["edges"], specs[28]["edges"]) + self.assertIn(("alignMaskVideo", "grow_pixels", "outpaintMaskGrow0"), specs[29]["bindings"]) + self.assertNotIn(("alignMaskVideo", "grow_pixels", "inpaintMaskGrow96"), specs[29]["bindings"]) + self.assertNotEqual(specs[29]["contentHash"], specs[28]["contentHash"]) + self.assertEqual( + [item[0] for item in specs[30]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "wanPipeline", + "wanGenerate", + "videoExport", + "loadControlVideo", + "normalizeVideo", + ], + ) + self.assertIn(("loadControlVideo", "video", "normalizeVideo", "video"), specs[30]["edges"]) + self.assertIn(("normalizeVideo", "output", "wanGenerate", "video"), specs[30]["edges"]) + self.assertIn(("loadControlVideo", "file", "controlVideo"), specs[30]["bindings"]) + self.assertIn(("normalizeVideo", "num_frames", "numFrames"), specs[30]["bindings"]) + self.assertNotIn(("loadVideo", "file", "sourceVideo"), specs[30]["bindings"]) + self.assertNotIn(("loadMaskVideo", "file", "maskVideo"), specs[30]["bindings"]) + self.assertEqual( + [item[0] for item in specs[31]["roles"]], + [ + "diffusersQuantization", + "diffusersRecipe", + "diffusersImagePipeline", + "loadImage", + "qwenOutpaintCanvas", + "diffusersImageInpaint", + "preview", + ], + ) + self.assertIn(("loadImage", "image", "qwenOutpaintCanvas", "image"), specs[31]["edges"]) + self.assertIn(("qwenOutpaintCanvas", "canvas", "diffusersImageInpaint", "image"), specs[31]["edges"]) + self.assertIn( + ("qwenOutpaintCanvas", "mask_image", "diffusersImageInpaint", "mask_image"), + specs[31]["edges"], + ) + self.assertIn(("qwenOutpaintCanvas", "overlap", "outpaintOverlap"), specs[31]["bindings"]) + self.assertNotIn(("loadMask", "file", "maskImage"), specs[31]["bindings"]) + self.assertEqual(specs[0]["actions"], ()) + self.assertRegex(specs[0]["contentHash"], r"^studio-spec-v1-[0-9a-f]{8}$") + self.assertEqual(specs, validate_studio_execution_specs(module_registry.MODULE_MAP)) + + for spec in specs: + definition = STUDIO_EXECUTION_SPEC_DEFINITIONS[spec["id"]] + profile = DIFFUSERS_EXECUTION_PROFILES[spec["executionProfileId"]] + self.assertEqual(profile.default_repo, definition["profile"]["default_repo"]) + self.assertEqual(profile.pipeline_class, spec["pipelineClass"]) + if "capability" in definition: + self.assertEqual(STUDIO_MODEL_CAPABILITIES[spec["modelType"]], definition["capability"]) + if "autoRequirements" in definition: + requirements_key = definition.get("autoRequirementKey", spec["modelType"]) + self.assertEqual(AUTO_MODEL_REQUIREMENTS[requirements_key], definition["autoRequirements"]) + + def test_registry_validation_rejects_unknown_nodes_params_handles_and_dangling_edges(self): + broken_modules = deepcopy(module_registry.MODULE_MAP) + del broken_modules["modules.DiffusersImage"]["Generate"] + with self.assertRaisesRegex(ValueError, "unknown node"): + validate_studio_execution_specs(broken_modules) + + broken_modules = deepcopy(module_registry.MODULE_MAP) + del broken_modules["modules.DiffusersImage"]["Generate"]["params"]["prompt"] + with self.assertRaisesRegex(ValueError, "binding"): + validate_studio_execution_specs(broken_modules) + + broken_modules = deepcopy(module_registry.MODULE_MAP) + broken_modules["modules.DiffusersImage"]["Generate"]["params"]["pipeline"]["type"] = "audio" + with self.assertRaisesRegex(ValueError, "incompatible handle"): + validate_studio_execution_specs(broken_modules) + + import modiff.studio_execution_specs as specs_module + + with patch.object( + specs_module, + "_GRAPH_EDGES", + (*specs_module._GRAPH_EDGES, ("missingRole", "output", "preview", "image")), + ): + with self.assertRaisesRegex(ValueError, "edge"): + validate_studio_execution_specs(module_registry.MODULE_MAP) + + with patch.object( + specs_module, + "_GRAPH_BINDINGS", + (*specs_module._GRAPH_BINDINGS, ("preview", "missing", "prompt")), + ): + with self.assertRaisesRegex(ValueError, "binding"): + validate_studio_execution_specs(module_registry.MODULE_MAP) + + with patch.object( + specs_module, + "_GRAPH_BINDINGS", + (*specs_module._GRAPH_BINDINGS, ("diffusersQuantization", "quantization_config", "empty")), + ): + with self.assertRaisesRegex(ValueError, "binding"): + validate_studio_execution_specs(module_registry.MODULE_MAP) + + with patch.object(specs_module, "_GRAPH_EDGES", specs_module._GRAPH_EDGES[:1]): + with self.assertRaisesRegex(ValueError, "disconnected"): + validate_studio_execution_specs(module_registry.MODULE_MAP) + + def test_runtime_receipt_binds_graph_profile_and_topology(self): + spec = studio_execution_spec_for_pair("FluxKreaPipeline", "text_to_image") + self.assertIsNotNone(spec) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + selected = { + "executionProfileId": spec["executionProfileId"], + "studioExecutionSpecContract": { + "schemaVersion": spec["schemaVersion"], + "id": spec["id"], + "contentHash": spec["contentHash"], + "executionProfileId": spec["executionProfileId"], + }, + } + hints["autoResourcePlan"] = selected + assert_studio_execution_graph(graph, hints) + + missing_receipt = dict(hints) + missing_receipt.pop("studioExecutionSpec") + with self.assertRaisesRegex(RuntimeError, "receipt is required"): + assert_studio_execution_graph(graph, missing_receipt) + + selected["studioExecutionSpecContract"] = { + **selected["studioExecutionSpecContract"], + "contentHash": "studio-spec-v1-00000000", + } + with self.assertRaisesRegex(RuntimeError, "Auto graph contract"): + assert_studio_execution_graph(graph, hints) + selected["studioExecutionSpecContract"]["contentHash"] = spec["contentHash"] + + selected["executionProfileId"] = "flux-dev:direct" + with self.assertRaisesRegex(RuntimeError, "Auto profile"): + assert_studio_execution_graph(graph, hints) + + selected["executionProfileId"] = spec["executionProfileId"] + graph["nodes"][hints["studioExecutionSpec"]["nodes"]["preview"]]["params"]["image"].pop( + "sourceId" + ) + with self.assertRaisesRegex(RuntimeError, "edge"): + assert_studio_execution_graph(graph, hints) + + def test_z_image_auto_seals_the_exact_direct_image_route(self): + spec = studio_execution_spec_for_pair("ZImageModularPipeline", "text_to_image") + self.assertIsNotNone(spec) + self.assertEqual(spec["id"], "z-image:text-to-image:v1") + self.assertEqual(spec["executionProfileId"], "z-image:auto") + self.assertEqual(spec["loaderModule"], "modules.DiffusersImage") + self.assertEqual(spec["loaderAction"], "LoadPipeline") + self.assertEqual(spec["executionPath"], "direct-diffusers-image") + self.assertEqual(spec["pipelineClass"], "ZImagePipeline") + self.assertEqual(spec["defaultRepo"], "Tongyi-MAI/Z-Image-Turbo") + self.assertEqual(spec["roles"], validate_studio_execution_specs(module_registry.MODULE_MAP)[0]["roles"]) + self.assertEqual(spec["edges"], validate_studio_execution_specs(module_registry.MODULE_MAP)[0]["edges"]) + self.assertEqual(spec["bindings"], validate_studio_execution_specs(module_registry.MODULE_MAP)[0]["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + graph["nodes"][hints["studioExecutionSpec"]["nodes"]["diffusersImagePipeline"]]["action"] = "Edit" + hints["autoResourcePlan"] = { + "executionProfileId": spec["executionProfileId"], + "studioExecutionSpecContract": { + "schemaVersion": spec["schemaVersion"], + "id": spec["id"], + "contentHash": spec["contentHash"], + "executionProfileId": spec["executionProfileId"], + }, + } + with self.assertRaisesRegex(RuntimeError, "node identity"): + assert_studio_execution_graph(graph, hints) + + def test_qwen_image_text_to_image_seals_the_exact_direct_image_route(self): + spec = studio_execution_spec_for_pair("QwenImageModularPipeline", "text_to_image") + self.assertIsNotNone(spec) + self.assertEqual(spec["id"], "qwen-image-2512:text-to-image:v1") + self.assertEqual(spec["executionProfileId"], "qwen-image:t2i-direct") + self.assertEqual(spec["loaderModule"], "modules.DiffusersImage") + self.assertEqual(spec["loaderAction"], "LoadPipeline") + self.assertEqual(spec["executionPath"], "direct-diffusers-image") + self.assertEqual(spec["pipelineClass"], "QwenImagePipeline") + self.assertEqual(spec["defaultRepo"], "Qwen/Qwen-Image-2512") + z_image = studio_execution_spec_for_pair("ZImageModularPipeline", "text_to_image") + self.assertEqual(spec["roles"], z_image["roles"]) + self.assertEqual(spec["edges"], z_image["edges"]) + self.assertEqual(spec["bindings"], z_image["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_wan_modes_have_exact_receipts_and_v2v_modes_share_the_reviewed_recipe(self): + text = studio_execution_spec_for_pair("WanVideoPipeline", "text_to_video") + video = studio_execution_spec_for_pair("WanVideoPipeline", "video_to_video") + color = studio_execution_spec_for_pair("WanVideoPipeline", "video_color_edit") + self.assertIsNotNone(text) + self.assertIsNotNone(video) + self.assertIsNotNone(color) + self.assertEqual(text["pipelineClass"], "WanPipeline") + self.assertEqual(video["pipelineClass"], "WanVideoToVideoPipeline") + self.assertEqual(color["pipelineClass"], "WanVideoToVideoPipeline") + self.assertEqual(color["roles"], video["roles"]) + self.assertEqual(color["edges"], video["edges"]) + self.assertEqual(color["bindings"], video["bindings"]) + self.assertNotEqual(color["contentHash"], video["contentHash"]) + for spec in (text, video, color): + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_all_ltx_modes_seal_portable_attention_with_exact_receipts(self): + for mode in ("text_to_video", "image_to_video", "video_to_video", "reference_to_video"): + spec = studio_execution_spec_for_pair("LTXVideoPipeline", mode) + self.assertIsNotNone(spec) + self.assertEqual(spec["pipelineClass"], "LTXConditionPipeline") + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_all_ace_modes_seal_exact_generic_audio_routes(self): + text = studio_execution_spec_for_pair("AceStepAudioPipeline", "text_to_audio") + variation = studio_execution_spec_for_pair("AceStepAudioPipeline", "audio_variation") + continuation = studio_execution_spec_for_pair("AceStepAudioPipeline", "audio_continuation") + repaint = studio_execution_spec_for_pair("AceStepAudioPipeline", "audio_repaint") + self.assertIsNotNone(text) + self.assertIsNotNone(variation) + self.assertIsNotNone(continuation) + self.assertIsNotNone(repaint) + self.assertEqual(text["executionProfileId"], "ace-step-audio:direct") + self.assertEqual(variation["executionProfileId"], "ace-step-audio:direct") + self.assertEqual(text["pipelineClass"], "AceStepPipeline") + self.assertEqual(variation["pipelineClass"], "AceStepPipeline") + self.assertEqual(text["defaultRepo"], "ACE-Step/acestep-v15-xl-turbo-diffusers") + self.assertIn(("audioGenerate", "task_type", "text2music"), text["bindings"]) + self.assertIn(("audioGenerate", "task_type", "cover"), variation["bindings"]) + self.assertIn(("loadAudio", "file", "sourceAudio"), variation["bindings"]) + self.assertIn(("loadAudio", "audio", "audioGenerate", "source_audio"), variation["edges"]) + self.assertIn(("audioGenerate", "task_type", "continuation"), continuation["bindings"]) + self.assertIn(("audioLoudnessMatch", "reference_window_seconds", "referenceWindow15"), continuation["bindings"]) + self.assertIn(("audioJoin", "boundary_fade_seconds", "boundaryFade001"), continuation["bindings"]) + self.assertIn(("loadAudio", "audio", "audioJoin", "source"), continuation["edges"]) + self.assertIn(("audioGenerate", "task_type", "repaint"), repaint["bindings"]) + self.assertIn(("loadAudio", "file", "sourceAudio"), repaint["bindings"]) + self.assertEqual(repaint["roles"], variation["roles"]) + self.assertEqual(repaint["edges"], variation["edges"]) + for spec in (text, variation, continuation, repaint): + self.assertIn(("audioGenerate", "sample_rate", "sampleRate48000"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_qwen_image_edit_inpaint_seals_the_exact_direct_mask_route(self): + spec = studio_execution_spec_for_pair("QwenImageEditModularPipeline", "inpaint") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-edit:direct-inpaint") + self.assertEqual(spec["pipelineClass"], "QwenImageEditInpaintPipeline") + self.assertIn(("loadImage", "image", "diffusersImageInpaint", "image"), spec["edges"]) + self.assertIn(("loadMask", "image", "diffusersImageInpaint", "mask_image"), spec["edges"]) + self.assertIn(("diffusersImageInpaint", "strength", "strength"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_qwen_image_edit_seals_the_exact_dynamic_modular_route(self): + spec = studio_execution_spec_for_pair("QwenImageEditModularPipeline", "edit_image") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-edit:modular") + self.assertEqual(spec["executionPath"], "modular-diffusers") + self.assertEqual(spec["pipelineClass"], "QwenImageEditModularPipeline") + self.assertEqual( + [item[0] for item in spec["roles"]], + ["models", "prompt", "loadImage", "imageEncode", "denoise", "decode", "preview"], + ) + self.assertIn(("loadImage", "image", "prompt", "image"), spec["edges"]) + self.assertIn(("imageEncode", "route_state_out", "denoise", "route_state_in"), spec["edges"]) + self.assertIn(("denoise", "route_state_out", "decode", "route_state_in"), spec["edges"]) + self.assertIn(("models", "model_type", "pipelineClass"), spec["bindings"]) + self.assertIn(("loadImage", "file", "referenceImages"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + graph["nodes"][hints["studioExecutionSpec"]["nodes"]["denoise"]]["params"]["route_state_in"].pop( + "sourceId" + ) + with self.assertRaisesRegex(RuntimeError, "edge"): + assert_studio_execution_graph(graph, hints) + + def test_qwen_image_edit_plus_modes_seal_the_exact_dynamic_modular_route(self): + specs = [ + studio_execution_spec_for_pair("QwenImageEditPlusModularPipeline", mode) + for mode in ("edit_image", "multi_image_reference_edit") + ] + for spec in specs: + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-edit-plus:modular") + self.assertEqual(spec["executionPath"], "modular-diffusers") + self.assertEqual(spec["pipelineClass"], "QwenImageEditPlusModularPipeline") + self.assertEqual(spec["roles"], specs[0]["roles"]) + self.assertEqual(spec["edges"], specs[0]["edges"]) + self.assertEqual(spec["bindings"], specs[0]["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + self.assertNotEqual(specs[0]["id"], specs[1]["id"]) + self.assertNotEqual(specs[0]["contentHash"], specs[1]["contentHash"]) + + def test_qwen_image_layered_seals_the_exact_dynamic_modular_route(self): + spec = studio_execution_spec_for_pair("QwenImageLayeredModularPipeline", "layer_decomposition") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-layered:modular") + self.assertEqual(spec["executionPath"], "modular-diffusers") + self.assertEqual(spec["pipelineClass"], "QwenImageLayeredModularPipeline") + self.assertEqual(len(spec["roles"]), 7) + self.assertEqual(len(spec["edges"]), 11) + self.assertEqual(len(spec["bindings"]), 17) + self.assertNotIn("route_state_out", [item[1] for item in spec["edges"]]) + self.assertIn(("loadImage", "alpha_channel", "addAlpha"), spec["bindings"]) + self.assertIn(("denoise", "layers", "layers"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_qwen_control_seals_the_exact_auxiliary_loader_and_route_state_chain(self): + spec = studio_execution_spec_for_pair("QwenImageModularPipeline", "control_image") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-image:modular") + self.assertEqual(spec["executionPath"], "modular-diffusers") + self.assertEqual(spec["pipelineClass"], "QwenImageModularPipeline") + self.assertEqual(len(spec["roles"]), 8) + self.assertEqual(len(spec["edges"]), 13) + self.assertEqual(len(spec["bindings"]), 32) + self.assertIn(("controlnetModel", "model_id", "repo"), spec["bindings"]) + self.assertIn(("controlnetModel", "revision", "revision"), spec["bindings"]) + self.assertIn(("controlnet", "route_state_out", "denoise", "route_state_in"), spec["edges"]) + self.assertIn(("denoise", "route_state_out", "decode", "route_state_in"), spec["edges"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_qwen_image_edit_outpaint_seals_the_generated_canvas_and_mask_route(self): + spec = studio_execution_spec_for_pair("QwenImageEditModularPipeline", "outpaint") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "qwen-edit:direct-inpaint") + self.assertEqual(spec["pipelineClass"], "QwenImageEditInpaintPipeline") + self.assertIn(("loadImage", "image", "qwenOutpaintCanvas", "image"), spec["edges"]) + self.assertIn(("qwenOutpaintCanvas", "canvas", "diffusersImageInpaint", "image"), spec["edges"]) + self.assertIn( + ("qwenOutpaintCanvas", "mask_image", "diffusersImageInpaint", "mask_image"), + spec["edges"], + ) + self.assertIn(("qwenOutpaintCanvas", "fill_color", "outpaintFillColor"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_wan_vace_text_to_video_seals_the_exact_generic_video_route(self): + spec = studio_execution_spec_for_pair("WanVACEPipeline", "text_to_video") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "wan-vace:direct") + self.assertEqual(spec["executionPath"], "direct-wan-vace") + self.assertEqual(spec["pipelineClass"], "WanVACEPipeline") + self.assertIn(("wanPipeline", "revision", "wanVaceRevision"), spec["bindings"]) + self.assertIn(("wanPipeline", "pipeline", "wanGenerate", "pipeline"), spec["edges"]) + self.assertIn(("wanGenerate", "mode", "mode"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_wan_vace_video_inpaint_seals_source_and_mask_conditioning(self): + spec = studio_execution_spec_for_pair("WanVACEPipeline", "video_inpaint") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "wan-vace:direct") + self.assertIn(("loadVideo", "video", "normalizeVideo", "video"), spec["edges"]) + self.assertIn(("loadMaskVideo", "video", "alignMaskVideo", "mask"), spec["edges"]) + self.assertIn(("alignMaskVideo", "output", "wanGenerate", "mask"), spec["edges"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_wan_vace_video_outpaint_seals_boundary_mask_conditioning(self): + spec = studio_execution_spec_for_pair("WanVACEPipeline", "video_outpaint") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "wan-vace:direct") + self.assertIn(("loadVideo", "video", "normalizeVideo", "video"), spec["edges"]) + self.assertIn(("normalizeVideo", "output", "alignMaskVideo", "video"), spec["edges"]) + self.assertIn(("alignMaskVideo", "output", "wanGenerate", "mask"), spec["edges"]) + self.assertIn(("alignMaskVideo", "grow_pixels", "outpaintMaskGrow0"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_wan_vace_control_to_video_seals_control_normalization(self): + spec = studio_execution_spec_for_pair("WanVACEPipeline", "control_to_video") + self.assertIsNotNone(spec) + self.assertEqual(spec["executionProfileId"], "wan-vace:direct") + self.assertIn(("loadControlVideo", "video", "normalizeVideo", "video"), spec["edges"]) + self.assertIn(("normalizeVideo", "output", "wanGenerate", "video"), spec["edges"]) + self.assertIn(("loadControlVideo", "file", "controlVideo"), spec["bindings"]) + self.assertIn(("normalizeVideo", "width", "width"), spec["bindings"]) + self.assertIn(("normalizeVideo", "height", "height"), spec["bindings"]) + self.assertIn(("normalizeVideo", "num_frames", "numFrames"), spec["bindings"]) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + def test_flux_kontext_modes_have_distinct_exact_receipts_and_only_edit_has_auto_requirements(self): + edit = studio_execution_spec_for_pair("FluxKontextPipeline", "edit_image") + multi = studio_execution_spec_for_pair("FluxKontextPipeline", "multi_image_reference_edit") + self.assertIsNotNone(edit) + self.assertIsNotNone(multi) + self.assertEqual(edit["pipelineClass"], "FluxKontextPipeline") + self.assertEqual(multi["pipelineClass"], "FluxKontextPipeline") + self.assertEqual(edit["executionProfileId"], multi["executionProfileId"]) + self.assertNotEqual(edit["contentHash"], multi["contentHash"]) + for spec in (edit, multi): + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + self.assertEqual(AUTO_MODEL_REQUIREMENTS["FluxKontextPipeline"]["supportedTasks"], ["edit_image"]) + + def test_flux_fill_modes_have_distinct_exact_receipts_and_shared_auto_requirements(self): + inpaint = studio_execution_spec_for_pair("FluxFillPipeline", "inpaint") + outpaint = studio_execution_spec_for_pair("FluxFillPipeline", "outpaint") + self.assertIsNotNone(inpaint) + self.assertIsNotNone(outpaint) + self.assertEqual(inpaint["pipelineClass"], "FluxFillPipeline") + self.assertEqual(outpaint["pipelineClass"], "FluxFillPipeline") + self.assertEqual(inpaint["executionProfileId"], outpaint["executionProfileId"]) + self.assertNotEqual(inpaint["contentHash"], outpaint["contentHash"]) + for spec in (inpaint, outpaint): + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + self.assertEqual(AUTO_MODEL_REQUIREMENTS["FluxFillPipeline"]["supportedTasks"], ["inpaint", "outpaint"]) + + def test_flux2_klein_modes_have_distinct_exact_receipts(self): + text = studio_execution_spec_for_pair("Flux2KleinPipeline", "text_to_image") + edit = studio_execution_spec_for_pair("Flux2KleinPipeline", "edit_image") + multi = studio_execution_spec_for_pair("Flux2KleinPipeline", "multi_image_reference_edit") + self.assertIsNotNone(text) + self.assertIsNotNone(edit) + self.assertIsNotNone(multi) + self.assertEqual(text["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(edit["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(multi["pipelineClass"], "Flux2KleinPipeline") + self.assertEqual(text["roles"], validate_studio_execution_specs(module_registry.MODULE_MAP)[0]["roles"]) + self.assertEqual(edit["roles"], studio_execution_spec_for_pair("FluxReduxPipeline", "edit_image")["roles"]) + self.assertEqual(multi["roles"], edit["roles"]) + self.assertNotEqual(text["contentHash"], edit["contentHash"]) + self.assertNotEqual(multi["contentHash"], edit["contentHash"]) + for spec in (text, edit, multi): + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + self.assertEqual( + AUTO_MODEL_REQUIREMENTS["Flux2KleinPipeline"]["supportedTasks"], + ["text_to_image", "edit_image", "multi_image_reference_edit"], + ) + + def test_runtime_hint_parser_rejects_malformed_new_receipts_without_legacy_fallback(self): + server = WebServer(module_registry.MODULE_MAP) + spec = studio_execution_spec_for_pair("FluxDevPipeline", "text_to_image") + graph, hints = executable_graph_for_spec(spec) + parsed = server._coerce_runtime_hints(hints) + self.assertEqual(parsed["studioExecutionSpec"], hints["studioExecutionSpec"]) + assert_studio_execution_graph(graph, parsed) + + for malformed in ( + {**hints["studioExecutionSpec"], "contentHash": "bad"}, + {**hints["studioExecutionSpec"], "nodes": {"x": "y"}}, + {**hints["studioExecutionSpec"], "extra": True}, + ): + with self.subTest(malformed=malformed): + with self.assertRaisesRegex(RuntimeError, "Studio execution specification"): + server._coerce_runtime_hints({**hints, "studioExecutionSpec": malformed}) + + def test_control_image_receipt_requires_the_exact_source_route(self): + spec = studio_execution_spec_for_pair("FluxDepthPipeline", "control_image") + self.assertIsNotNone(spec) + graph, hints = executable_graph_for_spec(spec) + assert_studio_execution_graph(graph, hints) + + control_id = hints["studioExecutionSpec"]["nodes"]["diffusersImageControl"] + graph["nodes"][control_id]["params"]["control_image"].pop("sourceId") + with self.assertRaisesRegex(RuntimeError, "edge"): + assert_studio_execution_graph(graph, hints) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wan_vace.py b/tests/test_wan_vace.py index d10b877..2ce3b22 100644 --- a/tests/test_wan_vace.py +++ b/tests/test_wan_vace.py @@ -32,8 +32,9 @@ def test_loader_uses_the_app_configured_hugging_face_cache(self): patch("modules.DiffusersVideo.wan_vace.apply_pipeline_offload"), ): result = node.execute( + pipeline_class="WanVACEPipeline", model_id={"source": "hub", "value": WAN_VACE_DEFAULT_REPO}, - revision="pinned-revision", + revision="ec4d2cb062b548996b179d493fdd05340de702a1", dtype="bfloat16", device="cuda:0", auto_offload=True, @@ -46,7 +47,7 @@ def test_loader_uses_the_app_configured_hugging_face_cache(self): WAN_VACE_DEFAULT_REPO, subfolder="vae", torch_dtype=torch.float32, - revision="pinned-revision", + revision="ec4d2cb062b548996b179d493fdd05340de702a1", local_files_only=True, cache_dir="E:/MoDiff/huggingface/hub", ) @@ -55,19 +56,84 @@ def test_loader_uses_the_app_configured_hugging_face_cache(self): self.assertEqual(load_args, (WAN_VACE_DEFAULT_REPO,)) self.assertEqual(load_kwargs["torch_dtype"], torch.bfloat16) self.assertIs(load_kwargs["vae"], vae) - self.assertEqual(load_kwargs["revision"], "pinned-revision") + self.assertEqual(load_kwargs["revision"], "ec4d2cb062b548996b179d493fdd05340de702a1") self.assertEqual(load_kwargs["cache_dir"], "E:/MoDiff/huggingface/hub") self.assertTrue(load_kwargs["local_files_only"]) self.assertTrue(load_kwargs["low_cpu_mem_usage"]) class WanVaceLongVideoTests(unittest.TestCase): + def test_generic_generate_preserves_canonical_numpy_and_torch_mask_layouts(self): + class Output: + frames = [["generated"]] + + class Pipeline: + _modiff_video_pipeline_class = "WanVACEPipeline" + _execution_device = "cpu" + vae_scale_factor_temporal = 1 + vae_scale_factor_spatial = 8 + transformer = type("Transformer", (), {"config": type("Config", (), {"patch_size": (1, 2, 2)})()})() + boundary_ratio = None + + def __init__(self): + self.calls = [] + + def __call__(self, **kwargs): + self.calls.append(kwargs) + return Output() + + cases = ( + ( + np.full((16, 16, 3), 23, dtype=np.uint8), + np.pad( + np.full((16, 8), 255, dtype=np.uint8), + ((0, 0), (8, 0)), + ), + lambda conditioned: ( + np.all(conditioned[:, :8] == 23), + np.all(conditioned[:, 8:] == 127), + ), + ), + ( + torch.full((3, 16, 16), 23, dtype=torch.uint8), + torch.cat( + [ + torch.zeros((16, 8), dtype=torch.uint8), + torch.full((16, 8), 255, dtype=torch.uint8), + ], + dim=1, + ), + lambda conditioned: ( + bool(torch.all(conditioned[:, :, :8] == 23)), + bool(torch.all(conditioned[:, :, 8:] == 127)), + ), + ), + ) + for index, (frame, mask, assertions) in enumerate(cases): + pipeline = Pipeline() + result = Generate(f"canonical-vace-layout-{index}").execute( + pipeline=pipeline, + mode="video_inpaint", + video=[frame], + mask=[mask], + width=16, + height=16, + num_frames=1, + num_inference_steps=1, + ) + + conditioned = pipeline.calls[0]["video"][0] + with self.subTest(container=type(frame).__name__): + self.assertEqual(assertions(conditioned), (True, True)) + self.assertEqual(result["frames_out"], 1) + def test_long_masked_video_uses_native_overlapping_segments_and_generated_anchor(self): class Output: def __init__(self, frames): self.frames = [frames] class Pipeline: + _modiff_video_pipeline_class = "WanVACEPipeline" _execution_device = "cpu" vae_scale_factor_temporal = 4 vae_scale_factor_spatial = 8 @@ -85,6 +151,7 @@ def __call__(self, **kwargs): mask = [np.full((4, 4), 255, dtype=np.uint8) for _ in range(161)] output = Generate().execute( pipeline=pipeline, + mode="video_inpaint", video=video, mask=mask, prompt="Replace the masked vessel with one stable amber vessel.", @@ -117,6 +184,15 @@ def test_mask_neutralization_preserves_black_regions(self): self.assertTrue(np.all(result[:, 0] == 23)) self.assertTrue(np.all(result[:, 1] == 127)) + def test_torch_mask_neutralization_broadcasts_a_spatial_mask_across_channels(self): + frame = torch.full((3, 2, 2), 23, dtype=torch.uint8) + mask = torch.tensor([[0, 255], [0, 255]], dtype=torch.uint8) + + result = _neutralize_masked_region(frame, mask) + + self.assertTrue(torch.all(result[:, :, 0] == 23)) + self.assertTrue(torch.all(result[:, :, 1] == 127)) + def test_rgb_mask_neutralization_uses_gray_not_packed_red(self): frame = Image.new("RGB", (2, 1), (23, 41, 59)) mask = Image.new("L", (2, 1), 0) diff --git a/tests/test_workflow_store.py b/tests/test_workflow_store.py index 2b4256b..2622f80 100644 --- a/tests/test_workflow_store.py +++ b/tests/test_workflow_store.py @@ -147,12 +147,21 @@ class FieldNode(NodeBase): def refresh(self, _values, _ref): self.set_field_params("dtype", {"options": ["float16", "bfloat16"]}) - module_name = ".".join(FieldNode.__module__.split(".")[:-1]) - definition = {module_name: {"FieldNode": {"params": {}}}} + FieldNode.__module__ = "tests.test_workflow_store" + module_name = "tests" + definition = { + module_name: { + "FieldNode": { + "params": { + "dtype": {"onChange": "refresh"}, + } + } + } + } with patch("modiff.NodeBase._module_map", return_value=definition): node = FieldNode("field-node") - server = WebServer(modules={}, work_dir=self.directory.name, data_dir=self.directory.name) + server = WebServer(modules=definition, work_dir=self.directory.name, data_dir=self.directory.name) server.loop = asyncio.get_running_loop() server.node_cache["field-node"] = node messages = [] @@ -162,6 +171,8 @@ def refresh(self, _values, _ref): { "node": "field-node", "sid": "field-session", + "module": module_name, + "action": "FieldNode", "fn": "refresh", "values": {"dtype": "float16"}, "fieldKey": "dtype", @@ -192,6 +203,127 @@ def refresh(self, _values, _ref): self.assertEqual(completed["workflow_canvas_epoch"], 17) self.assertEqual(completed["args"][1]["node"], "field-node") + async def test_field_action_rejects_unknown_or_undeclared_targets_before_import(self): + module_name = "modules.ModularDiffusers" + action_name = "ModelsLoader" + definition = { + module_name: { + action_name: { + "params": { + "repo_id": {"onChange": "refresh_pipeline_identity"}, + } + } + } + } + server = WebServer(modules=definition, work_dir=self.directory.name, data_dir=self.directory.name) + base_payload = { + "node": "imported-loader", + "sid": "field-session", + "module": module_name, + "action": action_name, + "fieldKey": "repo_id", + "fn": "refresh_pipeline_identity", + "values": {}, + "queue": False, + } + cases = { + "unknown module": {"module": "custom.Attacker"}, + "unknown action": {"action": "AttackerNode"}, + "unknown field": {"fieldKey": "removed_or_imported_field"}, + "undeclared method": {"fn": "prepare_for_workflow_reuse"}, + } + + with patch("modiff.server.import_module") as import_mock: + for label, override in cases.items(): + with self.subTest(label=label): + response = await server.field_action( + FakeRequest("imported-loader", {**base_payload, **override}) + ) + payload = json.loads(response.text) + self.assertEqual(response.status, 400) + self.assertTrue(payload["error"]) + import_mock.assert_not_called() + + async def test_field_action_rejects_non_object_payload_before_dispatch(self): + server = WebServer(modules={}, work_dir=self.directory.name, data_dir=self.directory.name) + with patch("modiff.server.import_module") as import_mock: + for payload in (None, [], ["attacker"]): + with self.subTest(payload=payload): + response = await server.field_action(FakeRequest("field-node", payload)) + body = json.loads(response.text) + self.assertEqual(response.status, 400) + self.assertTrue(body["error"]) + self.assertIn("JSON object", body["message"]) + import_mock.assert_not_called() + + async def test_field_action_dispatches_authorized_models_loader_callback(self): + module_name = "modules.ModularDiffusers" + action_name = "ModelsLoader" + definition = { + module_name: { + action_name: { + "params": { + "repo_id": { + "onSignal": [ + {"action": "value", "data": "repo_id"}, + [{"action": "exec", "data": "refresh_pipeline_identity"}], + ] + }, + } + } + } + } + + class CachedModelsLoader: + module_name = "modules.ModularDiffusers" + class_name = "ModelsLoader" + + def __init__(self): + self._sid = None + self.calls = [] + + def refresh_pipeline_identity(self, values, ref): + self.calls.append((values, ref)) + + def prepare_for_workflow_reuse(self): + raise AssertionError("An undeclared callback was dispatched.") + + cached_node = CachedModelsLoader() + server = WebServer(modules=definition, work_dir=self.directory.name, data_dir=self.directory.name) + server.loop = asyncio.get_running_loop() + server.node_cache["models-loader"] = cached_node + + response = await server.field_action( + FakeRequest( + "models-loader", + { + "node": "models-loader", + "sid": "field-session", + "module": module_name, + "action": action_name, + "fieldKey": "repo_id", + "fn": "refresh_pipeline_identity", + "values": {"repo_id": {"source": "hub", "value": "org/repo"}}, + "queue": False, + }, + ) + ) + + payload = json.loads(response.text) + self.assertEqual(response.status, 200) + self.assertFalse(payload["error"]) + self.assertEqual(payload["ref"], {"node": "models-loader", "key": "repo_id", "queue": False}) + self.assertEqual(cached_node._sid, "field-session") + self.assertEqual( + cached_node.calls, + [ + ( + {"repo_id": {"source": "hub", "value": "org/repo"}}, + {"node": "models-loader", "key": "repo_id", "queue": False}, + ) + ], + ) + async def test_generated_media_is_preserved_without_a_frontend_history_post(self): server = WebServer(modules={}, work_dir=self.directory.name, data_dir=self.directory.name) runtime_hints = { diff --git a/utils/huggingface.py b/utils/huggingface.py index 1901b6f..04a3511 100644 --- a/utils/huggingface.py +++ b/utils/huggingface.py @@ -1279,7 +1279,7 @@ def get_local_model_ids(id: Optional[str] = None, class_name: Optional[str] | bo return local_models -def cached_file_path(repo_id: str, file: str | None = None): +def cached_file_path(repo_id: str, file: str | None = None, *, revision: str | None = None): cache_dir = CONFIG.hf['cache_dir'] file_path = None @@ -1291,7 +1291,12 @@ def cached_file_path(repo_id: str, file: str | None = None): repo_id = '/'.join(path[:2]) try: - file_path = try_to_load_from_cache(repo_id=repo_id, filename=file, cache_dir=cache_dir) + file_path = try_to_load_from_cache( + repo_id=repo_id, + filename=file, + cache_dir=cache_dir, + revision=revision, + ) except Exception as e: logger.error(f'Error checking cache for {repo_id}/{file}: {e}') return None @@ -1301,6 +1306,29 @@ def cached_file_path(repo_id: str, file: str | None = None): return False + +def resolve_managed_hf_cache_file(path: str | os.PathLike[str]) -> Path: + """Resolve one cached Hub file without allowing a cache-root escape. + + Hugging Face snapshots normally contain symlinks into the repository's + ``blobs`` directory. Both locations remain below the configured Hub cache + root, so resolving the link before the containment check accepts the normal + layout while rejecting a tampered snapshot link that points elsewhere. + """ + + try: + resolved = Path(path).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f'Installed Hugging Face cache entry does not exist: {path}') from error + cache_root = Path(CONFIG.hf['cache_dir'] or str(HUGGINGFACE_HUB_CACHE)).expanduser().resolve(strict=False) + try: + resolved.relative_to(cache_root) + except (OSError, RuntimeError, ValueError) as error: + raise ValueError('Installed Hugging Face cache entry resolves outside the managed cache root.') from error + if not resolved.is_file(): + raise FileNotFoundError('Installed Hugging Face cache entry is not a file.') + return resolved + def is_file_cached(repo_id: str, file: str | list[str] | tuple[str, ...]) -> bool: if isinstance(file, str): file = [file] diff --git a/web/THIRD_PARTY_LICENSES.txt b/web/THIRD_PARTY_LICENSES.txt index 2f39fa6..f6127e0 100644 --- a/web/THIRD_PARTY_LICENSES.txt +++ b/web/THIRD_PARTY_LICENSES.txt @@ -1207,6 +1207,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +@jridgewell/source-map@0.3.11 +Declared license: MIT +================================================================================ + +--- LICENSE --- + +Copyright 2024 Justin Ridgewell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ @jridgewell/sourcemap-codec@1.5.5 Declared license: MIT @@ -2620,6 +2647,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +acorn@8.16.0 +Declared license: MIT +================================================================================ + +--- LICENSE --- + +MIT License + +Copyright (C) 2012-2022 by various contributors (see AUTHORS) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ================================================================================ aria-hidden@1.2.6 Declared license: MIT @@ -2649,6 +2705,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +buffer-from@1.1.2 +Declared license: MIT +================================================================================ + +--- LICENSE --- + +MIT License + +Copyright (c) 2016, 2018 Linus Unnebäck + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ classcat@5.0.5 Declared license: MIT @@ -2681,6 +2766,36 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +commander@2.20.3 +Declared license: MIT +================================================================================ + +--- LICENSE --- + +(The MIT License) + +Copyright (c) 2011 TJ Holowaychuk + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ================================================================================ csstype@3.2.3 Declared license: MIT @@ -4372,6 +4487,70 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +================================================================================ +source-map-support@0.5.21 +Declared license: MIT +================================================================================ + +--- LICENSE.md --- + +The MIT License (MIT) + +Copyright (c) 2014 Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +source-map@0.6.1 +Declared license: BSD-3-Clause +================================================================================ + +--- LICENSE --- + +Copyright (c) 2009-2011, Mozilla Foundation and contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the names of the Mozilla Foundation nor the names of project + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ================================================================================ tabbable@6.4.0 Declared license: MIT @@ -4459,6 +4638,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +terser@5.49.2 +Declared license: BSD-2-Clause +================================================================================ + +--- LICENSE --- + +Copyright 2012-2018 (c) Mihai Bazon + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above + copyright notice, this list of conditions and the following + disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER “AS IS” AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF +THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + ================================================================================ tinyglobby@0.2.17 Declared license: MIT diff --git a/web/assets/graph-vendor.js b/web/assets/graph-vendor.js index 5b566a0..85f6172 100644 --- a/web/assets/graph-vendor.js +++ b/web/assets/graph-vendor.js @@ -1,19 +1 @@ -import{n as e,t}from"./rolldown-runtime.js";var n=t((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function D(e,t){return E(e.type,t,e.props)}function O(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function k(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var A=/\/+/g;function j(e,t){return typeof e==`object`&&e&&e.key!=null?k(``+e.key):t.toString(36)}function M(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function N(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,N(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+j(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(A,`$&/`)+`/`),N(o,r,i,``,function(e){return e})):o!=null&&(O(o)&&(o=D(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(A,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=n()})),i=t((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&j(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&j(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,A=k.port2;k.port1.onmessage=D,O=function(){A.postMessage(null)}}else O=function(){_(D,0)};function j(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,j(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),a=t(((e,t)=>{t.exports=i()})),o=t((e=>{var t=r();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=o()})),c=t((e=>{var t=a(),n=r(),i=s();function o(e){var t=`https://react.dev/errors/`+e;if(1te||(e.current=ee[te],ee[te]=null,te--)}function re(e,t){te++,ee[te]=e.current,e.current=t}var ie=ne(null),ae=ne(null),oe=ne(null),se=ne(null);function ce(e,t){switch(re(oe,t),re(ae,e),re(ie,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Yd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Yd(t),e=Xd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}z(ie),re(ie,e)}function le(){z(ie),z(ae),z(oe)}function ue(e){e.memoizedState!==null&&re(se,e);var t=ie.current,n=Xd(t,e.type);t!==n&&(re(ae,e),re(ie,n))}function de(e){ae.current===e&&(z(ie),z(ae)),se.current===e&&(z(se),op._currentValue=R)}var fe,pe;function me(e){if(fe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);fe=t&&t[1]||``,pe=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{he=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?me(n):``}function _e(e,t){switch(e.tag){case 26:case 27:case 5:return me(e.type);case 16:return me(`Lazy`);case 13:return e.child!==t&&t!==null?me(`Suspense Fallback`):me(`Suspense`);case 19:return me(`SuspenseList`);case 0:case 15:return ge(e.type,!1);case 11:return ge(e.type.render,!1);case 1:return ge(e.type,!0);case 31:return me(`Activity`);default:return``}}function ve(e){try{var t=``,n=null;do t+=_e(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var ye=Object.prototype.hasOwnProperty,be=t.unstable_scheduleCallback,xe=t.unstable_cancelCallback,Se=t.unstable_shouldYield,Ce=t.unstable_requestPaint,we=t.unstable_now,Te=t.unstable_getCurrentPriorityLevel,Ee=t.unstable_ImmediatePriority,De=t.unstable_UserBlockingPriority,Oe=t.unstable_NormalPriority,ke=t.unstable_LowPriority,Ae=t.unstable_IdlePriority,je=t.log,Me=t.unstable_setDisableYieldValue,Ne=null,Pe=null;function Fe(e){if(typeof je==`function`&&Me(e),Pe&&typeof Pe.setStrictMode==`function`)try{Pe.setStrictMode(Ne,e)}catch{}}var Ie=Math.clz32?Math.clz32:ze,Le=Math.log,Re=Math.LN2;function ze(e){return e>>>=0,e===0?32:31-(Le(e)/Re|0)|0}var Be=256,Ve=262144,He=4194304;function Ue(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function We(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ue(n))):i=Ue(o):i=Ue(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ue(n))):i=Ue(o)):i=Ue(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ge(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Ke(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function qe(){var e=He;return He<<=1,!(He&62914560)&&(He=4194304),e}function Je(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ye(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Xe(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),ln=!1;if(cn)try{var un={};Object.defineProperty(un,"passive",{get:function(){ln=!0}}),window.addEventListener(`test`,un,un),window.removeEventListener(`test`,un,un)}catch{ln=!1}var dn=null,fn=null,pn=null;function mn(){if(pn)return pn;var e,t=fn,n=t.length,r,i=`value`in dn?dn.value:dn.textContent,a=i.length;for(e=0;e=Kn),Yn=` `,Xn=!1;function Zn(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Qn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var $n=!1;function er(e,t){switch(e){case`compositionend`:return Qn(t);case`keypress`:return t.which===32?(Xn=!0,Yn):null;case`textInput`:return e=t.data,e===Yn&&Xn?null:e;default:return null}}function tr(e,t){if($n)return e===`compositionend`||!Gn&&Zn(e,t)?(e=mn(),pn=fn=dn=null,$n=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Cr(n)}}function Tr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Tr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Er(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ft(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ft(e.document)}return t}function Dr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Or=cn&&`documentMode`in document&&11>=document.documentMode,kr=null,Ar=null,jr=null,Mr=!1;function Nr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Mr||kr==null||kr!==Ft(r)||(r=kr,`selectionStart`in r&&Dr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),jr&&Sr(jr,r)||(jr=r,r=Nd(Ar,`onSelect`),0>=o,i-=o,Ti=1<<32-Ie(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),o=a(_,o,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),Pi&&Di(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=a(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),Pi&&Di(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=a(v,s,g),d===null?u=v:d.sibling=v,d=v);return Pi&&Di(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=a(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),Pi&&Di(i,g),u}function b(e,r,a,c){if(typeof a==`object`&&a&&a.type===y&&a.key===null&&(a=a.props.children),typeof a==`object`&&a){switch(a.$$typeof){case _:a:{for(var l=a.key;r!==null;){if(r.key===l){if(l=a.type,l===y){if(r.tag===7){n(e,r.sibling),c=i(r,a.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&Da(l)===r.type){n(e,r.sibling),c=i(r,a.props),Pa(c,a),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}a.type===y?(c=fi(a.props.children,e.mode,c,a.key),c.return=e,e=c):(c=di(a.type,a.key,a.props,null,e.mode,c),Pa(c,a),c.return=e,e=c)}return s(e);case v:a:{for(l=a.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===a.containerInfo&&r.stateNode.implementation===a.implementation){n(e,r.sibling),c=i(r,a.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=hi(a,e.mode,c),c.return=e,e=c}return s(e);case O:return a=Da(a),b(e,r,a,c)}if(F(a))return h(e,r,a,c);if(M(a)){if(l=M(a),typeof l!=`function`)throw Error(o(150));return a=l.call(a),g(e,r,a,c)}if(typeof a.then==`function`)return b(e,r,Na(a),c);if(a.$$typeof===C)return b(e,r,na(e,a),c);Fa(e,a)}return typeof a==`string`&&a!==``||typeof a==`number`||typeof a==`bigint`?(a=``+a,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,a),c.return=e,e=c):(n(e,r),c=pi(a,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Ma=0;var i=b(e,t,n,r);return ja=null,i}catch(t){if(t===Sa||t===Ca)throw t;var a=si(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var La=Ia(!0),Ra=Ia(!1),za=!1;function Ba(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Va(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ha(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ua(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Hl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ii(e),ri(e,null,n),t}return ei(e,r,t,n),ii(e)}function Wa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}function Ga(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ka=!1;function qa(){if(Ka){var e=pa;if(e!==null)throw e}}function Ja(e,t,n,r){Ka=!1;var i=e.updateQueue;za=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(W&f)===f:(r&f)===f){f!==0&&f===fa&&(Ka=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:za=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Zl|=o,e.lanes=o,e.memoizedState=d}}function Ya(e,t){if(typeof e!=`function`)throw Error(o(191,e));e.call(t)}function Xa(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=I.T,s={};I.T=s,Is(e,!1,t,n);try{var c=i(),l=I.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Fs(e,t,ga(c,r),yu(e)):Fs(e,t,r,yu(e))}catch(n){Fs(e,t,{then:function(){},status:`rejected`,reason:n},yu())}finally{L.p=a,o!==null&&s.types!==null&&(o.types=s.types),I.T=o}}function Ts(){}function Es(e,t,n,r){if(e.tag!==5)throw Error(o(476));var i=Ds(e).queue;ws(e,i,t,R,n===null?Ts:function(){return Os(e),n(r)})}function Ds(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:R,baseState:R,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Lo,lastRenderedState:R},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Lo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Os(e){var t=Ds(e);t.next===null&&(t=e.alternate.memoizedState),Fs(e,t.next.queue,{},yu())}function ks(){return ta(op)}function As(){return Mo().memoizedState}function js(){return Mo().memoizedState}function Ms(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=yu();e=Ha(n);var r=Ua(t,e,n);r!==null&&(xu(r,t,n),Wa(r,t,n)),t={cache:ca()},e.payload=t;return}t=t.return}}function Ns(e,t,n){var r=yu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Ls(e)?Rs(t,n):(n=ti(e,t,n,r),n!==null&&(xu(n,e,r),zs(n,t,r)))}function Ps(e,t,n){Fs(e,t,n,yu())}function Fs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ls(e))Rs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,xr(s,o))return ei(e,t,i,0),Ul===null&&$r(),!1}catch{}if(n=ti(e,t,i,r),n!==null)return xu(n,e,r),zs(n,t,r),!0}return!1}function Is(e,t,n,r){if(r={lane:2,revertLane:vd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Ls(e)){if(t)throw Error(o(479))}else t=ti(e,n,r,2),t!==null&&xu(t,e,2)}function Ls(e){var t=e.alternate;return e===V||t!==null&&t===V}function Rs(e,t){go=ho=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function zs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}var Bs={readContext:ta,use:Fo,useCallback:So,useContext:So,useEffect:So,useImperativeHandle:So,useLayoutEffect:So,useInsertionEffect:So,useMemo:So,useReducer:So,useRef:So,useState:So,useDebugValue:So,useDeferredValue:So,useTransition:So,useSyncExternalStore:So,useId:So,useHostTransitionStatus:So,useFormState:So,useActionState:So,useOptimistic:So,useMemoCache:So,useCacheRefresh:So};Bs.useEffectEvent=So;var Vs={readContext:ta,use:Fo,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:ta,useEffect:ds,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ls(4194308,4,_s.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ls(4194308,4,e,t)},useInsertionEffect:function(e,t){ls(4,2,e,t)},useMemo:function(e,t){var n=jo();t=t===void 0?null:t;var r=e();if(_o){Fe(!0);try{e()}finally{Fe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=jo();if(n!==void 0){var i=n(t);if(_o){Fe(!0);try{n(t)}finally{Fe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ns.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:function(e){e=qo(e);var t=e.queue,n=Ps.bind(null,V,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ys,useDeferredValue:function(e,t){return Ss(jo(),e,t)},useTransition:function(){var e=qo(!1);return e=ws.bind(null,V,e.queue,!0,!1),jo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=V,i=jo();if(Pi){if(n===void 0)throw Error(o(407));n=n()}else{if(n=t(),Ul===null)throw Error(o(349));W&127||Ho(r,t,n)}i.memoizedState=n;var a={value:n,getSnapshot:t};return i.queue=a,ds(Wo.bind(null,r,a,e),[e]),r.flags|=2048,ss(9,{destroy:void 0},Uo.bind(null,r,a,n,t),null),n},useId:function(){var e=jo(),t=Ul.identifierPrefix;if(Pi){var n=Ei,r=Ti;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=vo++,0<\/script>`,a=a.removeChild(a.firstChild);break;case`select`:a=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?a.multiple=!0:r.size&&(a.size=r.size);break;default:a=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}a[at]=t,a[ot]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)a.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=a;a:switch(Hd(a,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Pc(t)}}return zc(t),Fc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(o(166));if(e=oe.current,Vi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=Mi,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[at]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||zd(e.nodeValue,n)),e||Ri(t,!0)}else e=Jd(e).createTextNode(r),e[at]=t,t.stateNode=e}return zc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Vi(t),n!==null){if(e===null){if(!r)throw Error(o(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(o(557));e[at]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;zc(t),e=!1}else n=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(co(t),t):(co(t),null);if(t.flags&128)throw Error(o(558))}return zc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=Vi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(o(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(o(317));i[at]=t}else Hi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;zc(t),i=!1}else i=Ui(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(co(t),t):(co(t),null)}return co(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),a=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(a=r.memoizedState.cachePool.pool),a!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Lc(t,t.updateQueue),zc(t),null);case 4:return le(),e===null&&kd(t.stateNode.containerInfo),zc(t),null;case 10:return Yi(t.type),zc(t),null;case 19:if(z(lo),r=t.memoizedState,r===null)return zc(t),null;if(i=(t.flags&128)!=0,a=r.rendering,a===null)if(i)Rc(r,!1);else{if(Xl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(a=uo(e),a!==null){for(t.flags|=128,Rc(r,!1),e=a.updateQueue,t.updateQueue=e,Lc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ui(n,e),n=n.sibling;return re(lo,lo.current&1|2),Pi&&Di(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&we()>su&&(t.flags|=128,i=!0,Rc(r,!1),t.lanes=4194304)}else{if(!i)if(e=uo(a),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,Lc(t,e),Rc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!a.alternate&&!Pi)return zc(t),null}else 2*we()-r.renderingStartTime>su&&n!==536870912&&(t.flags|=128,i=!0,Rc(r,!1),t.lanes=4194304);r.isBackwards?(a.sibling=t.child,t.child=a):(e=r.last,e===null?t.child=a:e.sibling=a,r.last=a)}return r.tail===null?(zc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=we(),e.sibling=null,n=lo.current,re(lo,i?n&1|2:n&1),Pi&&Di(t,r.treeForkCount),e);case 22:case 23:return co(t),to(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(zc(t),t.subtreeFlags&6&&(t.flags|=8192)):zc(t),n=t.updateQueue,n!==null&&Lc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&z(va),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Yi(sa),zc(t),null;case 25:return null;case 30:return null}throw Error(o(156,t.tag))}function Vc(e,t){switch(Ai(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Yi(sa),le(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return de(t),null;case 31:if(t.memoizedState!==null){if(co(t),t.alternate===null)throw Error(o(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(co(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(o(340));Hi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return z(lo),null;case 4:return le(),null;case 10:return Yi(t.type),null;case 22:case 23:return co(t),to(),e!==null&&z(va),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Yi(sa),null;case 25:return null;default:return null}}function Hc(e,t){switch(Ai(t),t.tag){case 3:Yi(sa),le();break;case 26:case 27:case 5:de(t);break;case 4:le();break;case 31:t.memoizedState!==null&&co(t);break;case 13:co(t);break;case 19:z(lo);break;case 10:Yi(t.type);break;case 22:case 23:co(t),to(),e!==null&&z(va);break;case 24:Yi(sa)}}function Uc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Zu(t,t.return,e)}}function Wc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Zu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Zu(t,t.return,e)}}function Gc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Xa(t,n)}catch(t){Zu(e,e.return,t)}}}function Kc(e,t,n){n.props=Js(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Zu(e,t,n)}}function qc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Zu(e,t,n)}}function Jc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Zu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Zu(e,t,n)}else n.current=null}function Yc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Zu(e,e.return,t)}}function Xc(e,t,n){try{var r=e.stateNode;Ud(r,e.type,n,t),r[ot]=t}catch(t){Zu(e,e.return,t)}}function Zc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&of(e.type)||e.tag===4}function Qc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Zc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&of(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function $c(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Qt));else if(r!==4&&(r===27&&of(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for($c(e,t,n),e=e.sibling;e!==null;)$c(e,t,n),e=e.sibling}function el(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&of(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(el(e,t,n),e=e.sibling;e!==null;)el(e,t,n),e=e.sibling}function tl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Hd(t,r,n),t[at]=e,t[ot]=n}catch(t){Zu(e,e.return,t)}}var nl=!1,rl=!1,il=!1,al=typeof WeakSet==`function`?WeakSet:Set,ol=null;function sl(e,t){if(e=e.containerInfo,Kd=q,e=Er(e),Dr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,a=r.focusNode;r=r.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=s+i),f!==a||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=s),p===a&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(qd={focusedElem:e,selectionRange:n},q=!1,ol=t;ol!==null;)if(t=ol,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,ol=e;else for(;ol!==null;){switch(t=ol,a=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Hd(a,r,n),a[at]=e,vt(a),r=a;break a;case`link`:var s=Yf(`link`,`href`,i).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=wr(s,h),v=wr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,I.T=null,n=hu,hu=null;var a=du,s=pu;if(uu=0,fu=du=null,pu=0,Hl&6)throw Error(o(331));var c=Hl;if(Hl|=4,Ll(a.current),kl(a,a.current,s,n),Hl=c,dd(0,!1),Pe&&typeof Pe.onPostCommitFiberRoot==`function`)try{Pe.onPostCommitFiberRoot(Ne,a)}catch{}return!0}finally{L.p=i,I.T=r,qu(e,t)}}function Xu(e,t,n){t=_i(n,t),t=ec(e.stateNode,t,2),e=Ua(e,t,2),e!==null&&(Ye(e,2),ud(e))}function Zu(e,t,n){if(e.tag===3)Xu(e,e,n);else for(;t!==null;){if(t.tag===3){Xu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(lu===null||!lu.has(r))){e=_i(n,e),n=tc(2),r=Ua(t,n,2),r!==null&&(nc(n,r,t,e),Ye(r,2),ud(r));break}}t=t.return}}function Qu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Vl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Jl=!0,i.add(n),e=$u.bind(null,e,t,n),t.then(e,e))}function $u(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Ul===e&&(W&n)===n&&(Xl===4||Xl===3&&(W&62914560)===W&&300>we()-au?!(Hl&2)&&Ou(e,0):$l|=n,tu===W&&(tu=0)),ud(e)}function ed(e,t){t===0&&(t=qe()),e=ni(e,t),e!==null&&(Ye(e,t),ud(e))}function td(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ed(e,n)}function nd(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(o(314))}r!==null&&r.delete(t),ed(e,n)}function rd(e,t){return be(e,t)}var id=null,ad=null,od=!1,sd=!1,cd=!1,ld=0;function ud(e){e!==ad&&e.next===null&&(ad===null?id=ad=e:ad=ad.next=e),sd=!0,od||(od=!0,_d())}function dd(e,t){if(!cd&&sd){cd=!0;do for(var n=!1,r=id;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ie(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,gd(r,a))}else a=W,a=We(r,r===Ul?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ge(r,a)||(n=!0,gd(r,a));r=r.next}while(n);cd=!1}}function fd(){pd()}function pd(){sd=od=!1;var e=0;ld!==0&&$d()&&(e=ld);for(var t=we(),n=null,r=id;r!==null;){var i=r.next,a=md(r,t);a===0?(r.next=null,n===null?id=i:n.next=i,i===null&&(ad=n)):(n=r,(e!==0||a&3)&&(sd=!0)),r=i}uu!==0&&uu!==5||dd(e,!1),ld!==0&&(ld=0)}function md(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Wd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function kf(e,t,n){var r=Of;if(r&&typeof t==`string`&&t){var i=Lt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),Cf.has(i)||(Cf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Hd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function Af(e){Tf.D(e),kf(`dns-prefetch`,e,null)}function jf(e,t){Tf.C(e,t),kf(`preconnect`,e,t)}function Mf(e,t,n){Tf.L(e,t,n);var r=Of;if(r&&e&&t){var i=`link[rel="preload"][as="`+Lt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Lt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Lt(n.imageSizes)+`"]`)):i+=`[href="`+Lt(e)+`"]`;var a=i;switch(t){case`style`:a=Rf(e);break;case`script`:a=Hf(e)}Sf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),Sf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(zf(a))||t===`script`&&r.querySelector(Uf(a))||(t=r.createElement(`link`),Hd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function Nf(e,t){Tf.m(e,t);var n=Of;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Lt(r)+`"][href="`+Lt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Hf(e)}if(!Sf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),Sf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Uf(a)))return}r=n.createElement(`link`),Hd(r,`link`,e),vt(r),n.head.appendChild(r)}}}function Pf(e,t,n){Tf.S(e,t,n);var r=Of;if(r&&e){var i=_t(r).hoistableStyles,a=Rf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(zf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=Sf.get(a))&&Kf(e,n);var c=o=r.createElement(`link`);vt(c),Hd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Gf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Ff(e,t){Tf.X(e,t);var n=Of;if(n&&e){var r=_t(n).hoistableScripts,i=Hf(e),a=r.get(i);a||(a=n.querySelector(Uf(i)),a||(e=h({src:e,async:!0},t),(t=Sf.get(i))&&qf(e,t),a=n.createElement(`script`),vt(a),Hd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function If(e,t){Tf.M(e,t);var n=Of;if(n&&e){var r=_t(n).hoistableScripts,i=Hf(e),a=r.get(i);a||(a=n.querySelector(Uf(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=Sf.get(i))&&qf(e,t),a=n.createElement(`script`),vt(a),Hd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Lf(e,t,n,r){var i=(i=oe.current)?wf(i):null;if(!i)throw Error(o(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Rf(n.href),n=_t(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Rf(n.href);var a=_t(i).hoistableStyles,s=a.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},a.set(e,s),(a=i.querySelector(zf(e)))&&!a._p&&(s.instance=a,s.state.loading=5),Sf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},Sf.set(e,n),a||Vf(i,e,n,s.state))),t&&r===null)throw Error(o(528,``));return s}if(t&&r!==null)throw Error(o(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Hf(n),n=_t(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(o(444,e))}}function Rf(e){return`href="`+Lt(e)+`"`}function zf(e){return`link[rel="stylesheet"][`+e+`]`}function Bf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Vf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Hd(t,`link`,n),vt(t),e.head.appendChild(t))}function Hf(e){return`[src="`+Lt(e)+`"]`}function Uf(e){return`script[async]`+e}function Wf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Lt(n.href)+`"]`);if(r)return t.instance=r,vt(r),r;var i=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),vt(r),Hd(r,`style`,i),Gf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Rf(n.href);var a=e.querySelector(zf(i));if(a)return t.state.loading|=4,t.instance=a,vt(a),a;r=Bf(n),(i=Sf.get(i))&&Kf(r,i),a=(e.ownerDocument||e).createElement(`link`),vt(a);var s=a;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Hd(a,`link`,r),t.state.loading|=4,Gf(a,n.precedence,e),t.instance=a;case`script`:return a=Hf(n.src),(i=e.querySelector(Uf(a)))?(t.instance=i,vt(i),i):(r=n,(i=Sf.get(a))&&(r=h({},n),qf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),vt(i),Hd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(o(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Gf(r,n.precedence,e));return t.instance}function Gf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Zf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Qf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function $f(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Rf(r.href),a=t.querySelector(zf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=np.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,vt(a);return}a=t.ownerDocument||t,r=Bf(r),(i=Sf.get(i))&&Kf(r,i),a=a.createElement(`link`),vt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Hd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=np.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var ep=0;function tp(e,t){return e.stylesheets&&e.count===0&&ip(e,e.stylesheets),0ep?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function np(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)ip(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var rp=null;function ip(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,rp=new Map,t.forEach(ap,e),rp=null,np.call(e))}function ap(e,t){if(!(t.state.loading&4)){var n=rp.get(e);if(n)var r=n.get(null);else{n=new Map,rp.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=c()})),u=t((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),d=t(((e,t)=>{t.exports=u()})),f=e(r(),1),p=d();function m(e){if(typeof e==`string`||typeof e==`number`)return``+e;let t=``;if(Array.isArray(e))for(let n=0,r;n{}};function g(){for(var e=0,t=arguments.length,n={},r;e=0&&(n=e.slice(r+1),e=e.slice(0,r)),e&&!t.hasOwnProperty(e))throw Error(`unknown type: `+e);return{type:e,name:n}})}_.prototype=g.prototype={constructor:_,on:function(e,t){var n=this._,r=v(e+``,n),i,a=-1,o=r.length;if(arguments.length<2){for(;++a0)for(var n=Array(i),r=0,i,a;r=0&&(t=e.slice(0,n))!==`xmlns`&&(e=e.slice(n+1)),x.hasOwnProperty(t)?{space:x[t],local:e}:e}function C(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===`http://www.w3.org/1999/xhtml`&&t.documentElement.namespaceURI===`http://www.w3.org/1999/xhtml`?t.createElement(e):t.createElementNS(n,e)}}function w(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function T(e){var t=S(e);return(t.local?w:C)(t)}function E(){}function D(e){return e==null?E:function(){return this.querySelector(e)}}function O(e){typeof e!=`function`&&(e=D(e));for(var t=this._groups,n=t.length,r=Array(n),i=0;i=v&&(v=_+1);!(b=g[v])&&++v=0;)(o=r[i])&&(a&&o.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(o,a),a=o);return this}function ve(e){e||=ye;function t(t,n){return t&&n?e(t.__data__,n.__data__):!t-!n}for(var n=this._groups,r=n.length,i=Array(r),a=0;at?1:e>=t?0:NaN}function be(){var e=arguments[0];return arguments[0]=this,e.apply(null,arguments),this}function xe(){return Array.from(this)}function Se(){for(var e=this._groups,t=0,n=e.length;t1?this.each((t==null?Pe:typeof t==`function`?Ie:Fe)(e,t,n??``)):Re(this.node(),e)}function Re(e,t){return e.style.getPropertyValue(t)||Ne(e).getComputedStyle(e,null).getPropertyValue(t)}function ze(e){return function(){delete this[e]}}function Be(e,t){return function(){this[e]=t}}function Ve(e,t){return function(){var n=t.apply(this,arguments);n==null?delete this[e]:this[e]=n}}function He(e,t){return arguments.length>1?this.each((t==null?ze:typeof t==`function`?Ve:Be)(e,t)):this.node()[e]}function Ue(e){return e.trim().split(/^|\s+/)}function We(e){return e.classList||new Ge(e)}function Ge(e){this._node=e,this._names=Ue(e.getAttribute(`class`)||``)}Ge.prototype={add:function(e){this._names.indexOf(e)<0&&(this._names.push(e),this._node.setAttribute(`class`,this._names.join(` `)))},remove:function(e){var t=this._names.indexOf(e);t>=0&&(this._names.splice(t,1),this._node.setAttribute(`class`,this._names.join(` `)))},contains:function(e){return this._names.indexOf(e)>=0}};function Ke(e,t){for(var n=We(e),r=-1,i=t.length;++r=0&&(t=e.slice(n+1),e=e.slice(0,n)),{type:e,name:t}})}function xt(e){return function(){var t=this.__on;if(t){for(var n=0,r=-1,i=t.length,a;n()=>e;function Ut(e,{sourceEvent:t,subject:n,target:r,identifier:i,active:a,x:o,y:s,dx:c,dy:l,dispatch:u}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},subject:{value:n,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:a,enumerable:!0,configurable:!0},x:{value:o,enumerable:!0,configurable:!0},y:{value:s,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:l,enumerable:!0,configurable:!0},_:{value:u}})}Ut.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function Wt(e){return!e.ctrlKey&&!e.button}function Gt(){return this.parentNode}function Kt(e,t){return t??{x:e.x,y:e.y}}function qt(){return navigator.maxTouchPoints||`ontouchstart`in this}function Jt(){var e=Wt,t=Gt,n=Kt,r=qt,i={},a=g(`start`,`drag`,`end`),o=0,s,c,l,u,d=0;function f(e){e.on(`mousedown.drag`,p).filter(r).on(`touchstart.drag`,_).on(`touchmove.drag`,v,It).on(`touchend.drag touchcancel.drag`,y).style(`touch-action`,`none`).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}function p(n,r){if(!(u||!e.call(this,n,r))){var i=b(this,t.call(this,n,r),n,r,`mouse`);i&&(Nt(n.view).on(`mousemove.drag`,m,Lt).on(`mouseup.drag`,h,Lt),Bt(n.view),Rt(n),l=!1,s=n.clientX,c=n.clientY,i(`start`,n))}}function m(e){if(zt(e),!l){var t=e.clientX-s,n=e.clientY-c;l=t*t+n*n>d}i.mouse(`drag`,e)}function h(e){Nt(e.view).on(`mousemove.drag mouseup.drag`,null),Vt(e.view,l),zt(e),i.mouse(`end`,e)}function _(n,r){if(e.call(this,n,r)){var i=n.changedTouches,a=t.call(this,n,r),o=i.length,s,c;for(s=0;s>8&15|t>>4&240,t>>4&15|t&240,(t&15)<<4|t&15,1):n===8?vn(t>>24&255,t>>16&255,t>>8&255,(t&255)/255):n===4?vn(t>>12&15|t>>8&240,t>>8&15|t>>4&240,t>>4&15|t&240,((t&15)<<4|t&15)/255):null):(t=an.exec(e))?new xn(t[1],t[2],t[3],1):(t=on.exec(e))?new xn(t[1]*255/100,t[2]*255/100,t[3]*255/100,1):(t=sn.exec(e))?vn(t[1],t[2],t[3],t[4]):(t=cn.exec(e))?vn(t[1]*255/100,t[2]*255/100,t[3]*255/100,t[4]):(t=ln.exec(e))?On(t[1],t[2]/100,t[3]/100,1):(t=un.exec(e))?On(t[1],t[2]/100,t[3]/100,t[4]):dn.hasOwnProperty(e)?_n(dn[e]):e===`transparent`?new xn(NaN,NaN,NaN,0):null}function _n(e){return new xn(e>>16&255,e>>8&255,e&255,1)}function vn(e,t,n,r){return r<=0&&(e=t=n=NaN),new xn(e,t,n,r)}function yn(e){return e instanceof Zt||(e=gn(e)),e?(e=e.rgb(),new xn(e.r,e.g,e.b,e.opacity)):new xn}function bn(e,t,n,r){return arguments.length===1?yn(e):new xn(e,t,n,r??1)}function xn(e,t,n,r){this.r=+e,this.g=+t,this.b=+n,this.opacity=+r}Yt(xn,bn,Xt(Zt,{brighter(e){return e=e==null?$t:$t**+e,new xn(this.r*e,this.g*e,this.b*e,this.opacity)},darker(e){return e=e==null?Qt:Qt**+e,new xn(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new xn(En(this.r),En(this.g),En(this.b),Tn(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Sn,formatHex:Sn,formatHex8:Cn,formatRgb:wn,toString:wn}));function Sn(){return`#${Dn(this.r)}${Dn(this.g)}${Dn(this.b)}`}function Cn(){return`#${Dn(this.r)}${Dn(this.g)}${Dn(this.b)}${Dn((isNaN(this.opacity)?1:this.opacity)*255)}`}function wn(){let e=Tn(this.opacity);return`${e===1?`rgb(`:`rgba(`}${En(this.r)}, ${En(this.g)}, ${En(this.b)}${e===1?`)`:`, ${e})`}`}function Tn(e){return isNaN(e)?1:Math.max(0,Math.min(1,e))}function En(e){return Math.max(0,Math.min(255,Math.round(e)||0))}function Dn(e){return e=En(e),(e<16?`0`:``)+e.toString(16)}function On(e,t,n,r){return r<=0?e=t=n=NaN:n<=0||n>=1?e=t=NaN:t<=0&&(e=NaN),new jn(e,t,n,r)}function kn(e){if(e instanceof jn)return new jn(e.h,e.s,e.l,e.opacity);if(e instanceof Zt||(e=gn(e)),!e)return new jn;if(e instanceof jn)return e;e=e.rgb();var t=e.r/255,n=e.g/255,r=e.b/255,i=Math.min(t,n,r),a=Math.max(t,n,r),o=NaN,s=a-i,c=(a+i)/2;return s?(o=t===a?(n-r)/s+(n0&&c<1?0:o,new jn(o,s,c,e.opacity)}function An(e,t,n,r){return arguments.length===1?kn(e):new jn(e,t,n,r??1)}function jn(e,t,n,r){this.h=+e,this.s=+t,this.l=+n,this.opacity=+r}Yt(jn,An,Xt(Zt,{brighter(e){return e=e==null?$t:$t**+e,new jn(this.h,this.s,this.l*e,this.opacity)},darker(e){return e=e==null?Qt:Qt**+e,new jn(this.h,this.s,this.l*e,this.opacity)},rgb(){var e=this.h%360+(this.h<0)*360,t=isNaN(e)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*t,i=2*n-r;return new xn(Pn(e>=240?e-240:e+120,i,r),Pn(e,i,r),Pn(e<120?e+240:e-120,i,r),this.opacity)},clamp(){return new jn(Mn(this.h),Nn(this.s),Nn(this.l),Tn(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let e=Tn(this.opacity);return`${e===1?`hsl(`:`hsla(`}${Mn(this.h)}, ${Nn(this.s)*100}%, ${Nn(this.l)*100}%${e===1?`)`:`, ${e})`}`}}));function Mn(e){return e=(e||0)%360,e<0?e+360:e}function Nn(e){return Math.max(0,Math.min(1,e||0))}function Pn(e,t,n){return(e<60?t+(n-t)*e/60:e<180?n:e<240?t+(n-t)*(240-e)/60:t)*255}var Fn=e=>()=>e;function In(e,t){return function(n){return e+n*t}}function Ln(e,t,n){return e**=+n,t=t**+n-e,n=1/n,function(r){return(e+r*t)**+n}}function Rn(e){return(e=+e)==1?zn:function(t,n){return n-t?Ln(t,n,e):Fn(isNaN(t)?n:t)}}function zn(e,t){var n=t-e;return n?In(e,n):Fn(isNaN(e)?t:e)}var Bn=(function e(t){var n=Rn(t);function r(e,t){var r=n((e=bn(e)).r,(t=bn(t)).r),i=n(e.g,t.g),a=n(e.b,t.b),o=zn(e.opacity,t.opacity);return function(t){return e.r=r(t),e.g=i(t),e.b=a(t),e.opacity=o(t),e+``}}return r.gamma=e,r})(1);function Vn(e,t){t||=[];var n=e?Math.min(t.length,e.length):0,r=t.slice(),i;return function(a){for(i=0;in&&(a=t.slice(n,a),s[o]?s[o]+=a:s[++o]=a),(r=r[0])===(i=i[0])?s[o]?s[o]+=i:s[++o]=i:(s[++o]=null,c.push({i:o,x:Gn(r,i)})),n=Jn.lastIndex;return n180?t+=360:t-e>180&&(e+=360),a.push({i:n.push(i(n)+`rotate(`,null,r)-2,x:Gn(e,t)}))}function s(e,t,n,a){e===t?t&&n.push(i(n)+`skewX(`+t+r):a.push({i:n.push(i(n)+`skewX(`,null,r)-2,x:Gn(e,t)})}function c(e,t,n,r,a,o){if(e!==n||t!==r){var s=a.push(i(a)+`scale(`,null,`,`,null,`)`);o.push({i:s-4,x:Gn(e,n)},{i:s-2,x:Gn(t,r)})}else(n!==1||r!==1)&&a.push(i(a)+`scale(`+n+`,`+r+`)`)}return function(t,n){var r=[],i=[];return t=e(t),n=e(n),a(t.translateX,t.translateY,n.translateX,n.translateY,r,i),o(t.rotate,n.rotate,r,i),s(t.skewX,n.skewX,r,i),c(t.scaleX,t.scaleY,n.scaleX,n.scaleY,r,i),t=n=null,function(e){for(var t=-1,n=i.length,a;++t=0&&e._call.call(void 0,t),e=e._next;--pr}function kr(){br=(yr=Sr.now())+xr,pr=mr=0;try{Or()}finally{pr=0,jr(),br=0}}function Ar(){var e=Sr.now(),t=e-yr;t>gr&&(xr-=t,yr=e)}function jr(){for(var e,t=_r,n,r=1/0;t;)t._call?(r>t._time&&(r=t._time),e=t,t=t._next):(n=t._next,t._next=null,t=e?e._next=n:_r=n);vr=e,Mr(r)}function Mr(e){pr||(mr&&=clearTimeout(mr),e-br>24?(e<1/0&&(mr=setTimeout(kr,e-Sr.now()-xr)),hr&&=clearInterval(hr)):(hr||=(yr=Sr.now(),setInterval(Ar,gr)),pr=1,Cr(kr)))}function Nr(e,t,n){var r=new Er;return t=t==null?0:+t,r.restart(n=>{r.stop(),e(n+t)},t,n),r}var Pr=g(`start`,`end`,`cancel`,`interrupt`),Fr=[];function Ir(e,t,n,r,i,a){var o=e.__transition;if(!o)e.__transition={};else if(n in o)return;Br(e,n,{name:t,index:r,group:i,on:Pr,tween:Fr,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:0})}function Lr(e,t){var n=zr(e,t);if(n.state>0)throw Error(`too late; already scheduled`);return n}function Rr(e,t){var n=zr(e,t);if(n.state>3)throw Error(`too late; already running`);return n}function zr(e,t){var n=e.__transition;if(!n||!(n=n[t]))throw Error(`transition not found`);return n}function Br(e,t,n){var r=e.__transition,i;r[t]=n,n.timer=Dr(a,0,n.time);function a(e){n.state=1,n.timer.restart(o,n.delay,n.time),n.delay<=e&&o(e-n.delay)}function o(a){var l,u,d,f;if(n.state!==1)return c();for(l in r)if(f=r[l],f.name===n.name){if(f.state===3)return Nr(o);f.state===4?(f.state=6,f.timer.stop(),f.on.call(`interrupt`,e,e.__data__,f.index,f.group),delete r[l]):+l2&&r.state<5,r.state=6,r.timer.stop(),r.on.call(i?`interrupt`:`cancel`,e,e.__data__,r.index,r.group),delete n[o]}a&&delete e.__transition}}function Hr(e){return this.each(function(){Vr(this,e)})}function Ur(e,t){var n,r;return function(){var i=Rr(this,e),a=i.tween;if(a!==n){r=n=a;for(var o=0,s=r.length;o=0&&(e=e.slice(0,t)),!e||e===`start`})}function yi(e,t,n){var r,i,a=vi(t)?Lr:Rr;return function(){var o=a(this,e),s=o.on;s!==r&&(i=(r=s).copy()).on(t,n),o.on=i}}function bi(e,t){var n=this._id;return arguments.length<2?zr(this.node(),n).on.on(e):this.each(yi(n,e,t))}function xi(e){return function(){var t=this.parentNode;for(var n in this.__transition)if(+n!==e)return;t&&t.removeChild(this)}}function Si(){return this.on(`end.remove`,xi(this._id))}function Ci(e){var t=this._name,n=this._id;typeof e!=`function`&&(e=D(e));for(var r=this._groups,i=r.length,a=Array(i),o=0;o()=>e;function ea(e,{sourceEvent:t,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function ta(e,t,n){this.k=e,this.x=t,this.y=n}ta.prototype={constructor:ta,scale:function(e){return e===1?this:new ta(this.k*e,this.x,this.y)},translate:function(e,t){return e===0&t===0?this:new ta(this.k,this.x+this.k*e,this.y+this.k*t)},apply:function(e){return[e[0]*this.k+this.x,e[1]*this.k+this.y]},applyX:function(e){return e*this.k+this.x},applyY:function(e){return e*this.k+this.y},invert:function(e){return[(e[0]-this.x)/this.k,(e[1]-this.y)/this.k]},invertX:function(e){return(e-this.x)/this.k},invertY:function(e){return(e-this.y)/this.k},rescaleX:function(e){return e.copy().domain(e.range().map(this.invertX,this).map(e.invert,e))},rescaleY:function(e){return e.copy().domain(e.range().map(this.invertY,this).map(e.invert,e))},toString:function(){return`translate(`+this.x+`,`+this.y+`) scale(`+this.k+`)`}};var na=new ta(1,0,0);ra.prototype=ta.prototype;function ra(e){for(;!e.__zoom;)if(!(e=e.parentNode))return na;return e.__zoom}function ia(e){e.stopImmediatePropagation()}function aa(e){e.preventDefault(),e.stopImmediatePropagation()}function oa(e){return(!e.ctrlKey||e.type===`wheel`)&&!e.button}function sa(){var e=this;return e instanceof SVGElement?(e=e.ownerSVGElement||e,e.hasAttribute(`viewBox`)?(e=e.viewBox.baseVal,[[e.x,e.y],[e.x+e.width,e.y+e.height]]):[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]):[[0,0],[e.clientWidth,e.clientHeight]]}function ca(){return this.__zoom||na}function la(e){return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*(e.ctrlKey?10:1)}function ua(){return navigator.maxTouchPoints||`ontouchstart`in this}function da(e,t,n){var r=e.invertX(t[0][0])-n[0][0],i=e.invertX(t[1][0])-n[1][0],a=e.invertY(t[0][1])-n[0][1],o=e.invertY(t[1][1])-n[1][1];return e.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),o>a?(a+o)/2:Math.min(0,a)||Math.max(0,o))}function fa(){var e=oa,t=sa,n=da,r=la,i=ua,a=[0,1/0],o=[[-1/0,-1/0],[1/0,1/0]],s=250,c=fr,l=g(`start`,`zoom`,`end`),u,d,f,p=500,m=150,h=0,_=10;function v(e){e.property(`__zoom`,ca).on(`wheel.zoom`,T,{passive:!1}).on(`mousedown.zoom`,E).on(`dblclick.zoom`,D).filter(i).on(`touchstart.zoom`,O).on(`touchmove.zoom`,k).on(`touchend.zoom touchcancel.zoom`,A).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}v.transform=function(e,t,n,r){var i=e.selection?e.selection():e;i.property(`__zoom`,ca),e===i?i.interrupt().each(function(){C(this,arguments).event(r).start().zoom(null,typeof t==`function`?t.apply(this,arguments):t).end()}):S(e,t,n,r)},v.scaleBy=function(e,t,n,r){v.scaleTo(e,function(){return this.__zoom.k*(typeof t==`function`?t.apply(this,arguments):t)},n,r)},v.scaleTo=function(e,r,i,a){v.transform(e,function(){var e=t.apply(this,arguments),a=this.__zoom,s=i==null?x(e):typeof i==`function`?i.apply(this,arguments):i,c=a.invert(s),l=typeof r==`function`?r.apply(this,arguments):r;return n(b(y(a,l),s,c),e,o)},i,a)},v.translateBy=function(e,r,i,a){v.transform(e,function(){return n(this.__zoom.translate(typeof r==`function`?r.apply(this,arguments):r,typeof i==`function`?i.apply(this,arguments):i),t.apply(this,arguments),o)},null,a)},v.translateTo=function(e,r,i,a,s){v.transform(e,function(){var e=t.apply(this,arguments),s=this.__zoom,c=a==null?x(e):typeof a==`function`?a.apply(this,arguments):a;return n(na.translate(c[0],c[1]).scale(s.k).translate(typeof r==`function`?-r.apply(this,arguments):-r,typeof i==`function`?-i.apply(this,arguments):-i),e,o)},a,s)};function y(e,t){return t=Math.max(a[0],Math.min(a[1],t)),t===e.k?e:new ta(t,e.x,e.y)}function b(e,t,n){var r=t[0]-n[0]*e.k,i=t[1]-n[1]*e.k;return r===e.x&&i===e.y?e:new ta(e.k,r,i)}function x(e){return[(+e[0][0]+ +e[1][0])/2,(+e[0][1]+ +e[1][1])/2]}function S(e,n,r,i){e.on(`start.zoom`,function(){C(this,arguments).event(i).start()}).on(`interrupt.zoom end.zoom`,function(){C(this,arguments).event(i).end()}).tween(`zoom`,function(){var e=this,a=arguments,o=C(e,a).event(i),s=t.apply(e,a),l=r==null?x(s):typeof r==`function`?r.apply(e,a):r,u=Math.max(s[1][0]-s[0][0],s[1][1]-s[0][1]),d=e.__zoom,f=typeof n==`function`?n.apply(e,a):n,p=c(d.invert(l).concat(u/d.k),f.invert(l).concat(u/f.k));return function(e){if(e===1)e=f;else{var t=p(e),n=u/t[2];e=new ta(n,l[0]-t[0]*n,l[1]-t[1]*n)}o.zoom(null,e)}})}function C(e,t,n){return!n&&e.__zooming||new w(e,t)}function w(e,n){this.that=e,this.args=n,this.active=0,this.sourceEvent=null,this.extent=t.apply(e,n),this.taps=0}w.prototype={event:function(e){return e&&(this.sourceEvent=e),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit(`start`)),this},zoom:function(e,t){return this.mouse&&e!==`mouse`&&(this.mouse[1]=t.invert(this.mouse[0])),this.touch0&&e!==`touch`&&(this.touch0[1]=t.invert(this.touch0[0])),this.touch1&&e!==`touch`&&(this.touch1[1]=t.invert(this.touch1[0])),this.that.__zoom=t,this.emit(`zoom`),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit(`end`)),this},emit:function(e){var t=Nt(this.that).datum();l.call(e,this.that,new ea(e,{sourceEvent:this.sourceEvent,target:v,type:e,transform:this.that.__zoom,dispatch:l}),t)}};function T(t,...i){if(!e.apply(this,arguments))return;var s=C(this,i).event(t),c=this.__zoom,l=Math.max(a[0],Math.min(a[1],c.k*2**r.apply(this,arguments))),u=Ft(t);if(s.wheel)(s.mouse[0][0]!==u[0]||s.mouse[0][1]!==u[1])&&(s.mouse[1]=c.invert(s.mouse[0]=u)),clearTimeout(s.wheel);else if(c.k===l)return;else s.mouse=[u,c.invert(u)],Vr(this),s.start();aa(t),s.wheel=setTimeout(d,m),s.zoom(`mouse`,n(b(y(c,l),s.mouse[0],s.mouse[1]),s.extent,o));function d(){s.wheel=null,s.end()}}function E(t,...r){if(f||!e.apply(this,arguments))return;var i=t.currentTarget,a=C(this,r,!0).event(t),s=Nt(t.view).on(`mousemove.zoom`,d,!0).on(`mouseup.zoom`,p,!0),c=Ft(t,i),l=t.clientX,u=t.clientY;Bt(t.view),ia(t),a.mouse=[c,this.__zoom.invert(c)],Vr(this),a.start();function d(e){if(aa(e),!a.moved){var t=e.clientX-l,r=e.clientY-u;a.moved=t*t+r*r>h}a.event(e).zoom(`mouse`,n(b(a.that.__zoom,a.mouse[0]=Ft(e,i),a.mouse[1]),a.extent,o))}function p(e){s.on(`mousemove.zoom mouseup.zoom`,null),Vt(e.view,a.moved),aa(e),a.event(e).end()}}function D(r,...i){if(e.apply(this,arguments)){var a=this.__zoom,c=Ft(r.changedTouches?r.changedTouches[0]:r,this),l=a.invert(c),u=a.k*(r.shiftKey?.5:2),d=n(b(y(a,u),c,l),t.apply(this,i),o);aa(r),s>0?Nt(this).transition().duration(s).call(S,d,c,r):Nt(this).call(v.transform,d,c,r)}}function O(t,...n){if(e.apply(this,arguments)){var r=t.touches,i=r.length,a=C(this,n,t.changedTouches.length===i).event(t),o,s,c,l;for(ia(t),s=0;s`Seems like you have not used zustand provider as an ancestor. Help: https://${e}flow.dev/error#001`,error002:()=>`It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.`,error003:e=>`Node type "${e}" not found. Using fallback type "default".`,error004:()=>`The parent container needs a width and a height to render the graph.`,error005:()=>`Only child nodes can use a parent extent.`,error006:()=>`Can't create edge. An edge needs a source and a target.`,error007:e=>`The old edge with id=${e} does not exist.`,error009:e=>`Marker type "${e}" doesn't exist.`,error008:(e,{id:t,sourceHandle:n,targetHandle:r})=>`Couldn't create edge for ${e} handle id: "${e===`source`?n:r}", edge id: ${t}.`,error010:()=>`Handle: No node id found. Make sure to only use a Handle inside a custom Node.`,error011:e=>`Edge type "${e}" not found. Using fallback type "default".`,error012:e=>`Node with id "${e}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(e=`react`)=>`It seems that you haven't loaded the styles. Please import '@xyflow/${e}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>`useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.`,error015:()=>`It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.`,error016:e=>`Edge with id "${e}" does not exist, it may have been removed. This can happen when an edge is deleted before the "onEdgeClick" handler is called.`},ma=[[-1/0,-1/0],[1/0,1/0]],ha=[`Enter`,` `,`Escape`],ga={"node.a11yDescription.default":`Press enter or space to select a node. Press delete to remove it and escape to cancel.`,"node.a11yDescription.keyboardDisabled":`Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.`,"node.a11yDescription.ariaLiveMessage":({direction:e,x:t,y:n})=>`Moved selected node ${e}. New position, x: ${t}, y: ${n}`,"edge.a11yDescription.default":`Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.`,"controls.ariaLabel":`Control Panel`,"controls.zoomIn.ariaLabel":`Zoom In`,"controls.zoomOut.ariaLabel":`Zoom Out`,"controls.fitView.ariaLabel":`Fit View`,"controls.interactive.ariaLabel":`Toggle Interactivity`,"minimap.ariaLabel":`Mini Map`,"handle.ariaLabel":`Handle`},_a;(function(e){e.Strict=`strict`,e.Loose=`loose`})(_a||={});var va;(function(e){e.Free=`free`,e.Vertical=`vertical`,e.Horizontal=`horizontal`})(va||={});var ya;(function(e){e.Partial=`partial`,e.Full=`full`})(ya||={});var ba={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null,pointer:null},xa;(function(e){e.Bezier=`default`,e.Straight=`straight`,e.Step=`step`,e.SmoothStep=`smoothstep`,e.SimpleBezier=`simplebezier`})(xa||={});var Sa;(function(e){e.Arrow=`arrow`,e.ArrowClosed=`arrowclosed`})(Sa||={});var B;(function(e){e.Left=`left`,e.Top=`top`,e.Right=`right`,e.Bottom=`bottom`})(B||={});var Ca={[B.Left]:B.Right,[B.Right]:B.Left,[B.Top]:B.Bottom,[B.Bottom]:B.Top};function wa(e){return e===null?null:e?`valid`:`invalid`}var Ta=e=>`id`in e&&`source`in e&&`target`in e,Ea=e=>`id`in e&&`position`in e&&!(`source`in e)&&!(`target`in e),Da=e=>`id`in e&&`internals`in e&&!(`source`in e)&&!(`target`in e),Oa=(e,t,n)=>{if(!e.id)return[];let r=new Set;return n.forEach(t=>{t.source===e.id&&r.add(t.target)}),t.filter(e=>r.has(e.id))},ka=(e,t,n)=>{if(!e.id)return[];let r=new Set;return n.forEach(t=>{t.target===e.id&&r.add(t.source)}),t.filter(e=>r.has(e.id))},Aa=(e,t=[0,0])=>{let{width:n,height:r}=lo(e),i=e.origin??t,a=n*i[0],o=r*i[1];return{x:e.position.x-a,y:e.position.y-o}},ja=(e,t={nodeOrigin:[0,0]})=>e.length===0?{x:0,y:0,width:0,height:0}:Ka(e.reduce((e,n)=>{let r=typeof n==`string`,i=!t.nodeLookup&&!r?n:void 0;return t.nodeLookup&&(i=r?t.nodeLookup.get(n):Da(n)?n:t.nodeLookup.get(n.id)),Wa(e,i?Ja(i,t.nodeOrigin):{x:0,y:0,x2:0,y2:0})},{x:1/0,y:1/0,x2:-1/0,y2:-1/0})),Ma=(e,t={})=>{let n={x:1/0,y:1/0,x2:-1/0,y2:-1/0},r=!1;return e.forEach(e=>{(t.filter===void 0||t.filter(e))&&(n=Wa(n,Ja(e)),r=!0)}),r?Ka(n):{x:0,y:0,width:0,height:0}},Na=(e,t,[n,r,i]=[0,0,1],a=!1,o=!1)=>{let s={...to(t,[n,r,i]),width:t.width/i,height:t.height/i},c=[];for(let t of e.values()){let{measured:e,selectable:n=!0,hidden:r=!1}=t;if(o&&!n||r)continue;let i=e.width??t.width??t.initialWidth??null,l=e.height??t.height??t.initialHeight??null,u=Xa(s,qa(t)),d=(i??0)*(l??0),f=a&&u>0;(!t.internals.handleBounds||f||u>=d||t.dragging)&&c.push(t)}return c},Pa=(e,t)=>{let n=new Set;return e.forEach(e=>{n.add(e.id)}),t.filter(e=>n.has(e.source)||n.has(e.target))};function Fa(e,t){let n=new Map,r=t?.nodes?new Set(t.nodes.map(e=>e.id)):null;return e.forEach(e=>{e.measured.width&&e.measured.height&&(t?.includeHiddenNodes||!e.hidden)&&(!r||r.has(e.id))&&n.set(e.id,e)}),n}async function Ia({nodes:e,width:t,height:n,panZoom:r,minZoom:i,maxZoom:a},o){if(e.size===0)return!0;let s=oo(Ma(Fa(e,o)),t,n,o?.minZoom??i,o?.maxZoom??a,o?.padding??.1);return await r.setViewport(s,{duration:o?.duration,ease:o?.ease,interpolate:o?.interpolate}),!0}function La({nodeId:e,nextPosition:t,nodeLookup:n,nodeOrigin:r=[0,0],nodeExtent:i,onError:a}){let o=n.get(e),s=o.parentId?n.get(o.parentId):void 0,{x:c,y:l}=s?s.internals.positionAbsolute:{x:0,y:0},u=o.origin??r,d=o.extent||i;if(o.extent===`parent`&&!o.expandParent)if(!s)a?.(`005`,pa.error005());else{let e=s.measured.width,t=s.measured.height;e&&t&&(d=[[c,l],[c+e,l+t]])}else s&&co(o.extent)&&(d=[[o.extent[0][0]+c,o.extent[0][1]+l],[o.extent[1][0]+c,o.extent[1][1]+l]]);let f=co(d)?Ba(t,d,o.measured):t;return(o.measured.width===void 0||o.measured.height===void 0)&&a?.(`015`,pa.error015()),{position:{x:f.x-c+(o.measured.width??0)*u[0],y:f.y-l+(o.measured.height??0)*u[1]},positionAbsolute:f}}async function Ra({nodesToRemove:e=[],edgesToRemove:t=[],nodes:n,edges:r,onBeforeDelete:i}){let a=new Set(e.map(e=>e.id)),o=[];for(let e of n){if(e.deletable===!1)continue;let t=a.has(e.id),n=!t&&e.parentId&&o.find(t=>t.id===e.parentId);(t||n)&&o.push(e)}let s=new Set(t.map(e=>e.id)),c=r.filter(e=>e.deletable!==!1),l=Pa(o,c);for(let e of c)s.has(e.id)&&!l.find(t=>t.id===e.id)&&l.push(e);if(!i)return{edges:l,nodes:o};let u=await i({nodes:o,edges:l});return typeof u==`boolean`?u?{edges:l,nodes:o}:{edges:[],nodes:[]}:u}var za=(e,t=0,n=1)=>Math.min(Math.max(e,t),n),Ba=(e={x:0,y:0},t,n)=>({x:za(e.x,t[0][0],t[1][0]-(n?.width??0)),y:za(e.y,t[0][1],t[1][1]-(n?.height??0))});function Va(e,t,n){let{width:r,height:i}=lo(n),{x:a,y:o}=n.internals.positionAbsolute;return Ba(e,[[a,o],[a+r,o+i]],t)}var Ha=(e,t,n)=>en?-za(Math.abs(e-n),1,t)/t:0,Ua=(e,t,n=15,r=40)=>[Ha(e.x,r,t.width-r)*n,Ha(e.y,r,t.height-r)*n],Wa=(e,t)=>({x:Math.min(e.x,t.x),y:Math.min(e.y,t.y),x2:Math.max(e.x2,t.x2),y2:Math.max(e.y2,t.y2)}),Ga=({x:e,y:t,width:n,height:r})=>({x:e,y:t,x2:e+n,y2:t+r}),Ka=({x:e,y:t,x2:n,y2:r})=>({x:e,y:t,width:n-e,height:r-t}),qa=(e,t=[0,0])=>{let{x:n,y:r}=Da(e)?e.internals.positionAbsolute:Aa(e,t);return{x:n,y:r,width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}},Ja=(e,t=[0,0])=>{let{x:n,y:r}=Da(e)?e.internals.positionAbsolute:Aa(e,t);return{x:n,y:r,x2:n+(e.measured?.width??e.width??e.initialWidth??0),y2:r+(e.measured?.height??e.height??e.initialHeight??0)}},Ya=(e,t)=>Ka(Wa(Ga(e),Ga(t))),Xa=(e,t)=>{let n=Math.max(0,Math.min(e.x+e.width,t.x+t.width)-Math.max(e.x,t.x)),r=Math.max(0,Math.min(e.y+e.height,t.y+t.height)-Math.max(e.y,t.y));return Math.ceil(n*r)},Za=e=>Qa(e.width)&&Qa(e.height)&&Qa(e.x)&&Qa(e.y),Qa=e=>!isNaN(e)&&isFinite(e),$a=(e,t)=>(e,t)=>{},eo=(e,t=[1,1])=>({x:t[0]*Math.round(e.x/t[0]),y:t[1]*Math.round(e.y/t[1])}),to=({x:e,y:t},[n,r,i],a=!1,o=[1,1])=>{let s={x:(e-n)/i,y:(t-r)/i};return a?eo(s,o):s},no=({x:e,y:t},[n,r,i])=>({x:e*i+n,y:t*i+r});function ro(e,t){if(typeof e==`number`)return Math.floor((t-t/(1+e))*.5);if(typeof e==`string`&&e.endsWith(`px`)){let t=parseFloat(e);if(!Number.isNaN(t))return Math.floor(t)}if(typeof e==`string`&&e.endsWith(`%`)){let n=parseFloat(e);if(!Number.isNaN(n))return Math.floor(t*n*.01)}return console.error(`The padding value "${e}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function io(e,t,n){if(typeof e==`string`||typeof e==`number`){let r=ro(e,n),i=ro(e,t);return{top:r,right:i,bottom:r,left:i,x:i*2,y:r*2}}if(typeof e==`object`){let r=ro(e.top??e.y??0,n),i=ro(e.bottom??e.y??0,n),a=ro(e.left??e.x??0,t),o=ro(e.right??e.x??0,t);return{top:r,right:o,bottom:i,left:a,x:a+o,y:r+i}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function ao(e,t,n,r,i,a){let{x:o,y:s}=no(e,[t,n,r]),{x:c,y:l}=no({x:e.x+e.width,y:e.y+e.height},[t,n,r]),u=i-c,d=a-l;return{left:Math.floor(o),top:Math.floor(s),right:Math.floor(u),bottom:Math.floor(d)}}var oo=(e,t,n,r,i,a)=>{let o=io(a,t,n),s=(t-o.x)/e.width,c=(n-o.y)/e.height,l=za(Math.min(s,c),r,i),u=e.x+e.width/2,d=e.y+e.height/2,f=t/2-u*l,p=n/2-d*l,m=ao(e,f,p,l,t,n),h={left:Math.min(m.left-o.left,0),top:Math.min(m.top-o.top,0),right:Math.min(m.right-o.right,0),bottom:Math.min(m.bottom-o.bottom,0)};return{x:f-h.left+h.right,y:p-h.top+h.bottom,zoom:l}},so=()=>typeof navigator<`u`&&navigator?.userAgent?.indexOf(`Mac`)>=0;function co(e){return e!=null&&e!==`parent`}function lo(e){return{width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}}function uo(e){return(e.measured?.width??e.width??e.initialWidth)!==void 0&&(e.measured?.height??e.height??e.initialHeight)!==void 0}function fo(e,t={width:0,height:0},n,r,i){let a={...e},o=r.get(n);if(o){let e=o.origin||i;a.x+=o.internals.positionAbsolute.x-(t.width??0)*e[0],a.y+=o.internals.positionAbsolute.y-(t.height??0)*e[1]}return a}function V(e,t){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}function po(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function mo(e){return{...ga,...e||{}}}function ho(e,{snapGrid:t=[0,0],snapToGrid:n=!1,transform:r,containerBounds:i}){let{x:a,y:o}=xo(e),s=to({x:a-(i?.left??0),y:o-(i?.top??0)},r),{x:c,y:l}=n?eo(s,t):s;return{xSnapped:c,ySnapped:l,...s}}var go=e=>({width:e.offsetWidth,height:e.offsetHeight}),_o=e=>e?.getRootNode?.()||window?.document,vo=[`INPUT`,`SELECT`,`TEXTAREA`];function yo(e){let t=e.composedPath?.()?.[0]||e.target;return t?.nodeType===1?vo.includes(t.nodeName)||t.hasAttribute(`contenteditable`)||!!t.closest(`.nokey`):!1}var bo=e=>`clientX`in e,xo=(e,t)=>{let n=bo(e),r=n?e.clientX:e.touches?.[0].clientX,i=n?e.clientY:e.touches?.[0].clientY;return{x:r-(t?.left??0),y:i-(t?.top??0)}},So=(e,t,n,r,i)=>{let a=t.querySelectorAll(`.${e}`);return!a||!a.length?null:Array.from(a).map(t=>{let a=t.getBoundingClientRect();return{id:t.getAttribute(`data-handleid`),type:e,nodeId:i,position:t.getAttribute(`data-handlepos`),x:(a.left-n.left)/r,y:(a.top-n.top)/r,...go(t)}})};function Co({sourceX:e,sourceY:t,targetX:n,targetY:r,sourceControlX:i,sourceControlY:a,targetControlX:o,targetControlY:s}){let c=e*.125+i*.375+o*.375+n*.125,l=t*.125+a*.375+s*.375+r*.125;return[c,l,Math.abs(c-e),Math.abs(l-t)]}function wo(e,t){return e>=0?.5*e:t*25*Math.sqrt(-e)}function To({pos:e,x1:t,y1:n,x2:r,y2:i,c:a}){switch(e){case B.Left:return[t-wo(t-r,a),n];case B.Right:return[t+wo(r-t,a),n];case B.Top:return[t,n-wo(n-i,a)];case B.Bottom:return[t,n+wo(i-n,a)]}}function Eo({sourceX:e,sourceY:t,sourcePosition:n=B.Bottom,targetX:r,targetY:i,targetPosition:a=B.Top,curvature:o=.25}){let[s,c]=To({pos:n,x1:e,y1:t,x2:r,y2:i,c:o}),[l,u]=To({pos:a,x1:r,y1:i,x2:e,y2:t,c:o}),[d,f,p,m]=Co({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:s,sourceControlY:c,targetControlX:l,targetControlY:u});return[`M${e},${t} C${s},${c} ${l},${u} ${r},${i}`,d,f,p,m]}function Do({sourceX:e,sourceY:t,targetX:n,targetY:r}){let i=Math.abs(n-e)/2,a=n0}var Ao=({source:e,sourceHandle:t,target:n,targetHandle:r})=>`xy-edge__${e}${t||``}-${n}${r||``}`,jo=(e,t)=>t.some(t=>t.source===e.source&&t.target===e.target&&(t.sourceHandle===e.sourceHandle||!t.sourceHandle&&!e.sourceHandle)&&(t.targetHandle===e.targetHandle||!t.targetHandle&&!e.targetHandle)),Mo=(e,t,n={})=>{if(!e.source||!e.target)return n.onError?.(`006`,pa.error006()),t;let r=n.getEdgeId||Ao,i;return i=Ta(e)?{...e}:{...e,id:r(e)},jo(i,t)?t:(i.sourceHandle===null&&delete i.sourceHandle,i.targetHandle===null&&delete i.targetHandle,t.concat(i))};function No({sourceX:e,sourceY:t,targetX:n,targetY:r}){let[i,a,o,s]=Do({sourceX:e,sourceY:t,targetX:n,targetY:r});return[`M ${e},${t}L ${n},${r}`,i,a,o,s]}var Po={[B.Left]:{x:-1,y:0},[B.Right]:{x:1,y:0},[B.Top]:{x:0,y:-1},[B.Bottom]:{x:0,y:1}},Fo=({source:e,sourcePosition:t=B.Bottom,target:n})=>t===B.Left||t===B.Right?e.xMath.sqrt((t.x-e.x)**2+(t.y-e.y)**2);function Lo({source:e,sourcePosition:t=B.Bottom,target:n,targetPosition:r=B.Top,center:i,offset:a,stepPosition:o}){let s=Po[t],c=Po[r],l={x:e.x+s.x*a,y:e.y+s.y*a},u={x:n.x+c.x*a,y:n.y+c.y*a},d=Fo({source:l,sourcePosition:t,target:u}),f=d.x===0?`y`:`x`,p=d[f],m=[],h,g,_={x:0,y:0},v={x:0,y:0},[,,y,b]=Do({sourceX:e.x,sourceY:e.y,targetX:n.x,targetY:n.y});if(s[f]*c[f]===-1){f===`x`?(h=i.x??l.x+(u.x-l.x)*o,g=i.y??(l.y+u.y)/2):(h=i.x??(l.x+u.x)/2,g=i.y??l.y+(u.y-l.y)*o);let e=[{x:h,y:l.y},{x:h,y:u.y}],t=[{x:l.x,y:g},{x:u.x,y:g}];m=s[f]===p?f===`x`?e:t:f===`x`?t:e}else{let i=[{x:l.x,y:u.y}],o=[{x:u.x,y:l.y}];if(m=f===`x`?s.x===p?o:i:s.y===p?i:o,t===r){let t=Math.abs(e[f]-n[f]);if(t<=a){let r=Math.min(a-1,a-t);s[f]===p?_[f]=(l[f]>e[f]?-1:1)*r:v[f]=(u[f]>n[f]?-1:1)*r}}if(t!==r){let e=f===`x`?`y`:`x`,t=s[f]===c[e],n=l[e]>u[e],r=l[e]=Math.max(Math.abs(d.y-m[0].y),Math.abs(y.y-m[0].y))?(h=(d.x+y.x)/2,g=m[0].y):(h=m[0].x,g=(d.y+y.y)/2)}let x={x:l.x+_.x,y:l.y+_.y},S={x:u.x+v.x,y:u.y+v.y};return[[e,...x.x!==m[0].x||x.y!==m[0].y?[x]:[],...m,...S.x!==m[m.length-1].x||S.y!==m[m.length-1].y?[S]:[],n],h,g,y,b]}function Ro(e,t,n,r){let i=Math.min(Io(e,t)/2,Io(t,n)/2,r),{x:a,y:o}=t;if(e.x===a&&a===n.x||e.y===o&&o===n.y)return`L${a} ${o}`;if(e.y===o){let t=e.xe.id===t):e[0])||null}function Go(e,t){return e?typeof e==`string`?e:`${t?`${t}__`:``}${Object.keys(e).sort().map(t=>`${t}=${e[t]}`).join(`&`)}`:``}function Ko(e,{id:t,defaultColor:n,defaultMarkerStart:r,defaultMarkerEnd:i}){let a=new Set;return e.reduce((e,o)=>([o.markerStart||r,o.markerEnd||i].forEach(r=>{if(r&&typeof r==`object`){let i=Go(r,t);a.has(i)||(e.push({id:i,color:r.color||n,...r}),a.add(i))}}),e),[]).sort((e,t)=>e.id.localeCompare(t.id))}var qo=1e3,Jo=10,Yo={nodeOrigin:[0,0],nodeExtent:ma,elevateNodesOnSelect:!0,zIndexMode:`basic`,defaults:{}},Xo={...Yo,checkEquality:!0};function Zo(e,t){let n={...e};for(let e in t)t[e]!==void 0&&(n[e]=t[e]);return n}function Qo(e,t,n){let r=Zo(Yo,n);for(let n of e.values())if(n.parentId)rs(n,e,t,r);else{let e=Ba(Aa(n,r.nodeOrigin),co(n.extent)?n.extent:r.nodeExtent,lo(n));n.internals.positionAbsolute=e}}function $o(e,t){if(!e.handles)return e.measured?t?.internals.handleBounds:void 0;let n=[],r=[];for(let t of e.handles){let i={id:t.id,width:t.width??1,height:t.height??1,nodeId:e.id,x:t.x,y:t.y,position:t.position,type:t.type};t.type===`source`?n.push(i):t.type===`target`&&r.push(i)}return{source:n,target:r}}function es(e){return e===`manual`}function ts(e,t,n,r={}){let i=Zo(Xo,r),a={i:0},o=new Map(t),s=i?.elevateNodesOnSelect&&!es(i.zIndexMode)?qo:0,c=e.length>0,l=!1;t.clear(),n.clear();for(let u of e){let e=o.get(u.id);if(i.checkEquality&&u===e?.internals.userNode)t.set(u.id,e);else{let n=Ba(Aa(u,i.nodeOrigin),co(u.extent)?u.extent:i.nodeExtent,lo(u));e={...i.defaults,...u,measured:{width:u.measured?.width,height:u.measured?.height},internals:{positionAbsolute:n,handleBounds:$o(u,e),z:is(u,s,i.zIndexMode),userNode:u}},t.set(u.id,e)}(e.measured===void 0||e.measured.width===void 0||e.measured.height===void 0)&&!e.hidden&&(c=!1),u.parentId&&rs(e,t,n,r,a),l||=u.selected??!1}return{nodesInitialized:c,hasSelectedNodes:l}}function ns(e,t){if(!e.parentId)return;let n=t.get(e.parentId);n?n.set(e.id,e):t.set(e.parentId,new Map([[e.id,e]]))}function rs(e,t,n,r,i){let{elevateNodesOnSelect:a,nodeOrigin:o,nodeExtent:s,zIndexMode:c}=Zo(Yo,r),l=e.parentId,u=t.get(l);if(!u){console.warn(`Parent node ${l} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}ns(e,n),i&&!u.parentId&&u.internals.rootParentIndex===void 0&&c===`auto`&&(u.internals.rootParentIndex=++i.i,u.internals.z=u.internals.z+i.i*Jo),i&&u.internals.rootParentIndex!==void 0&&(i.i=u.internals.rootParentIndex);let{x:d,y:f,z:p}=as(e,u,o,s,a&&!es(c)?qo:0,c),{positionAbsolute:m}=e.internals,h=d!==m.x||f!==m.y;(h||p!==e.internals.z)&&t.set(e.id,{...e,internals:{...e.internals,positionAbsolute:h?{x:d,y:f}:m,z:p}})}function is(e,t,n){let r=Qa(e.zIndex)?e.zIndex:0;return es(n)?r:r+(e.selected?t:0)}function as(e,t,n,r,i,a){let{x:o,y:s}=t.internals.positionAbsolute,c=lo(e),l=Aa(e,n),u=co(e.extent)?Ba(l,e.extent,c):l,d=Ba({x:o+u.x,y:s+u.y},r,c);e.extent===`parent`&&(d=Va(d,c,t));let f=is(e,i,a),p=t.internals.z??0;return{x:d.x,y:d.y,z:p>=f?p+1:f}}function os(e,t,n,r=[0,0]){let i=[],a=new Map;for(let n of e){let e=t.get(n.parentId);if(!e)continue;let r=Ya(a.get(n.parentId)?.expandedRect??qa(e),n.rect);a.set(n.parentId,{expandedRect:r,parent:e})}return a.size>0&&a.forEach(({expandedRect:t,parent:a},o)=>{let s=a.internals.positionAbsolute,c=lo(a),l=a.origin??r,u=t.x0||d>0||m||h)&&(i.push({id:o,type:`position`,position:{x:a.position.x-u+m,y:a.position.y-d+h}}),n.get(o)?.forEach(t=>{e.some(e=>e.id===t.id)||i.push({id:t.id,type:`position`,position:{x:t.position.x+u,y:t.position.y+d}})})),(c.width0){let e=os(f,t,n,i);l.push(...e)}return{changes:l,updatedInternals:c}}async function cs({delta:e,panZoom:t,transform:n,translateExtent:r,width:i,height:a}){if(!t||!e.x&&!e.y)return!1;let o=await t.setViewportConstrained({x:n[0]+e.x,y:n[1]+e.y,zoom:n[2]},[[0,0],[i,a]],r);return!!o&&(o.x!==n[0]||o.y!==n[1]||o.k!==n[2])}function ls(e,t,n,r,i,a){let o=i,s=r.get(o)||new Map;r.set(o,s.set(n,t)),o=`${i}-${e}`;let c=r.get(o)||new Map;if(r.set(o,c.set(n,t)),a){o=`${i}-${e}-${a}`;let s=r.get(o)||new Map;r.set(o,s.set(n,t))}}function us(e,t,n){e.clear(),t.clear();for(let r of n){let{source:n,target:i,sourceHandle:a=null,targetHandle:o=null}=r,s={edgeId:r.id,source:n,target:i,sourceHandle:a,targetHandle:o},c=`${n}-${a}--${i}-${o}`;ls(`source`,s,`${i}-${o}--${n}-${a}`,e,n,a),ls(`target`,s,c,e,i,o),t.set(r.id,r)}}function ds(e,t){if(!e.parentId)return!1;let n=t.get(e.parentId);return n?n.selected?!0:ds(n,t):!1}function fs(e,t,n){let r=e;do{if(r?.matches?.(t))return!0;if(r===n)return!1;r=r?.parentElement}while(r);return!1}function ps(e,t,n,r){let i=new Map;for(let[a,o]of e)if((o.selected||o.id===r)&&(!o.parentId||!ds(o,e))&&(o.draggable||t&&o.draggable===void 0)){let t=e.get(a);t&&i.set(a,{id:a,position:t.position||{x:0,y:0},distance:{x:n.x-t.internals.positionAbsolute.x,y:n.y-t.internals.positionAbsolute.y},extent:t.extent,parentId:t.parentId,origin:t.origin,expandParent:t.expandParent,internals:{positionAbsolute:t.internals.positionAbsolute||{x:0,y:0}},measured:{width:t.measured.width??0,height:t.measured.height??0}})}return i}function ms({nodeId:e,dragItems:t,nodeLookup:n,dragging:r=!0}){let i=[];for(let[e,a]of t){let t=n.get(e)?.internals.userNode;t&&i.push({...t,position:a.position,dragging:r})}if(!e)return[i[0],i];let a=n.get(e)?.internals.userNode;return[a?{...a,position:t.get(e)?.position||a.position,dragging:r}:i[0],i]}function hs({dragItems:e,snapGrid:t,x:n,y:r}){let i=e.values().next().value;if(!i)return null;let a={x:n-i.distance.x,y:r-i.distance.y},o=eo(a,t);return{x:o.x-a.x,y:o.y-a.y}}function gs({onNodeMouseDown:e,getStoreItems:t,onDragStart:n,onDrag:r,onDragStop:i}){let a={x:null,y:null},o=0,s=new Map,c=!1,l={x:0,y:0},u=null,d=!1,f=null,p=!1,m=!1,h=null;function g({noDragClassName:g,handleSelector:_,domNode:v,isSelectable:y,nodeId:b,nodeClickDistance:x=0}){f=Nt(v);function S({x:e,y:n}){let{nodeLookup:i,nodeExtent:o,snapGrid:c,snapToGrid:l,nodeOrigin:u,onNodeDrag:d,onSelectionDrag:f,onError:p,updateNodePositions:g}=t();a={x:e,y:n};let _=!1,v=s.size>1,y=v&&o?Ga(Ma(s)):null,x=v&&l?hs({dragItems:s,snapGrid:c,x:e,y:n}):null;for(let[t,r]of s){if(!i.has(t))continue;let a={x:e-r.distance.x,y:n-r.distance.y};l&&(a=x?{x:Math.round(a.x+x.x),y:Math.round(a.y+x.y)}:eo(a,c));let s=null;if(v&&o&&!r.extent&&y){let{positionAbsolute:e}=r.internals,t=e.x-y.x+o[0][0],n=e.x+r.measured.width-y.x2+o[1][0],i=e.y-y.y+o[0][1],a=e.y+r.measured.height-y.y2+o[1][1];s=[[t,i],[n,a]]}let{position:d,positionAbsolute:f}=La({nodeId:t,nextPosition:a,nodeLookup:i,nodeExtent:s||o,nodeOrigin:u,onError:p});_=_||r.position.x!==d.x||r.position.y!==d.y,r.position=d,r.internals.positionAbsolute=f}if(m||=_,_&&(g(s,!0),h&&(r||d||!b&&f))){let[e,t]=ms({nodeId:b,dragItems:s,nodeLookup:i});r?.(h,s,e,t),d?.(h,e,t),b||f?.(h,t)}}async function C(){if(!u)return;let{transform:e,panBy:n,autoPanSpeed:r,autoPanOnNodeDrag:i}=t();if(!i){c=!1,cancelAnimationFrame(o);return}let[s,d]=Ua(l,u,r);(s!==0||d!==0)&&(a.x=(a.x??0)-s/e[2],a.y=(a.y??0)-d/e[2],await n({x:s,y:d})&&S(a)),o=requestAnimationFrame(C)}function w(r){let{nodeLookup:i,multiSelectionActive:o,nodesDraggable:c,transform:l,snapGrid:f,snapToGrid:p,selectNodesOnDrag:m,onNodeDragStart:h,onSelectionDragStart:g,unselectNodesAndEdges:_}=t();d=!0,(!m||!y)&&!o&&b&&(i.get(b)?.selected||_()),y&&m&&b&&e?.(b);let v=ho(r.sourceEvent,{transform:l,snapGrid:f,snapToGrid:p,containerBounds:u});if(a=v,s=ps(i,c,v,b),s.size>0&&(n||h||!b&&g)){let[e,t]=ms({nodeId:b,dragItems:s,nodeLookup:i});n?.(r.sourceEvent,s,e,t),h?.(r.sourceEvent,e,t),b||g?.(r.sourceEvent,t)}}let T=Jt().clickDistance(x).on(`start`,e=>{let{domNode:n,nodeDragThreshold:r,transform:i,snapGrid:o,snapToGrid:s}=t();u=n?.getBoundingClientRect()||null,p=!1,m=!1,h=e.sourceEvent,r===0&&w(e),a=ho(e.sourceEvent,{transform:i,snapGrid:o,snapToGrid:s,containerBounds:u}),l=xo(e.sourceEvent,u)}).on(`drag`,e=>{let{autoPanOnNodeDrag:n,transform:r,snapGrid:i,snapToGrid:o,nodeDragThreshold:f,nodeLookup:m}=t(),g=ho(e.sourceEvent,{transform:r,snapGrid:i,snapToGrid:o,containerBounds:u});if(h=e.sourceEvent,(e.sourceEvent.type===`touchmove`&&e.sourceEvent.touches.length>1||b&&!m.has(b))&&(p=!0),!p){if(!c&&n&&d&&(c=!0,C()),!d){let t=xo(e.sourceEvent,u),n=t.x-l.x,r=t.y-l.y;Math.sqrt(n*n+r*r)>f&&w(e)}(a.x!==g.xSnapped||a.y!==g.ySnapped)&&s&&d&&(l=xo(e.sourceEvent,u),S(g))}}).on(`end`,e=>{if(!d||p){p&&s.size>0&&t().updateNodePositions(s,!1);return}if(c=!1,d=!1,cancelAnimationFrame(o),s.size>0){let{nodeLookup:n,updateNodePositions:r,onNodeDragStop:a,onSelectionDragStop:o}=t();if(m&&=(r(s,!1),!1),i||a||!b&&o){let[t,r]=ms({nodeId:b,dragItems:s,nodeLookup:n,dragging:!1});i?.(e.sourceEvent,s,t,r),a?.(e.sourceEvent,t,r),b||o?.(e.sourceEvent,r)}}}).filter(e=>{let t=e.target;return!e.button&&(!g||!fs(t,`.${g}`,v))&&(!_||fs(t,_,v))});f.call(T)}function _(){f?.on(`.drag`,null)}return{update:g,destroy:_}}function _s(e,t,n){let r=[],i={x:e.x-n,y:e.y-n,width:n*2,height:n*2};for(let e of t.values())Xa(i,qa(e))>0&&r.push(e);return r}var vs=250;function ys(e,t,n,r){let i=[],a=1/0,o=_s(e,n,t+vs);for(let n of o){let o=[...n.internals.handleBounds?.source??[],...n.internals.handleBounds?.target??[]];for(let s of o){if(r.nodeId===s.nodeId&&r.type===s.type&&r.id===s.id)continue;let{x:o,y:c}=Uo(n,s,s.position,!0),l=Math.sqrt((o-e.x)**2+(c-e.y)**2);l>t||(l1){let e=r.type===`source`?`target`:`source`;return i.find(t=>t.type===e)??i[0]}return i[0]}function bs(e,t,n,r,i,a=!1){let o=r.get(e);if(!o)return null;let s=i===`strict`?o.internals.handleBounds?.[t]:[...o.internals.handleBounds?.source??[],...o.internals.handleBounds?.target??[]],c=(n?s?.find(e=>e.id===n):s?.[0])??null;return c&&a?{...c,...Uo(o,c,c.position,!0)}:c}function xs(e,t){return e||(t?.classList.contains(`target`)?`target`:t?.classList.contains(`source`)?`source`:null)}function Ss(e,t){let n=null;return t?n=!0:e&&!t&&(n=!1),n}var Cs=()=>!0;function ws(e,{connectionMode:t,connectionRadius:n,handleId:r,nodeId:i,edgeUpdaterType:a,isTarget:o,domNode:s,nodeLookup:c,lib:l,autoPanOnConnect:u,flowId:d,panBy:f,cancelConnection:p,onConnectStart:m,onConnect:h,onConnectEnd:g,isValidConnection:_=Cs,onReconnectEnd:v,updateConnection:y,getTransform:b,getFromHandle:x,autoPanSpeed:S,dragThreshold:C=1,handleDomNode:w}){let T=_o(e.target),E=0,D,{x:O,y:k}=xo(e),A=xs(a,w),j=s?.getBoundingClientRect(),M=!1;if(!j||!A)return;let N=bs(i,A,r,c,t);if(!N)return;let P=xo(e,j),F=!1,I=null,L=!1,R=null;function ee(){if(!u||!j)return;let[e,t]=Ua(P,j,S);f({x:e,y:t}),E=requestAnimationFrame(ee)}let te={...N,nodeId:i,type:A,position:N.position},ne=c.get(i),z={inProgress:!0,isValid:null,from:Uo(ne,te,B.Left,!0),fromHandle:te,fromPosition:te.position,fromNode:ne,to:P,toHandle:null,toPosition:Ca[te.position],toNode:null,pointer:P};function re(){M=!0,y(z),m?.(e,{nodeId:i,handleId:r,handleType:A})}C===0&&re();function ie(e){if(!M){let{x:t,y:n}=xo(e),r=t-O,i=n-k;if(!(r*r+i*i>C*C))return;re()}if(!x()||!te){ae(e);return}let a=b();P=xo(e,j),D=ys(to(P,a,!1,[1,1]),n,c,te),F||=(ee(),!0);let s=Ts(e,{handle:D,connectionMode:t,fromNodeId:i,fromHandleId:r,fromType:o?`target`:`source`,isValidConnection:_,doc:T,lib:l,flowId:d,nodeLookup:c});R=s.handleDomNode,I=s.connection,L=Ss(!!D,s.isValid);let u=c.get(i),f=u?Uo(u,te,B.Left,!0):z.from,p={...z,from:f,isValid:L,to:s.toHandle&&L?no({x:s.toHandle.x,y:s.toHandle.y},a):P,toHandle:s.toHandle,toPosition:L&&s.toHandle?s.toHandle.position:Ca[te.position],toNode:s.toHandle?c.get(s.toHandle.nodeId):null,pointer:P};y(p),z=p}function ae(e){if(!(`touches`in e&&e.touches.length>0)){if(M){(D||R)&&I&&L&&h?.(I);let{inProgress:t,...n}=z,r={...n,toPosition:z.toHandle?z.toPosition:null};g?.(e,r),a&&v?.(e,r)}p(),cancelAnimationFrame(E),F=!1,L=!1,I=null,R=null,T.removeEventListener(`mousemove`,ie),T.removeEventListener(`mouseup`,ae),T.removeEventListener(`touchmove`,ie),T.removeEventListener(`touchend`,ae)}}T.addEventListener(`mousemove`,ie),T.addEventListener(`mouseup`,ae),T.addEventListener(`touchmove`,ie),T.addEventListener(`touchend`,ae)}function Ts(e,{handle:t,connectionMode:n,fromNodeId:r,fromHandleId:i,fromType:a,doc:o,lib:s,flowId:c,isValidConnection:l=Cs,nodeLookup:u}){let d=a===`target`,f=t?o.querySelector(`.${s}-flow__handle[data-id="${c}-${t?.nodeId}-${t?.id}-${t?.type}"]`):null,{x:p,y:m}=xo(e),h=o.elementFromPoint(p,m),g=h?.classList.contains(`${s}-flow__handle`)?h:f,_={handleDomNode:g,isValid:!1,connection:null,toHandle:null};if(g){let e=xs(void 0,g),t=g.getAttribute(`data-nodeid`),a=g.getAttribute(`data-handleid`),o=g.classList.contains(`connectable`),s=g.classList.contains(`connectableend`);if(!t||!e)return _;let c={source:d?t:r,sourceHandle:d?a:i,target:d?r:t,targetHandle:d?i:a};_.connection=c,_.isValid=o&&s&&(n===_a.Strict?d&&e===`source`||!d&&e===`target`:t!==r||a!==i)&&l(c),_.toHandle=bs(t,e,a,u,n,!0)}return _}var Es={onPointerDown:ws,isValid:Ts};function Ds({domNode:e,panZoom:t,getTransform:n,getViewScale:r}){let i=Nt(e);function a({translateExtent:e,width:a,height:o,zoomStep:s=1,pannable:c=!0,zoomable:l=!0,inversePan:u=!1}){let d=e=>{if(e.sourceEvent.type!==`wheel`||!t)return;let r=n(),i=e.sourceEvent.ctrlKey&&so()?10:1,a=-e.sourceEvent.deltaY*(e.sourceEvent.deltaMode===1?.05:e.sourceEvent.deltaMode?1:.002)*s,o=r[2]*2**(a*i);t.scaleTo(o)},f=[0,0],p=fa().on(`start`,e=>{(e.sourceEvent.type===`mousedown`||e.sourceEvent.type===`touchstart`)&&(f=[e.sourceEvent.clientX??e.sourceEvent.touches[0].clientX,e.sourceEvent.clientY??e.sourceEvent.touches[0].clientY])}).on(`zoom`,c?i=>{let s=n();if(i.sourceEvent.type!==`mousemove`&&i.sourceEvent.type!==`touchmove`||!t)return;let c=[i.sourceEvent.clientX??i.sourceEvent.touches[0].clientX,i.sourceEvent.clientY??i.sourceEvent.touches[0].clientY],l=[c[0]-f[0],c[1]-f[1]];f=c;let d=r()*Math.max(s[2],Math.log(s[2]))*(u?-1:1),p={x:s[0]-l[0]*d,y:s[1]-l[1]*d},m=[[0,0],[a,o]];t.setViewportConstrained({x:p.x,y:p.y,zoom:s[2]},m,e)}:null).on(`zoom.wheel`,l?d:null);i.call(p,{})}function o(){i.on(`zoom`,null)}return{update:a,destroy:o,pointer:Ft}}var Os=e=>({x:e.x,y:e.y,zoom:e.k}),ks=({x:e,y:t,zoom:n})=>na.translate(e,t).scale(n),As=(e,t)=>e.target.closest(`.${t}`),js=(e,t)=>t===2&&Array.isArray(e)&&e.includes(2),Ms=e=>((e*=2)<=1?e*e*e:(e-=2)*e*e+2)/2,Ns=(e,t=0,n=Ms,r=()=>{})=>{let i=typeof t==`number`&&t>0;return i||r(),i?e.transition().duration(t).ease(n).on(`end`,r):e},Ps=e=>{let t=e.ctrlKey&&so()?10:1;return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*t};function Fs({zoomPanValues:e,noWheelClassName:t,d3Selection:n,d3Zoom:r,panOnScrollMode:i,panOnScrollSpeed:a,zoomOnPinch:o,onPanZoomStart:s,onPanZoom:c,onPanZoomEnd:l}){return u=>{if(As(u,t))return u.ctrlKey&&u.preventDefault(),!1;u.preventDefault(),u.stopImmediatePropagation();let d=n.property(`__zoom`).k||1;if(u.ctrlKey&&o){let e=Ft(u),t=d*2**Ps(u);r.scaleTo(n,t,e,u);return}let f=u.deltaMode===1?20:1,p=i===va.Vertical?0:u.deltaX*f,m=i===va.Horizontal?0:u.deltaY*f;!so()&&u.shiftKey&&i!==va.Vertical&&(p=u.deltaY*f,m=0),r.translateBy(n,-(p/d)*a,-(m/d)*a,{internal:!0});let h=Os(n.property(`__zoom`));clearTimeout(e.panScrollTimeout),e.isPanScrolling?(c?.(u,h),e.panScrollTimeout=setTimeout(()=>{l?.(u,h),e.isPanScrolling=!1},150)):(e.isPanScrolling=!0,s?.(u,h))}}function Is({noWheelClassName:e,preventScrolling:t,d3ZoomHandler:n}){return function(r,i){let a=r.type===`wheel`,o=!t&&a&&!r.ctrlKey,s=As(r,e);if(r.ctrlKey&&a&&s&&r.preventDefault(),o||s)return null;r.preventDefault(),n.call(this,r,i)}}function Ls({zoomPanValues:e,onDraggingChange:t,onPanZoomStart:n}){return r=>{if(r.sourceEvent?.internal)return;let i=Os(r.transform);e.mouseButton=r.sourceEvent?.button||0,e.isZoomingOrPanning=!0,e.prevViewport=i,r.sourceEvent?.type===`mousedown`&&t(!0),n&&n?.(r.sourceEvent,i)}}function Rs({zoomPanValues:e,panOnDrag:t,onPaneContextMenu:n,onTransformChange:r,onPanZoom:i}){return a=>{e.usedRightMouseButton=!!(n&&js(t,e.mouseButton??0)),a.sourceEvent?.sync||r([a.transform.x,a.transform.y,a.transform.k]),i&&!a.sourceEvent?.internal&&i?.(a.sourceEvent,Os(a.transform))}}function zs({zoomPanValues:e,panOnDrag:t,panOnScroll:n,onDraggingChange:r,onPanZoomEnd:i,onPaneContextMenu:a}){return o=>{if(!o.sourceEvent?.internal&&(e.isZoomingOrPanning=!1,a&&js(t,e.mouseButton??0)&&!e.usedRightMouseButton&&o.sourceEvent&&a(o.sourceEvent),e.usedRightMouseButton=!1,r(!1),i)){let t=Os(o.transform);e.prevViewport=t,clearTimeout(e.timerId),e.timerId=setTimeout(()=>{i?.(o.sourceEvent,t)},n?150:0)}}}function Bs({zoomActivationKeyPressed:e,zoomOnScroll:t,zoomOnPinch:n,panOnDrag:r,panOnScroll:i,zoomOnDoubleClick:a,userSelectionActive:o,noWheelClassName:s,noPanClassName:c,lib:l,connectionInProgress:u}){return d=>{let f=e||t,p=n&&d.ctrlKey,m=d.type===`wheel`;if(d.button===1&&d.type===`mousedown`&&(As(d,`${l}-flow__node`)||As(d,`${l}-flow__edge`)))return!0;if(!r&&!f&&!i&&!a&&!n||o||u&&!m||As(d,s)&&m||As(d,c)&&(!m||i&&m&&!e)||!n&&d.ctrlKey&&m)return!1;if(!n&&d.type===`touchstart`&&d.touches?.length>1)return d.preventDefault(),!1;if(!f&&!i&&!p&&m||!r&&(d.type===`mousedown`||d.type===`touchstart`)||Array.isArray(r)&&!r.includes(d.button)&&d.type===`mousedown`)return!1;let h=Array.isArray(r)&&r.includes(d.button)||!d.button||d.button<=1;return(!d.ctrlKey||m)&&h}}function Vs({domNode:e,minZoom:t,maxZoom:n,translateExtent:r,viewport:i,onPanZoom:a,onPanZoomStart:o,onPanZoomEnd:s,onDraggingChange:c}){let l={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},u=e.getBoundingClientRect(),d=fa().scaleExtent([t,n]).translateExtent(r),f=Nt(e).call(d);v({x:i.x,y:i.y,zoom:za(i.zoom,t,n)},[[0,0],[u.width,u.height]],r);let p=f.on(`wheel.zoom`),m=f.on(`dblclick.zoom`);d.wheelDelta(Ps);async function h(e,t){return f?new Promise(n=>{d?.interpolate(t?.interpolate===`linear`?Qn:fr).transform(Ns(f,t?.duration,t?.ease,()=>n(!0)),e)}):!1}function g({noWheelClassName:e,noPanClassName:t,onPaneContextMenu:n,userSelectionActive:r,panOnScroll:i,panOnDrag:u,panOnScrollMode:h,panOnScrollSpeed:g,preventScrolling:v,zoomOnPinch:y,zoomOnScroll:b,zoomOnDoubleClick:x,zoomActivationKeyPressed:S,lib:C,onTransformChange:w,connectionInProgress:T,paneClickDistance:E,selectionOnDrag:D}){r&&!l.isZoomingOrPanning&&_();let O=i&&!S&&!r;d.clickDistance(D?1/0:!Qa(E)||E<0?0:E);let k=O?Fs({zoomPanValues:l,noWheelClassName:e,d3Selection:f,d3Zoom:d,panOnScrollMode:h,panOnScrollSpeed:g,zoomOnPinch:y,onPanZoomStart:o,onPanZoom:a,onPanZoomEnd:s}):Is({noWheelClassName:e,preventScrolling:v,d3ZoomHandler:p});f.on(`wheel.zoom`,k,{passive:!1});let A=Ls({zoomPanValues:l,onDraggingChange:c,onPanZoomStart:o});d.on(`start`,A);let j=Rs({zoomPanValues:l,panOnDrag:u,onPaneContextMenu:!!n,onPanZoom:a,onTransformChange:w});d.on(`zoom`,j);let M=zs({zoomPanValues:l,panOnDrag:u,panOnScroll:i,onPaneContextMenu:n,onPanZoomEnd:s,onDraggingChange:c});d.on(`end`,M);let N=Bs({zoomActivationKeyPressed:S,panOnDrag:u,zoomOnScroll:b,panOnScroll:i,zoomOnDoubleClick:x,zoomOnPinch:y,userSelectionActive:r,noPanClassName:t,noWheelClassName:e,lib:C,connectionInProgress:T});d.filter(N),x?f.on(`dblclick.zoom`,m):f.on(`dblclick.zoom`,null)}function _(){d.on(`zoom`,null)}async function v(e,t,n){let r=ks(e),i=d?.constrain()(r,t,n);return i&&await h(i),i}async function y(e,t){let n=ks(e);return await h(n,t),n}function b(e){if(f){let t=ks(e),n=f.property(`__zoom`);(n.k!==e.zoom||n.x!==e.x||n.y!==e.y)&&d?.transform(f,t,null,{sync:!0})}}function x(){let e=f?ra(f.node()):{x:0,y:0,k:1};return{x:e.x,y:e.y,zoom:e.k}}async function S(e,t){return f?new Promise(n=>{d?.interpolate(t?.interpolate===`linear`?Qn:fr).scaleTo(Ns(f,t?.duration,t?.ease,()=>n(!0)),e)}):!1}async function C(e,t){return f?new Promise(n=>{d?.interpolate(t?.interpolate===`linear`?Qn:fr).scaleBy(Ns(f,t?.duration,t?.ease,()=>n(!0)),e)}):!1}function w(e){d?.scaleExtent(e)}function T(e){d?.translateExtent(e)}function E(e){let t=!Qa(e)||e<0?0:e;d?.clickDistance(t)}return{update:g,destroy:_,setViewport:y,setViewportConstrained:v,getViewport:x,scaleTo:S,scaleBy:C,setScaleExtent:w,setTranslateExtent:T,syncViewport:b,setClickDistance:E}}var Hs;(function(e){e.Line=`line`,e.Handle=`handle`})(Hs||={});function Us({width:e,prevWidth:t,height:n,prevHeight:r,affectsX:i,affectsY:a}){let o=e-t,s=n-r,c=[o>0?1:o<0?-1:0,s>0?1:s<0?-1:0];return o&&i&&(c[0]*=-1),s&&a&&(c[1]*=-1),c}function Ws(e){return{isHorizontal:e.includes(`right`)||e.includes(`left`),isVertical:e.includes(`bottom`)||e.includes(`top`),affectsX:e.includes(`left`),affectsY:e.includes(`top`)}}function Gs(e,t){return Math.max(0,t-e)}function Ks(e,t){return Math.max(0,e-t)}function qs(e,t,n){return Math.max(0,t-e,e-n)}function Js(e,t){return e?!t:t}function Ys(e,t,n,r,i,a,o,s){let{affectsX:c,affectsY:l}=t,{isHorizontal:u,isVertical:d}=t,f=u&&d,{xSnapped:p,ySnapped:m}=n,{minWidth:h,maxWidth:g,minHeight:_,maxHeight:v}=r,{x:y,y:b,width:x,height:S,aspectRatio:C}=e,w=Math.floor(u?p-e.pointerX:0),T=Math.floor(d?m-e.pointerY:0),E=x+(c?-w:w),D=S+(l?-T:T),O=-a[0]*x,k=-a[1]*S,A=qs(E,h,g),j=qs(D,_,v);if(o){let e=0,t=0;c&&w<0?e=Gs(y+w+O,o[0][0]):!c&&w>0&&(e=Ks(y+E+O,o[1][0])),l&&T<0?t=Gs(b+T+k,o[0][1]):!l&&T>0&&(t=Ks(b+D+k,o[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(s){let e=0,t=0;c&&w>0?e=Ks(y+w,s[0][0]):!c&&w<0&&(e=Gs(y+E,s[1][0])),l&&T>0?t=Ks(b+T,s[0][1]):!l&&T<0&&(t=Gs(b+D,s[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(i){if(u){let e=qs(E/C,_,v)*C;if(A=Math.max(A,e),o){let e=0;e=!c&&!l||c&&!l&&f?Ks(b+k+E/C,o[1][1])*C:Gs(b+k+(c?w:-w)/C,o[0][1])*C,A=Math.max(A,e)}if(s){let e=0;e=!c&&!l||c&&!l&&f?Gs(b+E/C,s[1][1])*C:Ks(b+(c?w:-w)/C,s[0][1])*C,A=Math.max(A,e)}}if(d){let e=qs(D*C,h,g)/C;if(j=Math.max(j,e),o){let e=0;e=!c&&!l||l&&!c&&f?Ks(y+D*C+O,o[1][0])/C:Gs(y+(l?T:-T)*C+O,o[0][0])/C,j=Math.max(j,e)}if(s){let e=0;e=!c&&!l||l&&!c&&f?Gs(y+D*C,s[1][0])/C:Ks(y+(l?T:-T)*C,s[0][0])/C,j=Math.max(j,e)}}}T+=T<0?j:-j,w+=w<0?A:-A,i&&(f?E>D*C?T=(Js(c,l)?-w:w)/C:w=(Js(c,l)?-T:T)*C:u?(T=w/C,l=c):(w=T*C,c=l));let M=c?y+w:y,N=l?b+T:b;return{width:x+(c?-w:w),height:S+(l?-T:T),x:a[0]*w*(c?-1:1)+M,y:a[1]*T*(l?-1:1)+N}}var Xs={width:0,height:0,x:0,y:0},Zs={...Xs,pointerX:0,pointerY:0,aspectRatio:1};function Qs(e,t,n){let r=t.position.x+e.position.x,i=t.position.y+e.position.y,a=e.measured.width??0,o=e.measured.height??0,s=n[0]*a,c=n[1]*o;return[[r-s,i-c],[r+a-s,i+o-c]]}function $s({domNode:e,nodeId:t,getStoreItems:n,onChange:r,onEnd:i}){let a=Nt(e),o={controlDirection:Ws(`bottom-right`),boundaries:{minWidth:0,minHeight:0,maxWidth:Number.MAX_VALUE,maxHeight:Number.MAX_VALUE},resizeDirection:void 0,keepAspectRatio:!1};function s({controlPosition:e,boundaries:s,keepAspectRatio:c,resizeDirection:l,onResizeStart:u,onResize:d,onResizeEnd:f,shouldResize:p}){let m={...Xs},h={...Zs};o={boundaries:s,resizeDirection:l,keepAspectRatio:c,controlDirection:Ws(e)};let g,_=null,v=[],y,b,x,S=!1,C=Jt().on(`start`,e=>{let{nodeLookup:r,transform:i,snapGrid:a,snapToGrid:o,nodeOrigin:s,paneDomNode:c}=n();if(g=r.get(t),!g)return;_=c?.getBoundingClientRect()??null;let{xSnapped:l,ySnapped:d}=ho(e.sourceEvent,{transform:i,snapGrid:a,snapToGrid:o,containerBounds:_});m={width:g.measured.width??0,height:g.measured.height??0,x:g.position.x??0,y:g.position.y??0},h={...m,pointerX:l,pointerY:d,aspectRatio:m.width/m.height},y=void 0,b=co(g.extent)?g.extent:void 0,g.parentId&&(g.extent===`parent`||g.expandParent)&&(y=r.get(g.parentId)),y&&g.extent===`parent`&&(b=[[0,0],[y.measured.width,y.measured.height]]),v=[],x=void 0;for(let[e,n]of r)if(n.parentId===t&&(v.push({id:e,position:{...n.position},extent:n.extent}),n.extent===`parent`||n.expandParent)){let e=Qs(n,g,n.origin??s);x=x?[[Math.min(e[0][0],x[0][0]),Math.min(e[0][1],x[0][1])],[Math.max(e[1][0],x[1][0]),Math.max(e[1][1],x[1][1])]]:e}u?.(e,{...m})}).on(`drag`,e=>{let{transform:t,snapGrid:i,snapToGrid:a,nodeOrigin:s}=n(),c=ho(e.sourceEvent,{transform:t,snapGrid:i,snapToGrid:a,containerBounds:_}),l=[];if(!g)return;let{x:u,y:f,width:C,height:w}=m,T={},E=g.origin??s,{width:D,height:O,x:k,y:A}=Ys(h,o.controlDirection,c,o.boundaries,o.keepAspectRatio,E,b,x),j=D!==C,M=O!==w,N=k!==u&&j,P=A!==f&&M;if(!N&&!P&&!j&&!M)return;if((N||P||E[0]===1||E[1]===1)&&(T.x=N?k:m.x,T.y=P?A:m.y,m.x=T.x,m.y=T.y,v.length>0)){let e=k-u,t=A-f;for(let n of v)n.position={x:n.position.x-e+E[0]*(D-C),y:n.position.y-t+E[1]*(O-w)},l.push(n)}if((j||M)&&(T.width=j&&(!o.resizeDirection||o.resizeDirection===`horizontal`)?D:m.width,T.height=M&&(!o.resizeDirection||o.resizeDirection===`vertical`)?O:m.height,m.width=T.width,m.height=T.height),y&&g.expandParent){let e=E[0]*(T.width??0);T.x&&T.x{S&&=(f?.(e,{...m}),i?.({...m}),!1)});a.call(C)}function c(){a.on(`.drag`,null)}return{update:s,destroy:c}}var ec=t((e=>{var t=r();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:n,a=t.useState,o=t.useEffect,s=t.useLayoutEffect,c=t.useDebugValue;function l(e,t){var n=t(),r=a({inst:{value:n,getSnapshot:t}}),i=r[0].inst,l=r[1];return s(function(){i.value=n,i.getSnapshot=t,u(i)&&l({inst:i})},[e,n,t]),o(function(){return u(i)&&l({inst:i}),e(function(){u(i)&&l({inst:i})})},[e]),c(n),n}function u(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!i(e,n)}catch{return!0}}function d(e,t){return t()}var f=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?d:l;e.useSyncExternalStore=t.useSyncExternalStore===void 0?f:t.useSyncExternalStore})),tc=t(((e,t)=>{t.exports=ec()})),nc=t((e=>{var t=r(),n=tc();function i(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var a=typeof Object.is==`function`?Object.is:i,o=n.useSyncExternalStore,s=t.useRef,c=t.useEffect,l=t.useMemo,u=t.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,i){var d=s(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=l(function(){function e(e){if(!o){if(o=!0,s=e,e=r(e),i!==void 0&&f.hasValue){var t=f.value;if(i(t,e))return c=t}return c=e}if(t=c,a(s,e))return t;var n=r(e);return i!==void 0&&i(t,n)?(s=e,t):(s=e,c=n)}var o=!1,s,c,l=n===void 0?null:n;return[function(){return e(t())},l===null?void 0:function(){return e(l())}]},[t,n,r,i]);var p=o(e,d[0],d[1]);return c(function(){f.hasValue=!0,f.value=p},[p]),u(p),p}})),rc=e(t(((e,t)=>{t.exports=nc()}))(),1),ic=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e)),destroy:()=>{n.clear()}},o=t=e(r,i,a);return a},ac=e=>e?ic(e):ic,{useDebugValue:oc}=f.default,{useSyncExternalStoreWithSelector:sc}=rc.default,cc=e=>e;function lc(e,t=cc,n){let r=sc(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return oc(r),r}var uc=(e,t)=>{let n=ac(e),r=(e,r=t)=>lc(n,e,r);return Object.assign(r,n),r},dc=(e,t)=>e?uc(e,t):uc;function fc(e,t){if(Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map){if(e.size!==t.size)return!1;for(let[n,r]of e)if(!Object.is(r,t.get(n)))return!1;return!0}if(e instanceof Set&&t instanceof Set){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n)if(!Object.prototype.hasOwnProperty.call(t,r)||!Object.is(e[r],t[r]))return!1;return!0}var pc=e(s()),mc=(0,f.createContext)(null),hc=mc.Provider,gc=pa.error001(`react`);function H(e,t){let n=(0,f.useContext)(mc);if(n===null)throw Error(gc);return lc(n,e,t)}function _c(){let e=(0,f.useContext)(mc);if(e===null)throw Error(gc);return(0,f.useMemo)(()=>({getState:e.getState,setState:e.setState,subscribe:e.subscribe}),[e])}var vc={display:`none`},yc={position:`absolute`,width:1,height:1,margin:-1,border:0,padding:0,overflow:`hidden`,clip:`rect(0px, 0px, 0px, 0px)`,clipPath:`inset(100%)`},bc=`react-flow__node-desc`,xc=`react-flow__edge-desc`,Sc=`react-flow__aria-live`,Cc=e=>e.ariaLiveMessage,wc=e=>e.ariaLabelConfig;function Tc({rfId:e}){let t=H(Cc);return(0,p.jsx)(`div`,{id:`${Sc}-${e}`,"aria-live":`assertive`,"aria-atomic":`true`,style:yc,children:t})}function Ec({rfId:e,disableKeyboardA11y:t}){let n=H(wc);return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(`div`,{id:`${bc}-${e}`,style:vc,children:t?n[`node.a11yDescription.default`]:n[`node.a11yDescription.keyboardDisabled`]}),(0,p.jsx)(`div`,{id:`${xc}-${e}`,style:vc,children:n[`edge.a11yDescription.default`]}),!t&&(0,p.jsx)(Tc,{rfId:e})]})}var Dc=(0,f.forwardRef)(({position:e=`top-left`,children:t,className:n,style:r,...i},a)=>(0,p.jsx)(`div`,{className:m([`react-flow__panel`,n,...`${e}`.split(`-`)]),style:r,ref:a,...i,children:t}));Dc.displayName=`Panel`;function Oc({proOptions:e,position:t=`bottom-right`}){return e?.hideAttribution?null:(0,p.jsx)(Dc,{position:t,className:`react-flow__attribution`,"data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev`,children:(0,p.jsx)(`a`,{href:`https://reactflow.dev`,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`React Flow attribution`,children:`React Flow`})})}var kc=e=>{let t=[],n=[];for(let[,n]of e.nodeLookup)n.selected&&t.push(n.internals.userNode);for(let[,t]of e.edgeLookup)t.selected&&n.push(t);return{selectedNodes:t,selectedEdges:n}},Ac=e=>e.id;function jc(e,t){return fc(e.selectedNodes.map(Ac),t.selectedNodes.map(Ac))&&fc(e.selectedEdges.map(Ac),t.selectedEdges.map(Ac))}function Mc({onSelectionChange:e}){let t=_c(),{selectedNodes:n,selectedEdges:r}=H(kc,jc);return(0,f.useEffect)(()=>{let i={nodes:n,edges:r};e?.(i),t.getState().onSelectionChangeHandlers.forEach(e=>e(i))},[n,r,e]),null}var Nc=e=>!!e.onSelectionChangeHandlers;function Pc({onSelectionChange:e}){let t=H(Nc);return e||t?(0,p.jsx)(Mc,{onSelectionChange:e}):null}var Fc=[0,0],Ic={x:0,y:0,zoom:1},Lc=[...`nodes.edges.defaultNodes.defaultEdges.onConnect.onConnectStart.onConnectEnd.onClickConnectStart.onClickConnectEnd.nodesDraggable.autoPanOnNodeFocus.nodesConnectable.nodesFocusable.edgesFocusable.edgesReconnectable.elevateNodesOnSelect.elevateEdgesOnSelect.minZoom.maxZoom.nodeExtent.onNodesChange.onEdgesChange.elementsSelectable.connectionMode.snapGrid.snapToGrid.translateExtent.connectOnClick.defaultEdgeOptions.fitView.fitViewOptions.onNodesDelete.onEdgesDelete.onDelete.onNodeDrag.onNodeDragStart.onNodeDragStop.onSelectionDrag.onSelectionDragStart.onSelectionDragStop.onMoveStart.onMove.onMoveEnd.noPanClassName.nodeOrigin.autoPanOnConnect.autoPanOnNodeDrag.onError.connectionRadius.isValidConnection.selectNodesOnDrag.nodeDragThreshold.connectionDragThreshold.onBeforeDelete.debug.autoPanSpeed.ariaLabelConfig.zIndexMode`.split(`.`),`rfId`],Rc=e=>({setNodes:e.setNodes,setEdges:e.setEdges,setMinZoom:e.setMinZoom,setMaxZoom:e.setMaxZoom,setTranslateExtent:e.setTranslateExtent,setNodeExtent:e.setNodeExtent,reset:e.reset,setDefaultNodesAndEdges:e.setDefaultNodesAndEdges}),zc={translateExtent:ma,nodeOrigin:Fc,minZoom:.5,maxZoom:2,elementsSelectable:!0,noPanClassName:`nopan`,rfId:`1`};function Bc(e){let{setNodes:t,setEdges:n,setMinZoom:r,setMaxZoom:i,setTranslateExtent:a,setNodeExtent:o,reset:s,setDefaultNodesAndEdges:c}=H(Rc,fc),l=_c();(0,f.useEffect)(()=>(c(e.defaultNodes,e.defaultEdges),()=>{u.current=zc,s()}),[]);let u=(0,f.useRef)(zc);return(0,f.useEffect)(()=>{for(let s of Lc){let c=e[s];c!==u.current[s]&&e[s]!==void 0&&(s===`nodes`?t(c):s===`edges`?n(c):s===`minZoom`?r(c):s===`maxZoom`?i(c):s===`translateExtent`?a(c):s===`nodeExtent`?o(c):s===`ariaLabelConfig`?l.setState({ariaLabelConfig:mo(c)}):s===`fitView`?l.setState({fitViewQueued:c}):s===`fitViewOptions`?l.setState({fitViewOptions:c}):l.setState({[s]:c}))}u.current=e},Lc.map(t=>e[t])),null}function Vc(){return typeof window>`u`||!window.matchMedia?null:window.matchMedia(`(prefers-color-scheme: dark)`)}function Hc(e){let[t,n]=(0,f.useState)(e===`system`?null:e);return(0,f.useEffect)(()=>{if(e!==`system`){n(e);return}let t=Vc(),r=()=>n(t?.matches?`dark`:`light`);return r(),t?.addEventListener(`change`,r),()=>{t?.removeEventListener(`change`,r)}},[e]),t===null?Vc()?.matches?`dark`:`light`:t}var Uc=typeof document<`u`?document:null;function Wc(e=null,t={target:Uc,actInsideInputWithModifier:!0}){let[n,r]=(0,f.useState)(!1),i=(0,f.useRef)(!1),a=(0,f.useRef)(new Set([])),[o,s]=(0,f.useMemo)(()=>{if(e!==null){let t=(Array.isArray(e)?e:[e]).filter(e=>typeof e==`string`).map(e=>e.replace(`+`,` -`).replace(` - -`,` -+`).split(` -`));return[t,t.reduce((e,t)=>e.concat(...t),[])]}return[[],[]]},[e]);return(0,f.useEffect)(()=>{let n=t?.target??Uc,c=t?.actInsideInputWithModifier??!0;if(e!==null){let e=e=>{if(i.current=e.ctrlKey||e.metaKey||e.shiftKey||e.altKey,(!i.current||i.current&&!c)&&yo(e))return!1;let n=Kc(e.code,s);if(a.current.add(e[n]),Gc(o,a.current,!1)){let n=e.composedPath?.()?.[0]||e.target,a=n?.nodeName===`BUTTON`||n?.nodeName===`A`;t.preventDefault!==!1&&(i.current||!a)&&e.preventDefault(),r(!0)}},l=e=>{let t=Kc(e.code,s);Gc(o,a.current,!0)?(r(!1),a.current.clear()):a.current.delete(e[t]),e.key===`Meta`&&a.current.clear(),i.current=!1},u=()=>{a.current.clear(),r(!1)};return n?.addEventListener(`keydown`,e),n?.addEventListener(`keyup`,l),window.addEventListener(`blur`,u),window.addEventListener(`contextmenu`,u),()=>{n?.removeEventListener(`keydown`,e),n?.removeEventListener(`keyup`,l),window.removeEventListener(`blur`,u),window.removeEventListener(`contextmenu`,u)}}},[e,r]),n}function Gc(e,t,n){return e.filter(e=>n||e.length===t.size).some(e=>e.every(e=>t.has(e)))}function Kc(e,t){return t.includes(e)?`code`:`key`}var qc=()=>{let e=_c();return(0,f.useMemo)(()=>({zoomIn:async t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1.2,t):!1},zoomOut:async t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1/1.2,t):!1},zoomTo:async(t,n)=>{let{panZoom:r}=e.getState();return r?r.scaleTo(t,n):!1},getZoom:()=>e.getState().transform[2],setViewport:async(t,n)=>{let{transform:[r,i,a],panZoom:o}=e.getState();return o?(await o.setViewport({x:t.x??r,y:t.y??i,zoom:t.zoom??a},n),!0):!1},getViewport:()=>{let[t,n,r]=e.getState().transform;return{x:t,y:n,zoom:r}},setCenter:async(t,n,r)=>e.getState().setCenter(t,n,r),fitBounds:async(t,n)=>{let{width:r,height:i,minZoom:a,maxZoom:o,panZoom:s}=e.getState(),c=oo(t,r,i,a,o,n?.padding??.1);return s?(await s.setViewport(c,{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),!0):!1},screenToFlowPosition:(t,n={})=>{let{transform:r,snapGrid:i,snapToGrid:a,domNode:o}=e.getState();if(!o)return t;let{x:s,y:c}=o.getBoundingClientRect(),l={x:t.x-s,y:t.y-c},u=n.snapGrid??i;return to(l,r,n.snapToGrid??a,u)},flowToScreenPosition:t=>{let{transform:n,domNode:r}=e.getState();if(!r)return t;let{x:i,y:a}=r.getBoundingClientRect(),o=no(t,n);return{x:o.x+i,y:o.y+a}}}),[])};function Jc(e,t){let n=[],r=new Map,i=[];for(let t of e)if(t.type===`add`){i.push(t);continue}else if(t.type===`remove`||t.type===`replace`)r.set(t.id,[t]);else{let e=r.get(t.id);e?e.push(t):r.set(t.id,[t])}for(let e of t){let t=r.get(e.id);if(!t){n.push(e);continue}if(t[0].type===`remove`)continue;if(t[0].type===`replace`){n.push({...t[0].item});continue}let i={...e};for(let e of t)Yc(e,i);n.push(i)}return i.length&&i.forEach(e=>{e.index===void 0?n.push({...e.item}):n.splice(e.index,0,{...e.item})}),n}function Yc(e,t){switch(e.type){case`select`:t.selected=e.selected;break;case`position`:e.position!==void 0&&(t.position=e.position),e.dragging!==void 0&&(t.dragging=e.dragging);break;case`dimensions`:e.dimensions!==void 0&&(t.measured={...e.dimensions},e.setAttributes&&((e.setAttributes===!0||e.setAttributes===`width`)&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes===`height`)&&(t.height=e.dimensions.height))),typeof e.resizing==`boolean`&&(t.resizing=e.resizing);break}}function Xc(e,t){return Jc(e,t)}function Zc(e,t){return Jc(e,t)}function Qc(e,t){return{id:e,type:`select`,selected:t}}function $c(e,t=new Set,n=!1){let r=[];for(let[i,a]of e){let e=t.has(i);!(a.selected===void 0&&!e)&&a.selected!==e&&(n&&(a.selected=e),r.push(Qc(a.id,e)))}return r}function el({items:e=[],lookup:t}){let n=[],r=new Map(e.map(e=>[e.id,e]));for(let[r,i]of e.entries()){let e=t.get(i.id),a=e?.internals?.userNode??e;a!==void 0&&a!==i&&n.push({id:i.id,item:i,type:`replace`}),a===void 0&&n.push({item:i,type:`add`,index:r})}for(let[e]of t)r.get(e)===void 0&&n.push({id:e,type:`remove`});return n}function tl(e){return{id:e.id,type:`remove`}}var nl=$a(`React Flow`,`https://reactflow.dev/`);function rl(e,t,n={}){return Mo(e,t,{...n,onError:n.onError??nl})}var il=e=>Ea(e),al=e=>Ta(e);function ol(e){return(0,f.forwardRef)(e)}var sl=typeof window<`u`?f.useLayoutEffect:f.useEffect;function cl(e){let[t,n]=(0,f.useState)(BigInt(0)),[r]=(0,f.useState)(()=>ll(()=>n(e=>e+BigInt(1))));return sl(()=>{let t=r.get();t.length&&(e(t),r.reset())},[t]),r}function ll(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:n=>{t.push(n),e()}}}var ul=(0,f.createContext)(null);function dl({children:e}){let t=_c(),n=cl((0,f.useCallback)(e=>{let{nodes:n=[],setNodes:r,hasDefaultNodes:i,onNodesChange:a,nodeLookup:o,fitViewQueued:s,onNodesChangeMiddlewareMap:c}=t.getState(),l=n;for(let t of e)l=typeof t==`function`?t(l):t;let u=el({items:l,lookup:o});for(let e of c.values())u=e(u);i&&r(l),u.length>0?a?.(u):s&&window.requestAnimationFrame(()=>{let{fitViewQueued:e,nodes:n,setNodes:r}=t.getState();e&&r(n)})},[])),r=cl((0,f.useCallback)(e=>{let{edges:n=[],setEdges:r,hasDefaultEdges:i,onEdgesChange:a,edgeLookup:o}=t.getState(),s=n;for(let t of e)s=typeof t==`function`?t(s):t;i?r(s):a&&a(el({items:s,lookup:o}))},[])),i=(0,f.useMemo)(()=>({nodeQueue:n,edgeQueue:r}),[]);return(0,p.jsx)(ul.Provider,{value:i,children:e})}function fl(){let e=(0,f.useContext)(ul);if(!e)throw Error(`useBatchContext must be used within a BatchProvider`);return e}var pl=e=>!!e.panZoom;function ml(){let e=qc(),t=_c(),n=fl(),r=H(pl),i=(0,f.useMemo)(()=>{let e=e=>t.getState().nodeLookup.get(e),r=e=>{n.nodeQueue.push(e)},i=e=>{n.edgeQueue.push(e)},a=e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState(),i=il(e)?e:n.get(e.id),a=i.parentId?fo(i.position,i.measured,i.parentId,n,r):i.position;return qa({...i,position:a,width:i.measured?.width??i.width,height:i.measured?.height??i.height})},o=(e,t,n={replace:!1})=>{r(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&il(e)?e:{...r,...e}}return r}))},s=(e,t,n={replace:!1})=>{i(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&al(e)?e:{...r,...e}}return r}))};return{getNodes:()=>t.getState().nodes.map(e=>({...e})),getNode:t=>e(t)?.internals.userNode,getInternalNode:e,getEdges:()=>{let{edges:e=[]}=t.getState();return e.map(e=>({...e}))},getEdge:e=>t.getState().edgeLookup.get(e),setNodes:r,setEdges:i,addNodes:e=>{let t=Array.isArray(e)?e:[e];n.nodeQueue.push(e=>[...e,...t])},addEdges:e=>{let t=Array.isArray(e)?e:[e];n.edgeQueue.push(e=>[...e,...t])},toObject:()=>{let{nodes:e=[],edges:n=[],transform:r}=t.getState(),[i,a,o]=r;return{nodes:e.map(e=>({...e})),edges:n.map(e=>({...e})),viewport:{x:i,y:a,zoom:o}}},deleteElements:async({nodes:e=[],edges:n=[]})=>{let{nodes:r,edges:i,onNodesDelete:a,onEdgesDelete:o,triggerNodeChanges:s,triggerEdgeChanges:c,onDelete:l,onBeforeDelete:u}=t.getState(),{nodes:d,edges:f}=await Ra({nodesToRemove:e,edgesToRemove:n,nodes:r,edges:i,onBeforeDelete:u}),p=f.length>0,m=d.length>0;if(p){let e=f.map(tl);o?.(f),c(e)}if(m){let e=d.map(tl);a?.(d),s(e)}return(m||p)&&l?.({nodes:d,edges:f}),{deletedNodes:d,deletedEdges:f}},getIntersectingNodes:(e,n=!0,r)=>{let i=Za(e),o=i?e:a(e),s=r!==void 0;return o?(r||t.getState().nodes).filter(r=>{let a=t.getState().nodeLookup.get(r.id);if(a&&!i&&(r.id===e.id||!a.internals.positionAbsolute))return!1;let c=qa(s?r:a),l=Xa(c,o);return n&&l>0||l>=c.width*c.height||l>=o.width*o.height}):[]},isNodeIntersecting:(e,t,n=!0)=>{let r=Za(e)?e:a(e);if(!r)return!1;let i=Xa(r,t);return n&&i>0||i>=t.width*t.height||i>=r.width*r.height},updateNode:o,updateNodeData:(e,t,n={replace:!1})=>{o(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},updateEdge:s,updateEdgeData:(e,t,n={replace:!1})=>{s(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},getNodesBounds:e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState();return ja(e,{nodeLookup:n,nodeOrigin:r})},getHandleConnections:({type:e,id:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}-${e}${n?`-${n}`:``}`)?.values()??[]),getNodeConnections:({type:e,handleId:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}${e?n?`-${e}-${n}`:`-${e}`:``}`)?.values()??[]),fitView:async e=>{let r=t.getState().fitViewResolver??po();return t.setState({fitViewQueued:!0,fitViewOptions:e,fitViewResolver:r}),n.nodeQueue.push(e=>[...e]),r.promise}}},[]);return(0,f.useMemo)(()=>({...i,...e,viewportInitialized:r}),[r])}var hl=e=>e.selected,gl=typeof window<`u`?window:void 0;function _l({deleteKeyCode:e,multiSelectionKeyCode:t}){let n=_c(),{deleteElements:r}=ml(),i=Wc(e,{actInsideInputWithModifier:!1}),a=Wc(t,{target:gl});(0,f.useEffect)(()=>{if(i){let{edges:e,nodes:t}=n.getState();r({nodes:t.filter(hl),edges:e.filter(hl)}),n.setState({nodesSelectionActive:!1})}},[i]),(0,f.useEffect)(()=>{n.setState({multiSelectionActive:a})},[a])}function vl(e){let t=_c();(0,f.useEffect)(()=>{let n=()=>{if(!e.current||!(e.current.checkVisibility?.()??!0))return!1;let n=go(e.current);(n.height===0||n.width===0)&&t.getState().onError?.(`004`,pa.error004()),t.setState({width:n.width||500,height:n.height||500})};if(e.current){n(),window.addEventListener(`resize`,n);let t=new ResizeObserver(()=>n());return t.observe(e.current),()=>{window.removeEventListener(`resize`,n),t&&e.current&&t.unobserve(e.current)}}},[])}var yl={position:`absolute`,width:`100%`,height:`100%`,top:0,left:0},bl=e=>({userSelectionActive:e.userSelectionActive,lib:e.lib,connectionInProgress:e.connection.inProgress});function xl({onPaneContextMenu:e,zoomOnScroll:t=!0,zoomOnPinch:n=!0,panOnScroll:r=!1,panOnScrollSpeed:i=.5,panOnScrollMode:a=va.Free,zoomOnDoubleClick:o=!0,panOnDrag:s=!0,defaultViewport:c,translateExtent:l,minZoom:u,maxZoom:d,zoomActivationKeyCode:m,preventScrolling:h=!0,children:g,noWheelClassName:_,noPanClassName:v,onViewportChange:y,isControlledViewport:b,paneClickDistance:x,selectionOnDrag:S}){let C=_c(),w=(0,f.useRef)(null),{userSelectionActive:T,lib:E,connectionInProgress:D}=H(bl,fc),O=Wc(m),k=(0,f.useRef)();vl(w);let A=(0,f.useCallback)(e=>{y?.({x:e[0],y:e[1],zoom:e[2]}),b||C.setState({transform:e})},[y,b]);return(0,f.useEffect)(()=>{if(w.current){k.current=Vs({domNode:w.current,minZoom:u,maxZoom:d,translateExtent:l,viewport:c,onDraggingChange:e=>C.setState(t=>t.paneDragging===e?t:{paneDragging:e}),onPanZoomStart:(e,t)=>{let{onViewportChangeStart:n,onMoveStart:r}=C.getState();r?.(e,t),n?.(t)},onPanZoom:(e,t)=>{let{onViewportChange:n,onMove:r}=C.getState();r?.(e,t),n?.(t)},onPanZoomEnd:(e,t)=>{let{onViewportChangeEnd:n,onMoveEnd:r}=C.getState();r?.(e,t),n?.(t)}});let{x:e,y:t,zoom:n}=k.current.getViewport();return C.setState({panZoom:k.current,transform:[e,t,n],domNode:w.current.closest(`.react-flow`)}),()=>{k.current?.destroy()}}},[]),(0,f.useEffect)(()=>{k.current?.update({onPaneContextMenu:e,zoomOnScroll:t,zoomOnPinch:n,panOnScroll:r,panOnScrollSpeed:i,panOnScrollMode:a,zoomOnDoubleClick:o,panOnDrag:s,zoomActivationKeyPressed:O,preventScrolling:h,noPanClassName:v,userSelectionActive:T,noWheelClassName:_,lib:E,onTransformChange:A,connectionInProgress:D,selectionOnDrag:S,paneClickDistance:x})},[e,t,n,r,i,a,o,s,O,h,v,T,_,E,A,D,S,x]),(0,p.jsx)(`div`,{className:`react-flow__renderer`,ref:w,style:yl,children:g})}var Sl=e=>({userSelectionActive:e.userSelectionActive,userSelectionRect:e.userSelectionRect});function Cl(){let{userSelectionActive:e,userSelectionRect:t}=H(Sl,fc);return e&&t?(0,p.jsx)(`div`,{className:`react-flow__selection react-flow__container`,style:{width:t.width,height:t.height,transform:`translate(${t.x}px, ${t.y}px)`}}):null}var wl=(e,t)=>n=>{n.target===t.current&&e?.(n)},Tl=e=>({userSelectionActive:e.userSelectionActive,elementsSelectable:e.elementsSelectable,connectionInProgress:e.connection.inProgress,dragging:e.paneDragging,panBy:e.panBy,autoPanSpeed:e.autoPanSpeed});function El({isSelecting:e,selectionKeyPressed:t,selectionMode:n=ya.Full,panOnDrag:r,autoPanOnSelection:i,paneClickDistance:a,selectionOnDrag:o,onSelectionStart:s,onSelectionEnd:c,onPaneClick:l,onPaneContextMenu:u,onPaneScroll:d,onPaneMouseEnter:h,onPaneMouseMove:g,onPaneMouseLeave:_,children:v}){let y=(0,f.useRef)(0),b=_c(),{userSelectionActive:x,elementsSelectable:S,dragging:C,connectionInProgress:w,panBy:T,autoPanSpeed:E}=H(Tl,fc),D=S&&(e||x),O=(0,f.useRef)(null),k=(0,f.useRef)(),A=(0,f.useRef)(new Set),j=(0,f.useRef)(new Set),M=(0,f.useRef)(!1),N=(0,f.useRef)({x:0,y:0}),P=(0,f.useRef)(!1),F=e=>{if(M.current||w){M.current=!1;return}l?.(e),b.getState().resetSelectedElements(),b.setState({nodesSelectionActive:!1})},I=e=>{if(Array.isArray(r)&&r?.includes(2)){e.preventDefault();return}u?.(e)},L=d?e=>d(e):void 0,R=e=>{M.current&&=(e.stopPropagation(),!1)},ee=n=>{let{domNode:r,transform:i}=b.getState();if(k.current=r?.getBoundingClientRect(),!k.current)return;let a=n.target===O.current;if(!a&&n.target.closest(`.nokey`)||!e||!(o&&a||t)||n.button!==0||!n.isPrimary)return;n.target?.setPointerCapture?.(n.pointerId),M.current=!1;let{x:s,y:c}=xo(n.nativeEvent,k.current),l=to({x:s,y:c},i);b.setState({userSelectionRect:{width:0,height:0,startX:l.x,startY:l.y,x:s,y:c}}),a||(n.stopPropagation(),n.preventDefault())};function te(e,t){let{userSelectionRect:r}=b.getState();if(!r)return;let{transform:i,nodeLookup:a,edgeLookup:o,connectionLookup:s,triggerNodeChanges:c,triggerEdgeChanges:l,defaultEdgeOptions:u}=b.getState(),d={x:r.startX,y:r.startY},{x:f,y:p}=no(d,i),m={startX:d.x,startY:d.y,x:ee.id)),j.current=new Set;let _=u?.selectable??!0;for(let e of A.current){let t=s.get(e);if(t)for(let{edgeId:e}of t.values()){let t=o.get(e);t&&(t.selectable??_)&&j.current.add(e)}}V(h,A.current)||c($c(a,A.current,!0)),V(g,j.current)||l($c(o,j.current)),b.setState({userSelectionRect:m,userSelectionActive:!0,nodesSelectionActive:!1})}function ne(){if(!i||!k.current)return;let[e,t]=Ua(N.current,k.current,E);T({x:e,y:t}).then(e=>{if(!M.current||!e){y.current=requestAnimationFrame(ne);return}let{x:t,y:n}=N.current;te(t,n),y.current=requestAnimationFrame(ne)})}let z=()=>{cancelAnimationFrame(y.current),y.current=0,P.current=!1};return(0,f.useEffect)(()=>()=>z(),[]),(0,p.jsxs)(`div`,{className:m([`react-flow__pane`,{draggable:r===!0||Array.isArray(r)&&r.includes(0),dragging:C,selection:e}]),onClick:D?void 0:wl(F,O),onContextMenu:wl(I,O),onWheel:wl(L,O),onPointerEnter:D?void 0:h,onPointerMove:D?e=>{let{userSelectionRect:n,transform:r,resetSelectedElements:i}=b.getState();if(!k.current||!n)return;let{x:o,y:c}=xo(e.nativeEvent,k.current);N.current={x:o,y:c};let l=no({x:n.startX,y:n.startY},r);if(!M.current){let n=t?0:a;if(Math.hypot(o-l.x,c-l.y)<=n)return;i(),s?.(e)}M.current=!0,P.current||=(ne(),!0),te(o,c)}:g,onPointerUp:D?e=>{e.button===0&&(e.target?.releasePointerCapture?.(e.pointerId),!x&&e.target===O.current&&b.getState().userSelectionRect&&F?.(e),b.setState({userSelectionActive:!1,userSelectionRect:null}),M.current&&(c?.(e),b.setState({nodesSelectionActive:A.current.size>0})),z())}:void 0,onPointerCancel:D?e=>{e.target?.releasePointerCapture?.(e.pointerId),z()}:void 0,onPointerDownCapture:D?ee:void 0,onClickCapture:D?R:void 0,onPointerLeave:_,ref:O,style:yl,children:[v,(0,p.jsx)(Cl,{})]})}function Dl({id:e,store:t,unselect:n=!1,nodeRef:r}){let{addSelectedNodes:i,unselectNodesAndEdges:a,multiSelectionActive:o,nodeLookup:s,onError:c}=t.getState(),l=s.get(e);if(!l){c?.(`012`,pa.error012(e));return}t.setState({nodesSelectionActive:!1}),l.selected?(n||l.selected&&o)&&(a({nodes:[l],edges:[]}),requestAnimationFrame(()=>r?.current?.blur())):i([e])}function Ol({nodeRef:e,disabled:t=!1,noDragClassName:n,handleSelector:r,nodeId:i,isSelectable:a,nodeClickDistance:o}){let s=_c(),[c,l]=(0,f.useState)(!1),u=(0,f.useRef)();return(0,f.useEffect)(()=>{u.current=gs({getStoreItems:()=>s.getState(),onNodeMouseDown:t=>{Dl({id:t,store:s,nodeRef:e})},onDragStart:()=>{l(!0)},onDragStop:()=>{l(!1)}})},[]),(0,f.useEffect)(()=>{if(!(t||!e.current||!u.current))return u.current.update({noDragClassName:n,handleSelector:r,domNode:e.current,isSelectable:a,nodeId:i,nodeClickDistance:o}),()=>{u.current?.destroy()}},[n,r,t,a,e,i,o]),c}var kl=e=>t=>t.selected&&(t.draggable||e&&t.draggable===void 0);function Al(){let e=_c();return(0,f.useCallback)(t=>{let{nodeExtent:n,snapToGrid:r,snapGrid:i,nodesDraggable:a,onError:o,updateNodePositions:s,nodeLookup:c,nodeOrigin:l}=e.getState(),u=new Map,d=kl(a),f=r?i[0]:5,p=r?i[1]:5,m=t.direction.x*f*t.factor,h=t.direction.y*p*t.factor;for(let[,e]of c){if(!d(e))continue;let t={x:e.internals.positionAbsolute.x+m,y:e.internals.positionAbsolute.y+h};r&&(t=eo(t,i));let{position:a,positionAbsolute:s}=La({nodeId:e.id,nextPosition:t,nodeLookup:c,nodeExtent:n,nodeOrigin:l,onError:o});e.position=a,e.internals.positionAbsolute=s,u.set(e.id,e)}s(u)},[])}var jl=(0,f.createContext)(null),Ml=jl.Provider;jl.Consumer;var Nl=()=>(0,f.useContext)(jl),Pl=e=>({connectOnClick:e.connectOnClick,noPanClassName:e.noPanClassName,rfId:e.rfId}),Fl=(e,t,n)=>r=>{let{connectionClickStartHandle:i,connectionMode:a,connection:o}=r,{fromHandle:s,toHandle:c,isValid:l}=o,u=c?.nodeId===e&&c?.id===t&&c?.type===n;return{connectingFrom:s?.nodeId===e&&s?.id===t&&s?.type===n,connectingTo:u,clickConnecting:i?.nodeId===e&&i?.id===t&&i?.type===n,isPossibleEndHandle:a===_a.Strict?s?.type!==n:e!==s?.nodeId||t!==s?.id,connectionInProcess:!!s,clickConnectionInProcess:!!i,valid:u&&l}};function Il({type:e=`source`,position:t=B.Top,isValidConnection:n,isConnectable:r=!0,isConnectableStart:i=!0,isConnectableEnd:a=!0,id:o,onConnect:s,children:c,className:l,onMouseDown:u,onTouchStart:d,...f},h){let g=o||null,_=e===`target`,v=_c(),y=Nl(),{connectOnClick:b,noPanClassName:x,rfId:S}=H(Pl,fc),{connectingFrom:C,connectingTo:w,clickConnecting:T,isPossibleEndHandle:E,connectionInProcess:D,clickConnectionInProcess:O,valid:k}=H(Fl(y,g,e),fc);y||v.getState().onError?.(`010`,pa.error010());let A=e=>{let{defaultEdgeOptions:t,onConnect:n,hasDefaultEdges:r}=v.getState(),i={...t,...e};if(r){let{edges:e,setEdges:t,onError:n}=v.getState();t(rl(i,e,{onError:n}))}n?.(i),s?.(i)},j=e=>{if(!y)return;let t=bo(e.nativeEvent);if(i&&(t&&e.button===0||!t)){let t=v.getState();Es.onPointerDown(e.nativeEvent,{handleDomNode:e.currentTarget,autoPanOnConnect:t.autoPanOnConnect,connectionMode:t.connectionMode,connectionRadius:t.connectionRadius,domNode:t.domNode,nodeLookup:t.nodeLookup,lib:t.lib,isTarget:_,handleId:g,nodeId:y,flowId:t.rfId,panBy:t.panBy,cancelConnection:t.cancelConnection,onConnectStart:t.onConnectStart,onConnectEnd:(...e)=>v.getState().onConnectEnd?.(...e),updateConnection:t.updateConnection,onConnect:A,isValidConnection:n||((...e)=>v.getState().isValidConnection?.(...e)??!0),getTransform:()=>v.getState().transform,getFromHandle:()=>v.getState().connection.fromHandle,autoPanSpeed:t.autoPanSpeed,dragThreshold:t.connectionDragThreshold})}t?u?.(e):d?.(e)};return(0,p.jsx)(`div`,{"data-handleid":g,"data-nodeid":y,"data-handlepos":t,"data-id":`${S}-${y}-${g}-${e}`,className:m([`react-flow__handle`,`react-flow__handle-${t}`,`nodrag`,x,l,{source:!_,target:_,connectable:r,connectablestart:i,connectableend:a,clickconnecting:T,connectingfrom:C,connectingto:w,valid:k,connectionindicator:r&&(!D||E)&&(D||O?a:i)}]),onMouseDown:j,onTouchStart:j,onClick:b?t=>{let{onClickConnectStart:r,onClickConnectEnd:a,connectionClickStartHandle:o,connectionMode:s,isValidConnection:c,lib:l,rfId:u,nodeLookup:d,connection:f}=v.getState();if(!y||!o&&!i)return;if(!o){r?.(t.nativeEvent,{nodeId:y,handleId:g,handleType:e}),v.setState({connectionClickStartHandle:{nodeId:y,type:e,id:g}});return}let p=_o(t.target),m=n||c,{connection:h,isValid:_}=Es.isValid(t.nativeEvent,{handle:{nodeId:y,id:g,type:e},connectionMode:s,fromNodeId:o.nodeId,fromHandleId:o.id||null,fromType:o.type,isValidConnection:m,flowId:u,doc:p,lib:l,nodeLookup:d});_&&h&&A(h);let b=structuredClone(f);delete b.inProgress,b.toPosition=b.toHandle?b.toHandle.position:null,a?.(t,b),v.setState({connectionClickStartHandle:null})}:void 0,ref:h,...f,children:c})}var Ll=(0,f.memo)(ol(Il));function Rl({data:e,isConnectable:t,sourcePosition:n=B.Bottom}){return(0,p.jsxs)(p.Fragment,{children:[e?.label,(0,p.jsx)(Ll,{type:`source`,position:n,isConnectable:t})]})}function zl({data:e,isConnectable:t,targetPosition:n=B.Top,sourcePosition:r=B.Bottom}){return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(Ll,{type:`target`,position:n,isConnectable:t}),e?.label,(0,p.jsx)(Ll,{type:`source`,position:r,isConnectable:t})]})}function Bl(){return null}function Vl({data:e,isConnectable:t,targetPosition:n=B.Top}){return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(Ll,{type:`target`,position:n,isConnectable:t}),e?.label]})}var Hl={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},Ul={input:Rl,default:zl,output:Vl,group:Bl};function U(e){return e.internals.handleBounds===void 0?{width:e.width??e.initialWidth??e.style?.width,height:e.height??e.initialHeight??e.style?.height}:{width:e.width??e.style?.width,height:e.height??e.style?.height}}var W=e=>{let{width:t,height:n,x:r,y:i}=Ma(e.nodeLookup,{filter:e=>!!e.selected});return{width:Qa(t)?t:null,height:Qa(n)?n:null,userSelectionActive:e.userSelectionActive,transformString:`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]}) translate(${r}px,${i}px)`}};function Wl({onSelectionContextMenu:e,noPanClassName:t,disableKeyboardA11y:n}){let r=_c(),{width:i,height:a,transformString:o,userSelectionActive:s}=H(W,fc),c=Al(),l=(0,f.useRef)(null);(0,f.useEffect)(()=>{n||l.current?.focus({preventScroll:!0})},[n]);let u=!s&&i!==null&&a!==null;if(Ol({nodeRef:l,disabled:!u}),!u)return null;let d=e?t=>{e(t,r.getState().nodes.filter(e=>e.selected))}:void 0;return(0,p.jsx)(`div`,{className:m([`react-flow__nodesselection`,`react-flow__container`,t]),style:{transform:o},children:(0,p.jsx)(`div`,{ref:l,className:`react-flow__nodesselection-rect`,onContextMenu:d,tabIndex:n?void 0:-1,onKeyDown:n?void 0:e=>{Object.prototype.hasOwnProperty.call(Hl,e.key)&&(e.preventDefault(),c({direction:Hl[e.key],factor:e.shiftKey?4:1}))},style:{width:i,height:a}})})}var Gl=typeof window<`u`?window:void 0,Kl=e=>({nodesSelectionActive:e.nodesSelectionActive,userSelectionActive:e.userSelectionActive});function ql({children:e,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,paneClickDistance:s,deleteKeyCode:c,selectionKeyCode:l,selectionOnDrag:u,selectionMode:d,onSelectionStart:f,onSelectionEnd:m,multiSelectionKeyCode:h,panActivationKeyCode:g,zoomActivationKeyCode:_,elementsSelectable:v,zoomOnScroll:y,zoomOnPinch:b,panOnScroll:x,panOnScrollSpeed:S,panOnScrollMode:C,zoomOnDoubleClick:w,panOnDrag:T,autoPanOnSelection:E,defaultViewport:D,translateExtent:O,minZoom:k,maxZoom:A,preventScrolling:j,onSelectionContextMenu:M,noWheelClassName:N,noPanClassName:P,disableKeyboardA11y:F,onViewportChange:I,isControlledViewport:L}){let{nodesSelectionActive:R,userSelectionActive:ee}=H(Kl,fc),te=Wc(l,{target:Gl}),ne=Wc(g,{target:Gl}),z=ne||T,re=ne||x,ie=u&&z!==!0,ae=te||ee||ie;return _l({deleteKeyCode:c,multiSelectionKeyCode:h}),(0,p.jsx)(xl,{onPaneContextMenu:a,elementsSelectable:v,zoomOnScroll:y,zoomOnPinch:b,panOnScroll:re,panOnScrollSpeed:S,panOnScrollMode:C,zoomOnDoubleClick:w,panOnDrag:!te&&z,defaultViewport:D,translateExtent:O,minZoom:k,maxZoom:A,zoomActivationKeyCode:_,preventScrolling:j,noWheelClassName:N,noPanClassName:P,onViewportChange:I,isControlledViewport:L,paneClickDistance:s,selectionOnDrag:ie,children:(0,p.jsxs)(El,{onSelectionStart:f,onSelectionEnd:m,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,panOnDrag:z,autoPanOnSelection:E,isSelecting:!!ae,selectionMode:d,selectionKeyPressed:te,paneClickDistance:s,selectionOnDrag:ie,children:[e,R&&(0,p.jsx)(Wl,{onSelectionContextMenu:M,noPanClassName:P,disableKeyboardA11y:F})]})})}ql.displayName=`FlowRenderer`;var Jl=(0,f.memo)(ql),Yl=e=>t=>e?Na(t.nodeLookup,{x:0,y:0,width:t.width,height:t.height},t.transform,!0).map(e=>e.id):Array.from(t.nodeLookup.keys());function Xl(e){return H((0,f.useCallback)(Yl(e),[e]),fc)}var Zl=e=>e.updateNodeInternals;function Ql(){let e=H(Zl),[t]=(0,f.useState)(()=>typeof ResizeObserver>`u`?null:new ResizeObserver(t=>{let n=new Map;t.forEach(e=>{let t=e.target.getAttribute(`data-id`);n.set(t,{id:t,nodeElement:e.target,force:!0})}),e(n)}));return(0,f.useEffect)(()=>()=>{t?.disconnect()},[t]),t}function $l({node:e,nodeType:t,hasDimensions:n,resizeObserver:r}){let i=_c(),a=(0,f.useRef)(null),o=(0,f.useRef)(null),s=(0,f.useRef)(e.sourcePosition),c=(0,f.useRef)(e.targetPosition),l=(0,f.useRef)(t),u=n&&!!e.internals.handleBounds;return(0,f.useEffect)(()=>{a.current&&!e.hidden&&(!u||o.current!==a.current)&&(o.current&&r?.unobserve(o.current),r?.observe(a.current),o.current=a.current)},[u,e.hidden]),(0,f.useEffect)(()=>()=>{o.current&&=(r?.unobserve(o.current),null)},[]),(0,f.useEffect)(()=>{if(a.current){let n=l.current!==t,r=s.current!==e.sourcePosition,o=c.current!==e.targetPosition;(n||r||o)&&(l.current=t,s.current=e.sourcePosition,c.current=e.targetPosition,i.getState().updateNodeInternals(new Map([[e.id,{id:e.id,nodeElement:a.current,force:!0}]])))}},[e.id,t,e.sourcePosition,e.targetPosition]),a}function eu({id:e,onClick:t,onMouseEnter:n,onMouseMove:r,onMouseLeave:i,onContextMenu:a,onDoubleClick:o,nodesDraggable:s,elementsSelectable:c,nodesConnectable:l,nodesFocusable:u,resizeObserver:d,noDragClassName:f,noPanClassName:h,disableKeyboardA11y:g,rfId:_,nodeTypes:v,nodeClickDistance:y,onError:b}){let{node:x,internals:S,isParent:C}=H(t=>{let n=t.nodeLookup.get(e),r=t.parentLookup.has(e);return{node:n,internals:n.internals,isParent:r}},fc),w=x.type||`default`,T=v?.[w]||Ul[w];T===void 0&&(b?.(`003`,pa.error003(w)),w=`default`,T=v?.default||Ul.default);let E=!!(x.draggable||s&&x.draggable===void 0),D=!!(x.selectable||c&&x.selectable===void 0),O=!!(x.connectable||l&&x.connectable===void 0),k=!!(x.focusable||u&&x.focusable===void 0),A=_c(),j=uo(x),M=$l({node:x,nodeType:w,hasDimensions:j,resizeObserver:d}),N=Ol({nodeRef:M,disabled:x.hidden||!E,noDragClassName:f,handleSelector:x.dragHandle,nodeId:e,isSelectable:D,nodeClickDistance:y}),P=Al();if(x.hidden)return null;let F=lo(x),I=U(x),L=D||E||t||n||r||i,R=n?e=>n(e,{...S.userNode}):void 0,ee=r?e=>r(e,{...S.userNode}):void 0,te=i?e=>i(e,{...S.userNode}):void 0,ne=a?e=>a(e,{...S.userNode}):void 0,z=o?e=>o(e,{...S.userNode}):void 0,re=n=>{let{selectNodesOnDrag:r,nodeDragThreshold:i}=A.getState();D&&(!r||!E||i>0)&&Dl({id:e,store:A,nodeRef:M}),t&&t(n,{...S.userNode})},ie=t=>{if(!(yo(t.nativeEvent)||g)){if(ha.includes(t.key)&&D){let n=t.key===`Escape`;Dl({id:e,store:A,unselect:n,nodeRef:M})}else if(E&&x.selected&&Object.prototype.hasOwnProperty.call(Hl,t.key)){t.preventDefault();let{ariaLabelConfig:e}=A.getState();A.setState({ariaLiveMessage:e[`node.a11yDescription.ariaLiveMessage`]({direction:t.key.replace(`Arrow`,``).toLowerCase(),x:~~S.positionAbsolute.x,y:~~S.positionAbsolute.y})}),P({direction:Hl[t.key],factor:t.shiftKey?4:1})}}},ae=()=>{if(g||!M.current?.matches(`:focus-visible`))return;let{transform:t,width:n,height:r,autoPanOnNodeFocus:i,setCenter:a}=A.getState();i&&(Na(new Map([[e,x]]),{x:0,y:0,width:n,height:r},t,!0).length>0||a(x.position.x+F.width/2,x.position.y+F.height/2,{zoom:t[2]}))};return(0,p.jsx)(`div`,{className:m([`react-flow__node`,`react-flow__node-${w}`,{[h]:E},x.className,{selected:x.selected,selectable:D,parent:C,draggable:E,dragging:N}]),ref:M,style:{zIndex:S.z,transform:`translate(${S.positionAbsolute.x}px,${S.positionAbsolute.y}px)`,pointerEvents:L?`all`:`none`,visibility:j?`visible`:`hidden`,...x.style,...I},"data-id":e,"data-testid":`rf__node-${e}`,onMouseEnter:R,onMouseMove:ee,onMouseLeave:te,onContextMenu:ne,onClick:re,onDoubleClick:z,onKeyDown:k?ie:void 0,tabIndex:k?0:void 0,onFocus:k?ae:void 0,role:x.ariaRole??(k?`group`:void 0),"aria-roledescription":`node`,"aria-describedby":g?void 0:`${bc}-${_}`,"aria-label":x.ariaLabel,...x.domAttributes,children:(0,p.jsx)(Ml,{value:e,children:(0,p.jsx)(T,{id:e,data:x.data,type:w,positionAbsoluteX:S.positionAbsolute.x,positionAbsoluteY:S.positionAbsolute.y,selected:x.selected??!1,selectable:D,draggable:E,deletable:x.deletable??!0,isConnectable:O,sourcePosition:x.sourcePosition,targetPosition:x.targetPosition,dragging:N,dragHandle:x.dragHandle,zIndex:S.z,parentId:x.parentId,...F})})})}var tu=(0,f.memo)(eu),nu=e=>({nodesDraggable:e.nodesDraggable,nodesConnectable:e.nodesConnectable,nodesFocusable:e.nodesFocusable,elementsSelectable:e.elementsSelectable,onError:e.onError});function ru(e){let{nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,onError:a}=H(nu,fc),o=Xl(e.onlyRenderVisibleElements),s=Ql();return(0,p.jsx)(`div`,{className:`react-flow__nodes`,style:yl,children:o.map(o=>(0,p.jsx)(tu,{id:o,nodeTypes:e.nodeTypes,nodeExtent:e.nodeExtent,onClick:e.onNodeClick,onMouseEnter:e.onNodeMouseEnter,onMouseMove:e.onNodeMouseMove,onMouseLeave:e.onNodeMouseLeave,onContextMenu:e.onNodeContextMenu,onDoubleClick:e.onNodeDoubleClick,noDragClassName:e.noDragClassName,noPanClassName:e.noPanClassName,rfId:e.rfId,disableKeyboardA11y:e.disableKeyboardA11y,resizeObserver:s,nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,nodeClickDistance:e.nodeClickDistance,onError:a},o))})}ru.displayName=`NodeRenderer`;var iu=(0,f.memo)(ru);function au(e){return H((0,f.useCallback)(t=>{if(!e)return t.edges.map(e=>e.id);let n=[];if(t.width&&t.height)for(let e of t.edges){let r=t.nodeLookup.get(e.source),i=t.nodeLookup.get(e.target);r&&i&&ko({sourceNode:r,targetNode:i,width:t.width,height:t.height,transform:t.transform})&&n.push(e.id)}return n},[e]),fc)}var ou=({color:e=`none`,strokeWidth:t=1})=>(0,p.jsx)(`polyline`,{className:`arrow`,style:{strokeWidth:t,...e&&{stroke:e}},strokeLinecap:`round`,fill:`none`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4`}),su=({color:e=`none`,strokeWidth:t=1})=>(0,p.jsx)(`polyline`,{className:`arrowclosed`,style:{strokeWidth:t,...e&&{stroke:e,fill:e}},strokeLinecap:`round`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4 -5,-4`}),cu={[Sa.Arrow]:ou,[Sa.ArrowClosed]:su};function lu(e){let t=_c();return(0,f.useMemo)(()=>Object.prototype.hasOwnProperty.call(cu,e)?cu[e]:(t.getState().onError?.(`009`,pa.error009(e)),null),[e])}var uu=({id:e,type:t,color:n,width:r=12.5,height:i=12.5,markerUnits:a=`strokeWidth`,strokeWidth:o,orient:s=`auto-start-reverse`})=>{let c=lu(t);return c?(0,p.jsx)(`marker`,{className:`react-flow__arrowhead`,id:e,markerWidth:`${r}`,markerHeight:`${i}`,viewBox:`-10 -10 20 20`,markerUnits:a,orient:s,refX:`0`,refY:`0`,children:(0,p.jsx)(c,{color:n,strokeWidth:o})}):null},du=({defaultColor:e,rfId:t})=>{let n=H(e=>e.edges),r=H(e=>e.defaultEdgeOptions),i=(0,f.useMemo)(()=>Ko(n,{id:t,defaultColor:e,defaultMarkerStart:r?.markerStart,defaultMarkerEnd:r?.markerEnd}),[n,r,t,e]);return i.length?(0,p.jsx)(`svg`,{className:`react-flow__marker`,"aria-hidden":`true`,children:(0,p.jsx)(`defs`,{children:i.map(e=>(0,p.jsx)(uu,{id:e.id,type:e.type,color:e.color,width:e.width,height:e.height,markerUnits:e.markerUnits,strokeWidth:e.strokeWidth,orient:e.orient},e.id))})}):null};du.displayName=`MarkerDefinitions`;var fu=(0,f.memo)(du);function pu({x:e,y:t,label:n,labelStyle:r,labelShowBg:i=!0,labelBgStyle:a,labelBgPadding:o=[2,4],labelBgBorderRadius:s=2,children:c,className:l,...u}){let[d,h]=(0,f.useState)({x:1,y:0,width:0,height:0}),g=m([`react-flow__edge-textwrapper`,l]),_=(0,f.useRef)(null);return(0,f.useEffect)(()=>{if(_.current){let e=_.current.getBBox();h({x:e.x,y:e.y,width:e.width,height:e.height})}},[n]),n?(0,p.jsxs)(`g`,{transform:`translate(${e-d.width/2} ${t-d.height/2})`,className:g,visibility:d.width?`visible`:`hidden`,...u,children:[i&&(0,p.jsx)(`rect`,{width:d.width+2*o[0],x:-o[0],y:-o[1],height:d.height+2*o[1],className:`react-flow__edge-textbg`,style:a,rx:s,ry:s}),(0,p.jsx)(`text`,{className:`react-flow__edge-text`,y:d.height/2,dy:`0.3em`,ref:_,style:r,children:n}),c]}):null}pu.displayName=`EdgeText`;var mu=(0,f.memo)(pu);function hu({path:e,labelX:t,labelY:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c,interactionWidth:l=20,...u}){return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(`path`,{...u,d:e,fill:`none`,className:m([`react-flow__edge-path`,u.className])}),l?(0,p.jsx)(`path`,{d:e,fill:`none`,strokeOpacity:0,strokeWidth:l,className:`react-flow__edge-interaction`}):null,r&&Qa(t)&&Qa(n)?(0,p.jsx)(mu,{x:t,y:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c}):null]})}function gu({pos:e,x1:t,y1:n,x2:r,y2:i}){return e===B.Left||e===B.Right?[.5*(t+r),n]:[t,.5*(n+i)]}function _u({sourceX:e,sourceY:t,sourcePosition:n=B.Bottom,targetX:r,targetY:i,targetPosition:a=B.Top}){let[o,s]=gu({pos:n,x1:e,y1:t,x2:r,y2:i}),[c,l]=gu({pos:a,x1:r,y1:i,x2:e,y2:t}),[u,d,f,p]=Co({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:o,sourceControlY:s,targetControlX:c,targetControlY:l});return[`M${e},${t} C${o},${s} ${c},${l} ${r},${i}`,u,d,f,p]}function vu(e){return(0,f.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o,targetPosition:s,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:m,style:h,markerEnd:g,markerStart:_,interactionWidth:v})=>{let[y,b,x]=_u({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s});return(0,p.jsx)(hu,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:m,style:h,markerEnd:g,markerStart:_,interactionWidth:v})})}var yu=vu({isInternal:!1}),bu=vu({isInternal:!0});yu.displayName=`SimpleBezierEdge`,bu.displayName=`SimpleBezierEdgeInternal`;function xu(e){return(0,f.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,sourcePosition:m=B.Bottom,targetPosition:h=B.Top,markerEnd:g,markerStart:_,pathOptions:v,interactionWidth:y})=>{let[b,x,S]=zo({sourceX:n,sourceY:r,sourcePosition:m,targetX:i,targetY:a,targetPosition:h,borderRadius:v?.borderRadius,offset:v?.offset,stepPosition:v?.stepPosition});return(0,p.jsx)(hu,{id:e.isInternal?void 0:t,path:b,labelX:x,labelY:S,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:g,markerStart:_,interactionWidth:y})})}var Su=xu({isInternal:!1}),Cu=xu({isInternal:!0});Su.displayName=`SmoothStepEdge`,Cu.displayName=`SmoothStepEdgeInternal`;function wu(e){return(0,f.memo)(({id:t,...n})=>{let r=e.isInternal?void 0:t;return(0,p.jsx)(Su,{...n,id:r,pathOptions:(0,f.useMemo)(()=>({borderRadius:0,offset:n.pathOptions?.offset}),[n.pathOptions?.offset])})})}var Tu=wu({isInternal:!1}),Eu=wu({isInternal:!0});Tu.displayName=`StepEdge`,Eu.displayName=`StepEdgeInternal`;function Du(e){return(0,f.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:m,markerStart:h,interactionWidth:g})=>{let[_,v,y]=No({sourceX:n,sourceY:r,targetX:i,targetY:a});return(0,p.jsx)(hu,{id:e.isInternal?void 0:t,path:_,labelX:v,labelY:y,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:m,markerStart:h,interactionWidth:g})})}var Ou=Du({isInternal:!1}),ku=Du({isInternal:!0});Ou.displayName=`StraightEdge`,ku.displayName=`StraightEdgeInternal`;function Au(e){return(0,f.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o=B.Bottom,targetPosition:s=B.Top,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:m,style:h,markerEnd:g,markerStart:_,pathOptions:v,interactionWidth:y})=>{let[b,x,S]=Eo({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s,curvature:v?.curvature});return(0,p.jsx)(hu,{id:e.isInternal?void 0:t,path:b,labelX:x,labelY:S,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:m,style:h,markerEnd:g,markerStart:_,interactionWidth:y})})}var ju=Au({isInternal:!1}),Mu=Au({isInternal:!0});ju.displayName=`BezierEdge`,Mu.displayName=`BezierEdgeInternal`;var Nu={default:Mu,straight:ku,step:Eu,smoothstep:Cu,simplebezier:bu},Pu={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null},Fu=(e,t,n)=>n===B.Left?e-t:n===B.Right?e+t:e,Iu=(e,t,n)=>n===B.Top?e-t:n===B.Bottom?e+t:e,Lu=`react-flow__edgeupdater`;function Ru({position:e,centerX:t,centerY:n,radius:r=10,onMouseDown:i,onMouseEnter:a,onMouseOut:o,type:s}){return(0,p.jsx)(`circle`,{onMouseDown:i,onMouseEnter:a,onMouseOut:o,className:m([Lu,`${Lu}-${s}`]),cx:Fu(t,r,e),cy:Iu(n,r,e),r,stroke:`transparent`,fill:`transparent`})}function zu({isReconnectable:e,reconnectRadius:t,edge:n,sourceX:r,sourceY:i,targetX:a,targetY:o,sourcePosition:s,targetPosition:c,onReconnect:l,onReconnectStart:u,onReconnectEnd:d,setReconnecting:f,setUpdateHover:m}){let h=_c(),g=(e,t)=>{if(e.button!==0)return;let{autoPanOnConnect:r,domNode:i,connectionMode:a,connectionRadius:o,lib:s,onConnectStart:c,cancelConnection:p,nodeLookup:m,rfId:g,panBy:_,updateConnection:v}=h.getState(),y=t.type===`target`;Es.onPointerDown(e.nativeEvent,{autoPanOnConnect:r,connectionMode:a,connectionRadius:o,domNode:i,handleId:t.id,nodeId:t.nodeId,nodeLookup:m,isTarget:y,edgeUpdaterType:t.type,lib:s,flowId:g,cancelConnection:p,panBy:_,isValidConnection:(...e)=>h.getState().isValidConnection?.(...e)??!0,onConnect:e=>l?.(n,e),onConnectStart:(r,i)=>{f(!0),u?.(e,n,t.type),c?.(r,i)},onConnectEnd:(...e)=>h.getState().onConnectEnd?.(...e),onReconnectEnd:(e,r)=>{f(!1),d?.(e,n,t.type,r)},updateConnection:v,getTransform:()=>h.getState().transform,getFromHandle:()=>h.getState().connection.fromHandle,dragThreshold:h.getState().connectionDragThreshold,handleDomNode:e.currentTarget})},_=e=>g(e,{nodeId:n.target,id:n.targetHandle??null,type:`target`}),v=e=>g(e,{nodeId:n.source,id:n.sourceHandle??null,type:`source`}),y=()=>m(!0),b=()=>m(!1);return(0,p.jsxs)(p.Fragment,{children:[(e===!0||e===`source`)&&(0,p.jsx)(Ru,{position:s,centerX:r,centerY:i,radius:t,onMouseDown:_,onMouseEnter:y,onMouseOut:b,type:`source`}),(e===!0||e===`target`)&&(0,p.jsx)(Ru,{position:c,centerX:a,centerY:o,radius:t,onMouseDown:v,onMouseEnter:y,onMouseOut:b,type:`target`})]})}function Bu({id:e,edgesFocusable:t,edgesReconnectable:n,elementsSelectable:r,onClick:i,onDoubleClick:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,reconnectRadius:u,onReconnect:d,onReconnectStart:h,onReconnectEnd:g,rfId:_,edgeTypes:v,noPanClassName:y,onError:b,disableKeyboardA11y:x}){let S=H(t=>t.edgeLookup.get(e)),C=H(e=>e.defaultEdgeOptions);S=C?{...C,...S}:S;let w=S.type||`default`,T=v?.[w]||Nu[w];T===void 0&&(b?.(`011`,pa.error011(w)),w=`default`,T=v?.default||Nu.default);let E=!!(S.focusable||t&&S.focusable===void 0),D=d!==void 0&&(S.reconnectable||n&&S.reconnectable===void 0),O=!!(S.selectable||r&&S.selectable===void 0),k=(0,f.useRef)(null),[A,j]=(0,f.useState)(!1),[M,N]=(0,f.useState)(!1),P=_c(),{zIndex:F,sourceX:I,sourceY:L,targetX:R,targetY:ee,sourcePosition:te,targetPosition:ne}=H((0,f.useCallback)(t=>{let n=t.nodeLookup.get(S.source),r=t.nodeLookup.get(S.target);if(!n||!r)return{zIndex:S.zIndex,...Pu};let i=Vo({id:e,sourceNode:n,targetNode:r,sourceHandle:S.sourceHandle||null,targetHandle:S.targetHandle||null,connectionMode:t.connectionMode,onError:b});return{zIndex:Oo({selected:S.selected,zIndex:S.zIndex,sourceNode:n,targetNode:r,elevateOnSelect:t.elevateEdgesOnSelect,zIndexMode:t.zIndexMode}),...i||Pu}},[S.source,S.target,S.sourceHandle,S.targetHandle,S.selected,S.zIndex]),fc),z=(0,f.useMemo)(()=>S.markerStart?`url('#${Go(S.markerStart,_)}')`:void 0,[S.markerStart,_]),re=(0,f.useMemo)(()=>S.markerEnd?`url('#${Go(S.markerEnd,_)}')`:void 0,[S.markerEnd,_]);if(S.hidden||I===null||L===null||R===null||ee===null)return null;let ie=t=>{let{addSelectedEdges:n,unselectNodesAndEdges:r,multiSelectionActive:a}=P.getState();O&&(P.setState({nodesSelectionActive:!1}),S.selected&&a?(r({nodes:[],edges:[S]}),k.current?.blur()):n([e])),i&&i(t,S)},ae=a?e=>{a(e,{...S})}:void 0,oe=o?e=>{o(e,{...S})}:void 0,se=s?e=>{s(e,{...S})}:void 0,ce=c?e=>{c(e,{...S})}:void 0,le=l?e=>{l(e,{...S})}:void 0;return(0,p.jsx)(`svg`,{style:{zIndex:F},children:(0,p.jsxs)(`g`,{className:m([`react-flow__edge`,`react-flow__edge-${w}`,S.className,y,{selected:S.selected,animated:S.animated,inactive:!O&&!i,updating:A,selectable:O}]),onClick:ie,onDoubleClick:ae,onContextMenu:oe,onMouseEnter:se,onMouseMove:ce,onMouseLeave:le,onKeyDown:E?t=>{if(!x&&ha.includes(t.key)&&O){let{unselectNodesAndEdges:n,addSelectedEdges:r}=P.getState();t.key===`Escape`?(k.current?.blur(),n({edges:[S]})):r([e])}}:void 0,tabIndex:E?0:void 0,role:S.ariaRole??(E?`group`:`img`),"aria-roledescription":`edge`,"data-id":e,"data-testid":`rf__edge-${e}`,"aria-label":S.ariaLabel===null?void 0:S.ariaLabel||`Edge from ${S.source} to ${S.target}`,"aria-describedby":E?`${xc}-${_}`:void 0,ref:k,...S.domAttributes,children:[!M&&(0,p.jsx)(T,{id:e,source:S.source,target:S.target,type:S.type,selected:S.selected,animated:S.animated,selectable:O,deletable:S.deletable??!0,label:S.label,labelStyle:S.labelStyle,labelShowBg:S.labelShowBg,labelBgStyle:S.labelBgStyle,labelBgPadding:S.labelBgPadding,labelBgBorderRadius:S.labelBgBorderRadius,sourceX:I,sourceY:L,targetX:R,targetY:ee,sourcePosition:te,targetPosition:ne,data:S.data,style:S.style,sourceHandleId:S.sourceHandle,targetHandleId:S.targetHandle,markerStart:z,markerEnd:re,pathOptions:`pathOptions`in S?S.pathOptions:void 0,interactionWidth:S.interactionWidth}),D&&(0,p.jsx)(zu,{edge:S,isReconnectable:D,reconnectRadius:u,onReconnect:d,onReconnectStart:h,onReconnectEnd:g,sourceX:I,sourceY:L,targetX:R,targetY:ee,sourcePosition:te,targetPosition:ne,setUpdateHover:j,setReconnecting:N})]})})}var Vu=(0,f.memo)(Bu),Hu=e=>({edgesFocusable:e.edgesFocusable,edgesReconnectable:e.edgesReconnectable,elementsSelectable:e.elementsSelectable,connectionMode:e.connectionMode,onError:e.onError});function Uu({defaultMarkerColor:e,onlyRenderVisibleElements:t,rfId:n,edgeTypes:r,noPanClassName:i,onReconnect:a,onEdgeContextMenu:o,onEdgeMouseEnter:s,onEdgeMouseMove:c,onEdgeMouseLeave:l,onEdgeClick:u,reconnectRadius:d,onEdgeDoubleClick:f,onReconnectStart:m,onReconnectEnd:h,disableKeyboardA11y:g}){let{edgesFocusable:_,edgesReconnectable:v,elementsSelectable:y,onError:b}=H(Hu,fc),x=au(t);return(0,p.jsxs)(`div`,{className:`react-flow__edges`,children:[(0,p.jsx)(fu,{defaultColor:e,rfId:n}),x.map(e=>(0,p.jsx)(Vu,{id:e,edgesFocusable:_,edgesReconnectable:v,elementsSelectable:y,noPanClassName:i,onReconnect:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,onClick:u,reconnectRadius:d,onDoubleClick:f,onReconnectStart:m,onReconnectEnd:h,rfId:n,onError:b,edgeTypes:r,disableKeyboardA11y:g},e))]})}Uu.displayName=`EdgeRenderer`;var Wu=(0,f.memo)(Uu),Gu=e=>`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]})`;function Ku({children:e}){return(0,p.jsx)(`div`,{className:`react-flow__viewport xyflow__viewport react-flow__container`,style:{transform:H(Gu)},children:e})}function qu(e){let t=ml(),n=(0,f.useRef)(!1);(0,f.useEffect)(()=>{!n.current&&t.viewportInitialized&&e&&(setTimeout(()=>e(t),1),n.current=!0)},[e,t.viewportInitialized])}var Ju=e=>e.panZoom?.syncViewport;function Yu(e){let t=H(Ju),n=_c();return(0,f.useEffect)(()=>{e&&(t?.(e),n.setState({transform:[e.x,e.y,e.zoom]}))},[e,t]),null}function Xu(e){return e.connection.inProgress?{...e.connection,to:to(e.connection.to,e.transform)}:{...e.connection}}function Zu(e){return e?t=>e(Xu(t)):Xu}function Qu(e){return H(Zu(e),fc)}var $u=e=>({nodesConnectable:e.nodesConnectable,isValid:e.connection.isValid,inProgress:e.connection.inProgress,width:e.width,height:e.height});function ed({containerStyle:e,style:t,type:n,component:r}){let{nodesConnectable:i,width:a,height:o,isValid:s,inProgress:c}=H($u,fc);return a&&i&&c?(0,p.jsx)(`svg`,{style:e,width:a,height:o,className:`react-flow__connectionline react-flow__container`,children:(0,p.jsx)(`g`,{className:m([`react-flow__connection`,wa(s)]),children:(0,p.jsx)(td,{style:t,type:n,CustomComponent:r,isValid:s})})}):null}var td=({style:e,type:t=xa.Bezier,CustomComponent:n,isValid:r})=>{let{inProgress:i,from:a,fromNode:o,fromHandle:s,fromPosition:c,to:l,toNode:u,toHandle:d,toPosition:f,pointer:m}=Qu();if(!i)return;if(n)return(0,p.jsx)(n,{connectionLineType:t,connectionLineStyle:e,fromNode:o,fromHandle:s,fromX:a.x,fromY:a.y,toX:l.x,toY:l.y,fromPosition:c,toPosition:f,connectionStatus:wa(r),toNode:u,toHandle:d,pointer:m});let h=``,g={sourceX:a.x,sourceY:a.y,sourcePosition:c,targetX:l.x,targetY:l.y,targetPosition:f};switch(t){case xa.Bezier:[h]=Eo(g);break;case xa.SimpleBezier:[h]=_u(g);break;case xa.Step:[h]=zo({...g,borderRadius:0});break;case xa.SmoothStep:[h]=zo(g);break;default:[h]=No(g)}return(0,p.jsx)(`path`,{d:h,fill:`none`,className:`react-flow__connection-path`,style:e})};td.displayName=`ConnectionLine`;var nd={};function rd(e=nd){(0,f.useRef)(e),_c(),(0,f.useEffect)(()=>{},[e])}function id(){_c(),(0,f.useRef)(!1),(0,f.useEffect)(()=>{},[])}function ad({nodeTypes:e,edgeTypes:t,onInit:n,onNodeClick:r,onEdgeClick:i,onNodeDoubleClick:a,onEdgeDoubleClick:o,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,onSelectionContextMenu:d,onSelectionStart:f,onSelectionEnd:m,connectionLineType:h,connectionLineStyle:g,connectionLineComponent:_,connectionLineContainerStyle:v,selectionKeyCode:y,selectionOnDrag:b,selectionMode:x,multiSelectionKeyCode:S,panActivationKeyCode:C,zoomActivationKeyCode:w,deleteKeyCode:T,onlyRenderVisibleElements:E,elementsSelectable:D,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,preventScrolling:M,defaultMarkerColor:N,zoomOnScroll:P,zoomOnPinch:F,panOnScroll:I,panOnScrollSpeed:L,panOnScrollMode:R,zoomOnDoubleClick:ee,panOnDrag:te,autoPanOnSelection:ne,onPaneClick:z,onPaneMouseEnter:re,onPaneMouseMove:ie,onPaneMouseLeave:ae,onPaneScroll:oe,onPaneContextMenu:se,paneClickDistance:ce,nodeClickDistance:le,onEdgeContextMenu:ue,onEdgeMouseEnter:de,onEdgeMouseMove:fe,onEdgeMouseLeave:pe,reconnectRadius:me,onReconnect:he,onReconnectStart:ge,onReconnectEnd:_e,noDragClassName:ve,noWheelClassName:ye,noPanClassName:be,disableKeyboardA11y:xe,nodeExtent:Se,rfId:Ce,viewport:we,onViewportChange:Te}){return rd(e),rd(t),id(),qu(n),Yu(we),(0,p.jsx)(Jl,{onPaneClick:z,onPaneMouseEnter:re,onPaneMouseMove:ie,onPaneMouseLeave:ae,onPaneContextMenu:se,onPaneScroll:oe,paneClickDistance:ce,deleteKeyCode:T,selectionKeyCode:y,selectionOnDrag:b,selectionMode:x,onSelectionStart:f,onSelectionEnd:m,multiSelectionKeyCode:S,panActivationKeyCode:C,zoomActivationKeyCode:w,elementsSelectable:D,zoomOnScroll:P,zoomOnPinch:F,zoomOnDoubleClick:ee,panOnScroll:I,panOnScrollSpeed:L,panOnScrollMode:R,panOnDrag:te,autoPanOnSelection:ne,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,onSelectionContextMenu:d,preventScrolling:M,noDragClassName:ve,noWheelClassName:ye,noPanClassName:be,disableKeyboardA11y:xe,onViewportChange:Te,isControlledViewport:!!we,children:(0,p.jsxs)(Ku,{children:[(0,p.jsx)(Wu,{edgeTypes:t,onEdgeClick:i,onEdgeDoubleClick:o,onReconnect:he,onReconnectStart:ge,onReconnectEnd:_e,onlyRenderVisibleElements:E,onEdgeContextMenu:ue,onEdgeMouseEnter:de,onEdgeMouseMove:fe,onEdgeMouseLeave:pe,reconnectRadius:me,defaultMarkerColor:N,noPanClassName:be,disableKeyboardA11y:xe,rfId:Ce}),(0,p.jsx)(ed,{style:g,type:h,component:_,containerStyle:v}),(0,p.jsx)(`div`,{className:`react-flow__edgelabel-renderer`}),(0,p.jsx)(iu,{nodeTypes:e,onNodeClick:r,onNodeDoubleClick:a,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,nodeClickDistance:le,onlyRenderVisibleElements:E,noPanClassName:be,noDragClassName:ve,disableKeyboardA11y:xe,nodeExtent:Se,rfId:Ce}),(0,p.jsx)(`div`,{className:`react-flow__viewport-portal`})]})})}ad.displayName=`GraphView`;var od=(0,f.memo)(ad),sd=$a(`React Flow`,`https://reactflow.dev/`),cd=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c=.5,maxZoom:l=2,nodeOrigin:u,nodeExtent:d,zIndexMode:f=`basic`}={})=>{let p=new Map,m=new Map,h=new Map,g=new Map,_=r??t??[],v=n??e??[],y=u??[0,0],b=d??ma;us(h,g,_);let{nodesInitialized:x}=ts(v,p,m,{nodeOrigin:y,nodeExtent:b,zIndexMode:f}),S=[0,0,1];if(o&&i&&a){let{x:e,y:t,zoom:n}=oo(Ma(p,{filter:e=>!!((e.width||e.initialWidth)&&(e.height||e.initialHeight))}),i,a,c,l,s?.padding??.1);S=[e,t,n]}return{rfId:`1`,width:i??0,height:a??0,transform:S,nodes:v,nodesInitialized:x,nodeLookup:p,parentLookup:m,edges:_,edgeLookup:g,connectionLookup:h,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:n!==void 0,hasDefaultEdges:r!==void 0,panZoom:null,minZoom:c,maxZoom:l,translateExtent:ma,nodeExtent:b,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:_a.Strict,domNode:null,paneDragging:!1,noPanClassName:`nopan`,nodeOrigin:y,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!0,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:o??!1,fitViewOptions:s,fitViewResolver:null,connection:{...ba},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:``,autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:sd,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:`react`,debug:!1,ariaLabelConfig:ga,zIndexMode:f,onNodesChangeMiddlewareMap:new Map,onEdgesChangeMiddlewareMap:new Map}},ld=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,zIndexMode:f})=>dc((p,m)=>{async function h(){let{nodeLookup:e,panZoom:t,fitViewOptions:n,fitViewResolver:r,width:i,height:a,minZoom:o,maxZoom:s}=m();t&&(await Ia({nodes:e,width:i,height:a,panZoom:t,minZoom:o,maxZoom:s},n),r?.resolve(!0),p({fitViewResolver:null}))}return{...cd({nodes:e,edges:t,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,defaultNodes:n,defaultEdges:r,zIndexMode:f}),setNodes:e=>{let{nodeLookup:t,parentLookup:n,nodeOrigin:r,elevateNodesOnSelect:i,fitViewQueued:a,zIndexMode:o,nodesSelectionActive:s}=m(),{nodesInitialized:c,hasSelectedNodes:l}=ts(e,t,n,{nodeOrigin:r,nodeExtent:d,elevateNodesOnSelect:i,checkEquality:!0,zIndexMode:o}),u=s&&l;a&&c?(h(),p({nodes:e,nodesInitialized:c,fitViewQueued:!1,fitViewOptions:void 0,nodesSelectionActive:u})):p({nodes:e,nodesInitialized:c,nodesSelectionActive:u})},setEdges:e=>{let{connectionLookup:t,edgeLookup:n}=m();us(t,n,e),p({edges:e})},setDefaultNodesAndEdges:(e,t)=>{if(e){let{setNodes:t}=m();t(e),p({hasDefaultNodes:!0})}if(t){let{setEdges:e}=m();e(t),p({hasDefaultEdges:!0})}},updateNodeInternals:e=>{let{triggerNodeChanges:t,nodeLookup:n,parentLookup:r,domNode:i,nodeOrigin:a,nodeExtent:o,debug:s,fitViewQueued:c,zIndexMode:l}=m(),{changes:u,updatedInternals:d}=ss(e,n,r,i,a,o,l);d&&(Qo(n,r,{nodeOrigin:a,nodeExtent:o,zIndexMode:l}),c?(h(),p({fitViewQueued:!1,fitViewOptions:void 0})):p({}),u?.length>0&&(s&&console.log(`React Flow: trigger node changes`,u),t?.(u)))},updateNodePositions:(e,t=!1)=>{let n=[],r=[],{nodeLookup:i,triggerNodeChanges:a,connection:o,updateConnection:s,onNodesChangeMiddlewareMap:c}=m();for(let[a,c]of e){let e=i.get(a),l=!!(e?.expandParent&&e?.parentId&&c?.position),u={id:a,type:`position`,position:l?{x:Math.max(0,c.position.x),y:Math.max(0,c.position.y)}:c.position,dragging:t};if(e&&o.inProgress&&o.fromNode.id===e.id){let t=Uo(e,o.fromHandle,B.Left,!0);s({...o,from:t})}l&&e.parentId&&n.push({id:a,parentId:e.parentId,rect:{...c.internals.positionAbsolute,width:c.measured.width??0,height:c.measured.height??0}}),r.push(u)}if(n.length>0){let{parentLookup:e,nodeOrigin:t}=m(),a=os(n,i,e,t);r.push(...a)}for(let e of c.values())r=e(r);a(r)},triggerNodeChanges:e=>{let{onNodesChange:t,setNodes:n,nodes:r,hasDefaultNodes:i,debug:a}=m();e?.length&&(i&&n(Xc(e,r)),a&&console.log(`React Flow: trigger node changes`,e),t?.(e))},triggerEdgeChanges:e=>{let{onEdgesChange:t,setEdges:n,edges:r,hasDefaultEdges:i,debug:a}=m();e?.length&&(i&&n(Zc(e,r)),a&&console.log(`React Flow: trigger edge changes`,e),t?.(e))},addSelectedNodes:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=m();if(t){i(e.map(e=>Qc(e,!0)));return}i($c(r,new Set([...e]),!0)),a($c(n))},addSelectedEdges:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=m();if(t){a(e.map(e=>Qc(e,!0)));return}a($c(n,new Set([...e]))),i($c(r,new Set,!0))},unselectNodesAndEdges:({nodes:e,edges:t}={})=>{let{edges:n,nodes:r,nodeLookup:i,triggerNodeChanges:a,triggerEdgeChanges:o}=m(),s=e||r,c=t||n,l=[];for(let e of s){if(!e.selected)continue;let t=i.get(e.id);t&&(t.selected=!1),l.push(Qc(e.id,!1))}let u=[];for(let e of c)e.selected&&u.push(Qc(e.id,!1));a(l),o(u)},setMinZoom:e=>{let{panZoom:t,maxZoom:n}=m();t?.setScaleExtent([e,n]),p({minZoom:e})},setMaxZoom:e=>{let{panZoom:t,minZoom:n}=m();t?.setScaleExtent([n,e]),p({maxZoom:e})},setTranslateExtent:e=>{m().panZoom?.setTranslateExtent(e),p({translateExtent:e})},resetSelectedElements:()=>{let{edges:e,nodes:t,triggerNodeChanges:n,triggerEdgeChanges:r,elementsSelectable:i}=m();if(!i)return;let a=t.reduce((e,t)=>t.selected?[...e,Qc(t.id,!1)]:e,[]),o=e.reduce((e,t)=>t.selected?[...e,Qc(t.id,!1)]:e,[]);n(a),r(o)},setNodeExtent:e=>{let{nodes:t,nodeLookup:n,parentLookup:r,nodeOrigin:i,elevateNodesOnSelect:a,nodeExtent:o,zIndexMode:s}=m();e[0][0]===o[0][0]&&e[0][1]===o[0][1]&&e[1][0]===o[1][0]&&e[1][1]===o[1][1]||(ts(t,n,r,{nodeOrigin:i,nodeExtent:e,elevateNodesOnSelect:a,checkEquality:!1,zIndexMode:s}),p({nodeExtent:e}))},panBy:e=>{let{transform:t,width:n,height:r,panZoom:i,translateExtent:a}=m();return cs({delta:e,panZoom:i,transform:t,translateExtent:a,width:n,height:r})},setCenter:async(e,t,n)=>{let{width:r,height:i,maxZoom:a,panZoom:o}=m();if(!o)return!1;let s=n?.zoom===void 0?a:n.zoom;return await o.setViewport({x:r/2-e*s,y:i/2-t*s,zoom:s},{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),!0},cancelConnection:()=>{p({connection:{...ba}})},updateConnection:e=>{p({connection:e})},reset:()=>p({...cd()})}},Object.is);function ud({initialNodes:e,initialEdges:t,defaultNodes:n,defaultEdges:r,initialWidth:i,initialHeight:a,initialMinZoom:o,initialMaxZoom:s,initialFitViewOptions:c,fitView:l,nodeOrigin:u,nodeExtent:d,zIndexMode:m,children:h}){let[g]=(0,f.useState)(()=>ld({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:l,minZoom:o,maxZoom:s,fitViewOptions:c,nodeOrigin:u,nodeExtent:d,zIndexMode:m}));return(0,p.jsx)(hc,{value:g,children:(0,p.jsx)(dl,{children:h})})}function dd({children:e,nodes:t,edges:n,defaultNodes:r,defaultEdges:i,width:a,height:o,fitView:s,fitViewOptions:c,minZoom:l,maxZoom:u,nodeOrigin:d,nodeExtent:m,zIndexMode:h}){return(0,f.useContext)(mc)?(0,p.jsx)(p.Fragment,{children:e}):(0,p.jsx)(ud,{initialNodes:t,initialEdges:n,defaultNodes:r,defaultEdges:i,initialWidth:a,initialHeight:o,fitView:s,initialFitViewOptions:c,initialMinZoom:l,initialMaxZoom:u,nodeOrigin:d,nodeExtent:m,zIndexMode:h,children:e})}var fd={width:`100%`,height:`100%`,overflow:`hidden`,position:`relative`,zIndex:0};function pd({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,className:i,nodeTypes:a,edgeTypes:o,onNodeClick:s,onEdgeClick:c,onInit:l,onMove:u,onMoveStart:d,onMoveEnd:h,onConnect:g,onConnectStart:_,onConnectEnd:v,onClickConnectStart:y,onClickConnectEnd:b,onNodeMouseEnter:x,onNodeMouseMove:S,onNodeMouseLeave:C,onNodeContextMenu:w,onNodeDoubleClick:T,onNodeDragStart:E,onNodeDrag:D,onNodeDragStop:O,onNodesDelete:k,onEdgesDelete:A,onDelete:j,onSelectionChange:M,onSelectionDragStart:N,onSelectionDrag:P,onSelectionDragStop:F,onSelectionContextMenu:I,onSelectionStart:L,onSelectionEnd:R,onBeforeDelete:ee,connectionMode:te,connectionLineType:ne=xa.Bezier,connectionLineStyle:z,connectionLineComponent:re,connectionLineContainerStyle:ie,deleteKeyCode:ae=`Backspace`,selectionKeyCode:oe=`Shift`,selectionOnDrag:se=!1,selectionMode:ce=ya.Full,panActivationKeyCode:le=`Space`,multiSelectionKeyCode:ue=so()?`Meta`:`Control`,zoomActivationKeyCode:de=so()?`Meta`:`Control`,snapToGrid:fe,snapGrid:pe,onlyRenderVisibleElements:me=!1,selectNodesOnDrag:he,nodesDraggable:ge,autoPanOnNodeFocus:_e,nodesConnectable:ve,nodesFocusable:ye,nodeOrigin:be=Fc,edgesFocusable:xe,edgesReconnectable:Se,elementsSelectable:Ce=!0,defaultViewport:we=Ic,minZoom:Te=.5,maxZoom:Ee=2,translateExtent:De=ma,preventScrolling:Oe=!0,nodeExtent:ke,defaultMarkerColor:Ae=`#b1b1b7`,zoomOnScroll:je=!0,zoomOnPinch:Me=!0,panOnScroll:Ne=!1,panOnScrollSpeed:Pe=.5,panOnScrollMode:Fe=va.Free,zoomOnDoubleClick:Ie=!0,panOnDrag:Le=!0,onPaneClick:Re,onPaneMouseEnter:ze,onPaneMouseMove:Be,onPaneMouseLeave:Ve,onPaneScroll:He,onPaneContextMenu:Ue,paneClickDistance:We=1,nodeClickDistance:Ge=0,children:Ke,onReconnect:qe,onReconnectStart:Je,onReconnectEnd:Ye,onEdgeContextMenu:Xe,onEdgeDoubleClick:Ze,onEdgeMouseEnter:Qe,onEdgeMouseMove:$e,onEdgeMouseLeave:et,reconnectRadius:tt=10,onNodesChange:nt,onEdgesChange:rt,noDragClassName:it=`nodrag`,noWheelClassName:at=`nowheel`,noPanClassName:ot=`nopan`,fitView:st,fitViewOptions:ct,connectOnClick:lt,attributionPosition:ut,proOptions:dt,defaultEdgeOptions:ft,elevateNodesOnSelect:pt=!0,elevateEdgesOnSelect:mt=!1,disableKeyboardA11y:ht=!1,autoPanOnConnect:gt,autoPanOnNodeDrag:_t,autoPanOnSelection:vt=!0,autoPanSpeed:yt,connectionRadius:bt,isValidConnection:xt,onError:St,style:Ct,id:wt,nodeDragThreshold:Tt,connectionDragThreshold:Et,viewport:Dt,onViewportChange:Ot,width:kt,height:At,colorMode:jt=`light`,debug:Mt,onScroll:Nt,ariaLabelConfig:Pt,zIndexMode:Ft=`basic`,...It},Lt){let Rt=wt||`1`,zt=Hc(jt),Bt=(0,f.useCallback)(e=>{e.currentTarget.scrollTo({top:0,left:0,behavior:`instant`}),Nt?.(e)},[Nt]);return(0,p.jsx)(`div`,{"data-testid":`rf__wrapper`,...It,onScroll:Bt,style:{...Ct,...fd},ref:Lt,className:m([`react-flow`,i,zt]),id:wt,role:`application`,children:(0,p.jsxs)(dd,{nodes:e,edges:t,width:kt,height:At,fitView:st,fitViewOptions:ct,minZoom:Te,maxZoom:Ee,nodeOrigin:be,nodeExtent:ke,zIndexMode:Ft,children:[(0,p.jsx)(Bc,{nodes:e,edges:t,defaultNodes:n,defaultEdges:r,onConnect:g,onConnectStart:_,onConnectEnd:v,onClickConnectStart:y,onClickConnectEnd:b,nodesDraggable:ge,autoPanOnNodeFocus:_e,nodesConnectable:ve,nodesFocusable:ye,edgesFocusable:xe,edgesReconnectable:Se,elementsSelectable:Ce,elevateNodesOnSelect:pt,elevateEdgesOnSelect:mt,minZoom:Te,maxZoom:Ee,nodeExtent:ke,onNodesChange:nt,onEdgesChange:rt,snapToGrid:fe,snapGrid:pe,connectionMode:te,translateExtent:De,connectOnClick:lt,defaultEdgeOptions:ft,fitView:st,fitViewOptions:ct,onNodesDelete:k,onEdgesDelete:A,onDelete:j,onNodeDragStart:E,onNodeDrag:D,onNodeDragStop:O,onSelectionDrag:P,onSelectionDragStart:N,onSelectionDragStop:F,onMove:u,onMoveStart:d,onMoveEnd:h,noPanClassName:ot,nodeOrigin:be,rfId:Rt,autoPanOnConnect:gt,autoPanOnNodeDrag:_t,autoPanSpeed:yt,onError:St,connectionRadius:bt,isValidConnection:xt,selectNodesOnDrag:he,nodeDragThreshold:Tt,connectionDragThreshold:Et,onBeforeDelete:ee,debug:Mt,ariaLabelConfig:Pt,zIndexMode:Ft}),(0,p.jsx)(od,{onInit:l,onNodeClick:s,onEdgeClick:c,onNodeMouseEnter:x,onNodeMouseMove:S,onNodeMouseLeave:C,onNodeContextMenu:w,onNodeDoubleClick:T,nodeTypes:a,edgeTypes:o,connectionLineType:ne,connectionLineStyle:z,connectionLineComponent:re,connectionLineContainerStyle:ie,selectionKeyCode:oe,selectionOnDrag:se,selectionMode:ce,deleteKeyCode:ae,multiSelectionKeyCode:ue,panActivationKeyCode:le,zoomActivationKeyCode:de,onlyRenderVisibleElements:me,defaultViewport:we,translateExtent:De,minZoom:Te,maxZoom:Ee,preventScrolling:Oe,zoomOnScroll:je,zoomOnPinch:Me,zoomOnDoubleClick:Ie,panOnScroll:Ne,panOnScrollSpeed:Pe,panOnScrollMode:Fe,panOnDrag:Le,autoPanOnSelection:vt,onPaneClick:Re,onPaneMouseEnter:ze,onPaneMouseMove:Be,onPaneMouseLeave:Ve,onPaneScroll:He,onPaneContextMenu:Ue,paneClickDistance:We,nodeClickDistance:Ge,onSelectionContextMenu:I,onSelectionStart:L,onSelectionEnd:R,onReconnect:qe,onReconnectStart:Je,onReconnectEnd:Ye,onEdgeContextMenu:Xe,onEdgeDoubleClick:Ze,onEdgeMouseEnter:Qe,onEdgeMouseMove:$e,onEdgeMouseLeave:et,reconnectRadius:tt,defaultMarkerColor:Ae,noDragClassName:it,noWheelClassName:at,noPanClassName:ot,rfId:Rt,disableKeyboardA11y:ht,nodeExtent:ke,viewport:Dt,onViewportChange:Ot}),(0,p.jsx)(Pc,{onSelectionChange:M}),Ke,(0,p.jsx)(Oc,{proOptions:dt,position:ut}),(0,p.jsx)(Ec,{rfId:Rt,disableKeyboardA11y:ht})]})})}var md=ol(pd);function hd(){let e=_c();return(0,f.useCallback)(t=>{let{domNode:n,updateNodeInternals:r}=e.getState(),i=Array.isArray(t)?t:[t],a=new Map;i.forEach(e=>{let t=n?.querySelector(`.react-flow__node[data-id="${e}"]`);t&&a.set(e,{id:e,nodeElement:t,force:!0})}),requestAnimationFrame(()=>r(a,{triggerFitView:!1}))},[])}var gd=e=>({x:e.transform[0],y:e.transform[1],zoom:e.transform[2]});function _d(){return H(gd,fc)}pa.error014();function vd({dimensions:e,lineWidth:t,variant:n,className:r}){return(0,p.jsx)(`path`,{strokeWidth:t,d:`M${e[0]/2} 0 V${e[1]} M0 ${e[1]/2} H${e[0]}`,className:m([`react-flow__background-pattern`,n,r])})}function yd({radius:e,className:t}){return(0,p.jsx)(`circle`,{cx:e,cy:e,r:e,className:m([`react-flow__background-pattern`,`dots`,t])})}var bd;(function(e){e.Lines=`lines`,e.Dots=`dots`,e.Cross=`cross`})(bd||={});var xd={[bd.Dots]:1,[bd.Lines]:1,[bd.Cross]:6},Sd=e=>({transform:e.transform,patternId:`pattern-${e.rfId}`});function Cd({id:e,variant:t=bd.Dots,gap:n=20,size:r,lineWidth:i=1,offset:a=0,color:o,bgColor:s,style:c,className:l,patternClassName:u}){let d=(0,f.useRef)(null),{transform:h,patternId:g}=H(Sd,fc),_=r||xd[t],v=t===bd.Dots,y=t===bd.Cross,b=Array.isArray(n)?n:[n,n],x=[b[0]*h[2]||1,b[1]*h[2]||1],S=_*h[2],C=Array.isArray(a)?a:[a,a],w=y?[S,S]:x,T=[C[0]*h[2]||1+w[0]/2,C[1]*h[2]||1+w[1]/2],E=`${g}${e||``}`;return(0,p.jsxs)(`svg`,{className:m([`react-flow__background`,l]),style:{...c,...yl,"--xy-background-color-props":s,"--xy-background-pattern-color-props":o},ref:d,"data-testid":`rf__background`,children:[(0,p.jsx)(`pattern`,{id:E,x:h[0]%x[0],y:h[1]%x[1],width:x[0],height:x[1],patternUnits:`userSpaceOnUse`,patternTransform:`translate(-${T[0]},-${T[1]})`,children:v?(0,p.jsx)(yd,{radius:S/2,className:u}):(0,p.jsx)(vd,{dimensions:w,lineWidth:i,variant:t,className:u})}),(0,p.jsx)(`rect`,{x:`0`,y:`0`,width:`100%`,height:`100%`,fill:`url(#${E})`})]})}Cd.displayName=`Background`;var wd=(0,f.memo)(Cd);function Td(){return(0,p.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 32`,children:(0,p.jsx)(`path`,{d:`M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z`})})}function Ed(){return(0,p.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 5`,children:(0,p.jsx)(`path`,{d:`M0 0h32v4.2H0z`})})}function G(){return(0,p.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 30`,children:(0,p.jsx)(`path`,{d:`M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z`})})}function Dd(){return(0,p.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,p.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z`})})}function Od(){return(0,p.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,p.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z`})})}function kd({children:e,className:t,...n}){return(0,p.jsx)(`button`,{type:`button`,className:m([`react-flow__controls-button`,t]),...n,children:e})}var Ad=e=>({isInteractive:e.nodesDraggable||e.nodesConnectable||e.elementsSelectable,minZoomReached:e.transform[2]<=e.minZoom,maxZoomReached:e.transform[2]>=e.maxZoom,ariaLabelConfig:e.ariaLabelConfig});function jd({style:e,showZoom:t=!0,showFitView:n=!0,showInteractive:r=!0,fitViewOptions:i,onZoomIn:a,onZoomOut:o,onFitView:s,onInteractiveChange:c,className:l,children:u,position:d=`bottom-left`,orientation:f=`vertical`,"aria-label":h}){let g=_c(),{isInteractive:_,minZoomReached:v,maxZoomReached:y,ariaLabelConfig:b}=H(Ad,fc),{zoomIn:x,zoomOut:S,fitView:C}=ml();return(0,p.jsxs)(Dc,{className:m([`react-flow__controls`,f===`horizontal`?`horizontal`:`vertical`,l]),position:d,style:e,"data-testid":`rf__controls`,"aria-label":h??b[`controls.ariaLabel`],children:[t&&(0,p.jsxs)(p.Fragment,{children:[(0,p.jsx)(kd,{onClick:()=>{x(),a?.()},className:`react-flow__controls-zoomin`,title:b[`controls.zoomIn.ariaLabel`],"aria-label":b[`controls.zoomIn.ariaLabel`],disabled:y,children:(0,p.jsx)(Td,{})}),(0,p.jsx)(kd,{onClick:()=>{S(),o?.()},className:`react-flow__controls-zoomout`,title:b[`controls.zoomOut.ariaLabel`],"aria-label":b[`controls.zoomOut.ariaLabel`],disabled:v,children:(0,p.jsx)(Ed,{})})]}),n&&(0,p.jsx)(kd,{className:`react-flow__controls-fitview`,onClick:()=>{C(i),s?.()},title:b[`controls.fitView.ariaLabel`],"aria-label":b[`controls.fitView.ariaLabel`],children:(0,p.jsx)(G,{})}),r&&(0,p.jsx)(kd,{className:`react-flow__controls-interactive`,onClick:()=>{g.setState({nodesDraggable:!_,nodesConnectable:!_,elementsSelectable:!_}),c?.(!_)},title:b[`controls.interactive.ariaLabel`],"aria-label":b[`controls.interactive.ariaLabel`],children:_?(0,p.jsx)(Od,{}):(0,p.jsx)(Dd,{})}),u]})}jd.displayName=`Controls`,(0,f.memo)(jd);function Md({id:e,x:t,y:n,width:r,height:i,style:a,color:o,strokeColor:s,strokeWidth:c,className:l,borderRadius:u,shapeRendering:d,selected:f,onClick:h}){let{background:g,backgroundColor:_}=a||{},v=o||g||_;return(0,p.jsx)(`rect`,{className:m([`react-flow__minimap-node`,{selected:f},l]),x:t,y:n,rx:u,ry:u,width:r,height:i,style:{fill:v,stroke:s,strokeWidth:c},shapeRendering:d,onClick:h?t=>h(t,e):void 0})}var Nd=(0,f.memo)(Md),Pd=e=>e.nodes.map(e=>e.id),Fd=e=>e instanceof Function?e:()=>e;function Id({nodeStrokeColor:e,nodeColor:t,nodeClassName:n=``,nodeBorderRadius:r=5,nodeStrokeWidth:i,nodeComponent:a=Nd,onClick:o}){let s=H(Pd,fc),c=Fd(t),l=Fd(e),u=Fd(n),d=typeof window>`u`||window.chrome?`crispEdges`:`geometricPrecision`;return(0,p.jsx)(p.Fragment,{children:s.map(e=>(0,p.jsx)(Rd,{id:e,nodeColorFunc:c,nodeStrokeColorFunc:l,nodeClassNameFunc:u,nodeBorderRadius:r,nodeStrokeWidth:i,NodeComponent:a,onClick:o,shapeRendering:d},e))})}function Ld({id:e,nodeColorFunc:t,nodeStrokeColorFunc:n,nodeClassNameFunc:r,nodeBorderRadius:i,nodeStrokeWidth:a,shapeRendering:o,NodeComponent:s,onClick:c}){let{node:l,x:u,y:d,width:f,height:m}=H(t=>{let n=t.nodeLookup.get(e);if(!n)return{node:void 0,x:0,y:0,width:0,height:0};let r=n.internals.userNode,{x:i,y:a}=n.internals.positionAbsolute,{width:o,height:s}=lo(r);return{node:r,x:i,y:a,width:o,height:s}},fc);return!l||l.hidden||!uo(l)?null:(0,p.jsx)(s,{x:u,y:d,width:f,height:m,style:l.style,selected:!!l.selected,className:r(l),color:t(l),borderRadius:i,strokeColor:n(l),strokeWidth:a,shapeRendering:o,onClick:c,id:l.id})}var Rd=(0,f.memo)(Ld),zd=(0,f.memo)(Id),Bd=200,Vd=150,Hd=e=>!e.hidden,Ud=e=>{let t={x:-e.transform[0]/e.transform[2],y:-e.transform[1]/e.transform[2],width:e.width/e.transform[2],height:e.height/e.transform[2]};return{viewBB:t,boundingRect:e.nodeLookup.size>0?Ya(Ma(e.nodeLookup,{filter:Hd}),t):t,rfId:e.rfId,panZoom:e.panZoom,translateExtent:e.translateExtent,flowWidth:e.width,flowHeight:e.height,ariaLabelConfig:e.ariaLabelConfig}},Wd=`react-flow__minimap-desc`;function Gd({style:e,className:t,nodeStrokeColor:n,nodeColor:r,nodeClassName:i=``,nodeBorderRadius:a=5,nodeStrokeWidth:o,nodeComponent:s,bgColor:c,maskColor:l,maskStrokeColor:u,maskStrokeWidth:d,position:h=`bottom-right`,onClick:g,onNodeClick:_,pannable:v=!1,zoomable:y=!1,ariaLabel:b,inversePan:x,zoomStep:S=1,offsetScale:C=5}){let w=_c(),T=(0,f.useRef)(null),{boundingRect:E,viewBB:D,rfId:O,panZoom:k,translateExtent:A,flowWidth:j,flowHeight:M,ariaLabelConfig:N}=H(Ud,fc),P=e?.width??Bd,F=e?.height??Vd,I=E.width/P,L=E.height/F,R=Math.max(I,L),ee=R*P,te=R*F,ne=C*R,z=E.x-(ee-E.width)/2-ne,re=E.y-(te-E.height)/2-ne,ie=ee+ne*2,ae=te+ne*2,oe=`${Wd}-${O}`,se=(0,f.useRef)(0),ce=(0,f.useRef)();se.current=R,(0,f.useEffect)(()=>{if(T.current&&k)return ce.current=Ds({domNode:T.current,panZoom:k,getTransform:()=>w.getState().transform,getViewScale:()=>se.current}),()=>{ce.current?.destroy()}},[k]),(0,f.useEffect)(()=>{ce.current?.update({translateExtent:A,width:j,height:M,inversePan:x,pannable:v,zoomStep:S,zoomable:y})},[v,y,x,S,A,j,M]);let le=g?e=>{let[t,n]=ce.current?.pointer(e)||[0,0];g(e,{x:t,y:n})}:void 0,ue=_?(0,f.useCallback)((e,t)=>{let n=w.getState().nodeLookup.get(t).internals.userNode;_(e,n)},[]):void 0,de=b??N[`minimap.ariaLabel`];return(0,p.jsx)(Dc,{position:h,style:{...e,"--xy-minimap-background-color-props":typeof c==`string`?c:void 0,"--xy-minimap-mask-background-color-props":typeof l==`string`?l:void 0,"--xy-minimap-mask-stroke-color-props":typeof u==`string`?u:void 0,"--xy-minimap-mask-stroke-width-props":typeof d==`number`?d*R:void 0,"--xy-minimap-node-background-color-props":typeof r==`string`?r:void 0,"--xy-minimap-node-stroke-color-props":typeof n==`string`?n:void 0,"--xy-minimap-node-stroke-width-props":typeof o==`number`?o:void 0},className:m([`react-flow__minimap`,t]),"data-testid":`rf__minimap`,children:(0,p.jsxs)(`svg`,{width:P,height:F,viewBox:`${z} ${re} ${ie} ${ae}`,className:`react-flow__minimap-svg`,role:`img`,"aria-labelledby":oe,ref:T,onClick:le,children:[de&&(0,p.jsx)(`title`,{id:oe,children:de}),(0,p.jsx)(zd,{onClick:ue,nodeColor:r,nodeStrokeColor:n,nodeBorderRadius:a,nodeClassName:i,nodeStrokeWidth:o,nodeComponent:s}),(0,p.jsx)(`path`,{className:`react-flow__minimap-mask`,d:`M${z-ne},${re-ne}h${ie+ne*2}v${ae+ne*2}h${-ie-ne*2}z - M${D.x},${D.y}h${D.width}v${D.height}h${-D.width}z`,fillRule:`evenodd`,pointerEvents:`none`})]})})}Gd.displayName=`MiniMap`,(0,f.memo)(Gd);var Kd=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,qd={[Hs.Line]:`right`,[Hs.Handle]:`bottom-right`};function Jd({nodeId:e,position:t,variant:n=Hs.Handle,className:r,style:i=void 0,children:a,color:o,minWidth:s=10,minHeight:c=10,maxWidth:l=Number.MAX_VALUE,maxHeight:u=Number.MAX_VALUE,keepAspectRatio:d=!1,resizeDirection:h,autoScale:g=!0,shouldResize:_,onResizeStart:v,onResize:y,onResizeEnd:b}){let x=Nl(),S=typeof e==`string`?e:x,C=_c(),w=(0,f.useRef)(null),T=n===Hs.Handle,E=H((0,f.useCallback)(Kd(T&&g),[T,g]),fc),D=(0,f.useRef)(null),O=t??qd[n];return(0,f.useEffect)(()=>{if(!(!w.current||!S))return D.current||=$s({domNode:w.current,nodeId:S,getStoreItems:()=>{let{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,domNode:a}=C.getState();return{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,paneDomNode:a}},onChange:(e,t)=>{let{triggerNodeChanges:n,nodeLookup:r,parentLookup:i,nodeOrigin:a}=C.getState(),o=[],s={x:e.x,y:e.y},c=r.get(S);if(c&&c.expandParent&&c.parentId){let t=c.origin??a,n=e.width??c.measured.width??0,l=e.height??c.measured.height??0,u=os([{id:c.id,parentId:c.parentId,rect:{width:n,height:l,...fo({x:e.x??c.position.x,y:e.y??c.position.y},{width:n,height:l},c.parentId,r,t)}}],r,i,a);o.push(...u),s.x=e.x?Math.max(t[0]*n,e.x):void 0,s.y=e.y?Math.max(t[1]*l,e.y):void 0}if(s.x!==void 0&&s.y!==void 0){let e={id:S,type:`position`,position:{...s}};o.push(e)}if(e.width!==void 0&&e.height!==void 0){let t={id:S,type:`dimensions`,resizing:!0,setAttributes:h?h===`horizontal`?`width`:`height`:!0,dimensions:{width:e.width,height:e.height}};o.push(t)}for(let e of t){let t={...e,type:`position`};o.push(t)}n(o)},onEnd:({width:e,height:t})=>{let n={id:S,type:`dimensions`,resizing:!1,dimensions:{width:e,height:t}};C.getState().triggerNodeChanges([n])}}),D.current.update({controlPosition:O,boundaries:{minWidth:s,minHeight:c,maxWidth:l,maxHeight:u},keepAspectRatio:d,resizeDirection:h,onResizeStart:v,onResize:y,onResizeEnd:b,shouldResize:_}),()=>{D.current?.destroy()}},[O,s,c,l,u,d,v,y,b,_]),(0,p.jsx)(`div`,{className:m([`react-flow__resize-control`,`nodrag`,...O.split(`-`),n,r]),ref:w,style:{...i,scale:E,...o&&{[T?`backgroundColor`:`borderColor`]:o}},children:a})}var Yd=(0,f.memo)(Jd),Xd=e=>e?.ownerDocument??document,Zd=e=>e&&`window`in e&&e.window===e?e:Xd(e).defaultView||window;function Qd(e){return typeof e==`object`&&!!e&&`nodeType`in e&&typeof e.nodeType==`number`}function $d(e){return Qd(e)&&e.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&`host`in e}var ef=!1;function tf(){return ef}function nf(e,t){if(!tf())return t&&e?e.contains(t):!1;if(!e||!t)return!1;let n=t;for(;n!==null;){if(n===e)return!0;n=n.tagName===`SLOT`&&n.assignedSlot?n.assignedSlot.parentNode:$d(n)?n.host:n.parentNode}return!1}var rf=(e=document)=>{if(!tf())return e.activeElement;let t=e.activeElement;for(;t&&`shadowRoot`in t&&t.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t};function af(e){if(tf()&&e.target instanceof Element&&e.target.shadowRoot){if(`composedPath`in e)return e.composedPath()[0]??null;if(`composedPath`in e.nativeEvent)return e.nativeEvent.composedPath()[0]??null}return e.target}function of(e){if(cf())e.focus({preventScroll:!0});else{let t=lf(e);e.focus(),uf(t)}}var sf=null;function cf(){if(sf==null){sf=!1;try{document.createElement(`div`).focus({get preventScroll(){return sf=!0,!0}})}catch{}}return sf}function lf(e){let t=e.parentNode,n=[],r=document.scrollingElement||document.documentElement;for(;t instanceof HTMLElement&&t!==r;)(t.offsetHeight{};function ff(e){let t=e;return t.nativeEvent=e,t.isDefaultPrevented=()=>t.defaultPrevented,t.isPropagationStopped=()=>t.cancelBubble,t.persist=()=>{},t}function pf(e,t){Object.defineProperty(e,"target",{value:t}),Object.defineProperty(e,"currentTarget",{value:t})}function mf(e){let t=(0,f.useRef)({isFocused:!1,observer:null});return df(()=>{let e=t.current;return()=>{e.observer&&=(e.observer.disconnect(),null)}},[]),(0,f.useCallback)(n=>{let r=af(n);if(r instanceof HTMLButtonElement||r instanceof HTMLInputElement||r instanceof HTMLTextAreaElement||r instanceof HTMLSelectElement){t.current.isFocused=!0;let n=r;n.addEventListener(`focusout`,r=>{if(t.current.isFocused=!1,n.disabled){let t=ff(r);e?.(t)}t.current.observer&&(t.current.observer.disconnect(),t.current.observer=null)},{once:!0}),t.current.observer=new MutationObserver(()=>{if(t.current.isFocused&&n.disabled){t.current.observer?.disconnect();let e=n===rf()?null:rf();n.dispatchEvent(new FocusEvent(`blur`,{relatedTarget:e})),n.dispatchEvent(new FocusEvent(`focusout`,{bubbles:!0,relatedTarget:e}))}}),t.current.observer.observe(n,{attributes:!0,attributeFilter:[`disabled`]})}},[e])}function hf(e){if(typeof window>`u`||window.navigator==null)return!1;let t=window.navigator.userAgentData?.brands;return Array.isArray(t)&&t.some(t=>e.test(t.brand))||e.test(window.navigator.userAgent)}function gf(e){return typeof window<`u`&&window.navigator!=null&&e.test(window.navigator.userAgentData?.platform||window.navigator.platform)}function _f(e){let t=null;return()=>(t??=e(),t)}var vf=_f(function(){return gf(/^Mac/i)}),yf=_f(function(){return gf(/^iPad/i)||vf()&&navigator.maxTouchPoints>1}),bf=_f(function(){return hf(/AppleWebKit/i)&&!xf()}),xf=_f(function(){return hf(/Chrome/i)}),Sf=_f(function(){return hf(/Android/i)}),Cf=_f(function(){return hf(/Firefox/i)});function wf(e){return e.pointerType===``&&e.isTrusted?!0:Sf()&&e.pointerType?e.type===`click`&&e.buttons===1:e.detail===0&&!e.pointerType}function Tf(e,t,n=!0){let{metaKey:r,ctrlKey:i,altKey:a,shiftKey:o}=t;Cf()&&window.event?.type?.startsWith(`key`)&&e.target===`_blank`&&(vf()?r=!0:i=!0);let s=bf()&&vf()&&!yf()?new KeyboardEvent(`keydown`,{keyIdentifier:`Enter`,metaKey:r,ctrlKey:i,altKey:a,shiftKey:o}):new MouseEvent(`click`,{metaKey:r,ctrlKey:i,altKey:a,shiftKey:o,detail:1,bubbles:!0,cancelable:!0});Tf.isOpening=n,of(e),e.dispatchEvent(s),Tf.isOpening=!1}Tf.isOpening=!1;var Ef=null,Df=new Set,Of=new Map,kf=!1,Af=!1,jf={Tab:!0,Escape:!0};function Mf(e,t){for(let n of Df)n(e,t)}function Nf(e){return!(e.metaKey||!vf()&&e.altKey||e.ctrlKey||e.key===`Control`||e.key===`Shift`||e.key===`Meta`)}function Pf(e){kf=!0,!Tf.isOpening&&Nf(e)&&(Ef=`keyboard`,Mf(`keyboard`,e))}function Ff(e){Ef=`pointer`,`pointerType`in e&&e.pointerType,(e.type===`mousedown`||e.type===`pointerdown`)&&(kf=!0,Mf(`pointer`,e))}function If(e){!Tf.isOpening&&wf(e)&&(kf=!0,Ef=`virtual`)}function Lf(e){let t=Zd(af(e)),n=Xd(af(e));af(e)===t||af(e)===n||!e.isTrusted||(!kf&&!Af&&(Ef=`virtual`,Mf(`virtual`,e)),kf=!1,Af=!1)}function Rf(){kf=!1,Af=!0}function zf(e){if(typeof window>`u`||typeof document>`u`)return;let t=Zd(e),n=Xd(e);if(Of.get(t))return;let r=t.HTMLElement.prototype.focus;t.HTMLElement.prototype.focus=function(){kf=!0,r.apply(this,arguments)},n.addEventListener(`keydown`,Pf,!0),n.addEventListener(`keyup`,Pf,!0),n.addEventListener(`click`,If,!0),t.addEventListener(`focus`,Lf,!0),t.addEventListener(`blur`,Rf,!1),typeof PointerEvent<`u`&&(n.addEventListener(`pointerdown`,Ff,!0),n.addEventListener(`pointermove`,Ff,!0),n.addEventListener(`pointerup`,Ff,!0)),t.addEventListener(`beforeunload`,()=>{Bf(e)},{once:!0}),Of.set(t,{focus:r})}var Bf=(e,t)=>{let n=Zd(e),r=Xd(e);t&&r.removeEventListener(`DOMContentLoaded`,t),Of.has(n)&&(n.HTMLElement.prototype.focus=Of.get(n).focus,r.removeEventListener(`keydown`,Pf,!0),r.removeEventListener(`keyup`,Pf,!0),r.removeEventListener(`click`,If,!0),n.removeEventListener(`focus`,Lf,!0),n.removeEventListener(`blur`,Rf,!1),typeof PointerEvent<`u`&&(r.removeEventListener(`pointerdown`,Ff,!0),r.removeEventListener(`pointermove`,Ff,!0),r.removeEventListener(`pointerup`,Ff,!0)),Of.delete(n))};function Vf(e){let t=Xd(e),n;return t.readyState===`loading`?(n=()=>{zf(e)},t.addEventListener(`DOMContentLoaded`,n)):zf(e),()=>Bf(e,n)}typeof document<`u`&&Vf();function Hf(){return Ef!==`pointer`}var Uf=new Set([`checkbox`,`radio`,`range`,`color`,`file`,`image`,`button`,`submit`,`reset`]);function Wf(e,t,n){let r=n?af(n):void 0,i=Xd(r),a=Zd(r),o=a===void 0?HTMLInputElement:a.HTMLInputElement,s=a===void 0?HTMLTextAreaElement:a.HTMLTextAreaElement,c=a===void 0?HTMLElement:a.HTMLElement,l=a===void 0?KeyboardEvent:a.KeyboardEvent,u=rf(i);return e=e||u instanceof o&&!Uf.has(u.type)||u instanceof s||u instanceof c&&u.isContentEditable,!(e&&t===`keyboard`&&n instanceof l&&!jf[n.key])}function Gf(e,t,n){zf(),(0,f.useEffect)(()=>{if(n?.enabled===!1)return;let t=(t,r)=>{Wf(!!n?.isTextInput,t,r)&&e(Hf())};return Df.add(t),()=>{Df.delete(t)}},t)}function Kf(e){let{isDisabled:t,onFocus:n,onBlur:r,onFocusChange:i}=e,a=(0,f.useCallback)(e=>{if(af(e)===e.currentTarget)return r&&r(e),i&&i(!1),!0},[r,i]),o=mf(a),s=(0,f.useCallback)(e=>{let t=af(e),r=Xd(t),a=r?rf(r):rf();t===e.currentTarget&&t===a&&(n&&n(e),i&&i(!0),o(e))},[i,n,o]);return{focusProps:{onFocus:!t&&(n||i||r)?s:void 0,onBlur:!t&&(r||i)?a:void 0}}}function qf(){let e=(0,f.useRef)(new Map),t=(0,f.useCallback)((t,n,r,i)=>{let a=i?.once?(...t)=>{e.current.delete(r),r(...t)}:r;e.current.set(r,{type:n,eventTarget:t,fn:a,options:i}),t.addEventListener(n,a,i)},[]),n=(0,f.useCallback)((t,n,r,i)=>{let a=e.current.get(r)?.fn||r;t.removeEventListener(n,a,i),e.current.delete(r)},[]),r=(0,f.useCallback)(()=>{e.current.forEach((e,t)=>{n(e.eventTarget,e.type,t,e.options)})},[n]);return(0,f.useEffect)(()=>r,[r]),{addGlobalListener:t,removeGlobalListener:n,removeAllGlobalListeners:r}}function Jf(e){let{isDisabled:t,onBlurWithin:n,onFocusWithin:r,onFocusWithinChange:i}=e,a=(0,f.useRef)({isFocusWithin:!1}),{addGlobalListener:o,removeAllGlobalListeners:s}=qf(),c=(0,f.useCallback)(e=>{nf(e.currentTarget,af(e))&&a.current.isFocusWithin&&!nf(e.currentTarget,e.relatedTarget)&&(a.current.isFocusWithin=!1,s(),n&&n(e),i&&i(!1))},[n,i,a,s]),l=mf(c),u=(0,f.useCallback)(e=>{if(!nf(e.currentTarget,af(e)))return;let t=af(e),n=Xd(t),s=rf(n);if(!a.current.isFocusWithin&&s===t){r&&r(e),i&&i(!0),a.current.isFocusWithin=!0,l(e);let t=e.currentTarget;o(n,`focus`,e=>{let r=af(e);if(a.current.isFocusWithin&&!nf(t,r)){let e=new n.defaultView.FocusEvent(`blur`,{relatedTarget:r});pf(e,t);let i=ff(e);c(i)}},{capture:!0})}},[r,i,l,o,c]);return t?{focusWithinProps:{onFocus:void 0,onBlur:void 0}}:{focusWithinProps:{onFocus:u,onBlur:c}}}function Yf(e={}){let{autoFocus:t=!1,isTextInput:n,within:r}=e,i=(0,f.useRef)({isFocused:!1,isFocusVisible:t||Hf()}),[a,o]=(0,f.useState)(!1),[s,c]=(0,f.useState)(()=>i.current.isFocused&&i.current.isFocusVisible),l=(0,f.useCallback)(()=>c(i.current.isFocused&&i.current.isFocusVisible),[]),u=(0,f.useCallback)(e=>{i.current.isFocused=e,i.current.isFocusVisible=Hf(),o(e),l()},[l]);Gf(e=>{i.current.isFocusVisible=e,l()},[n,a],{enabled:a,isTextInput:n});let{focusProps:d}=Kf({isDisabled:r,onFocusChange:u}),{focusWithinProps:p}=Jf({isDisabled:!r,onFocusWithinChange:u});return{isFocused:a,isFocusVisible:s,focusProps:r?p:d}}var Xf=!1,Zf=0;function Qf(){Xf=!0,setTimeout(()=>{Xf=!1},500)}function $f(e){e.pointerType===`touch`&&Qf()}function ep(){let e=Xd(null);if(e!==void 0)return Zf===0&&typeof PointerEvent<`u`&&e.addEventListener(`pointerup`,$f),Zf++,()=>{Zf--,!(Zf>0)&&typeof PointerEvent<`u`&&e.removeEventListener(`pointerup`,$f)}}function tp(e){let{onHoverStart:t,onHoverChange:n,onHoverEnd:r,isDisabled:i}=e,[a,o]=(0,f.useState)(!1),s=(0,f.useRef)({isHovered:!1,ignoreEmulatedMouseEvents:!1,pointerType:``,target:null}).current;(0,f.useEffect)(ep,[]);let{addGlobalListener:c,removeAllGlobalListeners:l}=qf(),{hoverProps:u,triggerHoverEnd:d}=(0,f.useMemo)(()=>{let e=(e,r)=>{if(s.pointerType=r,i||r===`touch`||s.isHovered||!nf(e.currentTarget,af(e)))return;s.isHovered=!0;let l=e.currentTarget;s.target=l,c(Xd(af(e)),`pointerover`,e=>{s.isHovered&&s.target&&!nf(s.target,af(e))&&a(e,e.pointerType)},{capture:!0}),t&&t({type:`hoverstart`,target:l,pointerType:r}),n&&n(!0),o(!0)},a=(e,t)=>{let i=s.target;s.pointerType=``,s.target=null,!(t===`touch`||!s.isHovered||!i)&&(s.isHovered=!1,l(),r&&r({type:`hoverend`,target:i,pointerType:t}),n&&n(!1),o(!1))},u={};return typeof PointerEvent<`u`&&(u.onPointerEnter=t=>{Xf&&t.pointerType===`mouse`||e(t,t.pointerType)},u.onPointerLeave=e=>{!i&&nf(e.currentTarget,af(e))&&a(e,e.pointerType)}),{hoverProps:u,triggerHoverEnd:a}},[t,n,r,i,s,c,l]);return(0,f.useEffect)(()=>{i&&d({currentTarget:s.target},s.pointerType)},[i]),{hoverProps:u,isHovered:a}}var np=Object.defineProperty,rp=(e,t,n)=>t in e?np(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,ip=(e,t,n)=>(rp(e,typeof t==`symbol`?t:t+``,n),n),ap=new class{constructor(){ip(this,`current`,this.detect()),ip(this,`handoffState`,`pending`),ip(this,`currentId`,0)}set(e){this.current!==e&&(this.handoffState=`pending`,this.currentId=0,this.current=e)}reset(){this.set(this.detect())}nextId(){return++this.currentId}get isServer(){return this.current===`server`}get isClient(){return this.current===`client`}detect(){return typeof window>`u`||typeof document>`u`?`server`:`client`}handoff(){this.handoffState===`pending`&&(this.handoffState=`complete`)}get isHandoffComplete(){return this.handoffState===`complete`}};function op(e){return ap.isServer?null:e==null?document:e?.ownerDocument??document}function sp(e){return ap.isServer?null:e==null?document:(e?.getRootNode)?.call(e)??document}function cp(e){return sp(e)?.activeElement??null}function lp(e){return cp(e)===e}function up(e){typeof queueMicrotask==`function`?queueMicrotask(e):Promise.resolve().then(e).catch(e=>setTimeout(()=>{throw e}))}function dp(){let e=[],t={addEventListener(e,n,r,i){return e.addEventListener(n,r,i),t.add(()=>e.removeEventListener(n,r,i))},requestAnimationFrame(...e){let n=requestAnimationFrame(...e);return t.add(()=>cancelAnimationFrame(n))},nextFrame(...e){return t.requestAnimationFrame(()=>t.requestAnimationFrame(...e))},setTimeout(...e){let n=setTimeout(...e);return t.add(()=>clearTimeout(n))},microTask(...e){let n={current:!0};return up(()=>{n.current&&e[0]()}),t.add(()=>{n.current=!1})},style(e,t,n){let r=e.style.getPropertyValue(t);return Object.assign(e.style,{[t]:n}),this.add(()=>{Object.assign(e.style,{[t]:r})})},group(e){let t=dp();return e(t),this.add(()=>t.dispose())},add(t){return e.includes(t)||e.push(t),()=>{let n=e.indexOf(t);if(n>=0)for(let t of e.splice(n,1))t()}},dispose(){for(let t of e.splice(0))t()}};return t}function fp(){let[e]=(0,f.useState)(dp);return(0,f.useEffect)(()=>()=>e.dispose(),[e]),e}var K=(e,t)=>{ap.isServer?(0,f.useEffect)(e,t):(0,f.useLayoutEffect)(e,t)};function pp(e){let t=(0,f.useRef)(e);return K(()=>{t.current=e},[e]),t}var q=function(e){let t=pp(e);return f.useCallback((...e)=>t.current(...e),[t])};function mp(e){let t=e.width/2,n=e.height/2;return{top:e.clientY-n,right:e.clientX+t,bottom:e.clientY+n,left:e.clientX-t}}function hp(e,t){return!(!e||!t||e.rightt.right||e.bottomt.bottom)}function gp({disabled:e=!1}={}){let t=(0,f.useRef)(null),[n,r]=(0,f.useState)(!1),i=fp(),a=q(()=>{t.current=null,r(!1),i.dispose()}),o=q(e=>{if(i.dispose(),t.current===null){t.current=e.currentTarget,r(!0);{let n=op(e.currentTarget);i.addEventListener(n,`pointerup`,a,!1),i.addEventListener(n,`pointermove`,e=>{if(t.current){let n=mp(e);r(hp(n,t.current.getBoundingClientRect()))}},!1),i.addEventListener(n,`pointercancel`,a,!1)}}});return{pressed:n,pressProps:e?{}:{onPointerDown:o,onPointerUp:a,onClick:a}}}function _p(e){return(0,f.useMemo)(()=>e,Object.values(e))}var vp=(0,f.createContext)(void 0);function yp(){return(0,f.useContext)(vp)}function bp(...e){return Array.from(new Set(e.flatMap(e=>typeof e==`string`?e.split(` `):[]))).filter(Boolean).join(` `)}function xp(e,t,...n){if(e in t){let r=t[e];return typeof r==`function`?r(...n):r}let r=Error(`Tried to handle "${e}" but there is no handler defined. Only defined handlers are: ${Object.keys(t).map(e=>`"${e}"`).join(`, `)}.`);throw Error.captureStackTrace&&Error.captureStackTrace(r,xp),r}var Sp=(e=>(e[e.None=0]=`None`,e[e.RenderStrategy=1]=`RenderStrategy`,e[e.Static=2]=`Static`,e))(Sp||{}),Cp=(e=>(e[e.Unmount=0]=`Unmount`,e[e.Hidden=1]=`Hidden`,e))(Cp||{});function J(){let e=Ep();return(0,f.useCallback)(t=>wp({mergeRefs:e,...t}),[e])}function wp({ourProps:e,theirProps:t,slot:n,defaultTag:r,features:i,visible:a=!0,name:o,mergeRefs:s}){s??=Dp;let c=Op(t,e);if(a)return Tp(c,n,r,o,s);let l=i??0;if(l&2){let{static:e=!1,...t}=c;if(e)return Tp(t,n,r,o,s)}if(l&1){let{unmount:e=!0,...t}=c;return xp(+!e,{0(){return null},1(){return Tp({...t,hidden:!0,style:{display:`none`}},n,r,o,s)}})}return Tp(c,n,r,o,s)}function Tp(e,t={},n,r,i){let{as:a=n,children:o,refName:s=`ref`,...c}=jp(e,[`unmount`,`static`]),l=e.ref===void 0?{}:{[s]:e.ref},u=typeof o==`function`?o(t):o;u=Np(u),`className`in c&&c.className&&typeof c.className==`function`&&(c.className=c.className(t)),c[`aria-labelledby`]&&c[`aria-labelledby`]===c.id&&(c[`aria-labelledby`]=void 0);let d={};if(t){let e=!1,n=[];for(let[r,i]of Object.entries(t))typeof i==`boolean`&&(e=!0),i===!0&&n.push(r.replace(/([A-Z])/g,e=>`-${e.toLowerCase()}`));if(e){d[`data-headlessui-state`]=n.join(` `);for(let e of n)d[`data-${e}`]=``}}if(Pp(a)&&(Object.keys(Ap(c)).length>0||Object.keys(Ap(d)).length>0))if(!(0,f.isValidElement)(u)||Array.isArray(u)&&u.length>1||Fp(u)){if(Object.keys(Ap(c)).length>0)throw Error([`Passing props on "Fragment"!`,``,`The current component <${r} /> is rendering a "Fragment".`,`However we need to passthrough the following props:`,Object.keys(Ap(c)).concat(Object.keys(Ap(d))).map(e=>` - ${e}`).join(` -`),``,`You can apply a few solutions:`,['Add an `as="..."` prop, to ensure that we render an actual element instead of a "Fragment".',`Render a single element as the child so that we can forward the props onto that element.`].map(e=>` - ${e}`).join(` -`)].join(` -`))}else{let e=u.props?.className,t=typeof e==`function`?(...t)=>bp(e(...t),c.className):bp(e,c.className),n=t?{className:t}:{},r=Op(u.props,Ap(jp(c,[`ref`])));for(let e in d)e in r&&delete d[e];return(0,f.cloneElement)(u,Object.assign({},r,d,l,{ref:i(Mp(u),l.ref)},n))}return(0,f.createElement)(a,Object.assign({},jp(c,[`ref`]),!Pp(a)&&l,!Pp(a)&&d),u)}function Ep(){let e=(0,f.useRef)([]),t=(0,f.useCallback)(t=>{for(let n of e.current)n!=null&&(typeof n==`function`?n(t):n.current=t)},[]);return(...n)=>{if(!n.every(e=>e==null))return e.current=n,t}}function Dp(...e){return e.every(e=>e==null)?void 0:t=>{for(let n of e)n!=null&&(typeof n==`function`?n(t):n.current=t)}}function Op(...e){if(e.length===0)return{};if(e.length===1)return e[0];let t={},n={};for(let r of e)for(let e in r)e.startsWith(`on`)&&typeof r[e]==`function`?(n[e]??(n[e]=[]),n[e].push(r[e])):t[e]=r[e];if(t.disabled||t[`aria-disabled`])for(let e in n)/^(on(?:Click|Pointer|Mouse|Key)(?:Down|Up|Press)?)$/.test(e)&&(n[e]=[e=>(e?.preventDefault)?.call(e)]);for(let e in n)Object.assign(t,{[e](t,...r){let i=n[e];for(let e of i){if((t instanceof Event||t?.nativeEvent instanceof Event)&&t.defaultPrevented)return;e(t,...r)}}});return t}function kp(...e){if(e.length===0)return{};if(e.length===1)return e[0];let t={},n={};for(let r of e)for(let e in r)e.startsWith(`on`)&&typeof r[e]==`function`?(n[e]??(n[e]=[]),n[e].push(r[e])):t[e]=r[e];for(let e in n)Object.assign(t,{[e](...t){let r=n[e];for(let e of r)e?.(...t)}});return t}function Y(e){return Object.assign((0,f.forwardRef)(e),{displayName:e.displayName??e.name})}function Ap(e){let t=Object.assign({},e);for(let e in t)t[e]===void 0&&delete t[e];return t}function jp(e,t=[]){let n=Object.assign({},e);for(let e of t)e in n&&delete n[e];return n}function Mp(e){return`19.2.7`.split(`.`)[0]>=`19`?e.props.ref:e.ref}function Np(e){if(e!=null&&e.$$typeof===Symbol.for(`react.lazy`)){let t=e._payload;if(t!=null&&t.status===`fulfilled`)return Np(t.value)}return e}function Pp(e){return e===f.Fragment||e===Symbol.for(`react.fragment`)}function Fp(e){return Pp(e.type)}function Ip(e,t,n){let[r,i]=(0,f.useState)(n),a=e!==void 0,o=(0,f.useRef)(a),s=(0,f.useRef)(!1),c=(0,f.useRef)(!1);return a&&!o.current&&!s.current?(s.current=!0,o.current=a,console.error(`A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.`)):!a&&o.current&&!c.current&&(c.current=!0,o.current=a,console.error(`A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.`)),[a?e:r,q(e=>(a||(0,pc.flushSync)(()=>i(e)),t?.(e)))]}function Lp(e){let[t]=(0,f.useState)(e);return t}function Rp(e={},t=null,n=[]){for(let[r,i]of Object.entries(e))Bp(n,zp(t,r),i);return n}function zp(e,t){return e?e+`[`+t+`]`:t}function Bp(e,t,n){if(Array.isArray(n))for(let[r,i]of n.entries())Bp(e,zp(t,r.toString()),i);else n instanceof Date?e.push([t,n.toISOString()]):typeof n==`boolean`?e.push([t,n?`1`:`0`]):typeof n==`string`?e.push([t,n]):typeof n==`number`?e.push([t,`${n}`]):n==null?e.push([t,``]):Hp(n)&&!(0,f.isValidElement)(n)&&Rp(n,t,e)}function Vp(e){var t;let n=e?.form??e.closest(`form`);if(n){for(let t of n.elements)if(t!==e&&(t.tagName===`INPUT`&&t.type===`submit`||t.tagName===`BUTTON`&&t.type===`submit`||t.nodeName===`INPUT`&&t.type===`image`)){t.click();return}(t=n.requestSubmit)==null||t.call(n)}}function Hp(e){if(Object.prototype.toString.call(e)!==`[object Object]`)return!1;let t=Object.getPrototypeOf(e);return t===null||Object.getPrototypeOf(t)===null}var Up=`span`,Wp=(e=>(e[e.None=1]=`None`,e[e.Focusable=2]=`Focusable`,e[e.Hidden=4]=`Hidden`,e))(Wp||{});function Gp(e,t){let{features:n=1,...r}=e,i={ref:t,"aria-hidden":(n&2)==2?!0:r[`aria-hidden`]??void 0,hidden:(n&4)==4||void 0,style:{position:`fixed`,top:1,left:1,width:1,height:0,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,borderWidth:`0`,...(n&4)==4&&(n&2)!=2&&{display:`none`}}};return J()({ourProps:i,theirProps:r,slot:{},defaultTag:Up,name:`Hidden`})}var Kp=Y(Gp),qp=(0,f.createContext)(null);function Jp({children:e}){let t=(0,f.useContext)(qp);if(!t)return f.createElement(f.Fragment,null,e);let{target:n}=t;return n?(0,pc.createPortal)(f.createElement(f.Fragment,null,e),n):null}function Yp({data:e,form:t,disabled:n,onReset:r,overrides:i}){let[a,o]=(0,f.useState)(null),s=fp();return(0,f.useEffect)(()=>{if(r&&a)return s.addEventListener(a,`reset`,r)},[a,t,r]),f.createElement(Jp,null,f.createElement(Xp,{setForm:o,formId:t}),Rp(e).map(([e,r])=>f.createElement(Kp,{features:Wp.Hidden,...Ap({key:e,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,form:t,disabled:n,name:e,value:r,...i})})))}function Xp({setForm:e,formId:t}){return(0,f.useEffect)(()=>{if(t){let n=document.getElementById(t);n&&e(n)}},[e,t]),t?null:f.createElement(Kp,{features:Wp.Hidden,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,ref:t=>{if(!t)return;let n=t.closest(`form`);n&&e(n)}})}var Zp=(0,f.createContext)(void 0);function Qp(){return(0,f.useContext)(Zp)}function $p(e){return typeof e!=`object`||!e?!1:`nodeType`in e}function em(e){return $p(e)&&`tagName`in e}function tm(e){return em(e)&&`accessKey`in e}function nm(e){return em(e)&&`tabIndex`in e}function rm(e){return em(e)&&`style`in e}function im(e){return tm(e)&&e.nodeName===`IFRAME`}function am(e){return tm(e)&&e.nodeName===`INPUT`}function om(e){return tm(e)&&e.nodeName===`LABEL`}function sm(e){return tm(e)&&e.nodeName===`FIELDSET`}function cm(e){return tm(e)&&e.nodeName===`LEGEND`}function lm(e){return em(e)?e.matches(`a[href],audio[controls],button,details,embed,iframe,img[usemap],input:not([type="hidden"]),label,select,textarea,video[controls]`):!1}function um(e){let t=e.parentElement,n=null;for(;t&&!sm(t);)cm(t)&&(n=t),t=t.parentElement;let r=t?.getAttribute(`disabled`)===``;return r&&dm(n)?!1:r}function dm(e){if(!e)return!1;let t=e.previousElementSibling;for(;t!==null;){if(cm(t))return!1;t=t.previousElementSibling}return!0}var fm=Symbol();function pm(e,t=!0){return Object.assign(e,{[fm]:t})}function mm(...e){let t=(0,f.useRef)(e);(0,f.useEffect)(()=>{t.current=e},[e]);let n=q(e=>{for(let n of t.current)n!=null&&(typeof n==`function`?n(e):n.current=e)});return e.every(e=>e==null||e?.[fm])?void 0:n}var hm=(0,f.createContext)(null);hm.displayName=`DescriptionContext`;function gm(){let e=(0,f.useContext)(hm);if(e===null){let e=Error(`You used a component, but it is not inside a relevant parent.`);throw Error.captureStackTrace&&Error.captureStackTrace(e,gm),e}return e}function _m(){return(0,f.useContext)(hm)?.value??void 0}function vm(){let[e,t]=(0,f.useState)([]);return[e.length>0?e.join(` `):void 0,(0,f.useMemo)(()=>function(e){let n=q(e=>(t(t=>[...t,e]),()=>t(t=>{let n=t.slice(),r=n.indexOf(e);return r!==-1&&n.splice(r,1),n}))),r=(0,f.useMemo)(()=>({register:n,slot:e.slot,name:e.name,props:e.props,value:e.value}),[n,e.slot,e.name,e.props,e.value]);return f.createElement(hm.Provider,{value:r},e.children)},[t])]}var ym=`p`;function bm(e,t){let n=(0,f.useId)(),r=yp(),{id:i=`headlessui-description-${n}`,...a}=e,o=gm(),s=mm(t);K(()=>o.register(i),[i,o.register]);let c=_p({...o.slot,disabled:r||!1}),l={ref:s,...o.props,id:i};return J()({ourProps:l,theirProps:a,slot:c,defaultTag:ym,name:o.name||`Description`})}var xm=Y(bm),Sm=Object.assign(xm,{}),X=(e=>(e.Space=` `,e.Enter=`Enter`,e.Escape=`Escape`,e.Backspace=`Backspace`,e.Delete=`Delete`,e.ArrowLeft=`ArrowLeft`,e.ArrowUp=`ArrowUp`,e.ArrowRight=`ArrowRight`,e.ArrowDown=`ArrowDown`,e.Home=`Home`,e.End=`End`,e.PageUp=`PageUp`,e.PageDown=`PageDown`,e.Tab=`Tab`,e))(X||{}),Cm=(0,f.createContext)(null);Cm.displayName=`LabelContext`;function wm(){let e=(0,f.useContext)(Cm);if(e===null){let e=Error(`You used a