Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b7b32332 | ||
|
|
0caf422e1d | ||
|
|
f9bb50ef2f | ||
|
|
7415229552 | ||
|
|
48a5441ddb | ||
|
|
7ef26cd8a0 | ||
|
|
b8d636c7ac | ||
|
|
4a12adb41b | ||
|
|
baff0abb48 | ||
|
|
c59f59e804 | ||
|
|
f353035326 | ||
|
|
7f39271bd5 | ||
|
|
1e2ded7617 | ||
|
|
b1eeb30bcd | ||
|
|
b8d55be1a4 | ||
|
|
fe18accf9d | ||
|
|
4cdc08eb22 | ||
|
|
1227b048e1 | ||
|
|
df4c817333 | ||
|
|
d74007b58d | ||
|
|
66c8c8de96 | ||
|
|
d6d6554c86 | ||
|
|
758c226a25 | ||
|
|
c9a1765540 | ||
|
|
ce7879e741 | ||
|
|
ba03770ad1 | ||
|
|
40823f3995 | ||
|
|
39ccf9928f | ||
|
|
c095dd720d | ||
|
|
a72efcd71a | ||
|
|
24938cf999 | ||
|
|
4b97c8a573 | ||
|
|
ff49dea997 | ||
|
|
f6b0621ad1 | ||
|
|
9af5d64423 | ||
|
|
c40c53f587 | ||
|
|
1eb57d0b21 | ||
|
|
4f04c88d5f | ||
|
|
8215055c14 | ||
|
|
f2f5489bb9 | ||
|
|
53166cb840 | ||
|
|
ef0dad95f7 | ||
|
|
034abfc26a | ||
|
|
c3aa0219ae | ||
|
|
beaf0b641f | ||
|
|
0dc0cc77a4 | ||
|
|
959862c1d0 | ||
|
|
da1b782282 | ||
|
|
cf2c6a2598 | ||
|
|
2a8278d7df | ||
|
|
515d011a25 | ||
|
|
f07dcfaaa7 | ||
|
|
2e6aef56a0 | ||
|
|
b9fe43f4fc | ||
|
|
5a5870990b | ||
|
|
c5b7599f60 | ||
|
|
7012f1554c | ||
|
|
48c0edad2d | ||
|
|
59dbed428a | ||
|
|
dfcea9d757 | ||
|
|
c5f335ed80 | ||
|
|
7cfa5536f0 | ||
|
|
327bca9c87 | ||
|
|
be332511ec | ||
|
|
ae4f534076 | ||
|
|
282838efef | ||
|
|
6491125cf2 | ||
|
|
0503f07026 | ||
|
|
2b34422e63 | ||
|
|
6d7ed659c0 | ||
|
|
a54800eac5 | ||
|
|
4ee3899f4b | ||
|
|
be9e60435e | ||
|
|
638a3f9cf4 | ||
|
|
149c8e3aee | ||
|
|
c38322a6ae | ||
|
|
ef317b10ea | ||
|
|
08d1db2109 | ||
|
|
1739446843 | ||
|
|
81758c10d1 | ||
|
|
dd49d53b97 | ||
|
|
0acac058fb | ||
|
|
68cf06c8d7 | ||
|
|
2e30997519 | ||
|
|
6599fb02ef | ||
|
|
528ec591d1 | ||
|
|
05c76f959c | ||
|
|
f1ad7c3fbb | ||
|
|
eeee21e6ea | ||
|
|
49addbd068 | ||
|
|
634a3fba17 | ||
|
|
655deb4385 | ||
|
|
ed15e78108 | ||
|
|
c814379b85 | ||
|
|
648a785b87 | ||
|
|
ec4dfd7939 | ||
|
|
92d0b4eb52 | ||
|
|
c8263d51a2 | ||
|
|
d1f9b2b6ef | ||
|
|
9e7f95fd09 | ||
|
|
47e9a4736d | ||
|
|
dd0be7b06a | ||
|
|
4390eb64b0 | ||
|
|
b30a6c2640 | ||
|
|
3304bca1b9 | ||
|
|
7f75d64052 | ||
|
|
993ca95539 | ||
|
|
03402fd8f3 | ||
|
|
69f73883f1 | ||
|
|
8d7d82dd87 | ||
|
|
da8ef455b5 | ||
|
|
7ace16007e | ||
|
|
538ef6220d | ||
|
|
2fc6678292 | ||
|
|
4ea6165025 | ||
|
|
b4ce9ca0f7 | ||
|
|
99e2181392 | ||
|
|
ca3d513ceb | ||
|
|
227c577956 | ||
|
|
34fc79bc81 | ||
|
|
c217d2bb51 | ||
|
|
8d10f1bea8 | ||
|
|
9e8e5fb792 | ||
|
|
2836759773 | ||
|
|
61683d4cad | ||
|
|
ecf7aceef4 | ||
|
|
b1d482bb60 | ||
|
|
dc694161d1 | ||
|
|
b1caac4bd4 | ||
|
|
dfc2789df3 | ||
|
|
3d1f814010 | ||
|
|
83ae28fb65 | ||
|
|
493dc823bc | ||
|
|
2e7c77462a | ||
|
|
900cb92bf4 | ||
|
|
b7467b77f9 | ||
|
|
941dc6f7ea | ||
|
|
cf8eb3cf95 | ||
|
|
ec95ebefb5 | ||
|
|
6c05e1d2e4 | ||
|
|
23643add56 | ||
|
|
bf2efecac7 | ||
|
|
97d3ab9b95 | ||
|
|
89b0d3794c | ||
|
|
b9fd63b334 | ||
|
|
eabb391ece | ||
|
|
7d8756a8a7 | ||
|
|
cbc527cb26 | ||
|
|
44be281b12 | ||
|
|
ad802da968 | ||
|
|
313866a673 | ||
|
|
d17b97a274 | ||
|
|
718d9847ca | ||
|
|
4cfd0f94aa | ||
|
|
258939740b | ||
|
|
f5abccdbe9 | ||
|
|
9392cbdad2 | ||
|
|
7c2ba941d3 | ||
|
|
4d223b53b3 | ||
|
|
96cac47ae3 | ||
|
|
64082824a3 | ||
|
|
aba150bc25 | ||
|
|
9d89816c93 | ||
|
|
bf6eb8a2a8 | ||
|
|
ba9795ab34 | ||
|
|
6fe2a36a87 | ||
|
|
92ebdc82df | ||
|
|
00b46d2ad9 | ||
|
|
96d24e498a | ||
|
|
48bb8a64dc | ||
|
|
a251d196d7 | ||
|
|
e488b7fc85 | ||
|
|
e50ea96f9d | ||
|
|
f80dd956c3 | ||
|
|
9ac3383f61 | ||
|
|
63cfece552 | ||
|
|
89d186a7b6 | ||
|
|
7c63753c01 | ||
|
|
b6ebfd2b6d | ||
|
|
f50f90936b | ||
|
|
874bd1205f | ||
|
|
0edb45e6d4 | ||
|
|
b3f8bf90a3 | ||
|
|
2f70f8f28c | ||
|
|
7113f8918c | ||
|
|
fd996aa4e4 | ||
|
|
5fc7cc27ea | ||
|
|
d438d280db | ||
|
|
c93b1311cb | ||
|
|
7f36c4ba7a | ||
|
|
57329a8c21 | ||
|
|
1ab16d1433 | ||
|
|
d28cc9d482 | ||
|
|
e244a8262d | ||
|
|
ff46e2789c | ||
|
|
c615b44fc2 | ||
|
|
1a377b4ad1 | ||
|
|
3e4557b35f | ||
|
|
b0ea01c927 | ||
|
|
52bf3104ae | ||
|
|
b9950170a0 | ||
|
|
0c41f60db7 | ||
|
|
9f250dcdca | ||
|
|
44b48dc670 | ||
|
|
d3ba486db4 | ||
|
|
c89d815f81 | ||
|
|
bdad7ae055 | ||
|
|
a49df2a47a | ||
|
|
e789397110 | ||
|
|
c570867a5c | ||
|
|
1d0c236761 | ||
|
|
529c27e466 | ||
|
|
85f030a874 | ||
|
|
f0c9db42ed | ||
|
|
46065833da | ||
|
|
a95f54b7b2 | ||
|
|
685c36b342 | ||
|
|
f20dcc2068 | ||
|
|
63b25c042b | ||
|
|
38e6672d25 | ||
|
|
3ee11819d5 | ||
|
|
1c6bd33f3c | ||
|
|
ed48d013f9 | ||
|
|
1b3b72ec65 | ||
|
|
b83ef26b72 | ||
|
|
17986689f6 | ||
|
|
fd57fb2738 | ||
|
|
47c67c0947 | ||
|
|
373604d837 | ||
|
|
1d1599b370 | ||
|
|
a4dd9357ba | ||
|
|
01eb1e0154 | ||
|
|
0a34fd7186 | ||
|
|
dcb264ab1a | ||
|
|
301dd3a2c4 | ||
|
|
9eb9789b24 | ||
|
|
f93f4b19e4 | ||
|
|
18e09480f8 | ||
|
|
a94cf2c0ff | ||
|
|
210c502c1b | ||
|
|
81e812feb3 | ||
|
|
0a86d30f46 | ||
|
|
a540c43859 | ||
|
|
e1abb97643 | ||
|
|
96819aadf3 | ||
|
|
8e6f66aaf1 | ||
|
|
1ad0d437d1 | ||
|
|
0f510ae782 | ||
|
|
c591dd4362 | ||
|
|
b2931c48e4 | ||
|
|
12ad0223e9 | ||
|
|
1f7daa38db | ||
|
|
7dc29137fe | ||
|
|
b19631f930 | ||
|
|
7ba61869e9 | ||
|
|
7c09225328 | ||
|
|
73e730d1b3 | ||
|
|
cd2f3bb8ef | ||
|
|
03e7145a4b | ||
|
|
01aa9229a2 | ||
|
|
dcc74391e1 | ||
|
|
543ae60179 | ||
|
|
7b27e3454a | ||
|
|
44c7192b5c | ||
|
|
a69741867a | ||
|
|
e0efba1027 | ||
|
|
9c927b5011 | ||
|
|
d79b3f6904 | ||
|
|
d865208f3d | ||
|
|
4702c1c225 | ||
|
|
4fc90b2d73 | ||
|
|
567870271a | ||
|
|
6156aa11e0 | ||
|
|
13da022e49 | ||
|
|
3c2a8a15c0 | ||
|
|
cc5c002248 | ||
|
|
2c75c15de7 | ||
|
|
e6e2db6d34 | ||
|
|
777fc1be54 | ||
|
|
2db236a8a8 | ||
|
|
482c8cfdee | ||
|
|
6000be89c0 | ||
|
|
abc9ec5515 | ||
|
|
13f507af49 | ||
|
|
9b8c439063 | ||
|
|
5dc3cce7c9 | ||
|
|
d731cad37a | ||
|
|
39a0eb4123 | ||
|
|
043c50ff72 | ||
|
|
b62fc4304e | ||
|
|
907385c2d3 | ||
|
|
36620bdebf | ||
|
|
64ebdc6a81 | ||
|
|
3b4e710113 | ||
|
|
143ce027d2 | ||
|
|
03776f2ae4 | ||
|
|
0a478ee310 |
+3
-1
@@ -53,4 +53,6 @@ flakes/
|
||||
*.yml.local
|
||||
|
||||
tmp/
|
||||
docs/plans/
|
||||
docs/plans/
|
||||
docs/todos/
|
||||
docs/drafts/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// ComfyUI is managed with comfy-cli; this repo lives in <ComfyUI>/custom_nodes/.
|
||||
[
|
||||
{
|
||||
"label": "ComfyUI: launch",
|
||||
"command": "comfy",
|
||||
"args": ["--skip-prompt", "--workspace", "$ZED_WORKTREE_ROOT/../..", "launch"],
|
||||
"use_new_terminal": true,
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "always"
|
||||
},
|
||||
{
|
||||
"label": "ComfyUI: update",
|
||||
"command": "comfy",
|
||||
"args": ["--skip-prompt", "--workspace", "$ZED_WORKTREE_ROOT/../..", "update", "comfy"],
|
||||
"use_new_terminal": false,
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "always"
|
||||
}
|
||||
]
|
||||
+131
-1
@@ -7,6 +7,135 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Flake Animate** image-to-video for the **Wan 2.2** and **MiniMax H3**
|
||||
families: animate presets stored under `model_presets/video/<family>/`
|
||||
(#429), animate flakes with high/low LoRAs (#431), Flake Animate Preset and
|
||||
Preset Combo nodes (#430), and Flake Generate Animation, which samples the
|
||||
chain, optionally upscales and frame-interpolates, and saves an MP4 (with
|
||||
audio for MiniMax H3) shown in the node and the Generation Data overlay (#434).
|
||||
- Flake options: named optional prompts (`prompt_options`) on a flake or
|
||||
variant choice, on or off by default, and a `toggler` on downstream flakes
|
||||
and choices (`-tails, face, +front`) that switches the options of every
|
||||
flake before it in the generation (#462).
|
||||
|
||||
## [0.2.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- LoRAs and Flake Links on model presets (#385, #386).
|
||||
- "Applies by default" variant choices, with a "None" opt-out in the node
|
||||
option panel (#387, #388).
|
||||
- Anima ControlNet Model dropdown: any LLLite file or the VACE depth file
|
||||
(#381), and console lines showing the patches applied (#382).
|
||||
- Generation support for the **Anima**, **Krea2** and **Z-Image** model families,
|
||||
which ship as a bare diffusion model plus a separate text encoder and VAE
|
||||
rather than an all-in-one checkpoint (#356, #357, #358).
|
||||
- New `Krea2/Turbo` family (folder `krea2`).
|
||||
- Preset fields `diffusion_model`, `clip_type` and `shift`, with a
|
||||
diffusion-model picker and text-encoder type dropdown in the preset editor
|
||||
(#357, #360).
|
||||
- New presets seed their sampler settings from the selected family (#356).
|
||||
- `GET /flakes/families`, `/flakes/diffusion_models` and `/flakes/clip_types`
|
||||
(#360).
|
||||
- **Requires ComfyUI v0.34 or newer** — `comfy/ldm/krea2/`,
|
||||
`comfy.text_encoders.anima`/`.krea2`/`.z_image` and `CLIPType.KREA2` do not
|
||||
exist in older releases.
|
||||
- ControlNet support for **Anima** (ControlNet-LLLite) and **Z-Image** (Fun
|
||||
ControlNet Union). Both are model patches loaded from `models/model_patches/`
|
||||
and applied to the model rather than the conditioning; the flake ControlNet
|
||||
editor is unchanged and only lists the types the family supports
|
||||
(#363, #364, #365, #366).
|
||||
- Hook schedules (`schedule:` keyframes) for LoRAs, prompts and flake links,
|
||||
added from the card's ☰ menu in the flake editor (#369, #370, #372).
|
||||
- Multiple prompts per flake (`prompts:` list; `prompt:` still read) (#369, #371).
|
||||
- Live sampling previews in the Flake Generate node and overlay (#374).
|
||||
- Anima VACE-Depth ControlNet through the ComfyUI-Advanced-ControlNet-Anima
|
||||
fork (#378).
|
||||
- Flake Combo **Multiple** variant choices with Randomize, and an **All
|
||||
Variants** override that expands a flake into one generation per variant
|
||||
combo, shown as `[*]` on the grid item (#343, #345, #406, #421).
|
||||
- Variant choices can contribute LoRAs, ControlNets and flake links, with LoRA
|
||||
name/URL, negative strength and strength overrides in the Stack / Combo
|
||||
dropdowns; flake links override the linked choice's strengths
|
||||
(#299, #404, #413, #414).
|
||||
- New nodes: **Flake Inpaint** (#291), **Flake IPAdapter** (FaceID, #289).
|
||||
- Flake Generate options: ADetailer Off/Face/A/B (#287, #327), upscaling
|
||||
(#288), `_ad_` / `_up_` output stem suffixes (#325, #326), and ComfyUI
|
||||
workflow metadata embedded in outputs (#344, #346).
|
||||
- Flake links: multiple links per flake, a target picker and default overrides
|
||||
(#233, #234, #275).
|
||||
- Per-instance model preset overrides (filename, steps, CFG, sampler,
|
||||
scheduler) in Flake Model Preset / Combo (#279, #292).
|
||||
- Anima and Flux Klein model families (#286); ControlNet models are inferred
|
||||
from type and family (#246, #254).
|
||||
- Flake Stack / Combo: per-item activate checkbox and toggle-all, duplicate
|
||||
button on grid items, drag & drop between nodes, and favorite flakes
|
||||
(#277, #314, #315, #316, #402, #415).
|
||||
- Grid file picker with thumbnails and drive tabs for covers, ControlNet and
|
||||
variant images, and preset VAE / text encoder / diffusion model; flake-owned
|
||||
data is moved into the flake folder on save (#391, #392, #395, #396, #399).
|
||||
- Double-click an output image in Flake Generate or the Generation Data overlay
|
||||
to reveal it on disk (#329, #340).
|
||||
- Generation Data overlay: per-variant cards, image name and dimensions,
|
||||
ADetailer A/B toggle and Fit Output Image in View (#324, #345, #349, #390).
|
||||
|
||||
### Changed
|
||||
|
||||
- The Generation Data placeholder is a compact box with cards spread on both
|
||||
axes (#384).
|
||||
- Ungenerated combinations in the Generation Data overlay show scattered cover
|
||||
cards instead of a split mosaic (#375).
|
||||
- New Anima presets default to Euler Ancestral, Normal and the `QWEN_IMAGE`
|
||||
encoder type; family defaults now actually seed new presets (#376).
|
||||
- Flake Edit uses a single "+ Add field" button, seeds default fields by flake
|
||||
type and adds a Serie field (#269, #272, #330, #331).
|
||||
- Sampler and scheduler lists come from core ComfyUI KSampler (#285).
|
||||
- Generate overlay layout: landscape outputs widen the panel, metadata collapses
|
||||
behind a toggle and the card stack fills the panel (#407, #408, #410, #411,
|
||||
#412, #417).
|
||||
- Model families are defined once in `flake_families.py` instead of being
|
||||
duplicated across eight places in the Python and web layers (#355).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Live previews never showed: ComfyUI sends none unless a preview method is set.
|
||||
Flake Generate prompts now request `auto` previews when the setting is
|
||||
`default` and the server has none (#380).
|
||||
- Anima ControlNets took the SDXL path (no effect) when Flake Stack's family
|
||||
widget went stale behind a reroute or subgraph; the family now comes from the
|
||||
loaded preset (#382).
|
||||
- New model presets default to the extra model path root instead of the
|
||||
ComfyUI install folder (#377).
|
||||
- Z-Image flake ControlNets failed to load: the Fun ControlNet Union is a model
|
||||
patch, not a regular ControlNet (#365).
|
||||
- A UNET-only checkpoint (how Anima, Krea2 and Z-Image are usually distributed,
|
||||
even when the file sits in `models/checkpoints/`) no longer fails on the first
|
||||
`CLIPTextEncode`. The loader detects the missing text encoder and VAE and takes
|
||||
them from the preset (#358).
|
||||
- Standalone text encoders load through `comfy.sd.load_clip` with an explicit
|
||||
`CLIPType`, so `lumina2` and `krea2` encoders work at all (#358).
|
||||
- `clip_skip` is no longer applied to Qwen-based text encoders, where it is
|
||||
meaningless and can raise (#359).
|
||||
- The negative prompt is zeroed out when sampling at CFG ≤ 1, as distilled models
|
||||
require (#359).
|
||||
- Missing files named by a preset now fail with an actionable error instead of
|
||||
being silently ignored and generating with the wrong components (#358).
|
||||
- Bypassed or muted combo nodes are excluded from generation, nested combo
|
||||
batch loops follow graph order and the UI batch count multiplies combinations
|
||||
(#265, #304, #348).
|
||||
- ControlNet fixes: models in subdirectories, hint image fits the generation
|
||||
frame, upstream ControlNets are kept when chaining Flake nodes, empty
|
||||
entries no longer crash (#243, #248, #251, #254, #255, #257).
|
||||
- Combo highlights and overlay results survive tab switches, reordering and
|
||||
reconnects (#222, #241, #268, #373, #383).
|
||||
- Preset editing: edits kept on reopen, base directory selection and extra-path
|
||||
saves preserved (#242, #245, #262, #267).
|
||||
- Flake Data Split (Select) / Into Flake Data (Select) nodes could not be added
|
||||
or clicked (#208, #214, #215).
|
||||
- Saving a flake whose folder or image is on another drive or junction (#396).
|
||||
|
||||
## [0.1.0] - 2026-05-19
|
||||
|
||||
First tagged release. ComfyUI Flakes is a custom-node pack for ComfyUI that turns
|
||||
@@ -125,5 +254,6 @@ checkpoint loader and sampler.
|
||||
rows, overlay width constrained, prompts section height reduced, dropdowns
|
||||
scaled with canvas zoom.
|
||||
|
||||
[Unreleased]: https://github.com/JeyzerMC/comfyui-flakes/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://github.com/JeyzerMC/comfyui-flakes/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/JeyzerMC/comfyui-flakes/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/JeyzerMC/comfyui-flakes/releases/tag/v0.1.0
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
> Reusable prompt presets — swap characters, styles, and poses without rewiring your workflow.
|
||||
**Reusable config presets — swap characters, styles, poses, checkpoints and more without rewiring your workflow**
|
||||
|
||||
<!-- TODO: replace with your description -->
|
||||
_Description coming soon._
|
||||
ComfyUI Flakes is a set of nodes aiming to facilitate batch generation of images with reusable presets. Combine LoRAs, prompts, controlnets and more into a `flake.yaml` file that can be loaded, swapped, disabled and tweaked easily within the Flakes ecosystem.
|
||||
|
||||
<!-- TODO: add a screenshot/gif of an example workflow here -->
|
||||
<!--  -->
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
@@ -28,76 +29,404 @@ No extra dependencies — PyYAML is already part of ComfyUI's core.
|
||||
|
||||
---
|
||||
|
||||
## Flake file format
|
||||
|
||||
Flakes are reusable `.yaml` config files under `ComfyUI/models/flakes/`. They can represent any image generation concept, such as a character, a style, an environment, a pose, etc. Each flake has a number of different fields it can have that will be passed during the generation.
|
||||
|
||||
The only field required inside the YAML is `name` — the display label used everywhere in the UI. The flake's stable identifier is its filename relative to `models/flakes/` (subfolders included — `characters/musashi.yaml` is loaded as `characters/musashi`); that's the "output path" and lives in the filesystem rather than the file. Every other field below is optional — include only what your flake needs.
|
||||
|
||||
```yaml
|
||||
# ─── Required ─────────────────────────────────────────────────────────────
|
||||
name: "Miyamoto Musashi" # Display name shown in the UI and pickers
|
||||
|
||||
# ─── Optional fields ──────────────────────────────────────────────────────
|
||||
|
||||
# Prompt fragments contributed to the stack. Either side can be omitted.
|
||||
prompt:
|
||||
positive: "1boy, miyamoto musashi, long wild black hair"
|
||||
negative: "modern clothing, glasses"
|
||||
|
||||
# LoRAs — list of one or more entries. `path` is the model path under
|
||||
# `models/loras/`; `name` and `url` are metadata for the picker / link icon.
|
||||
loras:
|
||||
- name: "Musashi"
|
||||
url: "https://civitai.com/models/..." # optional, shown as link icon
|
||||
path: "<lora_path>/musashi" # under models/loras/
|
||||
strength: 0.9 # default 1.0
|
||||
schedule: # optional hook schedule, see below
|
||||
- {start: 0.0, strength: 1.0}
|
||||
- {start: 0.6, strength: 0.0}
|
||||
|
||||
# Default resolution for the generation. The first flake in the stack that
|
||||
# declares a `resolution` wins; otherwise it falls back to 1024 × 1024.
|
||||
resolution: [832, 1216]
|
||||
|
||||
# Cover image shown on the flake's grid block and in the picker. Moved to
|
||||
# `musashi/musashi.png` on save, or kept in place (`./musashi/...`) when it
|
||||
# already lives in the flake's folder — see Portable flakes below.
|
||||
cover_image: "<lora_path>/musashi.png"
|
||||
|
||||
# Variant groups — the UI lets you pick one choice per group. Each choice
|
||||
# can contribute extra positive/negative text and an optional preview image.
|
||||
# `default: true` on a group's first choice ("Applies by default" in the
|
||||
# editor) applies it when the node leaves the group on "-"; pick "None" in the
|
||||
# node's option panel to opt out.
|
||||
variants:
|
||||
outfit:
|
||||
ronin:
|
||||
default: true
|
||||
positive: "tattered kimono, frayed hakama"
|
||||
negative: "armor"
|
||||
image: "<lora_path>/musashi_outfit_ronin.png" # optional preview thumbnail
|
||||
shirtless:
|
||||
positive: "shirtless, hakama only"
|
||||
mood:
|
||||
calm:
|
||||
positive: "serene expression"
|
||||
fierce:
|
||||
positive: "battle stance, narrowed eyes"
|
||||
|
||||
# ControlNets — one entry per ControlNet to apply.
|
||||
controlnets:
|
||||
- type: openpose # ControlNet category label
|
||||
model: control_openpose_xl.safetensors # file under models/controlnet/
|
||||
image: standing_openpose # file under ComfyUI/input/
|
||||
strength: 0.8 # default 1.0
|
||||
start_percent: 0.0 # default 0.0
|
||||
end_percent: 1.0 # default 1.0
|
||||
|
||||
# Filename stem injected into `Save Image`'s filename_prefix when this flake
|
||||
# is in the stack. Multiple flakes' stems are joined with underscores.
|
||||
output_stem: "musashi"
|
||||
|
||||
# Flake links — pull in one or more other flakes' loras + prompts when this
|
||||
# flake is used. Each link can set default variant choices and lora-strength
|
||||
# overrides for its target. (A single legacy `flake_link:` mapping is still
|
||||
# read for backwards compatibility.)
|
||||
flake_links:
|
||||
- target: "styles/ink_wash" # another flake's output path
|
||||
variant:
|
||||
intensity: heavy # default variant choice on the target
|
||||
lora_strengths: [0.8] # per-lora overrides (null = target default)
|
||||
variant_loras: # strengths for the LoRAs of a variant choice
|
||||
intensity: {heavy: [0.6]} # {group: {choice: [strengths]}}
|
||||
- target: "poses/standing"
|
||||
```
|
||||
|
||||
A flake can hold several prompts with `prompts:` instead of `prompt:` (a list of
|
||||
`{positive, negative, schedule}` entries). Unscheduled prompts of one flake are
|
||||
joined with `, `.
|
||||
|
||||
### Hook schedules
|
||||
|
||||
LoRAs, prompts and flake links take an optional `schedule`: keyframes that set
|
||||
a strength multiplier from `start` (fraction of the denoising, 0–1) until the
|
||||
next keyframe. Before the first keyframe the strength is 1. In the flake editor,
|
||||
use **Add Hook Scheduler** from the card's ☰ menu.
|
||||
|
||||
- **LoRA**: scales the LoRA's model weights over time (core hook keyframes). Its
|
||||
text-encoder part stays on for the whole generation so trigger words encode.
|
||||
- **Prompt**: encoded on its own and applied only where the strength is above 0.
|
||||
- **Flake link**: multiplies into every LoRA and prompt the linked flake brings in.
|
||||
|
||||
Prompts are joined with `BREAK` between flakes so each flake acts as an independent CLIP region. LoRA strengths and variant choices can be overridden per-instance from the grid item's option panel without modifying the file on disk.
|
||||
|
||||
### Portable flakes
|
||||
|
||||
A flake owns the files it uses in a folder named after it, next to its yaml.
|
||||
Saving `poses/jojo.yaml` moves its external data into `poses/jojo/` and points
|
||||
the yaml at it with paths starting with `./` (relative to the yaml):
|
||||
|
||||
| Data | Stored as |
|
||||
|---|---|
|
||||
| Cover | `jojo/jojo.png` |
|
||||
| LoRA (+ preview / `.civitai.info` sidecars) | `jojo/jojo_<display name>.safetensors` (`jojo.safetensors` when unnamed) |
|
||||
| Variant choice image | `jojo/jojo_<group>_<choice>.png` |
|
||||
| ControlNet image | `jojo/cnets/<image>.png` |
|
||||
|
||||
A ControlNet image brings its whole batch: picking
|
||||
`001_pose_3_depthanythingv2.png` also moves every `001_pose_<n>_<token>` image
|
||||
beside it.
|
||||
|
||||
The cover, ControlNet and variant images are picked in place with the file
|
||||
picker, from `input/`, `output/`, `flakes/` or `models/`, so the originals are
|
||||
what moves. An image already in the flake's own folder stays where it is: a
|
||||
cover picked from `jojo/cnets/` is referenced there, and another ControlNet
|
||||
image from that folder needs no move.
|
||||
|
||||
- Files are moved, or copied when the flake folder is on another disk. A file
|
||||
another flake uses is moved too, so that flake has to be re-pointed.
|
||||
- Flake links are references and never move.
|
||||
- Renaming or moving a flake in the editor takes its folder along.
|
||||
- Deleting a flake keeps its folder.
|
||||
- Existing flakes convert the next time they are saved.
|
||||
|
||||
Move the yaml and its folder together to share or relocate a flake.
|
||||
|
||||
#### Sharing through images
|
||||
|
||||
Flake Generate and Flake Generate Animation save no metadata inside the PNG /
|
||||
MP4. The prompt and workflow of each output go to
|
||||
`.flake_metadata/<file name>.json` next to it (not written with
|
||||
`--disable-metadata`). Dropping an output into ComfyUI restores its workflow
|
||||
from that file, found by file name and size anywhere under the output folder.
|
||||
|
||||
The workflow saved for a Flake Generate image carries the flakes and model
|
||||
presets that made it (linked flakes included): their yaml, cover and folder
|
||||
files, without the model weights. Dropping such an image into ComfyUI loads
|
||||
them as local flakes, so only the checkpoint and LoRA files have to be supplied.
|
||||
|
||||
- Local flakes and presets stay with the workflow (they are kept in it and
|
||||
staged in ComfyUI's temp folder on load) and are not saved on disk: their
|
||||
grid items and preset widgets show a **Local** tag with a dashed border, and
|
||||
they don't appear in the pickers.
|
||||
- **Save to disk** (item hover button) saves one, with the local flakes it
|
||||
links to, under `models/flakes`. Nothing is
|
||||
overwritten: a flake or preset already present with the same yaml is reused,
|
||||
a different one with the same name is saved as `<name> (imported)`, and the
|
||||
workflow's nodes point at the saved name.
|
||||
|
||||
### Editor helpers
|
||||
|
||||
- **Favorites**: the ☆ next to the editor title marks a flake as favorite
|
||||
(`favorite: true` in the yaml). Favorites show a ★ on grid items and in the
|
||||
flake picker.
|
||||
- **Variant choices**: `→` between a choice's name and output stem fills the
|
||||
stem from the name (`Jojo Pose` → `jojo_pose`). A new choice gets the fields
|
||||
the group's choices already have (negative prompt, a ControlNet with the same
|
||||
settings minus the image, a LoRA row) and a name-derived stem when the others
|
||||
use theirs. A taken name becomes `Pose B`, `Pose C`, … Choice LoRAs have the
|
||||
same name/URL fields and -10…10 strength as the flake's own LoRAs.
|
||||
- **Variant dropdowns** on grid items list `None` first, then the choices, with
|
||||
the default one marked `(default)` and preselected. On Flake Combo,
|
||||
`Multiple ↗` opens a grid to check several choices of a group; each checked
|
||||
choice is generated as its own combination. The slider + `Randomize` checks
|
||||
that many random choices (press again to re-roll).
|
||||
The dropdown also shows strength sliders for the LoRAs of the selected
|
||||
choice, stored per choice in the grid item (`variant_loras`).
|
||||
- **Flake links**: the editor's link card and the grid dropdown's linked column
|
||||
show sliders for the LoRAs of the linked flake's selected variant choice
|
||||
(`flake_links[i].variant_loras`). A variant choice's own links (☰ →
|
||||
`Flake Link`) follow under a `<group>: <choice>` heading for the selected
|
||||
choice, overridden per choice in the grid item (`variant_link_overrides`).
|
||||
- **ControlNets**: new entries have `img res` on. Picking a ControlNet image uses
|
||||
its batch's `_0_cover` image as the flake cover when it has none, and as the
|
||||
variant choice's image for a ControlNet inside a choice.
|
||||
- **File pickers** show one drive at a time when your folders span several
|
||||
(C:, D:, …), starting on the extra model path drive. This includes the flake and
|
||||
preset pickers.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Flake Model Preset
|
||||
|
||||
A Flake model preset is a config for checkpoints. Alongside the SDXL family it
|
||||
supports Anima, Krea2 and Z-Image.
|
||||
|
||||

|
||||
|
||||
### Model families
|
||||
|
||||
The `model_family` dropdown picks both the on-disk layout (`img/<folder>/` under
|
||||
`models/flakes/`, `models/flakes/model_presets/` and `output/`) and how the model
|
||||
is loaded and sampled.
|
||||
|
||||
| Family | Folder | Model file | Text encoder | Encoder type | VAE |
|
||||
|---|---|---|---|---|---|
|
||||
| `SDXL/Base`, `SDXL/Illustrious`, `SDXL/Pony` | `sdxl`, `illustrious`, `pony` | all-in-one checkpoint | baked in | `STABLE_DIFFUSION` | baked in |
|
||||
| `Anima/Base` | `anima` | `anima-base-v1.0` | `qwen_3_06b_base` | `QWEN_IMAGE` | `qwen_image_vae` |
|
||||
| `Krea2/Turbo` | `krea2` | `krea2_turbo_fp8_scaled` | `qwen3vl_4b_fp8_scaled` | `KREA2` | `qwen_image_vae` |
|
||||
| `ZImage/Base`, `ZImage/Turbo` | `zib`, `zit` | `z_image_turbo_bf16` | `qwen_3_4b` | `LUMINA2` | `ae.safetensors` |
|
||||
|
||||
SDXL presets need only a **Checkpoint**. The other families ship as a bare
|
||||
diffusion model, so they also need a **Text Encoder**, its **Text Encoder Type**
|
||||
and a **VAE**. Point the preset at either a **Checkpoint** or a **Diffusion
|
||||
Model** — a UNET-only file that happens to live in `models/checkpoints/` is
|
||||
detected automatically, so you don't have to move it.
|
||||
|
||||
Presets seed their sampler defaults from the family (Z-Image and Krea2 Turbo: 8
|
||||
steps at CFG 1; Anima base: 30 steps at CFG 4 with Euler Ancestral / Normal). At CFG ≤ 1 the negative prompt is
|
||||
zeroed out, matching how distilled models are sampled. **Clip Skip** is hidden
|
||||
for the Qwen-based families, where it does not apply.
|
||||
|
||||
A preset can also carry **LoRAs** and **Flake Links** (sections at the bottom of
|
||||
the preset editor, same format as in a flake). They apply whenever the preset is
|
||||
used, before any flake LoRAs.
|
||||
|
||||
> Anima, Krea2 and Z-Image require **ComfyUI v0.34 or newer**. Their
|
||||
> architectures and the `KREA2` encoder type do not exist in older releases.
|
||||
|
||||
### ControlNets for Anima and Z-Image
|
||||
|
||||
A flake's ControlNet field works the same for every family: pick an image, a
|
||||
type, then tweak strength, start and end. Anima and Z-Image don't use regular
|
||||
ControlNets, though. Their ControlNets are **model patches** that hook the
|
||||
diffusion model, so the files go in `models/model_patches/`, not
|
||||
`models/controlnet/`:
|
||||
|
||||
| Family | Folder | Files | Types |
|
||||
|---|---|---|---|
|
||||
| `Anima/Base` | `models/model_patches/anima/` | `anima-lllite-{pose,depth,lineart,scribble}-1` ([Comfy-Org/Anima-LLLite](https://huggingface.co/Comfy-Org/Anima-LLLite)) | openpose, depth, lineart, scribble |
|
||||
| `ZImage/Base`, `ZImage/Turbo` | `models/model_patches/zimage/` | `Z-Image-Turbo-Fun-Controlnet-Union` (any version) | openpose, depth, canny, softedge |
|
||||
|
||||
- Anima depth expects a Depth Anything V2 map (white = near). The pose model is
|
||||
noticeably weaker than depth; for locking a pose, depth works better.
|
||||
- The Anima LLLite weights are Preview3 releases and lose some quality on Anima
|
||||
Base v1.0.
|
||||
- Z-Image's Fun ControlNet has no start/end: it applies to every step.
|
||||
- [TaihoC/Anima-ControlNet-VACE-Depth](https://huggingface.co/TaihoC/Anima-ControlNet-VACE-Depth)
|
||||
is not supported by core ComfyUI. Install the
|
||||
[ComfyUI-Advanced-ControlNet-Anima](https://github.com/TaihoC/ComfyUI-Advanced-ControlNet-Anima)
|
||||
fork (branch `feat/anima-vace-controlnet`), put the file in
|
||||
`models/controlnet/anima/` and select it in the ControlNet entry's Model dropdown.
|
||||
- For Anima, the Model dropdown lists every file in `models/model_patches/anima/`
|
||||
and the VACE files in `models/controlnet/anima/`. `Auto (by type)` uses the
|
||||
LLLite file for the type; a model that no longer exists falls back to Auto.
|
||||
- If an Anima ControlNet seems to have no effect, check the console:
|
||||
`[flakes] Anima lllite <file> ... sigma=<start>..<end>` per applied patch and
|
||||
`[FlakeGenerate] model patches: post_input=1, ...` before sampling (`none` means
|
||||
no patch reached the sampler). To compare, run ComfyUI's
|
||||
`Control to Image (Anima LLLite)` blueprint with the same image, file,
|
||||
strength, start/end, seed and sampler settings.
|
||||
- The base model is not reloaded when switching flakes. Each patch is a hook on
|
||||
a clone that shares its weights, and recently used patch files stay cached.
|
||||
|
||||
---
|
||||
|
||||
## Flake Animate
|
||||
|
||||
Image-to-video with its own presets and nodes: **Flake Animate Preset** /
|
||||
**Preset Combo** load an animate preset into a `flake_anim_data` pin, **Flake
|
||||
Animate Stack** / **Combo** add flakes, and **Flake Generate Animation** samples
|
||||
it and saves an MP4 to `output/video/<family>/`. Start images belong to the grid
|
||||
items (bottom-left `+` / count button); each image queues its own video. Flake
|
||||
Animate Stack previews the first active item's first image under its grid.
|
||||
|
||||
| Family | Folder | Models | Text encoder type | VAEs |
|
||||
|---|---|---|---|---|
|
||||
| `Wan 2.2` | `wan22` | high-noise + low-noise diffusion models | `WAN` | Wan VAE |
|
||||
| `MiniMax H3` | `mh3` | one diffusion model | `MINIMAX` | video VAE + audio VAE |
|
||||
|
||||
All files load with ComfyUI's core loaders (diffusion models, text encoders,
|
||||
VAEs), so they go in the usual `models/` folders. Wan 2.2 LoRAs take a high and
|
||||
a low file. MiniMax H3 videos are 24 fps with generated audio.
|
||||
|
||||
Auto-caption fields:
|
||||
|
||||
- `caption_model` (animate preset): a full Qwen3-VL 4B/8B file in
|
||||
`models/text_encoders/` that writes the video prompt from each start image.
|
||||
- `caption_instruction` (animate preset): replaces the family's built-in
|
||||
caption instruction; empty uses the built-in one.
|
||||
- `auto_caption: true` (animate flake, **Auto-caption** in the editor title):
|
||||
jobs with this flake get their prompt from the preset's caption model, which
|
||||
carries out the flake's action.
|
||||
|
||||
Flake Generate Animation:
|
||||
|
||||
- The video's short edge comes from the preset's resolution (480p / 580p /
|
||||
720p) and its aspect from the start image; both sides round to a multiple of 32.
|
||||
- **upscale** + **upscale_model** (`models/upscale_models/`) upscales every frame.
|
||||
- **interpolate** + **interpolation_model** (`models/frame_interpolation/`, RIFE
|
||||
or FILM) doubles the frame count and the fps.
|
||||
- **use_headroom** + **headroom** keeps that many GB of VRAM free while sampling
|
||||
and decoding.
|
||||
- Each field shows only while its toggle is on; a toggle on with no model
|
||||
selected is an error.
|
||||
- **attention** picks the attention backend for the diffusion model(s) (both
|
||||
Wan 2.2 models), like core Model Attention Backend; see Flake Generate.
|
||||
- The Generation Data overlay shows a job's auto-caption under its video.
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
### Flake Stack
|
||||
|
||||
Sits between your checkpoint loader and sampler. Loads an ordered list of **flakes** (YAML presets), merges their prompts and LoRAs, and outputs everything the sampler needs.
|
||||
|
||||
<!-- TODO: add a gif/image of the Flake Stack node in action -->
|
||||
<!--  -->
|
||||

|
||||
|
||||
**Inputs** — `model`, `clip`
|
||||
**Outputs** — `model`, `clip`, `positive`, `negative`, `latent`, `width`, `height`
|
||||
Flakes can be reordered, enabled / disabled, and edited, and dragged and dropped between Flake Stack / Flake Combo nodes in any direction. You can also open a small dropdown menu to make small non-persistent tweaks, such as changing a variant option, or a lora strength.
|
||||
|
||||
The built-in grid UI lets you:
|
||||
<div align="center">
|
||||
|
||||
- **`+ New flake`** — create and save a new flake to `models/flakes/`
|
||||
- **`↑ Load existing`** — add an on-disk flake to the stack
|
||||
- **Double-click a block** — edit; changes to saved flakes write back to disk
|
||||
- **Drag** blocks to reorder
|
||||
- **`✕`** — remove from stack (file is kept; use the modal's **Delete** to remove it)
|
||||

|
||||
|
||||
The first flake declaring a `resolution` wins; default is **1024 × 1024**.
|
||||
</div>
|
||||
|
||||
#### Flake file format
|
||||
### Flake Combo
|
||||
|
||||
Flakes are `.yaml` files under `ComfyUI/models/flakes/`. Subfolders become part of the name (`characters/musashi.yaml` → `characters/musashi`).
|
||||
TODO
|
||||
|
||||
```yaml
|
||||
# LoRA (optional)
|
||||
path: my_loras/musashi
|
||||
strength: 0.9
|
||||
### Flake Model Preset
|
||||
|
||||
# Prompt fragments
|
||||
prompt:
|
||||
positive: "1boy, miyamoto musashi, long wild black hair"
|
||||
negative: "modern clothing, glasses"
|
||||
TODO
|
||||
|
||||
# Resolution (optional — first flake with one wins)
|
||||
resolution: [832, 1216]
|
||||
### Flake Model Combo
|
||||
|
||||
# Variant groups (optional — pick one per group from the UI)
|
||||
variants:
|
||||
outfit:
|
||||
ronin:
|
||||
positive: "tattered kimono, frayed hakama"
|
||||
shirtless:
|
||||
positive: "shirtless, hakama only"
|
||||
TODO
|
||||
|
||||
# ControlNets (optional)
|
||||
controlnets:
|
||||
- type: openpose
|
||||
model: control_openpose_xl.safetensors
|
||||
image: standing_openpose
|
||||
strength: 0.8
|
||||
start_percent: 0.0
|
||||
end_percent: 1.0
|
||||
```
|
||||
### Flake Generate
|
||||
|
||||
Prompts are joined with `BREAK` between flakes so each flake acts as an independent CLIP region.
|
||||
Samples, decodes and saves the stack. While a combination is sampling, the node
|
||||
and its Generation Data overlay show the live step preview, including the
|
||||
ADetailer pass (upscaling has no steps to preview). When ComfyUI's **Live preview
|
||||
method** setting is `default` and the server runs without `--preview-method`,
|
||||
Flakes requests `auto` (latent2rgb) previews for its prompts; pick `taesd` for
|
||||
sharper previews or `none` to turn them off. Combinations that are not generated
|
||||
yet show their flakes' covers as scattered cards that fill the image panel (a
|
||||
choice without an image uses its flake's cover). Results stay matched to their
|
||||
grid item when items are moved or duplicated. **Fit Output Image in View** (top
|
||||
right of the overlay, on by default) fits the whole image in view; off, it shows
|
||||
at full size and scrolls both ways. Landscape outputs widen the overlay to
|
||||
nearly the screen width. Prompts,
|
||||
LoRAs and model details are folded under the `^` at the bottom of each panel.
|
||||
|
||||
**attention** picks the model's attention backend, ADetailer pass included:
|
||||
`default` keeps the one ComfyUI was launched with, and `PyTorch`, `Comfy Kitchen
|
||||
INT8`, `Sage`, `Sage 3` and `Flash` are listed when ComfyUI has them available.
|
||||
|
||||
### Flake Inpaint
|
||||
|
||||
Insert it right before Flake Generate to redraw part of an image with the
|
||||
chain's model, LoRAs and prompt. Load the image on the node and paint the area
|
||||
to redraw with ComfyUI's mask editor (right-click → Open in MaskEditor). The
|
||||
image's size replaces the chain resolution.
|
||||
|
||||
- **denoise** `1.0` fills the masked area from scratch (grown by `grow_mask`);
|
||||
below `1.0` it keeps the original as a starting point. Flake Generate samples
|
||||
at this denoise.
|
||||
- Without a mask the whole image is redrawn at `denoise` (img2img).
|
||||
- `positive_prompt` / `negative_prompt` are appended to the chain prompt.
|
||||
|
||||
To fix an image Flakes made, drop it into ComfyUI to restore its workflow,
|
||||
insert Flake Inpaint before Flake Generate and load the same image.
|
||||
|
||||
### Flake Data Split
|
||||
|
||||
TODO
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] _Planned items go here_
|
||||
- Flux support
|
||||
- Video support (Starting with WAN)
|
||||
- Post Process (ADetailler / Upscalers)
|
||||
- IP Adapters
|
||||
- LoRA training
|
||||
- Controlnet preprocessing
|
||||
- Variant LoRAs?
|
||||
|
||||
Fill free to open an issue if you'd like more features supported by Flakes.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full release history. This project follows [Semantic Versioning](https://semver.org/) and the format from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+18
@@ -26,6 +26,8 @@ else:
|
||||
|
||||
from .full_flake_node import FlakeModelPreset, FlakeStack, FlakeCombo, FlakeModelCombo # noqa: E402
|
||||
from .flake_data_nodes import FlakeDataSplitAll, FlakeDataSplitSelect, IntoFlakeDataAll, IntoFlakeDataSelect, PreviewFlakeData, FlakeGenerate # noqa: E402
|
||||
from .flake_extra_nodes import FlakeIPAdapter, FlakeInpaint # noqa: E402
|
||||
from .flake_anim_nodes import FlakeAnimPreset, FlakeAnimPresetCombo, FlakeAnimStack, FlakeAnimCombo, FlakeAnimCaption, FlakeGenerateAnimation # noqa: E402
|
||||
from . import flake_server # noqa: E402,F401 — registers aiohttp routes on import
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -39,6 +41,14 @@ NODE_CLASS_MAPPINGS = {
|
||||
"IntoFlakeDataSelect": IntoFlakeDataSelect,
|
||||
"PreviewFlakeData": PreviewFlakeData,
|
||||
"FlakeGenerate": FlakeGenerate,
|
||||
"FlakeIPAdapter": FlakeIPAdapter,
|
||||
"FlakeInpaint": FlakeInpaint,
|
||||
"FlakeAnimPreset": FlakeAnimPreset,
|
||||
"FlakeAnimPresetCombo": FlakeAnimPresetCombo,
|
||||
"FlakeAnimStack": FlakeAnimStack,
|
||||
"FlakeAnimCombo": FlakeAnimCombo,
|
||||
"FlakeAnimCaption": FlakeAnimCaption,
|
||||
"FlakeGenerateAnimation": FlakeGenerateAnimation,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -52,6 +62,14 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"IntoFlakeDataSelect": "Into Flake Data (Select)",
|
||||
"PreviewFlakeData": "Preview Flake Data",
|
||||
"FlakeGenerate": "Flake Generate",
|
||||
"FlakeIPAdapter": "Flake IPAdapter",
|
||||
"FlakeInpaint": "Flake Inpaint",
|
||||
"FlakeAnimPreset": "Flake Animate Preset",
|
||||
"FlakeAnimPresetCombo": "Flake Animate Preset Combo",
|
||||
"FlakeAnimStack": "Flake Animate Stack",
|
||||
"FlakeAnimCombo": "Flake Animate Combo",
|
||||
"FlakeAnimCaption": "Flake Animate Caption",
|
||||
"FlakeGenerateAnimation": "Flake Generate Animation",
|
||||
}
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,38 @@
|
||||
# Releasing
|
||||
|
||||
Releases are published to the Comfy registry (https://registry.comfy.org/nodes/comfyui-flakes)
|
||||
by the "Publish to Comfy registry" GitHub Action (`.github/workflows/publish.yml`).
|
||||
It runs `Comfy-Org/publish-node-action` on a push to `main` that changes
|
||||
`pyproject.toml`, or on `workflow_dispatch`, using the `REGISTRY_ACCESS_TOKEN` secret.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Pick the version (semver; pre-1.0: minor for features, patch for fixes).
|
||||
2. Bump `version` in `pyproject.toml`.
|
||||
3. In `CHANGELOG.md`, add an empty `## [Unreleased]` above and rename the old one to
|
||||
`## [X.Y.Z] - YYYY-MM-DD`. Update the compare links at the bottom.
|
||||
4. Commit: `chore: release vX.Y.Z`.
|
||||
5. Push `main`. This triggers the publish Action.
|
||||
6. Tag and push the tag:
|
||||
```
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
7. Create the GitHub release, from the web UI or with the changelog section saved to a file:
|
||||
```
|
||||
gh release create vX.Y.Z --notes-file <changelog-section.md>
|
||||
```
|
||||
8. Check the Action run in the repo's Actions tab and that the new version shows on the registry page.
|
||||
|
||||
## Manual publish
|
||||
|
||||
- Run the "Publish to Comfy registry" workflow from the Actions tab (`workflow_dispatch`), or
|
||||
- Locally with comfy-cli and the registry token: `comfy node publish`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- A version can only be published once. If the run fails because the version exists, bump `version`.
|
||||
- The Action only runs when `pyproject.toml` changes. Changing only other files (e.g. the icon) does
|
||||
not publish; bump the version or run the workflow manually.
|
||||
- If authentication fails, check that the `REGISTRY_ACCESS_TOKEN` secret is valid. Create a new
|
||||
token on registry.comfy.org under the publisher `jeyzermc` and update the repo secret.
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Flake Animate (image-to-video) nodes. They pass a ``FLAKE_ANIM_DATA`` dict
|
||||
down the chain; encoding and sampling happen once in Flake Generate Animation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
||||
import comfy.model_management
|
||||
import folder_paths
|
||||
import nodes
|
||||
from comfy.cli_args import args
|
||||
from comfy_api.latest import io
|
||||
|
||||
from . import flake_caption, flake_families, flake_io
|
||||
from .flake_compose import _load_cn_image, _load_lora
|
||||
from .flake_io import _resolve_model_name
|
||||
from .full_flake_node import (_apply_attention, _apply_preset_overrides, _attention_options, _build_filename_prefix,
|
||||
_join_prompts, _resolve_flakes)
|
||||
|
||||
_PLACEHOLDERS = ("Select a preset...", "No model preset is selected")
|
||||
|
||||
# FLAKE_ANIM_DATA["models"] key -> AnimPreset field, per family folder.
|
||||
_MODEL_FIELDS = {
|
||||
"wan22": {"high": "diffusion_model_high", "low": "diffusion_model_low"},
|
||||
"mh3": {"model": "diffusion_model"},
|
||||
}
|
||||
|
||||
|
||||
def _model_file(category: str, preset, field: str) -> str:
|
||||
ref = getattr(preset, field)
|
||||
if not ref:
|
||||
raise ValueError(f"Animate preset '{preset.name}' has no '{field}' set — pick one in the preset editor.")
|
||||
return _resolve_model_name(category, ref)
|
||||
|
||||
|
||||
def _apply_anim_loras(models: dict, loras: list[flake_io.LoraEntry]) -> tuple[dict, list[dict]]:
|
||||
"""Patch a copy of ``models`` with ``loras``, model-only: on Wan 2.2
|
||||
``path`` goes to the high-noise model and ``path_low`` to the low-noise
|
||||
one; a single-model family takes ``path``. Also returns the applied
|
||||
LoRAs' filename_state entries."""
|
||||
models = dict(models)
|
||||
lora_entries = []
|
||||
for lr in loras:
|
||||
targets = {"high": lr.path, "low": lr.path_low} if "high" in models else {"model": lr.path}
|
||||
applied = {key: path for key, path in targets.items() if path}
|
||||
for key, path in applied.items():
|
||||
models[key] = _load_lora(models[key], None, path, lr.strength, 0)[0]
|
||||
if applied:
|
||||
lora_entries.append({"name": lr.name or lr.path or lr.path_low, "path": lr.path, "path_low": lr.path_low, "strength": lr.strength})
|
||||
return models, lora_entries
|
||||
|
||||
|
||||
def _load_anim_preset_data(preset_name: str) -> dict:
|
||||
"""Load an animate preset's models, text encoder and VAEs with the core
|
||||
loaders and apply its LoRAs (``_apply_anim_loras``)."""
|
||||
p = flake_io.load_anim_preset(preset_name)
|
||||
spec = flake_families.ANIM_BY_FOLDER[p.family]
|
||||
loaders = nodes.NODE_CLASS_MAPPINGS
|
||||
unet = loaders["UNETLoader"]()
|
||||
models = {
|
||||
key: unet.load_unet(_model_file("diffusion_models", p, field), "default")[0]
|
||||
for key, field in _MODEL_FIELDS[p.family].items()
|
||||
}
|
||||
clip = loaders["CLIPLoader"]().load_clip(_model_file("text_encoders", p, "text_encoder"), p.clip_type.lower())[0]
|
||||
vae_loader = loaders["VAELoader"]()
|
||||
vae = vae_loader.load_vae(_model_file("vae", p, "vae"))[0]
|
||||
audio_vae = vae_loader.load_vae(_model_file("vae", p, "audio_vae"))[0] if p.family == "mh3" else None
|
||||
|
||||
models, lora_entries = _apply_anim_loras(models, p.loras)
|
||||
|
||||
stems = [f"{flake_io.ANIM_DIR}/{p.family}/"]
|
||||
if p.filename_prefix.strip():
|
||||
stems.append(p.filename_prefix.strip())
|
||||
return {
|
||||
"family": p.family,
|
||||
"preset": dataclasses.asdict(p),
|
||||
"models": models,
|
||||
"clip": clip,
|
||||
"vae": vae,
|
||||
"audio_vae": audio_vae,
|
||||
"image": None,
|
||||
"pos_text": p.positive.strip(),
|
||||
"neg_text": p.negative.strip(),
|
||||
"filename_state": {
|
||||
"preset": "",
|
||||
"stems": stems,
|
||||
"checkpoint": p.diffusion_model or p.diffusion_model_high,
|
||||
"vae": p.vae,
|
||||
"text_encoder": p.text_encoder,
|
||||
"loras": lora_entries,
|
||||
"model_family": spec.label,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _apply_anim_overrides(overrides_json: str, data: dict) -> dict:
|
||||
"""Per-instance steps/cfg/sampler/scheduler/filename_prefix overrides, read
|
||||
the same way as the image presets' (``_apply_preset_overrides``)."""
|
||||
preset = data["preset"]
|
||||
_, generation_data, sampling = _apply_preset_overrides(
|
||||
overrides_json, None, (None,) * 7 + (data["filename_state"],),
|
||||
(preset["steps"], preset["cfg"], preset["sampler"], preset["scheduler"], 1.0),
|
||||
)
|
||||
steps, cfg, sampler, scheduler, _ = sampling
|
||||
return {
|
||||
**data,
|
||||
"preset": {**preset, "steps": steps, "cfg": cfg, "sampler": sampler, "scheduler": scheduler},
|
||||
"filename_state": generation_data[7],
|
||||
}
|
||||
|
||||
|
||||
class FlakeAnimPreset:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
try:
|
||||
preset_names = flake_io.list_presets(kind="anim")
|
||||
except Exception:
|
||||
preset_names = []
|
||||
if not preset_names:
|
||||
preset_names = ["No model preset is selected"]
|
||||
return {
|
||||
"required": {
|
||||
"family": ([f.label for f in flake_families.ANIM_FAMILIES],),
|
||||
"preset": (["Select a preset..."] + preset_names,),
|
||||
},
|
||||
"optional": {
|
||||
"overrides_json": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "JSON override of steps/cfg/sampler/scheduler/filename_prefix. Managed by the Flake widget.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLAKE_ANIM_DATA",)
|
||||
RETURN_NAMES = ("flake_anim_data",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = (
|
||||
"Load an animate preset (diffusion models, text encoder, VAEs, LoRAs, prompts, sampling and "
|
||||
"video settings) into a flake_anim_data pin for the Flake Animate nodes."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, family: str, preset: str, overrides_json: str = ""):
|
||||
# Re-run when the preset file changes on disk, like FlakeModelPreset.
|
||||
preset_name = preset.strip() if preset else ""
|
||||
if not preset_name or preset_name in _PLACEHOLDERS:
|
||||
return f"{preset_name}:{overrides_json}"
|
||||
try:
|
||||
path = flake_io._resolve_preset_file(preset_name)
|
||||
st = os.stat(path)
|
||||
return f"{path}:{st.st_mtime_ns}:{st.st_size}:{overrides_json}"
|
||||
except Exception:
|
||||
return float("nan")
|
||||
|
||||
def execute(self, family: str, preset: str, overrides_json: str = ""):
|
||||
preset_name = preset.strip() if preset else ""
|
||||
if not preset_name or preset_name in _PLACEHOLDERS:
|
||||
raise ValueError("No animate preset is selected — pick one from the dropdown.")
|
||||
return (_apply_anim_overrides(overrides_json, _load_anim_preset_data(preset_name)),)
|
||||
|
||||
|
||||
class FlakeAnimPresetCombo(FlakeAnimPreset):
|
||||
"""Same backend as FlakeAnimPreset; the frontend rotates the ``preset``
|
||||
widget over its list when queueing, like FlakeModelCombo."""
|
||||
pass
|
||||
|
||||
|
||||
class FlakeAnimStack:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"family": ([f.label for f in flake_families.ANIM_FAMILIES],),
|
||||
"flake_anim_data": ("FLAKE_ANIM_DATA",),
|
||||
"flakes_json": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "[]",
|
||||
"tooltip": "JSON-encoded list of animate flake entries. Managed by the Flake widget.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLAKE_ANIM_DATA",)
|
||||
RETURN_NAMES = ("flake_anim_data",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = (
|
||||
"Compose animate flakes on top of an incoming flake_anim_data: applies their LoRAs to the "
|
||||
"diffusion model(s), appends their prompts and sets the start image from the grid items' input "
|
||||
"images (one job per image). Prompts are encoded in Flake Generate Animation."
|
||||
)
|
||||
|
||||
def execute(self, family: str, flake_anim_data: dict, flakes_json: str):
|
||||
flakes = _resolve_flakes(flakes_json)
|
||||
# The queue puts each job's start image on its grid item as `image`
|
||||
# (#441); the last one in the chain wins.
|
||||
images = [e["image"] for e in json.loads(flakes_json or "[]")
|
||||
if isinstance(e, dict) and e.get("image") and not e.get("bypassed")]
|
||||
# LoRA schedules are cond hooks and nothing is encoded here, so
|
||||
# animate flake LoRAs apply at their plain strength.
|
||||
models, lora_entries = _apply_anim_loras(flake_anim_data["models"], [lr for f in flakes for lr in f.loras])
|
||||
filename_state = flake_anim_data["filename_state"]
|
||||
pos_text, neg_text, prompt_parts = _join_prompts(
|
||||
flake_anim_data["pos_text"], flake_anim_data["neg_text"], flakes, filename_state.get("prompt_parts"))
|
||||
return ({
|
||||
**flake_anim_data,
|
||||
"models": models,
|
||||
"image": _load_cn_image(images[-1]) if images else flake_anim_data["image"],
|
||||
"pos_text": pos_text,
|
||||
"neg_text": neg_text,
|
||||
"filename_state": {
|
||||
**filename_state,
|
||||
"stems": filename_state["stems"] + [f.output_stem for f in flakes if f.output_stem],
|
||||
"loras": filename_state["loras"] + lora_entries,
|
||||
"prompt_parts": prompt_parts,
|
||||
},
|
||||
},)
|
||||
|
||||
|
||||
class FlakeAnimCombo(FlakeAnimStack):
|
||||
"""Same backend as FlakeAnimStack; the frontend keeps one active flake in
|
||||
``flakes_json`` per queued combination, like FlakeCombo."""
|
||||
pass
|
||||
|
||||
|
||||
class FlakeAnimCaption:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"jobs_json": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "[]",
|
||||
"tooltip": 'JSON list of {"preset", "flakes": [flakes_json per animate Stack/Combo in chain order], "image"}.',
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = (
|
||||
"Auto-captions Flake Animate start images with each preset's caption model. Queued by Flake "
|
||||
"Animate's queue before the videos; not meant to be placed by hand."
|
||||
)
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, jobs_json):
|
||||
# Fresh captions every run.
|
||||
return float("nan")
|
||||
|
||||
def execute(self, jobs_json: str):
|
||||
jobs = []
|
||||
for i, job in enumerate(json.loads(jobs_json)):
|
||||
p = flake_io.load_anim_preset(job["preset"])
|
||||
if not p.caption_model:
|
||||
raise ValueError(f"Animate preset '{p.name}' has no Caption model — set one in the preset editor to auto-caption.")
|
||||
if not job.get("image"):
|
||||
raise ValueError(f"Flake Animate Caption: job {i + 1} (preset '{p.name}') has no start image.")
|
||||
pos_text, neg_text, parts = p.positive.strip(), p.negative.strip(), None
|
||||
for flakes_json in job["flakes"]:
|
||||
pos_text, neg_text, parts = _join_prompts(pos_text, neg_text, _resolve_flakes(flakes_json), parts)
|
||||
jobs.append((_resolve_model_name("text_encoders", p.caption_model), p, pos_text, job["image"]))
|
||||
|
||||
m = nodes.NODE_CLASS_MAPPINGS
|
||||
captions = [""] * len(jobs)
|
||||
for model_file in dict.fromkeys(job[0] for job in jobs):
|
||||
clip = m["CLIPLoader"]().load_clip(model_file, "stable_diffusion")[0]
|
||||
for i, (file, p, pos_text, image) in enumerate(jobs):
|
||||
if file != model_file:
|
||||
continue
|
||||
sampling = {"sampling_mode": "on", "temperature": 0.7, "top_k": 64, "top_p": 0.95, "min_p": 0.05,
|
||||
"repetition_penalty": 1.05, "seed": random.randint(0, 0xffffffffffffffff)}
|
||||
text = m["TextGenerate"].execute(clip, pos_text, 1536, sampling, image=_load_cn_image(image), thinking=False,
|
||||
system_prompt=flake_caption.instruction_for(p)).result[0].strip()
|
||||
if not text:
|
||||
raise ValueError(f"Flake Animate Caption: '{model_file}' returned an empty caption for job {i + 1} (preset '{p.name}').")
|
||||
captions[i] = text
|
||||
return {"ui": {"flake_captions": captions}}
|
||||
|
||||
|
||||
# MiniMax H3 renders at a fixed 24 fps (FPS in comfy_extras/nodes_minimax_h3.py).
|
||||
_MH3_FPS = 24
|
||||
|
||||
|
||||
def _video_size(image, resolution: str, multiple: int) -> tuple[int, int]:
|
||||
"""The preset's short edge (480p/580p/720p) at the start image's aspect,
|
||||
both sides rounded to ``multiple`` (Wan 2.2 takes 16, MiniMax H3 32)."""
|
||||
h, w = image.shape[1], image.shape[2]
|
||||
scale = int(resolution.rstrip("p")) / min(w, h)
|
||||
return max(multiple, round(w * scale / multiple) * multiple), max(multiple, round(h * scale / multiple) * multiple)
|
||||
|
||||
|
||||
def _timing(family: str, preset: dict) -> tuple[int, int]:
|
||||
"""Frame count to request and the video fps. Wan 2.2 takes 4k+1 frames at
|
||||
the preset fps; MiniMax H3 snaps the count to its own grid."""
|
||||
if family == "wan22":
|
||||
return round(preset["duration"] * preset["fps"] / 4) * 4 + 1, preset["fps"]
|
||||
return round(preset["duration"] * _MH3_FPS), _MH3_FPS
|
||||
|
||||
|
||||
def _sample_wan22(data, seed, width, height, length):
|
||||
"""High-noise model up to ``refiner_step``, low-noise model for the rest."""
|
||||
m = nodes.NODE_CLASS_MAPPINGS
|
||||
p = data["preset"]
|
||||
shift = m["ModelSamplingSD3"]()
|
||||
high = shift.patch(data["models"]["high"], p["shift_high"])[0]
|
||||
low = shift.patch(data["models"]["low"], p["shift_low"])[0]
|
||||
encode = m["CLIPTextEncode"]()
|
||||
positive, negative, latent = m["WanImageToVideo"].execute(
|
||||
encode.encode(data["clip"], data["pos_text"])[0], encode.encode(data["clip"], data["neg_text"])[0],
|
||||
data["vae"], width, height, length, 1, start_image=data["image"]).result
|
||||
ks = m["KSamplerAdvanced"]()
|
||||
sampling = (p["steps"], p["cfg"], p["sampler"], p["scheduler"], positive, negative)
|
||||
latent = ks.sample(high, "enable", seed, *sampling, latent, 0, p["refiner_step"], "enable")[0]
|
||||
latent = ks.sample(low, "disable", seed, *sampling, latent, p["refiner_step"], 10000, "disable")[0]
|
||||
return m["VAEDecode"]().decode(data["vae"], latent)[0], None
|
||||
|
||||
|
||||
def _sample_mh3(data, seed, width, height, length):
|
||||
m = nodes.NODE_CLASS_MAPPINGS
|
||||
p = data["preset"]
|
||||
model = m["MiniMaxH3SigmaShift"].execute(data["models"]["model"], p["shift_video"], p["shift_audio"]).result[0]
|
||||
positive, latent = m["MiniMaxH3ImageToVideo"].execute(
|
||||
data["clip"], data["vae"], data["pos_text"], width, height, length, first_frame=data["image"]).result
|
||||
negative = m["CLIPTextEncode"]().encode(data["clip"], data["neg_text"])[0]
|
||||
latent = m["KSampler"]().sample(model, seed, p["steps"], p["cfg"], p["sampler"], p["scheduler"],
|
||||
positive, negative, latent, denoise=1.0)[0]
|
||||
# The AV latent decodes as is: VAEDecode reads its video half, VAEDecodeAudio its audio half.
|
||||
images = m["VAEDecode"]().decode(data["vae"], latent)[0]
|
||||
audio = m["VAEDecodeAudio"].execute(vae=data["audio_vae"], samples=latent).result[0]
|
||||
return images, audio
|
||||
|
||||
|
||||
class FlakeGenerateAnimation:
|
||||
"""Samples a FLAKE_ANIM_DATA chain into a video, optionally upscales and
|
||||
frame-interpolates it, and saves it with core Create Video / Save Video."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"flake_anim_data": ("FLAKE_ANIM_DATA",),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
|
||||
},
|
||||
"optional": {
|
||||
"upscale": ("BOOLEAN", {"default": False, "label_on": "Upscale", "label_off": "Upscale off"}),
|
||||
"upscale_model": ([""] + folder_paths.get_filename_list("upscale_models"), {"default": ""}),
|
||||
"interpolate": ("BOOLEAN", {"default": False, "label_on": "Interpolate", "label_off": "Interpolate off"}),
|
||||
"interpolation_model": ([""] + folder_paths.get_filename_list("frame_interpolation"), {
|
||||
"default": "", "tooltip": "Doubles the frame count and the fps."}),
|
||||
"use_headroom": ("BOOLEAN", {"default": False, "label_on": "Headroom", "label_off": "Headroom off"}),
|
||||
"headroom": ("FLOAT", {
|
||||
"default": 0.0, "min": 0.0, "max": 64.0, "step": 0.5,
|
||||
"tooltip": "GB of VRAM kept free while sampling and decoding, on top of ComfyUI's reserve."}),
|
||||
"attention": (_attention_options(), {
|
||||
"default": "default", "tooltip": "Attention backend for the diffusion model(s); default is the one ComfyUI was launched with."}),
|
||||
"caption": ("STRING", {
|
||||
"default": "", "multiline": False, "tooltip": "Auto-caption set by the queue; replaces the positive prompt when not empty."}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = "Generate a video from FLAKE_ANIM_DATA and its start image, then save it as MP4. Displays the result inline."
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, flake_anim_data, seed, upscale=False, upscale_model="", interpolate=False, interpolation_model="",
|
||||
use_headroom=False, headroom=0.0, attention="default", caption="", prompt=None, extra_pnginfo=None):
|
||||
data = flake_anim_data
|
||||
if data["image"] is None:
|
||||
raise ValueError("Flake Generate Animation has no start image — add one to a Flake Animate Stack/Combo grid item (its bottom-left + button).")
|
||||
if upscale and not upscale_model:
|
||||
raise ValueError("Flake Generate Animation: Upscale is on but no upscale_model is selected.")
|
||||
if interpolate and not interpolation_model:
|
||||
raise ValueError("Flake Generate Animation: Interpolate is on but no interpolation_model is selected.")
|
||||
family, preset = data["family"], data["preset"]
|
||||
width, height = _video_size(data["image"], preset["resolution"], 16 if data["family"] == "wan22" else 32)
|
||||
length, fps = _timing(family, preset)
|
||||
sample = _sample_wan22 if family == "wan22" else _sample_mh3
|
||||
data = {**data, "models": {key: _apply_attention(model, attention) for key, model in data["models"].items()}}
|
||||
if caption:
|
||||
data["pos_text"] = caption
|
||||
|
||||
reserved = comfy.model_management.EXTRA_RESERVED_VRAM
|
||||
comfy.model_management.EXTRA_RESERVED_VRAM = reserved + (int(headroom * 1024 ** 3) if use_headroom else 0)
|
||||
try:
|
||||
images, audio = sample(data, seed, width, height, length)
|
||||
finally:
|
||||
comfy.model_management.EXTRA_RESERVED_VRAM = reserved
|
||||
|
||||
m = nodes.NODE_CLASS_MAPPINGS
|
||||
if upscale:
|
||||
model = m["UpscaleModelLoader"].execute(upscale_model).result[0]
|
||||
images = m["ImageUpscaleWithModel"].execute(model, images).result[0]
|
||||
if interpolate:
|
||||
model = m["FrameInterpolationModelLoader"].execute(interpolation_model).result[0]
|
||||
images = m["FrameInterpolate"].execute(model, images, 2).result[0]
|
||||
fps *= 2
|
||||
|
||||
video = m["CreateVideo"].execute(images, fps, audio).result[0]
|
||||
fs = data["filename_state"]
|
||||
# The metadata goes to a sidecar, not the mp4 (#479).
|
||||
save = m["SaveVideo"].PREPARE_CLASS_CLONE({"hidden_inputs": {io.Hidden.prompt: None, io.Hidden.extra_pnginfo: None}})
|
||||
saved = save.execute(video, _build_filename_prefix(fs["preset"], fs["stems"]), "mp4").ui.as_dict()["images"]
|
||||
if not args.disable_metadata:
|
||||
for v in saved:
|
||||
path = os.path.join(folder_paths.get_output_directory(), v["subfolder"], v["filename"])
|
||||
flake_io.write_metadata_sidecar(path, prompt, extra_pnginfo)
|
||||
ui = {"flake_videos": saved, "seed": [seed]}
|
||||
if caption:
|
||||
ui["flake_caption"] = [caption]
|
||||
return {"ui": ui}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Built-in system instructions for auto-captioning an animate job's start
|
||||
image, one per animate family. The vision model gets the instruction as system
|
||||
prompt, the job's direction text as user message and the first frame as image."""
|
||||
|
||||
MH3_INSTRUCTION = """You write the video prompt for MiniMax H3, an image-to-video model that also generates audio. You get a start image and a direction text (the action, camera and mood wanted; its parts may be separated by " BREAK "). The image is the first frame of the video at 0.00 seconds. Describe what you see in it, then write forward from it so the characters in the image perform the directed action. Reply with the finished prompt only: no preamble, no markdown, no code fences, no quotes, no headings.
|
||||
|
||||
Everything is in English except spoken dialogue and visible on-screen text, which keep their language. Keep every person an adult and never describe anyone with a word implying a minor. Name nobody, even if you recognise them. Keep the whole prompt under 7000 characters.
|
||||
|
||||
Output exactly three fields in this order, separated by blank lines:
|
||||
|
||||
[Shot 1] ... (the whole visual description, see below)
|
||||
|
||||
overall_soundscape: ...
|
||||
|
||||
non_diegetic_music: ...
|
||||
|
||||
Visual description:
|
||||
- Open [Shot 1] with the overall style read from the image (live-action, 2D-animated, 3D CG, watercolor...) and the initial composition. Then establish the subjects as they look in the image (hair, eyes, build, anything unusual, clothing with colors), the setting, key props and light. Do not redesign the image; keep identity, clothing, colors and spatial relationships consistent.
|
||||
- Then write forward: action onset, continuous development, result or reaction. Name what changes during the shot, not a frozen frame. Use the direction for the action, camera and mood; keep its intent and drop nothing it names.
|
||||
- Prefer one continuous shot. Add a cut only when the direction asks for several shots or one is clearly motivated. Later shots are [Shot 2], [Shot 3]... and open with a strictly increasing cut time inside the clip, as "At 00:03.500, the camera cuts to...". [Shot 1] has no timestamp.
|
||||
- Write camera motion as a natural sentence: motion type plus amplitude and speed only where they matter. Motion types: zoom in/out, push in/pull out, pan left/right, truck left/right, tilt up/down, pedestal up/down, arc shot, tracking shot, static shot, shake slightly/strongly, POV, roll clockwise/counterclockwise. Example: "The camera pushes in with small amplitude at slow speed toward her hands." One main camera idea per shot.
|
||||
- Keep the characters in focus. Every sentence must be something a camera can see or a microphone can hear.
|
||||
- A character who speaks or sings gets a stable ID: "The woman with a low, calm voice (S1) says: <d>[English] Ready.</d>". Only the language tag and the exact spoken words go inside <d>. Write dialogue only when the direction gives it. Text visible on screen goes in double quotes, verbatim.
|
||||
|
||||
overall_soundscape: 1 to 4 sentences in one paragraph on the diegetic sound: ambient sound, sounds of the actions (footsteps, fabric, impacts, breathing). Do not repeat dialogue or singing.
|
||||
|
||||
non_diegetic_music: 1 to 3 sentences on a score only the audience hears: instrumentation, tempo, rhythm and dynamic changes, no mood words. Write N/A when the direction does not ask for music."""
|
||||
|
||||
WAN22_INSTRUCTION = """You write the video prompt for Wan 2.2, an image-to-video model. You get a start image, which is the first frame of the video, and a direction text (the action, camera and mood wanted; its parts may be separated by " BREAK "). Reply with the finished prompt only: one dense paragraph of plain English prose, no preamble, no markdown, no quotes, no lists, no audio or sound.
|
||||
|
||||
Describe the subjects as they look in the image (hair, eyes, build, clothing with colors) and the setting, then the directed action as it unfolds in order, the camera motion, and the lighting and mood. Keep everything the direction names. Do not redesign the image, name nobody, and keep every person an adult."""
|
||||
|
||||
INSTRUCTIONS = {"mh3": MH3_INSTRUCTION, "wan22": WAN22_INSTRUCTION}
|
||||
|
||||
|
||||
def instruction_for(preset) -> str:
|
||||
override = preset.caption_instruction.strip()
|
||||
if override:
|
||||
return override
|
||||
if preset.family not in INSTRUCTIONS:
|
||||
raise ValueError(f"No auto-caption instruction for animate family '{preset.family}'")
|
||||
return INSTRUCTIONS[preset.family]
|
||||
+260
-24
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
@@ -8,37 +10,25 @@ import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
import comfy.hooks
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import node_helpers
|
||||
from nodes import (
|
||||
CLIPTextEncode,
|
||||
ControlNetApplyAdvanced,
|
||||
ControlNetLoader,
|
||||
EmptyLatentImage,
|
||||
LoraLoader,
|
||||
NODE_CLASS_MAPPINGS,
|
||||
)
|
||||
|
||||
from . import flake_io
|
||||
|
||||
|
||||
def _resolve_model_name(category: str, stem_or_name: str) -> str:
|
||||
available = folder_paths.get_filename_list(category)
|
||||
available_norm = {p.replace("\\", "/"): p for p in available}
|
||||
|
||||
norm = stem_or_name.replace("\\", "/")
|
||||
if norm in available_norm:
|
||||
return available_norm[norm]
|
||||
|
||||
norm_stem, _ = os.path.splitext(norm)
|
||||
for cand_norm, candidate in available_norm.items():
|
||||
stem, _ = os.path.splitext(cand_norm)
|
||||
if stem == norm_stem:
|
||||
return candidate
|
||||
|
||||
return stem_or_name
|
||||
|
||||
|
||||
def _resolve_lora_name(stem_or_name: str) -> str:
|
||||
result = _resolve_model_name("loras", stem_or_name)
|
||||
result = flake_io._resolve_model_name("loras", stem_or_name)
|
||||
available = folder_paths.get_filename_list("loras")
|
||||
if result.replace("\\", "/") in {p.replace("\\", "/") for p in available}:
|
||||
return result
|
||||
@@ -48,22 +38,40 @@ def _resolve_lora_name(stem_or_name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _lora_file(path: str) -> str:
|
||||
"""Full path of a flake LoRA: a LoRA the flake owns arrives as an absolute
|
||||
path (#391), anything else is a name under models/loras/."""
|
||||
return path if os.path.isabs(path) else folder_paths.get_full_path_or_raise("loras", _resolve_lora_name(path))
|
||||
|
||||
|
||||
def _load_lora(model: Any, clip: Any, path: str, strength_model: float, strength_clip: float) -> tuple[Any, Any]:
|
||||
"""LoraLoader for a flake LoRA; an owned LoRA lives outside models/loras/,
|
||||
so it is loaded from its path the way LoraLoader does."""
|
||||
if not os.path.isabs(path):
|
||||
return LoraLoader().load_lora(model, clip, _resolve_lora_name(path), strength_model, strength_clip)
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
return model, clip
|
||||
lora, metadata = comfy.utils.load_torch_file(path, safe_load=True, return_metadata=True)
|
||||
return comfy.sd.load_lora_for_models(model, clip, lora, strength_model, strength_clip, lora_metadata=metadata)
|
||||
|
||||
|
||||
def _load_cn_image(image_name: str) -> torch.Tensor:
|
||||
"""Load an image tensor shaped [1, H, W, 3] (ComfyUI's IMAGE format) from ComfyUI/input/.
|
||||
|
||||
`image_name` may be a stem ('standing_openpose') or a filename with extension.
|
||||
Subdirectories under input/ are allowed ('cnet/standing_openpose').
|
||||
Absolute paths (a flake's resolved `./` images) are loaded directly.
|
||||
"""
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
norm = image_name.replace("\\", "/")
|
||||
|
||||
candidates: list[str] = []
|
||||
direct = os.path.join(input_dir, norm)
|
||||
direct = image_name if os.path.isabs(image_name) else os.path.join(input_dir, norm)
|
||||
if os.path.isfile(direct):
|
||||
candidates.append(direct)
|
||||
else:
|
||||
for ext in (".png", ".jpg", ".jpeg", ".webp"):
|
||||
alt = os.path.join(input_dir, f"{norm}{ext}")
|
||||
alt = f"{direct}{ext}"
|
||||
if os.path.isfile(alt):
|
||||
candidates.append(alt)
|
||||
break
|
||||
@@ -80,6 +88,226 @@ def _load_cn_image(image_name: str) -> torch.Tensor:
|
||||
return torch.from_numpy(arr)[None,]
|
||||
|
||||
|
||||
def _fit_cn_image(image: torch.Tensor, target_w: int, target_h: int) -> torch.Tensor:
|
||||
"""Resize a ControlNet hint image (IMAGE tensor [B, H, W, 3]) to the
|
||||
generation resolution, cropping to the target aspect first (#255).
|
||||
|
||||
ComfyUI otherwise stretches the hint to the latent size at sample time,
|
||||
which distorts the pose whenever the control image's aspect ratio differs
|
||||
from the generation resolution. Crop-and-resize (matching A1111's default)
|
||||
keeps the pose aligned with the generated frame.
|
||||
"""
|
||||
if target_w <= 0 or target_h <= 0:
|
||||
return image
|
||||
h, w = int(image.shape[1]), int(image.shape[2])
|
||||
if (w, h) == (target_w, target_h):
|
||||
return image
|
||||
samples = image.movedim(-1, 1) # [B, 3, H, W]
|
||||
samples = comfy.utils.common_upscale(samples, target_w, target_h, "lanczos", "center")
|
||||
return samples.movedim(1, -1) # back to [B, H, W, 3]
|
||||
|
||||
|
||||
# Model-patch ControlNets (#365), most recently used last: {name: (mtime, MODEL_PATCH)}.
|
||||
# Reusing the same MODEL_PATCH object across runs lets a Flake Combo batch skip
|
||||
# re-reading the file and lets ComfyUI keep it resident. Bounded so a multi-GB
|
||||
# Z-Image union patch isn't pinned once workflows stop using it.
|
||||
_CN_PATCH_CACHE: dict[str, tuple[float, Any]] = {}
|
||||
_CN_PATCH_CACHE_SIZE = 4
|
||||
|
||||
|
||||
def load_cn_patch(name: str) -> Any:
|
||||
"""Load a ControlNet model patch from models/model_patches/ (#365)."""
|
||||
from comfy_extras.nodes_model_patch import ModelPatchLoader
|
||||
|
||||
mtime = os.path.getmtime(folder_paths.get_full_path_or_raise("model_patches", name))
|
||||
hit = _CN_PATCH_CACHE.pop(name, None)
|
||||
if hit is None or hit[0] != mtime:
|
||||
hit = (mtime, ModelPatchLoader().load_model_patch(name)[0])
|
||||
_CN_PATCH_CACHE[name] = hit
|
||||
while len(_CN_PATCH_CACHE) > _CN_PATCH_CACHE_SIZE:
|
||||
del _CN_PATCH_CACHE[next(iter(_CN_PATCH_CACHE))]
|
||||
return hit[1]
|
||||
|
||||
|
||||
def apply_model_patch_cn(cn_kind: str, model: Any, vae: Any, model_patch: Any, image: torch.Tensor,
|
||||
strength: float, start_percent: float, end_percent: float) -> Any:
|
||||
"""Apply an Anima LLLite or Z-Image Fun ControlNet by patching the model (#365).
|
||||
|
||||
These hook the diffusion model's blocks rather than the conditioning. The
|
||||
returned model is a clone sharing the base weights, so swapping the patch
|
||||
between runs does not reload the diffusion model.
|
||||
"""
|
||||
from comfy_extras.nodes_model_patch import AnimaLLLiteApply, ZImageFunControlnet
|
||||
|
||||
if cn_kind == "anima_lllite":
|
||||
return AnimaLLLiteApply().apply_patch(model, model_patch, image, strength, start_percent, end_percent)[0]
|
||||
if (start_percent, end_percent) != (0.0, 1.0):
|
||||
logging.warning("[flakes] Z-Image Fun ControlNet has no start/end; applying it to every step")
|
||||
return ZImageFunControlnet().diffsynth_controlnet(model, model_patch, vae, image=image, strength=strength)[0]
|
||||
|
||||
|
||||
def _in_anima_folder(name: str, category: str) -> bool:
|
||||
return name.replace("\\", "/").lower().startswith("anima/") and name in folder_paths.get_filename_list(category)
|
||||
|
||||
|
||||
def _is_anima_vace(name: str) -> bool:
|
||||
"""Anima VACE ControlNets carry ``control_blocks.*.after_proj`` keys (#378)."""
|
||||
header = comfy.utils.safetensors_header(folder_paths.get_full_path_or_raise("controlnet", name))
|
||||
return bool(header) and any("control_blocks" in k and "after_proj" in k for k in json.loads(header))
|
||||
|
||||
|
||||
def anima_cn_choice(model_name: str) -> tuple[str, str] | None:
|
||||
"""The Anima ControlNet an explicit ``model_name`` picks, as ``(file, kind)``:
|
||||
a VACE file in models/controlnet/anima/ (kind "vace", #378) or any patch in
|
||||
models/model_patches/anima/ (kind "lllite"). None when it resolves to
|
||||
neither, so the type-inferred LLLite file applies (#381)."""
|
||||
name = flake_io.resolve_cn_model_name(model_name, "anima", "controlnet")
|
||||
if _in_anima_folder(name, "controlnet") and _is_anima_vace(name):
|
||||
return name, "vace"
|
||||
name = flake_io.resolve_cn_model_name(model_name, "anima", "model_patches")
|
||||
if _in_anima_folder(name, "model_patches"):
|
||||
return name, "lllite"
|
||||
return None
|
||||
|
||||
|
||||
def anima_cn_files() -> list[dict[str, str]]:
|
||||
"""Files selectable as an Anima ControlNet ``model`` (#381)."""
|
||||
files = [{"name": n, "kind": "lllite"} for n in folder_paths.get_filename_list("model_patches")
|
||||
if n.replace("\\", "/").lower().startswith("anima/")]
|
||||
files += [{"name": n, "kind": "vace"} for n in folder_paths.get_filename_list("controlnet")
|
||||
if n.replace("\\", "/").lower().startswith("anima/") and n.endswith(".safetensors") and _is_anima_vace(n)]
|
||||
return files
|
||||
|
||||
|
||||
def apply_anima_vace(positive: Any, negative: Any, name: str, vae: Any, image: torch.Tensor,
|
||||
strength: float, start_percent: float, end_percent: float) -> tuple[Any, Any]:
|
||||
"""Apply an Anima VACE ControlNet through the nodes of the
|
||||
ComfyUI-Advanced-ControlNet-Anima fork (#378); core ComfyUI can't load it."""
|
||||
loader = NODE_CLASS_MAPPINGS.get("ACN_ControlNetLoaderAdvanced")
|
||||
apply = NODE_CLASS_MAPPINGS.get("ACN_AdvancedControlNetApply_v2")
|
||||
# Upstream Advanced-ControlNet registers the same nodes without VACE support.
|
||||
if loader is None or apply is None or importlib.util.find_spec(
|
||||
loader.__module__.rsplit(".", 1)[0] + ".control_anima_vace") is None:
|
||||
raise RuntimeError(
|
||||
f"Anima VACE ControlNet '{name}' needs the ComfyUI-Advanced-ControlNet-Anima custom nodes "
|
||||
f"(github.com/TaihoC/ComfyUI-Advanced-ControlNet-Anima, branch feat/anima-vace-controlnet)."
|
||||
)
|
||||
control_net = loader().load_controlnet(name)[0]
|
||||
return apply().apply_controlnet(positive, negative, control_net, image, strength, start_percent, end_percent,
|
||||
vae_optional=vae)
|
||||
|
||||
|
||||
def maybe_zero_out_negative(negative: Any, cfg: float) -> Any:
|
||||
"""Zero out the negative conditioning when sampling at cfg <= 1 (#359).
|
||||
|
||||
Distilled/turbo models (Z-Image Turbo, Krea2 Turbo, Anima with the turbo
|
||||
LoRA) run at cfg 1.0, where the negative branch is never evaluated — the
|
||||
workflow templates ComfyUI ships all feed those samplers a
|
||||
``ConditioningZeroOut`` instead of encoded text.
|
||||
|
||||
Keying this off the *effective* cfg rather than the model family is what
|
||||
makes it correct for free: Anima base at cfg 4 keeps its real negative,
|
||||
Anima + turbo LoRA at cfg 1 does not, and no per-family flag can get out of
|
||||
step with the value the user actually sampled at.
|
||||
"""
|
||||
if negative is None or cfg is None or cfg > 1.0:
|
||||
return negative
|
||||
from nodes import ConditioningZeroOut
|
||||
|
||||
return ConditioningZeroOut().zero_out(negative)[0]
|
||||
|
||||
|
||||
def schedule_segments(schedule: list[flake_io.ScheduleKeyframe]) -> list[tuple[float, float, float]]:
|
||||
"""``(start, end, strength)`` spans covering [0, 1] for a hook schedule (#370)."""
|
||||
segments = []
|
||||
start, strength = 0.0, 1.0
|
||||
for kf in schedule:
|
||||
if kf.start > start:
|
||||
segments.append((start, kf.start, strength))
|
||||
start, strength = kf.start, kf.strength
|
||||
if start < 1.0:
|
||||
segments.append((start, 1.0, strength))
|
||||
return segments
|
||||
|
||||
|
||||
def scheduled_lora_hooks(lora_path: str, strength: float, schedule: list[flake_io.ScheduleKeyframe]) -> Any:
|
||||
"""Hook group applying a LoRA's model weights under ``schedule`` (#370).
|
||||
|
||||
Hooked weights only act on conds that carry the hooks, so the caller
|
||||
attaches the group to both positive and negative.
|
||||
"""
|
||||
lora = comfy.utils.load_torch_file(_lora_file(lora_path), safe_load=True)
|
||||
hooks = comfy.hooks.create_hook_lora(lora, strength, 0.0)
|
||||
keyframes = comfy.hooks.HookKeyframeGroup()
|
||||
for kf in schedule:
|
||||
keyframes.add(comfy.hooks.HookKeyframe(strength=kf.strength, start_percent=kf.start))
|
||||
hooks.set_keyframes_on_hooks(keyframes)
|
||||
return hooks
|
||||
|
||||
|
||||
def apply_loras(model: Any, clip: Any, flakes: list[flake_io.Flake]) -> tuple[Any, Any, list[dict], list]:
|
||||
"""Apply the flakes' LoRAs, returning ``(model, clip, lora_entries, hook_groups)``.
|
||||
|
||||
A scheduled LoRA's model weights become hooks on the conds (#370); its
|
||||
text-encoder part is applied statically so trigger words encode.
|
||||
"""
|
||||
lora_entries = []
|
||||
hook_groups = []
|
||||
for f in flakes:
|
||||
for lr in f.loras:
|
||||
if not lr.path:
|
||||
continue
|
||||
if lr.schedule:
|
||||
if all(s == 0 for _, _, s in schedule_segments(lr.schedule)):
|
||||
continue
|
||||
model, clip = _load_lora(model, clip, lr.path, 0, lr.strength)
|
||||
hook_groups.append(scheduled_lora_hooks(lr.path, lr.strength, lr.schedule))
|
||||
else:
|
||||
model, clip = _load_lora(model, clip, lr.path, lr.strength, lr.strength)
|
||||
lora_entries.append({"name": lr.name or lr.path, "path": lr.path, "strength": lr.strength})
|
||||
# Legacy single LoRA fallback
|
||||
if f.lora_path:
|
||||
model, clip = _load_lora(model, clip, f.lora_path, f.strength, f.strength)
|
||||
lora_entries.append({"name": f.lora_path, "path": f.lora_path, "strength": f.strength})
|
||||
return model, clip, lora_entries, hook_groups
|
||||
|
||||
|
||||
def encode_scheduled_prompts(clip: Any, flakes: list[flake_io.Flake]) -> tuple[list, list]:
|
||||
"""Time-ranged positive and negative entries for the flakes' scheduled prompts (#370)."""
|
||||
pos_ranged, neg_ranged = [], []
|
||||
for f in flakes:
|
||||
for p in f.scheduled_prompts:
|
||||
if p.positive.strip():
|
||||
pos_ranged += encode_scheduled(clip, p.positive.strip(), p.schedule)
|
||||
if p.negative.strip():
|
||||
neg_ranged += encode_scheduled(clip, p.negative.strip(), p.schedule)
|
||||
return pos_ranged, neg_ranged
|
||||
|
||||
|
||||
def encode_scheduled(clip: Any, text: str, schedule: list[flake_io.ScheduleKeyframe]) -> list:
|
||||
"""Encode ``text`` as one cond entry per schedule segment with strength > 0,
|
||||
to be appended after the base cond (#370)."""
|
||||
cond = CLIPTextEncode().encode(clip, text)[0]
|
||||
out = []
|
||||
for start, end, strength in schedule_segments(schedule):
|
||||
if strength > 0:
|
||||
out += node_helpers.conditioning_set_values(
|
||||
cond, {"start_percent": start, "end_percent": end, "strength": strength})
|
||||
return out
|
||||
|
||||
|
||||
def carry_schedules(src_cond: list, dst_cond: list, ranged: list, hook_groups: list) -> list:
|
||||
"""Append upstream and new time-ranged prompt entries to freshly encoded
|
||||
``dst_cond`` and attach upstream and new LoRA hooks to every entry (#370).
|
||||
|
||||
A chained Flake node re-encodes the accumulated text, which drops what
|
||||
upstream nodes attached (like ``control``, see ``_carry_controlnets``).
|
||||
"""
|
||||
upstream_ranged = [t for t in src_cond if "start_percent" in t[1]]
|
||||
hooks = comfy.hooks.HookGroup.combine_all_hooks([src_cond[0][1].get("hooks")] + hook_groups)
|
||||
return comfy.hooks.set_hooks_for_conditioning(dst_cond + upstream_ranged + ranged, hooks, append_hooks=False)
|
||||
|
||||
|
||||
def compose(
|
||||
model: Any,
|
||||
clip: Any,
|
||||
@@ -87,12 +315,10 @@ def compose(
|
||||
) -> tuple:
|
||||
flakes = [flake_io.resolve(e) for e in entries]
|
||||
|
||||
lora_loader = LoraLoader()
|
||||
for f in flakes:
|
||||
if not f.lora_path:
|
||||
continue
|
||||
lora_name = _resolve_lora_name(f.lora_path)
|
||||
model, clip = lora_loader.load_lora(model, clip, lora_name, f.strength, f.strength)
|
||||
model, clip = _load_lora(model, clip, f.lora_path, f.strength, f.strength)
|
||||
|
||||
pos_text = " BREAK ".join(f.positive.strip() for f in flakes if f.positive and f.positive.strip())
|
||||
neg_text = ", ".join(f.negative.strip() for f in flakes if f.negative and f.negative.strip())
|
||||
@@ -108,7 +334,13 @@ def compose(
|
||||
for cn in f.controlnets:
|
||||
if cn.strength == 0:
|
||||
continue
|
||||
cn_resolved = _resolve_model_name("controlnet", cn.model_name)
|
||||
if not cn.model_name.strip():
|
||||
print(f"[flakes] skipping controlnet entry with empty model_name")
|
||||
continue
|
||||
if not cn.image_name.strip():
|
||||
print(f"[flakes] skipping controlnet entry with empty image_name (type={cn.type})")
|
||||
continue
|
||||
cn_resolved = flake_io._resolve_model_name("controlnet", cn.model_name)
|
||||
if cn_resolved not in cn_model_cache:
|
||||
cn_model_cache[cn_resolved] = cn_loader.load_controlnet(cn_resolved)[0]
|
||||
cn_model = cn_model_cache[cn_resolved]
|
||||
@@ -117,6 +349,10 @@ def compose(
|
||||
positive, negative, cn_model, image,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
logging.info(
|
||||
"[flakes] applied CN model=%s image=%s strength=%.2f start=%.2f end=%.2f",
|
||||
cn_resolved, cn.image_name, cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
|
||||
width, height = 1024, 1024
|
||||
for f in flakes:
|
||||
|
||||
+379
-14
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
try:
|
||||
from comfy.samplers import KSampler as _KSampler
|
||||
@@ -8,9 +11,44 @@ try:
|
||||
except Exception:
|
||||
_SAMPLER_TYPES = ("STRING", "STRING")
|
||||
|
||||
import folder_paths
|
||||
from comfy.cli_args import args
|
||||
from nodes import KSampler, VAEDecode, SaveImage
|
||||
|
||||
from .full_flake_node import _build_filename_prefix
|
||||
from . import flake_compose, flake_io
|
||||
from .full_flake_node import _apply_attention, _attention_options, _build_filename_prefix
|
||||
|
||||
_ADETAILER_DEFAULT_BBOX = "bbox/face_yolov8m.pt"
|
||||
|
||||
|
||||
def _list_adetailer_bbox() -> list[str]:
|
||||
"""ADetailer bbox model choices (#306): Impact Pack's ultralytics_bbox plus a
|
||||
direct scan of models/adetailer/. Always includes the default so the COMBO is
|
||||
never empty."""
|
||||
names: list[str] = []
|
||||
try:
|
||||
names += folder_paths.get_filename_list("ultralytics_bbox")
|
||||
except Exception:
|
||||
pass
|
||||
base = os.path.join(folder_paths.base_path, "models", "adetailer")
|
||||
if os.path.isdir(base):
|
||||
for root, _dirs, files in os.walk(base):
|
||||
for f in files:
|
||||
if f.lower().endswith((".pt", ".pth", ".onnx", ".safetensors")):
|
||||
rel = os.path.relpath(os.path.join(root, f), base).replace(os.sep, "/")
|
||||
names.append(rel)
|
||||
out = sorted(set(names))
|
||||
if _ADETAILER_DEFAULT_BBOX not in out:
|
||||
out.append(_ADETAILER_DEFAULT_BBOX)
|
||||
return out
|
||||
|
||||
|
||||
def _list_upscale_models() -> list[str]:
|
||||
"""Upscale model choices (#306); "" = plain rescale to factor."""
|
||||
try:
|
||||
return [""] + folder_paths.get_filename_list("upscale_models")
|
||||
except Exception:
|
||||
return [""]
|
||||
|
||||
_ALL_PINS = [
|
||||
("model", "MODEL"),
|
||||
@@ -49,7 +87,7 @@ def _split_flake_data(flake_data):
|
||||
)
|
||||
else:
|
||||
filename_prefix = ""
|
||||
steps, cfg, sampler_name, scheduler = sampling_preset
|
||||
steps, cfg, sampler_name, scheduler, denoise = sampling_preset
|
||||
return {
|
||||
"model": model,
|
||||
"clip": clip,
|
||||
@@ -64,6 +102,7 @@ def _split_flake_data(flake_data):
|
||||
"cfg": cfg,
|
||||
"sampler_name": sampler_name,
|
||||
"scheduler": scheduler,
|
||||
"denoise": denoise,
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +198,7 @@ class IntoFlakeDataAll:
|
||||
else:
|
||||
generation_data = None
|
||||
|
||||
sampling_preset = (steps, cfg, sampler_name, scheduler)
|
||||
sampling_preset = (steps, cfg, sampler_name, scheduler, 1.0)
|
||||
|
||||
return ((model_bundle, generation_data, sampling_preset),)
|
||||
|
||||
@@ -209,7 +248,7 @@ class IntoFlakeDataSelect:
|
||||
orig_filename_state = {}
|
||||
if len(orig_generation_data) > 7 and isinstance(orig_generation_data[7], dict):
|
||||
orig_filename_state = orig_generation_data[7]
|
||||
orig_steps, orig_cfg, orig_sampler, orig_scheduler = orig_sampling_preset
|
||||
orig_steps, orig_cfg, orig_sampler, orig_scheduler, orig_denoise = orig_sampling_preset
|
||||
|
||||
new_model_bundle = (
|
||||
model if model is not None else orig_model,
|
||||
@@ -235,6 +274,7 @@ class IntoFlakeDataSelect:
|
||||
cfg if cfg is not None else orig_cfg,
|
||||
sampler_name if sampler_name is not None else orig_sampler,
|
||||
scheduler if scheduler is not None else orig_scheduler,
|
||||
orig_denoise,
|
||||
)
|
||||
|
||||
return ((new_model_bundle, new_generation_data, new_sampling_preset),)
|
||||
@@ -312,11 +352,12 @@ class PreviewFlakeData:
|
||||
|
||||
# --- Parameters ---
|
||||
if sampling_preset is not None:
|
||||
steps, cfg, sampler_name, scheduler = sampling_preset
|
||||
steps, cfg, sampler_name, scheduler, denoise = sampling_preset
|
||||
params_info["Steps"] = str(steps)
|
||||
params_info["CFG"] = str(cfg)
|
||||
params_info["Sampler"] = str(sampler_name)
|
||||
params_info["Scheduler"] = str(scheduler)
|
||||
params_info["Denoise"] = str(denoise)
|
||||
|
||||
# --- Metadata ---
|
||||
if generation_data is not None:
|
||||
@@ -351,6 +392,210 @@ class PreviewFlakeData:
|
||||
}
|
||||
|
||||
|
||||
def _wf_node(extra_pnginfo, nid):
|
||||
"""The workflow node of prompt node ``nid``, or None. Nodes inside subgraphs
|
||||
have path ids ("<instance id>:<inner id>") and live in the workflow's
|
||||
``definitions.subgraphs``, keyed by the instance node's type."""
|
||||
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
||||
if not isinstance(workflow, dict):
|
||||
return None
|
||||
subgraphs = {sg.get("id"): sg for sg in (workflow.get("definitions") or {}).get("subgraphs") or []}
|
||||
nodes = workflow.get("nodes") or []
|
||||
*path, last = str(nid).split(":")
|
||||
for part in path:
|
||||
inst = next((n for n in nodes if str(n.get("id")) == part), None)
|
||||
sg = subgraphs.get(inst.get("type")) if inst else None
|
||||
if sg is None:
|
||||
return None
|
||||
nodes = sg.get("nodes") or []
|
||||
return next((n for n in nodes if str(n.get("id")) == last), None)
|
||||
|
||||
|
||||
# The Flake Stack widget's index 0 (web/utils.js makeDefaultEntry).
|
||||
_STACK_DEFAULT_ENTRY = {"inline": True, "content": {"prompt": {"positive": ""}}, "loras": [], "strength": 1.0, "option": {}}
|
||||
|
||||
|
||||
def _used_entry(entry):
|
||||
"""A stack entry as it ran: All Variants / Multiple collapsed to the variant
|
||||
queue.js queued, combo-only fields dropped, per-choice overrides kept for
|
||||
the used choices only, option toggles for the options it has only."""
|
||||
entry = {k: v for k, v in entry.items() if k not in ("_uid", "_all_variants")}
|
||||
if "variant" in entry:
|
||||
entry["variant"] = {g: c[0] if isinstance(c, list) else c for g, c in entry["variant"].items()}
|
||||
keys = [k for k in ("variant_loras", "variant_link_overrides") if isinstance(entry.get(k), dict)]
|
||||
toggles = entry.get("option_overrides")
|
||||
if (keys or isinstance(toggles, dict)) and not entry.get("inline"):
|
||||
try:
|
||||
flake = flake_io.resolve(entry)
|
||||
except FileNotFoundError:
|
||||
return entry
|
||||
if isinstance(toggles, dict):
|
||||
entry["option_overrides"] = {n: on for n, on in toggles.items() if n in flake.options}
|
||||
if not entry["option_overrides"]:
|
||||
del entry["option_overrides"]
|
||||
used = flake.used_variants.get(entry["name"], {})
|
||||
for k in keys:
|
||||
kept = {g: {c: v for c, v in choices.items() if c in used.get(g, ())}
|
||||
for g, choices in entry[k].items() if isinstance(choices, dict)}
|
||||
entry[k] = {g: choices for g, choices in kept.items() if choices}
|
||||
if not entry[k]:
|
||||
del entry[k]
|
||||
return entry
|
||||
|
||||
|
||||
def _mergeable_stack(entries):
|
||||
"""Whether a stack's entries can join the end of the upstream stack without
|
||||
changing the image: no inline Default with content (the widget only keeps
|
||||
one, at index 0), and no new size (FlakeStack.execute fits every stack's
|
||||
ControlNet images to the size its first sizing flake sets)."""
|
||||
flakes = [flake_io.resolve(e) for e in entries]
|
||||
if entries and entries[0].get("inline") and flakes[0] != flake_io.resolve(_STACK_DEFAULT_ENTRY):
|
||||
return False
|
||||
return not any(f.resolution or any(cn.resolution_from_image for cn in f.controlnets) for f in flakes)
|
||||
|
||||
|
||||
def _merge_stacks(prompt, workflow):
|
||||
"""Fold each run of chained root-level Flake Stacks into its first node, so
|
||||
a saved image restores one compact stack (#436). A stack is folded into
|
||||
the one feeding it only when it is that stack's sole consumer; subgraph
|
||||
definitions are shared by every instance and stay untouched."""
|
||||
nodes = workflow.get("nodes") or []
|
||||
links = workflow.get("links") or []
|
||||
by_id = {str(n["id"]): n for n in nodes}
|
||||
removed = set()
|
||||
for a in nodes:
|
||||
aid = str(a["id"])
|
||||
if aid in removed or prompt.get(aid, {}).get("class_type") != "FlakeStack":
|
||||
continue
|
||||
while True:
|
||||
out = [l for l in links if str(l[1]) == aid]
|
||||
bid = str(out[0][3]) if len(out) == 1 else None
|
||||
if prompt.get(bid, {}).get("class_type") != "FlakeStack":
|
||||
break
|
||||
a_raw, b_raw = prompt[aid]["inputs"]["flakes_json"], prompt[bid]["inputs"]["flakes_json"]
|
||||
b_entries = json.loads(b_raw)
|
||||
if not _mergeable_stack(b_entries):
|
||||
break
|
||||
if b_entries and b_entries[0].get("inline"):
|
||||
b_entries = b_entries[1:]
|
||||
new_raw = json.dumps(json.loads(a_raw) + b_entries)
|
||||
prompt[aid]["inputs"]["flakes_json"] = new_raw
|
||||
a["widgets_values"] = [new_raw if v == a_raw else v for v in a.get("widgets_values") or []]
|
||||
a.setdefault("properties", {})["_fit_size"] = True
|
||||
|
||||
# A takes over B's outgoing links; the A -> B link goes away with B.
|
||||
links.remove(out[0])
|
||||
for l in links:
|
||||
if str(l[1]) == bid:
|
||||
l[1] = a["id"]
|
||||
a["outputs"][0]["links"] = by_id[bid]["outputs"][0]["links"]
|
||||
del prompt[bid]
|
||||
for node in prompt.values():
|
||||
for k, v in node["inputs"].items():
|
||||
if isinstance(v, list) and len(v) == 2 and str(v[0]) == bid:
|
||||
node["inputs"][k] = [aid, v[1]]
|
||||
removed.add(bid)
|
||||
workflow["nodes"] = [n for n in nodes if str(n["id"]) not in removed]
|
||||
|
||||
|
||||
def _used_only_metadata(prompt, extra_pnginfo):
|
||||
"""Deep copies of ``prompt`` and ``extra_pnginfo`` describing only what
|
||||
produced this run, so a drag-dropped image restores just that (#346, #426):
|
||||
Flake Model Combo -> Flake Model Preset with the used preset and overrides,
|
||||
Flake Combo -> Flake Stack holding the used entry, stacks minus bypassed
|
||||
entries, chained stacks folded into one (#436). queue.js sets every combo node to a single item per queued
|
||||
prompt, so the prompt inputs already hold the used ones.
|
||||
"""
|
||||
if not prompt:
|
||||
return prompt, extra_pnginfo
|
||||
prompt = copy.deepcopy(prompt)
|
||||
extra_pnginfo = copy.deepcopy(extra_pnginfo) if extra_pnginfo else extra_pnginfo
|
||||
|
||||
for nid, node in prompt.items():
|
||||
cls = node.get("class_type")
|
||||
if cls not in ("FlakeStack", "FlakeCombo", "FlakeModelCombo"):
|
||||
continue
|
||||
inputs = node["inputs"]
|
||||
wf = _wf_node(extra_pnginfo, nid)
|
||||
if cls != "FlakeStack":
|
||||
node["class_type"] = "FlakeStack" if cls == "FlakeCombo" else "FlakeModelPreset"
|
||||
if wf:
|
||||
wf["type"] = node["class_type"]
|
||||
props = {k: v for k, v in (wf.get("properties") or {}).items() if not k.startswith("_combo_")}
|
||||
if props.get("Node name for S&R") == cls:
|
||||
props["Node name for S&R"] = node["class_type"]
|
||||
if cls != "FlakeStack":
|
||||
# Fit the converted node to its content on load (web/flake_stack.js).
|
||||
props["_fit_size"] = True
|
||||
if cls == "FlakeModelCombo":
|
||||
# The Preset widget re-serialises overrides_json from _overrides.
|
||||
props["_overrides"] = json.loads(inputs.get("overrides_json") or "{}")
|
||||
wf["properties"] = props
|
||||
if cls == "FlakeModelCombo":
|
||||
continue
|
||||
raw = inputs["flakes_json"]
|
||||
entries = [_used_entry(e) for e in json.loads(raw) if not e.get("bypassed")]
|
||||
if cls == "FlakeCombo":
|
||||
entries.insert(0, dict(_STACK_DEFAULT_ENTRY))
|
||||
new_raw = json.dumps(entries)
|
||||
inputs["flakes_json"] = new_raw
|
||||
# Mirror into the workflow node's positional widgets_values by matching
|
||||
# the original flakes_json string (robust to widget ordering).
|
||||
if wf:
|
||||
wf["widgets_values"] = [new_raw if v == raw else v for v in wf.get("widgets_values") or []]
|
||||
|
||||
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
||||
if isinstance(workflow, dict):
|
||||
_merge_stacks(prompt, workflow)
|
||||
return prompt, extra_pnginfo
|
||||
|
||||
|
||||
def _set_generate_image(extra_pnginfo, node_id, seed, image):
|
||||
"""Point the workflow's Flake Generate node at one saved image: its seed,
|
||||
control fixed, and an overlay showing only that image (#426)."""
|
||||
wf = _wf_node(extra_pnginfo, node_id)
|
||||
if wf is None:
|
||||
return
|
||||
wf["widgets_values"][:2] = [seed, "fixed"]
|
||||
props = wf.setdefault("properties", {})
|
||||
props.pop("_last_image_ad", None)
|
||||
props.update(_images_by_combo={"": image}, _images_by_combo_ad={}, _adetailer_ab=False, _last_image=image)
|
||||
|
||||
|
||||
def _flake_bundle(prompt, node_id):
|
||||
"""The portable bundle (#427) of the flakes and presets feeding prompt node
|
||||
``node_id``, read from a ``_used_only_metadata`` prompt. Flakes keep only
|
||||
the variant choices this run applied."""
|
||||
flakes, presets, used = [], [], {}
|
||||
seen, todo = set(), [str(node_id)]
|
||||
while todo:
|
||||
nid = todo.pop()
|
||||
if nid in seen or nid not in prompt:
|
||||
continue
|
||||
seen.add(nid)
|
||||
inputs = prompt[nid]["inputs"]
|
||||
todo += [str(v[0]) for v in inputs.values() if isinstance(v, list)]
|
||||
cls = prompt[nid].get("class_type")
|
||||
if cls == "FlakeStack":
|
||||
entries = json.loads(inputs["flakes_json"])
|
||||
flakes += [e["name"] for e in entries if not e.get("inline") and e.get("name")]
|
||||
for e in entries:
|
||||
try:
|
||||
flake_io.merge_used_variants(used, flake_io.resolve(e).used_variants)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
elif cls == "FlakeModelPreset" and isinstance(inputs.get("preset"), str):
|
||||
presets.append(inputs["preset"])
|
||||
try:
|
||||
# Preset links resolve like full_flake_node's __preset__ flake.
|
||||
preset_flake = flake_io.Flake(name="__preset__", flake_links=flake_io.load_preset(inputs["preset"]).flake_links)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
flake_io._apply_flake_link(preset_flake, {})
|
||||
flake_io.merge_used_variants(used, preset_flake.used_variants)
|
||||
return flake_io.collect_bundle(flakes, presets, used)
|
||||
|
||||
|
||||
class FlakeGenerate:
|
||||
"""Takes a FLAKE_DATA input and runs KSampler + VAE Decode + Save Image
|
||||
internally. Displays a seed widget, a 2x2 preview grid, and the generated
|
||||
@@ -363,6 +608,30 @@ class FlakeGenerate:
|
||||
"flake_data": ("FLAKE_DATA",),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
|
||||
},
|
||||
"optional": {
|
||||
# ADetailer post-process (#287, SDXL-first). Off / Face / A/B; A/B
|
||||
# generates each image twice — once without, once with ADetailer (#327).
|
||||
"adetailer": (["Off", "Face", "A/B"], {"default": "Off"}),
|
||||
"adetailer_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
# ADetailer sampling steps (#301). 0 = reuse the first-pass steps.
|
||||
"adetailer_steps": ("INT", {"default": 0, "min": 0, "max": 150, "step": 1, "tooltip": "ADetailer (Face Detailer) sampling steps. 0 = use the same steps as the first pass."}),
|
||||
# bbox model dropdown from ultralytics_bbox / models/adetailer (#306).
|
||||
"adetailer_bbox": (_list_adetailer_bbox(), {"default": _ADETAILER_DEFAULT_BBOX}),
|
||||
# Upscale post-process (#288). upscale_model is a filename under
|
||||
# models/upscale_models/ (blank = plain rescale to factor).
|
||||
"upscale": ("BOOLEAN", {"default": False, "label_on": "Upscale", "label_off": "Upscale off"}),
|
||||
"upscale_model": (_list_upscale_models(), {"default": ""}),
|
||||
"upscale_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 8.0, "step": 0.1}),
|
||||
"attention": (_attention_options(), {
|
||||
"default": "default", "tooltip": "Attention backend for the model, ADetailer included; default is the one ComfyUI was launched with."}),
|
||||
},
|
||||
# Embed the workflow/prompt into saved PNGs so outputs can be
|
||||
# drag-dropped back into ComfyUI to restore the graph (#344).
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
@@ -371,9 +640,12 @@ class FlakeGenerate:
|
||||
DESCRIPTION = "Generate an image from FLAKE_DATA using KSampler, VAE Decode, and Save Image. Displays the result inline."
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, flake_data, seed):
|
||||
def execute(self, flake_data, seed, adetailer="Off", adetailer_denoise=0.4,
|
||||
adetailer_steps=0, adetailer_bbox="bbox/face_yolov8m.pt",
|
||||
upscale=False, upscale_model="", upscale_factor=1.5, attention="default",
|
||||
prompt=None, extra_pnginfo=None, unique_id=None):
|
||||
parts = _split_flake_data(flake_data)
|
||||
model = parts["model"]
|
||||
model = _apply_attention(parts["model"], attention)
|
||||
clip = parts["clip"]
|
||||
vae = parts["vae"]
|
||||
positive = parts["positive"]
|
||||
@@ -384,19 +656,107 @@ class FlakeGenerate:
|
||||
cfg = parts["cfg"]
|
||||
sampler_name = parts["sampler_name"]
|
||||
scheduler = parts["scheduler"]
|
||||
denoise = parts["denoise"]
|
||||
|
||||
# Distilled models sample at cfg 1 with a zeroed negative (#359). Done
|
||||
# here rather than at preset-load time because this is the one place the
|
||||
# conditioning and the cfg are both final: upstream Flake nodes re-encode
|
||||
# the accumulated prompt on every hop, and cfg can still be changed by a
|
||||
# per-instance override (#279). ControlNets have already been applied by
|
||||
# this point, so the `control` key is carried through the zero-out.
|
||||
negative = flake_compose.maybe_zero_out_negative(negative, cfg)
|
||||
|
||||
# Proves whether model-patch ControlNets (Anima LLLite: post_input,
|
||||
# attn1/attn2_patch, mlp_patch) reached the sampler (#382).
|
||||
patches = model.model_options["transformer_options"].get("patches", {})
|
||||
logging.info("[FlakeGenerate] model patches: %s",
|
||||
", ".join(f"{k}={len(v)}" for k, v in patches.items()) or "none")
|
||||
|
||||
ks = KSampler()
|
||||
sampled = ks.sample(
|
||||
model, seed, steps, cfg, sampler_name, scheduler,
|
||||
positive, negative, latent, denoise=1.0,
|
||||
positive, negative, latent, denoise=denoise,
|
||||
)
|
||||
sampled_latent = sampled[0]
|
||||
|
||||
vae_dec = VAEDecode()
|
||||
images = vae_dec.decode(vae, sampled_latent)[0]
|
||||
|
||||
base_images = images
|
||||
|
||||
def _detail(imgs):
|
||||
from .flake_postprocess import run_face_detailer
|
||||
# 0 means "match the first pass"; otherwise use the explicit count (#301).
|
||||
ad_steps = adetailer_steps if adetailer_steps and adetailer_steps > 0 else steps
|
||||
return run_face_detailer(
|
||||
imgs, model, clip, vae, positive, negative,
|
||||
seed, ad_steps, cfg, sampler_name, scheduler,
|
||||
denoise=adetailer_denoise, bbox_model=adetailer_bbox,
|
||||
)
|
||||
|
||||
# Build the output variant(s): Off/Face produce one image set; A/B produces
|
||||
# two — once without ADetailer, once with (#327). The ADetailer variant is
|
||||
# tagged so its stem gets the _ad_ marker (#326).
|
||||
if adetailer == "A/B":
|
||||
variants = [(base_images, False), (_detail(base_images), True)]
|
||||
elif adetailer == "Face":
|
||||
variants = [(_detail(base_images), True)]
|
||||
else:
|
||||
variants = [(base_images, False)]
|
||||
|
||||
saver = SaveImage()
|
||||
save_result = saver.save_images(images, filename_prefix=filename_prefix)
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
|
||||
# Keep only what produced this image in its metadata sidecar (#346, #426, #479).
|
||||
meta_prompt, meta_extra = _used_only_metadata(prompt, extra_pnginfo)
|
||||
# Inside the workflow: the frontend only hands that to extensions on drop.
|
||||
if meta_prompt and isinstance((meta_extra or {}).get("workflow"), dict):
|
||||
meta_extra["workflow"].setdefault("extra", {})["flake_bundle"] = _flake_bundle(meta_prompt, unique_id)
|
||||
|
||||
def _save(imgs, is_ad):
|
||||
# Stem markers before the increment: _ad_ for ADetailer (#326), _up_ for
|
||||
# Upscale (#325). Upscale (#288) applies to every variant when on.
|
||||
out_imgs, marker = imgs, ""
|
||||
if is_ad:
|
||||
marker += "_ad"
|
||||
if upscale:
|
||||
from .flake_postprocess import upscale_images
|
||||
out_imgs = upscale_images(imgs, upscale_model, upscale_factor)
|
||||
marker += "_up"
|
||||
# The name SaveImage will give the first image, after the rename below.
|
||||
_, name, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||
filename_prefix + marker, output_dir, out_imgs[0].shape[1], out_imgs[0].shape[0])
|
||||
_set_generate_image(meta_extra, unique_id, seed, {
|
||||
"filename": f"{name.replace('%batch_num%', '0')}_{counter:05}.png",
|
||||
"subfolder": subfolder, "type": "output"})
|
||||
res = saver.save_images(out_imgs, filename_prefix=filename_prefix + marker)
|
||||
ui_imgs = res.get("ui", {}).get("images", [])
|
||||
# Strip the trailing underscore ComfyUI's SaveImage hardcodes
|
||||
# (e.g. "..._00001_.png" -> "..._00001.png").
|
||||
for img_info in ui_imgs:
|
||||
folder = os.path.join(output_dir, img_info.get("subfolder", ""))
|
||||
old_name = img_info["filename"]
|
||||
if old_name.endswith("_.png"):
|
||||
new_name = old_name[:-5] + ".png"
|
||||
old_path = os.path.join(folder, old_name)
|
||||
new_path = os.path.join(folder, new_name)
|
||||
try:
|
||||
if os.path.exists(old_path):
|
||||
os.rename(old_path, new_path)
|
||||
img_info["filename"] = new_name
|
||||
except OSError:
|
||||
pass
|
||||
if not args.disable_metadata:
|
||||
flake_io.write_metadata_sidecar(os.path.join(folder, img_info["filename"]), meta_prompt, meta_extra)
|
||||
return ui_imgs
|
||||
|
||||
regular_ui, ad_ui = [], []
|
||||
for imgs, is_ad in variants:
|
||||
saved = _save(imgs, is_ad)
|
||||
if is_ad:
|
||||
ad_ui = saved
|
||||
else:
|
||||
regular_ui = saved
|
||||
|
||||
model_bundle, generation_data, sampling_preset = flake_data
|
||||
meta = {}
|
||||
@@ -443,6 +803,7 @@ class FlakeGenerate:
|
||||
models_info["CFG"] = str(cfg)
|
||||
models_info["Sampler"] = str(sampler_name)
|
||||
models_info["Scheduler"] = str(scheduler)
|
||||
models_info["Denoise"] = str(denoise)
|
||||
|
||||
# ── Inputs overlay: prompts (grouped by BREAK) + controlnet info ──
|
||||
inputs_info = {}
|
||||
@@ -463,11 +824,15 @@ class FlakeGenerate:
|
||||
"Inputs": inputs_info,
|
||||
}
|
||||
|
||||
ui_data = dict(save_result.get("ui", {}))
|
||||
# Rename "images" to "flake_images" so ComfyUI's default image renderer
|
||||
# doesn't display a duplicate; the custom JS widget reads "flake_images".
|
||||
if "images" in ui_data:
|
||||
ui_data["flake_images"] = ui_data.pop("images")
|
||||
# Custom keys ("flake_images") so ComfyUI's default renderer doesn't show a
|
||||
# duplicate; the JS widget reads them. For A/B, flake_images is the regular
|
||||
# (non-ADetailer) output and flake_images_ad is the detailed one, with a flag
|
||||
# so the Generation Data overlay shows the ADetailer toggle (#327, #328).
|
||||
ui_data = {}
|
||||
ui_data["flake_images"] = regular_ui if regular_ui else ad_ui
|
||||
if adetailer == "A/B":
|
||||
ui_data["flake_images_ad"] = ad_ui
|
||||
ui_data["adetailer_ab"] = [True]
|
||||
ui_data["preview_data"] = [preview_data]
|
||||
ui_data["seed"] = [seed]
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Extra Flake nodes that wrap optional, soft-dependency features:
|
||||
IPAdapter FaceID (#289) and Inpaint (#291, #428).
|
||||
|
||||
These operate on the FLAKE_DATA bundle (model_bundle, generation_data,
|
||||
sampling_preset) produced by FlakeModelPreset / FlakeStack.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from nodes import CLIPTextEncode, LoadImage, SetLatentNoiseMask, VAEEncode, VAEEncodeForInpaint
|
||||
|
||||
from .flake_compose import carry_schedules
|
||||
from .full_flake_node import _carry_controlnets
|
||||
|
||||
|
||||
class FlakeIPAdapter:
|
||||
"""Apply IPAdapter FaceID to the model inside a FLAKE_DATA bundle for
|
||||
consistent-face generation, then pass the bundle through (#289).
|
||||
|
||||
Soft-depends on ComfyUI_IPAdapter_plus (+ FaceID models + insightface).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"flake_data": ("FLAKE_DATA",),
|
||||
"image": ("IMAGE",),
|
||||
},
|
||||
"optional": {
|
||||
"weight": ("FLOAT", {"default": 0.8, "min": -1.0, "max": 3.0, "step": 0.05}),
|
||||
"preset": ("STRING", {"default": "FACEID PLUS V2"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLAKE_DATA",)
|
||||
RETURN_NAMES = ("flake_data",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = (
|
||||
"Apply IPAdapter FaceID to the model in a FLAKE_DATA bundle for consistent "
|
||||
"faces, passing the bundle through. Requires ComfyUI_IPAdapter_plus."
|
||||
)
|
||||
|
||||
def execute(self, flake_data, image, weight=0.8, preset="FACEID PLUS V2"):
|
||||
from .flake_postprocess import apply_ipadapter_faceid
|
||||
|
||||
model_bundle, generation_data, sampling_preset = flake_data
|
||||
model, clip, vae = model_bundle
|
||||
new_model = apply_ipadapter_faceid(model, image, weight=weight, preset=preset)
|
||||
return (((new_model, clip, vae), generation_data, sampling_preset),)
|
||||
|
||||
|
||||
def _reencode(clip, cond, text):
|
||||
"""Encode ``text`` and carry the hooks, scheduled prompts and ControlNets
|
||||
of ``cond`` onto it, like FlakeStack does."""
|
||||
new_cond = CLIPTextEncode().encode(clip, text)[0]
|
||||
return _carry_controlnets(cond, carry_schedules(cond, new_cond, [], []))
|
||||
|
||||
|
||||
class FlakeInpaint:
|
||||
"""Put an image into a FLAKE_DATA bundle so Flake Generate inpaints it
|
||||
(#291, #428).
|
||||
|
||||
The mask is the image's alpha channel, painted with ComfyUI's mask editor
|
||||
on the node. Without a mask the whole image is redrawn at ``denoise``.
|
||||
Optional prompts are appended to the chain prompt.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"flake_data": ("FLAKE_DATA",),
|
||||
"image": LoadImage.INPUT_TYPES()["required"]["image"],
|
||||
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"grow_mask": ("INT", {"default": 6, "min": 0, "max": 64,
|
||||
"tooltip": "Grow the mask by this many pixels. Used at denoise 1."}),
|
||||
},
|
||||
"optional": {
|
||||
"positive_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"negative_prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLAKE_DATA",)
|
||||
RETURN_NAMES = ("flake_data",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "flakes"
|
||||
DESCRIPTION = (
|
||||
"Inpaint the masked area of an image: insert before Flake Generate. Paint "
|
||||
"the mask with the mask editor; without one the whole image is redrawn at denoise."
|
||||
)
|
||||
|
||||
def execute(self, flake_data, image, denoise=1.0, grow_mask=6,
|
||||
positive_prompt="", negative_prompt=""):
|
||||
model_bundle, generation_data, sampling_preset = flake_data
|
||||
_, clip, vae = model_bundle
|
||||
positive, negative, _, _, _, pos_text, neg_text = generation_data[:7]
|
||||
|
||||
pixels, mask = LoadImage().load_image(image)
|
||||
if not mask.any():
|
||||
latent = VAEEncode().encode(vae, pixels)[0]
|
||||
elif denoise < 1.0:
|
||||
latent = SetLatentNoiseMask().set_mask(VAEEncode().encode(vae, pixels)[0], mask)[0]
|
||||
else:
|
||||
latent = VAEEncodeForInpaint().encode(vae, pixels, mask, grow_mask_by=grow_mask)[0]
|
||||
|
||||
if positive_prompt.strip():
|
||||
pos_text = " BREAK ".join(t for t in (pos_text.strip(), positive_prompt.strip()) if t)
|
||||
positive = _reencode(clip, positive, pos_text)
|
||||
if negative_prompt.strip():
|
||||
neg_text = ", ".join(t for t in (neg_text.strip(), negative_prompt.strip()) if t)
|
||||
negative = _reencode(clip, negative, neg_text)
|
||||
|
||||
generation_data = (positive, negative, latent, pixels.shape[2], pixels.shape[1],
|
||||
pos_text, neg_text) + tuple(generation_data[7:])
|
||||
return ((model_bundle, generation_data, sampling_preset[:4] + (denoise,)),)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image, **kwargs):
|
||||
return LoadImage.IS_CHANGED(image)
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, image):
|
||||
return LoadImage.VALIDATE_INPUTS(image)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Single source of truth for model families (#355).
|
||||
|
||||
A "model family" is the axis that routes almost everything in this nodepack: the
|
||||
``img/<folder>/`` output prefix, which LoRAs and flakes a preset may use, which
|
||||
ControlNet file to infer, and (from #356) how the checkpoint is loaded and
|
||||
sampled.
|
||||
|
||||
Before this module the same eight facts were copy-pasted across
|
||||
``flake_io.py``, ``full_flake_node.py``, ``web/utils.js``, ``web/flake-modal.js``
|
||||
and ``web/preset-modal.js``. Adding one family meant eight edits and the copies
|
||||
drifted. Everything is now derived from :data:`FAMILIES`, and a
|
||||
``model_family`` value that names no registered family is a new family with
|
||||
the default fields (:func:`get`, #470).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The ControlNet hint types this pack knows how to infer a model name for. The
|
||||
# inferred name is ``controlnet_<type>_<family.cn_suffix>``; whether it resolves
|
||||
# depends on the user actually having a matching file (see
|
||||
# ``flake_io.resolve_cn_model_name``).
|
||||
CN_TYPES: tuple[str, ...] = (
|
||||
"openpose",
|
||||
"depth",
|
||||
"canny",
|
||||
"lineart",
|
||||
"lineart_anime",
|
||||
"softedge",
|
||||
"scribble",
|
||||
"normalbae",
|
||||
"seg",
|
||||
"tile",
|
||||
"ip2p",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilySpec:
|
||||
"""One model family.
|
||||
|
||||
``label`` is what the ``model_family`` field shows; ``folder`` is the
|
||||
on-disk segment used under ``models/flakes/img/``,
|
||||
``models/flakes/model_presets/img/`` and ``output/img/``. Changing either of
|
||||
those for a registered family breaks the user's workflows or moves their
|
||||
files, so don't. Unregistered families get the defaults below (#470).
|
||||
"""
|
||||
|
||||
label: str
|
||||
folder: str
|
||||
|
||||
# ControlNet routing. ``cn_suffix`` completes ``controlnet_<type>_<suffix>``;
|
||||
# ``cn_subfolder`` is the directory under ``models/controlnet/`` searched
|
||||
# first (#254). Families that share an architecture share both, which is why
|
||||
# illustrious/pony resolve SDXL controlnets and zit reuses zib's.
|
||||
cn_suffix: str
|
||||
cn_subfolder: str
|
||||
|
||||
# How a flake's ControlNet is applied. "controlnet" goes through core
|
||||
# ControlNetLoader/ControlNetApplyAdvanced on the conditioning. The other
|
||||
# kinds are model patches loaded from models/model_patches/ that hook the
|
||||
# MODEL instead: "anima_lllite" (ControlNet-LLLite, AnimaLLLiteApply) and
|
||||
# "zimage_fun" (Fun ControlNet Union, ZImageFunControlnet).
|
||||
cn_kind: str = "controlnet"
|
||||
|
||||
# Per-type filename stems that replace the ``controlnet_<type>_<suffix>``
|
||||
# convention. When set, only these types are offered for the family.
|
||||
cn_names: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
# Compat tags beyond the implicit {"common", folder} — only the SDXL
|
||||
# derivatives need this, so illustrious/pony can consume plain sdxl flakes.
|
||||
extra_compat: tuple[str, ...] = ()
|
||||
|
||||
# "Common" is a real family folder for shared flakes but is never suggested
|
||||
# in the model_family field.
|
||||
selectable: bool = True
|
||||
|
||||
# --- Generation metadata (#356) ----------------------------------------
|
||||
# Name of the ``comfy.sd.CLIPType`` member used when the preset loads its
|
||||
# text encoder from a standalone file. Looked up by name, never by value —
|
||||
# the enum gets renumbered upstream.
|
||||
clip_type: str = "STABLE_DIFFUSION"
|
||||
|
||||
# Defaults seeded into a newly created preset. Existing presets keep
|
||||
# whatever is on disk; these only fill the editor's blank form.
|
||||
steps: int = 31
|
||||
cfg: float = 4.7
|
||||
sampler: str = "dpmpp_2m"
|
||||
scheduler: str = "karras"
|
||||
width: int = 853
|
||||
height: int = 1440
|
||||
|
||||
# ``clip.clip_layer()`` is a CLIP-ism. On the Qwen3 / Qwen3-VL encoders the
|
||||
# newer families use it is meaningless and can raise, so it must be skipped.
|
||||
supports_clip_skip: bool = True
|
||||
|
||||
# ModelSamplingAuraFlow shift, or None for families that don't use it.
|
||||
default_shift: float | None = None
|
||||
|
||||
@property
|
||||
def compat(self) -> set[str]:
|
||||
return {"common", *self.extra_compat, self.folder}
|
||||
|
||||
@property
|
||||
def cn_models(self) -> dict[str, str]:
|
||||
if self.cn_names:
|
||||
return dict(self.cn_names)
|
||||
return {t: f"controlnet_{t}_{self.cn_suffix}" for t in CN_TYPES}
|
||||
|
||||
@property
|
||||
def cn_category(self) -> str:
|
||||
"""``folder_paths`` category the family's ControlNet files live in."""
|
||||
return "controlnet" if self.cn_kind == "controlnet" else "model_patches"
|
||||
|
||||
|
||||
# Generation metadata for the non-SDXL families is taken from the workflow
|
||||
# templates ComfyUI ships (comfyui_workflow_templates_json): image_anima_base_v1,
|
||||
# image_krea2_turbo_t2i and image_z_image_turbo. They are the reference graphs —
|
||||
# if a default here disagrees with a template, the template wins.
|
||||
_ZIMAGE_FUN_UNION = "Z-Image-Turbo-Fun-Controlnet-Union"
|
||||
|
||||
FAMILIES: tuple[FamilySpec, ...] = (
|
||||
FamilySpec("SDXL/Base", "sdxl", cn_suffix="sdxl", cn_subfolder="sdxl"),
|
||||
FamilySpec("SDXL/Illustrious", "illustrious", cn_suffix="sdxl", cn_subfolder="sdxl",
|
||||
extra_compat=("sdxl",)),
|
||||
FamilySpec("SDXL/Pony", "pony", cn_suffix="sdxl", cn_subfolder="sdxl",
|
||||
extra_compat=("sdxl",)),
|
||||
# Z-Image: Lumina2 architecture, Qwen3-4B text encoder, ae.safetensors VAE.
|
||||
# Both variants are distilled and run at cfg 1 with res_multistep. One Fun
|
||||
# ControlNet Union file serves every control type.
|
||||
FamilySpec("ZImage/Base", "zib", cn_suffix="zib", cn_subfolder="zimage",
|
||||
cn_kind="zimage_fun",
|
||||
cn_names=tuple((t, _ZIMAGE_FUN_UNION) for t in ("openpose", "depth", "canny", "softedge")),
|
||||
clip_type="LUMINA2", steps=8, cfg=1.0, sampler="res_multistep",
|
||||
scheduler="simple", width=1024, height=1024,
|
||||
supports_clip_skip=False, default_shift=3.0),
|
||||
# zit deliberately reuses zib's controlnet files — same architecture.
|
||||
FamilySpec("ZImage/Turbo", "zit", cn_suffix="zib", cn_subfolder="zimage",
|
||||
cn_kind="zimage_fun",
|
||||
cn_names=tuple((t, _ZIMAGE_FUN_UNION) for t in ("openpose", "depth", "canny", "softedge")),
|
||||
clip_type="LUMINA2", steps=8, cfg=1.0, sampler="res_multistep",
|
||||
scheduler="simple", width=1024, height=1024,
|
||||
supports_clip_skip=False, default_shift=3.0),
|
||||
# Anima: Qwen3-0.6B encoder loaded as QWEN_IMAGE (core picks the model by file contents), qwen
|
||||
# image VAE. Base is undistilled — 30 steps at cfg 4 with a real negative.
|
||||
# ControlNets are kohya's ControlNet-LLLite (anima-lllite-<type>-1). An entry
|
||||
# whose model names a VACE file in models/controlnet/anima/ goes through the
|
||||
# ComfyUI-Advanced-ControlNet-Anima fork instead (#378).
|
||||
FamilySpec("Anima/Base", "anima", cn_suffix="anima", cn_subfolder="anima",
|
||||
cn_kind="anima_lllite",
|
||||
cn_names=(("openpose", "anima-lllite-pose"), ("depth", "anima-lllite-depth"),
|
||||
("lineart", "anima-lllite-lineart"), ("scribble", "anima-lllite-scribble")),
|
||||
clip_type="QWEN_IMAGE", steps=30, cfg=4.0, sampler="euler_ancestral",
|
||||
scheduler="normal", width=1024, height=1024,
|
||||
supports_clip_skip=False, default_shift=3.0),
|
||||
# Krea2: Qwen3-VL-4B encoder (CLIPType.KREA2), qwen image VAE. The public
|
||||
# weights are the distilled turbo variant — 8 steps at cfg 1.
|
||||
FamilySpec("Krea2/Turbo", "krea2", cn_suffix="krea2", cn_subfolder="krea2",
|
||||
clip_type="KREA2", steps=8, cfg=1.0, sampler="euler",
|
||||
scheduler="simple", width=1024, height=1024,
|
||||
supports_clip_skip=False, default_shift=1.15),
|
||||
FamilySpec("Flux/Klein", "flux_klein", cn_suffix="flux", cn_subfolder="flux"),
|
||||
FamilySpec("Common", "common", cn_suffix="sdxl", cn_subfolder="sdxl", selectable=False),
|
||||
)
|
||||
|
||||
|
||||
def _check_unique() -> None:
|
||||
"""Labels and folders back the lookups in :func:`get` and
|
||||
:func:`from_folder`, so a duplicate would silently shadow a family."""
|
||||
labels = [f.label for f in FAMILIES]
|
||||
folders = [f.folder for f in FAMILIES]
|
||||
for name, values in (("label", labels), ("folder", folders)):
|
||||
dupes = {v for v in values if values.count(v) > 1}
|
||||
if dupes:
|
||||
raise ValueError(f"duplicate family {name}(s): {sorted(dupes)}")
|
||||
|
||||
|
||||
_check_unique()
|
||||
|
||||
|
||||
BY_FOLDER: dict[str, FamilySpec] = {f.folder: f for f in FAMILIES}
|
||||
_BY_NAME: dict[str, FamilySpec] = {k: f for f in FAMILIES for k in (f.label.lower(), f.folder)}
|
||||
|
||||
|
||||
def folder_slug(value: str) -> str:
|
||||
"""Folder of an unregistered family: ``Moss/Base`` -> ``moss_base``.
|
||||
Mirrored by ``familyFolder`` in web/utils.js."""
|
||||
return re.sub(r"[^a-z0-9_]+", "_", value.strip().lower()).strip("_")
|
||||
|
||||
|
||||
def from_folder(folder: str | None, label: str | None = None) -> FamilySpec | None:
|
||||
"""The family whose files live in ``img/<folder>/``; an unregistered folder
|
||||
is a family with the default fields, labelled *label* or the folder."""
|
||||
if not folder:
|
||||
return None
|
||||
return BY_FOLDER.get(folder) or FamilySpec(label or folder, folder, cn_suffix=folder, cn_subfolder=folder)
|
||||
|
||||
|
||||
def get(value: str | None) -> FamilySpec | None:
|
||||
"""The family a ``model_family`` value names (#470): the registered family
|
||||
whose label or folder equals it in any case (``anima`` -> ``Anima/Base``),
|
||||
else a new family in folder :func:`folder_slug`. None for an empty value."""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
spec = _BY_NAME.get(value.strip().lower())
|
||||
if spec:
|
||||
return spec
|
||||
folder = folder_slug(value)
|
||||
if not folder:
|
||||
raise ValueError(f"model family {value!r} has no usable folder name")
|
||||
return from_folder(folder, value.strip())
|
||||
|
||||
|
||||
# --- Animate (image-to-video) families --------------------------------------
|
||||
# Kept apart from FAMILIES so they never reach the image dropdowns or the
|
||||
# routing maps above. ``folder`` is the segment under ``models/flakes/video/``
|
||||
# and ``models/flakes/model_presets/video/``.
|
||||
|
||||
ANIM_RESOLUTIONS: tuple[str, ...] = ("480p", "580p", "720p")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnimFamilySpec:
|
||||
"""One animate family; the numbers seed a new animate preset. Fields a
|
||||
family doesn't use stay None: Wan 2.2 splits sampling between a high- and a
|
||||
low-noise model at ``refiner_step``, MiniMax H3 shifts video and audio
|
||||
separately."""
|
||||
|
||||
label: str
|
||||
folder: str
|
||||
clip_type: str
|
||||
steps: int
|
||||
cfg: float
|
||||
sampler: str
|
||||
scheduler: str
|
||||
fps: int
|
||||
duration: float
|
||||
resolution: str = "720p"
|
||||
shift_high: float | None = None
|
||||
shift_low: float | None = None
|
||||
refiner_step: int | None = None
|
||||
shift_video: float | None = None
|
||||
shift_audio: float | None = None
|
||||
|
||||
|
||||
ANIM_FAMILIES: tuple[AnimFamilySpec, ...] = (
|
||||
AnimFamilySpec("Wan 2.2", "wan22", clip_type="WAN", steps=4, cfg=1.0, sampler="euler",
|
||||
scheduler="linear_quadratic", fps=16, duration=5.0,
|
||||
shift_high=5.0, shift_low=5.0, refiner_step=2),
|
||||
AnimFamilySpec("MiniMax H3", "mh3", clip_type="MINIMAX", steps=4, cfg=1.0, sampler="euler",
|
||||
scheduler="simple", fps=24, duration=5.0,
|
||||
shift_video=10.0, shift_audio=3.0),
|
||||
)
|
||||
|
||||
ANIM_BY_LABEL: dict[str, AnimFamilySpec] = {f.label: f for f in ANIM_FAMILIES}
|
||||
ANIM_BY_FOLDER: dict[str, AnimFamilySpec] = {f.folder: f for f in ANIM_FAMILIES}
|
||||
+1579
-263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
"""Optional post-processing for Flake Generate: ADetailer (Face Detailer) and
|
||||
upscaling (#287, #288).
|
||||
|
||||
These features soft-depend on community/core node packs. Rather than importing
|
||||
those packages directly (their import paths differ across installs), we resolve
|
||||
the node classes from ComfyUI's global ``NODE_CLASS_MAPPINGS`` at call time and
|
||||
raise a clear, actionable error if they are missing. Nothing here is imported at
|
||||
module load, so Flakes still loads fine without the optional packs.
|
||||
|
||||
NOTE: external node signatures (especially Impact Pack's ``FaceDetailer.doit``)
|
||||
vary between versions. The calls below target current mainline signatures and
|
||||
may need adjustment for your installed versions — verify in a running ComfyUI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _node_classes(*names: str) -> dict[str, Any]:
|
||||
import nodes
|
||||
ncm = getattr(nodes, "NODE_CLASS_MAPPINGS", {})
|
||||
return {n: ncm.get(n) for n in names}
|
||||
|
||||
|
||||
def run_face_detailer(
|
||||
images,
|
||||
model,
|
||||
clip,
|
||||
vae,
|
||||
positive,
|
||||
negative,
|
||||
seed: int,
|
||||
steps: int,
|
||||
cfg: float,
|
||||
sampler_name: str,
|
||||
scheduler: str,
|
||||
denoise: float = 0.4,
|
||||
bbox_model: str = "bbox/face_yolov8m.pt",
|
||||
):
|
||||
"""Run an ADetailer-style face-detail pass using ComfyUI-Impact-Pack.
|
||||
|
||||
Returns the enhanced IMAGE tensor batch, or the input unchanged if the pass
|
||||
cannot run. Requires ComfyUI-Impact-Pack (FaceDetailer) and Impact Subpack
|
||||
(UltralyticsDetectorProvider).
|
||||
"""
|
||||
cls = _node_classes("FaceDetailer", "UltralyticsDetectorProvider", "SAMLoader")
|
||||
FaceDetailer = cls["FaceDetailer"]
|
||||
UltralyticsDetectorProvider = cls["UltralyticsDetectorProvider"]
|
||||
SAMLoader = cls["SAMLoader"]
|
||||
if FaceDetailer is None or UltralyticsDetectorProvider is None:
|
||||
raise RuntimeError(
|
||||
"ADetailer requires ComfyUI-Impact-Pack (FaceDetailer) and Impact "
|
||||
"Subpack (UltralyticsDetectorProvider). Install them and restart ComfyUI."
|
||||
)
|
||||
|
||||
bbox_detector = UltralyticsDetectorProvider().doit(bbox_model)[0]
|
||||
sam_model_opt = None
|
||||
if SAMLoader is not None:
|
||||
try:
|
||||
sam_model_opt = SAMLoader().load_model("sam_vit_b_01ec64.pth", "AUTO")[0]
|
||||
except Exception:
|
||||
sam_model_opt = None
|
||||
|
||||
result = FaceDetailer().doit(
|
||||
images, model, clip, vae,
|
||||
guide_size=512, guide_size_for=True, max_size=1024,
|
||||
seed=seed, steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler,
|
||||
positive=positive, negative=negative, denoise=denoise,
|
||||
feather=5, noise_mask=True, force_inpaint=True,
|
||||
bbox_threshold=0.5, bbox_dilation=10, bbox_crop_factor=3.0,
|
||||
sam_detection_hint="center-1", sam_dilation=0, sam_threshold=0.93,
|
||||
sam_bbox_expansion=0, sam_mask_hint_threshold=0.7,
|
||||
sam_mask_hint_use_negative="False", drop_size=10,
|
||||
bbox_detector=bbox_detector, wildcard="", cycle=1,
|
||||
sam_model_opt=sam_model_opt,
|
||||
)
|
||||
# FaceDetailer.doit returns (image, cropped, cropped_enhanced,
|
||||
# cropped_enhanced_alpha, mask, detailer_pipe, cnet_images). [0] is the
|
||||
# composited result image.
|
||||
enhanced = result[0] if isinstance(result, (tuple, list)) else result
|
||||
logging.info("[flakes] ADetailer (FaceDetailer) pass complete")
|
||||
return enhanced
|
||||
|
||||
|
||||
def upscale_images(images, upscale_model_name: str = "", factor: float = 1.5):
|
||||
"""Upscale an IMAGE batch (#288).
|
||||
|
||||
If ``upscale_model_name`` is set, run it through the core ESRGAN-style model
|
||||
upscaler first; then rescale so the final size is ``factor`` × the original
|
||||
(so the requested factor holds regardless of the model's native scale). Uses
|
||||
core ComfyUI nodes; raises a clear error if they are unavailable.
|
||||
"""
|
||||
import comfy.utils
|
||||
|
||||
out = images
|
||||
name = (upscale_model_name or "").strip()
|
||||
if name and name.lower() not in ("(none)", "none", ""):
|
||||
cls = _node_classes("UpscaleModelLoader", "ImageUpscaleWithModel")
|
||||
loader = cls["UpscaleModelLoader"]
|
||||
applier = cls["ImageUpscaleWithModel"]
|
||||
if loader is None or applier is None:
|
||||
raise RuntimeError(
|
||||
"Upscaling with a model requires the core ComfyUI upscale nodes "
|
||||
"(UpscaleModelLoader / ImageUpscaleWithModel)."
|
||||
)
|
||||
upscale_model = loader().load_model(name)[0]
|
||||
out = applier().upscale(upscale_model, out)[0]
|
||||
|
||||
if factor and abs(float(factor) - 1.0) > 1e-3:
|
||||
h, w = int(images.shape[1]), int(images.shape[2])
|
||||
target_w = max(1, int(round(w * float(factor))))
|
||||
target_h = max(1, int(round(h * float(factor))))
|
||||
samples = out.movedim(-1, 1)
|
||||
samples = comfy.utils.common_upscale(samples, target_w, target_h, "lanczos", "disabled")
|
||||
out = samples.movedim(1, -1)
|
||||
|
||||
logging.info("[flakes] upscale pass complete (model=%s factor=%.2f)", name or "none", float(factor))
|
||||
return out
|
||||
|
||||
|
||||
def apply_ipadapter_faceid(model, image, weight: float = 0.8, preset: str = "FACEID PLUS V2"):
|
||||
"""Apply IPAdapter FaceID to a model for consistent-face generation (#289).
|
||||
|
||||
Soft-depends on ComfyUI_IPAdapter_plus, resolved from NODE_CLASS_MAPPINGS.
|
||||
Returns the patched model. SDXL-first; requires FaceID models + insightface.
|
||||
"""
|
||||
cls = _node_classes(
|
||||
"IPAdapterUnifiedLoaderFaceID", "IPAdapterUnifiedLoader",
|
||||
"IPAdapterFaceID", "IPAdapterAdvanced", "IPAdapter",
|
||||
)
|
||||
loader = cls["IPAdapterUnifiedLoaderFaceID"] or cls["IPAdapterUnifiedLoader"]
|
||||
applier = cls["IPAdapterFaceID"] or cls["IPAdapterAdvanced"] or cls["IPAdapter"]
|
||||
if loader is None or applier is None:
|
||||
raise RuntimeError(
|
||||
"Flake IPAdapter requires ComfyUI_IPAdapter_plus (and FaceID models + "
|
||||
"insightface). Install it and restart ComfyUI."
|
||||
)
|
||||
|
||||
loaded = loader().load_models(model, preset)
|
||||
patched_model, ipadapter = loaded[0], loaded[1]
|
||||
|
||||
applied = applier().apply_ipadapter(patched_model, ipadapter, image=image, weight=weight)
|
||||
new_model = applied[0] if isinstance(applied, (tuple, list)) else applied
|
||||
logging.info("[flakes] IPAdapter FaceID applied (preset=%s weight=%.2f)", preset, float(weight))
|
||||
return new_model
|
||||
+522
-118
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -7,7 +9,9 @@ from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
import folder_paths
|
||||
from . import flake_io
|
||||
import latent_preview
|
||||
from . import flake_families, flake_io, flake_thumbs
|
||||
from .flake_compose import anima_cn_files
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
@@ -32,13 +36,100 @@ def _server_error(msg: str) -> web.Response:
|
||||
async def _list_flakes(request: web.Request) -> web.Response:
|
||||
family = request.query.get("family", "").strip() or None
|
||||
try:
|
||||
names = flake_io.list_flakes(family=family)
|
||||
dirs = flake_io.list_dirs(family=family)
|
||||
display_names = flake_io.list_flake_display_names(family=family)
|
||||
kind = _route_kind(request.query.get("kind"))
|
||||
info = flake_io.list_flake_info(family=family, kind=kind)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list flakes")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"flakes": names, "directories": dirs, "display_names": display_names})
|
||||
return web.json_response(info)
|
||||
|
||||
|
||||
@routes.get("/flakes/drives")
|
||||
async def _flake_drives(request: web.Request) -> web.Response:
|
||||
"""Drives holding each flake and folder (each animate flake and folder
|
||||
with kind=anim, each preset with kind=presets), so the load picker can show
|
||||
one drive at a time like the file picker."""
|
||||
family = request.query.get("family", "").strip() or None
|
||||
if request.query.get("kind") == "presets":
|
||||
presets: dict[str, list[str]] = {}
|
||||
for root in folder_paths.get_folder_paths("model_presets"):
|
||||
for name in flake_io.list_presets(family=family, roots=[root]):
|
||||
presets.setdefault(name, []).append(_drive(root))
|
||||
drives = sorted({d for ds in presets.values() for d in ds})
|
||||
return web.json_response({
|
||||
"presets": presets, "drives": drives,
|
||||
"default": _default_drive(drives) if drives else "",
|
||||
})
|
||||
kind = "anim" if request.query.get("kind") == "anim" else "img"
|
||||
flakes: dict[str, list[str]] = {}
|
||||
dirs: dict[str, list[str]] = {}
|
||||
for root in folder_paths.get_folder_paths("flakes"):
|
||||
drive = _drive(root)
|
||||
for name in flake_io.list_flakes(family=family, roots=[root], kind=kind):
|
||||
flakes.setdefault(name, []).append(drive)
|
||||
for name in flake_io.list_dirs(family=family, roots=[root], kind=kind):
|
||||
dirs.setdefault(name, []).append(drive)
|
||||
drives = sorted({d for ds in (*flakes.values(), *dirs.values()) for d in ds})
|
||||
return web.json_response({
|
||||
"flakes": flakes, "directories": dirs, "drives": drives,
|
||||
"default": _default_drive(drives) if drives else "",
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/flakes/series")
|
||||
async def _list_series(request: web.Request) -> web.Response:
|
||||
family = request.query.get("family", "").strip() or None
|
||||
try:
|
||||
series = flake_io.list_series(family=family, kind=_route_kind(request.query.get("kind")))
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list series")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"series": series})
|
||||
|
||||
|
||||
@routes.get("/flakes/lora_names")
|
||||
async def _list_lora_names(request: web.Request) -> web.Response:
|
||||
try:
|
||||
names = flake_io.list_lora_names()
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list LoRA names")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"names": names})
|
||||
|
||||
|
||||
@routes.get("/flakes/option_names")
|
||||
async def _list_option_names(request: web.Request) -> web.Response:
|
||||
try:
|
||||
names = flake_io.list_option_names()
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list option names")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"names": names})
|
||||
|
||||
|
||||
@routes.get("/flakes/samplers")
|
||||
async def _list_samplers(request: web.Request) -> web.Response:
|
||||
"""Expose the installed ComfyUI's KSampler sampler/scheduler lists so the
|
||||
preset editor stays in sync with core (Advanced KSampler parity)."""
|
||||
try:
|
||||
from comfy.samplers import KSampler
|
||||
samplers = list(KSampler.SAMPLERS)
|
||||
schedulers = list(KSampler.SCHEDULERS)
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list samplers/schedulers")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"samplers": samplers, "schedulers": schedulers})
|
||||
|
||||
|
||||
@routes.get("/flakes/preview_default")
|
||||
async def _preview_default(request: web.Request) -> web.Response:
|
||||
"""The server's --preview-method, used when the frontend Live preview
|
||||
setting is left at "default" (#380)."""
|
||||
return web.json_response({"method": latent_preview.default_preview_method.value})
|
||||
|
||||
|
||||
@routes.get("/flakes/meta")
|
||||
@@ -47,7 +138,7 @@ async def _flake_meta(request: web.Request) -> web.Response:
|
||||
if not name:
|
||||
return _bad_request("missing 'name' query param")
|
||||
try:
|
||||
variants = flake_io.flake_variants(name)
|
||||
flake = flake_io.load_flake(name)
|
||||
except FileNotFoundError as exc:
|
||||
return _not_found(str(exc))
|
||||
except ValueError as exc:
|
||||
@@ -57,7 +148,9 @@ async def _flake_meta(request: web.Request) -> web.Response:
|
||||
return _server_error(str(exc))
|
||||
# Return both keys for a transitional window; clients should prefer
|
||||
# `variants`.
|
||||
return web.json_response({"name": name, "variants": variants, "options": variants})
|
||||
variants = {group: list(choices.keys()) for group, choices in flake.variants.items()}
|
||||
return web.json_response({"name": name, "variants": variants, "options": variants, "defaults": flake_io.variant_defaults(flake),
|
||||
"auto_caption": flake.auto_caption})
|
||||
|
||||
|
||||
@routes.get("/flakes/get")
|
||||
@@ -67,6 +160,7 @@ async def _get_flake(request: web.Request) -> web.Response:
|
||||
return _bad_request("missing 'name' query param")
|
||||
try:
|
||||
data = flake_io.read_flake_raw(name)
|
||||
base_root_index = flake_io._find_flake_root_index(name)
|
||||
except FileNotFoundError as exc:
|
||||
return _not_found(str(exc))
|
||||
except ValueError as exc:
|
||||
@@ -74,7 +168,7 @@ async def _get_flake(request: web.Request) -> web.Response:
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to read %s", name)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"name": name, "data": data})
|
||||
return web.json_response({"name": name, "data": data, "base_root_index": base_root_index})
|
||||
|
||||
|
||||
@routes.put("/flakes/save")
|
||||
@@ -107,6 +201,7 @@ async def _save_flake(request: web.Request) -> web.Response:
|
||||
base_root_index=base_root_index,
|
||||
output_path=output_path,
|
||||
old_name=old_name,
|
||||
kind=_route_kind(body.get("kind")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
@@ -133,6 +228,31 @@ async def _delete_flake(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, "name": name})
|
||||
|
||||
|
||||
@routes.post("/flakes/anim/import")
|
||||
async def _import_anim_flake(request: web.Request) -> web.Response:
|
||||
"""Copy an image flake into an animate family (#433)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _bad_request("invalid JSON body")
|
||||
if not isinstance(body, dict):
|
||||
return _bad_request("body must be a JSON object")
|
||||
source = (body.get("source") or "").strip()
|
||||
family = (body.get("family") or "").strip()
|
||||
if not source or not family:
|
||||
return _bad_request("missing 'source' or 'family'")
|
||||
try:
|
||||
name = flake_io.import_anim_flake(source, family)
|
||||
except FileNotFoundError as exc:
|
||||
return _not_found(str(exc))
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to import %s", source)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"ok": True, "name": name})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autocomplete sources (Phase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -174,14 +294,19 @@ async def _list_loras(_request: web.Request) -> web.Response:
|
||||
return web.json_response({"loras": _shorten_filenames(loras)})
|
||||
|
||||
|
||||
@routes.get("/flakes/cnmodels")
|
||||
async def _list_controlnets(_request: web.Request) -> web.Response:
|
||||
@routes.get("/flakes/cn_models")
|
||||
async def _list_cn_models(request: web.Request) -> web.Response:
|
||||
"""ControlNet files a flake's CN entry can name as ``model`` for a family:
|
||||
Anima LLLite patches and VACE files (#381). Other families pick by type."""
|
||||
family = flake_families.get(request.query.get("family"))
|
||||
if family is None or family.cn_kind != "anima_lllite":
|
||||
return web.json_response({"models": []})
|
||||
try:
|
||||
cns = folder_paths.get_filename_list("controlnet")
|
||||
models = anima_cn_files()
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list controlnets")
|
||||
logging.exception("[flakes] failed to list Anima controlnets")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"controlnets": _shorten_filenames(cns)})
|
||||
return web.json_response({"models": models})
|
||||
|
||||
|
||||
@routes.get("/flakes/cn_types")
|
||||
@@ -202,38 +327,66 @@ async def _list_checkpoints(_request: web.Request) -> web.Response:
|
||||
return web.json_response({"checkpoints": _shorten_filenames(ckpts)})
|
||||
|
||||
|
||||
@routes.get("/flakes/vaes")
|
||||
async def _list_vaes(_request: web.Request) -> web.Response:
|
||||
def _family_json(f: flake_families.FamilySpec) -> dict:
|
||||
return {
|
||||
"label": f.label,
|
||||
"folder": f.folder,
|
||||
"selectable": f.selectable,
|
||||
"clip_type": f.clip_type,
|
||||
"steps": f.steps,
|
||||
"cfg": f.cfg,
|
||||
"sampler": f.sampler,
|
||||
"scheduler": f.scheduler,
|
||||
"width": f.width,
|
||||
"height": f.height,
|
||||
"supports_clip_skip": f.supports_clip_skip,
|
||||
"default_shift": f.default_shift,
|
||||
"cn_models": f.cn_models,
|
||||
"cn_kind": f.cn_kind,
|
||||
}
|
||||
|
||||
|
||||
@routes.get("/flakes/families")
|
||||
async def _list_families(_request: web.Request) -> web.Response:
|
||||
"""Serve the model-family registry so the web layer stops duplicating it
|
||||
(#355, #360). The JS keeps its own literals as a bootstrap fallback, so a
|
||||
failure here degrades to the previous behaviour rather than breaking the
|
||||
editor. ``generic`` holds the fields of an unregistered family, whose
|
||||
ControlNets follow ``controlnet_<type>_<folder>`` for ``cn_types``, and
|
||||
``folders`` the family folders on disk (#470)."""
|
||||
try:
|
||||
vaes = folder_paths.get_filename_list("vae")
|
||||
families = [_family_json(f) for f in flake_families.FAMILIES]
|
||||
generic = {k: v for k, v in _family_json(flake_families.from_folder("_")).items()
|
||||
if k not in ("label", "folder", "cn_models")}
|
||||
folders = flake_io.list_family_folders()
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list VAEs")
|
||||
logging.exception("[flakes] failed to list families")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"vaes": _shorten_filenames(vaes)})
|
||||
return web.json_response({"families": families, "generic": generic,
|
||||
"cn_types": list(flake_families.CN_TYPES), "folders": folders})
|
||||
|
||||
|
||||
@routes.get("/flakes/text_encoders")
|
||||
async def _list_text_encoders(_request: web.Request) -> web.Response:
|
||||
@routes.get("/flakes/anim/families")
|
||||
async def _list_anim_families(_request: web.Request) -> web.Response:
|
||||
"""Animate families with their preset defaults; fields a family doesn't
|
||||
use are null."""
|
||||
return web.json_response({
|
||||
"families": [dataclasses.asdict(f) for f in flake_families.ANIM_FAMILIES],
|
||||
"resolutions": list(flake_families.ANIM_RESOLUTIONS),
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/flakes/clip_types")
|
||||
async def _list_clip_types(_request: web.Request) -> web.Response:
|
||||
"""CLIPType members this ComfyUI build supports, for the preset editor's
|
||||
text-encoder type dropdown."""
|
||||
try:
|
||||
te_paths = folder_paths.get_filename_paths("text_encoders") if hasattr(folder_paths, "get_filename_paths") and folder_paths.get_filename_paths("text_encoders") else []
|
||||
tes = folder_paths.get_filename_list("text_encoders") if os.environ.get("FLAKES_TEXT_ENCODER_FOLDER") or True else []
|
||||
import comfy.sd
|
||||
|
||||
types = sorted(m.name for m in comfy.sd.CLIPType)
|
||||
except Exception:
|
||||
tes = []
|
||||
try:
|
||||
if not tes:
|
||||
te_dir = folder_paths.get_folder_paths("text_encoders") if hasattr(folder_paths, "get_folder_paths") else []
|
||||
if isinstance(te_dir, str):
|
||||
te_dir = [te_dir]
|
||||
for d in te_dir:
|
||||
if os.path.isdir(d):
|
||||
for dirpath, _, filenames in os.walk(d):
|
||||
for fn in filenames:
|
||||
if fn.lower().endswith((".safetensors", ".bin", ".pt", ".ckpt")):
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), d).replace(os.sep, "/")
|
||||
tes.append(rel)
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"text_encoders": _shorten_filenames(tes)})
|
||||
types = ["STABLE_DIFFUSION"]
|
||||
return web.json_response({"clip_types": types})
|
||||
|
||||
|
||||
@routes.get("/flakes/inputs")
|
||||
@@ -267,22 +420,32 @@ async def _list_embeddings(_request: web.Request) -> web.Response:
|
||||
# Cover image (Phase 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _image_file_response(request: web.Request, resolve, missing: str) -> web.Response:
|
||||
"""Serve the image file ``resolve()`` finds as a file (ETag / Last-Modified,
|
||||
so browsers revalidate with 304s), or with ``?thumb=<px>`` its cached
|
||||
downscaled copy for grids and pickers (#451)."""
|
||||
try:
|
||||
path = resolve()
|
||||
if path and "thumb" in request.query:
|
||||
path = await asyncio.to_thread(flake_thumbs.thumbnail, path, int(request.query["thumb"]))
|
||||
except FileNotFoundError as exc:
|
||||
return _not_found(str(exc))
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to serve %s", request.path_qs)
|
||||
return _server_error(str(exc))
|
||||
if path is None:
|
||||
return _not_found(missing)
|
||||
return web.FileResponse(path)
|
||||
|
||||
|
||||
@routes.get("/flakes/cover")
|
||||
async def _get_cover(request: web.Request) -> web.Response:
|
||||
name = request.query.get("name", "").strip()
|
||||
if not name:
|
||||
return _bad_request("missing 'name' query param")
|
||||
try:
|
||||
result = flake_io.read_cover(name)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to read cover for %s", name)
|
||||
return _server_error(str(exc))
|
||||
if result is None:
|
||||
return _not_found(f"no cover for '{name}'")
|
||||
data, mime = result
|
||||
return web.Response(body=data, content_type=mime)
|
||||
return await _image_file_response(request, lambda: flake_io.cover_file(name), f"no cover for '{name}'")
|
||||
|
||||
|
||||
@routes.get("/flakes/variant_image")
|
||||
@@ -292,38 +455,33 @@ async def _get_variant_image(request: web.Request) -> web.Response:
|
||||
choice = request.query.get("choice", "").strip()
|
||||
if not name or not group or not choice:
|
||||
return _bad_request("missing 'name', 'group' or 'choice' query param")
|
||||
try:
|
||||
result = flake_io.read_variant_image(name, group, choice)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to read variant image for %s/%s/%s", name, group, choice)
|
||||
return _server_error(str(exc))
|
||||
if result is None:
|
||||
return _not_found(f"no variant image for '{name}' {group}/{choice}")
|
||||
data, mime = result
|
||||
return web.Response(body=data, content_type=mime)
|
||||
return await _image_file_response(request, lambda: flake_io.variant_image_file(name, group, choice),
|
||||
f"no variant image for '{name}' {group}/{choice}")
|
||||
|
||||
|
||||
@routes.post("/flakes/cover")
|
||||
async def _upload_cover(request: web.Request) -> web.Response:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
return _bad_request("no file field in multipart body")
|
||||
filename = field.filename or "cover.png"
|
||||
ext = os.path.splitext(filename)[1]
|
||||
data = await field.read()
|
||||
@routes.get("/flakes/asset")
|
||||
async def _get_asset(request: web.Request) -> web.Response:
|
||||
"""Serve a `./` image owned by a flake (relative to its yaml directory)."""
|
||||
name = request.query.get("name", "").strip()
|
||||
path = request.query.get("path", "").strip()
|
||||
if not name or not path:
|
||||
return _bad_request("missing 'name' or 'path' query param")
|
||||
return await _image_file_response(request, lambda: flake_io.flake_asset_file(name, path), f"no image '{path}' for '{name}'")
|
||||
|
||||
|
||||
@routes.delete("/flakes/cover")
|
||||
async def _delete_cover_route(request: web.Request) -> web.Response:
|
||||
"""Delete the cover image file alongside a flake's yaml. Used by the
|
||||
modal's hover-X remove button (#239)."""
|
||||
name = request.query.get("name", "").strip()
|
||||
if not name:
|
||||
return _bad_request("missing 'name' query param")
|
||||
try:
|
||||
flake_io.save_cover(name, ext, data)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
flake_io._delete_cover(name)
|
||||
except FileNotFoundError:
|
||||
pass # already gone — idempotent
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to save cover for %s", name)
|
||||
logging.exception("[flakes] failed to delete cover for %s", name)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"ok": True, "name": name})
|
||||
|
||||
@@ -332,12 +490,24 @@ async def _upload_cover(request: web.Request) -> web.Response:
|
||||
# Presets (Phase 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _route_kind(value: str | None) -> str:
|
||||
"""``kind`` of a flake or preset route: "img" (default) or "anim" for
|
||||
animate ones. Becomes a path segment, so anything else is rejected."""
|
||||
kind = (value or "").strip() or "img"
|
||||
if kind not in ("img", "anim"):
|
||||
raise ValueError("kind must be 'img' or 'anim'")
|
||||
return kind
|
||||
|
||||
|
||||
@routes.get("/flakes/presets")
|
||||
async def _list_presets(request: web.Request) -> web.Response:
|
||||
family = request.query.get("family", "").strip() or None
|
||||
try:
|
||||
names = flake_io.list_presets(family=family)
|
||||
display_names = flake_io.list_preset_display_names(family=family)
|
||||
kind = _route_kind(request.query.get("kind"))
|
||||
names = flake_io.list_presets(family=family, kind=kind)
|
||||
display_names = flake_io.list_preset_display_names(family=family, kind=kind)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to list presets")
|
||||
return _server_error(str(exc))
|
||||
@@ -358,7 +528,11 @@ async def _get_preset(request: web.Request) -> web.Response:
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to read preset %s", name)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"name": name, "data": data})
|
||||
try:
|
||||
base_root_index = flake_io._find_preset_root_index(name)
|
||||
except Exception:
|
||||
base_root_index = None
|
||||
return web.json_response({"name": name, "data": data, "base_root_index": base_root_index})
|
||||
|
||||
|
||||
@routes.put("/flakes/presets/save")
|
||||
@@ -391,6 +565,7 @@ async def _save_preset(request: web.Request) -> web.Response:
|
||||
base_root_index=base_root_index,
|
||||
output_path=output_path,
|
||||
old_name=old_name,
|
||||
kind=_route_kind(body.get("kind")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
@@ -418,7 +593,7 @@ async def _delete_preset(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkpoint sibling image (auto-cover)
|
||||
# Model sibling image (auto-cover)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHECKPOINT_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
||||
@@ -481,6 +656,37 @@ def _resolve_sibling_image_relpath(folder_type: str, path: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_variant_sibling_image_relpath(folder_type: str, path: str, group: str, choice: str) -> str | None:
|
||||
"""Return the relative path of the sibling image named
|
||||
``<basename>_<group>_<choice>.ext`` for the given weight file, or None."""
|
||||
resolved = _resolve_model_name(folder_type, path)
|
||||
try:
|
||||
full_path = folder_paths.get_full_path(folder_type, resolved)
|
||||
except Exception:
|
||||
full_path = None
|
||||
if not full_path or not os.path.isfile(full_path):
|
||||
return None
|
||||
dir_path = os.path.dirname(full_path)
|
||||
basename = os.path.splitext(os.path.basename(full_path))[0]
|
||||
suffix = f"_{group.lower()}_{choice.lower()}"
|
||||
for ext in _CHECKPOINT_IMAGE_EXTS:
|
||||
sibling = os.path.join(dir_path, basename + suffix + ext)
|
||||
if os.path.isfile(sibling):
|
||||
try:
|
||||
roots = folder_paths.get_folder_paths(folder_type)
|
||||
except Exception:
|
||||
roots = []
|
||||
for root in roots:
|
||||
try:
|
||||
rel = os.path.relpath(sibling, root)
|
||||
except ValueError:
|
||||
continue
|
||||
if not rel.startswith(".."):
|
||||
return rel.replace(os.sep, "/")
|
||||
return os.path.basename(sibling)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registered roots (so the UI can offer a base-path dropdown)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -504,12 +710,27 @@ async def _list_roots(request: web.Request) -> web.Response:
|
||||
roots = []
|
||||
if isinstance(roots, str):
|
||||
roots = [roots]
|
||||
entries = []
|
||||
install_dir = os.path.join(folder_paths.models_dir, "flakes")
|
||||
if kind == "model_presets":
|
||||
install_dir = os.path.join(install_dir, "model_presets")
|
||||
install_dir = os.path.normcase(os.path.realpath(install_dir))
|
||||
local_dir = os.path.normcase(os.path.realpath(flake_io.local_root(kind)))
|
||||
base_root = None
|
||||
extra_roots = []
|
||||
for i, root in enumerate(roots):
|
||||
if not root or not isinstance(root, str):
|
||||
if not root or not isinstance(root, str) or os.path.normcase(os.path.realpath(root)) == local_dir:
|
||||
continue
|
||||
label = "Default" if i == 0 else f"Extra {i}"
|
||||
entries.append({"index": i, "path": root, "label": label})
|
||||
if os.path.normcase(os.path.realpath(root)) == install_dir:
|
||||
base_root = {"index": i, "path": root, "label": "Comfy Install", "is_install": True}
|
||||
else:
|
||||
extra_roots.append({"index": i, "path": root, "label": "", "is_install": False})
|
||||
|
||||
entries = []
|
||||
if base_root:
|
||||
entries.append(base_root)
|
||||
for idx, extra in enumerate(extra_roots, start=1):
|
||||
extra["label"] = f"Extra Path {idx}"
|
||||
entries.append(extra)
|
||||
return web.json_response({"roots": entries})
|
||||
|
||||
|
||||
@@ -524,6 +745,19 @@ async def _get_lora_sibling_image_path(request: web.Request) -> web.Response:
|
||||
return web.json_response({"path": rel})
|
||||
|
||||
|
||||
@routes.get("/flakes/lora_variant_sibling_image_path")
|
||||
async def _get_lora_variant_sibling_image_path(request: web.Request) -> web.Response:
|
||||
path = request.query.get("path", "").strip()
|
||||
group = request.query.get("group", "").strip()
|
||||
choice = request.query.get("choice", "").strip()
|
||||
if not path or not group or not choice:
|
||||
return _bad_request("missing 'path', 'group' or 'choice' query param")
|
||||
rel = _resolve_variant_sibling_image_relpath("loras", path, group, choice)
|
||||
if rel is None:
|
||||
return _not_found("no variant sibling image found")
|
||||
return web.json_response({"path": rel})
|
||||
|
||||
|
||||
@routes.get("/flakes/checkpoint_sibling_image")
|
||||
async def _get_checkpoint_sibling_image(request: web.Request) -> web.Response:
|
||||
path = request.query.get("path", "").strip()
|
||||
@@ -532,6 +766,14 @@ async def _get_checkpoint_sibling_image(request: web.Request) -> web.Response:
|
||||
return _sibling_image_response("checkpoints", path)
|
||||
|
||||
|
||||
@routes.get("/flakes/diffusion_model_sibling_image")
|
||||
async def _get_diffusion_model_sibling_image(request: web.Request) -> web.Response:
|
||||
path = request.query.get("path", "").strip()
|
||||
if not path:
|
||||
return _bad_request("missing 'path' query param")
|
||||
return _sibling_image_response("diffusion_models", path)
|
||||
|
||||
|
||||
@routes.get("/flakes/lora_sibling_image")
|
||||
async def _get_lora_sibling_image(request: web.Request) -> web.Response:
|
||||
path = request.query.get("path", "").strip()
|
||||
@@ -549,17 +791,7 @@ async def _get_preset_cover(request: web.Request) -> web.Response:
|
||||
name = request.query.get("name", "").strip()
|
||||
if not name:
|
||||
return _bad_request("missing 'name' query param")
|
||||
try:
|
||||
result = flake_io.read_preset_cover(name)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to read preset cover for %s", name)
|
||||
return _server_error(str(exc))
|
||||
if result is None:
|
||||
return _not_found(f"no cover for preset '{name}'")
|
||||
data, mime = result
|
||||
return web.Response(body=data, content_type=mime)
|
||||
return await _image_file_response(request, lambda: flake_io.preset_cover_file(name), f"no cover for preset '{name}'")
|
||||
|
||||
|
||||
@routes.post("/flakes/preset_cover")
|
||||
@@ -585,34 +817,127 @@ async def _upload_preset_cover(request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, "name": name})
|
||||
|
||||
|
||||
@routes.get("/flakes/metadata")
|
||||
async def _output_metadata(request: web.Request) -> web.Response:
|
||||
"""The prompt and workflow saved in ``.flake_metadata`` for a dropped
|
||||
output file (#480), found by its name and size in the output directory."""
|
||||
filename = request.query.get("filename", "").strip()
|
||||
if not filename:
|
||||
return _bad_request("missing 'filename' query param")
|
||||
try:
|
||||
size = int(request.query.get("size", ""))
|
||||
sidecar = await asyncio.to_thread(flake_io.find_metadata_sidecar, folder_paths.get_output_directory(), filename, size)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
if sidecar is None:
|
||||
return _not_found(f"no metadata for '{filename}'")
|
||||
return web.FileResponse(sidecar)
|
||||
|
||||
|
||||
@routes.post("/flakes/bundle/stage")
|
||||
async def _stage_bundle(request: web.Request) -> web.Response:
|
||||
"""Stage the flake bundle embedded in a loaded workflow (#427) as
|
||||
workflow-local flakes (#437)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _bad_request("invalid JSON body")
|
||||
if not isinstance(body, dict):
|
||||
return _bad_request("body must be a JSON object")
|
||||
try:
|
||||
result = flake_io.stage_bundle(body)
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to stage flake bundle")
|
||||
return _server_error(str(exc))
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
@routes.post("/flakes/bundle/save")
|
||||
async def _save_local(request: web.Request) -> web.Response:
|
||||
"""Save a workflow-local flake or preset on disk (#437)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _bad_request("invalid JSON body")
|
||||
name = body.get("name") if isinstance(body, dict) else None
|
||||
if not isinstance(name, str) or not name:
|
||||
return _bad_request("missing 'name'")
|
||||
try:
|
||||
result = flake_io.save_local(name)
|
||||
except FileNotFoundError as exc:
|
||||
return _not_found(str(exc))
|
||||
except ValueError as exc:
|
||||
return _bad_request(str(exc))
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to save local flake %s", name)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File browser (custom directory picker backed by Python)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BROWSE_TYPES = ("checkpoints", "loras", "flakes", "inputs")
|
||||
_MODEL_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft")
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")
|
||||
|
||||
_BROWSE_FILTERS = {
|
||||
"checkpoints": (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft"),
|
||||
"loras": (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft"),
|
||||
"checkpoints": _MODEL_EXTS,
|
||||
"loras": _MODEL_EXTS,
|
||||
"vae": _MODEL_EXTS,
|
||||
"text_encoders": (*_MODEL_EXTS, ".gguf"),
|
||||
"diffusion_models": (*_MODEL_EXTS, ".gguf"),
|
||||
"flakes": (".yaml", ".yml"),
|
||||
"inputs": (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"),
|
||||
"inputs": _IMAGE_EXTS,
|
||||
"output": _IMAGE_EXTS,
|
||||
"flake_images": _IMAGE_EXTS,
|
||||
"models": _IMAGE_EXTS,
|
||||
"lora_images": _IMAGE_EXTS,
|
||||
}
|
||||
_BROWSE_TYPES = tuple(_BROWSE_FILTERS)
|
||||
|
||||
|
||||
def _get_browse_roots(browse_type: str) -> list[str]:
|
||||
"""Resolve folder roots dynamically at request time."""
|
||||
if browse_type == "inputs":
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
return [input_dir] if input_dir and os.path.isdir(input_dir) else []
|
||||
try:
|
||||
paths = folder_paths.get_folder_paths(browse_type)
|
||||
except Exception:
|
||||
paths = []
|
||||
# get_folder_paths returns a list of str on modern ComfyUI,
|
||||
# but defensively wrap a single string.
|
||||
if isinstance(paths, str):
|
||||
return [paths]
|
||||
return [p for p in paths if p and isinstance(p, str)]
|
||||
roots = [folder_paths.get_input_directory()]
|
||||
elif browse_type == "output":
|
||||
roots = [folder_paths.get_output_directory()]
|
||||
elif browse_type == "models":
|
||||
roots = [folder_paths.models_dir]
|
||||
elif browse_type == "flake_images":
|
||||
roots = folder_paths.get_folder_paths("flakes")
|
||||
elif browse_type == "lora_images":
|
||||
roots = folder_paths.get_folder_paths("loras")
|
||||
else:
|
||||
roots = folder_paths.get_folder_paths(browse_type)
|
||||
return [r for r in roots if r and os.path.isdir(r)]
|
||||
|
||||
|
||||
@routes.get("/flakes/image")
|
||||
async def _get_image(request: web.Request) -> web.Response:
|
||||
"""Serve an image by absolute path for the file picker and editor previews,
|
||||
confined to the ComfyUI folders the picker browses."""
|
||||
path = request.query.get("path", "").strip()
|
||||
if not path or os.path.splitext(path)[1].lower() not in _IMAGE_EXTS:
|
||||
return _bad_request("'path' must be an image file")
|
||||
if not any(flake_io._is_inside(path, root) for root in flake_io.picker_roots()):
|
||||
return _bad_request("path is outside the browsable folders")
|
||||
if not os.path.isfile(path):
|
||||
return _not_found(f"image not found: {path}")
|
||||
return web.FileResponse(path)
|
||||
|
||||
|
||||
def _drive(path: str) -> str:
|
||||
return os.path.splitdrive(os.path.abspath(path))[0].upper()
|
||||
|
||||
|
||||
def _default_drive(drives: list[str]) -> str:
|
||||
"""An extra model path's drive when one is in use, else ComfyUI's own."""
|
||||
base = _drive(folder_paths.models_dir)
|
||||
return next((d for d in drives if d != base), base if base in drives else drives[0])
|
||||
|
||||
|
||||
@routes.post("/flakes/browse")
|
||||
@@ -626,6 +951,7 @@ async def _browse(_request: web.Request) -> web.Response:
|
||||
|
||||
browse_type = (body.get("type") or "").strip()
|
||||
rel_path = (body.get("path") or "").strip()
|
||||
drive = (body.get("drive") or "").strip()
|
||||
|
||||
if browse_type not in _BROWSE_TYPES:
|
||||
return _bad_request(f"invalid browse type: {browse_type!r}")
|
||||
@@ -636,18 +962,42 @@ async def _browse(_request: web.Request) -> web.Response:
|
||||
if not roots:
|
||||
return _server_error(f"no roots configured for {browse_type!r}")
|
||||
|
||||
seen: set[str] = set()
|
||||
seen: set[str] = set() # normcase'd names: one entry per folder on Windows, whatever the case
|
||||
entries: list[dict[str, str]] = []
|
||||
valid_target_found = False
|
||||
|
||||
for base in roots:
|
||||
target = os.path.normpath(os.path.join(base, rel_path)) if rel_path else base
|
||||
# (base, path under base) pairs to list. models/<category>/ also lists the
|
||||
# category's extra model paths, like ComfyUI resolves them.
|
||||
targets = [(base, rel_path) for base in roots]
|
||||
categories = {
|
||||
name: [p for p in folder_paths.get_folder_paths(name) if os.path.isdir(p)]
|
||||
for name in folder_paths.folder_names_and_paths if name != "custom_nodes"
|
||||
} if browse_type == "models" else {}
|
||||
category, _, rest = rel_path.replace("\\", "/").partition("/")
|
||||
if category in categories:
|
||||
targets += [(p, rest) for p in categories[category]]
|
||||
|
||||
# Security: ensure target is inside base
|
||||
real_target = os.path.realpath(target)
|
||||
real_base = os.path.realpath(base)
|
||||
# Folders on different drives are never merged: the picker shows one drive
|
||||
# at a time and switches between them (C:, D:, ...).
|
||||
drives = sorted({_drive(b) for b in roots} | {_drive(p) for paths in categories.values() for p in paths})
|
||||
if drive not in drives:
|
||||
drive = _default_drive(drives)
|
||||
targets = [(b, r) for b, r in targets if _drive(b) == drive]
|
||||
if browse_type == "models" and not category:
|
||||
for name, paths in categories.items():
|
||||
if os.path.normcase(name) not in seen and any(_drive(p) == drive for p in paths):
|
||||
seen.add(os.path.normcase(name))
|
||||
entries.append({"name": name, "type": "dir"})
|
||||
|
||||
for base, rel in targets:
|
||||
target = os.path.normpath(os.path.join(base, rel)) if rel else base
|
||||
|
||||
# Security: ensure target is inside base. Checked on the path itself, not
|
||||
# its realpath, so symlinked/junctioned folders (e.g. models/unet on
|
||||
# another drive) open like ComfyUI loads them (#440).
|
||||
abs_base = os.path.abspath(base)
|
||||
try:
|
||||
if os.path.commonpath([real_target, real_base]) != real_base:
|
||||
if os.path.commonpath([os.path.abspath(target), abs_base]) != abs_base:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -658,20 +1008,74 @@ async def _browse(_request: web.Request) -> web.Response:
|
||||
valid_target_found = True
|
||||
try:
|
||||
for item in sorted(os.listdir(target)):
|
||||
if item in seen:
|
||||
if os.path.normcase(item) in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
seen.add(os.path.normcase(item))
|
||||
full = os.path.join(target, item)
|
||||
if os.path.isdir(full):
|
||||
if item.startswith("."): # .flake_metadata sidecars (#480)
|
||||
continue
|
||||
entries.append({"name": item, "type": "dir"})
|
||||
elif os.path.isfile(full):
|
||||
if exts and not item.lower().endswith(exts):
|
||||
continue
|
||||
entries.append({"name": item, "type": "file"})
|
||||
entries.append({"name": item, "type": "file", "path": full})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not valid_target_found and not entries:
|
||||
return _not_found(f"directory not found: {rel_path}")
|
||||
|
||||
return web.json_response({"path": rel_path, "entries": entries})
|
||||
return web.json_response({"path": rel_path, "entries": entries, "drive": drive, "drives": drives})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reveal an output image in the OS file explorer (#329)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@routes.post("/flakes/reveal")
|
||||
async def _reveal_image(request: web.Request) -> web.Response:
|
||||
"""Open the OS file explorer at a generated image, selecting it when possible.
|
||||
|
||||
The client posts {filename, subfolder, type}; the absolute path is resolved
|
||||
server-side via folder_paths so no absolute path is trusted from the browser.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _bad_request("invalid JSON body")
|
||||
if not isinstance(body, dict):
|
||||
return _bad_request("body must be a JSON object")
|
||||
|
||||
filename = (body.get("filename") or "").strip()
|
||||
if not filename:
|
||||
return _bad_request("missing 'filename'")
|
||||
subfolder = (body.get("subfolder") or "").strip()
|
||||
folder_type = (body.get("type") or "output").strip()
|
||||
|
||||
try:
|
||||
base = folder_paths.get_directory_by_type(folder_type) or folder_paths.get_output_directory()
|
||||
except Exception:
|
||||
base = folder_paths.get_output_directory()
|
||||
|
||||
target = os.path.normpath(os.path.join(base, subfolder, filename))
|
||||
# Confine the target to the resolved base directory.
|
||||
real_base, real_target = os.path.realpath(base), os.path.realpath(target)
|
||||
if os.path.commonpath([real_base, real_target]) != real_base:
|
||||
return _bad_request("path escapes the output directory")
|
||||
if not os.path.exists(real_target):
|
||||
return _not_found(f"image not found: {filename}")
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.Popen(["explorer", "/select,", real_target])
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", real_target])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(real_target)])
|
||||
except Exception as exc:
|
||||
logging.exception("[flakes] failed to reveal %s", real_target)
|
||||
return _server_error(str(exc))
|
||||
return web.json_response({"ok": True, "path": real_target})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Downscaled cover images for grids and pickers (#451), cached as WebP in
|
||||
ComfyUI's temp dir and keyed by source path, mtime and size so an edited
|
||||
cover gets a new thumbnail."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
import folder_paths
|
||||
|
||||
|
||||
def thumbnail(path: str, px: int) -> str:
|
||||
"""Path of a WebP copy of the image at *path* fitting in px x px."""
|
||||
st = os.stat(path)
|
||||
key = hashlib.sha1(f"{os.path.abspath(path)}|{st.st_mtime_ns}|{st.st_size}|{px}".encode()).hexdigest()
|
||||
folder = os.path.join(folder_paths.get_temp_directory(), "flakes_thumbs")
|
||||
out = os.path.join(folder, f"{key}.webp")
|
||||
if os.path.isfile(out):
|
||||
return out
|
||||
with Image.open(path) as img:
|
||||
thumb = ImageOps.exif_transpose(img)
|
||||
thumb.thumbnail((px, px))
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
# Write then rename: a concurrent request for the same cover must never
|
||||
# serve a half-written file.
|
||||
fd, tmp = tempfile.mkstemp(dir=folder, suffix=".tmp")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
thumb.save(f, "WEBP", quality=85)
|
||||
os.replace(tmp, out)
|
||||
return out
|
||||
+514
-159
@@ -6,152 +6,403 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
import folder_paths
|
||||
import comfy.ldm.modules.attention
|
||||
import comfy.sd
|
||||
import comfy.utils
|
||||
from nodes import CLIPTextEncode, EmptyLatentImage, ControlNetApplyAdvanced, ControlNetLoader, LoraLoader
|
||||
from nodes import CLIPTextEncode, EmptyLatentImage, ControlNetApplyAdvanced, ControlNetLoader
|
||||
|
||||
from . import flake_io
|
||||
from . import flake_families, flake_io
|
||||
from .flake_io import _resolve_model_name
|
||||
|
||||
|
||||
def _split_stem_for_filename(stem: str) -> tuple[str, str, str, str]:
|
||||
"""Parse one filename-prefix stem into placement buckets, honoring the
|
||||
``**`` suffix marker (#290).
|
||||
|
||||
The last path segment is the filename contribution; preceding segments are
|
||||
folders. A ``**`` before the folder portion makes that folder a *suffix*
|
||||
(placed after other flakes' folders); a ``**`` before the file portion
|
||||
makes that name a *suffix* (placed after other flakes' names).
|
||||
|
||||
Returns ``(folder_prefix, folder_suffix, file_prefix, file_suffix)`` with
|
||||
each element possibly empty. Folder values keep their trailing ``/``.
|
||||
"""
|
||||
folder_prefix = folder_suffix = ""
|
||||
file_prefix = file_suffix = ""
|
||||
|
||||
slash = stem.rfind("/")
|
||||
if slash >= 0:
|
||||
folder_region = stem[:slash + 1]
|
||||
file_region = stem[slash + 1:]
|
||||
else:
|
||||
folder_region = ""
|
||||
file_region = stem
|
||||
|
||||
if folder_region:
|
||||
if folder_region.startswith("**"):
|
||||
folder_suffix = folder_region[2:].replace("**", "")
|
||||
else:
|
||||
folder_prefix = folder_region.replace("**", "")
|
||||
|
||||
if file_region:
|
||||
before, sep, after = file_region.partition("**")
|
||||
file_prefix = before.replace("**", "")
|
||||
if sep:
|
||||
file_suffix = after.replace("**", "")
|
||||
|
||||
return folder_prefix, folder_suffix, file_prefix, file_suffix
|
||||
|
||||
|
||||
def _build_filename_prefix(preset_name: str, stems: list[str]) -> str:
|
||||
"""Build a filename prefix from preset name and flake stems."""
|
||||
folder_parts = []
|
||||
file_parts = []
|
||||
"""Build a filename prefix from preset name and flake stems.
|
||||
|
||||
Folders accumulate as ``<folder_prefixes><folder_suffixes>`` and the
|
||||
filename as ``<time>_<file_prefixes>_<file_suffixes>``, where prefixes come
|
||||
from each stem in stack order and suffixes (marked with ``**``) follow all
|
||||
prefixes — see :func:`_split_stem_for_filename` (#290).
|
||||
"""
|
||||
folder_prefixes: list[str] = []
|
||||
folder_suffixes: list[str] = []
|
||||
file_prefixes: list[str] = []
|
||||
file_suffixes: list[str] = []
|
||||
|
||||
for stem in stems:
|
||||
if not stem:
|
||||
continue
|
||||
if "/" in stem:
|
||||
folder_parts.append(stem)
|
||||
else:
|
||||
file_parts.append(stem)
|
||||
fpre, fsuf, npre, nsuf = _split_stem_for_filename(stem)
|
||||
if fpre:
|
||||
folder_prefixes.append(fpre)
|
||||
if fsuf:
|
||||
folder_suffixes.append(fsuf)
|
||||
if npre:
|
||||
file_prefixes.append(npre)
|
||||
if nsuf:
|
||||
file_suffixes.append(nsuf)
|
||||
|
||||
path = (preset_name + "/") if preset_name else ""
|
||||
if folder_parts:
|
||||
path += "".join(folder_parts)
|
||||
path += "".join(folder_prefixes) + "".join(folder_suffixes)
|
||||
now = datetime.now()
|
||||
path += now.strftime("%Y-%m-%d") + "/"
|
||||
filename = now.strftime("%H-%M-%S")
|
||||
if file_parts:
|
||||
filename += "_" + "_".join(file_parts)
|
||||
path += now.strftime("%y%m%d") + "/"
|
||||
|
||||
filename = now.strftime("%H%M%S")
|
||||
name_parts = file_prefixes + file_suffixes
|
||||
if name_parts:
|
||||
filename += "_" + "_".join(name_parts)
|
||||
return path + filename
|
||||
|
||||
|
||||
def _resolve_model_name(category: str, stem_or_name: str) -> str:
|
||||
available = folder_paths.get_filename_list(category)
|
||||
available_norm = {p.replace("\\", "/"): p for p in available}
|
||||
|
||||
norm = stem_or_name.replace("\\", "/")
|
||||
if norm in available_norm:
|
||||
return available_norm[norm]
|
||||
|
||||
norm_stem, _ = os.path.splitext(norm)
|
||||
for cand_norm, candidate in available_norm.items():
|
||||
stem, _ = os.path.splitext(cand_norm)
|
||||
if stem == norm_stem:
|
||||
return candidate
|
||||
|
||||
return stem_or_name
|
||||
# Attention backends core registers per model when installed, by combo label (#455).
|
||||
_ATTENTION_BACKENDS = {"PyTorch": "pytorch", "Comfy Kitchen INT8": "comfy_kitchen_int8", "Sage": "sage", "Sage 3": "sage3", "Flash": "flash"}
|
||||
|
||||
|
||||
def _resolve_lora_name(stem_or_name: str) -> str:
|
||||
result = _resolve_model_name("loras", stem_or_name)
|
||||
available = folder_paths.get_filename_list("loras")
|
||||
if result.replace("\\", "/") in {p.replace("\\", "/") for p in available}:
|
||||
return result
|
||||
raise FileNotFoundError(f"LoRA '{stem_or_name}' not found in models/loras/")
|
||||
def _attention_options() -> list[str]:
|
||||
registered = comfy.ldm.modules.attention.REGISTERED_ATTENTION_FUNCTIONS
|
||||
return ["default"] + [label for label, name in _ATTENTION_BACKENDS.items() if name in registered]
|
||||
|
||||
|
||||
def _load_preset_bundle(preset_name: str):
|
||||
"""Load a model preset and return (model_bundle, generation_data, sampling_preset)."""
|
||||
preset_data = flake_io.load_preset(preset_name)
|
||||
def _apply_attention(model, attention: str):
|
||||
"""``model`` set to the ``attention`` backend like core Model Attention
|
||||
Backend; "default" keeps the one ComfyUI was launched with."""
|
||||
if attention == "default":
|
||||
return model
|
||||
m = model.clone()
|
||||
m.set_model_optimized_attention(comfy.ldm.modules.attention.get_attention_function(_ATTENTION_BACKENDS[attention]))
|
||||
return m
|
||||
|
||||
# --- Load checkpoint ----------------------------------------------------
|
||||
ckpt_name = _resolve_model_name("checkpoints", preset_data.checkpoint)
|
||||
ckpt_path = folder_paths.get_full_path("checkpoints", ckpt_name)
|
||||
if not ckpt_path or not os.path.isfile(ckpt_path):
|
||||
|
||||
def _carry_controlnets(src_cond, dst_cond):
|
||||
"""Copy the controlnet chain from upstream conditioning onto freshly
|
||||
re-encoded conditioning (#257).
|
||||
|
||||
Flake nodes re-encode the accumulated prompt text every time, producing
|
||||
fresh conditioning that loses the ``control`` key — which is how ComfyUI
|
||||
chains ControlNets (see ControlNetApplyAdvanced). Without this, a
|
||||
controlnet applied by an upstream Flake node is silently dropped and only
|
||||
the last node's controlnets survive. Carrying ``control`` forward lets this
|
||||
node's own controlnets chain on top via ``set_previous_controlnet``.
|
||||
"""
|
||||
if not src_cond or not dst_cond:
|
||||
return dst_cond
|
||||
src_meta = src_cond[0][1] if len(src_cond[0]) > 1 else {}
|
||||
control = src_meta.get("control")
|
||||
if control is None:
|
||||
return dst_cond
|
||||
out = []
|
||||
for t in dst_cond:
|
||||
d = t[1].copy()
|
||||
d["control"] = control
|
||||
if "control_apply_to_uncond" in src_meta:
|
||||
d["control_apply_to_uncond"] = src_meta["control_apply_to_uncond"]
|
||||
out.append([t[0], d])
|
||||
return out
|
||||
|
||||
|
||||
def _require_model_file(category: str, ref: str, field: str) -> str:
|
||||
"""Resolve a preset's model reference to a real path or fail loudly (#358).
|
||||
|
||||
Silently skipping a missing file (as the old VAE/text-encoder overrides did)
|
||||
means the preset quietly generates with the wrong components.
|
||||
"""
|
||||
resolved = _resolve_model_name(category, ref)
|
||||
path = folder_paths.get_full_path(category, resolved)
|
||||
if not path or not os.path.isfile(path):
|
||||
raise FileNotFoundError(
|
||||
f"Checkpoint '{preset_data.checkpoint}' not found in models/checkpoints/"
|
||||
f"Preset field '{field}' points at '{ref}', which was not found in "
|
||||
f"models/{category}/."
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_clip_type(name: str | None, family=None):
|
||||
"""Look up a ``comfy.sd.CLIPType`` member by name (#358).
|
||||
|
||||
By name, never by value: the enum is renumbered upstream whenever a new
|
||||
architecture lands, so a hardcoded integer silently selects the wrong
|
||||
encoder after a ComfyUI update.
|
||||
"""
|
||||
key = (name or (family.clip_type if family else None) or "STABLE_DIFFUSION").strip().upper()
|
||||
try:
|
||||
return comfy.sd.CLIPType[key]
|
||||
except KeyError:
|
||||
available = ", ".join(sorted(m.name for m in comfy.sd.CLIPType))
|
||||
raise ValueError(
|
||||
f"clip_type '{key}' is not supported by this ComfyUI build. "
|
||||
f"Available types: {available}"
|
||||
) from None
|
||||
|
||||
|
||||
def _apply_sampling_shift(model, shift: float):
|
||||
"""Apply a ModelSamplingAuraFlow sigma shift to the model (#359).
|
||||
|
||||
Imported lazily: comfy_extras is not importable at module scope during node
|
||||
registration. Degrades to a warning rather than failing the run, since the
|
||||
detected model config already carries a sensible default shift.
|
||||
"""
|
||||
try:
|
||||
from comfy_extras.nodes_model_advanced import ModelSamplingAuraFlow
|
||||
except ImportError:
|
||||
logging.warning("[flakes] ModelSamplingAuraFlow unavailable; skipping shift=%s", shift)
|
||||
return model
|
||||
return ModelSamplingAuraFlow().patch_aura(model, shift)[0]
|
||||
|
||||
|
||||
def _load_preset_bundle(preset_name: str, model_family: str | None = None):
|
||||
"""Load a model preset and return (model_bundle, generation_data, sampling_preset).
|
||||
|
||||
``model_family`` is used to derive the ``img/<family>/`` output folder
|
||||
prefix (#240). It's optional for backwards compatibility but should be
|
||||
passed by the node so the structural prefix is present.
|
||||
"""
|
||||
preset_data = flake_io.load_preset(preset_name)
|
||||
family = flake_families.get(model_family)
|
||||
|
||||
embedding_dir = folder_paths.get_folder_paths("embeddings")
|
||||
model, clip, vae, _ = comfy.sd.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=embedding_dir,
|
||||
)
|
||||
category, model_ref = preset_data.model_source
|
||||
|
||||
# --- Load the model -----------------------------------------------------
|
||||
if category == "diffusion_models":
|
||||
model = comfy.sd.load_diffusion_model(
|
||||
_require_model_file("diffusion_models", model_ref, "diffusion_model")
|
||||
)
|
||||
clip = vae = None
|
||||
else:
|
||||
ckpt_path = _require_model_file("checkpoints", model_ref, "checkpoint")
|
||||
model, clip, vae, _ = comfy.sd.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=embedding_dir,
|
||||
)
|
||||
if clip is None or vae is None:
|
||||
# A UNET-only file living under models/checkpoints/ — how Anima,
|
||||
# Krea2 and Z-Image are usually distributed (#358). The call still
|
||||
# built `model` correctly from the model.diffusion_model.* keys; it
|
||||
# just had no text encoder or VAE to give us, so those come from the
|
||||
# preset. Only reached when the call *succeeded*: a genuine load
|
||||
# failure raises and is left to propagate.
|
||||
logging.info(
|
||||
"[flakes] '%s' has no bundled %s — loading them from the preset",
|
||||
model_ref,
|
||||
" or ".join(n for n, v in (("text encoder", clip), ("VAE", vae)) if v is None),
|
||||
)
|
||||
|
||||
# --- Text encoder -------------------------------------------------------
|
||||
if preset_data.text_encoder:
|
||||
te_path = _require_model_file("text_encoders", preset_data.text_encoder, "text_encoder")
|
||||
clip = comfy.sd.load_clip(
|
||||
ckpt_paths=[te_path],
|
||||
embedding_directory=embedding_dir,
|
||||
clip_type=_resolve_clip_type(preset_data.clip_type, family),
|
||||
)
|
||||
if clip is None:
|
||||
raise ValueError(
|
||||
f"Preset '{preset_name}' loads a model with no built-in text encoder, "
|
||||
f"so it needs a 'text_encoder' set in the preset editor."
|
||||
)
|
||||
|
||||
# --- VAE ----------------------------------------------------------------
|
||||
if preset_data.vae:
|
||||
vae_path = _require_model_file("vae", preset_data.vae, "vae")
|
||||
vae = comfy.sd.VAE(sd=comfy.utils.load_torch_file(vae_path))
|
||||
if vae is None:
|
||||
raise ValueError(
|
||||
f"Preset '{preset_name}' loads a model with no built-in VAE, "
|
||||
f"so it needs a 'vae' set in the preset editor."
|
||||
)
|
||||
|
||||
# --- Clip skip ----------------------------------------------------------
|
||||
if preset_data.clip_skip:
|
||||
# clip_layer() is a CLIP-ism: it drops the last N transformer layers of a
|
||||
# CLIP text encoder. Anima, Krea2 and Z-Image encode with Qwen3 / Qwen3-VL,
|
||||
# where it is meaningless and can raise, so families declare whether they
|
||||
# support it (#359). Presets carry clip_skip: -2 by default whether or not
|
||||
# the family can use it, so this has to be gated here rather than on the
|
||||
# value being set.
|
||||
if preset_data.clip_skip and (family is None or family.supports_clip_skip):
|
||||
clip = clip.clone()
|
||||
clip.clip_layer(preset_data.clip_skip)
|
||||
|
||||
# --- Optional VAE override ----------------------------------------------
|
||||
if preset_data.vae:
|
||||
vae_name = _resolve_model_name("vae", preset_data.vae)
|
||||
vae_path = folder_paths.get_full_path("vae", vae_name)
|
||||
if vae_path and os.path.isfile(vae_path):
|
||||
vae_sd = comfy.utils.load_torch_file(vae_path)
|
||||
vae = comfy.sd.VAE(sd=vae_sd)
|
||||
# --- Sampling shift -----------------------------------------------------
|
||||
# Flow-matching families need a sigma shift; the templates apply it with an
|
||||
# explicit ModelSamplingAuraFlow node. The preset value wins over the
|
||||
# family default so a preset can be tuned without touching the registry.
|
||||
shift = preset_data.shift if preset_data.shift is not None else (
|
||||
family.default_shift if family else None
|
||||
)
|
||||
if shift is not None:
|
||||
model = _apply_sampling_shift(model, shift)
|
||||
|
||||
# --- Optional text encoder override ------------------------------------
|
||||
if preset_data.text_encoder:
|
||||
te_name = _resolve_model_name("text_encoders", preset_data.text_encoder)
|
||||
te_path = folder_paths.get_full_path("text_encoders", te_name)
|
||||
if te_path and os.path.isfile(te_path):
|
||||
te_sd = comfy.utils.load_torch_file(te_path)
|
||||
_, clip, _ = comfy.sd.load_checkpoint_guess_config(
|
||||
te_path,
|
||||
output_vae=False,
|
||||
output_clip=True,
|
||||
embedding_directory=embedding_dir,
|
||||
)
|
||||
if preset_data.clip_skip:
|
||||
clip = clip.clone()
|
||||
clip.clip_layer(preset_data.clip_skip)
|
||||
# --- LoRAs and flake links ---------------------------------------------
|
||||
# The preset acts as a flake of its own, so its links resolve (and
|
||||
# schedule) exactly like a flake's. Hooks attached here are kept by
|
||||
# carry_schedules in downstream Flake nodes.
|
||||
from .flake_compose import apply_loras, carry_schedules, encode_scheduled_prompts
|
||||
|
||||
preset_flake = flake_io.Flake(
|
||||
name="__preset__",
|
||||
positive=preset_data.positive.strip(),
|
||||
negative=preset_data.negative.strip(),
|
||||
loras=list(preset_data.loras),
|
||||
flake_links=list(preset_data.flake_links),
|
||||
)
|
||||
flake_io._apply_flake_link(preset_flake, {})
|
||||
model, clip, lora_entries, hook_groups = apply_loras(model, clip, [preset_flake])
|
||||
|
||||
# --- Encode prompts -----------------------------------------------------
|
||||
encoder = CLIPTextEncode()
|
||||
pos_text = preset_data.positive.strip()
|
||||
neg_text = preset_data.negative.strip()
|
||||
pos_text, neg_text, prompt_parts = _join_prompts("", "", [preset_flake], None)
|
||||
positive = encoder.encode(clip, pos_text)[0] if pos_text else encoder.encode(clip, "")[0]
|
||||
negative = encoder.encode(clip, neg_text)[0] if neg_text else encoder.encode(clip, "")[0]
|
||||
pos_ranged, neg_ranged = encode_scheduled_prompts(clip, [preset_flake])
|
||||
positive = carry_schedules(positive, positive, pos_ranged, hook_groups)
|
||||
negative = carry_schedules(negative, negative, neg_ranged, hook_groups)
|
||||
|
||||
# --- Latent -------------------------------------------------------------
|
||||
width, height = preset_data.width, preset_data.height
|
||||
latent = EmptyLatentImage().generate(width, height, 1)[0]
|
||||
|
||||
model_bundle = (model, clip, vae)
|
||||
# Build the structural prefix `img/<family>/` (#240) plus the explicit
|
||||
# filename_prefix field. We do NOT inherit from the preset's yaml location
|
||||
# on disk (#217). The img/<family>/ segment is the project's output folder
|
||||
# convention; without it, outputs land at output/<rest>/... instead of
|
||||
# output/img/<family>/<rest>/...
|
||||
family_folder = flake_io._family_folder(model_family) if model_family else None
|
||||
structural = f"img/{family_folder}/" if family_folder else ""
|
||||
prefix_stem = (preset_data.filename_prefix or "").strip()
|
||||
stems = []
|
||||
if structural:
|
||||
# stems entries containing "/" land in folder_parts (see _build_filename_prefix).
|
||||
stems.append(structural)
|
||||
if prefix_stem:
|
||||
stems.append(prefix_stem)
|
||||
filename_state = {
|
||||
"preset": preset_data.filename_prefix or preset_name,
|
||||
"stems": [],
|
||||
"preset": "",
|
||||
"stems": stems,
|
||||
"checkpoint": preset_data.checkpoint,
|
||||
"vae": preset_data.vae or "baked-in",
|
||||
"text_encoder": preset_data.text_encoder or "baked-in",
|
||||
"loras": [],
|
||||
"loras": lora_entries,
|
||||
"model_family": model_family,
|
||||
"prompt_parts": prompt_parts,
|
||||
}
|
||||
generation_data = (positive, negative, latent, width, height, pos_text, neg_text, filename_state)
|
||||
sampling_preset = (preset_data.steps, preset_data.cfg, preset_data.sampler, preset_data.scheduler)
|
||||
sampling_preset = (preset_data.steps, preset_data.cfg, preset_data.sampler, preset_data.scheduler, 1.0)
|
||||
|
||||
return model_bundle, generation_data, sampling_preset
|
||||
|
||||
|
||||
_MODEL_FAMILIES = ["SDXL/Base", "SDXL/Illustrious", "SDXL/Pony", "ZImage/Base", "ZImage/Turbo"]
|
||||
def _apply_preset_overrides(overrides_json, model_bundle, generation_data, sampling_preset):
|
||||
"""Overlay per-instance overrides (steps/cfg/sampler/scheduler/
|
||||
filename_prefix) onto a loaded preset bundle (#279).
|
||||
|
||||
Empty/blank fields are ignored so the preset's own values pass through.
|
||||
``filename_prefix`` is appended to the stem chain rather than replacing it.
|
||||
"""
|
||||
if not overrides_json:
|
||||
return model_bundle, generation_data, sampling_preset
|
||||
try:
|
||||
ovr = json.loads(overrides_json) or {}
|
||||
except (ValueError, TypeError):
|
||||
return model_bundle, generation_data, sampling_preset
|
||||
if not isinstance(ovr, dict) or not ovr:
|
||||
return model_bundle, generation_data, sampling_preset
|
||||
|
||||
steps, cfg, sampler, scheduler, denoise = sampling_preset
|
||||
|
||||
def _conv(key, conv, cur):
|
||||
v = ovr.get(key)
|
||||
if v is None or v == "":
|
||||
return cur
|
||||
try:
|
||||
return conv(v)
|
||||
except (ValueError, TypeError):
|
||||
return cur
|
||||
|
||||
steps = _conv("steps", int, steps)
|
||||
cfg = _conv("cfg", float, cfg)
|
||||
if ovr.get("sampler"):
|
||||
sampler = str(ovr["sampler"])
|
||||
if ovr.get("scheduler"):
|
||||
scheduler = str(ovr["scheduler"])
|
||||
sampling_preset = (steps, cfg, sampler, scheduler, denoise)
|
||||
|
||||
fp = (ovr.get("filename_prefix") or "").strip()
|
||||
if fp and isinstance(generation_data, tuple) and len(generation_data) > 7 and isinstance(generation_data[7], dict):
|
||||
fs = dict(generation_data[7])
|
||||
fs["stems"] = list(fs.get("stems", [])) + [fp]
|
||||
generation_data = generation_data[:7] + (fs,)
|
||||
|
||||
return model_bundle, generation_data, sampling_preset
|
||||
|
||||
|
||||
# Free text (#470): a registered label or folder in any case, else a new family.
|
||||
_FAMILY_TOOLTIP = "Model family, e.g. SDXL/Illustrious or anima. An unknown name is a new family with default settings."
|
||||
|
||||
|
||||
class FlakeModelPreset:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
try:
|
||||
preset_names = flake_io.list_presets()
|
||||
preset_names = flake_io.list_presets(local=True)
|
||||
except Exception:
|
||||
preset_names = []
|
||||
if not preset_names:
|
||||
preset_names = ["No model preset is selected"]
|
||||
return {
|
||||
"required": {
|
||||
"model_family": (_MODEL_FAMILIES, {"default": "SDXL/Base"}),
|
||||
"model_family": ("STRING", {"default": "SDXL/Base", "tooltip": _FAMILY_TOOLTIP}),
|
||||
"preset": (["Select a preset..."] + preset_names,),
|
||||
},
|
||||
"optional": {
|
||||
# Per-instance overrides (steps/cfg/sampler/scheduler/
|
||||
# filename_prefix) as JSON. Managed by the node's widget; lets a
|
||||
# preset be A/B-tested without editing its file (#279).
|
||||
"overrides_json": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "JSON override of steps/cfg/sampler/scheduler/filename_prefix. Managed by the Flake widget.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLAKE_DATA",)
|
||||
@@ -164,35 +415,94 @@ class FlakeModelPreset:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, model_family: str, preset: str):
|
||||
def IS_CHANGED(cls, model_family: str, preset: str, overrides_json: str = ""):
|
||||
# Re-run whenever the preset file's contents change on disk, even if the
|
||||
# selected preset name (the node input) stays the same. Without this,
|
||||
# ComfyUI caches the output and edits to Steps/CFG/etc. are ignored.
|
||||
# The override JSON is part of the signature so override edits re-run too.
|
||||
# Edits to a flake the preset links to are not tracked.
|
||||
preset_name = preset.strip() if preset else ""
|
||||
if not preset_name or preset_name in ("Select a preset...", "No model preset is selected"):
|
||||
return preset_name
|
||||
return f"{preset_name}:{overrides_json}"
|
||||
try:
|
||||
path = flake_io._resolve_preset_file(preset_name)
|
||||
st = os.stat(path)
|
||||
return f"{path}:{st.st_mtime_ns}:{st.st_size}"
|
||||
return f"{path}:{st.st_mtime_ns}:{st.st_size}:{overrides_json}"
|
||||
except Exception:
|
||||
return float("nan")
|
||||
|
||||
def execute(self, model_family: str, preset: str):
|
||||
def execute(self, model_family: str, preset: str, overrides_json: str = ""):
|
||||
preset_name = preset.strip() if preset else ""
|
||||
if not preset_name or preset_name in ("Select a preset...", "No model preset is selected"):
|
||||
raise ValueError("No model preset is selected — pick one from the dropdown.")
|
||||
|
||||
model_bundle, generation_data, sampling_preset = _load_preset_bundle(preset_name)
|
||||
model_bundle, generation_data, sampling_preset = _load_preset_bundle(preset_name, model_family)
|
||||
model_bundle, generation_data, sampling_preset = _apply_preset_overrides(
|
||||
overrides_json, model_bundle, generation_data, sampling_preset
|
||||
)
|
||||
return ((model_bundle, generation_data, sampling_preset),)
|
||||
|
||||
|
||||
def _resolve_flakes(flakes_json: str) -> list[flake_io.Flake]:
|
||||
"""Resolve the active (non-bypassed) entries of a Flake Stack / Combo
|
||||
``flakes_json`` list, skipping malformed ones."""
|
||||
try:
|
||||
entries = json.loads(flakes_json) if flakes_json else []
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"flakes_json is not valid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("flakes_json must be a JSON list")
|
||||
|
||||
normalized: list[dict] = []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
logging.warning("[FlakeStack] skipping non-object entry %d: %r", i, entry)
|
||||
continue
|
||||
if entry.get("inline") or entry.get("name"):
|
||||
normalized.append(entry)
|
||||
else:
|
||||
logging.warning("[FlakeStack] skipping entry %d (needs 'name' or 'inline')", i)
|
||||
|
||||
return [flake_io.resolve(e) for e in normalized if not e.get("bypassed")]
|
||||
|
||||
|
||||
def _render_parts(parts: list) -> tuple[str, str]:
|
||||
"""(positive, negative) of the prompt parts: each part's text with its
|
||||
active options; positives joined with " BREAK ", negatives with ", "."""
|
||||
pos = (", ".join(t for t in [p, *(o[0] for o in options.values() if o[2])] if t) for p, _, options in parts)
|
||||
neg = (t for _, n, options in parts for t in [n, *(o[1] for o in options.values() if o[2])] if t)
|
||||
return " BREAK ".join(t for t in pos if t), ", ".join(neg)
|
||||
|
||||
|
||||
def _join_prompts(pos_text: str, neg_text: str, flakes: list[flake_io.Flake], parts: list | None) -> tuple[str, str, list]:
|
||||
"""Incoming prompt text followed by each flake's: positives joined with
|
||||
" BREAK ", negatives with ", ". A flake's active options join its prompts.
|
||||
|
||||
``parts`` (``[[positive, negative, {name: [positive, negative, on]}], ...]``,
|
||||
from the chain state) keep upstream options apart so each flake's toggler
|
||||
can switch them before its own are added (#462, #467). They are reused
|
||||
only while they still render the incoming texts; returns the new parts
|
||||
for the chain state."""
|
||||
if not parts or _render_parts(parts) != (pos_text.strip(), neg_text.strip()):
|
||||
parts = [[pos_text.strip(), neg_text.strip(), {}]]
|
||||
parts = [[p, n, {k: list(o) for k, o in options.items()}] for p, n, options in parts]
|
||||
for f in flakes:
|
||||
for name, on in f.toggler:
|
||||
for *_, options in parts:
|
||||
if name in options:
|
||||
options[name][2] = on
|
||||
parts.append([f.positive.strip(), f.negative.strip(),
|
||||
{k: [o.positive, o.negative, o.on] for k, o in f.options.items()}])
|
||||
return *_render_parts(parts), parts
|
||||
|
||||
|
||||
class FlakeStack:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_family": (_MODEL_FAMILIES, {"default": "SDXL/Base"}),
|
||||
"model_family": ("STRING", {"default": "SDXL/Base", "tooltip": _FAMILY_TOOLTIP}),
|
||||
"flake_data": ("FLAKE_DATA",),
|
||||
"flakes_json": ("STRING", {
|
||||
"multiline": True,
|
||||
@@ -216,7 +526,7 @@ class FlakeStack:
|
||||
model_bundle, generation_data, sampling_preset = flake_data
|
||||
model, clip, vae = model_bundle
|
||||
positive_cond, negative_cond, latent, width, height, pos_text, neg_text = generation_data[:7]
|
||||
steps, cfg, sampler, scheduler = sampling_preset
|
||||
steps, cfg, sampler, scheduler, denoise = sampling_preset
|
||||
|
||||
# --- Filename prefix state ----------------------------------------------
|
||||
# Copy the incoming filename_state so we don't mutate the original tuple.
|
||||
@@ -228,71 +538,78 @@ class FlakeStack:
|
||||
else:
|
||||
filename_state = {"preset": "", "stems": []}
|
||||
|
||||
# --- Parse flakes_json --------------------------------------------------
|
||||
try:
|
||||
entries = json.loads(flakes_json) if flakes_json else []
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"flakes_json is not valid JSON: {exc}") from exc
|
||||
# The widget is only a frontend copy of the upstream node's family and
|
||||
# goes stale behind reroutes/subgraphs; the preset loader records the
|
||||
# family it actually loaded (#382).
|
||||
model_family = filename_state.get("model_family") or model_family
|
||||
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("flakes_json must be a JSON list")
|
||||
flakes = _resolve_flakes(flakes_json)
|
||||
|
||||
normalized: list[dict] = []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
logging.warning("[FlakeStack] skipping non-object entry %d: %r", i, entry)
|
||||
continue
|
||||
if entry.get("inline") or entry.get("name"):
|
||||
normalized.append(entry)
|
||||
else:
|
||||
logging.warning("[FlakeStack] skipping entry %d (needs 'name' or 'inline')", i)
|
||||
|
||||
# --- Resolve flakes (skip bypassed) --------------------------------------
|
||||
active_entries = [e for i, e in enumerate(normalized) if not e.get("bypassed")]
|
||||
flakes = [flake_io.resolve(e) for e in active_entries]
|
||||
from .flake_compose import (
|
||||
_load_cn_image, _fit_cn_image, anima_cn_choice, apply_anima_vace, apply_loras, apply_model_patch_cn,
|
||||
carry_schedules, encode_scheduled_prompts, load_cn_patch,
|
||||
)
|
||||
|
||||
# --- Apply LoRAs --------------------------------------------------------
|
||||
lora_loader = LoraLoader()
|
||||
lora_entries = []
|
||||
for f in flakes:
|
||||
for lr in f.loras:
|
||||
if not lr.path:
|
||||
continue
|
||||
lora_name = _resolve_lora_name(lr.path)
|
||||
model, clip = lora_loader.load_lora(model, clip, lora_name, lr.strength, lr.strength)
|
||||
lora_entries.append({"name": lr.name or lr.path, "path": lr.path, "strength": lr.strength})
|
||||
# Legacy single LoRA fallback
|
||||
if f.lora_path:
|
||||
lora_name = _resolve_lora_name(f.lora_path)
|
||||
model, clip = lora_loader.load_lora(model, clip, lora_name, f.strength, f.strength)
|
||||
lora_entries.append({"name": f.lora_path, "path": f.lora_path, "strength": f.strength})
|
||||
model, clip, lora_entries, hook_groups = apply_loras(model, clip, flakes)
|
||||
|
||||
# --- Build prompt text (existing → flakes) ------------------------------
|
||||
pos_parts: list[str] = []
|
||||
neg_parts: list[str] = []
|
||||
|
||||
if pos_text and pos_text.strip():
|
||||
pos_parts.append(pos_text.strip())
|
||||
if neg_text and neg_text.strip():
|
||||
neg_parts.append(neg_text.strip())
|
||||
|
||||
for f in flakes:
|
||||
if f.positive and f.positive.strip():
|
||||
pos_parts.append(f.positive.strip())
|
||||
if f.negative and f.negative.strip():
|
||||
neg_parts.append(f.negative.strip())
|
||||
|
||||
new_pos_text = " BREAK ".join(pos_parts) if pos_parts else ""
|
||||
new_neg_text = ", ".join(neg_parts) if neg_parts else ""
|
||||
new_pos_text, new_neg_text, filename_state["prompt_parts"] = _join_prompts(
|
||||
pos_text, neg_text, flakes, filename_state.get("prompt_parts"))
|
||||
|
||||
# --- Re-encode prompts --------------------------------------------------
|
||||
# Re-encoding produces fresh conditioning that drops the ControlNet
|
||||
# chain attached by upstream Flake nodes, so snapshot the incoming
|
||||
# conditioning and carry its `control` forward afterwards (#257).
|
||||
prev_positive_cond, prev_negative_cond = positive_cond, negative_cond
|
||||
|
||||
encoder = CLIPTextEncode()
|
||||
positive_cond = encoder.encode(clip, new_pos_text)[0] if new_pos_text else encoder.encode(clip, "")[0]
|
||||
negative_cond = encoder.encode(clip, new_neg_text)[0] if new_neg_text else encoder.encode(clip, "")[0]
|
||||
|
||||
# --- Apply ControlNets (from flakes) ------------------------------------
|
||||
from .flake_compose import _load_cn_image
|
||||
# Scheduled prompts become time-ranged entries after the base cond (#370).
|
||||
pos_ranged, neg_ranged = encode_scheduled_prompts(clip, flakes)
|
||||
positive_cond = carry_schedules(prev_positive_cond, positive_cond, pos_ranged, hook_groups)
|
||||
negative_cond = carry_schedules(prev_negative_cond, negative_cond, neg_ranged, hook_groups)
|
||||
|
||||
positive_cond = _carry_controlnets(prev_positive_cond, positive_cond)
|
||||
negative_cond = _carry_controlnets(prev_negative_cond, negative_cond)
|
||||
|
||||
# --- Target resolution (skip bypassed) ----------------------------------
|
||||
# Computed before ControlNets so hint images can be fitted to the frame.
|
||||
new_width, new_height = width, height
|
||||
for f in flakes:
|
||||
if f.resolution is not None:
|
||||
new_width, new_height = f.resolution
|
||||
break
|
||||
|
||||
# --- Apply ControlNets (from flakes) ------------------------------------
|
||||
# ControlNet "use image dimensions as resolution" override (#260): the
|
||||
# first active CN entry flagged resolution_from_image drives the
|
||||
# generation size, taking precedence over the flake resolution above.
|
||||
for f in flakes:
|
||||
hit = False
|
||||
for cn in f.controlnets:
|
||||
if cn.strength == 0 or not cn.resolution_from_image or not cn.image_name.strip():
|
||||
continue
|
||||
try:
|
||||
ov = _load_cn_image(cn.image_name)
|
||||
except Exception as exc:
|
||||
logging.warning("[flakes] resolution_from_image failed for %s: %s", cn.image_name, exc)
|
||||
continue
|
||||
new_width, new_height = int(ov.shape[2]), int(ov.shape[1])
|
||||
logging.info("[flakes] resolution override from CN image %s -> %dx%d", cn.image_name, new_width, new_height)
|
||||
hit = True
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
|
||||
family_folder = flake_io._family_folder(model_family) or "sdxl"
|
||||
# Anima and Z-Image ControlNets are model patches that hook the MODEL
|
||||
# instead of the conditioning (#365); the patched model flows out in
|
||||
# the bundle, so downstream Flake nodes chain on top of it.
|
||||
family = flake_families.get(model_family)
|
||||
cn_kind = family.cn_kind if family else "controlnet"
|
||||
cn_category = family.cn_category if family else "controlnet"
|
||||
cn_model_cache = {}
|
||||
cn_loader = ControlNetLoader()
|
||||
cn_apply = ControlNetApplyAdvanced()
|
||||
@@ -300,28 +617,66 @@ class FlakeStack:
|
||||
for cn in f.controlnets:
|
||||
if cn.strength == 0:
|
||||
continue
|
||||
cn_resolved = _resolve_model_name("controlnet", cn.model_name)
|
||||
if cn_resolved not in cn_model_cache:
|
||||
cn_model_cache[cn_resolved] = cn_loader.load_controlnet(cn_resolved)[0]
|
||||
cn_model = cn_model_cache[cn_resolved]
|
||||
# An Anima entry whose model resolves to an LLLite patch or a
|
||||
# VACE file (fork route, #378) uses that file; otherwise the
|
||||
# type picks the LLLite file (#381).
|
||||
picked = anima_cn_choice(cn.model_name) if cn_kind == "anima_lllite" else None
|
||||
vace_name = picked[0] if picked and picked[1] == "vace" else None
|
||||
model_name = cn.model_name
|
||||
if cn.type and not picked:
|
||||
inferred = flake_io.infer_cn_model(cn.type, family_folder)
|
||||
if inferred:
|
||||
model_name = inferred
|
||||
if not model_name.strip():
|
||||
print(f"[flakes] skipping controlnet entry with empty model_name (type={cn.type})")
|
||||
continue
|
||||
if not cn.image_name.strip():
|
||||
print(f"[flakes] skipping controlnet entry with empty image_name (type={cn.type})")
|
||||
continue
|
||||
cn_resolved = picked[0] if picked else flake_io.resolve_cn_model_name(model_name, family_folder, cn_category)
|
||||
image = _load_cn_image(cn.image_name)
|
||||
positive_cond, negative_cond = cn_apply.apply_controlnet(
|
||||
positive_cond, negative_cond, cn_model, image,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
|
||||
# --- Resolution (skip bypassed) ----------------------------------------
|
||||
new_width, new_height = width, height
|
||||
for f in flakes:
|
||||
if f.resolution is not None:
|
||||
new_width, new_height = f.resolution
|
||||
break
|
||||
image = _fit_cn_image(image, new_width, new_height)
|
||||
if vace_name:
|
||||
positive_cond, negative_cond = apply_anima_vace(
|
||||
positive_cond, negative_cond, vace_name, vae, image,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
elif cn_kind == "controlnet":
|
||||
if cn_resolved not in cn_model_cache:
|
||||
cn_model_cache[cn_resolved] = cn_loader.load_controlnet(cn_resolved)[0]
|
||||
positive_cond, negative_cond = cn_apply.apply_controlnet(
|
||||
positive_cond, negative_cond, cn_model_cache[cn_resolved], image,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
else:
|
||||
model = apply_model_patch_cn(
|
||||
cn_kind, model, vae, load_cn_patch(cn_resolved), image,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
# The sigma window shows which steps an Anima ControlNet can
|
||||
# act on; FlakeGenerate logs whether the patch reached the sampler (#382).
|
||||
if cn_kind == "anima_lllite":
|
||||
model_sampling = model.get_model_object("model_sampling")
|
||||
logging.info(
|
||||
"[flakes] Anima %s %s image=%s fit=%dx%d strength=%.2f start=%.2f end=%.2f sigma=%.3f..%.3f",
|
||||
picked[1] if picked else "lllite", cn_resolved, cn.image_name, new_width, new_height,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
float(model_sampling.percent_to_sigma(cn.start_percent)),
|
||||
float(model_sampling.percent_to_sigma(cn.end_percent)),
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"[flakes] applied CN model=%s image=%s fit=%dx%d strength=%.2f start=%.2f end=%.2f",
|
||||
cn_resolved, cn.image_name, new_width, new_height,
|
||||
cn.strength, cn.start_percent, cn.end_percent,
|
||||
)
|
||||
|
||||
# --- Latent (resolution resolved above, before ControlNets) -------------
|
||||
if (new_width, new_height) != (width, height):
|
||||
latent = EmptyLatentImage().generate(new_width, new_height, 1)[0]
|
||||
|
||||
# --- Lora metadata for Preview Flake Data ---------------------------------
|
||||
filename_state["loras"] = lora_entries
|
||||
filename_state["loras"] = list(filename_state.get("loras", [])) + lora_entries
|
||||
|
||||
# --- Filename prefix (skip bypassed) ------------------------------------
|
||||
for f in flakes:
|
||||
@@ -335,7 +690,7 @@ class FlakeStack:
|
||||
|
||||
out_model_bundle = (model, clip, vae)
|
||||
out_generation_data = (positive_cond, negative_cond, latent, new_width, new_height, new_pos_text, new_neg_text, filename_state)
|
||||
out_sampling_preset = (steps, cfg, sampler, scheduler)
|
||||
out_sampling_preset = (steps, cfg, sampler, scheduler, denoise)
|
||||
|
||||
return (
|
||||
(out_model_bundle, out_generation_data, out_sampling_preset),
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-flakes"
|
||||
description = "Stackable preset 'flakes' (prompt + LoRA + resolution + ControlNet) inserted between a checkpoint and a sampler."
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
@@ -11,4 +11,4 @@ Repository = "https://github.com/JeyzerMC/comfyui-flakes"
|
||||
[tool.comfy]
|
||||
PublisherId = "jeyzermc"
|
||||
DisplayName = "ComfyUI Flakes"
|
||||
Icon = ""
|
||||
Icon = "https://raw.githubusercontent.com/JeyzerMC/comfyui-flakes/main/assets/img/logo.png"
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Tests for Flake Generate Animation (#434): video size and frame count per
|
||||
family, and the core node pipeline each family runs. The core nodes are stubs
|
||||
that record their calls; flake_anim_nodes and full_flake_node are the real
|
||||
modules, loaded like test_anim_preset_nodes.py."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PKG = "_fpkg_anim_generate"
|
||||
|
||||
|
||||
def _load(modname):
|
||||
spec = importlib.util.spec_from_file_location(f"{PKG}.{modname}", os.path.join(ROOT, f"{modname}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"{PKG}.{modname}"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _setup():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.get_folder_paths = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
fp.folder_names_and_paths = {}
|
||||
sys.modules["folder_paths"] = fp
|
||||
for name in ("comfy", "comfy.sd", "comfy.utils", "comfy.model_management", "comfy.cli_args", "comfy.ldm",
|
||||
"comfy.ldm.modules", "comfy.ldm.modules.attention", "comfy_api", "comfy_api.latest"):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
sys.modules["comfy.cli_args"].args = types.SimpleNamespace(disable_metadata=False)
|
||||
mm = sys.modules["comfy.model_management"]
|
||||
mm.EXTRA_RESERVED_VRAM = 100
|
||||
sys.modules["comfy"].model_management = mm
|
||||
# As registered on a machine with Comfy Kitchen and Sage but no Sage 3 / Flash.
|
||||
attention = sys.modules["comfy.ldm.modules.attention"]
|
||||
attention.REGISTERED_ATTENTION_FUNCTIONS = {name: f"attention_{name}" for name in ("comfy_kitchen_int8", "sage", "pytorch", "split")}
|
||||
attention.get_attention_function = lambda name: attention.REGISTERED_ATTENTION_FUNCTIONS[name]
|
||||
sys.modules["comfy"].ldm = sys.modules["comfy.ldm"]
|
||||
sys.modules["comfy.ldm"].modules = sys.modules["comfy.ldm.modules"]
|
||||
sys.modules["comfy.ldm.modules"].attention = attention
|
||||
sys.modules["comfy_api.latest"].io = types.SimpleNamespace(
|
||||
Hidden=types.SimpleNamespace(prompt="PROMPT", extra_pnginfo="EXTRA_PNGINFO"))
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
for attr in ("CLIPTextEncode", "EmptyLatentImage", "ControlNetApplyAdvanced", "ControlNetLoader", "LoraLoader"):
|
||||
setattr(nodes, attr, object)
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules[PKG] = pkg
|
||||
compose = types.ModuleType(f"{PKG}.flake_compose")
|
||||
compose._load_lora = compose._load_cn_image = None
|
||||
sys.modules[f"{PKG}.flake_compose"] = compose
|
||||
|
||||
_load("flake_io")
|
||||
_load("full_flake_node")
|
||||
return nodes, mm, _load("flake_anim_nodes")
|
||||
|
||||
|
||||
nodes, mm, anim = _setup()
|
||||
CALLS = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _output_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(anim.folder_paths, "get_output_directory", lambda: str(tmp_path), raising=False)
|
||||
|
||||
|
||||
class _Out:
|
||||
def __init__(self, *result, ui=None):
|
||||
self.result, self.ui = result, ui
|
||||
|
||||
|
||||
def _v1(name, method):
|
||||
"""A legacy node: instance method returning a 1-tuple of its name."""
|
||||
def run(*args, **kwargs):
|
||||
CALLS.append((name, args, kwargs, mm.EXTRA_RESERVED_VRAM))
|
||||
return (name,)
|
||||
return lambda: types.SimpleNamespace(**{method: run})
|
||||
|
||||
|
||||
def _v3(name, outputs=1):
|
||||
"""A V3 node: ``execute`` returning a NodeOutput-like result."""
|
||||
def execute(*args, **kwargs):
|
||||
CALLS.append((name, args, kwargs, mm.EXTRA_RESERVED_VRAM))
|
||||
return _Out(*([name] if outputs == 1 else [f"{name}{i}" for i in range(outputs)]))
|
||||
return types.SimpleNamespace(execute=execute)
|
||||
|
||||
|
||||
def _save_video():
|
||||
def prepare(v3_data):
|
||||
def execute(*args):
|
||||
CALLS.append(("SaveVideo", args, v3_data["hidden_inputs"], mm.EXTRA_RESERVED_VRAM))
|
||||
return _Out(ui=types.SimpleNamespace(as_dict=lambda: {
|
||||
"images": [{"filename": "clip_00001_.mp4", "subfolder": "video", "type": "output"}]}))
|
||||
return types.SimpleNamespace(execute=execute)
|
||||
return types.SimpleNamespace(PREPARE_CLASS_CLONE=prepare)
|
||||
|
||||
|
||||
nodes.NODE_CLASS_MAPPINGS = {
|
||||
"ModelSamplingSD3": _v1("ModelSamplingSD3", "patch"),
|
||||
"CLIPTextEncode": _v1("CLIPTextEncode", "encode"),
|
||||
"KSamplerAdvanced": _v1("KSamplerAdvanced", "sample"),
|
||||
"KSampler": _v1("KSampler", "sample"),
|
||||
"VAEDecode": _v1("VAEDecode", "decode"),
|
||||
"WanImageToVideo": _v3("WanImageToVideo", 3),
|
||||
"MiniMaxH3SigmaShift": _v3("MiniMaxH3SigmaShift"),
|
||||
"MiniMaxH3ImageToVideo": _v3("MiniMaxH3ImageToVideo", 2),
|
||||
"VAEDecodeAudio": _v3("VAEDecodeAudio"),
|
||||
"UpscaleModelLoader": _v3("UpscaleModelLoader"),
|
||||
"ImageUpscaleWithModel": _v3("ImageUpscaleWithModel"),
|
||||
"FrameInterpolationModelLoader": _v3("FrameInterpolationModelLoader"),
|
||||
"FrameInterpolate": _v3("FrameInterpolate"),
|
||||
"CreateVideo": _v3("CreateVideo"),
|
||||
"SaveVideo": _save_video(),
|
||||
}
|
||||
|
||||
IMAGE = types.SimpleNamespace(shape=(1, 600, 800, 3))
|
||||
|
||||
|
||||
def _data(family, **preset):
|
||||
base = dict(steps=4, cfg=1.0, sampler="euler", scheduler="simple", fps=16, duration=5.0, resolution="720p",
|
||||
shift_high=5.0, shift_low=3.0, refiner_step=2, shift_video=10.0, shift_audio=3.0)
|
||||
models = {"high": "high", "low": "low"} if family == "wan22" else {"model": "model"}
|
||||
return {
|
||||
"family": family, "preset": {**base, **preset}, "models": models,
|
||||
"clip": "clip", "vae": "vae", "audio_vae": "audio_vae" if family == "mh3" else None,
|
||||
"image": IMAGE, "pos_text": "fennec ears twitch", "neg_text": "blurry",
|
||||
"filename_state": {"preset": "", "stems": [f"video/{family}/", "fennec"]},
|
||||
}
|
||||
|
||||
|
||||
def _run(data, **kw):
|
||||
CALLS.clear()
|
||||
return anim.FlakeGenerateAnimation().execute(data, 7, prompt={"1": {}}, extra_pnginfo={"workflow": {}}, **kw)
|
||||
|
||||
|
||||
def _call(name):
|
||||
return [c for c in CALLS if c[0] == name]
|
||||
|
||||
|
||||
def test_video_size_keeps_aspect_at_the_preset_short_edge():
|
||||
assert anim._video_size(IMAGE, "720p", 16) == (960, 720)
|
||||
assert anim._video_size(IMAGE, "720p", 32) == (960, 704)
|
||||
assert anim._video_size(IMAGE, "580p", 32) == (768, 576)
|
||||
assert anim._video_size(types.SimpleNamespace(shape=(1, 1920, 1080, 3)), "480p", 16) == (480, 848)
|
||||
|
||||
|
||||
def test_timing_per_family():
|
||||
assert anim._timing("wan22", {"duration": 5.0, "fps": 16}) == (81, 16)
|
||||
assert anim._timing("wan22", {"duration": 3.0, "fps": 24}) == (73, 24)
|
||||
assert anim._timing("mh3", {"duration": 5.0, "fps": 30}) == (120, 24)
|
||||
|
||||
|
||||
def test_wan22_pipeline(tmp_path):
|
||||
out = _run(_data("wan22"))
|
||||
assert [c[1][1] for c in _call("ModelSamplingSD3")] == [5.0, 3.0]
|
||||
assert [c[1][1] for c in _call("CLIPTextEncode")] == ["fennec ears twitch", "blurry"]
|
||||
(_, args, kwargs, _), = _call("WanImageToVideo")
|
||||
assert args[2:] == ("vae", 960, 720, 81, 1) and kwargs == {"start_image": IMAGE}
|
||||
high, low = _call("KSamplerAdvanced")
|
||||
assert high[1] == ("ModelSamplingSD3", "enable", 7, 4, 1.0, "euler", "simple",
|
||||
"WanImageToVideo0", "WanImageToVideo1", "WanImageToVideo2", 0, 2, "enable")
|
||||
assert low[1][1:3] == ("disable", 7) and low[1][9:] == ("KSamplerAdvanced", 2, 10000, "disable")
|
||||
(_, args, _, _), = _call("CreateVideo")
|
||||
assert args == ("VAEDecode", 16, None)
|
||||
(_, args, hidden, _), = _call("SaveVideo")
|
||||
assert args[0] == "CreateVideo" and args[1].startswith("video/wan22/") and args[1].endswith("_fennec")
|
||||
assert args[2] == "mp4" and hidden == {"PROMPT": None, "EXTRA_PNGINFO": None}
|
||||
with open(tmp_path / "video" / ".flake_metadata" / "clip_00001_.mp4.json", encoding="utf-8") as f:
|
||||
assert json.load(f) == {"prompt": {"1": {}}, "workflow": {}}
|
||||
assert out["ui"] == {"flake_videos": [{"filename": "clip_00001_.mp4", "subfolder": "video", "type": "output"}],
|
||||
"seed": [7]}
|
||||
|
||||
|
||||
def test_disable_metadata_writes_no_sidecar(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(anim.args, "disable_metadata", True)
|
||||
_run(_data("wan22"))
|
||||
assert not (tmp_path / "video" / ".flake_metadata").exists()
|
||||
|
||||
|
||||
def test_mh3_pipeline_decodes_audio():
|
||||
_run(_data("mh3", fps=30))
|
||||
(_, args, _, _), = _call("MiniMaxH3SigmaShift")
|
||||
assert args == ("model", 10.0, 3.0)
|
||||
(_, args, kwargs, _), = _call("MiniMaxH3ImageToVideo")
|
||||
assert args == ("clip", "vae", "fennec ears twitch", 960, 704, 120) and kwargs == {"first_frame": IMAGE}
|
||||
(_, args, kwargs, _), = _call("KSampler")
|
||||
assert args[:3] == ("MiniMaxH3SigmaShift", 7, 4) and args[6:] == ("MiniMaxH3ImageToVideo0", "CLIPTextEncode", "MiniMaxH3ImageToVideo1")
|
||||
assert _call("VAEDecodeAudio")[0][2] == {"vae": "audio_vae", "samples": "KSampler"}
|
||||
assert _call("CreateVideo")[0][1] == ("VAEDecode", 24, "VAEDecodeAudio")
|
||||
|
||||
|
||||
def test_headroom_applies_only_while_sampling():
|
||||
_run(_data("mh3"), use_headroom=True, headroom=2.0, upscale=True, upscale_model="4x.pth")
|
||||
assert _call("KSampler")[0][3] == 100 + 2 * 1024 ** 3
|
||||
assert _call("VAEDecodeAudio")[0][3] == 100 + 2 * 1024 ** 3
|
||||
assert _call("ImageUpscaleWithModel")[0][3] == 100
|
||||
assert mm.EXTRA_RESERVED_VRAM == 100
|
||||
|
||||
|
||||
def test_upscale_and_interpolation_double_the_fps():
|
||||
_run(_data("wan22"), upscale=True, upscale_model="4x.pth", interpolate=True, interpolation_model="rife.pth")
|
||||
assert _call("UpscaleModelLoader")[0][1] == ("4x.pth",)
|
||||
assert _call("ImageUpscaleWithModel")[0][1] == ("UpscaleModelLoader", "VAEDecode")
|
||||
assert _call("FrameInterpolate")[0][1] == ("FrameInterpolationModelLoader", "ImageUpscaleWithModel", 2)
|
||||
assert _call("CreateVideo")[0][1] == ("FrameInterpolate", 32, None)
|
||||
|
||||
|
||||
def test_toggles_off_ignore_their_models_and_headroom():
|
||||
_run(_data("wan22"), upscale_model="4x.pth", interpolation_model="rife.pth", headroom=2.0)
|
||||
assert _call("KSamplerAdvanced")[0][3] == 100
|
||||
assert not _call("UpscaleModelLoader") and not _call("FrameInterpolationModelLoader")
|
||||
assert _call("CreateVideo")[0][1] == ("VAEDecode", 16, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("toggle", ["upscale", "interpolate"])
|
||||
def test_toggle_on_without_a_model_is_a_clear_error(toggle):
|
||||
with pytest.raises(ValueError, match="is on but no"):
|
||||
_run(_data("wan22"), **{toggle: True})
|
||||
assert CALLS == []
|
||||
|
||||
|
||||
def test_missing_image_is_a_clear_error():
|
||||
data = _data("wan22")
|
||||
data["image"] = None
|
||||
with pytest.raises(ValueError, match="Flake Animate Stack/Combo grid item"):
|
||||
_run(data)
|
||||
assert CALLS == []
|
||||
|
||||
|
||||
class _Model:
|
||||
def __init__(self, name, attention=None):
|
||||
self.name, self.attention = name, attention
|
||||
|
||||
def clone(self):
|
||||
return _Model(self.name, self.attention)
|
||||
|
||||
def set_model_optimized_attention(self, func):
|
||||
self.attention = func
|
||||
|
||||
|
||||
def test_attention_options_list_the_registered_backends():
|
||||
options, extra = anim.FlakeGenerateAnimation.INPUT_TYPES()["optional"]["attention"]
|
||||
assert options == ["default", "PyTorch", "Comfy Kitchen INT8", "Sage"] and extra["default"] == "default"
|
||||
|
||||
|
||||
def test_attention_patches_clones_of_both_wan22_models():
|
||||
data = _data("wan22")
|
||||
data["models"] = {"high": _Model("high"), "low": _Model("low")}
|
||||
_run(data, attention="Sage")
|
||||
high, low = [c[1][0] for c in _call("ModelSamplingSD3")]
|
||||
assert (high.name, high.attention) == ("high", "attention_sage")
|
||||
assert (low.name, low.attention) == ("low", "attention_sage")
|
||||
assert all(m.attention is None for m in data["models"].values())
|
||||
|
||||
|
||||
def test_default_attention_keeps_the_models():
|
||||
data = _data("mh3")
|
||||
data["models"] = {"model": _Model("model")}
|
||||
_run(data)
|
||||
assert _call("MiniMaxH3SigmaShift")[0][1][0] is data["models"]["model"]
|
||||
|
||||
|
||||
def test_caption_replaces_the_positive_prompt():
|
||||
out = _run(_data("wan22"), caption="a fennec girl waves at the camera")
|
||||
assert [c[1][1] for c in _call("CLIPTextEncode")] == ["a fennec girl waves at the camera", "blurry"]
|
||||
assert out["ui"]["flake_caption"] == ["a fennec girl waves at the camera"]
|
||||
_run(_data("mh3"), caption="she hums")
|
||||
assert _call("MiniMaxH3ImageToVideo")[0][1][2] == "she hums"
|
||||
assert "flake_caption" not in _run(_data("mh3"))["ui"]
|
||||
|
||||
|
||||
# Caption pre-pass (#483): inline flakes, a toggler switching an upstream option.
|
||||
FOX = {"inline": True, "content": {"prompt": {"positive": "fox girl", "negative": "static"},
|
||||
"prompt_options": [{"name": "tails", "positive": "nine tails", "default": True}]}}
|
||||
CLOSE = {"inline": True, "content": {"prompt": {"positive": "close-up"}, "toggler": "-tails"}}
|
||||
|
||||
|
||||
def _anim_preset(name, caption_model, **kw):
|
||||
base = dict(steps=4, cfg=1.0, sampler="euler", scheduler="simple", clip_type="WAN", fps=16, duration=5.0,
|
||||
resolution="720p", family="wan22", positive=" a fennec girl ", negative="blurry")
|
||||
return anim.flake_io.AnimPreset(name=name, caption_model=caption_model, **{**base, **kw})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caption_env(monkeypatch):
|
||||
presets = {p.name: p for p in (
|
||||
_anim_preset("wan", "qwen3vl_8b.safetensors"),
|
||||
_anim_preset("mh3", "qwen3vl_4b.safetensors", family="mh3", caption_instruction="Be brief."),
|
||||
_anim_preset("wan_too", "qwen3vl_8b.safetensors", positive="tail wag"),
|
||||
_anim_preset("none", None),
|
||||
)}
|
||||
monkeypatch.setattr(anim.flake_io, "load_anim_preset", lambda name: presets[name])
|
||||
monkeypatch.setattr(anim, "_resolve_model_name", lambda category, name: f"{category}/{name}")
|
||||
monkeypatch.setattr(anim, "_load_cn_image", lambda ref: f"loaded:{ref}")
|
||||
loads = []
|
||||
|
||||
def load_clip(name, type):
|
||||
loads.append((name, type))
|
||||
return (f"clip:{name}",)
|
||||
|
||||
def generate(clip, prompt, max_length, sampling_mode, **kw):
|
||||
CALLS.append(("TextGenerate", (clip, prompt, max_length, sampling_mode), kw, None))
|
||||
return _Out(f" {clip} | {prompt} \n", "")
|
||||
|
||||
monkeypatch.setitem(nodes.NODE_CLASS_MAPPINGS, "CLIPLoader", lambda: types.SimpleNamespace(load_clip=load_clip))
|
||||
monkeypatch.setitem(nodes.NODE_CLASS_MAPPINGS, "TextGenerate", types.SimpleNamespace(execute=generate))
|
||||
CALLS.clear()
|
||||
return loads
|
||||
|
||||
|
||||
def _caption(jobs):
|
||||
return anim.FlakeAnimCaption().execute(json.dumps(jobs))["ui"]["flake_captions"]
|
||||
|
||||
|
||||
def test_caption_direction_text_matches_the_chain(caption_env):
|
||||
chain = [json.dumps([FOX]), json.dumps([CLOSE])]
|
||||
data = {"pos_text": "a fennec girl", "neg_text": "blurry", "models": {}, "image": None,
|
||||
"filename_state": {"stems": [], "loras": []}}
|
||||
for flakes_json in chain:
|
||||
data = anim.FlakeAnimStack().execute("Wan 2.2", data, flakes_json)[0]
|
||||
assert data["pos_text"] == "a fennec girl BREAK fox girl BREAK close-up"
|
||||
assert _caption([{"preset": "wan", "flakes": chain, "image": "fennec.png"}]) == [
|
||||
f"clip:text_encoders/qwen3vl_8b.safetensors | {data['pos_text']}"]
|
||||
|
||||
|
||||
def test_caption_loads_each_model_once_and_keeps_job_order(caption_env):
|
||||
captions = _caption([
|
||||
{"preset": "wan", "flakes": [], "image": "a.png"},
|
||||
{"preset": "mh3", "flakes": [json.dumps([FOX])], "image": "b.png"},
|
||||
{"preset": "wan_too", "flakes": [], "image": "c.png"},
|
||||
])
|
||||
assert caption_env == [("text_encoders/qwen3vl_8b.safetensors", "stable_diffusion"),
|
||||
("text_encoders/qwen3vl_4b.safetensors", "stable_diffusion")]
|
||||
assert captions == ["clip:text_encoders/qwen3vl_8b.safetensors | a fennec girl",
|
||||
"clip:text_encoders/qwen3vl_4b.safetensors | a fennec girl BREAK fox girl, nine tails",
|
||||
"clip:text_encoders/qwen3vl_8b.safetensors | tail wag"]
|
||||
calls = _call("TextGenerate")
|
||||
assert [c[2]["image"] for c in calls] == ["loaded:a.png", "loaded:c.png", "loaded:b.png"]
|
||||
assert calls[2][2]["system_prompt"] == "Be brief."
|
||||
assert calls[0][2]["system_prompt"].startswith("You write the video prompt for Wan 2.2")
|
||||
assert all(c[2]["thinking"] is False and c[1][3]["sampling_mode"] == "on" for c in calls)
|
||||
|
||||
|
||||
def test_caption_without_caption_model_is_a_clear_error(caption_env):
|
||||
with pytest.raises(ValueError, match="'none' has no Caption model"):
|
||||
_caption([{"preset": "wan", "flakes": [], "image": "a.png"}, {"preset": "none", "flakes": [], "image": "b.png"}])
|
||||
assert caption_env == []
|
||||
|
||||
|
||||
def test_caption_job_without_image_is_a_clear_error(caption_env):
|
||||
with pytest.raises(ValueError, match="job 1 .* no start image"):
|
||||
_caption([{"preset": "wan", "flakes": []}])
|
||||
|
||||
|
||||
def test_empty_caption_is_a_clear_error(caption_env, monkeypatch):
|
||||
monkeypatch.setitem(nodes.NODE_CLASS_MAPPINGS, "TextGenerate",
|
||||
types.SimpleNamespace(execute=lambda *a, **kw: _Out(" ", "")))
|
||||
with pytest.raises(ValueError, match="empty caption"):
|
||||
_caption([{"preset": "wan", "flakes": [], "image": "a.png"}])
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests for the Flake Animate Preset node's loading (#430): which file each
|
||||
core loader gets, LoRA routing to the high/low models, and per-instance
|
||||
overrides; and for the Flake Animate Stack composing animate flakes on top
|
||||
(#432). The core loaders and the LoRA loader are stubbed to record calls;
|
||||
flake_io and full_flake_node are the real modules, loaded like
|
||||
test_anim_presets.py / test_filename_prefix.py."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PKG = "_fpkg_anim_nodes"
|
||||
|
||||
|
||||
def _load(modname):
|
||||
spec = importlib.util.spec_from_file_location(f"{PKG}.{modname}", os.path.join(ROOT, f"{modname}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"{PKG}.{modname}"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _setup():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.get_folder_paths = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
fp.folder_names_and_paths = {}
|
||||
sys.modules["folder_paths"] = fp
|
||||
for name in ("comfy", "comfy.sd", "comfy.utils", "comfy.model_management", "comfy.cli_args", "comfy_api", "comfy_api.latest"):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
sys.modules["comfy.cli_args"].args = types.SimpleNamespace(disable_metadata=False)
|
||||
sys.modules["comfy_api.latest"].io = types.SimpleNamespace()
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
for attr in ("CLIPTextEncode", "EmptyLatentImage", "ControlNetApplyAdvanced", "ControlNetLoader", "LoraLoader"):
|
||||
setattr(nodes, attr, object)
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules[PKG] = pkg
|
||||
compose = types.ModuleType(f"{PKG}.flake_compose")
|
||||
# Models are tuples of the file they were loaded from plus applied LoRAs.
|
||||
compose._load_lora = lambda model, clip, path, sm, sc: (model + ((path, sm),), clip)
|
||||
compose._load_cn_image = lambda path: f"loaded:{path}"
|
||||
sys.modules[f"{PKG}.flake_compose"] = compose
|
||||
|
||||
fio = _load("flake_io")
|
||||
_load("full_flake_node")
|
||||
return nodes, fio, _load("flake_anim_nodes")
|
||||
|
||||
|
||||
nodes, fio, anim = _setup()
|
||||
|
||||
|
||||
class _Loader:
|
||||
def __init__(self, method):
|
||||
setattr(self, method, lambda *args: ((method,) + args,))
|
||||
|
||||
|
||||
nodes.NODE_CLASS_MAPPINGS = {
|
||||
"UNETLoader": lambda: _Loader("load_unet"),
|
||||
"CLIPLoader": lambda: _Loader("load_clip"),
|
||||
"VAELoader": lambda: _Loader("load_vae"),
|
||||
}
|
||||
|
||||
|
||||
def _preset(**kw):
|
||||
base = dict(steps=4, cfg=1.0, sampler="euler", scheduler="simple", fps=16, duration=5.0, resolution="720p")
|
||||
return fio.AnimPreset(**{**base, **kw})
|
||||
|
||||
|
||||
def _data(preset, overrides=""):
|
||||
fio.load_anim_preset = lambda name: preset
|
||||
return anim.FlakeAnimPreset().execute(preset.family, preset.name, overrides)[0]
|
||||
|
||||
|
||||
def test_wan22_loads_high_low_and_routes_loras():
|
||||
d = _data(_preset(
|
||||
name="video/wan22/fennec", family="wan22", clip_type="WAN", filename_prefix="fennec/",
|
||||
diffusion_model_high="high.safetensors", diffusion_model_low="low.safetensors",
|
||||
text_encoder="umt5.safetensors", vae="wan_vae.safetensors", positive=" tail wag ",
|
||||
loras=[
|
||||
fio.LoraEntry(name="speed", path="hi_lora", path_low="lo_lora", strength=0.8),
|
||||
fio.LoraEntry(name="style", path="style_lora", strength=0.5),
|
||||
],
|
||||
))
|
||||
assert d["models"] == {
|
||||
"high": ("load_unet", "high.safetensors", "default", ("hi_lora", 0.8), ("style_lora", 0.5)),
|
||||
"low": ("load_unet", "low.safetensors", "default", ("lo_lora", 0.8)),
|
||||
}
|
||||
assert d["clip"] == ("load_clip", "umt5.safetensors", "wan")
|
||||
assert d["vae"] == ("load_vae", "wan_vae.safetensors")
|
||||
assert d["audio_vae"] is None and d["image"] is None
|
||||
assert (d["family"], d["pos_text"], d["neg_text"]) == ("wan22", "tail wag", "")
|
||||
assert d["preset"]["refiner_step"] is None and d["preset"]["steps"] == 4
|
||||
fs = d["filename_state"]
|
||||
assert fs["stems"] == ["video/wan22/", "fennec/"]
|
||||
assert (fs["checkpoint"], fs["model_family"]) == ("high.safetensors", "Wan 2.2")
|
||||
assert [lr["name"] for lr in fs["loras"]] == ["speed", "style"]
|
||||
|
||||
|
||||
def test_mh3_loads_audio_vae_and_single_model():
|
||||
d = _data(_preset(
|
||||
name="video/mh3/clip", family="mh3", clip_type="MINIMAX", diffusion_model="mh3.safetensors",
|
||||
text_encoder="qwen3vl.safetensors", vae="video_vae.safetensors", audio_vae="audio_vae.safetensors",
|
||||
loras=[fio.LoraEntry(path="lora", path_low="ignored", strength=1.0)],
|
||||
))
|
||||
assert d["models"] == {"model": ("load_unet", "mh3.safetensors", "default", ("lora", 1.0))}
|
||||
assert d["clip"] == ("load_clip", "qwen3vl.safetensors", "minimax")
|
||||
assert d["audio_vae"] == ("load_vae", "audio_vae.safetensors")
|
||||
assert d["filename_state"]["stems"] == ["video/mh3/"]
|
||||
|
||||
|
||||
def test_missing_model_field_is_a_clear_error():
|
||||
try:
|
||||
_data(_preset(name="video/mh3/x", family="mh3", clip_type="MINIMAX", text_encoder="t", vae="v", audio_vae="a"))
|
||||
except ValueError as exc:
|
||||
assert "diffusion_model" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
def test_overrides_apply_to_preset_and_stems():
|
||||
p = _preset(name="video/mh3/x", family="mh3", clip_type="MINIMAX", diffusion_model="m",
|
||||
text_encoder="t", vae="v", audio_vae="a")
|
||||
d = _data(p, json.dumps({"steps": 8, "cfg": "", "scheduler": "beta", "filename_prefix": "take2"}))
|
||||
assert (d["preset"]["steps"], d["preset"]["cfg"], d["preset"]["scheduler"]) == (8, 1.0, "beta")
|
||||
assert d["filename_state"]["stems"] == ["video/mh3/", "take2"]
|
||||
assert _data(p)["preset"]["steps"] == 4
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _flakes(files):
|
||||
"""Temp flakes root holding ``files`` ({name: yaml data})."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
fio.folder_paths.folder_names_and_paths["flakes"] = ([tmp], {".yaml", ".yml"})
|
||||
for name, data in files.items():
|
||||
path = os.path.join(tmp, f"{name}.yaml")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f)
|
||||
yield
|
||||
|
||||
|
||||
def _anim_data(models, stems):
|
||||
return {
|
||||
"family": "wan22", "preset": {}, "models": models, "clip": "clip", "vae": "vae", "audio_vae": None,
|
||||
"image": None, "pos_text": "a fennec girl", "neg_text": "blurry",
|
||||
"filename_state": {"preset": "", "stems": stems, "loras": [{"name": "speed"}], "model_family": "Wan 2.2"},
|
||||
}
|
||||
|
||||
|
||||
def _stack(data, entries):
|
||||
return anim.FlakeAnimStack().execute("Wan 2.2", data, json.dumps(entries))[0]
|
||||
|
||||
|
||||
def test_stack_routes_flake_loras_and_joins_prompts():
|
||||
files = {
|
||||
"video/wan22/tail": {
|
||||
"prompt": {"positive": "swishing her fluffy tail", "negative": "static"},
|
||||
"output_stem": "tail",
|
||||
"loras": [{"name": "swish", "path": "swish_hi", "path_low": "swish_lo", "strength": 1.0}],
|
||||
"variants": {"mood": {"happy": {"positive": "smiling", "output_stem": "happy",
|
||||
"loras": [{"path": "smile_hi", "strength": 0.6}]}}},
|
||||
},
|
||||
"video/wan22/off": {"prompt": {"positive": "never"}, "loras": [{"path": "never"}]},
|
||||
}
|
||||
data = _anim_data({"high": ("H",), "low": ("L",)}, ["video/wan22/"])
|
||||
with _flakes(files):
|
||||
out = _stack(data, [
|
||||
{"inline": True, "content": {"prompt": {"positive": "blue eyes"}}},
|
||||
{"name": "video/wan22/tail", "loras": [0.5], "variant": {"mood": "happy"}},
|
||||
{"name": "video/wan22/off", "bypassed": True},
|
||||
])
|
||||
assert out["models"] == {"high": ("H", ("swish_hi", 0.5), ("smile_hi", 0.6)), "low": ("L", ("swish_lo", 0.5))}
|
||||
assert out["pos_text"] == "a fennec girl BREAK blue eyes BREAK swishing her fluffy tail, smiling"
|
||||
assert out["neg_text"] == "blurry, static"
|
||||
fs = out["filename_state"]
|
||||
assert fs["stems"] == ["video/wan22/", "tail_happy"]
|
||||
assert [lr["name"] for lr in fs["loras"]] == ["speed", "swish", "smile_hi"]
|
||||
assert (out["clip"], out["image"], fs["model_family"]) == ("clip", None, "Wan 2.2")
|
||||
# The incoming data is left as it was.
|
||||
assert data["models"] == {"high": ("H",), "low": ("L",)}
|
||||
assert (data["filename_state"]["stems"], len(data["filename_state"]["loras"])) == (["video/wan22/"], 1)
|
||||
|
||||
|
||||
def test_stack_single_model_follows_flake_links():
|
||||
files = {
|
||||
"video/mh3/host": {"loras": [{"path": "host", "path_low": "ignored", "strength": 0.8}],
|
||||
"flake_links": [{"target": "video/mh3/style"}]},
|
||||
"video/mh3/style": {"prompt": {"positive": "watercolor"}, "loras": [{"path": "style", "strength": 0.4}]},
|
||||
}
|
||||
data = {**_anim_data({"model": ("M",)}, []), "image": "upstream"}
|
||||
with _flakes(files):
|
||||
out = _stack(data, [{"name": "video/mh3/host"}])
|
||||
kept = _stack(data, [])
|
||||
assert out["models"] == {"model": ("M", ("host", 0.8), ("style", 0.4))}
|
||||
assert out["pos_text"] == "a fennec girl BREAK watercolor"
|
||||
assert kept["models"] == data["models"] and kept["pos_text"] == "a fennec girl"
|
||||
|
||||
|
||||
def test_stack_loads_the_job_image_of_active_items():
|
||||
"""The queue sets each job's `image` on a grid item (#441): the last active
|
||||
one is loaded and replaces the upstream image; the `images` lists and
|
||||
bypassed items are ignored."""
|
||||
data = {**_anim_data({"model": ("M",)}, []), "image": "upstream"}
|
||||
with _flakes({"video/mh3/a": {}, "video/mh3/b": {}}):
|
||||
picked = _stack(data, [
|
||||
{"inline": True, "content": {}, "image": "pose/first.png"},
|
||||
{"name": "video/mh3/a", "images": ["x.png", "y.png"], "image": "C:/shots/fennec.png"},
|
||||
{"name": "video/mh3/b", "bypassed": True, "image": "skipped.png"},
|
||||
])
|
||||
listed = _stack(data, [{"name": "video/mh3/a", "images": ["x.png"]}])
|
||||
assert picked["image"] == "loaded:C:/shots/fennec.png"
|
||||
assert listed["image"] == "upstream"
|
||||
|
||||
|
||||
def test_chained_stacks_toggle_upstream_options():
|
||||
files = {
|
||||
"video/wan22/fox": {"prompt": {"positive": "fox girl"},
|
||||
"prompt_options": [{"name": "tails", "positive": "nine tails", "default": True}]},
|
||||
"video/wan22/close": {"prompt": {"positive": "close-up"}, "toggler": "-tails"},
|
||||
}
|
||||
with _flakes(files):
|
||||
first = _stack(_anim_data({"high": ("H",)}, []), [{"name": "video/wan22/fox"}])
|
||||
second = _stack(first, [{"name": "video/wan22/close"}])
|
||||
assert first["pos_text"] == "a fennec girl BREAK fox girl, nine tails"
|
||||
assert second["pos_text"] == "a fennec girl BREAK fox girl BREAK close-up"
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Tests for the animate families, preset storage (#429), flake storage
|
||||
(#431) and importing image flakes (#433). Same loading
|
||||
approach as test_portable_flakes.py, with the stub `folder_paths` pointing the
|
||||
flakes and model_presets roots at temp dirs."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.get_folder_paths = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
fp.folder_names_and_paths = {}
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_anim")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_anim"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_anim.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_anim.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod, fp
|
||||
|
||||
|
||||
fio, fp = _load_flake_io()
|
||||
families = fio.flake_families
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _roots():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
presets = os.path.join(tmp, "model_presets")
|
||||
fp.folder_names_and_paths["flakes"] = ([tmp], {".yaml", ".yml"})
|
||||
fp.folder_names_and_paths["model_presets"] = ([presets], {".yaml", ".yml"})
|
||||
yield tmp, presets
|
||||
|
||||
|
||||
def _write(path, data=b""):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def _read(path):
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _raises_value_error(fn):
|
||||
try:
|
||||
fn()
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_anim_families_stay_out_of_image_families():
|
||||
anim = {f.label for f in families.ANIM_FAMILIES}
|
||||
assert anim == {"Wan 2.2", "MiniMax H3"}
|
||||
assert not anim & {f.label for f in families.FAMILIES}
|
||||
assert not {f.folder for f in families.ANIM_FAMILIES} & set(families.BY_FOLDER)
|
||||
|
||||
|
||||
def test_wan22_preset_round_trip():
|
||||
with _roots() as (_, presets):
|
||||
name = fio.save_preset("", {
|
||||
"display_name": "Wan fennec",
|
||||
"diffusion_model_high": "wan/high.safetensors",
|
||||
"diffusion_model_low": "wan/low.safetensors",
|
||||
"text_encoder": "umt5_xxl_fp8.safetensors",
|
||||
"vae": "wan_2.1_vae.safetensors",
|
||||
"shift_high": 8,
|
||||
"resolution": "720p",
|
||||
"prompt": {"positive": "a fennec girl waves her fluffy tail", "negative": "blurry"},
|
||||
"loras": [{"name": "lightx2v", "path": "wan/lightx2v.safetensors", "strength": 0.8}],
|
||||
}, family="Wan 2.2", output_path="chars/fennec", kind="anim")
|
||||
assert name == "video/wan22/chars/fennec"
|
||||
assert os.path.isfile(os.path.join(presets, "video", "wan22", "chars", "fennec.yaml"))
|
||||
|
||||
p = fio.load_anim_preset(name)
|
||||
assert p.family == "wan22"
|
||||
assert p.display_name == "Wan fennec"
|
||||
assert (p.diffusion_model_high, p.diffusion_model_low) == ("wan/high.safetensors", "wan/low.safetensors")
|
||||
assert p.diffusion_model is None
|
||||
assert p.text_encoder == "umt5_xxl_fp8.safetensors"
|
||||
assert p.vae == "wan_2.1_vae.safetensors"
|
||||
assert p.positive == "a fennec girl waves her fluffy tail"
|
||||
assert p.negative == "blurry"
|
||||
assert [(lr.path, lr.strength) for lr in p.loras] == [("wan/lightx2v.safetensors", 0.8)]
|
||||
# Saved values win, the rest come from the family.
|
||||
assert (p.shift_high, p.resolution) == (8.0, "720p")
|
||||
assert (p.shift_low, p.refiner_step) == (5.0, 2)
|
||||
assert (p.steps, p.cfg, p.sampler, p.scheduler) == (4, 1.0, "euler", "linear_quadratic")
|
||||
assert (p.fps, p.duration, p.clip_type) == (16, 5.0, "WAN")
|
||||
assert (p.shift_video, p.shift_audio, p.audio_vae) == (None, None, None)
|
||||
|
||||
|
||||
def test_mh3_preset_round_trip():
|
||||
with _roots():
|
||||
name = fio.save_preset("clip", {
|
||||
"diffusion_model": "mh3.safetensors",
|
||||
"vae": "mh3_video_vae.safetensors",
|
||||
"audio_vae": "mh3_audio_vae.safetensors",
|
||||
"steps": 6,
|
||||
}, family="MiniMax H3", kind="anim")
|
||||
assert name == "video/mh3/clip"
|
||||
|
||||
p = fio.load_anim_preset(name)
|
||||
assert p.family == "mh3"
|
||||
assert (p.diffusion_model, p.audio_vae) == ("mh3.safetensors", "mh3_audio_vae.safetensors")
|
||||
assert p.steps == 6
|
||||
assert (p.shift_video, p.shift_audio) == (10.0, 3.0)
|
||||
assert (p.fps, p.scheduler, p.clip_type, p.resolution) == (24, "simple", "MINIMAX", "720p")
|
||||
assert (p.shift_high, p.shift_low, p.refiner_step) == (None, None, None)
|
||||
|
||||
|
||||
def test_anim_preset_caption_fields_round_trip():
|
||||
with _roots():
|
||||
name = fio.save_preset("cap", {
|
||||
"caption_model": "qwen3vl_8b.safetensors",
|
||||
"caption_instruction": "Describe the fennec girl, then her action.",
|
||||
}, family="Wan 2.2", kind="anim")
|
||||
p = fio.load_anim_preset(name)
|
||||
assert p.caption_model == "qwen3vl_8b.safetensors"
|
||||
assert p.caption_instruction == "Describe the fennec girl, then her action."
|
||||
assert fio.read_preset_raw(name) == {
|
||||
"caption_model": "qwen3vl_8b.safetensors",
|
||||
"caption_instruction": "Describe the fennec girl, then her action.",
|
||||
}
|
||||
|
||||
plain = fio.load_anim_preset(fio.save_preset("plain", {"steps": 6}, family="MiniMax H3", kind="anim"))
|
||||
assert (plain.caption_model, plain.caption_instruction) == (None, "")
|
||||
|
||||
|
||||
def test_anim_preset_save_rejects_unknown_family():
|
||||
with _roots():
|
||||
assert _raises_value_error(lambda: fio.save_preset("x", {}, family="SDXL/Base", kind="anim"))
|
||||
assert _raises_value_error(lambda: fio.save_preset("x", {}, kind="anim"))
|
||||
|
||||
|
||||
def test_load_anim_preset_rejects_image_preset():
|
||||
with _roots():
|
||||
name = fio.save_preset("base", {"checkpoint": "a.safetensors"}, family="SDXL/Base")
|
||||
assert _raises_value_error(lambda: fio.load_anim_preset(name))
|
||||
|
||||
|
||||
def test_preset_lists_split_by_kind():
|
||||
with _roots():
|
||||
img = fio.save_preset("base", {"display_name": "Base"}, family="SDXL/Base")
|
||||
wan = fio.save_preset("w", {"display_name": "W"}, family="Wan 2.2", kind="anim")
|
||||
mh3 = fio.save_preset("m", {}, family="MiniMax H3", kind="anim")
|
||||
|
||||
assert fio.list_presets() == [img]
|
||||
assert fio.list_presets(family="SDXL/Base") == [img]
|
||||
assert fio.list_presets(kind="anim") == [mh3, wan]
|
||||
assert fio.list_presets(family="Wan 2.2", kind="anim") == [wan]
|
||||
assert fio.list_preset_display_names(kind="anim") == {wan: "W"}
|
||||
assert fio.list_preset_display_names() == {img: "Base"}
|
||||
|
||||
|
||||
def test_anim_preset_cover_and_delete():
|
||||
with _roots() as (_, presets):
|
||||
name = fio.save_preset("w", {}, family="Wan 2.2", kind="anim")
|
||||
fio.save_preset_cover(name, ".png", b"png")
|
||||
assert _read(fio.preset_cover_file(name)) == b"png"
|
||||
fio.delete_preset(name)
|
||||
assert fio.list_presets(kind="anim") == []
|
||||
assert not os.path.exists(os.path.join(presets, "video", "wan22", "w.png"))
|
||||
|
||||
|
||||
def test_image_flake_lists_exclude_anim():
|
||||
with _roots() as (root, _):
|
||||
_write(os.path.join(root, "img", "sdxl", "hero.yaml"), b"loras: []")
|
||||
_write(os.path.join(root, "video", "wan22", "wave.yaml"), b"loras: []")
|
||||
_write(os.path.join(root, "model_presets", "img", "sdxl", "base.yaml"), b"{}")
|
||||
assert fio.list_flakes() == ["img/sdxl/hero"]
|
||||
assert fio.list_dirs() == ["img", "img/sdxl"]
|
||||
|
||||
|
||||
def test_anim_flake_round_trip_with_low_lora():
|
||||
with _roots() as (root, _):
|
||||
name = fio.save_flake("chars/fennec", {
|
||||
"name": "Fennec",
|
||||
"prompt": {"positive": "a fennec girl swishes her fluffy tail"},
|
||||
"loras": [{"name": "swish", "path": "wan/swish_high.safetensors",
|
||||
"name_low": "swish low", "path_low": "wan/swish_low.safetensors", "strength": 0.7}],
|
||||
}, family="Wan 2.2", kind="anim")
|
||||
assert name == "video/wan22/chars/fennec"
|
||||
assert os.path.isfile(os.path.join(root, "video", "wan22", "chars", "fennec.yaml"))
|
||||
assert fio.read_flake_raw(name)["loras"][0]["path_low"] == "wan/swish_low.safetensors"
|
||||
|
||||
flake = fio.resolve({"name": name})
|
||||
assert flake.positive == "a fennec girl swishes her fluffy tail"
|
||||
lora = flake.loras[0]
|
||||
assert (lora.path, lora.path_low, lora.name_low, lora.strength) == (
|
||||
"wan/swish_high.safetensors", "wan/swish_low.safetensors", "swish low", 0.7)
|
||||
# Image flakes keep an empty LOW file.
|
||||
img = fio.save_flake("hero", {"loras": [{"path": "hero.safetensors"}]}, family="SDXL/Base")
|
||||
assert (fio.load_flake(img).loras[0].path_low, fio.load_flake(img).loras[0].name_low) == ("", "")
|
||||
|
||||
|
||||
def test_anim_flake_auto_caption_round_trip():
|
||||
with _roots():
|
||||
name = fio.save_flake("dance", {"prompt": {"positive": "she dances"}, "auto_caption": True},
|
||||
family="Wan 2.2", kind="anim")
|
||||
assert fio.read_flake_raw(name)["auto_caption"] is True
|
||||
assert fio.load_flake(name).auto_caption is True
|
||||
assert fio.resolve({"name": name}).auto_caption is True
|
||||
|
||||
plain = fio.save_flake("wave", {"prompt": {"positive": "she waves"}}, family="Wan 2.2", kind="anim")
|
||||
assert "auto_caption" not in fio.read_flake_raw(plain)
|
||||
assert fio.load_flake(plain).auto_caption is False
|
||||
|
||||
|
||||
def test_anim_flake_lists_cover_and_delete():
|
||||
with _roots() as (root, _):
|
||||
img = fio.save_flake("hero", {"favorite": True, "serie": "Heroes"}, family="SDXL/Base")
|
||||
wan = fio.save_flake("chars/wave", {"name": "Wave", "favorite": True, "serie": "Fennec"}, family="Wan 2.2", kind="anim")
|
||||
mh3 = fio.save_flake("clip", {}, family="MiniMax H3", kind="anim")
|
||||
|
||||
assert fio.list_flakes() == [img]
|
||||
assert fio.list_dirs() == ["img", "img/sdxl"]
|
||||
assert fio.list_flake_info()["favorites"] == [img]
|
||||
assert fio.list_series() == ["Heroes"]
|
||||
assert fio.list_flakes(kind="anim") == [mh3, wan]
|
||||
assert fio.list_flakes(family="Wan 2.2", kind="anim") == [wan]
|
||||
assert fio.list_dirs(kind="anim") == ["video", "video/mh3", "video/wan22", "video/wan22/chars"]
|
||||
assert fio.list_dirs(family="MiniMax H3", kind="anim") == ["video/mh3"]
|
||||
assert fio.list_flake_info(kind="anim")["display_names"] == {wan: "Wave"}
|
||||
assert fio.list_flake_info(kind="anim")["favorites"] == [wan]
|
||||
assert fio.list_series(kind="anim") == ["Fennec"]
|
||||
assert _raises_value_error(lambda: fio.list_flakes(family="SDXL/Base", kind="anim"))
|
||||
|
||||
_write(os.path.join(root, "video", "wan22", "chars", "wave", "wave.png"), b"png")
|
||||
assert _read(fio.cover_file(wan)) == b"png"
|
||||
fio.delete_flake(wan)
|
||||
assert fio.list_flakes(kind="anim") == [mh3]
|
||||
|
||||
|
||||
def test_lora_names_cover_both_kinds_and_variants():
|
||||
with _roots() as (root, _):
|
||||
_write(os.path.join(root, "img", "sdxl", "hero", "hero.yaml"), b"""
|
||||
loras:
|
||||
- {name: " Fennec Ears ", path: a.safetensors}
|
||||
- {name: "", path: b.safetensors}
|
||||
- just a string
|
||||
variants:
|
||||
outfit:
|
||||
maid: {loras: [{name: Maid Outfit}]}
|
||||
plain: {}
|
||||
""")
|
||||
_write(os.path.join(root, "img", "sdxl", "odd", "odd.yaml"), b"loras: {name: Not A List}\n")
|
||||
_write(os.path.join(root, "video", "wan22", "wave", "wave.yaml"),
|
||||
b"loras:\n - {name: Tail Swish, name_low: Tail Swish low}\n - {name: Fennec Ears}\n")
|
||||
assert fio.list_lora_names() == ["Fennec Ears", "Maid Outfit", "Tail Swish", "Tail Swish low"]
|
||||
|
||||
|
||||
def test_list_option_names_spans_kinds_and_variant_choices():
|
||||
with _roots() as (root, _):
|
||||
_write(os.path.join(root, "img", "sdxl", "ahri", "ahri.yaml"), b"""
|
||||
prompt_options: [{name: " face ", positive: golden eyes}, {name: ""}, just a string]
|
||||
variants:
|
||||
skin:
|
||||
classic: {prompt_options: [{name: tails}]}
|
||||
plain: {}
|
||||
""")
|
||||
_write(os.path.join(root, "video", "wan22", "wave", "wave.yaml"), b"prompt_options: [{name: tails}, {name: ears}]\n")
|
||||
assert fio.list_option_names() == ["ears", "face", "tails"]
|
||||
|
||||
|
||||
HERO = b"""
|
||||
name: Fennec Hero
|
||||
flake_type: character
|
||||
serie: Fennec
|
||||
output_stem: hero/
|
||||
resolution: [832, 1216]
|
||||
prompt: {positive: a fennec girl with a big fluffy tail, negative: blurry}
|
||||
loras:
|
||||
- {name: hero, path: ./hero/hero_hero.safetensors, strength: 0.8}
|
||||
controlnets:
|
||||
- {type: openpose, model: op.safetensors, image: ./hero/cnets/pose.png}
|
||||
flake_links:
|
||||
- {target: img/sdxl/style}
|
||||
variants:
|
||||
outfit:
|
||||
armor:
|
||||
positive: shiny armor
|
||||
negative: rust
|
||||
output_stem: armor
|
||||
default: true
|
||||
image: ./hero/hero_outfit_armor.png
|
||||
loras: [{name: armor, path: ./hero/armor.safetensors}]
|
||||
controlnets: [{type: depth, model: d.safetensors, image: ./hero/cnets/depth.png}]
|
||||
flake_links: [{target: img/sdxl/style}]
|
||||
dress: {positive: blue dress}
|
||||
"""
|
||||
|
||||
|
||||
def test_import_anim_flake_copies_prompts_and_files():
|
||||
with _roots() as (root, _):
|
||||
src_dir = os.path.join(root, "img", "sdxl", "chars")
|
||||
_write(os.path.join(src_dir, "hero.yaml"), HERO)
|
||||
_write(os.path.join(src_dir, "hero", "hero.png"), b"cover")
|
||||
_write(os.path.join(src_dir, "hero", "hero_outfit_armor.png"), b"armor")
|
||||
before = sorted(os.path.join(d, f) for d, _, fs in os.walk(src_dir) for f in fs)
|
||||
|
||||
name = fio.import_anim_flake("img/sdxl/chars/hero", "Wan 2.2")
|
||||
assert name == "video/wan22/chars/hero"
|
||||
assert fio.read_flake_raw(name) == {
|
||||
"name": "Fennec Hero",
|
||||
"prompt": {"positive": "a fennec girl with a big fluffy tail", "negative": "blurry"},
|
||||
"output_stem": "hero/",
|
||||
"flake_type": "character",
|
||||
"serie": "Fennec",
|
||||
"variants": {"outfit": {
|
||||
"armor": {"positive": "shiny armor", "negative": "rust", "output_stem": "armor", "default": True,
|
||||
"image": "./hero/hero_outfit_armor.png"},
|
||||
"dress": {"positive": "blue dress"},
|
||||
}},
|
||||
}
|
||||
assert _read(fio.cover_file(name)) == b"cover"
|
||||
assert _read(fio.variant_image_file(name, "outfit", "armor")) == b"armor"
|
||||
assert fio.list_flakes(kind="anim") == [name]
|
||||
# The source flake and its files are untouched.
|
||||
assert sorted(os.path.join(d, f) for d, _, fs in os.walk(src_dir) for f in fs) == before
|
||||
with open(os.path.join(src_dir, "hero.yaml"), "rb") as f:
|
||||
assert f.read() == HERO
|
||||
|
||||
|
||||
def test_import_anim_flake_collision_naming():
|
||||
with _roots() as (root, _):
|
||||
_write(os.path.join(root, "img", "sdxl", "hero.yaml"), b"name: Hero")
|
||||
_write(os.path.join(root, "img", "pony", "hero.yaml"), b"name: Pony Hero")
|
||||
assert fio.import_anim_flake("img/sdxl/hero", "Wan 2.2") == "video/wan22/hero"
|
||||
assert fio.import_anim_flake("img/pony/hero", "Wan 2.2") == "video/wan22/hero (2)"
|
||||
# A deleted flake's kept owned folder still takes the name.
|
||||
_write(os.path.join(root, "video", "wan22", "hero (3)", "hero (3).png"), b"old")
|
||||
assert fio.import_anim_flake("img/sdxl/hero", "Wan 2.2") == "video/wan22/hero (4)"
|
||||
assert fio.import_anim_flake("img/sdxl/hero", "MiniMax H3") == "video/mh3/hero"
|
||||
assert fio.read_flake_raw("video/wan22/hero (2)") == {"name": "Pony Hero"}
|
||||
assert _raises_value_error(lambda: fio.import_anim_flake("img/sdxl/hero", "SDXL/Base"))
|
||||
|
||||
|
||||
def test_new_image_family_saves_lists_and_infers_controlnets():
|
||||
# A family nothing special-cases gets its own img/<folder>/ (#470).
|
||||
with _roots() as (root, presets):
|
||||
hero = fio.save_flake("hero", {}, family="SDXL/Illustrious")
|
||||
moss = fio.save_flake("chars/fern", {"controlnets": [{"type": "depth"}]}, family="Moss")
|
||||
shared = fio.save_flake("style", {}, family="Common")
|
||||
preset = fio.save_preset("base", {}, family="moss")
|
||||
assert (hero, moss, shared) == ("img/illustrious/hero", "img/moss/chars/fern", "img/common/style")
|
||||
assert preset == "img/moss/base"
|
||||
assert fio.list_flakes(family="moss") == [shared, moss]
|
||||
assert fio.list_flakes(family="Moss") == [shared, moss]
|
||||
assert fio.list_flakes(family="anima") == [shared]
|
||||
assert hero in fio.list_flakes(family="SDXL/Illustrious") and moss not in fio.list_flakes(family="SDXL/Illustrious")
|
||||
assert fio.list_presets(family="Moss") == [preset]
|
||||
assert fio.load_flake(moss).controlnets[0].model_name == "controlnet_depth_moss"
|
||||
assert fio.list_family_folders() == ["common", "illustrious", "moss"]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for the file picker's `models` root and the diffusion model sibling
|
||||
image (#440): models/unet and models/diffusion_models are listed and
|
||||
browsable, junctioned folders included. flake_server is loaded with aiohttp,
|
||||
PromptServer and flake_compose stubbed."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PKG = "_fpkg_browse"
|
||||
|
||||
|
||||
def _load(modname):
|
||||
spec = importlib.util.spec_from_file_location(f"{PKG}.{modname}", os.path.join(ROOT, f"{modname}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"{PKG}.{modname}"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _setup():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
web = types.SimpleNamespace(
|
||||
Request=object, FileResponse=lambda path: types.SimpleNamespace(status=200, path=path), Response=lambda body, content_type: types.SimpleNamespace(status=200, body=body, content_type=content_type),
|
||||
json_response=lambda data, status=200: types.SimpleNamespace(status=status, data=data))
|
||||
sys.modules["aiohttp"] = types.SimpleNamespace(web=web)
|
||||
sys.modules["aiohttp.web"] = web
|
||||
routes = types.SimpleNamespace(**{m: lambda path: lambda f: f for m in ("get", "post", "put", "delete")})
|
||||
sys.modules["server"] = types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=routes)))
|
||||
sys.modules["latent_preview"] = types.ModuleType("latent_preview")
|
||||
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules[PKG] = pkg
|
||||
compose = types.ModuleType(f"{PKG}.flake_compose")
|
||||
compose.anima_cn_files = None
|
||||
sys.modules[f"{PKG}.flake_compose"] = compose
|
||||
_load("flake_io")
|
||||
return fp, _load("flake_server")
|
||||
|
||||
|
||||
fp, srv = _setup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def models(tmp_path):
|
||||
models = tmp_path / "models"
|
||||
for folder, files in {"unet": ["wan_high.safetensors", "wan_high.png"], "diffusion_models": ["h3.png"], "checkpoints": []}.items():
|
||||
(models / folder).mkdir(parents=True)
|
||||
for f in files:
|
||||
(models / folder / f).write_bytes(b"x")
|
||||
fp.models_dir = str(models)
|
||||
fp.folder_names_and_paths = {
|
||||
"checkpoints": ([str(models / "checkpoints")], set()),
|
||||
"diffusion_models": ([str(models / "unet"), str(models / "diffusion_models")], set()),
|
||||
}
|
||||
fp.get_folder_paths = lambda name: fp.folder_names_and_paths[name][0]
|
||||
fp.get_full_path = lambda name, f: next((p for d in fp.get_folder_paths(name) if os.path.isfile(p := os.path.join(d, f))), None)
|
||||
return models
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, body=None, query=None):
|
||||
self.body, self.query = body, query or {}
|
||||
|
||||
async def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
def _browse(path=""):
|
||||
return asyncio.run(srv._browse(_Request({"type": "models", "path": path})))
|
||||
|
||||
|
||||
def _names(resp):
|
||||
return {e["name"] for e in resp.data["entries"]}
|
||||
|
||||
|
||||
def test_models_root_lists_unet_and_diffusion_models(models):
|
||||
assert {"unet", "diffusion_models", "checkpoints"} <= _names(_browse())
|
||||
|
||||
|
||||
def test_unet_lists_its_images(models):
|
||||
resp = _browse("unet")
|
||||
assert _names(resp) == {"wan_high.png"}
|
||||
assert resp.data["entries"][0]["path"] == str(models / "unet" / "wan_high.png")
|
||||
|
||||
|
||||
def test_diffusion_models_merges_unet_images(models):
|
||||
assert _names(_browse("diffusion_models")) == {"wan_high.png", "h3.png"}
|
||||
|
||||
|
||||
def test_junctioned_unet_is_browsable(models, tmp_path):
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
os.replace(models / "unet", elsewhere)
|
||||
try:
|
||||
os.symlink(elsewhere, models / "unet", target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("symlinks not permitted")
|
||||
assert _names(_browse("unet")) == {"wan_high.png"}
|
||||
|
||||
|
||||
def test_parent_of_models_is_refused(models):
|
||||
assert _browse("..").status == 404
|
||||
|
||||
|
||||
def test_diffusion_model_sibling_image(models):
|
||||
resp = asyncio.run(srv._get_diffusion_model_sibling_image(_Request(query={"path": "wan_high.safetensors"})))
|
||||
assert (resp.status, resp.content_type) == (200, "image/png")
|
||||
|
||||
|
||||
def test_browse_hides_dot_directories(models, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
(out / ".flake_metadata").mkdir(parents=True)
|
||||
(out / "sub").mkdir()
|
||||
(out / "a.png").write_bytes(b"x")
|
||||
fp.get_output_directory = lambda: str(out)
|
||||
resp = asyncio.run(srv._browse(_Request({"type": "output"})))
|
||||
assert _names(resp) == {"sub", "a.png"}
|
||||
|
||||
|
||||
def _output(tmp_path, files):
|
||||
"""Output dir holding {relative path: (bytes, mtime, has sidecar)}."""
|
||||
out = tmp_path / "output"
|
||||
for rel, (data, mtime, sidecar) in files.items():
|
||||
path = out / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
os.utime(path, (mtime, mtime))
|
||||
if sidecar:
|
||||
srv.flake_io.write_metadata_sidecar(str(path), {"rel": rel}, {"workflow": {"rel": rel}})
|
||||
fp.get_output_directory = lambda: str(out)
|
||||
return out
|
||||
|
||||
|
||||
def _metadata(filename, size):
|
||||
return asyncio.run(srv._output_metadata(_Request(query={"filename": filename, "size": str(size)})))
|
||||
|
||||
|
||||
def test_metadata_newest_same_size_match_wins(tmp_path):
|
||||
out = _output(tmp_path, {
|
||||
"a/img.png": (b"xx", 100, True),
|
||||
"b/img.png": (b"xx", 300, True),
|
||||
"c/img.png": (b"xxx", 500, True),
|
||||
"d/img.png": (b"xx", 900, False),
|
||||
})
|
||||
resp = _metadata("IMG.png" if os.name == "nt" else "img.png", 2)
|
||||
assert resp.path == srv.flake_io.metadata_sidecar_path(str(out / "b" / "img.png"))
|
||||
|
||||
|
||||
def test_metadata_skips_dot_directories(tmp_path):
|
||||
_output(tmp_path, {".hidden/img.png": (b"xx", 100, True)})
|
||||
assert _metadata("img.png", 2).status == 404
|
||||
|
||||
|
||||
def test_metadata_misses_without_sidecar_or_size(tmp_path):
|
||||
_output(tmp_path, {"img.png": (b"xx", 100, False), "other.png": (b"xx", 100, True)})
|
||||
assert _metadata("img.png", 2).status == 404
|
||||
assert _metadata("other.png", 3).status == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["../img.png", "a/img.png", "a\\img.png", ".."])
|
||||
def test_metadata_rejects_paths(tmp_path, filename):
|
||||
_output(tmp_path, {})
|
||||
assert _metadata(filename, 2).status == 400
|
||||
|
||||
|
||||
def test_roots_exclude_temp_local_root(tmp_path):
|
||||
fp.get_temp_directory = lambda: str(tmp_path / "temp")
|
||||
fp.models_dir = str(tmp_path / "models")
|
||||
for kind in ("flakes", "model_presets"):
|
||||
real = str(tmp_path / "models" / kind)
|
||||
fp.get_folder_paths = lambda k, real=real, kind=kind: [real, srv.flake_io.local_root(kind)]
|
||||
roots = asyncio.run(srv._list_roots(_Request(query={"type": kind}))).data["roots"]
|
||||
assert [r["index"] for r in roots] == [0]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for the built-in auto-caption instructions (#482)."""
|
||||
import importlib.util
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
_src = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "flake_caption.py")
|
||||
_spec = importlib.util.spec_from_file_location("flake_caption", _src)
|
||||
cap = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(cap)
|
||||
|
||||
|
||||
def _preset(family, caption_instruction=""):
|
||||
return SimpleNamespace(family=family, caption_instruction=caption_instruction)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["mh3", "wan22"])
|
||||
def test_family_default(family):
|
||||
assert cap.instruction_for(_preset(family)) == cap.INSTRUCTIONS[family]
|
||||
assert cap.INSTRUCTIONS[family].strip()
|
||||
|
||||
|
||||
def test_override_wins_and_is_stripped():
|
||||
assert cap.instruction_for(_preset("mh3", " custom\n")) == "custom"
|
||||
|
||||
|
||||
def test_blank_override_falls_back():
|
||||
assert cap.instruction_for(_preset("wan22", " ")) == cap.INSTRUCTIONS["wan22"]
|
||||
|
||||
|
||||
def test_unknown_family_errors():
|
||||
with pytest.raises(ValueError, match="other"):
|
||||
cap.instruction_for(_preset("other"))
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for resolving ControlNet files per family (#254, #364).
|
||||
|
||||
Anima and Z-Image ControlNets are model patches living in
|
||||
``models/model_patches/<subfolder>/``; SDXL ones stay in ``models/controlnet/``.
|
||||
Stubs ``folder_paths`` with a per-category file list and loads ``flake_io``
|
||||
under a synthetic package (same approach as the other tests).
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
FILES = {
|
||||
"controlnet": [
|
||||
"sdxl/controlnet_depth_sdxl.safetensors",
|
||||
"sdxl/controlnet-scribble-sdxl-1.0.safetensors",
|
||||
],
|
||||
"model_patches": [
|
||||
"anima/anima-lllite-depth-1.safetensors",
|
||||
"anima/anima-lllite-pose-1.safetensors",
|
||||
"zimage/Z-Image-Turbo-Fun-Controlnet-Union-2.1.safetensors",
|
||||
"flat-anima-lllite-lineart-1.safetensors",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: list(FILES.get(category, []))
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_cnres")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_cnres"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_cnres.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_cnres.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
|
||||
|
||||
def _resolve(cn_type, folder, category):
|
||||
return fio.resolve_cn_model_name(fio.infer_cn_model(cn_type, folder), folder, category)
|
||||
|
||||
|
||||
def test_sdxl_still_resolves_in_controlnet():
|
||||
assert _resolve("depth", "illustrious", "controlnet") == "sdxl/controlnet_depth_sdxl.safetensors"
|
||||
assert _resolve("scribble", "sdxl", "controlnet") == "sdxl/controlnet-scribble-sdxl-1.0.safetensors"
|
||||
|
||||
|
||||
def test_default_category_is_controlnet():
|
||||
assert fio.resolve_cn_model_name("controlnet_depth_sdxl", "sdxl") == "sdxl/controlnet_depth_sdxl.safetensors"
|
||||
|
||||
|
||||
def test_anima_resolves_versioned_lllite_in_model_patches():
|
||||
assert _resolve("depth", "anima", "model_patches") == "anima/anima-lllite-depth-1.safetensors"
|
||||
assert _resolve("openpose", "anima", "model_patches") == "anima/anima-lllite-pose-1.safetensors"
|
||||
|
||||
|
||||
def test_zimage_types_resolve_to_union_file():
|
||||
for folder in ("zib", "zit"):
|
||||
for cn_type in ("depth", "openpose", "canny"):
|
||||
assert _resolve(cn_type, folder, "model_patches") == \
|
||||
"zimage/Z-Image-Turbo-Fun-Controlnet-Union-2.1.safetensors"
|
||||
|
||||
|
||||
def test_explicit_name_outside_subfolder_falls_back_to_global():
|
||||
got = fio.resolve_cn_model_name("flat-anima-lllite-lineart-1", "anima", "model_patches")
|
||||
assert got == "flat-anima-lllite-lineart-1.safetensors"
|
||||
|
||||
|
||||
def test_patch_family_does_not_pick_up_controlnet_folder():
|
||||
# A missing LLLite file must not silently resolve to an SDXL controlnet.
|
||||
assert _resolve("lineart", "anima", "model_patches") == "anima-lllite-lineart"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fname, fn in sorted(globals().items()):
|
||||
if fname.startswith("test_") and callable(fn):
|
||||
fn()
|
||||
print(f"PASS {fname}")
|
||||
print("\nALL PASSED")
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Tests for the model-family registry (#355, #356, #361).
|
||||
|
||||
``flake_families`` has no ComfyUI dependencies, so it loads directly — no
|
||||
synthetic package needed, unlike the node modules.
|
||||
|
||||
Run directly (``python tests/test_families.py``) or via pytest.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _load_module():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_families.py")
|
||||
spec = importlib.util.spec_from_file_location("flake_families", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Must be in sys.modules before exec: @dataclass resolves annotations
|
||||
# through sys.modules[cls.__module__].
|
||||
sys.modules["flake_families"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
F = _load_module()
|
||||
|
||||
# The lookups the old module-level maps served, now answered by get() /
|
||||
# from_folder() (#470); pinned against the pre-#355 literals below.
|
||||
FAMILY_MAP = {f.label: F.get(f.label).folder for f in F.FAMILIES}
|
||||
FAMILY_COMPAT = {f.label: F.get(f.label).compat for f in F.FAMILIES if f.selectable}
|
||||
CN_MODEL_MAP = {f.folder: F.from_folder(f.folder).cn_models for f in F.FAMILIES}
|
||||
CN_SUBFOLDER = {f.folder: F.from_folder(f.folder).cn_subfolder for f in F.FAMILIES}
|
||||
MODEL_FAMILIES = [f.label for f in F.FAMILIES if f.selectable]
|
||||
|
||||
|
||||
# --- Refactor-equivalence pins ---------------------------------------------
|
||||
# Literally what flake_io.py and full_flake_node.py contained before #355,
|
||||
# minus the families added afterwards. This is what proves the registry did not
|
||||
# quietly change the SDXL/Illustrious path everything else depends on.
|
||||
|
||||
_PRE_REFACTOR_FAMILY_MAP = {
|
||||
"SDXL/Base": "sdxl",
|
||||
"SDXL/Illustrious": "illustrious",
|
||||
"SDXL/Pony": "pony",
|
||||
"ZImage/Base": "zib",
|
||||
"ZImage/Turbo": "zit",
|
||||
"Anima/Base": "anima",
|
||||
"Flux/Klein": "flux_klein",
|
||||
"Common": "common",
|
||||
}
|
||||
|
||||
_PRE_REFACTOR_FAMILY_COMPAT = {
|
||||
"SDXL/Base": {"common", "sdxl"},
|
||||
"SDXL/Illustrious": {"common", "sdxl", "illustrious"},
|
||||
"SDXL/Pony": {"common", "sdxl", "pony"},
|
||||
"ZImage/Base": {"common", "zib"},
|
||||
"ZImage/Turbo": {"common", "zit"},
|
||||
"Anima/Base": {"common", "anima"},
|
||||
"Flux/Klein": {"common", "flux_klein"},
|
||||
}
|
||||
|
||||
_PRE_REFACTOR_CN_SUBFOLDER = {
|
||||
"sdxl": "sdxl",
|
||||
"illustrious": "sdxl",
|
||||
"pony": "sdxl",
|
||||
"common": "sdxl",
|
||||
"zib": "zimage",
|
||||
"zit": "zimage",
|
||||
"anima": "anima",
|
||||
"flux_klein": "flux",
|
||||
}
|
||||
|
||||
_PRE_REFACTOR_CN_SUFFIX = {
|
||||
"sdxl": "sdxl",
|
||||
"illustrious": "sdxl",
|
||||
"pony": "sdxl",
|
||||
"common": "sdxl",
|
||||
"zib": "zib",
|
||||
"zit": "zib",
|
||||
"anima": "anima",
|
||||
"flux_klein": "flux",
|
||||
}
|
||||
|
||||
_PRE_REFACTOR_MODEL_FAMILIES = [
|
||||
"SDXL/Base", "SDXL/Illustrious", "SDXL/Pony",
|
||||
"ZImage/Base", "ZImage/Turbo", "Anima/Base", "Flux/Klein",
|
||||
]
|
||||
|
||||
# Families added after the refactor; excluded when comparing against the pins.
|
||||
_ADDED_LABELS = {"Krea2/Turbo"}
|
||||
_ADDED_FOLDERS = {"krea2"}
|
||||
|
||||
# Families whose ControlNets are model patches with their own names (#363).
|
||||
_PATCH_CN_FOLDERS = {"zib", "zit", "anima"}
|
||||
|
||||
|
||||
def _without_added(d, keys):
|
||||
return {k: v for k, v in d.items() if k not in keys}
|
||||
|
||||
|
||||
def test_family_map_matches_pre_refactor():
|
||||
assert _without_added(FAMILY_MAP, _ADDED_LABELS) == _PRE_REFACTOR_FAMILY_MAP
|
||||
|
||||
|
||||
def test_family_compat_matches_pre_refactor():
|
||||
assert _without_added(FAMILY_COMPAT, _ADDED_LABELS) == _PRE_REFACTOR_FAMILY_COMPAT
|
||||
|
||||
|
||||
def test_cn_subfolder_matches_pre_refactor():
|
||||
assert _without_added(CN_SUBFOLDER, _ADDED_FOLDERS) == _PRE_REFACTOR_CN_SUBFOLDER
|
||||
|
||||
|
||||
def test_cn_model_map_matches_pre_refactor():
|
||||
# Z-Image and Anima moved to model-patch ControlNets with their own file
|
||||
# names (#363); every other family keeps the original convention.
|
||||
expected = {
|
||||
folder: {t: f"controlnet_{t}_{suffix}" for t in F.CN_TYPES}
|
||||
for folder, suffix in _PRE_REFACTOR_CN_SUFFIX.items()
|
||||
if folder not in _PATCH_CN_FOLDERS
|
||||
}
|
||||
assert _without_added(CN_MODEL_MAP, _ADDED_FOLDERS | _PATCH_CN_FOLDERS) == expected
|
||||
|
||||
|
||||
def test_model_families_matches_pre_refactor():
|
||||
assert [f for f in MODEL_FAMILIES if f not in _ADDED_LABELS] == _PRE_REFACTOR_MODEL_FAMILIES
|
||||
|
||||
|
||||
# --- Registry invariants ----------------------------------------------------
|
||||
|
||||
def test_labels_and_folders_are_unique():
|
||||
labels = [f.label for f in F.FAMILIES]
|
||||
folders = [f.folder for f in F.FAMILIES]
|
||||
assert len(labels) == len(set(labels))
|
||||
# A duplicate folder would silently shadow a family in the reverse map.
|
||||
assert len(folders) == len(set(folders))
|
||||
|
||||
|
||||
def test_label_folder_round_trip():
|
||||
for spec in F.FAMILIES:
|
||||
assert F.from_folder(spec.folder) is spec
|
||||
assert F.get(spec.label) is spec
|
||||
|
||||
|
||||
def test_every_family_has_cn_coverage():
|
||||
for spec in F.FAMILIES:
|
||||
types = CN_MODEL_MAP[spec.folder].keys()
|
||||
if spec.cn_names:
|
||||
assert types == {t for t, _ in spec.cn_names}
|
||||
assert types <= set(F.CN_TYPES)
|
||||
else:
|
||||
assert types == set(F.CN_TYPES)
|
||||
assert CN_SUBFOLDER[spec.folder]
|
||||
|
||||
|
||||
def test_cn_kind_and_category():
|
||||
for spec in F.FAMILIES:
|
||||
if spec.folder in _PATCH_CN_FOLDERS:
|
||||
assert spec.cn_category == "model_patches"
|
||||
else:
|
||||
assert spec.cn_kind == "controlnet"
|
||||
assert spec.cn_category == "controlnet"
|
||||
assert F.get("Anima/Base").cn_kind == "anima_lllite"
|
||||
assert F.get("ZImage/Base").cn_kind == "zimage_fun"
|
||||
assert F.get("ZImage/Turbo").cn_kind == "zimage_fun"
|
||||
|
||||
|
||||
def test_anima_cn_names_follow_kohya_lllite():
|
||||
assert CN_MODEL_MAP["anima"] == {
|
||||
"openpose": "anima-lllite-pose",
|
||||
"depth": "anima-lllite-depth",
|
||||
"lineart": "anima-lllite-lineart",
|
||||
"scribble": "anima-lllite-scribble",
|
||||
}
|
||||
|
||||
|
||||
def test_zimage_types_share_the_union_file():
|
||||
for folder in ("zib", "zit"):
|
||||
assert set(CN_MODEL_MAP[folder].values()) == {"Z-Image-Turbo-Fun-Controlnet-Union"}
|
||||
|
||||
|
||||
def test_common_is_not_selectable():
|
||||
# "Common" is a real folder for shared flakes but must never be offered as a
|
||||
# model_family choice.
|
||||
assert "Common" in FAMILY_MAP
|
||||
assert "Common" not in MODEL_FAMILIES
|
||||
assert "Common" not in FAMILY_COMPAT
|
||||
|
||||
|
||||
def test_compat_always_includes_common_and_own_folder():
|
||||
for label, compat in FAMILY_COMPAT.items():
|
||||
assert "common" in compat
|
||||
assert FAMILY_MAP[label] in compat
|
||||
|
||||
|
||||
def test_get_and_from_folder_handle_none():
|
||||
assert F.get(None) is None
|
||||
assert F.get(" ") is None
|
||||
assert F.from_folder(None) is None
|
||||
|
||||
|
||||
def test_get_matches_labels_and_folders_in_any_case():
|
||||
# The draft's `anima` instead of picking `Anima/Base` (#470).
|
||||
for spec in F.FAMILIES:
|
||||
for value in (spec.label, spec.label.lower(), spec.label.upper(), spec.folder, f" {spec.folder.upper()} "):
|
||||
assert F.get(value) is spec
|
||||
assert F.get("anima") is F.get("Anima/Base")
|
||||
assert F.get("illustrious").label == "SDXL/Illustrious"
|
||||
|
||||
|
||||
def test_unregistered_values_are_new_families_with_defaults():
|
||||
moss = F.get(" Moss ")
|
||||
assert (moss.label, moss.folder, moss.cn_subfolder, moss.cn_kind) == ("Moss", "moss", "moss", "controlnet")
|
||||
assert moss.cn_models["depth"] == "controlnet_depth_moss"
|
||||
assert moss.compat == {"common", "moss"}
|
||||
assert moss.selectable
|
||||
defaults = F.FamilySpec("x", "x", cn_suffix="x", cn_subfolder="x")
|
||||
assert (moss.clip_type, moss.steps, moss.cfg, moss.width, moss.supports_clip_skip, moss.default_shift) == (
|
||||
defaults.clip_type, defaults.steps, defaults.cfg, defaults.width, defaults.supports_clip_skip, None)
|
||||
assert F.get("moss").folder == "moss"
|
||||
assert F.get("Moss/Base").folder == "moss_base"
|
||||
# No partial aliases: a label segment is not a family.
|
||||
assert F.get("zimage").folder == "zimage"
|
||||
# A value whose slug is a registered folder is that family.
|
||||
assert F.get("Flux Klein") is F.get("Flux/Klein")
|
||||
assert F.from_folder("moss").label == "moss"
|
||||
|
||||
|
||||
def test_value_without_folder_name_is_rejected():
|
||||
try:
|
||||
F.get("???")
|
||||
except ValueError:
|
||||
return
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
# --- Generation metadata (#356) --------------------------------------------
|
||||
|
||||
def test_sdxl_generation_defaults_unchanged():
|
||||
for label in ("SDXL/Base", "SDXL/Illustrious", "SDXL/Pony", "Flux/Klein", "Common"):
|
||||
spec = F.get(label)
|
||||
assert spec.clip_type == "STABLE_DIFFUSION"
|
||||
assert (spec.steps, spec.cfg) == (31, 4.7)
|
||||
assert (spec.sampler, spec.scheduler) == ("dpmpp_2m", "karras")
|
||||
assert (spec.width, spec.height) == (853, 1440)
|
||||
assert spec.supports_clip_skip is True
|
||||
assert spec.default_shift is None
|
||||
|
||||
|
||||
def test_new_families_match_shipped_templates():
|
||||
# From comfyui_workflow_templates_json: image_z_image_turbo,
|
||||
# image_krea2_turbo_t2i, image_anima_base_v1.
|
||||
expected = {
|
||||
"ZImage/Turbo": ("LUMINA2", 8, 1.0, "res_multistep", "simple", 3.0),
|
||||
"ZImage/Base": ("LUMINA2", 8, 1.0, "res_multistep", "simple", 3.0),
|
||||
"Krea2/Turbo": ("KREA2", 8, 1.0, "euler", "simple", 1.15),
|
||||
"Anima/Base": ("QWEN_IMAGE", 30, 4.0, "euler_ancestral", "normal", 3.0),
|
||||
}
|
||||
for label, want in expected.items():
|
||||
spec = F.get(label)
|
||||
got = (spec.clip_type, spec.steps, spec.cfg, spec.sampler,
|
||||
spec.scheduler, spec.default_shift)
|
||||
assert got == want, f"{label}: {got} != {want}"
|
||||
# None of these encode with CLIP, so clip_layer() must never run.
|
||||
assert spec.supports_clip_skip is False
|
||||
assert (spec.width, spec.height) == (1024, 1024)
|
||||
|
||||
|
||||
def test_krea2_is_registered_and_selectable():
|
||||
spec = F.get("Krea2/Turbo")
|
||||
assert spec.folder == "krea2"
|
||||
assert spec.cn_subfolder == "krea2"
|
||||
assert CN_MODEL_MAP["krea2"]["depth"] == "controlnet_depth_krea2"
|
||||
assert "Krea2/Turbo" in MODEL_FAMILIES
|
||||
|
||||
|
||||
def test_duplicate_folder_is_rejected():
|
||||
dupe = F.FamilySpec("Bogus/Dupe", "sdxl", cn_suffix="sdxl", cn_subfolder="sdxl")
|
||||
original = F.FAMILIES
|
||||
try:
|
||||
F.FAMILIES = original + (dupe,)
|
||||
try:
|
||||
F._check_unique()
|
||||
except ValueError as exc:
|
||||
assert "sdxl" in str(exc)
|
||||
else:
|
||||
raise AssertionError("_check_unique did not reject a duplicate folder")
|
||||
finally:
|
||||
F.FAMILIES = original
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for fname, fn in sorted(globals().items()):
|
||||
if fname.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {fname}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {fname}: {exc}")
|
||||
print(f"\n{failures} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for filename-prefix building, including the ``**`` suffix syntax (#290).
|
||||
|
||||
``full_flake_node`` imports ComfyUI runtime modules at import time and uses
|
||||
package-relative imports, and the package directory name contains a hyphen
|
||||
(``comfyui-flakes``) which is not a valid module name. So we stub the external
|
||||
modules and load the file under a synthetic package so its relative imports
|
||||
resolve to stubs.
|
||||
|
||||
Run directly (``python tests/test_filename_prefix.py``) or via pytest.
|
||||
"""
|
||||
import datetime as _dt
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_module():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "full_flake_node.py")
|
||||
|
||||
for name in ("folder_paths", "comfy", "comfy.sd", "comfy.utils"):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
for attr in ("CLIPTextEncode", "EmptyLatentImage", "ControlNetApplyAdvanced",
|
||||
"ControlNetLoader", "LoraLoader"):
|
||||
setattr(nodes, attr, object)
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
pkg = types.ModuleType("_flakepkg")
|
||||
# Point at the repo root so `from . import flake_families` resolves to the
|
||||
# real module (it has no ComfyUI dependencies, so it imports cleanly).
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_flakepkg"] = pkg
|
||||
flake_io = types.ModuleType("_flakepkg.flake_io")
|
||||
flake_io._resolve_model_name = lambda *a, **k: ""
|
||||
sys.modules["_flakepkg.flake_io"] = flake_io
|
||||
|
||||
spec = importlib.util.spec_from_file_location("_flakepkg.full_flake_node", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_flakepkg.full_flake_node"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_mod = _load_module()
|
||||
|
||||
|
||||
class _FixedClock:
|
||||
@staticmethod
|
||||
def now():
|
||||
return _dt.datetime(2026, 6, 15, 12, 30, 45)
|
||||
|
||||
|
||||
def _build(preset, stems):
|
||||
# Patch the module-level datetime so output is deterministic.
|
||||
orig = _mod.datetime
|
||||
_mod.datetime = _FixedClock
|
||||
try:
|
||||
return _mod._build_filename_prefix(preset, stems)
|
||||
finally:
|
||||
_mod.datetime = orig
|
||||
|
||||
|
||||
def test_split_plain_folder_and_file():
|
||||
# test/ok -> folder "test/", file prefix "ok"
|
||||
assert _mod._split_stem_for_filename("test/ok") == ("test/", "", "ok", "")
|
||||
|
||||
|
||||
def test_split_file_suffix_only():
|
||||
# **ok -> file suffix "ok"
|
||||
assert _mod._split_stem_for_filename("**ok") == ("", "", "", "ok")
|
||||
|
||||
|
||||
def test_split_file_prefix_and_suffix():
|
||||
# test/ok**abc -> folder prefix "test/", file prefix "ok", file suffix "abc"
|
||||
assert _mod._split_stem_for_filename("test/ok**abc") == ("test/", "", "ok", "abc")
|
||||
|
||||
|
||||
def test_split_folder_suffix_and_file_prefix_suffix():
|
||||
# **test/ok**abc -> folder SUFFIX "test/", file prefix "ok", file suffix "abc"
|
||||
assert _mod._split_stem_for_filename("**test/ok**abc") == ("", "test/", "ok", "abc")
|
||||
|
||||
|
||||
def test_split_plain_file():
|
||||
assert _mod._split_stem_for_filename("musashi") == ("", "", "musashi", "")
|
||||
|
||||
|
||||
def test_split_plain_folder_only():
|
||||
assert _mod._split_stem_for_filename("characters/") == ("characters/", "", "", "")
|
||||
|
||||
|
||||
def test_build_plain():
|
||||
assert _build("", ["test/ok"]) == "test/260615/123045_ok"
|
||||
|
||||
|
||||
def test_build_file_suffix_orders_after_other_prefixes():
|
||||
# ok (prefix) from flake A, **zz (suffix) from flake B -> ok before zz.
|
||||
assert _build("", ["ok", "**zz"]) == "260615/123045_ok_zz"
|
||||
|
||||
|
||||
def test_build_full_example():
|
||||
# test/ok (prefix) then **abc (suffix) -> time_ok_..._abc
|
||||
assert _build("", ["test/ok", "other", "**abc"]) == "test/260615/123045_ok_other_abc"
|
||||
|
||||
|
||||
def test_build_folder_suffix_after_folder_prefix():
|
||||
# img/sdxl/ structural prefix + **series/ folder suffix -> img/sdxl/series/
|
||||
assert _build("", ["img/sdxl/", "**series/", "name"]) == "img/sdxl/series/260615/123045_name"
|
||||
|
||||
|
||||
def test_build_no_double_star_regression():
|
||||
# No ** anywhere: identical structure to the legacy builder.
|
||||
assert _build("", ["img/sdxl/", "characters/", "musashi"]) == "img/sdxl/characters/260615/123045_musashi"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Round-trip tests for flake-link LoRA-strength / variant overrides (#300).
|
||||
|
||||
A host flake (``dim_room``) links a target flake (``dramatic_lighting``) and
|
||||
overrides the target's default LoRA strength from its own yaml
|
||||
(``flake_links[i].lora_strengths``). Resolving the host must apply that override
|
||||
to the linked flake's LoRA — with no per-grid placement override present.
|
||||
|
||||
``flake_io`` only imports ``folder_paths``, so we stub it and load the module
|
||||
under a synthetic package, then monkeypatch ``load_flake`` to serve flakes from
|
||||
an in-memory yaml map. Run directly or via pytest.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg")
|
||||
# Point at the repo root so `from . import flake_families` resolves to the
|
||||
# real module (it has no ComfyUI dependencies, so it imports cleanly).
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
|
||||
|
||||
def _with_flakes(raw_map):
|
||||
"""Monkeypatch load_flake to build flakes from an in-memory raw-yaml map."""
|
||||
fio.load_flake = lambda name: fio._flake_from_raw(name, raw_map[name])
|
||||
|
||||
|
||||
def _strength_of(flake, path):
|
||||
for lr in flake.loras:
|
||||
if lr.path == path:
|
||||
return lr.strength
|
||||
return None
|
||||
|
||||
|
||||
def test_link_override_applies_modern_loras():
|
||||
_with_flakes({
|
||||
"dramatic_lighting": {
|
||||
"name": "Dramatic Lighting",
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.5}],
|
||||
},
|
||||
"dim_room": {
|
||||
"name": "Dim Room",
|
||||
"flake_links": [{"target": "dramatic_lighting", "lora_strengths": [0.8]}],
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "dim_room"})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.8
|
||||
|
||||
|
||||
def test_link_override_applies_legacy_single_lora():
|
||||
# The target stores its LoRA the legacy way (path/strength, no `loras` list).
|
||||
_with_flakes({
|
||||
"dramatic_lighting": {
|
||||
"name": "Dramatic Lighting",
|
||||
"path": "drama.safetensors",
|
||||
"strength": 0.5,
|
||||
},
|
||||
"dim_room": {
|
||||
"name": "Dim Room",
|
||||
"flake_links": [{"target": "dramatic_lighting", "lora_strengths": [0.8]}],
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "dim_room"})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.8
|
||||
|
||||
|
||||
def test_link_no_override_uses_target_default():
|
||||
_with_flakes({
|
||||
"dramatic_lighting": {
|
||||
"name": "Dramatic Lighting",
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.5}],
|
||||
},
|
||||
"dim_room": {
|
||||
"name": "Dim Room",
|
||||
"flake_links": [{"target": "dramatic_lighting"}],
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "dim_room"})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.5
|
||||
|
||||
|
||||
def test_grid_override_beats_yaml_override():
|
||||
# A per-placement grid override should win over the host yaml override.
|
||||
_with_flakes({
|
||||
"dramatic_lighting": {
|
||||
"name": "Dramatic Lighting",
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.5}],
|
||||
},
|
||||
"dim_room": {
|
||||
"name": "Dim Room",
|
||||
"flake_links": [{"target": "dramatic_lighting", "lora_strengths": [0.8]}],
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "dim_room", "flake_link_overrides": [{"lora_strengths": [0.3]}]})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.3
|
||||
|
||||
|
||||
def _choice_lora_flakes(link):
|
||||
_with_flakes({
|
||||
"test_a": {
|
||||
"name": "Test A",
|
||||
"variants": {"mood": {
|
||||
"calm": {"default": True, "loras": [{"name": "calm", "path": "calm.safetensors", "strength": 0.5}]},
|
||||
"dark": {"loras": [
|
||||
{"name": "dark", "path": "dark.safetensors", "strength": 0.7},
|
||||
{"name": "rim", "path": "rim.safetensors", "strength": 0.3},
|
||||
]},
|
||||
}},
|
||||
},
|
||||
"test_b": {"name": "Test B", "flake_links": [link]},
|
||||
})
|
||||
|
||||
|
||||
def test_link_variant_loras_from_yaml():
|
||||
"""The host yaml link overrides the linked choice's LoRA strengths (#414)."""
|
||||
_choice_lora_flakes({"target": "test_a", "variant": {"mood": "dark"}, "variant_loras": {"mood": {"dark": [None, 1.1]}}})
|
||||
flake = fio.resolve({"name": "test_b"})
|
||||
assert _strength_of(flake, "dark.safetensors") == 0.7
|
||||
assert _strength_of(flake, "rim.safetensors") == 1.1
|
||||
|
||||
|
||||
def test_link_variant_loras_default_choice():
|
||||
_choice_lora_flakes({"target": "test_a", "variant_loras": {"mood": {"calm": [0.9]}}})
|
||||
flake = fio.resolve({"name": "test_b"})
|
||||
assert _strength_of(flake, "calm.safetensors") == 0.9
|
||||
|
||||
|
||||
def test_grid_variant_loras_beat_yaml_per_slot():
|
||||
_choice_lora_flakes({"target": "test_a", "variant": {"mood": "dark"}, "variant_loras": {"mood": {"dark": [0.2, 1.1]}}})
|
||||
flake = fio.resolve({
|
||||
"name": "test_b",
|
||||
"flake_link_overrides": [{"variant_loras": {"mood": {"dark": [None, 2.5]}}}],
|
||||
})
|
||||
assert _strength_of(flake, "dark.safetensors") == 0.2
|
||||
assert _strength_of(flake, "rim.safetensors") == 2.5
|
||||
|
||||
|
||||
def test_grid_variant_loras_follow_grid_choice():
|
||||
_choice_lora_flakes({"target": "test_a", "variant_loras": {"mood": {"calm": [0.9]}}})
|
||||
flake = fio.resolve({
|
||||
"name": "test_b",
|
||||
"flake_link_overrides": [{"variant": {"mood": "dark"}, "variant_loras": {"mood": {"dark": [1.5]}}}],
|
||||
})
|
||||
assert _strength_of(flake, "calm.safetensors") is None
|
||||
assert _strength_of(flake, "dark.safetensors") == 1.5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the Load picker's folder cover thumbnails (#424).
|
||||
|
||||
A folder shows its first favorited flake's cover, else its first flake's,
|
||||
else its first subfolder's; flakes without a cover are skipped. Same loading
|
||||
approach as test_variant_defaults.py.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_fc")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_fc"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_fc.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_fc.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
|
||||
|
||||
def _covers(names, dirs, favorites=(), covered=None):
|
||||
covered = set(names) if covered is None else set(covered)
|
||||
return fio.list_folder_covers(names, dirs, list(favorites), covered.__contains__)
|
||||
|
||||
|
||||
def test_first_flake_in_folder():
|
||||
assert _covers(["a/zed", "a/Bob", "a/carl"], ["a"]) == {"a": "a/Bob"}
|
||||
|
||||
|
||||
def test_favorite_wins():
|
||||
assert _covers(["a/bob", "a/carl"], ["a"], favorites=["a/carl"]) == {"a": "a/carl"}
|
||||
|
||||
|
||||
def test_flakes_without_cover_are_skipped():
|
||||
assert _covers(["a/bob", "a/carl"], ["a"], favorites=["a/bob"], covered=["a/carl"]) == {"a": "a/carl"}
|
||||
|
||||
|
||||
def test_falls_back_to_first_subfolder_recursively():
|
||||
names = ["a/y/deep/eve", "a/z/zoe"]
|
||||
dirs = ["a", "a/y", "a/y/deep", "a/z"]
|
||||
assert _covers(names, dirs) == {"a": "a/y/deep/eve", "a/y": "a/y/deep/eve", "a/y/deep": "a/y/deep/eve", "a/z": "a/z/zoe"}
|
||||
|
||||
|
||||
def test_direct_flake_beats_subfolder():
|
||||
assert _covers(["a/b/bob", "a/zed"], ["a", "a/b"])["a"] == "a/zed"
|
||||
|
||||
|
||||
def test_empty_folders_have_no_cover():
|
||||
assert _covers(["a/bob"], ["a", "empty", "a/nocover"], covered=[]) == {}
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Tests for applying LoRA and prompt hook schedules at sampling (#370).
|
||||
|
||||
Stubs the ComfyUI runtime (including ``comfy.hooks`` and ``node_helpers``) and
|
||||
loads the sources under a synthetic package, matching test_model_patch_cn.py.
|
||||
Runs ``FlakeStack.execute`` end to end, including two chained nodes.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
PKG = "flakes_under_test_hook_schedules"
|
||||
|
||||
LORAS = ["a.safetensors", "b.safetensors"]
|
||||
CALLS = {"lora": [], "cn_apply": []}
|
||||
|
||||
|
||||
class HookKeyframe:
|
||||
def __init__(self, strength, start_percent=0.0, guarantee_steps=1):
|
||||
self.strength = strength
|
||||
self.start_percent = start_percent
|
||||
|
||||
|
||||
class HookKeyframeGroup:
|
||||
def __init__(self):
|
||||
self.keyframes = []
|
||||
|
||||
def add(self, keyframe):
|
||||
self.keyframes.append(keyframe)
|
||||
|
||||
|
||||
class Hook:
|
||||
def __init__(self, lora, strength_model, strength_clip):
|
||||
self.lora = lora
|
||||
self.strength_model = strength_model
|
||||
self.strength_clip = strength_clip
|
||||
self.keyframes = None
|
||||
|
||||
|
||||
class HookGroup:
|
||||
def __init__(self):
|
||||
self.hooks = []
|
||||
|
||||
def add(self, hook):
|
||||
if hook not in self.hooks:
|
||||
self.hooks.append(hook)
|
||||
|
||||
def set_keyframes_on_hooks(self, hook_kf):
|
||||
for hook in self.hooks:
|
||||
hook.keyframes = hook_kf
|
||||
|
||||
@staticmethod
|
||||
def combine_all_hooks(hooks_list):
|
||||
actual = [g for g in hooks_list if g is not None]
|
||||
if not actual:
|
||||
return None
|
||||
combined = HookGroup()
|
||||
for group in actual:
|
||||
for hook in group.hooks:
|
||||
combined.add(hook)
|
||||
return combined
|
||||
|
||||
|
||||
def _create_hook_lora(lora, strength_model, strength_clip):
|
||||
group = HookGroup()
|
||||
group.add(Hook(lora, strength_model, strength_clip))
|
||||
return group
|
||||
|
||||
|
||||
def _set_hooks_for_conditioning(cond, hooks, append_hooks=True):
|
||||
if hooks is None:
|
||||
return cond
|
||||
return [[t[0], {**t[1], "hooks": hooks}] for t in cond]
|
||||
|
||||
|
||||
def _stub_externals():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda cat: list(LORAS) if cat == "loras" else []
|
||||
fp.get_full_path_or_raise = lambda cat, name: f"/fake/{cat}/{name}"
|
||||
fp.get_full_path = lambda cat, name: os.path.abspath(__file__)
|
||||
fp.get_folder_paths = lambda cat: []
|
||||
fp.get_input_directory = lambda: "/fake/input"
|
||||
fp.base_path = "/fake"
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
for name in ("numpy", "torch"):
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
if "PIL" not in sys.modules:
|
||||
pil = types.ModuleType("PIL")
|
||||
pil.Image = types.SimpleNamespace(open=None)
|
||||
pil.ImageOps = types.SimpleNamespace(exif_transpose=None)
|
||||
sys.modules["PIL"] = pil
|
||||
sys.modules["PIL.Image"] = pil.Image
|
||||
sys.modules["PIL.ImageOps"] = pil.ImageOps
|
||||
|
||||
comfy = types.ModuleType("comfy")
|
||||
comfy.sd = types.ModuleType("comfy.sd")
|
||||
comfy.sd.load_checkpoint_guess_config = lambda path, **kw: ("MODEL", "CLIP", "VAE", None)
|
||||
comfy.utils = types.ModuleType("comfy.utils")
|
||||
comfy.utils.load_torch_file = lambda path, safe_load=False: {"sd": path}
|
||||
comfy.hooks = types.ModuleType("comfy.hooks")
|
||||
comfy.hooks.HookKeyframe = HookKeyframe
|
||||
comfy.hooks.HookKeyframeGroup = HookKeyframeGroup
|
||||
comfy.hooks.HookGroup = HookGroup
|
||||
comfy.hooks.create_hook_lora = _create_hook_lora
|
||||
comfy.hooks.set_hooks_for_conditioning = _set_hooks_for_conditioning
|
||||
sys.modules["comfy"] = comfy
|
||||
sys.modules["comfy.sd"] = comfy.sd
|
||||
sys.modules["comfy.utils"] = comfy.utils
|
||||
sys.modules["comfy.hooks"] = comfy.hooks
|
||||
|
||||
nh = types.ModuleType("node_helpers")
|
||||
nh.conditioning_set_values = lambda cond, values={}, append=False: [[t[0], {**t[1], **values}] for t in cond]
|
||||
sys.modules["node_helpers"] = nh
|
||||
|
||||
class CLIPTextEncode:
|
||||
def encode(self, clip, text):
|
||||
return ([[f"COND({text})", {}]],)
|
||||
|
||||
class EmptyLatentImage:
|
||||
def generate(self, w, h, b):
|
||||
return ({"samples": f"latent{w}x{h}"},)
|
||||
|
||||
class LoraLoader:
|
||||
def load_lora(self, model, clip, name, strength_model, strength_clip):
|
||||
CALLS["lora"].append((name, strength_model, strength_clip))
|
||||
return (f"{model}+{name}", f"{clip}+{name}")
|
||||
|
||||
class ControlNetLoader:
|
||||
def load_controlnet(self, name):
|
||||
return (f"CN({name})",)
|
||||
|
||||
class ControlNetApplyAdvanced:
|
||||
def apply_controlnet(self, pos, neg, cn, image, strength, start, end):
|
||||
CALLS["cn_apply"].append(cn)
|
||||
return ([[t[0], {**t[1], "control": cn}] for t in pos],
|
||||
[[t[0], {**t[1], "control": cn}] for t in neg])
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
nodes.CLIPTextEncode = CLIPTextEncode
|
||||
nodes.EmptyLatentImage = EmptyLatentImage
|
||||
nodes.LoraLoader = LoraLoader
|
||||
nodes.ControlNetLoader = ControlNetLoader
|
||||
nodes.ControlNetApplyAdvanced = ControlNetApplyAdvanced
|
||||
nodes.NODE_CLASS_MAPPINGS = {}
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
|
||||
_stub_externals()
|
||||
|
||||
|
||||
def _load(name):
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PKG not in sys.modules:
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [root]
|
||||
sys.modules[PKG] = pkg
|
||||
full = f"{PKG}.{name}"
|
||||
if full in sys.modules:
|
||||
return sys.modules[full]
|
||||
spec = importlib.util.spec_from_file_location(full, os.path.join(root, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[full] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
flake_io = _load("flake_io")
|
||||
flake_compose = _load("flake_compose")
|
||||
full_flake_node = _load("full_flake_node")
|
||||
|
||||
flake_compose._load_cn_image = lambda name: f"IMG({name})"
|
||||
flake_compose._fit_cn_image = lambda image, w, h: image
|
||||
|
||||
KF = flake_io.ScheduleKeyframe
|
||||
HALF = [KF(0.0, 1.0), KF(0.5, 0.0)]
|
||||
|
||||
BASE_DATA = (
|
||||
("MODEL", "CLIP", "VAE"),
|
||||
([["P", {}]], [["N", {}]], {"samples": "l"}, 1024, 1024, "", "", {"stems": []}),
|
||||
(30, 4.0, "euler", "simple", 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _run(flakes, flake_data=BASE_DATA, family="SDXL/Illustrious"):
|
||||
by_name = {f.name: f for f in flakes}
|
||||
flake_io.resolve = lambda e: by_name[e["name"]]
|
||||
entries = [{"name": f.name} for f in flakes]
|
||||
return full_flake_node.FlakeStack().execute(family, flake_data, json.dumps(entries))[0]
|
||||
|
||||
|
||||
def _reset():
|
||||
for v in CALLS.values():
|
||||
v.clear()
|
||||
|
||||
|
||||
def _hooks(cond):
|
||||
return [t[1].get("hooks") for t in cond]
|
||||
|
||||
|
||||
def test_schedule_segments():
|
||||
seg = flake_compose.schedule_segments
|
||||
assert seg([]) == [(0.0, 1.0, 1.0)]
|
||||
assert seg(HALF) == [(0.0, 0.5, 1.0), (0.5, 1.0, 0.0)]
|
||||
# Strength is 1.0 before the first keyframe.
|
||||
assert seg([KF(0.3, 0.5)]) == [(0.0, 0.3, 1.0), (0.3, 1.0, 0.5)]
|
||||
assert seg([KF(0.2, 0.0), KF(1.0, 1.0)]) == [(0.0, 0.2, 1.0), (0.2, 1.0, 0.0)]
|
||||
|
||||
|
||||
def test_unscheduled_lora_stays_static():
|
||||
_reset()
|
||||
out = _run([flake_io.Flake(name="f", loras=[flake_io.LoraEntry(path="a", strength=0.7)])])
|
||||
(model, clip, _vae), gen, _ = out
|
||||
assert CALLS["lora"] == [("a.safetensors", 0.7, 0.7)]
|
||||
assert (model, clip) == ("MODEL+a.safetensors", "CLIP+a.safetensors")
|
||||
assert _hooks(gen[0]) == [None] and _hooks(gen[1]) == [None]
|
||||
|
||||
|
||||
def test_scheduled_lora_hooks_model_and_applies_clip_statically():
|
||||
_reset()
|
||||
out = _run([flake_io.Flake(name="f", loras=[flake_io.LoraEntry(path="a", strength=0.8, schedule=HALF)])])
|
||||
_bundle, gen, _ = out
|
||||
assert CALLS["lora"] == [("a.safetensors", 0, 0.8)]
|
||||
pos_hooks, neg_hooks = _hooks(gen[0]), _hooks(gen[1])
|
||||
(hook,) = pos_hooks[0].hooks
|
||||
assert neg_hooks[0].hooks == [hook]
|
||||
assert hook.lora == {"sd": "/fake/loras/a.safetensors"}
|
||||
assert (hook.strength_model, hook.strength_clip) == (0.8, 0.0)
|
||||
assert [(k.start_percent, k.strength) for k in hook.keyframes.keyframes] == [(0.0, 1.0), (0.5, 0.0)]
|
||||
assert gen[7]["loras"] == [{"name": "a", "path": "a", "strength": 0.8}]
|
||||
|
||||
|
||||
def test_lora_scheduled_off_everywhere_is_skipped():
|
||||
_reset()
|
||||
out = _run([flake_io.Flake(name="f", loras=[flake_io.LoraEntry(path="a", schedule=[KF(0.0, 0.0)])])])
|
||||
_bundle, gen, _ = out
|
||||
assert CALLS["lora"] == []
|
||||
assert _hooks(gen[0]) == [None]
|
||||
assert gen[7]["loras"] == []
|
||||
|
||||
|
||||
def test_scheduled_prompt_becomes_ranged_entries():
|
||||
_reset()
|
||||
flake = flake_io.Flake(name="f", positive="cat", scheduled_prompts=[
|
||||
flake_io.PromptEntry(positive="sunset", negative="day", schedule=[KF(0.3, 0.5), KF(0.8, 0.0)]),
|
||||
])
|
||||
_bundle, gen, _ = _run([flake])
|
||||
assert gen[0] == [
|
||||
["COND(cat)", {}],
|
||||
["COND(sunset)", {"start_percent": 0.0, "end_percent": 0.3, "strength": 1.0}],
|
||||
["COND(sunset)", {"start_percent": 0.3, "end_percent": 0.8, "strength": 0.5}],
|
||||
]
|
||||
assert [t[0] for t in gen[1]] == ["COND()", "COND(day)", "COND(day)"]
|
||||
# Scheduled text is not folded into the plain prompt text.
|
||||
assert gen[5] == "cat"
|
||||
|
||||
|
||||
def test_controlnet_reaches_ranged_entries():
|
||||
_reset()
|
||||
flake = flake_io.Flake(
|
||||
name="f",
|
||||
scheduled_prompts=[flake_io.PromptEntry(positive="sunset", schedule=HALF)],
|
||||
controlnets=[flake_io.ControlNetEntry(type="", model_name="cn_depth", image_name="img")],
|
||||
)
|
||||
_bundle, gen, _ = _run([flake])
|
||||
assert len(gen[0]) == 2
|
||||
assert all(t[1]["control"] == "CN(cn_depth)" for t in gen[0])
|
||||
|
||||
|
||||
def test_chained_nodes_keep_upstream_ranged_entries_and_hooks():
|
||||
_reset()
|
||||
first = flake_io.Flake(
|
||||
name="first", positive="cat",
|
||||
loras=[flake_io.LoraEntry(path="a", schedule=HALF)],
|
||||
scheduled_prompts=[flake_io.PromptEntry(positive="sunset", schedule=HALF)],
|
||||
)
|
||||
second = flake_io.Flake(
|
||||
name="second", positive="hat",
|
||||
loras=[flake_io.LoraEntry(path="b", schedule=[KF(0.5, 1.0)])],
|
||||
)
|
||||
upstream = _run([first])
|
||||
_bundle, gen, _ = _run([second], flake_data=upstream)
|
||||
|
||||
assert [t[0] for t in gen[0]] == ["COND(cat BREAK hat)", "COND(sunset)"]
|
||||
assert gen[0][1][1]["end_percent"] == 0.5
|
||||
for cond in (gen[0], gen[1]):
|
||||
for hooks in _hooks(cond):
|
||||
assert sorted(h.lora["sd"] for h in hooks.hooks) == ["/fake/loras/a.safetensors", "/fake/loras/b.safetensors"]
|
||||
|
||||
|
||||
def test_chain_without_schedules_unchanged():
|
||||
_reset()
|
||||
upstream = _run([flake_io.Flake(name="first", positive="cat")])
|
||||
_bundle, gen, _ = _run([flake_io.Flake(name="second", positive="hat")], flake_data=upstream)
|
||||
assert gen[0] == [["COND(cat BREAK hat)", {}]]
|
||||
assert gen[1] == [["COND()", {}]]
|
||||
|
||||
|
||||
def _preset_bundle(**kw):
|
||||
preset = flake_io.ModelPreset(name="p", checkpoint="ckpt.safetensors", clip_skip=0, **kw)
|
||||
flake_io.load_preset = lambda n: preset
|
||||
return full_flake_node._load_preset_bundle("p", "SDXL/Illustrious")
|
||||
|
||||
|
||||
def test_preset_loras_apply_and_seed_metadata():
|
||||
_reset()
|
||||
(model, clip, _vae), gen, _ = _preset_bundle(positive="cat", loras=[
|
||||
flake_io.LoraEntry(path="a", strength=0.7),
|
||||
flake_io.LoraEntry(path="b", strength=0.4, schedule=HALF),
|
||||
])
|
||||
assert CALLS["lora"] == [("a.safetensors", 0.7, 0.7), ("b.safetensors", 0, 0.4)]
|
||||
assert model == "MODEL+a.safetensors+b.safetensors"
|
||||
assert [h.lora["sd"] for h in _hooks(gen[0])[0].hooks] == ["/fake/loras/b.safetensors"]
|
||||
assert _hooks(gen[1])[0].hooks == _hooks(gen[0])[0].hooks
|
||||
assert [lr["path"] for lr in gen[7]["loras"]] == ["a", "b"]
|
||||
|
||||
|
||||
def test_preset_links_add_prompts_and_loras():
|
||||
_reset()
|
||||
linked = flake_io.Flake(name="style", positive="ink", negative="color",
|
||||
loras=[flake_io.LoraEntry(path="a", strength=0.5)])
|
||||
flake_io.resolve = lambda e: linked if e["name"] == "style" else None
|
||||
_bundle, gen, _ = _preset_bundle(positive="cat", flake_links=[flake_io.FlakeLink(target="style")])
|
||||
assert (gen[5], gen[6]) == ("cat, ink", "color")
|
||||
assert gen[0] == [["COND(cat, ink)", {}]]
|
||||
assert CALLS["lora"] == [("a.safetensors", 0.5, 0.5)]
|
||||
|
||||
|
||||
def test_preset_scheduled_link_becomes_ranged_entries():
|
||||
_reset()
|
||||
linked = flake_io.Flake(name="style", positive="ink")
|
||||
flake_io.resolve = lambda e: linked
|
||||
_bundle, gen, _ = _preset_bundle(positive="cat", flake_links=[flake_io.FlakeLink(target="style", schedule=HALF)])
|
||||
assert gen[5] == "cat"
|
||||
assert gen[0] == [
|
||||
["COND(cat)", {}],
|
||||
["COND(ink)", {"start_percent": 0.0, "end_percent": 0.5, "strength": 1.0}],
|
||||
]
|
||||
|
||||
|
||||
def test_flake_stack_keeps_preset_hooks_and_loras():
|
||||
_reset()
|
||||
upstream = _preset_bundle(loras=[flake_io.LoraEntry(path="a", schedule=HALF)])
|
||||
_bundle, gen, _ = _run([flake_io.Flake(name="f", loras=[flake_io.LoraEntry(path="b", strength=0.3)])],
|
||||
flake_data=upstream)
|
||||
assert [lr["path"] for lr in gen[7]["loras"]] == ["a", "b"]
|
||||
for cond in (gen[0], gen[1]):
|
||||
assert [h.lora["sd"] for h in _hooks(cond)[0].hooks] == ["/fake/loras/a.safetensors"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for fname, fn in sorted(globals().items()):
|
||||
if fname.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {fname}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"FAIL {fname}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Saved Flake Generate images only describe the combination that produced
|
||||
them (#426): Model Combo -> Model Preset, Combo -> Stack with the used entry,
|
||||
stack All Variants collapsed, Generate seed fixed with a single-image overlay.
|
||||
|
||||
``flake_data_nodes`` imports ComfyUI runtime modules and sibling modules with
|
||||
relative imports, so those are stubbed and the file is loaded under a
|
||||
synthetic package.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_module():
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.modules.setdefault("folder_paths", types.ModuleType("folder_paths"))
|
||||
for name in ("comfy", "comfy.cli_args"):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
sys.modules["comfy.cli_args"].args = types.SimpleNamespace(disable_metadata=False)
|
||||
nodes =sys.modules.setdefault("nodes", types.ModuleType("nodes"))
|
||||
for attr in ("KSampler", "VAEDecode", "SaveImage"):
|
||||
if not hasattr(nodes, attr):
|
||||
setattr(nodes, attr, object)
|
||||
|
||||
pkg = types.ModuleType("_flakemeta")
|
||||
pkg.__path__ = [root]
|
||||
sys.modules["_flakemeta"] = pkg
|
||||
sys.modules["_flakemeta.flake_compose"] = types.ModuleType("_flakemeta.flake_compose")
|
||||
ffn = types.ModuleType("_flakemeta.full_flake_node")
|
||||
ffn._build_filename_prefix = lambda *a, **k: ""
|
||||
ffn._attention_options = lambda: ["default"]
|
||||
ffn._apply_attention = lambda model, attention: model
|
||||
sys.modules["_flakemeta.full_flake_node"] = ffn
|
||||
|
||||
spec = importlib.util.spec_from_file_location("_flakemeta.flake_data_nodes", os.path.join(root, "flake_data_nodes.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_flakemeta.flake_data_nodes"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_mod = _load_module()
|
||||
|
||||
DEFAULT = {"inline": True, "content": {"prompt": {"positive": ""}}, "loras": [], "strength": 1.0, "option": {}}
|
||||
|
||||
# What queue.js queues for preset 2 x item 5 of Combo A x item 3 of Combo B.
|
||||
OVERRIDES = '{"steps":30,"cfg":5}'
|
||||
COMBO_A_ITEM = {"name": "Characters/alice", "loras": [0.8], "variant": {"outfit": "red", "hair": "long"},
|
||||
"_uid": "a5", "_all_variants": True}
|
||||
COMBO_B_ITEM = {"name": "Styles/ink", "loras": [1.0], "variant": {"mood": ["calm"]}, "_uid": "b3",
|
||||
"_output_stem_override": "ink"}
|
||||
STACK = [
|
||||
{"inline": True, "content": {"prompt": {"positive": "masterpiece"}}, "loras": [], "strength": 1.0, "option": {}},
|
||||
{"name": "Poses/sitting", "loras": [1.0], "variant": {}},
|
||||
{"name": "Poses/standing", "loras": [1.0], "variant": {}, "bypassed": True},
|
||||
{"name": "Places/beach", "loras": [0.6], "variant": {"time": "dusk"}, "_all_variants": True},
|
||||
]
|
||||
|
||||
|
||||
def _fixture():
|
||||
a_raw = json.dumps([COMBO_A_ITEM])
|
||||
b_raw = json.dumps([COMBO_B_ITEM])
|
||||
stack_raw = json.dumps(STACK)
|
||||
prompt = {
|
||||
"1": {"class_type": "FlakeModelCombo", "inputs": {"model_family": "SDXL/Base", "preset": "SDXL/preset2", "overrides_json": OVERRIDES}},
|
||||
"2": {"class_type": "FlakeCombo", "inputs": {"model_family": "SDXL/Base", "flake_data": ["1", 0], "flakes_json": a_raw}},
|
||||
"10:3": {"class_type": "FlakeCombo", "inputs": {"model_family": "SDXL/Base", "flake_data": ["2", 0], "flakes_json": b_raw}},
|
||||
"4": {"class_type": "FlakeStack", "inputs": {"model_family": "SDXL/Base", "flake_data": ["10:3", 0], "flakes_json": stack_raw}},
|
||||
"5": {"class_type": "FlakeGenerate", "inputs": {"flake_data": ["4", 0], "seed": 1234, "adetailer": "A/B"}},
|
||||
}
|
||||
combo_props = {"Node name for S&R": "FlakeCombo", "_combo_active_index": 4}
|
||||
workflow = {
|
||||
"nodes": [
|
||||
{"id": 1, "type": "FlakeModelCombo", "widgets_values": ["SDXL/Base", "SDXL/preset2", OVERRIDES],
|
||||
"properties": {"Node name for S&R": "FlakeModelCombo", "_combo_presets": ["SDXL/preset1", "SDXL/preset2"],
|
||||
"_combo_overrides": [{}, {"steps": 30, "cfg": 5}], "_combo_uids": ["p1", "p2"],
|
||||
"_combo_bypassed": [], "_combo_display_names": {}, "_combo_active_index": 0}},
|
||||
{"id": 2, "type": "FlakeCombo", "widgets_values": ["SDXL/Base", a_raw],
|
||||
"properties": {**combo_props, "_combo_flakes": [{"name": "Characters/bob", "_uid": "a1"}, COMBO_A_ITEM]}},
|
||||
{"id": 10, "type": "sg-uuid", "widgets_values": []},
|
||||
{"id": 4, "type": "FlakeStack", "widgets_values": ["SDXL/Base", stack_raw], "properties": {"Node name for S&R": "FlakeStack"}},
|
||||
{"id": 5, "type": "FlakeGenerate",
|
||||
"widgets_values": [1234, "randomize", "A/B", 0.4, 0, "bbox/face_yolov8m.pt", False, "", 1.5],
|
||||
"properties": {"_images_by_combo": {"1:p1|2:a1": {"filename": "old.png"}}, "_images_by_combo_ad": {"1:p1|2:a1": {"filename": "old_ad.png"}},
|
||||
"_adetailer_ab": True, "_last_image": {"filename": "old.png"}, "_last_image_ad": {"filename": "old_ad.png"}}},
|
||||
],
|
||||
"definitions": {"subgraphs": [{
|
||||
"id": "sg-uuid",
|
||||
"nodes": [{"id": 3, "type": "FlakeCombo", "widgets_values": ["SDXL/Base", b_raw],
|
||||
"properties": {**combo_props, "_combo_flakes": [COMBO_B_ITEM]}}],
|
||||
}]},
|
||||
}
|
||||
return prompt, {"workflow": workflow}
|
||||
|
||||
|
||||
def _wf(extra, nid):
|
||||
return _mod._wf_node(extra, nid)
|
||||
|
||||
|
||||
def test_originals_untouched():
|
||||
prompt, extra = _fixture()
|
||||
before = json.dumps([prompt, extra])
|
||||
_mod._used_only_metadata(prompt, extra)
|
||||
assert json.dumps([prompt, extra]) == before
|
||||
|
||||
|
||||
def test_model_combo_becomes_preset():
|
||||
prompt, extra = _mod._used_only_metadata(*_fixture())
|
||||
assert prompt["1"] == {"class_type": "FlakeModelPreset", "inputs": {"model_family": "SDXL/Base", "preset": "SDXL/preset2", "overrides_json": OVERRIDES}}
|
||||
wf = _wf(extra, "1")
|
||||
assert wf["type"] == "FlakeModelPreset"
|
||||
assert wf["widgets_values"] == ["SDXL/Base", "SDXL/preset2", OVERRIDES]
|
||||
assert wf["properties"] == {"Node name for S&R": "FlakeModelPreset", "_overrides": {"steps": 30, "cfg": 5}, "_fit_size": True}
|
||||
|
||||
|
||||
def test_combo_becomes_stack_with_used_entry():
|
||||
prompt, extra = _mod._used_only_metadata(*_fixture())
|
||||
entry = {"name": "Characters/alice", "loras": [0.8], "variant": {"outfit": "red", "hair": "long"}}
|
||||
assert prompt["2"]["class_type"] == "FlakeStack"
|
||||
assert json.loads(prompt["2"]["inputs"]["flakes_json"]) == [DEFAULT, entry]
|
||||
wf = _wf(extra, "2")
|
||||
assert wf["type"] == "FlakeStack"
|
||||
assert wf["widgets_values"] == ["SDXL/Base", prompt["2"]["inputs"]["flakes_json"]]
|
||||
assert wf["properties"] == {"Node name for S&R": "FlakeStack", "_fit_size": True}
|
||||
|
||||
|
||||
def test_combo_in_subgraph():
|
||||
prompt, extra = _mod._used_only_metadata(*_fixture())
|
||||
entry = {"name": "Styles/ink", "loras": [1.0], "variant": {"mood": "calm"}, "_output_stem_override": "ink"}
|
||||
assert prompt["10:3"]["class_type"] == "FlakeStack"
|
||||
assert json.loads(prompt["10:3"]["inputs"]["flakes_json"]) == [DEFAULT, entry]
|
||||
wf = extra["workflow"]["definitions"]["subgraphs"][0]["nodes"][0]
|
||||
assert wf["type"] == "FlakeStack"
|
||||
assert wf["widgets_values"][1] == prompt["10:3"]["inputs"]["flakes_json"]
|
||||
assert _wf(extra, "3") is None
|
||||
|
||||
|
||||
def test_stack_drops_bypassed_and_collapses_all_variants():
|
||||
prompt, extra = _mod._used_only_metadata(*_fixture())
|
||||
used = json.loads(prompt["4"]["inputs"]["flakes_json"])
|
||||
assert used == [STACK[0], STACK[1], {"name": "Places/beach", "loras": [0.6], "variant": {"time": "dusk"}}]
|
||||
assert _wf(extra, "4")["widgets_values"][1] == prompt["4"]["inputs"]["flakes_json"]
|
||||
assert "_fit_size" not in _wf(extra, "4")["properties"]
|
||||
|
||||
|
||||
def _chain(*stacks, extra_consumer=None):
|
||||
"""Root-level preset 1 -> stacks 2, 3, ... -> Flake Generate 99, as
|
||||
(class_type, entries) pairs; link i+1 feeds node i+2."""
|
||||
ids = [str(i + 2) for i in range(len(stacks))]
|
||||
prompt = {"1": {"class_type": "FlakeModelPreset", "inputs": {"preset": "SDXL/p"}}}
|
||||
nodes = [{"id": 1, "type": "FlakeModelPreset", "outputs": [{"links": [1]}]}]
|
||||
links = []
|
||||
for i, (nid, (cls, entries)) in enumerate(zip(ids, stacks)):
|
||||
raw = json.dumps(entries)
|
||||
prompt[nid] = {"class_type": cls, "inputs": {"model_family": "SDXL/Base", "flake_data": [str(i + 1), 0], "flakes_json": raw}}
|
||||
nodes.append({"id": int(nid), "type": cls, "widgets_values": ["SDXL/Base", raw], "outputs": [{"links": [i + 2]}],
|
||||
"properties": {"Node name for S&R": cls}})
|
||||
links.append([i + 1, i + 1, 0, int(nid), 0, "FLAKE_DATA"])
|
||||
prompt["99"] = {"class_type": "FlakeGenerate", "inputs": {"flake_data": [ids[-1], 0], "seed": 1}}
|
||||
nodes.append({"id": 99, "type": "FlakeGenerate", "inputs": [{"link": len(stacks) + 1}]})
|
||||
links.append([len(stacks) + 1, int(ids[-1]), 0, 99, 0, "FLAKE_DATA"])
|
||||
if extra_consumer:
|
||||
nid = str(extra_consumer)
|
||||
prompt["98"] = {"class_type": "PreviewFlakeData", "inputs": {"flake_data": [nid, 0]}}
|
||||
nodes.append({"id": 98, "type": "PreviewFlakeData"})
|
||||
next(n for n in nodes if str(n["id"]) == nid)["outputs"][0]["links"].append(50)
|
||||
links.append([50, int(nid), 0, 98, 0, "FLAKE_DATA"])
|
||||
return prompt, {"workflow": {"nodes": nodes, "links": links}}
|
||||
|
||||
|
||||
def _fake_flakes(monkeypatch):
|
||||
sizes = {"Sizes/portrait": (832, 1216)}
|
||||
monkeypatch.setattr(_mod.flake_io, "load_flake", lambda name: _mod.flake_io.Flake(name=name, resolution=sizes.get(name)))
|
||||
|
||||
|
||||
SITTING = {"name": "Poses/sitting", "loras": [1.0], "variant": {}}
|
||||
PORTRAIT = {"name": "Sizes/portrait"}
|
||||
PROMPTED = {**DEFAULT, "content": {"prompt": {"positive": "masterpiece"}}}
|
||||
|
||||
|
||||
def _stack_ids(prompt):
|
||||
return sorted(k for k, v in prompt.items() if v["class_type"] == "FlakeStack")
|
||||
|
||||
|
||||
def test_chained_stacks_merge_into_first(monkeypatch):
|
||||
_fake_flakes(monkeypatch)
|
||||
prompt, extra = _mod._used_only_metadata(*_chain(
|
||||
("FlakeCombo", [COMBO_A_ITEM]), ("FlakeCombo", [COMBO_B_ITEM]), ("FlakeStack", [DEFAULT, SITTING])))
|
||||
alice = {"name": "Characters/alice", "loras": [0.8], "variant": {"outfit": "red", "hair": "long"}}
|
||||
ink = {"name": "Styles/ink", "loras": [1.0], "variant": {"mood": "calm"}, "_output_stem_override": "ink"}
|
||||
assert _stack_ids(prompt) == ["2"]
|
||||
assert json.loads(prompt["2"]["inputs"]["flakes_json"]) == [DEFAULT, alice, ink, SITTING]
|
||||
assert prompt["2"]["inputs"]["flake_data"] == ["1", 0]
|
||||
assert prompt["99"]["inputs"]["flake_data"] == ["2", 0]
|
||||
|
||||
wf = extra["workflow"]
|
||||
assert [n["id"] for n in wf["nodes"]] == [1, 2, 99]
|
||||
assert wf["links"] == [[1, 1, 0, 2, 0, "FLAKE_DATA"], [4, 2, 0, 99, 0, "FLAKE_DATA"]]
|
||||
node = _wf(extra, "2")
|
||||
assert node["outputs"][0]["links"] == [4]
|
||||
assert node["widgets_values"] == ["SDXL/Base", prompt["2"]["inputs"]["flakes_json"]]
|
||||
assert node["properties"]["_fit_size"] is True
|
||||
|
||||
|
||||
def test_plain_stacks_merge_and_get_fitted(monkeypatch):
|
||||
_fake_flakes(monkeypatch)
|
||||
prompt, extra = _mod._used_only_metadata(*_chain(("FlakeStack", [PROMPTED, PORTRAIT]), ("FlakeStack", [DEFAULT, SITTING])))
|
||||
assert _stack_ids(prompt) == ["2"]
|
||||
assert json.loads(prompt["2"]["inputs"]["flakes_json"]) == [PROMPTED, PORTRAIT, SITTING]
|
||||
assert _wf(extra, "2")["properties"]["_fit_size"] is True
|
||||
|
||||
|
||||
def test_prompted_default_splits_the_run(monkeypatch):
|
||||
_fake_flakes(monkeypatch)
|
||||
prompt, extra = _mod._used_only_metadata(*_chain(
|
||||
("FlakeStack", [DEFAULT, SITTING]), ("FlakeStack", [PROMPTED]), ("FlakeStack", [DEFAULT, SITTING])))
|
||||
assert _stack_ids(prompt) == ["2", "3"]
|
||||
assert json.loads(prompt["3"]["inputs"]["flakes_json"]) == [PROMPTED, SITTING]
|
||||
assert prompt["3"]["inputs"]["flake_data"] == ["2", 0]
|
||||
assert prompt["99"]["inputs"]["flake_data"] == ["3", 0]
|
||||
assert "_fit_size" not in _wf(extra, "2")["properties"]
|
||||
|
||||
|
||||
def test_sizing_stack_starts_a_new_run(monkeypatch):
|
||||
_fake_flakes(monkeypatch)
|
||||
prompt, _ = _mod._used_only_metadata(*_chain(
|
||||
("FlakeStack", [DEFAULT, PORTRAIT]), ("FlakeStack", [DEFAULT, SITTING]), ("FlakeStack", [DEFAULT, PORTRAIT])))
|
||||
assert _stack_ids(prompt) == ["2", "4"]
|
||||
assert json.loads(prompt["2"]["inputs"]["flakes_json"]) == [DEFAULT, PORTRAIT, SITTING]
|
||||
assert prompt["4"]["inputs"]["flake_data"] == ["2", 0]
|
||||
|
||||
|
||||
def test_stack_with_two_consumers_is_not_merged(monkeypatch):
|
||||
_fake_flakes(monkeypatch)
|
||||
prompt, extra = _mod._used_only_metadata(*_chain(
|
||||
("FlakeStack", [DEFAULT, SITTING]), ("FlakeStack", [DEFAULT, SITTING]), extra_consumer=2))
|
||||
assert _stack_ids(prompt) == ["2", "3"]
|
||||
assert len(extra["workflow"]["links"]) == 4
|
||||
|
||||
|
||||
def test_generate_points_at_saved_image():
|
||||
_, extra = _mod._used_only_metadata(*_fixture())
|
||||
image = {"filename": "SDXL/alice_ad_00007.png", "subfolder": "", "type": "output"}
|
||||
_mod._set_generate_image(extra, "5", 1234, image)
|
||||
wf = _wf(extra, "5")
|
||||
assert wf["widgets_values"][:3] == [1234, "fixed", "A/B"]
|
||||
assert wf["properties"] == {"_images_by_combo": {"": image}, "_images_by_combo_ad": {}, "_adetailer_ab": False, "_last_image": image}
|
||||
|
||||
|
||||
def test_bundle_holds_flakes_and_preset_feeding_generate(monkeypatch):
|
||||
prompt, _ = _mod._used_only_metadata(*_fixture())
|
||||
prompt["6"] = {"class_type": "FlakeStack", "inputs": {"flake_data": ["1", 0], "flakes_json": json.dumps([{"name": "Other/unused"}])}}
|
||||
fio = _mod.flake_io
|
||||
variants = {"outfit": {"red": {}, "blue": {}}, "hair": {"long": {}}, "time": {"dusk": {}, "noon": {}}, "mood": {"calm": {}}}
|
||||
monkeypatch.setattr(fio, "load_flake", lambda name: fio.Flake(name=name, variants=variants))
|
||||
monkeypatch.setattr(fio, "load_preset", lambda name: types.SimpleNamespace(
|
||||
flake_links=[fio.FlakeLink(target="Places/beach", variant={"time": "noon"})]))
|
||||
monkeypatch.setattr(fio, "collect_bundle", lambda flakes, presets, used: (sorted(flakes), presets, used))
|
||||
assert _mod._flake_bundle(prompt, "5") == (
|
||||
["Characters/alice", "Places/beach", "Poses/sitting", "Styles/ink"], ["SDXL/preset2"],
|
||||
{"Characters/alice": {"outfit": {"red"}, "hair": {"long"}}, "Styles/ink": {"mood": {"calm"}}, "Places/beach": {"time": {"dusk", "noon"}}})
|
||||
|
||||
|
||||
def test_entry_keeps_overrides_of_used_choices_only(monkeypatch):
|
||||
fio = _mod.flake_io
|
||||
variants = {"outfit": {"red": {"default": True}, "blue": {}}, "hat": {"cap": {}}}
|
||||
monkeypatch.setattr(fio, "load_flake", lambda name: fio.Flake(name=name, variants=variants))
|
||||
entry = {"name": "Characters/alice", "variant": {"hat": ["cap"]},
|
||||
"variant_loras": {"outfit": {"red": [0.5], "blue": [0.7]}, "hat": {"cap": [0.2]}},
|
||||
"variant_link_overrides": {"outfit": {"blue": [{"variant": {"style": "beret"}}]}}}
|
||||
assert _mod._used_entry(entry) == {"name": "Characters/alice", "variant": {"hat": "cap"},
|
||||
"variant_loras": {"outfit": {"red": [0.5]}, "hat": {"cap": [0.2]}}}
|
||||
|
||||
|
||||
|
||||
def test_entry_keeps_toggles_of_its_options_only(monkeypatch):
|
||||
fio = _mod.flake_io
|
||||
variants = {"skin": {"classic": {"prompt_options": [{"name": "tails"}]}, "arcade": {"prompt_options": [{"name": "visor"}]}}}
|
||||
monkeypatch.setattr(fio, "load_flake", lambda name: fio.Flake(
|
||||
name=name, variants=variants, options={"face": fio.PromptOption("eyes")}))
|
||||
entry = {"name": "Characters/ahri", "variant": {"skin": "classic"},
|
||||
"option_overrides": {"face": False, "tails": True, "visor": True}}
|
||||
assert _mod._used_entry(entry)["option_overrides"] == {"face": False, "tails": True}
|
||||
entry["option_overrides"] = {"visor": True}
|
||||
assert "option_overrides" not in _mod._used_entry(entry)
|
||||
|
||||
def test_without_workflow():
|
||||
prompt, extra = _fixture()
|
||||
meta_prompt, meta_extra = _mod._used_only_metadata(prompt, None)
|
||||
assert meta_extra is None
|
||||
assert meta_prompt["1"]["class_type"] == "FlakeModelPreset"
|
||||
_mod._set_generate_image(meta_extra, "5", 1234, {})
|
||||
|
||||
|
||||
class _Saver:
|
||||
"""SaveImage writing empty files named like ComfyUI does, under ``sub/``."""
|
||||
def save_images(self, images, filename_prefix, prompt=None, extra_pnginfo=None):
|
||||
assert prompt is None and extra_pnginfo is None
|
||||
out = []
|
||||
for i in range(1, len(images) + 1):
|
||||
name = f"{filename_prefix}_{i:05}_.png"
|
||||
open(os.path.join(_mod.folder_paths.get_output_directory(), "sub", name), "w").close()
|
||||
out.append({"filename": name, "subfolder": "sub", "type": "output"})
|
||||
return {"ui": {"images": out}}
|
||||
|
||||
|
||||
def _generate(monkeypatch, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
fp, images = _mod.folder_paths, [types.SimpleNamespace(shape=(64, 64, 3))] * 2
|
||||
monkeypatch.setattr(fp, "get_output_directory", lambda: str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(fp, "get_save_image_path", lambda prefix, *a: ("", prefix, 1, "sub", prefix), raising=False)
|
||||
monkeypatch.setattr(_mod.flake_compose, "maybe_zero_out_negative", lambda neg, cfg: neg, raising=False)
|
||||
monkeypatch.setitem(sys.modules, "_flakemeta.flake_postprocess",
|
||||
types.SimpleNamespace(run_face_detailer=lambda imgs, *a, **k: imgs))
|
||||
monkeypatch.setattr(_mod, "KSampler", lambda: types.SimpleNamespace(sample=lambda *a, **k: ("latent",)))
|
||||
monkeypatch.setattr(_mod, "VAEDecode", lambda: types.SimpleNamespace(decode=lambda vae, latent: (images,)))
|
||||
monkeypatch.setattr(_mod, "SaveImage", _Saver)
|
||||
monkeypatch.setattr(_mod, "_flake_bundle", lambda prompt, nid: "bundle")
|
||||
model = types.SimpleNamespace(model_options={"transformer_options": {}})
|
||||
flake_data = ((model, "clip", "vae"), ("pos", "neg", "latent", 64, 64, "p", "n"), (4, 1.0, "euler", "simple", 1.0))
|
||||
prompt, extra = _fixture()
|
||||
_mod.FlakeGenerate().execute(flake_data, 1234, adetailer="A/B", prompt=prompt, extra_pnginfo=extra, unique_id="5")
|
||||
|
||||
|
||||
def test_generate_writes_a_sidecar_per_saved_image(monkeypatch, tmp_path):
|
||||
_generate(monkeypatch, tmp_path)
|
||||
names = ["ComfyUI_00001.png", "ComfyUI_00002.png", "ComfyUI_ad_00001.png", "ComfyUI_ad_00002.png"]
|
||||
assert sorted(os.listdir(tmp_path / "sub" / ".flake_metadata")) == [n + ".json" for n in names]
|
||||
for name in names:
|
||||
assert (tmp_path / "sub" / name).exists()
|
||||
with open(tmp_path / "sub" / ".flake_metadata" / f"{name}.json", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
assert meta["prompt"]["1"]["class_type"] == "FlakeModelPreset"
|
||||
assert meta["workflow"]["extra"]["flake_bundle"] == "bundle"
|
||||
# Each variant's sidecar points Flake Generate at that variant's first image.
|
||||
first = "ComfyUI_ad_00001.png" if "_ad_" in name else "ComfyUI_00001.png"
|
||||
assert _wf(meta, "5")["properties"]["_last_image"]["filename"] == first
|
||||
|
||||
|
||||
def test_disable_metadata_writes_no_sidecar(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(_mod.args, "disable_metadata", True)
|
||||
_generate(monkeypatch, tmp_path)
|
||||
assert sorted(os.listdir(tmp_path / "sub")) == ["ComfyUI_00001.png", "ComfyUI_00002.png", "ComfyUI_ad_00001.png", "ComfyUI_ad_00002.png"]
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Tests for model-patch ControlNets: Anima LLLite and Z-Image Fun (#365), and
|
||||
Anima VACE routed to the ComfyUI-Advanced-ControlNet-Anima fork (#378).
|
||||
|
||||
Stubs the ComfyUI runtime (including ``comfy_extras.nodes_model_patch``) and
|
||||
loads the sources under a synthetic package, matching test_preset_loader.py.
|
||||
Runs ``FlakeStack.execute`` end to end and records what reaches the core
|
||||
ControlNet / model-patch nodes.
|
||||
|
||||
Run directly (``python tests/test_model_patch_cn.py``) or via pytest.
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
PKG = "flakes_under_test_patch_cn"
|
||||
|
||||
FILES = {"controlnet": [], "model_patches": []}
|
||||
MTIMES = {}
|
||||
HEADERS = {}
|
||||
NODE_MAPPINGS = {}
|
||||
CALLS = {"patch_load": [], "lllite": [], "zimage": [], "cn_load": [], "cn_apply": [],
|
||||
"acn_load": [], "acn_apply": []}
|
||||
|
||||
ACN_PKG = "fake_acn.adv_control"
|
||||
|
||||
|
||||
class FakeModel(str):
|
||||
"""A MODEL stand-in that compares by name; flow-model sigmas = 1 - percent."""
|
||||
|
||||
def get_model_object(self, name):
|
||||
assert name == "model_sampling"
|
||||
return types.SimpleNamespace(percent_to_sigma=lambda p: 1.0 - p)
|
||||
|
||||
|
||||
class ACNLoader:
|
||||
def load_controlnet(self, cnet, _tk_opt=None):
|
||||
CALLS["acn_load"].append(cnet)
|
||||
return (f"ACN({cnet})",)
|
||||
|
||||
|
||||
class ACNApply:
|
||||
def apply_controlnet(self, positive, negative, control_net, image, strength, start_percent, end_percent,
|
||||
mask_optional=None, vae_optional=None):
|
||||
CALLS["acn_apply"].append((control_net, image, strength, start_percent, end_percent, vae_optional))
|
||||
return (positive + [["VACE", {}]], negative)
|
||||
|
||||
|
||||
ACNLoader.__module__ = ACNApply.__module__ = f"{ACN_PKG}.nodes_main"
|
||||
|
||||
|
||||
def _install_acn(with_vace):
|
||||
for name in ("fake_acn", ACN_PKG):
|
||||
mod = types.ModuleType(name)
|
||||
mod.__path__ = []
|
||||
sys.modules[name] = mod
|
||||
sys.modules.pop(f"{ACN_PKG}.control_anima_vace", None)
|
||||
if with_vace:
|
||||
vace = types.ModuleType(f"{ACN_PKG}.control_anima_vace")
|
||||
vace.__spec__ = importlib.machinery.ModuleSpec(vace.__name__, None)
|
||||
sys.modules[vace.__name__] = vace
|
||||
NODE_MAPPINGS["ACN_ControlNetLoaderAdvanced"] = ACNLoader
|
||||
NODE_MAPPINGS["ACN_AdvancedControlNetApply_v2"] = ACNApply
|
||||
|
||||
|
||||
def _stub_externals():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda cat: list(FILES.get(cat, []))
|
||||
fp.get_full_path_or_raise = lambda cat, name: f"/fake/{cat}/{name}"
|
||||
fp.get_input_directory = lambda: "/fake/input"
|
||||
fp.base_path = "/fake"
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
for name in ("numpy", "torch"):
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
if "PIL" not in sys.modules:
|
||||
pil = types.ModuleType("PIL")
|
||||
pil.Image = types.SimpleNamespace(open=None)
|
||||
pil.ImageOps = types.SimpleNamespace(exif_transpose=None)
|
||||
sys.modules["PIL"] = pil
|
||||
sys.modules["PIL.Image"] = pil.Image
|
||||
sys.modules["PIL.ImageOps"] = pil.ImageOps
|
||||
|
||||
comfy = types.ModuleType("comfy")
|
||||
comfy.sd = types.ModuleType("comfy.sd")
|
||||
comfy.utils = types.ModuleType("comfy.utils")
|
||||
sys.modules["comfy"] = comfy
|
||||
sys.modules["comfy.sd"] = comfy.sd
|
||||
sys.modules["comfy.utils"] = comfy.utils
|
||||
comfy.utils.safetensors_header = lambda path: HEADERS.get(path)
|
||||
# No scheduled LoRAs here, so carry_schedules sees no hooks.
|
||||
comfy.hooks = types.ModuleType("comfy.hooks")
|
||||
comfy.hooks.HookGroup = types.SimpleNamespace(combine_all_hooks=lambda groups: None)
|
||||
comfy.hooks.set_hooks_for_conditioning = lambda cond, hooks, append_hooks=True: cond
|
||||
sys.modules["comfy.hooks"] = comfy.hooks
|
||||
sys.modules["node_helpers"] = types.ModuleType("node_helpers")
|
||||
|
||||
class CLIPTextEncode:
|
||||
def encode(self, clip, text):
|
||||
return ([[f"COND({text})", {}]],)
|
||||
|
||||
class EmptyLatentImage:
|
||||
def generate(self, w, h, b):
|
||||
return ({"samples": f"latent{w}x{h}"},)
|
||||
|
||||
class ControlNetLoader:
|
||||
def load_controlnet(self, name):
|
||||
CALLS["cn_load"].append(name)
|
||||
return (f"CN({name})",)
|
||||
|
||||
class ControlNetApplyAdvanced:
|
||||
def apply_controlnet(self, pos, neg, cn, image, strength, start, end):
|
||||
CALLS["cn_apply"].append((cn, strength, start, end))
|
||||
return pos + [["CTRL", {}]], neg
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
nodes.CLIPTextEncode = CLIPTextEncode
|
||||
nodes.EmptyLatentImage = EmptyLatentImage
|
||||
nodes.ControlNetLoader = ControlNetLoader
|
||||
nodes.ControlNetApplyAdvanced = ControlNetApplyAdvanced
|
||||
nodes.LoraLoader = type("LoraLoader", (), {})
|
||||
nodes.NODE_CLASS_MAPPINGS = NODE_MAPPINGS
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
class ModelPatchLoader:
|
||||
def load_model_patch(self, name):
|
||||
CALLS["patch_load"].append(name)
|
||||
return (object(),)
|
||||
|
||||
class AnimaLLLiteApply:
|
||||
def apply_patch(self, model, model_patch, image, strength, start_percent, end_percent):
|
||||
CALLS["lllite"].append((model, model_patch, image, strength, start_percent, end_percent))
|
||||
return (FakeModel(f"{model}+lllite"),)
|
||||
|
||||
class ZImageFunControlnet:
|
||||
def diffsynth_controlnet(self, model, model_patch, vae, image=None, strength=1.0):
|
||||
CALLS["zimage"].append((model, model_patch, vae, image, strength))
|
||||
return (f"{model}+zfun",)
|
||||
|
||||
extras = types.ModuleType("comfy_extras")
|
||||
nmp = types.ModuleType("comfy_extras.nodes_model_patch")
|
||||
nmp.ModelPatchLoader = ModelPatchLoader
|
||||
nmp.AnimaLLLiteApply = AnimaLLLiteApply
|
||||
nmp.ZImageFunControlnet = ZImageFunControlnet
|
||||
extras.nodes_model_patch = nmp
|
||||
sys.modules["comfy_extras"] = extras
|
||||
sys.modules["comfy_extras.nodes_model_patch"] = nmp
|
||||
|
||||
|
||||
_stub_externals()
|
||||
|
||||
|
||||
def _load(name):
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if PKG not in sys.modules:
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [root]
|
||||
sys.modules[PKG] = pkg
|
||||
full = f"{PKG}.{name}"
|
||||
if full in sys.modules:
|
||||
return sys.modules[full]
|
||||
spec = importlib.util.spec_from_file_location(full, os.path.join(root, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[full] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
flake_io = _load("flake_io")
|
||||
flake_compose = _load("flake_compose")
|
||||
full_flake_node = _load("full_flake_node")
|
||||
|
||||
flake_compose._load_cn_image = lambda name: f"IMG({name})"
|
||||
flake_compose._fit_cn_image = lambda image, w, h: f"{image}@{w}x{h}"
|
||||
flake_compose.os = types.SimpleNamespace(path=types.SimpleNamespace(
|
||||
getmtime=lambda p: MTIMES.get(p.rsplit("/", 1)[-1], 1.0)))
|
||||
|
||||
|
||||
def _reset():
|
||||
for v in CALLS.values():
|
||||
v.clear()
|
||||
for v in FILES.values():
|
||||
v.clear()
|
||||
MTIMES.clear()
|
||||
HEADERS.clear()
|
||||
NODE_MAPPINGS.clear()
|
||||
flake_compose._CN_PATCH_CACHE.clear()
|
||||
|
||||
|
||||
def _cn(cn_type="depth", **kw):
|
||||
base = dict(type=cn_type, model_name="", image_name="pose_a", strength=0.8,
|
||||
start_percent=0.1, end_percent=0.9)
|
||||
base.update(kw)
|
||||
return flake_io.ControlNetEntry(**base)
|
||||
|
||||
|
||||
def _run(model_family, *flake_cns, upstream_family=None):
|
||||
flakes = {f"f{i}": flake_io.Flake(name=f"f{i}", controlnets=list(cns)) for i, cns in enumerate(flake_cns)}
|
||||
flake_io.resolve = lambda e: flakes[e["name"]]
|
||||
flake_data = (
|
||||
(FakeModel("BASE_MODEL"), "CLIP", "VAE"),
|
||||
([["P", {}]], [["N", {}]], {"samples": "l"}, 1024, 1024, "", "", {"stems": [], "model_family": upstream_family}),
|
||||
(30, 4.0, "euler", "simple", 1.0),
|
||||
)
|
||||
entries = [{"name": n} for n in flakes]
|
||||
import json
|
||||
return full_flake_node.FlakeStack().execute(model_family, flake_data, json.dumps(entries))[0]
|
||||
|
||||
|
||||
def test_anima_depth_patches_model_with_lllite():
|
||||
_reset()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("Anima/Base", [_cn("depth")])
|
||||
(model, _clip, _vae), gen, _ = out
|
||||
assert model == "BASE_MODEL+lllite"
|
||||
assert CALLS["patch_load"] == ["anima/anima-lllite-depth-1.safetensors"]
|
||||
m, _patch, image, strength, start, end = CALLS["lllite"][0]
|
||||
assert (m, image, strength, start, end) == ("BASE_MODEL", "IMG(pose_a)@1024x1024", 0.8, 0.1, 0.9)
|
||||
# Conditioning is untouched — no SDXL ControlNet involved.
|
||||
assert not CALLS["cn_load"] and not CALLS["cn_apply"]
|
||||
assert gen[0] == [["COND()", {}]]
|
||||
|
||||
|
||||
def test_multiple_anima_cns_chain_on_the_model():
|
||||
_reset()
|
||||
FILES["model_patches"] += ["anima/anima-lllite-depth-1.safetensors", "anima/anima-lllite-pose-1.safetensors"]
|
||||
out = _run("Anima/Base", [_cn("depth")], [_cn("openpose", image_name="pose_b")])
|
||||
assert out[0][0] == "BASE_MODEL+lllite+lllite"
|
||||
assert [c[0] for c in CALLS["lllite"]] == ["BASE_MODEL", "BASE_MODEL+lllite"]
|
||||
|
||||
|
||||
def test_zimage_passes_vae_and_strength():
|
||||
_reset()
|
||||
FILES["model_patches"].append("zimage/Z-Image-Turbo-Fun-Controlnet-Union-2.1.safetensors")
|
||||
out = _run("ZImage/Turbo", [_cn("canny", start_percent=0.0, end_percent=1.0)])
|
||||
assert out[0][0] == "BASE_MODEL+zfun"
|
||||
m, _patch, vae, image, strength = CALLS["zimage"][0]
|
||||
assert (m, vae, image, strength) == ("BASE_MODEL", "VAE", "IMG(pose_a)@1024x1024", 0.8)
|
||||
|
||||
|
||||
def test_preset_family_wins_over_stale_widget():
|
||||
# A reroute/subgraph leaves the widget on SDXL/Base; the family recorded by
|
||||
# the preset loader must still route Anima CNs to LLLite (#382).
|
||||
_reset()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("SDXL/Base", [_cn("depth")], upstream_family="Anima/Base")
|
||||
assert out[0][0] == "BASE_MODEL+lllite"
|
||||
assert not CALLS["cn_load"] and not CALLS["cn_apply"]
|
||||
|
||||
|
||||
def test_sdxl_path_unchanged():
|
||||
_reset()
|
||||
FILES["controlnet"].append("sdxl/controlnet_depth_sdxl.safetensors")
|
||||
out = _run("SDXL/Illustrious", [_cn("depth")])
|
||||
assert out[0][0] == "BASE_MODEL"
|
||||
assert CALLS["cn_load"] == ["sdxl/controlnet_depth_sdxl.safetensors"]
|
||||
assert CALLS["cn_apply"][0][1:] == (0.8, 0.1, 0.9)
|
||||
assert not CALLS["patch_load"] and not CALLS["lllite"]
|
||||
|
||||
|
||||
def test_zero_strength_and_missing_image_are_skipped():
|
||||
_reset()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("Anima/Base", [_cn("depth", strength=0), _cn("depth", image_name=" ")])
|
||||
assert out[0][0] == "BASE_MODEL"
|
||||
assert not CALLS["patch_load"]
|
||||
|
||||
|
||||
def test_patch_reused_across_combo_runs():
|
||||
_reset()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
_run("Anima/Base", [_cn("depth", image_name="pose_a")])
|
||||
_run("Anima/Base", [_cn("depth", image_name="pose_b")])
|
||||
_run("Anima/Base", [_cn("depth", image_name="pose_c")])
|
||||
assert CALLS["patch_load"] == ["anima/anima-lllite-depth-1.safetensors"]
|
||||
patches = {c[1] for c in CALLS["lllite"]}
|
||||
assert len(patches) == 1
|
||||
|
||||
|
||||
def test_patch_reloads_when_file_changes():
|
||||
_reset()
|
||||
name = "anima/anima-lllite-depth-1.safetensors"
|
||||
flake_compose.load_cn_patch(name)
|
||||
MTIMES["anima-lllite-depth-1.safetensors"] = 2.0
|
||||
flake_compose.load_cn_patch(name)
|
||||
assert CALLS["patch_load"] == [name, name]
|
||||
|
||||
|
||||
def test_patch_cache_is_bounded_lru():
|
||||
_reset()
|
||||
names = [f"p{i}.safetensors" for i in range(flake_compose._CN_PATCH_CACHE_SIZE + 1)]
|
||||
for n in names:
|
||||
flake_compose.load_cn_patch(n)
|
||||
assert names[0] not in flake_compose._CN_PATCH_CACHE
|
||||
assert list(flake_compose._CN_PATCH_CACHE) == names[1:]
|
||||
# Touching the oldest survivor makes it most recent.
|
||||
flake_compose.load_cn_patch(names[1])
|
||||
assert list(flake_compose._CN_PATCH_CACHE)[-1] == names[1]
|
||||
|
||||
|
||||
VACE_FILE = "anima/Anima-ControlNet-VACE-Depth.safetensors"
|
||||
|
||||
|
||||
def _add_vace_file(keys=("control_blocks.0.after_proj.weight", "control_blocks.0.attn.weight")):
|
||||
FILES["controlnet"].append(VACE_FILE)
|
||||
HEADERS[f"/fake/controlnet/{VACE_FILE}"] = json.dumps({k: {} for k in keys}).encode()
|
||||
|
||||
|
||||
def test_anima_vace_routes_to_fork_with_vae():
|
||||
_reset()
|
||||
_install_acn(with_vace=True)
|
||||
_add_vace_file()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("Anima/Base", [_cn("depth", model_name="Anima-ControlNet-VACE-Depth")])
|
||||
(model, _clip, _vae), gen, _ = out
|
||||
assert CALLS["acn_load"] == [VACE_FILE]
|
||||
assert CALLS["acn_apply"] == [(f"ACN({VACE_FILE})", "IMG(pose_a)@1024x1024", 0.8, 0.1, 0.9, "VAE")]
|
||||
assert gen[0][-1] == ["VACE", {}]
|
||||
# Conditioning-level control: the model is not patched.
|
||||
assert model == "BASE_MODEL"
|
||||
assert not CALLS["patch_load"] and not CALLS["lllite"]
|
||||
|
||||
|
||||
def test_anima_lllite_stays_default_when_vace_file_present():
|
||||
_reset()
|
||||
_install_acn(with_vace=True)
|
||||
_add_vace_file()
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("Anima/Base", [_cn("depth")])
|
||||
assert out[0][0] == "BASE_MODEL+lllite"
|
||||
assert not CALLS["acn_load"]
|
||||
|
||||
|
||||
def test_anima_controlnet_file_without_vace_keys_uses_lllite():
|
||||
_reset()
|
||||
_install_acn(with_vace=True)
|
||||
_add_vace_file(keys=("lllite_unet.weight",))
|
||||
FILES["model_patches"].append("anima/anima-lllite-depth-1.safetensors")
|
||||
out = _run("Anima/Base", [_cn("depth", model_name="Anima-ControlNet-VACE-Depth")])
|
||||
assert out[0][0] == "BASE_MODEL+lllite"
|
||||
assert not CALLS["acn_load"]
|
||||
|
||||
|
||||
def _vace_error():
|
||||
try:
|
||||
_run("Anima/Base", [_cn("depth", model_name="Anima-ControlNet-VACE-Depth")])
|
||||
except RuntimeError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def test_anima_vace_without_fork_errors():
|
||||
_reset()
|
||||
_add_vace_file()
|
||||
assert "ComfyUI-Advanced-ControlNet-Anima" in (_vace_error() or "")
|
||||
|
||||
|
||||
def test_anima_vace_with_upstream_acn_errors():
|
||||
_reset()
|
||||
_install_acn(with_vace=False)
|
||||
_add_vace_file()
|
||||
assert "ComfyUI-Advanced-ControlNet-Anima" in (_vace_error() or "")
|
||||
assert not CALLS["acn_load"]
|
||||
|
||||
|
||||
LLLITE_FILES = ["anima/anima-lllite-depth-1.safetensors", "anima/anima-lllite-any-test-like-v2.safetensors"]
|
||||
|
||||
|
||||
def test_anima_explicit_lllite_file_wins_over_type():
|
||||
_reset()
|
||||
FILES["model_patches"] += LLLITE_FILES
|
||||
out = _run("Anima/Base", [_cn("depth", model_name="anima/anima-lllite-any-test-like-v2.safetensors")])
|
||||
assert out[0][0] == "BASE_MODEL+lllite"
|
||||
assert CALLS["patch_load"] == ["anima/anima-lllite-any-test-like-v2.safetensors"]
|
||||
|
||||
|
||||
def test_anima_explicit_vace_wins_over_type():
|
||||
_reset()
|
||||
_install_acn(with_vace=True)
|
||||
_add_vace_file()
|
||||
FILES["model_patches"] += LLLITE_FILES
|
||||
out = _run("Anima/Base", [_cn("depth", model_name=VACE_FILE)])
|
||||
assert out[0][0] == "BASE_MODEL"
|
||||
assert CALLS["acn_load"] == [VACE_FILE]
|
||||
assert not CALLS["patch_load"]
|
||||
|
||||
|
||||
def test_anima_stale_model_falls_back_to_type():
|
||||
_reset()
|
||||
FILES["model_patches"] += LLLITE_FILES + ["zimage/Z-Image-Turbo-Fun-Controlnet-Union-2.1.safetensors"]
|
||||
FILES["controlnet"].append("sdxl/controlnet_depth_sdxl.safetensors")
|
||||
for stale in ("controlnet_depth_sdxl", "Z-Image-Turbo-Fun-Controlnet-Union-2.1", "anima/deleted.safetensors"):
|
||||
CALLS["patch_load"].clear()
|
||||
flake_compose._CN_PATCH_CACHE.clear()
|
||||
out = _run("Anima/Base", [_cn("depth", model_name=stale)])
|
||||
assert out[0][0] == "BASE_MODEL+lllite"
|
||||
assert CALLS["patch_load"] == ["anima/anima-lllite-depth-1.safetensors"], stale
|
||||
|
||||
|
||||
def test_explicit_model_ignored_with_type_outside_anima():
|
||||
_reset()
|
||||
FILES["controlnet"] += ["sdxl/controlnet_depth_sdxl.safetensors", "sdxl/other_depth.safetensors"]
|
||||
_run("SDXL/Base", [_cn("depth", model_name="other_depth")])
|
||||
assert CALLS["cn_load"] == ["sdxl/controlnet_depth_sdxl.safetensors"]
|
||||
|
||||
|
||||
def test_anima_cn_files_lists_lllite_and_vace():
|
||||
_reset()
|
||||
_add_vace_file()
|
||||
FILES["controlnet"] += ["anima/not-vace.safetensors", "sdxl/controlnet_depth_sdxl.safetensors"]
|
||||
HEADERS["/fake/controlnet/anima/not-vace.safetensors"] = json.dumps({"x.weight": {}}).encode()
|
||||
FILES["model_patches"] += LLLITE_FILES + ["zimage/Z-Image-Turbo-Fun-Controlnet-Union-2.1.safetensors"]
|
||||
assert flake_compose.anima_cn_files() == [{"name": n, "kind": "lllite"} for n in LLLITE_FILES] + [
|
||||
{"name": VACE_FILE, "kind": "vace"}]
|
||||
|
||||
|
||||
def _logged_run(*args):
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
logger = logging.getLogger()
|
||||
level = logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
try:
|
||||
_run(*args)
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(level)
|
||||
return [r.getMessage() for r in records if r.getMessage().startswith("[flakes] Anima")]
|
||||
|
||||
|
||||
def test_anima_patch_logs_file_kind_and_sigma_range():
|
||||
_reset()
|
||||
_install_acn(with_vace=True)
|
||||
_add_vace_file()
|
||||
FILES["model_patches"] += LLLITE_FILES
|
||||
lines = _logged_run("Anima/Base", [_cn("depth"), _cn("depth", model_name=VACE_FILE)])
|
||||
assert lines == [
|
||||
"[flakes] Anima lllite anima/anima-lllite-depth-1.safetensors image=pose_a fit=1024x1024 "
|
||||
"strength=0.80 start=0.10 end=0.90 sigma=0.900..0.100",
|
||||
f"[flakes] Anima vace {VACE_FILE} image=pose_a fit=1024x1024 "
|
||||
"strength=0.80 start=0.10 end=0.90 sigma=0.900..0.100",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fname, fn in sorted(globals().items()):
|
||||
if fname.startswith("test_") and callable(fn):
|
||||
fn()
|
||||
print(f"PASS {fname}")
|
||||
print("\nALL PASSED")
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Tests for `./` paths relative to the flake yaml, the owned cover
|
||||
location (#391) and taking ownership of external data on save (#392). Same loading approach as test_variant_extras.py, with the
|
||||
stub `folder_paths` pointing the flakes root at a temp dir."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.get_folder_paths = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
fp.folder_names_and_paths = {}
|
||||
|
||||
def add_model_folder_path(category, path):
|
||||
paths = fp.folder_names_and_paths[category][0]
|
||||
if path not in paths:
|
||||
paths.append(path)
|
||||
fp.add_model_folder_path = add_model_folder_path
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_pf")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_pf"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_pf.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_pf.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod, fp
|
||||
|
||||
|
||||
fio, fp = _load_flake_io()
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\nfennec"
|
||||
|
||||
MUSASHI = """
|
||||
loras:
|
||||
- {name: hero, path: ./musashi/musashi_hero.safetensors}
|
||||
- {name: other, path: chars/other.safetensors}
|
||||
controlnets:
|
||||
- {type: openpose, model: op.safetensors, image: ./musashi/cnets/001_x_3_hed.png}
|
||||
- {type: openpose, model: op.safetensors, image: poses/stand.png}
|
||||
variants:
|
||||
outfit:
|
||||
armor:
|
||||
image: ./musashi/musashi_outfit_armor.png
|
||||
loras: [{name: armor, path: ./musashi/armor.safetensors}]
|
||||
controlnets: [{type: depth, model: d.safetensors, image: ./musashi/cnets/depth.png}]
|
||||
"""
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _flakes_root():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
fp.folder_names_and_paths["flakes"] = ([tmp], {".yaml", ".yml"})
|
||||
_write(os.path.join(tmp, "chars", "musashi.yaml"), MUSASHI.encode())
|
||||
yield tmp
|
||||
|
||||
|
||||
def _write(path, data=b""):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def _read(path):
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _raises_value_error(fn):
|
||||
try:
|
||||
fn()
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_lora_dot_path_resolves_to_absolute_path():
|
||||
with _flakes_root() as root:
|
||||
flake = fio.resolve({"name": "chars/musashi", "variant": {"outfit": "armor"}})
|
||||
assert [lr.path for lr in flake.loras] == [
|
||||
os.path.join(root, "chars", "musashi", "musashi_hero.safetensors"),
|
||||
"chars/other.safetensors",
|
||||
os.path.join(root, "chars", "musashi", "armor.safetensors"),
|
||||
]
|
||||
|
||||
|
||||
def test_lora_dot_path_at_root_level():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "solo.yaml"), b"loras: [{path: ./solo/x.safetensors}]")
|
||||
assert fio.load_flake("solo").loras[0].path == os.path.join(root, "solo", "x.safetensors")
|
||||
|
||||
|
||||
def test_cn_dot_image_resolves_to_absolute_path():
|
||||
with _flakes_root() as root:
|
||||
flake = fio.resolve({"name": "chars/musashi", "variant": {"outfit": "armor"}})
|
||||
images = [cn.image_name for cn in flake.controlnets]
|
||||
assert images[0] == os.path.normpath(os.path.join(root, "chars", "musashi", "cnets", "001_x_3_hed.png"))
|
||||
assert images[1] == "poses/stand.png"
|
||||
assert images[2] == os.path.normpath(os.path.join(root, "chars", "musashi", "cnets", "depth.png"))
|
||||
|
||||
|
||||
def test_inline_flake_keeps_dot_paths():
|
||||
with _flakes_root():
|
||||
flake = fio.resolve({"inline": True, "content": {
|
||||
"loras": [{"path": "./a.safetensors"}],
|
||||
"controlnets": [{"type": "openpose", "model": "op.safetensors", "image": "./a.png"}],
|
||||
}})
|
||||
assert flake.loras[0].path == "./a.safetensors"
|
||||
assert flake.controlnets[0].image_name == "./a.png"
|
||||
|
||||
|
||||
def test_variant_image_dot_path():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi_outfit_armor.png"), PNG)
|
||||
assert _read(fio.variant_image_file("chars/musashi", "outfit", "armor")) == PNG
|
||||
|
||||
|
||||
def test_asset_served_under_yaml_dir():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "chars", "musashi", "cnets", "001_x_3_hed.png"), PNG)
|
||||
assert _read(fio.flake_asset_file("chars/musashi", "./musashi/cnets/001_x_3_hed.png")) == PNG
|
||||
assert fio.flake_asset_file("chars/musashi", "./musashi/missing.png") is None
|
||||
|
||||
|
||||
def test_asset_rejects_non_image_and_bad_paths():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi_hero.safetensors"), b"weights")
|
||||
_write(os.path.join(root, "secret.png"), PNG)
|
||||
assert fio.flake_asset_file("chars/musashi", "./musashi/musashi_hero.safetensors") is None
|
||||
assert _raises_value_error(lambda: fio.flake_asset_file("chars/musashi", "musashi/cnets/x.png"))
|
||||
assert _raises_value_error(lambda: fio.flake_asset_file("chars/musashi", "./../secret.png"))
|
||||
|
||||
|
||||
def test_cover_prefers_owned_location():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "chars", "musashi.jpg"), b"legacy")
|
||||
assert _read(fio.cover_file("chars/musashi")) == b"legacy"
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi.png"), PNG)
|
||||
assert _read(fio.cover_file("chars/musashi")) == PNG
|
||||
|
||||
|
||||
def test_delete_flake_keeps_owned_folder():
|
||||
with _flakes_root() as root:
|
||||
legacy = os.path.join(root, "chars", "musashi.jpg")
|
||||
owned_cover = os.path.join(root, "chars", "musashi", "musashi.png")
|
||||
_write(legacy, b"legacy")
|
||||
_write(owned_cover, PNG)
|
||||
fio.delete_flake("chars/musashi")
|
||||
assert not os.path.exists(os.path.join(root, "chars", "musashi.yaml"))
|
||||
assert not os.path.exists(legacy)
|
||||
assert os.path.isfile(owned_cover)
|
||||
|
||||
|
||||
def test_list_dirs_skips_owned_folders():
|
||||
with _flakes_root() as root:
|
||||
os.makedirs(os.path.join(root, "chars", "musashi", "cnets"))
|
||||
os.makedirs(os.path.join(root, "chars", "loose"))
|
||||
_write(os.path.join(root, "solo.yml"), b"{}")
|
||||
os.makedirs(os.path.join(root, "solo"))
|
||||
assert fio.list_dirs() == ["chars", "chars/loose"]
|
||||
|
||||
|
||||
def test_list_flake_info():
|
||||
with _flakes_root() as root:
|
||||
_write(os.path.join(root, "chars", "fennec.yaml"), b"name: Fennec\nfavorite: true\nloras:\n - {name: ' swish ', tag_name: true}\n - {name: plain}\n - {name: second, tag_name: true}\nvariants:\n ears: {big: {default: true, loras: [{name: ears, tag_name: true}]}}\n")
|
||||
# Without a flake tag, the first default choice's tag-name LoRA wins (#476).
|
||||
_write(os.path.join(root, "chars", "tails.yaml"), b"variants:\n tail: {fluffy: {loras: [{name: fluffy, tag_name: true}]}}\n outfit: {coat: {default: true, loras: [{name: coat, tag_name: true}]}}\n hat: {cap: {default: true, loras: [{name: cap, tag_name: true}]}}\n")
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi.png"), PNG)
|
||||
_write(os.path.join(root, "heroes", "solo.yml"), b"favorite: false\ncover_image: ./solo/hero.png\n")
|
||||
_write(os.path.join(root, "heroes", "solo", "hero.png"), PNG)
|
||||
_write(os.path.join(root, "broken.yaml"), b"name: [unclosed\n")
|
||||
assert fio.list_flake_info() == {
|
||||
"flakes": ["broken", "chars/fennec", "chars/musashi", "chars/tails", "heroes/solo"],
|
||||
"directories": ["chars", "heroes"],
|
||||
"display_names": {"chars/fennec": "Fennec"},
|
||||
"tag_names": {"chars/fennec": ["swish"], "chars/tails": ["coat"]},
|
||||
"favorites": ["chars/fennec"],
|
||||
"folder_covers": {"chars": "chars/musashi", "heroes": "heroes/solo"},
|
||||
}
|
||||
|
||||
|
||||
def _get_full_path(loras):
|
||||
def get_full_path(category, name):
|
||||
# Like ComfyUI: names only; an absolute path on another drive raises.
|
||||
if os.path.isabs(name):
|
||||
raise ValueError("path is on mount 'D:', start on mount 'C:'")
|
||||
full = os.path.join(loras, name)
|
||||
return full if category == "loras" and os.path.isfile(full) else None
|
||||
return get_full_path
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _save_env():
|
||||
"""Flakes root plus temp `input/` and LoRA dirs for the ownership pass (#392)."""
|
||||
with _flakes_root() as root, tempfile.TemporaryDirectory() as ext:
|
||||
inp, loras = os.path.join(ext, "input"), os.path.join(ext, "loras")
|
||||
fp.get_input_directory = lambda: inp
|
||||
fp.get_full_path = _get_full_path(loras)
|
||||
fp.get_output_directory = lambda: os.path.join(ext, "output")
|
||||
fp.models_dir = os.path.join(ext, "models")
|
||||
fp.folder_names_and_paths["loras"] = ([loras], set())
|
||||
fp.get_folder_paths = lambda c: fp.folder_names_and_paths.get(c, ([],))[0]
|
||||
try:
|
||||
yield root, inp, loras, ext
|
||||
finally:
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.folder_names_and_paths.pop("loras", None)
|
||||
|
||||
|
||||
def _exists(*parts):
|
||||
return os.path.isfile(os.path.join(*parts))
|
||||
|
||||
|
||||
def test_cn_batch_detection():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for fn in ("001_x_1_hed.png", "001_x_2_depth.png", "001_x_3_cover.png", "001_x_4_notes.txt", "001_x_extra.png", "002_x_1_hed.png", "plain.png"):
|
||||
_write(os.path.join(tmp, fn), PNG)
|
||||
batch = sorted(os.path.basename(p) for p in fio._cn_batch(os.path.join(tmp, "001_x_2_depth.png")))
|
||||
assert batch == ["001_x_1_hed.png", "001_x_2_depth.png", "001_x_3_cover.png"], batch
|
||||
assert fio._cn_batch(os.path.join(tmp, "001_x_extra.png")) == [os.path.join(tmp, "001_x_extra.png")]
|
||||
assert fio._cn_batch(os.path.join(tmp, "plain.png")) == [os.path.join(tmp, "plain.png")]
|
||||
|
||||
|
||||
def test_save_moves_loras_with_sidecars():
|
||||
with _save_env() as (root, _, loras, _ext):
|
||||
for fn in ("hero.safetensors", "hero.preview.png", "hero.civitai.info", "hero.v2.safetensors", "b.safetensors"):
|
||||
_write(os.path.join(loras, "chars", fn), b"w")
|
||||
data = {
|
||||
"loras": [
|
||||
{"name": "Hero!", "path": "chars/hero.safetensors"},
|
||||
{"name": "Hero", "path": "chars/b.safetensors"},
|
||||
{"path": "./musashi/kept.safetensors"},
|
||||
{"path": "missing.safetensors"},
|
||||
],
|
||||
"variants": {"outfit": {"armor": {"loras": [{"name": "x", "path": "chars/hero.safetensors"}]}}},
|
||||
}
|
||||
fio.save_flake("chars/musashi", data)
|
||||
raw = fio.read_flake_raw("chars/musashi")
|
||||
assert [lr["path"] for lr in raw["loras"]] == [
|
||||
"./musashi/musashi_Hero.safetensors",
|
||||
"./musashi/musashi_Hero_2.safetensors",
|
||||
"./musashi/kept.safetensors",
|
||||
"missing.safetensors",
|
||||
], raw["loras"]
|
||||
assert raw["variants"]["outfit"]["armor"]["loras"][0]["path"] == "./musashi/musashi_Hero.safetensors"
|
||||
owned = os.path.join(root, "chars", "musashi")
|
||||
for fn in ("musashi_Hero.safetensors", "musashi_Hero.preview.png", "musashi_Hero.civitai.info", "musashi_Hero_2.safetensors"):
|
||||
assert _exists(owned, fn), fn
|
||||
assert not _exists(loras, "chars", "hero.safetensors")
|
||||
assert _exists(loras, "chars", "hero.v2.safetensors")
|
||||
|
||||
|
||||
def test_save_moves_low_lora_file():
|
||||
with _save_env() as (root, _, loras, _ext):
|
||||
for fn in ("swish_high.safetensors", "swish_low.safetensors", "swish_low.preview.png"):
|
||||
_write(os.path.join(loras, "wan", fn), b"w")
|
||||
name = fio.save_flake("fennec", {"loras": [
|
||||
{"name": "Swish", "path": "wan/swish_high.safetensors", "path_low": "wan/swish_low.safetensors"},
|
||||
]}, family="Wan 2.2", kind="anim")
|
||||
lora = fio.read_flake_raw(name)["loras"][0]
|
||||
assert (lora["path"], lora["path_low"]) == ("./fennec/fennec_Swish.safetensors", "./fennec/fennec_Swish_low.safetensors")
|
||||
owned = os.path.join(root, "video", "wan22", "fennec")
|
||||
for fn in ("fennec_Swish.safetensors", "fennec_Swish_low.safetensors", "fennec_Swish_low.preview.png"):
|
||||
assert _exists(owned, fn), fn
|
||||
assert not _exists(loras, "wan", "swish_low.safetensors")
|
||||
assert fio.load_flake(name).loras[0].path_low == os.path.join(owned, "fennec_Swish_low.safetensors")
|
||||
|
||||
|
||||
def test_save_unnamed_legacy_lora_path():
|
||||
with _save_env() as (root, _, loras, _ext):
|
||||
_write(os.path.join(loras, "solo.safetensors"), b"w")
|
||||
fio.save_flake("chars/musashi", {"path": "solo.safetensors", "strength": 0.5})
|
||||
assert fio.read_flake_raw("chars/musashi")["path"] == "./musashi/musashi.safetensors"
|
||||
assert _exists(root, "chars", "musashi", "musashi.safetensors")
|
||||
|
||||
|
||||
def test_save_moves_cn_batches_and_variant_images():
|
||||
with _save_env() as (root, inp, _, _ext):
|
||||
for fn in ("001_x_1_hed.png", "001_x_2_depth.png", "other.png", "unrelated.png"):
|
||||
_write(os.path.join(inp, "poses", fn), PNG)
|
||||
data = {
|
||||
"controlnets": [{"type": "openpose", "image": "poses/001_x_1_hed"}],
|
||||
"variants": {"outfit": {
|
||||
"Red Armor": {"image": "poses/other.png", "controlnets": [{"type": "depth", "image": "poses/001_x_2_depth.png"}]},
|
||||
"plain": {"image": "poses/001_x_1_hed.png"},
|
||||
}},
|
||||
}
|
||||
fio.save_flake("chars/musashi", data)
|
||||
raw = fio.read_flake_raw("chars/musashi")
|
||||
outfit = raw["variants"]["outfit"]
|
||||
assert raw["controlnets"][0]["image"] == "./musashi/cnets/001_x_1_hed.png"
|
||||
assert outfit["Red Armor"]["controlnets"][0]["image"] == "./musashi/cnets/001_x_2_depth.png"
|
||||
assert outfit["Red Armor"]["image"] == "./musashi/musashi_outfit_Red_Armor.png"
|
||||
assert outfit["plain"]["image"] == "./musashi/cnets/001_x_1_hed.png"
|
||||
assert _exists(root, "chars", "musashi", "cnets", "001_x_2_depth.png")
|
||||
assert _exists(root, "chars", "musashi", "musashi_outfit_Red_Armor.png")
|
||||
assert _exists(inp, "poses", "unrelated.png")
|
||||
assert not _exists(inp, "poses", "001_x_1_hed.png")
|
||||
|
||||
|
||||
def test_save_moves_whole_batch_from_input_subfolder():
|
||||
batch = ("000_my_pose_0_cover.png", "000_my_pose_1_base.png", "000_my_pose_2_prep.png", "000_my_pose_3_densepose.png",
|
||||
"000_my_pose_3_depthanythingv2.png", "000_my_pose_3_dwpose.png", "000_my_pose_3_hed.png")
|
||||
with _save_env() as (root, inp, _, _ext):
|
||||
folder = os.path.join(inp, "cnets", "poses", "fam")
|
||||
for fn in (*batch, "001_my_pose_0_cover.png"):
|
||||
_write(os.path.join(folder, fn), PNG)
|
||||
fio.save_flake("poses/jojo", {"controlnets": [{"type": "depth", "image": "cnets/poses/fam/000_my_pose_3_depthanythingv2.png"}]})
|
||||
assert fio.read_flake_raw("poses/jojo")["controlnets"][0]["image"] == "./jojo/cnets/000_my_pose_3_depthanythingv2.png"
|
||||
assert sorted(os.listdir(os.path.join(root, "poses", "jojo", "cnets"))) == sorted(batch)
|
||||
assert os.listdir(folder) == ["001_my_pose_0_cover.png"]
|
||||
|
||||
|
||||
def test_save_moves_external_cover():
|
||||
with _save_env() as (root, _, _l, ext):
|
||||
source = os.path.join(_l, "pics", "c.JPG")
|
||||
_write(source, b"jpg")
|
||||
_write(os.path.join(root, "chars", "musashi.png"), PNG)
|
||||
fio.save_flake("chars/musashi", {"cover_image": source})
|
||||
assert "cover_image" not in fio.read_flake_raw("chars/musashi")
|
||||
assert not os.path.exists(source)
|
||||
assert not _exists(root, "chars", "musashi.png")
|
||||
assert _read(fio.cover_file("chars/musashi")) == b"jpg"
|
||||
|
||||
|
||||
def test_save_leaves_cover_outside_input_and_loras():
|
||||
with _save_env() as (root, _, _l, ext):
|
||||
source = os.path.join(ext, "elsewhere", "c.png")
|
||||
_write(source, PNG)
|
||||
fio.save_flake("chars/musashi", {"cover_image": source})
|
||||
assert fio.read_flake_raw("chars/musashi")["cover_image"] == source
|
||||
assert os.path.isfile(source)
|
||||
assert not os.path.isdir(os.path.join(root, "chars", "musashi"))
|
||||
assert fio.cover_file("chars/musashi") == source
|
||||
os.remove(source)
|
||||
assert fio.cover_file("chars/musashi") is None
|
||||
|
||||
|
||||
def test_save_references_cover_already_owned_in_place():
|
||||
with _save_env() as (root, inp, _, _ext):
|
||||
_write(os.path.join(inp, "001_x_1_hed.png"), b"hed")
|
||||
_write(os.path.join(inp, "001_x_2_cover.png"), PNG)
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi.jpg"), b"old")
|
||||
fio.save_flake("chars/musashi", {"controlnets": [{"type": "openpose", "image": "001_x_1_hed.png"}], "cover_image": "001_x_2_cover.png"})
|
||||
assert fio.read_flake_raw("chars/musashi")["cover_image"] == "./musashi/cnets/001_x_2_cover.png"
|
||||
assert not _exists(root, "chars", "musashi", "musashi.jpg")
|
||||
assert _read(fio.cover_file("chars/musashi")) == PNG
|
||||
|
||||
|
||||
def test_save_keeps_owned_images_in_place():
|
||||
with _save_env() as (root, _, _l, _ext):
|
||||
cnets = os.path.join(root, "chars", "musashi", "cnets")
|
||||
for fn in ("000_p_0_cover.png", "000_p_3_hed.png", "000_p_3_dwpose.png"):
|
||||
_write(os.path.join(cnets, fn), PNG)
|
||||
data = {
|
||||
"controlnets": [{"type": "openpose", "image": os.path.join(cnets, "000_p_3_dwpose.png")}],
|
||||
"variants": {"pose": {"a": {"image": os.path.join(cnets, "000_p_3_hed.png")}}},
|
||||
"cover_image": os.path.join(cnets, "000_p_0_cover.png"),
|
||||
}
|
||||
fio.save_flake("chars/musashi", data)
|
||||
raw = fio.read_flake_raw("chars/musashi")
|
||||
assert raw["controlnets"][0]["image"] == "./musashi/cnets/000_p_3_dwpose.png"
|
||||
assert raw["variants"]["pose"]["a"]["image"] == "./musashi/cnets/000_p_3_hed.png"
|
||||
assert raw["cover_image"] == "./musashi/cnets/000_p_0_cover.png"
|
||||
assert sorted(os.listdir(cnets)) == ["000_p_0_cover.png", "000_p_3_dwpose.png", "000_p_3_hed.png"]
|
||||
assert sorted(os.listdir(os.path.join(root, "chars", "musashi"))) == ["cnets"]
|
||||
|
||||
|
||||
def test_save_moves_image_from_extra_model_path():
|
||||
with _save_env() as (root, _, _l, ext):
|
||||
source = os.path.join(ext, "extra_drive", "upscale_models", "sample.png")
|
||||
_write(source, PNG)
|
||||
fp.folder_names_and_paths["upscale_models"] = ([os.path.dirname(source)], set())
|
||||
try:
|
||||
fio.save_flake("chars/musashi", {"cover_image": source})
|
||||
finally:
|
||||
fp.folder_names_and_paths.pop("upscale_models")
|
||||
assert not os.path.exists(source)
|
||||
assert _read(fio.cover_file("chars/musashi")) == PNG
|
||||
|
||||
|
||||
def test_save_in_place_through_junctioned_flakes_root():
|
||||
# The flakes root is reached through a junction while the picked image
|
||||
# comes as its real path; on another drive this used to fail the save.
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
import _winapi
|
||||
with _save_env() as (_, _i, _l, ext):
|
||||
real, link = os.path.join(ext, "real_flakes"), os.path.join(ext, "linked_flakes")
|
||||
os.makedirs(real)
|
||||
_winapi.CreateJunction(real, link)
|
||||
fp.folder_names_and_paths["flakes"] = ([link], {".yaml", ".yml"})
|
||||
_write(os.path.join(link, "chars", "musashi.yaml"), b"{}")
|
||||
cover = os.path.join(real, "chars", "musashi", "cnets", "000_p_0_cover.png")
|
||||
_write(cover, PNG)
|
||||
fio.save_flake("chars/musashi", {"cover_image": cover})
|
||||
assert fio.read_flake_raw("chars/musashi")["cover_image"] == "./musashi/cnets/000_p_0_cover.png"
|
||||
|
||||
|
||||
def test_save_variant_cover_from_its_cn_batch():
|
||||
# #397: picking a CN in a choice sets the choice image (and the flake cover)
|
||||
# to the batch's _0_cover; both end up referencing the moved cnets/ file.
|
||||
with _save_env() as (root, inp, _l, _ext):
|
||||
for fn in ("001_x_0_cover.png", "001_x_3_depthanythingv2.png", "001_x_3_hed.png"):
|
||||
_write(os.path.join(inp, "poses", fn), PNG)
|
||||
data = {
|
||||
"cover_image": "poses/001_x_0_cover.png",
|
||||
"variants": {"pose": {"stand": {
|
||||
"image": "poses/001_x_0_cover.png",
|
||||
"controlnets": [{"type": "depth", "image": "poses/001_x_3_depthanythingv2.png"}],
|
||||
}}},
|
||||
}
|
||||
fio.save_flake("chars/musashi", data)
|
||||
raw = fio.read_flake_raw("chars/musashi")
|
||||
assert raw["variants"]["pose"]["stand"]["image"] == "./musashi/cnets/001_x_0_cover.png"
|
||||
assert raw["cover_image"] == "./musashi/cnets/001_x_0_cover.png"
|
||||
assert sorted(os.listdir(os.path.join(root, "chars", "musashi"))) == ["cnets"]
|
||||
assert os.listdir(os.path.join(inp, "poses")) == []
|
||||
|
||||
|
||||
def test_save_moves_image_from_output():
|
||||
with _save_env() as (root, _, _l, ext):
|
||||
source = os.path.join(ext, "output", "run", "001_q_3_hed.png")
|
||||
_write(source, PNG)
|
||||
fio.save_flake("chars/musashi", {"controlnets": [{"type": "softedge", "image": source}]})
|
||||
assert fio.read_flake_raw("chars/musashi")["controlnets"][0]["image"] == "./musashi/cnets/001_q_3_hed.png"
|
||||
assert not os.path.exists(source)
|
||||
|
||||
|
||||
def test_save_moves_legacy_sibling_cover():
|
||||
with _save_env() as (root, _, _l, _ext):
|
||||
_write(os.path.join(root, "chars", "musashi.png"), PNG)
|
||||
fio.save_flake("chars/musashi", {})
|
||||
assert not _exists(root, "chars", "musashi.png")
|
||||
assert _exists(root, "chars", "musashi", "musashi.png")
|
||||
|
||||
|
||||
def test_rename_moves_owned_folder():
|
||||
with _save_env() as (root, _, _l, _ext):
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi_hero.safetensors"), b"w")
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi.png"), PNG)
|
||||
data = {"loras": [{"name": "hero", "path": "./musashi/musashi_hero.safetensors"}], "flake_links": [{"target": "chars/other"}]}
|
||||
assert fio.save_flake("solo/miyamoto", data, old_name="chars/musashi") == "solo/miyamoto"
|
||||
assert not os.path.exists(os.path.join(root, "chars", "musashi.yaml"))
|
||||
assert not os.path.exists(os.path.join(root, "chars", "musashi"))
|
||||
raw = fio.read_flake_raw("solo/miyamoto")
|
||||
assert raw["loras"][0]["path"] == "./miyamoto/musashi_hero.safetensors"
|
||||
assert raw["flake_links"] == [{"target": "chars/other"}]
|
||||
assert _exists(root, "solo", "miyamoto", "musashi_hero.safetensors")
|
||||
assert _read(fio.cover_file("solo/miyamoto")) == PNG
|
||||
|
||||
|
||||
# Portable bundles (#427): a source machine's flakes travel inside the image.
|
||||
BUNDLE_MUSASHI = """loras:
|
||||
- {name: hero, path: ./musashi/musashi_hero.safetensors}
|
||||
controlnets:
|
||||
- {type: openpose, model: op.safetensors, image: ./musashi/cnets/pose.png}
|
||||
flake_links:
|
||||
- {target: chars/horse}
|
||||
"""
|
||||
BUNDLE_HORSE = "prompt: {positive: horse}\n"
|
||||
BUNDLE_PRESET = "checkpoint: base.safetensors\nflake_links:\n- {target: chars/horse}\n"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _machine():
|
||||
with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as comfy_temp:
|
||||
presets = os.path.join(tmp, "model_presets")
|
||||
os.makedirs(presets)
|
||||
fp.folder_names_and_paths["flakes"] = ([tmp], {".yaml", ".yml"})
|
||||
fp.folder_names_and_paths["model_presets"] = ([presets], {".yaml", ".yml"})
|
||||
fp.get_temp_directory = lambda: comfy_temp
|
||||
yield tmp
|
||||
|
||||
|
||||
def _source_bundle():
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "musashi.yaml"), BUNDLE_MUSASHI.encode())
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi.png"), PNG)
|
||||
_write(os.path.join(root, "chars", "musashi", "cnets", "pose.png"), b"pose")
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi_hero.safetensors"), b"weights")
|
||||
_write(os.path.join(root, "chars", "musashi", "musashi_hero.civitai.info"), b"{}")
|
||||
_write(os.path.join(root, "chars", "horse.yaml"), BUNDLE_HORSE.encode())
|
||||
_write(os.path.join(root, "chars", "horse", "horse.png"), PNG)
|
||||
_write(os.path.join(root, "model_presets", "img", "sdxl", "base.yaml"), BUNDLE_PRESET.encode())
|
||||
_write(os.path.join(root, "model_presets", "img", "sdxl", "base.png"), b"preset cover")
|
||||
bundle = fio.collect_bundle(["chars/musashi"], ["img/sdxl/base"])
|
||||
# Installing on the machine that made it changes nothing.
|
||||
assert fio.install_bundle(bundle) == {"installed": [], "flakes": {}, "presets": {}}
|
||||
return json.loads(json.dumps(bundle))
|
||||
|
||||
|
||||
def test_bundle_round_trip():
|
||||
bundle = _source_bundle()
|
||||
assert list(bundle["flakes"]) == ["chars/musashi", "chars/horse"]
|
||||
assert sorted(bundle["flakes"]["chars/musashi"]["files"]) == [
|
||||
"musashi/cnets/pose.png", "musashi/musashi.png", "musashi/musashi_hero.civitai.info"]
|
||||
assert list(bundle["presets"]["img/sdxl/base"]["files"]) == ["base.png"]
|
||||
# The two flakes' identical covers are stored once.
|
||||
assert len(bundle["files"]) == 4
|
||||
with _machine() as root:
|
||||
result = fio.install_bundle(bundle)
|
||||
assert sorted(result["installed"]) == ["chars/horse", "chars/musashi", "img/sdxl/base"]
|
||||
assert result["flakes"] == result["presets"] == {}
|
||||
with open(os.path.join(root, "chars", "musashi.yaml"), encoding="utf-8") as f:
|
||||
assert f.read() == BUNDLE_MUSASHI
|
||||
assert _read(fio.cover_file("chars/musashi")) == PNG
|
||||
assert _read(fio.cover_file("chars/horse")) == PNG
|
||||
assert _read(fio.preset_cover_file("img/sdxl/base")) == b"preset cover"
|
||||
assert fio.resolve({"name": "chars/musashi"}).controlnets[0].image_name ==os.path.join(root, "chars", "musashi", "cnets", "pose.png")
|
||||
assert not _exists(root, "chars", "musashi", "musashi_hero.safetensors")
|
||||
assert fio.install_bundle(bundle) == {"installed": [], "flakes": {}, "presets": {}}
|
||||
|
||||
|
||||
def test_bundle_collision_installs_imported_copy():
|
||||
bundle = _source_bundle()
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "horse.yaml"), b"prompt: {positive: pony}\n")
|
||||
result = fio.install_bundle(bundle)
|
||||
renamed = {"chars/horse": "chars/horse (imported)"}
|
||||
assert result["flakes"] == renamed and result["presets"] == {}
|
||||
assert sorted(result["installed"]) == ["chars/horse (imported)", "chars/musashi", "img/sdxl/base"]
|
||||
assert fio.read_flake_raw("chars/horse")["prompt"] == {"positive": "pony"}
|
||||
assert fio.read_flake_raw("chars/horse (imported)")["prompt"] == {"positive": "horse"}
|
||||
assert _read(fio.cover_file("chars/horse (imported)")) == PNG
|
||||
# Links follow the rename, in flakes and presets.
|
||||
assert fio.read_flake_raw("chars/musashi")["flake_links"] == [{"target": "chars/horse (imported)"}]
|
||||
assert fio.read_preset_raw("img/sdxl/base")["flake_links"] == [{"target": "chars/horse (imported)"}]
|
||||
# A second drop of the same image reuses the imported copy.
|
||||
assert fio.install_bundle(bundle) == {"installed": [], "flakes": renamed, "presets": {}}
|
||||
|
||||
|
||||
def test_bundle_rejects_paths_outside_the_flake():
|
||||
bundle = _source_bundle()
|
||||
blob = next(iter(bundle["files"]))
|
||||
for rel in ("../evil.png", "musashi/../../evil.png", "/evil.png", "C:/evil.png", "horse/evil.png",
|
||||
"musashi\\evil.png", "musashi/evil.safetensors"):
|
||||
bad = json.loads(json.dumps(bundle))
|
||||
bad["flakes"]["chars/musashi"]["files"][rel] = blob
|
||||
with _machine() as root:
|
||||
assert _raises_value_error(lambda: fio.install_bundle(bad)), rel
|
||||
assert _raises_value_error(lambda: fio.stage_bundle(bad)), rel
|
||||
assert os.listdir(root) == ["model_presets"]
|
||||
assert os.listdir(fp.get_temp_directory()) == []
|
||||
for name in ("../evil", "/evil", "chars/../../evil"):
|
||||
bad = json.loads(json.dumps(bundle))
|
||||
bad["flakes"][name] = bad["flakes"].pop("chars/horse")
|
||||
with _machine() as root:
|
||||
assert _raises_value_error(lambda: fio.install_bundle(bad)), name
|
||||
assert _raises_value_error(lambda: fio.stage_bundle(bad)), name
|
||||
|
||||
|
||||
# Workflow-local flakes (#437): a dropped image's bundle is staged, not installed.
|
||||
def _staged_prefix(renames):
|
||||
staged = renames["flakes"]["chars/musashi"]
|
||||
assert re.fullmatch(r"_local/[0-9a-f]+/chars/musashi", staged)
|
||||
return staged[:-len("chars/musashi")]
|
||||
|
||||
|
||||
def test_stage_bundle_keeps_flakes_local():
|
||||
bundle = _source_bundle()
|
||||
with _machine() as root:
|
||||
renames = fio.stage_bundle(bundle)
|
||||
p = _staged_prefix(renames)
|
||||
assert renames == {"flakes": {"chars/musashi": p + "chars/musashi", "chars/horse": p + "chars/horse"},
|
||||
"presets": {"img/sdxl/base": p + "img/sdxl/base"}}
|
||||
# Nothing on disk; the temp root comes after the flakes folder.
|
||||
assert os.listdir(root) == ["model_presets"] and os.listdir(os.path.join(root, "model_presets")) == []
|
||||
assert fio._primary_root() == root and fio._primary_presets_root() == os.path.join(root, "model_presets")
|
||||
# Staged flakes resolve like installed ones, links retargeted.
|
||||
assert fio.read_flake_raw(p + "chars/musashi")["flake_links"] == [{"target": p + "chars/horse"}]
|
||||
assert fio.read_preset_raw(p + "img/sdxl/base")["flake_links"] == [{"target": p + "chars/horse"}]
|
||||
assert _read(fio.cover_file(p + "chars/musashi")) == PNG
|
||||
assert _read(fio.preset_cover_file(p + "img/sdxl/base")) == b"preset cover"
|
||||
pose = fio.resolve({"name": p + "chars/musashi"}).controlnets[0].image_name
|
||||
assert pose.startswith(fp.get_temp_directory()) and os.path.isfile(pose)
|
||||
# Listings leave them out; the preset node still accepts them.
|
||||
assert fio.list_flakes() == fio.list_dirs() == fio.list_presets() == []
|
||||
assert fio.list_presets(local=True) == [p + "img/sdxl/base"]
|
||||
# Staging again writes nothing and registers the roots once.
|
||||
staged = fio._resolve_file(p + "chars/horse")
|
||||
_write(staged, b"prompt: {positive: edited}\n")
|
||||
assert fio.stage_bundle(json.loads(json.dumps(bundle))) == renames
|
||||
assert fio.read_flake_raw(p + "chars/horse")["prompt"] == {"positive": "edited"}
|
||||
assert len(fp.folder_names_and_paths["flakes"][0]) == len(fp.folder_names_and_paths["model_presets"][0]) == 2
|
||||
|
||||
|
||||
def test_save_local_installs_with_linked_flakes():
|
||||
bundle = _source_bundle()
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "horse.yaml"), b"prompt: {positive: pony}\n")
|
||||
p = _staged_prefix(fio.stage_bundle(bundle))
|
||||
assert fio.save_local(p + "chars/musashi") == {
|
||||
"flakes": {p + "chars/musashi": "chars/musashi", p + "chars/horse": "chars/horse (imported)"}, "presets": {}}
|
||||
assert fio.read_flake_raw("chars/musashi")["flake_links"] == [{"target": "chars/horse (imported)"}]
|
||||
assert fio.read_flake_raw("chars/horse")["prompt"] == {"positive": "pony"}
|
||||
assert _read(fio.cover_file("chars/musashi")) == PNG
|
||||
assert _read(fio.cover_file("chars/horse (imported)")) == PNG
|
||||
# The preset reuses the already saved horse.
|
||||
assert fio.save_local(p + "img/sdxl/base") == {
|
||||
"flakes": {p + "chars/horse": "chars/horse (imported)"}, "presets": {p + "img/sdxl/base": "img/sdxl/base"}}
|
||||
assert fio.read_preset_raw("img/sdxl/base")["flake_links"] == [{"target": "chars/horse (imported)"}]
|
||||
assert fio.list_flakes() == ["chars/horse", "chars/horse (imported)", "chars/musashi"]
|
||||
assert _raises_value_error(lambda: fio.save_local("chars/musashi"))
|
||||
|
||||
|
||||
def test_collect_bundle_strips_local_prefix():
|
||||
bundle = _source_bundle()
|
||||
with _machine():
|
||||
p = _staged_prefix(fio.stage_bundle(bundle))
|
||||
again = fio.collect_bundle([p + "chars/musashi"], [p + "img/sdxl/base"])
|
||||
assert list(again["flakes"]) == ["chars/musashi", "chars/horse"] and list(again["presets"]) == ["img/sdxl/base"]
|
||||
assert yaml.safe_load(again["flakes"]["chars/musashi"]["yaml"]) == yaml.safe_load(BUNDLE_MUSASHI)
|
||||
assert yaml.safe_load(again["presets"]["img/sdxl/base"]["yaml"]) == yaml.safe_load(BUNDLE_PRESET)
|
||||
assert again["flakes"]["chars/horse"] == bundle["flakes"]["chars/horse"]
|
||||
assert again["files"] == bundle["files"]
|
||||
|
||||
|
||||
# Saved images bundle only the variant choices they used.
|
||||
BUNDLE_SORA = """prompt: {positive: sora}
|
||||
variants:
|
||||
outfit:
|
||||
red: {default: true, positive: red, image: ./sora/red.png, controlnets: [{type: depth, model: d.safetensors, image: ./sora/cn/pose_1_d.png}]}
|
||||
blue: {positive: blue, image: ./sora/shared.png, flake_links: [{target: chars/hat, variant: {style: cap}}]}
|
||||
green: {positive: green, image: ./sora/green.png, controlnets: [{type: depth, model: d.safetensors, image: ./sora/cn/walk_1_d.png}]}
|
||||
mood:
|
||||
calm: {positive: calm}
|
||||
"""
|
||||
BUNDLE_HAT = """variants:
|
||||
style:
|
||||
cap: {positive: cap, image: ./hat/cap.png}
|
||||
beret: {positive: beret, image: ./hat/beret.png}
|
||||
"""
|
||||
|
||||
|
||||
def _sora_bundle(*entries):
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "sora.yaml"), BUNDLE_SORA.encode())
|
||||
for fn in ("sora.png", "red.png", "shared.png", "green.png", "cn/pose_1_d.png", "cn/pose_2_d.png", "cn/walk_1_d.png", "cn/walk_2_d.png"):
|
||||
_write(os.path.join(root, "chars", "sora", *fn.split("/")), fn.encode())
|
||||
_write(os.path.join(root, "chars", "hat.yaml"), BUNDLE_HAT.encode())
|
||||
_write(os.path.join(root, "chars", "hat", "cap.png"), b"cap")
|
||||
_write(os.path.join(root, "chars", "hat", "beret.png"), b"beret")
|
||||
used = {}
|
||||
for entry in entries:
|
||||
fio.merge_used_variants(used, fio.resolve(entry).used_variants)
|
||||
return used, fio.collect_bundle(["chars/sora"], [], used)
|
||||
|
||||
|
||||
def test_resolve_records_used_variants():
|
||||
used, _ = _sora_bundle({"name": "chars/sora", "variant": {"outfit": "blue", "mood": "__none__"}})
|
||||
assert used == {"chars/sora": {"outfit": {"blue"}}, "chars/hat": {"style": {"cap"}}}
|
||||
|
||||
|
||||
def test_bundle_keeps_only_used_choices():
|
||||
_, bundle = _sora_bundle({"name": "chars/sora", "variant": {"outfit": "blue"}})
|
||||
sora = yaml.safe_load(bundle["flakes"]["chars/sora"]["yaml"])
|
||||
assert sora["variants"] == {"outfit": {"blue": yaml.safe_load(BUNDLE_SORA)["variants"]["outfit"]["blue"]}}
|
||||
assert sorted(bundle["flakes"]["chars/sora"]["files"]) == ["sora/shared.png", "sora/sora.png"]
|
||||
assert list(yaml.safe_load(bundle["flakes"]["chars/hat"]["yaml"])["variants"]["style"]) == ["cap"]
|
||||
assert list(bundle["flakes"]["chars/hat"]["files"]) == ["hat/cap.png"]
|
||||
assert len(bundle["files"]) == 3
|
||||
|
||||
|
||||
def test_bundle_keeps_default_choice_and_drops_choice_links():
|
||||
_, bundle = _sora_bundle({"name": "chars/sora"})
|
||||
assert list(bundle["flakes"]) == ["chars/sora"]
|
||||
assert list(yaml.safe_load(bundle["flakes"]["chars/sora"]["yaml"])["variants"]) == ["outfit"]
|
||||
assert sorted(bundle["flakes"]["chars/sora"]["files"]) == ["sora/cn/pose_1_d.png", "sora/cn/pose_2_d.png", "sora/red.png", "sora/sora.png"]
|
||||
|
||||
|
||||
def test_bundle_keeps_choices_of_every_use():
|
||||
_, bundle = _sora_bundle({"name": "chars/sora", "variant": {"outfit": "green"}},
|
||||
{"name": "chars/sora", "variant": {"outfit": "red", "mood": "calm"}})
|
||||
sora = yaml.safe_load(bundle["flakes"]["chars/sora"]["yaml"])
|
||||
assert {g: list(c) for g, c in sora["variants"].items()} == {"outfit": ["red", "green"], "mood": ["calm"]}
|
||||
assert "sora/shared.png" not in bundle["flakes"]["chars/sora"]["files"]
|
||||
|
||||
|
||||
|
||||
def test_bundle_drops_options_of_unused_choices():
|
||||
ahri = {"prompt_options": [{"name": "face", "positive": "eyes"}],
|
||||
"variants": {"skin": {"classic": {"prompt_options": [{"name": "tails", "positive": "tails"}]},
|
||||
"arcade": {"prompt_options": [{"name": "visor", "positive": "visor"}]}}}}
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "ahri.yaml"), yaml.safe_dump(ahri).encode())
|
||||
used = fio.resolve({"name": "chars/ahri", "variant": {"skin": "classic"}}).used_variants
|
||||
bundled = yaml.safe_load(fio.collect_bundle(["chars/ahri"], [], used)["flakes"]["chars/ahri"]["yaml"])
|
||||
assert bundled["prompt_options"] == ahri["prompt_options"]
|
||||
assert bundled["variants"] == {"skin": {"classic": ahri["variants"]["skin"]["classic"]}}
|
||||
|
||||
def test_bundle_without_used_keeps_every_choice():
|
||||
with _machine() as root:
|
||||
_write(os.path.join(root, "chars", "sora.yaml"), BUNDLE_SORA.encode())
|
||||
_write(os.path.join(root, "chars", "hat.yaml"), BUNDLE_HAT.encode())
|
||||
bundle = fio.collect_bundle(["chars/sora"], [])
|
||||
assert bundle["flakes"]["chars/sora"]["yaml"] == BUNDLE_SORA
|
||||
assert list(bundle["flakes"]) == ["chars/sora", "chars/hat"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,496 @@
|
||||
"""Tests for the preset schema and the reworked model loader (#357, #358, #359, #361).
|
||||
|
||||
``full_flake_node`` imports ComfyUI runtime modules at import time and uses
|
||||
package-relative imports, and the package directory name contains a hyphen
|
||||
(``comfyui-flakes``) which is not a valid module name. So we stub the external
|
||||
modules and load the sources under a synthetic package, matching
|
||||
tests/test_filename_prefix.py.
|
||||
|
||||
No real model weights are touched: everything is faked at the ``comfy.sd``
|
||||
boundary.
|
||||
|
||||
Run directly (``python tests/test_preset_loader.py``) or via pytest.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
|
||||
PKG = "flakes_under_test"
|
||||
|
||||
|
||||
def _stub_externals():
|
||||
"""Minimal stand-ins for the ComfyUI modules the sources import."""
|
||||
files = {"checkpoints": [], "diffusion_models": [], "vae": [], "text_encoders": [], "loras": []}
|
||||
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda cat: files.get(cat, [])
|
||||
fp.get_full_path = lambda cat, name: (
|
||||
os.path.abspath(__file__) if name in files.get(cat, []) else None
|
||||
)
|
||||
fp.get_folder_paths = lambda cat: ["/fake/embeddings"]
|
||||
fp.get_input_directory = lambda: "/fake/input"
|
||||
fp.base_path = "/fake"
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
class CLIPType:
|
||||
STABLE_DIFFUSION = "STABLE_DIFFUSION"
|
||||
LUMINA2 = "LUMINA2"
|
||||
KREA2 = "KREA2"
|
||||
|
||||
def __class_getitem__(cls, key):
|
||||
if not hasattr(cls, key) or key.startswith("_"):
|
||||
raise KeyError(key)
|
||||
return getattr(cls, key)
|
||||
|
||||
@classmethod
|
||||
def __iter__(cls):
|
||||
return iter([])
|
||||
|
||||
# Enum-ish: `for m in CLIPType` is only used to build an error message.
|
||||
class _CLIPTypeMeta(type):
|
||||
def __iter__(cls):
|
||||
return iter([types.SimpleNamespace(name=n) for n in
|
||||
("STABLE_DIFFUSION", "LUMINA2", "KREA2")])
|
||||
|
||||
def __getitem__(cls, key):
|
||||
if key not in ("STABLE_DIFFUSION", "LUMINA2", "KREA2"):
|
||||
raise KeyError(key)
|
||||
return key
|
||||
|
||||
CLIPType = _CLIPTypeMeta("CLIPType", (), {})
|
||||
|
||||
calls = {"load_checkpoint": [], "load_diffusion_model": [], "load_clip": [], "vae": []}
|
||||
|
||||
sd = types.ModuleType("comfy.sd")
|
||||
sd.CLIPType = CLIPType
|
||||
sd.calls = calls
|
||||
sd.checkpoint_result = None # set per-test: (model, clip, vae, extra)
|
||||
sd.checkpoint_raises = None
|
||||
|
||||
def load_checkpoint_guess_config(path, **kw):
|
||||
calls["load_checkpoint"].append(path)
|
||||
if sd.checkpoint_raises:
|
||||
raise sd.checkpoint_raises
|
||||
return sd.checkpoint_result
|
||||
|
||||
def load_diffusion_model(path, **kw):
|
||||
calls["load_diffusion_model"].append(path)
|
||||
return "MODEL_FROM_UNET"
|
||||
|
||||
def load_clip(ckpt_paths=None, embedding_directory=None, clip_type=None, **kw):
|
||||
calls["load_clip"].append({"paths": ckpt_paths, "clip_type": clip_type})
|
||||
return f"CLIP({clip_type})"
|
||||
|
||||
class VAE:
|
||||
def __init__(self, sd=None):
|
||||
calls["vae"].append(sd)
|
||||
|
||||
def __repr__(self):
|
||||
return "VAE_FROM_PRESET"
|
||||
|
||||
sd.load_checkpoint_guess_config = load_checkpoint_guess_config
|
||||
sd.load_diffusion_model = load_diffusion_model
|
||||
sd.load_clip = load_clip
|
||||
sd.VAE = VAE
|
||||
|
||||
utils = types.ModuleType("comfy.utils")
|
||||
utils.load_torch_file = lambda p, **kw: {"fake": "state_dict"}
|
||||
|
||||
comfy = types.ModuleType("comfy")
|
||||
comfy.sd = sd
|
||||
comfy.utils = utils
|
||||
sys.modules["comfy"] = comfy
|
||||
sys.modules["comfy.sd"] = sd
|
||||
sys.modules["comfy.utils"] = utils
|
||||
comfy.hooks = types.ModuleType("comfy.hooks")
|
||||
# Presets without LoRAs or links attach no hooks.
|
||||
comfy.hooks.HookGroup = types.SimpleNamespace(combine_all_hooks=lambda groups: None)
|
||||
comfy.hooks.set_hooks_for_conditioning = lambda cond, hooks, append_hooks=True: cond
|
||||
sys.modules["comfy.hooks"] = comfy.hooks
|
||||
sys.modules["node_helpers"] = types.ModuleType("node_helpers")
|
||||
|
||||
class _Clip:
|
||||
def __init__(self, tag="CLIP"):
|
||||
self.tag = tag
|
||||
self.layers = []
|
||||
|
||||
def clone(self):
|
||||
c = _Clip(self.tag)
|
||||
c.layers = list(self.layers)
|
||||
return c
|
||||
|
||||
def clip_layer(self, n):
|
||||
self.layers.append(n)
|
||||
|
||||
class CLIPTextEncode:
|
||||
def encode(self, clip, text):
|
||||
return ([[f"COND({text})", {}]],)
|
||||
|
||||
class EmptyLatentImage:
|
||||
def generate(self, w, h, b):
|
||||
return ({"samples": f"latent{w}x{h}"},)
|
||||
|
||||
class ConditioningZeroOut:
|
||||
def zero_out(self, conditioning):
|
||||
return ([["ZEROED", dict(t[1])] for t in conditioning],)
|
||||
|
||||
# flake_compose imports these at module scope; only maybe_zero_out_negative
|
||||
# is under test here, and it touches none of them.
|
||||
for name in ("numpy", "torch"):
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
if "PIL" not in sys.modules:
|
||||
pil = types.ModuleType("PIL")
|
||||
pil.Image = types.SimpleNamespace(open=None)
|
||||
pil.ImageOps = types.SimpleNamespace(exif_transpose=None)
|
||||
sys.modules["PIL"] = pil
|
||||
sys.modules["PIL.Image"] = pil.Image
|
||||
sys.modules["PIL.ImageOps"] = pil.ImageOps
|
||||
|
||||
nodes = types.ModuleType("nodes")
|
||||
nodes.CLIPTextEncode = CLIPTextEncode
|
||||
nodes.EmptyLatentImage = EmptyLatentImage
|
||||
nodes.ConditioningZeroOut = ConditioningZeroOut
|
||||
for extra in ("ControlNetApplyAdvanced", "ControlNetLoader", "LoraLoader",
|
||||
"KSampler", "VAEDecode", "SaveImage"):
|
||||
setattr(nodes, extra, type(extra, (), {}))
|
||||
nodes.NODE_CLASS_MAPPINGS = {}
|
||||
sys.modules["nodes"] = nodes
|
||||
|
||||
return files, sd, _Clip
|
||||
|
||||
|
||||
FILES, SD, FakeClip = _stub_externals()
|
||||
|
||||
|
||||
def _load(name):
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.dirname(here)
|
||||
if PKG not in sys.modules:
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [root]
|
||||
sys.modules[PKG] = pkg
|
||||
full = f"{PKG}.{name}"
|
||||
if full in sys.modules:
|
||||
return sys.modules[full]
|
||||
spec = importlib.util.spec_from_file_location(full, os.path.join(root, f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[full] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
flake_families = _load("flake_families")
|
||||
flake_io = _load("flake_io")
|
||||
full_flake_node = _load("full_flake_node")
|
||||
|
||||
|
||||
# _install() monkeypatches load_preset; keep the real one so the schema tests
|
||||
# (which run after the loader tests alphabetically) still exercise it.
|
||||
_REAL_LOAD_PRESET = flake_io.load_preset
|
||||
|
||||
|
||||
def _restore_load_preset():
|
||||
flake_io.load_preset = _REAL_LOAD_PRESET
|
||||
full_flake_node.flake_io.load_preset = _REAL_LOAD_PRESET
|
||||
|
||||
|
||||
def _reset():
|
||||
for k in SD.calls:
|
||||
SD.calls[k].clear()
|
||||
SD.checkpoint_raises = None
|
||||
for k in FILES:
|
||||
FILES[k].clear()
|
||||
|
||||
|
||||
def _preset(**kw):
|
||||
"""A ModelPreset with sane required bits, overridable per test."""
|
||||
base = dict(name="t", checkpoint="", steps=8, cfg=1.0, width=1024, height=1024,
|
||||
positive="hello", negative="bad")
|
||||
base.update(kw)
|
||||
return flake_io.ModelPreset(**base)
|
||||
|
||||
|
||||
def _install(preset):
|
||||
flake_io.load_preset = lambda n: preset
|
||||
full_flake_node.flake_io.load_preset = lambda n: preset
|
||||
|
||||
|
||||
# --- Schema (#357) ----------------------------------------------------------
|
||||
|
||||
def test_new_fields_default_to_none():
|
||||
p = flake_io.ModelPreset(name="t", checkpoint="a.safetensors")
|
||||
assert p.diffusion_model is None
|
||||
assert p.clip_type is None
|
||||
assert p.shift is None
|
||||
|
||||
|
||||
def test_model_source_prefers_diffusion_model():
|
||||
p = flake_io.ModelPreset(name="t", checkpoint="a.safetensors",
|
||||
diffusion_model="b.safetensors")
|
||||
assert p.model_source == ("diffusion_models", "b.safetensors")
|
||||
|
||||
|
||||
def test_model_source_falls_back_to_checkpoint():
|
||||
p = flake_io.ModelPreset(name="t", checkpoint="a.safetensors")
|
||||
assert p.model_source == ("checkpoints", "a.safetensors")
|
||||
|
||||
|
||||
def test_model_source_errors_when_neither_set():
|
||||
p = flake_io.ModelPreset(name="lonely")
|
||||
try:
|
||||
p.model_source
|
||||
except ValueError as exc:
|
||||
assert "lonely" in str(exc)
|
||||
assert "diffusion_model" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected a ValueError")
|
||||
|
||||
|
||||
def test_load_preset_reads_new_keys():
|
||||
_restore_load_preset()
|
||||
raw = {
|
||||
"checkpoint": "", "diffusion_model": "z.safetensors",
|
||||
"clip_type": "lumina2", "shift": 3.0,
|
||||
"prompt": {"positive": "p", "negative": "n"},
|
||||
}
|
||||
flake_io.read_preset_raw = lambda n: raw
|
||||
p = flake_io.load_preset("x")
|
||||
assert p.diffusion_model == "z.safetensors"
|
||||
assert p.clip_type == "LUMINA2" # normalised to the CLIPType member name
|
||||
assert p.shift == 3.0
|
||||
|
||||
|
||||
def test_load_preset_back_compat_without_new_keys():
|
||||
_restore_load_preset()
|
||||
"""An existing SDXL preset yaml has none of the new keys."""
|
||||
raw = {
|
||||
"display_name": "Wai Illustrious V17",
|
||||
"checkpoint": "img/illustrious/wai/waiIllustriousSDXL_v170.safetensors",
|
||||
"clip_skip": -2, "vae": None, "text_encoder": None,
|
||||
"steps": 20, "cfg": 4, "sampler": "euler_ancestral", "scheduler": "normal",
|
||||
"width": 832, "height": 1216,
|
||||
"prompt": {"positive": "masterpiece", "negative": "bad quality"},
|
||||
}
|
||||
flake_io.read_preset_raw = lambda n: raw
|
||||
p = flake_io.load_preset("wai")
|
||||
assert (p.diffusion_model, p.clip_type, p.shift) == (None, None, None)
|
||||
assert p.clip_skip == -2
|
||||
assert (p.steps, p.cfg, p.sampler, p.scheduler) == (20, 4.0, "euler_ancestral", "normal")
|
||||
assert p.model_source[0] == "checkpoints"
|
||||
|
||||
|
||||
def test_load_preset_reads_loras_and_links():
|
||||
_restore_load_preset()
|
||||
raw = {
|
||||
"checkpoint": "a.safetensors",
|
||||
"loras": [{"name": "Ink", "path": "ink", "strength": 0.6,
|
||||
"schedule": [{"start": 0.5, "strength": 0.0}]}],
|
||||
"flake_links": [{"target": "style", "variant": {"tone": "dark"}}],
|
||||
}
|
||||
flake_io.read_preset_raw = lambda n: raw
|
||||
p = flake_io.load_preset("x")
|
||||
assert [(lr.path, lr.strength) for lr in p.loras] == [("ink", 0.6)]
|
||||
assert p.loras[0].schedule == [flake_io.ScheduleKeyframe(0.5, 0.0)]
|
||||
assert [(ln.target, ln.variant) for ln in p.flake_links] == [("style", {"tone": "dark"})]
|
||||
|
||||
|
||||
# --- CLIPType resolution (#358) --------------------------------------------
|
||||
|
||||
def test_clip_type_from_preset_wins():
|
||||
fam = flake_families.get("Anima/Base") # declares STABLE_DIFFUSION
|
||||
assert full_flake_node._resolve_clip_type("lumina2", fam) == "LUMINA2"
|
||||
|
||||
|
||||
def test_clip_type_falls_back_to_family():
|
||||
fam = flake_families.get("Krea2/Turbo")
|
||||
assert full_flake_node._resolve_clip_type(None, fam) == "KREA2"
|
||||
|
||||
|
||||
def test_clip_type_defaults_to_stable_diffusion():
|
||||
assert full_flake_node._resolve_clip_type(None, None) == "STABLE_DIFFUSION"
|
||||
|
||||
|
||||
def test_unknown_clip_type_names_the_available_ones():
|
||||
try:
|
||||
full_flake_node._resolve_clip_type("nonsense", None)
|
||||
except ValueError as exc:
|
||||
assert "NONSENSE" in str(exc)
|
||||
assert "LUMINA2" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected a ValueError")
|
||||
|
||||
|
||||
# --- Loader strategy (#358) -------------------------------------------------
|
||||
|
||||
def test_all_in_one_checkpoint_uses_checkpoint_path():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("sdxl.safetensors")
|
||||
SD.checkpoint_result = ("MODEL", FakeClip(), "VAE_BAKED", None)
|
||||
_install(_preset(checkpoint="sdxl.safetensors", cfg=4.0))
|
||||
bundle, _gen, _samp = full_flake_node._load_preset_bundle("p", "SDXL/Illustrious")
|
||||
assert SD.calls["load_checkpoint"] and not SD.calls["load_diffusion_model"]
|
||||
assert not SD.calls["load_clip"] # no external encoder needed
|
||||
assert bundle[0] == "MODEL" and bundle[2] == "VAE_BAKED"
|
||||
|
||||
|
||||
def test_diffusion_model_field_uses_component_path():
|
||||
_reset()
|
||||
FILES["diffusion_models"].append("z_image_turbo.safetensors")
|
||||
FILES["text_encoders"].append("qwen_3_4b.safetensors")
|
||||
FILES["vae"].append("ae.safetensors")
|
||||
_install(_preset(diffusion_model="z_image_turbo.safetensors",
|
||||
text_encoder="qwen_3_4b.safetensors", vae="ae.safetensors"))
|
||||
bundle, _gen, _samp = full_flake_node._load_preset_bundle("p", "ZImage/Turbo")
|
||||
assert SD.calls["load_diffusion_model"] and not SD.calls["load_checkpoint"]
|
||||
assert SD.calls["load_clip"][0]["clip_type"] == "LUMINA2"
|
||||
assert bundle[0] == "MODEL_FROM_UNET"
|
||||
|
||||
|
||||
def test_unet_only_checkpoint_falls_back_to_preset_components():
|
||||
"""The reported bug: a UNET-only file sitting in models/checkpoints/."""
|
||||
_reset()
|
||||
FILES["checkpoints"].append("cyberrealisticZImage_v40.safetensors")
|
||||
FILES["text_encoders"].append("qwen_3_4b_fp8.safetensors")
|
||||
FILES["vae"].append("ae.safetensors")
|
||||
# load_checkpoint_guess_config builds the model but has no clip/vae to give.
|
||||
SD.checkpoint_result = ("MODEL_FROM_CKPT", None, None, None)
|
||||
_install(_preset(checkpoint="cyberrealisticZImage_v40.safetensors",
|
||||
text_encoder="qwen_3_4b_fp8.safetensors", vae="ae.safetensors"))
|
||||
bundle, _gen, _samp = full_flake_node._load_preset_bundle("p", "ZImage/Turbo")
|
||||
# The model built by the checkpoint call is kept — no second full load.
|
||||
assert bundle[0] == "MODEL_FROM_CKPT"
|
||||
assert not SD.calls["load_diffusion_model"]
|
||||
assert SD.calls["load_clip"][0]["clip_type"] == "LUMINA2"
|
||||
assert repr(bundle[2]) == "VAE_FROM_PRESET"
|
||||
|
||||
|
||||
def test_unet_only_checkpoint_without_text_encoder_is_actionable():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("bare.safetensors")
|
||||
SD.checkpoint_result = ("MODEL", None, None, None)
|
||||
_install(_preset(checkpoint="bare.safetensors"))
|
||||
try:
|
||||
full_flake_node._load_preset_bundle("p", "ZImage/Turbo")
|
||||
except ValueError as exc:
|
||||
assert "text_encoder" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected a ValueError naming the missing field")
|
||||
|
||||
|
||||
def test_checkpoint_load_failure_is_not_masked_by_the_fallback():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("corrupt.safetensors")
|
||||
SD.checkpoint_raises = RuntimeError("header too small")
|
||||
_install(_preset(checkpoint="corrupt.safetensors"))
|
||||
try:
|
||||
full_flake_node._load_preset_bundle("p", "ZImage/Turbo")
|
||||
except RuntimeError as exc:
|
||||
assert "header too small" in str(exc)
|
||||
else:
|
||||
raise AssertionError("a real load failure must propagate, not fall back")
|
||||
assert not SD.calls["load_diffusion_model"]
|
||||
|
||||
|
||||
def test_missing_file_named_by_preset_fails_loudly():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("ok.safetensors")
|
||||
SD.checkpoint_result = ("MODEL", FakeClip(), "VAE", None)
|
||||
_install(_preset(checkpoint="ok.safetensors", vae="gone.safetensors"))
|
||||
try:
|
||||
full_flake_node._load_preset_bundle("p", "SDXL/Base")
|
||||
except FileNotFoundError as exc:
|
||||
assert "gone.safetensors" in str(exc)
|
||||
else:
|
||||
raise AssertionError("a missing VAE must not be silently ignored")
|
||||
|
||||
|
||||
# --- clip_skip gating (#359) ------------------------------------------------
|
||||
|
||||
def test_clip_skip_applied_for_sdxl():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("sdxl.safetensors")
|
||||
clip = FakeClip()
|
||||
SD.checkpoint_result = ("MODEL", clip, "VAE", None)
|
||||
_install(_preset(checkpoint="sdxl.safetensors", clip_skip=-2, cfg=4.0))
|
||||
bundle, _gen, _samp = full_flake_node._load_preset_bundle("p", "SDXL/Illustrious")
|
||||
assert bundle[1].layers == [-2]
|
||||
|
||||
|
||||
def test_clip_skip_skipped_for_qwen_families():
|
||||
_reset()
|
||||
FILES["checkpoints"].append("zit.safetensors")
|
||||
clip = FakeClip()
|
||||
SD.checkpoint_result = ("MODEL", clip, "VAE", None)
|
||||
# Presets carry clip_skip: -2 by default whether or not the family can use it.
|
||||
_install(_preset(checkpoint="zit.safetensors", clip_skip=-2))
|
||||
bundle, _gen, _samp = full_flake_node._load_preset_bundle("p", "ZImage/Turbo")
|
||||
assert bundle[1].layers == []
|
||||
|
||||
|
||||
# --- cfg-driven negative zero-out (#359) ------------------------------------
|
||||
|
||||
flake_compose = _load("flake_compose")
|
||||
|
||||
|
||||
def test_negative_kept_above_cfg_one():
|
||||
cond = [["COND", {"control": "CN"}]]
|
||||
assert flake_compose.maybe_zero_out_negative(cond, 4.0) is cond
|
||||
|
||||
|
||||
def test_negative_zeroed_at_cfg_one():
|
||||
cond = [["COND", {"control": "CN"}]]
|
||||
out = flake_compose.maybe_zero_out_negative(cond, 1.0)
|
||||
assert out[0][0] == "ZEROED"
|
||||
|
||||
|
||||
def test_zero_out_preserves_controlnet_key():
|
||||
cond = [["COND", {"control": "CN_OBJ", "control_apply_to_uncond": True}]]
|
||||
out = flake_compose.maybe_zero_out_negative(cond, 1.0)
|
||||
assert out[0][1]["control"] == "CN_OBJ"
|
||||
assert out[0][1]["control_apply_to_uncond"] is True
|
||||
|
||||
|
||||
def test_zero_out_handles_none():
|
||||
assert flake_compose.maybe_zero_out_negative(None, 1.0) is None
|
||||
assert flake_compose.maybe_zero_out_negative([["C", {}]], None) is not None
|
||||
|
||||
|
||||
def test_override_cfg_drives_the_decision_not_the_preset():
|
||||
"""cfg can still change via overrides_json (#279), so the zero-out must read
|
||||
the post-override value."""
|
||||
bundle = ("M", "C", "V")
|
||||
gen = ("POS", [["COND", {}]], "LAT", 1024, 1024, "p", "n", {"stems": []})
|
||||
samp = (30, 4.0, "euler", "simple", 1.0) # preset says cfg 4
|
||||
_b, _g, samp2 = full_flake_node._apply_preset_overrides(
|
||||
'{"cfg": 1.0}', bundle, gen, samp)
|
||||
assert samp2[1] == 1.0
|
||||
out = flake_compose.maybe_zero_out_negative(gen[1], samp2[1])
|
||||
assert out[0][0] == "ZEROED"
|
||||
|
||||
|
||||
def test_list_presets_walks_given_roots_only():
|
||||
"""The preset picker's drive tabs list each model_presets root on its own (#399)."""
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
os.makedirs(os.path.join(a, "img", "sdxl"))
|
||||
open(os.path.join(a, "img", "sdxl", "base.yaml"), "w").close()
|
||||
open(os.path.join(b, "other.yaml"), "w").close()
|
||||
assert flake_io.list_presets(roots=[a]) == ["img/sdxl/base"]
|
||||
assert flake_io.list_presets(roots=[a, b]) == ["img/sdxl/base", "other"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for fname, fn in sorted(globals().items()):
|
||||
if fname.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {fname}")
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {fname}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{failures} failure(s)")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Tests for flake options and togglers (#462): `prompt_options` on flakes and
|
||||
variant choices resolve into `Flake.options`, grid items override them with
|
||||
`option_overrides`, and `_join_prompts` lets a downstream flake's `toggler`
|
||||
switch the options of every flake before it, across chained nodes. flake_io
|
||||
and full_flake_node are the real modules, loaded like test_anim_preset_nodes.py."""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PKG = "_fpkg_prompt_options"
|
||||
|
||||
|
||||
def _load(modname):
|
||||
spec = importlib.util.spec_from_file_location(f"{PKG}.{modname}", os.path.join(ROOT, f"{modname}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"{PKG}.{modname}"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _setup():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.get_full_path = lambda category, name: None
|
||||
fp.get_folder_paths = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
fp.folder_names_and_paths = {}
|
||||
sys.modules["folder_paths"] = fp
|
||||
for name in ("comfy", "comfy.sd", "comfy.utils", "comfy.model_management",
|
||||
"comfy.ldm", "comfy.ldm.modules", "comfy.ldm.modules.attention"):
|
||||
sys.modules.setdefault(name, types.ModuleType(name))
|
||||
# Other test modules stub `nodes` too; only add what is missing.
|
||||
nodes = sys.modules.setdefault("nodes", types.ModuleType("nodes"))
|
||||
for attr in ("CLIPTextEncode", "EmptyLatentImage", "ControlNetApplyAdvanced", "ControlNetLoader", "LoraLoader"):
|
||||
if not hasattr(nodes, attr):
|
||||
setattr(nodes, attr, object)
|
||||
pkg = types.ModuleType(PKG)
|
||||
pkg.__path__ = [ROOT]
|
||||
sys.modules[PKG] = pkg
|
||||
return _load("flake_io"), _load("full_flake_node")
|
||||
|
||||
|
||||
fio, ffn = _setup()
|
||||
|
||||
AHRI = {
|
||||
"prompt": {"positive": "ahri, fox ears"},
|
||||
"prompt_options": [{"name": "face", "positive": "golden eyes", "default": True}],
|
||||
"variants": {"skin": {"classic": {
|
||||
"default": True,
|
||||
"positive": "classic skin",
|
||||
"prompt_options": [
|
||||
{"name": "tails", "positive": "nine tails", "default": True},
|
||||
{"name": "front", "positive": "chest bow"},
|
||||
],
|
||||
}, "arcade": {"positive": "arcade skin"}}},
|
||||
}
|
||||
|
||||
|
||||
def _with_flakes(raw_map):
|
||||
fio.load_flake = lambda name: fio._flake_from_raw(name, raw_map[name])
|
||||
|
||||
|
||||
def _states(flake):
|
||||
return {n: o.on for n, o in flake.options.items()}
|
||||
|
||||
|
||||
def _join(*stacks):
|
||||
"""Chain `_join_prompts` over stacks of resolved flakes, like FlakeStack nodes."""
|
||||
pos, parts = "masterpiece", None
|
||||
for flakes in stacks:
|
||||
pos, _, parts = ffn._join_prompts(pos, "", flakes, parts)
|
||||
return pos
|
||||
|
||||
|
||||
def test_toggler_parsing():
|
||||
assert fio._parse_toggler({"toggler": " -tails, face,, +front , -, abc"}) == [
|
||||
("tails", False), ("face", True), ("front", True), ("abc", True)]
|
||||
assert fio._parse_toggler({}) == []
|
||||
|
||||
|
||||
def test_flake_and_choice_options_resolve_with_defaults():
|
||||
_with_flakes({"ahri": AHRI})
|
||||
flake = fio.resolve({"name": "ahri"})
|
||||
assert _states(flake) == {"face": True, "tails": True, "front": False}
|
||||
assert flake.positive == "ahri, fox ears, classic skin"
|
||||
assert _states(fio.resolve({"name": "ahri", "variant": {"skin": "arcade"}})) == {"face": True}
|
||||
|
||||
|
||||
def test_option_overrides_and_unknown_names():
|
||||
_with_flakes({"ahri": AHRI})
|
||||
flake = fio.resolve({"name": "ahri", "option_overrides": {"tails": False, "front": True, "abc": True}})
|
||||
assert _states(flake) == {"face": True, "tails": False, "front": True}
|
||||
|
||||
|
||||
def test_linked_options_join_host_and_host_names_win():
|
||||
_with_flakes({
|
||||
"host": {"prompt_options": [{"name": "face", "positive": "host face"}],
|
||||
"flake_links": [{"target": "ahri"}]},
|
||||
"ahri": AHRI,
|
||||
})
|
||||
flake = fio.resolve({"name": "host", "option_overrides": {"tails": False}})
|
||||
assert flake.options["face"].positive == "host face"
|
||||
assert _states(flake) == {"face": False, "tails": False, "front": False}
|
||||
|
||||
|
||||
def test_no_options_keeps_the_joined_text():
|
||||
_with_flakes({"a": {"prompt": {"positive": " a "}}, "b": {"prompt": {"positive": "b", "negative": "n"}}})
|
||||
pos, neg, _ = ffn._join_prompts(" base ", "neg", [fio.resolve({"name": "a"}), fio.resolve({"name": "b"})], None)
|
||||
assert (pos, neg) == ("base BREAK a BREAK b", "neg, n")
|
||||
|
||||
|
||||
def test_downstream_toggler_switches_upstream_options():
|
||||
_with_flakes({
|
||||
"ahri": AHRI,
|
||||
"pose": {"prompt": {"positive": "from behind"}, "variants": {"view": {
|
||||
"back": {"toggler": "-face, -front"},
|
||||
"front": {"positive": "facing viewer", "toggler": "-tails, +front, abc"},
|
||||
}}},
|
||||
})
|
||||
ahri = fio.resolve({"name": "ahri"})
|
||||
assert _join([ahri]) == "masterpiece BREAK ahri, fox ears, classic skin, golden eyes, nine tails"
|
||||
back = fio.resolve({"name": "pose", "variant": {"view": "back"}})
|
||||
assert _join([ahri], [back]) == "masterpiece BREAK ahri, fox ears, classic skin, nine tails BREAK from behind"
|
||||
front = fio.resolve({"name": "pose", "variant": {"view": "front"}})
|
||||
assert _join([ahri], [front]) == (
|
||||
"masterpiece BREAK ahri, fox ears, classic skin, golden eyes, chest bow BREAK from behind, facing viewer")
|
||||
# Same node: a later entry toggles an earlier one's options too.
|
||||
assert _join([ahri, back]) == _join([ahri], [back])
|
||||
|
||||
|
||||
def test_toggler_does_not_touch_its_own_options():
|
||||
_with_flakes({"f": {"prompt_options": [{"name": "x", "positive": "xx", "default": True}], "toggler": "-x"}})
|
||||
assert _join([fio.resolve({"name": "f"})]) == "masterpiece BREAK xx"
|
||||
|
||||
|
||||
def test_parts_reused_only_while_they_render_the_incoming_text():
|
||||
_with_flakes({"ahri": AHRI, "pose": {"toggler": "-face"}})
|
||||
pos, _, parts = ffn._join_prompts("", "", [fio.resolve({"name": "ahri"})], None)
|
||||
pose = fio.resolve({"name": "pose"})
|
||||
assert "golden eyes" not in ffn._join_prompts(pos, "", [pose], parts)[0]
|
||||
# A node in between rewrote the text: the options are frozen into it.
|
||||
rewritten = f"{pos} BREAK inpaint"
|
||||
assert ffn._join_prompts(rewritten, "", [pose], parts)[0] == rewritten
|
||||
|
||||
|
||||
def test_upstream_parts_are_not_mutated():
|
||||
_with_flakes({"ahri": AHRI, "pose": {"toggler": "-face"}})
|
||||
pos, _, parts = ffn._join_prompts("", "", [fio.resolve({"name": "ahri"})], None)
|
||||
ffn._join_prompts(pos, "", [fio.resolve({"name": "pose"})], parts)
|
||||
assert parts[1][2]["face"] == ["golden eyes", "", True]
|
||||
|
||||
|
||||
def test_option_negatives_toggle_with_their_positive():
|
||||
_with_flakes({
|
||||
"ahri": {"prompt": {"positive": "ahri", "negative": "bad hands"},
|
||||
"prompt_options": [{"name": "tails", "positive": "nine tails", "negative": "one tail", "default": True}]},
|
||||
"close": {"toggler": "-tails"},
|
||||
})
|
||||
ahri = fio.resolve({"name": "ahri"})
|
||||
pos, neg, parts = ffn._join_prompts("base", "blurry", [ahri], None)
|
||||
assert (pos, neg) == ("base BREAK ahri, nine tails", "blurry, bad hands, one tail")
|
||||
assert ffn._join_prompts(pos, neg, [fio.resolve({"name": "close"})], parts)[:2] == ("base BREAK ahri", "blurry, bad hands")
|
||||
# A node in between rewrote only the negative: the options are frozen into the texts.
|
||||
assert ffn._join_prompts(pos, "other", [fio.resolve({"name": "close"})], parts)[:2] == (pos, "other")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for hook schedules and multiple prompts in flake files (#369).
|
||||
|
||||
Stubs `folder_paths` and loads `flake_io` under a synthetic package (same
|
||||
approach as test_variant_extras.py), then serves flakes from an in-memory map.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_sched")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_sched"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_sched.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_sched.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
KF = fio.ScheduleKeyframe
|
||||
|
||||
|
||||
def _with_flakes(raw_map):
|
||||
fio.load_flake = lambda name: fio._flake_from_raw(name, raw_map[name])
|
||||
|
||||
|
||||
def _lora(flake, path):
|
||||
return next(lr for lr in flake.loras if lr.path == path)
|
||||
|
||||
|
||||
def test_schedule_sorted_clamped_last_duplicate_wins():
|
||||
sched = fio._parse_schedule([
|
||||
{"start": 0.6, "strength": 0.0},
|
||||
{"start": -0.2, "strength": 0.5},
|
||||
{"start": 1.5, "strength": 2.0},
|
||||
{"start": 0.6, "strength": 0.3},
|
||||
])
|
||||
assert sched == [KF(0.0, 0.5), KF(0.6, 0.3), KF(1.0, 2.0)]
|
||||
|
||||
|
||||
def test_empty_or_missing_schedule_is_no_schedule():
|
||||
assert fio._parse_schedule([]) == []
|
||||
assert fio._parse_schedule(None) == []
|
||||
flake = fio._flake_from_raw("f", {"loras": [{"path": "a.safetensors", "schedule": []}]})
|
||||
assert flake.loras[0].schedule == []
|
||||
|
||||
|
||||
def test_strength_is_one_before_first_keyframe():
|
||||
sched = [KF(0.3, 0.0), KF(0.7, 0.5)]
|
||||
assert fio.schedule_strength(sched, 0.0) == 1.0
|
||||
assert fio.schedule_strength(sched, 0.3) == 0.0
|
||||
assert fio.schedule_strength(sched, 0.9) == 0.5
|
||||
|
||||
|
||||
def test_multiply_schedules_piecewise_product():
|
||||
a = [KF(0.0, 1.0), KF(0.5, 0.0)]
|
||||
b = [KF(0.25, 0.5)]
|
||||
assert fio.multiply_schedules(a, b) == [KF(0.0, 1.0), KF(0.25, 0.5), KF(0.5, 0.0)]
|
||||
assert fio.multiply_schedules([], b) == b
|
||||
assert fio.multiply_schedules(a, []) == a
|
||||
|
||||
|
||||
def test_legacy_prompt_still_read():
|
||||
flake = fio._flake_from_raw("f", {"prompt": {"positive": "a cat", "negative": "blurry"}})
|
||||
assert (flake.positive, flake.negative) == ("a cat", "blurry")
|
||||
assert flake.scheduled_prompts == []
|
||||
|
||||
|
||||
def test_prompts_list_splits_unscheduled_and_scheduled():
|
||||
flake = fio._flake_from_raw("f", {"prompts": [
|
||||
{"positive": "a cat", "negative": "blurry"},
|
||||
{"positive": "sunset", "schedule": [{"start": 0.5, "strength": 0.0}]},
|
||||
{"positive": "red hat"},
|
||||
]})
|
||||
assert flake.positive == "a cat, red hat"
|
||||
assert flake.negative == "blurry"
|
||||
assert len(flake.scheduled_prompts) == 1
|
||||
assert flake.scheduled_prompts[0].positive == "sunset"
|
||||
assert flake.scheduled_prompts[0].schedule == [KF(0.5, 0.0)]
|
||||
|
||||
|
||||
def test_lora_and_link_schedules_parsed():
|
||||
flake = fio._flake_from_raw("f", {
|
||||
"loras": [{"path": "a.safetensors", "schedule": [{"start": 0.0, "strength": 1.0}, {"start": 0.6, "strength": 0.0}]}],
|
||||
"flake_links": [{"target": "g", "schedule": [{"start": 0.2, "strength": 0.5}]}],
|
||||
})
|
||||
assert flake.loras[0].schedule == [KF(0.0, 1.0), KF(0.6, 0.0)]
|
||||
assert flake.flake_links[0].schedule == [KF(0.2, 0.5)]
|
||||
|
||||
|
||||
def test_link_schedule_multiplies_into_linked_loras_and_prompts():
|
||||
_with_flakes({
|
||||
"light": {
|
||||
"prompt": {"positive": "rim light", "negative": "flat"},
|
||||
"loras": [
|
||||
{"path": "plain.safetensors"},
|
||||
{"path": "sched.safetensors", "schedule": [{"start": 0.25, "strength": 0.5}]},
|
||||
],
|
||||
},
|
||||
"host": {
|
||||
"prompt": {"positive": "portrait"},
|
||||
"loras": [{"path": "host.safetensors"}],
|
||||
"flake_links": [{"target": "light", "schedule": [{"start": 0.5, "strength": 0.0}]}],
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert _lora(flake, "host.safetensors").schedule == []
|
||||
assert _lora(flake, "plain.safetensors").schedule == [KF(0.5, 0.0)]
|
||||
assert _lora(flake, "sched.safetensors").schedule == [KF(0.25, 0.5), KF(0.5, 0.0)]
|
||||
# The linked text moves out of the host's unscheduled prompt.
|
||||
assert flake.positive == "portrait"
|
||||
assert len(flake.scheduled_prompts) == 1
|
||||
p = flake.scheduled_prompts[0]
|
||||
assert (p.positive, p.negative, p.schedule) == ("rim light", "flat", [KF(0.5, 0.0)])
|
||||
|
||||
|
||||
def test_unscheduled_link_keeps_text_join_and_carries_scheduled_prompts():
|
||||
_with_flakes({
|
||||
"light": {"prompts": [
|
||||
{"positive": "rim light"},
|
||||
{"positive": "glow", "schedule": [{"start": 0.7, "strength": 0.0}]},
|
||||
]},
|
||||
"host": {"prompt": {"positive": "portrait"}, "flake_links": [{"target": "light"}]},
|
||||
})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert flake.positive == "portrait, rim light"
|
||||
assert [p.positive for p in flake.scheduled_prompts] == ["glow"]
|
||||
|
||||
|
||||
def test_variant_choice_lora_keeps_own_schedule_and_text_appends():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"prompt": {"positive": "base"},
|
||||
"variants": {"style": {"dramatic": {
|
||||
"positive": "moody",
|
||||
"loras": [{"path": "drama.safetensors", "schedule": [{"start": 0.4, "strength": 0.0}]}],
|
||||
}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}})
|
||||
assert flake.positive == "base, moody"
|
||||
assert _lora(flake, "drama.safetensors").schedule == [KF(0.4, 0.0)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for the cached cover thumbnails (#451): downscaled WebP files in the
|
||||
temp dir, reused until the cover changes. Needs Pillow (a ComfyUI dependency)."""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
Image = pytest.importorskip("PIL.Image")
|
||||
|
||||
|
||||
def _load_flake_thumbs(temp_dir):
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_temp_directory = lambda: temp_dir
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
spec = importlib.util.spec_from_file_location("_flake_thumbs", os.path.join(os.path.dirname(here), "flake_thumbs.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_thumbnail_is_cached_until_the_cover_changes():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
thumbs = _load_flake_thumbs(os.path.join(tmp, "temp"))
|
||||
cover = os.path.join(tmp, "fennec.png")
|
||||
Image.new("RGBA", (800, 400), (240, 200, 90, 255)).save(cover)
|
||||
|
||||
first = thumbs.thumbnail(cover, 256)
|
||||
assert os.path.dirname(first) == os.path.join(tmp, "temp", "flakes_thumbs")
|
||||
with Image.open(first) as img:
|
||||
assert (img.format, img.size) == ("WEBP", (256, 128))
|
||||
mtime = os.path.getmtime(first)
|
||||
assert thumbs.thumbnail(cover, 256) == first
|
||||
assert os.path.getmtime(first) == mtime
|
||||
assert thumbs.thumbnail(cover, 128) != first
|
||||
|
||||
Image.new("RGB", (300, 600), (90, 140, 240)).save(cover)
|
||||
changed = thumbs.thumbnail(cover, 256)
|
||||
assert changed != first
|
||||
with Image.open(changed) as img:
|
||||
assert img.size == (128, 256)
|
||||
assert not [f for f in os.listdir(os.path.dirname(first)) if f.endswith(".tmp")]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for default variant choices (#387).
|
||||
|
||||
A group with no selection applies its first choice when that choice has
|
||||
`default: true`; "__none__" opts out. Same loading approach as
|
||||
test_variant_extras.py.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg_vd")
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg_vd"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg_vd.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg_vd.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
|
||||
|
||||
def _with_flakes(raw_map):
|
||||
fio.load_flake = lambda name: fio._flake_from_raw(name, raw_map[name])
|
||||
|
||||
|
||||
POSE = {
|
||||
"name": "Pose",
|
||||
"prompt": {"positive": "base"},
|
||||
"variants": {
|
||||
"light": {"soft": {"positive": "soft light", "default": True}, "hard": {"positive": "hard light"}},
|
||||
"mood": {"calm": {"positive": "calm"}, "angry": {"positive": "angry", "default": True}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_default_applied_when_absent():
|
||||
_with_flakes({"pose": POSE})
|
||||
flake = fio.resolve({"name": "pose"})
|
||||
assert "soft light" in flake.positive
|
||||
assert "calm" not in flake.positive and "angry" not in flake.positive
|
||||
|
||||
|
||||
def test_default_applied_when_empty():
|
||||
_with_flakes({"pose": POSE})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"light": ""}})
|
||||
assert "soft light" in flake.positive
|
||||
|
||||
|
||||
def test_explicit_choice_wins():
|
||||
_with_flakes({"pose": POSE})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"light": "hard"}})
|
||||
assert "hard light" in flake.positive and "soft light" not in flake.positive
|
||||
|
||||
|
||||
def test_none_skips_default():
|
||||
_with_flakes({"pose": POSE})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"light": "__none__"}})
|
||||
assert flake.positive == "base"
|
||||
|
||||
|
||||
def test_variant_defaults_only_first_choice():
|
||||
flake = fio._flake_from_raw("pose", POSE)
|
||||
assert fio.variant_defaults(flake) == {"light": "soft"}
|
||||
|
||||
|
||||
def test_link_variant_beats_default():
|
||||
_with_flakes({
|
||||
"pose": POSE,
|
||||
"host": {"name": "Host", "flake_links": [{"target": "pose", "variant": {"light": "hard"}}]},
|
||||
})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert "hard light" in flake.positive and "soft light" not in flake.positive
|
||||
|
||||
|
||||
def test_link_gets_default():
|
||||
_with_flakes({"pose": POSE, "host": {"name": "Host", "flake_links": [{"target": "pose"}]}})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert "soft light" in flake.positive
|
||||
|
||||
|
||||
def test_link_empty_variant_gets_default():
|
||||
_with_flakes({"pose": POSE, "host": {"name": "Host", "flake_links": [{"target": "pose", "variant": {"light": ""}}]}})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert "soft light" in flake.positive
|
||||
|
||||
|
||||
def test_link_none_skips_default():
|
||||
# A link saved with "None" picked in the editor opts out of the target default (#419).
|
||||
_with_flakes({"pose": POSE, "host": {"name": "Host", "flake_links": [{"target": "pose", "variant": {"light": "__none__"}}]}})
|
||||
flake = fio.resolve({"name": "host"})
|
||||
assert flake.positive == "base"
|
||||
|
||||
|
||||
def test_grid_override_beats_link_variant():
|
||||
_with_flakes({
|
||||
"pose": POSE,
|
||||
"host": {"name": "Host", "flake_links": [{"target": "pose", "variant": {"light": "hard"}}]},
|
||||
})
|
||||
flake = fio.resolve({"name": "host", "flake_link_overrides": [{"variant": {"light": "__none__"}}]})
|
||||
assert "light" not in flake.positive
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Tests for variant choices carrying LoRAs / ControlNets / flake links (#299).
|
||||
|
||||
Selecting a variant choice must merge that choice's `loras`, `controlnets`, and
|
||||
`flake_links` on top of the flake's base. Stubs `folder_paths` and loads
|
||||
`flake_io` under a synthetic package (same approach as the other tests), then
|
||||
monkeypatches `load_flake` to serve flakes from an in-memory yaml map.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
fp = types.ModuleType("folder_paths")
|
||||
fp.get_filename_list = lambda category: []
|
||||
fp.base_path = os.getcwd()
|
||||
sys.modules["folder_paths"] = fp
|
||||
|
||||
pkg = types.ModuleType("_fpkg2")
|
||||
# Point at the repo root so `from . import flake_families` resolves to the
|
||||
# real module (it has no ComfyUI dependencies, so it imports cleanly).
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_fpkg2"] = pkg
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
spec = importlib.util.spec_from_file_location("_fpkg2.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_fpkg2.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
fio = _load_flake_io()
|
||||
|
||||
|
||||
def _with_flakes(raw_map):
|
||||
fio.load_flake = lambda name: fio._flake_from_raw(name, raw_map[name])
|
||||
|
||||
|
||||
def _strength_of(flake, path):
|
||||
for lr in flake.loras:
|
||||
if lr.path == path:
|
||||
return lr.strength
|
||||
return None
|
||||
|
||||
|
||||
def test_choice_loras_applied_when_selected():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"variants": {"style": {"dramatic": {
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.7}],
|
||||
}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.7
|
||||
|
||||
|
||||
def test_choice_loras_absent_when_unselected():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"variants": {"style": {"dramatic": {
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.7}],
|
||||
}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose"})
|
||||
assert _strength_of(flake, "drama.safetensors") is None
|
||||
|
||||
|
||||
def test_choice_controlnets_applied():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"variants": {"style": {"dramatic": {
|
||||
"controlnets": [{"type": "openpose", "model": "openpose.safetensors", "image": "pose.png", "strength": 0.9}],
|
||||
}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}})
|
||||
assert any(cn.model_name == "openpose.safetensors" and cn.strength == 0.9 for cn in flake.controlnets)
|
||||
|
||||
|
||||
def test_choice_flake_links_applied_with_override():
|
||||
_with_flakes({
|
||||
"dramatic_lighting": {
|
||||
"name": "Dramatic Lighting",
|
||||
"loras": [{"name": "drama", "path": "drama.safetensors", "strength": 0.5}],
|
||||
},
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"variants": {"style": {"dramatic": {
|
||||
"flake_links": [{"target": "dramatic_lighting", "lora_strengths": [0.9]}],
|
||||
}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}})
|
||||
# The linked flake's LoRA comes in at the choice's overridden strength.
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.9
|
||||
|
||||
|
||||
def _host_and_choice_links():
|
||||
_with_flakes({
|
||||
"light": {
|
||||
"name": "Light",
|
||||
"loras": [{"name": "light", "path": "light.safetensors", "strength": 0.5}],
|
||||
"variants": {"tone": {"warm": {"default": True, "positive": "warm"}, "cold": {"positive": "cold"}}},
|
||||
},
|
||||
"ink": {"name": "Ink", "loras": [{"name": "ink", "path": "ink.safetensors", "strength": 0.4}]},
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"flake_links": [{"target": "ink"}],
|
||||
"variants": {"style": {
|
||||
"calm": {"default": True},
|
||||
"dramatic": {"flake_links": [{"target": "light", "lora_strengths": [0.9]}]},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def test_variant_link_overrides_apply_to_choice_links():
|
||||
"""`variant_link_overrides[group][choice][i]` overrides the choice's link i (#420)."""
|
||||
_host_and_choice_links()
|
||||
flake = fio.resolve({
|
||||
"name": "pose",
|
||||
"variant": {"style": "dramatic"},
|
||||
"variant_link_overrides": {"style": {"dramatic": [{"variant": {"tone": "cold"}, "lora_strengths": [1.3]}]}},
|
||||
})
|
||||
assert _strength_of(flake, "light.safetensors") == 1.3
|
||||
assert "cold" in flake.positive and "warm" not in flake.positive
|
||||
assert _strength_of(flake, "ink.safetensors") == 0.4
|
||||
|
||||
|
||||
def test_host_link_overrides_keep_their_index():
|
||||
"""Host links keep `flake_link_overrides[i]`; choice links never read it (#420)."""
|
||||
_host_and_choice_links()
|
||||
flake = fio.resolve({
|
||||
"name": "pose",
|
||||
"variant": {"style": "dramatic"},
|
||||
"flake_link_overrides": [{"lora_strengths": [0.2]}, {"lora_strengths": [2.0]}],
|
||||
})
|
||||
assert _strength_of(flake, "ink.safetensors") == 0.2
|
||||
assert _strength_of(flake, "light.safetensors") == 0.9
|
||||
|
||||
|
||||
def test_variant_link_overrides_of_other_choice_ignored():
|
||||
_host_and_choice_links()
|
||||
for ovr in ({"style": {"calm": [{"lora_strengths": [1.3]}]}}, "x", {"style": "x"}, {"style": {"dramatic": "x"}}, {"style": {"dramatic": ["x"]}}):
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}, "variant_link_overrides": ovr})
|
||||
assert _strength_of(flake, "light.safetensors") == 0.9
|
||||
|
||||
|
||||
def test_choice_prompt_still_works():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"prompt": {"positive": "base"},
|
||||
"variants": {"style": {"dramatic": {"positive": "moody lighting"}}},
|
||||
},
|
||||
})
|
||||
flake = fio.resolve({"name": "pose", "variant": {"style": "dramatic"}})
|
||||
assert "base" in flake.positive and "moody lighting" in flake.positive
|
||||
|
||||
|
||||
def _two_choice_pose():
|
||||
_with_flakes({
|
||||
"pose": {
|
||||
"name": "Pose",
|
||||
"variants": {"style": {
|
||||
"calm": {"default": True, "loras": [{"name": "calm", "path": "calm.safetensors", "strength": 0.5}]},
|
||||
"dramatic": {"loras": [
|
||||
{"name": "drama", "path": "drama.safetensors", "strength": 0.7},
|
||||
{"name": "light", "path": "light.safetensors", "strength": 0.3},
|
||||
]},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def test_variant_loras_override_selected_choice():
|
||||
"""`variant_loras[group][choice][i]` overrides the choice's LoRA strengths (#413)."""
|
||||
_two_choice_pose()
|
||||
flake = fio.resolve({
|
||||
"name": "pose",
|
||||
"variant": {"style": "dramatic"},
|
||||
"variant_loras": {"style": {"dramatic": [None, 1.2], "calm": [2.0]}},
|
||||
})
|
||||
assert _strength_of(flake, "drama.safetensors") == 0.7
|
||||
assert _strength_of(flake, "light.safetensors") == 1.2
|
||||
assert _strength_of(flake, "calm.safetensors") is None
|
||||
|
||||
|
||||
def test_variant_loras_override_default_choice():
|
||||
_two_choice_pose()
|
||||
flake = fio.resolve({"name": "pose", "variant_loras": {"style": {"calm": [0.9]}}})
|
||||
assert _strength_of(flake, "calm.safetensors") == 0.9
|
||||
|
||||
|
||||
def test_variant_loras_extra_slots_and_malformed_ignored():
|
||||
_two_choice_pose()
|
||||
flake = fio.resolve({"name": "pose", "variant_loras": {"style": {"calm": [0.9, 1.5, 2.0]}}})
|
||||
assert [lr.strength for lr in flake.loras] == [0.9]
|
||||
for bad in ("x", ["x"], {"style": "x"}, {"style": {"calm": "x"}}, {"style": {"calm": ["x"]}}):
|
||||
flake = fio.resolve({"name": "pose", "variant_loras": bad})
|
||||
assert _strength_of(flake, "calm.safetensors") == 0.5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures += 1
|
||||
print(f"ERROR {name}: {type(exc).__name__}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
|
||||
def test_choice_flat_prompt_and_prompt_list():
|
||||
# One prompt stays flat; several (or a scheduled one) live in `prompts:` (#468).
|
||||
_with_flakes({
|
||||
"ahri": {
|
||||
"prompt": {"positive": "ahri", "negative": "bad"},
|
||||
"variants": {"skin": {
|
||||
"classic": {"positive": "classic", "negative": ""},
|
||||
"arcade": {"positive": "ignored", "prompts": [
|
||||
{"positive": "arcade", "negative": "plain"},
|
||||
{"positive": "visor"},
|
||||
{"positive": "glow", "schedule": [{"start": 0.5, "strength": 1.0}]},
|
||||
]},
|
||||
}},
|
||||
},
|
||||
})
|
||||
classic = fio.resolve({"name": "ahri", "variant": {"skin": "classic"}})
|
||||
assert (classic.positive, classic.negative, classic.scheduled_prompts) == ("ahri, classic", "bad", [])
|
||||
arcade = fio.resolve({"name": "ahri", "variant": {"skin": "arcade"}})
|
||||
assert (arcade.positive, arcade.negative) == ("ahri, arcade, visor", "bad, plain")
|
||||
assert [p.positive for p in arcade.scheduled_prompts] == ["glow"]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for variant output-stem combination (#347).
|
||||
|
||||
A variant choice's ``output_stem`` must combine with the flake's base stem as a
|
||||
filename segment (``style_a`` + ``variant_b`` -> ``style_a_variant_b``), not a
|
||||
folder (``style_a/variant_b``). ``flake_io`` is loaded under a synthetic package
|
||||
with its external imports stubbed (same approach as test_filename_prefix.py).
|
||||
|
||||
Run directly (``python tests/test_variant_stem.py``) or via pytest.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_flake_io():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = os.path.join(os.path.dirname(here), "flake_io.py")
|
||||
|
||||
# Stub external imports used at module import time.
|
||||
sys.modules.setdefault("folder_paths", types.ModuleType("folder_paths"))
|
||||
if "yaml" not in sys.modules:
|
||||
try:
|
||||
import yaml # noqa: F401
|
||||
except Exception:
|
||||
yaml_stub = types.ModuleType("yaml")
|
||||
yaml_stub.safe_load = lambda *a, **k: {}
|
||||
sys.modules["yaml"] = yaml_stub
|
||||
|
||||
pkg = types.ModuleType("_flakepkg2")
|
||||
# Point at the repo root so `from . import flake_families` resolves to the
|
||||
# real module (it has no ComfyUI dependencies, so it imports cleanly).
|
||||
pkg.__path__ = [os.path.dirname(os.path.dirname(os.path.abspath(__file__)))]
|
||||
sys.modules["_flakepkg2"] = pkg
|
||||
spec = importlib.util.spec_from_file_location("_flakepkg2.flake_io", src)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_flakepkg2.flake_io"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
_io = _load_flake_io()
|
||||
_combine = _io._combine_variant_stem
|
||||
|
||||
|
||||
def test_file_prefix_base_appends_with_underscore():
|
||||
# The reported bug: a file-prefix base must NOT become a folder.
|
||||
assert _combine("style_a", "variant_b") == "style_a_variant_b"
|
||||
|
||||
|
||||
def test_folder_base_nests_variant():
|
||||
# An explicit folder base keeps the variant inside the folder.
|
||||
assert _combine("musashi/", "variant_b") == "musashi/variant_b"
|
||||
|
||||
|
||||
def test_no_base_uses_variant_only():
|
||||
assert _combine(None, "variant_b") == "variant_b"
|
||||
assert _combine("", "variant_b") == "variant_b"
|
||||
|
||||
|
||||
def test_variant_leading_slash_stripped():
|
||||
assert _combine("style_a", "/variant_b") == "style_a_variant_b"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except AssertionError as exc:
|
||||
failures += 1
|
||||
print(f"FAIL {name}: {exc}")
|
||||
print(f"\n{('ALL PASSED' if not failures else str(failures) + ' FAILED')}")
|
||||
sys.exit(1 if failures else 0)
|
||||
+188
-48
@@ -1,21 +1,28 @@
|
||||
// ---------- API ----------
|
||||
|
||||
let LIST_PROMISE = null;
|
||||
let LIST_FAMILY = null;
|
||||
// "kind:family" -> list promise.
|
||||
const LIST_PROMISES = new Map();
|
||||
|
||||
export function invalidateList() { LIST_PROMISE = null; LIST_FAMILY = null; }
|
||||
export function invalidateList() { LIST_PROMISES.clear(); LORA_NAMES_PROMISE = null; OPTION_NAMES_PROMISE = null; }
|
||||
|
||||
export async function fetchList(family = "") {
|
||||
const query = family ? `?family=${encodeURIComponent(family)}` : "";
|
||||
if (!LIST_PROMISE || LIST_FAMILY !== family) {
|
||||
LIST_FAMILY = family;
|
||||
LIST_PROMISE = fetch(`/flakes/list${query}`).then(r => r.json()).then(d => ({
|
||||
// `kind` "anim" lists the animate flakes under video/ (#431); `family` is then
|
||||
// an animate family label.
|
||||
export async function fetchList(family = "", kind = "img") {
|
||||
const key = `${kind}:${family}`;
|
||||
if (!LIST_PROMISES.has(key)) {
|
||||
const query = new URLSearchParams({ family: family || "", kind });
|
||||
LIST_PROMISES.set(key, fetch(`/flakes/list?${query}`).then(r => r.json()).then(d => ({
|
||||
flakes: Array.isArray(d.flakes) ? d.flakes : [],
|
||||
directories: Array.isArray(d.directories) ? d.directories : [],
|
||||
display_names: d.display_names || {},
|
||||
}));
|
||||
// Surface tag_names so the Load-existing-flake picker can show the
|
||||
// LoRA tag line, matching the grid items (#334).
|
||||
tag_names: d.tag_names || {},
|
||||
favorites: Array.isArray(d.favorites) ? d.favorites : [],
|
||||
folder_covers: d.folder_covers || {},
|
||||
})));
|
||||
}
|
||||
return LIST_PROMISE;
|
||||
return LIST_PROMISES.get(key);
|
||||
}
|
||||
|
||||
export async function fetchFlake(name) {
|
||||
@@ -27,19 +34,41 @@ export async function fetchFlake(name) {
|
||||
return (await r.json()).data || {};
|
||||
}
|
||||
|
||||
export async function saveFlakeApi(name, data, family = "") {
|
||||
export async function saveFlakeApi(name, data, family = "", baseRootIndex = null, outputPath = null, oldName = null, kind = "img") {
|
||||
const body = { name, data, family: family || undefined, kind };
|
||||
if (baseRootIndex != null) body.base_root_index = baseRootIndex;
|
||||
if (outputPath != null) body.output_path = outputPath;
|
||||
if (oldName != null) body.old_name = oldName;
|
||||
const r = await fetch("/flakes/save", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, data, family: family || undefined }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
invalidateList();
|
||||
const body = await r.json().catch(() => ({}));
|
||||
return body.name || name;
|
||||
delete META_CACHE[name];
|
||||
delete DEFAULTS_CACHE[name];
|
||||
delete AUTO_CAPTION_CACHE[name];
|
||||
const result = await r.json().catch(() => ({}));
|
||||
return result.name || name;
|
||||
}
|
||||
|
||||
// Copy image flake `source` into animate `family` (#433); resolves to the new name.
|
||||
export async function importAnimFlakeApi(source, family) {
|
||||
const r = await fetch("/flakes/anim/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, family }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
invalidateList();
|
||||
return (await r.json()).name;
|
||||
}
|
||||
|
||||
export async function deleteFlakeApi(name) {
|
||||
@@ -59,10 +88,28 @@ export function fetchLoras() {
|
||||
return LORAS_PROMISE;
|
||||
}
|
||||
|
||||
let CNMODELS_PROMISE = null;
|
||||
export function fetchCnModels() {
|
||||
if (!CNMODELS_PROMISE) CNMODELS_PROMISE = fetch("/flakes/cnmodels").then(r => r.json()).then(d => d.controlnets || []);
|
||||
return CNMODELS_PROMISE;
|
||||
// LoRA display names used across all flakes, suggested in the LoRA Name field.
|
||||
let LORA_NAMES_PROMISE = null;
|
||||
export function fetchLoraNames() {
|
||||
if (!LORA_NAMES_PROMISE) LORA_NAMES_PROMISE = fetch("/flakes/lora_names").then(r => r.json()).then(d => d.names || []);
|
||||
return LORA_NAMES_PROMISE;
|
||||
}
|
||||
|
||||
// Option names used across all flakes, suggested in toggler fields (#466).
|
||||
let OPTION_NAMES_PROMISE = null;
|
||||
export function fetchOptionNames() {
|
||||
if (!OPTION_NAMES_PROMISE) OPTION_NAMES_PROMISE = fetch("/flakes/option_names").then(r => r.json()).then(d => d.names || []);
|
||||
return OPTION_NAMES_PROMISE;
|
||||
}
|
||||
|
||||
// family label -> [{name, kind}] files a CN entry can pick as `model` (#381).
|
||||
const CN_MODELS_PROMISES = {};
|
||||
export function fetchCnModels(family) {
|
||||
if (!CN_MODELS_PROMISES[family]) {
|
||||
CN_MODELS_PROMISES[family] = fetch(`/flakes/cn_models?family=${encodeURIComponent(family || "")}`)
|
||||
.then(r => r.json()).then(d => d.models || []);
|
||||
}
|
||||
return CN_MODELS_PROMISES[family];
|
||||
}
|
||||
|
||||
let CN_TYPES_PROMISE = null;
|
||||
@@ -83,17 +130,11 @@ export function fetchCheckpoints() {
|
||||
return CKPTS_PROMISE;
|
||||
}
|
||||
|
||||
let VAES_PROMISE = null;
|
||||
export function fetchVaes() {
|
||||
if (!VAES_PROMISE) VAES_PROMISE = fetch("/flakes/vaes").then(r => r.json()).then(d => d.vaes || []);
|
||||
return VAES_PROMISE;
|
||||
}
|
||||
|
||||
export async function fetchBrowse(type, path = "") {
|
||||
export async function fetchBrowse(type, path = "", drive = "") {
|
||||
const r = await fetch("/flakes/browse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, path }),
|
||||
body: JSON.stringify({ type, path, drive }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
@@ -102,13 +143,40 @@ export async function fetchBrowse(type, path = "") {
|
||||
return (await r.json());
|
||||
}
|
||||
|
||||
// name -> preset data promise, cleared when a preset is saved or deleted.
|
||||
const PRESET_CACHE = new Map();
|
||||
|
||||
export function invalidatePresets() { PRESET_CACHE.clear(); }
|
||||
|
||||
// Each caller gets its own copy: the preset editor fills blanks in place.
|
||||
export async function fetchPreset(name) {
|
||||
const r = await fetch(`/flakes/preset?name=${encodeURIComponent(name)}`);
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
if (!PRESET_CACHE.has(name)) {
|
||||
PRESET_CACHE.set(name, fetch(`/flakes/preset?name=${encodeURIComponent(name)}`).then(async r => {
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
return (await r.json()).data || {};
|
||||
}).catch(e => {
|
||||
PRESET_CACHE.delete(name);
|
||||
throw e;
|
||||
}));
|
||||
}
|
||||
return structuredClone(await PRESET_CACHE.get(name));
|
||||
}
|
||||
|
||||
// Resolve which registered model_presets root the preset currently lives in,
|
||||
// so the editor can preselect the correct base path on edit. Returns null when
|
||||
// unknown. Kept separate from fetchPreset so existing callers are untouched.
|
||||
export async function fetchPresetRootIndex(name) {
|
||||
try {
|
||||
const r = await fetch(`/flakes/preset?name=${encodeURIComponent(name)}`);
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
return d.base_root_index ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return (await r.json()).data || {};
|
||||
}
|
||||
|
||||
export async function fetchPresets(family = "") {
|
||||
@@ -121,14 +189,63 @@ export async function fetchPresets(family = "") {
|
||||
return (await r.json()).presets || [];
|
||||
}
|
||||
|
||||
// --- Cover ---
|
||||
|
||||
export function getCoverUrl(name) {
|
||||
return `/flakes/cover?name=${encodeURIComponent(name)}`;
|
||||
// { families: [{label, folder, steps, ...}], resolutions } for animate presets.
|
||||
let ANIM_FAMILIES_PROMISE = null;
|
||||
export function fetchAnimFamilies() {
|
||||
if (!ANIM_FAMILIES_PROMISE) ANIM_FAMILIES_PROMISE = fetch("/flakes/anim/families").then(r => r.json());
|
||||
return ANIM_FAMILIES_PROMISE;
|
||||
}
|
||||
|
||||
export function getVariantImageUrl(name, group, choice) {
|
||||
return `/flakes/variant_image?name=${encodeURIComponent(name)}&group=${encodeURIComponent(group)}&choice=${encodeURIComponent(choice)}`;
|
||||
export async function fetchAnimFamily(label) {
|
||||
return ((await fetchAnimFamilies()).families || []).find(f => f.label === label) || null;
|
||||
}
|
||||
|
||||
// --- Cover ---
|
||||
|
||||
// Pass a `thumb` size (e.g. THUMB) for small displays: the server sends a
|
||||
// cached downscaled copy instead of the full image (#451).
|
||||
export const THUMB = 256;
|
||||
const thumbParam = (thumb) => thumb ? `&thumb=${thumb}` : "";
|
||||
|
||||
export function getCoverUrl(name, thumb) {
|
||||
return `/flakes/cover?name=${encodeURIComponent(name)}${thumbParam(thumb)}`;
|
||||
}
|
||||
|
||||
export function getPresetCoverUrl(name, thumb) {
|
||||
return `/flakes/preset_cover?name=${encodeURIComponent(name)}${thumbParam(thumb)}`;
|
||||
}
|
||||
|
||||
export function getVariantImageUrl(name, group, choice, thumb) {
|
||||
return `/flakes/variant_image?name=${encodeURIComponent(name)}&group=${encodeURIComponent(group)}&choice=${encodeURIComponent(choice)}${thumbParam(thumb)}`;
|
||||
}
|
||||
|
||||
// /view drops any folder in `filename`, so a path under input/ goes as `subfolder`.
|
||||
export function inputImageUrl(path) {
|
||||
const parts = path.replace(/\\/g, "/").split("/");
|
||||
const filename = parts.pop();
|
||||
return `/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(parts.join("/"))}&type=input`;
|
||||
}
|
||||
|
||||
// Preview URL of an image picked with pickImage: absolute (outside input/) or input-relative.
|
||||
export function pickedImageUrl(path) {
|
||||
return /^([A-Za-z]:)?[\\/]/.test(path) ? `/flakes/image?path=${encodeURIComponent(path)}` : inputImageUrl(path);
|
||||
}
|
||||
|
||||
// Upload a dropped image file to input/flakes/ (#447); resolves to its
|
||||
// input-relative path, the same form as an input/ pick.
|
||||
export async function uploadInputImage(file) {
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("subfolder", "flakes");
|
||||
form.append("type", "input");
|
||||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
return d.subfolder ? `${d.subfolder}/${d.name}` : d.name;
|
||||
}
|
||||
|
||||
export function flakeAssetUrl(name, path) {
|
||||
return `/flakes/asset?name=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
export function loraSiblingImageUrl(path) {
|
||||
@@ -152,31 +269,54 @@ export async function fetchLoraSiblingImagePath(loraPath) {
|
||||
return d.path || null;
|
||||
}
|
||||
|
||||
export async function uploadCover(name, file) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const r = await fetch(`/flakes/cover?name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
export async function fetchLoraVariantSiblingImagePath(loraPath, group, choice) {
|
||||
const r = await fetch(`/flakes/lora_variant_sibling_image_path?path=${encodeURIComponent(loraPath)}&group=${encodeURIComponent(group)}&choice=${encodeURIComponent(choice)}`);
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json().catch(() => ({}));
|
||||
return d.path || null;
|
||||
}
|
||||
|
||||
// --- Flake meta ---
|
||||
|
||||
const META_CACHE = {};
|
||||
const DEFAULTS_CACHE = {};
|
||||
const AUTO_CAPTION_CACHE = {};
|
||||
export async function fetchFlakeMeta(name) {
|
||||
if (META_CACHE[name]) return META_CACHE[name];
|
||||
const r = await fetch(`/flakes/meta?name=${encodeURIComponent(name)}`);
|
||||
if (!r.ok) return {};
|
||||
const d = await r.json();
|
||||
META_CACHE[name] = d.variants || d.options || {};
|
||||
DEFAULTS_CACHE[name] = d.defaults || {};
|
||||
AUTO_CAPTION_CACHE[name] = !!d.auto_caption;
|
||||
return META_CACHE[name];
|
||||
}
|
||||
|
||||
// A flake's {group: default choice} map (#388), or undefined until
|
||||
// fetchFlakeMeta has loaded it.
|
||||
export function cachedVariantDefaults(name) {
|
||||
return DEFAULTS_CACHE[name];
|
||||
}
|
||||
|
||||
// Whether an animate flake has `auto_caption: true`, once fetchFlakeMeta has loaded it.
|
||||
export function cachedAutoCaption(name) {
|
||||
return AUTO_CAPTION_CACHE[name];
|
||||
}
|
||||
|
||||
// Synchronous best-effort count of a flake's full variant-combination set
|
||||
// (product of each group's choice count), using whatever is in META_CACHE.
|
||||
// Returns 1 when the meta isn't cached yet (#343) — the exact batch count is
|
||||
// still computed at queue time from the freshly-fetched meta.
|
||||
export function variantComboCount(name) {
|
||||
const m = META_CACHE[name];
|
||||
if (!m) return 1;
|
||||
let n = 1;
|
||||
for (const g of Object.keys(m)) {
|
||||
if (Array.isArray(m[g]) && m[g].length) n *= m[g].length;
|
||||
}
|
||||
return n || 1;
|
||||
}
|
||||
|
||||
let EMBEDDINGS_PROMISE = null;
|
||||
export function fetchEmbeddings() {
|
||||
if (!EMBEDDINGS_PROMISE) EMBEDDINGS_PROMISE = fetch("/flakes/embeddings").then(r => r.json()).then(d => d.embeddings || []);
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { invalidateList } from "./api.js";
|
||||
import { refreshPresetOptions } from "./preset-modal.js";
|
||||
|
||||
// Workflow-local flakes and presets (#437) are staged as "_local/<hash>/<name>".
|
||||
const LOCAL_PREFIX = /^_local\/[0-9a-f]+\//;
|
||||
|
||||
function* graphNodes() {
|
||||
for (const graph of [app.graph, ...(app.graph.subgraphs?.values() ?? [])]) yield* graph.nodes;
|
||||
}
|
||||
|
||||
function flakeEntries(node) {
|
||||
const widget = node.widgets?.find(w => w.name === "flakes_json");
|
||||
return [widget, widget ? JSON.parse(widget.value || "[]") : []];
|
||||
}
|
||||
|
||||
// Point the nodes' flakes and presets at their new names. A name staged from
|
||||
// an earlier bundle matches by its original name.
|
||||
function renameInNodes({ flakes, presets }) {
|
||||
const renamed = (map, name) => map[name] ?? map[name?.replace(LOCAL_PREFIX, "")] ?? name;
|
||||
for (const node of graphNodes()) {
|
||||
const [flakesWidget, entries] = flakeEntries(node);
|
||||
if (entries.some(e => renamed(flakes, e.name) !== e.name)) {
|
||||
for (const e of entries) e.name = renamed(flakes, e.name);
|
||||
flakesWidget.value = JSON.stringify(entries);
|
||||
(node._flakes_render ?? node._combo_render)?.();
|
||||
}
|
||||
const presetWidget = node.widgets?.find(w => w.name === "preset");
|
||||
const combo = node.properties?._combo_presets;
|
||||
if (presetWidget && [presetWidget.value, ...(combo ?? [])].some(p => renamed(presets, p) !== p)) {
|
||||
presetWidget.value = renamed(presets, presetWidget.value);
|
||||
if (combo) {
|
||||
node.properties._combo_presets = combo.map(p => renamed(presets, p));
|
||||
node.properties._combo_bypassed = (node.properties._combo_bypassed ?? []).map(p => renamed(presets, p));
|
||||
}
|
||||
(node._preset_render ?? node._model_combo_render)?.();
|
||||
}
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function graphUsesLocal() {
|
||||
for (const node of graphNodes()) {
|
||||
const names = [...flakeEntries(node)[1].map(e => e.name), node.widgets?.find(w => w.name === "preset")?.value, ...(node.properties?._combo_presets ?? [])];
|
||||
if (names.some(n => LOCAL_PREFIX.test(n ?? ""))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// "Save to disk" on a local flake or preset: installs it and the flakes it
|
||||
// links to, then points the workflow at the saved names. The bundle goes once
|
||||
// nothing local is left.
|
||||
export async function saveLocalToDisk(name) {
|
||||
const r = await fetch("/flakes/bundle/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const result = await r.json();
|
||||
if (!r.ok) {
|
||||
window.alert(`Failed to save '${name}' to disk: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
invalidateList();
|
||||
await refreshPresetOptions();
|
||||
renameInNodes(result);
|
||||
if (!graphUsesLocal()) delete app.graph.extra?.flake_bundle;
|
||||
}
|
||||
|
||||
// Staging results by bundle JSON: a tab switch reloads a bundle this session
|
||||
// already staged.
|
||||
const STAGED = new Map();
|
||||
|
||||
function stageBundle(bundle) {
|
||||
const body = JSON.stringify(bundle);
|
||||
if (!STAGED.has(body)) {
|
||||
STAGED.set(body, fetch("/flakes/bundle/stage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
}).then(async r => {
|
||||
const result = await r.json();
|
||||
if (!r.ok) throw new Error(`[flakes] bundle staging failed: ${result.error}`);
|
||||
return result;
|
||||
}).catch(e => {
|
||||
STAGED.delete(body);
|
||||
throw e;
|
||||
}));
|
||||
}
|
||||
return STAGED.get(body);
|
||||
}
|
||||
|
||||
// A dropped Flake Generate image carries the flakes and presets it used in
|
||||
// workflow.extra.flake_bundle (#427). They stay local to the workflow (#437):
|
||||
// staged in ComfyUI's temp folder, nothing installed on disk. Loading doesn't
|
||||
// wait for staging; the nodes are renamed once it's done, unless another
|
||||
// workflow was loaded meanwhile.
|
||||
app.registerExtension({
|
||||
name: "comfyui-flakes.Bundle",
|
||||
afterConfigureGraph() {
|
||||
const bundle = app.graph.extra?.flake_bundle;
|
||||
if (!bundle) return;
|
||||
stageBundle(bundle).then(result => {
|
||||
if (app.graph.extra?.flake_bundle === bundle) renameInNodes(result);
|
||||
});
|
||||
},
|
||||
});
|
||||
+1237
-658
File diff suppressed because it is too large
Load Diff
+70
-18
@@ -6,6 +6,7 @@ import { setupFlakeModelComboWidget } from "./widgets/flake-model-combo.js";
|
||||
import { setupFlakeDataSplitSelect, setupIntoFlakeDataSelect, DEFAULT_SPLIT_PINS, DEFAULT_INTO_PINS } from "./widgets/flake-data-select.js";
|
||||
import { setupPreviewFlakeDataWidget } from "./widgets/flake-preview.js";
|
||||
import { setupFlakeGenerateWidget } from "./widgets/flake-generate.js";
|
||||
import { ensureFamilies } from "./utils.js";
|
||||
import "./queue.js";
|
||||
|
||||
function removeHiddenInputs(node, names) {
|
||||
@@ -25,15 +26,53 @@ function setDefaultSize(node, minWidth) {
|
||||
}
|
||||
}
|
||||
|
||||
// Saved image metadata flags nodes it converted or merged (#436): they still
|
||||
// carry the original node's size, so shrink to the minimum width and fit.
|
||||
function fitSavedSize(node, minWidth) {
|
||||
if (!node.properties?._fit_size) return;
|
||||
delete node.properties._fit_size;
|
||||
node.setSize([minWidth, node.size[1]]);
|
||||
setDefaultSize(node, minWidth);
|
||||
}
|
||||
|
||||
// Recover from workflows saved by the broken v0.1.x where model_family was
|
||||
// detached from node.widgets when flake_data was connected — that shifted
|
||||
// the widget value order so onConfigure assigned `flakes_json` (a JSON
|
||||
// string) into model_family and left flakes_json at its default "[]".
|
||||
//
|
||||
// Heuristic: if model_family looks like JSON ("[" or "{") and flakes_json
|
||||
// is empty/default, swap them. Same for `preset` on the model nodes (the
|
||||
// preset is stored as a yaml-relative name, never starts with [).
|
||||
function _recoverShiftedWidgets(node, otherWidgetName) {
|
||||
if (!node.widgets) return;
|
||||
const family = node.widgets.find(w => w.name === "model_family");
|
||||
const other = node.widgets.find(w => w.name === otherWidgetName);
|
||||
if (!family || !other) return;
|
||||
const fv = String(family.value || "");
|
||||
if (!fv.startsWith("[") && !fv.startsWith("{")) return;
|
||||
const ov = String(other.value || "");
|
||||
const otherIsEmpty = ov === "" || ov === "[]" || ov === "Select a preset..." || ov === "No model preset is selected";
|
||||
if (!otherIsEmpty) return;
|
||||
other.value = fv;
|
||||
family.value = "SDXL/Base";
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyui-flakes.FlakeStack",
|
||||
// Warm the family registry at startup (#360) so modals — which build their
|
||||
// widgets synchronously — can read family defaults without awaiting.
|
||||
async setup() {
|
||||
try { await ensureFamilies(); } catch { /* falls back to the literals */ }
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "FlakeStack") {
|
||||
if (nodeData.name === "FlakeStack" || nodeData.name === "FlakeAnimStack") {
|
||||
const kind = nodeData.name === "FlakeAnimStack" ? "anim" : "img";
|
||||
const hidden = [kind === "anim" ? "family" : "model_family", "flakes_json"];
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = origOnNodeCreated?.apply(this, arguments);
|
||||
setupFlakeWidget(this);
|
||||
removeHiddenInputs(this, ["model_family", "flakes_json"]);
|
||||
setupFlakeWidget(this, { kind });
|
||||
removeHiddenInputs(this, hidden);
|
||||
if (!this._configured) {
|
||||
setDefaultSize(this, 340);
|
||||
}
|
||||
@@ -44,17 +83,21 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
this._configured = true;
|
||||
removeHiddenInputs(this, ["model_family", "flakes_json"]);
|
||||
removeHiddenInputs(this, hidden);
|
||||
_recoverShiftedWidgets(this, "flakes_json");
|
||||
this._flakes_render?.();
|
||||
fitSavedSize(this, 340);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
if (nodeData.name === "FlakeModelPreset") {
|
||||
if (nodeData.name === "FlakeModelPreset" || nodeData.name === "FlakeAnimPreset") {
|
||||
const kind = nodeData.name === "FlakeAnimPreset" ? "anim" : "img";
|
||||
const hidden = [kind === "anim" ? "family" : "model_family", "preset"];
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = origOnNodeCreated?.apply(this, arguments);
|
||||
setupFlakeModelPresetWidget(this);
|
||||
removeHiddenInputs(this, ["model_family", "preset"]);
|
||||
setupFlakeModelPresetWidget(this, { kind });
|
||||
removeHiddenInputs(this, hidden);
|
||||
if (!this._configured) {
|
||||
setDefaultSize(this, 300);
|
||||
}
|
||||
@@ -65,17 +108,20 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
this._configured = true;
|
||||
removeHiddenInputs(this, ["model_family", "preset"]);
|
||||
removeHiddenInputs(this, hidden);
|
||||
this._preset_render?.();
|
||||
fitSavedSize(this, 300);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
if (nodeData.name === "FlakeCombo") {
|
||||
if (nodeData.name === "FlakeCombo" || nodeData.name === "FlakeAnimCombo") {
|
||||
const kind = nodeData.name === "FlakeAnimCombo" ? "anim" : "img";
|
||||
const hidden = [kind === "anim" ? "family" : "model_family", "flakes_json"];
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = origOnNodeCreated?.apply(this, arguments);
|
||||
setupFlakeComboWidget(this);
|
||||
removeHiddenInputs(this, ["model_family", "flakes_json"]);
|
||||
setupFlakeComboWidget(this, { kind });
|
||||
removeHiddenInputs(this, hidden);
|
||||
if (!this._configured) {
|
||||
setDefaultSize(this, 340);
|
||||
}
|
||||
@@ -86,17 +132,20 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
this._configured = true;
|
||||
removeHiddenInputs(this, ["model_family", "flakes_json"]);
|
||||
removeHiddenInputs(this, hidden);
|
||||
_recoverShiftedWidgets(this, "flakes_json");
|
||||
this._combo_render?.();
|
||||
return r;
|
||||
};
|
||||
}
|
||||
if (nodeData.name === "FlakeModelCombo") {
|
||||
if (nodeData.name === "FlakeModelCombo" || nodeData.name === "FlakeAnimPresetCombo") {
|
||||
const kind = nodeData.name === "FlakeAnimPresetCombo" ? "anim" : "img";
|
||||
const hidden = [kind === "anim" ? "family" : "model_family", "preset"];
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = origOnNodeCreated?.apply(this, arguments);
|
||||
setupFlakeModelComboWidget(this);
|
||||
removeHiddenInputs(this, ["model_family", "preset"]);
|
||||
setupFlakeModelComboWidget(this, { kind });
|
||||
removeHiddenInputs(this, hidden);
|
||||
if (!this._configured) {
|
||||
setDefaultSize(this, 300);
|
||||
}
|
||||
@@ -107,7 +156,7 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
this._configured = true;
|
||||
removeHiddenInputs(this, ["model_family", "preset"]);
|
||||
removeHiddenInputs(this, hidden);
|
||||
this._model_combo_render?.();
|
||||
return r;
|
||||
};
|
||||
@@ -172,11 +221,13 @@ app.registerExtension({
|
||||
return r;
|
||||
};
|
||||
}
|
||||
if (nodeData.name === "FlakeGenerate") {
|
||||
if (nodeData.name === "FlakeGenerate" || nodeData.name === "FlakeGenerateAnimation") {
|
||||
const video = nodeData.name === "FlakeGenerateAnimation";
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = origOnNodeCreated?.apply(this, arguments);
|
||||
setupFlakeGenerateWidget(this);
|
||||
setupFlakeGenerateWidget(this, { video });
|
||||
removeHiddenInputs(this, ["caption"]);
|
||||
if (!this._configured) {
|
||||
setDefaultSize(this, 300);
|
||||
}
|
||||
@@ -187,6 +238,7 @@ app.registerExtension({
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
this._configured = true;
|
||||
removeHiddenInputs(this, ["caption"]);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
import {
|
||||
css, makeSmallButton, makeComfyInput, makeComfyDropdown, makeSearchableDropdown, makeTextarea, attachAutoGrow, makeHoverRemoveWrapper,
|
||||
makeComfyNumberInput, makeComfyValueSlider, makeLabel, familyFolder, variantDefaultsOf, variantDropdownOptions,
|
||||
} from "./utils.js";
|
||||
import { fetchLoras, fetchLoraNames, fetchOptionNames, fetchFlake } from "./api.js";
|
||||
import { openFileBrowser, openFileLoadPicker } from "./pickers.js";
|
||||
|
||||
// LoRA and flake-link list editors shared by the flake and model preset editors.
|
||||
|
||||
// Transient menu anchored under a ☰ handle. `items` is [{ label, onClick }].
|
||||
export function openFieldMenu(anchorEl, items) {
|
||||
const menu = document.createElement("div");
|
||||
css(menu, "display:flex;flex-direction:column;gap:2px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;box-shadow:0 4px 12px rgba(0,0,0,0.5);position:fixed;z-index:10000;");
|
||||
function cleanup() {
|
||||
document.removeEventListener("click", onOutside, true);
|
||||
if (menu.parentElement) menu.remove();
|
||||
}
|
||||
function onOutside(e) {
|
||||
if (!menu.contains(e.target) && e.target !== anchorEl) cleanup();
|
||||
}
|
||||
for (const o of items) {
|
||||
const it = document.createElement("button");
|
||||
it.textContent = o.label;
|
||||
css(it, "text-align:left;padding:4px 8px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:12px;");
|
||||
it.addEventListener("click", (e) => { e.stopPropagation(); cleanup(); o.onClick(); });
|
||||
menu.appendChild(it);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
menu.style.left = `${rect.left}px`;
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
// Defer so the opening click doesn't immediately close it.
|
||||
setTimeout(() => document.addEventListener("click", onOutside, true), 0);
|
||||
}
|
||||
|
||||
// ☰ handle for a schedulable card (LoRA / prompt / flake link, #372).
|
||||
export function makeScheduleHandle(entry, rerender) {
|
||||
const handle = document.createElement("span");
|
||||
handle.textContent = "☰";
|
||||
css(handle, "cursor:pointer;color:#666;font-size:12px;user-select:none;");
|
||||
handle.title = "Options";
|
||||
handle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openFieldMenu(handle, [scheduleMenuItem(entry, rerender)]);
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
function scheduleMenuItem(entry, rerender) {
|
||||
if (entry.schedule) {
|
||||
return { label: "Remove Hook Scheduler", onClick: () => { delete entry.schedule; rerender(); } };
|
||||
}
|
||||
return {
|
||||
label: "Add Hook Scheduler",
|
||||
onClick: () => { entry.schedule = [{ start: 0.0, strength: 1.0 }, { start: 0.5, strength: 0.0 }]; rerender(); },
|
||||
};
|
||||
}
|
||||
|
||||
// Keyframe editor for `entry.schedule` (#372): a 0-100% timeline showing the
|
||||
// strength of each segment plus Start % / Strength rows. Start is stored as a
|
||||
// fraction; strength holds until the next keyframe and is 1.0 before the first.
|
||||
export function makeSchedulePanel(entry) {
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "display:flex;flex-direction:column;gap:4px;background:#1e1e1e;border:1px solid #333;border-radius:4px;padding:6px;");
|
||||
const fmt = (v) => String(Math.round(v * 100) / 100);
|
||||
function draw() {
|
||||
panel.replaceChildren();
|
||||
const kfs = entry.schedule;
|
||||
kfs.sort((a, b) => a.start - b.start);
|
||||
const maxStrength = Math.max(1, ...kfs.map(k => k.strength));
|
||||
const bar = document.createElement("div");
|
||||
css(bar, "position:relative;height:18px;background:#141414;border:1px solid #333;border-radius:3px;overflow:hidden;");
|
||||
const segments = [{ start: 0, strength: 1.0 }, ...kfs];
|
||||
segments.forEach((k, i) => {
|
||||
const end = i + 1 < segments.length ? segments[i + 1].start : 1;
|
||||
if (end <= k.start) return;
|
||||
const seg = document.createElement("div");
|
||||
const alpha = 0.15 + 0.75 * Math.min(1, k.strength / maxStrength);
|
||||
css(seg, `position:absolute;top:0;bottom:0;left:${k.start * 100}%;width:${(end - k.start) * 100}%;background:rgba(74,158,255,${k.strength > 0 ? alpha : 0});border-left:1px solid #444;box-sizing:border-box;font-size:10px;color:#ddd;display:flex;align-items:center;justify-content:center;overflow:hidden;white-space:nowrap;`);
|
||||
seg.textContent = fmt(k.strength);
|
||||
seg.title = `${fmt(k.start * 100)}% – ${fmt(end * 100)}%: strength ${fmt(k.strength)}`;
|
||||
bar.appendChild(seg);
|
||||
});
|
||||
panel.appendChild(bar);
|
||||
|
||||
kfs.forEach((k, i) => {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;gap:6px;align-items:center;");
|
||||
const startLbl = document.createElement("span");
|
||||
startLbl.textContent = "Start %";
|
||||
css(startLbl, "font-size:11px;color:#888;white-space:nowrap;");
|
||||
row.appendChild(startLbl);
|
||||
const startIn = makeComfyNumberInput(fmt(k.start * 100), "0", 1);
|
||||
startIn.min = "0";
|
||||
startIn.max = "100";
|
||||
css(startIn, startIn.style.cssText + "flex:1;min-width:0;padding:3px 6px;font-size:12px;");
|
||||
startIn.addEventListener("change", () => {
|
||||
const v = parseFloat(startIn.value);
|
||||
k.start = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : k.start;
|
||||
draw();
|
||||
});
|
||||
row.appendChild(startIn);
|
||||
const strLbl = document.createElement("span");
|
||||
strLbl.textContent = "Strength";
|
||||
css(strLbl, "font-size:11px;color:#888;white-space:nowrap;");
|
||||
row.appendChild(strLbl);
|
||||
const strIn = makeComfyNumberInput(fmt(k.strength), "1", 0.05);
|
||||
strIn.min = "0";
|
||||
css(strIn, strIn.style.cssText + "flex:1;min-width:0;padding:3px 6px;font-size:12px;");
|
||||
strIn.addEventListener("change", () => {
|
||||
const v = parseFloat(strIn.value);
|
||||
k.strength = Number.isFinite(v) ? Math.max(0, v) : k.strength;
|
||||
draw();
|
||||
});
|
||||
row.appendChild(strIn);
|
||||
const rm = makeSmallButton("✕");
|
||||
rm.title = "Remove keyframe";
|
||||
if (kfs.length === 1) rm.style.visibility = "hidden";
|
||||
rm.addEventListener("click", () => { kfs.splice(i, 1); draw(); });
|
||||
row.appendChild(rm);
|
||||
panel.appendChild(row);
|
||||
});
|
||||
|
||||
const addBtn = makeSmallButton("+ Keyframe");
|
||||
addBtn.style.alignSelf = "flex-start";
|
||||
addBtn.addEventListener("click", () => {
|
||||
const last = kfs[kfs.length - 1];
|
||||
kfs.push({ start: last ? (last.start + 1) / 2 : 0, strength: 1.0 });
|
||||
draw();
|
||||
});
|
||||
panel.appendChild(addBtn);
|
||||
}
|
||||
draw();
|
||||
return panel;
|
||||
}
|
||||
|
||||
// Open the load picker (search bar + folders + flakes) and return the chosen
|
||||
// flake name, or null. `exclude` keeps a flake from linking to itself.
|
||||
export async function pickFlakeTarget(family, exclude, kind = "img") {
|
||||
try {
|
||||
const list = await (await fetch(`/flakes/list?${new URLSearchParams({ family: family || "", kind })}`)).json();
|
||||
const flakes = (list.flakes || []).filter(n => n !== exclude);
|
||||
const res = await openFileLoadPicker({
|
||||
flakes,
|
||||
directories: list.directories || [],
|
||||
family: family || "",
|
||||
kind,
|
||||
displayNames: list.display_names || {},
|
||||
tagNames: list.tag_names || {},
|
||||
favorites: list.favorites || [],
|
||||
folderCovers: list.folder_covers || {},
|
||||
});
|
||||
return res && res.name ? res.name : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// LoRA cards for `loras`. `tagName` enables the tag-name checkbox (#274);
|
||||
// `onOutputTag` adds a toggle putting the LoRA's name in the output stem and
|
||||
// path, and is called when that changes (#458).
|
||||
// `onFirstPath` is called when the first LoRA's file changes.
|
||||
// `loraDir()` gives the folder the LoRA picker opens in (the flake's own
|
||||
// path, e.g. img/anima/characters); defaults to the family folder.
|
||||
// `lowFile` adds a LOW file (`path_low`) under the HIGH one, sharing the
|
||||
// strength (Wan 2.2 animate flakes, #431).
|
||||
export function renderLoraList(box, loras, { family, tagName = false, onOutputTag, onFirstPath, loraDir, showAdd = true, defaultStrength = 1.0, lowFile = false } = {}) {
|
||||
const folder = familyFolder(family);
|
||||
// File row for `lora[key]`; only the first LoRA's `path` calls onFirstPath.
|
||||
function makePathRow(lora, i, key, label) {
|
||||
const pathRow = document.createElement("div");
|
||||
css(pathRow, "display:flex;gap:4px;align-items:center;");
|
||||
if (label) {
|
||||
const tag = document.createElement("span");
|
||||
tag.textContent = label;
|
||||
css(tag, "flex:0 0 32px;font-size:10px;color:#888;");
|
||||
pathRow.appendChild(tag);
|
||||
}
|
||||
const fileLabel = (v) => v ? v.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "No LoRA selected";
|
||||
const firstPath = (v) => { if (i === 0 && key === "path" && v) onFirstPath?.(v); };
|
||||
|
||||
const loraWrap = makeSearchableDropdown([], lora[key] || "", "Select LoRA...");
|
||||
loraWrap.container.style.display = "none";
|
||||
(async () => {
|
||||
try {
|
||||
const names = await fetchLoras();
|
||||
for (const l of names) loraWrap.datalist.appendChild(Object.assign(document.createElement("option"), { value: l }));
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
const pathBox = document.createElement("div");
|
||||
css(pathBox, "flex:1;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;height:32px;box-sizing:border-box;display:flex;align-items:center;");
|
||||
pathBox.textContent = fileLabel(lora[key]);
|
||||
pathBox.title = lora[key] || "";
|
||||
|
||||
const pathEditBtn = makeSmallButton("...");
|
||||
pathEditBtn.title = "Type manually";
|
||||
|
||||
pathBox.addEventListener("click", async () => {
|
||||
const result = await openFileBrowser({ type: "loras", defaultPath: loraDir?.() || (folder ? `img/${folder}` : "") });
|
||||
if (result && result.file) {
|
||||
lora[key] = result.file;
|
||||
pathBox.textContent = fileLabel(lora[key]);
|
||||
pathBox.title = lora[key] || "";
|
||||
loraWrap.element.value = lora[key];
|
||||
firstPath(lora[key]);
|
||||
}
|
||||
});
|
||||
|
||||
pathEditBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pathBox.style.display = "none";
|
||||
pathEditBtn.style.display = "none";
|
||||
loraWrap.container.style.display = "block";
|
||||
loraWrap.element.focus();
|
||||
});
|
||||
|
||||
const commitTyped = () => {
|
||||
const val = loraWrap.element.value;
|
||||
lora[key] = val;
|
||||
pathBox.textContent = fileLabel(val);
|
||||
pathBox.title = val;
|
||||
pathBox.style.display = "block";
|
||||
pathEditBtn.style.display = "inline-block";
|
||||
loraWrap.container.style.display = "none";
|
||||
firstPath(val);
|
||||
};
|
||||
loraWrap.element.addEventListener("change", commitTyped);
|
||||
loraWrap.element.addEventListener("blur", () => setTimeout(commitTyped, 200));
|
||||
|
||||
pathRow.appendChild(pathBox);
|
||||
pathRow.appendChild(pathEditBtn);
|
||||
pathRow.appendChild(loraWrap.container);
|
||||
|
||||
if (lora[key]) {
|
||||
const clearBtn = makeSmallButton("✕");
|
||||
css(clearBtn, "color:#f88;");
|
||||
clearBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
lora[key] = "";
|
||||
pathBox.textContent = "No LoRA selected";
|
||||
pathBox.title = "";
|
||||
loraWrap.element.value = "";
|
||||
});
|
||||
pathRow.appendChild(clearBtn);
|
||||
}
|
||||
return pathRow;
|
||||
}
|
||||
|
||||
// Free-text name field suggesting display names already used in flakes.
|
||||
function makeLoraNameInput(value, placeholder) {
|
||||
const dd = makeSearchableDropdown([], value, placeholder);
|
||||
fetchLoraNames().then(names => {
|
||||
for (const n of names) dd.datalist.appendChild(Object.assign(document.createElement("option"), { value: n }));
|
||||
}).catch(() => {});
|
||||
return dd;
|
||||
}
|
||||
|
||||
function renderLoras() {
|
||||
box.replaceChildren();
|
||||
for (let i = 0; i < loras.length; i++) {
|
||||
const lora = loras[i];
|
||||
const card = document.createElement("div");
|
||||
css(card, "background:#252525;padding:10px;border-radius:6px;display:flex;flex-direction:column;gap:6px;border:1px solid #333;");
|
||||
|
||||
const header = document.createElement("div");
|
||||
css(header, "display:flex;gap:6px;align-items:center;");
|
||||
|
||||
header.appendChild(makeScheduleHandle(lora, renderLoras));
|
||||
|
||||
const title = document.createElement("span");
|
||||
const titleText = lora.name ? `LoRA: ${lora.name}` : "LoRA";
|
||||
title.textContent = titleText;
|
||||
css(title, "flex:1;font-size:12px;font-weight:500;color:#aaa;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
|
||||
header.appendChild(title);
|
||||
|
||||
if (lora.url) {
|
||||
const linkIcon = document.createElement("a");
|
||||
linkIcon.textContent = "\uD83D\uDD17";
|
||||
css(linkIcon, "font-size:12px;text-decoration:none;cursor:pointer;color:#4a9eff;");
|
||||
linkIcon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
window.open(lora.url, "_blank");
|
||||
});
|
||||
header.appendChild(linkIcon);
|
||||
}
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.innerHTML = lora._editing ? "▴" : "▾";
|
||||
css(editBtn, "background:transparent;color:#888;border:none;padding:0;font-size:14px;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center;height:20px;width:20px;");
|
||||
editBtn.title = "Edit name and URL";
|
||||
editBtn.addEventListener("click", () => {
|
||||
lora._editing = !lora._editing;
|
||||
renderLoras();
|
||||
});
|
||||
header.appendChild(editBtn);
|
||||
|
||||
const removeBtn = makeSmallButton("\u2715");
|
||||
removeBtn.addEventListener("click", () => {
|
||||
loras.splice(i, 1);
|
||||
renderLoras();
|
||||
});
|
||||
header.appendChild(removeBtn);
|
||||
|
||||
card.appendChild(header);
|
||||
|
||||
if (lora._editing) {
|
||||
const editRow = document.createElement("div");
|
||||
css(editRow, "display:flex;flex-direction:column;gap:4px;");
|
||||
const nameDD = makeLoraNameInput(lora.name || "", "Display name");
|
||||
const nameInput = nameDD.element;
|
||||
nameInput.addEventListener("change", () => {
|
||||
lora.name = nameInput.value;
|
||||
if (lora._tag_output) onOutputTag?.();
|
||||
renderLoras();
|
||||
});
|
||||
const urlInput = makeComfyInput(lora.url || "", "https://civitai.com/models/...");
|
||||
urlInput.addEventListener("change", () => {
|
||||
lora.url = urlInput.value;
|
||||
renderLoras();
|
||||
});
|
||||
editRow.appendChild(makeLabel("Name"));
|
||||
editRow.appendChild(nameDD.container);
|
||||
if (lowFile) {
|
||||
const lowNameDD = makeLoraNameInput(lora.name_low || "", "Display name of the LOW file");
|
||||
lowNameDD.element.addEventListener("change", () => { lora.name_low = lowNameDD.element.value; });
|
||||
editRow.appendChild(makeLabel("LOW name"));
|
||||
editRow.appendChild(lowNameDD.container);
|
||||
}
|
||||
if (tagName || onOutputTag) {
|
||||
const tagRow = document.createElement("div");
|
||||
css(tagRow, "display:flex;align-items:center;gap:6px;margin-top:2px;");
|
||||
const addToggle = (text, key, onChange) => {
|
||||
const chk = document.createElement("input");
|
||||
chk.type = "checkbox";
|
||||
chk.checked = !!lora[key];
|
||||
css(chk, "cursor:pointer;margin:0;");
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = text;
|
||||
css(lbl, "font-size:11px;color:#aaa;cursor:pointer;");
|
||||
const toggle = () => { lora[key] = chk.checked; onChange?.(); };
|
||||
chk.addEventListener("change", toggle);
|
||||
lbl.addEventListener("click", () => { chk.checked = !chk.checked; toggle(); });
|
||||
tagRow.append(chk, lbl);
|
||||
return chk;
|
||||
};
|
||||
// Tag-name checkbox (#274): append this LoRA's name to the grid display name.
|
||||
if (tagName) {
|
||||
const tagChk = addToggle("Tag flake with this LoRA's name", "tag_name");
|
||||
// Typing a name into an empty field turns tagging on unless it was set explicitly (#459).
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (lora.tag_name !== undefined || lora.name) return;
|
||||
lora.tag_name = tagChk.checked = true;
|
||||
});
|
||||
}
|
||||
if (onOutputTag) {
|
||||
// _tag_output is runtime-only and stripped on save.
|
||||
const spacer = document.createElement("span");
|
||||
css(spacer, "flex:1;");
|
||||
tagRow.appendChild(spacer);
|
||||
addToggle("Add to output path", "_tag_output", onOutputTag);
|
||||
}
|
||||
editRow.appendChild(tagRow);
|
||||
}
|
||||
editRow.appendChild(makeLabel("URL"));
|
||||
editRow.appendChild(urlInput);
|
||||
card.appendChild(editRow);
|
||||
}
|
||||
|
||||
card.appendChild(makePathRow(lora, i, "path", lowFile ? "HIGH" : ""));
|
||||
if (lowFile) card.appendChild(makePathRow(lora, i, "path_low", "LOW"));
|
||||
|
||||
const strSlider = makeComfyValueSlider(lora.strength ?? 1.0, -10, 10, 0.05, (v) => {
|
||||
lora.strength = v;
|
||||
});
|
||||
card.appendChild(strSlider);
|
||||
if (lora.schedule) card.appendChild(makeSchedulePanel(lora));
|
||||
|
||||
box.appendChild(card);
|
||||
}
|
||||
|
||||
if (showAdd) {
|
||||
const addBtn = makeSmallButton("+ Add LoRA");
|
||||
addBtn.addEventListener("click", () => {
|
||||
// Open new LoRA entries expanded (name + URL visible).
|
||||
// _editing is runtime-only and stripped on save.
|
||||
loras.push({ name: "", url: "", path: "", strength: defaultStrength, _editing: true });
|
||||
renderLoras();
|
||||
});
|
||||
box.appendChild(addBtn);
|
||||
}
|
||||
}
|
||||
renderLoras();
|
||||
}
|
||||
|
||||
export function serializeLoras(loras) {
|
||||
return loras.map(l => ({
|
||||
name: l.name || "",
|
||||
url: l.url || "",
|
||||
path: l.path || "",
|
||||
...(l.name_low ? { name_low: l.name_low } : {}),
|
||||
...(l.path_low ? { path_low: l.path_low } : {}),
|
||||
strength: l.strength ?? 1.0,
|
||||
...(l.tag_name ? { tag_name: true } : {}),
|
||||
...(l.schedule?.length ? { schedule: l.schedule } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Flake links as { target, variant, lora_strengths, schedule? }, with the
|
||||
// legacy single `flake_link` normalized into the list (#234).
|
||||
export function normalizeLinks(data) {
|
||||
return (Array.isArray(data.flake_links)
|
||||
? data.flake_links
|
||||
: (data.flake_link ? [data.flake_link] : [])
|
||||
).filter(l => l && l.target).map(l => ({
|
||||
target: String(l.target || ""),
|
||||
variant: { ...(l.variant || {}) },
|
||||
lora_strengths: Array.isArray(l.lora_strengths) ? [...l.lora_strengths] : [],
|
||||
variant_loras: JSON.parse(JSON.stringify(l.variant_loras || {})),
|
||||
...(Array.isArray(l.schedule) ? { schedule: l.schedule.map(k => ({ ...k })) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function renderOneLinkOverrides(link, box) {
|
||||
box.replaceChildren();
|
||||
if (!link.target) return;
|
||||
(async () => {
|
||||
let linkedData;
|
||||
try {
|
||||
linkedData = await fetchFlake(link.target);
|
||||
} catch {
|
||||
const err = document.createElement("div");
|
||||
err.textContent = `Target not found: ${link.target}`;
|
||||
css(err, "font-size:11px;color:#f99;");
|
||||
box.appendChild(err);
|
||||
return;
|
||||
}
|
||||
const variants = linkedData.variants || linkedData.options || {};
|
||||
if (Object.keys(variants).length > 0) {
|
||||
const vLabel = document.createElement("div");
|
||||
vLabel.textContent = "Variant choice override (saved on this flake)";
|
||||
css(vLabel, "font-size:11px;color:#aaa;font-weight:500;");
|
||||
box.appendChild(vLabel);
|
||||
const targetDefaults = variantDefaultsOf(variants);
|
||||
for (const [group, choices] of Object.entries(variants)) {
|
||||
const targetDefault = targetDefaults[group];
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;gap:8px;align-items:center;");
|
||||
const gLabel = document.createElement("span");
|
||||
gLabel.textContent = group;
|
||||
css(gLabel, "flex:0 0 120px;font-size:12px;color:#bbb;");
|
||||
row.appendChild(gLabel);
|
||||
// The target's default choice shows as "<choice> (default)" = "" (#419).
|
||||
const v = link.variant?.[group] || "";
|
||||
const dd = makeComfyDropdown(variantDropdownOptions(Object.keys(choices), targetDefault), v === targetDefault ? "" : v);
|
||||
// Strengths of the chosen (or the target's default) choice LoRAs (#414).
|
||||
const vlBox = document.createElement("div");
|
||||
css(vlBox, "display:flex;flex-direction:column;gap:6px;");
|
||||
const renderVl = () => {
|
||||
vlBox.replaceChildren();
|
||||
const choice = link.variant?.[group] || targetDefault;
|
||||
const choiceLoras = choices[choice]?.loras;
|
||||
if (!Array.isArray(choiceLoras)) return;
|
||||
choiceLoras.forEach((lr, i) => {
|
||||
const vrow = document.createElement("div");
|
||||
css(vrow, "display:flex;gap:8px;align-items:center;padding-left:128px;");
|
||||
const nm = document.createElement("span");
|
||||
nm.textContent = lr.name || String(lr.path || `LoRA ${i + 1}`).split(/[\\/]/).pop().replace(/\.[^.]+$/, "");
|
||||
css(nm, "flex:0 0 160px;font-size:12px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
|
||||
vrow.appendChild(nm);
|
||||
const slider = makeComfyValueSlider(link.variant_loras?.[group]?.[choice]?.[i] ?? lr.strength ?? 1.0, -10, 10, 0.05, (v) => {
|
||||
const slots = (((link.variant_loras ||= {})[group] ||= {})[choice] ||= []);
|
||||
while (slots.length <= i) slots.push(null);
|
||||
slots[i] = v;
|
||||
});
|
||||
slider.style.flex = "1";
|
||||
vrow.appendChild(slider);
|
||||
const resetBtn = makeSmallButton("↺");
|
||||
resetBtn.title = "Use linked flake's default strength";
|
||||
resetBtn.addEventListener("click", () => {
|
||||
const slots = link.variant_loras?.[group]?.[choice];
|
||||
if (slots) slots[i] = null;
|
||||
renderVl();
|
||||
});
|
||||
vrow.appendChild(resetBtn);
|
||||
vlBox.appendChild(vrow);
|
||||
});
|
||||
};
|
||||
dd.element.addEventListener("change", () => {
|
||||
link.variant = link.variant || {};
|
||||
if (dd.element.value) link.variant[group] = dd.element.value;
|
||||
else delete link.variant[group];
|
||||
renderVl();
|
||||
});
|
||||
css(dd.container, "flex:1;min-width:0;");
|
||||
row.appendChild(dd.container);
|
||||
box.append(row, vlBox);
|
||||
renderVl();
|
||||
}
|
||||
}
|
||||
// Linked flake's LoRAs — the modern `loras` list, or a
|
||||
// legacy single LoRA stored as `path`/`strength` (#300).
|
||||
// Without the legacy fallback, a single-LoRA target shows
|
||||
// no override sliders at all.
|
||||
const loras = (Array.isArray(linkedData.loras) && linkedData.loras.length)
|
||||
? linkedData.loras
|
||||
: (linkedData.path ? [{ name: "", path: linkedData.path, strength: linkedData.strength ?? 1.0 }] : []);
|
||||
if (loras.length > 0) {
|
||||
const lLabel = document.createElement("div");
|
||||
lLabel.textContent = "LoRA strength overrides";
|
||||
css(lLabel, "font-size:11px;color:#aaa;font-weight:500;margin-top:4px;");
|
||||
box.appendChild(lLabel);
|
||||
link.lora_strengths = link.lora_strengths || [];
|
||||
while (link.lora_strengths.length < loras.length) link.lora_strengths.push(null);
|
||||
loras.forEach((lr, i) => {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;gap:8px;align-items:center;");
|
||||
const nm = document.createElement("span");
|
||||
nm.textContent = lr.name || lr.path || `LoRA ${i + 1}`;
|
||||
css(nm, "flex:0 0 160px;font-size:12px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
|
||||
row.appendChild(nm);
|
||||
const cur = link.lora_strengths[i];
|
||||
const defaultStrength = lr.strength ?? 1.0;
|
||||
const initial = (cur === null || cur === undefined) ? defaultStrength : cur;
|
||||
// makeSmallValueSlider returns a bare element (not {container}).
|
||||
// The old `.container` access made css() throw, so the
|
||||
// sliders never rendered — only the label did (#307).
|
||||
// Range matches the grid override dropdown (-10..10).
|
||||
// Uses the larger makeComfyValueSlider for readability (#312).
|
||||
const slider = makeComfyValueSlider(initial, -10, 10, 0.05, (v) => {
|
||||
link.lora_strengths[i] = v;
|
||||
});
|
||||
slider.style.flex = "1";
|
||||
row.appendChild(slider);
|
||||
const resetBtn = makeSmallButton("↺");
|
||||
resetBtn.title = "Use linked flake's default strength";
|
||||
resetBtn.addEventListener("click", () => {
|
||||
link.lora_strengths[i] = null;
|
||||
renderOneLinkOverrides(link, box);
|
||||
});
|
||||
row.appendChild(resetBtn);
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Flake link cards (#234): each target is picked with `pickTarget` and can
|
||||
// override the target's variant choices and LoRA strengths.
|
||||
export function renderLinkList(box, links, pickTarget) {
|
||||
function renderLinks() {
|
||||
box.replaceChildren();
|
||||
links.forEach((link, li) => {
|
||||
const card = document.createElement("div");
|
||||
css(card, "background:#252525;padding:8px;border-radius:6px;border:1px solid #333;display:flex;flex-direction:column;gap:6px;");
|
||||
const header = document.createElement("div");
|
||||
css(header, "display:flex;gap:8px;align-items:center;");
|
||||
header.appendChild(makeScheduleHandle(link, renderLinks));
|
||||
const title = document.createElement("span");
|
||||
title.textContent = link.target ? link.target.split("/").pop() : "(no target)";
|
||||
title.title = link.target || "";
|
||||
css(title, "flex:1;font-size:12px;font-weight:500;color:#cdd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
|
||||
header.appendChild(title);
|
||||
const changeBtn = makeSmallButton("Change");
|
||||
changeBtn.addEventListener("click", async () => {
|
||||
const picked = await pickTarget();
|
||||
if (picked && picked !== link.target) {
|
||||
link.target = picked;
|
||||
link.variant = {};
|
||||
link.lora_strengths = [];
|
||||
link.variant_loras = {};
|
||||
renderLinks();
|
||||
}
|
||||
});
|
||||
header.appendChild(changeBtn);
|
||||
const removeBtn = makeSmallButton("✕");
|
||||
removeBtn.addEventListener("click", () => {
|
||||
links.splice(li, 1);
|
||||
renderLinks();
|
||||
});
|
||||
header.appendChild(removeBtn);
|
||||
card.appendChild(header);
|
||||
const ovrBox = document.createElement("div");
|
||||
css(ovrBox, "display:flex;flex-direction:column;gap:6px;");
|
||||
card.appendChild(ovrBox);
|
||||
renderOneLinkOverrides(link, ovrBox);
|
||||
if (link.schedule) card.appendChild(makeSchedulePanel(link));
|
||||
box.appendChild(card);
|
||||
});
|
||||
const addBtn = makeSmallButton("+ Add flake link");
|
||||
addBtn.addEventListener("click", async () => {
|
||||
const picked = await pickTarget();
|
||||
if (picked) {
|
||||
links.push({ target: picked, variant: {}, lora_strengths: [] });
|
||||
renderLinks();
|
||||
}
|
||||
});
|
||||
box.appendChild(addBtn);
|
||||
}
|
||||
renderLinks();
|
||||
}
|
||||
|
||||
export function serializeLinks(links) {
|
||||
const outLinks = [];
|
||||
for (const link of links) {
|
||||
if (!link || !link.target) continue;
|
||||
const out = { target: link.target };
|
||||
const variant = link.variant ? Object.fromEntries(Object.entries(link.variant).filter(([, v]) => v)) : {};
|
||||
if (Object.keys(variant).length > 0) out.variant = variant;
|
||||
// Drop trailing nulls to keep the yaml clean.
|
||||
let strengths = (link.lora_strengths || []).slice();
|
||||
while (strengths.length && (strengths[strengths.length - 1] === null || strengths[strengths.length - 1] === undefined)) {
|
||||
strengths.pop();
|
||||
}
|
||||
if (strengths.length > 0) out.lora_strengths = strengths;
|
||||
const variantLoras = {};
|
||||
for (const [g, choices] of Object.entries(link.variant_loras || {})) {
|
||||
for (const [c, slots] of Object.entries(choices || {})) {
|
||||
const kept = (slots || []).slice();
|
||||
while (kept.length && (kept[kept.length - 1] === null || kept[kept.length - 1] === undefined)) kept.pop();
|
||||
if (kept.length) (variantLoras[g] ||= {})[c] = kept;
|
||||
}
|
||||
}
|
||||
if (Object.keys(variantLoras).length > 0) out.variant_loras = variantLoras;
|
||||
if (link.schedule?.length) out.schedule = link.schedule;
|
||||
outLinks.push(out);
|
||||
}
|
||||
return outLinks;
|
||||
}
|
||||
|
||||
// Option rows (#464): the `prompt_options` of a flake or variant choice, each a
|
||||
// name, a positive prompt and whether it is on by default.
|
||||
export function renderOptionList(box, options) {
|
||||
const taCss = "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;";
|
||||
|
||||
const clearIndicators = () => {
|
||||
for (const ind of box.querySelectorAll(".option-drop-indicator")) ind.remove();
|
||||
};
|
||||
|
||||
function renderOptions() {
|
||||
box.replaceChildren();
|
||||
let dragIdx = null;
|
||||
options.forEach((opt, i) => {
|
||||
const card = document.createElement("div");
|
||||
css(card, "background:#252525;padding:6px;border-radius:6px;border:1px solid #333;display:flex;flex-direction:column;gap:6px;");
|
||||
const header = document.createElement("div");
|
||||
css(header, "display:flex;gap:8px;align-items:center;");
|
||||
// Drag to reorder (#477); only our own drags stop propagating so
|
||||
// the parent choice cards keep their reorder.
|
||||
const dragHandle = document.createElement("span");
|
||||
dragHandle.textContent = "☰";
|
||||
css(dragHandle, "cursor:grab;color:#666;font-size:12px;padding:0 4px;user-select:none;");
|
||||
dragHandle.title = "Drag to reorder";
|
||||
dragHandle.draggable = true;
|
||||
dragHandle.addEventListener("dragstart", (e) => {
|
||||
e.stopPropagation();
|
||||
dragIdx = i;
|
||||
card.style.opacity = "0.4";
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
dragHandle.addEventListener("dragend", (e) => {
|
||||
e.stopPropagation();
|
||||
dragIdx = null;
|
||||
card.style.opacity = "";
|
||||
clearIndicators();
|
||||
});
|
||||
card.addEventListener("dragover", (e) => {
|
||||
if (dragIdx === null) return;
|
||||
e.stopPropagation();
|
||||
if (dragIdx === i) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
clearIndicators();
|
||||
const indicator = document.createElement("div");
|
||||
indicator.className = "option-drop-indicator";
|
||||
css(indicator, "height:2px;background:#2a6acf;border-radius:1px;margin:2px 0;");
|
||||
const rect = card.getBoundingClientRect();
|
||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||
box.insertBefore(indicator, above ? card : card.nextSibling);
|
||||
});
|
||||
card.addEventListener("dragleave", (e) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) clearIndicators();
|
||||
});
|
||||
card.addEventListener("drop", (e) => {
|
||||
if (dragIdx === null) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearIndicators();
|
||||
const src = dragIdx;
|
||||
dragIdx = null;
|
||||
if (src === i) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
let dst = (e.clientY - rect.top) < rect.height / 2 ? i : i + 1;
|
||||
if (src < dst) dst--;
|
||||
if (dst === src) return;
|
||||
options.splice(dst, 0, ...options.splice(src, 1));
|
||||
renderOptions();
|
||||
});
|
||||
const nameInput = makeComfyInput(opt.name || "", "Name, e.g. decorations");
|
||||
nameInput.addEventListener("input", () => { opt.name = nameInput.value; });
|
||||
const onWrap = document.createElement("label");
|
||||
css(onWrap, "display:flex;align-items:center;gap:4px;font-size:11px;color:#aaa;white-space:nowrap;cursor:pointer;");
|
||||
const onChk = document.createElement("input");
|
||||
onChk.type = "checkbox";
|
||||
onChk.checked = !!opt.default;
|
||||
css(onChk, "width:14px;height:14px;cursor:pointer;margin:0;");
|
||||
onChk.addEventListener("change", () => { opt.default = onChk.checked; });
|
||||
onWrap.append(onChk, "On by default");
|
||||
const removeBtn = makeSmallButton("✕");
|
||||
removeBtn.addEventListener("click", () => {
|
||||
options.splice(i, 1);
|
||||
renderOptions();
|
||||
});
|
||||
header.append(dragHandle, nameInput, onWrap, removeBtn);
|
||||
const promptRow = document.createElement("div");
|
||||
css(promptRow, "display:flex;gap:6px;align-items:flex-start;");
|
||||
// Prompts wrap and grow like the flake prompt fields (#475).
|
||||
const posInput = makeTextarea(opt.positive || "", "Positive prompt", 1);
|
||||
css(posInput, taCss);
|
||||
posInput.addEventListener("input", () => { opt.positive = posInput.value; });
|
||||
attachAutoGrow(posInput);
|
||||
promptRow.appendChild(posInput);
|
||||
// Optional negative (#467), added like a flake prompt's.
|
||||
if (opt.negative != null) {
|
||||
const negInput = makeTextarea(opt.negative, "Negative prompt", 1);
|
||||
css(negInput, taCss);
|
||||
negInput.addEventListener("input", () => { opt.negative = negInput.value; });
|
||||
attachAutoGrow(negInput);
|
||||
const negRemove = makeSmallButton("✕");
|
||||
negRemove.title = "Remove negative prompt";
|
||||
negRemove.addEventListener("click", () => {
|
||||
delete opt.negative;
|
||||
renderOptions();
|
||||
});
|
||||
promptRow.append(negInput, negRemove);
|
||||
} else {
|
||||
const negBtn = makeSmallButton("+ Negative");
|
||||
negBtn.style.whiteSpace = "nowrap";
|
||||
negBtn.addEventListener("click", () => {
|
||||
opt.negative = "";
|
||||
renderOptions();
|
||||
});
|
||||
promptRow.appendChild(negBtn);
|
||||
}
|
||||
card.append(header, promptRow);
|
||||
box.appendChild(card);
|
||||
});
|
||||
const addBtn = makeSmallButton("+ option");
|
||||
addBtn.addEventListener("click", () => {
|
||||
options.push({ name: "", positive: "" });
|
||||
renderOptions();
|
||||
});
|
||||
box.appendChild(addBtn);
|
||||
}
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
export function serializeOptions(options) {
|
||||
return options.filter(o => o?.name?.trim()).map(o => ({
|
||||
name: o.name.trim(),
|
||||
positive: (o.positive || "").trim(),
|
||||
...(o.negative?.trim() ? { negative: o.negative.trim() } : {}),
|
||||
...(o.default ? { default: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Toggler input (#464): `-name` turns an upstream option off, `name` / `+name` on.
|
||||
// A datalist suggests option names used in any flake for the last token,
|
||||
// each as the whole completed value (#466).
|
||||
let togglerListId = 0;
|
||||
export function makeTogglerInput(owner) {
|
||||
const wrap = document.createElement("div");
|
||||
css(wrap, "flex:1;min-width:0;");
|
||||
const input = makeComfyInput(owner.toggler || "", "e.g. -tails, -face, +front");
|
||||
const list = document.createElement("datalist");
|
||||
list.id = `flake-toggler-names-${++togglerListId}`;
|
||||
input.setAttribute("list", list.id);
|
||||
const suggest = async () => {
|
||||
const names = await fetchOptionNames().catch(() => []);
|
||||
const [, head, sign, token] = input.value.match(/^((?:.*,)?\s*)([+-]?)\s*([^,]*)$/);
|
||||
const typed = token.trim().toLowerCase();
|
||||
list.replaceChildren(...names
|
||||
.filter(n => n.toLowerCase().startsWith(typed) && n.toLowerCase() !== typed)
|
||||
.map(n => Object.assign(document.createElement("option"), { value: `${head}${sign}${n}` })));
|
||||
};
|
||||
input.addEventListener("focus", suggest);
|
||||
input.addEventListener("input", () => {
|
||||
owner.toggler = input.value;
|
||||
suggest();
|
||||
});
|
||||
wrap.append(input, list);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Prompt cards (#371), shared by a flake's Prompts field and its variant
|
||||
// choices (#468): positive + optional negative, each removable, with a
|
||||
// schedule handle. Removing the last prompt calls onEmpty.
|
||||
export function renderPromptList(box, prompts, onEmpty) {
|
||||
const taCss = "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;";
|
||||
|
||||
function removePrompt(i) {
|
||||
prompts.splice(i, 1);
|
||||
if (prompts.length) renderPrompts();
|
||||
else onEmpty();
|
||||
}
|
||||
|
||||
function renderPrompts() {
|
||||
box.replaceChildren();
|
||||
if (!prompts.length) prompts.push({});
|
||||
prompts.forEach((prompt, i) => {
|
||||
const card = document.createElement("div");
|
||||
css(card, "background:#252525;padding:8px;border-radius:6px;border:1px solid #333;display:flex;flex-direction:column;gap:6px;");
|
||||
const header = document.createElement("div");
|
||||
css(header, "display:flex;gap:6px;align-items:center;");
|
||||
header.appendChild(makeScheduleHandle(prompt, renderPrompts));
|
||||
const title = document.createElement("span");
|
||||
title.textContent = `Prompt ${i + 1}`;
|
||||
css(title, "flex:1;font-size:12px;font-weight:500;color:#aaa;");
|
||||
header.appendChild(title);
|
||||
if (prompts.length > 1) {
|
||||
const removeBtn = makeSmallButton("✕");
|
||||
removeBtn.title = "Remove prompt";
|
||||
removeBtn.addEventListener("click", () => removePrompt(i));
|
||||
header.appendChild(removeBtn);
|
||||
}
|
||||
card.appendChild(header);
|
||||
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;gap:8px;align-items:stretch;");
|
||||
card.appendChild(row);
|
||||
const posCol = document.createElement("div");
|
||||
css(posCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
|
||||
row.appendChild(posCol);
|
||||
const negCol = document.createElement("div");
|
||||
css(negCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
|
||||
row.appendChild(negCol);
|
||||
|
||||
const hasNeg = prompt.negative != null;
|
||||
// Positive spans the width when alone; equal split with a
|
||||
// negative present. The "+ Negative" placeholder stays
|
||||
// compact so it never steals the positive's space (#264).
|
||||
posCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
|
||||
negCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
|
||||
|
||||
// A positive prompt is always present by default (#332) — no
|
||||
// "+ Positive" button. Seed an empty string when missing.
|
||||
if (prompt.positive == null) prompt.positive = "";
|
||||
const posTA = makeTextarea(prompt.positive, "positive prompt", 3);
|
||||
css(posTA, taCss);
|
||||
posTA.addEventListener("change", () => { prompt.positive = posTA.value; });
|
||||
posTA.addEventListener("input", () => { prompt.positive = posTA.value; });
|
||||
attachAutoGrow(posTA);
|
||||
posCol.appendChild(makeHoverRemoveWrapper(posTA, () => {
|
||||
if (prompt.negative == null) removePrompt(i);
|
||||
else { prompt.positive = null; renderPrompts(); }
|
||||
}, "Remove positive prompt"));
|
||||
|
||||
if (hasNeg) {
|
||||
const negTA = makeTextarea(prompt.negative, "negative prompt", 2);
|
||||
css(negTA, taCss);
|
||||
negTA.addEventListener("change", () => { prompt.negative = negTA.value; });
|
||||
negTA.addEventListener("input", () => { prompt.negative = negTA.value; });
|
||||
attachAutoGrow(negTA);
|
||||
negCol.appendChild(makeHoverRemoveWrapper(negTA, () => {
|
||||
if (prompt.positive == null) removePrompt(i);
|
||||
else { prompt.negative = null; renderPrompts(); }
|
||||
}, "Remove negative prompt"));
|
||||
} else {
|
||||
const negBtn = document.createElement("button");
|
||||
negBtn.textContent = "+ Negative";
|
||||
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;box-sizing:border-box;white-space:nowrap;padding:6px 10px;");
|
||||
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
|
||||
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
|
||||
negBtn.addEventListener("click", () => {
|
||||
prompt.negative = "";
|
||||
renderPrompts();
|
||||
});
|
||||
negCol.appendChild(negBtn);
|
||||
}
|
||||
if (prompt.schedule) card.appendChild(makeSchedulePanel(prompt));
|
||||
box.appendChild(card);
|
||||
});
|
||||
const addBtn = makeSmallButton("+ Add prompt");
|
||||
addBtn.addEventListener("click", () => {
|
||||
prompts.push({});
|
||||
renderPrompts();
|
||||
});
|
||||
box.appendChild(addBtn);
|
||||
}
|
||||
renderPrompts();
|
||||
}
|
||||
|
||||
export function serializePrompts(prompts) {
|
||||
return prompts.map(p => {
|
||||
const out = {};
|
||||
if (p.positive != null) out.positive = p.positive;
|
||||
if (p.negative != null) out.negative = p.negative;
|
||||
if (p.schedule?.length) out.schedule = p.schedule;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Flake Generate outputs keep their prompt and workflow in
|
||||
// .flake_metadata/<file name>.json next to them instead of inside the file
|
||||
// (#479). Dropping one loads that workflow (#480); any other file, older
|
||||
// outputs with an embedded workflow included, goes to ComfyUI's own handler.
|
||||
const SIDECAR_TYPES = ["image/png", "image/webp", "image/jpeg", "video/mp4", "video/webm"];
|
||||
|
||||
async function sidecarWorkflow(file) {
|
||||
if (!SIDECAR_TYPES.includes(file.type)) return null;
|
||||
const r = await fetch(`/flakes/metadata?${new URLSearchParams({ filename: file.name, size: file.size })}`);
|
||||
return r.ok ? (await r.json()).workflow : null;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyui-flakes.Metadata",
|
||||
setup() {
|
||||
const handleFile = app.handleFile;
|
||||
app.handleFile = async function (file, ...args) {
|
||||
const workflow = await sidecarWorkflow(file);
|
||||
if (!workflow) return handleFile.call(this, file, ...args);
|
||||
const [openSource, options] = args;
|
||||
await this.loadGraphData(workflow, true, true, file.name.replace(/\.\w+$/, ""),
|
||||
{ openSource, deferWarnings: options?.deferWarnings });
|
||||
};
|
||||
},
|
||||
});
|
||||
+665
-451
File diff suppressed because it is too large
Load Diff
+426
-122
@@ -1,14 +1,55 @@
|
||||
import { openOverlay } from "./modal.js";
|
||||
import {
|
||||
css, makeButton, makeComfyLabel, makeComfyInput,
|
||||
makeComfyDropdown, makeSearchableDropdown, makeComfySlider,
|
||||
makeTextarea,
|
||||
makeComfyDropdown, makeSearchableDropdown, makeChipMultiSelect, makeComfySlider,
|
||||
makeTextarea, makeHoverRemoveWrapper, attachAutoGrow,
|
||||
familyFolder, familyFromFolder, familySpec, ensureFamilies, ANIM_DIR,
|
||||
} from "./utils.js";
|
||||
import { fetchPreset, fetchCheckpoints, fetchVaes, fetchEmbeddings } from "./api.js";
|
||||
import { openFileBrowser } from "./pickers.js";
|
||||
import { fetchPreset, invalidatePresets, fetchPresetRootIndex, fetchCheckpoints, fetchEmbeddings, inputImageUrl, fetchAnimFamilies, fetchAnimFamily } from "./api.js";
|
||||
import { openFileBrowser, IMAGE_ROOTS, makeImageDropTarget } from "./pickers.js";
|
||||
import { pickFlakeTarget, renderLoraList, serializeLoras, normalizeLinks, renderLinkList, serializeLinks } from "./list-editors.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
export function openPresetEditModal({ mode, name, data, family = "SDXL/Base" }) {
|
||||
// Animate preset fields per family folder: model files [key, label, browse type]
|
||||
// and the numbers shown when the family has a default for them.
|
||||
const ANIM_MODEL_FIELDS = {
|
||||
wan22: [["diffusion_model_high", "High-noise Model", "diffusion_models"], ["diffusion_model_low", "Low-noise Model", "diffusion_models"]],
|
||||
mh3: [["diffusion_model", "Diffusion Model", "diffusion_models"], ["audio_vae", "Audio VAE", "vae"]],
|
||||
};
|
||||
const ANIM_NUMBER_FIELDS = [
|
||||
["shift_high", "Shift High", 0, 30, 0.1], ["shift_low", "Shift Low", 0, 30, 0.1], ["refiner_step", "Refiner Step", 0, 150, 1],
|
||||
["shift_video", "Shift Video", 0, 30, 0.1], ["shift_audio", "Shift Audio", 0, 30, 0.1],
|
||||
["fps", "FPS", 1, 60, 1], ["duration", "Duration (s)", 0.5, 30, 0.5],
|
||||
];
|
||||
|
||||
export async function openPresetEditModal({ mode, name, data, family = "SDXL/Base", kind = "img" }) {
|
||||
// Animate presets show the family's fields, blanks filled from its
|
||||
// defaults (the loader falls back to them the same way).
|
||||
const anim = kind === "anim";
|
||||
const animSpec = anim ? await fetchAnimFamily(family) : null;
|
||||
const animResolutions = anim ? (await fetchAnimFamilies()).resolutions || [] : [];
|
||||
if (animSpec) {
|
||||
data = data || {};
|
||||
for (const k of ["steps", "cfg", "sampler", "scheduler", "resolution", ...ANIM_NUMBER_FIELDS.map(f => f[0])]) {
|
||||
if (data[k] === undefined || data[k] === null || data[k] === "") data[k] = animSpec[k];
|
||||
}
|
||||
}
|
||||
// Seed a brand-new preset from the family's defaults (#360) — 8 steps at
|
||||
// cfg 1 with res_multistep for Z-Image, 30 at cfg 4 for Anima base, etc.
|
||||
// Done before the widgets are built because the sliders are write-once.
|
||||
// Only fills blanks, so an explicitly supplied value always wins, and an
|
||||
// existing preset being edited is never touched.
|
||||
if (mode === "create" && !anim) {
|
||||
await ensureFamilies();
|
||||
const spec = familySpec(family);
|
||||
if (spec) {
|
||||
data = data || {};
|
||||
for (const k of ["steps", "cfg", "sampler", "scheduler", "width", "height"]) {
|
||||
if (data[k] === undefined || data[k] === null || data[k] === "") data[k] = spec[k];
|
||||
}
|
||||
if (!data.clip_type && spec.clip_type !== "STABLE_DIFFUSION") data.clip_type = spec.clip_type;
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let { content, footer, close, handlers, panel } = openOverlay();
|
||||
handlers.onClose = (v) => resolve(v ?? null);
|
||||
@@ -24,7 +65,7 @@ export function openPresetEditModal({ mode, name, data, family = "SDXL/Base" })
|
||||
|
||||
const title = document.createElement("h3");
|
||||
css(title, "margin:0 0 8px;font-size:16px;color:#fff;font-weight:500;");
|
||||
title.textContent = mode === "create" ? "New Model Preset" : `Edit ${name}`;
|
||||
title.textContent = mode === "create" ? (anim ? "New Animate Preset" : "New Model Preset") : `Edit ${name}`;
|
||||
content.appendChild(title);
|
||||
|
||||
let pathInput = null;
|
||||
@@ -34,18 +75,17 @@ export function openPresetEditModal({ mode, name, data, family = "SDXL/Base" })
|
||||
let availableRoots = [];
|
||||
let resolvedPathLabel = null;
|
||||
|
||||
const FAMILY_FROM_FOLDER = {
|
||||
sdxl: "SDXL/Base",
|
||||
illustrious: "SDXL/Illustrious",
|
||||
pony: "SDXL/Pony",
|
||||
zib: "ZImage/Base",
|
||||
zit: "ZImage/Turbo",
|
||||
common: "Common",
|
||||
};
|
||||
// Folder -> label, from the shared registry (#355/#360) rather than a
|
||||
// local copy that has to be edited every time a family is added.
|
||||
const FAMILY_FROM_FOLDER = new Proxy({}, {
|
||||
get: (_t, folder) => familyFromFolder(String(folder)) || undefined,
|
||||
has: (_t, folder) => !!familyFromFolder(String(folder)),
|
||||
});
|
||||
|
||||
function stripPresetPrefix(p) {
|
||||
const parts = p.replace(/\\/g, "/").split("/");
|
||||
if (parts[0] === "img" && parts.length >= 3 && FAMILY_FROM_FOLDER[parts[1]]) return parts.slice(2).join("/");
|
||||
// Any img/<folder>/ is a family folder, registered or not (#470).
|
||||
if (parts.length >= 3 && (parts[0] === "img" || parts[0] === ANIM_DIR)) return parts.slice(2).join("/");
|
||||
if (parts.length >= 2 && FAMILY_FROM_FOLDER[parts[0]]) return parts.slice(1).join("/");
|
||||
return p;
|
||||
}
|
||||
@@ -97,10 +137,10 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
return;
|
||||
}
|
||||
const rootIdx = parseInt(baseRootSelect?.value || "0", 10);
|
||||
const root = availableRoots[rootIdx] || availableRoots[0];
|
||||
const root = (availableRoots.find(r => r.index === rootIdx)) || availableRoots[0];
|
||||
const rootPart = root ? (root.path || "").replace(/\\/g, "/").replace(/\/+$/, "") + "/" : "C:/<comfy>/model_presets/";
|
||||
const folder = familyFolderLocal(family);
|
||||
const familyPrefix = folder ? `img/${folder}/` : "";
|
||||
const folder = anim ? animSpec?.folder : familyFolderLocal(family);
|
||||
const familyPrefix = folder ? `${kind}/${folder}/` : "";
|
||||
const fullPath = `${rootPart}${familyPrefix}${raw}.yaml`;
|
||||
resolvedPathLabel.textContent = fullPath;
|
||||
}
|
||||
@@ -109,16 +149,13 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
let pathManuallyEdited = mode !== "create";
|
||||
pathInput.addEventListener("input", () => { pathManuallyEdited = true; updateResolvedPath(); });
|
||||
|
||||
// Track manual edits to the filename prefix so syncOutputPath() does not
|
||||
// clobber a user-cleared/customized value when they touch the name field.
|
||||
let filenamePrefixManuallyEdited = mode !== "create";
|
||||
filenamePrefixInput.addEventListener("input", () => { filenamePrefixManuallyEdited = true; });
|
||||
|
||||
function familyFolderLocal(fam) {
|
||||
const map = {
|
||||
"SDXL/Base": "sdxl",
|
||||
"SDXL/Illustrious": "illustrious",
|
||||
"SDXL/Pony": "pony",
|
||||
"ZImage/Base": "zib",
|
||||
"ZImage/Turbo": "zit",
|
||||
"Common": "common",
|
||||
};
|
||||
return map[fam] || "";
|
||||
return familyFolder(fam) || "";
|
||||
}
|
||||
|
||||
function snake(s) {
|
||||
@@ -128,7 +165,9 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
function syncOutputPath() {
|
||||
const stem = snake(nameInput.value);
|
||||
if (!pathManuallyEdited) pathInput.value = stem;
|
||||
filenamePrefixInput.value = stem;
|
||||
// Autofill the filename prefix as a folder (trailing "/") so outputs
|
||||
// for this preset land in their own subfolder by default (#296).
|
||||
if (!filenamePrefixManuallyEdited) filenamePrefixInput.value = stem ? `${stem}/` : "";
|
||||
updateResolvedPath();
|
||||
}
|
||||
nameInput.addEventListener("input", syncOutputPath);
|
||||
@@ -139,6 +178,10 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
updateResolvedPath();
|
||||
}
|
||||
|
||||
// Stays disabled until roots load, otherwise Save would send a bogus root index.
|
||||
const saveBtn = makeButton("Save", true);
|
||||
saveBtn.disabled = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/flakes/roots?type=model_presets");
|
||||
@@ -159,15 +202,30 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
opt.value = "0";
|
||||
baseRootSelect.appendChild(opt);
|
||||
}
|
||||
// On edit, preselect the root the preset actually lives in, so
|
||||
// saving doesn't relocate it to "Comfy Install" (root 0).
|
||||
if (mode !== "create" && name) {
|
||||
const savedIdx = await fetchPresetRootIndex(name);
|
||||
if (savedIdx != null && availableRoots.some(r => r.index === savedIdx)) {
|
||||
baseRootSelect.value = String(savedIdx);
|
||||
}
|
||||
}
|
||||
// For new presets, default the base folder to the first Extra Path
|
||||
// root when one is configured, instead of Comfy Install.
|
||||
if (mode === "create") {
|
||||
const extraRoot = availableRoots.find(r => !r.is_install);
|
||||
if (extraRoot) baseRootSelect.value = String(extraRoot.index);
|
||||
}
|
||||
if (mode === "create" && !pathManuallyEdited) syncOutputPath();
|
||||
updateResolvedPath();
|
||||
saveBtn.disabled = !availableRoots.length;
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
baseRootSelect.addEventListener("change", updateResolvedPath);
|
||||
|
||||
// Cover image
|
||||
let presetCoverFile = null;
|
||||
let presetCoverUrl = null; // picked image, copied as the cover on save
|
||||
let presetCoverImg = null;
|
||||
const presetCoverWrap = document.createElement("div");
|
||||
css(presetCoverWrap, "display:flex;flex-direction:column;align-items:center;gap:4px;margin:8px 0;");
|
||||
@@ -184,10 +242,6 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
css(presetCoverLabel, "font-size:10px;color:#666;pointer-events:none;");
|
||||
presetCoverBox.appendChild(presetCoverLabel);
|
||||
|
||||
const presetCoverInput = document.createElement("input");
|
||||
presetCoverInput.type = "file";
|
||||
presetCoverInput.accept = ".png,.jpg,.jpeg,.webp,.gif";
|
||||
presetCoverInput.style.display = "none";
|
||||
|
||||
function updatePresetCoverPreview(src) {
|
||||
if (src) {
|
||||
@@ -209,38 +263,43 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
presetCoverLabel.style.display = "block";
|
||||
});
|
||||
|
||||
async function tryAutoCover(val) {
|
||||
if (!val || presetCoverFile || presetCoverImg.style.display === "block") return;
|
||||
// Animate presets have no checkpoint cover fallback, so their diffusion
|
||||
// model's sibling image is copied as the cover on save (#440).
|
||||
async function tryAutoCover(val, route = "checkpoint_sibling_image") {
|
||||
if (!val || presetCoverUrl || presetCoverImg.style.display === "block") return;
|
||||
const url = `/flakes/${route}?path=${encodeURIComponent(val)}`;
|
||||
try {
|
||||
const resp = await fetch(`/flakes/checkpoint_sibling_image?path=${encodeURIComponent(val)}`);
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
updatePresetCoverPreview(`/flakes/checkpoint_sibling_image?path=${encodeURIComponent(val)}`);
|
||||
if (anim) presetCoverUrl = url;
|
||||
updatePresetCoverPreview(url);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
presetCoverBox.addEventListener("click", () => presetCoverInput.click());
|
||||
presetCoverInput.addEventListener("change", () => {
|
||||
const file = presetCoverInput.files?.[0];
|
||||
if (file) {
|
||||
presetCoverFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => updatePresetCoverPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
function applyPresetCover(url) {
|
||||
presetCoverUrl = url;
|
||||
updatePresetCoverPreview(url);
|
||||
}
|
||||
presetCoverBox.addEventListener("click", async () => {
|
||||
const result = await openFileBrowser({ type: "inputs", roots: IMAGE_ROOTS });
|
||||
if (!result?.file) return;
|
||||
applyPresetCover(result.type === "inputs" ? inputImageUrl(result.file) : `/flakes/image?path=${encodeURIComponent(result.path)}`);
|
||||
});
|
||||
makeImageDropTarget(presetCoverBox, (paths) => applyPresetCover(inputImageUrl(paths[0])));
|
||||
|
||||
presetCoverWrap.appendChild(presetCoverBox);
|
||||
presetCoverWrap.appendChild(presetCoverInput);
|
||||
// presetCoverWrap is appended below alongside the Checkpoint column.
|
||||
|
||||
// Update close handler to upload cover
|
||||
const origPresetClose = close;
|
||||
close = async (value) => {
|
||||
if (value && (value.created || value.saved) && presetCoverFile) {
|
||||
if (value && (value.created || value.saved) && presetCoverUrl) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", presetCoverFile);
|
||||
// Presets don't own files: the picked image is copied.
|
||||
const blob = await (await fetch(presetCoverUrl)).blob();
|
||||
form.append("file", blob, `cover.${blob.type.split("/")[1] || "png"}`);
|
||||
await fetch(`/flakes/preset_cover?name=${encodeURIComponent(value.name)}`, { method: "POST", body: form });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -258,7 +317,9 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
content.appendChild(ckptCoverRow);
|
||||
|
||||
const ckptUrlInput = makeComfyInput(data.checkpoint_url || "", "https://civitai.com/models/...");
|
||||
ckptUrlInput.style.display = "none";
|
||||
// On a new preset, expand the checkpoint URL field by default so a
|
||||
// download URL can be entered directly without finding the toggle (#295).
|
||||
ckptUrlInput.style.display = mode === "create" ? "block" : "none";
|
||||
|
||||
const ckptLabelRow = document.createElement("div");
|
||||
css(ckptLabelRow, "display:flex;gap:2px;align-items:center;");
|
||||
@@ -286,9 +347,29 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
ckptUrlToggle.innerHTML = showing ? "▾" : "▴";
|
||||
});
|
||||
ckptLabelRow.appendChild(ckptUrlToggle);
|
||||
ckptCol.appendChild(ckptLabelRow);
|
||||
|
||||
ckptCol.appendChild(ckptUrlInput);
|
||||
// New presets expand the URL field by default (#295) — point the toggle up.
|
||||
if (mode === "create") ckptUrlToggle.textContent = "▲";
|
||||
const animModels = {};
|
||||
if (anim) {
|
||||
const animModelRow = document.createElement("div");
|
||||
css(animModelRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
for (const [key, label, type] of ANIM_MODEL_FIELDS[animSpec?.folder] || []) {
|
||||
const col = document.createElement("div");
|
||||
css(col, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
col.appendChild(makeComfyLabel(label));
|
||||
animModels[key] = makeSearchableDropdown([], data[key] || "", `Select ${label}...`);
|
||||
col.appendChild(animModels[key].container);
|
||||
attachModelPicker(animModels[key], type, `Select ${label}`);
|
||||
if (key === "diffusion_model_high" || key === "diffusion_model") {
|
||||
animModels[key].element.addEventListener("change", () => tryAutoCover(animModels[key].element.value, "diffusion_model_sibling_image"));
|
||||
}
|
||||
animModelRow.appendChild(col);
|
||||
}
|
||||
ckptCol.appendChild(animModelRow);
|
||||
} else {
|
||||
ckptCol.appendChild(ckptLabelRow);
|
||||
ckptCol.appendChild(ckptUrlInput);
|
||||
}
|
||||
|
||||
const ckptWrap = makeSearchableDropdown([], data.checkpoint || "", "Select checkpoint...");
|
||||
|
||||
@@ -296,8 +377,10 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
css(ckptBox, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;");
|
||||
ckptBox.textContent = data.checkpoint ? data.checkpoint.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "Select Checkpoint";
|
||||
|
||||
ckptCol.appendChild(ckptBox);
|
||||
ckptCol.appendChild(ckptWrap.container);
|
||||
if (!anim) {
|
||||
ckptCol.appendChild(ckptBox);
|
||||
ckptCol.appendChild(ckptWrap.container);
|
||||
}
|
||||
ckptWrap.container.style.display = "none";
|
||||
(async () => {
|
||||
try {
|
||||
@@ -336,13 +419,47 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
ckptUrlInput.addEventListener("input", updateCkptUrlVisibility);
|
||||
updateCkptUrlVisibility();
|
||||
|
||||
// VAE / text encoder / diffusion model are picked with the grid picker,
|
||||
// like the checkpoint; the hidden dropdown input keeps the value.
|
||||
function attachModelPicker(wrap, type, placeholder) {
|
||||
const box = document.createElement("div");
|
||||
css(box, "position:relative;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 26px 6px 8px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;");
|
||||
const clear = document.createElement("span");
|
||||
clear.textContent = "✕";
|
||||
clear.title = "Clear";
|
||||
css(clear, "position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#888;");
|
||||
const show = () => {
|
||||
const val = wrap.element.value;
|
||||
box.textContent = val ? val.replace(/\.(safetensors|sft|ckpt|pt|pth|bin|gguf)$/i, "").split(/[\\/]/).pop() : placeholder;
|
||||
box.title = val;
|
||||
if (val) box.appendChild(clear);
|
||||
};
|
||||
box.addEventListener("click", async (e) => {
|
||||
if (e.target === clear) {
|
||||
wrap.element.value = "";
|
||||
show();
|
||||
return;
|
||||
}
|
||||
const result = await openFileBrowser({ type });
|
||||
if (result?.file) {
|
||||
wrap.element.value = result.file;
|
||||
wrap.element.dispatchEvent(new Event("change"));
|
||||
show();
|
||||
}
|
||||
});
|
||||
wrap.container.style.display = "none";
|
||||
wrap.container.before(box);
|
||||
show();
|
||||
}
|
||||
|
||||
const vaeSampRow = document.createElement("div");
|
||||
css(vaeSampRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
const vaeColWrap = document.createElement("div");
|
||||
css(vaeColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
vaeColWrap.appendChild(makeComfyLabel("VAE (optional)"));
|
||||
vaeColWrap.appendChild(makeComfyLabel(anim ? "VAE" : "VAE (optional)"));
|
||||
const vaeWrap = makeSearchableDropdown([], data.vae || "", "Select VAE...");
|
||||
vaeColWrap.appendChild(vaeWrap.container);
|
||||
attachModelPicker(vaeWrap, "vae", "Select VAE");
|
||||
vaeSampRow.appendChild(vaeColWrap);
|
||||
const sampColWrap = document.createElement("div");
|
||||
css(sampColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
@@ -356,9 +473,10 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
css(teSchedRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
const teColWrap = document.createElement("div");
|
||||
css(teColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
teColWrap.appendChild(makeComfyLabel("Text Encoder (optional)"));
|
||||
teColWrap.appendChild(makeComfyLabel(anim ? "Text Encoder" : "Text Encoder (optional)"));
|
||||
const teWrap = makeSearchableDropdown([], data.text_encoder || "", "Select text encoder...");
|
||||
teColWrap.appendChild(teWrap.container);
|
||||
attachModelPicker(teWrap, "text_encoders", "Select Text Encoder");
|
||||
teSchedRow.appendChild(teColWrap);
|
||||
const schedColWrap = document.createElement("div");
|
||||
css(schedColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
@@ -368,18 +486,69 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
schedColWrap.appendChild(schedDD.container);
|
||||
teSchedRow.appendChild(schedColWrap);
|
||||
ckptCol.appendChild(teSchedRow);
|
||||
|
||||
// Diffusion model + CLIP type (#360). Anima, Krea2 and Z-Image ship as a
|
||||
// bare diffusion model with a separate text encoder, and the encoder
|
||||
// cannot be loaded without knowing its CLIPType.
|
||||
const dmRow = document.createElement("div");
|
||||
css(dmRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
const dmColWrap = document.createElement("div");
|
||||
css(dmColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
dmColWrap.appendChild(makeComfyLabel("Diffusion Model (optional)"));
|
||||
const dmWrap = makeSearchableDropdown([], data.diffusion_model || "", "Select diffusion model...");
|
||||
dmColWrap.appendChild(dmWrap.container);
|
||||
attachModelPicker(dmWrap, "diffusion_models", "Select Diffusion Model");
|
||||
dmWrap.container.previousSibling.title = "Use instead of Checkpoint for models shipped as a bare UNET. Requires a Text Encoder and VAE.";
|
||||
if (!anim) dmRow.appendChild(dmColWrap);
|
||||
const ctColWrap = document.createElement("div");
|
||||
css(ctColWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
ctColWrap.appendChild(makeComfyLabel("Text Encoder Type"));
|
||||
const ctDD = makeComfyDropdown([{ value: "", label: "Family default" }], data.clip_type || "");
|
||||
ctColWrap.appendChild(ctDD.container);
|
||||
dmRow.appendChild(ctColWrap);
|
||||
ckptCol.appendChild(dmRow);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const vaes = await fetchVaes();
|
||||
for (const v of vaes) vaeWrap.datalist.appendChild(Object.assign(document.createElement("option"), { value: v }));
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const r = await fetch("/flakes/text_encoders");
|
||||
const r = await fetch("/flakes/clip_types");
|
||||
const d = await r.json();
|
||||
const tes = d.text_encoders || [];
|
||||
for (const t of tes) teWrap.datalist.appendChild(Object.assign(document.createElement("option"), { value: t }));
|
||||
const types = d.clip_types || [];
|
||||
if (types.length) {
|
||||
const want = data.clip_type || "";
|
||||
ctDD.element.replaceChildren();
|
||||
for (const [value, label] of [["", "Family default"], ...types.map(t => [t, t])]) {
|
||||
const o = document.createElement("option");
|
||||
o.value = value; o.textContent = label;
|
||||
if (value === want) o.selected = true;
|
||||
ctDD.element.appendChild(o);
|
||||
}
|
||||
ctDD.element.value = types.includes(want) ? want : "";
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
// Replace the hardcoded sampler/scheduler lists with the installed
|
||||
// ComfyUI's KSampler options so the editor matches core's Advanced
|
||||
// KSampler. Falls back to the hardcoded lists above on fetch failure.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/flakes/samplers");
|
||||
const d = await r.json();
|
||||
const fill = (dd, values, current) => {
|
||||
if (!Array.isArray(values) || !values.length) return;
|
||||
const want = current || dd.element.value;
|
||||
dd.element.replaceChildren();
|
||||
for (const v of values) {
|
||||
const o = document.createElement("option");
|
||||
o.value = v; o.textContent = v;
|
||||
if (v === want) o.selected = true;
|
||||
dd.element.appendChild(o);
|
||||
}
|
||||
if (values.includes(want)) dd.element.value = want;
|
||||
};
|
||||
fill(samplerDD, d.samplers, data.sampler);
|
||||
fill(schedDD, d.schedulers, data.scheduler);
|
||||
} catch { /* keep hardcoded fallback */ }
|
||||
})();
|
||||
|
||||
const numRow = document.createElement("div");
|
||||
css(numRow, "display:flex;gap:8px;align-items:flex-start;margin-top:-6px;");
|
||||
@@ -388,19 +557,19 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
wWrap.appendChild(makeComfyLabel("Width"));
|
||||
const wSlider = makeComfySlider(data.width ?? 1024, 64, 4096, 64);
|
||||
wWrap.appendChild(wSlider);
|
||||
numRow.appendChild(wWrap);
|
||||
if (!anim) numRow.appendChild(wWrap);
|
||||
const hWrap = document.createElement("div");
|
||||
css(hWrap, "flex:1;min-width:0;");
|
||||
hWrap.appendChild(makeComfyLabel("Height"));
|
||||
const hSlider = makeComfySlider(data.height ?? 1024, 64, 4096, 64);
|
||||
hWrap.appendChild(hSlider);
|
||||
numRow.appendChild(hWrap);
|
||||
if (!anim) numRow.appendChild(hWrap);
|
||||
const csWrap = document.createElement("div");
|
||||
css(csWrap, "flex:1;min-width:0;");
|
||||
csWrap.appendChild(makeComfyLabel("Clip Skip"));
|
||||
const csSlider = makeComfySlider(Math.abs(data.clip_skip ?? -2), 1, 24, 1);
|
||||
csWrap.appendChild(csSlider);
|
||||
numRow.appendChild(csWrap);
|
||||
if (!anim) numRow.appendChild(csWrap);
|
||||
const stepsWrap = document.createElement("div");
|
||||
css(stepsWrap, "flex:1;min-width:0;");
|
||||
stepsWrap.appendChild(makeComfyLabel("Steps"));
|
||||
@@ -415,21 +584,57 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
numRow.appendChild(cfgWrap);
|
||||
content.appendChild(numRow);
|
||||
|
||||
const animNumbers = {};
|
||||
let resolutionDD = null;
|
||||
if (anim) {
|
||||
const animNumRow = document.createElement("div");
|
||||
css(animNumRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
for (const [key, label, min, max, step] of ANIM_NUMBER_FIELDS) {
|
||||
if (animSpec?.[key] == null) continue;
|
||||
const col = document.createElement("div");
|
||||
css(col, "flex:1;min-width:0;");
|
||||
col.appendChild(makeComfyLabel(label));
|
||||
animNumbers[key] = makeComfySlider(data[key], min, max, step);
|
||||
col.appendChild(animNumbers[key]);
|
||||
animNumRow.appendChild(col);
|
||||
}
|
||||
const resCol = document.createElement("div");
|
||||
css(resCol, "flex:1;min-width:0;");
|
||||
resCol.appendChild(makeComfyLabel("Resolution"));
|
||||
resolutionDD = makeComfyDropdown(animResolutions.map(r => ({ value: r, label: r })), data.resolution);
|
||||
resCol.appendChild(resolutionDD.container);
|
||||
animNumRow.appendChild(resCol);
|
||||
content.appendChild(animNumRow);
|
||||
}
|
||||
|
||||
// Clip Skip only means something on a CLIP text encoder. Anima, Krea2
|
||||
// and Z-Image encode with Qwen3 / Qwen3-VL, so hide it there rather
|
||||
// than offer a control the loader ignores (#359/#360). Re-applied once
|
||||
// the registry resolves, in case the modal opened before startup did.
|
||||
function applyClipSkipVisibility() {
|
||||
const spec = familySpec(family);
|
||||
csWrap.style.display = (spec && spec.supports_clip_skip === false) ? "none" : "";
|
||||
}
|
||||
applyClipSkipVisibility();
|
||||
ensureFamilies().then(applyClipSkipVisibility).catch(() => {});
|
||||
|
||||
const embRow = document.createElement("div");
|
||||
css(embRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
const posEmbCol = document.createElement("div");
|
||||
css(posEmbCol, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
posEmbCol.appendChild(makeComfyLabel("Positive embeddings"));
|
||||
const posEmbWrap = makeSearchableDropdown([], (data.embeddings?.positive || []).join(", "), "embedding1, embedding2...");
|
||||
// Chip list (#294): add several embeddings in a row without the dropdown
|
||||
// collapsing after each pick.
|
||||
const posEmbWrap = makeChipMultiSelect({ selected: data.embeddings?.positive || [], placeholder: "add embedding…" });
|
||||
posEmbCol.appendChild(posEmbWrap.container);
|
||||
embRow.appendChild(posEmbCol);
|
||||
const negEmbCol = document.createElement("div");
|
||||
css(negEmbCol, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
negEmbCol.appendChild(makeComfyLabel("Negative embeddings"));
|
||||
const negEmbWrap = makeSearchableDropdown([], (data.embeddings?.negative || []).join(", "), "embedding1, embedding2...");
|
||||
const negEmbWrap = makeChipMultiSelect({ selected: data.embeddings?.negative || [], placeholder: "add embedding…" });
|
||||
negEmbCol.appendChild(negEmbWrap.container);
|
||||
embRow.appendChild(negEmbCol);
|
||||
content.appendChild(embRow);
|
||||
if (!anim) content.appendChild(embRow);
|
||||
(async () => {
|
||||
try {
|
||||
const embs = await fetchEmbeddings();
|
||||
@@ -443,20 +648,109 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
// Side-by-side positive/negative prompts (mirrors #209 layout in flake-modal).
|
||||
// Negative is hidden by default unless the preset already has one — then a
|
||||
// "+ Negative" button reveals it on demand (per #225).
|
||||
const prompt = data.prompt || {};
|
||||
content.appendChild(makeComfyLabel("Positive prompt"));
|
||||
const posTA = makeTextarea(prompt.positive || "", "masterpiece, best quality", 3);
|
||||
css(posTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;min-height:78px;flex-shrink:0;");
|
||||
const promptState = {
|
||||
positive: prompt.positive ?? "",
|
||||
negative: prompt.negative != null && prompt.negative !== "" ? prompt.negative : null,
|
||||
};
|
||||
const promptRow = document.createElement("div");
|
||||
css(promptRow, "display:flex;gap:8px;align-items:stretch;");
|
||||
content.appendChild(promptRow);
|
||||
const posCol = document.createElement("div");
|
||||
css(posCol, "min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
promptRow.appendChild(posCol);
|
||||
const negCol = document.createElement("div");
|
||||
css(negCol, "min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
promptRow.appendChild(negCol);
|
||||
|
||||
const posLabel = makeComfyLabel("Positive prompt");
|
||||
posCol.appendChild(posLabel);
|
||||
const posTA = makeTextarea(promptState.positive, "masterpiece, best quality", 3);
|
||||
css(posTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;min-height:78px;flex-shrink:0;");
|
||||
posTA.addEventListener("focus", () => posTA.style.borderColor = "#555");
|
||||
posTA.addEventListener("blur", () => posTA.style.borderColor = "#333");
|
||||
content.appendChild(posTA);
|
||||
posTA.addEventListener("input", () => { promptState.positive = posTA.value; });
|
||||
attachAutoGrow(posTA);
|
||||
// Positive prompt is required, but we still wrap it so the styling is
|
||||
// consistent with the negative side; no remove button needed though, so
|
||||
// hover-X helper isn't applied here — only on negative.
|
||||
posCol.appendChild(posTA);
|
||||
|
||||
content.appendChild(makeComfyLabel("Negative prompt"));
|
||||
const negTA = makeTextarea(prompt.negative || "", "worst quality, low quality", 3);
|
||||
css(negTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;min-height:78px;flex-shrink:0;");
|
||||
negTA.addEventListener("focus", () => negTA.style.borderColor = "#555");
|
||||
negTA.addEventListener("blur", () => negTA.style.borderColor = "#333");
|
||||
content.appendChild(negTA);
|
||||
function renderNegative() {
|
||||
negCol.replaceChildren();
|
||||
const hasNeg = promptState.negative != null;
|
||||
// Equal split when a negative is present; positive spans the width
|
||||
// and the "+ Negative" stays compact when it's absent (#264).
|
||||
posCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
|
||||
negCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
|
||||
if (hasNeg) {
|
||||
negCol.appendChild(makeComfyLabel("Negative prompt"));
|
||||
const negTA = makeTextarea(promptState.negative, "worst quality, low quality", 3);
|
||||
css(negTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;min-height:78px;flex-shrink:0;");
|
||||
negTA.addEventListener("focus", () => negTA.style.borderColor = "#555");
|
||||
negTA.addEventListener("blur", () => negTA.style.borderColor = "#333");
|
||||
negTA.addEventListener("input", () => { promptState.negative = negTA.value; });
|
||||
attachAutoGrow(negTA);
|
||||
const negWrap = makeHoverRemoveWrapper(negTA, () => {
|
||||
promptState.negative = null;
|
||||
renderNegative();
|
||||
}, "Remove negative prompt");
|
||||
negCol.appendChild(negWrap);
|
||||
} else {
|
||||
// Hidden by default — show a "+ Negative" button.
|
||||
negCol.appendChild(makeComfyLabel(" ")); // spacer label to align tops
|
||||
const negBtn = document.createElement("button");
|
||||
negBtn.type = "button";
|
||||
negBtn.textContent = "+ Negative";
|
||||
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:6px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;white-space:nowrap;padding:8px 12px;");
|
||||
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
|
||||
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
|
||||
negBtn.addEventListener("click", () => {
|
||||
promptState.negative = "";
|
||||
renderNegative();
|
||||
});
|
||||
negCol.appendChild(negBtn);
|
||||
}
|
||||
}
|
||||
renderNegative();
|
||||
|
||||
// Vision model that writes the video prompt for auto-caption flakes.
|
||||
const captionModelWrap = makeSearchableDropdown([], data.caption_model || "", "Select caption model...");
|
||||
const captionTA = makeTextarea(data.caption_instruction || "", "Empty uses the built-in instruction for this family", 2);
|
||||
if (anim) {
|
||||
const captionRow = document.createElement("div");
|
||||
css(captionRow, "display:flex;gap:8px;align-items:flex-start;");
|
||||
const cmCol = document.createElement("div");
|
||||
css(cmCol, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
cmCol.appendChild(makeComfyLabel("Caption model"));
|
||||
cmCol.appendChild(captionModelWrap.container);
|
||||
attachModelPicker(captionModelWrap, "text_encoders", "None");
|
||||
const ciCol = document.createElement("div");
|
||||
css(ciCol, "flex:2;min-width:0;display:flex;flex-direction:column;gap:4px;");
|
||||
ciCol.appendChild(makeComfyLabel("Caption instruction"));
|
||||
css(captionTA, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:8px;border-radius:6px;font-size:13px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;");
|
||||
attachAutoGrow(captionTA);
|
||||
ciCol.appendChild(captionTA);
|
||||
captionRow.append(cmCol, ciCol);
|
||||
content.appendChild(captionRow);
|
||||
}
|
||||
|
||||
// LoRAs and flake links applied whenever the preset is used, edited
|
||||
// like the flake editor's fields.
|
||||
const presetLoras = JSON.parse(JSON.stringify(data.loras || []));
|
||||
const presetLinks = normalizeLinks(data);
|
||||
function addListSection(label) {
|
||||
const box = document.createElement("div");
|
||||
css(box, "display:flex;flex-direction:column;gap:6px;");
|
||||
content.appendChild(makeComfyLabel(label));
|
||||
content.appendChild(box);
|
||||
return box;
|
||||
}
|
||||
renderLoraList(addListSection("LoRAs"), presetLoras, { family, lowFile: animSpec?.folder === "wan22" });
|
||||
if (!anim) renderLinkList(addListSection("Flake Links"), presetLinks, () => pickFlakeTarget(family));
|
||||
|
||||
if (mode === "edit") {
|
||||
const delBtn = makeButton("Delete");
|
||||
@@ -465,6 +759,7 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
if (!window.confirm(`Delete preset '${name}'?`)) return;
|
||||
try {
|
||||
await fetch(`/flakes/presets/delete?name=${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
invalidatePresets();
|
||||
close({ deleted: true, name });
|
||||
} catch (err) {
|
||||
window.alert(`Delete failed: ${err.message}`);
|
||||
@@ -480,27 +775,48 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
});
|
||||
footer.appendChild(cancelBtn);
|
||||
|
||||
const saveBtn = makeButton("Save", true);
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const ordered = {
|
||||
const flakeLinks = serializeLinks(presetLinks);
|
||||
const ordered = anim ? {
|
||||
display_name: (nameInput.value || "").trim() || undefined,
|
||||
filename_prefix: (filenamePrefixInput.value || "").trim() || undefined,
|
||||
...Object.fromEntries(Object.entries(animModels).map(([k, w]) => [k, w.element.value || null])),
|
||||
text_encoder: teWrap.element.value || null,
|
||||
clip_type: ctDD.element.value || null,
|
||||
vae: vaeWrap.element.value || null,
|
||||
steps: stepsSlider.getValue(),
|
||||
cfg: cfgSlider.getValue(),
|
||||
sampler: samplerDD.element.value,
|
||||
scheduler: schedDD.element.value,
|
||||
...Object.fromEntries(Object.entries(animNumbers).map(([k, sl]) => [k, sl.getValue()])),
|
||||
resolution: resolutionDD.element.value,
|
||||
prompt: { positive: promptState.positive, negative: promptState.negative ?? "" },
|
||||
caption_model: captionModelWrap.element.value || undefined,
|
||||
caption_instruction: captionTA.value.trim() || undefined,
|
||||
loras: presetLoras.length ? serializeLoras(presetLoras) : undefined,
|
||||
} : {
|
||||
display_name: (nameInput.value || "").trim() || undefined,
|
||||
filename_prefix: (filenamePrefixInput.value || "").trim() || undefined,
|
||||
checkpoint: ckptWrap.element.value,
|
||||
checkpoint_url: ckptUrlInput.value || "",
|
||||
diffusion_model: dmWrap.element.value || null,
|
||||
clip_skip: -Math.abs(csSlider.getValue()),
|
||||
vae: vaeWrap.element.value || null,
|
||||
text_encoder: teWrap.element.value || null,
|
||||
clip_type: ctDD.element.value || null,
|
||||
steps: stepsSlider.getValue(),
|
||||
cfg: cfgSlider.getValue(),
|
||||
sampler: samplerDD.element.value,
|
||||
scheduler: schedDD.element.value,
|
||||
width: wSlider.getValue(),
|
||||
height: hSlider.getValue(),
|
||||
prompt: { positive: posTA.value, negative: negTA.value },
|
||||
prompt: { positive: promptState.positive, negative: promptState.negative ?? "" },
|
||||
embeddings: {
|
||||
positive: posEmbWrap.element.value.split(",").map(s => s.trim()).filter(Boolean),
|
||||
negative: negEmbWrap.element.value.split(",").map(s => s.trim()).filter(Boolean),
|
||||
positive: posEmbWrap.getValues(),
|
||||
negative: negEmbWrap.getValues(),
|
||||
},
|
||||
loras: presetLoras.length ? serializeLoras(presetLoras) : undefined,
|
||||
flake_links: flakeLinks.length ? flakeLinks : undefined,
|
||||
};
|
||||
// Drop undefined keys so the YAML stays clean
|
||||
for (const k of Object.keys(ordered)) if (ordered[k] === undefined) delete ordered[k];
|
||||
@@ -515,6 +831,7 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
family: family || undefined,
|
||||
base_root_index: Number.isFinite(baseRootIndex) ? baseRootIndex : 0,
|
||||
output_path: outputPath,
|
||||
kind,
|
||||
};
|
||||
if (mode !== "create") body.old_name = name;
|
||||
const r = await fetch("/flakes/presets/save", {
|
||||
@@ -526,6 +843,7 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
const err = await r.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${r.status}`);
|
||||
}
|
||||
invalidatePresets();
|
||||
const savedName = (await r.json()).name || outputPath;
|
||||
if (mode === "create") {
|
||||
close({ created: true, name: savedName });
|
||||
@@ -542,41 +860,32 @@ pathWrap.appendChild(makeComfyLabel("Output path"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshPresetOptions(family = "") {
|
||||
const ANIM_PRESET_NODES = ["FlakeAnimPreset", "FlakeAnimPresetCombo"];
|
||||
const PRESET_NODES = ["FlakeModelPreset", "FlakeModelCombo", ...ANIM_PRESET_NODES];
|
||||
|
||||
// /flakes/presets query for a preset node: its kind and family.
|
||||
function presetQuery(n) {
|
||||
const anim = ANIM_PRESET_NODES.includes(n.type);
|
||||
const family = n.widgets?.find(w => w.name === (anim ? "family" : "model_family"))?.value || "";
|
||||
return new URLSearchParams({ kind: anim ? "anim" : "img", ...(family ? { family } : {}) }).toString();
|
||||
}
|
||||
|
||||
export async function refreshPresetOptions() {
|
||||
try {
|
||||
// Fetch presets for all unique families used by nodes, plus the requested family
|
||||
const familyMap = new Map();
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.type !== "FlakeModelPreset" && n.type !== "FlakeModelCombo") continue;
|
||||
const fw = n.widgets?.find(w => w.name === "model_family");
|
||||
const nodeFamily = fw?.value || family || "";
|
||||
if (!familyMap.has(nodeFamily)) {
|
||||
const query = nodeFamily ? `?family=${encodeURIComponent(nodeFamily)}` : "";
|
||||
const r = await fetch(`/flakes/presets${query}`, { cache: "no-store" });
|
||||
const d = await r.json();
|
||||
const names = d.presets || [];
|
||||
const newValues = names.length ? ["Select a preset...", ...names] : ["No model preset is selected"];
|
||||
familyMap.set(nodeFamily, newValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Also fetch for the explicitly requested family if no nodes exist yet
|
||||
if (family && !familyMap.has(family)) {
|
||||
const query = `?family=${encodeURIComponent(family)}`;
|
||||
const r = await fetch(`/flakes/presets${query}`, { cache: "no-store" });
|
||||
const d = await r.json();
|
||||
const nodes = app.graph.nodes.filter(n => PRESET_NODES.includes(n.type));
|
||||
const valuesByQuery = new Map();
|
||||
for (const n of nodes) {
|
||||
const query = presetQuery(n);
|
||||
if (valuesByQuery.has(query)) continue;
|
||||
const d = await (await fetch(`/flakes/presets?${query}`, { cache: "no-store" })).json();
|
||||
const names = d.presets || [];
|
||||
const newValues = names.length ? ["Select a preset...", ...names] : ["No model preset is selected"];
|
||||
familyMap.set(family, newValues);
|
||||
valuesByQuery.set(query, names.length ? ["Select a preset...", ...names] : ["No model preset is selected"]);
|
||||
}
|
||||
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.type !== "FlakeModelPreset" && n.type !== "FlakeModelCombo") continue;
|
||||
for (const n of nodes) {
|
||||
const pw = n.widgets?.find(w => w.name === "preset");
|
||||
if (!pw || !pw.options) continue;
|
||||
const fw = n.widgets?.find(w => w.name === "model_family");
|
||||
const nodeFamily = fw?.value || "";
|
||||
const newValues = familyMap.get(nodeFamily) || familyMap.get("") || ["No model preset is selected"];
|
||||
const newValues = valuesByQuery.get(presetQuery(n));
|
||||
pw.options.values = newValues;
|
||||
|
||||
// Update the widget value list that ComfyUI/LiteGraph uses internally
|
||||
@@ -706,22 +1015,17 @@ export async function handlePresetButton(e) {
|
||||
if (isPlaceholder) {
|
||||
const result = await openPresetEditModal({
|
||||
mode: "create",
|
||||
family: currentFamily,
|
||||
data: {
|
||||
checkpoint: "",
|
||||
checkpoint_url: "",
|
||||
clip_skip: -2,
|
||||
vae: "",
|
||||
steps: 20,
|
||||
cfg: 4.0,
|
||||
sampler: "dpmpp_2m",
|
||||
scheduler: "karras",
|
||||
width: 832,
|
||||
height: 1216,
|
||||
prompt: { positive: "", negative: "" },
|
||||
embeddings: [],
|
||||
},
|
||||
});
|
||||
if (result) refreshPresetOptions(currentFamily);
|
||||
if (result) refreshPresetOptions();
|
||||
} else {
|
||||
let data;
|
||||
try {
|
||||
@@ -736,6 +1040,6 @@ export async function handlePresetButton(e) {
|
||||
data,
|
||||
family: currentFamily,
|
||||
});
|
||||
if (result) refreshPresetOptions(currentFamily);
|
||||
if (result) refreshPresetOptions();
|
||||
}
|
||||
}
|
||||
|
||||
+762
-83
@@ -1,4 +1,49 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { collectChain, MODEL_COMBO_TYPES, STACK_TYPES, FLAKE_COMBO_TYPES } from "./widgets/generation-data.js";
|
||||
import { serializeModelOverrides, expandEntryVariants } from "./utils.js";
|
||||
import { fetchFlakeMeta, cachedAutoCaption, variantComboCount } from "./api.js";
|
||||
|
||||
// Full set of variant selections for a flake whose groups are `meta`
|
||||
// ({group: [choices]}) — the cartesian product, one {group: choice} per combo.
|
||||
// Empty meta yields a single empty selection (#343).
|
||||
function enumerateVariantCombos(meta) {
|
||||
const groups = Object.keys(meta || {}).filter(g => Array.isArray(meta[g]) && meta[g].length);
|
||||
if (groups.length === 0) return [{}];
|
||||
const arrays = groups.map(g => meta[g].map(ch => [g, ch]));
|
||||
return cartesianProduct(arrays).map(pairs => Object.fromEntries(pairs));
|
||||
}
|
||||
|
||||
// A FlakeStack's flakes_json entries.
|
||||
function stackEntries(node) {
|
||||
const w = node.widgets?.find(w => w.name === "flakes_json");
|
||||
let entries = [];
|
||||
try { entries = JSON.parse(w?.value || "[]"); } catch { entries = []; }
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
// Active (non-bypassed, non-inline) "All Variants" flakes in a FlakeStack's
|
||||
// flakes_json, as [{ index, flake }] (#343).
|
||||
function stackAllVariantsFlakes(node) {
|
||||
return stackEntries(node)
|
||||
.map((flake, index) => ({ flake, index }))
|
||||
.filter(({ flake }) => flake && !flake.inline && !flake.bypassed && flake._all_variants && flake.name);
|
||||
}
|
||||
|
||||
// Flake Animate grid items carry input images (#441), one job each. A combo
|
||||
// item splits into [{ image, key: `${key}@${k}` }]; without images it stays
|
||||
// one job under its own key. The Generation Data overlay mirrors these keys.
|
||||
export function itemImageJobs(entry, key) {
|
||||
const images = entry.images || [];
|
||||
return images.length ? images.map((image, k) => ({ image, key: `${key}@${k}` })) : [{ image: null, key }];
|
||||
}
|
||||
|
||||
// A Flake Animate Stack's jobs: one per input image of its active items, in
|
||||
// grid order, as [{ index, image, key: `s@${k}` }] (index: the item's entry).
|
||||
export function stackImageJobs(node) {
|
||||
return stackEntries(node)
|
||||
.flatMap((e, index) => (e && !e.bypassed && e.images || []).map(image => ({ index, image })))
|
||||
.map((job, k) => ({ ...job, key: `s@${k}` }));
|
||||
}
|
||||
|
||||
export function getComboFlakes(node) {
|
||||
return node.properties?._combo_flakes || [];
|
||||
@@ -8,11 +53,30 @@ export function getComboPresets(node) {
|
||||
return node.properties?._combo_presets || [];
|
||||
}
|
||||
|
||||
// Persistent id of a combo grid item (#383): Flake Combo entries carry it as
|
||||
// `_uid`, Flake Model Combo keeps `_combo_uids` parallel to `_combo_presets`.
|
||||
// Combo keys use it, so results, previews and highlights survive reordering.
|
||||
export function newItemUid() {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
// Sub-key of an "All Variants" item: its selection as sorted group=choice pairs.
|
||||
export function variantKey(variant) {
|
||||
return Object.keys(variant).sort().map(g => `${g}=${variant[g]}`).join(",");
|
||||
}
|
||||
|
||||
// Active (non-bypassed) flakes for a FlakeCombo node.
|
||||
function activeComboFlakes(node) {
|
||||
return getComboFlakes(node).filter(f => !f.bypassed);
|
||||
}
|
||||
|
||||
// Active (non-bypassed) presets for a FlakeModelCombo node. Bypass is keyed by
|
||||
// preset name in node.properties._combo_bypassed.
|
||||
function activeComboPresets(node) {
|
||||
const bypassed = new Set(node.properties?._combo_bypassed || []);
|
||||
return getComboPresets(node).filter(p => !bypassed.has(p));
|
||||
}
|
||||
|
||||
// Number of prompts a generation will queue: the product of each
|
||||
// FlakeCombo's active-flake count and each FlakeModelCombo's preset count.
|
||||
// Returns 1 when there are combo nodes but a single combination, or 1 when
|
||||
@@ -22,12 +86,26 @@ export function computeJobCount(graph) {
|
||||
if (!g) return 1;
|
||||
let count = 1;
|
||||
for (const node of g.nodes) {
|
||||
if (node.type === "FlakeCombo") {
|
||||
const n = activeComboFlakes(node).length;
|
||||
if (n > 0) count *= n;
|
||||
} else if (node.type === "FlakeModelCombo") {
|
||||
const n = getComboPresets(node).length;
|
||||
if (FLAKE_COMBO_TYPES.includes(node.type)) {
|
||||
// Each active flake is one axis item; an "All Variants" flake expands
|
||||
// into one item per variant combination (#343), a "Multiple" one
|
||||
// into one per checked choice set (#406), each times its input images (#441).
|
||||
let axis = 0;
|
||||
for (const f of activeComboFlakes(node)) {
|
||||
const variants = f._all_variants ? Math.max(1, variantComboCount(f.name)) : expandEntryVariants(f).length;
|
||||
axis += variants * itemImageJobs(f, "").length;
|
||||
}
|
||||
if (axis > 0) count *= axis;
|
||||
} else if (MODEL_COMBO_TYPES.includes(node.type)) {
|
||||
const n = activeComboPresets(node).length;
|
||||
if (n > 0) count *= n;
|
||||
} else if (STACK_TYPES.includes(node.type)) {
|
||||
// Stack flakes flagged "All Variants" are extra axes (#343), and so
|
||||
// are a Flake Animate Stack's input images (#441).
|
||||
for (const { flake } of stackAllVariantsFlakes(node)) {
|
||||
count *= Math.max(1, variantComboCount(flake.name));
|
||||
}
|
||||
count *= Math.max(1, stackImageJobs(node).length);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
@@ -47,52 +125,583 @@ export function cartesianProduct(arrays) {
|
||||
}
|
||||
|
||||
const _originalQueuePrompt = app.queuePrompt;
|
||||
app.queuePrompt = async function(number, batchCount = 1) {
|
||||
const comboNodes = app.graph.nodes.filter(n => n.type === "FlakeCombo");
|
||||
const modelComboNodes = app.graph.nodes.filter(n => n.type === "FlakeModelCombo");
|
||||
|
||||
if (comboNodes.length === 0 && modelComboNodes.length === 0) {
|
||||
let _batchCleanup = null;
|
||||
|
||||
function getPromptIdFromEvent(e) {
|
||||
const detail = e.detail;
|
||||
return typeof detail === "string" ? detail : detail?.prompt_id;
|
||||
}
|
||||
|
||||
// Live batch state per workflow (#373): running combo item per combo node id,
|
||||
// results per FlakeGenerate node id, progress counters. Switching workflow tabs
|
||||
// rebuilds every node and the batch's workflow may not be the active one when
|
||||
// its events arrive, so nodes read this when they render instead of owning it.
|
||||
const _batchStates = new WeakMap(); // workflow -> { generating, images, completed, total }
|
||||
const _batchListeners = new Set();
|
||||
|
||||
// Nodes that sample a chain: they show previews and keep per-combination results.
|
||||
const GENERATE_TYPES = ["FlakeGenerate", "FlakeGenerateAnimation"];
|
||||
|
||||
function activeWorkflow() {
|
||||
// Frontends without a workflow store have a single implicit workflow.
|
||||
return app.extensionManager?.workflow?.activeWorkflow ?? app;
|
||||
}
|
||||
|
||||
function activeState() {
|
||||
return _batchStates.get(activeWorkflow());
|
||||
}
|
||||
|
||||
// Combo node -> the running uid its last render highlighted.
|
||||
const _shownUids = new WeakMap();
|
||||
|
||||
// Uid of the running item of a FlakeCombo / FlakeModelCombo node, if any.
|
||||
// Called by the node's render.
|
||||
export function getGeneratingUid(node) {
|
||||
const uid = activeState()?.generating[node.id];
|
||||
_shownUids.set(node, uid);
|
||||
return uid;
|
||||
}
|
||||
|
||||
// { combo: {comboKey: img}, ad: {comboKey: img}, ab, last, lastKey } for a FlakeGenerate
|
||||
// node, or null when no batch of this workflow produced results for it.
|
||||
export function getBatchImages(node) {
|
||||
return activeState()?.images[node.id] || null;
|
||||
}
|
||||
|
||||
export function getBatchProgress() {
|
||||
return activeState() || null;
|
||||
}
|
||||
|
||||
// fn() runs after any batch state change; returns an unsubscribe function.
|
||||
export function subscribeBatch(fn) {
|
||||
_batchListeners.add(fn);
|
||||
return () => _batchListeners.delete(fn);
|
||||
}
|
||||
|
||||
// Re-renders only the combo nodes whose highlighted item changed.
|
||||
function renderHighlights(state) {
|
||||
if (activeState() !== state) return;
|
||||
for (const n of app.graph.nodes) {
|
||||
if (_shownUids.has(n) && _shownUids.get(n) === state?.generating[n.id]) continue;
|
||||
if (FLAKE_COMBO_TYPES.includes(n.type)) n._combo_render?.();
|
||||
else if (MODEL_COMBO_TYPES.includes(n.type)) n._model_combo_render?.();
|
||||
}
|
||||
}
|
||||
|
||||
// FlakeGenerate nodes read the active workflow's state themselves.
|
||||
function renderResults() {
|
||||
for (const n of app.graph.nodes) {
|
||||
if (GENERATE_TYPES.includes(n.type)) n._flake_generate_render?.();
|
||||
}
|
||||
for (const fn of _batchListeners) fn();
|
||||
}
|
||||
|
||||
// Latest per-step sampling preview (#374). Only one prompt executes at a time,
|
||||
// so a single blob URL is kept; the previous one is revoked when replaced.
|
||||
let _preview = null; // { url, nodeId, promptId }
|
||||
|
||||
function setPreview(next) {
|
||||
if (_preview) URL.revokeObjectURL(_preview.url);
|
||||
_preview = next;
|
||||
renderResults();
|
||||
}
|
||||
|
||||
// { url, comboKey } while `node` is sampling, else null. comboKey is the
|
||||
// running batch combination ("" for a plain run). Previews of a batch queued
|
||||
// from another workflow are not shown.
|
||||
export function getLivePreview(node) {
|
||||
if (!_preview || _preview.nodeId !== String(node.id)) return null;
|
||||
const idx = _activeBatch.promptIds.indexOf(_preview.promptId);
|
||||
if (idx >= 0 && _activeBatch.state !== activeState()) return null;
|
||||
return { url: _preview.url, comboKey: idx >= 0 ? _activeBatch.comboKeys[idx] : "" };
|
||||
}
|
||||
|
||||
// FlakeGenerate shows its sampling previews itself; keep core from also drawing
|
||||
// them on the node (#380). Registered at module load, i.e. before core's own
|
||||
// listener (extensions load before the app adds its api handlers), so stopping
|
||||
// propagation here hides the event from it.
|
||||
_resolveComfyApi()?.addEventListener("b_preview_with_metadata", (e) => {
|
||||
const { blob, displayNodeId, jobId } = e.detail;
|
||||
if (!GENERATE_TYPES.includes(app.graph?.getNodeById(displayNodeId)?.type)) return;
|
||||
e.stopImmediatePropagation();
|
||||
setPreview({ url: URL.createObjectURL(blob), nodeId: String(displayNodeId), promptId: jobId });
|
||||
});
|
||||
|
||||
// ComfyUI sends no sampling previews when the Live preview setting is
|
||||
// "default" and the server runs without --preview-method (#380).
|
||||
let _serverPreviewDefault = null;
|
||||
function serverPreviewDefault() {
|
||||
_serverPreviewDefault ??= fetch("/flakes/preview_default").then(r => r.json()).then(d => d.method).catch(() => null);
|
||||
return _serverPreviewDefault;
|
||||
}
|
||||
|
||||
// Set by setupBatchTracking while a batch runs; called with each prompt the
|
||||
// batch loop queues.
|
||||
let _onBatchPromptQueued = null;
|
||||
|
||||
// api.queuePrompt stays wrapped for the session: prompts with a FlakeGenerate
|
||||
// get previews unless the user or the server chose a method, and batch prompts
|
||||
// report their prompt_id (app.queuePrompt in current frontends returns void;
|
||||
// api.queuePrompt returns the {prompt_id, number, node_errors} REST response).
|
||||
function installQueuePromptWrapper(comfyApi) {
|
||||
const origQueuePrompt = comfyApi.queuePrompt.bind(comfyApi);
|
||||
comfyApi.queuePrompt = async function(number, data, opts) {
|
||||
// Snapshot the combo key/index now — they may already be cleared by
|
||||
// the time the awaits below resolve.
|
||||
const comboKey = _currentBatchComboKey;
|
||||
const comboIndex = _currentComboIndex;
|
||||
if ((!opts?.previewMethod || opts.previewMethod === "default")
|
||||
&& Object.values(data?.output || {}).some(n => GENERATE_TYPES.includes(n?.class_type))
|
||||
&& await serverPreviewDefault() === "none") {
|
||||
opts = { ...opts, previewMethod: "auto" };
|
||||
}
|
||||
const result = await origQueuePrompt(number, data, opts);
|
||||
if (result?.prompt_id && comboKey !== null) _onBatchPromptQueued?.(result.prompt_id, comboKey, comboIndex);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "comfyui-flakes.BatchState",
|
||||
setup() {
|
||||
const comfyApi = _resolveComfyApi();
|
||||
if (!comfyApi) return;
|
||||
installQueuePromptWrapper(comfyApi);
|
||||
// The node's result replaces its preview; a finished, interrupted or
|
||||
// superseded prompt drops it.
|
||||
comfyApi.addEventListener("executed", ({ detail }) => {
|
||||
if (_preview && String(detail.display_node || detail.node) === _preview.nodeId) setPreview(null);
|
||||
});
|
||||
const dropForPrompt = (e) => { if (_preview && getPromptIdFromEvent(e) === _preview.promptId) setPreview(null); };
|
||||
comfyApi.addEventListener("execution_success", dropForPrompt);
|
||||
comfyApi.addEventListener("execution_error", dropForPrompt);
|
||||
comfyApi.addEventListener("execution_interrupted", dropForPrompt);
|
||||
comfyApi.addEventListener("execution_start", (e) => { if (_preview && getPromptIdFromEvent(e) !== _preview.promptId) setPreview(null); });
|
||||
comfyApi.addEventListener("reconnected", () => { if (_preview) setPreview(null); });
|
||||
},
|
||||
// The workflow store switches the active workflow only after the new
|
||||
// graph's nodes are configured, so re-read the store once loading is complete.
|
||||
afterLoadGraph() {
|
||||
renderHighlights(activeState());
|
||||
renderResults();
|
||||
},
|
||||
});
|
||||
|
||||
// ComfyUI exposes the websocket-event-emitting `api` singleton in slightly
|
||||
// different places depending on frontend version. Try several spots so we
|
||||
// don't silently lose batch tracking on a newer/older frontend.
|
||||
function _resolveComfyApi() {
|
||||
return (
|
||||
window.comfyAPI?.api?.api
|
||||
|| window.comfyAPI?.api
|
||||
|| app.api
|
||||
|| window.app?.api
|
||||
|| null
|
||||
);
|
||||
}
|
||||
|
||||
// Set by the outer batch loop before each combo's queue call; read by the
|
||||
// patched api.queuePrompt so the captured prompt_id can be tagged with the
|
||||
// right comboKey. Cleared between combos.
|
||||
let _currentBatchComboKey = null;
|
||||
|
||||
// The combination index for the prompt currently being queued (#348). With a UI
|
||||
// batch count > 1 each combination queues several prompts, so the prompt-id
|
||||
// index no longer equals the combination index — this maps it back.
|
||||
let _currentComboIndex = null;
|
||||
|
||||
// Active batch promptId → comboKey map, plus a live "running" pointer.
|
||||
// Exported so the Generation Data overlay can show per-combo progress (#230).
|
||||
const _activeBatch = { promptIds: [], comboKeys: [], runningPromptId: null, state: null };
|
||||
export function getActiveBatch() {
|
||||
return _activeBatch;
|
||||
}
|
||||
|
||||
function setupBatchTracking(state, combinations, comboKeys) {
|
||||
const comfyApi = _resolveComfyApi();
|
||||
if (!comfyApi || typeof comfyApi.addEventListener !== "function" || combinations.length === 0) {
|
||||
if (!comfyApi) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[flakes] could not resolve ComfyUI api singleton; combo highlight/per-combo image storage disabled.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const jobs = state.total;
|
||||
|
||||
const promptIds = []; // queue order
|
||||
const comboIndexByPrompt = []; // parallel to promptIds: the combination index (#348)
|
||||
const done = new Set(); // finished prompt ids (events and /history backfill may both report one)
|
||||
let closed = false;
|
||||
_activeBatch.state = state;
|
||||
|
||||
// Pending event buffer: websocket events for a prompt_id can arrive BEFORE
|
||||
// the REST response that gives us that prompt_id (different transport
|
||||
// channels). Buffer events we don't yet know about and replay them when
|
||||
// the patched api.queuePrompt finally registers the id. Without this, the
|
||||
// first generation in a batch consistently lost its execution_start /
|
||||
// executed events (#241).
|
||||
const pendingByPid = new Map(); // promptId -> [{type, e}]
|
||||
function applyEventForKnownPid(type, e, pid) {
|
||||
if (type === "execution_start") {
|
||||
setRunning(pid);
|
||||
} else if (type === "execution_success" || type === "execution_error" || type === "execution_interrupted") {
|
||||
markDone(pid);
|
||||
} else if (type === "executed") {
|
||||
handleOutput(pid, e.detail.display_node || e.detail.node, e.detail.output);
|
||||
}
|
||||
}
|
||||
function bufferOrApply(type, e) {
|
||||
const pid = type === "executed" ? (typeof e?.detail === "string" ? null : e?.detail?.prompt_id) : getPromptIdFromEvent(e);
|
||||
if (!pid) return;
|
||||
if (promptIds.includes(pid)) {
|
||||
applyEventForKnownPid(type, e, pid);
|
||||
} else {
|
||||
let arr = pendingByPid.get(pid);
|
||||
if (!arr) { arr = []; pendingByPid.set(pid, arr); }
|
||||
arr.push({ type, e });
|
||||
}
|
||||
}
|
||||
function flushPendingFor(pid) {
|
||||
const arr = pendingByPid.get(pid);
|
||||
if (!arr) return;
|
||||
pendingByPid.delete(pid);
|
||||
for (const { type, e } of arr) applyEventForKnownPid(type, e, pid);
|
||||
}
|
||||
|
||||
// The api.queuePrompt wrapper reports each prompt_id the batch loop queues.
|
||||
_onBatchPromptQueued = (pid, comboKey, comboIndex) => {
|
||||
promptIds.push(pid);
|
||||
comboIndexByPrompt.push(comboIndex ?? 0);
|
||||
comboKeys.push(comboKey);
|
||||
_activeBatch.promptIds.push(pid);
|
||||
_activeBatch.comboKeys.push(comboKey);
|
||||
// Replay any websocket events that arrived before this push.
|
||||
flushPendingFor(pid);
|
||||
};
|
||||
|
||||
function setRunning(pid) {
|
||||
const idx = promptIds.indexOf(pid);
|
||||
if (idx < 0) return;
|
||||
_activeBatch.runningPromptId = pid;
|
||||
state.generating = {};
|
||||
for (const item of combinations[comboIndexByPrompt[idx]]) {
|
||||
// stack_variant items have no node-level highlight (#343).
|
||||
if (item.type === "combo" || item.type === "model_combo") state.generating[item.node.id] = item.uid;
|
||||
}
|
||||
renderHighlights(state);
|
||||
}
|
||||
|
||||
function markDone(pid) {
|
||||
if (!promptIds.includes(pid) || done.has(pid)) return;
|
||||
done.add(pid);
|
||||
state.completed = done.size;
|
||||
if (done.size >= jobs && _batchCleanup === cleanup) {
|
||||
_batchCleanup();
|
||||
_batchCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A FlakeGenerate output (from the `executed` event or /history): store
|
||||
// its images (or FlakeGenerateAnimation's videos) by the prompt's comboKey
|
||||
// under the producing node's id.
|
||||
function handleOutput(pid, nodeId, output) {
|
||||
const idx = promptIds.indexOf(pid);
|
||||
const images = output?.flake_images || output?.flake_videos;
|
||||
if (idx < 0 || !Array.isArray(images) || images.length === 0) return;
|
||||
const comboKey = comboKeys[idx] ?? "";
|
||||
const caption = output.flake_caption?.[0];
|
||||
const image = caption ? { ...images[0], caption } : images[0];
|
||||
const entry = state.images[nodeId] || (state.images[nodeId] = { combo: {}, ad: {}, ab: false, last: null, lastKey: null });
|
||||
entry.combo[comboKey] = image;
|
||||
entry.last = image;
|
||||
entry.lastKey = comboKey;
|
||||
const adImages = output.flake_images_ad;
|
||||
if (Array.isArray(output.adetailer_ab) && output.adetailer_ab.length > 0 && Array.isArray(adImages) && adImages.length > 0) {
|
||||
entry.ad[comboKey] = adImages[0];
|
||||
entry.ab = true;
|
||||
}
|
||||
renderResults();
|
||||
}
|
||||
|
||||
// Websocket events are lost while the socket is down (background tabs,
|
||||
// reconnects): recover finished prompts from /history and the running one
|
||||
// from /queue. Prompts gone from both were deleted from the queue.
|
||||
async function backfill() {
|
||||
if (closed) return;
|
||||
const queue = await (await comfyApi.fetchApi("/queue")).json();
|
||||
if (closed) return;
|
||||
const queued = new Set([...queue.queue_running, ...queue.queue_pending].map(q => q[1]));
|
||||
for (const pid of promptIds.slice()) {
|
||||
if (done.has(pid) || queued.has(pid)) continue;
|
||||
const history = (await (await comfyApi.fetchApi(`/history/${pid}`)).json())[pid];
|
||||
if (closed) return;
|
||||
for (const [nodeId, output] of Object.entries(history?.outputs || {})) handleOutput(pid, nodeId, output);
|
||||
markDone(pid);
|
||||
}
|
||||
const running = queue.queue_running[0]?.[1];
|
||||
if (running && !closed) setRunning(running);
|
||||
}
|
||||
|
||||
// All websocket lifecycle/output events route through bufferOrApply so
|
||||
// events arriving before their prompt_id is registered get queued.
|
||||
const onExecStart = (e) => bufferOrApply("execution_start", e);
|
||||
const onExecSuccess = (e) => bufferOrApply("execution_success", e);
|
||||
const onExecError = (e) => bufferOrApply("execution_error", e);
|
||||
const onExecInterrupted = (e) => bufferOrApply("execution_interrupted", e);
|
||||
const onExecuted = (e) => bufferOrApply("executed", e);
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") backfill(); };
|
||||
|
||||
comfyApi.addEventListener("execution_start", onExecStart);
|
||||
comfyApi.addEventListener("execution_success", onExecSuccess);
|
||||
comfyApi.addEventListener("execution_error", onExecError);
|
||||
comfyApi.addEventListener("execution_interrupted", onExecInterrupted);
|
||||
comfyApi.addEventListener("executed", onExecuted);
|
||||
comfyApi.addEventListener("reconnected", backfill);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
function cleanup() {
|
||||
closed = true;
|
||||
comfyApi.removeEventListener("execution_start", onExecStart);
|
||||
comfyApi.removeEventListener("execution_success", onExecSuccess);
|
||||
comfyApi.removeEventListener("execution_error", onExecError);
|
||||
comfyApi.removeEventListener("execution_interrupted", onExecInterrupted);
|
||||
comfyApi.removeEventListener("executed", onExecuted);
|
||||
comfyApi.removeEventListener("reconnected", backfill);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
_onBatchPromptQueued = null;
|
||||
_currentBatchComboKey = null;
|
||||
_activeBatch.promptIds = [];
|
||||
_activeBatch.comboKeys = [];
|
||||
_activeBatch.runningPromptId = null;
|
||||
_activeBatch.state = null;
|
||||
pendingByPid.clear();
|
||||
state.generating = {};
|
||||
renderHighlights(state);
|
||||
}
|
||||
|
||||
return { promptIds, cleanup };
|
||||
}
|
||||
|
||||
// Order the combinatorial nodes (FlakeCombo + FlakeModelCombo) by their
|
||||
// position in the pipeline connection chain (source -> sink), so the loop
|
||||
// nesting follows the graph flow rather than node creation order. The
|
||||
// FlakeModelCombo is the chain source (it loads the heavy checkpoint), so it
|
||||
// lands first = outermost loop = reloaded least; each subsequent FlakeCombo
|
||||
// toward FlakeGenerate becomes a deeper inner loop. Combo nodes not reachable
|
||||
// from any FlakeGenerate (orphans) are appended in creation order so
|
||||
// disconnected graphs keep working.
|
||||
function orderedComboNodes() {
|
||||
const isCombo = (n) => FLAKE_COMBO_TYPES.includes(n.type) || MODEL_COMBO_TYPES.includes(n.type);
|
||||
const ordered = [];
|
||||
const seen = new Set();
|
||||
for (const gen of app.graph.nodes) {
|
||||
if (!GENERATE_TYPES.includes(gen.type)) continue;
|
||||
for (const { node } of generateChain(gen)) {
|
||||
if (isCombo(node) && !seen.has(node.id)) { seen.add(node.id); ordered.push(node); }
|
||||
}
|
||||
}
|
||||
for (const node of app.graph.nodes) {
|
||||
if (isCombo(node) && !seen.has(node.id)) { seen.add(node.id); ordered.push(node); }
|
||||
}
|
||||
// Exclude node-level bypassed (mode 4) / muted (mode 2) combo nodes entirely,
|
||||
// so they never contribute presets or raise "no active presets" errors (#304).
|
||||
return ordered.filter((n) => n.mode !== 4 && n.mode !== 2);
|
||||
}
|
||||
|
||||
// A Generate node's upstream chain, source -> sink ([] when unconnected).
|
||||
function generateChain(gen) {
|
||||
const fdInput = gen.inputs?.find(i => i.name === "flake_data" || i.name === "flake_anim_data");
|
||||
if (fdInput?.link == null) return [];
|
||||
const link = app.graph.links?.[fdInput.link];
|
||||
const startNode = link && app.graph.getNodeById(link.origin_id);
|
||||
if (!startNode) return [];
|
||||
try { return collectChain(startNode); } catch { return []; }
|
||||
}
|
||||
|
||||
// Sets the combo / stack / preset widgets for one combination. Values are
|
||||
// restored from the saved originals once queueing is done.
|
||||
function applyCombination(combination) {
|
||||
for (const item of combination) {
|
||||
if (item.type === "combo") {
|
||||
const w = item.node.widgets?.find(w => w.name === "flakes_json");
|
||||
if (w) w.value = JSON.stringify([item.value]);
|
||||
} else if (item.type === "stack_variant") {
|
||||
// Set just this stack flake's variant for the combination;
|
||||
// other stack flakes (and other all-variants indices) are left
|
||||
// to their own items (#343).
|
||||
const w = item.node.widgets?.find(w => w.name === "flakes_json");
|
||||
if (w) {
|
||||
let arr; try { arr = JSON.parse(w.value || "[]"); } catch { arr = []; }
|
||||
if (arr[item.flakeIndex]) {
|
||||
arr[item.flakeIndex] = { ...arr[item.flakeIndex], variant: item.value };
|
||||
w.value = JSON.stringify(arr);
|
||||
}
|
||||
}
|
||||
} else if (item.type === "stack_image") {
|
||||
// This job's input image goes on its item only; the previous
|
||||
// job's is cleared (#441).
|
||||
const w = item.node.widgets?.find(w => w.name === "flakes_json");
|
||||
if (w) {
|
||||
let arr; try { arr = JSON.parse(w.value || "[]"); } catch { arr = []; }
|
||||
arr.forEach((e, i) => { e.image = i === item.flakeIndex ? item.value : undefined; });
|
||||
w.value = JSON.stringify(arr);
|
||||
}
|
||||
} else {
|
||||
const w = item.node.widgets?.find(w => w.name === "preset");
|
||||
if (w) w.value = item.value;
|
||||
// Per-instance preset overrides for this combination (#279).
|
||||
const ow = item.node.widgets?.find(w => w.name === "overrides_json");
|
||||
if (ow) {
|
||||
const ovr = (item.node.properties?._combo_overrides || [])[item.index] || {};
|
||||
ow.value = serializeModelOverrides(ovr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FlakeAnimCaption job ({ preset, flakes, image }) for a Generate Animation
|
||||
// node's chain as currently set, or null when no active flake directly in it
|
||||
// has auto_caption (#484). Bypassed / muted chain nodes are skipped like the
|
||||
// backend passes them through; the last start image in the chain wins.
|
||||
async function captionJob(gen) {
|
||||
let preset = null, image = null, needed = false;
|
||||
const flakes = [];
|
||||
for (const { node } of generateChain(gen)) {
|
||||
if (node.mode === 2 || node.mode === 4) continue;
|
||||
if (!STACK_TYPES.includes(node.type) && !FLAKE_COMBO_TYPES.includes(node.type)) {
|
||||
preset = node.widgets?.find(w => w.name === "preset")?.value ?? null;
|
||||
continue;
|
||||
}
|
||||
flakes.push(node.widgets?.find(w => w.name === "flakes_json")?.value || "[]");
|
||||
for (const e of stackEntries(node)) {
|
||||
if (!e || e.bypassed) continue;
|
||||
if (e.image) image = e.image;
|
||||
if (e.inline || !e.name) continue;
|
||||
await fetchFlakeMeta(e.name);
|
||||
if (cachedAutoCaption(e.name)) needed = true;
|
||||
}
|
||||
}
|
||||
return needed ? { preset, flakes, image } : null;
|
||||
}
|
||||
|
||||
// Queues one FlakeAnimCaption prompt and resolves with its captions (parallel
|
||||
// to `jobs`), or null when it errors or is interrupted. Events that arrive
|
||||
// before the REST response gives the prompt_id are replayed after it.
|
||||
async function runCaptionPrompt(comfyApi, jobs) {
|
||||
const types = ["executed", "execution_error", "execution_interrupted"];
|
||||
let pid = null, finish;
|
||||
const early = [];
|
||||
const result = new Promise(resolve => { finish = resolve; });
|
||||
const onEvent = (e) => {
|
||||
if (pid === null) { early.push(e); return; }
|
||||
if (getPromptIdFromEvent(e) !== pid) return;
|
||||
if (e.type !== "executed") finish(null);
|
||||
else if (Array.isArray(e.detail.output?.flake_captions)) finish(e.detail.output.flake_captions);
|
||||
};
|
||||
for (const t of types) comfyApi.addEventListener(t, onEvent);
|
||||
try {
|
||||
const output = { flake_caption: { class_type: "FlakeAnimCaption", inputs: { jobs_json: JSON.stringify(jobs) } } };
|
||||
pid = (await comfyApi.queuePrompt(-1, { output })).prompt_id;
|
||||
early.splice(0).forEach(onEvent);
|
||||
return await result;
|
||||
} finally {
|
||||
for (const t of types) comfyApi.removeEventListener(t, onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
app.queuePrompt = async function(number, batchCount = 1) {
|
||||
const orderedNodes = orderedComboNodes();
|
||||
|
||||
// Stack flakes flagged "All Variants" become extra combinatorial axes,
|
||||
// independent of any combo nodes (#343).
|
||||
const stackAxes = [];
|
||||
for (const node of app.graph.nodes) {
|
||||
if (!STACK_TYPES.includes(node.type) || node.mode === 2 || node.mode === 4) continue;
|
||||
for (const { flake, index } of stackAllVariantsFlakes(node)) {
|
||||
const meta = await fetchFlakeMeta(flake.name);
|
||||
const combos = enumerateVariantCombos(meta);
|
||||
if (combos.length <= 1) continue;
|
||||
stackAxes.push(combos.map((variant) => ({
|
||||
node, type: "stack_variant", flakeIndex: index, value: variant, vkey: `s${index}.${variantKey(variant)}`,
|
||||
})));
|
||||
}
|
||||
const imageJobs = stackImageJobs(node);
|
||||
if (imageJobs.length) {
|
||||
stackAxes.push(imageJobs.map(({ index, image, key }) => ({
|
||||
node, type: "stack_image", flakeIndex: index, value: image, vkey: key,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedNodes.length === 0 && stackAxes.length === 0) {
|
||||
// A plain run supersedes this workflow's finished batch results.
|
||||
const state = activeState();
|
||||
if (state && state.completed >= state.total) _batchStates.delete(activeWorkflow());
|
||||
return _originalQueuePrompt.call(this, number, batchCount);
|
||||
}
|
||||
|
||||
const optionsArrays = [];
|
||||
|
||||
for (const node of comboNodes) {
|
||||
const flakes = getComboFlakes(node);
|
||||
if (flakes.length === 0) {
|
||||
window.alert("FlakeCombo node has no flakes selected.");
|
||||
return;
|
||||
for (const node of orderedNodes) {
|
||||
if (FLAKE_COMBO_TYPES.includes(node.type)) {
|
||||
const flakes = getComboFlakes(node);
|
||||
if (flakes.length === 0) {
|
||||
window.alert(`${node.type} node has no flakes selected.`);
|
||||
return;
|
||||
}
|
||||
const activeFlakes = flakes.filter(flake => !flake.bypassed);
|
||||
if (activeFlakes.length === 0) {
|
||||
window.alert(`${node.type} node has no active (non-bypassed) flakes.`);
|
||||
return;
|
||||
}
|
||||
// Expand "All Variants" flakes into one axis item per variant combo,
|
||||
// "Multiple" choices into one per checked choice (#406); ordinary
|
||||
// flakes stay a single item. vkey keeps each combo's comboKey
|
||||
// unique (#343). Each splits again per input image (#441), the
|
||||
// job's entry carrying it as `image`.
|
||||
const comboItems = [];
|
||||
for (const flake of activeFlakes) {
|
||||
const uid = flake._uid;
|
||||
const variants = flake._all_variants ? enumerateVariantCombos(await fetchFlakeMeta(flake.name)) : expandEntryVariants(flake);
|
||||
const split = flake._all_variants || variants.length > 1;
|
||||
for (const variant of variants) {
|
||||
const value = split ? { ...flake, variant } : flake;
|
||||
for (const { image, key } of itemImageJobs(flake, split ? `${uid}.${variantKey(variant)}` : uid)) {
|
||||
comboItems.push({ node, type: "combo", value: image ? { ...value, image } : value, uid, vkey: key });
|
||||
}
|
||||
}
|
||||
}
|
||||
optionsArrays.push(comboItems);
|
||||
} else {
|
||||
const presets = getComboPresets(node);
|
||||
if (presets.length === 0) {
|
||||
window.alert(`${node.type} node has no presets selected.`);
|
||||
return;
|
||||
}
|
||||
const bypassed = new Set(node.properties?._combo_bypassed || []);
|
||||
const activePresets = presets
|
||||
.map((preset, i) => ({ preset, i }))
|
||||
.filter(({ preset }) => !bypassed.has(preset));
|
||||
if (activePresets.length === 0) {
|
||||
window.alert(`${node.type} node has no active (non-bypassed) presets.`);
|
||||
return;
|
||||
}
|
||||
// index picks the preset's overrides when its combination is queued.
|
||||
const uids = node.properties._combo_uids;
|
||||
optionsArrays.push(activePresets.map(({ preset, i }) => ({
|
||||
node,
|
||||
type: "model_combo",
|
||||
value: preset,
|
||||
index: i,
|
||||
uid: uids[i],
|
||||
vkey: uids[i],
|
||||
})));
|
||||
}
|
||||
// Bypassed (crossed-out) flakes are excluded from the combinatorial
|
||||
// job set — only active flakes contribute to the queue.
|
||||
const activeFlakes = flakes
|
||||
.map((flake, i) => ({ flake, i }))
|
||||
.filter(({ flake }) => !flake.bypassed);
|
||||
if (activeFlakes.length === 0) {
|
||||
window.alert("FlakeCombo node has no active (non-bypassed) flakes.");
|
||||
return;
|
||||
}
|
||||
optionsArrays.push(activeFlakes.map(({ flake, i }) => ({
|
||||
node,
|
||||
type: "combo",
|
||||
value: flake,
|
||||
index: i,
|
||||
})));
|
||||
}
|
||||
|
||||
for (const node of modelComboNodes) {
|
||||
const presets = getComboPresets(node);
|
||||
if (presets.length === 0) {
|
||||
window.alert("FlakeModelCombo node has no presets selected.");
|
||||
return;
|
||||
}
|
||||
optionsArrays.push(presets.map((preset, i) => ({
|
||||
node,
|
||||
type: "model_combo",
|
||||
value: preset,
|
||||
index: i,
|
||||
})));
|
||||
}
|
||||
// Append the stack "All Variants" axes (#343).
|
||||
for (const axis of stackAxes) optionsArrays.push(axis);
|
||||
|
||||
const combinations = cartesianProduct(optionsArrays);
|
||||
if (combinations.length === 0) {
|
||||
@@ -103,58 +712,128 @@ app.queuePrompt = async function(number, batchCount = 1) {
|
||||
const nodeOriginals = new Map();
|
||||
for (const item of combinations[0]) {
|
||||
if (!nodeOriginals.has(item.node.id)) {
|
||||
if (item.type === "combo") {
|
||||
if (item.type === "combo" || item.type === "stack_variant" || item.type === "stack_image") {
|
||||
// All mutate the node's flakes_json; save it once to restore later.
|
||||
const w = item.node.widgets?.find(w => w.name === "flakes_json");
|
||||
nodeOriginals.set(item.node.id, { node: item.node, widget: w, value: w?.value });
|
||||
} else {
|
||||
const w = item.node.widgets?.find(w => w.name === "preset");
|
||||
nodeOriginals.set(item.node.id, { node: item.node, widget: w, value: w?.value });
|
||||
const ow = item.node.widgets?.find(w => w.name === "overrides_json");
|
||||
nodeOriginals.set(item.node.id, {
|
||||
node: item.node, widget: w, value: w?.value,
|
||||
overrideWidget: ow, overrideValue: ow?.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flakeGenerateNodes = app.graph.nodes.filter(n => n.type === "FlakeGenerate");
|
||||
|
||||
try {
|
||||
for (const combination of combinations) {
|
||||
for (const item of combination) {
|
||||
if (item.type === "combo") {
|
||||
const w = item.node.widgets?.find(w => w.name === "flakes_json");
|
||||
if (w) w.value = JSON.stringify([item.value]);
|
||||
// Highlight the flake being generated on its combo node
|
||||
item.node._combo_generating_index = item.index;
|
||||
item.node._combo_render?.();
|
||||
} else {
|
||||
const w = item.node.widgets?.find(w => w.name === "preset");
|
||||
if (w) w.value = item.value;
|
||||
item.node._model_combo_generating_index = item.index;
|
||||
item.node._model_combo_render?.();
|
||||
}
|
||||
}
|
||||
// Stamp the active combination key (order-independent: combo node
|
||||
// id : selected index, sorted by node id) so FlakeGenerate can map
|
||||
// the resulting image to this combination in its data overlay.
|
||||
const comboKey = combination
|
||||
.map(it => [it.node.id, it.index])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([id, idx]) => `${id}:${idx}`)
|
||||
.join("|");
|
||||
for (const fg of flakeGenerateNodes) fg._pending_combination_key = comboKey;
|
||||
await _originalQueuePrompt.call(this, number, 1);
|
||||
}
|
||||
} finally {
|
||||
const flakeGenerateNodes = app.graph.nodes.filter(n => GENERATE_TYPES.includes(n.type));
|
||||
const restoreWidgets = () => {
|
||||
for (const orig of nodeOriginals.values()) {
|
||||
if (orig.widget) orig.widget.value = orig.value;
|
||||
if (orig.overrideWidget) orig.overrideWidget.value = orig.overrideValue;
|
||||
}
|
||||
for (const n of app.graph.nodes) {
|
||||
if (n.type === "FlakeCombo") {
|
||||
delete n._combo_generating_index;
|
||||
n._combo_render?.();
|
||||
}
|
||||
if (n.type === "FlakeModelCombo") {
|
||||
delete n._model_combo_generating_index;
|
||||
n._model_combo_render?.();
|
||||
}
|
||||
};
|
||||
|
||||
// The UI batch count multiplies the combinations: each combination is run
|
||||
// `runsPerCombo` times in a row before moving to the next (#348). The seed
|
||||
// advances between runs via control_after_generate (randomize/increment).
|
||||
const runsPerCombo = Math.max(1, batchCount | 0);
|
||||
const totalJobs = combinations.length * runsPerCombo;
|
||||
|
||||
// Auto-caption pre-pass (#484): per combination, the caption job of each
|
||||
// active Generate Animation node (null when its chain needs none). Every
|
||||
// queued video gets its own caption, so repeats are listed again.
|
||||
const animNodes = flakeGenerateNodes.filter(n => n.type === "FlakeGenerateAnimation" && n.mode !== 2 && n.mode !== 4);
|
||||
const captionJobs = [];
|
||||
try {
|
||||
for (const combination of combinations) {
|
||||
applyCombination(combination);
|
||||
captionJobs.push(await Promise.all(animNodes.map(captionJob)));
|
||||
}
|
||||
} finally {
|
||||
restoreWidgets();
|
||||
}
|
||||
const jobs = captionJobs.flatMap(js => Array(runsPerCombo).fill(js.filter(Boolean)).flat());
|
||||
for (const n of animNodes) {
|
||||
const w = n.widgets?.find(w => w.name === "caption");
|
||||
nodeOriginals.set(n.id, { node: n, widget: w, value: "" });
|
||||
}
|
||||
|
||||
// Clean up any previous batch tracking
|
||||
if (_batchCleanup) {
|
||||
_batchCleanup();
|
||||
_batchCleanup = null;
|
||||
}
|
||||
|
||||
// Fresh state for this workflow: the "[i/N] done" label restarts at "[0/N]"
|
||||
// (#227) and the previous batch's results are dropped.
|
||||
const workflow = activeWorkflow();
|
||||
const state = { generating: {}, images: {}, completed: 0, total: totalJobs, captioning: jobs.length > 0 };
|
||||
_batchStates.set(workflow, state);
|
||||
for (const n of flakeGenerateNodes) {
|
||||
n.properties._images_by_combo = {};
|
||||
n.properties._images_by_combo_ad = {};
|
||||
n.properties._adetailer_ab = false;
|
||||
}
|
||||
renderResults();
|
||||
|
||||
let captions = null, captionIdx = 0;
|
||||
if (jobs.length) {
|
||||
try {
|
||||
captions = await runCaptionPrompt(_resolveComfyApi(), jobs);
|
||||
} catch (e) {
|
||||
console.error("[flakes] caption job failed", e);
|
||||
}
|
||||
state.captioning = false;
|
||||
// The videos are queued from the active graph, so a tab switch while
|
||||
// captioning would queue the wrong workflow.
|
||||
if (!captions || activeWorkflow() !== workflow) {
|
||||
if (_batchStates.get(workflow) === state) _batchStates.delete(workflow);
|
||||
renderResults();
|
||||
window.alert(captions ? "The workflow tab changed while captioning; no videos were queued."
|
||||
: "Auto-captioning failed or was cancelled; no videos were queued.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const comboKeys = []; // parallel to promptIds; comboKeys[idx] = key for promptIds[idx]
|
||||
const tracker = setupBatchTracking(state, combinations, comboKeys);
|
||||
if (tracker) {
|
||||
_batchCleanup = tracker.cleanup;
|
||||
}
|
||||
|
||||
try {
|
||||
for (let comboIdx = 0; comboIdx < combinations.length; comboIdx++) {
|
||||
const combination = combinations[comboIdx];
|
||||
applyCombination(combination);
|
||||
const comboKey = combination
|
||||
.map(it => [it.node.id, it.vkey])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([id, vk]) => `${id}:${vk}`)
|
||||
.join("|");
|
||||
|
||||
// Run this combination `runsPerCombo` times before the next (#348).
|
||||
// Tell our patched api.queuePrompt which comboKey/index to attach to
|
||||
// each captured prompt_id. app.queuePrompt in modern frontends returns
|
||||
// void; the prompt_id is captured inside the patch instead.
|
||||
for (let b = 0; b < runsPerCombo; b++) {
|
||||
if (captions) {
|
||||
animNodes.forEach((n, i) => {
|
||||
const w = n.widgets?.find(w => w.name === "caption");
|
||||
const caption = captionJobs[comboIdx][i] ? captions[captionIdx++] : "";
|
||||
if (w) w.value = caption;
|
||||
});
|
||||
}
|
||||
if (tracker) { _currentBatchComboKey = comboKey; _currentComboIndex = comboIdx; }
|
||||
try {
|
||||
await _originalQueuePrompt.call(this, number, 1);
|
||||
} finally {
|
||||
if (tracker) { _currentBatchComboKey = null; _currentComboIndex = null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
restoreWidgets();
|
||||
}
|
||||
};
|
||||
|
||||
+971
-10
File diff suppressed because it is too large
Load Diff
+510
-63
@@ -1,21 +1,29 @@
|
||||
import {
|
||||
css, ensureDefault, makeSmallButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, TYPE_COLORS,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock,
|
||||
makePanelDropdown, makeSmallValueSlider, variantSuffix,
|
||||
_registerOpenPanel, _unregisterOpenPanel,
|
||||
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, makeFavoriteStar, TYPE_COLORS,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock, flakeDrag, clearFlakeDrag,
|
||||
makePanelDropdown, makeSmallValueSlider, renderVariantLoraSliders, renderOptionToggles, variantInitials, renderFlakeLabel, splitNameAndTags, loraTagNames,
|
||||
_registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, attachHoldToSingleOut, effectiveVariant, variantDropdownOptions, variantDefaultsOf, choiceFlakeLinks,
|
||||
isLocalName, markLocal, SAVE_TO_DISK_SVG,
|
||||
} from "../utils.js";
|
||||
import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, fetchFlakeMeta } from "../api.js";
|
||||
import { saveLocalToDisk } from "../bundle.js";
|
||||
import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, THUMB, fetchFlakeMeta, cachedVariantDefaults, importAnimFlakeApi, pickedImageUrl } from "../api.js";
|
||||
import { openEditModal } from "../flake-modal.js";
|
||||
import { openFileLoadPicker } from "../pickers.js";
|
||||
import { openFileLoadPicker, openVariantMultiPicker, makeItemImagesButton, makeItemImagesDropTarget } from "../pickers.js";
|
||||
import { getGeneratingUid, newItemUid } from "../queue.js";
|
||||
|
||||
function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove, onReplace, onToggleBypass, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
// Grid item [width, height] per kind; animate items are larger to show their
|
||||
// input image (#441).
|
||||
const ITEM_SIZE = { img: [90, 100], anim: [104, 128] };
|
||||
|
||||
function makeComboBlock({ entry, idx, kind, isActive, isGenerating, onEdit, onRemove, onReplace, onToggleBypass, onSingleOut, onDuplicate, onImagesChanged, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
const hasCover = !!entry.name;
|
||||
const isBypassed = !!entry.bypassed;
|
||||
const isAnim = kind === "anim";
|
||||
const block = document.createElement("div");
|
||||
block.dataset.idx = String(idx);
|
||||
block.dataset.flakeBlock = "1";
|
||||
|
||||
css(block, `position:relative;height:80px;background:${
|
||||
css(block, `position:relative;height:${ITEM_SIZE[kind][1]}px;background:${
|
||||
isBypassed ? "#1a1a1a" : "#2a2a2a"
|
||||
};border:${
|
||||
isGenerating ? "2px solid #4a9eff" : "1px solid #444"
|
||||
@@ -23,19 +31,22 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
hasCover ? "background-size:cover;background-position:center;" : ""
|
||||
}${isGenerating ? "box-shadow:0 0 8px 1px rgba(74,158,255,0.7);" : ""}${isBypassed ? "opacity:0.45;" : ""}`);
|
||||
|
||||
// Cover image — swap to a selected variant choice's image when it has one,
|
||||
// otherwise fall back to the flake's base cover.
|
||||
// Cover image — the first input image of an animate item (#441), else an
|
||||
// effective variant choice's image when it has one, else the base cover.
|
||||
function refreshCover() {
|
||||
if (entry.images?.length) {
|
||||
block.style.backgroundImage = `url("${pickedImageUrl(entry.images[0])}")`;
|
||||
return;
|
||||
}
|
||||
if (!hasCover) return;
|
||||
const baseUrl = getCoverUrl(entry.name);
|
||||
const variant = entry.variant || {};
|
||||
const sel = Object.entries(variant).find(([, c]) => c != null && c !== "");
|
||||
const baseUrl = getCoverUrl(entry.name, THUMB);
|
||||
const sel = Object.entries(effectiveVariant(entry.variant, cachedVariantDefaults(entry.name)))[0];
|
||||
if (!sel) {
|
||||
block.style.backgroundImage = `url(${baseUrl})`;
|
||||
return;
|
||||
}
|
||||
const [group, choice] = sel;
|
||||
const variantUrl = getVariantImageUrl(entry.name, group, choice);
|
||||
const variantUrl = getVariantImageUrl(entry.name, group, Array.isArray(choice) ? choice[0] : choice, THUMB);
|
||||
const probe = new Image();
|
||||
probe.onload = () => { block.style.backgroundImage = `url(${variantUrl})`; };
|
||||
probe.onerror = () => { block.style.backgroundImage = `url(${baseUrl})`; };
|
||||
@@ -43,9 +54,14 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
}
|
||||
refreshCover();
|
||||
|
||||
// Type ribbon — clickable to toggle bypass
|
||||
const ribbon = makeTypeRibbon(entry, isBypassed, onToggleBypass, idx);
|
||||
block.appendChild(ribbon);
|
||||
// Type ribbon — static type indicator. Activation/deactivation is handled
|
||||
// by the top-right checkbox (#277), mirroring Flake Model Combo.
|
||||
const ribbon = makeTypeRibbon(entry, isBypassed);
|
||||
if (ribbon) {
|
||||
ribbon.style.cursor = "default";
|
||||
ribbon.title = entry.flake_type;
|
||||
block.appendChild(ribbon);
|
||||
}
|
||||
|
||||
// Strikethrough for bypassed state
|
||||
if (isBypassed) {
|
||||
@@ -60,18 +76,58 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
makeHoverButton({ svg: `<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`, title: "Replace Flake", onClick: () => onReplace(idx) }),
|
||||
makeHoverButton({ svg: `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`, title: "Edit Flake", onClick: () => onEdit(idx) }),
|
||||
makeHoverButton({ svg: `<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>`, title: "Remove from Combo", onClick: () => onRemove(idx) }),
|
||||
...(isLocalName(entry.name) ? [makeHoverButton({ svg: SAVE_TO_DISK_SVG, title: "Save to disk", onClick: () => saveLocalToDisk(entry.name) })] : []),
|
||||
],
|
||||
showTriangle: !!entry.name,
|
||||
});
|
||||
if (isLocalName(entry.name)) markLocal(block);
|
||||
|
||||
// Name — show full display name with word wrapping, plus selected variant
|
||||
// Enable/disable checkbox (top-right). Checked = active; unchecked =
|
||||
// bypassed (excluded from the combo product). Sits above the hover overlay
|
||||
// so it stays clickable on hover. Mirrors Flake Model Combo (#277).
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = !isBypassed;
|
||||
toggle.title = isBypassed ? "Flake disabled (click to enable)" : "Flake enabled (click to disable)";
|
||||
css(toggle, "position:absolute;top:3px;right:3px;z-index:6;cursor:pointer;margin:0;width:14px;height:14px;");
|
||||
toggle.addEventListener("click", (e) => { e.stopPropagation(); });
|
||||
toggle.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("change", (e) => { e.stopPropagation(); onToggleBypass(idx); });
|
||||
if (onSingleOut) attachHoldToSingleOut(toggle, () => onSingleOut(idx));
|
||||
block.appendChild(toggle);
|
||||
|
||||
// Duplicate (») button (bottom-right): adds a copy of this flake beside it (#316).
|
||||
if (onDuplicate) {
|
||||
const dup = document.createElement("button");
|
||||
dup.textContent = "»";
|
||||
dup.title = "Duplicate this flake";
|
||||
css(dup, "position:absolute;bottom:2px;right:2px;z-index:6;width:16px;height:16px;padding:0;line-height:14px;font-size:12px;font-weight:700;border:none;background:transparent;color:#cfe6ff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,0.9);");
|
||||
dup.addEventListener("click", (e) => { e.stopPropagation(); onDuplicate(idx); });
|
||||
dup.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dup.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(dup);
|
||||
}
|
||||
|
||||
if (entry.favorite) {
|
||||
const star = makeFavoriteStar();
|
||||
// Animate items keep the bottom-left for their input images button.
|
||||
if (isAnim) Object.assign(star.style, { left: "auto", right: "20px" });
|
||||
block.appendChild(star);
|
||||
}
|
||||
if (isAnim) {
|
||||
block.appendChild(makeItemImagesButton(entry, onImagesChanged));
|
||||
makeItemImagesDropTarget(block, entry, onImagesChanged);
|
||||
}
|
||||
|
||||
// Name — stacked label: name line, dimmer tag line (#297), variant suffix.
|
||||
const baseName = entry.display_name || entry.name || "(missing)";
|
||||
const nameEl = document.createElement("div");
|
||||
css(nameEl, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;text-align:center;line-height:1.2;text-shadow:0 1px 3px rgba(0,0,0,0.8);padding:0 4px;overflow:hidden;z-index:1;word-break:break-word;hyphens:auto;border-radius:3px;");
|
||||
function refreshName() {
|
||||
const displayed = baseName + variantSuffix(entry);
|
||||
nameEl.title = displayed;
|
||||
nameEl.textContent = displayed;
|
||||
const { name, tags } = splitNameAndTags(baseName, entry.tags);
|
||||
const base = name.includes("/") ? name.split("/").pop() : name;
|
||||
renderFlakeLabel(nameEl, { name: base, tags, variantText: variantInitials(entry, cachedVariantDefaults(entry.name)) });
|
||||
}
|
||||
refreshName();
|
||||
block.appendChild(nameEl);
|
||||
@@ -98,6 +154,8 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
block.addEventListener("drop", (e) => onDrop(e, idx, block));
|
||||
|
||||
function refreshVisuals() { refreshName(); refreshCover(); }
|
||||
// Default variant choices show once the flake's meta is loaded (#388).
|
||||
if (hasCover && !cachedVariantDefaults(entry.name)) fetchFlakeMeta(entry.name).then(refreshVisuals, () => {});
|
||||
|
||||
return { block, triangleBtn, refreshName, refreshVisuals };
|
||||
}
|
||||
@@ -105,19 +163,29 @@ function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove,
|
||||
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVariantChange) {
|
||||
if (entry.inline) return { toggleOptionsPanel: () => {} };
|
||||
|
||||
// Options panel (hidden by default)
|
||||
// Options panel (hidden by default). Same two-column layout as FlakeStack
|
||||
// for #235 — native panel + linked flake panel when host has a flake_link.
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);width:160px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;display:none;flex-direction:column;gap:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:0;display:none;flex-direction:row;gap:0;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
panel.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(panel);
|
||||
|
||||
const nativeColumn = document.createElement("div");
|
||||
css(nativeColumn, "width:160px;padding:4px;display:flex;flex-direction:column;gap:3px;box-sizing:border-box;");
|
||||
panel.appendChild(nativeColumn);
|
||||
|
||||
const linkedColumn = document.createElement("div");
|
||||
css(linkedColumn, "width:160px;padding:4px;display:none;flex-direction:column;gap:3px;box-sizing:border-box;border-left:1px solid #333;");
|
||||
panel.appendChild(linkedColumn);
|
||||
|
||||
let optionsLoaded = false;
|
||||
let hasOptions = null;
|
||||
let flakeData = null;
|
||||
let linkedFlakes = {};
|
||||
|
||||
function rebuildPanel() {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
|
||||
const lorasMeta = flakeData?.loras || [];
|
||||
if (entry.loras.length > lorasMeta.length) {
|
||||
@@ -138,7 +206,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
const opLabel = document.createElement("div");
|
||||
opLabel.textContent = "Output Stem";
|
||||
css(opLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
panel.appendChild(opLabel);
|
||||
nativeColumn.appendChild(opLabel);
|
||||
const opInput = document.createElement("input");
|
||||
opInput.type = "text";
|
||||
opInput.value = outputStem;
|
||||
@@ -148,12 +216,12 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
entry._output_stem_override = opInput.value || null;
|
||||
onChanged();
|
||||
});
|
||||
panel.appendChild(opInput);
|
||||
nativeColumn.appendChild(opInput);
|
||||
|
||||
if (hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (let i = 0; i < lorasMeta.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
@@ -167,14 +235,55 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
panel.appendChild(sliderRow);
|
||||
nativeColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
// Flake options (#465); a choice's own options sit under its group.
|
||||
if (flakeData?.prompt_options?.length) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
const optHost = document.createElement("div");
|
||||
renderOptionToggles(optHost, flakeData.prompt_options, entry, onChanged);
|
||||
nativeColumn.append(sep, optHost);
|
||||
}
|
||||
|
||||
if (hasOptionGroups) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
|
||||
// "All Variants" — generate one output per variant combination (#343).
|
||||
const avRow = document.createElement("label");
|
||||
css(avRow, "display:flex;align-items:center;justify-content:center;gap:6px;font-size:10px;cursor:pointer;");
|
||||
const avChk = document.createElement("input");
|
||||
avChk.type = "checkbox";
|
||||
avChk.checked = !!entry._all_variants;
|
||||
css(avChk, "cursor:pointer;margin:0;");
|
||||
const avLbl = document.createElement("span");
|
||||
avLbl.textContent = "All Variants";
|
||||
avRow.append(avChk, avLbl);
|
||||
nativeColumn.appendChild(avRow);
|
||||
|
||||
// The per-group dropdowns are superseded while All Variants is on.
|
||||
const groupsWrap = document.createElement("div");
|
||||
css(groupsWrap, "display:flex;flex-direction:column;gap:3px;");
|
||||
nativeColumn.appendChild(groupsWrap);
|
||||
// Per-group choice LoRA sliders, hidden while All Variants is on (#413).
|
||||
const variantLoraRenders = [];
|
||||
const applyAv = () => {
|
||||
groupsWrap.style.opacity = entry._all_variants ? "0.4" : "1";
|
||||
groupsWrap.style.pointerEvents = entry._all_variants ? "none" : "auto";
|
||||
variantLoraRenders.forEach(r => r());
|
||||
};
|
||||
avChk.addEventListener("change", () => {
|
||||
entry._all_variants = avChk.checked || undefined;
|
||||
applyAv();
|
||||
rebuildLinkedPanel();
|
||||
onChanged();
|
||||
if (onVariantChange) onVariantChange();
|
||||
});
|
||||
|
||||
for (const group of Object.keys(hasOptions)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
@@ -183,24 +292,185 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
|
||||
const ddOptions = [{ value: "", label: "-" }];
|
||||
for (const ch of hasOptions[group]) {
|
||||
ddOptions.push({ value: ch, label: ch });
|
||||
}
|
||||
const dd = makePanelDropdown(ddOptions, (entry.variant || {})[group] || "");
|
||||
dd.element.addEventListener("change", () => {
|
||||
if (dd.element.value) {
|
||||
// "Multiple ↗" stores an array of choices, picked in an overlay (#406).
|
||||
const defaultChoice = (cachedVariantDefaults(entry.name) || {})[group];
|
||||
const stored = () => (entry.variant || {})[group] || "";
|
||||
// The shown value is a hidden option, so re-picking the listed "Multiple" always fires change (#418).
|
||||
const ddValue = (v) => Array.isArray(v) ? "__multiple_current__" : v === defaultChoice ? "" : v;
|
||||
const multipleLabel = (v) => Array.isArray(v) ? `Multiple (${v.length}) ↗` : "Multiple ↗";
|
||||
const ddOptions = variantDropdownOptions(hasOptions[group], defaultChoice);
|
||||
const noneValue = ddOptions[0].value;
|
||||
ddOptions.push({ value: "__multiple__", label: multipleLabel(stored()) });
|
||||
ddOptions.push({ value: "__multiple_current__", label: multipleLabel(stored()) });
|
||||
const dd = makePanelDropdown(ddOptions, ddValue(stored()));
|
||||
const currentOpt = dd.element.options[dd.element.options.length - 1];
|
||||
currentOpt.hidden = true;
|
||||
const multipleOpt = dd.element.options[dd.element.options.length - 2];
|
||||
// No sliders for a "Multiple" selection (#413).
|
||||
const vlHost = document.createElement("div");
|
||||
const voHost = document.createElement("div");
|
||||
const renderVl = () => {
|
||||
const ch = stored() || defaultChoice;
|
||||
const choice = entry._all_variants || Array.isArray(ch) ? null : ch;
|
||||
renderVariantLoraSliders(vlHost, flakeData?.variants, entry, group, choice, onChanged);
|
||||
renderOptionToggles(voHost, choice && flakeData?.variants?.[group]?.[choice]?.prompt_options, entry, onChanged);
|
||||
};
|
||||
variantLoraRenders.push(renderVl);
|
||||
const store = (v) => {
|
||||
if (v) {
|
||||
entry.variant = entry.variant || {};
|
||||
entry.variant[group] = dd.element.value;
|
||||
entry.variant[group] = v;
|
||||
} else {
|
||||
if (entry.variant) delete entry.variant[group];
|
||||
}
|
||||
multipleOpt.textContent = currentOpt.textContent = multipleLabel(v);
|
||||
dd.element.value = ddValue(v);
|
||||
entry.tags = loraTagNames(flakeData, entry.variant);
|
||||
renderVl();
|
||||
rebuildLinkedPanel();
|
||||
onChanged();
|
||||
if (onVariantChange) onVariantChange();
|
||||
};
|
||||
dd.element.addEventListener("change", async () => {
|
||||
if (dd.element.value !== "__multiple__") return store(dd.element.value);
|
||||
const current = stored() || defaultChoice;
|
||||
const picked = await openVariantMultiPicker({
|
||||
flakeName: entry.name, group, choices: hasOptions[group],
|
||||
selected: Array.isArray(current) ? current : current && current !== "__none__" ? [current] : [],
|
||||
});
|
||||
if (!picked) {
|
||||
dd.element.value = ddValue(stored());
|
||||
return;
|
||||
}
|
||||
store(picked.length > 1 ? picked : picked[0] || noneValue);
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
panel.appendChild(row);
|
||||
row.append(dd.container, vlHost, voHost);
|
||||
groupsWrap.appendChild(row);
|
||||
}
|
||||
applyAv();
|
||||
}
|
||||
|
||||
rebuildLinkedPanel();
|
||||
}
|
||||
|
||||
function rebuildLinkedPanel() {
|
||||
const links = flakeData?.flake_links || (flakeData?.flake_link ? [flakeData.flake_link] : []);
|
||||
// Then the links of the effective variant choices, under a "<group>: <choice>"
|
||||
// heading and overridden in entry.variant_link_overrides (#420).
|
||||
const choiceLinks = entry._all_variants ? [] : choiceFlakeLinks(flakeData?.variants, entry.variant)
|
||||
.filter(([, , cl]) => cl.some(l => linkedFlakes[l?.target]));
|
||||
if (!links.some(l => linkedFlakes[l?.target]) && !choiceLinks.length) {
|
||||
linkedColumn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
linkedColumn.style.display = "flex";
|
||||
linkedColumn.replaceChildren();
|
||||
|
||||
// Migrate the legacy single override into the per-link array (#234).
|
||||
if (!Array.isArray(entry.flake_link_overrides)) {
|
||||
entry.flake_link_overrides = entry.flake_link_override ? [entry.flake_link_override] : [];
|
||||
}
|
||||
|
||||
const renderLink = (link, overrides, li) => {
|
||||
const linkedFlakeData = linkedFlakes[link?.target];
|
||||
if (!linkedFlakeData) return;
|
||||
overrides[li] = overrides[li] || { variant: {}, lora_strengths: [] };
|
||||
const ovr = overrides[li];
|
||||
ovr.variant = ovr.variant || {};
|
||||
ovr.lora_strengths = ovr.lora_strengths || [];
|
||||
const yamlDefaults = link;
|
||||
|
||||
if (li > 0) {
|
||||
const gap = document.createElement("div");
|
||||
css(gap, "border-top:2px solid #444;margin:4px 0;");
|
||||
linkedColumn.appendChild(gap);
|
||||
}
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = (linkedFlakeData.name || link.target).split("/").pop();
|
||||
title.title = `Linked: ${link.target}`;
|
||||
css(title, "font-size:9px;opacity:0.8;text-align:center;font-weight:600;color:#4a9eff;padding:2px 0;");
|
||||
linkedColumn.appendChild(title);
|
||||
|
||||
const linkedLoras = Array.isArray(linkedFlakeData.loras) ? linkedFlakeData.loras : [];
|
||||
if (linkedLoras.length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (let i = 0; i < linkedLoras.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
const name = linkedLoras[i]?.name || "LoRA";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
|
||||
sliderRow.appendChild(label);
|
||||
let initial = ovr.lora_strengths[i];
|
||||
if (initial === null || initial === undefined) initial = (yamlDefaults.lora_strengths || [])[i];
|
||||
if (initial === null || initial === undefined) initial = linkedLoras[i]?.strength ?? 1.0;
|
||||
const strSlider = makeSmallValueSlider(initial, -10, 10, 0.05, (v) => {
|
||||
while (ovr.lora_strengths.length <= i) ovr.lora_strengths.push(null);
|
||||
ovr.lora_strengths[i] = v;
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
linkedColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
const linkedVariants = linkedFlakeData.variants || linkedFlakeData.options || {};
|
||||
if (Object.keys(linkedVariants).length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
const targetDefaults = variantDefaultsOf(linkedVariants);
|
||||
for (const group of Object.keys(linkedVariants)) {
|
||||
const targetDefault = targetDefaults[group];
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
const gLabel = document.createElement("span");
|
||||
gLabel.textContent = group;
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
const yamlChoice = (yamlDefaults.variant || {})[group] || "";
|
||||
const current = ovr.variant[group] || yamlChoice;
|
||||
const dd = makePanelDropdown(variantDropdownOptions(Object.keys(linkedVariants[group]), targetDefault), current === targetDefault ? "" : current);
|
||||
// Choice LoRA sliders: override > link yaml > target default choice (#414).
|
||||
const vlHost = document.createElement("div");
|
||||
const renderVl = () => {
|
||||
const ch = ovr.variant[group] || yamlChoice || targetDefault;
|
||||
renderVariantLoraSliders(vlHost, linkedVariants, ovr, group, ch, onChanged, yamlDefaults.variant_loras?.[group]?.[ch] || []);
|
||||
};
|
||||
// "" (the default entry, or None without a default) follows the link
|
||||
// yaml when it is unset; otherwise it must beat the yaml choice (#419).
|
||||
dd.element.addEventListener("change", () => {
|
||||
const value = dd.element.value || (yamlChoice && (targetDefault || "__none__"));
|
||||
if (value) ovr.variant[group] = value;
|
||||
else delete ovr.variant[group];
|
||||
renderVl();
|
||||
onChanged();
|
||||
});
|
||||
row.append(dd.container, vlHost);
|
||||
linkedColumn.appendChild(row);
|
||||
renderVl();
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedLoras.length === 0 && Object.keys(linkedVariants).length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no overrides";
|
||||
linkedColumn.appendChild(empty);
|
||||
}
|
||||
};
|
||||
links.forEach((link, li) => renderLink(link, entry.flake_link_overrides, li));
|
||||
for (const [group, choice, cl] of choiceLinks) {
|
||||
const heading = document.createElement("div");
|
||||
heading.textContent = `${group}: ${choice}`;
|
||||
css(heading, `font-size:9px;opacity:0.8;text-align:center;font-weight:600;padding:2px 0;${linkedColumn.childElementCount ? "border-top:2px solid #444;margin-top:4px;padding-top:6px;" : ""}`);
|
||||
linkedColumn.appendChild(heading);
|
||||
const overrides = (((entry.variant_link_overrides ||= {})[group] ||= {})[choice] ||= []);
|
||||
cl.forEach((link, li) => renderLink(link, overrides, li));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +481,12 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
optionsLoaded = true;
|
||||
hasOptions = variants;
|
||||
flakeData = fdata;
|
||||
const links = [...(fdata?.flake_links || (fdata?.flake_link ? [fdata.flake_link] : []))];
|
||||
for (const choices of Object.values(fdata?.variants || {})) {
|
||||
for (const choice of Object.values(choices || {})) links.push(...(choice?.flake_links || []));
|
||||
}
|
||||
const targets = [...new Set(links.map(l => l?.target).filter(Boolean))];
|
||||
linkedFlakes = Object.fromEntries(await Promise.all(targets.map(async t => [t, await fetchFlake(t).catch(() => null)])));
|
||||
rebuildPanel();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -243,20 +519,20 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
}
|
||||
|
||||
if (!optionsLoaded && entry.name) {
|
||||
panel.textContent = "";
|
||||
nativeColumn.replaceChildren();
|
||||
const loading = document.createElement("div");
|
||||
css(loading, "font-size:9px;opacity:0.5;text-align:center;padding:4px;");
|
||||
loading.textContent = "loading...";
|
||||
panel.appendChild(loading);
|
||||
nativeColumn.appendChild(loading);
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
} catch {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
const err = document.createElement("div");
|
||||
css(err, "font-size:9px;color:#f88;padding:4px;text-align:center;");
|
||||
err.textContent = "failed to load";
|
||||
panel.appendChild(err);
|
||||
nativeColumn.appendChild(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,9 +548,12 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
return { toggleOptionsPanel };
|
||||
}
|
||||
|
||||
export function setupFlakeComboWidget(node) {
|
||||
// kind "anim": a Flake Animate Combo node, see setupFlakeWidget.
|
||||
export function setupFlakeComboWidget(node, { kind = "img" } = {}) {
|
||||
const familyName = kind === "anim" ? "family" : "model_family";
|
||||
const dataInput = kind === "anim" ? "flake_anim_data" : "flake_data";
|
||||
const flakesHidden = node.widgets?.find(w => w.name === "flakes_json");
|
||||
const familyWidget = node.widgets?.find(w => w.name === "model_family");
|
||||
const familyWidget = node.widgets?.find(w => w.name === familyName);
|
||||
if (!flakesHidden) return;
|
||||
|
||||
flakesHidden.computeSize = () => [0, -4];
|
||||
@@ -287,6 +566,43 @@ export function setupFlakeComboWidget(node) {
|
||||
return familyWidget?.value || "SDXL/Base";
|
||||
}
|
||||
|
||||
// Hide model_family widget when flake_data input is connected. See
|
||||
// flake-stack.js for full rationale; uses setWidgetHidden() from utils.js
|
||||
// which stacks every known hide signal across LiteGraph + modern frontends.
|
||||
let _familyHidden = false;
|
||||
function _inferUpstreamFamily() {
|
||||
const flakeDataInput = node.inputs?.find(i => i.name === dataInput);
|
||||
if (!flakeDataInput || flakeDataInput.link == null) return null;
|
||||
const link = node.graph?.links?.[flakeDataInput.link];
|
||||
if (!link) return null;
|
||||
const upstreamNode = node.graph.getNodeById(link.origin_id);
|
||||
if (!upstreamNode) return null;
|
||||
const upstreamFamily = upstreamNode.widgets?.find(w => w.name === familyName);
|
||||
return upstreamFamily?.value || null;
|
||||
}
|
||||
function _isFlakeDataConnected() {
|
||||
const flakeDataInput = node.inputs?.find(i => i.name === dataInput);
|
||||
return !!(flakeDataInput && flakeDataInput.link != null);
|
||||
}
|
||||
function _updateFamilyVisibility() {
|
||||
if (!familyWidget) return;
|
||||
// Hide whenever the input pin is connected — not only when the upstream
|
||||
// family is inferable. Otherwise a connected-but-uninferable upstream
|
||||
// leaves the widget visible-but-disabled (#261).
|
||||
const shouldHide = _isFlakeDataConnected();
|
||||
const upstream = _inferUpstreamFamily();
|
||||
if (upstream !== null && upstream !== familyWidget.value) {
|
||||
familyWidget.value = upstream;
|
||||
if (familyWidget.callback) familyWidget.callback(upstream);
|
||||
}
|
||||
if (shouldHide === _familyHidden) return;
|
||||
_familyHidden = shouldHide;
|
||||
setWidgetHidden(familyWidget, shouldHide);
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
const _familyPoll = setInterval(_updateFamilyVisibility, 200);
|
||||
setTimeout(_updateFamilyVisibility, 0);
|
||||
|
||||
if (!node.properties) node.properties = {};
|
||||
if (!node.properties._combo_flakes) node.properties._combo_flakes = [];
|
||||
if (node.properties._combo_active_index == null) node.properties._combo_active_index = 0;
|
||||
@@ -303,6 +619,7 @@ export function setupFlakeComboWidget(node) {
|
||||
delete entry.option;
|
||||
}
|
||||
if (!entry.variant) entry.variant = {};
|
||||
if (!entry._uid) entry._uid = newItemUid();
|
||||
}
|
||||
return flakes;
|
||||
}
|
||||
@@ -320,8 +637,28 @@ export function setupFlakeComboWidget(node) {
|
||||
const container = document.createElement("div");
|
||||
css(container, "display:flex;flex-direction:column;gap:2px;padding:0 6px 3px 6px;font-size:12px;color:#ddd;");
|
||||
|
||||
// Toggle-all button (#315): activate/deactivate every flake in the combo at
|
||||
// once. Matches the Generation Data button via the shared helper, centered (#338).
|
||||
const { btn: toggleAll, lbl: toggleAllLbl } = makeAccentButton("Activate all");
|
||||
toggleAll.style.alignSelf = "center";
|
||||
toggleAll.style.margin = "2px 0";
|
||||
function refreshToggleAll() {
|
||||
const arr = readAllFlakes();
|
||||
const allActive = arr.length > 0 && arr.every(e => !e.bypassed);
|
||||
toggleAllLbl.textContent = allActive ? "Deactivate all" : "Activate all";
|
||||
}
|
||||
toggleAll.addEventListener("click", () => {
|
||||
const arr = readAllFlakes();
|
||||
const allActive = arr.length > 0 && arr.every(e => !e.bypassed);
|
||||
arr.forEach(e => { e.bypassed = allActive; });
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
});
|
||||
container.appendChild(toggleAll);
|
||||
|
||||
const [itemW, itemH] = ITEM_SIZE[kind];
|
||||
const grid = document.createElement("div");
|
||||
css(grid, "display:grid;grid-template-columns:repeat(auto-fill, minmax(72px, 1fr));gap:4px;");
|
||||
css(grid, `display:grid;grid-template-columns:repeat(auto-fill, minmax(${itemW}px, 1fr));gap:4px;`);
|
||||
container.appendChild(grid);
|
||||
|
||||
let dragSrcIdx = null;
|
||||
@@ -329,26 +666,31 @@ export function setupFlakeComboWidget(node) {
|
||||
function render() {
|
||||
const flakes = readAllFlakes();
|
||||
const activeIdx = node.properties._combo_active_index || 0;
|
||||
const generatingIdx = node._combo_generating_index;
|
||||
const generatingUid = getGeneratingUid(node);
|
||||
grid.replaceChildren();
|
||||
|
||||
for (let i = 0; i < flakes.length; i++) {
|
||||
const { block: blk, triangleBtn, refreshVisuals } = makeComboBlock({
|
||||
entry: flakes[i],
|
||||
idx: i,
|
||||
kind,
|
||||
isActive: i === activeIdx,
|
||||
isGenerating: generatingIdx != null && i === generatingIdx,
|
||||
isGenerating: generatingUid != null && flakes[i]._uid === generatingUid,
|
||||
onEdit: handleEdit,
|
||||
onRemove: handleRemove,
|
||||
onReplace: handleReplace,
|
||||
onToggleBypass: handleToggleBypass,
|
||||
onSingleOut: handleSingleOut,
|
||||
onDuplicate: handleDuplicate,
|
||||
onImagesChanged: () => { writeAllFlakes(flakes); render(); },
|
||||
onDragStart: (e, idx, el) => {
|
||||
dragSrcIdx = idx;
|
||||
Object.assign(flakeDrag, { node, entry: flakes[idx], take: () => takeDragged(idx), kind });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
el.style.opacity = "0.4";
|
||||
},
|
||||
onDragOver: (e, idx, el) => {
|
||||
if (dragSrcIdx === null || idx === dragSrcIdx) return;
|
||||
if (!isForeignDrag() && (dragSrcIdx === null || idx === dragSrcIdx)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
_showDropIndicator(el);
|
||||
@@ -356,6 +698,10 @@ export function setupFlakeComboWidget(node) {
|
||||
onDrop: (e, idx, el) => {
|
||||
e.preventDefault();
|
||||
_hideDropIndicator(el);
|
||||
if (isForeignDrag()) {
|
||||
dropForeign(idx);
|
||||
return;
|
||||
}
|
||||
if (dragSrcIdx === null || idx === dragSrcIdx) return;
|
||||
const arr = readAllFlakes();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
@@ -374,6 +720,7 @@ export function setupFlakeComboWidget(node) {
|
||||
onDragEnd: (el) => {
|
||||
el.style.opacity = "";
|
||||
dragSrcIdx = null;
|
||||
clearFlakeDrag();
|
||||
_hideAllDropIndicators();
|
||||
},
|
||||
});
|
||||
@@ -382,6 +729,38 @@ export function setupFlakeComboWidget(node) {
|
||||
}
|
||||
|
||||
if (grid._addBlock) grid.appendChild(grid._addBlock);
|
||||
refreshToggleAll();
|
||||
}
|
||||
|
||||
// Cross-node drag & drop with other Flake Stack / Flake Combo grids (#415).
|
||||
function isForeignDrag() {
|
||||
return flakeDrag.node !== null && flakeDrag.node !== node && flakeDrag.kind === kind;
|
||||
}
|
||||
|
||||
// The source block leaves the DOM on re-render, so its dragend may never fire.
|
||||
function takeDragged(idx) {
|
||||
const arr = readAllFlakes();
|
||||
arr.splice(idx, 1);
|
||||
if (node.properties._combo_active_index > idx) node.properties._combo_active_index--;
|
||||
if (node.properties._combo_active_index >= arr.length) {
|
||||
node.properties._combo_active_index = Math.max(0, arr.length - 1);
|
||||
}
|
||||
writeAllFlakes(arr);
|
||||
dragSrcIdx = null;
|
||||
clearFlakeDrag();
|
||||
render();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function dropForeign(at) {
|
||||
const entry = JSON.parse(JSON.stringify(flakeDrag.entry));
|
||||
entry._uid = newItemUid();
|
||||
const arr = readAllFlakes();
|
||||
if (arr.length && node.properties._combo_active_index >= at) node.properties._combo_active_index++;
|
||||
arr.splice(at, 0, entry);
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
flakeDrag.take();
|
||||
}
|
||||
|
||||
async function handleEdit(idx) {
|
||||
@@ -395,12 +774,13 @@ export function setupFlakeComboWidget(node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { directories } = await fetchList(getFamily());
|
||||
const { directories } = await fetchList(getFamily(), kind);
|
||||
const result = await openEditModal({
|
||||
mode: "edit",
|
||||
name: entry.name,
|
||||
data,
|
||||
dirs: directories,
|
||||
kind,
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
@@ -420,8 +800,10 @@ export function setupFlakeComboWidget(node) {
|
||||
if (result.name && result.name !== arr[idx].name) arr[idx].name = result.name;
|
||||
arr[idx]._edited_at = Date.now();
|
||||
arr[idx].flake_type = result.data?.flake_type || null;
|
||||
arr[idx].favorite = !!result.data?.favorite;
|
||||
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
|
||||
arr[idx].display_name = result.data?.name || null;
|
||||
arr[idx].tags = loraTagNames(result.data, arr[idx].variant);
|
||||
const newLoras = Array.isArray(result.data?.loras)
|
||||
? result.data.loras.map(l => l?.strength ?? 1.0)
|
||||
: (result.data?.path ? [result.data.strength ?? 1.0] : []);
|
||||
@@ -454,35 +836,58 @@ export function setupFlakeComboWidget(node) {
|
||||
render();
|
||||
}
|
||||
|
||||
function handleDuplicate(idx) {
|
||||
const arr = readAllFlakes();
|
||||
const copy = JSON.parse(JSON.stringify(arr[idx]));
|
||||
copy._uid = newItemUid();
|
||||
arr.splice(idx + 1, 0, copy);
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
// Hold the activation checkbox to single out: enable only this flake,
|
||||
// disable every other one in the combo (#281).
|
||||
function handleSingleOut(idx) {
|
||||
const arr = readAllFlakes();
|
||||
arr.forEach((e, i) => { e.bypassed = i !== idx; });
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleReplace(idx) {
|
||||
const { flakes, directories, display_names } = await fetchList(getFamily());
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names });
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList(getFamily(), kind);
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), kind, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!result || !result.name) return;
|
||||
const arr = readAllFlakes();
|
||||
let has_lora = false;
|
||||
let display_name = null;
|
||||
let flake_type = null;
|
||||
let favorite = false;
|
||||
let loras = [];
|
||||
let tags = [];
|
||||
try {
|
||||
const d = await fetchFlake(result.name);
|
||||
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
|
||||
display_name = d.name || null;
|
||||
flake_type = d.flake_type || null;
|
||||
favorite = !!d.favorite;
|
||||
tags = loraTagNames(d);
|
||||
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
|
||||
else if (d.path) loras = [d.strength ?? 1.0];
|
||||
} catch {}
|
||||
arr[idx] = { ...arr[idx], name: result.name, loras, variant: {}, has_lora, display_name, flake_type };
|
||||
arr[idx] = { ...arr[idx], name: result.name, loras, variant: {}, variant_loras: undefined, option_overrides: undefined, has_lora, display_name, flake_type, favorite, tags };
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleNew() {
|
||||
const { directories } = await fetchList(getFamily());
|
||||
const { directories } = await fetchList(getFamily(), kind);
|
||||
const result = await openEditModal({
|
||||
mode: "create",
|
||||
data: {},
|
||||
dirs: directories,
|
||||
family: getFamily(),
|
||||
kind,
|
||||
});
|
||||
if (!result || !result.created) return;
|
||||
const arr = readAllFlakes();
|
||||
@@ -510,37 +915,66 @@ export function setupFlakeComboWidget(node) {
|
||||
flake_type = flake_type || d.flake_type || null;
|
||||
} catch {}
|
||||
}
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite: !!result.data?.favorite, tags: loraTagNames(result.data) });
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleLoad() {
|
||||
const { flakes, directories, display_names } = await fetchList(getFamily());
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names });
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList(getFamily(), kind);
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), kind, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!result || !result.name) return;
|
||||
await addFlake(result.name);
|
||||
}
|
||||
|
||||
// Animate grids: copy a picked image flake into the node's family, edit
|
||||
// the copy, then add it like a loaded flake (#433).
|
||||
async function handleImport() {
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList("", "img");
|
||||
const picked = await openFileLoadPicker({ flakes, directories, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!picked || !picked.name) return;
|
||||
let name;
|
||||
let data;
|
||||
try {
|
||||
name = await importAnimFlakeApi(picked.name, getFamily());
|
||||
data = await fetchFlake(name);
|
||||
} catch (err) {
|
||||
window.alert(`Failed to import ${picked.name}: ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
const result = await openEditModal({ mode: "edit", name, data, dirs: (await fetchList(getFamily(), kind)).directories, kind });
|
||||
if (result?.deleted) return;
|
||||
await addFlake(result?.name || name);
|
||||
}
|
||||
|
||||
async function addFlake(name) {
|
||||
const arr = readAllFlakes();
|
||||
let has_lora = false;
|
||||
let display_name = null;
|
||||
let flake_type = null;
|
||||
let favorite = false;
|
||||
let loras = [];
|
||||
let tags = [];
|
||||
try {
|
||||
const d = await fetchFlake(result.name);
|
||||
const d = await fetchFlake(name);
|
||||
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
|
||||
display_name = d.name || null;
|
||||
flake_type = d.flake_type || null;
|
||||
favorite = !!d.favorite;
|
||||
tags = loraTagNames(d);
|
||||
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
|
||||
else if (d.path) loras = [d.strength ?? 1.0];
|
||||
} catch {}
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
|
||||
arr.push({ name, loras, variant: {}, has_lora, display_name, flake_type, favorite, tags });
|
||||
writeAllFlakes(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
grid._addBlock = makeAddBlock({ onNew: handleNew, onLoad: handleLoad });
|
||||
grid._addBlock = makeAddBlock({ onNew: handleNew, onLoad: handleLoad, onImport: kind === "anim" ? handleImport : null });
|
||||
grid._addBlock.style.height = `${itemH}px`;
|
||||
|
||||
grid._addBlock.addEventListener("dragover", (e) => {
|
||||
if (dragSrcIdx === null) return;
|
||||
if (dragSrcIdx === null && !isForeignDrag()) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
_showDropIndicator(grid._addBlock);
|
||||
@@ -551,6 +985,10 @@ export function setupFlakeComboWidget(node) {
|
||||
grid._addBlock.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
_hideDropIndicator(grid._addBlock);
|
||||
if (isForeignDrag()) {
|
||||
dropForeign(readAllFlakes().length);
|
||||
return;
|
||||
}
|
||||
if (dragSrcIdx === null) return;
|
||||
const arr = readAllFlakes();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
@@ -572,9 +1010,18 @@ export function setupFlakeComboWidget(node) {
|
||||
node._combo_render = render;
|
||||
const comboWidget = node.addDOMWidget("combo_ui", "div", container, { serialize: false, margin: 4 });
|
||||
comboWidget.computeSize = () => {
|
||||
const rows = Math.max(1, Math.ceil((readAllFlakes().length + 1) / 2));
|
||||
return [node.size[0], rows * 84 + 31];
|
||||
// Match the actual auto-fill column count (minmax item width + 4px gap) so a
|
||||
// wide node doesn't over-count rows and pad itself with empty space (#317).
|
||||
const cols = Math.max(1, Math.floor((node.size[0] - 16) / (itemW + 4)));
|
||||
const rows = Math.max(1, Math.ceil((readAllFlakes().length + 1) / cols));
|
||||
return [node.size[0], rows * (itemH + 4) + 55]; // +24 for the toggle-all button (#315)
|
||||
};
|
||||
updateActiveFlake();
|
||||
render();
|
||||
|
||||
const origOnRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
clearInterval(_familyPoll);
|
||||
return origOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
@@ -39,12 +39,17 @@ export const DEFAULT_INTO_PINS = [];
|
||||
// Manages output pin visibility. State lives in node.properties._split_pins.
|
||||
|
||||
function syncSplitOutputs(node, selected) {
|
||||
if (!node.outputs || !node.outputs.length) return;
|
||||
for (let i = 0; i < node.outputs.length; i++) {
|
||||
const out = node.outputs[i];
|
||||
const pinDef = ALL_SPLIT_PINS.find(p => p.name === out.name);
|
||||
if (!pinDef) continue;
|
||||
const shouldHide = !selected.includes(out.name);
|
||||
if (shouldHide && !out.hidden) node.disconnectOutputs(i);
|
||||
if (shouldHide && !out.hidden && out.links && out.links.length) {
|
||||
for (const linkId of [...out.links]) {
|
||||
node.graph?.removeLink?.(linkId);
|
||||
}
|
||||
}
|
||||
out.hidden = shouldHide;
|
||||
}
|
||||
node.setDirtyCanvas(true, true);
|
||||
@@ -65,7 +70,19 @@ export function setupFlakeDataSplitSelect(node) {
|
||||
}
|
||||
node.properties._split_pins = [...selected];
|
||||
|
||||
syncSplitOutputs(node, selected);
|
||||
// Delay sync until outputs are populated (new ComfyUI frontend may create
|
||||
// outputs after onNodeCreated).
|
||||
if (node.outputs && node.outputs.length) {
|
||||
syncSplitOutputs(node, selected);
|
||||
} else {
|
||||
const checkOutputs = setInterval(() => {
|
||||
if (node.outputs && node.outputs.length) {
|
||||
clearInterval(checkOutputs);
|
||||
syncSplitOutputs(node, selected);
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => clearInterval(checkOutputs), 2000);
|
||||
}
|
||||
|
||||
// ── UI ──
|
||||
const container = document.createElement("div");
|
||||
@@ -181,22 +198,29 @@ const OPTIONAL_WIDGET_NAMES = new Set([
|
||||
"filename_prefix", "width", "height", "steps", "cfg", "sampler_name", "scheduler"
|
||||
]);
|
||||
|
||||
function hideIntoWidgets(node) {
|
||||
function removeIntoWidgets(node) {
|
||||
if (!node.widgets) return;
|
||||
for (const w of node.widgets) {
|
||||
for (let i = node.widgets.length - 1; i >= 0; i--) {
|
||||
const w = node.widgets[i];
|
||||
if (!OPTIONAL_WIDGET_NAMES.has(w.name)) continue;
|
||||
w.computeSize = () => [0, -4];
|
||||
w.type = "hidden";
|
||||
w.hidden = true;
|
||||
if (w.element) { w.element.style.display = "none"; }
|
||||
if (w.element) { w.element.remove(); w.element = null; }
|
||||
if (w.inputEl) { w.inputEl.remove(); w.inputEl = null; }
|
||||
node.widgets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function setupIntoFlakeDataSelect(node) {
|
||||
if (!node.properties) node.properties = {};
|
||||
if (node._intoSetupDone) {
|
||||
node._intoPinUpdate?.();
|
||||
return;
|
||||
}
|
||||
node._intoSetupDone = true;
|
||||
|
||||
// Hide auto-created widgets for optional fields — pins are managed dynamically.
|
||||
hideIntoWidgets(node);
|
||||
// Fully remove auto-created widgets for optional fields — pins are managed dynamically.
|
||||
// (Previously these were hidden but the canvas-drawn widgets still caught clicks,
|
||||
// hijacking clicks meant for the dropdown / + button.)
|
||||
removeIntoWidgets(node);
|
||||
|
||||
// Remove any pre-existing optional inputs that Python adds automatically.
|
||||
// Keep only flake_data.
|
||||
|
||||
+164
-70
@@ -1,35 +1,92 @@
|
||||
import { css } from "../utils.js";
|
||||
import { computeJobCount } from "../queue.js";
|
||||
import { css, setWidgetHidden, makeImageSection } from "../utils.js";
|
||||
import { computeJobCount, getBatchImages, getBatchProgress, getLivePreview } from "../queue.js";
|
||||
import { buildModel, openGenerationDataOverlay } from "./generation-data.js";
|
||||
|
||||
const ACCENT = "#4a9eff";
|
||||
|
||||
export function setupFlakeGenerateWidget(node) {
|
||||
// `video`: Flake Generate Animation, whose results are videos read from
|
||||
// flake_videos and whose chain comes in on flake_anim_data (#434).
|
||||
export function setupFlakeGenerateWidget(node, { video = false } = {}) {
|
||||
if (!node.properties) node.properties = {};
|
||||
|
||||
// Conditional optional widgets (#306): each toggle's sub-widgets only show
|
||||
// when it is on. The animation node has its own toggles (#438).
|
||||
const findWidget = (name) => node.widgets?.find((x) => x.name === name);
|
||||
const DEPS = video ? {
|
||||
upscale: ["upscale_model"],
|
||||
interpolate: ["interpolation_model"],
|
||||
use_headroom: ["headroom"],
|
||||
} : {
|
||||
adetailer: ["adetailer_denoise", "adetailer_steps", "adetailer_bbox"],
|
||||
upscale: ["upscale_model", "upscale_factor"],
|
||||
};
|
||||
function syncOptionalWidgets() {
|
||||
for (const [toggle, deps] of Object.entries(DEPS)) {
|
||||
const value = findWidget(toggle)?.value;
|
||||
const on = toggle === "adetailer" ? (value || "Off") !== "Off" : !!value;
|
||||
for (const n of deps) setWidgetHidden(findWidget(n), !on);
|
||||
}
|
||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||
node.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
for (const toggle of Object.keys(DEPS)) {
|
||||
const tw = findWidget(toggle);
|
||||
if (!tw) continue;
|
||||
const cb = tw.callback;
|
||||
tw.callback = function () {
|
||||
const r = cb?.apply(this, arguments);
|
||||
syncOptionalWidgets();
|
||||
return r;
|
||||
};
|
||||
}
|
||||
// Defer once so all native widgets exist before first sync.
|
||||
setTimeout(syncOptionalWidgets, 0);
|
||||
// The queue sets each job's auto-caption here (#484).
|
||||
if (video) setWidgetHidden(findWidget("caption"), true);
|
||||
|
||||
const container = document.createElement("div");
|
||||
css(container, "display:flex;flex-direction:column;gap:4px;padding:4px 6px;font-size:12px;color:#ddd;");
|
||||
|
||||
// ── Jobs label + Generation Data button (below the Seed section) ──
|
||||
// ── Jobs label (+ done label) + Generation Data button (below the Seed section) ──
|
||||
const topRow = document.createElement("div");
|
||||
css(topRow, "display:flex;align-items:center;gap:8px;font-size:12px;color:#ddd;min-height:36px;");
|
||||
|
||||
const jobsCol = document.createElement("div");
|
||||
css(jobsCol, "display:flex;flex-direction:column;gap:1px;flex:1;min-width:0;");
|
||||
topRow.appendChild(jobsCol);
|
||||
|
||||
const jobsLabel = document.createElement("span");
|
||||
css(jobsLabel, "font-size:11px;font-weight:600;color:" + ACCENT + ";flex-shrink:0;");
|
||||
topRow.appendChild(jobsLabel);
|
||||
css(jobsLabel, "font-size:11px;font-weight:600;color:" + ACCENT + ";");
|
||||
jobsCol.appendChild(jobsLabel);
|
||||
|
||||
const doneLabel = document.createElement("span");
|
||||
css(doneLabel, "font-size:10px;color:#888;");
|
||||
jobsCol.appendChild(doneLabel);
|
||||
|
||||
let lastJobCount = -1;
|
||||
function refreshJobCount() {
|
||||
const n = computeJobCount(node.graph);
|
||||
if (n === lastJobCount) return;
|
||||
lastJobCount = n;
|
||||
jobsLabel.textContent = `Jobs: ${n}`;
|
||||
if (n !== lastJobCount) {
|
||||
lastJobCount = n;
|
||||
jobsLabel.textContent = `Total jobs: ${n}`;
|
||||
}
|
||||
}
|
||||
function renderBatchProgress() {
|
||||
const progress = getBatchProgress();
|
||||
if (progress?.total > 0) {
|
||||
doneLabel.textContent = progress.captioning ? "captioning…" : `[${progress.completed}/${progress.total}] done`;
|
||||
doneLabel.style.display = "block";
|
||||
} else {
|
||||
doneLabel.style.display = "none";
|
||||
}
|
||||
}
|
||||
refreshJobCount();
|
||||
const jobsPoll = setInterval(refreshJobCount, 200);
|
||||
renderBatchProgress();
|
||||
const jobsPoll = setInterval(() => { refreshJobCount(); renderBatchProgress(); }, 200);
|
||||
|
||||
// Generation Data button — width fits content (no flex:1) per #227.
|
||||
const genDataBtn = document.createElement("div");
|
||||
css(genDataBtn, `flex:1;background:#222;border:1px solid ${ACCENT}44;border-radius:8px;padding:8px 6px;cursor:pointer;transition:border-color 0.15s,background 0.15s;display:flex;align-items:center;justify-content:center;gap:6px;min-height:30px;box-sizing:border-box;`);
|
||||
css(genDataBtn, `flex:0 0 auto;background:#222;border:1px solid ${ACCENT}44;border-radius:8px;padding:6px 10px;cursor:pointer;transition:border-color 0.15s,background 0.15s;display:inline-flex;align-items:center;gap:6px;min-height:30px;box-sizing:border-box;white-space:nowrap;`);
|
||||
const gdIcon = document.createElement("span");
|
||||
gdIcon.textContent = "📊";
|
||||
gdIcon.style.fontSize = "14px";
|
||||
@@ -40,42 +97,49 @@ export function setupFlakeGenerateWidget(node) {
|
||||
genDataBtn.appendChild(gdLabel);
|
||||
genDataBtn.addEventListener("mouseenter", () => { genDataBtn.style.borderColor = ACCENT; genDataBtn.style.background = "#282828"; });
|
||||
genDataBtn.addEventListener("mouseleave", () => { genDataBtn.style.borderColor = ACCENT + "44"; genDataBtn.style.background = "#222"; });
|
||||
genDataBtn.addEventListener("click", (e) => {
|
||||
genDataBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const flakeInput = node.inputs?.find(i => i.name === "flake_data");
|
||||
const flakeInput = node.inputs?.find(i => i.name === (video ? "flake_anim_data" : "flake_data"));
|
||||
const graph = node.graph;
|
||||
let startNode = null;
|
||||
if (flakeInput?.link && graph) {
|
||||
const link = graph.links[flakeInput.link];
|
||||
if (link) startNode = graph.getNodeById(link.origin_id);
|
||||
}
|
||||
const model = startNode ? buildModel(startNode) : { axes: [], fixed: { presetName: null, stackFlakes: [] } };
|
||||
openGenerationDataOverlay(model, node.properties._images_by_combo || {});
|
||||
// buildModel is async (fetches variant meta for All Variants cards, #345).
|
||||
const model = startNode ? await buildModel(startNode) : { axes: [], fixed: { presetName: null, stackFlakes: [] } };
|
||||
const adetailer = findWidget("adetailer")?.value || "Off";
|
||||
openGenerationDataOverlay(model, comboImages, {
|
||||
adetailer,
|
||||
adetailerDenoise: findWidget("adetailer_denoise")?.value,
|
||||
adetailerSteps: findWidget("adetailer_steps")?.value,
|
||||
adetailerBbox: findWidget("adetailer_bbox")?.value,
|
||||
upscale: !!findWidget("upscale")?.value,
|
||||
upscaleModel: findWidget("upscale_model")?.value,
|
||||
upscaleFactor: findWidget("upscale_factor")?.value,
|
||||
video,
|
||||
});
|
||||
});
|
||||
topRow.appendChild(genDataBtn);
|
||||
container.appendChild(topRow);
|
||||
|
||||
// ── Image Display ──
|
||||
const imageContainer = document.createElement("div");
|
||||
css(imageContainer, "display:flex;flex-direction:column;align-items:center;gap:4px;margin-top:4px;");
|
||||
|
||||
const imageEl = document.createElement("img");
|
||||
css(imageEl, "max-width:100%;border-radius:4px;display:none;");
|
||||
imageContainer.appendChild(imageEl);
|
||||
|
||||
const noImageLabel = document.createElement("div");
|
||||
css(noImageLabel, "font-size:11px;opacity:0.4;text-align:center;padding:8px 0;");
|
||||
noImageLabel.textContent = "No image generated yet";
|
||||
imageContainer.appendChild(noImageLabel);
|
||||
|
||||
// Read-only filename label (filename only, full path in tooltip) + dimensions
|
||||
const filenameLabel = document.createElement("div");
|
||||
css(filenameLabel, "width:100%;box-sizing:border-box;color:#ccc;padding:0 4px;font-size:12px;font-family:inherit;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none;cursor:default;");
|
||||
imageContainer.appendChild(filenameLabel);
|
||||
|
||||
const dimensionsLabel = document.createElement("div");
|
||||
css(dimensionsLabel, "width:100%;box-sizing:border-box;color:#888;padding:0 4px;font-size:11px;font-family:inherit;text-align:center;display:none;");
|
||||
imageContainer.appendChild(dimensionsLabel);
|
||||
// Double-click the output image to reveal it in the OS file explorer (#329).
|
||||
let currentImage = null;
|
||||
let previewUrl = null; // live sampling preview shown instead of currentImage (#374)
|
||||
const reveal = () => {
|
||||
if (!currentImage) return;
|
||||
fetch("/flakes/reveal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: currentImage.filename,
|
||||
subfolder: currentImage.subfolder || "",
|
||||
type: currentImage.type || "output",
|
||||
}),
|
||||
}).catch((e) => console.error("[flakes] reveal failed", e));
|
||||
};
|
||||
const section = makeImageSection({ video, emptyText: video ? "No video generated yet" : "No image generated yet", onReveal: reveal });
|
||||
|
||||
function outputRelPath(img) {
|
||||
if (!img || (img.type && img.type !== "output")) return "";
|
||||
@@ -84,38 +148,59 @@ export function setupFlakeGenerateWidget(node) {
|
||||
}
|
||||
|
||||
function showImage(img) {
|
||||
imageEl.src = `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || "output"}&subfolder=${encodeURIComponent(img.subfolder || "")}`;
|
||||
imageEl.style.display = "block";
|
||||
noImageLabel.style.display = "none";
|
||||
currentImage = img;
|
||||
previewUrl = null;
|
||||
const rel = outputRelPath(img);
|
||||
if (rel) {
|
||||
const fileName = img.filename;
|
||||
filenameLabel.textContent = fileName;
|
||||
filenameLabel.title = rel;
|
||||
filenameLabel.style.display = "block";
|
||||
} else {
|
||||
filenameLabel.style.display = "none";
|
||||
}
|
||||
imageEl.onload = () => {
|
||||
if (imageEl.naturalWidth && imageEl.naturalHeight) {
|
||||
dimensionsLabel.textContent = `${imageEl.naturalWidth} × ${imageEl.naturalHeight}`;
|
||||
dimensionsLabel.style.display = "block";
|
||||
}
|
||||
};
|
||||
if (imageEl.complete && imageEl.naturalWidth) {
|
||||
dimensionsLabel.textContent = `${imageEl.naturalWidth} × ${imageEl.naturalHeight}`;
|
||||
dimensionsLabel.style.display = "block";
|
||||
}
|
||||
section.show(`/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || "output"}&subfolder=${encodeURIComponent(img.subfolder || "")}`, rel && img.filename, rel);
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
imageEl.style.display = "none";
|
||||
noImageLabel.style.display = "block";
|
||||
filenameLabel.style.display = "none";
|
||||
dimensionsLabel.style.display = "none";
|
||||
previewUrl = null;
|
||||
section.clear();
|
||||
}
|
||||
|
||||
container.appendChild(imageContainer);
|
||||
// Live sampling preview (#374), shown until the result replaces it.
|
||||
function showPreview(url) {
|
||||
if (url === previewUrl) return;
|
||||
previewUrl = url;
|
||||
currentImage = null;
|
||||
section.showPreview(url);
|
||||
}
|
||||
|
||||
container.appendChild(section.el);
|
||||
|
||||
// Per-combo results: the live batch state when this workflow ran a batch,
|
||||
// else what the saved workflow carried (#373).
|
||||
// `preview` is the live sampling preview ({ url, comboKey }) or null (#374).
|
||||
function comboImages() {
|
||||
const images = getBatchImages(node) || {
|
||||
combo: node.properties._images_by_combo || {},
|
||||
ad: node.properties._images_by_combo_ad || {},
|
||||
ab: !!node.properties._adetailer_ab,
|
||||
};
|
||||
return { ...images, preview: getLivePreview(node) };
|
||||
}
|
||||
|
||||
// Called by queue.js when batch results or the live preview change, and
|
||||
// after a workflow load. Mirrors batch results into properties so they are
|
||||
// saved with the workflow.
|
||||
node._flake_generate_render = function () {
|
||||
const entry = getBatchImages(node);
|
||||
if (entry) {
|
||||
node.properties._images_by_combo = entry.combo;
|
||||
node.properties._images_by_combo_ad = entry.ad;
|
||||
node.properties._adetailer_ab = entry.ab;
|
||||
if (entry.last) node.properties._last_image = entry.last;
|
||||
}
|
||||
const preview = getLivePreview(node);
|
||||
if (preview) {
|
||||
showPreview(preview.url);
|
||||
return;
|
||||
}
|
||||
const img = node.properties._last_image;
|
||||
if (img && (previewUrl || img !== currentImage)) showImage(img);
|
||||
else if (!img && previewUrl) clearImage();
|
||||
};
|
||||
|
||||
// ── Add DOM widget ──
|
||||
const genWidget = node.addDOMWidget("flake_generate_ui", "div", container, { serialize: false, margin: 4 });
|
||||
@@ -129,17 +214,26 @@ export function setupFlakeGenerateWidget(node) {
|
||||
const origOnExecuted = node.onExecuted;
|
||||
node.onExecuted = function (output) {
|
||||
const r = origOnExecuted?.apply(this, arguments);
|
||||
if (output && output.flake_images && output.flake_images.length > 0) {
|
||||
const img = output.flake_images[0];
|
||||
const files = video ? output?.flake_videos : output?.flake_images;
|
||||
if (files && files.length > 0) {
|
||||
const img = output.flake_caption?.[0] ? { ...files[0], caption: output.flake_caption[0] } : files[0];
|
||||
showImage(img);
|
||||
node.properties._last_image = img;
|
||||
// Record the generated image keyed by the active combination (set
|
||||
// by queue.js before this prompt) so the Generation Data overlay
|
||||
// can show it for that combination. Empty key = no combo nodes.
|
||||
const comboKey = node._pending_combination_key || "";
|
||||
node.properties._images_by_combo = node.properties._images_by_combo || {};
|
||||
node.properties._images_by_combo[comboKey] = img;
|
||||
} else if (output && output.flake_images) {
|
||||
// Store ADetailer A/B outputs when present (#328).
|
||||
const isAB = Array.isArray(output.adetailer_ab) && output.adetailer_ab.length > 0;
|
||||
if (isAB && Array.isArray(output.flake_images_ad) && output.flake_images_ad.length > 0) {
|
||||
node.properties._last_image_ad = output.flake_images_ad[0];
|
||||
node.properties._adetailer_ab = true;
|
||||
} else {
|
||||
node.properties._last_image_ad = null;
|
||||
node.properties._adetailer_ab = false;
|
||||
}
|
||||
// Batch results are stored per combination by queue.js (from the
|
||||
// `executed` event, which carries the prompt_id). A plain run
|
||||
// replaces them with its own result under the empty key, so the
|
||||
// overlay shows it once its live preview ends (#374).
|
||||
if (!getBatchImages(node)) node.properties._images_by_combo = { "": img };
|
||||
} else if (files) {
|
||||
clearImage();
|
||||
}
|
||||
return r;
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import {
|
||||
css, svgIcon, makeGridItemOverlay, makeHoverButton,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators,
|
||||
css, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeBypassStrike,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, attachHoldToSingleOut, makeAddBlock,
|
||||
makeModelOverridePanel, serializeModelOverrides, isLocalName, markLocal, SAVE_TO_DISK_SVG, attachFamilyInput,
|
||||
} from "../utils.js";
|
||||
import { saveLocalToDisk } from "../bundle.js";
|
||||
import { openPresetPicker } from "../pickers.js";
|
||||
import { fetchPreset } from "../api.js";
|
||||
import { openPresetEditModal, refreshPresetOptions } from "../preset-modal.js";
|
||||
import { fetchPreset, getPresetCoverUrl, THUMB } from "../api.js";
|
||||
import { getGeneratingUid, newItemUid } from "../queue.js";
|
||||
|
||||
export function makeModelComboBlock({ preset, display_name, idx, isActive, isGenerating, onActivate, onRemove, onReplace, onEdit, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
export function makeModelComboBlock({ preset, display_name, idx, isActive, isBypassed, isGenerating, overrides, onOverrideChange, onActivate, onToggleBypass, onSingleOut, onDuplicate, onRemove, onReplace, onEdit, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
const block = document.createElement("div");
|
||||
block.dataset.idx = String(idx);
|
||||
|
||||
// No overflow:hidden — it would clip the top:100% override panel (the cause of
|
||||
// the "arrow does nothing" bug). The rounded background still clips to the
|
||||
// border-radius. Mirrors the Flake Stack grid block (#303).
|
||||
css(block, `position:relative;height:80px;background:#2a2a2a;border:${
|
||||
isGenerating ? "2px solid #4a9eff" : "1px solid #444"
|
||||
};border-radius:4px;cursor:pointer;font-size:11px;color:#ddd;user-select:none;box-sizing:border-box;overflow:hidden;background-image:url(/flakes/preset_cover?name=${encodeURIComponent(preset)});background-size:cover;background-position:center;${
|
||||
};border-radius:4px;cursor:pointer;font-size:11px;color:#ddd;user-select:none;box-sizing:border-box;background-image:url(${getPresetCoverUrl(preset, THUMB)});background-size:cover;background-position:center;${
|
||||
isGenerating ? "box-shadow:inset 0 0 0 2px rgba(74,158,255,0.7);" : ""
|
||||
}`);
|
||||
}${isBypassed ? "opacity:0.45;" : ""}`);
|
||||
|
||||
// Dark overlay, hover buttons — using shared helper (no triangle for model combo)
|
||||
makeGridItemOverlay({
|
||||
// Dark overlay, hover buttons, and triangle dropdown (override panel, #279).
|
||||
const { triangleBtn } = makeGridItemOverlay({
|
||||
block,
|
||||
showHoverButtons: true,
|
||||
buttons: [
|
||||
makeHoverButton({ svg: `<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`, title: "Replace Preset", onClick: () => onReplace(idx) }),
|
||||
makeHoverButton({ svg: `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`, title: "Edit Preset", onClick: () => onEdit(idx) }),
|
||||
makeHoverButton({ svg: `<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>`, title: "Remove from Combo", onClick: () => onRemove(idx) }),
|
||||
...(isLocalName(preset) ? [makeHoverButton({ svg: SAVE_TO_DISK_SVG, title: "Save to disk", onClick: () => saveLocalToDisk(preset) })] : []),
|
||||
],
|
||||
showTriangle: false,
|
||||
showTriangle: true,
|
||||
});
|
||||
if (isLocalName(preset)) markLocal(block);
|
||||
|
||||
const fullName = display_name || preset.split(/[\/\\]+/).pop() || preset;
|
||||
const nameEl = document.createElement("div");
|
||||
@@ -34,6 +43,37 @@ export function makeModelComboBlock({ preset, display_name, idx, isActive, isGen
|
||||
nameEl.textContent = fullName;
|
||||
block.appendChild(nameEl);
|
||||
|
||||
// Strikethrough when bypassed (mirrors FlakeCombo's disabled visual).
|
||||
if (isBypassed) block.appendChild(makeBypassStrike());
|
||||
|
||||
// Enable/disable checkbox (top-right). Checked = active; unchecked =
|
||||
// bypassed (excluded from the generation product). Sits above the hover
|
||||
// overlay so it stays clickable on hover.
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = !isBypassed;
|
||||
toggle.title = isBypassed ? "Preset disabled (click to enable)" : "Preset enabled (click to disable)";
|
||||
css(toggle, "position:absolute;top:3px;right:3px;z-index:6;cursor:pointer;margin:0;width:14px;height:14px;");
|
||||
toggle.addEventListener("click", (e) => { e.stopPropagation(); });
|
||||
toggle.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("change", (e) => { e.stopPropagation(); onToggleBypass(idx); });
|
||||
if (onSingleOut) attachHoldToSingleOut(toggle, () => onSingleOut(idx));
|
||||
block.appendChild(toggle);
|
||||
|
||||
// Duplicate (») button (bottom-right): adds a copy of this preset beside it,
|
||||
// with its own overrides, for quick variations (#316).
|
||||
if (onDuplicate) {
|
||||
const dup = document.createElement("button");
|
||||
dup.textContent = "»";
|
||||
dup.title = "Duplicate this preset";
|
||||
css(dup, "position:absolute;bottom:2px;right:2px;z-index:6;width:16px;height:16px;padding:0;line-height:14px;font-size:12px;font-weight:700;border:none;background:transparent;color:#cfe6ff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,0.9);");
|
||||
dup.addEventListener("click", (e) => { e.stopPropagation(); onDuplicate(idx); });
|
||||
dup.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dup.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(dup);
|
||||
}
|
||||
|
||||
block.draggable = true;
|
||||
block.style.cursor = "grab";
|
||||
block.addEventListener("dragstart", (e) => {
|
||||
@@ -54,12 +94,53 @@ export function makeModelComboBlock({ preset, display_name, idx, isActive, isGen
|
||||
block.addEventListener("dragleave", () => { block.style.outline = ""; block.style.boxShadow = ""; });
|
||||
block.addEventListener("drop", (e) => onDrop(e, idx, block));
|
||||
|
||||
// Per-instance override dropdown (#279): Filename Prefix / Steps / CFG /
|
||||
// Sampler / Scheduler — A/B test a preset without editing its file.
|
||||
if (triangleBtn && overrides) {
|
||||
// Floating panel anchored under the block, exactly like the Flake Stack
|
||||
// grid override dropdown (#303). With the block's overflow:hidden removed,
|
||||
// a position:absolute;top:100% panel shows correctly (position:fixed
|
||||
// mispositioned inside ComfyUI's transformed canvas).
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1e1e1e;border:1px solid #444;border-radius:4px;display:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
panel.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
panel.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
|
||||
// Rebuild once the preset's current values are known so each field shows
|
||||
// its current value (#302/#303).
|
||||
let _defaults = {};
|
||||
const rebuild = () => panel.replaceChildren(makeModelOverridePanel(overrides, () => { if (onOverrideChange) onOverrideChange(); }, _defaults));
|
||||
rebuild();
|
||||
fetchPreset(preset).then(d => {
|
||||
_defaults = { filename_prefix: d.filename_prefix, steps: d.steps, cfg: d.cfg, sampler: d.sampler, scheduler: d.scheduler };
|
||||
rebuild();
|
||||
}).catch(() => {});
|
||||
block.appendChild(panel);
|
||||
|
||||
let outside = null;
|
||||
const closePanel = () => {
|
||||
panel.style.display = "none";
|
||||
triangleBtn.textContent = "▾";
|
||||
if (outside) { document.removeEventListener("mousedown", outside); outside = null; }
|
||||
};
|
||||
triangleBtn.addEventListener("click", () => {
|
||||
if (panel.style.display === "block") { closePanel(); return; }
|
||||
panel.style.display = "block";
|
||||
triangleBtn.textContent = "▴";
|
||||
outside = (e) => { if (!block.contains(e.target)) closePanel(); };
|
||||
document.addEventListener("mousedown", outside);
|
||||
});
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
export function setupFlakeModelComboWidget(node) {
|
||||
// kind "anim": a Flake Animate Preset Combo node (family widget, animate presets).
|
||||
export function setupFlakeModelComboWidget(node, { kind = "img" } = {}) {
|
||||
const presetWidget = node.widgets?.find(w => w.name === "preset");
|
||||
const familyWidget = node.widgets?.find(w => w.name === "model_family");
|
||||
const familyWidget = node.widgets?.find(w => w.name === (kind === "anim" ? "family" : "model_family"));
|
||||
const overridesWidget = node.widgets?.find(w => w.name === "overrides_json");
|
||||
if (!presetWidget) return;
|
||||
|
||||
// Hide the original ComfyUI combo widget
|
||||
@@ -69,20 +150,62 @@ export function setupFlakeModelComboWidget(node) {
|
||||
if (presetWidget.element) { presetWidget.element.remove(); presetWidget.element = null; }
|
||||
if (presetWidget.inputEl) { presetWidget.inputEl.remove(); presetWidget.inputEl = null; }
|
||||
|
||||
// Hide the overrides_json widget — it's driven by the per-item panel (#279).
|
||||
if (overridesWidget) {
|
||||
overridesWidget.computeSize = () => [0, -4];
|
||||
overridesWidget.type = "hidden";
|
||||
overridesWidget.hidden = true;
|
||||
if (overridesWidget.element) { overridesWidget.element.remove(); overridesWidget.element = null; }
|
||||
if (overridesWidget.inputEl) { overridesWidget.inputEl.remove(); overridesWidget.inputEl = null; }
|
||||
}
|
||||
|
||||
function getFamily() {
|
||||
return familyWidget?.value || "SDXL/Base";
|
||||
}
|
||||
|
||||
const syncFamily = kind === "img" && familyWidget ? attachFamilyInput(node, familyWidget) : null;
|
||||
|
||||
if (!node.properties) node.properties = {};
|
||||
if (!node.properties._combo_presets) node.properties._combo_presets = [];
|
||||
if (node.properties._combo_active_index == null) node.properties._combo_active_index = 0;
|
||||
if (!node.properties._combo_display_names) node.properties._combo_display_names = {};
|
||||
// Bypassed presets, keyed by preset name (survives drag/remove reindexing).
|
||||
if (!Array.isArray(node.properties._combo_bypassed)) node.properties._combo_bypassed = [];
|
||||
// Per-instance overrides, aligned by index with _combo_presets (#279).
|
||||
if (!Array.isArray(node.properties._combo_overrides)) node.properties._combo_overrides = [];
|
||||
|
||||
function readPresets() {
|
||||
return node.properties._combo_presets || [];
|
||||
}
|
||||
function overridesArr() {
|
||||
if (!Array.isArray(node.properties._combo_overrides)) node.properties._combo_overrides = [];
|
||||
return node.properties._combo_overrides;
|
||||
}
|
||||
// Per-item uids, aligned by index with _combo_presets (#383). Presets
|
||||
// saved without one get one here.
|
||||
function uidsArr() {
|
||||
if (!Array.isArray(node.properties._combo_uids)) node.properties._combo_uids = [];
|
||||
const uids = node.properties._combo_uids;
|
||||
while (uids.length < readPresets().length) uids.push(newItemUid());
|
||||
return uids;
|
||||
}
|
||||
function getOverridesAt(i) {
|
||||
const arr = overridesArr();
|
||||
while (arr.length <= i) arr.push({});
|
||||
if (!arr[i] || typeof arr[i] !== "object") arr[i] = {};
|
||||
return arr[i];
|
||||
}
|
||||
function isPresetBypassed(preset) {
|
||||
return (node.properties._combo_bypassed || []).includes(preset);
|
||||
}
|
||||
// Drop bypass entries for presets no longer present in the combo.
|
||||
function reconcileBypassed() {
|
||||
const present = new Set(readPresets());
|
||||
node.properties._combo_bypassed = (node.properties._combo_bypassed || []).filter(p => present.has(p));
|
||||
}
|
||||
function writePresets(presets) {
|
||||
node.properties._combo_presets = presets;
|
||||
reconcileBypassed();
|
||||
updateActivePreset();
|
||||
}
|
||||
function updateActivePreset() {
|
||||
@@ -90,6 +213,11 @@ export function setupFlakeModelComboWidget(node) {
|
||||
const idx = node.properties._combo_active_index || 0;
|
||||
const active = presets[idx] || "Select a preset...";
|
||||
presetWidget.value = active;
|
||||
// Sync the active preset's overrides to the hidden widget so a single
|
||||
// (non-batch) run uses them; the batch cycler sets it per combination.
|
||||
if (overridesWidget) {
|
||||
overridesWidget.value = serializeModelOverrides(overridesArr()[idx] || {});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDisplayName(presetName) {
|
||||
@@ -104,6 +232,24 @@ export function setupFlakeModelComboWidget(node) {
|
||||
const container = document.createElement("div");
|
||||
css(container, "display:flex;flex-direction:column;gap:2px;padding:0 6px 3px 6px;font-size:12px;color:#ddd;");
|
||||
|
||||
// Toggle-all button (#315): activate/deactivate every preset in the combo at
|
||||
// once. Matches the Generation Data button via the shared helper, centered (#338).
|
||||
const { btn: toggleAll, lbl: toggleAllLbl } = makeAccentButton("Activate all");
|
||||
toggleAll.style.alignSelf = "center";
|
||||
toggleAll.style.margin = "2px 0";
|
||||
function refreshToggleAll() {
|
||||
const presets = readPresets();
|
||||
const allActive = presets.length > 0 && presets.every(p => !isPresetBypassed(p));
|
||||
toggleAllLbl.textContent = allActive ? "Deactivate all" : "Activate all";
|
||||
}
|
||||
toggleAll.addEventListener("click", () => {
|
||||
const presets = readPresets();
|
||||
const allActive = presets.length > 0 && presets.every(p => !isPresetBypassed(p));
|
||||
node.properties._combo_bypassed = allActive ? [...new Set(presets)] : [];
|
||||
render();
|
||||
});
|
||||
container.appendChild(toggleAll);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
css(grid, "display:grid;grid-template-columns:repeat(auto-fill, minmax(72px, 1fr));gap:4px;");
|
||||
container.appendChild(grid);
|
||||
@@ -111,9 +257,11 @@ export function setupFlakeModelComboWidget(node) {
|
||||
let dragSrcIdx = null;
|
||||
|
||||
function render() {
|
||||
syncFamily?.();
|
||||
const presets = readPresets();
|
||||
const activeIdx = node.properties._combo_active_index || 0;
|
||||
const generatingIdx = node._model_combo_generating_index;
|
||||
const uids = uidsArr();
|
||||
const generatingUid = getGeneratingUid(node);
|
||||
grid.replaceChildren();
|
||||
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
@@ -123,15 +271,46 @@ export function setupFlakeModelComboWidget(node) {
|
||||
display_name: displayName,
|
||||
idx: i,
|
||||
isActive: i === activeIdx,
|
||||
isGenerating: generatingIdx != null && i === generatingIdx,
|
||||
isBypassed: isPresetBypassed(presets[i]),
|
||||
isGenerating: generatingUid != null && uids[i] === generatingUid,
|
||||
overrides: getOverridesAt(i),
|
||||
onOverrideChange: () => updateActivePreset(),
|
||||
onActivate: (idx) => {
|
||||
node.properties._combo_active_index = idx;
|
||||
updateActivePreset();
|
||||
render();
|
||||
},
|
||||
onToggleBypass: (idx) => {
|
||||
const p = readPresets()[idx];
|
||||
if (!p) return;
|
||||
const arr = node.properties._combo_bypassed || (node.properties._combo_bypassed = []);
|
||||
const at = arr.indexOf(p);
|
||||
if (at >= 0) arr.splice(at, 1); else arr.push(p);
|
||||
render();
|
||||
},
|
||||
// Hold the checkbox to single out: enable only this preset,
|
||||
// disable every other one in the combo (#281).
|
||||
onSingleOut: (idx) => {
|
||||
const presets = readPresets();
|
||||
const keep = presets[idx];
|
||||
if (keep == null) return;
|
||||
node.properties._combo_bypassed = [...new Set(presets.filter(p => p !== keep))];
|
||||
render();
|
||||
},
|
||||
onDuplicate: (idx) => {
|
||||
const arr = readPresets();
|
||||
if (arr[idx] == null) return;
|
||||
uidsArr().splice(idx + 1, 0, newItemUid());
|
||||
arr.splice(idx + 1, 0, arr[idx]);
|
||||
overridesArr().splice(idx + 1, 0, JSON.parse(JSON.stringify(getOverridesAt(idx))));
|
||||
writePresets(arr);
|
||||
render();
|
||||
},
|
||||
onRemove: (idx) => {
|
||||
const arr = readPresets();
|
||||
uidsArr().splice(idx, 1);
|
||||
arr.splice(idx, 1);
|
||||
overridesArr().splice(idx, 1);
|
||||
if (node.properties._combo_active_index >= arr.length) {
|
||||
node.properties._combo_active_index = Math.max(0, arr.length - 1);
|
||||
}
|
||||
@@ -139,10 +318,13 @@ export function setupFlakeModelComboWidget(node) {
|
||||
render();
|
||||
},
|
||||
onReplace: async (idx) => {
|
||||
const result = await openPresetPicker({ family: getFamily() });
|
||||
const result = await openPresetPicker({ family: getFamily(), kind });
|
||||
if (!result || !result.name) return;
|
||||
const arr = readPresets();
|
||||
arr[idx] = result.name;
|
||||
// New preset under this slot — reset its overrides.
|
||||
getOverridesAt(idx);
|
||||
overridesArr()[idx] = {};
|
||||
const name = await fetchDisplayName(result.name);
|
||||
node.properties._combo_display_names[result.name] = name;
|
||||
writePresets(arr);
|
||||
@@ -153,7 +335,7 @@ export function setupFlakeModelComboWidget(node) {
|
||||
if (!presetName) return;
|
||||
const { openPresetEditModal } = await import("../preset-modal.js");
|
||||
const data = await fetchPreset(presetName);
|
||||
const result = await openPresetEditModal({ mode: "edit", name: presetName, data });
|
||||
const result = await openPresetEditModal({ mode: "edit", name: presetName, data, family: getFamily(), kind });
|
||||
if (result) {
|
||||
const name = await fetchDisplayName(presetName);
|
||||
node.properties._combo_display_names[presetName] = name;
|
||||
@@ -176,10 +358,17 @@ export function setupFlakeModelComboWidget(node) {
|
||||
_hideDropIndicator(el);
|
||||
if (dragSrcIdx === null || idx === dragSrcIdx) return;
|
||||
const arr = readPresets();
|
||||
const uids = uidsArr();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
let insertIdx = idx;
|
||||
if (dragSrcIdx < idx) insertIdx--;
|
||||
arr.splice(insertIdx, 0, moved);
|
||||
uids.splice(insertIdx, 0, uids.splice(dragSrcIdx, 1)[0]);
|
||||
// Keep per-instance overrides aligned with the reordered presets.
|
||||
const ov = overridesArr();
|
||||
while (ov.length < arr.length) ov.push({});
|
||||
const [movedOv] = ov.splice(dragSrcIdx, 1);
|
||||
ov.splice(insertIdx, 0, movedOv || {});
|
||||
if (node.properties._combo_active_index === dragSrcIdx) {
|
||||
node.properties._combo_active_index = insertIdx;
|
||||
} else if (node.properties._combo_active_index > dragSrcIdx && node.properties._combo_active_index <= idx) {
|
||||
@@ -210,26 +399,40 @@ export function setupFlakeModelComboWidget(node) {
|
||||
});
|
||||
}
|
||||
|
||||
const addBtn = document.createElement("div");
|
||||
css(addBtn, "position:relative;height:80px;background:#2a2a2a;border:1px dashed #555;border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer;font-size:11px;color:#999;user-select:none;box-sizing:border-box;");
|
||||
addBtn.draggable = false;
|
||||
const icon = document.createElement("div");
|
||||
css(icon, "font-size:20px;font-weight:300;color:#666;line-height:1;");
|
||||
icon.textContent = "+";
|
||||
addBtn.appendChild(icon);
|
||||
const label = document.createElement("div");
|
||||
css(label, "font-size:9px;text-align:center;");
|
||||
label.textContent = "Add preset";
|
||||
addBtn.appendChild(label);
|
||||
addBtn.addEventListener("click", async () => {
|
||||
const result = await openPresetPicker({ family: getFamily() });
|
||||
if (!result || !result.name) return;
|
||||
// Add block with a New/Existing dropdown (mirrors Flake Combo's add
|
||||
// block): create a brand-new model preset, or add an existing one (#278).
|
||||
async function pushPreset(name) {
|
||||
const arr = readPresets();
|
||||
arr.push(result.name);
|
||||
const name = await fetchDisplayName(result.name);
|
||||
node.properties._combo_display_names[result.name] = name;
|
||||
uidsArr().push(newItemUid());
|
||||
arr.push(name);
|
||||
overridesArr().push({});
|
||||
node.properties._combo_display_names[name] = await fetchDisplayName(name);
|
||||
writePresets(arr);
|
||||
render();
|
||||
}
|
||||
const addBtn = makeAddBlock({
|
||||
addLabel: "Add preset",
|
||||
newLabel: "+ New preset",
|
||||
loadLabel: "↑ Add existing",
|
||||
onNew: async () => {
|
||||
const result = await openPresetEditModal({
|
||||
mode: "create",
|
||||
family: getFamily(),
|
||||
kind,
|
||||
data: {
|
||||
checkpoint: "", checkpoint_url: "", clip_skip: -2, vae: "",
|
||||
prompt: { positive: "", negative: "" }, embeddings: [],
|
||||
},
|
||||
});
|
||||
if (!result || !result.name) return;
|
||||
await refreshPresetOptions();
|
||||
await pushPreset(result.name);
|
||||
},
|
||||
onLoad: async () => {
|
||||
const result = await openPresetPicker({ family: getFamily(), kind });
|
||||
if (!result || !result.name) return;
|
||||
await pushPreset(result.name);
|
||||
},
|
||||
});
|
||||
|
||||
addBtn.addEventListener("dragover", (e) => {
|
||||
@@ -246,14 +449,21 @@ export function setupFlakeModelComboWidget(node) {
|
||||
_hideDropIndicator(addBtn);
|
||||
if (dragSrcIdx === null) return;
|
||||
const arr = readPresets();
|
||||
const uids = uidsArr();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
arr.push(moved);
|
||||
uids.push(uids.splice(dragSrcIdx, 1)[0]);
|
||||
const ov = overridesArr();
|
||||
while (ov.length < arr.length) ov.push({});
|
||||
const [movedOv] = ov.splice(dragSrcIdx, 1);
|
||||
ov.push(movedOv || {});
|
||||
writePresets(arr);
|
||||
dragSrcIdx = null;
|
||||
render();
|
||||
});
|
||||
|
||||
grid.appendChild(addBtn);
|
||||
refreshToggleAll();
|
||||
}
|
||||
|
||||
// React to native family widget changes
|
||||
@@ -269,8 +479,11 @@ export function setupFlakeModelComboWidget(node) {
|
||||
node._model_combo_render = render;
|
||||
const comboWidget = node.addDOMWidget("model_combo_ui", "div", container, { serialize: false, margin: 4 });
|
||||
comboWidget.computeSize = () => {
|
||||
const rows = Math.max(1, Math.ceil((readPresets().length + 1) / 2));
|
||||
return [node.size[0], rows * 84 + 31];
|
||||
// Match the actual auto-fill column count (minmax 72px + 4px gap) so a wide
|
||||
// node doesn't over-count rows and pad itself with empty space (#317).
|
||||
const cols = Math.max(1, Math.floor((node.size[0] - 16) / 76));
|
||||
const rows = Math.max(1, Math.ceil((readPresets().length + 1) / cols));
|
||||
return [node.size[0], rows * 84 + 55]; // +24 for the toggle-all button (#315)
|
||||
};
|
||||
updateActivePreset();
|
||||
render();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { css } from "../utils.js";
|
||||
import { css, makeModelOverridePanel, serializeModelOverrides, isLocalName, markLocal, SAVE_TO_DISK_SVG, attachFamilyInput } from "../utils.js";
|
||||
import { saveLocalToDisk } from "../bundle.js";
|
||||
import { fetchPreset } from "../api.js";
|
||||
import { openPresetEditModal, refreshPresetOptions } from "../preset-modal.js";
|
||||
import { openPresetPicker } from "../pickers.js";
|
||||
|
||||
export function setupFlakeModelPresetWidget(node) {
|
||||
// kind "anim": a Flake Animate Preset node (family widget, animate presets).
|
||||
export function setupFlakeModelPresetWidget(node, { kind = "img" } = {}) {
|
||||
const presetWidget = node.widgets?.find(w => w.name === "preset");
|
||||
const familyWidget = node.widgets?.find(w => w.name === "model_family");
|
||||
const familyWidget = node.widgets?.find(w => w.name === (kind === "anim" ? "family" : "model_family"));
|
||||
if (!presetWidget) return;
|
||||
|
||||
// Hide the original ComfyUI combo widget
|
||||
@@ -46,6 +48,8 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
setTimeout(hideNativePresetWidget, 0);
|
||||
setTimeout(hideNativePresetWidget, 100);
|
||||
|
||||
const syncFamily = kind === "img" && familyWidget ? attachFamilyInput(node, familyWidget) : null;
|
||||
|
||||
function getFamily() {
|
||||
return familyWidget?.value || "SDXL/Base";
|
||||
}
|
||||
@@ -66,7 +70,7 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
const result = await openPresetPicker({ selected: presetWidget.value, family: getFamily() });
|
||||
const result = await openPresetPicker({ selected: presetWidget.value, family: getFamily(), kind });
|
||||
if (result && result.name) {
|
||||
presetWidget.value = result.name;
|
||||
render();
|
||||
@@ -89,24 +93,19 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
const result = await openPresetEditModal({
|
||||
mode: "create",
|
||||
family: getFamily(),
|
||||
kind,
|
||||
data: {
|
||||
checkpoint: "",
|
||||
checkpoint_url: "",
|
||||
clip_skip: -2,
|
||||
vae: "",
|
||||
steps: 20,
|
||||
cfg: 4.0,
|
||||
sampler: "dpmpp_2m",
|
||||
scheduler: "karras",
|
||||
width: 832,
|
||||
height: 1216,
|
||||
prompt: { positive: "", negative: "" },
|
||||
embeddings: [],
|
||||
},
|
||||
});
|
||||
if (result && result.name) {
|
||||
presetWidget.value = result.name;
|
||||
await refreshPresetOptions(getFamily());
|
||||
await refreshPresetOptions();
|
||||
render();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -150,7 +149,7 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
replacePresetBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const result = await openPresetPicker({ selected: presetWidget.value, family: getFamily() });
|
||||
const result = await openPresetPicker({ selected: presetWidget.value, family: getFamily(), kind });
|
||||
if (result && result.name) {
|
||||
presetWidget.value = result.name;
|
||||
render();
|
||||
@@ -179,7 +178,7 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
window.alert(`Failed to load preset '${current}': ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
const result = await openPresetEditModal({ mode: "edit", name: current, data, family: getFamily() });
|
||||
const result = await openPresetEditModal({ mode: "edit", name: current, data, family: getFamily(), kind });
|
||||
if (result) {
|
||||
await refreshPresetOptions();
|
||||
render();
|
||||
@@ -201,6 +200,20 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
});
|
||||
hoverBtns.appendChild(removeBtn);
|
||||
|
||||
// Save a workflow-local preset (#437) on disk.
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.title = "Save to disk";
|
||||
saveBtn.appendChild(svgIcon(SAVE_TO_DISK_SVG));
|
||||
css(saveBtn, HOVER_BTN_STYLE);
|
||||
saveBtn.addEventListener("mouseenter", () => { saveBtn.style.background = "#fff"; saveBtn.style.transform = "scale(1.08)"; });
|
||||
saveBtn.addEventListener("mouseleave", () => { saveBtn.style.background = "rgba(255,255,255,0.92)"; saveBtn.style.transform = "scale(1)"; });
|
||||
saveBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
saveLocalToDisk(presetWidget.value);
|
||||
});
|
||||
hoverBtns.appendChild(saveBtn);
|
||||
let localTag = null;
|
||||
|
||||
imgWrap.appendChild(hoverBtns);
|
||||
|
||||
// Show/hide hover buttons on image hover
|
||||
@@ -220,7 +233,7 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
window.alert(`Failed to load preset '${current}': ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
const result = await openPresetEditModal({ mode: "edit", name: current, data, family: getFamily() });
|
||||
const result = await openPresetEditModal({ mode: "edit", name: current, data, family: getFamily(), kind });
|
||||
if (result) {
|
||||
await refreshPresetOptions();
|
||||
render();
|
||||
@@ -233,9 +246,76 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
css(nameLabel, "font-size:12px;color:#aaa;text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px;");
|
||||
selectedWrap.appendChild(nameLabel);
|
||||
|
||||
// Per-instance overrides (#279): steps/cfg/sampler/scheduler/filename_prefix
|
||||
// applied on top of the preset without editing its file. Stored in
|
||||
// node.properties._overrides and mirrored to the hidden overrides_json widget.
|
||||
if (!node.properties) node.properties = {};
|
||||
if (!node.properties._overrides || typeof node.properties._overrides !== "object") node.properties._overrides = {};
|
||||
const overridesWidget = node.widgets?.find(w => w.name === "overrides_json");
|
||||
if (overridesWidget) {
|
||||
overridesWidget.computeSize = () => [0, -4];
|
||||
overridesWidget.type = "hidden";
|
||||
overridesWidget.hidden = true;
|
||||
if (overridesWidget.element) { overridesWidget.element.remove(); overridesWidget.element = null; }
|
||||
if (overridesWidget.inputEl) { overridesWidget.inputEl.remove(); overridesWidget.inputEl = null; }
|
||||
}
|
||||
function syncOverridesWidget() {
|
||||
if (overridesWidget) overridesWidget.value = serializeModelOverrides(node.properties._overrides);
|
||||
}
|
||||
syncOverridesWidget();
|
||||
|
||||
// Per-instance overrides (#279/#292/#302): a hover triangle on the cover image
|
||||
// opens a FLOATING override dropdown anchored below the arrow (like the Flake
|
||||
// Stack grid override panel), instead of an inline panel that pushed the node
|
||||
// taller. Rebuilt with the selected preset's current values so each field
|
||||
// shows its current value.
|
||||
const ovrArrow = document.createElement("button");
|
||||
ovrArrow.type = "button";
|
||||
ovrArrow.textContent = "▾";
|
||||
css(ovrArrow, "position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:transparent;color:#fff;border:none;padding:0;font-size:22px;line-height:1;cursor:pointer;z-index:3;opacity:0;text-shadow:0 1px 3px rgba(0,0,0,0.8);");
|
||||
imgWrap.appendChild(ovrArrow);
|
||||
imgWrap.addEventListener("mouseenter", () => { ovrArrow.style.opacity = "1"; });
|
||||
imgWrap.addEventListener("mouseleave", () => { ovrArrow.style.opacity = "0"; });
|
||||
|
||||
// Floating panel: absolute within selectedWrap (position:relative), so it
|
||||
// overlays rather than extends the node.
|
||||
const ovrPanelWrap = document.createElement("div");
|
||||
css(ovrPanelWrap, "position:absolute;display:none;background:#1e1e1e;border:1px solid #444;border-radius:4px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);");
|
||||
ovrPanelWrap.addEventListener("click", (e) => e.stopPropagation());
|
||||
ovrPanelWrap.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
let _ovrDefaults = {};
|
||||
function rebuildOverridePanel() {
|
||||
ovrPanelWrap.replaceChildren(makeModelOverridePanel(node.properties._overrides, syncOverridesWidget, _ovrDefaults));
|
||||
}
|
||||
rebuildOverridePanel();
|
||||
|
||||
let _ovrOutside = null;
|
||||
function closeOvr() {
|
||||
ovrPanelWrap.style.display = "none";
|
||||
ovrArrow.textContent = "▾";
|
||||
if (_ovrOutside) { document.removeEventListener("mousedown", _ovrOutside); _ovrOutside = null; }
|
||||
}
|
||||
function openOvr() {
|
||||
const a = ovrArrow.getBoundingClientRect();
|
||||
const w = selectedWrap.getBoundingClientRect();
|
||||
ovrPanelWrap.style.left = `${a.left + a.width / 2 - w.left}px`;
|
||||
ovrPanelWrap.style.top = `${a.bottom - w.top + 2}px`;
|
||||
ovrPanelWrap.style.transform = "translateX(-50%)";
|
||||
ovrPanelWrap.style.display = "block";
|
||||
ovrArrow.textContent = "▴";
|
||||
_ovrOutside = (e) => { if (!ovrPanelWrap.contains(e.target) && e.target !== ovrArrow) closeOvr(); };
|
||||
document.addEventListener("mousedown", _ovrOutside);
|
||||
}
|
||||
ovrArrow.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (ovrPanelWrap.style.display === "block") closeOvr(); else openOvr();
|
||||
});
|
||||
selectedWrap.appendChild(ovrPanelWrap);
|
||||
|
||||
container.appendChild(selectedWrap);
|
||||
|
||||
async function render() {
|
||||
syncFamily?.();
|
||||
const val = presetWidget.value || "";
|
||||
const isPlaceholder = !val || val === "Select a preset..." || val === "No model preset is selected";
|
||||
if (isPlaceholder) {
|
||||
@@ -247,11 +327,25 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
coverImg.src = `/flakes/preset_cover?name=${encodeURIComponent(val)}`;
|
||||
nameLabel.textContent = val.replace(/\\/g, "/").split("/").pop();
|
||||
nameLabel.title = val;
|
||||
localTag?.remove();
|
||||
imgWrap.style.borderStyle = "solid";
|
||||
localTag = isLocalName(val) ? markLocal(imgWrap) : null;
|
||||
saveBtn.style.display = localTag ? "flex" : "none";
|
||||
try {
|
||||
const data = await fetchPreset(val);
|
||||
if (data.display_name) {
|
||||
nameLabel.textContent = data.display_name;
|
||||
}
|
||||
// Feed the preset's current values to the override panel so each
|
||||
// field's placeholder shows the real default (#292).
|
||||
_ovrDefaults = {
|
||||
filename_prefix: data.filename_prefix,
|
||||
steps: data.steps,
|
||||
cfg: data.cfg,
|
||||
sampler: data.sampler,
|
||||
scheduler: data.scheduler,
|
||||
};
|
||||
rebuildOverridePanel();
|
||||
} catch { /* fall back to path segment */ }
|
||||
}
|
||||
}
|
||||
@@ -263,13 +357,6 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
coverImg.style.display = "block";
|
||||
});
|
||||
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function () {
|
||||
const r = origOnConfigure?.apply(this, arguments);
|
||||
render();
|
||||
return r;
|
||||
};
|
||||
|
||||
const origSetValue = presetWidget.setValue;
|
||||
presetWidget.setValue = function (v) {
|
||||
const r = origSetValue?.apply(this, arguments);
|
||||
@@ -290,7 +377,7 @@ export function setupFlakeModelPresetWidget(node) {
|
||||
const origCallback = familyWidget.callback;
|
||||
familyWidget.callback = function (value) {
|
||||
const r = origCallback?.apply(this, arguments);
|
||||
refreshPresetOptions(value);
|
||||
refreshPresetOptions();
|
||||
render();
|
||||
return r;
|
||||
};
|
||||
|
||||
+527
-71
@@ -1,42 +1,60 @@
|
||||
import {
|
||||
css, ensureDefault, makeSmallButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, TYPE_COLORS,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock,
|
||||
makePanelDropdown, makeSmallValueSlider, variantSuffix,
|
||||
_registerOpenPanel, _unregisterOpenPanel,
|
||||
css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, makeFavoriteStar, TYPE_COLORS,
|
||||
_showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock, flakeDrag, clearFlakeDrag,
|
||||
makePanelDropdown, makeSmallValueSlider, renderVariantLoraSliders, renderOptionToggles, variantInitials, renderFlakeLabel, splitNameAndTags, loraTagNames,
|
||||
_registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, effectiveVariant, variantDropdownOptions, variantDefaultsOf, choiceFlakeLinks, makeImageSection,
|
||||
isLocalName, markLocal, SAVE_TO_DISK_SVG,
|
||||
} from "../utils.js";
|
||||
import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, fetchFlakeMeta, invalidateList } from "../api.js";
|
||||
import { saveLocalToDisk } from "../bundle.js";
|
||||
import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, THUMB, fetchFlakeMeta, cachedVariantDefaults, invalidateList, importAnimFlakeApi, pickedImageUrl } from "../api.js";
|
||||
import { openEditModal } from "../flake-modal.js";
|
||||
import { openFileLoadPicker } from "../pickers.js";
|
||||
import { openFileLoadPicker, makeItemImagesButton, makeItemImagesDropTarget } from "../pickers.js";
|
||||
import { stackImageJobs } from "../queue.js";
|
||||
|
||||
function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
// Append the name of the first tag-name LoRA (#274, #476) to a flake's grid display
|
||||
// name, e.g. "Askeladd" + a tagged "Some Author Name" -> "Askeladd — Some Author Name".
|
||||
function appendLoraTags(baseName, data, variant) {
|
||||
const tags = loraTagNames(data, variant);
|
||||
return baseName && tags.length ? `${baseName} — ${tags[0]}` : baseName;
|
||||
}
|
||||
|
||||
// Grid item [width, height] per kind; animate items are larger to show their
|
||||
// input image (#441).
|
||||
const ITEM_SIZE = { img: [90, 100], anim: [104, 128] };
|
||||
|
||||
function makeBlock({ entry, idx, kind, onEdit, onRemove, onReplace, onToggleBypass, onDuplicate, onImagesChanged, onDragStart, onDragOver, onDrop, onDragEnd }) {
|
||||
const isDefault = !!entry.inline;
|
||||
const isBypassed = !!entry.bypassed;
|
||||
const isAnim = kind === "anim";
|
||||
const hasCover = !isDefault && entry.name;
|
||||
const block = document.createElement("div");
|
||||
block.dataset.idx = String(idx);
|
||||
block.dataset.flakeBlock = "1";
|
||||
|
||||
css(block, `position:relative;height:80px;background:${
|
||||
css(block, `position:relative;height:${ITEM_SIZE[kind][1]}px;background:${
|
||||
isDefault ? "#2a3a4a" : isBypassed ? "#1a1a1a" : "#2a2a2a"
|
||||
};border:1px solid ${
|
||||
isDefault ? "#3a5a8a" : isBypassed ? "#333" : "#444"
|
||||
};border-radius:4px;cursor:pointer;font-size:11px;color:#ddd;user-select:none;box-sizing:border-box;${
|
||||
hasCover ? "background-size:cover;background-position:center;" : ""
|
||||
hasCover || isAnim ? "background-size:cover;background-position:center;" : ""
|
||||
}${isBypassed ? "opacity:0.45;" : ""}`);
|
||||
|
||||
// Cover image — swap to a selected variant choice's image when it has one,
|
||||
// otherwise fall back to the flake's base cover.
|
||||
// Cover image — the first input image of an animate item (#441), else an
|
||||
// effective variant choice's image when it has one, else the base cover.
|
||||
function refreshCover() {
|
||||
if (entry.images?.length) {
|
||||
block.style.backgroundImage = `url("${pickedImageUrl(entry.images[0])}")`;
|
||||
return;
|
||||
}
|
||||
if (!hasCover) return;
|
||||
const baseUrl = getCoverUrl(entry.name);
|
||||
const variant = entry.variant || {};
|
||||
const sel = Object.entries(variant).find(([, c]) => c != null && c !== "");
|
||||
const baseUrl = getCoverUrl(entry.name, THUMB);
|
||||
const sel = Object.entries(effectiveVariant(entry.variant, cachedVariantDefaults(entry.name)))[0];
|
||||
if (!sel) {
|
||||
block.style.backgroundImage = `url(${baseUrl})`;
|
||||
return;
|
||||
}
|
||||
const [group, choice] = sel;
|
||||
const variantUrl = getVariantImageUrl(entry.name, group, choice);
|
||||
const variantUrl = getVariantImageUrl(entry.name, group, choice, THUMB);
|
||||
const probe = new Image();
|
||||
probe.onload = () => { block.style.backgroundImage = `url(${variantUrl})`; };
|
||||
probe.onerror = () => { block.style.backgroundImage = `url(${baseUrl})`; };
|
||||
@@ -44,10 +62,15 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
}
|
||||
refreshCover();
|
||||
|
||||
// Type ribbon + bypass (clicking toggles bypass)
|
||||
// Type ribbon — static; activation is via the top-right checkbox (#314),
|
||||
// mirroring Flake Combo / Flake Model Combo.
|
||||
if (!isDefault) {
|
||||
const ribbon = makeTypeRibbon(entry, isBypassed, onToggleBypass, idx);
|
||||
block.appendChild(ribbon);
|
||||
const ribbon = makeTypeRibbon(entry, isBypassed);
|
||||
if (ribbon) {
|
||||
ribbon.style.cursor = "default";
|
||||
ribbon.title = entry.flake_type;
|
||||
block.appendChild(ribbon);
|
||||
}
|
||||
}
|
||||
|
||||
// Strikethrough for bypassed state
|
||||
@@ -55,15 +78,56 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
block.appendChild(makeBypassStrike());
|
||||
}
|
||||
|
||||
// Name — show portion after / with word wrapping, plus selected variant
|
||||
const fullName = isDefault ? "Default" : (entry.display_name || entry.name || "(missing)");
|
||||
const nameAfterSlash = fullName.includes("/") ? fullName.split("/").pop() : fullName;
|
||||
// Enable/disable checkbox (top-right): checked = active, unchecked = bypassed.
|
||||
// Replaces the old "click the type tag" toggle (#314).
|
||||
if (!isDefault) {
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = !isBypassed;
|
||||
toggle.title = isBypassed ? "Flake disabled (click to enable)" : "Flake enabled (click to disable)";
|
||||
css(toggle, "position:absolute;top:3px;right:3px;z-index:6;cursor:pointer;margin:0;width:14px;height:14px;");
|
||||
toggle.addEventListener("click", (e) => { e.stopPropagation(); });
|
||||
toggle.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
toggle.addEventListener("change", (e) => { e.stopPropagation(); onToggleBypass(idx); });
|
||||
block.appendChild(toggle);
|
||||
}
|
||||
|
||||
// Duplicate (») button (bottom-right): adds a copy of this flake beside it so
|
||||
// the user can quickly make variations with different overrides (#316).
|
||||
if (!isDefault && onDuplicate) {
|
||||
const dup = document.createElement("button");
|
||||
dup.textContent = "»";
|
||||
dup.title = "Duplicate this flake";
|
||||
css(dup, "position:absolute;bottom:2px;right:2px;z-index:6;width:16px;height:16px;padding:0;line-height:14px;font-size:12px;font-weight:700;border:none;background:transparent;color:#cfe6ff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,0.9);");
|
||||
dup.addEventListener("click", (e) => { e.stopPropagation(); onDuplicate(idx); });
|
||||
dup.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dup.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(dup);
|
||||
}
|
||||
|
||||
if (!isDefault && entry.favorite) {
|
||||
const star = makeFavoriteStar();
|
||||
// Animate items keep the bottom-left for their input images button.
|
||||
if (isAnim) Object.assign(star.style, { left: "auto", right: "20px" });
|
||||
block.appendChild(star);
|
||||
}
|
||||
if (isAnim) {
|
||||
block.appendChild(makeItemImagesButton(entry, onImagesChanged));
|
||||
makeItemImagesDropTarget(block, entry, onImagesChanged);
|
||||
}
|
||||
|
||||
// Name — stacked label: name line, dimmer tag line (#297), variant suffix.
|
||||
const nameEl = document.createElement("div");
|
||||
css(nameEl, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;text-align:center;line-height:1.2;text-shadow:0 1px 3px rgba(0,0,0,0.8);padding:0 4px;overflow:hidden;z-index:1;word-break:break-word;hyphens:auto;");
|
||||
function refreshName() {
|
||||
const suffix = isDefault ? "" : variantSuffix(entry);
|
||||
nameEl.title = fullName + suffix;
|
||||
nameEl.textContent = nameAfterSlash + suffix;
|
||||
if (isDefault) {
|
||||
renderFlakeLabel(nameEl, { name: "Default" });
|
||||
return;
|
||||
}
|
||||
const { name, tags } = splitNameAndTags(entry.display_name || entry.name || "(missing)", entry.tags);
|
||||
const base = name.includes("/") ? name.split("/").pop() : name;
|
||||
renderFlakeLabel(nameEl, { name: base, tags, variantText: variantInitials(entry, cachedVariantDefaults(entry.name)) });
|
||||
}
|
||||
refreshName();
|
||||
block.appendChild(nameEl);
|
||||
@@ -98,9 +162,11 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
makeHoverButton({ svg: `<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`, title: "Replace Flake", onClick: () => onReplace(idx) }),
|
||||
makeHoverButton({ svg: `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`, title: "Edit Flake", onClick: () => onEdit(idx) }),
|
||||
makeHoverButton({ svg: `<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>`, title: "Remove Flake", onClick: () => onRemove(idx) }),
|
||||
...(isLocalName(entry.name) ? [makeHoverButton({ svg: SAVE_TO_DISK_SVG, title: "Save to disk", onClick: () => saveLocalToDisk(entry.name) })] : []),
|
||||
] : [],
|
||||
showTriangle: !isDefault && !!entry.name,
|
||||
});
|
||||
if (!isDefault && isLocalName(entry.name)) markLocal(block);
|
||||
|
||||
block.addEventListener("dblclick", () => onEdit(idx));
|
||||
block.addEventListener("dragover", (e) => onDragOver(e, idx, block));
|
||||
@@ -108,6 +174,8 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
block.addEventListener("drop", (e) => onDrop(e, idx, block));
|
||||
|
||||
function refreshVisuals() { refreshName(); refreshCover(); }
|
||||
// Default variant choices show once the flake's meta is loaded (#388).
|
||||
if (hasCover && !cachedVariantDefaults(entry.name)) fetchFlakeMeta(entry.name).then(refreshVisuals, () => {});
|
||||
|
||||
return { block, triangleBtn, refreshName, refreshVisuals };
|
||||
}
|
||||
@@ -115,19 +183,29 @@ function makeBlock({ entry, idx, onEdit, onRemove, onReplace, onToggleBypass, on
|
||||
function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVariantChange) {
|
||||
if (entry.inline) return { toggleOptionsPanel: () => {} };
|
||||
|
||||
// Options panel (hidden by default)
|
||||
// Options panel (hidden by default). Container is a flex row so we can add
|
||||
// a second panel to the right when the host flake has a flake_link (#235).
|
||||
const panel = document.createElement("div");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);width:160px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;display:none;flex-direction:column;gap:3px;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
css(panel, "position:absolute;top:100%;left:50%;transform:translateX(-50%);background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:0;display:none;flex-direction:row;gap:0;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,0.5);margin-top:1px;");
|
||||
panel.addEventListener("click", (e) => e.stopPropagation());
|
||||
panel.addEventListener("dblclick", (e) => e.stopPropagation());
|
||||
block.appendChild(panel);
|
||||
|
||||
const nativeColumn = document.createElement("div");
|
||||
css(nativeColumn, "width:160px;padding:4px;display:flex;flex-direction:column;gap:3px;box-sizing:border-box;");
|
||||
panel.appendChild(nativeColumn);
|
||||
|
||||
const linkedColumn = document.createElement("div");
|
||||
css(linkedColumn, "width:160px;padding:4px;display:none;flex-direction:column;gap:3px;box-sizing:border-box;border-left:1px solid #333;");
|
||||
panel.appendChild(linkedColumn);
|
||||
|
||||
let optionsLoaded = false;
|
||||
let hasOptions = false;
|
||||
let flakeData = null;
|
||||
let linkedFlakes = {};
|
||||
|
||||
function rebuildPanel() {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
|
||||
const lorasMeta = flakeData?.loras || [];
|
||||
// Sync override array length to match the YAML
|
||||
@@ -149,7 +227,7 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
const opLabel = document.createElement("div");
|
||||
opLabel.textContent = "Output Stem";
|
||||
css(opLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
panel.appendChild(opLabel);
|
||||
nativeColumn.appendChild(opLabel);
|
||||
const opInput = document.createElement("input");
|
||||
opInput.type = "text";
|
||||
opInput.value = outputStem;
|
||||
@@ -159,13 +237,13 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
entry._output_stem_override = opInput.value || null;
|
||||
onChanged();
|
||||
});
|
||||
panel.appendChild(opInput);
|
||||
nativeColumn.appendChild(opInput);
|
||||
|
||||
// 2. LoRA strength sliders
|
||||
if (hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
for (let i = 0; i < lorasMeta.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
@@ -179,15 +257,55 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
panel.appendChild(sliderRow);
|
||||
nativeColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
// Flake options (#465); a choice's own options sit under its group.
|
||||
if (flakeData?.prompt_options?.length) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
const optHost = document.createElement("div");
|
||||
renderOptionToggles(optHost, flakeData.prompt_options, entry, onChanged);
|
||||
nativeColumn.append(sep, optHost);
|
||||
}
|
||||
|
||||
// 3. Variant groups
|
||||
if (hasOptionGroups) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
|
||||
// "All Variants" — generate one output per variant combination (#343).
|
||||
const avRow = document.createElement("label");
|
||||
css(avRow, "display:flex;align-items:center;justify-content:center;gap:6px;font-size:10px;cursor:pointer;");
|
||||
const avChk = document.createElement("input");
|
||||
avChk.type = "checkbox";
|
||||
avChk.checked = !!entry._all_variants;
|
||||
css(avChk, "cursor:pointer;margin:0;");
|
||||
const avLbl = document.createElement("span");
|
||||
avLbl.textContent = "All Variants";
|
||||
avRow.append(avChk, avLbl);
|
||||
nativeColumn.appendChild(avRow);
|
||||
|
||||
const groupsWrap = document.createElement("div");
|
||||
css(groupsWrap, "display:flex;flex-direction:column;gap:3px;");
|
||||
nativeColumn.appendChild(groupsWrap);
|
||||
// Per-group choice LoRA sliders, hidden while All Variants is on (#413).
|
||||
const variantLoraRenders = [];
|
||||
const applyAv = () => {
|
||||
groupsWrap.style.opacity = entry._all_variants ? "0.4" : "1";
|
||||
groupsWrap.style.pointerEvents = entry._all_variants ? "none" : "auto";
|
||||
variantLoraRenders.forEach(r => r());
|
||||
};
|
||||
avChk.addEventListener("change", () => {
|
||||
entry._all_variants = avChk.checked || undefined;
|
||||
applyAv();
|
||||
rebuildLinkedPanel();
|
||||
onChanged();
|
||||
if (onVariantChange) onVariantChange();
|
||||
});
|
||||
|
||||
for (const group of Object.keys(hasOptions)) {
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
@@ -196,11 +314,18 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
|
||||
const ddOptions = [{ value: "", label: "-" }];
|
||||
for (const ch of hasOptions[group]) {
|
||||
ddOptions.push({ value: ch, label: ch });
|
||||
}
|
||||
const dd = makePanelDropdown(ddOptions, (entry.variant || {})[group] || "");
|
||||
const defaultChoice = (cachedVariantDefaults(entry.name) || {})[group];
|
||||
const current = (entry.variant || {})[group] || "";
|
||||
const dd = makePanelDropdown(variantDropdownOptions(hasOptions[group], defaultChoice), current === defaultChoice ? "" : current);
|
||||
const vlHost = document.createElement("div");
|
||||
const voHost = document.createElement("div");
|
||||
const renderVl = () => {
|
||||
const ch = (entry.variant || {})[group] || defaultChoice;
|
||||
const choice = entry._all_variants ? null : ch;
|
||||
renderVariantLoraSliders(vlHost, flakeData?.variants, entry, group, choice, onChanged);
|
||||
renderOptionToggles(voHost, choice && flakeData?.variants?.[group]?.[choice]?.prompt_options, entry, onChanged);
|
||||
};
|
||||
variantLoraRenders.push(renderVl);
|
||||
dd.element.addEventListener("change", () => {
|
||||
if (dd.element.value) {
|
||||
entry.variant = entry.variant || {};
|
||||
@@ -208,20 +333,151 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
} else {
|
||||
if (entry.variant) delete entry.variant[group];
|
||||
}
|
||||
if (flakeData?.name) entry.display_name = appendLoraTags(flakeData.name, flakeData, entry.variant);
|
||||
renderVl();
|
||||
rebuildLinkedPanel();
|
||||
onChanged();
|
||||
if (onVariantChange) onVariantChange();
|
||||
});
|
||||
row.appendChild(dd.container);
|
||||
panel.appendChild(row);
|
||||
row.append(dd.container, vlHost, voHost);
|
||||
groupsWrap.appendChild(row);
|
||||
}
|
||||
applyAv();
|
||||
} else if (!hasLoras) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
panel.appendChild(sep);
|
||||
nativeColumn.appendChild(sep);
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no variants";
|
||||
panel.appendChild(empty);
|
||||
nativeColumn.appendChild(empty);
|
||||
}
|
||||
|
||||
rebuildLinkedPanel();
|
||||
}
|
||||
|
||||
// Linked flake panel (#235): shown when the host flake declares a
|
||||
// flake_link. The overrides write to entry.flake_link_override which the
|
||||
// backend's resolve() reads at execute time.
|
||||
function rebuildLinkedPanel() {
|
||||
const links = flakeData?.flake_links || (flakeData?.flake_link ? [flakeData.flake_link] : []);
|
||||
// Then the links of the effective variant choices, under a "<group>: <choice>"
|
||||
// heading and overridden in entry.variant_link_overrides (#420).
|
||||
const choiceLinks = entry._all_variants ? [] : choiceFlakeLinks(flakeData?.variants, entry.variant)
|
||||
.filter(([, , cl]) => cl.some(l => linkedFlakes[l?.target]));
|
||||
if (!links.some(l => linkedFlakes[l?.target]) && !choiceLinks.length) {
|
||||
linkedColumn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
linkedColumn.style.display = "flex";
|
||||
linkedColumn.replaceChildren();
|
||||
|
||||
// Migrate the legacy single override into the per-link array (#234).
|
||||
if (!Array.isArray(entry.flake_link_overrides)) {
|
||||
entry.flake_link_overrides = entry.flake_link_override ? [entry.flake_link_override] : [];
|
||||
}
|
||||
|
||||
const renderLink = (link, overrides, li) => {
|
||||
const linkedFlakeData = linkedFlakes[link?.target];
|
||||
if (!linkedFlakeData) return;
|
||||
overrides[li] = overrides[li] || { variant: {}, lora_strengths: [] };
|
||||
const ovr = overrides[li];
|
||||
ovr.variant = ovr.variant || {};
|
||||
ovr.lora_strengths = ovr.lora_strengths || [];
|
||||
const yamlDefaults = link;
|
||||
|
||||
if (li > 0) {
|
||||
const gap = document.createElement("div");
|
||||
css(gap, "border-top:2px solid #444;margin:4px 0;");
|
||||
linkedColumn.appendChild(gap);
|
||||
}
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = (linkedFlakeData.name || link.target).split("/").pop();
|
||||
title.title = `Linked: ${link.target}`;
|
||||
css(title, "font-size:9px;opacity:0.8;text-align:center;font-weight:600;color:#4a9eff;padding:2px 0;");
|
||||
linkedColumn.appendChild(title);
|
||||
|
||||
const linkedLoras = Array.isArray(linkedFlakeData.loras) ? linkedFlakeData.loras : [];
|
||||
if (linkedLoras.length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
for (let i = 0; i < linkedLoras.length; i++) {
|
||||
const sliderRow = document.createElement("div");
|
||||
css(sliderRow, "padding:2px 0;");
|
||||
const name = linkedLoras[i]?.name || "LoRA";
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
css(label, "font-size:9px;opacity:0.7;padding:2px 0;text-align:center;");
|
||||
sliderRow.appendChild(label);
|
||||
// Effective default: per-grid override > yaml default > linked flake's own
|
||||
let initial = ovr.lora_strengths[i];
|
||||
if (initial === null || initial === undefined) initial = (yamlDefaults.lora_strengths || [])[i];
|
||||
if (initial === null || initial === undefined) initial = linkedLoras[i]?.strength ?? 1.0;
|
||||
const strSlider = makeSmallValueSlider(initial, -10, 10, 0.05, (v) => {
|
||||
while (ovr.lora_strengths.length <= i) ovr.lora_strengths.push(null);
|
||||
ovr.lora_strengths[i] = v;
|
||||
onChanged();
|
||||
});
|
||||
sliderRow.appendChild(strSlider);
|
||||
linkedColumn.appendChild(sliderRow);
|
||||
}
|
||||
}
|
||||
|
||||
const linkedVariants = linkedFlakeData.variants || linkedFlakeData.options || {};
|
||||
if (Object.keys(linkedVariants).length > 0) {
|
||||
const sep = document.createElement("div");
|
||||
css(sep, "border-top:1px solid #333;margin:2px 0;");
|
||||
linkedColumn.appendChild(sep);
|
||||
const targetDefaults = variantDefaultsOf(linkedVariants);
|
||||
for (const group of Object.keys(linkedVariants)) {
|
||||
const targetDefault = targetDefaults[group];
|
||||
const row = document.createElement("div");
|
||||
css(row, "display:flex;flex-direction:column;gap:2px;");
|
||||
const gLabel = document.createElement("span");
|
||||
gLabel.textContent = group;
|
||||
css(gLabel, "font-size:9px;opacity:0.7;text-align:center;");
|
||||
row.appendChild(gLabel);
|
||||
const yamlChoice = (yamlDefaults.variant || {})[group] || "";
|
||||
const current = ovr.variant[group] || yamlChoice;
|
||||
const dd = makePanelDropdown(variantDropdownOptions(Object.keys(linkedVariants[group]), targetDefault), current === targetDefault ? "" : current);
|
||||
// Choice LoRA sliders: override > link yaml > target default choice (#414).
|
||||
const vlHost = document.createElement("div");
|
||||
const renderVl = () => {
|
||||
const ch = ovr.variant[group] || yamlChoice || targetDefault;
|
||||
renderVariantLoraSliders(vlHost, linkedVariants, ovr, group, ch, onChanged, yamlDefaults.variant_loras?.[group]?.[ch] || []);
|
||||
};
|
||||
// "" (the default entry, or None without a default) follows the link
|
||||
// yaml when it is unset; otherwise it must beat the yaml choice (#419).
|
||||
dd.element.addEventListener("change", () => {
|
||||
const value = dd.element.value || (yamlChoice && (targetDefault || "__none__"));
|
||||
if (value) ovr.variant[group] = value;
|
||||
else delete ovr.variant[group];
|
||||
renderVl();
|
||||
onChanged();
|
||||
});
|
||||
row.append(dd.container, vlHost);
|
||||
linkedColumn.appendChild(row);
|
||||
renderVl();
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedLoras.length === 0 && Object.keys(linkedVariants).length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
css(empty, "font-size:9px;opacity:0.5;padding:4px;text-align:center;");
|
||||
empty.textContent = "no overrides";
|
||||
linkedColumn.appendChild(empty);
|
||||
}
|
||||
};
|
||||
links.forEach((link, li) => renderLink(link, entry.flake_link_overrides, li));
|
||||
for (const [group, choice, cl] of choiceLinks) {
|
||||
const heading = document.createElement("div");
|
||||
heading.textContent = `${group}: ${choice}`;
|
||||
css(heading, `font-size:9px;opacity:0.8;text-align:center;font-weight:600;padding:2px 0;${linkedColumn.childElementCount ? "border-top:2px solid #444;margin-top:4px;padding-top:6px;" : ""}`);
|
||||
linkedColumn.appendChild(heading);
|
||||
const overrides = (((entry.variant_link_overrides ||= {})[group] ||= {})[choice] ||= []);
|
||||
cl.forEach((link, li) => renderLink(link, overrides, li));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +488,13 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
optionsLoaded = true;
|
||||
hasOptions = variants;
|
||||
flakeData = fdata;
|
||||
// Also fetch each linked flake's data if the host declares links.
|
||||
const links = [...(fdata?.flake_links || (fdata?.flake_link ? [fdata.flake_link] : []))];
|
||||
for (const choices of Object.values(fdata?.variants || {})) {
|
||||
for (const choice of Object.values(choices || {})) links.push(...(choice?.flake_links || []));
|
||||
}
|
||||
const targets = [...new Set(links.map(l => l?.target).filter(Boolean))];
|
||||
linkedFlakes = Object.fromEntries(await Promise.all(targets.map(async t => [t, await fetchFlake(t).catch(() => null)])));
|
||||
rebuildPanel();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -264,20 +527,20 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
}
|
||||
|
||||
if (!optionsLoaded && entry.name) {
|
||||
panel.textContent = "";
|
||||
nativeColumn.replaceChildren();
|
||||
const loading = document.createElement("div");
|
||||
css(loading, "font-size:9px;opacity:0.5;text-align:center;padding:4px;");
|
||||
loading.textContent = "loading...";
|
||||
panel.appendChild(loading);
|
||||
nativeColumn.appendChild(loading);
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
} catch {
|
||||
panel.replaceChildren();
|
||||
nativeColumn.replaceChildren();
|
||||
const err = document.createElement("div");
|
||||
css(err, "font-size:9px;color:#f88;padding:4px;text-align:center;");
|
||||
err.textContent = "failed to load";
|
||||
panel.appendChild(err);
|
||||
nativeColumn.appendChild(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,22 +558,66 @@ function makeInstanceControls(block, entry, idx, onChanged, triangleBtn, onVaria
|
||||
return { toggleOptionsPanel };
|
||||
}
|
||||
|
||||
export function setupFlakeWidget(node) {
|
||||
// kind "anim": a Flake Animate Stack node (family widget, flake_anim_data
|
||||
// input, animate flakes).
|
||||
export function setupFlakeWidget(node, { kind = "img" } = {}) {
|
||||
const familyName = kind === "anim" ? "family" : "model_family";
|
||||
const dataInput = kind === "anim" ? "flake_anim_data" : "flake_data";
|
||||
const flakesHidden = node.widgets?.find(w => w.name === "flakes_json");
|
||||
const familyWidget = node.widgets?.find(w => w.name === "model_family");
|
||||
const familyWidget = node.widgets?.find(w => w.name === familyName);
|
||||
if (!flakesHidden) return;
|
||||
|
||||
// Hide flakes_json STRING widget
|
||||
flakesHidden.computeSize = () => [0, -4];
|
||||
flakesHidden.type = "hidden";
|
||||
flakesHidden.hidden = true;
|
||||
if (flakesHidden.element) { flakesHidden.element.remove(); flakesHidden.element = null; }
|
||||
if (flakesHidden.inputEl) { flakesHidden.inputEl.remove(); flakesHidden.inputEl = null; }
|
||||
// Hide flakes_json STRING widget permanently, with serialize=true
|
||||
// so edited values (including the default flake's prompt) reach the
|
||||
// backend. Manual hiding via type/hidden/removeElement was skipping
|
||||
// serialization in ComfyUI's graph payload.
|
||||
setWidgetHidden(flakesHidden, true);
|
||||
|
||||
function getFamily() {
|
||||
return familyWidget?.value || "SDXL/Base";
|
||||
}
|
||||
|
||||
// Hide model_family widget when flake_data input is connected, and infer
|
||||
// the family value from the upstream node. The widget stays in node.widgets
|
||||
// so serialization still includes model_family (REQUIRED on the Python
|
||||
// side). setWidgetHidden() in utils.js stacks every known hide signal
|
||||
// (computeSize, type, hidden, draw, options, DOM element display).
|
||||
let _familyHidden = false;
|
||||
function _inferUpstreamFamily() {
|
||||
const flakeDataInput = node.inputs?.find(i => i.name === dataInput);
|
||||
if (!flakeDataInput || flakeDataInput.link == null) return null;
|
||||
const link = node.graph?.links?.[flakeDataInput.link];
|
||||
if (!link) return null;
|
||||
const upstreamNode = node.graph.getNodeById(link.origin_id);
|
||||
if (!upstreamNode) return null;
|
||||
const upstreamFamily = upstreamNode.widgets?.find(w => w.name === familyName);
|
||||
return upstreamFamily?.value || null;
|
||||
}
|
||||
function _isFlakeDataConnected() {
|
||||
const flakeDataInput = node.inputs?.find(i => i.name === dataInput);
|
||||
return !!(flakeDataInput && flakeDataInput.link != null);
|
||||
}
|
||||
function _updateFamilyVisibility() {
|
||||
if (!familyWidget) return;
|
||||
// Hide whenever the input pin is connected — not only when the upstream
|
||||
// family is inferable. Otherwise a connected-but-uninferable upstream
|
||||
// leaves the widget visible-but-disabled (#261).
|
||||
const shouldHide = _isFlakeDataConnected();
|
||||
const upstream = _inferUpstreamFamily();
|
||||
if (upstream !== null && upstream !== familyWidget.value) {
|
||||
familyWidget.value = upstream;
|
||||
if (familyWidget.callback) familyWidget.callback(upstream);
|
||||
}
|
||||
if (shouldHide === _familyHidden) return;
|
||||
_familyHidden = shouldHide;
|
||||
setWidgetHidden(familyWidget, shouldHide);
|
||||
node.setDirtyCanvas(true, true);
|
||||
// Note: we deliberately don't call node.setSize() here — doing so
|
||||
// overrode user-resized node sizes on reload.
|
||||
}
|
||||
const _familyPoll = setInterval(_updateFamilyVisibility, 200);
|
||||
setTimeout(_updateFamilyVisibility, 0);
|
||||
|
||||
function readEntries() {
|
||||
try {
|
||||
const arr = JSON.parse(flakesHidden.value || "[]");
|
||||
@@ -330,9 +637,35 @@ export function setupFlakeWidget(node) {
|
||||
const container = document.createElement("div");
|
||||
css(container, "display:flex;flex-direction:column;gap:2px;padding:0 6px 3px 6px;font-size:12px;color:#ddd;");
|
||||
|
||||
// Toggle-all button (#315): activate/deactivate every flake on the grid at once.
|
||||
// Matches the native Generation Data button via the shared helper (#338),
|
||||
// centered in the column.
|
||||
const { btn: toggleAll, lbl: toggleAllLbl } = makeAccentButton("Activate all");
|
||||
toggleAll.style.alignSelf = "center";
|
||||
toggleAll.style.margin = "2px 0";
|
||||
function setAllActive(active) {
|
||||
const arr = readEntries();
|
||||
for (let i = 1; i < arr.length; i++) arr[i].bypassed = !active; // skip inline Default
|
||||
writeEntries(arr);
|
||||
render();
|
||||
}
|
||||
function refreshToggleAll() {
|
||||
const arr = readEntries().slice(1);
|
||||
const allActive = arr.length > 0 && arr.every(e => !e.bypassed);
|
||||
toggleAllLbl.textContent = allActive ? "Deactivate all" : "Activate all";
|
||||
}
|
||||
toggleAll.addEventListener("click", () => {
|
||||
const arr = readEntries().slice(1);
|
||||
const allActive = arr.length > 0 && arr.every(e => !e.bypassed);
|
||||
setAllActive(!allActive);
|
||||
refreshToggleAll();
|
||||
});
|
||||
container.appendChild(toggleAll);
|
||||
|
||||
// Flakes grid
|
||||
const [itemW, itemH] = ITEM_SIZE[kind];
|
||||
const grid = document.createElement("div");
|
||||
css(grid, "display:grid;grid-template-columns:repeat(auto-fill, minmax(72px, 1fr));gap:4px;");
|
||||
css(grid, `display:grid;grid-template-columns:repeat(auto-fill, minmax(${itemW}px, 1fr));gap:4px;`);
|
||||
container.appendChild(grid);
|
||||
|
||||
let dragSrcIdx = null;
|
||||
@@ -347,17 +680,21 @@ export function setupFlakeWidget(node) {
|
||||
const { block: blk, triangleBtn, refreshVisuals } = makeBlock({
|
||||
entry: entries[i],
|
||||
idx: i,
|
||||
kind,
|
||||
onEdit: handleEdit,
|
||||
onRemove: handleRemove,
|
||||
onReplace: handleReplace,
|
||||
onToggleBypass: handleToggleBypass,
|
||||
onDuplicate: handleDuplicate,
|
||||
onImagesChanged: () => { writeEntries(entries); render(); },
|
||||
onDragStart: (e, idx, el) => {
|
||||
dragSrcIdx = idx;
|
||||
Object.assign(flakeDrag, { node, entry: entries[idx], take: () => takeDragged(idx), kind });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
el.style.opacity = "0.4";
|
||||
},
|
||||
onDragOver: (e, idx, el) => {
|
||||
if (dragSrcIdx === null || idx === 0 || idx === dragSrcIdx) return;
|
||||
if (!isForeignDrag() && (dragSrcIdx === null || idx === 0 || idx === dragSrcIdx)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
_showDropIndicator(el);
|
||||
@@ -365,6 +702,10 @@ export function setupFlakeWidget(node) {
|
||||
onDrop: (e, idx, el) => {
|
||||
e.preventDefault();
|
||||
_hideDropIndicator(el);
|
||||
if (isForeignDrag()) {
|
||||
dropForeign(Math.max(idx, 1)); // never displace the inline Default
|
||||
return;
|
||||
}
|
||||
if (dragSrcIdx === null || idx === 0 || idx === dragSrcIdx) return;
|
||||
const arr = readEntries();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
@@ -378,6 +719,7 @@ export function setupFlakeWidget(node) {
|
||||
onDragEnd: (el) => {
|
||||
el.style.opacity = "";
|
||||
dragSrcIdx = null;
|
||||
clearFlakeDrag();
|
||||
_hideAllDropIndicators();
|
||||
},
|
||||
});
|
||||
@@ -385,6 +727,37 @@ export function setupFlakeWidget(node) {
|
||||
grid.appendChild(blk);
|
||||
}
|
||||
if (grid._addBlock) grid.appendChild(grid._addBlock);
|
||||
refreshToggleAll();
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// Cross-node drag & drop with other Flake Stack / Flake Combo grids (#415).
|
||||
function isForeignDrag() {
|
||||
return flakeDrag.node !== null && flakeDrag.node !== node && flakeDrag.kind === kind;
|
||||
}
|
||||
|
||||
// The source block leaves the DOM on re-render, so its dragend may never fire.
|
||||
function takeDragged(idx) {
|
||||
const arr = readEntries();
|
||||
arr.splice(idx, 1);
|
||||
writeEntries(arr);
|
||||
dragSrcIdx = null;
|
||||
clearFlakeDrag();
|
||||
render();
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function dropForeign(at) {
|
||||
const entry = JSON.parse(JSON.stringify(flakeDrag.entry));
|
||||
delete entry._uid;
|
||||
for (const [group, choice] of Object.entries(entry.variant || {})) {
|
||||
if (Array.isArray(choice)) entry.variant[group] = choice[0];
|
||||
}
|
||||
const arr = readEntries();
|
||||
arr.splice(at, 0, entry);
|
||||
writeEntries(arr);
|
||||
render();
|
||||
flakeDrag.take();
|
||||
}
|
||||
|
||||
async function handleEdit(idx) {
|
||||
@@ -404,12 +777,15 @@ export function setupFlakeWidget(node) {
|
||||
}
|
||||
}
|
||||
|
||||
const { directories } = await fetchList(getFamily());
|
||||
const { directories } = await fetchList(getFamily(), kind);
|
||||
const result = await openEditModal({
|
||||
mode: isDefault ? "default" : "edit",
|
||||
name: entry.name,
|
||||
data,
|
||||
dirs: directories,
|
||||
// The inline Default animate flake takes its family from the node.
|
||||
family: kind === "anim" ? getFamily() : undefined,
|
||||
kind,
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
@@ -432,6 +808,8 @@ export function setupFlakeWidget(node) {
|
||||
if (result.name && result.name !== arr[idx].name) arr[idx].name = result.name;
|
||||
arr[idx]._edited_at = Date.now();
|
||||
arr[idx].flake_type = result.data?.flake_type || null;
|
||||
arr[idx].favorite = !!result.data?.favorite;
|
||||
if (result.data?.name) arr[idx].display_name = appendLoraTags(result.data.name, result.data, arr[idx].variant);
|
||||
arr[idx].has_lora = !!(result.data && (result.data.path || (result.data.loras && result.data.loras.length > 0)));
|
||||
// Clear per-instance overrides so the grid item reflects the new
|
||||
// defaults instead of masking them with stale values.
|
||||
@@ -466,36 +844,48 @@ export function setupFlakeWidget(node) {
|
||||
render();
|
||||
}
|
||||
|
||||
function handleDuplicate(idx) {
|
||||
if (idx === 0) return; // never duplicate the inline Default entry
|
||||
const arr = readEntries();
|
||||
const copy = JSON.parse(JSON.stringify(arr[idx]));
|
||||
arr.splice(idx + 1, 0, copy);
|
||||
writeEntries(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleReplace(idx) {
|
||||
if (idx === 0) return;
|
||||
const { flakes, directories, display_names } = await fetchList(getFamily());
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names });
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList(getFamily(), kind);
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), kind, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!result || !result.name) return;
|
||||
const arr = readEntries();
|
||||
let has_lora = false;
|
||||
let display_name = null;
|
||||
let flake_type = null;
|
||||
let favorite = false;
|
||||
let loras = [];
|
||||
try {
|
||||
const d = await fetchFlake(result.name);
|
||||
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
|
||||
display_name = d.name || null;
|
||||
display_name = appendLoraTags(d.name || null, d);
|
||||
flake_type = d.flake_type || null;
|
||||
favorite = !!d.favorite;
|
||||
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
|
||||
else if (d.path) loras = [d.strength ?? 1.0];
|
||||
} catch {}
|
||||
arr[idx] = { name: result.name, loras, variant: {}, has_lora, display_name, flake_type };
|
||||
arr[idx] = { name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite, images: arr[idx].images };
|
||||
writeEntries(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleNew() {
|
||||
const { directories } = await fetchList(getFamily());
|
||||
const { directories } = await fetchList(getFamily(), kind);
|
||||
const result = await openEditModal({
|
||||
mode: "create",
|
||||
data: {},
|
||||
dirs: directories,
|
||||
family: getFamily(),
|
||||
kind,
|
||||
});
|
||||
if (!result || !result.created) return;
|
||||
invalidateList(); // ensure the load picker sees the newly created flake
|
||||
@@ -524,38 +914,66 @@ export function setupFlakeWidget(node) {
|
||||
flake_type = flake_type || d.flake_type || null;
|
||||
} catch {}
|
||||
}
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
|
||||
display_name = appendLoraTags(display_name, result.data);
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type, favorite: !!result.data?.favorite });
|
||||
writeEntries(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleLoad() {
|
||||
const { flakes, directories, display_names } = await fetchList(getFamily());
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), displayNames: display_names });
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList(getFamily(), kind);
|
||||
const result = await openFileLoadPicker({ flakes, directories, family: getFamily(), kind, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!result || !result.name) return;
|
||||
await addFlake(result.name);
|
||||
}
|
||||
|
||||
// Animate grids: copy a picked image flake into the node's family, edit
|
||||
// the copy, then add it like a loaded flake (#433).
|
||||
async function handleImport() {
|
||||
const { flakes, directories, display_names, tag_names, favorites, folder_covers } = await fetchList("", "img");
|
||||
const picked = await openFileLoadPicker({ flakes, directories, displayNames: display_names, tagNames: tag_names, favorites, folderCovers: folder_covers });
|
||||
if (!picked || !picked.name) return;
|
||||
let name;
|
||||
let data;
|
||||
try {
|
||||
name = await importAnimFlakeApi(picked.name, getFamily());
|
||||
data = await fetchFlake(name);
|
||||
} catch (err) {
|
||||
window.alert(`Failed to import ${picked.name}: ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
const result = await openEditModal({ mode: "edit", name, data, dirs: (await fetchList(getFamily(), kind)).directories, kind });
|
||||
if (result?.deleted) return;
|
||||
await addFlake(result?.name || name);
|
||||
}
|
||||
|
||||
async function addFlake(name) {
|
||||
const arr = readEntries();
|
||||
let has_lora = false;
|
||||
let display_name = null;
|
||||
let flake_type = null;
|
||||
let favorite = false;
|
||||
let loras = [];
|
||||
try {
|
||||
const d = await fetchFlake(result.name);
|
||||
const d = await fetchFlake(name);
|
||||
has_lora = !!(d && (d.path || (d.loras && d.loras.length > 0)));
|
||||
display_name = d.name || null;
|
||||
display_name = appendLoraTags(d.name || null, d);
|
||||
flake_type = d.flake_type || null;
|
||||
favorite = !!d.favorite;
|
||||
if (d.loras) loras = d.loras.map(l => l.strength ?? 1.0);
|
||||
else if (d.path) loras = [d.strength ?? 1.0];
|
||||
} catch {}
|
||||
arr.push({ name: result.name, loras, variant: {}, has_lora, display_name, flake_type });
|
||||
arr.push({ name, loras, variant: {}, has_lora, display_name, flake_type, favorite });
|
||||
writeEntries(arr);
|
||||
render();
|
||||
}
|
||||
|
||||
grid._addBlock = makeAddBlock({ onNew: handleNew, onLoad: handleLoad });
|
||||
grid._addBlock = makeAddBlock({ onNew: handleNew, onLoad: handleLoad, onImport: kind === "anim" ? handleImport : null });
|
||||
grid._addBlock.style.height = `${itemH}px`;
|
||||
|
||||
// Allow dropping after the last item onto the add block
|
||||
grid._addBlock.addEventListener("dragover", (e) => {
|
||||
if (dragSrcIdx === null) return;
|
||||
if (dragSrcIdx === null && !isForeignDrag()) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
_showDropIndicator(grid._addBlock);
|
||||
@@ -566,6 +984,10 @@ export function setupFlakeWidget(node) {
|
||||
grid._addBlock.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
_hideDropIndicator(grid._addBlock);
|
||||
if (isForeignDrag()) {
|
||||
dropForeign(readEntries().length);
|
||||
return;
|
||||
}
|
||||
if (dragSrcIdx === null) return;
|
||||
const arr = readEntries();
|
||||
const [moved] = arr.splice(dragSrcIdx, 1);
|
||||
@@ -585,13 +1007,47 @@ export function setupFlakeWidget(node) {
|
||||
};
|
||||
}
|
||||
|
||||
// Flake Animate Stack: the first job's input image under the grid (#442).
|
||||
const preview = kind === "anim" ? makeImageSection({ emptyText: "No input image" }) : null;
|
||||
let previewPath;
|
||||
if (preview) {
|
||||
container.appendChild(preview.el);
|
||||
preview.imageEl.addEventListener("load", () => {
|
||||
node.setSize([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
}
|
||||
function refreshPreview() {
|
||||
const path = stackImageJobs(node)[0]?.image ?? null;
|
||||
if (!preview || path === previewPath) return;
|
||||
previewPath = path;
|
||||
if (path) preview.show(pickedImageUrl(path), path.split(/[\\/]/).pop(), path);
|
||||
else preview.clear();
|
||||
}
|
||||
// Image scaled to the widget width (6px padding + 4px margin a side), plus
|
||||
// the filename and dimension labels; the empty label otherwise.
|
||||
function previewHeight() {
|
||||
if (!preview) return 0;
|
||||
const { naturalWidth: w, naturalHeight: h } = preview.imageEl;
|
||||
return previewPath && w ? Math.min(w, node.size[0] - 20) * h / w + 46 : 36;
|
||||
}
|
||||
|
||||
// ---- Widget registration ----
|
||||
node._flakes_render = render;
|
||||
const flakeWidget = node.addDOMWidget("flakes_ui", "div", container, { serialize: false, margin: 4 });
|
||||
flakeWidget.computeSize = () => {
|
||||
const rows = Math.max(1, Math.ceil((readEntries().length + 1) / 2));
|
||||
return [node.size[0], rows * 84 + 31];
|
||||
// Match the actual auto-fill column count (minmax item width + 4px gap) so a
|
||||
// wide node doesn't over-count rows and pad itself with empty space (#317).
|
||||
const cols = Math.max(1, Math.floor((node.size[0] - 16) / (itemW + 4)));
|
||||
const rows = Math.max(1, Math.ceil((readEntries().length + 1) / cols));
|
||||
return [node.size[0], rows * (itemH + 4) + 55 + previewHeight()]; // +24 for the toggle-all button (#315)
|
||||
};
|
||||
writeEntries(readEntries());
|
||||
render();
|
||||
|
||||
const origOnRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
clearInterval(_familyPoll);
|
||||
return origOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
+991
-235
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user