297 Commits
Author SHA1 Message Date
Mehdi Chaid 58b7b32332 feat: Generation Data overlay shows the auto-caption under the input image (#485) 2026-11-09 21:36:30 -05:00
Mehdi Chaid 0caf422e1d feat: queue captions animate start images before the video jobs (#484) 2026-11-09 21:35:18 -05:00
Mehdi Chaid f9bb50ef2f feat: Flake Animate Caption node and caption input on Flake Generate Animation (#483) 2026-11-09 21:30:23 -05:00
Mehdi Chaid 7415229552 feat: built-in auto-caption instructions for MiniMax H3 and Wan 2.2 (#482) 2026-11-09 21:28:19 -05:00
Mehdi Chaid 48a5441ddb feat: auto-caption fields on animate presets and flakes (#481) 2026-11-09 21:27:15 -05:00
Mehdi Chaid 7ef26cd8a0 feat: dropped outputs restore their workflow from .flake_metadata (#480) 2026-11-09 21:24:49 -05:00
Mehdi Chaid b8d636c7ac feat: flake metadata goes to .flake_metadata sidecars instead of the image (#479) 2026-11-09 21:21:42 -05:00
Mehdi Chaid 4a12adb41b feat: scope button previews flake cover, variant choice cover and ControlNet images (#478) 2026-10-30 13:35:14 -04:00
Mehdi Chaid baff0abb48 feat: flake options reorder by drag (#477) 2026-10-29 16:20:05 -04:00
Mehdi Chaid c59f59e804 feat: variant choice LoRAs can tag the flake with their name (#476) 2026-10-28 22:48:45 -04:00
Mehdi Chaid f353035326 feat: option prompt fields wrap and grow like prompt fields (#475) 2026-10-28 22:45:12 -04:00
Mehdi Chaid 7f39271bd5 feat: variant multi picker has an Unselect all button (#474) 2026-10-28 22:44:36 -04:00
Mehdi Chaid 1e2ded7617 feat: model family text input with autocomplete on preset nodes (#472) 2026-10-27 20:13:59 -04:00
Mehdi Chaid b1eeb30bcd feat: frontend resolves typed model families like the backend (#471) 2026-10-27 20:12:53 -04:00
Mehdi Chaid b8d55be1a4 feat: model family values resolve to registered or new families (#470) 2026-10-27 20:11:43 -04:00
Mehdi Chaid fe18accf9d feat: variant choice negative prompt hidden until added (#469) 2026-10-27 12:38:19 -04:00
Mehdi Chaid 4cdc08eb22 feat: variant choice prompts are an optional field with several prompts (#468) 2026-10-27 12:37:48 -04:00
Mehdi Chaid 1227b048e1 feat: prompt options carry an optional negative prompt (#467) 2026-10-27 12:35:14 -04:00
Mehdi Chaid df4c817333 flake option decorations 2026-10-27 12:25:02 -04:00
Mehdi Chaid d74007b58d feat: Toggler field suggests option names used in other flakes (#466) 2026-10-27 11:35:17 -04:00
Mehdi Chaid 66c8c8de96 feat: overrides dropdown toggles flake and variant choice options (#465) 2026-10-27 11:33:43 -04:00
Mehdi Chaid d6d6554c86 feat: Options and Toggler fields on flakes and variant choices (#464) 2026-10-27 11:32:26 -04:00
Mehdi Chaid 758c226a25 feat: saved stack entries drop option toggles the flake does not use (#463) 2026-10-27 11:30:16 -04:00
Mehdi Chaid c9a1765540 feat: flake options and downstream togglers (#462) 2026-10-27 11:29:40 -04:00
Mehdi Chaid ce7879e741 feat: saved stack entries drop overrides for unused variant choices (#461)
variant_loras and variant_link_overrides keep only the choices the entry resolved to. Closes #461
2026-10-26 15:48:13 -04:00
Mehdi Chaid ba03770ad1 feat: saved image bundles keep only the variant choices used (#460)
resolve() records the choices it applied in Flake.used_variants, linked flakes included. Flake Generate passes them to collect_bundle, which drops the other choices, the files only they reference and the flakes only they link to. Closes #460
2026-10-26 15:47:43 -04:00
Mehdi Chaid 40823f3995 feat: typing a LoRA name turns on name tagging by default (#459)
Closes #459.
2026-10-24 20:35:50 -04:00
Mehdi Chaid 39ccf9928f feat: separate LoRA toggle adds its name to Output Stem and Path (#458)
The LoRA name tag no longer fills Output Stem/Path; a new right-aligned "Add to output path" toggle does, in create mode only. Closes #458.
2026-10-24 20:35:27 -04:00
Mehdi Chaid c095dd720d feat: LoRA Name field suggests display names used in other flakes (#457) 2026-10-23 16:14:55 -04:00
Mehdi Chaid a72efcd71a perf: /flakes/list parses each flake once (#454) 2026-10-22 23:31:24 -04:00
Mehdi Chaid 24938cf999 fix: serve flake covers as cacheable files with thumbnails (#451) 2026-10-22 23:31:20 -04:00
Mehdi Chaid 4b97c8a573 perf: fewer node renders and cached preset/list requests on load (#453) 2026-10-22 23:25:19 -04:00
Mehdi Chaid ff49dea997 fix: bundle staging no longer blocks workflow load (#452) 2026-10-22 23:25:19 -04:00
Mehdi Chaid f6b0621ad1 feat: attention backend selector on Flake Generate and Flake Generate Animation (#455) 2026-10-22 23:24:27 -04:00
Mehdi Chaid 9af5d64423 feat: Generate Animation overlay shows the input image card (#456) 2026-10-22 23:23:54 -04:00
Mehdi Chaid c40c53f587 fix: file picker breadcrumb regex broke loading of every Flakes node UI (#449) 2026-10-22 18:08:52 -04:00
Mehdi Chaid 1eb57d0b21 feat: drag & drop input images onto Flake Animate grid items (#448) 2026-10-22 15:45:03 -04:00
Mehdi Chaid 4f04c88d5f feat: drag & drop images onto flake and preset image fields (#447) 2026-10-22 15:45:03 -04:00
Mehdi Chaid 8215055c14 feat: file picker image preview button (#450) 2026-10-22 15:42:29 -04:00
Mehdi Chaid f2f5489bb9 feat: file picker breadcrumb navigation (#449) 2026-10-22 15:42:29 -04:00
Mehdi Chaid 53166cb840 refactor: Flake Animate uses video/ folders for outputs, flakes and presets (#445) 2026-10-22 10:39:54 -04:00
Mehdi Chaid ef0dad95f7 fix: new flake and preset dialogs never offer the temp local directory (#446) 2026-10-22 10:38:48 -04:00
Mehdi Chaid 034abfc26a feat: Load Existing Flake folder tiles look like Windows folders (#444) 2026-10-22 10:38:33 -04:00
Mehdi Chaid c3aa0219ae fix: image grid items 25% larger so hover buttons fit on one row (#443) 2026-10-22 10:38:33 -04:00
Mehdi Chaid beaf0b641f feat: image metadata flakes stay local to the workflow until saved on disk (#437) 2026-10-21 17:55:06 -04:00
Mehdi Chaid 0dc0cc77a4 feat: Flake Animate Stack previews the first active input image (#442) 2026-10-21 17:53:05 -04:00
Mehdi Chaid 959862c1d0 feat: Flake Animate Stack / Combo per-item input image lists (#441) 2026-10-21 17:50:17 -04:00
Mehdi Chaid da1b782282 feat: image metadata merges stacks into one compact node (#436) 2026-10-21 17:46:05 -04:00
Mehdi Chaid cf2c6a2598 feat: Flake Animate Preset cover from the selected diffusion model (#440) 2026-10-21 17:45:56 -04:00
Mehdi Chaid 2a8278d7df fix: double-clicking an output video reveals its file instead of fullscreen (#439) 2026-10-21 17:44:30 -04:00
Mehdi Chaid 515d011a25 fix: Flake Generate Animation upscaler field and upscale / interpolation / headroom toggles (#438) 2026-10-21 17:44:30 -04:00
Mehdi Chaid f07dcfaaa7 feat: Import Flake into Flake Animate grids (#433) 2026-10-21 13:39:55 -04:00
Mehdi Chaid 2e6aef56a0 feat: Flake Generate Animation node with video overlay (#434) 2026-10-21 13:39:07 -04:00
Mehdi Chaid b9fe43f4fc feat: Flake Animate Stack and Combo nodes (#432)
Compose animate flakes onto flake_anim_data: LoRAs model-only (high/low on Wan 2.2), joined prompts, stems and an optional start image. The Flake Stack/Combo widgets, load picker and queue combos take an anim kind.
2026-10-21 13:35:49 -04:00
Mehdi Chaid 5a5870990b feat: Flake Animate Preset and Preset Combo nodes (#430) 2026-10-21 13:30:22 -04:00
Mehdi Chaid c5b7599f60 feat: portable flake bundles embedded in output images (#427) 2026-10-21 13:27:36 -04:00
Mehdi Chaid 7012f1554c feat: Flake Animate flake storage and editor with high/low LoRAs (#431) 2026-10-21 13:27:24 -04:00
Mehdi Chaid 48c0edad2d feat: image metadata only describes the combination that produced it (#426) 2026-10-21 13:20:15 -04:00
Mehdi Chaid 59dbed428a feat: Flake Animate families and preset storage (#429) 2026-10-21 13:20:02 -04:00
Mehdi Chaid dfcea9d757 feat: Flake Inpaint chain node feeding Flake Generate (#428) 2026-10-21 13:19:10 -04:00
Mehdi Chaid c5f335ed80 feat: Load Existing Flake folder tiles show a cover thumbnail (#424) 2026-10-21 13:17:37 -04:00
Mehdi Chaid 7cfa5536f0 feat: Generate overlay opens on the combination being generated (#423) 2026-10-21 13:14:05 -04:00
Mehdi Chaid 327bca9c87 feat: Character flakes default the first variant choice on and LoRA strength 0.8 (#425) 2026-10-21 13:12:07 -04:00
Mehdi Chaid be332511ec chore: release v0.2.0 (#422) 2026-10-19 20:39:48 -04:00
Mehdi Chaid ae4f534076 fix: variant choice flake links open the picker, show in the grid dropdown and Generation Data (#420)
The choice menu's Flake Link called openFileLoadPicker, whose import was
dropped in #386; the ReferenceError was swallowed by the catch, so the
picker never opened and no link was added. Use pickFlakeTarget instead.

Choice links get per-choice grid overrides (variant_link_overrides), show
under a "<group>: <choice>" heading in the linked column, are listed in
Generation Data, and use the shared link override editor.
2026-10-19 20:37:46 -04:00
Mehdi Chaid 282838efef feat: All Variants shows [*] on the grid item (#421) 2026-10-19 20:31:30 -04:00
Mehdi Chaid 6491125cf2 fix: flake link dropdowns show the linked flake's default variant choice (#419) 2026-10-19 12:44:24 -04:00
Mehdi Chaid 0503f07026 fix: Generate overlay shrinks to the fitted image when Fit in View is on (#417) 2026-10-19 12:43:10 -04:00
Mehdi Chaid 2b34422e63 fix: re-picking Multiple reopens the variant selection overlay (#418) 2026-10-19 12:42:29 -04:00
Mehdi Chaid 6d7ed659c0 docs: variant LoRA strength overrides, cross-node drag & drop, overlay layout (#416) 2026-10-19 10:54:44 -04:00
Mehdi Chaid a54800eac5 feat: drag & drop flake items between Flake Stack / Flake Combo nodes (#415) 2026-10-19 10:53:48 -04:00
Mehdi Chaid 4ee3899f4b feat: flake links override the linked variant choice LoRA strengths (#414) 2026-10-19 10:51:20 -04:00
Mehdi Chaid be9e60435e feat: variant choice LoRA strength overrides in Flake Stack / Combo dropdowns (#413) 2026-10-19 10:49:31 -04:00
Mehdi Chaid 638a3f9cf4 fix: Generate overlay widens for landscape outputs, Fit in View contain/scroll (#412) 2026-10-19 10:44:35 -04:00
Mehdi Chaid 149c8e3aee fix: Generate overlay card stack fills the panel, single scrollbar (#411) 2026-10-19 10:43:13 -04:00
Mehdi Chaid c38322a6ae fix: Generate overlay card stack falls back to the flake cover (#410) 2026-10-19 10:39:47 -04:00
Mehdi Chaid ef317b10ea docs: variant LoRAs, dropdown default and Multiple, Generate overlay layout (#409) 2026-10-18 23:12:43 -04:00
Mehdi Chaid 08d1db2109 feat: Flake Combo Multiple variant choices with Randomize (#406) 2026-10-18 23:12:12 -04:00
Mehdi Chaid 1739446843 feat: variant dropdown lists None first and marks the default choice (#405) 2026-10-18 23:08:48 -04:00
Mehdi Chaid 81758c10d1 feat: Generate overlay collapses metadata behind a toggle, larger combo cards (#408) 2026-10-18 23:07:42 -04:00
Mehdi Chaid dd49d53b97 feat: Generate overlay widens the image panel for landscape outputs (#407) 2026-10-18 23:07:21 -04:00
Mehdi Chaid 0acac058fb feat: variant choice LoRAs get name/URL, negative strength and full-size controls (#404) 2026-10-18 23:06:22 -04:00
Mehdi Chaid 68cf06c8d7 docs: favorites, variant choice helpers, ControlNet defaults, drive tabs (#403) 2026-10-18 18:59:03 -04:00
Mehdi Chaid 2e30997519 feat: favorite flakes (#402) 2026-10-18 18:58:35 -04:00
Mehdi Chaid 6599fb02ef feat: preset picker shares the flake picker with drive tabs (#399) 2026-10-18 18:56:15 -04:00
Mehdi Chaid 528ec591d1 feat: variant choice name-to-stem button, inherited fields and lettered duplicate names (#401) 2026-10-18 18:55:09 -04:00
Mehdi Chaid 05c76f959c feat: img res on by default for new ControlNets (#400) 2026-10-18 18:53:57 -04:00
Mehdi Chaid f1ad7c3fbb feat: Anima ControlNet defaults to VACE depth at 0.7 until 0.3 (#398) 2026-10-17 19:15:47 -04:00
Mehdi Chaid eeee21e6ea feat: ControlNet image sets the flake and variant choice cover from its batch's _0_cover (#397) 2026-10-17 19:15:35 -04:00
Mehdi Chaid 49addbd068 feat: picker defaults to the extra model path drive and the flake's folder, adds loras/ tab and drive tabs to the flake picker (#395) 2026-10-17 19:14:03 -04:00
Mehdi Chaid 634a3fba17 feat: Fit Output Image in View on by default (#390) 2026-10-17 19:12:08 -04:00
Mehdi Chaid 655deb4385 fix: saving a flake with an image picked on another drive than ComfyUI's (#396) 2026-10-17 19:11:41 -04:00
Mehdi Chaid ed15e78108 feat: picker shows one drive at a time with drive tabs instead of merging drives (#395) 2026-10-17 18:08:09 -04:00
Mehdi Chaid c814379b85 refactor: load flake-owned LoRAs by path instead of registering flakes as a LoRA folder (#391) 2026-10-17 17:57:42 -04:00
Mehdi Chaid 648a785b87 fix: saving a flake whose folder is reached through another drive or a junction (#396) 2026-10-17 17:57:38 -04:00
Mehdi Chaid ec4dfd7939 fix: picker merges folders whose names differ only in case (#395) 2026-10-17 17:57:21 -04:00
Mehdi Chaid 92d0b4eb52 fix: picker's models/ folder includes extra model paths (#395) 2026-10-17 17:30:24 -04:00
Mehdi Chaid c8263d51a2 feat: grid picker for the preset VAE, text encoder and diffusion model (#395) 2026-10-17 15:20:52 -04:00
Mehdi Chaid d1f9b2b6ef feat: pick covers, ControlNet and variant images from any ComfyUI folder; keep flake-owned images in place (#396) 2026-10-17 15:05:17 -04:00
Mehdi Chaid 9e7f95fd09 feat: grid file picker over input, output, flakes and models with thumbnails (#395) 2026-10-17 15:02:43 -04:00
Mehdi Chaid 47e9a4736d fix: input image thumbnails load from their subfolder; Explorer-style 7-wide grid with folders first (#392) 2026-10-17 13:03:35 -04:00
Mehdi Chaid dd0be7b06a feat: thumbnail grid in the input image picker (#392) 2026-10-17 12:49:05 -04:00
Mehdi Chaid 4390eb64b0 fix: pick ControlNet and variant images in place so the save moves the originals and their batch (#392) 2026-10-17 12:47:00 -04:00
Mehdi Chaid b30a6c2640 fix: ControlNet batches only take image files (#392) 2026-10-17 12:12:11 -04:00
Mehdi Chaid 3304bca1b9 docs: covers outside input/ and LoRA folders stay referenced (#394) 2026-10-17 12:10:58 -04:00
Mehdi Chaid 7f75d64052 fix: only move flake data that lives in input/ or a LoRA folder (#392) 2026-10-17 12:10:52 -04:00
Mehdi Chaid 993ca95539 docs: portable flakes and the overlay fit toggle (#394) 2026-10-17 12:09:28 -04:00
Mehdi Chaid 03402fd8f3 fix: editor no longer deletes the cover a save just moved into the flake folder (#392) 2026-10-17 12:09:04 -04:00
Mehdi Chaid 69f73883f1 feat: move a flake's external data into its folder on save (#392) 2026-10-17 12:08:24 -04:00
Mehdi ChaidandClaude Opus 5.5 8d7d82dd87 feat: editor previews for flake-owned ControlNet images (#393)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:04:16 -04:00
Mehdi ChaidandClaude Opus 5.5 da8ef455b5 feat: ./ paths relative to the flake yaml and owned cover location (#391)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:03:05 -04:00
Mehdi ChaidandClaude Opus 5.5 7ace16007e feat: Fit Output Image in View toggle in the generation overlay (#390)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:59:35 -04:00
Mehdi Chaid 538ef6220d docs: live preview, Anima ControlNet model picker, preset LoRAs, variant defaults (#389) 2026-10-16 19:38:24 -04:00
Mehdi Chaid 2fc6678292 feat: compact card-stack placeholder spread on both axes (#384)
The not-yet-generated placeholder is a compact 16:10 box (max 40vh) whatever the output aspect, with smaller cards scattered over a seeded jittered grid on both axes. Real images and live previews keep the output aspect.
2026-10-16 19:37:33 -04:00
Mehdi Chaid 4ea6165025 fix: overlay previews break when Flake Combo grid items are reordered (#383)
Combo keys use a persistent per-item uid (Flake Combo entry _uid, Flake Model Combo _combo_uids) instead of grid positions, and a sorted group=choice sub-key for All Variants items. Results, live previews, progress bars and generating highlights follow the item when it moves. The overlay now also has an axis for FlakeStack All Variants flakes.
2026-10-16 19:37:33 -04:00
Mehdi Chaid b4ce9ca0f7 fix: live preview never shows because ComfyUI previews are off by default (#380)
Prompts with a FlakeGenerate request latent previews (auto) when the Live preview setting is "default" and the server runs with --preview-method none. api.queuePrompt is wrapped once for the session; batch prompt_id capture goes through the same wrapper. FlakeGenerate previews are no longer also drawn by core on the node.
2026-10-16 19:37:19 -04:00
Mehdi Chaid 99e2181392 feat: LoRAs and flake links sections in the model preset editor (#386) 2026-10-16 19:35:44 -04:00
Mehdi Chaid ca3d513ceb feat: LoRAs and flake links on model presets (data model + application) (#385) 2026-10-16 19:35:33 -04:00
Mehdi Chaid 227c577956 chore: log applied Anima ControlNet patches to diagnose missing effect (#382)
FlakeStack logs one line per applied Anima ControlNet (kind, file, image, fit size, strength, start/end and the sigma window from model_sampling); FlakeGenerate logs the model patches present on the model it samples. README explains how to read them and compare with the Anima LLLite blueprint.
2026-10-16 19:35:22 -04:00
Mehdi Chaid 34fc79bc81 feat: pick the exact Anima ControlNet file (LLLite variants or VACE depth) (#381)
An Anima ControlNet entry's model now wins over the type-inferred LLLite file when it resolves to a file in models/model_patches/anima/ or a VACE file in models/controlnet/anima/; anything else falls back to Auto. GET /flakes/cn_models?family= replaces the unused /flakes/cnmodels and lists those files with their kind; the flake editor's CN entries (flake and variant choices) get a Model dropdown with Auto (by type) for Anima, saved as model.
2026-10-16 19:34:55 -04:00
Mehdi Chaid c217d2bb51 feat: Applies by default checkbox and default variant display (#388) 2026-10-16 19:33:29 -04:00
Mehdi Chaid 8d10f1bea8 feat: variant choice applies by default (data model, resolve, meta) (#387) 2026-10-16 19:33:29 -04:00
Mehdi Chaid 9e8e5fb792 docs: hook schedulers, multiple prompts, live previews, Anima ControlNet notes (#379) 2026-10-15 21:12:12 -04:00
Mehdi Chaid 2836759773 feat: scattered card-stack placeholder for ungenerated combos in overlay (#375)
Replace the split cover mosaic with one card per selected cover, drawn at the cover's own aspect ratio with a white border and shadow, scattered along the canvas's long axis with a rotation (up to 15 degrees) and offset seeded by the combo key. A missing cover is drawn as a plain card. Used by both the combo and single-panel overlay paths.
2026-10-15 21:11:32 -04:00
Mehdi Chaid 61683d4cad feat: live sampling previews in Flake Generate node and overlay (#374)
queue.js keeps the latest b_preview_with_metadata blob URL (revoking the previous one) with its node and prompt id, mapped to the running batch combination. The Flake Generate node shows it while it samples and the overlay shows it for the selected running combination; the result replaces it on executed. Plain runs now record their result under the empty key so the overlay shows it after the preview.
2026-10-15 21:11:23 -04:00
Mehdi Chaid ecf7aceef4 fix: combo highlights and overlay results lost across tab switches and reconnects (#373)
Batch state (running combo per node id, results per FlakeGenerate node id, progress) now lives in a per-workflow store in queue.js. Nodes read it when they render and after a workflow load; the overlay reads results through a getter and re-renders when one arrives. On websocket reconnect or when the tab becomes visible, finished prompts are backfilled from /history and the running one from /queue. Results are cleared at batch start.
2026-10-15 21:11:23 -04:00
Mehdi Chaid b1d482bb60 feat: Anima VACE-Depth ControlNet support via the Advanced-ControlNet-Anima fork (#378) 2026-10-15 21:09:54 -04:00
Mehdi Chaid dc694161d1 fix: new model presets default to the extra model path root (#377) 2026-10-15 21:09:54 -04:00
Mehdi Chaid b1caac4bd4 feat: Anima preset defaults euler_ancestral / normal / QWEN_IMAGE (#376) 2026-10-15 21:09:54 -04:00
Mehdi Chaid dfc2789df3 feat: Hook Scheduler menu and keyframe editor for LoRAs, prompts and flake links (#372) 2026-10-15 21:09:54 -04:00
Mehdi Chaid 3d1f814010 feat: multiple prompt fields per flake in the editor (#371) 2026-10-15 21:09:54 -04:00
Mehdi Chaid 83ae28fb65 feat: apply LoRA and prompt hook schedules at sampling (#370) 2026-10-15 21:09:54 -04:00
Mehdi Chaid 493dc823bc feat: hook schedule and multi-prompt data model in flake files (#369) 2026-10-15 21:09:54 -04:00
Mehdi Chaid 2e7c77462a chore: Zed tasks to launch and update ComfyUI via comfy-cli (#368) 2026-10-06 11:28:03 -04:00
Mehdi Chaid 900cb92bf4 docs: Anima and Z-Image ControlNet setup (#367) 2026-10-06 11:27:45 -04:00
Mehdi Chaid b7467b77f9 feat: ControlNet type dropdown only offers types the family supports (#366) 2026-10-06 11:27:26 -04:00
Mehdi Chaid 941dc6f7ea feat: apply Anima LLLite and Z-Image Fun ControlNets as model patches (#365) 2026-10-06 11:26:53 -04:00
Mehdi Chaid cf8eb3cf95 feat: resolve Anima and Z-Image ControlNets from models/model_patches (#364) 2026-10-06 11:25:06 -04:00
Mehdi Chaid ec95ebefb5 feat: family registry declares ControlNet kind and per-type names for Anima and Z-Image (#363) 2026-10-06 11:24:33 -04:00
Mehdi Chaid 6c05e1d2e4 docs: model family support table and ComfyUI version requirement (#362)
README gains a table of which families generate and what files each needs,
plus notes on the checkpoint-vs-diffusion-model choice, the family-seeded
sampler defaults, the CFG<=1 negative zero-out and the hidden Clip Skip.

CHANGELOG records the new families, the loader fix and the registry
consolidation, and flags that Anima, Krea2 and Z-Image need ComfyUI v0.34
or newer.
2026-09-07 17:24:12 -04:00
Mehdi Chaid 23643add56 test: family registry equivalence and preset loader strategies (#361)
test_families.py pins the derived maps against the literals flake_io.py
and full_flake_node.py held before #355. That is what proves the registry
refactor did not quietly change the SDXL/Illustrious path, rather than
assuming it. Also covers registry invariants and the new families'
metadata against the shipped workflow templates.

test_preset_loader.py fakes everything at the comfy.sd boundary, so no
model weights are needed. Covers the three loading strategies, that the
UNET-only fallback does not fire when the checkpoint load raises, CLIPType
resolution, clip_skip gating, and the cfg<=1 zero-out including when cfg
arrives via an override rather than the preset.

Also repoints the four existing suites' synthetic packages at the repo
root so 'from . import flake_families' resolves — they broke in #355.

All six suites pass when run directly (66 assertions). Note: 'pytest
tests/' errors, but it already did so on cbc527c before this work — the
files install conflicting global stubs in one process.
2026-09-07 17:23:29 -04:00
Mehdi Chaid bf2efecac7 feat: serve family registry to the frontend, add diffusion model and CLIP type fields (#360)
Server: GET /flakes/families serves the flake_families registry, plus
/flakes/diffusion_models and /flakes/clip_types for the preset editor.

Web: utils.js fetches the registry and exposes familySpec/familyFromFolder.
The hardcoded literals stay as a bootstrap fallback and are only replaced
once a real payload arrives, so a failed fetch degrades to the previous
behaviour rather than breaking the editor. flake-modal and preset-modal
now derive families from the shared helpers instead of keeping three more
private copies of the list.

Preset editor gains a diffusion-model picker and a text-encoder type
dropdown, hides Clip Skip for families whose encoder is not CLIP, and
seeds a new preset from its family defaults. The registry is warmed at
extension setup because the sliders are write-once and modals build
synchronously.

Verified against a running server: all three endpoints respond and
Krea2/Turbo appears in the FlakeModelPreset model_family dropdown.
2026-09-07 17:20:11 -04:00
Mehdi Chaid 97d3ab9b95 feat: gate clip_skip, zero out negative at cfg<=1, apply sampling shift (#359)
Three behaviours that are right for SDXL but wrong for the new families.

clip_layer() drops the last N layers of a CLIP text encoder. Anima, Krea2
and Z-Image encode with Qwen3 / Qwen3-VL, where it is meaningless and can
raise. Presets carry clip_skip: -2 by default regardless of family, so
gate on the family flag rather than on the value being set.

Distilled models sample at cfg 1 with a zeroed negative, as every shipped
template does. Key it off the effective cfg rather than the family: Anima
base at cfg 4 keeps its real negative, Anima + turbo LoRA at cfg 1 does
not, and no per-family flag can drift from what was actually sampled.
Applied in FlakeGenerate, the one point where conditioning and cfg are
both final — upstream nodes re-encode on every hop and cfg can still move
via a per-instance override (#279). Verified ConditioningZeroOut copies
the metadata dict, so a ControlNet's control key survives.

Flow-matching families also get their ModelSamplingAuraFlow shift, preset
value winning over the family default.
2026-09-07 17:15:57 -04:00
Mehdi Chaid 89b0d3794c fix: load diffusion models with external text encoder and VAE (#358)
_load_preset_bundle had exactly one loading strategy, load_checkpoint_
guess_config on a file in models/checkpoints/. Anima, Krea2 and Z-Image
all ship as a bare diffusion model plus a separate text encoder and VAE,
so selecting those families and generating could never work.

Reproducible with cyberrealisticZImage_v40.safetensors, whose 453 tensors
are all under model.diffusion_model.*: the call returns clip=None and
vae=None, and the next CLIPTextEncode raises.

- honour the new diffusion_model preset field via load_diffusion_model
- when a checkpoint yields no clip/vae, keep the model it did build and
  source the text encoder and VAE from the preset. Only on success, so a
  genuine load failure still propagates rather than being masked
- load standalone text encoders with comfy.sd.load_clip and an explicit
  CLIPType instead of load_checkpoint_guess_config, which cannot produce
  a lumina2 or krea2 CLIP. Members are looked up by name, not value
- missing files named by a preset now fail loudly instead of silently
  generating with the wrong components

The 4-vs-16 channel latent difference needs no handling: common_ksampler
calls fix_empty_latent_channels, which reshapes an all-zero latent to the
model's channel count.
2026-09-07 17:14:15 -04:00
Mehdi Chaid b9fd63b334 feat: preset schema gains diffusion_model, clip_type and shift (#357)
Anima, Krea2 and Z-Image ship as a bare diffusion model plus a separate
text encoder and VAE, which the preset schema had no way to express.

Add three optional fields to ModelPreset and load_preset, plus a
model_source property that resolves which file to load and raises a
readable error when a preset has neither a checkpoint nor a diffusion
model, instead of letting an empty path surface as FileNotFoundError.

save_preset writes the payload verbatim, so no write-path change is
needed. Verified the three existing illustrious presets on disk load
identically with all three new fields defaulting to None.
2026-09-07 17:12:40 -04:00
Mehdi Chaid eabb391ece feat: add Krea2/Turbo family and per-family generation metadata (#356)
Register Krea2/Turbo (folder krea2) and give every FamilySpec the metadata
the generation path needs: clip_type, default steps/cfg/sampler/scheduler
and resolution, supports_clip_skip and default_shift.

Values for Anima, Krea2 and Z-Image come from the workflow templates
ComfyUI ships (image_anima_base_v1, image_krea2_turbo_t2i,
image_z_image_turbo) rather than being guessed.

SDXL/Base, SDXL/Illustrious, SDXL/Pony, Flux/Klein and Common keep their
existing 20 steps / cfg 4.0 / euler_ancestral / normal / 832x1216 defaults
and clip-skip support, so existing presets are unaffected.
2026-09-07 17:11:47 -04:00
Mehdi Chaid 7d8756a8a7 refactor: single source of truth for model families (#355)
The family definition was copy-pasted across eight places: _FAMILY_MAP,
_FAMILY_COMPAT, _CN_MODEL_MAP and _CN_SUBFOLDER in flake_io.py,
_MODEL_FAMILIES in full_flake_node.py, and four more literals in the web
layer. Adding a family meant eight edits and the copies drifted.

Add flake_families.py with a FamilySpec dataclass and a FAMILIES tuple as
the single source of truth, and derive every Python map from it. The
derived dicts keep their original names, shapes and contents, so all call
sites are untouched.

Verified the five derived maps compare equal to the pre-refactor literals.
2026-09-07 17:10:58 -04:00
Mehdi Chaid cbc527cb26 fix: sharper, field-filling Generation Data composite preview (#354) 2026-06-23 12:34:23 -04:00
Mehdi Chaid 44be281b12 feat: first ControlNet image auto-sets flake cover from its sibling cover (#353) 2026-06-23 12:33:39 -04:00
Mehdi Chaid ad802da968 feat: manually selected cover copies beside the flake yaml (#352) 2026-06-23 12:32:08 -04:00
Mehdi Chaid 313866a673 feat: show ControlNet image dimensions on variant-choice ControlNets (#351) 2026-06-22 13:00:33 -04:00
Mehdi Chaid d17b97a274 feat: sharper Generation Data composite with vertical-first combo split (#350) 2026-06-22 13:00:15 -04:00
Mehdi Chaid 718d9847ca feat: Generation Data shows image name and dimensions above the preview (#349) 2026-06-22 12:59:51 -04:00
Mehdi Chaid 4cfd0f94aa feat: Generation Data overlay shows one card per variant for All Variants (#345)
buildModel (now async) expands an All Variants combo flake into one card per variant combination, mirroring queue.js's expansion order. combinationKeyFor and the progress-bar card matching carry the variant sub-index (id:idx.variantSub) so each card maps to its generated image and progress. Non-all-variants combos are unchanged.
2026-06-20 21:33:59 -04:00
Mehdi Chaid 258939740b fix: UI batch count now multiplies combinations (#348)
app.queuePrompt ignored batchCount and ran each combination once. Now each combination runs batchCount times in a row (seed advances per run via control_after_generate), for combinations.length * batchCount total jobs. setupBatchTracking takes totalJobs for the completion check and [i/N] label; a comboIndexByPrompt map keeps combo highlighting/image storage correct now that prompt-id count exceeds the combination count.
2026-06-20 21:30:09 -04:00
Mehdi Chaid f5abccdbe9 feat: strip unused flakes from embedded output metadata (#346)
Before save_images, deep-copy prompt + extra_pnginfo and reduce each FlakeStack/FlakeCombo node's flakes_json to active flakes only (drop bypassed/inline), mirroring into the workflow node's widgets_values by matching the original string. The live objects aren't mutated; the saved PNG embeds only the used flakes so a drag-drop restore reflects what generated the image.
2026-06-20 21:27:49 -04:00
Mehdi Chaid 9392cbdad2 fix: variant output stem no longer treated as a folder (#347)
A variant choice's output_stem was joined to the base with '/', so a file-prefix base like style_a became a folder (style_a/variant_b). Extract _combine_variant_stem: nest only when the base ends with '/', else append with '_' (style_a_variant_b). Adds tests/test_variant_stem.py.
2026-06-20 21:17:39 -04:00
Mehdi Chaid 7c2ba941d3 feat: All Variants override expands a flake into one generation per variant combo (#343)
Adds an 'All Variants' checkbox to the Flake Combo and Flake Stack override panels (entry._all_variants). At queue time, queue.js expands such flakes: a combo flake becomes one axis item per variant combination, and a stack flake becomes an extra combinatorial axis. comboKeys carry a per-variant sub-key so each generation stays distinct; computeJobCount reflects the multiplied total (best-effort from cached meta). Non-all-variants combos/stacks are unchanged.
2026-06-20 18:25:33 -04:00
Mehdi Chaid 4d223b53b3 feat: embed ComfyUI workflow metadata in Flake Generate outputs (#344)
Add hidden prompt/extra_pnginfo inputs to FlakeGenerate and pass them to SaveImage.save_images so the workflow and prompt are written into the PNG text chunks — saved outputs can be drag-dropped into ComfyUI to restore the graph (respects --disable-metadata).
2026-06-20 17:30:22 -04:00
Mehdi Chaid 96cac47ae3 fix: Generation Data overlay shows the actual image dimensions (#342)
The readout used the preset's configured width/height (e.g. 853x1440) regardless of the real output. Now, when a generated image is shown, the dimensions come from the loaded image's natural size (both combo and single paths); the cover-composite placeholder keeps the preset dims.
2026-06-20 17:29:39 -04:00
Mehdi Chaid 64082824a3 feat: enlarge Generation Data overlay and shrink its preview image (#341)
Raise the overlay's inner scroll-region max-heights (~+20%): combo columns 65vh->78vh, single panel 60vh->72vh. Shrink the output preview image to 80% width (~-20%) in both render paths; it stays centered via compositeWrap.
2026-06-20 11:51:57 -04:00
Mehdi Chaid aba150bc25 feat: variant LoRAs flow beside the cover image, wrap full-width when tall (#336) 2026-06-19 22:47:49 -04:00
Mehdi Chaid 9d89816c93 feat: double-click Generation Data overlay image to reveal it on disk (#340) 2026-06-19 22:42:55 -04:00
Mehdi Chaid bf6eb8a2a8 feat: Generation Data overlay image fills panel width via aspect-ratio (#339) 2026-06-19 22:41:33 -04:00
Mehdi Chaid ba9795ab34 fix: only show variant-choice section labels when the field has items (#335) 2026-06-19 22:40:15 -04:00
Mehdi Chaid 6fe2a36a87 feat: shared accent-button styling for Activate-all toggle on all 3 nodes (#338) 2026-06-19 22:39:37 -04:00
Mehdi Chaid 92ebdc82df style: remove » duplicate button panel, keep glyph with drop shadow (#337) 2026-06-19 22:37:47 -04:00
Mehdi Chaid 00b46d2ad9 fix: surface tag_names from fetchList so Load picker shows LoRA tag (#334) 2026-06-19 18:39:33 -04:00
Mehdi Chaid 96d24e498a feat: variant-choice fields move into the hamburger 'Add field' menu (#331) 2026-06-19 18:29:48 -04:00
Mehdi Chaid 48bb8a64dc feat: collapse Flake Edit add-row to a single '+ Add field' button (#330) 2026-06-19 18:27:17 -04:00
Mehdi Chaid a251d196d7 feat: default prompts to a positive field, drop '+ Positive' button (#332) 2026-06-19 18:25:36 -04:00
Mehdi Chaid e488b7fc85 fix: restyle Flake Stack activate-all toggle to look native (#333) 2026-06-19 18:22:47 -04:00
Mehdi Chaid e50ea96f9d feat: Generation Data overlay layout + ADetailer A/B toggle (#324, #328)
- Widen the overlay and switch the combo view to a 33/67 left/right split.
- Increase the preview/composite image size to 384px.
- Make combo-axis card grids wrap instead of horizontal-scroll.
- Add ADetailer/Upscale settings as a new 'Generation settings' section.
- Store ADetailer A/B outputs per combo key in queue.js; add an A/B toggle
  below the image dimensions to switch between regular and ADetailer outputs
  in the overlay (#328).
2026-06-19 14:22:49 -04:00
Mehdi Chaid f80dd956c3 fix: hide type ribbon for flakes without a flake_type (#276)
makeTypeRibbon now returns null when entry.flake_type is missing, so newly
saved flakes that don't have a type no longer display a misleading 'O'
(first letter of 'Other') in the grid.
2026-06-19 14:13:57 -04:00
Mehdi Chaid 9ac3383f61 feat: edit-overlay add-field buttons + auto-flows (#319, #320, #321, #322)
- Add quick +LoRA / +ControlNet / +Flake Link buttons in a labeled row;
  remaining field types live in the '+ Add field' dropdown (#322).
- ControlNet add-flow: auto-add the first CN section and open the image file
  picker immediately (#319).
- Flake Link add-flow: auto-open the Load existing flake popup and create the
  link once a target is chosen (#320).
- Variants add-flow: selecting Variants seeds a Default group with a Default
  choice (#321).
- Match the Select LoRA button height to the LoRA strength slider (32px).
2026-06-19 14:08:24 -04:00
Mehdi Chaid 63cfece552 feat: ControlNet cover lookup matches JIP _0_cover_ naming (#318)
inferCnFromImage now maps a JIP CN filename (<stem>_3_<preproc>_<inc>) to its
sibling cover <stem>_0_cover_<inc> (the numeric order marker becomes 0 and the
preproc token becomes cover), while keeping the old <stem>_<type>_<inc> ->
<stem>_cover_<inc> behaviour. Type auto-set and the cover probe already existed.
2026-06-19 12:03:46 -04:00
Mehdi Chaid 89d186a7b6 feat: toggle-all activate/deactivate button on Stack/Combo/Model Combo (#315)
A button above the grid flips every flake/preset between active and bypassed in
one click; its label reflects the current state. Node height accounts for the
extra row.
2026-06-19 12:01:39 -04:00
Mehdi Chaid 7c63753c01 feat: duplicate (») button on grid items for Stack/Combo/Model Combo (#316)
Each flake/preset item gets a bottom-right » button that inserts a deep copy of
itself beside it (Model Combo also copies the preset's overrides), so the user
can quickly make multiple versions with different overrides.
2026-06-19 11:37:25 -04:00
Mehdi Chaid b6ebfd2b6d feat: Flake Stack per-item activate checkbox (replace click-the-type-tag) (#314)
The type ribbon is now a static indicator and a top-right enable/disable checkbox
toggles the flake's bypassed state, mirroring Flake Combo / Flake Model Combo.
2026-06-19 11:15:06 -04:00
Mehdi Chaid f50f90936b fix: grid nodes compute real column count so they stop padding with empty space (#317)
The grid DOM widget computeSize hardcoded 2 columns ((len+1)/2), but the grid
uses auto-fill minmax(72px,1fr). On a wide node the real column count is higher,
so the node over-counted rows and reserved empty space above/below the grid on
reload. Derive cols from the node width (≈76px per item) on Flake Stack, Flake
Combo and Flake Model Combo.
2026-06-19 10:56:43 -04:00
Mehdi Chaid 874bd1205f feat: double-click the Flake Generate output image to reveal it on disk (#329)
Adds POST /flakes/reveal that resolves {filename,subfolder,type} via folder_paths
(confined to the output dir) and opens the OS file explorer selecting the file
(explorer /select, on Windows; open -R on macOS; xdg-open on Linux). The node's
output image gets a dblclick handler that posts the current image.
2026-06-19 10:50:38 -04:00
Mehdi Chaid 0edb45e6d4 feat: ADetailer Off/Face/A/B; A/B generates without + with ADetailer (#327)
Replace the ADetailer boolean with an Off/Face/A/B combo. A/B runs each image
twice — once without ADetailer (regular, no _ad marker) and once with (_ad
marker). The execute post-process+save path is refactored into per-variant save
helpers; the regular output is returned as flake_images and, for A/B, the
detailed output as flake_images_ad with an adetailer_ab flag so the Generation
Data overlay can offer a toggle (#328). Frontend treats adetailer as non-Off.
2026-06-19 10:41:09 -04:00
Mehdi Chaid b3f8bf90a3 feat: append _ad_ to the output stem when ADetailer is on (#326) 2026-06-18 19:52:27 -04:00
Mehdi Chaid 2f70f8f28c feat: append _up_ to the output stem when Upscale is on (#325) 2026-06-18 19:51:47 -04:00
Mehdi Chaid 7113f8918c feat: regroup ControlNet editor rows and give variant CN the image-left layout (#311) 2026-06-17 23:22:52 -04:00
Mehdi Chaid fd996aa4e4 feat: variant choice LoRA uses the Select LoRA file popup (#310) 2026-06-17 23:16:23 -04:00
Mehdi Chaid 5fc7cc27ea feat: bigger flake-link override sliders (makeComfyValueSlider) (#312) 2026-06-17 23:15:38 -04:00
Mehdi Chaid d438d280db feat: two-column model override panel (prefix/sampler/scheduler | steps/cfg) (#309) 2026-06-17 23:14:58 -04:00
Mehdi Chaid c93b1311cb fix: setWidgetHidden sets state.hidden so widgets are removed, not just disabled (#313) 2026-06-17 23:14:03 -04:00
Mehdi Chaid 7f36c4ba7a feat: per-variant-choice ControlNet and Flake Link editors (#299) 2026-06-17 14:31:08 -04:00
Mehdi Chaid 57329a8c21 feat: auto-set ControlNet type and cover from the selected image filename (#306) 2026-06-17 14:27:09 -04:00
Mehdi Chaid 1ab16d1433 feat: hide adetailer/upscale sub-widgets when off; bbox/upscale model dropdowns (#305) 2026-06-17 14:24:19 -04:00
Mehdi Chaid d28cc9d482 feat: Flake Model Preset override panel floats below the arrow (no node growth) (#302) 2026-06-17 14:21:16 -04:00
Mehdi Chaid e244a8262d fix: Flake Model Combo override panel opens (absolute panel, no overflow clip) with slider fields (#303) 2026-06-17 14:19:54 -04:00
Mehdi Chaid ff46e2789c fix: exclude bypassed/muted combo nodes from generation (#304) 2026-06-17 14:17:13 -04:00
Mehdi Chaid c615b44fc2 fix: variant + LoRA button no longer throws on slider.container (#308) 2026-06-17 14:16:55 -04:00
Mehdi Chaid 1a377b4ad1 fix: render flake-link override sliders (slider.container threw) + 'Flake Link' title (#307) 2026-06-17 14:16:33 -04:00
Mehdi Chaid 3e4557b35f feat: per-variant-choice LoRA editor in the flake overlay (#299) 2026-06-17 12:18:24 -04:00
Mehdi Chaid b0ea01c927 feat: variant choices contribute LoRAs/ControlNets/flake-links at resolve (#299) 2026-06-17 12:15:19 -04:00
Mehdi Chaid 52bf3104ae fix: flake-link override sliders for legacy single-LoRA targets + clearer labels (#300) 2026-06-17 12:11:18 -04:00
Mehdi Chaid b9950170a0 feat: add ADetailer sampling steps control to Flake Generate (#301) 2026-06-17 00:22:43 -04:00
Mehdi Chaid 0c41f60db7 feat: show selected variant choice initials as [P, C] line on flake grid (#298) 2026-06-17 00:21:32 -04:00
Mehdi Chaid 9f250dcdca feat: show flake LoRA tags on a dimmer line on grid and load picker (#297) 2026-06-17 00:20:47 -04:00
Mehdi Chaid 44b48dc670 fix: Flake Model Combo override arrow now opens (escape overflow clip) with defaults (#293) 2026-06-16 23:56:15 -04:00
Mehdi Chaid d3ba486db4 feat: Flake Model Preset overrides via hover arrow showing current defaults (#292) 2026-06-16 23:54:47 -04:00
Mehdi Chaid c89d815f81 fix: make model preset embeddings a growable chip list (#294) 2026-06-16 23:52:51 -04:00
Mehdi Chaid bdad7ae055 feat: expand checkpoint URL field by default for new presets (#295) 2026-06-16 23:51:54 -04:00
Mehdi Chaid a49df2a47a feat: autofill model preset filename prefix as a folder (#296) 2026-06-16 23:49:41 -04:00
Mehdi Chaid e789397110 feat: add Flake Inpaint node (#291) 2026-06-15 15:39:45 -04:00
Mehdi Chaid c570867a5c feat: add Flake IPAdapter node (FaceID) (#289) 2026-06-15 15:38:48 -04:00
Mehdi Chaid 1d0c236761 feat: upscaling option on Flake Generate (#288) 2026-06-15 15:36:59 -04:00
Mehdi Chaid 529c27e466 feat: ADetailer (Face Detailer) toggle on Flake Generate (#287) 2026-06-15 15:35:56 -04:00
Mehdi Chaid 85f030a874 feat: per-instance model-preset overrides (filename/steps/cfg/sampler/scheduler) (#279) 2026-06-15 15:34:03 -04:00
Mehdi Chaid f0c9db42ed feat: add Anima and Flux Klein model families (LoRA + controlnet resolution) (#286) 2026-06-15 15:25:20 -04:00
Mehdi Chaid 46065833da feat: support ** suffix syntax in filename prefixes (#290) 2026-06-15 15:20:08 -04:00
Mehdi Chaid a95f54b7b2 feat: Flake Model Combo add-preset dropdown (new vs existing) (#278) 2026-06-15 15:16:20 -04:00
Mehdi Chaid 685c36b342 feat: hold activation checkbox to single out a combo item (#281) 2026-06-15 15:14:34 -04:00
Mehdi Chaid f20dcc2068 feat: activate/deactivate Flake Combo items via top-right checkbox (#277) 2026-06-15 15:12:13 -04:00
Mehdi Chaid 63b25c042b feat: populate sampler/scheduler lists from core ComfyUI KSampler (#285) 2026-06-15 15:10:42 -04:00
Mehdi Chaid 38e6672d25 feat: default new model presets to Extra Path base folder when available (#280) 2026-06-15 15:09:38 -04:00
Mehdi Chaid 3ee11819d5 feat: update new model-preset defaults to 853x1440, 31 steps, CFG 4.7 (#284) 2026-06-15 15:07:20 -04:00
Mehdi Chaid 1c6bd33f3c fix: auto-grow variant-choice prompt textareas (#283) 2026-06-15 15:05:12 -04:00
Mehdi Chaid ed48d013f9 feat: flake-link picker overlay and multiple links per flake (#275)
Replace the single flake_link dropdown with a picker overlay (search +
folders, reusing openFileLoadPicker) and support multiple links per flake.

- Schema: flake_link (single) -> flake_links (list); legacy single still
  read on load and at runtime (backward compatible).
- Backend: Flake.flake_links list, _flake_from_raw parses both formats,
  _apply_flake_link loops over links with per-link grid overrides
  (entry.flake_link_overrides[i], legacy flake_link_override -> index 0).
- Modal: fieldState.flake_links array; add/change via overlay, per-link
  variant + lora-strength override cards; serialized as flake_links.
- Stack/Combo grid panels render one linked-override section per link.
- Document flake_links in the README flake-file format.
2026-06-09 19:56:29 -04:00
Mehdi Chaid 1b3b72ec65 feat: auto-scroll to a newly-added flake field (#271)
When a field is added via the field menu or seeded by selecting a flake
type, scroll the modal body so the new field is revealed instead of
remaining below the fold. Uses scrollIntoView on the rendered fieldWrap
(matched by a new data-field-key attribute), deferred to the next frame,
and honours prefers-reduced-motion.
2026-06-09 09:50:27 -04:00
Mehdi Chaid b83ef26b72 feat: add LoRA tag-name checkbox affecting stem, path, and grid name (#274)
Add a "Tag flake with this LoRA's name" checkbox to each LoRA card. When
checked, the LoRA name (slugged) is appended after the display name in the
Output Path and Output Stem prefills, and appended to the flake grid item
's
display name across all entry-creation paths (create, load, replace, edit-
save). Persist tag_name per LoRA in the YAML; runtime parsing ignores the
extra key.
2026-06-09 09:36:34 -04:00
Mehdi Chaid 17986689f6 feat: reorder flake default fields and add Serie field (#272)
Reorder the flake editor top section to Display Name + Flake Type, then
Serie + Output Stem, then Base Directory + Output Path. Add a Serie
text/dropdown combo backed by a new GET /flakes/series endpoint that
aggregates distinct serie values. Introduce a shared recomputePrefills()
helper composing Output Path and Output Stem from serie -> display name
-> tagged LoRA names (create mode only, with independent manual-edit
guards); persist serie in the flake YAML.
2026-06-09 00:02:38 -04:00
Mehdi Chaid fd57fb2738 feat: seed default flake fields based on selected type (#269) 2026-06-04 21:41:36 -04:00
Mehdi Chaid 47c67c0947 feat: expand newly-added LoRA field by default (#270) 2026-06-04 21:33:04 -04:00
Mehdi Chaid 373604d837 fix: align Generation Data overlay combo keys with queued indices (#268) 2026-06-04 21:27:43 -04:00
Mehdi Chaid 1d1599b370 fix: preserve preset base path on edit and pass family from model combo (#267) 2026-06-04 21:11:32 -04:00
Mehdi Chaid a4dd9357ba feat: per-preset enable/disable toggle in Flake Model Combo (#266) 2026-06-04 20:32:58 -04:00
Mehdi Chaid 01eb1e0154 fix: nest combo batch loops by graph connection order (#265) 2026-06-04 20:26:15 -04:00
Mehdi Chaid 0a34fd7186 fix: flake-link target picker dropdown clipped by modal overflow (#263)
makeSearchableDropdown rendered its menu position:absolute inside the
field, so for fields near the bottom of the overflow:auto modal body (the
flake-link target sits last) the downward menu was clipped and appeared
to do nothing. Render the menu position:fixed with coordinates computed
from the input (flipping upward when there's no room below), and
reposition on scroll/resize. Fixes loras/embeddings pickers too.
2026-06-03 01:29:42 -04:00
Mehdi Chaid dcb264ab1a fix: preset edits lost on reopen with multiple preset roots (#262)
save_preset wrote the chosen root but left same-name copies in other
model_presets roots intact, so the next read could resolve a stale
duplicate — edits (e.g. scheduler) appeared not to persist. Remove
same-canonical copies (and their covers) from other roots on save,
mirroring the flake fix in #242.
2026-06-03 01:15:53 -04:00
Mehdi Chaid 301dd3a2c4 feat: compact + Negative, equal split when present, auto-grow prompts (#264)
Prompt editors now give positive the full width when no negative is set,
with a compact '+ Negative' control; adding a negative splits the row
50/50. Flake and preset prompt fields auto-grow to fit their content (new
attachAutoGrow helper, capped with scroll). Variant-choice editors get the
narrow button + equal split while keeping their image-matched height.
2026-06-02 23:27:13 -04:00
Mehdi Chaid 9eb9789b24 feat: ControlNet image-dimensions resolution override + two-row slider layout (#260)
Adds a per-CN 'Img res' checkbox (resolution_from_image) that drives the
generation resolution from the control image's dimensions, taking
precedence over the flake resolution. Restructures the strength/start/end
row to labels-on-top / fields-on-bottom and places the checkbox alongside
them. Backend reads the flagged entry's image size before the CN apply
loop (so _fit_cn_image is a no-op for it).
2026-06-02 20:15:48 -04:00
Mehdi Chaid f93f4b19e4 feat: store manually-added cover images by absolute path, no copy (#259)
The cover file dialog now stores the picked file's absolute path (from
file.path, available in the ComfyUI desktop app) as a cover_image
reference instead of uploading a copy next to the flake yaml — matching
how LoRA-sibling covers are referenced. _resolve_cover_source reads
absolute-path references directly (image extensions only). A reference
also clears any stale beside-yaml copy on save. Plain browsers without
file.path fall back to the previous upload behaviour.
2026-06-02 19:35:56 -04:00
Mehdi Chaid 18e09480f8 fix: hide model_family widget whenever flake_data input is connected (#261)
Visibility was gated on the upstream family being inferable, so a
connected-but-uninferable input left the widget visible-but-disabled.
Hide based on the input link being present; still set the widget value
from the upstream family when it can be read.
2026-06-02 19:11:54 -04:00
Mehdi Chaid a94cf2c0ff fix: preserve upstream ControlNets when chaining Flake nodes (#257)
Each Flake node re-encodes the accumulated prompt text, producing fresh
conditioning that dropped the 'control' chain attached by upstream nodes
— so a controlnet on an earlier FlakeStack/FlakeCombo was silently
discarded and only the last node's controlnets applied. Snapshot the
incoming conditioning and carry its 'control' (and control_apply_to_uncond)
onto the re-encoded conditioning so this node's controlnets chain on top
via set_previous_controlnet.
2026-06-02 13:10:20 -04:00
Mehdi Chaid 210c502c1b fix: fit ControlNet hint image to the generation frame (#255)
The control image was applied before the target resolution was known, so
ComfyUI stretched the hint to the latent size at sample time, distorting
the pose whenever the control image aspect differed from the generation
resolution. Resolve the resolution first and crop-and-resize each hint to
it (lanczos, center crop) before apply_controlnet, matching A1111's
default. Also logs the fitted size.
2026-06-02 12:30:25 -04:00
Mehdi Chaid 81e812feb3 fix: resolve ControlNet models from the family subfolder (#254)
SDXL controlnets live under models/controlnet/sdxl/ and ZImage under
.../zimage/. Add resolve_cn_model_name() which prefers the family's
controlnet subfolder and fuzzy-matches dash/version-named files (e.g.
controlnet-scribble-sdxl-1.0) against the inferred name, falling back to
the generic global resolver for flat layouts. Wire it into FlakeStack.
2026-06-02 12:22:47 -04:00
Mehdi Chaid 0a86d30f46 feat: show ControlNet image dimensions under the thumbnail (#256)
Adds a caption beneath the controlnet image preview showing its
intrinsic WxH, read from the preview img's naturalWidth/naturalHeight on
load. Updates on re-pick and hides when no image is set.
2026-06-02 12:20:42 -04:00
Mehdi Chaid a540c43859 fix: enable saving the Default flake in a Flake Stack (#253)
The edit modal gated the Save button on rootsLoaded, which is only set
true inside the non-default branch that loads base-directory roots. In
default mode it stayed false, leaving Save permanently disabled. Treat
default mode as ready immediately since it loads no roots.
2026-06-02 12:19:34 -04:00
Mehdi Chaid e1abb97643 fix: ensure default flake serialization and always re-infer controlnet model from preset family (#251) 2026-06-02 11:48:01 -04:00
Mehdi Chaid 96819aadf3 feat: add controlnet debug logging for model/image/params (#250) 2026-06-02 11:21:41 -04:00
Mehdi Chaid 8e6f66aaf1 fix: resolve controlnet models in subdirectories (#251) 2026-06-02 11:21:12 -04:00
Mehdi Chaid 1ad0d437d1 fix: sync default flake prompt on input events (#252) 2026-06-02 11:19:34 -04:00
Mehdi Chaid 0f510ae782 feat: show inferred ControlNet model name beneath type dropdown (#249) 2026-05-30 15:34:56 -04:00
Mehdi Chaid c591dd4362 fix: guard against empty ControlNet image_name to prevent crash (#248) 2026-05-30 15:34:05 -04:00
Mehdi Chaid b2931c48e4 fix: use !important CSS to reliably hide model_family widget (#216) 2026-05-30 14:45:05 -04:00
Mehdi Chaid 12ad0223e9 feat: infer ControlNet model from type + model family, remove model file field (#246) 2026-05-30 14:37:34 -04:00
Mehdi Chaid 1f7daa38db fix: preserve base directory selection in edit modal (#245) 2026-05-30 14:34:45 -04:00
Mehdi Chaid 7dc29137fe fix: wire ControlNet slider onChange callbacks (#244) 2026-05-30 14:32:08 -04:00
Mehdi Chaid b19631f930 fix: prevent extra-path double-save and edit-lost-on-reload (#242) 2026-05-28 10:43:40 -04:00
Mehdi Chaid 7ba61869e9 fix: skip controlnet entries with empty model_name (#243) 2026-05-28 10:40:48 -04:00
Mehdi Chaid 7c09225328 draft workflow 2026-05-27 09:18:20 -04:00
Mehdi Chaid 73e730d1b3 todos 2026-05-24 10:24:31 -04:00
Mehdi Chaid cd2f3bb8ef feat: generation data overlay surfaces linked flake's effective data (#236)
flakeEntryRows() now appends the linked flake's loras + prompts
(post-override) when a host flake has a flake_link. Effective values
use the same precedence as the backend's _apply_flake_link:
  per-grid override > yaml flake_link default > linked flake's own default.

Layout:
- Linked loras are labeled "[linked] <name> [<strength>]".
- Linked prompts are grouped as "<host> · linked: <target_basename>"
  with the effective variant choice suffix appended.
- Per-variant-group positive/negative prompts on the linked flake are
  also surfaced when a choice is selected.

These rows flow through the existing common/combo bucket split — a
linked flake on a Flake Stack contribution shows on the right (common)
panel; one on a Flake Combo grid item shows on the left (combo).

Refs #232, closes #236
2026-05-22 11:35:55 -04:00
Mehdi Chaid 03e7145a4b feat: grid dropdown shows linked flake tunables as a second panel (#235)
When a host flake on the FlakeStack / FlakeCombo grid has a flake_link,
the option dropdown now shows a SECOND panel to the right of the
native panel:
- Title with the linked flake's display name (and full target path in
  tooltip).
- One slider per lora on the linked flake, defaulting in priority:
  per-grid override -> yaml flake_link default -> linked flake's own
  default. Writes go to entry.flake_link_override.lora_strengths[i].
- One dropdown per variant group on the linked flake, with the same
  override precedence. Writes go to entry.flake_link_override.variant.

Implementation:
- Restructure the option panel from a single-column popout into a
  two-column flex row (nativeColumn + linkedColumn). nativeColumn
  keeps the existing rendering; linkedColumn is display:none until
  loadOptions discovers a flake_link on the host.
- loadOptions also fetches the linked flake's data when the host
  declares a flake_link, so we know its loras + variants for the
  linked panel.
- Per-placement overrides ride along with the entry through
  flakes_json (FlakeStack) / _combo_flakes (FlakeCombo) without any
  extra wiring — JSON.stringify already includes the field.

Applied to both flake-stack.js and flake-combo.js with the same
two-column structure.

Refs #232, closes #235
2026-05-22 11:27:25 -04:00
Mehdi Chaid 01aa9229a2 feat: Flake link modal UI — target picker + default overrides (#234)
Add 'Flake link' to the optional fields menu. When added:

- Target row: a searchable dropdown listing all flakes (filtered to
  the host's family, excluding self) sourced from /flakes/list.
- Variant overrides: one dropdown per group on the linked flake, with
  a "— none —" option. Saved to flake_link.variant.
- LoRA strength overrides: one slider per lora on the linked flake,
  starting at the linked default. Saved to flake_link.lora_strengths
  as a sparse list ([null, 0.8, null]) where null means "use the
  linked flake's own default". A reset ↺ button next to each slider
  sets the slot back to null.

State lives on fieldState.flake_link = {target, variant, lora_strengths}.
The save block serializes only when target is set, drops trailing
nulls from strengths, and omits empty variant maps so the yaml stays
clean. Load picks data.flake_link straight from /flakes/get.

Refs #232, closes #234
2026-05-22 11:23:47 -04:00
Mehdi Chaid dcc74391e1 feat: Flake link backend — yaml schema + resolve semantics (#233)
Add a new flake field type that lets a flake reference another flake
with optional default overrides. Closes #233 (sub-issue of #232).

### YAML schema

\`\`\`yaml
flake_link:
  target: img/illustrious/outfits/frieren_outfits
  variant:           # optional default variant choice overrides
    Outfit: Classic
  lora_strengths:    # optional default strength overrides (null = use linked default)
    - 0.8
    - null
\`\`\`

### Dataclasses

- FlakeLink(target, variant, lora_strengths)
- Flake.flake_link: FlakeLink | None

### Resolve semantics

resolve(entry) now calls _apply_flake_link(host, entry) which:
- Resolves the linked flake with merged overrides
  (per-grid override > yaml default > linked flake's own defaults).
- Appends linked.loras to host.loras and concatenates linked.positive
  / linked.negative into host's prompts.
- Cycle-guarded via _visited set so chains can't loop.
- FileNotFoundError on a missing target logs and returns instead of
  breaking generation.

Per-grid placement overrides live under entry["flake_link_override"]
= {variant, lora_strengths} — populated by the grid dropdown UI in
the follow-up sub-issue #235.

Refs #232
2026-05-22 11:21:01 -04:00
Mehdi Chaid 543ae60179 feat: variant choice v3 — 832x1216 image, hover delete dropdown, taller prompts (#238)
Restructure of the variant choice card (extends #224):

- One unified body row with name+stem header + prompts stacked on the
  LEFT, image on the RIGHT. The previous separate top cRow is moved
  inside the left column so the entire left side lines up next to the
  image.
- Image box is now 160x234 (832x1216 portrait aspect ≈ 0.684) instead
  of the previous 140x140 square.
- Hover-only ✕ button at the top-right of the image opens a small
  dropdown with conditional "Remove image" (only when a cover is set)
  and "Delete choice" (always). Replaces the previous separate
  remove-choice and clear-image buttons below the image.
- Drop the image filename / "No image" label that sat below the image.
- Prompt textareas inherit the full body height via the existing
  align-items:stretch wiring.

Closes #238
2026-05-22 11:12:48 -04:00
Mehdi Chaid 7b27e3454a feat: hover-X to remove the flake's cover image (#239)
Add an opacity:0-by-default ✕ button at the top-right of the cover
image box in the flake modal. Only visible when a cover is set AND
the box is hovered. Clicking it:
- Resets coverFile / coverSourcePath / preview so the modal forgets
  the cover entirely.
- In edit mode, calls a new DELETE /flakes/cover?name=<name> endpoint
  so the on-disk cover file is removed (otherwise getCoverUrl() would
  resurface the cover on next open, since covers live next to the yaml
  on disk rather than inside it).

Backend: add _delete_cover_route delegating to flake_io._delete_cover.

Closes #239
2026-05-22 11:12:38 -04:00
Mehdi Chaid 44c7192b5c fix: overlay grid items fall back to cover image when variant image missing (#237)
The overlay's combo grid cards built coverUrl as
"getVariantImageUrl(...) if a variant is selected, else getCoverUrl(...)"
with no fallback. When the selected variant choice's image didn't
exist, the card's background-image silently 404'd and the cell was
blank.

Mirror the flake-combo node grid behavior: track both base and variant
cover URLs on the item, probe-load the variant image, and fall back to
the base cover on error. Show base immediately while probing so the
cell is never blank.

Closes #237
2026-05-22 10:10:46 -04:00
Mehdi Chaid a69741867a fix: re-add img/<model_family>/ structural prefix to output path (#240)
After #217 dropped the preset's yaml folder path from
filename_state["preset"], the structural img/<family>/ segment was
also lost — outputs landed at output/<rest>/... instead of
output/img/<family>/<rest>/...

Re-introduce it explicitly: _load_preset_bundle now takes model_family
and prepends "img/<family>/" to the stems list (it contains "/", so
_build_filename_prefix routes it into folder_parts). The explicit
filename_prefix field still contributes its own non-folder segment
afterwards. Thread model_family through FlakeModelPreset.execute, and
FlakeModelCombo inherits it automatically.

Closes #240
2026-05-22 10:10:39 -04:00
Mehdi Chaid e0efba1027 fix: hide model_family by mirroring hide signals onto widget._state (#216)
Devtools dump on the user's frontend confirmed setWidgetHidden() was
correctly setting widget.type='hidden', widget.hidden=true,
computeSize=[0,-4], and draw=noop — but the widget still rendered.
The widget object has a _state Proxy that mirrors render-relevant
properties; the modern frontend's canvas-drawn ComboWidget renders
from _state, not from the top-level widget object.

Extend setWidgetHidden() to also mutate widget._state (type, disabled,
label) when present, with try/catch in case the Proxy traps make any
of those read-only. The original _state values are saved so unhide
restores them.

Refs #216 (pending runtime verification)
2026-05-22 10:10:32 -04:00
Mehdi Chaid 9c927b5011 fix: combo highlight + progress for first generation (#241)
Two parts:

1. queue.js: websocket events (execution_start / executed / etc.) can
   arrive on the websocket transport BEFORE the REST response that
   gives us their prompt_id. The previous handlers looked up the pid
   in promptIds and bailed when it wasn't found — so the very first
   generation in a batch consistently lost its highlight and per-combo
   image stamp, and the overlay missed its progress events.

   Add a pendingByPid event buffer. When an event arrives for an
   unknown pid, queue it. When the patched api.queuePrompt finally
   resolves and registers the pid, replay the buffered events for it.
   Snapshot _currentBatchComboKey at call time so the queued combo key
   stays correct even if the await resolves out of order.

2. generation-data.js: when the overlay opens mid-batch, seed the
   progress bar on the currently-running combo's grid cards by
   reading _activeBatch.runningPromptId synchronously. Without this,
   a late-opened overlay only started showing progress on the *next*
   running combo.

Closes #241
2026-05-22 10:10:25 -04:00
Mehdi Chaid d79b3f6904 feat: overlay panel split, dedup, width, progress bars, aspect ratio (#228, #229, #230, #231)
#228 — combinationData() now returns separate {commonLoraRows,
commonPromptRows} (from Model Preset + Flake Stack) and
{comboLoraRows, comboPromptRows} (from ALL flake-axis selected
entries, not just the first). The right panel renders only common;
the left panel below the combo grids renders only combo. Previously
the right panel showed both, and the left panel only showed the
first combo axis — so second-combo data leaked right and was missing
on the left.

#229 — Reduce overlay max-width from min(1400px, 95vw) to
min(980px, 66vw) (30% smaller per the spec). Shrink the gap between
left and right panels from 14px to 8px.

#230 — Progress bars on combo grid items while the corresponding
combo is the currently-running job. queue.js exports an _activeBatch
view (promptIds + comboKeys + runningPromptId); the overlay
subscribes to the ComfyUI api `progress` websocket event, maps the
running prompt_id back to its comboKey, parses the comboKey into
[nodeId, itemIdx] pairs, and updates the matching cards' progress
bars. Cleared on execution_success/error/interrupted and unsubscribed
on overlay close.

#231 — Composite preview image aspect ratio now matches the output
resolution (combinationData reports {width, height} from the active
preset). applyAspectRatio() sizes the image to fit within a 256px
square while preserving the output's ratio (e.g. a 1200x800 preset
yields a 256x171 preview).

Closes #228
Closes #229
Closes #230
Closes #231
2026-05-21 15:15:24 -04:00
Mehdi Chaid d865208f3d feat: Flake Generate node — Total jobs label, [i/N] done progress, narrower button (#227)
- Rename "Jobs: N" → "Total jobs: N".
- Add "[i/N] done" sub-label below it, updated as each prompt in the
  batch finishes. queue.js writes _batch_completed_count and
  _batch_total_count onto every FlakeGenerate node and invokes the
  node's _batch_progress_render() callback; resets to 0/N at batch
  start.
- Shrink the Generation Data button to fit its label + a bit of
  padding (flex:0 0 auto, inline-flex, white-space:nowrap) instead of
  stretching across the row.

Closes #227
2026-05-21 14:56:04 -04:00
Mehdi Chaid 4702c1c225 feat: hover-X remove on prompt fields; side-by-side prompts in preset modal (#225, #226)
#226 — Add makeHoverRemoveWrapper(child, onRemove) helper in utils.js
that wraps a textarea in a position:relative container with an
absolute-positioned ✕ at top-right. The button is opacity:0 by default
and fades in on container hover (or button focus), so it consumes no
layout space. Apply to all flake-modal prompt fields (host flake +
variant choices) and the new preset-modal negative prompt.

#225 — Restructure preset-modal prompts to the #209 side-by-side
layout: 70/30 positive/negative columns. Negative is hidden by
default behind a "+ Negative" button unless the preset already has
one. The promptState object holds the current values; the save
handler reads from it instead of the now-dynamic DOM nodes.

Closes #225
Closes #226
2026-05-21 14:51:13 -04:00
Mehdi Chaid 4fc90b2d73 feat: variant choice card restructure — drag handle, taller prompts, repositioned remove (#224)
Per #224:
- Replace the ↑/↓ buttons with a hold-to-drag handle (☰) on the left
  of the choice name input, matching the existing flake-field drag
  affordance. Drag-and-drop reorders choices within the variant group.
- Name and Output Stem fields each take ~50% width on the same row
  (the only things in the header row besides the drag handle).
- Make the prompt textareas fill the remaining vertical space down
  to the bottom of the cover image (bodyRow align-items:stretch;
  promptsWrap flex:1; textarea flex:1 + height:100%).
- Move the ✕ remove-choice button to a new bottom row under the
  image, next to the image path label / clear-image ✕.

Closes #224
2026-05-21 14:41:10 -04:00
Mehdi Chaid 567870271a fix: more aggressive model_family widget hide via setWidgetHidden helper (#216)
The previous hide mechanism (computeSize/type/hidden/draw overrides
inline in each widget setup) didn't take effect on the user's frontend
version — model_family stayed visibly rendered when flake_data was
connected.

Refactor into a single setWidgetHidden(widget, hide) helper in utils.js
that stacks every known hide signal:
  - widget.computeSize = () => [0, -4]   (collapse row height)
  - widget.type = "hidden"               (LiteGraph + frontend flag)
  - widget.hidden = true                 (older frontend flag)
  - widget.draw = no-op                  (LiteGraph canvas path)
  - widget.options.serialize = true      (keep value in serialized JSON)
  - widget.element.style.display = none  (modern DOM-widget path)
  - widget.inputEl.style.display = none  (DOM input path)
And the inverse on unhide via a per-widget saved-state WeakMap.

Refs #216
2026-05-21 13:27:03 -04:00
Mehdi Chaid 6156aa11e0 fix: capture prompt_id via api.queuePrompt patch for combo highlight (#207, #222)
Devtools console confirmed the previous defensive fallback's warning:
\`[flakes] queuePrompt result missing prompt_id; combo highlight may
not work for this batch.\` In current ComfyUI frontends, app.queuePrompt
returns void — the {prompt_id, number, node_errors} response only
flows through the lower-level api.queuePrompt call inside it.

Patch api.queuePrompt for the duration of each batch. The outer loop
sets _currentBatchComboKey before each combo's queue call; the patch
captures the returned prompt_id and pushes [promptId, comboKey] into
the parallel arrays the event listeners read. Restore the original
api.queuePrompt in the tracker cleanup.

This unblocks both:
- #207: setComboHighlight via execution_start now matches the right
  combination, so the blue border draws on the correct grid item.
- #222: per-combo image storage via the executed event now writes to
  the right combo key on the FlakeGenerate node, so the overlay
  composite image stops collapsing to the last-generated combo.

Closes #207
Closes #222
2026-05-21 13:26:54 -04:00
Mehdi Chaid 13da022e49 fix: revert widget detach in #216 — keep model_family in node.widgets (#223)
Closes #223. The previous #216 fix (commit 2db236a) detached the
familyWidget from node.widgets to hide it. That introduced three
regressions:

1. Generation failed with "Required input is missing: model_family".
   The widget was the only carrier for the required model_family value
   in the serialized prompt; once detached, prompt validation rejected
   every job.

2. Node sizes were reset on reload. _updateFamilyVisibility called
   node.setSize(node.computeSize()) on every state transition, and on
   workflow load the 200ms poll fired a transition after onConfigure
   restored the saved size — overriding it.

3. Flake Stack items vanished on reload. Detaching the widget shifted
   the widget value order in the saved JSON: only [flakes_json_value]
   was serialized, but the reload code (correctly) still has the
   widget present, so the saved JSON string landed in model_family
   and flakes_json kept its empty default.

Fix:
- Keep familyWidget permanently in node.widgets so serialization is
  stable and the required input is always sent.
- Hide it visually by stacking computeSize=[0,-4], type="hidden",
  hidden=true, AND a no-op draw override (covers ComfyUI frontends
  that don't honor type/hidden for canvas-drawn COMBO widgets).
- Drop node.setSize from the visibility transition (rely on
  setDirtyCanvas to repaint).
- Defer the initial sync to setTimeout(0) so onConfigure has hydrated
  link state before we read it.
- Add _recoverShiftedWidgets in flake_stack.js onConfigure for
  FlakeStack and FlakeCombo: if model_family looks like JSON and
  flakes_json is at its empty default, swap them so workflows saved
  by the broken version recover their stack contents on next open.
2026-05-20 23:38:41 -04:00
Mehdi Chaid 3c2a8a15c0 fix: per-combo image storage races, defensive batch tracking (#222, #207)
#222 — flake-generate.js used `node._pending_combination_key` to tag
the upcoming prompt's combo, and the FlakeGenerate node read that key
in onExecuted to stamp the generated image into _images_by_combo. But
queue.js's batch loop advanced the key for the next combo *before* the
previous onExecuted fired, so every image ended up filed under the
last combo's key — the overlay then only displayed the last option.

Move the per-combo storage into queue.js, keyed by prompt_id via the
`executed` websocket event. Maintain a `comboKeys` array parallel to
`promptIds` so an event's prompt_id maps unambiguously to the combo
that produced it, regardless of how many prompts are in flight.
flake-generate.js still records the empty-key image as a safety net
for single-prompt (no combo) runs.

#207 — Make batch tracking defensive against frontend variation:
- Resolve the ComfyUI api singleton from several known locations
  (comfyAPI.api.api, comfyAPI.api, app.api, window.app.api) and warn
  to the console if none are found instead of silently failing.
- Warn when _originalQueuePrompt returns no prompt_id (highlight and
  per-combo image tagging both depend on it).

Closes #222
Refs #207 (defensive helpers; runtime verification still pending the user)
2026-05-20 18:38:12 -04:00
Mehdi Chaid cc5c002248 fix: overlay no longer shows empty image when combo has one option (#220)
When `hasCombos` was false (every combo axis has exactly one item),
the single-panel branch called `combinationData(model, [])` with an
empty selection. combinationData iterates `model.axes` and accesses
`selection[i].presetName`, which throws on undefined — and the catch
in the caller swallowed it, leaving compositeImg.src unset.

Build a default selection of the first (and only) item per axis so
combinationData has the entries it needs. Also align the
lastImagesByCombo lookup key with the one queue.js stamps for the
zero-option case, falling back to `""` when there is no key.

Closes #220
2026-05-20 18:37:51 -04:00
Mehdi Chaid 2c75c15de7 fix: overlay field rows shrink to content width (#219)
The data-row styling forced `min-width:140px; flex:0 1 auto`, which
left empty space inside every row even for short values like "20" or
"euler". Switch to `min-width:0; flex:0 0 auto` so each row sizes to
its content. Applied to both copies of the `section()` helper (combo
panel + single-panel layout).

Closes #219
2026-05-20 18:37:45 -04:00
Mehdi Chaid e6e2db6d34 fix: overlay panel matches content size instead of forcing 95vw (#218)
#197 / 6380a09 expanded the overlay to width:95vw with a 760px floor.
The user actually wanted the background panel to match the size of
its content, not stretch beyond it. Switch the rule to
`width:auto;max-width:min(1400px,95vw);min-width:0;` so the panel
sizes to its content but never exceeds the viewport.

Closes #218
2026-05-20 18:37:39 -04:00
Mehdi Chaid 777fc1be54 fix: overlay shows Flake Stack prompts (corrected field path) (#221)
combinationData() was reading `d.positive_prompt` and `d.negative_prompt`
from the flake yaml, but the yaml has always stored prompts under
`prompt: { positive, negative }` — the snake_case top-level fields
never existed. The overlay therefore silently dropped every flake
prompt (stack and combo alike), making it look like only Model Preset
prompts were displayed.

Fix in both the main combinationData loop and the per-combo refresh
block that re-collects prompts for the left column.

Closes #221
2026-05-20 18:37:31 -04:00
Mehdi Chaid 2db236a8a8 feat: hide model_family on Stack/Combo when flake_data is connected (#216)
The previous attempt (#204 / 36620bd) only set `widget.hidden = true`,
`widget.type = "hidden"`, and zeroed computeSize. On the modern ComfyUI
frontend these flags are not enough — the canvas-drawn COMBO widget
still renders. Result: the model_family dropdown stayed visible on
FlakeStack and FlakeCombo even when an upstream flake_data pin was
connected.

Detach the widget entirely from node.widgets when the input is
connected (saving its prior index so it can be re-inserted in place
when disconnected) and re-add it on disconnect. Also drop the
DOM-querying `_getFamilyWidgetEl` fallback — it relied on label text
that doesn't exist in canvas-drawn widgets and never worked. Run an
initial sync at setup so loaded workflows hide the widget on open
without waiting for the 200ms poll.

Closes #216
2026-05-20 18:37:10 -04:00
Mehdi Chaid 482c8cfdee fix: Into Flake Data (Select) hidden widgets catching pointer events (#214)
The previous approach (`hideIntoWidgets`) flagged optional widgets as
hidden and zeroed their compute size, but left them in node.widgets.
Canvas-drawn LiteGraph widgets that remain attached to the node still
have a hit-test region — they intercepted clicks meant for the new
custom dropdown / + button, changing the cursor and opening the
hidden widget's editor instead of the dropdown.

Rename to `removeIntoWidgets` and actually splice these widgets out of
node.widgets so there is no hit region left. Add an idempotency guard
on setupIntoFlakeDataSelect so the second call (from onConfigure) is a
no-op rather than re-adding the DOM widget twice.

Closes #214
2026-05-20 18:37:01 -04:00
Mehdi Chaid 6000be89c0 fix: Flake Data Split (Select) cannot be added to workflow (#215)
syncSplitOutputs() called `node.disconnectOutputs(i)` (plural), which
does not exist on LiteGraph nodes — the correct API is
`node.disconnectOutput(slot)`. The throw happened during initial setup
because the default state hides 12 of the 13 declared outputs, and
each hide attempt invoked the missing method. The node failed to
register on every add.

Replace the broken call with proper link removal via
`node.graph.removeLink(linkId)` over each output's existing links, so
hiding an output cleanly disconnects any downstream consumers without
relying on a method that doesn't exist. Setting `out.hidden` is left
intact (the modern ComfyUI frontend honors it).

Closes #215
2026-05-20 18:36:54 -04:00
Mehdi Chaid abc9ec5515 fix: Model Preset filename prefix optional, drop yaml-path inheritance (#217)
Two bugs in one:

1. `_load_preset_bundle` used the preset's full yaml-relative name
   (e.g. img/illustrious/nova/nova_anime_xl_v17) as filename_state["preset"],
   which leaked into the output directory structure. The user expects
   the output path to derive only from the explicit Filename Prefix
   field on the preset — not from where the yaml file happens to live
   on disk. Set preset to "" and let the explicit prefix (added via
   stems) be the sole contributor.

2. The preset modal's syncOutputPath() unconditionally rewrote
   filenamePrefixInput.value from the Name field every time the user
   typed in Name. The user could never keep an empty or customized
   prefix. Add filenamePrefixManuallyEdited tracking (mirroring the
   existing pathManuallyEdited pattern) so once the user touches the
   field, sync stops clobbering it.

Closes #217
2026-05-20 18:36:42 -04:00
Mehdi Chaid 13f507af49 feat: variant choice card — name and stem on same line, larger image (#212)
- Move the per-choice Output Stem input into the same row as the
  choice name (and reorder buttons), instead of stacking below.
- Bump the variant choice cover image from 80x80 to 140x140 so it's
  actually legible when comparing choices in a section.

Closes #212
2026-05-20 18:36:30 -04:00
Mehdi Chaid 9b8c439063 fix: Resolution Override field disappears after save (#213)
The previous fix in #201 added a `change` listener to commit typed
values, but `change` only fires on blur. If the user typed values and
clicked Save without first tabbing/clicking away, `fieldState.resolution`
was never updated and the save handler wrote the initial default
(or omitted the field entirely on edge paths).

Two complementary changes:
- Also listen for `input` events (real-time) so values land in
  fieldState as the user types.
- At save time, prefer reading the inputs directly from the DOM
  (`fieldState._resolutionInputs`) and only fall back to the cached
  array. This way the saved value reflects whatever is visible in the
  field even if no event fired.

Closes #213
2026-05-20 18:36:17 -04:00
Mehdi Chaid 5dc3cce7c9 fix: Base Directory dropdown resolved to opposite path (#211)
The /flakes/roots endpoint returns entries whose `index` is the position
in the original folder_paths list, but the entries themselves are
reordered (Comfy Install first, extras after). The frontend dropdown
preserved root.index as the option value, then used it directly as an
array index into rootsCache — which is the reordered list. When the
two orders did not match, selecting "Comfy Install" mapped to the
extra-path entry in rootsCache.

Switch to .find(r => r.index === rootIdx) so the lookup goes through
the entry whose index matches, regardless of cache order. Fixes the
same bug in both the flake modal and the preset modal.

Closes #211
2026-05-20 18:36:02 -04:00
Mehdi Chaid d731cad37a feat: variant choice enhancements — output stem, cover image, auto-detect, reorder (#205)
- Add output_stem field to each variant choice; combines with flake-level stem.
- Move variant choice cover image to the right side of the card.
- Auto-detect variant sibling images from the first LoRA folder when
  adding a new choice (<lora_basename>_<group>_<choice>.ext).
- Add default positive prompt when creating a new variant choice.
- Add up/down reorder buttons and fix rename logic to preserve choice
  order instead of moving renamed choices to the end.
- Backend: new /flakes/lora_variant_sibling_image_path endpoint and
  _resolve_variant_sibling_image_relpath helper.
- Backend: apply ariant.output_stem during flake entry resolution.
2026-05-20 11:12:00 -04:00
Mehdi Chaid 39a0eb4123 feat: improve generation data overlay layout and data display (#210)
- Make overlay panel take nearly full viewport width (95vw).
- Only split into two halves when at least one combo axis has multiple
  options; otherwise use a single centered panel.
- Convert data sections from vertical stacks to flex-wrap grids with
  variable-width items that stack horizontally then vertically.
- Remove Output section; move dimensions below the composite image.
- Update LoRA rows to show actual lora name as label and path as value.
- Show all prompts individually (base + variant) instead of combining
  them into single rows.
2026-05-20 10:55:16 -04:00
Mehdi Chaid 043c50ff72 feat: highlight current combo item during batch generation (#207)
Use ComfyUI execution_start / execution_success / execution_error /
execution_interrupted websocket events to track which queued prompt
is currently running, and highlight the corresponding flake/preset
grid item in FlakeCombo and FlakeModelCombo nodes. Highlights are
cleared only after all batch jobs finish, not immediately after the
queuePrompt HTTP response.
2026-05-20 10:39:21 -04:00
Mehdi Chaid b62fc4304e feat: Prompts layout side-by-side with toggle buttons (#209) 2026-05-20 10:18:25 -04:00
Mehdi Chaid 907385c2d3 fix: Flake Data Split nodes broken - hidden widgets catching clicks and missing outputs (#208) 2026-05-20 10:14:30 -04:00
Mehdi Chaid 36620bdebf feat: Hide Model Family dropdown when flake_data input is connected (#204) 2026-05-20 10:09:25 -04:00
Mehdi Chaid 64ebdc6a81 fix: Filename prefix path missing img/<model_family> structure (#206) 2026-05-20 10:01:00 -04:00
Mehdi Chaid 3b4e710113 fix: Base Directory label rename and listing bug (#202) 2026-05-20 09:52:56 -04:00
Mehdi Chaid 143ce027d2 fix: Resolution Override field changes not saved (#201) 2026-05-20 09:48:37 -04:00
Mehdi Chaid 03776f2ae4 Add logo to Extensions manager 2026-05-19 21:49:49 -04:00
Mehdi Chaid 0a478ee310 WIP updated README 2026-05-19 21:43:17 -04:00
60 changed files with 17986 additions and 2524 deletions
+3 -1
View File
@@ -53,4 +53,6 @@ flakes/
*.yml.local
tmp/
docs/plans/
docs/plans/
docs/todos/
docs/drafts/
+19
View File
@@ -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
View File
@@ -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
+376 -47
View File
@@ -1,14 +1,15 @@
<div align="center">
![ComfyUI Flakes](assets/img/logo_flakes.png)
> 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 -->
<!-- ![Example workflow](docs/workflow_example.png) -->
![Extension Overview](assets/img/extension_overview.png)
</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 Edit](assets/img/flake_fields.png)
---
## Flake Model Preset
A Flake model preset is a config for checkpoints. Alongside the SDXL family it
supports Anima, Krea2 and Z-Image.
![Preset Edit](assets/img/preset_fields.png)
### 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 -->
<!-- ![Flake Stack demo](docs/flake_stack.gif) -->
![Flake Stack demo](assets/img/flake_stack.png)
**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)
![Flake option dropdown](assets/img/option_dropdowns.png)
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
View File
@@ -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

+38
View File
@@ -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.
+423
View File
@@ -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}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+125
View File
@@ -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)
+258
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+146
View File
@@ -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
View File
@@ -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})
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+368
View File
@@ -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"}])
+237
View File
@@ -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"
+369
View File
@@ -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"]
+182
View File
@@ -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]
+34
View File
@@ -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"))
+89
View File
@@ -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")
+302
View File
@@ -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)
+129
View File
@@ -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)
+181
View File
@@ -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)
+63
View File
@@ -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=[]) == {}
+365
View File
@@ -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)
+345
View File
@@ -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"]
+457
View File
@@ -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")
+729
View File
@@ -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)
+496
View File
@@ -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)
+166
View File
@@ -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")
+171
View File
@@ -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)
+46
View File
@@ -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")]
+130
View File
@@ -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)
+248
View File
@@ -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"]
+76
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+70 -18
View File
@@ -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;
};
}
+900
View File
@@ -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 ? "&#9652;" : "&#9662;";
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;
});
}
+27
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+426 -122
View File
@@ -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 ? "&#9662;" : "&#9652;";
});
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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+510 -63
View File
@@ -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);
};
}
+34 -10
View File
@@ -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
View File
@@ -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;
+247 -34
View File
@@ -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();
+109 -22
View File
@@ -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
View File
@@ -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);
};
}
File diff suppressed because it is too large Load Diff