Compare commits

...
Author SHA1 Message Date
Vito c1a8477b8f Merge pull request #109 from ComfyAssets/chore/release-1.3.0
chore(release): 1.3.0 with the Model Resolution Picker
2026-10-27 16:26:15 -07:00
Vito Sansevero 98e9dc4661 chore(release): 1.3.0 with the Model Resolution Picker
- PR #108 changed pyproject.toml without bumping the version, so the
  registry publish on main failed with "The node version already exists".
- Bump to 1.3.0 (new node, route and override file) and add the release
  notes to CHANGELOG.md and the README.
2026-10-27 15:54:32 -07:00
Vito 60f197274c Merge pull request #108 from ComfyAssets/feat/model-resolution-picker
feat(model_resolution_picker): add the Model Resolution Picker node
2026-10-27 15:44:16 -07:00
Vito Sansevero 0cd25a1073 test(model_resolution_picker): cover an override step off the latent grid being ignored whole
- A step of 3 in a user override must drop the whole file with the panel warning and leave the shipped SDXL step at 64.
2026-10-27 15:01:55 -07:00
Vito Sansevero ef5c9bb85a fix(model_resolution_picker): require family steps to be multiples of 8
- An override family could declare a step such as 3; sizes rounded to it
  are not divisible by 8, so create_empty_latent_batch raised ValueError
  at execution instead of producing a latent (Codex review on PR #108).
- validate_table now rejects a step that is not a multiple of 8 with a
  message naming the latent grid; the shipped steps (16, 32, 64) already
  comply, and the schema docs state the rule.
2026-10-27 15:01:19 -07:00
Vito Sansevero f04f2584a8 fix(model_resolution_picker): refit on the next frame after a resize notification
- The ResizeObserver callback measured the panel and resized the node
  synchronously, which forces a layout while the stylesheet may still be
  applying; WebKit then reports "ResizeObserver loop completed with
  undelivered notifications" as a page error, and the browser fixture
  fails any test with a page error (CI WebKit job on PR #108).
- The callback now only schedules a single-flight requestAnimationFrame
  that measures and refits, so no observed box changes during delivery.
- Reproduced in the pinned Playwright WebKit image: the picker tests pass
  48/48 (16 tests × 3) with this change.
2026-10-27 14:54:11 -07:00
Vito Sansevero dfe11928b9 fix(model_resolution_picker): fit the node height to the panel content
- New nodes started at a fixed 340×600, so one-entry size lists left a
  large empty area between the list and the Swap button.
- The panel now reports its measured content height as both the minimum
  and maximum DOM-widget height, and the extension sets the node height to
  the host's computeSize() after every render, keeping the width.
- Hidden or detached panels are not measured, the first measurement is
  retried for a few frames, and a ResizeObserver on the panel children
  refits when the stylesheet arrives after the first render (the coverage
  run exposed that race; a held-stylesheet regression test pins it).
- Workflows load at their saved width and are re-fitted on load; the spec
  and docs describe the new sizing rule.
2026-10-27 14:07:47 -07:00
Vito Sansevero 959c6b9e9f style(model_resolution_picker): format the override tests 2026-10-27 12:50:43 -07:00
Vito Sansevero 35f4463742 docs(model_resolution_picker): correct when override edits apply and say shipped families
- edits show up on a page reload or Refresh node definitions, not on the
  next node mount, because the frontend caches the payload per page
- the 14 count refers to shipped families; an override can add or remove
2026-10-27 12:50:24 -07:00
Vito Sansevero b9ca7976aa fix(model_resolution_picker): map four-value legacy workflows on load
- workflows saved before the resolution widget load shifted by one slot
- onConfigure now maps exactly four widgets_values onto model, width,
  height and batch_size and resets resolution to custom
- the host test stub applies widgets_values and calls onConfigure like LiteGraph
2026-10-27 12:50:16 -07:00
Vito Sansevero 729652facf fix(model_resolution_picker): include file size in the override cache key
- a replacement with the same mtime but different size was served stale
2026-10-27 12:49:17 -07:00
Vito Sansevero 47e95f95c3 fix(model_resolution_picker): bound sizes to the width/height widget limits
- table validation accepts sides 64..8192, matching the widget min/max
- parse_size_label (Python and JS) caps digits and rejects out-of-range
  sizes so create() falls back to width/height instead of overflowing
2026-10-27 12:49:10 -07:00
Vito Sansevero 623915fd7d fix(model_resolution_picker): feed override families into the model combo
- family_labels() and the combo default now read the merged table, so an
  appended family is selectable and a removed one is not offered
- an alias used as the override default is normalised to its label
- the frontend rebuilds the model options from the payload and keeps a
  saved family that is no longer listed so old workflows still show it
2026-10-27 12:48:42 -07:00
Vito Sansevero 0d3ffcb460 docs(model_resolution_picker): add the override example and loading state
- replace the {ex} placeholder with a real override example validated against validate_table
- list the loading state among the cases that show custom
- say the override default may be a label or alias
2026-10-27 12:42:43 -07:00
Vito Sansevero 03ef5b17d6 docs(model_resolution_picker): document the JSON table, override file and resolution dropdown
- describe the families.json schema and the user override file with a full example
- document the warnings behaviour and resolution dropdown precedence
- add the dropdown sync rule to web/ui/README.md
2026-10-27 12:40:25 -07:00
Vito Sansevero d55bc72c34 fix(model_resolution_picker): reset a stale resolution label and use binary megapixels
- Set resolution to custom whenever the panel has no selection (wired,
  failed, non-finite or loading) so a stale label cannot override width/height.
- Compute label megapixels with 1 MP = 1,048,576 px, matching the panel
  header, with half-up rounding so Python and JS labels agree on ties.
2026-10-27 12:31:09 -07:00
Vito Sansevero eaf03874b3 feat(model_resolution_picker): add a resolution dropdown synced with the chip grid
- Add a native resolution COMBO (shipped sizes only) so a size can be picked
  and saved in the workflow without the panel; custom or unparseable labels
  fall back to width/height on both sides.
- Mirror the dropdown with the chips: options follow the active chip, the
  value tracks the current size, and picking a label writes width/height.
- Draw a decorative ratio shape on each ratio chip and show the first
  families warning in the status line.
2026-10-27 12:22:16 -07:00
Vito Sansevero 211d69e04e fix(model_resolution_picker): ignore an override that nests too deeply to parse
- catch RecursionError alongside ValueError when loading, merging and validating
- add tests for deep nesting, remove vs families, and a default naming a removed family
2026-10-27 12:03:04 -07:00
Vito Sansevero cd6d0d9650 feat(model_resolution_picker): merge a user override file into the family table
- add override.py: override_path, merge_override, mtime-cached current_table
- resolve_family and families_payload consult the merged table and expose warnings
- an invalid override is ignored whole with a logged warning
2026-10-27 12:01:20 -07:00
Vito Sansevero 469d3740b8 fix(model_resolution_picker): stop huge integers from overflowing the table validator
- treat ints as finite instead of calling math.isfinite on them
- bound step (4096), megapixels (64) and side length (65536) with normal problem messages
- cover step, megapixels, default and size values of 10**400
2026-10-27 11:58:11 -07:00
Vito Sansevero cb3027717e fix(model_resolution_picker): keep the table validator from raising on malformed data
- check default, labels and aliases are strings before set membership
- reject non-finite step and megapixels via a shared _is_step helper
- cover unhashable and Infinity/NaN inputs in the garbage test
2026-10-27 11:56:54 -07:00
Vito Sansevero db64038b25 refactor(model_resolution_picker): move the family table to families.json with a validator
- ship the table as families.json with per-family notes from the old source comments
- add table.py: load_table, pure validate_table and build_families
- families.py loads and validates at import, raising RuntimeError on problems
- include *.json as package data
2026-10-27 11:54:19 -07:00
Vito Sansevero 61b7d83593 docs(model_resolution_picker): add ratio shapes on chips to the addendum 2026-10-27 11:52:57 -07:00
Vito Sansevero 3421981dcd docs(model_resolution_picker): add addendum for JSON table, user override and resolution dropdown 2026-10-27 11:51:31 -07:00
Vito Sansevero 38f820c8f5 fix(model_resolution_picker): accept renamed labels, dedupe chip sizes, apply early model change
- VALIDATE_INPUTS lets unknown family labels reach create()
- no size is listed under two chips; SD 1.5 3:4 gets true 3:4 sizes
- model change before the families payload loads is applied on load
- custom ratio: clear error on chip click, cap sides at 8192, accept X
- real-host assertions for model change and wired-in state
2026-10-27 11:02:45 -07:00
Vito Sansevero baae2a0fac docs(model_resolution_picker): name the manual GPU check cases
- name Flux.2 / Klein / MOSS at 1248x832 and HunyuanImage 2.1 at 2560x1536
- require saved PNG dimensions to match the node outputs, recorded in the PR
2026-10-27 10:39:59 -07:00
Vito Sansevero f9f9f9a134 docs(model_resolution_picker): document the node, families and keyframe use
- add README tools row, section and status row
- add ui-migration row and web/ui README section
- add per-node doc with family table and manual GPU check
2026-10-27 10:38:09 -07:00
Vito Sansevero c8a1823569 test(model_resolution_picker): cover both renderers and workflow round-trip on the pinned host
- extract the real-host beforeEach into tests/comfyui/host-setup.mjs and proxy /kikotools/model_resolution/ to the fixture
- add the picker spec: renderer switching, shared swap, themes, workflow reload, scope abort
- add light and dark screenshots for the docs
2026-10-27 10:36:01 -07:00
Vito Sansevero 655049eec8 feat(model_resolution_picker): model change, custom ratio, wired-in and failure handling
- render the static chip list in every state; payload only drives enabled/active
- re-derive size on model change via sizeOnModelChange
- validate custom ratio, keep last valid list, ignore repeated commits
- add swap, model change, custom ratio and route failure browser tests
- add coverage floors for the three picker files
2026-10-27 10:31:15 -07:00
Vito Sansevero c62f363831 fix(model_resolution_picker): keep focus, pressed hover and wired-in state current
- restore keyboard focus to the equivalent button after each render
- keep the accent background when hovering a pressed chip or size
- refresh the panel when input links change
- treat a size listed in the clicked chip as a no-op
2026-10-27 10:23:32 -07:00
Vito Sansevero 97a4c7b78b feat(model_resolution_picker): add the chip grid panel and extension
- add resolution picker view with chip grid, size list and custom ratio input
- add extension that projects native widgets and writes sizes via callbacks
- add browser tests for rendering, theming, idempotent mount and disposal
2026-10-27 10:20:02 -07:00
Vito Sansevero bc4a3f44f9 feat(ui): add resolution picker derivation module
- add pure ratio parsing, chip derivation and size generation helpers
- round sizes to the family step with a 64 pixel floor
2026-10-27 10:16:02 -07:00
Vito Sansevero 408fc5e2f8 feat(ui): let swapDimensions work on nodes without a preset widget
- Treat a missing preset widget like preset value 'custom' in swapDimensions
- Keep every preset access behind the custom check
- Add a test that swaps width and height directly on a preset-less node
2026-10-27 10:14:24 -07:00
Vito Sansevero 83ed5ae42d feat(model_resolution_picker): add node, families route and registration
- add ModelResolutionPickerNode rounding to the family step
- serve GET /kikotools/model_resolution/families
- register the node in NODE_CLASS_MAPPINGS
2026-10-27 10:13:07 -07:00
Vito Sansevero f916636c6a feat(model_resolution_picker): add the model family resolution table
- add CHIPS, MIRROR, Family and the FAMILIES literal table
- add family_labels, resolve_family and families_payload helpers
- cover step, ratio, mirror and sort invariants with tests
2026-10-27 10:09:50 -07:00
Vito Sansevero 2f497cec5a feat(model_resolution_picker): add step rounding and size generation
- add round_to_step, generate_size and ratio_label pure helpers
- cover nearest-step rounding, minimum clamp, size math and aliases
2026-10-27 10:06:50 -07:00
Vito Sansevero a7061a29e6 docs(model_resolution_picker): add implementation plan 2026-10-27 10:01:51 -07:00
Vito Sansevero 98e18267f5 docs(model_resolution_picker): add approved design for the Model Resolution Picker node
- New node: model dropdown drives a Pixaroma-style ratio chip grid and size
  list, outputs width, height and an empty latent, with the toolkit Swap W×H
- Family table lives in Python and is served to the frontend by a local route
- Panel state is derived from the native model/width/height widgets only
- Video families are listed as keyframe sizes for first/last-frame generation
- Records the builder-check calls and the SDXL default
2026-10-27 09:54:37 -07:00
Vito 1c31ea118f Merge pull request #107 from ComfyAssets/fix/empty-latent-batch-spatial-ratio
fix: mark EmptyLatentBatch latents with their 8x spatial ratio
2026-10-27 04:48:08 -07:00
Vito Sansevero 297f2b1863 fix: mark EmptyLatentBatch latents with their 8x spatial ratio
ComfyUI resizes an empty latent to the model's latent format only when the
latent carries downscale_ratio_spacial, as core EmptyLatentImage sets it.
Without it, models with 16x VAEs (Flux.2, Klein, MOSS-Image, Qwen-Image 2.1)
sampled at twice the requested width and height.
2026-10-26 20:07:03 -07:00
Vito eb2db82a80 Merge pull request #106 from ComfyAssets/test/pinned-comfyui-ci
test(ci): automate real ComfyUI checks and prepare 1.2.0
2026-10-16 18:32:11 -07:00
Vito Sansevero 1820fb19ea test(ci): enforce reviewed CPU dependency versions 2026-10-16 17:29:08 -07:00
Vito Sansevero 7edf08f1e2 docs: repair legacy README references 2026-10-16 17:26:23 -07:00
Vito Sansevero 1721778cb5 test(ci): automate pinned ComfyUI checks and prepare 1.2.0 2026-10-16 17:23:11 -07:00
Vito 6bac864c4d Merge pull request #104 from ComfyAssets/fix/follow-execution-lifecycle
fix(follow_execution): honor live settings and stabilize subscriptions
2026-10-16 17:05:02 -07:00
Vito Sansevero 8dc81364a0 test(follow_execution): verify native menus in both renderers 2026-10-16 16:58:34 -07:00
Vito Sansevero 957ca3c142 fix(follow_execution): honor live settings and stabilize subscriptions 2026-10-16 16:50:58 -07:00
Vito ad73422472 Merge pull request #101 from ComfyAssets/chore/deprecate-gemini
chore(gemini): soft-deprecate legacy prompt node
2026-10-16 16:42:35 -07:00
Vito 1a7e0dfcd7 Merge branch 'main' into chore/deprecate-gemini 2026-10-16 16:36:20 -07:00
Vito 0b85600d38 Merge pull request #102 from ComfyAssets/feat/shared-custom-colors
feat(custom_colors): adopt shared ComfyAssets picker
2026-10-16 16:34:34 -07:00
Vito Sansevero ca65cc7961 test(loader): await initial gallery before keyboard controls 2026-10-16 16:25:33 -07:00
Vito Sansevero 6adba639bb feat(custom_colors): adopt shared ComfyAssets picker 2026-10-16 16:17:28 -07:00
Vito Sansevero 1e86b73168 chore(gemini): soft-deprecate legacy prompt node 2026-10-16 15:44:31 -07:00
Vito 0fa595a9b7 Merge pull request #98 from ComfyAssets/feat/shared-embedding-autocomplete
feat(autocomplete): unify UI and restore tag management
2026-10-16 15:38:45 -07:00
Vito Sansevero f8ce4cb982 fix(autocomplete): address review findings and WebKit readiness 2026-10-16 15:31:46 -07:00
Vito Sansevero 42b6009f1b feat(autocomplete): restore tag imports and advanced settings 2026-10-16 15:11:56 -07:00
Vito Sansevero 963ac0a4af docs(autocomplete): document versioned Danbooru tag pack and terms 2026-10-16 14:45:29 -07:00
Vito Sansevero b4bd8b98b1 feat(ui): unify Embedding Autocomplete controls and suggestions 2026-10-16 13:49:11 -07:00
Vito ed6675721d Merge pull request #95 from ComfyAssets/feat/shared-display-views
feat(ui): unify Display Text and Display Any output views
2026-10-16 13:25:23 -07:00
Vito Sansevero b0c0d53676 feat(ui): unify Display Text and Display Any output views 2026-10-16 13:20:28 -07:00
Vito b9103ad9c4 Merge pull request #93 from ComfyAssets/feat/shared-node-dimensions-dialog
feat(ui): migrate Node Dimensions to shared dialog controls
2026-10-16 13:09:13 -07:00
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
Vito Sansevero 0c47dc6085 feat(ui): migrate Workflow Timer to shared controls 2026-10-16 12:18:02 -07:00
Vito ee7d1e9011 Merge pull request #87 from ComfyAssets/feat/shared-seed-history
feat(ui): migrate Seed History to shared controls
2026-10-16 12:00:07 -07:00
Vito Sansevero b7a29a1731 feat(ui): migrate Seed History to shared controls 2026-10-16 11:55:57 -07:00
Vito 3cabbffb40 Merge pull request #85 from ComfyAssets/feat/shared-image-viewer
feat(ui): migrate Save Image viewer to shared window controls
2026-10-16 11:37:46 -07:00
Vito Sansevero 47dc177517 feat(ui): migrate saved-image viewer to shared window controls 2026-10-16 11:30:11 -07:00
Vito ddb356fe17 Merge pull request #83 from ComfyAssets/feat/live-image-loader-ui
feat(ui): ship shared ComfyAssets controls in Local Image Loader
2026-10-16 11:08:36 -07:00
Vito Sansevero 9e2b271824 fix(ui): keep gallery thumbnails large and adjustable 2026-10-16 11:03:33 -07:00
Vito Sansevero 2e7fac4b70 docs(ui): capture the loader in both ComfyUI renderers 2026-10-16 10:53:07 -07:00
Vito Sansevero 740eccec27 feat(ui): migrate Local Image Loader to shared ComfyAssets controls 2026-10-16 10:52:28 -07:00
Vito 55bce1004b Merge pull request #79 from ComfyAssets/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-16 09:18:54 -07:00
Vito 63a8596b81 Merge branch 'main' into dependabot/github_actions/actions/setup-node-7 2026-10-16 09:14:46 -07:00
Vito 11494c747c Merge pull request #78 from ComfyAssets/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-10-16 09:14:32 -07:00
Vito 88a9238ee2 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-16 09:10:46 -07:00
Vito 3879f18d79 Merge pull request #72 from ComfyAssets/feat/checkpoint-folder-iterator
feat(xyz_helpers): Checkpoint Folder Iterator and Model Compare Sheet
2026-10-16 09:10:28 -07:00
Vito Sansevero 64f70b6970 fix(model_compare_sheet): hide staging files from output image discovery 2026-10-16 09:03:56 -07:00
Vito Sansevero ae0fb7f229 fix(model_compare_sheet): replace rerun tiles without retaining stale batches 2026-10-16 08:59:29 -07:00
Vito ddf73a667e Merge branch 'main' into feat/checkpoint-folder-iterator 2026-10-16 08:57:09 -07:00
dependabot[bot] 46999c8d90 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:59 +00:00
dependabot[bot] 852864a270 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:47 +00:00
Vito c182c775f4 Merge pull request #77 from ComfyAssets/test/merge-confidence
test: add extension workflows coverage and required merge checks
2026-10-16 08:46:26 -07:00
Vito Sansevero 02108be2d1 test: cover gallery pagination and directory autocomplete deterministically 2026-10-16 08:42:23 -07:00
Vito Sansevero 2a9fbe78c7 test: allow same-origin WebKit blob requests in network guard 2026-10-16 08:36:38 -07:00
Vito Sansevero 5a8a8b62e4 test: add extension workflows coverage and required merge checks 2026-10-16 08:32:11 -07:00
Vito Sansevero 335cc53961 fix(model_compare_sheet): reject run_name segments on both separators
- Split run_name on "/" and "\" and reject empty, "." and ".." parts, so
  a Windows-style "..\foo" can no longer resolve outside model_compare
  and let the index-0 archive step rename an unrelated output folder
- Reject drive-letter names and require the resolved directory to sit
  strictly below output/model_compare after symlinks are followed
- Nested run names such as project/date/sweep still work
2026-10-16 08:11:17 -07:00
Vito Sansevero c5e00c130b feat(xyz_helpers): add Checkpoint Folder Iterator and Model Compare Sheet
- Checkpoint Folder Iterator loads one checkpoint per queued run from a
  folder (relative to models/checkpoints or absolute), so a whole folder
  can be swept with only one model in memory at a time
- Model Compare Sheet saves each run's image as a tile under
  output/model_compare/<run_name>/ and rebuilds a labeled contact sheet
  from disk every run, so the grid survives between independent prompts
- Starting a sweep at index 0 archives stale tiles to a timestamped folder
- Running past the last checkpoint raises a clear error instead of
  wrapping, so an over-queued batch never restarts and archives a
  finished sweep
- Checkpoints load from the scanned absolute path, so folders outside the
  configured roots work as the tooltip promises
- Extract the LoRA Folder Batch folder-scanning, natural-sort and regex
  filter helpers into kikotools/base/model_folders.py so both tools share
  one implementation
2026-10-16 08:08:00 -07:00
Vito 78f1498ea8 Merge pull request #71 from ComfyAssets/release/1.1.2
Release 1.1.2 - ComfyUI scanner fixes
2026-10-15 04:21:22 -07:00
Vito Sansevero c65f3aad03 chore(release): 1.1.2 with .comfyignore for registry archive
- Add .comfyignore so `comfy node publish` ships only the runtime
  package: __init__.py, kikotools/, web/, examples/, README, LICENSE,
  pyproject.toml, requirements.txt
- Exclude tests/, scripts/, .github/, dev tooling configs, contributor
  docs, and the stray example.py reference file (not imported anywhere)
- Registry scanner flagged 1.1.1 on five info-level pattern matches;
  two were in tests/ and the historical Model.eval matches came from
  example.py, none of which should be in the published archive
- Bump version to 1.1.2
2026-10-15 04:16:17 -07:00
Vito 9a55c23282 Merge pull request #70 from ComfyAssets/release/1.1.1
Release 1.1.1
2026-10-13 18:36:23 -07:00
Vito Sansevero e90bde5849 chore: release 1.1.1
Gemini Prompt node migrated from the deprecated google-generativeai SDK to google-genai.
2026-10-13 18:29:24 -07:00
Vito Sansevero 49523e10de chore(gemini_prompt): migrate to google-genai SDK
- google-generativeai is deprecated and emits a FutureWarning at node
  load; google-genai is Google's supported replacement
- Image analysis now uses genai.Client().models.generate_content
  instead of the module-level configure/GenerativeModel API
- Model listing reads supported_actions, the new SDK's name for
  supported_generation_methods
- Install instructions, requirements.txt, and README updated to the
  new package name
2026-10-13 18:27:52 -07:00
Vito 8e6cf4233a Merge pull request #69 from ComfyAssets/release/1.1.0
Release 1.1.0
2026-10-13 08:12:57 -07:00
Vito Sansevero 6301cf90f8 chore: release 1.1.0
Security hardening and fixes (#64), Project File Path node (#65, #67), runtime state untracked (#66), dependency bumps (#68).
2026-10-13 08:01:04 -07:00
Vito f87eb959b3 Merge pull request #68 from ComfyAssets/chore/dependency-bumps
Apply pending Dependabot bumps (#56-#63)
2026-10-13 07:47:06 -07:00
Vito Sansevero 7783ed8a16 build(deps): apply pending Dependabot bumps
Combines #56-#63:
- torch >=2.11.0, pillow >=12.2.0, mypy >=1.20.0, pytest-mock >=3.15.1,
  pre-commit >=4.5.1 in both pyproject.toml and requirements-dev.txt
  (Dependabot only edited pyproject, but CI installs requirements-dev.txt)
- actions/checkout v7, actions/cache v6, softprops/action-gh-release v3
  across all workflows, including the new test/coverage/js jobs
2026-10-13 07:44:16 -07:00
Vito 726fcee761 Merge pull request #66 from ComfyAssets/chore/untrack-runtime-state
Stop tracking runtime state files
2026-10-13 07:42:21 -07:00
Vito 98f3defe50 Merge pull request #67 from ComfyAssets/fix/project-file-path-output-label
Project File Path: label output STRING like djz-nodes
2026-10-13 07:41:55 -07:00
Vito Sansevero 143a6c47f6 fix(project_file_path): label the output STRING like djz-nodes
Drop RETURN_NAMES so ComfyUI shows the output as STRING, matching the original Project File Path Generator. Links connect by slot index, so existing workflows are unaffected.
2026-10-13 07:38:33 -07:00
Vito Sansevero 76a52765c7 chore: stop tracking runtime state files
The image loader's config.json/selections.json (one user's saved folders
and selections) and the Gemini model cache were committed, so every
install shipped them as defaults, and running from a symlinked checkout
dirtied the tree. Untrack them and ignore them, plus gemini_config.json
which would hold an API key. All loaders already handle the files being
absent.
2026-10-13 07:37:27 -07:00
Vito f308b23631 Merge pull request #65 from ComfyAssets/feature/project-file-path
Add Project File Path node
2026-10-13 07:32:25 -07:00
Vito Sansevero d65c11c76c feat(project_file_path): add Project File Path node
Builds a save prefix like MyProject/2026-10-13/images/image for a save
node's filename_prefix, replacing djz-nodes' Project File Path Generator
with a fresh implementation:

- Same inputs, root defaults to empty (prefixes are already relative to
  the output folder, so "output" nested as output/output/...)
- Optional date folder; IS_CHANGED tracks the date instead of forcing a
  re-run on every queue
- Components are split on / and \ with '..' segments dropped, so a path
  can't climb out of the output folder
- Only real file extensions are stripped (render_v1.5 keeps its dot)
- The separator option is applied last instead of being undone by
  normpath; an empty filename raises a clear error
2026-10-13 07:27:49 -07:00
Vito 1879f42f83 Merge pull request #64 from ComfyAssets/fix/review-security-and-coverage
Security hardening, crash fixes, cross-platform CI and 95% coverage
2026-10-13 07:26:02 -07:00
Vito Sansevero c2d85d4ba3 test: compare normalized save subfolder (forward slashes on Windows) 2026-10-13 07:23:11 -07:00
Vito Sansevero 2246484b99 fix: keep console output ASCII so Windows legacy consoles can load the node
A Windows console on a legacy code page (cp1252, or cp437 in cmd)
raises UnicodeEncodeError on emoji. The startup banner printed a 🫶
prefix and display names like "Model Downloader 🌐", which aborts
loading the whole node pack. Same approach as ComfyUI_PromptManager:

- Banner prints ASCII-only display names (emoji stay in the UI names)
- Preset validation messages print "1024x1024" instead of "1024×1024"
- Explicit UTF-8 for text files (Windows defaults to cp1252)
- Regression test imports the package with a strict cp1252/cp437 stdout
2026-10-13 07:20:11 -07:00
Vito Sansevero 2a637322b9 test: fix Windows-only test failures
- Close PIL images and NamedTemporaryFile handles before deleting the
  files; Windows refuses to delete open files (WinError 32)
- Set USERPROFILE alongside HOME: expanduser ignores HOME on Windows
- Compare realpaths in the nested save-prefix test; Windows temp dirs
  use 8.3 short names (RUNNER~1)
2026-10-13 07:18:30 -07:00
Vito Sansevero 684e61638b chore: exclude gitignored referance/ from flake8 2026-10-13 07:13:50 -07:00
Vito Sansevero a0b132ef7b ci: run pytest on Linux, Windows and macOS with an 85% coverage gate
tests.yml only ran inline smoke scripts on Ubuntu, never the pytest
suite, so broken tests went unnoticed on main. Mirror ComfyUI_PromptManager:

- pytest on ubuntu/windows/macos x Python 3.10-3.13 (fail-fast off)
- separate coverage job enforcing fail_under = 85
- syntax check every web/*.js as an ES module
- CPU-only torch on Linux to avoid the ~2.5 GB CUDA wheel
2026-10-13 07:13:08 -07:00
Vito Sansevero 935e94b651 fix(web): restore custom colors on group menus and honor auto-shade
- Groups have no node-menu hook, so the menu-hook refactor dropped their
  custom color entry; add it via getCanvasMenuItems using the group under
  the mouse, as the frontend's own Comfy.GroupOptions does
- The "Auto-shade Title" setting was read but never applied
- Share one hidden color input instead of three
- Don't mark the canvas dirty while merely building the node menu
2026-10-13 07:07:34 -07:00
Vito Sansevero 7513479419 refactor(web): use extension menu hooks instead of patching LiteGraph
Replace monkey-patches of LGraphCanvas.onMenuNodeColors and the
canvas/node menu getters (applied after a 500ms timeout, and scraping
.litecontextmenu DOM for "No color") with the frontend's
getNodeMenuItems/getCanvasMenuItems extension hooks. Custom colors move
into a "Custom Colors" submenu; follow-execution state moves to module
scope so repeated setup() calls no longer stack wrappers.
2026-10-13 06:57:41 -07:00
Vito Sansevero 865dde6cdf feat(web): offer "Add folder & open" when browsing an unsaved folder
The backend now 403s folders outside the saved paths. Show the full path
and add it only on an explicit click; server-side refusals (e.g. a
too-broad folder) are shown in the status line.
2026-10-13 06:56:49 -07:00
Vito Sansevero 30fc6aa7a7 fix(local_image_loader): refuse filesystem roots and home as saved paths
save_paths is reachable without auth, so saving "/" made every media
file on the machine servable. Reject filesystem/drive roots, the home
directory and its ancestors (realpath-resolved, so symlinks count), and
ignore such entries already in config.json.
2026-10-13 06:56:49 -07:00
Vito Sansevero a055543d16 fix(model_downloader): never write into custom_nodes or save code files
ComfyUI registers custom_nodes in folder_names_and_paths, so treating
every registered folder as a download root let a queued prompt drop a
.py file there that runs on the next restart.

- Skip custom_nodes when collecting allowed roots
- save_response only writes model/data extensions (.safetensors, .gguf,
  .ckpt, .pth, .json, ...), so any future code-holding folder is still
  protected
2026-10-13 06:41:13 -07:00
Vito Sansevero 78fd20d89a fix(local_image_loader): load the resolved path that was validated 2026-10-13 06:38:23 -07:00
Vito Sansevero 4b66592715 test: cover gemini, embedding autocomplete, batch prompts, presets and plot nodes 2026-10-13 06:38:20 -07:00
Vito Sansevero dc3fcf65e2 fix: save path escape, global RNG reseed and small node bugs
- KikoSaveImage: reject absolute or '..' filename_prefix directories that
  resolved outside the output folder
- Film grain: use a local torch.Generator instead of reseeding the global
  RNG; strength 0 no longer skips the toe adjustment
- Purge VRAM: IS_CHANGED returns NaN so caching never skips the purge
- Resolution calculator: clamp outputs to >= 8
- Plot sampler params: font size floor of 1 for images under 32px wide
- Gemini prompt: drop Image.fromarray mode= argument (removed in Pillow)
2026-10-13 06:38:20 -07:00
Vito Sansevero 8196e65906 chore: remove dead modules and consolidate test config
- Delete kikotools/core (registry/settings) and batch_prompts/node_simple
  which nothing imports
- pytest.ini used the [tool:pytest] header and was ignored; make it the
  single pytest config (drop the pyproject duplicate), keep
  --strict-markers
- Add pytest-asyncio to dev deps; enforce 85% coverage via
  [tool.coverage.report] fail_under
2026-10-13 06:38:20 -07:00
Vito Sansevero c8de8f26d6 fix(model_downloader): confine downloads to model dirs and harden fetches
- Reject filenames with path components; reduce server-derived names
  (Content-Disposition, CivitAI redirect, URL) to a safe basename
- save_path must resolve inside models_dir or a registered model folder
  (including extra_model_paths.yaml roots); relative paths resolve
  against ComfyUI's base path
- Only http/https for user URLs and redirects; drop the Authorization
  header on cross-host redirects
- Stream to <file>.part and rename on success, removing the partial file
  on any failure, so a dropped connection no longer leaves a truncated
  model that later runs treat as complete
- 60s timeout on every request; shared streaming loop in BaseDownloader
- Custom downloader honours Content-Disposition before the default name
2026-10-13 06:38:20 -07:00
Vito Sansevero 10027fcef9 fix(local_image_loader): confine file routes to saved roots and media types
/view served any readable file (e.g. ~/.ssh/id_rsa) to anyone who could
reach the server; the only guard was a '..' substring check.

- Resolve paths with realpath and require commonpath containment in the
  saved paths or ComfyUI input/output directories
- /view serves image/video/audio only, /thumbnail and load_media images
  only; the extension check applies to the resolved path, so symlinks
  can't smuggle other files
- /images 403s outside the roots; page/per_page validated and clamped
- /list_directories (folder picker) returns directory names only
- load_media refuses disallowed paths from a tampered selections.json
- Route handlers moved to module level so they are unit tested
- Update stale tests to the (image, info) return contract
2026-10-13 06:34:13 -07:00
Vito Sansevero 60ce078aed fix(xyz_helpers): raise on invalid sampler/LoRA input instead of hanging
- LoRA strength strings: step <= 0 looped forever and reversed ranges
  returned [] (ZeroDivisionError later); now raise ValueError, and cap
  generated values at 1000
- Flux sampler params: unparseable steps/guidance/shift/denoise/seed
  tokens raise with the field name instead of being dropped, and an
  empty LoRA folder raises instead of returning (None, [])
- Detect Flux schnell via unet_config guidance_embed (the ModelType.FLOW
  check never matched Flux)
- IS_CHANGED: re-run Flux params when a seed is '?'; LoRA folder batch
  hashes file list + mtimes instead of always re-executing
- LoRA folder scan uses commonpath containment across all lora roots
- Prompt separators must be whole lines; failed encodes raise
2026-10-13 06:33:17 -07:00
Vito Sansevero 37c00b633c fix(web): escape server data in innerHTML and plug listener leaks
- Escape file names, paths and image metadata interpolated into
  innerHTML in the local image loader and save image viewer (XSS via
  crafted file names or a shared workflow's filename_prefix)
- Save image viewer: bind document drag listeners once and remove them
  in disconnectedCallback instead of adding two per run; drop debug
  logging and the window.kikoSaveImageTest helper
- Local image loader: check response.ok and surface 400/403 errors,
  disconnect the ResizeObserver on node removal
- Gemini prompt: restore the user's node color after the run flash
- Workflow timer: chain onRemoved, merge properties in onConfigure
- Seed history: build entries with textContent
2026-10-13 06:32:14 -07:00
Vito c7f24a3262 Merge pull request #55 from ComfyAssets/feature/batch-list-converter
feat: add batch/list conversion nodes for IMAGE and LATENT
2026-02-09 17:46:52 -08:00
Vito Sansevero c69281e795 fix(batch_list_converter): preserve all latent dict keys during split/join
Previously split_latent_batch and join_latent_batch only handled the
"samples" key, silently dropping metadata like noise_mask or batch_index.
This broke inpaint and masked workflows after a round-trip conversion.
2026-02-09 17:11:01 -08:00
Vito Sansevero 3bf8391e88 feat(batch_list_converter): add batch/list conversion nodes for IMAGE and LATENT
Add 4 utility nodes for converting between batched tensors and lists,
eliminating the dependency on Impact Pack for this common operation:
- ImageBatchToImageList (OUTPUT_IS_LIST)
- ImageListToImageBatch (INPUT_IS_LIST)
- LatentBatchToLatentList (OUTPUT_IS_LIST)
- LatentListToLatentBatch (INPUT_IS_LIST)
2026-02-09 16:39:28 -08:00
Vito d13bfe8fb4 Merge pull request #53 from ComfyAssets/feature/seed-range-and-latent-output
fix: 32-bit seed range, latent batch_size output
2026-02-09 16:32:17 -08:00
Vito Sansevero 702d0c889c fix(seed_history): accept legacy kwargs for backward compatibility
Old workflows may still pass 'mode' to output_seed. Using **kwargs
prevents TypeError when ComfyUI invokes with the extra argument.
2026-02-09 16:29:11 -08:00
Vito Sansevero d1a5282ca0 style: fix Black formatting issues
Remove extra blank line in local_image_loader/node.py and wrap long
tuple assertion in test_empty_latent_batch.py.
2026-02-09 16:26:08 -08:00
Vito Sansevero 36212adf73 ci: reduce test matrix to Python 3.11, 3.12, 3.13
Drop 3.8, 3.9, and 3.10 which are no longer needed.
2026-02-09 16:25:30 -08:00
Vito Sansevero 2cb2262293 chore(local_image_loader): update saved paths and selections 2026-02-09 16:22:10 -08:00
Vito Sansevero 8773c8f249 feat(latent): add batch_size as 4th output from EmptyLatentBatchNode
Exposes batch_size as an output so downstream nodes can reference it
directly without needing a separate input.
2026-02-09 16:22:03 -08:00
Vito Sansevero 0a1cbe4990 fix(seeds): reduce seed range from 64-bit to 32-bit across all nodes
JS Math.random() only has 53 bits of integer precision, making the
64-bit range produce non-uniform values. 32-bit (0xFFFFFFFF) is the
standard ComfyUI seed range and works correctly in both Python and JS.

Also removes the redundant mode input from SeedHistoryNode, relying on
ComfyUI's built-in control_after_generate widget instead.
2026-02-09 16:21:56 -08:00
Vito c4a27137e7 Merge pull request #52 from ComfyAssets/feature/height-width-2-vec
Feature/height width 2 vec
2025-12-21 06:49:05 -08:00
Vito 8457db0041 Merge pull request #50 from ComfyAssets/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-17 07:07:19 -08:00
Vito 8a84927adf Merge pull request #51 from ComfyAssets/dependabot/github_actions/actions/cache-5
build(deps): bump actions/cache from 4 to 5
2025-12-17 07:06:54 -08:00
dependabot[bot] 97ea5b9a7d build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 16:28:14 +00:00
dependabot[bot] f5f956ab4e build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 16:55:51 +00:00
228 changed files with 22493 additions and 6966 deletions
+29
View File
@@ -0,0 +1,29 @@
# Files excluded from the Comfy Registry archive (gitignore syntax).
# Everything here is development-only; the published node needs
# __init__.py, kikotools/, web/, examples/, README, LICENSE, pyproject.toml.
# Test suite and tooling
tests/
scripts/
.github/
Makefile
pytest.ini
mypy.ini
.flake8
.pre-commit-config.yaml
.secrets.baseline
requirements-dev.txt
# Reference / scratch code not imported by the node
example.py
# Agent and contributor docs
CLAUDE.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
tools/
.ci-comfyui/
coverage/
TESTING.md
AGENTS.md
+3 -1
View File
@@ -2,6 +2,7 @@
max-line-length = 127 max-line-length = 127
max-complexity = 10 max-complexity = 10
exclude = exclude =
node_modules,
.git, .git,
__pycache__, __pycache__,
.mypy_cache, .mypy_cache,
@@ -11,7 +12,8 @@ exclude =
build, build,
dist, dist,
*.egg-info, *.egg-info,
.tox .tox,
referance
ignore = ignore =
# W503: line break before binary operator (conflicts with Black) # W503: line break before binary operator (conflicts with Black)
W503, W503,
+16
View File
@@ -0,0 +1,16 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "real ComfyUI compatibility", "code-quality"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
+4
View File
@@ -8,3 +8,7 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
- package-ecosystem: "npm"
directory: "/tools/testing"
schedule:
interval: "weekly"
+9 -7
View File
@@ -13,15 +13,15 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.10' python-version: '3.10'
- name: Cache pip dependencies - name: Cache pip dependencies
uses: actions/cache@v4 uses: actions/cache@v6
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }} key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
@@ -31,6 +31,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Format check with Black - name: Format check with Black
@@ -141,10 +142,10 @@ jobs:
security: security:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.10' python-version: '3.10'
@@ -172,16 +173,17 @@ jobs:
architecture: architecture:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Check architecture compliance - name: Check architecture compliance
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: ${{ github.repository_owner == 'ComfyAssets' }} if: ${{ github.repository_owner == 'ComfyAssets' }}
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v5 uses: actions/checkout@v7
with: with:
submodules: true submodules: true
- name: Publish Custom Node - name: Publish Custom Node
+10 -37
View File
@@ -10,55 +10,28 @@ on:
jobs: jobs:
create-release: create-release:
outputs:
version: ${{ steps.get_version.outputs.version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.10' python-version: '3.10'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run full test suite - name: Run full test suite and coverage gate
run: | run: python -m pytest tests --cov=kikotools --cov-report=term-missing
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Run comprehensive tests before release
from kikotools.base import ComfyAssetsBaseNode
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
import torch
print('Running pre-release validation...')
# Test all major functionality
node = ResolutionCalculatorNode()
# Test various scenarios
test_cases = [
(torch.randn(1, 512, 512, 3), 2.0),
(torch.randn(1, 1024, 1024, 3), 1.5),
(torch.randn(1, 1216, 832, 3), 1.53), # User scenario
]
for i, (image, scale) in enumerate(test_cases):
width, height = node.calculate_resolution(scale, image=image)
print(f'✓ Test case {i+1}: {image.shape[2]}×{image.shape[1]} → {width}×{height} (scale: {scale})')
assert width % 8 == 0 and height % 8 == 0
print('🎉 All pre-release tests passed!')
"
- name: Extract version from tag - name: Extract version from tag
id: get_version id: get_version
@@ -100,7 +73,7 @@ jobs:
### 📊 Technical Details ### 📊 Technical Details
- **Nodes**: 1 (Resolution Calculator) - **Nodes**: 1 (Resolution Calculator)
- **Test Coverage**: 100% - **Python Coverage Gate**: 85% combined statement/branch coverage
- **Python Support**: 3.8+ - **Python Support**: 3.8+
- **ComfyUI Compatibility**: Latest - **ComfyUI Compatibility**: Latest
@@ -114,7 +87,7 @@ jobs:
EOF EOF
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ steps.get_version.outputs.version }} tag_name: ${{ steps.get_version.outputs.version }}
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }} name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
@@ -147,4 +120,4 @@ jobs:
echo "- Resolution Calculator tool" echo "- Resolution Calculator tool"
echo "- Complete documentation" echo "- Complete documentation"
echo "- Example workflows" echo "- Example workflows"
echo "- 100% test coverage" echo "- Enforced Python coverage and frontend regression checks"
+162 -449
View File
@@ -11,478 +11,191 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest name: py${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false
matrix: matrix:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Cache pip dependencies # The default Linux torch wheel bundles CUDA (~2.5 GB); tests only need CPU.
uses: actions/cache@v4 # Windows and macOS default wheels are already CPU-only.
with: - name: Install CPU torch (Linux)
path: ~/.cache/pip if: runner.os == 'Linux'
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }} run: |
restore-keys: | python -m pip install --upgrade pip
${{ runner.os }}-pip- pip install torch --index-url https://download.pytorch.org/whl/cpu
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run core functionality tests - name: Run tests
run: | run: python -m pytest tests --junitxml=test-results/pytest.xml
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Test imports - name: Upload test results
from kikotools.base import ComfyAssetsBaseNode if: always()
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions uses: actions/upload-artifact@v7
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
import torch
print('✓ All imports successful')
# Test base node
assert 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
print('✓ Base node tests passed')
# Test dimension extraction
mock_image = torch.randn(1, 1216, 832, 3)
width, height = extract_dimensions(image=mock_image)
assert width == 832 and height == 1216
print('✓ Dimension extraction tests passed')
# Test scaling
new_width, new_height = calculate_scaled_dimensions(832, 1216, 1.5)
assert new_width % 8 == 0 and new_height % 8 == 0
print('✓ Scaling tests passed')
# Test node interface
node = ResolutionCalculatorNode()
result_width, result_height = node.calculate_resolution(1.5, image=mock_image)
assert isinstance(result_width, int) and isinstance(result_height, int)
assert result_width % 8 == 0 and result_height % 8 == 0
print('✓ Node interface tests passed')
# Test user scenario
width, height = node.calculate_resolution(1.53, image=mock_image)
print(f'✓ User scenario test: 832×1216 → {width}×{height} (1.53x scale)')
print('🎉 All tests passed!')
"
- name: Test Width Height Selector
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Test Width Height Selector imports
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA
from kikotools.tools.width_height_selector.logic import get_preset_dimensions
print('✓ Width Height Selector imports successful')
# Test preset structure
assert len(PRESET_OPTIONS) > 0
assert 'custom' in PRESET_OPTIONS
assert len(PRESET_METADATA) > 0
print('✓ Preset structure tests passed')
# Test node interface
node = WidthHeightSelectorNode()
input_types = node.INPUT_TYPES()
assert 'required' in input_types
assert 'preset' in input_types['required']
assert 'width' in input_types['required']
assert 'height' in input_types['required']
print('✓ Node interface tests passed')
# Test formatted presets
preset_options = input_types['required']['preset'][0]
assert 'custom' in preset_options
formatted_count = len([opt for opt in preset_options if ' - ' in opt and 'MP' in opt])
assert formatted_count > 0
print(f'✓ Found {formatted_count} formatted presets')
# Test dimension calculation
result = node.get_dimensions('1024×1024', 512, 512)
assert result == (1024, 1024)
print('✓ Dimension calculation tests passed')
# Test formatted preset dimensions
formatted_preset = '1024×1024 - 1:1 (1.1MP) - SDXL'
result = node.get_dimensions(formatted_preset, 512, 512)
assert result == (1024, 1024)
print('✓ Formatted preset tests passed')
# Test preset extraction
extracted = node._extract_preset_name(formatted_preset)
assert extracted == '1024×1024'
print('✓ Preset extraction tests passed')
print('🎉 All Width Height Selector tests passed!')
"
- name: Test Sampler Combo
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Test Sampler Combo imports
from kikotools.tools.sampler_combo.node import SamplerComboNode
from kikotools.tools.sampler_combo.logic import (
get_sampler_combo, validate_sampler_settings, SAMPLERS, SCHEDULERS
)
print('✓ Sampler Combo imports successful')
# Test node interface
node = SamplerComboNode()
input_types = node.INPUT_TYPES()
assert 'required' in input_types
assert 'sampler_name' in input_types['required']
assert 'scheduler' in input_types['required']
assert 'steps' in input_types['required']
assert 'cfg' in input_types['required']
print('✓ Sampler Combo interface tests passed')
# Test return types - Updated to match SAMPLERS list change
assert node.RETURN_TYPES[0] == SAMPLERS # Now returns SAMPLERS list
assert isinstance(node.RETURN_TYPES[1], list) # SCHEDULERS is a list
assert node.RETURN_TYPES[2] == 'INT'
assert node.RETURN_TYPES[3] == 'FLOAT'
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
assert node.CATEGORY == '🫶 ComfyAssets/🌀 Samplers'
print('✓ Sampler Combo return types tests passed')
# Test sampler combo functionality
result = node.get_sampler_combo('euler', 'normal', 20, 7.0)
assert result == ('euler', 'normal', 20, 7.0)
print('✓ Sampler combo functionality tests passed')
# Test validation
assert validate_sampler_settings('euler', 'normal', 20, 7.0) == True
print('✓ Sampler validation tests passed')
# Test available samplers and schedulers
samplers = node.get_available_samplers()
schedulers = node.get_available_schedulers()
assert len(samplers) > 0
assert len(schedulers) > 0
assert 'euler' in samplers
assert 'normal' in schedulers
print(f'✓ Found {len(samplers)} samplers and {len(schedulers)} schedulers')
print('🎉 All Sampler Combo tests passed!')
"
- name: Test Seed History
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Test Seed History imports
from kikotools.tools.seed_history.node import SeedHistoryNode
from kikotools.tools.seed_history.logic import (
generate_random_seed, validate_seed_value, sanitize_seed_value
)
print('✓ Seed History imports successful')
# Test node interface
node = SeedHistoryNode()
input_types = node.INPUT_TYPES()
assert 'required' in input_types
assert 'seed' in input_types['required']
print('✓ Seed History interface tests passed')
# Test return types
assert node.RETURN_TYPES == ('INT',)
assert node.RETURN_NAMES == ('seed',)
assert node.CATEGORY == '🫶 ComfyAssets/🌱 Seeds'
print('✓ Seed History return types tests passed')
# Test seed output functionality
result = node.output_seed(12345)
assert result == (12345,)
print('✓ Seed output functionality tests passed')
# Test seed validation
assert validate_seed_value(12345) == True
assert validate_seed_value(-1) == False
print('✓ Seed validation tests passed')
# Test seed generation
new_seed = generate_random_seed()
assert isinstance(new_seed, int)
assert validate_seed_value(new_seed) == True
print('✓ Seed generation tests passed')
# Test seed sanitization
clean_seed = sanitize_seed_value(12345)
assert clean_seed == 12345
print('✓ Seed sanitization tests passed')
# Test node helper methods
assert node.is_seed_in_range(12345) == True
assert node.is_seed_in_range(-1) == False
assert node.get_default_seed() == 12345
print('✓ Seed helper methods tests passed')
print('🎉 All Seed History tests passed!')
"
- name: Test error handling for all tools
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
print('=== Testing Error Handling for All Tools ===')
# Test Resolution Calculator error handling
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
res_node = ResolutionCalculatorNode()
try:
res_node.calculate_resolution(2.0) # No input provided
assert False, 'Should have raised ValueError'
except ValueError:
print('✓ Resolution Calculator error handling test passed')
try:
res_node.calculate_resolution(0.0) # Invalid scale
assert False, 'Should have raised ValueError'
except ValueError:
print('✓ Resolution Calculator scale factor validation test passed')
# Test Width Height Selector error handling
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
wh_node = WidthHeightSelectorNode()
# Test invalid preset fallback
result = wh_node.get_dimensions('invalid_preset', 800, 600)
assert result == (800, 600) # Should fallback to custom dimensions
print('✓ Width Height Selector invalid preset handling test passed')
# Test Sampler Combo error handling
from kikotools.tools.sampler_combo.node import SamplerComboNode
sampler_node = SamplerComboNode()
# Test with invalid sampler (should use safe defaults)
result = sampler_node.get_sampler_combo('invalid_sampler', 'normal', 20, 7.0)
assert result == ('euler', 'normal', 20, 7.0) # Safe defaults
print('✓ Sampler Combo invalid input handling test passed')
# Test Seed History error handling
from kikotools.tools.seed_history.node import SeedHistoryNode
seed_node = SeedHistoryNode()
# Test invalid seed value (should use fallback)
result = seed_node.output_seed(-1) # Invalid negative seed
assert result == (12345,) # Fallback seed
print('✓ Seed History invalid seed handling test passed')
print('🎉 All error handling tests passed for all tools!')
"
- name: Test ComfyUI integration readiness for all tools
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
print('=== Testing ComfyUI Integration for All Tools ===')
# Test Resolution Calculator
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
res_class = ResolutionCalculatorNode
assert hasattr(res_class, 'INPUT_TYPES')
assert hasattr(res_class, 'RETURN_TYPES')
assert hasattr(res_class, 'RETURN_NAMES')
assert hasattr(res_class, 'FUNCTION')
assert hasattr(res_class, 'CATEGORY')
input_types = res_class.INPUT_TYPES()
assert 'required' in input_types
assert 'optional' in input_types
assert 'scale_factor' in input_types['required']
assert 'image' in input_types['optional']
assert 'latent' in input_types['optional']
assert res_class.RETURN_TYPES == ('INT', 'INT')
assert res_class.RETURN_NAMES == ('width', 'height')
assert 'ComfyAssets/' in res_class.CATEGORY
print('✓ Resolution Calculator ComfyUI integration passed')
# Test Width Height Selector
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
wh_class = WidthHeightSelectorNode
assert hasattr(wh_class, 'INPUT_TYPES')
assert hasattr(wh_class, 'RETURN_TYPES')
assert hasattr(wh_class, 'RETURN_NAMES')
assert hasattr(wh_class, 'FUNCTION')
assert hasattr(wh_class, 'CATEGORY')
input_types = wh_class.INPUT_TYPES()
assert 'required' in input_types
assert 'preset' in input_types['required']
assert 'width' in input_types['required']
assert 'height' in input_types['required']
assert wh_class.RETURN_TYPES == ('INT', 'INT')
assert wh_class.RETURN_NAMES == ('width', 'height')
assert 'ComfyAssets/' in wh_class.CATEGORY
print('✓ Width Height Selector ComfyUI integration passed')
# Test Sampler Combo
from kikotools.tools.sampler_combo.node import SamplerComboNode
sampler_class = SamplerComboNode
assert hasattr(sampler_class, 'INPUT_TYPES')
assert hasattr(sampler_class, 'RETURN_TYPES')
assert hasattr(sampler_class, 'RETURN_NAMES')
assert hasattr(sampler_class, 'FUNCTION')
assert hasattr(sampler_class, 'CATEGORY')
input_types = sampler_class.INPUT_TYPES()
assert 'required' in input_types
assert 'sampler_name' in input_types['required']
assert 'scheduler' in input_types['required']
assert 'steps' in input_types['required']
assert 'cfg' in input_types['required']
assert 'ComfyAssets/' in sampler_class.CATEGORY
print('✓ Sampler Combo ComfyUI integration passed')
# Test Seed History
from kikotools.tools.seed_history.node import SeedHistoryNode
seed_class = SeedHistoryNode
assert hasattr(seed_class, 'INPUT_TYPES')
assert hasattr(seed_class, 'RETURN_TYPES')
assert hasattr(seed_class, 'RETURN_NAMES')
assert hasattr(seed_class, 'FUNCTION')
assert hasattr(seed_class, 'CATEGORY')
input_types = seed_class.INPUT_TYPES()
assert 'required' in input_types
assert 'seed' in input_types['required']
assert seed_class.RETURN_TYPES == ('INT',)
assert seed_class.RETURN_NAMES == ('seed',)
assert 'ComfyAssets/' in seed_class.CATEGORY
print('✓ Seed History ComfyUI integration passed')
print('🎉 All tools ComfyUI integration readiness tests passed!')
"
test-package-structure:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v6
with: with:
python-version: "3.10" name: python-${{ matrix.os }}-${{ matrix.python-version }}
path: test-results/
if-no-files-found: ignore
retention-days: 14
- name: Test package structure comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/testing/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: | run: |
# Check required files exist node --input-type=module <<'JS' >> "$GITHUB_OUTPUT"
test -f README.md || (echo "README.md missing" && exit 1) import runtime from './tools/testing/comfyui-runtime.json' with { type: 'json' };
test -f LICENSE || (echo "LICENSE missing" && exit 1) for (const [key, value] of Object.entries(runtime)) console.log(`${key}=${value}`);
test -f requirements-dev.txt || (echo "requirements-dev.txt missing" && exit 1) JS
- name: Check out pinned ComfyUI
uses: actions/checkout@v7
with:
repository: ${{ steps.runtime.outputs.repository }}
ref: ${{ steps.runtime.outputs.revision }}
path: .ci-comfyui
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/testing/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
- run: npm ci --prefix tools/testing --ignore-scripts
- run: npx --prefix tools/testing playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
KIKO_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
KIKO_TEST_PYTHON: python
run: npm run --prefix tools/testing test:comfyui
- name: Upload real-host reports and startup logs
if: always()
uses: actions/upload-artifact@v7
with:
name: real-comfyui-reports
path: tools/testing/test-results/
if-no-files-found: ignore
retention-days: 14
# Check directory structure coverage:
test -d kikotools || (echo "kikotools directory missing" && exit 1) name: coverage gate (85%)
test -d kikotools/base || (echo "kikotools/base directory missing" && exit 1)
test -d kikotools/tools || (echo "kikotools/tools directory missing" && exit 1)
test -d kikotools/tools/resolution_calculator || (echo "resolution_calculator directory missing" && exit 1)
test -d kikotools/tools/width_height_selector || (echo "width_height_selector directory missing" && exit 1)
test -d kikotools/tools/sampler_combo || (echo "sampler_combo directory missing" && exit 1)
test -d kikotools/tools/seed_history || (echo "seed_history directory missing" && exit 1)
test -d tests || (echo "tests directory missing" && exit 1)
test -d examples || (echo "examples directory missing" && exit 1)
test -d web || (echo "web directory missing" && exit 1)
# Check key files
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
# Resolution Calculator files
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
# Width Height Selector files
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
# Sampler Combo files
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
# Seed History files
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
# Model Downloader files
test -f kikotools/tools/model_downloader/node.py || (echo "model_downloader node.py missing" && exit 1)
test -f kikotools/tools/model_downloader/base.py || (echo "model_downloader base.py missing" && exit 1)
test -f kikotools/tools/model_downloader/detector.py || (echo "model_downloader detector.py missing" && exit 1)
# Text Input files
test -f kikotools/tools/text_input/node.py || (echo "text_input node.py missing" && exit 1)
# Web files
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
echo "✓ Package structure tests passed"
test-documentation:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- name: Test documentation completeness - uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: | run: |
# Check documentation files for all tools python -m pip install --upgrade pip
test -f examples/documentation/resolution_calculator.md || (echo "Resolution calculator docs missing" && exit 1) pip install torch --index-url https://download.pytorch.org/whl/cpu
test -f examples/workflows/resolution_calculator_example.json || (echo "Resolution calculator workflow missing" && exit 1) pip install -r requirements-dev.txt
test -f examples/documentation/width_height_selector.md || (echo "Width height selector docs missing" && exit 1)
test -f examples/workflows/width_height_selector_example.json || (echo "Width height selector workflow missing" && exit 1)
test -f examples/documentation/sampler_combo.md || (echo "Sampler combo docs missing" && exit 1)
test -f examples/workflows/sampler_combo_example.json || (echo "Sampler combo workflow missing" && exit 1)
test -f examples/documentation/seed_history.md || (echo "Seed history docs missing" && exit 1)
test -f examples/workflows/seed_history_example.json || (echo "Seed history workflow missing" && exit 1)
# Check README has key sections # fail_under = 85 lives in pyproject.toml [tool.coverage.report]
grep -q "Installation" README.md || (echo "README missing Installation section" && exit 1) - name: Measure coverage
grep -q "Quick Start" README.md || (echo "README missing Quick Start section" && exit 1) run: python -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html --cov-report=xml
grep -q "Development" README.md || (echo "README missing Development section" && exit 1)
grep -q "Contributing" README.md || (echo "README missing Contributing section" && exit 1)
echo "✓ Documentation tests passed" - name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v7
with:
name: python-coverage
path: |
htmlcov/
coverage.xml
if-no-files-found: ignore
retention-days: 14
js-check:
name: frontend syntax
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
# Check every shipped module, including files without behavioral tests.
- name: Check all shipped JavaScript modules parse
run: |
shopt -s globstar nullglob
for f in web/**/*.js web/**/*.mjs; do
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
done
frontend:
name: frontend workflows and coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install Python test dependencies
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/testing/package-lock.json
- run: npm ci --prefix tools/testing --ignore-scripts
- run: npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
- name: JavaScript regressions, Chromium workflows, and coverage gate
run: npm run --prefix tools/testing test:coverage
- name: Firefox and WebKit workflows
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
- name: Upload frontend reports
if: always()
uses: actions/upload-artifact@v7
with:
name: frontend-test-reports
path: |
tools/testing/test-results/
coverage/js/
!coverage/js/raw/
if-no-files-found: ignore
retention-days: 14
+14
View File
@@ -164,3 +164,17 @@ experiments/
# Gemini model cache # Gemini model cache
.gemini_models_cache.json .gemini_models_cache.json
referance/ referance/
# Runtime state written inside the package (repo is symlinked into custom_nodes)
kikotools/tools/local_image_loader/config.json
kikotools/tools/local_image_loader/selections.json
gemini_config.json
# Frontend test tooling and reports
node_modules/
coverage/
playwright-report/
test-results/
# Disposable pinned host checkout used by CI
.ci-comfyui/
+20 -3
View File
@@ -4,10 +4,9 @@
repos: repos:
# Python code formatting with Black # Python code formatting with Black
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 25.1.0 rev: 26.10.1
hooks: hooks:
- id: black - id: black
language_version: python3.10
args: ['--line-length=88'] # Match CI configuration args: ['--line-length=88'] # Match CI configuration
# Python linting with flake8 # Python linting with flake8
@@ -61,9 +60,27 @@ repos:
args: ['--baseline', '.secrets.baseline'] args: ['--baseline', '.secrets.baseline']
exclude: '^(tests/|\.git/)' exclude: '^(tests/|\.git/)'
- repo: local
hooks:
- id: python-regressions
name: Python regression suite
entry: python -m pytest tests
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
- id: frontend-regressions
name: JavaScript regression suite
entry: npm test --prefix tools/testing
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
# Configuration for specific hooks # Configuration for specific hooks
default_stages: [pre-commit]
default_language_version: default_language_version:
python: python3.10 python: python3
# Run hooks on all files by default # Run hooks on all files by default
fail_fast: false fail_fast: false
+46
View File
@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
`kikotools/tools/<tool>/logic.py` contains reusable logic; `node.py` defines the
ComfyUI node. Shared classes live in `kikotools/base/`. Register nodes in
`kikotools/__init__.py`; the root `__init__.py` exposes the plugin and API routes.
`web/` contains browser extensions. Tests live in `tests/unit/`, `tests/js/`, and
`tests/browser/`. Examples are in `examples/`.
## Development Commands
Install Python dependencies with `python -m pip install -r requirements-dev.txt`.
Run `python -m pytest tests` or `make test` for the complete Python suite.
Use `python -m pytest tests --cov=kikotools` for the 85% statement/branch gate.
Format with `black .`; use `flake8 .` for lint findings.
Frontend tooling needs Node.js 24 and `npm ci --prefix tools/testing
--ignore-scripts`. Run `npm test --prefix tools/testing` for JavaScript tests,
`npm run --prefix tools/testing test:coverage` for coverage and Chromium workflows,
and `npm run --prefix tools/testing test:browser` for all browser projects.
See `TESTING.md` for browser installation, environment overrides, and artifacts.
## Coding Style & Naming
Use four-space Python indentation and Black's 88-character formatting.
Use `snake_case` for Python functions/modules, `PascalCase` for classes, and
`UPPER_CASE` for constants. Keep node interfaces separate from logic. Preserve
existing ComfyUI lifecycle hooks when extending widgets and clean up timers,
listeners, and observers when nodes are removed.
## Testing Guidelines
Write regression tests first and verify failure before implementing fixes.
Name Python tests `test_*.py` and JavaScript tests `*.test.mjs`; put browser
workflows in `tests/browser/*.spec.mjs`. Test behavior and persistence, including
failure recovery. Keep fixtures disposable and external services mocked.
Never lower coverage floors to make a change pass.
## Commits & Pull Requests
Use Conventional Commits, such as `fix(local_image_loader): preserve error
status`. Link the tracked issue, describe observable behavior, and report the
commands and results used for validation. Include screenshots for visual changes.
Required checks must pass before merging. Keep credentials and runtime files out
of commits; restart ComfyUI after installing Python changes.
+58
View File
@@ -0,0 +1,58 @@
# Changelog
## 1.3.0
### Model Resolution Picker
- New `Model Resolution Picker` node (🖼️ Resolution): pick a native size for a
model family from a chip grid of aspect ratios and output an empty latent plus
width/height. Fourteen shipped families (SD 1.5, SDXL, Illustrious XL, Anima,
Flux.1, Z-Image, Qwen-Image, Flux.2 / Klein / MOSS, Qwen-Image 2.1,
HunyuanImage 2.1 and four video keyframe rows), each rounded to its VAE step.
- Native `resolution` dropdown that mirrors the chip grid both ways; typed or
wired sizes show `custom`.
- Shipped family table in `families.json` with a validator, and a user override
file at `user/kikotools/model_resolutions.json` that can replace, add or remove
families and set the default. Invalid files are ignored whole with a warning
in the panel.
- The node height follows the panel content, and workflows saved before the
dropdown existed still load.
- `GET /kikotools/model_resolution/families` serves the merged table; the shared
dimension swap now works on nodes without a preset widget.
## 1.2.0
### Unified ComfyAssets UI
- Consistent blue controls and strong red destructive actions that follow
ComfyUI's light/dark theme.
- Updated Local Image Loader, Save Image viewer, Seed History, Workflow Timer,
dimension swaps, Node Dimensions, Display Text/Any and Embedding Autocomplete.
- Larger adjustable gallery thumbnails and saved-image previews that use the
available space; responsive layouts, keyboard controls and lifecycle cleanup.
- Shared Custom Colors picker with hex entry, HSV sliders and presets.
- Restored autocomplete tag management, four-column CSV parsing, worker-based
imports and persistent tag caching. Tag packs remain a separate download;
see [tag-pack provenance and terms](docs/tag-packs/README.md).
- Follow Execution honors live enablement, avoids duplicate subscriptions and
safely groups navigation menus.
### Compatibility and testing
- Classic and Nodes 2.0 coverage includes fresh startup in each renderer,
switching both ways, themes, saved workflows and removal cleanup.
- Required CPU-only real-ComfyUI CI joins the existing Python, JavaScript,
Chromium, touch, Firefox and WebKit suites. Runtime pins and failure artifacts
make compatibility checks repeatable.
- Retained [screenshots](docs/screenshots/README.md) and documented
[testing commands](TESTING.md).
### Deprecation and upgrade
Gemini Prompt Engineer is deprecated and hidden from normal new-node discovery.
Its registration, schema and execution remain available for saved workflows;
ongoing Google API/model compatibility maintenance has ended.
Restart ComfyUI and hard-refresh the browser after upgrading. Existing node
interfaces, workflow values and setting IDs are retained. No workflow conversion
is required. Follow Execution remains opt-in through its Enable setting.
+4 -1
View File
@@ -357,9 +357,12 @@ Brief description of changes.
### Review Process ### Review Process
See [TESTING.md](TESTING.md) for automated suites, coverage gates, required merge
checks, and the remaining ComfyUI/GPU/service boundaries.
1. **Automated Checks**: CI runs tests and quality checks 1. **Automated Checks**: CI runs tests and quality checks
2. **Code Review**: Maintainer reviews code and provides feedback 2. **Code Review**: Maintainer reviews code and provides feedback
3. **Testing**: Manual testing in ComfyUI environment 3. **Testing**: Run relevant automated suites; use live ComfyUI checks for host, GPU, service, or visual changes
4. **Merge**: After approval, changes are merged to main 4. **Merge**: After approval, changes are merged to main
## 🎨 Code Style Guidelines ## 🎨 Code Style Guidelines
+20 -9
View File
@@ -1,6 +1,6 @@
# ComfyUI-KikoTools Development Makefile # ComfyUI-KikoTools Development Makefile
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test .PHONY: coverage frontend-test browser-test frontend-coverage help install test test-fast lint format type-check quality-check clean setup dev-test release-test
# Python and virtual environment setup # Python and virtual environment setup
PYTHON := python3 PYTHON := python3
@@ -34,7 +34,11 @@ help:
@echo " quality-check - Run all quality checks" @echo " quality-check - Run all quality checks"
@echo "" @echo ""
@echo "Testing:" @echo "Testing:"
@echo " test - Run comprehensive tests" @echo " test - Run the complete pytest suite"
@echo " coverage - Python statement/branch coverage (85% gate)"
@echo " frontend-test - JavaScript regression tests"
@echo " browser-test - Extension workflows in four browser configurations"
@echo " frontend-coverage - JavaScript tests, browser tests, and coverage gate"
@echo " test-fast - Run core functionality tests" @echo " test-fast - Run core functionality tests"
@echo " dev-test - Quick development test" @echo " dev-test - Quick development test"
@echo " release-test - Full release validation" @echo " release-test - Full release validation"
@@ -120,13 +124,20 @@ test-fast: $(VENV_DIR)
print('🎉 All core tests passed!'); \ print('🎉 All core tests passed!'); \
" "
test: test-fast test: $(VENV_DIR)
@echo "Running comprehensive test suite..." @$(PYTHON_CMD) -m pytest tests
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)" coverage: $(VENV_DIR)
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)" @$(PYTHON_CMD) -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html
@echo "✅ Error handling test passed"
@echo "🎉 All comprehensive tests passed!" frontend-test:
npm test --prefix tools/testing
browser-test:
npm run --prefix tools/testing test:browser
frontend-coverage:
npm run --prefix tools/testing test:coverage
release-test: quality-check test release-test: quality-check test
@echo "Running release validation..." @echo "Running release validation..."
+151 -46
View File
@@ -1,7 +1,7 @@
# ComfyUI-KikoTools # ComfyUI-KikoTools
![License](https://img.shields.io/badge/license-MIT-blue.svg) ![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg) ![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg)
![Tests](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Tests/badge.svg) ![Tests](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Tests/badge.svg)
![Code Quality](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Code%20Quality/badge.svg) ![Code Quality](https://github.com/ComfyAssets/ComfyUI-KikoTools/workflows/Code%20Quality/badge.svg)
@@ -17,6 +17,27 @@ I tried forking, patching, and submitting merge requests, but eventually decided
I’m sharing them here with the community, and I hope you find them as useful as I do. I’m sharing them here with the community, and I hope you find them as useful as I do.
## 1.3.0 — Model Resolution Picker
A new node that picks a native resolution for a model family from a chip grid of
aspect ratios, with a synced `resolution` dropdown, a shipped family table and a
user override file. See the [release notes](CHANGELOG.md) and the
[node documentation](examples/documentation/model_resolution_picker.md).
## 1.2.0 — unified ComfyAssets UI
A consistent interface across the KikoTools controls, with ComfyUI-following
light/dark styling and Classic/Nodes 2.0 support. Existing workflow interfaces
and settings are retained. See the [release notes](CHANGELOG.md),
[migration overview](docs/ui-migration.md) and [screenshots](docs/screenshots/).
## ComfyUI Classic and Nodes 2.0
KikoTools supports both ComfyUI Classic and Nodes 2.0. Our shared ComfyAssets
controls follow ComfyUI's dark/light theme, with automated tests covering renderer
switching, workflow persistence and cleanup. See the retained
[UI screenshots](docs/screenshots/) for examples from the migrations.
## 🚀 Features ## 🚀 Features
### ✨ Current Tools ### ✨ Current Tools
@@ -28,9 +49,10 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | 🌱 Seeds | | [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | 🌱 Seeds |
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | 🌀 Samplers | | [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | 🌀 Samplers |
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents | | [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
| [🎯 Model Resolution Picker](#-model-resolution-picker) | Pick a model family and size from ratio chips, with latent and width/height outputs | 🖼️ Resolution |
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images | | [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display | | [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts | | [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | Deprecated; retained for existing workflows | 🧠 Prompts |
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display | | [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution | | [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution | | [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
@@ -40,6 +62,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
| [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images | | [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images |
| [🌐 Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | 🛠️ Utils | | [🌐 Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | 🛠️ Utils |
| [⏱️ Workflow Timer](#️-workflow-timer) | Real-time execution timer with customizable display | 🛠️ Utils | | [⏱️ Workflow Timer](#️-workflow-timer) | Real-time execution timer with customizable display | 🛠️ Utils |
| [🗂️ Project File Path](#️-project-file-path) | Build safe, organized save prefixes like `MyProject/images/image` | 🛠️ Utils |
### 🧰 xyz-helpers Tools ### 🧰 xyz-helpers Tools
@@ -49,6 +72,8 @@ Advanced parameter management tools adapted from [comfyui-essentials-nodes](http
|------|-------------|----------| |------|-------------|----------|
| [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers | | [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers |
| [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers | | [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers |
| [🔁 Checkpoint Folder Iterator](#-checkpoint-folder-iterator) | Load one checkpoint per queued run from a folder | 🧰 xyz-helpers |
| [🧩 Model Compare Sheet](#-model-compare-sheet) | Save each run's image and rebuild a labeled contact sheet | 🧰 xyz-helpers |
| [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers | | [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers |
| [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers | | [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers |
| [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers | | [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers |
@@ -138,6 +163,25 @@ Advanced empty latent creation with preset support and batch processing capabili
![Empty Latent Batch Example](examples/workflows/empty_latent_batch_example.png) ![Empty Latent Batch Example](examples/workflows/empty_latent_batch_example.png)
#### 🎯 Model Resolution Picker
Choose a model family and a size from ratio chips, and get an empty latent plus width and height.
- **Model Dropdown**: 14 shipped families (SD 1.5, SDXL, Illustrious XL, Flux.1, Flux.2, Qwen-Image and more) with their preferred sizes
- **Chips and List**: Pick an aspect ratio chip, then a size from the list under it; the header shows `W × H · ratio · MP`
- **Resolution Dropdown**: A native `resolution` dropdown (`custom` plus size labels like `1216×832 - 3:2 (1.0MP)`) lists the active chip's sizes and stays in sync with the chips; a listed size wins over width and height when the node runs
- **Swap W×H**: Exchanges width and height and lands on the mirrored chip
- **Custom Ratio and Resolution**: Enter a ratio like 16:9 or type width and height; sizes are rounded to the family's step
- **Your Own Sizes**: An optional override file at `ComfyUI/user/kikotools/model_resolutions.json` (under the default user directory) can replace a family by label, add new ones, hide shipped ones with `remove`, or set `default`; an invalid file is ignored and the panel shows why ([details](examples/documentation/model_resolution_picker.md#user-override-file))
- **Outputs**: `latent` (4-channel 8x, resizable for 16x and 32x VAEs), `width` and `height`
- **Keyframe Rows**: Video families (Wan 2.2, LTX-2, HunyuanVideo 1.5, MiniMax H3) are first/last-frame sizes; the latent is an image latent, never fed to a video model
**Use Cases:**
- Pick the right size for each model without remembering bucket lists
- Switch families while keeping a listed size
- Feed one node to both the latent and the size inputs of a workflow
![Model Resolution Picker](docs/screenshots/resolution-picker/ModelResolutionPicker-dark.png)
#### 💾 Kiko Save Image #### 💾 Kiko Save Image
Enhanced image saving with format selection, quality control, and floating popup viewer. Enhanced image saving with format selection, quality control, and floating popup viewer.
@@ -173,28 +217,17 @@ Advanced text display node with intelligent formatting and enhanced user interac
![Display Text Example](examples/workflows/display_text_example.png) ![Display Text Example](examples/workflows/display_text_example.png)
#### 🤖 Gemini Prompt Engineer #### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
- **Multi-Model Support**: Generate prompts for FLUX, SDXL, Danbooru, and Video generation **Deprecated — retained for existing workflows only.** ComfyUI hides this node
- **Smart Analysis**: Gemini analyzes composition, style, lighting, colors, and details from normal new-node discovery using its deprecated-node metadata. The
- **Format-Specific Output**: FLUX artistic prompts, SDXL positive/negative pairs, Danbooru tags, Video motion descriptions `GeminiPrompt` identifier, inputs, outputs and execution remain available so
- **Custom System Prompts**: Override templates with your own analysis instructions saved workflows can still load.
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Use Cases:** Google API/model compatibility updates and UI migration have ended. Existing
- Reverse-engineer prompts from reference images workflows still depend on Google's service and the optional `google-genai`
- Convert artistic descriptions between different AI model formats package; deprecation does not guarantee future API compatibility. See the
- Generate consistent style descriptions across workflows [legacy Gemini documentation](examples/documentation/gemini_prompt.md) for the
- Create detailed scene breakdowns for complex compositions existing configuration and behavior.
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
![Gemini Prompt Example](examples/workflows/gemini_prompt_example.png)
#### 🔍 Display Any #### 🔍 Display Any
Universal debugging node that displays any type of input value or tensor information. Universal debugging node that displays any type of input value or tensor information.
@@ -300,6 +333,28 @@ Automated batch processing for multiple LoRA models from folders.
![LoRA Folder Batch Example](examples/workflows/xyz_helpers_lora_testing.png) ![LoRA Folder Batch Example](examples/workflows/xyz_helpers_lora_testing.png)
#### 🔁 Checkpoint Folder Iterator
Run the same prompt through every checkpoint in a folder, one model per queued run.
- **folder**: relative to `models/checkpoints`, or an absolute path anywhere on disk; regex include/exclude filters
- **index** with `control_after_generate`: set 0 + *increment*, then queue a batch count equal to the checkpoint count
- Queuing past the last checkpoint fails that run with a clear message instead of wrapping around, so the finished sweep is never restarted or archived by accident
- Outputs MODEL/CLIP/VAE for the indexed file plus `ckpt_name`, `label`, `index`, `count`
- Only one checkpoint is loaded at a time, so large folders do not exhaust RAM
#### 🧩 Model Compare Sheet
Output node that pairs with Checkpoint Folder Iterator.
- Saves each run's image(s) to `output/model_compare/<run_name>/<NN>_<label>.png`
- Rebuilds `grid.png` from every tile saved so far, each labeled with its filename
- `columns`, `tile_width`, `font_size` control the layout; the grid is also returned as IMAGE
- The node preview (and anything else that reads it) gets `grid_preview.png`, at most 1024 px wide; `grid.png` keeps full resolution
- After the last queued run the preview shows the complete sheet
- Wire Project File Path into `run_name` for `project/date/sweep` folders
- A run at index 0 moves tiles already in the folder to `<run_name>_<timestamp>`, so an interrupted sweep never leaks into the new grid
- Rerunning a nonzero index replaces its complete batch, even when the label or batch size changes; other indexes are preserved
- Replacement PNGs are fully written before existing tiles are changed; a failed tile write preserves the prior tiles and grid
#### 📊 Plot Parameters #### 📊 Plot Parameters
Visual analysis tool for understanding parameter relationships and effects. Visual analysis tool for understanding parameter relationships and effects.
@@ -404,8 +459,6 @@ Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and
- Build reproducible workflows with automatic model fetching - Build reproducible workflows with automatic model fetching
- Quickly test new models from the community - Quickly test new models from the community
![Model Downloader Example](examples/workflows/model_downloader_example.png)
#### ⏱️ Workflow Timer #### ⏱️ Workflow Timer
Real-time execution timer that displays workflow duration with millisecond precision. Real-time execution timer that displays workflow duration with millisecond precision.
@@ -426,6 +479,42 @@ Real-time execution timer that displays workflow duration with millisecond preci
- **Workflow Timer: Color** - Custom color picker for timer display - **Workflow Timer: Color** - Custom color picker for timer display
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off - **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
#### 🎨 Custom Colors
Right-click a node and choose **🫶 Custom Colors** to change its full color,
title or background. The shared picker supports hex entry, HSV sliders and
presets, and follows ComfyUI's dark/light theme. Changes apply immediately;
**Done** or **Escape** closes the picker. Multiple selected nodes receive the
same change. Right-click a group for its color option.
Existing Custom Colors settings control the available menu options and automatic
title shading. Node and group colors are saved with the workflow.
Screenshots: [Dark](docs/screenshots/custom-colors/dark.png) ·
[Light](docs/screenshots/custom-colors/light.png).
#### 🧭 Follow Execution
Enable **Follow Execution: Enable** in ComfyUI settings, then right-click the
canvas to follow/stop execution, show the executing node, or navigate to a node
by type. **Auto-start** begins following when a workflow starts. Disabling the
feature stops following immediately; enabling it again waits for a manual start
or the next workflow start when Auto-start is enabled. The same host menus work
in Classic and Nodes 2.0. See the [Classic](docs/screenshots/follow-execution/classic.png)
and [Nodes 2.0](docs/screenshots/follow-execution/nodes2.png) menu screenshots.
#### 🗂️ Project File Path
Builds an organized save prefix to wire into a save node's `filename_prefix`.
- **Structure**: `root/project_name/[YYYY-MM-DD/]subfolder/filename`, e.g. `MyProject/2026-10-13/images/image`
- **Date Folder**: Optional toggle inserts today's date after the project; the node re-runs when the date changes
- **Safe Paths**: Unsafe characters are removed, spaces become underscores, and `..` segments are dropped so paths stay inside the output folder
- **Nested Folders**: Use `a/b` in `subfolder` for deeper structure
- **Extensions**: Known file extensions (`.png`, `.mp4`, ...) are stripped from `filename`; `render_v1.5` is left as is
- **Separator**: `auto`, `forward_slash` or `backslash`
Based on the idea of the Project File Path Generator from djz-nodes.
### 🔤 Embedding Autocomplete ### 🔤 Embedding Autocomplete
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.** **Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
@@ -440,8 +529,8 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
**Key Features:** **Key Features:**
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags - **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL - **Custom Word Lists**: Import CSV/TXT files or browser-accessible URLs, including [versioned Danbooru tag packs](docs/tag-packs/README.md), with local offline caching
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks - **Safe Rendering**: Tag data stays plain text, with validated CSV fields and file-size limits
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more - **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting - **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators - **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
@@ -455,9 +544,9 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
- Load custom word lists from URLs with security validation - Load custom word lists from URLs with security validation
**Security Features:** **Security Features:**
- Validates all loaded content to prevent script injection - Validates CSV structure, tag names, categories, counts, and file size
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.) - Renders tag data as plain text rather than executable HTML or JavaScript
- Safe character whitelist for tags - Rejects control characters and HTML error documents
- File size limits to prevent memory exhaustion - File size limits to prevent memory exhaustion
- Clear error messages for rejected content - Clear error messages for rejected content
@@ -526,7 +615,7 @@ Time: 150.0ms
### 🔧 Architecture Highlights ### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable - **Modular Design**: Each tool is self-contained and independently testable
- **Test-Driven Development**: 100% test coverage with comprehensive unit tests - **Test-Driven Development**: Regression tests for Python logic, JavaScript extensions, and browser workflows
- **Clean Interfaces**: Standardized input/output patterns across all tools - **Clean Interfaces**: Standardized input/output patterns across all tools
- **Separation of Concerns**: Clear distinction between logic, UI, and integration layers - **Separation of Concerns**: Clear distinction between logic, UI, and integration layers
- **SOLID Principles**: Extensible architecture following software engineering best practices - **SOLID Principles**: Extensible architecture following software engineering best practices
@@ -642,6 +731,9 @@ Gemini Prompt → Display Text → Copy to Clipboard
**Smart Detection:** Automatically formats SDXL-style prompts **Smart Detection:** Automatically formats SDXL-style prompts
### Gemini Prompt Engineer Example ### Gemini Prompt Engineer Example
**Legacy example for the deprecated node; existing workflows only.**
``` ```
Load Image → Gemini Prompt → Display Text → Text Generation Model Load Image → Gemini Prompt → Display Text → Text Generation Model
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..." 🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
@@ -734,13 +826,14 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) | | **Seed History** | Advanced seed tracking with interactive history management | ✅ Complete | [Docs](examples/documentation/seed_history.md) |
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) | | **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) | | **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Model Resolution Picker** | Model family size picker with ratio chips, empty latent and width/height outputs | ✅ Complete | [Docs](examples/documentation/model_resolution_picker.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) | | **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) | | **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) | | **Gemini Prompt Engineer** | Legacy image analysis and prompt generation | Deprecated | [Docs](examples/documentation/gemini_prompt.md) |
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) | | **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) | | **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) | | **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](#-image-scale-down-by) |
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) | | **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](#-film-grain) |
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) | | **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) | | **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) | | **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
@@ -748,8 +841,8 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
| **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) | | **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) |
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) | | **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
| **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) | | **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) |
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](examples/documentation/model_downloader.md) | | **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](#-model-downloader) |
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](examples/documentation/workflow_timer.md) | | **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](#️-workflow-timer) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon | | **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon | | **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -905,7 +998,7 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
### Prerequisites ### Prerequisites
- Python 3.8+ - Python 3.10+
- ComfyUI installation - ComfyUI installation
- PyTorch 2.0+ - PyTorch 2.0+
@@ -990,8 +1083,8 @@ Following **Test-Driven Development (TDD)**:
# Test structure # Test structure
tests/ tests/
├── unit/ # Individual component tests ├── unit/ # Individual component tests
├── integration/ # ComfyUI workflow tests ├── js/ # Extension and coverage-gate regression tests
└── fixtures/ # Test data and workflows └── browser/ # Browser workflows and disposable host fixtures
``` ```
### Adding New Tools ### Adding New Tools
@@ -1043,21 +1136,28 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats ## 📈 Stats
- **Nodes**: 21 (15 core tools + 6 xyz-helpers) - **Registered Nodes**: 33, including the deprecated Gemini node
- **Features**: Embedding Autocomplete (settings-based, not a node) - **Extensions**: Embedding Autocomplete, Custom Colors and Follow Execution
- **Categories**: 9 emoji-based categories for better organization - **Organization**: Tools grouped under ComfyAssets
- **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs) - **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls) - **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets - **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization) - **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
- **AI Integration**: Gemini API with 40+ model support - **Legacy AI Integration**: Deprecated Gemini node retained for saved workflows
- **Test Coverage**: 100% (470+ comprehensive tests) - **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
- **Python Version**: 3.8+ - **Python Version**: 3.10+
- **ComfyUI Compatibility**: Latest - **ComfyUI Compatibility**: Classic and Nodes 2.0; tested runtime pins in [TESTING.md](TESTING.md)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini) - **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
## 🙏 Attribution ## 🙏 Attribution
### UI inspiration
[Pixaroma's ComfyUI toolkit](https://github.com/pixaroma/ComfyUI-Pixaroma)
inspired the reusable controls and editor organization. ComfyAssets uses its own
implementation and styling; no Pixaroma toolkit code or assets are vendored.
### xyz-helpers Tools ### xyz-helpers Tools
The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community. The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community.
@@ -1080,3 +1180,8 @@ All adaptations maintain compatibility while adding new features and optimizatio
[⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) [⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues)
</div> </div>
## Automated testing
See [TESTING.md](TESTING.md) for Python and JavaScript tests, cross-browser
workflows, coverage gates, CI reports, and required merge checks.
+166
View File
@@ -0,0 +1,166 @@
# Testing and merge confidence
Use regression tests before fixing a bug. Confirm that the test fails on the
original implementation, then run the relevant suites after the fix.
## What runs
| Layer | Real components | Replaced boundaries |
| --- | --- | --- |
| Python | Tool logic, tensors, image files, API validation | ComfyUI services and external models where needed |
| JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions |
| Browser fixtures | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
| Real ComfyUI | Pinned server/frontend, LiteGraph, native menus, DOM widgets, initial renderers, switching and workflow serialization | Disposable media routes; simulated execution/progress events; model/service boundaries |
Browser workflows cover swaps, local image browsing/selection, pagination,
sorting, hidden folders, directory autocomplete, saved paths,
denied paths and network recovery, display output, seed persistence/cleanup, and
workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium.
The harness binds to localhost and writes configuration, selections, and fixture
images only inside a temporary directory. External browser requests fail tests;
custom-word downloads are disabled in the fixture. No credentials, model
weights, running ComfyUI, or GPU are required.
## Run locally
Use Python 3.10–3.13 and Node.js 24. Install the CPU PyTorch wheel on Linux if you
do not need CUDA, then install the development dependencies:
```sh
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
python -m pip install -r requirements-dev.txt
python -m pytest tests
python -m pytest tests --cov=kikotools --cov-report=term-missing
npm ci --prefix tools/testing --ignore-scripts
npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
npm test --prefix tools/testing
npm run --prefix tools/testing test:coverage
npm run --prefix tools/testing test:browser -- --project firefox --project webkit
```
`make test` runs the full pytest suite and propagates failures. `make coverage`,
`make frontend-test`, `make browser-test`, and `make frontend-coverage` provide
shortcuts. The Python Make targets use the existing virtual-environment setup.
Install local commit and push hooks with `pre-commit install --hook-type
pre-commit --hook-type pre-push`. The push hooks run Python and JavaScript
regressions; CI additionally runs all browser workflows and coverage gates.
For a quick browser check, use `test:browser -- --project chromium`.
`KIKO_TEST_PYTHON` selects a Python executable with the development dependencies;
`KIKO_TEST_PORT` changes the default port 8766. Servers are never reused.
Unsupported Linux distributions may lack WebKit libraries; Ubuntu CI validates
all engines.
## Coverage and diagnosis
Python CI checks all twelve Python/OS combinations and enforces 85% combined
statement/branch coverage. Every production `web/*.js` file appears in frontend
coverage, including code without behavioral coverage. V8 unit coverage and
Chromium desktop/touch coverage are merged through c8. The committed per-file
floors are an honest starting point, not a claim that every feature is tested.
`npm run --prefix tools/testing coverage:update` reruns the suites and raises
floors with two percentage points of runtime allowance. It cannot lower existing
floors; new source files require 80% line coverage when added with this command.
Missing measurements, missing baselines, and per-file regressions fail CI.
CI artifacts include Python JUnit results, Python HTML/XML coverage, and
`frontend-test-reports` with frontend HTML coverage and browser failure traces
and screenshots. Open traces using Playwright's `show-trace` command. Local
frontend coverage is at `coverage/js/index.html`.
The fixture suite covers plugin behavior without a real ComfyUI frontend. The
separate required real-host job covers the integration described below. GPU/model
execution, cloud inference and model downloads still need targeted checks when
changed; the compatibility suite does not queue workflows or download weights.
Type checking and security jobs retain their existing advisory behavior.
## Pinned real-ComfyUI compatibility
The required **real ComfyUI compatibility** CI job provisions a CPU-only host.
`tools/testing/comfyui-runtime.json` pins upstream commit
`e8665ca8e4089ab917567cdb491308e1397663d4` (server 0.39.0), frontend 1.55.14,
Python 3.12 and the CPU PyTorch/torchvision pair. The launcher rejects a different
revision or modified tracked upstream files. The suite verifies server/frontend
versions and attaches them to each test's report.
Two projects, `classic-start` and `nodes2-start`, select the renderer during
startup, before creating nodes. Both run the complete suite, including live
renderer switching, zoom/resize, palette changes, workflow save/reload and
cleanup. Test-local settings start from a blank workspace and skip first-run
onboarding. They do not change the server's saved preferences.
The migrated controls covered are Local Image Loader, Save Image, Seed History,
Workflow Timer and its color picker, both dimension swap nodes, Node Dimensions,
Display Text/Any, Embedding Autocomplete/settings/tag imports, Custom Colors and
Follow Execution. Native canvas menus are opened with actual right clicks.
Execution/progress events are simulated; generation is never queued.
### Managed local host
Use a separate clean checkout of the pinned ComfyUI commit and a Python 3.12
environment containing its `requirements.txt` and this repository's
`requirements-dev.txt`. Install the CPU torch/torchvision versions from the
runtime manifest first, using the official CPU wheel index. The CI workflow in
`.github/workflows/tests.yml` contains the full provisioning recipe.
```sh
KIKO_COMFY_ROOT=/path/to/pinned/ComfyUI \
KIKO_TEST_PYTHON=/path/to/cpu-venv/bin/python \
npm run --prefix tools/testing test:comfyui
```
Playwright owns this host on `127.0.0.1:8189` and a fixture server on port 8771.
The launcher creates a temporary base/user/model directory, installs only a
symlink to the current KikoTools checkout, disables other custom nodes and
partner services, and uses an in-memory database. It removes owned state after
normal shutdown, startup failure or process termination. Existing installations
and model folders are not used. An occupied port fails instead of reusing an
unrelated server.
### Existing local host
An opt-in mode still supports an already running host with the same pinned
versions:
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ \
npm run --prefix tools/testing test:comfyui
```
Only loopback URLs are accepted. A separate browser context serves the current
checkout's extensions. Media requests use real plugin Python routes with
disposable fixture data. Other HTTP writes are intercepted, preserving server
settings, saved workflows and selections. This mode does not start or stop the
existing server.
### Reports and pin updates
CI retains `real-comfyui-reports` for 14 days, including server startup logs at
`tools/testing/test-results/comfyui-host.log`, browser traces, screenshots and
version attachments. Startup logs survive failure. Reviewed captures copied to
[docs/screenshots](docs/screenshots/README.md) are committed separately from
throwaway test artifacts.
To update the host, review and change the runtime manifest, provision the new
checkout/dependencies, and run both startup projects before merging. A version
assertion update alone is not compatibility validation. Cross-pack installation
and shared-source version compatibility remain tracked in #81.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
Python coverage, frontend syntax, frontend workflows/coverage, real ComfyUI
compatibility, and the existing
`code-quality` job (including Black and fatal lint checks). Administrators follow
the same checks and branches must be current with main. No extra human approval
is required. Workflows run on every pull request without path filters.
After the checks pass, an administrator applies and verifies the manifest:
```sh
gh api --method PUT repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection \
--input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection/required_status_checks
```
Keep the manifest synchronized with job names when changing CI.
+5 -2
View File
@@ -105,7 +105,7 @@ def get_version():
try: try:
pyproject_path = Path(__file__).parent / "pyproject.toml" pyproject_path = Path(__file__).parent / "pyproject.toml"
if pyproject_path.exists(): if pyproject_path.exists():
content = pyproject_path.read_text() content = pyproject_path.read_text(encoding="utf-8")
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if match: if match:
return match.group(1) return match.group(1)
@@ -118,7 +118,10 @@ def get_version():
print() print()
print(f"\033[94m[ComfyUI-KikoTools] Version:\033[0m {get_version()}") print(f"\033[94m[ComfyUI-KikoTools] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items(): for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
print(f"🫶 \033[94mLoaded:\033[0m {display_name}") # ASCII only: a Windows console using a legacy code page cannot print emoji,
# and a UnicodeEncodeError here would abort loading the whole node.
ascii_name = display_name.encode("ascii", "ignore").decode().strip()
print(f" \033[94mLoaded:\033[0m {ascii_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m") print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print() print()
+22
View File
@@ -0,0 +1,22 @@
# ComfyAssets UI screenshots
Retained captures from the KikoTools UI migrations. Real-ComfyUI tests cover
Classic and Nodes 2.0, renderer switching, saved workflows and cleanup.
| Interface | Captures |
| --- | --- |
| Local Image Loader | [Classic](live-image-loader/classic.png), [Nodes 2.0](live-image-loader/nodes2.png) |
| Saved-image viewer | [Dark](save-image-viewer/dark.png), [Light](save-image-viewer/light.png) |
| Seed History | [Dark](seed-history/dark.png), [Light](seed-history/light.png) |
| Workflow Timer | [Dark](workflow-timer/dark.png), [Light](workflow-timer/light.png), [Color picker](workflow-timer/color-picker.png) |
| Dimension swap | [Selector dark](dimension-swap/selector-dark.png), [Selector light](dimension-swap/selector-light.png), [Latent dark](dimension-swap/latent-dark.png), [Latent light](dimension-swap/latent-light.png) |
| Node Dimensions | [Dark](node-dimensions/dark.png), [Light](node-dimensions/light.png) |
| Display Text | [Dark](display-output/text-dark.png), [Light](display-output/text-light.png) |
| Display Any | [Dark](display-output/any-dark.png), [Light](display-output/any-light.png) |
| Embedding Autocomplete | [Suggestions dark](embedding-autocomplete/suggestions-dark.png), [Suggestions light](embedding-autocomplete/suggestions-light.png), [Settings dark](embedding-autocomplete/settings-dark.png), [Settings light](embedding-autocomplete/settings-light.png) |
| Custom Colors | [Dark](custom-colors/dark.png), [Light](custom-colors/light.png) |
| Follow Execution | [Classic](follow-execution/classic.png), [Nodes 2.0](follow-execution/nodes2.png) |
Screenshots record the tested appearance at capture time. Updated Playwright
captures are temporary test artifacts until copied into this directory and
committed for review.
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,734 @@
# Model Resolution Picker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A new ComfyAssets node whose model dropdown drives a ratio-chip grid and size list, outputting width, height and an empty latent, with the toolkit Swap W×H.
**Architecture:** The family table lives in Python (`families.py`) and is served by a GET route; the Python node rounds to the family step and builds the latent with the existing `create_empty_latent_batch`. The frontend is a pure derivation module (`web/ui/resolution-picker.js`), a DOM view (`web/views/resolution-picker.js`) and an extension (`web/model_resolution_picker.js`) that projects the native `model`/`width`/`height` widgets into the panel and writes back through widget callbacks. No panel state is serialized.
**Tech Stack:** Python 3.10–3.13, torch (CPU), aiohttp routes on `PromptServer`; ES modules on the `web/ui` toolkit; pytest, `node --test`, Playwright fixture host (`tests/browser`), pinned real ComfyUI (`tests/comfyui`).
**Spec:** `docs/superpowers/specs/2026-10-27-model-resolution-picker-design.md`
## Global Constraints
- Node id `ModelResolutionPicker`, display name `Model Resolution Picker`, category `🫶 ComfyAssets/🖼️ Resolution`.
- Combo value is the family **label**; labels are stable ids; each family carries `aliases`; unknown label → step 8, logged warning, no re-rounding to another family's step.
- Chips, in grid order: `1:1`, `4:5`, `5:4`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`, `16:9`, `21:9`, `9:21`, then `Custom ratio`. Mirror pairs: 4:5↔5:4, 3:4↔4:3, 2:3↔3:2, 9:16↔16:9, 21:9↔9:21, 1:1↔1:1.
- Chip tolerance for derived (non-listed) sizes: **7%** relative to the chip ratio. Authored list membership always wins.
- 1 MP = 1,048,576 px. Custom-ratio sizes use core Resolution Selector math: `scale = sqrt(mp·1048576/(rw·rh))`, each side `round(side/step)·step`, never below 64.
- Default family `SDXL`, default size 1024×1024, `batch_size` default 1, min 1, max 64. `width`/`height` widgets: default 1024, min 64, max 8192, step 8.
- Route: `GET /kikotools/model_resolution/families`, registered at module import like `local_image_loader/node.py`, guarded by `try/except ImportError`.
- Only `createScope()`, `mountTheme()`, `createButton()` and `controls.css` tokens for UI; new CSS classes are prefixed `.ca-resolution-`.
- Panel DOM widget is added with `serialize: false`; it mounts after the native widgets; new nodes start at 340 × 600.
- Never vendor Pixaroma code or assets. No external HTTP.
- Python coverage ≥ 85% overall; every new `web/*.js` file gets a row in `tools/testing/frontend-coverage-baseline.json`.
- Width Height Selector and Empty Latent Batch tests must keep passing unchanged.
- Commits: conventional type with scope, imperative subject, bullet body. No AI attribution lines.
## Review Focus
1. Typed width/height below 64 or not on the family step (e.g. 70×1000 on HunyuanImage 2.1): Python emits ≥ 64 on the step, the header shows the same effective size. Pinned in Task 1 (`round_to_step`) and Task 3 (`test_execution_rounds_to_family_step`).
2. Families route unavailable (network error or 500): panel shows one status line, native widgets and Swap still work, no page error. Pinned in Task 7 (`route failure keeps native widgets usable`).
3. Workflow saved with a former label or an unknown label: former label resolves through `aliases`; unknown label keeps the saved size (step 8). Pinned in Task 2 (`test_resolve_family_by_alias`) and Task 3 (`test_unknown_label_keeps_size_and_warns`).
4. `width` and `height` converted to inputs: chips and list disabled, header says "dimensions are wired in", Swap disabled. Pinned in Task 7 (`wired-in dimensions disable the picker`).
5. Custom ratio text such as `0:5`, `16:`, `a:b`, `99999999:1`: rejected with the inline error, previous list kept. Pinned in Task 5 (`parseRatio rejects malformed input`) and Task 7 (`custom ratio field`).
---
## File structure
| Path | Responsibility |
|---|---|
| `kikotools/tools/model_resolution_picker/logic.py` | step rounding, size generation, ratio label |
| `kikotools/tools/model_resolution_picker/families.py` | `Family` record, `CHIPS`, `MIRROR`, `FAMILIES`, lookup, JSON payload |
| `kikotools/tools/model_resolution_picker/node.py` | node class, route handler, module-level registration |
| `kikotools/tools/model_resolution_picker/__init__.py` | exports `ModelResolutionPickerNode` |
| `kikotools/__init__.py` | registration (modify) |
| `web/ui/dimensions.js` | swap without a `preset` widget (modify) |
| `web/ui/resolution-picker.js` | pure derivation and ratio math |
| `web/views/resolution-picker.js` | DOM view built from toolkit primitives |
| `web/model_resolution_picker.js` | extension: fetch families, mount view + swap, bind widgets |
| `web/ui/controls.css` | `.ca-resolution-*` rules (modify) |
| `tests/unit/tools/model_resolution_picker/` | `test_logic.py`, `test_families.py`, `test_node.py` |
| `tests/js/resolution-picker.test.mjs`, `tests/js/dimensions.test.mjs` | JS unit tests |
| `tests/browser/workflows.spec.mjs` | fixture-host workflows (modify) |
| `tests/comfyui/host-setup.mjs`, `tests/comfyui/resolution-picker.spec.mjs` | real-host coverage |
| `tools/testing/frontend-coverage-baseline.json` | floors for the 3 new JS files (modify) |
| `README.md`, `docs/ui-migration.md`, `examples/documentation/model_resolution_picker.md`, `docs/screenshots/resolution-picker/` | docs |
All pytest runs use the scratch venv: `V=/tmp/claude-1000/-home-vito-code-personal-ComfyUI-KikoTools/f79925f2-d12d-4383-ad2e-85d80c922cf7/scratchpad/venv; $V/bin/python -m pytest … -p no:cacheprovider`. Frontend: `npm test --prefix tools/testing`, `npm run --prefix tools/testing test:browser -- --project chromium`.
---
### Task 1: Rounding and size generation (`logic.py`)
**Files:**
- Create: `kikotools/tools/model_resolution_picker/__init__.py` (empty docstring for now), `kikotools/tools/model_resolution_picker/logic.py`
- Test: `tests/unit/tools/model_resolution_picker/__init__.py` (empty), `tests/unit/tools/model_resolution_picker/test_logic.py`
**Interfaces:**
- Produces: `round_to_step(value: int, step: int, minimum: int = 64) -> int`; `generate_size(ratio_w: int, ratio_h: int, megapixels: float, step: int) -> tuple[int, int]`; `ratio_label(width: int, height: int) -> str` (reduced `W:H` with aliases `8:5→16:10`, `5:8→10:16`, `7:3→21:9`, `3:7→9:21`); `MEGAPIXEL = 1_048_576`.
- [ ] **Step 1: Write the failing tests**
```python
from kikotools.tools.model_resolution_picker.logic import (
MEGAPIXEL, generate_size, ratio_label, round_to_step)
def test_round_to_step_rounds_nearest_and_never_below_minimum():
assert round_to_step(1000, 32) == 992
assert round_to_step(1232, 16) == 1232
assert round_to_step(70, 32) == 64
assert round_to_step(10, 64) == 64
assert round_to_step(1016, 64) == 1024
def test_generate_size_matches_core_resolution_selector():
assert generate_size(1, 1, 1.0, 8) == (1024, 1024)
assert generate_size(16, 9, 2.0, 16) == (1936, 1088)
assert generate_size(3, 2, 4.0, 32) == (2496, 1664)
assert generate_size(21, 9, 0.25, 64) == (768, 320)
def test_ratio_label_reduces_and_aliases():
assert ratio_label(1024, 1024) == "1:1"
assert ratio_label(1216, 832) == "19:13"
assert ratio_label(1280, 800) == "16:10"
assert ratio_label(1344, 576) == "21:9"
assert MEGAPIXEL == 1024 * 1024
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_logic.py -p no:cacheprovider -q`
Expected: FAIL with `ModuleNotFoundError`
- [ ] **Step 3: Implement the three functions in `logic.py`**
`round_to_step`: nearest multiple, then `max(result, ceil(minimum/step)*step)`. `generate_size`: the Global Constraints formula, each side through `round_to_step`. `ratio_label`: `math.gcd`, then the alias dict.
- [ ] **Step 4: Run tests to verify they pass**
Run: same command. Expected: `3 passed`
- [ ] **Step 5: Commit**
```bash
git add kikotools/tools/model_resolution_picker/__init__.py kikotools/tools/model_resolution_picker/logic.py tests/unit/tools/model_resolution_picker/
git commit -m "feat(model_resolution_picker): add step rounding and size generation"
```
---
### Task 2: The family table (`families.py`)
**Files:**
- Create: `kikotools/tools/model_resolution_picker/families.py`
- Test: `tests/unit/tools/model_resolution_picker/test_families.py`
**Interfaces:**
- Consumes: `generate_size` from Task 1 (used once, by hand, to author the generated lists; not imported by `families.py`).
- Produces:
- `CHIPS: tuple[str, ...]` in the Global Constraints order (11 chips, without `Custom ratio`).
- `MIRROR: dict[str, str]`.
- `class Family(NamedTuple)`: `label: str`, `aliases: tuple[str, ...]`, `models: str`, `group: str` (`"image"`|`"video"`), `vae_ratio: int`, `step: int`, `megapixels: tuple[float, ...]`, `default: tuple[int, int]`, `sizes: dict[str, tuple[tuple[int, int], ...]]`.
- `FAMILIES: tuple[Family, ...]`, `DEFAULT_FAMILY = "SDXL"`.
- `family_labels() -> list[str]`, `resolve_family(label: str) -> Family | None` (label or alias), `families_payload() -> dict` with keys `chips`, `mirror`, `default`, `families` (each: `label, aliases, models, group, vaeRatio, step, megapixels, default: [w, h], sizes: {chip: [[w, h], …]}`).
Authored data (copy verbatim; order of `FAMILIES` is this order):
| label | group | vae | step | MP tiers | default | official sizes to list verbatim; everything else generated with `generate_size` at each tier and chip |
|---|---|---|---|---|---|---|
| `SD 1.5` | image | 8 | 64 | 0.25, 0.5 | 512×512 | must include 512², 768², 512×768, 768×512 |
| `SDXL` (models: "SDXL, Pony and other SDXL finetunes") | image | 8 | 64 | 1.0 | 1024×1024 | 1:1 1024²; 4:5 832×1024; 5:4 1024×832; 3:4 896×1152; 4:3 1152×896; 2:3 832×1216; 3:2 1216×832; 9:16 768×1344; 16:9 1344×768; 21:9 1536×640; 9:21 640×1536. No generated entries. |
| `Illustrious XL` (aliases: none; models: "Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes") | image | 8 | 32 | 1.0, 1.5, 2.25 | 1024×1024 | all SDXL entries, plus 1:1 1536²; 4:5 1248×1536; 5:4 1536×1248; 3:4 1344×1792; 4:3 1792×1344; 2:3 1024×1536, 1248×1824; 3:2 1536×1024, 1824×1248; 9:16 1152×2048; 16:9 2048×1152. No generated entries. |
| `Anima` | image | 8 | 16 | 0.5, 1.0, 1.5 | 1024×1024 | generated; drop any entry with a side < 512 or > 1536 |
| `Flux.1` (models: "Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2") | image | 8 | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | generated; must include 16:9 1920×1088 and 9:16 1088×1920 (replace the 2.0 MP generated entry) |
| `Z-Image` (models: "Z-Image Turbo and Z-Image Base") | image | 8 | 16 | 1.0, 1.6 | 1024×1024 | 1:1 1024², 1280²; 4:3 1152×896, 1152×864, 1440×1120, 1472×1104; 3:4 896×1152, 864×1152, 1120×1440, 1104×1472; 3:2 1248×832, 1536×1024; 2:3 832×1248, 1024×1536; 16:9 1280×720, 1600×896; 9:16 720×1280, 896×1600; 21:9 1344×576, 1680×720; 9:21 576×1344, 720×1680. 4:5 and 5:4 empty. |
| `Qwen-Image` (models: "Qwen-Image and Qwen-Image 2512") | image | 8 | 16 | 1.76 | 1328×1328 | 1:1 1328²; 16:9 1664×928; 9:16 928×1664; 4:3 1472×1104; 3:4 1104×1472; 3:2 1584×1056; 2:3 1056×1584. Other chips empty. |
| `Flux.2 / Klein / MOSS` (models: "Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4") | image | 16 | 16 | 1.0, 2.0, 4.0 | 1024×1024 | generated; must include 3:2 1248×832, 2:3 832×1248, 1:1 2048², 16:9 1920×1088, 9:16 1088×1920 |
| `Qwen-Image 2.1` (models: "Qwen-Image 2.1 and 2.1 Turbo") | image | 16 | 32 | 1.0, 4.0 | 1024×1024 | 1:1 2048²; 4:3 2400×1792; 3:4 1792×2400; 3:2 2528×1696; 2:3 1696×2528; 16:9 2752×1536; 9:16 1536×2752; plus generated 1.0 MP entries for every chip |
| `HunyuanImage 2.1` | image | 32 | 32 | 4.0 | 2048×2048 | 1:1 2048²; 16:9 2560×1536; 9:16 1536×2560; 4:3 2304×1792; 3:4 1792×2304; 3:2 2496×1664; 2:3 1664×2496. Other chips empty. |
| `Wan 2.2 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280×720 | 16:9 832×480, 1280×720, 1280×704; 9:16 480×832, 720×1280, 704×1280; 1:1 640×640, 960×960; other chips generated |
| `LTX-2 (keyframes)` | video | 32 | 32 | 0.4, 0.9 | 768×512 | 3:2 768×512; 2:3 512×768; 16:9 960×544; 9:16 544×960; other chips generated |
| `HunyuanVideo 1.5 (keyframes)` | video | 16 | 16 | 0.4, 0.9 | 1280×720 | 16:9 848×480, 1280×720; 9:16 480×848, 720×1280; other chips generated |
| `MiniMax H3 (keyframes)` | video | 16 | 32 | 0.4, 1.0 | 864×480 | generated |
Every list is sorted by pixel count ascending; mirrored chips hold mirrored entries.
- [ ] **Step 1: Write the failing tests**
```python
from kikotools.tools.model_resolution_picker.families import (
CHIPS, DEFAULT_FAMILY, FAMILIES, MIRROR, families_payload, family_labels, resolve_family)
CHIP_RATIO = {c: int(c.split(":")[0]) / int(c.split(":")[1]) for c in CHIPS}
def test_chip_order_and_mirrors():
assert CHIPS == ("1:1", "4:5", "5:4", "3:4", "4:3", "2:3", "3:2", "9:16", "16:9", "21:9", "9:21")
assert all(MIRROR[MIRROR[c]] == c for c in CHIPS)
def test_every_size_is_on_step_and_within_seven_percent_of_its_chip():
for family in FAMILIES:
for chip, entries in family.sizes.items():
for w, h in entries:
assert w % family.step == 0 and h % family.step == 0, (family.label, w, h)
assert abs(w / h - CHIP_RATIO[chip]) / CHIP_RATIO[chip] <= 0.07, (family.label, chip, w, h)
def test_every_entry_has_its_mirror_and_lists_are_sorted():
for family in FAMILIES:
for chip, entries in family.sizes.items():
assert list(entries) == sorted(entries, key=lambda s: s[0] * s[1])
for w, h in entries:
assert (h, w) in family.sizes.get(MIRROR[chip], ()), (family.label, chip, w, h)
def test_defaults_labels_and_must_includes():
labels = family_labels()
assert labels[0] == "SD 1.5" and "SDXL" in labels and DEFAULT_FAMILY == "SDXL"
assert len(set(labels)) == len(labels)
for family in FAMILIES:
assert any(family.default in entries for entries in family.sizes.values()), family.label
must = {"SDXL": ("16:9", (1344, 768)), "Flux.1": ("16:9", (1920, 1088)),
"Flux.2 / Klein / MOSS": ("3:2", (1248, 832)), "Z-Image": ("21:9", (1680, 720)),
"Qwen-Image": ("16:9", (1664, 928)), "Qwen-Image 2.1": ("16:9", (2752, 1536)),
"HunyuanImage 2.1": ("16:9", (2560, 1536)), "Illustrious XL": ("2:3", (1248, 1824)),
"Wan 2.2 (keyframes)": ("16:9", (1280, 704)), "MiniMax H3 (keyframes)": ("16:9", (864, 480))}
for label, (chip, size) in must.items():
assert size in resolve_family(label).sizes[chip], label
def test_resolve_family_by_alias_and_unknown():
assert resolve_family("SDXL").step == 64
assert resolve_family("nope") is None
def test_payload_is_json_shaped():
payload = families_payload()
assert payload["default"] == "SDXL" and payload["chips"] == list(CHIPS)
sdxl = next(f for f in payload["families"] if f["label"] == "SDXL")
assert sdxl["sizes"]["3:2"] == [[1216, 832]] and sdxl["vaeRatio"] == 8 and sdxl["group"] == "image"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_families.py -p no:cacheprovider -q`
Expected: FAIL with `ModuleNotFoundError`
- [ ] **Step 3: Author `families.py`**
Produce generated entries with a one-off snippet calling `generate_size` for each chip and tier, apply the "must include" replacements and the Anima range filter, sort, then paste the literals. `families_payload()` converts tuples to lists.
- [ ] **Step 4: Run tests to verify they pass**
Run: same command. Expected: `6 passed`
- [ ] **Step 5: Commit**
```bash
git add kikotools/tools/model_resolution_picker/families.py tests/unit/tools/model_resolution_picker/test_families.py
git commit -m "feat(model_resolution_picker): add the model family resolution table"
```
---
### Task 3: Node, route and registration
**Files:**
- Create: `kikotools/tools/model_resolution_picker/node.py`
- Modify: `kikotools/tools/model_resolution_picker/__init__.py`, `kikotools/__init__.py` (import + both mapping dicts, next to `EmptyLatentBatch`)
- Test: `tests/unit/tools/model_resolution_picker/test_node.py`
**Interfaces:**
- Consumes: `resolve_family`, `family_labels`, `families_payload`, `DEFAULT_FAMILY` (Task 2); `round_to_step` (Task 1); `create_empty_latent_batch(width, height, batch_size)` from `kikotools/tools/empty_latent_batch/logic.py`.
- Produces: `class ModelResolutionPickerNode(ComfyAssetsBaseNode)` with `INPUT_TYPES` (`model` COMBO of `family_labels()` default `DEFAULT_FAMILY`, tooltip "Model family; the chips list its preferred sizes", `width`, `height`, `batch_size` per Global Constraints), `RETURN_TYPES = ("LATENT", "INT", "INT")`, `RETURN_NAMES = ("latent", "width", "height")`, `FUNCTION = "create"`, `create(self, model, width, height, batch_size) -> tuple[dict, int, int]`; `async def get_families(request) -> web.Response` (JSON of `families_payload()`); module-level registration of `GET /kikotools/model_resolution/families` in a `try: import server … except ImportError: pass` block.
- [ ] **Step 1: Write the failing tests**
```python
import asyncio, json, logging
from kikotools.tools.model_resolution_picker.node import ModelResolutionPickerNode, get_families
def test_inputs_and_outputs():
inputs = ModelResolutionPickerNode.INPUT_TYPES()["required"]
assert inputs["model"][1]["default"] == "SDXL" and "HunyuanImage 2.1" in inputs["model"][0]
width = inputs["width"][1]
assert (width["default"], width["min"], width["max"], width["step"]) == (1024, 64, 8192, 8)
assert ModelResolutionPickerNode.RETURN_TYPES == ("LATENT", "INT", "INT")
assert ModelResolutionPickerNode.CATEGORY == "🫶 ComfyAssets/🖼️ Resolution"
def test_execution_rounds_to_family_step_and_marks_latent():
latent, w, h = ModelResolutionPickerNode().create("HunyuanImage 2.1", 70, 1000, 2)
assert (w, h) == (64, 992)
assert latent["samples"].shape == (2, 4, 124, 8) and latent["downscale_ratio_spacial"] == 8
def test_exact_sizes_pass_through():
_, w, h = ModelResolutionPickerNode().create("Flux.2 / Klein / MOSS", 1248, 832, 1)
assert (w, h) == (1248, 832)
def test_unknown_label_keeps_size_and_warns(caplog):
with caplog.at_level(logging.WARNING):
_, w, h = ModelResolutionPickerNode().create("Renamed Family", 1000, 1000, 1)
assert (w, h) == (1000, 1000) and "Renamed Family" in caplog.text
def test_families_route_returns_payload():
response = asyncio.run(get_families(None))
assert response.status == 200 and json.loads(response.text)["default"] == "SDXL"
def test_registered_in_kikotools():
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
assert NODE_CLASS_MAPPINGS["ModelResolutionPicker"] is ModelResolutionPickerNode
assert NODE_DISPLAY_NAME_MAPPINGS["ModelResolutionPicker"] == "Model Resolution Picker"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_node.py -p no:cacheprovider -q`
Expected: FAIL with `ImportError`
- [ ] **Step 3: Implement `node.py`, export it, register it**
`create`: resolve → `step = family.step if family else 8`, warning via `logger.warning` when `None`; round both sides; `log_info` when a value changed; build the latent; return. `get_families` returns `web.json_response(families_payload())`.
- [ ] **Step 4: Run the new tests, then the whole Python suite with coverage**
Run: `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker -p no:cacheprovider -q` → `6 passed`
Run: `$V/bin/python -m pytest tests -p no:cacheprovider -q --cov=kikotools | tail -3` → all passed, coverage ≥ 85%
Run: `$V/bin/black --check kikotools tests && $V/bin/flake8 kikotools/tools/model_resolution_picker` → clean
- [ ] **Step 5: Commit**
```bash
git add kikotools/tools/model_resolution_picker kikotools/__init__.py tests/unit/tools/model_resolution_picker/test_node.py
git commit -m "feat(model_resolution_picker): add node, families route and registration"
```
---
### Task 4: Swap without a preset widget (`web/ui/dimensions.js`)
**Files:**
- Modify: `web/ui/dimensions.js:904-932` (`swapDimensions`)
- Test: `tests/js/dimensions.test.mjs`
**Interfaces:**
- Produces: `swapDimensions(node, app)` now swaps when the node has `width` and `height` widgets but no `preset` widget, taking the existing custom path (direct swap, callbacks once each, dirty canvas). Behavior with a `preset` widget is unchanged.
- [ ] **Step 1: Write the failing test**
```js
test('nodes without a preset widget swap width and height directly', () => {
const calls = [];
const node = { widgets: [
{ name: 'width', value: 1344, callback(value) { calls.push(['width', value]); } },
{ name: 'height', value: 768, callback(value) { calls.push(['height', value]); } },
], graph: { setDirtyCanvas() { calls.push(['dirty']); } } };
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), [768, 1344]);
assert.deepEqual(calls, [['width', 768], ['height', 1344], ['dirty']]);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `npm test --prefix tools/testing -- ../../tests/js/dimensions.test.mjs` (or `node --test tests/js/dimensions.test.mjs`)
Expected: FAIL, values unchanged
- [ ] **Step 3: Change the guard**
`if (!width || !height || swapping.has(node)) return; const custom = !preset || preset.value === 'custom';` and leave every `preset.*` access behind `!custom`.
- [ ] **Step 4: Run the JS unit tests**
Run: `npm test --prefix tools/testing` Expected: all pass, including the existing dimensions tests
- [ ] **Step 5: Commit**
```bash
git add web/ui/dimensions.js tests/js/dimensions.test.mjs
git commit -m "feat(ui): let swapDimensions work on nodes without a preset widget"
```
---
### Task 5: Derivation module (`web/ui/resolution-picker.js`)
**Files:**
- Create: `web/ui/resolution-picker.js`
- Test: `tests/js/resolution-picker.test.mjs`
**Interfaces:**
- Consumes: the families payload shape from Task 2.
- Produces (all pure, no DOM):
- `parseRatio(text) -> [w, h] | null` — accepts `W:H`, `W/H`, `WxH`, `W×H`, surrounding whitespace; integers 1–10000; else `null`.
- `reducedRatio(width, height) -> string` — same rule and aliases as Python `ratio_label`.
- `findFamily(payload, label) -> family | null` — label or alias.
- `deriveSelection(payload, label, width, height) -> { family, chip, entryIndex, custom, ratioText }` — `chip` is the chip whose list holds `[width, height]`; else nearest chip within 7%; else `null` with `custom: true` and `ratioText` from `reducedRatio`. `entryIndex` is the index in `family.sizes[chip]` or `-1`.
- `sizesForRatio(family, [rw, rh]) -> [[w, h], …]` — one per tier, Global Constraints formula, deduped, ascending.
- `defaultEntry(family, chip) -> [w, h] | null` — entry of `family.sizes[chip]` with pixel count closest to `family.default`'s.
- `effectiveSize(family, width, height) -> [w, h]` — rounded to `family.step` (8 when `family` is null), ≥ 64.
- `megapixels(width, height) -> string` — one decimal, e.g. `"1.0"`.
- `sizeOnModelChange(payload, newLabel, width, height, chip) -> [w, h]` — keep if listed in the new family; else `defaultEntry(newFamily, chip)` when non-null; else `newFamily.default`.
- [ ] **Step 1: Write the failing tests** (use a fixture payload with `SDXL` (step 64, default 1024², sizes 1:1 1024², 3:2 1216×832, 2:3 832×1216, 16:9 1344×768, 9:16 768×1344) and `HunyuanImage 2.1` (step 32, default 2048², 1:1 2048², 16:9 2560×1536, 9:16 1536×2560), plus `aliases: ['SDXL 1.0']` on SDXL)
```js
test('parseRatio accepts the documented forms and rejects malformed input', () => {
for (const text of ['16:9', ' 16 / 9 ', '16x9', '16×9']) assert.deepEqual(parseRatio(text), [16, 9]);
for (const text of ['0:5', '16:', 'a:b', '99999999:1', '', null]) assert.equal(parseRatio(text), null);
});
test('deriveSelection prefers authored membership, then the 7% band, then custom', () => {
assert.deepEqual(deriveSelection(payload, 'HunyuanImage 2.1', 2560, 1536), { family: payload.families[1], chip: '16:9', entryIndex: 0, custom: false, ratioText: '5:3' });
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).chip, '3:2'); // 1.5 exactly, not listed
assert.equal(deriveSelection(payload, 'SDXL', 1200, 800).entryIndex, -1);
const custom = deriveSelection(payload, 'SDXL', 1600, 800); // 2:1 is >7% from every chip
assert.deepEqual([custom.chip, custom.custom, custom.ratioText], [null, true, '2:1']);
assert.equal(deriveSelection(payload, 'SDXL 1.0', 1024, 1024).chip, '1:1'); // alias
assert.equal(deriveSelection(payload, 'nope', 1024, 1024).family, null);
});
test('sizesForRatio follows the core formula per tier and effectiveSize rounds to the step', () => {
assert.deepEqual(sizesForRatio({ step: 16, megapixels: [1, 2] }, [16, 9]), [[1360, 768], [1936, 1088]]);
assert.deepEqual(effectiveSize(payload.families[1], 70, 1000), [64, 992]);
assert.deepEqual(effectiveSize(null, 1001, 1001), [1000, 1000]);
assert.equal(megapixels(1216, 832), '1.0');
});
test('model change keeps listed sizes, else the same chip default, else the family default', () => {
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 2560, 1536, '16:9'), [2560, 1536]);
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1344, 768, '16:9'), [2560, 1536]);
assert.deepEqual(sizeOnModelChange(payload, 'HunyuanImage 2.1', 1216, 832, '3:2'), [2048, 2048]);
assert.deepEqual(defaultEntry(payload.families[0], '21:9'), null);
});
```
- [ ] **Step 2: Run to verify failure** — `node --test tests/js/resolution-picker.test.mjs` → FAIL (module missing)
- [ ] **Step 3: Implement the module** — plain functions; `sizesForRatio` uses `Math.round(side / step) * step` with the 64 floor; nearest-chip selection picks the smallest relative error.
- [ ] **Step 4: Run** — `npm test --prefix tools/testing` → all pass
- [ ] **Step 5: Commit**
```bash
git add web/ui/resolution-picker.js tests/js/resolution-picker.test.mjs
git commit -m "feat(ui): add resolution picker derivation module"
```
---
### Task 6: View, extension and first browser workflows
**Files:**
- Create: `web/views/resolution-picker.js`, `web/model_resolution_picker.js`
- Modify: `web/ui/controls.css` (append `.ca-resolution-*` rules), `tests/browser/workflows.spec.mjs`
**Interfaces:**
- Consumes: Task 5 functions; `createScope`, `mountTheme`, `createButton` from `web/ui/core.js`; `mountDimensionSwap` from `web/ui/dimension-swap.js`; `swapDimensions` (Task 4); `api.fetchApi` from `../../scripts/api.js`.
- Produces:
- `createResolutionPickerView(scope, actions) -> { element, render(state), setStatus(text, isError) }` in `web/views/resolution-picker.js`. `actions = { selectChip(chip), openCustom(), commitRatio(text), selectSize(w, h) }`. `state = { disabled: boolean, header: string, chips: [{ id, label, active, disabled }], customOpen: boolean, ratioText: string, entries: [{ w, h, active }], error: string | null }`. Markup: `element.className = 'ca-tools ca-resolution-picker'`; header `<p class="ca-resolution-header">`; chips in `<div class="ca-resolution-chips" role="group" aria-label="Aspect ratio">` as `<button type="button" aria-pressed>` with text `1:1` … and a last chip `Custom ratio`; sizes in `<div class="ca-resolution-sizes" role="group" aria-label="Resolution">` as `<button type="button" aria-pressed>` with text `1216 × 832`; custom mode shows `<input aria-label="Custom ratio" placeholder="16:9">` (commit on Enter and on `change`) above the generated sizes; status `<p class="ca-resolution-status" role="status">`.
- Extension `comfyassets.ModelResolutionPicker` in `web/model_resolution_picker.js`: `beforeRegisterNodeDef` for `nodeData.name === 'ModelResolutionPicker'`; in `onNodeCreated`: `this.setSize([340, 600])`, create scope, mount view with `this.addDOMWidget('resolution_picker', 'div', view.element, { serialize: false, hideOnZoom: false, getMinHeight: () => 320 })`, `mountTheme`, `this.swapDimensions = () => { swapDimensions(this, app); render(); }`, `this.createSwapButton = () => mountDimensionSwap(this, app)` then call it, wrap the `model`/`width`/`height` widget callbacks to call the original then `render()`, `onRemoved` disposes the scope and preserves the previous hook's return. Families are loaded once per page via a module-level `loadFamilies(api)` promise using `api.fetchApi('/kikotools/model_resolution/families')`; while loading the view renders `disabled: true` with header `Loading sizes…`; on failure header `Sizes unavailable; type width and height` via `setStatus(…, true)` and chips stay disabled. Custom-ratio open flag and pending text live on the node instance (`this.resolutionCustom = { open, text }`), never serialized. Wired-in check: `node.inputs?.some(input => input.widget?.name === name && input.link != null)` for `width` or `height` → `disabled: true`, header `Dimensions are wired in`, and the swap button `disabled`.
- Writing a size: set `width.value`/`height.value`, call each widget's `callback(value, app.canvas, node, [0, 0], null)` once, `node.graph?.setDirtyCanvas?.(true, true)`.
- [ ] **Step 1: Write the failing browser tests** (append to `tests/browser/workflows.spec.mjs`; the fixture host creates text inputs for the initial keys and serves the real route because `node.py` registered it on the stubbed `PromptServer`)
```js
test('resolution picker renders chips from the families route and writes sizes to native widgets', async ({ page, request }) => {
await openHost(page);
const payload = await (await request.get('/kikotools/model_resolution/families')).json();
expect(payload.default).toBe('SDXL');
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.locator('.ca-resolution-header')).toHaveText('1024 × 1024 · 1:1 · 1.0 MP');
await panel.getByRole('button', { name: '3:2', exact: true }).click();
const node = page.locator('[data-node="ModelResolutionPicker"]');
await expect(node.getByLabel('width', { exact: true })).toHaveValue('1216');
await expect(node.getByLabel('height', { exact: true })).toHaveValue('832');
await expect(panel.getByRole('button', { name: '1216 × 832', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('button', { name: '21:9', exact: true })).toBeEnabled();
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#fff' : '#202020'), theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
});
test('resolution picker mounting is idempotent and removal disposes its scope', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); node.createSwapButton(); node.createSwapButton(); }, id);
await expect(page.locator('.ca-resolution-picker')).toHaveCount(1);
await expect(page.locator('.ca-dimension-swap')).toHaveCount(1);
const aborted = await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); kikoHost.removeNode(id); return node.resolutionScope.signal.aborted; }, id);
expect(aborted).toBe(true);
await expect(page.locator('.ca-resolution-picker')).toHaveCount(0);
});
```
- [ ] **Step 2: Run to verify failure** — `npm run --prefix tools/testing test:browser -- --project chromium -g "resolution picker"` → FAIL (no panel)
- [ ] **Step 3: Implement the view, the CSS and the extension**
CSS: chips grid `grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px`; active chip and active size use `--ca-accent` background with `#fff` text (same as `.ca-primary`); size list is a single column of full-width buttons; header uses `--ca-muted`; everything inherits the `.ca-tools` tokens so light/dark follow `data-ca-theme`. Expose the scope as `node.resolutionScope` for the disposal test.
- [ ] **Step 4: Run the browser suite and the JS unit suite** — `npm run --prefix tools/testing test:browser -- --project chromium` and `npm test --prefix tools/testing` → all pass, no page errors
- [ ] **Step 5: Commit**
```bash
git add web/views/resolution-picker.js web/model_resolution_picker.js web/ui/controls.css tests/browser/workflows.spec.mjs
git commit -m "feat(model_resolution_picker): add the chip grid panel and extension"
```
---
### Task 7: Swap, model change, custom ratio, wired-in, route failure; coverage floors
**Files:**
- Modify: `web/model_resolution_picker.js`, `web/views/resolution-picker.js` (only as the tests demand), `tests/browser/workflows.spec.mjs`, `tools/testing/frontend-coverage-baseline.json`
**Interfaces:**
- Consumes: Task 6 extension and view; `sizeOnModelChange`, `sizesForRatio`, `parseRatio` (Task 5).
- [ ] **Step 1: Write the failing browser tests**
```js
test('resolution picker swap lands on the mirrored chip and entry', async ({ page }) => {
await openHost(page);
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1344, height: 768, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('button', { name: '768 × 1344', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
test('resolution picker model change keeps listed sizes or falls back to the chip default', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1344, height: 768, batch_size: 1 });
const node = page.locator('[data-node="ModelResolutionPicker"]');
await node.getByLabel('model', { exact: true }).fill('HunyuanImage 2.1');
await node.getByLabel('model', { exact: true }).dispatchEvent('change');
await expect(node.getByLabel('width', { exact: true })).toHaveValue('2560');
await expect(node.getByLabel('height', { exact: true })).toHaveValue('1536');
await expect(page.locator('.ca-resolution-header')).toHaveText('2560 × 1536 · 16:9 · 3.8 MP');
});
test('resolution picker custom ratio field computes tiers and rejects bad input', async ({ page }) => {
await openHost(page);
await createNode(page, 'ModelResolutionPicker', { model: 'Flux.1', width: 1024, height: 1024, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
await panel.getByRole('button', { name: 'Custom ratio', exact: true }).click();
const field = panel.getByLabel('Custom ratio', { exact: true });
await expect(field).toHaveValue('1:1');
await field.fill('16:10'); await field.press('Enter');
await expect(panel.getByRole('button', { name: '1296 × 816', exact: true })).toBeVisible(); // 1.0 MP, step 16
await panel.getByRole('button', { name: '1296 × 816', exact: true }).click();
await expect(page.locator('[data-node="ModelResolutionPicker"]').getByLabel('width', { exact: true })).toHaveValue('1296');
await field.fill('a:b'); await field.press('Enter');
await expect(panel.locator('.ca-resolution-status')).toHaveText(/Enter a ratio like 16:9/);
await expect(panel.getByRole('button', { name: '1296 × 816', exact: true })).toBeVisible();
});
test('wired-in dimensions disable the picker and swap', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
await page.evaluate(id => { const node = kikoHost.nodes.get(String(id)); node.inputs.push({ name: 'width', widget: { name: 'width' }, link: 7 }); node.widgets.find(w => w.name === 'width').callback('1024', kikoHost.app.canvas, node, [0, 0], null); }, id);
const panel = page.locator('.ca-resolution-picker');
await expect(panel.locator('.ca-resolution-header')).toHaveText('Dimensions are wired in');
await expect(panel.getByRole('button', { name: '3:2', exact: true })).toBeDisabled();
await expect(page.locator('.ca-dimension-swap').getByRole('button')).toBeDisabled();
});
test('route failure keeps native widgets usable', async ({ page }) => {
await page.route('**/kikotools/model_resolution/families', route => route.fulfill({ status: 500, body: 'nope' }));
await openHost(page);
await createNode(page, 'ModelResolutionPicker', { model: 'SDXL', width: 1024, height: 1024, batch_size: 1 });
const panel = page.locator('.ca-resolution-picker');
await expect(panel.locator('.ca-resolution-status')).toHaveText('Sizes unavailable; type width and height');
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toBeDisabled();
await page.locator('.ca-dimension-swap').getByRole('button').click();
await expect(page.locator('[data-node="ModelResolutionPicker"]').getByLabel('width', { exact: true })).toHaveValue('1024');
});
```
The 500 response must not be counted as a page error: the fixture's `afterEach` collects responses ≥ 500, so this test registers its route before `openHost` and removes the 500 URL from `page.errors` at the end (`page.errors = page.errors.filter(e => !e.includes('families'))`).
- [ ] **Step 2: Run to verify failure** — `npm run --prefix tools/testing test:browser -- --project chromium -g "resolution picker|wired-in|route failure"` → the new tests FAIL
- [ ] **Step 3: Implement the remaining behavior** — model-change handler uses `sizeOnModelChange`; custom field uses `parseRatio` + `sizesForRatio`, error copy `Enter a ratio like 16:9`; the fixture model widget is a text input, so the extension must listen to the widget callback, not to a `<select>`.
- [ ] **Step 4: Add coverage floors and run everything frontend**
Add rows for `model_resolution_picker.js`, `ui/resolution-picker.js`, `views/resolution-picker.js` to `tools/testing/frontend-coverage-baseline.json`, then:
Run: `npm test --prefix tools/testing && npm run --prefix tools/testing test:browser -- --project chromium && npm run --prefix tools/testing test:coverage`
Expected: all pass; set each new row to the measured values rounded down to the nearest whole percent (never below 80 for lines/statements).
- [ ] **Step 5: Commit**
```bash
git add web/model_resolution_picker.js web/views/resolution-picker.js tests/browser/workflows.spec.mjs tools/testing/frontend-coverage-baseline.json
git commit -m "feat(model_resolution_picker): model change, custom ratio, wired-in and failure handling"
```
---
### Task 8: Real-host coverage (both renderers, workflow round-trip)
**Files:**
- Create: `tests/comfyui/host-setup.mjs`, `tests/comfyui/resolution-picker.spec.mjs`
- Modify: `tests/comfyui/image-browser.spec.mjs:1-60` (import the shared `beforeEach` instead of its inline copy)
**Interfaces:**
- Produces: `installHost(test, expect)` in `host-setup.mjs` that registers exactly the existing `beforeEach` (versions check, fixture reset, route proxying, settings override, extension filter, `app.graph.clear()`), with `/kikotools/model_resolution/` added to the list of prefixes proxied to the fixture server so the route is deterministic.
- [ ] **Step 1: Extract the shared setup and confirm the existing spec still passes**
Run: `KIKO_COMFY_ROOT=… npm run --prefix tools/testing test:comfyui` (or against `KIKO_COMFY_URL`; see `TESTING.md`). Expected: unchanged results for `image-browser.spec.mjs`.
- [ ] **Step 2: Write the failing real-host test** (mirrors the sibling swap test at `image-browser.spec.mjs:61-150`)
```js
test('resolution picker survives renderers, swap, themes and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); const node = LiteGraph.createNode('ModelResolutionPicker'); app.graph.add(node); app.canvas.centerOnNode(node); });
const panel = page.locator('.ca-resolution-picker');
await expect(panel.getByRole('button', { name: '1:1', exact: true })).toHaveAttribute('aria-pressed', 'true');
await panel.getByRole('button', { name: '16:9', exact: true }).click();
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => { const { app } = await import('/scripts/app.js'); await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); }, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await page.locator('.ca-dimension-swap').getByRole('button', { name: 'Swap width and height', exact: true }).click();
}
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true'); // three swaps from 16:9
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => { const { app } = await import('/scripts/app.js'); await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme); }, theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`ModelResolutionPicker-${theme}.png`) });
}
const restored = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === 'ModelResolutionPicker');
if (saved.widgets_values.slice(4).some(value => value !== '' && value != null)) throw new Error('Picker UI leaked into workflow values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker');
return { saved: saved.widgets_values.slice(0, 4), restored: node.widgets.filter(w => ['model', 'width', 'height', 'batch_size'].includes(w.name)).map(w => w.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
});
expect(restored.restored).toEqual(restored.saved);
expect(restored.size).toEqual(restored.savedSize);
await expect(panel.getByRole('button', { name: '9:16', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); const node = app.graph._nodes.find(node => node.type === 'ModelResolutionPicker'); window.removedPicker = node; app.graph.remove(node); });
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedPicker.resolutionScope.signal.aborted)).toBe(true);
});
```
- [ ] **Step 3: Run it against the pinned host for both projects** — `npm run --prefix tools/testing test:comfyui` → `classic-start` and `nodes2-start` pass. Fix extension behavior if a renderer differs; do not special-case the test.
- [ ] **Step 4: Copy the two screenshots** from the test output to `docs/screenshots/resolution-picker/ModelResolutionPicker-{light,dark}.png` (used by Task 9).
- [ ] **Step 5: Commit**
```bash
git add tests/comfyui/host-setup.mjs tests/comfyui/resolution-picker.spec.mjs tests/comfyui/image-browser.spec.mjs docs/screenshots/resolution-picker
git commit -m "test(model_resolution_picker): cover both renderers and workflow round-trip on the pinned host"
```
---
### Task 9: Documentation
**Files:**
- Modify: `README.md` (tools table after the `Empty Latent Batch` row at line 44; a `#### 🎯 Model Resolution Picker` section after `#### 📦 Empty Latent Batch`; the status table near line 801), `docs/ui-migration.md` (table row `Model Resolution Picker | views/resolution-picker.js, ui/resolution-picker.js, shared swap | [Picker](screenshots/resolution-picker/)`), `web/ui/README.md` (a short "Model Resolution Picker" section stating: derived state only, in-memory custom-ratio flag, families route, disabled chips for empty lists)
- Create: `examples/documentation/model_resolution_picker.md` (inputs, outputs, the family table with labels and steps, the keyframe note for video families, the manual GPU check procedure from the spec's "Verification before merge")
- [ ] **Step 1: Write the docs** — copy the family labels and steps from `families.py`; README section bullets: model dropdown; chips and list; Swap; custom ratio and custom resolution rounding; outputs; "video rows are keyframe sizes, the latent is an image latent".
- [ ] **Step 2: Verify links** — `grep -n "resolution-picker\|model_resolution_picker" README.md docs/ui-migration.md web/ui/README.md` shows every referenced path exists (`ls` each).
- [ ] **Step 3: Commit**
```bash
git add README.md docs/ui-migration.md web/ui/README.md examples/documentation/model_resolution_picker.md
git commit -m "docs(model_resolution_picker): document the node, families and keyframe use"
```
---
### Task 10: Full verification and manual GPU check
**Files:** none new.
- [ ] **Step 1: Run every suite**
```
$V/bin/python -m pytest tests -p no:cacheprovider -q --cov=kikotools | tail -3
$V/bin/black --check . && $V/bin/flake8 kikotools
npm test --prefix tools/testing && npm run --prefix tools/testing test:coverage
npm run --prefix tools/testing test:browser
npm run --prefix tools/testing test:comfyui
```
Expected: all green; Python coverage ≥ 85%; frontend floors met; Width Height Selector and Empty Latent Batch tests untouched and passing.
- [ ] **Step 2: Manual generation check on the user's ComfyUI** (spec: "Verification before merge"): one queued image each for `Flux.2 / Klein / MOSS` at 1248×832 and `HunyuanImage 2.1` at 2560×1536 through this node → KSampler → VAE Decode → Save; confirm the saved PNGs are exactly those sizes. Record both results in the PR description. This step needs the user's GPU box; if it cannot run, say so in the PR and mark it as a pre-merge TODO.
- [ ] **Step 3: Hand off** — use `superpowers:finishing-a-development-branch` (push with the explicit refspec, PR against `main`).
---
## Addendum tasks (spec addendum 2026-10-27: JSON table, user override, resolution dropdown)
Global Constraints additions: the shipped file is `kikotools/tools/model_resolution_picker/families.json`; the override is `<user_dir>/kikotools/model_resolutions.json`; dropdown label format `{w}×{h} - {ratio} ({mp:.1f}MP)` with `custom` first; `resolution` COMBO sits between `model` and `width`; `VALIDATE_INPUTS(cls, model, resolution)` returns True; an invalid override is ignored whole, logged, and surfaced as `warnings` in the payload; workflows serialize five native widgets.
### Task 11: Shipped JSON table with a runtime validator
**Files:**
- Create: `kikotools/tools/model_resolution_picker/families.json`, `kikotools/tools/model_resolution_picker/table.py`
- Modify: `kikotools/tools/model_resolution_picker/families.py` (becomes a thin loader), `pyproject.toml` (package-data for `*.json` under `kikotools`)
- Test: `tests/unit/tools/model_resolution_picker/test_table.py`; `test_families.py` keeps passing unchanged
**Interfaces:**
- Produces in `table.py`: `load_table(path: Path) -> dict` (JSON load; raises `ValueError` with the path on bad JSON); `validate_table(data: dict) -> list[str]` (returns human-readable problems, empty when valid; checks: top-level keys and `version == 1`, chips equal `CHIPS`, mirror consistent, unique labels/aliases across families, every chip key present per family, sizes on step, 7% tolerance, exact mirrors, ascending order, one chip per size, default present in sizes, `group` in {image, video}, `vaeRatio` in {8, 16, 32}, `step` > 0, non-empty `megapixels`); `build_families(data: dict) -> tuple[Family, ...]`.
- `families.py` keeps exporting `CHIPS`, `MIRROR`, `Family`, `FAMILIES`, `DEFAULT_FAMILY`, `family_labels`, `resolve_family`, `families_payload`, now built from `families.json` at import; a validation failure raises `RuntimeError` listing the problems.
- [ ] **Step 1: Write the failing tests** — `test_validate_table_accepts_the_shipped_file` (loads the shipped path, `validate_table` returns `[]`), `test_validate_table_reports_each_invariant` (parametrized: a copy of the shipped data mutated to break one rule each — off-step size, >7% size, missing mirror, unsorted list, duplicate size under two chips, duplicate label, missing chip key, default not listed, bad vaeRatio — must produce exactly one message naming the family and chip), `test_load_table_bad_json_names_the_path`, `test_families_module_is_built_from_json` (`FAMILIES` labels equal the JSON labels in order).
- [ ] **Step 2: Run to verify failure** — `$V/bin/python -m pytest tests/unit/tools/model_resolution_picker/test_table.py -p no:cacheprovider -q` → ImportError.
- [ ] **Step 3: Generate `families.json` from the current `FAMILIES`** with a one-off snippet (`families_payload()` plus `"version": 1`), indent 2, then rewrite `families.py` to load/validate/build; delete the literals; keep the source comments as a `"notes"` string per family (the validator ignores unknown keys).
- [ ] **Step 4: Run** the new tests, `test_families.py`, the full suite with coverage, black and flake8.
- [ ] **Step 5: Commit** — `refactor(model_resolution_picker): move the family table to families.json with a validator`.
### Task 12: User override file
**Files:**
- Create: `kikotools/tools/model_resolution_picker/override.py`
- Modify: `families.py` (resolution and payload consult the merged table), `node.py` (route payload includes `warnings`)
- Test: `tests/unit/tools/model_resolution_picker/test_override.py`, `test_node.py`
**Interfaces:**
- Produces: `override_path() -> Path | None` (uses `folder_paths.get_user_directory()`; `None` when `folder_paths` or the function is unavailable); `merge_override(shipped: dict, override: dict) -> dict` (rules from the spec: replace by label, append new, `remove`, `default`); `current_table() -> tuple[dict, list[str]]` (shipped merged with a valid override, cached by override mtime; returns `(merged_data, warnings)`; an unreadable/invalid override yields the shipped data plus one `"Override ignored: <reason>"` warning and a `logger.warning`).
- `families.py`: `resolve_family(label)` and `families_payload()` use `current_table()`; `families_payload()["warnings"]` is the warnings list; `FAMILIES` remains the shipped tuple for tests.
- [ ] **Step 1: Write the failing tests** — with `tmp_path` as a fake user directory (patch `folder_paths` in `sys.modules` with `get_user_directory`): override replaces SDXL's `sizes` and `resolve_family("SDXL")` reflects it; a new family `My Model` appears last in `families_payload()["families"]` and resolves; `"remove": ["SD 1.5"]` hides it; invalid JSON → shipped data, `warnings == ["Override ignored: <path>: <json error>"]` and a warning log; an override whose merged result breaks an invariant (e.g. off-step size) → ignored whole with the validator message in the warning; editing the file (newer mtime) is picked up on the next call; no `folder_paths` → no override, no warnings; `get_families` route JSON includes `warnings`.
- [ ] **Step 2: Run to verify failure.**
- [ ] **Step 3: Implement** `override.py` and wire `families.py`/`node.py`. `current_table()` caches on `(path, mtime_ns)`.
- [ ] **Step 4: Run** the picker tests, full suite with coverage, black, flake8.
- [ ] **Step 5: Commit** — `feat(model_resolution_picker): merge a user override file into the family table`.
### Task 13: Native `resolution` dropdown synced with the panel
**Files:**
- Modify: `node.py` (input, `VALIDATE_INPUTS`, execution precedence), `logic.py` (`size_label(w, h, ratio_label) -> str`, `parse_size_label(text) -> tuple[int, int] | None`), `web/ui/resolution-picker.js` (`sizeLabel`, `parseSizeLabel`, `dropdownOptions(payload, label, chip) -> string[]`), `web/model_resolution_picker.js` (rebuild options and sync value; dropdown callback writes size), `web/views/resolution-picker.js` (status shows the first payload warning), `tests/js/resolution-picker.test.mjs`, `tests/browser/workflows.spec.mjs`, `tests/comfyui/resolution-picker.spec.mjs` (five native widgets: `slice(5)`, include `resolution` in the round-trip), `tools/testing/frontend-coverage-baseline.json` if floors move
- Test: `test_node.py`, `test_logic.py`
**Interfaces:**
- Python: `INPUT_TYPES` adds `resolution` COMBO (`["custom"] + sorted distinct labels across shipped families`, default `custom`, tooltip "Sizes for the selected model and ratio; custom uses width/height") between `model` and `width`; `create(self, model, resolution, width, height, batch_size)`: when `resolution != "custom"` and `parse_size_label` succeeds, those dims replace `width`/`height` before rounding; `VALIDATE_INPUTS(cls, model, resolution) -> bool`.
- JS: `dropdownOptions` returns `['custom', ...labels]` for the active chip's sizes, or all family sizes when `chip` is null/custom; the extension sets `resolution.options.values` (array) on every render where model or active chip changed, sets `resolution.value` to the current size's label or `custom`, and wraps the `resolution` widget callback: a label value writes the parsed size through `width`/`height` callbacks (re-entrancy guarded), `custom` leaves sizes alone.
- Fixture host note: the fixture renders `resolution` as a text input; tests set its value and dispatch `change`.
- [ ] **Step 1: Write the failing tests** — Python: `test_resolution_label_overrides_width_height` (`create("SDXL", "1216×832 - 3:2 (1.0MP)", 1024, 1024, 1)` → `(1216, 832)`), `test_custom_uses_width_height`, `test_validate_inputs_accepts_unlisted_resolution`, `size_label(1216, 832, "3:2") == "1216×832 - 3:2 (1.0MP)"`, `parse_size_label("1216×832 - 3:2 (1.0MP)") == (1216, 832)` and `None` for `custom`/garbage. JS unit: `dropdownOptions(payload, 'SDXL', '3:2')` → `['custom', '1216×832 - 3:2 (1.0MP)']`; `dropdownOptions(payload, 'SDXL', null)` lists all five SDXL sizes with ratio labels; `parseSizeLabel` round-trips `sizeLabel`. Browser: creating the node with `resolution: 'custom'` and clicking `3:2` sets the `resolution` input to `1216×832 - 3:2 (1.0MP)`; setting `resolution` to `1344×768 - 16:9 (1.0MP)` and dispatching `change` writes width/height 1344/768 and activates `16:9`; a swap sets `resolution` to the mirrored label; a typed width of 1000 sets it to `custom`; with the families route fulfilled by a payload carrying `warnings: ['Override ignored: test']` the status line shows that text. Real host: the existing spec asserts `widgets_values.slice(5)` is empty and that `resolution` survives reload.
- [ ] **Step 2: Run to verify failure** (Python, JS unit, chromium focused).
- [ ] **Step 3: Implement** Python then JS. The option list is set as a plain array on `widget.options.values`; the sibling `dimensions.js` already reads array or function options.
- [ ] **Step 3b: Ratio shapes on chips** (spec addendum part 4): in `web/views/resolution-picker.js` prepend to each ratio chip a `<span class="ca-resolution-shape" aria-hidden="true">` with inline `width`/`height` computed from the chip ratio (longest side 18 px, shortest at least 6 px, whole pixels), `1:1` square, none on `Custom ratio`; CSS in `controls.css`: `display:inline-block; border:1px solid currentColor; border-radius:2px; margin-inline-end:6px; vertical-align:middle`. Browser assertion in the existing render test: the `16:9` chip's shape is wider than tall and the `9:16` chip's is taller than wide; the chips' accessible names are unchanged.
- [ ] **Step 4: Run** Python suite with coverage, black/flake8, JS unit, chromium+touch browser suite (`env KIKO_TEST_PYTHON=…`), coverage gate, real host on both projects.
- [ ] **Step 5: Commit** — `feat(model_resolution_picker): add a resolution dropdown synced with the chip grid`.
### Task 14: Docs and final verification for the addendum
- [ ] Update `README.md` section bullets (dropdown; override file path and rules), `examples/documentation/model_resolution_picker.md` (JSON schema, override example with `remove` and a new family, warning behavior, dropdown precedence), `web/ui/README.md` (dropdown sync rule). Verify links.
- [ ] Run every suite as in Task 10 Step 1 (Python, black/flake8, JS unit, coverage gate, chromium+touch, real host). WebKit is a known local environment limitation.
- [ ] Commit — `docs(model_resolution_picker): document the JSON table, override file and resolution dropdown`.
@@ -0,0 +1,399 @@
# Model Resolution Picker — design
Date: 2026-10-27. Status: approved 2026-10-27.
## Intent
A new ComfyAssets node that picks an image size the way Pixaroma's
"Resolution" node does — a grid of aspect-ratio chips with a list of concrete
sizes under the active chip — but driven by a **model dropdown**. Pick the model
family first and every chip's size list becomes that family's preferred
resolutions. The node outputs `width`, `height` and an empty `LATENT`, and has
the toolkit's Swap W×H button, so one node replaces Width Height Selector plus
Empty Latent Batch in a workflow.
This is a **new node**. Width Height Selector and Empty Latent Batch stay as
they are.
Why now: every new model family (Flux.2, Klein, Z-Image, Qwen-Image 2.1,
MOSS-Image, Anima, HunyuanImage) has its own preferred sizes and its own VAE
step. The existing preset dropdown is one flat SDXL/FLUX list; keeping it
current for a dozen families makes a 60-entry combo nobody can scan. The chip
grid makes the choice two clicks, and the model dropdown keeps the wrong sizes
out of reach.
## Goals
- Two clicks from "I loaded Illustrious" to a correct size: model, then chip,
then (optionally) a different entry in the list.
- Swap W×H flips the dimensions, moves the highlight to the mirrored chip
(16:9 → 9:16) and to the matching entry in that chip's list.
- Custom Ratio: type any `W:H` and get sizes computed at the family's
megapixel tiers, rounded to the family's step.
- Custom Resolution: type exact width/height; the node rounds to the family's
step and shows the effective size before you queue.
- The empty latent is correct for every listed family, including 16x and 32x
VAEs, by carrying the `downscale_ratio_spacial` mark that core
`EmptyLatentImage` sets (the fix from commit 297f2b1).
- Looks and behaves the same in the Classic and Nodes 2.0 frontends, using the
existing `web/ui` toolkit.
## Non-goals
- Replacing or changing Width Height Selector or Empty Latent Batch, their
presets, JS, or tests.
- Video-length or frame-count handling. Video families are listed for their
**frame size** only: the latent output is an image latent for generating
first/last keyframes with an image model at the video model's native size.
- Per-checkpoint detection. The user picks the family; the node does not
inspect the loaded model.
- Snap/step toggles on the node. The step is a property of the family.
## Anti-goals (would make this a failure even if it works)
- Vendoring or adapting Pixaroma's JS, CSS or assets. `docs/ui-migration.md`
records that only the organization was an inspiration; that stays true.
- Storing UI state anywhere except ComfyUI's native widgets. The DOM panel is
a projection of `model`, `width`, `height`; it must never need its own
serialized state to reconstruct itself.
- A size table that is "generated from the formula" for a family that has an
official list. Official lists are used verbatim.
- Breaking the real-host CI (pinned ComfyUI, both renderers).
## How it looks
Top to bottom inside the node:
1. Native `model` combo widget.
2. The DOM panel (`.ca-tools`):
- A header line: effective size, ratio label and megapixels, e.g.
`1216 × 832 · 3:2 · 1.0 MP`. "Effective" means after rounding to the
family step, so what you see is what Python will emit.
- The chip grid: `1:1`, `4:5`, `5:4`, `3:4`, `4:3`, `2:3`, `3:2`, `9:16`,
`16:9`, `21:9`, `9:21`, and `Custom ratio`. Three columns. Chips with no
sizes for the selected family are disabled, not hidden, so the grid never
reflows. The active chip uses the toolkit's primary (blue) style.
- Below the grid, the size list for the active chip, one entry highlighted.
With `Custom ratio` active, the list is replaced by a `W:H` text field
and the computed sizes for that ratio.
- The toolkit Swap W×H button.
3. Native `width`, `height`, `batch_size` widgets. `width`/`height` double as
the Custom Resolution fields and as the inputs you convert when wiring
dimensions in from another node.
Placement: the panel mounts after the native widgets, which is the toolkit
convention used by the two sibling nodes and the behavior `addDOMWidget` gives
for free in both renderers. **My call**; the builder may move the panel
directly under `model` only if it proves stable in both renderers under the
real-host tests.
New nodes are 340 px wide. The node height is fitted to the panel content (the
chip grid plus the active chip's list) after every render, so no empty space is
left under the list; it grows and shrinks with the list. Saved workflows keep
their width and are re-fitted on load. **Changed by the owner after testing**: the
original 340 × 600 default left a large gap under one-entry lists.
## Behavior
### State model
The only persisted state is the four native widgets: `model` (COMBO),
`width` (INT), `height` (INT), `batch_size` (INT). Everything the panel
shows is derived:
- **Active chip**: the chip under which the family table lists
`width × height` (authored membership always wins; that is how HunyuanImage's
2560×1536, a 5:3 size, lives under 16:9). If no list contains it, the
nearest chip whose ratio is within 7% of `width / height` (so 1216×832
reads as 3:2 and 1536×640 as 21:9). If none, `Custom ratio` is active and
the ratio field shows the reduced fraction, with aliases so 8:5 displays as
16:10 and 7:3 as 21:9.
- **Custom-ratio mode flag**: the panel keeps one in-memory, non-serialized
flag "custom ratio open" plus the pending text in the field. Clicking the
`Custom ratio` chip sets the flag and prefills the field with the current
reduced ratio; it does not change `width`/`height`. Committing a ratio and
picking a tier size sets `width`/`height` and keeps the custom list open
until another chip is clicked. On reload the flag is gone and the pure
derivation above decides, which is the intended behavior. This is the only
panel-owned state and it is never needed to reconstruct anything.
- **Highlighted list entry**: the entry equal to `width × height`, else none.
- **Header**: computed from `width`, `height` and the family step.
Consequence: reload, undo, copy/paste, and converting `width`/`height` to
inputs all work with zero code, because there is nothing to restore. When
`width` or `height` is converted to an input, the panel disables the chips
and list and shows "dimensions are wired in" in the header; the Swap button
stays enabled only when both are widgets.
### Interactions
- **Chip click**: sets `width`/`height` to that chip's default entry for the
family (the entry closest to the family's default megapixels), unless the
current size already belongs to that chip's list.
- **List click**: sets `width`/`height` to that entry.
- **Custom ratio field**: accepts `W:H` (integers, also `W/H` and `W x H`);
on commit, the list shows one size per family megapixel tier, computed with
ComfyUI core's Resolution Selector formula (`scale = sqrt(MP·1024² /
(w·h))`, each side rounded to the family step). Invalid input keeps the
previous list and shows an inline error in the panel's status line.
- **Swap W×H**: swaps `width`/`height` through the native widgets with
callbacks (so converted inputs and other extensions see it), then the panel
re-derives. Because every family list is authored with mirrored pairs, the
swapped size lands on the mirrored chip with a highlighted entry.
- **Model change**: if the current size is in the new family's lists, keep it.
Otherwise pick, in order: the same chip's default entry in the new family;
the family default size. Log nothing; the header reflects the change.
- **Width/height edited directly**: panel re-derives; the header shows the
rounded effective size if the typed value is not on the family step.
Every width/height change goes through the widgets' callbacks exactly once,
as `ui/dimensions.js` already guarantees for the sibling nodes.
### Python execution
Inputs: `model`, `width`, `height`, `batch_size`.
Outputs: `LATENT` (`latent`), `INT` (`width`), `INT` (`height`).
1. Resolve the family record. The combo value **is the label**, because a
ComfyUI COMBO stores the displayed string in both renderers; labels are
therefore short and treated as stable ids (`SDXL`, `Illustrious XL`,
`Flux.1`, `Z-Image`, `Qwen-Image`, `Flux.2 / Klein / MOSS`,
`Qwen-Image 2.1`, `HunyuanImage 2.1`, `Anima`, `SD 1.5`, and the four
video rows suffixed ` (keyframes)`). The model list that a label covers
goes in the combo tooltip and the README, not in the label. Each record
carries an `aliases` list of former labels so a rename maps forward.
An unknown value falls back to a generic 8-pixel step with a logged
warning: it never re-rounds a saved size to another family's step.
2. Round `width`/`height` to the nearest multiple of the family step, never
below 64; log at info level when a value changed.
3. Build the latent with the existing
`empty_latent_batch.logic.create_empty_latent_batch` (4 channels, 8x,
carries `downscale_ratio_spacial: 8`). ComfyUI's sampler resizes it to the
family's real latent ratio because of that mark; for 16x and 32x families
the step rounding in step 2 is what keeps that resize exact.
4. Return `(latent, width, height)` as rounded.
The family table lives in Python
(`kikotools/tools/model_resolution_picker/families.py`) and is the single
source of truth. The frontend fetches it once from a GET route
`/kikotools/model_resolution/families` registered on `PromptServer` (the
project already registers routes for Local Image Loader) and caches it per
page. If the fetch fails the panel shows one status line and the native
widgets remain fully usable. **My call**; the alternative is duplicating the
table in JS, which is exactly the drift this node exists to avoid.
### Family table schema
```
id: stable string used as the combo value, e.g. "sdxl"
label: combo text, e.g. "SDXL / Pony"
vae_ratio: 8 | 16 | 32 (informational; the latent is always built at 8)
step: pixel multiple for rounding and custom sizes
megapixels: list of tiers for Custom ratio, in 1024² units
default: (w, h)
sizes: { chip: [(w, h), ...] } authored, mirrored pairs across chips
```
The `sizes` lists are **authored literals checked into Python**. For the
"generated" families they are produced once by a helper script using the
Resolution Selector formula and then hand-adjusted to include the official
examples named below (for instance 1920×1088 for Flux 16:9 does not fall out
of the formula; it is added by hand). The validator never regenerates them.
The live Custom-ratio list does use the formula, so it may offer a slightly
different size than the chip list for the same ratio; that is acceptable and
the header shows the exact size either way.
Python unit tests validate the table: every size is a multiple of `step`,
every size's ratio is within 7% of its chip, every landscape entry has its
portrait mirror under the mirrored chip, defaults are present in `sizes`,
labels and aliases are unique, and every "must include" size named in the
family table below is present.
## The families (v1)
Sources are listed per row. "Official list" means the sizes are copied; for
families whose guidance is "any size, multiples of N, within a megapixel
range", the sizes are generated with the Resolution Selector formula at the
stated tiers and step, then spot-checked against the official examples
named. 1 MP = 1,048,576 px.
| id | Label | VAE | Step | Tiers (MP) | Default | Sizes |
|---|---|---|---|---|---|---|
| `sd15` | SD 1.5 | 8 | 64 | 0.25, 0.5 | 512×512 | generated; 512-class, plus 768×768 |
| `sdxl` | SDXL / Pony | 8 | 64 | 1.0 | 1024×1024 | official 1 MP buckets: 1024², 1152×896, 1216×832, 1344×768, 1536×640 and mirrors; 4:5/5:4 as 832×1024 / 1024×832 |
| `illustrious` | Illustrious XL (v1+) | 8 | 32 | 1.0, 1.5, 2.25 | 1024×1024 | SDXL buckets plus the 1536 class from the v1.0 model card and paper: 1536², 1024×1536, 1248×1824, 1344×1792, 1152×2048 and mirrors |
| `anima` | Anima | 8 | 16 | 0.5, 1.0, 1.5 | 1024×1024 | generated within the documented 512–1536 range; Wan 2.1 latent format in ComfyUI |
| `flux1` | Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2 | 8 | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | generated; must include 1920×1088 / 1088×1920 (BFL's 16:9 example) |
| `zimage` | Z-Image Turbo / Base | 8 | 16 | 1.0, 1.6 | 1024×1024 | official 1024 and 1280 sets (11 sizes each) confirmed by Tongyi on the model's HF discussion #28 |
| `qwen_image` | Qwen-Image / 2512 | 8 | 16 | 1.76 | 1328×1328 | official training-stage list: 1328², 1664×928, 1472×1104, 1584×1056 and mirrors |
| `flux2` | Flux.2 dev / Klein, Flux.3 Klein, MOSS-Image, Ideogram 4 | 16 | 16 | 1.0, 2.0, 4.0 | 1024×1024 | generated; must include 1248×832 (Flux.2 template default) and 2048² (4 MP max) |
| `qwen_image_21` | Qwen-Image 2.1 / Turbo | 16 | 32 | 1.0, 4.0 | 1024×1024 | official 2K presets 2048², 2400×1792, 2528×1696, 2752×1536 and mirrors, plus generated 1 MP set (ComfyUI template default) |
| `hunyuan_image_21` | HunyuanImage 2.1 | 32 | 32 | 4.0 | 2048×2048 | official: 2048², 2560×1536, 2304×1792 and mirrors; 3:2/2:3 generated at 4 MP. No 1 MP tier: the card says 1K output artifacts |
Families named in one row share a VAE and step; the combo label lists them so
users find their model. Adding a family later is one Python record plus its
test row.
### Video families (frame sizes for first/last-frame generation)
These rows exist so a keyframe can be generated at the exact size the video
model will be run at. The combo groups them under a "Video (keyframes)"
suffix in the label. The latent output is still an 8x image latent, which is
what the keyframe image model needs; it is never fed to the video model.
| id | Label | Step | Tiers (MP) | Default | Sizes |
|---|---|---|---|---|---|
| `wan22` | Wan 2.2 (keyframes) | 16 | 0.4, 0.9 | 1280×720 | official 480p/720p classes: 832×480, 1280×720, 1280×704 (5B TI2V template), 640×640, 960×960 and mirrors |
| `ltx2` | LTX-2 (keyframes) | 32 | 0.4, 0.9 | 768×512 | template sizes 768×512, 960×544 (ComfyUI templates), plus generated at 0.9 MP with the 32 step the LTX templates use |
| `hunyuan_video_15` | HunyuanVideo 1.5 (keyframes) | 16 | 0.4, 0.9 | 1280×720 | generated; 480p and 720p classes |
| `minimax_h3` | MiniMax H3 (keyframes) | 32 | 0.4, 1.0 | 864×480 | generated at 0.4 MP, step 32, which is what every official H3 template sets on Resolution Selector; 1.0 MP tier covers H3-Base's 768p ceiling |
Video family sizes are validated by the same table tests. They carry no
frame-count data by design.
Deliberately out of v1: Ming Image, SenseNova U1.5 and other pixel-space
models (ratio 1, needs its own latent path), HunyuanImage 3.0 (no native
ComfyUI support), Flux.3 Image (API only).
## Decisions
- Default family on first add: **SDXL** at 1024×1024 (decided 2026-10-27).
Decided 2026-10-27: video families are in, as keyframe sizes (see above).
Builder-check calls made by the author, 2026-10-27: authored chip membership
wins over ratio tolerance (7%); combo value is the label with an alias list;
custom-ratio mode is an in-memory flag; size lists are authored literals;
manual 16x/32x generation check before merge.
## Verification before merge
The real-host CI never queues a generation, so the latent path is only
unit-tested. Core `EmptyLatentImage` is the same 4-channel, 8x, marked latent
this node emits, and the official Anima template already runs that path
(channel repeat 4 → 16). The two cases to confirm by hand on the user's GPU
box before merge, each with one queued image: a 16x family (Flux.2 or
Qwen-Image 2.1) and the 32x family (HunyuanImage 2.1) at a non-square size,
checking the saved image's dimensions equal the node's outputs. Record the
result in the PR.
## Constraints
- Node id `ModelResolutionPicker`, display name "Model Resolution Picker",
category `🫶 ComfyAssets/🖼️ Resolution`. Registered in
`kikotools/__init__.py` like the others.
- Frontend: `web/model_resolution_picker.js` extension plus a view under
`web/views/` built only from `web/ui/core.js` primitives and
`controls.css` tokens; styling added to `controls.css` under a new
`.ca-resolution-*` family. `createScope()` owns listeners, the fetch
signal and theme observers; node removal disposes it and preserves the
previous `onRemoved` hook's context and return value, as every migrated
node does.
- Swap reuses `ui/dimension-swap.js` for the button and `ui/dimensions.js`
for the swap; the latter gains support for nodes that have `width`/`height`
but no `preset` widget (today it returns early).
- Tests, per `TESTING.md` tiers: Python unit tests for the table validator,
rounding and execution (including the latent mark and the unknown-id
fallback); JS unit tests for derivation, ratio parsing, custom-ratio
generation and model-change selection; browser-fixture workflows for chip
→ widgets, list → widgets, swap → mirrored chip, model change, custom
ratio, wired-in dimensions, theme switch, idempotent mount and disposal;
real-host coverage of both renderers and workflow round-trip. Python
coverage stays above the 85% gate; new JS files get floors in
`tools/testing/frontend-coverage-baseline.json`.
- README gets a tools-table row and a section with a screenshot under
`docs/screenshots/`; `docs/ui-migration.md` gets a row.
- Version bump to 1.3.0 (new node), in the release PR, not this one.
- No external requests. The only HTTP call is the local families route.
## Left to the builder
- Exact chip grid markup (`createSegments` vs buttons with
`aria-pressed`), keyboard order, and how disabled chips announce
themselves.
- The generated-size algorithm's tie-breaking and the spot-check test list.
- Whether the families route also serves ETag/caching headers.
- Node default size fine-tuning and the status line's wording.
- File split inside `kikotools/tools/model_resolution_picker/` (`node.py`,
`logic.py`, `families.py`, `routes.py`).
## Addendum (2026-10-27, after first hands-on test): JSON table, user override, resolution dropdown
Decided with the owner after testing the first build. Three changes, same branch.
### 1. The family table is a shipped JSON file
`kikotools/tools/model_resolution_picker/families.json` replaces the Python
literals. Its shape is exactly the payload the route already serves:
```json
{ "version": 1, "chips": [...], "mirror": {...}, "default": "SDXL",
"families": [ { "label": "SDXL", "aliases": [], "models": "...", "group": "image",
"vaeRatio": 8, "step": 64, "megapixels": [1.0], "default": [1024, 1024],
"sizes": { "1:1": [[1024, 1024]], "4:5": [[832, 1024]], ... } } ] }
```
`families.py` loads it at import, validates it with a runtime validator that
enforces the same invariants the tests enforce today (step multiples, 7% chip
tolerance, exact mirrors, ascending order, one chip per size, unique labels
and aliases, default present, every chip key present), and builds the same
`Family` records. A shipped file that fails validation is a startup error.
The file is package data (`[tool.setuptools.package-data]`), and the git
checkout is what ComfyUI loads, so the file simply lives next to the module.
### 2. A user override file
`<ComfyUI user directory>/kikotools/model_resolutions.json` (via
`folder_paths.get_user_directory()`; when that is unavailable there is no
override). Same schema as the shipped file; top-level `chips`/`mirror` are
ignored if present. Merge rules, by `label`:
- same label as a shipped family → the override record **replaces** it whole;
- new label → appended after the shipped families, in file order;
- optional `"remove": ["label", ...]` hides shipped families;
- `default` in the override, if present, becomes the combo default.
The merged table is validated with the same validator. An unreadable or
invalid override is **ignored entirely** (never partially applied), logged at
warning level, and reported to the panel: the payload gains
`"warnings": ["Override ignored: <reason>"]` and the panel shows the first
warning in its status line. The override is re-read when its mtime changes,
so edits are picked up on a page reload (or ComfyUI's "Refresh node definitions"), which also refreshes the `model` dropdown, without a restart. The frontend caches the families payload per page.
The shipped file is never modified by the node.
### 3. A native `resolution` dropdown
A COMBO widget named `resolution` sits between `model` and `width`. It lists
the sizes that fit the **active ratio chip** for the selected model, plus
`custom` first; with `Custom ratio` active or no chip matched it lists every
size for the model. Labels use the old node's format:
`1216×832 - 3:2 (1.0MP)` (ratio label = the chip the size is listed under,
else the reduced ratio; megapixels to one decimal, 1 MP = 1024²).
Sync rules (the panel remains a projection of the native widgets):
- Picking from the dropdown writes `width`/`height` through their callbacks,
exactly like a size-list click.
- Any size change (chip list, swap, typed width/height, model change) sets
`resolution` to the matching label, or `custom` when the size is not in the
active list. The dropdown's option list is rebuilt whenever the model or the
active chip changes.
- Python: when `resolution` is not `custom` and parses as `W×H`, those
dimensions win over `width`/`height` (same precedence as the old preset
dropdown); then the family-step rounding applies as before. The combo's
Python option list is `custom` plus every distinct size label across all
shipped families, and `VALIDATE_INPUTS` accepts `resolution` as it does
`model`, so override-added sizes and old labels still execute.
- Workflows now serialize five native widgets; nothing else changes.
Non-goals unchanged. The chip grid, Custom ratio and Swap stay as built.
### 4. Ratio shape on each chip
Each ratio chip shows a small outlined box shaped like its ratio before the
label (wide for 16:9, tall for 9:16, square for 1:1). It is drawn with the
toolkit's own markup and tokens: a `<span class="ca-resolution-shape">`
sized from the ratio (longest side 18 px, shortest side at least 6 px,
rounded to whole pixels), `border: 1px solid currentColor`, with the active
chip's white text colour showing through. Purely decorative: `aria-hidden`,
no effect on the accessible name, hidden on `Custom ratio`. No Pixaroma
code or assets.
+48
View File
@@ -0,0 +1,48 @@
# Autocomplete tag packs
ComfyAssets prepares filtered autocomplete data from Danbooru's public
`tags.json` and `tag_aliases.json` APIs. Dataset releases are versioned separately
from KikoTools and stored as release assets, keeping bulk data out of Git history.
## Danbooru — Illustrious / NoobAI, 2026-10-16
[Download the CSV and manifest](https://github.com/ComfyAssets/ComfyUI-KikoTools/releases/tag/tags-2026-10-16).
This snapshot contains 165,158 canonical tags, their categories, post counts, and
aliases. The adjacent manifest records the retrieval date, model targets,
selection filters, schema, byte size, and SHA-256 checksum.
The UTF-8 CSV has no header. Columns are `tag,category,post_count,aliases`.
Aliases share a comma-separated field; use a CSV parser that respects quoting.
An empty alias field means no aliases. Category codes are 0 (general), 1
(artist), 3 (copyright), 4 (character), and 5 (meta).
In ComfyUI Settings, search **Embedding Autocomplete**, open its settings,
then download the pack and choose the CSV under **Tag list file**. Enable
**Show Custom Words** and select **Save Settings**. Importing stages the list;
Cancel leaves the active list unchanged. The browser caches the saved file in
IndexedDB for offline use and restores it after a refresh. Clearing browser site
data removes this cache. There is no server-side autocomplete folder to populate.
Alternatively, enter a CSV/TXT URL and select **Load URL**, then save. Remote
servers must allow browser cross-origin requests; for GitHub release assets,
download and import the file locally. Existing one-column word lists and
`tag,frequency` lists remain supported. The default URL is unchanged.
Parsing runs in a Web Worker with a 32 MB UTF-8 limit. Search checks canonical
names and aliases, inserts the canonical tag, and ranks exact matches ahead of
prefixes and substrings, then by descending post count. The number of displayed
results is bounded by Maximum suggestions. Categories are retained in records;
category-specific controls are not currently exposed.
## Source and terms
Source data: [Danbooru](https://danbooru.donmai.us/), collected 2026-10-16.
Selection and packaging: ComfyAssets. This is an independent filtered snapshot,
not an official Danbooru release or a guarantee of model compatibility.
Refer to [Danbooru's Terms of Service](https://danbooru.donmai.us/terms) and the
[official terms template](https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb).
The template's Copyright Infringement section identifies tags as factual
information that is generally not copyrightable. Its API Usage section requires
compliance with API rate limits. This reference does not claim an explicit
dataset license or extend KikoTools' software license to upstream data.
@@ -0,0 +1,35 @@
{
"source": "danbooru.donmai.us tags.json + tag_aliases.json",
"pulled": "2026-10-16",
"target_models": "Illustrious 1.0-3.x, NoobAI-XL",
"filters": {
"created_before": "2025-01-01",
"min_post_count": {
"general": 50,
"artist": 30,
"copyright": 20,
"character": 20
},
"artists_kept": true,
"deprecated_removed": true,
"meta_tags": "removed except small allowlist"
},
"tag_count": 165158,
"schema_version": 1,
"file": "danbooru-illustrious-noobai-2026-10-16.csv",
"encoding": "UTF-8",
"header_row": false,
"columns": [
"tag",
"category",
"post_count",
"aliases"
],
"aliases_format": "Comma-separated inside a CSV-quoted field when necessary",
"size_bytes": 3979593,
"sha256": "733531a60f2e2d38925f53381b18cff21513ef14550d1f2ca670a0f55b86b798",
"prepared_by": "ComfyAssets",
"terms_url": "https://danbooru.donmai.us/terms",
"terms_source_url": "https://github.com/danbooru/danbooru/blob/master/app/views/static/terms_of_service.html.erb",
"release_tag": "tags-2026-10-16"
}
+45
View File
@@ -0,0 +1,45 @@
# KikoTools UI migration
KikoTools' custom controls use the original ComfyAssets foundation in `web/ui/`.
They share blue primary actions, red destructive actions, keyboard controls and
ComfyUI-following dark/light colors. Layouts resize with the available space.
Pixaroma's toolkit inspired the organization; its code/assets are not vendored.
| Interface | Shared implementation | Retained examples |
| --- | --- | --- |
| Local Image Loader | `views/image-browser.js`, controls and scoped controller | [Loader](screenshots/live-image-loader/) |
| Save Image | Floating window, batch selection and responsive preview | [Viewer](screenshots/save-image-viewer/) |
| Seed History | `views/seed-history.js`, scoped watcher and disclosure | [History](screenshots/seed-history/) |
| Workflow Timer | Shared clock, controls and color picker | [Timer](screenshots/workflow-timer/) |
| Width Height / Empty Latent swaps | `ui/dimension-swap.js`, `ui/dimensions.js` | [Swaps](screenshots/dimension-swap/) |
| Model Resolution Picker | `views/resolution-picker.js`, `ui/resolution-picker.js`, shared swap | [Picker](screenshots/resolution-picker/) |
| Node Dimensions | `views/node-dimensions.js`, native dialog shell | [Dimensions](screenshots/node-dimensions/) |
| Display Text / Any | Shared output view, controller and widget adapter | [Output](screenshots/display-output/) |
| Embedding Autocomplete | Shared suggestions/settings, worker parsing and cache | [Autocomplete](screenshots/embedding-autocomplete/) |
| Custom Colors | Shared color picker, native node/group menus | [Colors](screenshots/custom-colors/) |
| Follow Execution | Native host menus/settings, idempotent execution listeners | [Menus](screenshots/follow-execution/) |
`createScope()` owns listeners, observers and delayed work. Node removal preserves
previous lifecycle hooks while disposing owned resources. Host-specific text
widget handling lives in `ui/text-widget-adapter.js`; floating windows and native
dialogs manage their own focus and cleanup. See [module notes](../web/ui/README.md).
Pure Python nodes continue using ComfyUI's native widgets. Gemini Prompt
Engineer retains its legacy UI for saved workflows under the agreed deprecation;
its UI migration was cancelled.
The [test guide](../TESTING.md) distinguishes fixture coverage, real-host
compatibility and model/GPU behavior. Required real-host CI uses a reviewed
upstream commit, fresh Classic/Nodes 2.0 starts and disposable CPU-only state.
## Remaining shared-toolset work
Before PromptManager adopts the foundation, extract a versioned shared source
package with reproducible browser artifacts. Then test both node packs alone and
together, including different bundled foundation versions, isolated settings
and identical styling. These requirements remain open in issue #81; the KikoTools
migration does not establish cross-pack compatibility by itself.
The approved design-preview draft [#82](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
records the original direction. This inventory describes the shipped interfaces
and supersedes that draft's implementation sequence.
+15 -4
View File
@@ -1,4 +1,15 @@
# Gemini Prompt Engineer # Gemini Prompt Engineer (Deprecated)
**Legacy documentation for existing workflows.** This node is soft-deprecated
and hidden from normal new-node discovery through ComfyUI's deprecated-node
metadata. Users who enable deprecated-node visibility may still find it.
The `GeminiPrompt` registration, display name, input/output schema and execution
remain available for saved workflows. Existing configuration and model-refresh
behavior remain in place. Google API/model compatibility updates and UI migration
have ended; the historical model and setup details below may no longer match
Google's current service. No replacement or automatic workflow conversion is
provided.
The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models. The Gemini Prompt Engineer node uses Google's Gemini AI to analyze images and generate optimized prompts for various AI image generation models.
@@ -21,7 +32,7 @@ Get your free Gemini API key from [Google AI Studio](https://makersuite.google.c
### 2. Install Dependencies ### 2. Install Dependencies
```bash ```bash
pip install google-generativeai pip install google-genai
``` ```
### 3. Configure API Key ### 3. Configure API Key
@@ -204,6 +215,6 @@ Errors are displayed in the prompt output for easy debugging.
- Verify API key is valid - Verify API key is valid
- Image might be too large (resize if needed) - Image might be too large (resize if needed)
**Import error for google-generativeai**: **Import error for google-genai**:
- Run `pip install google-generativeai` in your ComfyUI environment - Run `pip install google-genai` in your ComfyUI environment
- Restart ComfyUI after installation - Restart ComfyUI after installation
@@ -0,0 +1,190 @@
# Model Resolution Picker Documentation
## Overview
The Model Resolution Picker is a ComfyUI node that picks a model family and a size for it, then outputs an empty latent plus the width and height. A chip grid of aspect ratios and a size list under the active chip replace hunting through a long preset dropdown. The sizes come from each family's official lists or from megapixel tiers rounded to the family's grid step.
![Model Resolution Picker](../../docs/screenshots/resolution-picker/ModelResolutionPicker-dark.png)
A light-theme capture is at `docs/screenshots/resolution-picker/ModelResolutionPicker-light.png`.
## Node Interface
- **Node id**: `ModelResolutionPicker`
- **Display name**: Model Resolution Picker
- **Category**: `🫶 ComfyAssets/🖼️ Resolution`
### Inputs
- **model**: Dropdown of the 14 shipped family labels (default `SDXL`)
- **resolution**: Dropdown of `custom` plus size labels such as `1216×832 - 3:2 (1.0MP)` (default `custom`); see [Resolution dropdown](#resolution-dropdown)
- **width**: Width in pixels (64-8192, step 8)
- **height**: Height in pixels (64-8192, step 8)
- **batch_size**: Number of empty latents (1-64)
### Outputs
- **latent**: Empty latent batch at the rounded size
- **width**: Rounded width (INT)
- **height**: Rounded height (INT)
A `resolution` label other than `custom` that parses as `W×H` takes precedence over width and height; `custom` or an unparseable label uses width and height. Width and height are rounded to the selected family's step at execution and never go below 64. An unknown family label (for example from an old workflow) falls back to step 8 and logs a warning.
The latent is the shared 4-channel 8x empty latent. It carries `downscale_ratio_spacial: 8`, which lets ComfyUI resize it for 16x and 32x VAEs, so the same output works with every family below.
The frontend reads the family table from `GET /kikotools/model_resolution/families`. The response carries the table, plus a `warnings` list that is empty unless an override file was ignored.
## Model Families
| Label | Models covered | VAE ratio | Step | Megapixel tiers | Default size | Group |
|---|---|---|---|---|---|---|
| SD 1.5 | SD 1.5 and SD 1.5 finetunes | 8x | 64 | 0.25, 0.5 | 512×512 | image |
| SDXL | SDXL, Pony and other SDXL finetunes | 8x | 64 | 1.0 | 1024×1024 | image |
| Illustrious XL | Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes | 8x | 32 | 1.0, 1.5, 2.25 | 1024×1024 | image |
| Anima | Anima | 8x | 16 | 0.5, 1.0, 1.5 | 1024×1024 | image |
| Flux.1 | Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2 | 8x | 16 | 0.5, 1.0, 1.5, 2.0 | 1024×1024 | image |
| Z-Image | Z-Image Turbo and Z-Image Base | 8x | 16 | 1.0, 1.6 | 1024×1024 | image |
| Qwen-Image | Qwen-Image and Qwen-Image 2512 | 8x | 16 | 1.76 | 1328×1328 | image |
| Flux.2 / Klein / MOSS | Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4 | 16x | 16 | 1.0, 2.0, 4.0 | 1024×1024 | image |
| Qwen-Image 2.1 | Qwen-Image 2.1 and 2.1 Turbo | 16x | 32 | 1.0, 4.0 | 1024×1024 | image |
| HunyuanImage 2.1 | HunyuanImage 2.1 | 32x | 32 | 4.0 | 2048×2048 | image |
| Wan 2.2 (keyframes) | Wan 2.2 (first/last-frame sizes) | 16x | 16 | 0.4, 0.9 | 1280×720 | video |
| LTX-2 (keyframes) | LTX-2 (first/last-frame sizes) | 32x | 32 | 0.4, 0.9 | 768×512 | video |
| HunyuanVideo 1.5 (keyframes) | HunyuanVideo 1.5 (first/last-frame sizes) | 16x | 16 | 0.4, 0.9 | 1280×720 | video |
| MiniMax H3 (keyframes) | MiniMax H3 (first/last-frame sizes) | 16x | 32 | 0.4, 1.0 | 864×480 | video |
The family steps and megapixel tiers are the grid the node rounds to and the tiers custom ratios use. Video families are suffixed `(keyframes)`; see the keyframe note below.
## Family Table File
The families live in `kikotools/tools/model_resolution_picker/families.json`, loaded and checked by `table.py` when ComfyUI starts. A shipped file that fails the check is a startup error. Top-level keys:
- `version`: must be `1`
- `chips`: exactly the 11 chips listed above, in that order
- `mirror`: pairs every chip with its mirrored chip (`4:5` with `5:4`, `1:1` with itself)
- `default`: label or alias of the family selected by default
- `families`: a non-empty list of family objects
Each family object has:
- `label`: unique name shown in the dropdown; `aliases` (optional list) are old names that still resolve to it
- `models`: description string; `notes` (optional string) is ignored by the node
- `group`: `image` or `video`
- `vaeRatio`: `8`, `16` or `32`
- `step`: positive integer grid step, a multiple of 8 (at most 4096), so rounded sizes land on the 8-pixel latent grid
- `megapixels`: non-empty list of tiers used by custom ratios (each above 0, at most 64)
- `default`: a `[width, height]` pair that appears in `sizes`
- `sizes`: an object with one key per chip, each a list of `[width, height]` pairs in ascending pixel order (an empty list disables the chip)
Every size must be a multiple of `step`, within 7% of its chip's ratio, listed under one chip only, and have its swapped size under the mirrored chip. `validate_table` returns every problem as text and never raises.
## User Override File
To change sizes without editing the shipped file, create `model_resolutions.json` in a `kikotools` folder inside ComfyUI's user directory (`folder_paths.get_user_directory()`; typically `ComfyUI/user/kikotools/model_resolutions.json`). It uses the same schema as the shipped file, and any top-level `chips` or `mirror` are ignored. Rules:
- Families are matched by `label`. A family with a shipped label replaces that family whole; a new label is appended after the shipped ones, in file order.
- `remove` is a list of labels that deletes shipped families. It wins over `families` when both name the same label.
- `default` is an optional label or alias that becomes the default family; it must name a family that survives the merge.
- The file is read again when its modification time changes, so a page reload (or ComfyUI's "Refresh node definitions") picks up edits, including the `model` dropdown, without restarting ComfyUI.
- The shipped file is never modified.
This example replaces SDXL with a shorter list, adds a new family, hides two shipped ones and makes the new family the default:
```json
{
"version": 1,
"default": "My SDXL",
"remove": ["SD 1.5", "Anima"],
"families": [
{
"label": "SDXL",
"aliases": [],
"models": "SDXL with a trimmed size list",
"group": "image",
"vaeRatio": 8,
"step": 64,
"megapixels": [1.0],
"default": [1024, 1024],
"sizes": {
"1:1": [[1024, 1024]],
"4:5": [[832, 1024]],
"5:4": [[1024, 832]],
"3:4": [], "4:3": [], "2:3": [], "3:2": [],
"9:16": [], "16:9": [], "21:9": [], "9:21": []
}
},
{
"label": "My SDXL",
"aliases": ["my-sdxl"],
"models": "My own SDXL finetune",
"group": "image",
"vaeRatio": 8,
"step": 64,
"megapixels": [1.0],
"default": [1152, 896],
"sizes": {
"1:1": [[1024, 1024]],
"4:5": [[896, 1152]], "5:4": [[1152, 896]],
"3:4": [], "4:3": [], "2:3": [], "3:2": [],
"9:16": [], "16:9": [], "21:9": [], "9:21": []
}
}
]
}
```
The merged table is checked with the same `validate_table`. If the file is unreadable, is not valid JSON, nests too deeply, or fails the check, the whole file is ignored and the shipped table is used. ComfyUI logs a warning `Override ignored: <path>: <reason>`, the same text is returned in the `warnings` list of `GET /kikotools/model_resolution/families`, and the panel's status line shows the first warning. Fix the file and reload to clear it.
## How to Use
### Chips and size list
1. **Pick a model** in the dropdown.
2. **Click a ratio chip** (`1:1 4:5 5:4 3:4 4:3 2:3 3:2 9:16 16:9 21:9 9:21`; each shows a small outline of its shape). The size list under the active chip shows that family's sizes for the ratio.
3. **Click a size** to write it to width and height. The header shows `W × H · ratio · MP` for the current values.
A chip is disabled when the family lists no sizes for that ratio.
### Resolution dropdown
The native `resolution` dropdown sits between `model` and `width` and mirrors the panel. Its options are `custom` plus every size label from the shipped families (sorted as text), written `{w}×{h} - {ratio} ({mp}MP)` with 1 MP = 1,048,576 pixels, for example `1216×832 - 3:2 (1.0MP)`.
- In the UI the dropdown lists the sizes of the active ratio chip, or every size of the family when `Custom ratio` is active or no chip matches.
- Picking a label writes width and height and activates the matching chip.
- Clicking a chip or a size, or swapping, sets the label to match.
- Typing a size that is not in the list, wiring width or height, a families table that is still loading, or a failed load shows `custom`.
- On execution a non-`custom` label that parses wins over width and height; `custom` or an unparseable label uses width and height. Labels that only an override file adds are accepted too.
### Swap
The Swap W×H button (the same control Width Height Selector and Empty Latent Batch use) exchanges width and height and lands on the mirrored chip, for example 3:4 becomes 4:3.
### Changing the model
Switching the model keeps the current size if the new family lists it. Otherwise it falls back to the active chip's default size in the new family, then to the family default.
### Custom ratio
Choose the `Custom ratio` chip and type a ratio such as `16:9`. The panel computes sizes at the family's megapixel tiers, rounded to the family's step. Bad input is rejected with "Enter a ratio like 16:9".
### Custom resolution
Type width and height directly in the native widgets. The panel re-derives its chip, list and header from those values. They are rounded to the family's step when the node runs.
### Wired-in width or height
When width or height is driven by another node, the panel and Swap are disabled.
### Sizes unavailable
If the families route fails, the panel shows "Sizes unavailable; type width and height" with the chips disabled. The node still works through the native widgets.
Panel state is derived from the native widgets, so nothing extra is saved in the workflow. New nodes are 340 px wide and the node height follows the panel content: it is re-fitted whenever the list changes (a chip click, a model change, opening Custom ratio), so there is no empty space under the list. A saved workflow keeps its width; the height is fitted again on load.
## Keyframe Note for Video Families
`Wan 2.2`, `LTX-2`, `HunyuanVideo 1.5` and `MiniMax H3` (all suffixed `(keyframes)`) are listed for first/last-frame generation at the video model's frame size. The latent output is still an image latent and is never fed to a video model. The image latent decodes to the listed size when the keyframe image model's VAE ratio divides the video family's step (for example an 8x or 16x image model for Wan 2.2's 1280×720; a 32x model would not land on 720).
## Manual GPU Check Before Merge
The real-host CI never queues a generation, so the latent path is only unit-tested. Core `EmptyLatentImage` is the same 4-channel, 8x, marked latent this node emits, and the official Anima template already runs that path (channel repeat 4 → 16). Confirm two cases by hand on a GPU box. Each is one queued image through this node → KSampler → VAE Decode → Save:
1. `Flux.2 / Klein / MOSS` (16x) at 1248×832.
2. `HunyuanImage 2.1` (32x) at 2560×1536.
For each case, the saved PNG's dimensions must equal the node's width and height outputs. Record both results in the PR description.
## Related Nodes
- [Width Height Selector](width_height_selector.md): preset-based dimensions with swap
- [Empty Latent Batch](empty_latent_batch.md): empty latent batches with presets
+26
View File
@@ -3,7 +3,14 @@ KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools Handles automatic discovery and registration of all ComfyAssets tools
""" """
from .tools.batch_list_converter import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
from .tools.batch_prompts import BatchPromptsNode from .tools.batch_prompts import BatchPromptsNode
from .tools.checkpoint_folder_iterator import CheckpointFolderIteratorNode
from .tools.display_any import DisplayAnyNode from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode from .tools.display_text import DisplayTextNode
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
@@ -16,7 +23,10 @@ from .tools.kiko_purge_vram import KikoPurgeVRAM
from .tools.kiko_workflow_timer import KikoWorkflowTimerNode from .tools.kiko_workflow_timer import KikoWorkflowTimerNode
from .tools.kiko_save_image import KikoSaveImageNode from .tools.kiko_save_image import KikoSaveImageNode
from .tools.local_image_loader import LocalImageLoaderNode from .tools.local_image_loader import LocalImageLoaderNode
from .tools.project_file_path import ProjectFilePathNode
from .tools.model_compare_sheet import ModelCompareSheetNode
from .tools.model_downloader import ModelDownloaderNode from .tools.model_downloader import ModelDownloaderNode
from .tools.model_resolution_picker import ModelResolutionPickerNode
from .tools.resolution_calculator import ResolutionCalculatorNode from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
from .tools.seed_history import SeedHistoryNode from .tools.seed_history import SeedHistoryNode
@@ -34,6 +44,10 @@ from .tools.xyz_helpers import (
# ComfyUI node registration mappings # ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
"BatchPrompts": BatchPromptsNode, "BatchPrompts": BatchPromptsNode,
"ResolutionCalculator": ResolutionCalculatorNode, "ResolutionCalculator": ResolutionCalculatorNode,
"WidthHeightSelector": WidthHeightSelectorNode, "WidthHeightSelector": WidthHeightSelectorNode,
@@ -41,6 +55,7 @@ NODE_CLASS_MAPPINGS = {
"SamplerCombo": SamplerComboNode, "SamplerCombo": SamplerComboNode,
"SamplerComboCompact": SamplerComboCompactNode, "SamplerComboCompact": SamplerComboCompactNode,
"EmptyLatentBatch": EmptyLatentBatchNode, "EmptyLatentBatch": EmptyLatentBatchNode,
"ModelResolutionPicker": ModelResolutionPickerNode,
"KikoSaveImage": KikoSaveImageNode, "KikoSaveImage": KikoSaveImageNode,
"ImageToMultipleOf": ImageToMultipleOfNode, "ImageToMultipleOf": ImageToMultipleOfNode,
"ImageScaleDownBy": ImageScaleDownByNode, "ImageScaleDownBy": ImageScaleDownByNode,
@@ -58,13 +73,20 @@ NODE_CLASS_MAPPINGS = {
"FluxSamplerParams": FluxSamplerParamsNode, "FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode, "PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode, "LoRAFolderBatch": LoRAFolderBatchNode,
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
"ModelCompareSheet": ModelCompareSheetNode,
"WidthHeightToVec2": WidthHeightToVec2Node, "WidthHeightToVec2": WidthHeightToVec2Node,
"KikoWorkflowTimer": KikoWorkflowTimerNode, "KikoWorkflowTimer": KikoWorkflowTimerNode,
"KikoProjectFilePath": ProjectFilePathNode,
# Note: KikoEmbeddingAutocomplete is not registered as a node # Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu # It's a settings-only feature accessed through ComfyUI settings menu
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
"BatchPrompts": "Batch Prompts", "BatchPrompts": "Batch Prompts",
"ResolutionCalculator": "Resolution Calculator", "ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector", "WidthHeightSelector": "Width Height Selector",
@@ -72,6 +94,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SamplerCombo": "Sampler Combo", "SamplerCombo": "Sampler Combo",
"SamplerComboCompact": "Sampler Combo (Compact)", "SamplerComboCompact": "Sampler Combo (Compact)",
"EmptyLatentBatch": "Empty Latent Batch", "EmptyLatentBatch": "Empty Latent Batch",
"ModelResolutionPicker": "Model Resolution Picker",
"KikoSaveImage": "Kiko Save Image", "KikoSaveImage": "Kiko Save Image",
"ImageToMultipleOf": "Image to Multiple of", "ImageToMultipleOf": "Image to Multiple of",
"ImageScaleDownBy": "Image Scale Down By", "ImageScaleDownBy": "Image Scale Down By",
@@ -89,8 +112,11 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FluxSamplerParams": "Flux Sampler Parameters", "FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters", "PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch", "LoRAFolderBatch": "LoRA Folder Batch",
"CheckpointFolderIterator": "Checkpoint Folder Iterator",
"ModelCompareSheet": "Model Compare Sheet",
"WidthHeightToVec2": "Width Height to VEC2", "WidthHeightToVec2": "Width Height to VEC2",
"KikoWorkflowTimer": "Workflow Timer", "KikoWorkflowTimer": "Workflow Timer",
"KikoProjectFilePath": "Project File Path",
# KikoEmbeddingAutocomplete removed - settings only, not a node # KikoEmbeddingAutocomplete removed - settings only, not a node
} }
+106
View File
@@ -0,0 +1,106 @@
"""Scan ComfyUI model folders (loras, checkpoints, ...) for .safetensors files."""
import logging
import os
import re
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
def get_model_roots(folder_key: str) -> List[str]:
"""Return every configured root directory for a folder_paths key."""
try:
import folder_paths
entry = folder_paths.folder_names_and_paths.get(folder_key)
return list(entry[0]) if entry else []
except (ImportError, AttributeError, IndexError, TypeError):
return []
def is_within(path: str, root: str) -> bool:
"""True if realpath `path` equals or is inside realpath `root`."""
try:
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
return os.path.commonpath([real_path, real_root]) == real_root
except ValueError: # different drives or mixed absolute/relative
return False
def resolve_scan_targets(
folder_path: str, folder_key: str
) -> List[Tuple[str, Optional[str]]]:
"""
Resolve a folder setting to (directory, base_root) pairs to scan.
base_root is the model root the directory lives in, or None when the
directory is an absolute path outside every known root.
"""
roots = get_model_roots(folder_key)
if os.path.isabs(folder_path):
base = next((r for r in roots if is_within(folder_path, r)), None)
return [(folder_path, base)]
targets = []
for root in roots:
candidate = os.path.join(root, folder_path)
if not is_within(candidate, root):
logger.warning(f"Folder escapes the {folder_key} directory: {folder_path}")
continue
targets.append((candidate, root))
return targets
def scan_model_entries(folder_path: str, folder_key: str) -> List[Tuple[str, str]]:
"""Return naturally sorted (relative_name, absolute_path) for every .safetensors."""
entries: Dict[str, str] = {}
for directory, base in resolve_scan_targets(folder_path, folder_key):
if not os.path.isdir(directory):
continue
for root, _, files in os.walk(directory):
for file in files:
if not file.endswith(".safetensors"):
continue
full = os.path.join(root, file)
try:
rel = os.path.relpath(full, base or directory)
except ValueError:
rel = os.path.relpath(full, directory)
entries.setdefault(rel.replace("\\", "/"), full)
return [(name, entries[name]) for name in natural_sort(list(entries))]
def natural_sort(items: List[str]) -> List[str]:
"""Sort strings so embedded numbers compare numerically (v2 < v10)."""
def natural_key(text):
parts = []
for part in re.split(r"(\d+)", text):
if not part:
continue
parts.append((0, int(part)) if part.isdigit() else (1, part.lower()))
return parts
return sorted(items, key=natural_key)
def filter_by_pattern(
names: List[str], include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""Keep names matching include_pattern (if set) and not exclude_pattern (if set)."""
filtered = list(names)
if include_pattern:
try:
include_re = re.compile(include_pattern)
filtered = [f for f in filtered if include_re.search(f)]
except re.error as e:
logger.error(f"Invalid include pattern: {e}")
if exclude_pattern:
try:
exclude_re = re.compile(exclude_pattern)
filtered = [f for f in filtered if not exclude_re.search(f)]
except re.error as e:
logger.error(f"Invalid exclude pattern: {e}")
return filtered
-95
View File
@@ -1,95 +0,0 @@
"""Tool registry for KikoTools.
This module provides the central registration system for all KikoTools nodes.
"""
import importlib
from typing import Dict, Any
from pathlib import Path
class ToolRegistry:
"""Central registry for all KikoTools."""
def __init__(self):
self.tools: Dict[str, Any] = {}
self.node_classes: Dict[str, Any] = {}
def register_tool(self, tool_name: str, node_class: Any) -> None:
"""Register a tool and its node class.
Args:
tool_name: Name of the tool
node_class: The ComfyUI node class
"""
self.tools[tool_name] = node_class
# Also register by class name for ComfyUI
class_name = node_class.__name__
self.node_classes[class_name] = node_class
def discover_tools(self) -> None:
"""Automatically discover and load all tools in the tools directory."""
tools_dir = Path(__file__).parent.parent / "tools"
if not tools_dir.exists():
return
for tool_dir in tools_dir.iterdir():
if tool_dir.is_dir() and not tool_dir.name.startswith("_"):
self._load_tool(tool_dir.name)
def _load_tool(self, tool_name: str) -> None:
"""Load a single tool module.
Args:
tool_name: Name of the tool directory
"""
try:
# Try to import the tool's node module
module = importlib.import_module(f"kikotools.tools.{tool_name}.node")
# Look for node classes (classes with ComfyUI node attributes)
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and hasattr(attr, "INPUT_TYPES")
and hasattr(attr, "FUNCTION")
):
self.register_tool(tool_name, attr)
# If the tool has settings, register them
if hasattr(attr, "SETTINGS"):
from .settings import settings_registry
settings_registry.register_tool_settings(
tool_name,
getattr(
attr,
"DISPLAY_NAME",
tool_name.replace("_", " ").title(),
),
attr.SETTINGS,
)
except ImportError:
# Tool might not have a node.py file yet
pass
def get_node_class_mappings(self) -> Dict[str, Any]:
"""Get node class mappings for ComfyUI registration."""
return self.node_classes.copy()
def get_node_display_name_mappings(self) -> Dict[str, str]:
"""Get display name mappings for ComfyUI."""
mappings = {}
for class_name, node_class in self.node_classes.items():
if hasattr(node_class, "DISPLAY_NAME"):
mappings[class_name] = node_class.DISPLAY_NAME
else:
# Generate a display name from class name
mappings[class_name] = class_name.replace("Kiko", "").replace(
"Node", ""
)
return mappings
-201
View File
@@ -1,201 +0,0 @@
"""Settings registry for KikoTools.
This module provides a centralized settings management system for all KikoTools.
Tools can register their settings, which are then exposed in ComfyUI's settings UI.
"""
import json
import os
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass, field
@dataclass
class SettingDefinition:
"""Definition of a single setting."""
id: str
name: str
type: str # "boolean", "combo", "number", "string", "custom"
default: Any
description: Optional[str] = None
options: Optional[Union[List[Any], Dict[str, Any]]] = None
min_value: Optional[float] = None
max_value: Optional[float] = None
step: Optional[float] = None
on_change: Optional[str] = None # JavaScript callback as string
@dataclass
class ToolSettings:
"""Settings collection for a single tool."""
tool_name: str
display_name: str
settings: List[SettingDefinition] = field(default_factory=list)
class SettingsRegistry:
"""Central registry for all KikoTools settings."""
def __init__(self):
self.tools: Dict[str, ToolSettings] = {}
self.settings_by_id: Dict[str, SettingDefinition] = {}
def register_tool_settings(
self, tool_name: str, display_name: str, settings: Dict[str, Dict[str, Any]]
) -> None:
"""Register settings for a tool.
Args:
tool_name: Internal tool identifier (e.g., "embedding_autocomplete")
display_name: Display name for the tool (e.g., "Embedding Autocomplete")
settings: Dictionary of setting configurations
{
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable embedding autocomplete"
},
"max_suggestions": {
"type": "combo",
"default": 20,
"options": [10, 20, 50],
"description": "Maximum number of suggestions"
}
}
"""
tool_settings = ToolSettings(tool_name, display_name)
for setting_key, config in settings.items():
# Generate fully qualified setting ID
setting_id = f"kikotools.{tool_name}.{setting_key}"
# Create display name with branding
setting_name = f"🫶 {display_name}: {setting_key.replace('_', ' ').title()}"
setting_def = SettingDefinition(
id=setting_id,
name=setting_name,
type=config.get("type", "string"),
default=config.get("default"),
description=config.get("description"),
options=config.get("options"),
min_value=config.get("min"),
max_value=config.get("max"),
step=config.get("step"),
on_change=config.get("on_change"),
)
tool_settings.settings.append(setting_def)
self.settings_by_id[setting_id] = setting_def
self.tools[tool_name] = tool_settings
def get_setting(self, setting_id: str) -> Optional[SettingDefinition]:
"""Get a setting definition by ID."""
return self.settings_by_id.get(setting_id)
def get_tool_settings(self, tool_name: str) -> Optional[ToolSettings]:
"""Get all settings for a tool."""
return self.tools.get(tool_name)
def generate_frontend_registration(self) -> str:
"""Generate JavaScript code for frontend settings registration."""
js_lines = [
"// Auto-generated KikoTools settings registration",
"// This file is automatically generated by the settings registry",
"",
"import { app } from '../../scripts/app.js';",
"",
"app.registerExtension({",
" name: 'kikotools.settings',",
" async init() {",
" // Register all KikoTools settings",
]
for tool_name, tool_settings in self.tools.items():
js_lines.append(f" // {tool_settings.display_name} settings")
for setting in tool_settings.settings:
js_lines.append(" app.ui.settings.addSetting({")
js_lines.append(f' id: "{setting.id}",')
js_lines.append(f' name: "{setting.name}",')
js_lines.append(
f" defaultValue: {self._js_value(setting.default)},"
)
js_lines.append(f' type: "{setting.type}",')
if setting.description:
js_lines.append(f' tooltip: "{setting.description}",')
if setting.type == "combo" and setting.options:
js_lines.append(" options: (value) => {")
js_lines.append(
f" const options = {json.dumps(setting.options)};"
)
js_lines.append(" return options.map(opt => ({")
js_lines.append(" value: opt,")
js_lines.append(" text: String(opt),")
js_lines.append(" selected: opt === value")
js_lines.append(" }));")
js_lines.append(" }},")
if setting.type == "number":
if setting.min_value is not None:
js_lines.append(f" min: {setting.min_value},")
if setting.max_value is not None:
js_lines.append(f" max: {setting.max_value},")
if setting.step is not None:
js_lines.append(f" step: {setting.step},")
if setting.on_change:
js_lines.append(" onChange(value) {")
js_lines.append(f" {setting.on_change}")
js_lines.append(" }")
js_lines.append(" }});")
js_lines.append("")
js_lines.extend([" }", "});", ""])
return "\n".join(js_lines)
def _js_value(self, value: Any) -> str:
"""Convert Python value to JavaScript literal."""
if isinstance(value, bool):
return "true" if value else "false"
elif isinstance(value, str):
return f'"{value}"'
elif value is None:
return "null"
else:
return str(value)
def save_frontend_settings(
self, output_path: str = "web/js/kikoSettings.js"
) -> None:
"""Save the generated frontend settings to a file."""
js_content = self.generate_frontend_registration()
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w") as f:
f.write(js_content)
def get_all_settings(self) -> Dict[str, Any]:
"""Get all registered settings as a dictionary."""
result = {}
for tool_name, tool_settings in self.tools.items():
result[tool_name] = {
"display_name": tool_settings.display_name,
"settings": {
setting.id.split(".")[-1]: {
"type": setting.type,
"default": setting.default,
"description": setting.description,
"options": setting.options,
}
for setting in tool_settings.settings
},
}
return result
@@ -0,0 +1,15 @@
"""Batch/List conversion tool for ComfyUI."""
from .node import (
ImageBatchToImageListNode,
ImageListToImageBatchNode,
LatentBatchToLatentListNode,
LatentListToLatentBatchNode,
)
__all__ = [
"ImageBatchToImageListNode",
"ImageListToImageBatchNode",
"LatentBatchToLatentListNode",
"LatentListToLatentBatchNode",
]
@@ -0,0 +1,55 @@
"""Pure tensor split/join functions for batch-list conversions."""
import torch
from typing import Dict, List
def split_image_batch(images: torch.Tensor) -> List[torch.Tensor]:
"""Split [B,H,W,C] image batch into list of [1,H,W,C] tensors."""
return [images[i : i + 1] for i in range(images.shape[0])]
def join_image_batch(image_list: List[torch.Tensor]) -> torch.Tensor:
"""Join list of image tensors into single [B,H,W,C] batch."""
return torch.cat(image_list, dim=0)
def split_latent_batch(
latent: Dict[str, torch.Tensor],
) -> List[Dict[str, torch.Tensor]]:
"""Split latent dict into list of single-item latent dicts.
Preserves all keys (e.g. noise_mask, batch_index). Tensor values whose
first dimension matches the batch size of ``samples`` are sliced along
dim-0; all other values are copied as-is to every item.
"""
samples = latent["samples"]
batch_size = samples.shape[0]
result: List[Dict[str, torch.Tensor]] = []
for i in range(batch_size):
item: Dict[str, torch.Tensor] = {}
for key, value in latent.items():
if isinstance(value, torch.Tensor) and value.shape[0] == batch_size:
item[key] = value[i : i + 1]
else:
item[key] = value
result.append(item)
return result
def join_latent_batch(
latent_list: List[Dict[str, torch.Tensor]],
) -> Dict[str, torch.Tensor]:
"""Join list of latent dicts into single batched latent dict.
Tensor values that were sliced during split are concatenated along dim-0.
Non-tensor values are taken from the first item.
"""
result: Dict[str, torch.Tensor] = {}
first = latent_list[0]
for key in first:
if isinstance(first[key], torch.Tensor):
result[key] = torch.cat([lat[key] for lat in latent_list], dim=0)
else:
result[key] = first[key]
return result
@@ -0,0 +1,130 @@
"""Batch/List conversion nodes for ComfyUI."""
from typing import Dict, List, Tuple
import torch
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
split_image_batch,
join_image_batch,
split_latent_batch,
join_latent_batch,
)
class ImageBatchToImageListNode(ComfyAssetsBaseNode):
"""Split an IMAGE batch [B,H,W,C] into a list of individual images."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
image_list = split_image_batch(images)
count = len(image_list)
self.log_info(f"Split image batch of {count} into list")
return (image_list, count)
class ImageListToImageBatchNode(ComfyAssetsBaseNode):
"""Join a list of IMAGE tensors into a single batched IMAGE [B,H,W,C]."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
}
}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(self, images: List[torch.Tensor]) -> Tuple[torch.Tensor, int]:
batch = join_image_batch(images)
count = batch.shape[0]
self.log_info(f"Joined {count} images into batch")
return (batch, count)
class LatentBatchToLatentListNode(ComfyAssetsBaseNode):
"""Split a LATENT batch into a list of individual latent dicts."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latents", "count")
OUTPUT_IS_LIST = (True, False)
FUNCTION = "split_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def split_batch(
self, latent: Dict[str, torch.Tensor]
) -> Tuple[List[Dict[str, torch.Tensor]], int]:
latent_list = split_latent_batch(latent)
count = len(latent_list)
self.log_info(f"Split latent batch of {count} into list")
return (latent_list, count)
class LatentListToLatentBatchNode(ComfyAssetsBaseNode):
"""Join a list of LATENT dicts into a single batched LATENT."""
INPUT_IS_LIST = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"latents": ("LATENT",),
}
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latent", "count")
FUNCTION = "join_batch"
CATEGORY = "🫶 ComfyAssets/📦 Latents"
def join_batch(
self, latents: List[Dict[str, torch.Tensor]]
) -> Tuple[Dict[str, torch.Tensor], int]:
batch = join_latent_batch(latents)
count = batch["samples"].shape[0]
self.log_info(f"Joined {count} latents into batch")
return (batch, count)
NODE_CLASS_MAPPINGS = {
"ImageBatchToImageList": ImageBatchToImageListNode,
"ImageListToImageBatch": ImageListToImageBatchNode,
"LatentBatchToLatentList": LatentBatchToLatentListNode,
"LatentListToLatentBatch": LatentListToLatentBatchNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
}
@@ -1,72 +0,0 @@
"""Simple Batch Prompts node for ComfyUI - debugging version."""
import os
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
load_prompts_from_file,
get_prompt_at_index,
split_prompt_into_positive_negative,
)
# Global counter that persists across all executions
GLOBAL_COUNTER = {"count": 0}
class SimpleBatchPromptsNode(ComfyAssetsBaseNode):
"""
Simplified Batch Prompts node for debugging.
Uses a global counter to ensure prompts change.
"""
@classmethod
def INPUT_TYPES(cls):
"""Define the input types for the ComfyUI node."""
return {
"required": {
"prompt_file": ("STRING", {"default": "prompts.txt"}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("positive", "negative", "index")
FUNCTION = "get_next_prompt"
CATEGORY = "🫶 ComfyAssets/📝 Text"
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Force re-execution every time."""
GLOBAL_COUNTER["count"] += 1
return GLOBAL_COUNTER["count"]
def get_next_prompt(self, prompt_file: str) -> Tuple[str, str, int]:
"""Get the next prompt in sequence."""
# Resolve file path
if not os.path.isabs(prompt_file):
try:
import folder_paths
input_dir = folder_paths.get_input_directory()
full_path = os.path.join(input_dir, prompt_file)
except ImportError:
full_path = os.path.abspath(prompt_file)
else:
full_path = prompt_file
# Load prompts
prompts = load_prompts_from_file(full_path)
if not prompts:
return ("No prompts found", "", 0)
# Get current prompt based on global counter
index = GLOBAL_COUNTER["count"] % len(prompts)
current_prompt, _ = get_prompt_at_index(prompts, index, wrap=True)
# Split positive/negative
positive, negative = split_prompt_into_positive_negative(current_prompt)
print(
f"[SimpleBatchPrompts] Counter={GLOBAL_COUNTER['count']}, Index={index}, Prompt={positive[:30]}..."
)
return (positive, negative, index)
@@ -19,7 +19,7 @@ class StateManager:
"""Load state from file.""" """Load state from file."""
if self.state_file.exists(): if self.state_file.exists():
try: try:
with open(self.state_file, "r") as f: with open(self.state_file, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
pass pass
@@ -28,7 +28,7 @@ class StateManager:
def save_state(self, state: Dict[str, Any]): def save_state(self, state: Dict[str, Any]):
"""Save state to file.""" """Save state to file."""
try: try:
with open(self.state_file, "w") as f: with open(self.state_file, "w", encoding="utf-8") as f:
json.dump(state, f) json.dump(state, f)
except Exception as e: except Exception as e:
print(f"[BatchPrompts] Failed to save state: {e}") print(f"[BatchPrompts] Failed to save state: {e}")
@@ -0,0 +1,5 @@
"""Checkpoint Folder Iterator module."""
from .node import CheckpointFolderIteratorNode
__all__ = ["CheckpointFolderIteratorNode"]
@@ -0,0 +1,52 @@
"""Logic for the Checkpoint Folder Iterator node."""
import os
from typing import List, Sequence, Tuple, TypeVar
from ...base.model_folders import filter_by_pattern, scan_model_entries
T = TypeVar("T")
def list_checkpoint_entries(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[Tuple[str, str]]:
"""(name, absolute_path) pairs for every checkpoint, filtered, in natural order.
The absolute path is what gets loaded, so folders outside the configured
checkpoints roots work too; the name is relative to the root (or to the
folder itself when it lives outside every root) and is used for labels.
"""
entries = scan_model_entries(folder_path, "checkpoints")
names = [name for name, _ in entries]
kept = set(filter_by_pattern(names, include_pattern, exclude_pattern))
return [(name, path) for name, path in entries if name in kept]
def list_checkpoints(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""Checkpoint names (relative to the checkpoints roots) in natural order."""
entries = list_checkpoint_entries(folder_path, include_pattern, exclude_pattern)
return [name for name, _ in entries]
def pick_checkpoint(items: Sequence[T], index: int) -> T:
"""Return items[index], or raise when the sweep has run past the end.
Wrapping would silently restart the sweep (and, downstream, archive the
grid that just finished), so an over-queued run fails loudly instead.
"""
if not items:
raise ValueError("No checkpoints found")
if not 0 <= index < len(items):
raise ValueError(
f"Index {index} is past the end: only {len(items)} checkpoints matched. "
"The sweep is complete; reset index to 0 to start a new one."
)
return items[index]
def label_for(ckpt_name: str) -> str:
"""Label drawn under a tile for this checkpoint. Default: the file stem."""
return os.path.splitext(os.path.basename(ckpt_name))[0]
@@ -0,0 +1,80 @@
"""Checkpoint Folder Iterator node for ComfyUI."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import label_for, list_checkpoint_entries, pick_checkpoint
class CheckpointFolderIteratorNode(ComfyAssetsBaseNode):
"""
Load one checkpoint from a folder per queued run.
Set index to 0 with "increment" and queue as many runs as there are
checkpoints: each run loads the next file, so only one model is in
memory at a time. A run past the last checkpoint fails with a clear
error rather than wrapping around, so over-queuing never restarts the
sweep. Pairs with Model Compare Sheet to build a labeled grid.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": (
"STRING",
{
"default": ".",
"multiline": False,
"tooltip": "Folder relative to models/checkpoints (or absolute)",
},
),
"index": (
"INT",
{
"default": 0,
"min": 0,
"max": 9999,
"control_after_generate": True,
"tooltip": "Which checkpoint to load; errors past the end",
},
),
},
"optional": {
"include_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to include (empty = all)"},
),
"exclude_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to exclude (empty = none)"},
),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "INT", "INT")
RETURN_NAMES = ("model", "clip", "vae", "ckpt_name", "label", "index", "count")
FUNCTION = "load"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def load(
self,
folder: str,
index: int,
include_pattern: str = "",
exclude_pattern: str = "",
) -> Tuple[object, object, object, str, str, int, int]:
import folder_paths
import comfy.sd
entries = list_checkpoint_entries(folder, include_pattern, exclude_pattern)
ckpt_name, ckpt_path = pick_checkpoint(entries, index)
self.log_info(f"Loading {index + 1}/{len(entries)}: {ckpt_name}")
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)[:3]
return model, clip, vae, ckpt_name, label_for(ckpt_name), index, len(entries)
+2 -1
View File
@@ -43,7 +43,8 @@ def create_empty_latent_batch(
# Standard VAE uses 4 channels # Standard VAE uses 4 channels
latent_tensor = torch.zeros(batch_size, 4, latent_height, latent_width) latent_tensor = torch.zeros(batch_size, 4, latent_height, latent_width)
return {"samples": latent_tensor} # Same mark as core EmptyLatentImage: lets the sampler resize this empty latent for 16x VAEs
return {"samples": latent_tensor, "downscale_ratio_spacial": 8}
def validate_dimensions(width: int, height: int) -> bool: def validate_dimensions(width: int, height: int) -> bool:
+5 -5
View File
@@ -93,14 +93,14 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
} }
} }
RETURN_TYPES = ("LATENT", "INT", "INT") RETURN_TYPES = ("LATENT", "INT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height") RETURN_NAMES = ("latent", "width", "height", "batch_size")
FUNCTION = "create_empty_latent" FUNCTION = "create_empty_latent"
CATEGORY = "🫶 ComfyAssets/📦 Latents" CATEGORY = "🫶 ComfyAssets/📦 Latents"
def create_empty_latent( def create_empty_latent(
self, preset: str, width: int, height: int, batch_size: int self, preset: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, torch.Tensor], int, int]: ) -> Tuple[Dict[str, torch.Tensor], int, int, int]:
""" """
Create empty latent tensor with specified dimensions and batch size. Create empty latent tensor with specified dimensions and batch size.
@@ -111,7 +111,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
batch_size: Number of latents in the batch batch_size: Number of latents in the batch
Returns: Returns:
Tuple containing (latent dictionary with 'samples' tensor, width, height) Tuple containing (latent dict, width, height, batch_size)
""" """
try: try:
# Extract original preset name from formatted string if needed # Extract original preset name from formatted string if needed
@@ -160,7 +160,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
f"(pixel dims: {final_width}×{final_height})" f"(pixel dims: {final_width}×{final_height})"
) )
return (latent_dict, final_width, final_height) return (latent_dict, final_width, final_height, batch_size)
except Exception as e: except Exception as e:
# Handle any unexpected errors gracefully # Handle any unexpected errors gracefully
@@ -1,89 +0,0 @@
{
"models": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro-preview-03-25",
"gemini-2.5-flash-preview-05-20",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-06-05",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.0-flash",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-001",
"gemini-2.0-flash-lite",
"gemini-2.5-flash-preview-tts",
"gemini-2.5-pro-preview-tts",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-flash-lite-preview",
"gemini-2.0-pro-exp",
"gemini-2.0-pro-exp-02-05",
"learnlm-2.0-flash-experimental",
"gemini-1.5-pro-latest",
"gemini-1.5-pro-002",
"gemini-1.5-pro",
"gemini-1.5-flash-latest",
"gemini-1.5-flash",
"gemini-1.5-flash-002",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-8b-001",
"gemini-1.5-flash-8b-latest",
"gemini-2.0-flash-thinking-exp-01-21",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-flash-thinking-exp-1219",
"gemma-3-1b-it",
"gemma-3-4b-it",
"gemma-3-12b-it",
"gemma-3-27b-it",
"gemma-3n-e4b-it",
"gemma-3n-e2b-it",
"gemini-exp-1206"
],
"descriptions": {
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
"gemini-1.5-pro": "Gemini 1.5 Pro",
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
"gemini-1.5-flash": "Gemini 1.5 Flash",
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash": "Gemini 2.5 Flash",
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
"gemini-2.5-pro": "Gemini 2.5 Pro",
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
"gemini-exp-1206": "Gemini Experimental 1206",
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
"gemma-3-1b-it": "Gemma 3 1B",
"gemma-3-4b-it": "Gemma 3 4B",
"gemma-3-12b-it": "Gemma 3 12B",
"gemma-3-27b-it": "Gemma 3 27B",
"gemma-3n-e4b-it": "Gemma 3n E4B",
"gemma-3n-e2b-it": "Gemma 3n E2B",
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
},
"timestamp": 1754568195.1098156
}
+9 -15
View File
@@ -30,7 +30,7 @@ def tensor_to_pil(tensor: np.ndarray) -> Image.Image:
image_array = (tensor * 255).astype(np.uint8) image_array = (tensor * 255).astype(np.uint8)
# Convert to PIL # Convert to PIL
return Image.fromarray(image_array, mode="RGB") return Image.fromarray(image_array)
def image_to_base64(image: Image.Image, format: str = "PNG") -> str: def image_to_base64(image: Image.Image, format: str = "PNG") -> str:
@@ -65,7 +65,7 @@ def get_api_key() -> Optional[str]:
os.path.dirname(__file__), "..", "..", "..", "gemini_config.json" os.path.dirname(__file__), "..", "..", "..", "gemini_config.json"
) )
if os.path.exists(config_path): if os.path.exists(config_path):
with open(config_path, "r") as f: with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
api_key = config.get("api_key") api_key = config.get("api_key")
except Exception: except Exception:
@@ -115,30 +115,24 @@ def analyze_image_with_gemini(
else: else:
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"]) system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
# Here we would normally make the API call to Gemini
# For now, we'll import the google-generativeai library
try: try:
import google.generativeai as genai from google import genai
except ImportError: except ImportError:
return ( return (
"", "",
"google-generativeai library not installed. Please run: pip install google-generativeai", "google-genai library not installed. Please run: pip install google-genai",
) )
try: try:
# Configure Gemini client = genai.Client(api_key=api_key)
genai.configure(api_key=api_key)
# Create model response = client.models.generate_content(
model = genai.GenerativeModel(model_name) model=model_name,
contents=[
# Generate content
response = model.generate_content(
[
system_prompt, system_prompt,
pil_image, pil_image,
"Analyze this image and generate an appropriate prompt according to the instructions.", "Analyze this image and generate an appropriate prompt according to the instructions.",
] ],
) )
# Extract text from response # Extract text from response
+7 -7
View File
@@ -59,10 +59,10 @@ def _fetch_models_from_api(
Tuple of (model_names_list, model_descriptions_dict) Tuple of (model_names_list, model_descriptions_dict)
""" """
try: try:
import google.generativeai as genai from google import genai
except ImportError: except ImportError:
if not silent: if not silent:
logger.error("google-generativeai not installed") logger.error("google-genai not installed")
return [], {} return [], {}
# Get API key # Get API key
@@ -77,15 +77,15 @@ def _fetch_models_from_api(
return [], {} return [], {}
try: try:
genai.configure(api_key=api_key) client = genai.Client(api_key=api_key)
models = [] models = []
descriptions = {} descriptions = {}
# Fetch all models # Fetch all models
for model in genai.list_models(): for model in client.models.list():
# Only include models that support generateContent # Only include models that support generateContent
if "generateContent" in model.supported_generation_methods: if "generateContent" in (model.supported_actions or []):
# Remove "models/" prefix from name # Remove "models/" prefix from name
model_name = model.name.replace("models/", "") model_name = model.name.replace("models/", "")
models.append(model_name) models.append(model_name)
@@ -154,7 +154,7 @@ def _load_cache() -> Optional[Dict]:
return None return None
try: try:
with open(CACHE_FILE, "r") as f: with open(CACHE_FILE, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
# Check if cache is expired # Check if cache is expired
@@ -176,7 +176,7 @@ def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
"timestamp": time.time(), "timestamp": time.time(),
} }
with open(CACHE_FILE, "w") as f: with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
except Exception as e: except Exception as e:
logger.warning(f"Failed to save cache: {e}") logger.warning(f"Failed to save cache: {e}")
+6 -1
View File
@@ -12,6 +12,8 @@ from .models import get_available_models
class GeminiPromptNode(ComfyAssetsBaseNode): class GeminiPromptNode(ComfyAssetsBaseNode):
"""Analyzes images using Gemini AI to generate optimized prompts for various AI models.""" """Analyzes images using Gemini AI to generate optimized prompts for various AI models."""
DEPRECATED = True
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
"""Define input types for the node.""" """Define input types for the node."""
@@ -54,6 +56,9 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
CATEGORY = "🫶 ComfyAssets/🧠 Prompts" CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
DESCRIPTION = """ DESCRIPTION = """
Deprecated: retained for existing workflows only. This node no longer receives
Google API/model compatibility updates.
Analyzes images using Google's Gemini AI to generate optimized prompts. Analyzes images using Google's Gemini AI to generate optimized prompts.
Supports multiple prompt formats: Supports multiple prompt formats:
@@ -63,7 +68,7 @@ Supports multiple prompt formats:
- Video: Motion and temporal descriptions for video generation - Video: Motion and temporal descriptions for video generation
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node). Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
Install: pip install google-generativeai Install: pip install google-genai
""" """
def generate_prompt( def generate_prompt(
+6 -3
View File
@@ -100,13 +100,15 @@ def generate_grain_texture(
Returns: Returns:
Grain texture tensor of shape [B, H/scale, W/scale, 3] Grain texture tensor of shape [B, H/scale, W/scale, 3]
""" """
torch.manual_seed(seed) # Local generator: never touch the global RNG state
generator = torch.Generator()
generator.manual_seed(int(seed))
grain_height = max(1, int(height / scale)) grain_height = max(1, int(height / scale))
grain_width = max(1, int(width / scale)) grain_width = max(1, int(width / scale))
# Generate random noise # Generate random noise
grain = torch.rand(batch_size, grain_height, grain_width, 3) grain = torch.rand(batch_size, grain_height, grain_width, 3, generator=generator)
return grain return grain
@@ -140,7 +142,8 @@ def apply_film_grain(
Returns: Returns:
Image with film grain applied Image with film grain applied
""" """
if strength == 0.0: # Only short-circuit when nothing at all would change (toe still applies)
if strength == 0.0 and toe == 0.0:
return image return image
# Handle empty batch # Handle empty batch
+1 -1
View File
@@ -75,7 +75,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
{ {
"default": 0, "default": 0,
"min": 0, "min": 0,
"max": 0xFFFFFFFFFFFFFFFF, "max": 0xFFFFFFFF, # 2**32 - 1
"description": "Random seed for grain pattern generation", "description": "Random seed for grain pattern generation",
}, },
), ),
+5
View File
@@ -55,6 +55,11 @@ class KikoPurgeVRAM(BaseNode):
OUTPUT_NODE = True OUTPUT_NODE = True
DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged." DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged."
@classmethod
def IS_CHANGED(cls, *args: Any, **kwargs: Any) -> float:
"""Always re-run: a purge must never be skipped by ComfyUI caching."""
return float("nan")
def purge_vram( def purge_vram(
self, self,
anything: Any, anything: Any,
+29 -4
View File
@@ -43,7 +43,7 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
counter = 0 counter = 0
if os.path.exists(counter_file): if os.path.exists(counter_file):
try: try:
with open(counter_file, "r") as f: with open(counter_file, "r", encoding="utf-8") as f:
content = f.read().strip() content = f.read().strip()
counter = int(content) if content else 0 counter = int(content) if content else 0
except (ValueError, IOError): except (ValueError, IOError):
@@ -55,7 +55,7 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
# Save updated counter # Save updated counter
try: try:
with open(counter_file, "w") as f: with open(counter_file, "w", encoding="utf-8") as f:
f.write(str(counter)) f.write(str(counter))
except IOError: except IOError:
# If we can't write the counter file, continue anyway # If we can't write the counter file, continue anyway
@@ -65,6 +65,23 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
return counter return counter
def _ensure_inside_output_dir(
folder: str, output_dir: str, prefix_dir: str, subfolder: str
) -> None:
"""Raise ValueError unless folder resolves to a location inside output_dir."""
real_root = os.path.realpath(output_dir)
real_folder = os.path.realpath(folder)
try:
inside = os.path.commonpath([real_root, real_folder]) == real_root
except ValueError: # e.g. different drives on Windows
inside = False
if not inside:
raise ValueError(
"Resolved output folder is outside the output directory "
f"(prefix directory {prefix_dir!r}, subfolder {subfolder!r})"
)
def get_save_image_path( def get_save_image_path(
filename_prefix: str, filename_prefix: str,
counter: int, counter: int,
@@ -87,6 +104,11 @@ def get_save_image_path(
""" """
# Split filename_prefix into directory path and actual filename prefix # Split filename_prefix into directory path and actual filename prefix
# This allows for directory structures like "kittybear/anime/images/kittybear" # This allows for directory structures like "kittybear/anime/images/kittybear"
if os.path.isabs(filename_prefix) or os.path.isabs(subfolder or ""):
raise ValueError(
"Absolute paths are not allowed in filename_prefix or subfolder: "
f"{filename_prefix!r}"
)
prefix_dir = os.path.dirname(filename_prefix) prefix_dir = os.path.dirname(filename_prefix)
prefix_name = os.path.basename(filename_prefix) prefix_name = os.path.basename(filename_prefix)
@@ -114,6 +136,9 @@ def get_save_image_path(
full_output_folder = os.path.join(*path_components) full_output_folder = os.path.join(*path_components)
# Reject anything that would resolve outside the output directory
_ensure_inside_output_dir(full_output_folder, output_dir, prefix_dir, subfolder)
# Ensure directory exists # Ensure directory exists
os.makedirs(full_output_folder, exist_ok=True) os.makedirs(full_output_folder, exist_ok=True)
@@ -265,7 +290,7 @@ def process_image_batch(
popup: bool = True, popup: bool = True,
prompt: Optional[Dict] = None, prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None, extra_pnginfo: Optional[Dict] = None,
) -> List[Dict[str, Any]]: ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
""" """
Process and save a batch of images with specified format settings Process and save a batch of images with specified format settings
@@ -281,7 +306,7 @@ def process_image_batch(
extra_pnginfo: Additional PNG metadata extra_pnginfo: Additional PNG metadata
Returns: Returns:
List of saved image information dicts Tuple of (ComfyUI preview result dicts, enhanced info dicts)
""" """
# Get output directory # Get output directory
output_dir = folder_paths.get_output_directory() output_dir = folder_paths.get_output_directory()
@@ -1,12 +0,0 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids/images",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/",
"/home/vito/Downloads/vito",
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
"/home/vito/ai-apps/ComfyUI/output",
"/home/vito/Downloads",
"/home/vito/Downloads/images"
]
}
+103 -1
View File
@@ -5,7 +5,7 @@ import json
import torch import torch
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from typing import Tuple, Dict, Any, List from typing import Tuple, Dict, Any, List, Optional, Iterable
def get_supported_extensions() -> Dict[str, List[str]]: def get_supported_extensions() -> Dict[str, List[str]]:
@@ -17,6 +17,108 @@ def get_supported_extensions() -> Dict[str, List[str]]:
} }
def resolve_path(path: Any) -> Optional[str]:
"""Resolve a user supplied path with realpath (symlinks and ``..``).
Returns None for anything that is not a usable non-empty string.
"""
if not isinstance(path, str) or not path or "\0" in path:
return None
return os.path.realpath(path)
def is_within_roots(path: Any, roots: Iterable[Any]) -> bool:
"""Return True if the resolved path is inside (or equal to) one of the roots.
Uses os.path.commonpath on realpath-resolved values, so sibling
directories sharing a string prefix, ``..`` and symlinks cannot escape.
"""
real = resolve_path(path)
if real is None:
return False
for root in roots:
real_root = resolve_path(root)
if real_root is None:
continue
try:
if os.path.commonpath([real, real_root]) == real_root:
return True
except ValueError: # different drives / mixed absolute and relative
continue
return False
def is_too_broad_root(path: Any, home: Optional[str] = None) -> bool:
"""Return True if path is unusable or too broad to be a saved root.
Refuses filesystem/drive roots, the home directory itself and any
ancestor of it (e.g. /home), since those would expose every media file
a user owns. Folders inside home are fine.
"""
real = resolve_path(path)
if real is None:
return True
if os.path.dirname(real) == real: # "/" or a drive root like "C:\\"
return True
real_home = os.path.realpath(home or os.path.expanduser("~"))
try:
return os.path.commonpath([real, real_home]) == real
except ValueError: # different drives
return False
def is_allowed_media_path(
path: Any, roots: Iterable[Any], kinds: Iterable[str] = ("image",)
) -> bool:
"""Return True if path is a media file of the given kinds inside a root.
``kinds`` are keys of get_supported_extensions() (image/video/audio).
The extension is checked on the resolved path so a symlink named
``x.png`` pointing at a non-media file is refused.
"""
real = resolve_path(path)
if real is None:
return False
extensions = get_supported_extensions()
allowed = {e for k in kinds for e in extensions.get(k, [])}
if os.path.splitext(real)[1].lower() not in allowed:
return False
return is_within_roots(real, roots)
def is_allowed_image_path(path: Any, roots: Iterable[Any]) -> bool:
"""Return True if path is an image file located inside one of the roots."""
return is_allowed_media_path(path, roots, ("image",))
def list_subdirectories(path: str, limit: int = 50) -> List[str]:
"""List sub-directory paths (never files) for the folder picker.
If ``path`` ends with a separator, its child directories are returned;
otherwise sibling directories whose name starts with the last component.
May raise PermissionError.
"""
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
parent_dir, prefix = path, ""
else:
parent_dir = os.path.dirname(path)
prefix = os.path.basename(path).lower()
if not parent_dir:
return []
if not os.path.isdir(parent_dir):
return []
dirs = [
os.path.join(parent_dir, entry)
for entry in os.listdir(parent_dir)
if entry.lower().startswith(prefix)
and os.path.isdir(os.path.join(parent_dir, entry))
]
dirs.sort(key=lambda x: x.lower())
return dirs[:limit]
def load_image_from_path(path: str) -> Tuple[torch.Tensor, Dict[str, Any]]: def load_image_from_path(path: str) -> Tuple[torch.Tensor, Dict[str, Any]]:
""" """
Load an image from the given path and convert it to a tensor. Load an image from the given path and convert it to a tensor.
+267 -222
View File
@@ -1,13 +1,37 @@
"""Local Image Loader node for ComfyUI.""" """Local Image Loader node for ComfyUI."""
import os import io
import json import json
import logging
import os
from typing import Any, Dict, List, Tuple
import torch import torch
from typing import Dict, Any, Tuple
from ...base.base_node import ComfyAssetsBaseNode from ...base.base_node import ComfyAssetsBaseNode
from .logic import load_image_from_path, create_empty_tensor from .logic import (
create_empty_tensor,
is_allowed_image_path,
is_allowed_media_path,
is_too_broad_root,
is_within_roots,
list_subdirectories,
load_image_from_path,
resolve_path,
scan_directory,
)
try:
from aiohttp import web
except ImportError: # pragma: no cover - aiohttp ships with ComfyUI
web = None
logger = logging.getLogger(__name__)
MEDIA_KINDS = ("image", "video", "audio")
MAX_PER_PAGE = 200
DEFAULT_PER_PAGE = 50
MAX_DIRECTORY_SUGGESTIONS = 50
NODE_DIR = os.path.dirname(os.path.abspath(__file__)) NODE_DIR = os.path.dirname(os.path.abspath(__file__))
SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json") SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json")
@@ -31,7 +55,7 @@ def save_selections(data: Dict[str, Any]) -> None:
with open(SELECTIONS_FILE, "w", encoding="utf-8") as f: with open(SELECTIONS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False) json.dump(data, f, indent=4, ensure_ascii=False)
except Exception as e: except Exception as e:
print(f"KikoLocalImageLoader: Error saving selections: {e}") logger.error("Error saving selections: %s", e)
def load_config() -> Dict[str, Any]: def load_config() -> Dict[str, Any]:
@@ -51,7 +75,28 @@ def save_config(data: Dict[str, Any]) -> None:
with open(CONFIG_FILE, "w", encoding="utf-8") as f: with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
except Exception as e: except Exception as e:
print(f"KikoLocalImageLoader: Error saving config: {e}") logger.error("Error saving config: %s", e)
def get_allowed_roots() -> List[str]:
"""Directories whose contents may be served: saved paths + ComfyUI in/out."""
roots: List[str] = []
saved = load_config().get("saved_paths", [])
if isinstance(saved, list):
# Skip broad roots saved before save_paths refused them
roots.extend(
p for p in saved if isinstance(p, str) and p and not is_too_broad_root(p)
)
try:
import folder_paths
for getter in ("get_input_directory", "get_output_directory"):
value = getattr(folder_paths, getter)()
if isinstance(value, str) and value:
roots.append(value)
except Exception: # folder_paths missing or misbehaving outside ComfyUI
pass
return roots
class LocalImageLoaderNode(ComfyAssetsBaseNode): class LocalImageLoaderNode(ComfyAssetsBaseNode):
@@ -102,229 +147,223 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
# Load image if selected # Load image if selected
image_selection = node_selections.get("image") image_selection = node_selections.get("image")
if image_selection and image_selection.get("path"): if image_selection and image_selection.get("path"):
image_path = image_selection["path"] # Load the resolved path we validated, so a symlink swapped after
if os.path.exists(image_path): # the check can't redirect the read.
image_path = resolve_path(image_selection["path"])
if not is_allowed_image_path(image_path, get_allowed_roots()):
# selections.json may have been tampered with
logger.warning("Refusing to load disallowed path: %s", image_path)
elif os.path.exists(image_path):
try: try:
image_tensor, metadata = load_image_from_path(image_path) image_tensor, metadata = load_image_from_path(image_path)
info_string = json.dumps(metadata, indent=4, ensure_ascii=False) info_string = json.dumps(metadata, indent=4, ensure_ascii=False)
except Exception as e: except Exception as e:
print(f"KikoLocalImageLoader: Error loading image: {e}") logger.error("Error loading image: %s", e)
return (image_tensor, info_string) return (image_tensor, info_string)
# Setup API routes # ---------------------------------------------------------------------------
try: # API route handlers (module level so they can be tested without a server)
import server # ---------------------------------------------------------------------------
from aiohttp import web
import urllib.parse
import io
from PIL import Image
from .logic import scan_directory
prompt_server = server.PromptServer.instance
@prompt_server.routes.post("/kiko_local_image_loader/set_node_selection") def _error(status: int, message: str):
async def set_node_selection(request): return web.json_response({"status": "error", "error": message}, status=status)
"""API endpoint to set node selection."""
try:
data = await request.json()
node_id = str(data.get("node_id"))
path = data.get("path")
media_type = data.get("type")
if not all([node_id, path, media_type]):
return web.json_response(
{"status": "error", "message": "Missing required data."}, status=400
)
selections = load_selections() def _check_file(filepath: Any, kinds: Tuple[str, ...] = ("image",)):
if node_id not in selections: """Validate a requested file. Returns (resolved_path, error_response)."""
selections[node_id] = {} if not isinstance(filepath, str) or not filepath or "\0" in filepath:
return None, web.Response(status=400)
real = resolve_path(filepath)
if not is_allowed_media_path(real, get_allowed_roots(), kinds):
return None, web.Response(status=403)
if not os.path.isfile(real):
return None, web.Response(status=404)
return real, None
selections[node_id][media_type] = {"path": path}
save_selections(selections)
return web.json_response({"status": "ok"}) def _parse_paging(query) -> Tuple[int, int]:
except Exception as e: """Parse page/per_page; raises ValueError on garbage."""
return web.json_response({"status": "error", "message": str(e)}, status=500) page = int(query.get("page", 1))
per_page = int(query.get("per_page", DEFAULT_PER_PAGE))
if page < 1:
raise ValueError("page must be >= 1")
return page, max(1, min(per_page, MAX_PER_PAGE))
@prompt_server.routes.get("/kiko_local_image_loader/get_saved_paths")
async def get_saved_paths(request):
"""API endpoint to get saved directory paths."""
config = load_config()
return web.json_response({"saved_paths": config.get("saved_paths", [])})
@prompt_server.routes.post("/kiko_local_image_loader/save_paths") async def set_node_selection(request):
async def save_paths(request): """Store the selected image for a node (image files in allowed roots only)."""
"""API endpoint to save directory paths.""" try:
try: data = await request.json()
data = await request.json() except Exception:
paths = data.get("paths", []) return _error(400, "Invalid JSON.")
config = load_config() if not isinstance(data, dict):
config["saved_paths"] = paths return _error(400, "Invalid payload.")
save_config(config)
return web.json_response({"status": "ok"})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
@prompt_server.routes.get("/kiko_local_image_loader/images") node_id = data.get("node_id")
async def get_local_images(request): path = data.get("path")
"""API endpoint to get images from a directory.""" media_type = data.get("type")
directory = request.query.get("directory", "") if (
node_id is None
or not isinstance(path, str)
or not path
or "\0" in path
or not isinstance(media_type, str)
or not media_type
):
return _error(400, "Missing required data.")
if not directory or not os.path.isdir(directory): # The frontend also selects video/audio; load_media only ever reads images.
return web.json_response({"error": "Directory not found."}, status=404) if not is_allowed_media_path(path, get_allowed_roots(), MEDIA_KINDS):
return _error(403, "Path not allowed.")
# Normalize path to remove trailing slashes and resolve relative paths selections = load_selections()
directory = os.path.normpath(directory) selections.setdefault(str(node_id), {})[media_type] = {"path": path}
save_selections(selections)
return web.json_response({"status": "ok"})
# Save last path
config = load_config()
config["last_path"] = directory
save_config(config)
show_videos = request.query.get("show_videos", "false").lower() == "true" async def get_saved_paths(request):
show_audio = request.query.get("show_audio", "false").lower() == "true" """Return saved directory paths."""
hide_dot_folders = ( return web.json_response({"saved_paths": load_config().get("saved_paths", [])})
request.query.get("hide_dot_folders", "true").lower() == "true"
async def save_paths(request):
"""Persist the list of saved directory paths."""
try:
data = await request.json()
except Exception:
return _error(400, "Invalid JSON.")
paths = data.get("paths", []) if isinstance(data, dict) else None
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
return _error(400, "paths must be a list of strings.")
too_broad = [p for p in paths if is_too_broad_root(p)]
if too_broad:
return _error(
400,
f"Folder too broad to share: {', '.join(too_broad)}. "
"Pick a folder inside your home directory or another media folder.",
)
config = load_config()
config["saved_paths"] = paths
save_config(config)
return web.json_response({"status": "ok"})
async def get_local_images(request):
"""List images and sub-directories of a directory inside an allowed root."""
query = request.query
directory = query.get("directory", "")
if not directory or "\0" in directory:
return _error(400, "Directory is required.")
try:
page, per_page = _parse_paging(query)
except ValueError:
return _error(400, "Invalid page or per_page.")
if not is_within_roots(directory, get_allowed_roots()):
return _error(403, "Directory not allowed.")
real = resolve_path(directory)
if not os.path.isdir(real):
return _error(404, "Directory not found.")
# Normalize path (trailing slashes etc.) but keep the user-visible spelling
directory = os.path.normpath(directory)
config = load_config()
config["last_path"] = directory
save_config(config)
show_videos = query.get("show_videos", "false").lower() == "true"
show_audio = query.get("show_audio", "false").lower() == "true"
hide_dot_folders = query.get("hide_dot_folders", "true").lower() == "true"
try:
items = scan_directory(
directory,
show_videos,
show_audio,
query.get("sort_by", "name"),
query.get("sort_order", "asc"),
hide_dot_folders,
)
except Exception:
logger.exception("Error scanning directory %s", directory)
return _error(500, "Error reading directory.")
# Do not offer navigation above the allowed roots
parent_directory = os.path.dirname(directory)
if parent_directory == directory or not is_within_roots(
parent_directory, get_allowed_roots()
):
parent_directory = None
start = (page - 1) * per_page
return web.json_response(
{
"items": items[start : start + per_page],
"total_pages": (len(items) + per_page - 1) // per_page,
"current_page": page,
"current_directory": directory,
"parent_directory": parent_directory,
}
)
async def get_last_path(request):
"""Return the last used directory path."""
return web.json_response({"last_path": load_config().get("last_path", "")})
async def list_directories(request):
"""List directory names for the folder-picker autocomplete.
Trade-off: this is how users browse to a location that is not yet a saved
root, so it is intentionally NOT restricted to the allowed roots. It only
ever returns directory paths (never files), capped at
MAX_DIRECTORY_SUGGESTIONS; file contents stay protected by the root and
extension checks on every other route.
"""
path = request.query.get("path", "")
if "\0" in path:
return web.json_response({"directories": []})
try:
if not path:
if os.name == "nt":
import string
drives = [
f"{d}:\\"
for d in string.ascii_uppercase
if os.path.exists(f"{d}:\\")
]
return web.json_response({"directories": drives})
return web.json_response({"directories": [os.sep]})
dirs = list_subdirectories(os.path.expanduser(path), MAX_DIRECTORY_SUGGESTIONS)
return web.json_response({"directories": dirs})
except PermissionError:
return web.json_response({"directories": [], "error": "Permission denied"})
except Exception:
logger.exception("Error listing directories for %s", path)
return web.json_response(
{"directories": [], "error": "Error listing directories"}
) )
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", 50))
sort_by = request.query.get("sort_by", "name")
sort_order = request.query.get("sort_order", "asc")
try: async def get_thumbnail(request):
items = scan_directory( """Return a thumbnail of an allowed image."""
directory, real, error = _check_file(request.query.get("filepath"))
show_videos, if error is not None:
show_audio, return error
sort_by,
sort_order,
hide_dot_folders,
)
# Get parent directory try:
parent_directory = os.path.dirname(directory) from PIL import Image
if parent_directory == directory:
parent_directory = None
# Paginate results with Image.open(real) as img:
start = (page - 1) * per_page
end = start + per_page
paginated_items = items[start:end]
return web.json_response(
{
"items": paginated_items,
"total_pages": (len(items) + per_page - 1) // per_page,
"current_page": page,
"current_directory": directory,
"parent_directory": parent_directory,
}
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
async def get_last_path(request):
"""API endpoint to get last used directory path."""
return web.json_response({"last_path": load_config().get("last_path", "")})
@prompt_server.routes.get("/kiko_local_image_loader/list_directories")
async def list_directories(request):
"""API endpoint to list directories for autocomplete."""
path = request.query.get("path", "")
try:
# Handle empty path - show root or common starting points
if not path:
# Return filesystem root
if os.name == "nt": # Windows
import string
drives = [
f"{d}:\\"
for d in string.ascii_uppercase
if os.path.exists(f"{d}:\\")
]
return web.json_response({"directories": drives})
else: # Unix/Linux/Mac
return web.json_response({"directories": ["/"]})
# Normalize the path
path = os.path.expanduser(path) # Handle ~ for home directory
# If path ends with separator, list contents of that directory
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
if os.path.isdir(path):
try:
entries = os.listdir(path)
dirs = []
for entry in entries:
full_path = os.path.join(path, entry)
if os.path.isdir(full_path):
dirs.append(full_path)
dirs.sort(key=lambda x: x.lower())
return web.json_response(
{"directories": dirs[:50]}
) # Limit results
except PermissionError:
return web.json_response(
{"directories": [], "error": "Permission denied"}
)
else:
return web.json_response({"directories": []})
# Otherwise, find matching directories in parent
parent_dir = os.path.dirname(path)
basename = os.path.basename(path).lower()
if not parent_dir:
# Handle root level on Unix
if path.startswith("/"):
parent_dir = "/"
basename = path[1:].lower()
else:
return web.json_response({"directories": []})
if os.path.isdir(parent_dir):
try:
entries = os.listdir(parent_dir)
dirs = []
for entry in entries:
full_path = os.path.join(parent_dir, entry)
if os.path.isdir(full_path) and entry.lower().startswith(
basename
):
dirs.append(full_path)
dirs.sort(key=lambda x: x.lower())
return web.json_response(
{"directories": dirs[:50]}
) # Limit results
except PermissionError:
return web.json_response(
{"directories": [], "error": "Permission denied"}
)
return web.json_response({"directories": []})
except Exception as e:
return web.json_response({"directories": [], "error": str(e)})
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
async def get_thumbnail(request):
"""API endpoint to get image thumbnail."""
filepath = request.query.get("filepath")
if not filepath or ".." in filepath:
return web.Response(status=400)
filepath = urllib.parse.unquote(filepath)
if not os.path.exists(filepath):
return web.Response(status=404)
try:
img = Image.open(filepath)
has_alpha = img.mode == "RGBA" or ( has_alpha = img.mode == "RGBA" or (
img.mode == "P" and "transparency" in img.info img.mode == "P" and "transparency" in img.info
) )
@@ -332,33 +371,39 @@ try:
img.thumbnail([320, 320], Image.LANCZOS) img.thumbnail([320, 320], Image.LANCZOS)
buffer = io.BytesIO() buffer = io.BytesIO()
format, content_type = ( fmt, content_type = (
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg") ("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
) )
img.save(buffer, format=format, quality=90 if format == "JPEG" else None) img.save(buffer, format=fmt, quality=90 if fmt == "JPEG" else None)
buffer.seek(0) return web.Response(body=buffer.getvalue(), content_type=content_type)
except Exception:
logger.exception("Error generating thumbnail for %s", real)
return web.Response(status=500)
return web.Response(body=buffer.read(), content_type=content_type)
except Exception as e:
print(f"KikoLocalImageLoader: Error generating thumbnail: {e}")
return web.Response(status=500)
@prompt_server.routes.get("/kiko_local_image_loader/view") async def view_image(request):
async def view_image(request): """Serve a full-size allowed media file (image, video or audio)."""
"""API endpoint to view full image.""" real, error = _check_file(request.query.get("filepath"), MEDIA_KINDS)
filepath = request.query.get("filepath") if error is not None:
if not filepath or ".." in filepath: return error
return web.Response(status=400) return web.FileResponse(real)
filepath = urllib.parse.unquote(filepath)
if not os.path.exists(filepath):
return web.Response(status=404)
try: # Setup API routes
return web.FileResponse(filepath) try:
except Exception: import server
return web.Response(status=500)
prompt_server = server.PromptServer.instance
_routes = prompt_server.routes
_prefix = "/kiko_local_image_loader"
_routes.post(f"{_prefix}/set_node_selection")(set_node_selection)
_routes.get(f"{_prefix}/get_saved_paths")(get_saved_paths)
_routes.post(f"{_prefix}/save_paths")(save_paths)
_routes.get(f"{_prefix}/images")(get_local_images)
_routes.get(f"{_prefix}/get_last_path")(get_last_path)
_routes.get(f"{_prefix}/list_directories")(list_directories)
_routes.get(f"{_prefix}/thumbnail")(get_thumbnail)
_routes.get(f"{_prefix}/view")(view_image)
except ImportError: except ImportError:
# Server not available during testing # Server not available during testing
pass pass
@@ -1,77 +0,0 @@
{
"57": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
}
},
"58": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
}
},
"18": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"445": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"23": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
}
},
"69": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"52": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
}
},
"38": {
"image": {
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
}
},
"170": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
}
},
"214": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
}
},
"299": {
"image": {
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
}
},
"527": {
"image": {
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
}
},
"522": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"517": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/images/kittybear_00004.png"
}
},
"144": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
}
}
}
@@ -0,0 +1,5 @@
"""Model Compare Sheet module."""
from .node import ModelCompareSheetNode
__all__ = ["ModelCompareSheetNode"]
@@ -0,0 +1,163 @@
"""Logic for the Model Compare Sheet node: tile files on disk into a labeled grid."""
import math
import os
import re
from datetime import datetime
from typing import List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from ...base.model_folders import is_within, natural_sort
GRID_FILENAME = "grid.png"
PREVIEW_FILENAME = "grid_preview.png"
PREVIEW_WIDTH = 1024
BACKGROUND = (28, 28, 28)
LABEL_COLOR = (235, 235, 235)
def resolve_run_dir(output_dir: str, run_name: str) -> str:
"""Directory for one compare run, always strictly inside output/model_compare.
run_name may be nested ("project/date/sweep"). Every segment must be a plain
name: no empty, "." or ".." parts, on either separator, and the resolved
path must stay below model_compare even after symlinks are followed.
"""
parts = re.split(r"[\\/]", run_name)
if not run_name or os.path.isabs(run_name) or ":" in run_name:
raise ValueError(f"Invalid run_name: {run_name!r}")
if any(part in ("", ".", "..") for part in parts):
raise ValueError(f"Invalid run_name: {run_name!r}")
base = os.path.join(output_dir, "model_compare")
run_dir = os.path.join(base, *parts)
if not is_within(run_dir, base) or os.path.realpath(run_dir) == os.path.realpath(
base
):
raise ValueError(f"Invalid run_name: {run_name!r}")
return run_dir
def tile_filename(index: int, label: str, batch_index: int, batch_size: int) -> str:
"""'03_label.png', or '03_label_2.png' for the second image of a batch."""
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", label)
suffix = f"_{batch_index + 1}" if batch_size > 1 else ""
return f"{index:02d}_{safe}{suffix}.png"
def label_from_filename(filename: str) -> str:
stem = os.path.splitext(os.path.basename(filename))[0]
return re.sub(r"^\d+_", "", stem)
def list_tiles(run_dir: str) -> List[str]:
"""PNG tiles in a run directory in natural order, excluding the grid itself."""
names = [
f
for f in os.listdir(run_dir)
if f.lower().endswith(".png")
and f not in (GRID_FILENAME, PREVIEW_FILENAME)
and os.path.isfile(os.path.join(run_dir, f))
]
return [os.path.join(run_dir, f) for f in natural_sort(names)]
def replace_index_tiles(
run_dir: str, index: int, staged_dir: str, names: List[str]
) -> None:
"""Publish one index's staged tiles, restoring old files on a rename failure."""
previous_dir = os.path.join(staged_dir, "previous")
os.mkdir(previous_dir)
prefix = f"{index:02d}_"
old_tiles = [
path
for path in list_tiles(run_dir)
if os.path.basename(path).startswith(prefix)
]
moved: List[Tuple[str, str]] = []
published: List[str] = []
try:
for path in old_tiles:
backup = os.path.join(previous_dir, os.path.basename(path) + ".previous")
os.replace(path, backup)
moved.append((path, backup))
for name in names:
destination = os.path.join(run_dir, name)
os.replace(os.path.join(staged_dir, name + ".tmp"), destination)
published.append(destination)
except OSError:
for path in published:
os.remove(path)
for original, backup in reversed(moved):
os.replace(backup, original)
raise
def archive_previous_sweep(run_dir: str) -> Optional[str]:
"""Rename a run folder that already holds tiles to '<run_dir>_<timestamp>'.
Called when a sweep starts over (index 0) so stale tiles from an
interrupted sweep never end up in the new grid. Returns the archive path,
or None when there was nothing to archive.
"""
if not os.path.isdir(run_dir) or not list_tiles(run_dir):
return None
stamp = datetime.fromtimestamp(os.path.getmtime(run_dir)).strftime("%Y%m%d-%H%M%S")
archive = f"{run_dir}_{stamp}"
n = 1
while os.path.exists(archive):
n += 1
archive = f"{run_dir}_{stamp}_{n}"
os.rename(run_dir, archive)
return archive
def _font(font_size: int) -> ImageFont.ImageFont:
try:
return ImageFont.truetype("DejaVuSans.ttf", font_size)
except OSError:
return ImageFont.load_default(size=font_size)
def _fit_label(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
while text and draw.textlength(text, font=font) > max_width:
text = text[:-2] + "…" if len(text) > 1 else ""
return text
def build_contact_sheet(
paths: List[str], columns: int, tile_width: int, font_size: int
) -> Image.Image:
"""Tile the images at `paths` into a grid with each file's label underneath."""
tiles = []
for path in paths:
with Image.open(path) as im:
im = im.convert("RGB")
tile_height = max(1, round(im.height * tile_width / im.width))
tiles.append((im.resize((tile_width, tile_height), Image.LANCZOS), path))
band = font_size * 2
cell_height = max(t.height for t, _ in tiles) + band
cols = min(len(tiles), columns)
rows = math.ceil(len(tiles) / cols)
sheet = Image.new("RGB", (cols * tile_width, rows * cell_height), BACKGROUND)
draw = ImageDraw.Draw(sheet)
font = _font(font_size)
for i, (tile, path) in enumerate(tiles):
x = (i % cols) * tile_width
y = (i // cols) * cell_height
sheet.paste(tile, (x, y))
label = _fit_label(draw, label_from_filename(path), font, tile_width - 8)
text_x = x + (tile_width - draw.textlength(label, font=font)) / 2
text_y = y + tile.height + (band - font_size) / 2
draw.text((text_x, text_y), label, fill=LABEL_COLOR, font=font)
return sheet
def preview_of(sheet: Image.Image) -> Image.Image:
"""A copy of the sheet no wider than PREVIEW_WIDTH, for the node preview and galleries."""
if sheet.width <= PREVIEW_WIDTH:
return sheet
height = max(1, round(sheet.height * PREVIEW_WIDTH / sheet.width))
return sheet.resize((PREVIEW_WIDTH, height), Image.LANCZOS)
+125
View File
@@ -0,0 +1,125 @@
"""Model Compare Sheet node for ComfyUI."""
import json
import os
from tempfile import TemporaryDirectory
from typing import Any, Dict, Optional
import numpy as np
import torch
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
GRID_FILENAME,
PREVIEW_FILENAME,
archive_previous_sweep,
build_contact_sheet,
list_tiles,
preview_of,
replace_index_tiles,
resolve_run_dir,
tile_filename,
)
class ModelCompareSheetNode(ComfyAssetsBaseNode):
"""
Save each run's image into output/model_compare/<run_name>/ and rebuild a
labeled contact sheet from every tile saved so far. Pair with Checkpoint
Folder Iterator: after the last queued run the grid holds every model.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"label": ("STRING", {"forceInput": True}),
"index": ("INT", {"forceInput": True}),
"run_name": (
"STRING",
{
"default": "illustrious",
"tooltip": "Subfolder of output/model_compare for this run",
},
),
"columns": ("INT", {"default": 5, "min": 1, "max": 32}),
"tile_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
"font_size": ("INT", {"default": 28, "min": 8, "max": 256}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("grid",)
FUNCTION = "collect"
OUTPUT_NODE = True
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def collect(
self,
images: torch.Tensor,
label: str,
index: int,
run_name: str,
columns: int,
tile_width: int,
font_size: int,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> Dict[str, Any]:
import folder_paths
if images.shape[0] == 0:
raise ValueError("images must contain at least one image")
output_dir = folder_paths.get_output_directory()
run_dir = resolve_run_dir(output_dir, run_name)
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
for key, value in (extra_pnginfo or {}).items():
metadata.add_text(key, json.dumps(value))
parent = os.path.dirname(run_dir)
os.makedirs(parent, exist_ok=True)
with TemporaryDirectory(prefix=".model-compare-", dir=parent) as staging:
names = []
for i, image in enumerate(images):
array = (image.cpu().numpy().clip(0, 1) * 255).round().astype(np.uint8)
name = tile_filename(index, label, i, images.shape[0])
with Image.fromarray(array) as tile:
tile.save(
os.path.join(staging, name + ".tmp"),
format="PNG",
pnginfo=metadata,
)
names.append(name)
if index == 0:
archived = archive_previous_sweep(run_dir)
if archived:
self.log_info(f"New sweep: previous tiles moved to {archived}")
os.makedirs(run_dir, exist_ok=True)
replace_index_tiles(run_dir, index, staging, names)
sheet = build_contact_sheet(list_tiles(run_dir), columns, tile_width, font_size)
sheet.save(os.path.join(run_dir, GRID_FILENAME))
preview_of(sheet).save(os.path.join(run_dir, PREVIEW_FILENAME))
self.log_info(f"Contact sheet: {run_dir}/{GRID_FILENAME}")
grid = torch.from_numpy(np.asarray(sheet).astype(np.float32) / 255.0)[None]
subfolder = os.path.relpath(run_dir, output_dir)
return {
"ui": {
"images": [
{
"filename": PREVIEW_FILENAME,
"subfolder": subfolder,
"type": "output",
}
]
},
"result": (grid,),
}
+297 -10
View File
@@ -2,9 +2,44 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Optional, Callable from typing import Any, Callable, List, Optional, Sequence, Union
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
import logging
import os import os
import re
import sys
import time
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
DOWNLOAD_TIMEOUT = 60 # seconds, applied to every network operation
CHUNK_SIZE = 1638400
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
ALLOWED_SCHEMES = ("http", "https")
PART_SUFFIX = ".part"
# Only model weights and inert data files may be written; never code.
ALLOWED_FILE_EXTENSIONS = frozenset(
{
".safetensors",
".sft",
".ckpt",
".pt",
".pt2",
".pth",
".bin",
".gguf",
".onnx",
".model",
".json",
".yaml",
".yml",
".txt",
}
)
# folder_paths entries that hold code rather than models.
EXCLUDED_FOLDER_NAMES = frozenset({"custom_nodes"})
try: try:
import comfy.model_management import comfy.model_management
@@ -14,16 +49,132 @@ except ImportError:
COMFY_AVAILABLE = False COMFY_AVAILABLE = False
def validate_url_scheme(url: str) -> None:
"""Raise ValueError unless url uses an allowed scheme (http/https)."""
scheme = urlparse(url or "").scheme.lower()
if scheme not in ALLOWED_SCHEMES:
raise ValueError(
f"Unsupported URL scheme '{scheme}'. Only http and https are allowed."
)
def validate_filename(name: Optional[str]) -> str:
"""Validate a user-supplied filename.
Raises:
ValueError: If the name is empty, a dot name, or contains path parts.
"""
if (
not name
or name in (".", "..")
or os.path.basename(name) != name
or "/" in name
or "\\" in name
or "\x00" in name
):
raise ValueError(f"Invalid filename: {name!r}")
return name
def safe_server_filename(name: Optional[str], default: str) -> str:
"""Reduce a server-derived filename to a safe basename, or use default."""
if name:
candidate = os.path.basename(name.replace("\\", "/"))
try:
return validate_filename(candidate)
except ValueError:
pass
return default
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Only follow http(s) redirects and drop credentials on cross-host hops."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urlparse(newurl).scheme.lower() not in ALLOWED_SCHEMES:
raise urllib.error.HTTPError(
newurl, code, "Redirect to disallowed URL scheme", headers, fp
)
new_request = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_request is not None and (
urlparse(newurl).netloc.lower() != urlparse(req.full_url).netloc.lower()
):
new_request.remove_header("Authorization")
return new_request
class _NoRedirectProcessor(urllib.request.HTTPErrorProcessor):
"""Return every response as-is so redirects can be handled manually."""
def http_response(self, request, response):
return response
https_response = http_response
def _folder_paths():
"""Return the folder_paths module, or None when unavailable."""
try:
import folder_paths
return folder_paths
except ImportError:
return None
def _default_model_roots() -> List[str]:
"""Return ComfyUI's models dir plus every registered model folder.
Covers extra_model_paths.yaml entries on other drives.
"""
fp = _folder_paths()
if fp is None:
return []
roots: List[str] = []
models_dir = getattr(fp, "models_dir", None)
if isinstance(models_dir, str) and models_dir:
roots.append(models_dir)
registry = getattr(fp, "folder_names_and_paths", None)
if isinstance(registry, dict):
for name, entry in registry.items():
if name in EXCLUDED_FOLDER_NAMES:
continue
try:
paths = entry[0]
except (TypeError, IndexError, KeyError):
continue
if isinstance(paths, (list, tuple)):
roots.extend(p for p in paths if isinstance(p, str) and p)
return roots
def _default_base_path() -> Optional[str]:
"""Return ComfyUI's base path used to resolve relative save paths."""
fp = _folder_paths()
base = getattr(fp, "base_path", None) if fp is not None else None
return base if isinstance(base, str) and base else None
class BaseDownloader(ABC): class BaseDownloader(ABC):
"""Abstract base class for all downloaders""" """Abstract base class for all downloaders"""
def __init__(self, token: Optional[str] = None): def __init__(
self,
token: Optional[str] = None,
models_root: Union[str, Sequence[str], None] = None,
):
"""Initialize downloader with optional API token """Initialize downloader with optional API token
Args: Args:
token: Optional API token for authentication token: Optional API token for authentication
models_root: Directory (or list of directories) downloads must
stay inside. Defaults to ComfyUI's models directory plus all
registered model folders.
""" """
self.token = token self.token = token
if isinstance(models_root, str):
models_root = [models_root]
self._models_root = list(models_root) if models_root else None
self._progress_callback: Optional[Callable[[int, int, str], None]] = None self._progress_callback: Optional[Callable[[int, int, str], None]] = None
def set_progress_callback(self, callback: Callable[[int, int, str], None]) -> None: def set_progress_callback(self, callback: Callable[[int, int, str], None]) -> None:
@@ -90,16 +241,152 @@ class BaseDownloader(ABC):
Returns: Returns:
Extracted filename or None Extracted filename or None
""" """
try: if not content_disposition:
if "filename=" in content_disposition: return None
filename = content_disposition.split("filename=")[1] match = re.search(r"filename\*\s*=\s*[^']*'[^']*'([^;]+)", content_disposition)
# Remove quotes and whitespace if match:
filename = filename.strip().strip('"').strip("'") return unquote(match.group(1).strip()) or None
return filename match = re.search(
except Exception: r'filename\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^;]+))', content_disposition
pass )
if match:
value = next(g for g in match.groups() if g is not None)
return value.strip() or None
return None return None
@staticmethod
def _is_within(path: str, root: str) -> bool:
try:
return os.path.commonpath([root, path]) == root
except ValueError: # different drives
return False
def resolve_output_dir(self, output_path: str) -> str:
"""Resolve output_path and ensure it lives inside the models root.
Returns:
Real (symlink-resolved) directory path, created if missing.
Raises:
ValueError: If the path escapes the models directory.
"""
roots = self._models_root or _default_model_roots()
if not roots:
raise ValueError("Models directory is unavailable; refusing to download")
if not os.path.isabs(output_path):
output_path = os.path.join(_default_base_path() or os.getcwd(), output_path)
real_dir = os.path.realpath(output_path)
real_roots = [os.path.realpath(r) for r in roots]
if not any(self._is_within(real_dir, r) for r in real_roots):
raise ValueError(
f"Save path must be inside a ComfyUI models directory "
f"({', '.join(real_roots)})"
)
self.validate_output_path(real_dir)
return real_dir
def open_url(self, request: urllib.request.Request, follow_redirects: bool = True):
"""Open a request with scheme checks, timeout and safe redirects."""
validate_url_scheme(request.full_url)
handler = _SafeRedirectHandler if follow_redirects else _NoRedirectProcessor
opener = urllib.request.build_opener(handler)
return opener.open(request, timeout=DOWNLOAD_TIMEOUT)
@staticmethod
def parse_content_length(response: Any) -> Optional[int]:
"""Return Content-Length as int, or None if absent/invalid."""
value = response.getheader("Content-Length")
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def save_response(
self,
response: Any,
output_file: str,
total_size: Optional[int],
strict_size: bool = True,
) -> str:
"""Stream response to output_file atomically via a .part file.
The .part file is removed on any failure (including interrupts).
Raises:
ValueError: If output_file is not a model/data file type.
"""
extension = os.path.splitext(output_file)[1].lower()
if extension not in ALLOWED_FILE_EXTENSIONS:
raise ValueError(
f"Refusing to save file type '{extension or 'none'}': "
f"only model files ({', '.join(sorted(ALLOWED_FILE_EXTENSIONS))})"
)
part_file = output_file + PART_SUFFIX
try:
downloaded = 0
start_time = time.time()
with open(part_file, "wb") as f:
while True:
chunk_start = time.time()
buffer = response.read(CHUNK_SIZE)
chunk_time = time.time() - chunk_start
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
self.check_interrupt()
speed = self.calculate_speed(len(buffer), chunk_time)
self._report_chunk(downloaded, total_size, speed)
sys.stdout.write("\n")
actual_size = os.path.getsize(part_file)
if total_size and actual_size != total_size:
message = (
f"Download incomplete. Expected {total_size} bytes, "
f"got {actual_size} bytes"
)
if strict_size:
raise Exception(message)
logger.warning(message)
os.replace(part_file, output_file)
logger.info(
"Download completed in %.0fs: %s", time.time() - start_time, output_file
)
return output_file
except BaseException:
if os.path.exists(part_file):
os.remove(part_file)
raise
finally:
close = getattr(response, "close", None)
if callable(close):
close()
def _report_chunk(
self, downloaded: int, total_size: Optional[int], speed: float
) -> None:
"""Write console progress and notify the callback."""
if total_size:
progress = downloaded / total_size
sys.stdout.write(
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
)
self.report_progress(downloaded, total_size, f"{speed:.2f} MB/s")
else:
sys.stdout.write(
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
)
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
sys.stdout.flush()
def raise_for_http_status(self, code: int, reason: str, hints: dict) -> None:
"""Raise a friendly Exception for an HTTP error status."""
message = hints.get(code)
if message is None and code == 429:
message = "Rate limited. Please wait a moment before trying again."
raise Exception(message or f"HTTP error {code}: {reason}")
def validate_output_path(self, output_path: str) -> bool: def validate_output_path(self, output_path: str) -> bool:
"""Validate and create output path if needed """Validate and create output path if needed
+74 -177
View File
@@ -1,48 +1,37 @@
"""CivitAI downloader implementation""" """CivitAI downloader implementation"""
import os
import sys
import json import json
import time import logging
import os
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import urllib.error import urllib.error
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from urllib.parse import urlparse, parse_qs, unquote from urllib.parse import urlparse, parse_qs, unquote, urljoin
from .base import BaseDownloader from .base import (
USER_AGENT,
BaseDownloader,
safe_server_filename,
validate_filename,
validate_url_scheme,
)
try: logger = logging.getLogger(__name__)
import comfy.model_management
COMFY_AVAILABLE = True
InterruptProcessingException = comfy.model_management.InterruptProcessingException
except ImportError:
COMFY_AVAILABLE = False
# Fallback exception type that will never be raised
InterruptProcessingException = type(
"InterruptProcessingException", (Exception,), {}
)
CHUNK_SIZE = 1638400
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
API_BASE = "https://civitai.com/api/v1" API_BASE = "https://civitai.com/api/v1"
MAX_RETRIES = 3 REDIRECT_STATUSES = (301, 302, 303, 307, 308)
RETRY_DELAY = 5
HTTP_ERROR_HINTS = {
401: "Authentication required. Please provide a valid API token.",
403: "Access forbidden. The model might be restricted or require special permissions.",
404: "Model not found. The URL might be incorrect or the model was removed.",
}
class CivitAIDownloader(BaseDownloader): class CivitAIDownloader(BaseDownloader):
"""Downloader for CivitAI models""" """Downloader for CivitAI models"""
def __init__(self, token: Optional[str] = None):
"""Initialize CivitAI downloader
Args:
token: Optional CivitAI API token
"""
super().__init__(token)
def _make_request( def _make_request(
self, url: str, headers: Optional[Dict[str, str]] = None self, url: str, headers: Optional[Dict[str, str]] = None
) -> urllib.request.Request: ) -> urllib.request.Request:
@@ -117,7 +106,7 @@ class CivitAIDownloader(BaseDownloader):
request = self._make_request(url) request = self._make_request(url)
try: try:
with urllib.request.urlopen(request) as response: with self.open_url(request) as response:
return json.loads(response.read().decode()) return json.loads(response.read().decode())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 404: if e.code == 404:
@@ -145,8 +134,10 @@ class CivitAIDownloader(BaseDownloader):
Raises: Raises:
Exception: If download fails Exception: If download fails
""" """
# Validate output path validate_url_scheme(url)
self.validate_output_path(output_path) if filename:
validate_filename(filename)
output_dir = self.resolve_output_dir(output_path)
# Validate that URL is from civitai.com domain # Validate that URL is from civitai.com domain
parsed_url = urlparse(url) parsed_url = urlparse(url)
@@ -181,161 +172,67 @@ class CivitAIDownloader(BaseDownloader):
headers = {"User-Agent": USER_AGENT} headers = {"User-Agent": USER_AGENT}
if self.token: if self.token:
headers["Authorization"] = f"Bearer {self.token}" headers["Authorization"] = f"Bearer {self.token}"
# Disable automatic redirect handling
class NoRedirection(urllib.request.HTTPErrorProcessor):
def http_response(self, request, response):
return response
https_response = http_response
request = urllib.request.Request(url, headers=headers) request = urllib.request.Request(url, headers=headers)
opener = urllib.request.build_opener(NoRedirection)
try: try:
response = opener.open(request) response = self.open_url(request, follow_redirects=False)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
raise Exception( except urllib.error.URLError as e:
"Authentication required. Please provide a valid API token." raise Exception(f"Network error: {e.reason}")
)
elif e.code == 403:
raise Exception(
"Access forbidden. The model might be restricted or require special permissions."
)
elif e.code == 404:
raise Exception(
"Model not found. The URL might be incorrect or the model was removed."
)
elif e.code == 429:
raise Exception(
"Rate limited. Please wait a moment before trying again."
)
else:
raise Exception(f"HTTP error {e.code}: {e.reason}")
# Handle redirects if response.status in REDIRECT_STATUSES:
if response.status in [301, 302, 303, 307, 308]:
redirect_url = response.getheader("Location") redirect_url = response.getheader("Location")
response.close()
if not redirect_url:
raise Exception("Redirect response did not include a Location header")
redirect_url = urljoin(url, redirect_url)
validate_url_scheme(redirect_url)
# Handle relative redirects
if redirect_url.startswith("/"):
base_url = urlparse(url)
redirect_url = f"{base_url.scheme}://{base_url.netloc}{redirect_url}"
# Extract filename from redirect URL if not provided
if not filename: if not filename:
parsed_url = urlparse(redirect_url) filename = self._filename_from_redirect(redirect_url)
query_params = parse_qs(parsed_url.query)
content_disposition = query_params.get(
"response-content-disposition", [None]
)[0]
if content_disposition and "filename=" in content_disposition: # The signed redirect target gets no credentials
filename = unquote( try:
content_disposition.split("filename=")[1].strip('"') response = self.open_url(
urllib.request.Request(
redirect_url, headers={"User-Agent": USER_AGENT}
) )
else:
# Fallback: extract filename from URL path
path = parsed_url.path
if path and "/" in path:
filename = path.split("/")[-1]
else:
filename = "downloaded_file.safetensors"
response = urllib.request.urlopen(redirect_url)
elif response.status == 404:
raise Exception("File not found")
elif response.status != 200:
raise Exception(f"Download failed with status {response.status}")
# Use provided filename or extracted filename
if not filename:
filename = self.extract_filename(url, default="model.safetensors")
output_file = os.path.join(output_path, filename)
# Check if should download
if not self.should_download(output_file, force):
print(f"File already exists: {output_file}")
return output_file
total_size = response.getheader("Content-Length")
if total_size is not None:
total_size = int(total_size)
print(f"Downloading: {filename}")
print(f"Destination: {output_file}")
if total_size:
print(f"Size: {self.format_size(total_size)}")
# Download with progress
try:
with open(output_file, "wb") as f:
downloaded = 0
start_time = time.time()
while True:
chunk_start_time = time.time()
buffer = response.read(CHUNK_SIZE)
chunk_end_time = time.time()
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
chunk_time = chunk_end_time - chunk_start_time
# Check for user cancellation
self.check_interrupt()
# Calculate speed
speed = self.calculate_speed(len(buffer), chunk_time)
# Report progress
if total_size is not None:
progress = downloaded / total_size
sys.stdout.write(
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
)
sys.stdout.flush()
self.report_progress(
downloaded, total_size, f"{speed:.2f} MB/s"
)
else:
sys.stdout.write(
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
)
sys.stdout.flush()
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
end_time = time.time()
time_taken = end_time - start_time
hours, remainder = divmod(time_taken, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
elif minutes > 0:
time_str = f"{int(minutes)}m {int(seconds)}s"
else:
time_str = f"{int(seconds)}s"
sys.stdout.write("\n")
print(f"✓ Download completed in {time_str}")
print(f"✓ File saved as: {output_file}")
# Verify file size
actual_size = os.path.getsize(output_file)
if total_size and actual_size != total_size:
raise Exception(
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
) )
except urllib.error.HTTPError as e:
self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
except urllib.error.URLError as e:
raise Exception(f"Network error: {e.reason}")
elif response.status != 200:
code = response.status
response.close()
self.raise_for_http_status(
code, "Download failed", {**HTTP_ERROR_HINTS, 404: "File not found"}
)
if not filename:
filename = safe_server_filename(
self.extract_filename(url, default="model.safetensors"),
"model.safetensors",
)
output_file = os.path.join(output_dir, filename)
if not self.should_download(output_file, force):
response.close()
logger.info("File already exists: %s", output_file)
return output_file return output_file
except InterruptProcessingException:
# Clean up partial download on interrupt total_size = self.parse_content_length(response)
if os.path.exists(output_file): logger.info("Downloading %s to %s", filename, output_file)
os.remove(output_file) return self.save_response(response, output_file, total_size)
raise InterruptProcessingException("Download interrupted")
def _filename_from_redirect(self, redirect_url: str) -> str:
"""Derive a safe filename from a CivitAI redirect URL."""
parsed = urlparse(redirect_url)
disposition = parse_qs(parsed.query).get("response-content-disposition", [""])[
0
]
name = self.extract_filename_from_header(unquote(disposition))
if not name:
name = parsed.path.rsplit("/", 1)[-1]
return safe_server_filename(name, "downloaded_file.safetensors")
+52 -158
View File
@@ -1,41 +1,36 @@
"""Custom URL downloader - best effort for direct download links""" """Custom URL downloader - best effort for direct download links"""
import logging
import os import os
import sys
import time
import urllib.request
import urllib.error import urllib.error
import urllib.request
from typing import Optional from typing import Optional
from .base import BaseDownloader from .base import (
USER_AGENT,
BaseDownloader,
safe_server_filename,
validate_filename,
validate_url_scheme,
)
try: logger = logging.getLogger(__name__)
import comfy.model_management
COMFY_AVAILABLE = True DEFAULT_FILENAME = "downloaded_model.safetensors"
InterruptProcessingException = comfy.model_management.InterruptProcessingException
except ImportError:
COMFY_AVAILABLE = False
# Fallback exception type that will never be raised
InterruptProcessingException = type(
"InterruptProcessingException", (Exception,), {}
)
HTTP_ERROR_HINTS = {
CHUNK_SIZE = 1638400 401: "Authentication required. Please provide a valid token if needed.",
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 403: "Access forbidden. The URL might require authentication or special permissions.",
404: "File not found. Please check the URL.",
}
class CustomDownloader(BaseDownloader): class CustomDownloader(BaseDownloader):
"""Best-effort downloader for custom/direct URLs""" """Best-effort downloader for custom/direct URLs
def __init__(self, token: Optional[str] = None): The optional token is sent as a Bearer token and is dropped automatically
"""Initialize custom downloader if the server redirects to a different host.
"""
Args:
token: Optional authentication token (will be sent as Bearer token)
"""
super().__init__(token)
def download( def download(
self, self,
@@ -47,158 +42,57 @@ class CustomDownloader(BaseDownloader):
"""Download file from custom URL """Download file from custom URL
Args: Args:
url: Direct download URL url: Direct download URL (http or https)
output_path: Directory to save file output_path: Directory to save file (must be inside models dir)
filename: Optional filename override filename: Optional filename override (plain name, no path parts)
force: Force re-download if file exists force: Force re-download if file exists
Returns: Returns:
Path to downloaded file Path to downloaded file
Raises: Raises:
ValueError: For bad scheme, filename or save path
Exception: If download fails Exception: If download fails
""" """
# Validate output path validate_url_scheme(url)
self.validate_output_path(output_path) if filename:
validate_filename(filename)
output_dir = self.resolve_output_dir(output_path)
# Determine filename # Fast path: a known filename lets us skip the request entirely
if not filename: if filename:
filename = self.extract_filename( output_file = os.path.join(output_dir, filename)
url, default="downloaded_model.safetensors" if not self.should_download(output_file, force):
) logger.info("File already exists: %s", output_file)
return output_file
output_file = os.path.join(output_path, filename)
# Check if should download
if not self.should_download(output_file, force):
print(f"File already exists: {output_file}")
return output_file
# Prepare headers
headers = {"User-Agent": USER_AGENT} headers = {"User-Agent": USER_AGENT}
# Add authentication if token provided
if self.token: if self.token:
headers["Authorization"] = f"Bearer {self.token}" headers["Authorization"] = f"Bearer {self.token}"
# Create request
request = urllib.request.Request(url, headers=headers) request = urllib.request.Request(url, headers=headers)
try: try:
# First request to check if file exists and get metadata response = self.open_url(request)
response = urllib.request.urlopen(request)
# Try to extract filename from Content-Disposition header if not provided
if not filename:
content_disposition = response.getheader("Content-Disposition")
if content_disposition:
extracted_filename = self.extract_filename_from_header(
content_disposition
)
if extracted_filename:
filename = extracted_filename
output_file = os.path.join(output_path, filename)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
raise Exception(
"Authentication required. Please provide a valid token if needed."
)
elif e.code == 403:
raise Exception(
"Access forbidden. The URL might require authentication or special permissions."
)
elif e.code == 404:
raise Exception("File not found. Please check the URL.")
elif e.code == 429:
raise Exception(
"Rate limited. Please wait a moment before trying again."
)
else:
raise Exception(f"HTTP error {e.code}: {e.reason}")
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise Exception(f"Network error: {e.reason}") raise Exception(f"Network error: {e.reason}")
# Get file size if not filename:
total_size = response.getheader("Content-Length") header_name = self.extract_filename_from_header(
if total_size is not None: response.getheader("Content-Disposition") or ""
total_size = int(total_size) )
filename = safe_server_filename(
header_name, self.extract_filename(url, default=DEFAULT_FILENAME)
)
output_file = os.path.join(output_dir, filename)
if not self.should_download(output_file, force):
response.close()
logger.info("File already exists: %s", output_file)
return output_file
print(f"Downloading: {filename}") total_size = self.parse_content_length(response)
print(f"Destination: {output_file}") logger.info("Downloading %s to %s", filename, output_file)
if total_size:
print(f"Size: {self.format_size(total_size)}")
else:
print("Size: Unknown")
# Download with progress # Size mismatch is only a warning for arbitrary URLs
try: return self.save_response(response, output_file, total_size, strict_size=False)
with open(output_file, "wb") as f:
downloaded = 0
start_time = time.time()
while True:
chunk_start_time = time.time()
buffer = response.read(CHUNK_SIZE)
chunk_end_time = time.time()
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
chunk_time = chunk_end_time - chunk_start_time
# Check for user cancellation
self.check_interrupt()
# Calculate speed
speed = self.calculate_speed(len(buffer), chunk_time)
# Report progress
if total_size is not None:
progress = downloaded / total_size
sys.stdout.write(
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
)
sys.stdout.flush()
self.report_progress(
downloaded, total_size, f"{speed:.2f} MB/s"
)
else:
sys.stdout.write(
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
)
sys.stdout.flush()
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
end_time = time.time()
time_taken = end_time - start_time
hours, remainder = divmod(time_taken, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
elif minutes > 0:
time_str = f"{int(minutes)}m {int(seconds)}s"
else:
time_str = f"{int(seconds)}s"
sys.stdout.write("\n")
print(f"✓ Download completed in {time_str}")
print(f"✓ File saved as: {output_file}")
# Verify file size if known
actual_size = os.path.getsize(output_file)
if total_size and actual_size != total_size:
print(
f"⚠ Warning: Downloaded size ({actual_size} bytes) doesn't match expected size ({total_size} bytes)"
)
# Don't raise error for custom URLs as size mismatch might be acceptable
return output_file
except InterruptProcessingException:
# Clean up partial download on interrupt
if os.path.exists(output_file):
os.remove(output_file)
raise
+33 -143
View File
@@ -1,43 +1,32 @@
"""HuggingFace downloader implementation""" """HuggingFace downloader implementation"""
import logging
import os import os
import sys
import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from typing import Optional from typing import Optional
from urllib.parse import urlparse, quote from urllib.parse import urlparse, quote
from .base import BaseDownloader from .base import (
USER_AGENT,
BaseDownloader,
safe_server_filename,
validate_filename,
validate_url_scheme,
)
try: logger = logging.getLogger(__name__)
import comfy.model_management
COMFY_AVAILABLE = True HTTP_ERROR_HINTS = {
InterruptProcessingException = comfy.model_management.InterruptProcessingException 401: "Authentication required. Please provide a valid HuggingFace token.",
except ImportError: 403: "Access forbidden. The model might be gated or require special permissions.",
COMFY_AVAILABLE = False 404: "File not found. The URL might be incorrect or the file was removed.",
# Fallback exception type that will never be raised }
InterruptProcessingException = type(
"InterruptProcessingException", (Exception,), {}
)
CHUNK_SIZE = 1638400
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
class HuggingFaceDownloader(BaseDownloader): class HuggingFaceDownloader(BaseDownloader):
"""Downloader for HuggingFace models""" """Downloader for HuggingFace models"""
def __init__(self, token: Optional[str] = None):
"""Initialize HuggingFace downloader
Args:
token: Optional HuggingFace API token
"""
super().__init__(token)
def _parse_huggingface_url(self, url: str) -> dict: def _parse_huggingface_url(self, url: str) -> dict:
"""Parse HuggingFace URL to extract repo and file information """Parse HuggingFace URL to extract repo and file information
@@ -115,21 +104,23 @@ class HuggingFaceDownloader(BaseDownloader):
"""Download file from HuggingFace """Download file from HuggingFace
Args: Args:
url: HuggingFace URL to download url: HuggingFace URL to download (http or https)
output_path: Directory to save file output_path: Directory to save file (must be inside models dir)
filename: Optional filename override filename: Optional filename override (plain name, no path parts)
force: Force re-download if file exists force: Force re-download if file exists
Returns: Returns:
Path to downloaded file Path to downloaded file
Raises: Raises:
ValueError: For bad scheme, filename or save path
Exception: If download fails Exception: If download fails
""" """
# Validate output path validate_url_scheme(url)
self.validate_output_path(output_path) if filename:
validate_filename(filename)
output_dir = self.resolve_output_dir(output_path)
# Parse URL to get file information
url_info = self._parse_huggingface_url(url) url_info = self._parse_huggingface_url(url)
# Convert blob URL to resolve URL if needed # Convert blob URL to resolve URL if needed
@@ -137,135 +128,34 @@ class HuggingFaceDownloader(BaseDownloader):
download_url = self._construct_download_url( download_url = self._construct_download_url(
url_info["repo_id"], url_info["filename"], url_info["revision"] url_info["repo_id"], url_info["filename"], url_info["revision"]
) )
print(f"[HuggingFace] Converted URL to: {download_url}") logger.info("Converted URL to: %s", download_url)
else: else:
# Use original URL if parsing failed
download_url = url download_url = url
# Determine filename
if not filename: if not filename:
if url_info["filename"]: if url_info["filename"]:
# Use just the basename from the URL name = os.path.basename(url_info["filename"])
filename = os.path.basename(url_info["filename"])
else: else:
filename = self.extract_filename(url, default="model.safetensors") name = self.extract_filename(url, default="model.safetensors")
filename = safe_server_filename(name, "model.safetensors")
output_file = os.path.join(output_path, filename) output_file = os.path.join(output_dir, filename)
# Check if should download
if not self.should_download(output_file, force): if not self.should_download(output_file, force):
print(f"File already exists: {output_file}") logger.info("File already exists: %s", output_file)
return output_file return output_file
# Prepare headers
headers = {"User-Agent": USER_AGENT} headers = {"User-Agent": USER_AGENT}
if self.token: if self.token:
headers["Authorization"] = f"Bearer {self.token}" headers["Authorization"] = f"Bearer {self.token}"
# Create request with converted download URL
request = urllib.request.Request(download_url, headers=headers) request = urllib.request.Request(download_url, headers=headers)
try: try:
response = urllib.request.urlopen(request) response = self.open_url(request)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
raise Exception(
"Authentication required. Please provide a valid HuggingFace token."
)
elif e.code == 403:
raise Exception(
"Access forbidden. The model might be gated or require special permissions."
)
elif e.code == 404:
raise Exception(
"File not found. The URL might be incorrect or the file was removed."
)
elif e.code == 429:
raise Exception(
"Rate limited. Please wait a moment before trying again."
)
else:
raise Exception(f"HTTP error {e.code}: {e.reason}")
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise Exception(f"Network error: {e.reason}") raise Exception(f"Network error: {e.reason}")
# Get file size total_size = self.parse_content_length(response)
total_size = response.getheader("Content-Length") logger.info("Downloading %s to %s", filename, output_file)
if total_size is not None: return self.save_response(response, output_file, total_size)
total_size = int(total_size)
print(f"Downloading: {filename}")
print(f"Destination: {output_file}")
if total_size:
print(f"Size: {self.format_size(total_size)}")
# Download with progress
try:
with open(output_file, "wb") as f:
downloaded = 0
start_time = time.time()
while True:
chunk_start_time = time.time()
buffer = response.read(CHUNK_SIZE)
chunk_end_time = time.time()
if not buffer:
break
downloaded += len(buffer)
f.write(buffer)
chunk_time = chunk_end_time - chunk_start_time
# Check for user cancellation
self.check_interrupt()
# Calculate speed
speed = self.calculate_speed(len(buffer), chunk_time)
# Report progress
if total_size is not None:
progress = downloaded / total_size
sys.stdout.write(
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
)
sys.stdout.flush()
self.report_progress(
downloaded, total_size, f"{speed:.2f} MB/s"
)
else:
sys.stdout.write(
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
)
sys.stdout.flush()
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
end_time = time.time()
time_taken = end_time - start_time
hours, remainder = divmod(time_taken, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
elif minutes > 0:
time_str = f"{int(minutes)}m {int(seconds)}s"
else:
time_str = f"{int(seconds)}s"
sys.stdout.write("\n")
print(f"✓ Download completed in {time_str}")
print(f"✓ File saved as: {output_file}")
# Verify file size
actual_size = os.path.getsize(output_file)
if total_size and actual_size != total_size:
raise Exception(
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
)
return output_file
except InterruptProcessingException:
# Clean up partial download on interrupt
if os.path.exists(output_file):
os.remove(output_file)
raise
+12 -10
View File
@@ -1,8 +1,12 @@
"""ComfyUI Model Downloader Node""" """ComfyUI Model Downloader Node"""
import logging
from ...base import ComfyAssetsBaseNode from ...base import ComfyAssetsBaseNode
from .detector import URLDetector from .detector import URLDetector
logger = logging.getLogger(__name__)
class ModelDownloaderNode(ComfyAssetsBaseNode): class ModelDownloaderNode(ComfyAssetsBaseNode):
"""ComfyUI node for downloading models from CivitAI, HuggingFace, and custom URLs""" """ComfyUI node for downloading models from CivitAI, HuggingFace, and custom URLs"""
@@ -101,15 +105,13 @@ class ModelDownloaderNode(ComfyAssetsBaseNode):
detector = URLDetector() detector = URLDetector()
downloader_type = detector.detect(url) downloader_type = detector.detect(url)
print( logger.info("Detected downloader type: %s", downloader_type.value)
f"\n[Model Downloader] Detected downloader type: {downloader_type.value}" logger.info("URL: %s", url)
) logger.info("Save path: %s", save_path)
print(f"[Model Downloader] URL: {url}")
print(f"[Model Downloader] Save path: {save_path}")
if filename: if filename:
print(f"[Model Downloader] Filename: {filename}") logger.info("Filename: %s", filename)
if force_download: if force_download:
print("[Model Downloader] Force download: enabled") logger.info("Force download: enabled")
# Get downloader instance # Get downloader instance
downloader = detector.get_downloader(url, api_token=api_token) downloader = detector.get_downloader(url, api_token=api_token)
@@ -120,18 +122,18 @@ class ModelDownloaderNode(ComfyAssetsBaseNode):
) )
message = f"Successfully downloaded to {file_path}" message = f"Successfully downloaded to {file_path}"
print(f"[Model Downloader] {message}") logger.info(message)
return {"ui": {"text": [message]}} return {"ui": {"text": [message]}}
except ValueError as e: except ValueError as e:
error_msg = f"Invalid URL: {str(e)}" error_msg = f"Invalid URL: {str(e)}"
print(f"[Model Downloader] Error: {error_msg}") logger.error(error_msg)
return {"ui": {"text": [error_msg]}} return {"ui": {"text": [error_msg]}}
except Exception as e: except Exception as e:
error_msg = f"Download failed: {str(e)}" error_msg = f"Download failed: {str(e)}"
print(f"[Model Downloader] Error: {error_msg}") logger.error(error_msg)
return {"ui": {"text": [error_msg]}} return {"ui": {"text": [error_msg]}}
@classmethod @classmethod
@@ -0,0 +1,5 @@
"""Model Resolution Picker module."""
from .node import ModelResolutionPickerNode
__all__ = ["ModelResolutionPickerNode"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
"""Model family table for the Model Resolution Picker node.
The data lives in ``families.json`` next to this module; it is loaded and
validated once at import (see ``table.py``). A shipped file that fails
validation is a startup error.
"""
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .logic import size_label
from .override import current_table
from .table import (
CHIPS,
MIRROR,
Family,
Size,
build_families,
load_table,
validate_table,
)
__all__ = [
"CHIPS",
"MIRROR",
"Family",
"Size",
"FAMILIES",
"DEFAULT_FAMILY",
"family_labels",
"default_family",
"resolve_family",
"families_payload",
"resolution_labels",
"SHIPPED_DATA",
"load_shipped_families",
]
TABLE_PATH = Path(__file__).with_name("families.json")
def load_shipped_families(path: Path = TABLE_PATH) -> Tuple[Tuple[Family, ...], str]:
"""Load and validate ``path``; return its families and default label."""
data = load_table(path)
problems = validate_table(data)
if problems:
listing = "\n - ".join(problems)
raise RuntimeError(f"invalid family table {path}:\n - {listing}")
return build_families(data), data["default"]
SHIPPED_DATA = load_table(TABLE_PATH)
FAMILIES, DEFAULT_FAMILY = load_shipped_families()
def _current() -> Tuple[Tuple[Family, ...], str, List[str]]:
"""Return families, default label and warnings for the merged table."""
data, warnings = current_table()
if data is SHIPPED_DATA:
return FAMILIES, DEFAULT_FAMILY, warnings
built = build_families(data)
return built, _default_label(built, data["default"]), warnings
def _default_label(built: Tuple[Family, ...], default: str) -> str:
"""Return the label of the family named by ``default`` (label or alias)."""
for family in built:
if default == family.label or default in family.aliases:
return family.label
return default
def family_labels() -> List[str]:
"""Return every family label of the merged table in display order."""
return [family.label for family in _current()[0]]
def default_family() -> str:
"""Return the merged table's default family label."""
return _current()[1]
def resolution_labels() -> List[str]:
"""Return the sorted distinct size labels across the shipped families."""
labels = {
size_label(width, height, chip)
for family in FAMILIES
for chip, sizes in family.sizes.items()
for width, height in sizes
}
return sorted(labels)
def resolve_family(label: str) -> Optional[Family]:
"""Return the family matching ``label`` or one of its aliases, else None."""
for family in _current()[0]:
if label == family.label or label in family.aliases:
return family
return None
def families_payload() -> Dict[str, Any]:
"""Return the JSON-shaped table the frontend consumes."""
current, default, warnings = _current()
return {
"chips": list(CHIPS),
"mirror": dict(MIRROR),
"default": default,
"warnings": list(warnings),
"families": [
{
"label": family.label,
"aliases": list(family.aliases),
"models": family.models,
"group": family.group,
"vaeRatio": family.vae_ratio,
"step": family.step,
"megapixels": list(family.megapixels),
"default": list(family.default),
"sizes": {
chip: [list(size) for size in family.sizes.get(chip, ())]
for chip in CHIPS
},
}
for family in current
],
}
@@ -0,0 +1,71 @@
"""Pure size math for the Model Resolution Picker node."""
import math
import re
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from .table import MAX_SIDE, MIN_SIDE
MEGAPIXEL = 1_048_576
_ALIASES = {
"8:5": "16:10",
"5:8": "10:16",
"7:3": "21:9",
"3:7": "9:21",
}
def round_to_step(value: float, step: int, minimum: int = 64) -> int:
"""Round ``value`` to the nearest multiple of ``step``, never below ``minimum``.
The result is also raised to the smallest multiple of ``step`` that is at
least ``minimum``.
"""
nearest = math.floor(value / step + 0.5) * step
floor_multiple = math.ceil(minimum / step) * step
return max(nearest, floor_multiple)
def generate_size(
ratio_w: int, ratio_h: int, megapixels: float, step: int
) -> tuple[int, int]:
"""Return a ``(width, height)`` near ``megapixels`` with the given aspect ratio.
Uses the core Resolution Selector scale ``sqrt(mp * MEGAPIXEL / (w * h))``
and snaps each side to ``step`` via :func:`round_to_step`.
"""
scale = math.sqrt(megapixels * MEGAPIXEL / (ratio_w * ratio_h))
width = round_to_step(ratio_w * scale, step)
height = round_to_step(ratio_h * scale, step)
return width, height
def ratio_label(width: int, height: int) -> str:
"""Return the reduced ``W:H`` label for a size, with common aliases applied."""
divisor = math.gcd(width, height)
label = f"{width // divisor}:{height // divisor}"
return _ALIASES.get(label, label)
_SIZE_LABEL = re.compile(r"^(\d{1,5})×(\d{1,5}) - \S+ \(\d+\.\dMP\)$")
def size_label(width: int, height: int, ratio_text: str) -> str:
"""Return the dropdown label ``W×H - ratio (X.XMP)`` for a size."""
exact = Decimal(width * height) / Decimal(MEGAPIXEL) # exact: MEGAPIXEL is 2**20
megapixels = exact.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) # as JS toFixed
return f"{width}×{height} - {ratio_text} ({megapixels}MP)"
def parse_size_label(text: object) -> Optional[tuple[int, int]]:
"""Return ``(width, height)`` from a :func:`size_label` string, else None."""
if not isinstance(text, str):
return None
match = _SIZE_LABEL.match(text)
if match is None:
return None
width, height = int(match.group(1)), int(match.group(2))
in_range = MIN_SIDE <= width <= MAX_SIDE and MIN_SIDE <= height <= MAX_SIDE
return (width, height) if in_range else None
@@ -0,0 +1,130 @@
"""Model Resolution Picker node and its families route for ComfyUI."""
import logging
from typing import Any, Dict, Tuple
from aiohttp import web
from ...base.base_node import ComfyAssetsBaseNode
from ..empty_latent_batch.logic import create_empty_latent_batch
from .families import (
default_family,
families_payload,
family_labels,
resolution_labels,
resolve_family,
)
from .logic import parse_size_label, round_to_step
from .table import MAX_SIDE, MIN_SIDE
logger = logging.getLogger(__name__)
FALLBACK_STEP = 8
class ModelResolutionPickerNode(ComfyAssetsBaseNode):
"""Pick a model family and size, and get an empty latent plus width/height."""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""Define the input types for the ComfyUI node."""
size_options = {"default": 1024, "min": MIN_SIDE, "max": MAX_SIDE, "step": 8}
return {
"required": {
"model": (
family_labels(),
{
"default": default_family(),
"tooltip": "Model family; the chips list its preferred sizes",
},
),
"resolution": (
["custom"] + resolution_labels(),
{
"default": "custom",
"tooltip": "Sizes for the selected model and ratio; "
"custom uses width/height",
},
),
"width": (
"INT",
{
**size_options,
"tooltip": "Custom width in pixels; rounded to the model "
"family's step",
},
),
"height": (
"INT",
{
**size_options,
"tooltip": "Custom height in pixels; rounded to the model "
"family's step",
},
),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Number of empty latents in the batch",
},
),
}
}
@classmethod
def VALIDATE_INPUTS(cls, model: str, resolution: str) -> bool:
"""Accept any label so renamed or future families still execute (create() falls back)."""
return True
RETURN_TYPES = ("LATENT", "INT", "INT")
RETURN_NAMES = ("latent", "width", "height")
FUNCTION = "create"
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
def create(
self, model: str, resolution: str, width: int, height: int, batch_size: int
) -> Tuple[Dict[str, Any], int, int]:
"""Round the size to the family's step and build the empty latent.
A listed ``resolution`` label overrides ``width``/``height``; ``custom``
or any label that does not parse leaves them alone.
"""
listed = parse_size_label(resolution) if resolution != "custom" else None
if listed is not None:
width, height = listed
family = resolve_family(model)
if family is None:
logger.warning(
"Unknown model family '%s'; using step %d", model, FALLBACK_STEP
)
step = family.step if family else FALLBACK_STEP
new_width = round_to_step(width, step)
new_height = round_to_step(height, step)
if (new_width, new_height) != (width, height):
self.log_info(
f"Rounded {width}x{height} to {new_width}x{new_height} "
f"(step {step})"
)
latent = create_empty_latent_batch(new_width, new_height, batch_size)
return (latent, new_width, new_height)
async def get_families(request: Any) -> web.Response:
"""Serve the model family table as JSON."""
return web.json_response(families_payload())
# Setup API routes
try:
import server
server.PromptServer.instance.routes.get("/kikotools/model_resolution/families")(
get_families
)
except ImportError:
# Server not available during testing
pass
@@ -0,0 +1,122 @@
"""User override file for the Model Resolution Picker family table.
``<user dir>/kikotools/model_resolutions.json`` may replace families by label,
append new ones, remove shipped ones and change the default. The merged table
is validated as a whole; an override that fails to load, merge or validate is
ignored entirely and reported as a warning.
"""
import copy
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from .table import load_table, validate_table
logger = logging.getLogger(__name__)
OVERRIDE_DIR = "kikotools"
OVERRIDE_NAME = "model_resolutions.json"
MAX_REPORTED_PROBLEMS = 3
Table = Dict[str, Any]
_CacheKey = Tuple[Path, int, int]
_cache: Optional[Tuple[_CacheKey, Tuple[Table, List[str]]]] = None
def _reset_cache() -> None:
"""Forget the cached merge result (used by tests)."""
global _cache
_cache = None
def override_path() -> Optional[Path]:
"""Return the override file location, or None when no user dir is known."""
try:
import folder_paths
user_dir = folder_paths.get_user_directory()
return Path(user_dir) / OVERRIDE_DIR / OVERRIDE_NAME
except Exception: # ImportError, AttributeError, or a broken host
return None
def _check_override_shape(override: Any) -> None:
"""Raise ValueError unless ``override`` has the minimal expected shape."""
if not isinstance(override, dict):
raise ValueError("override must be a JSON object")
fams = override.get("families", [])
if not isinstance(fams, list):
raise ValueError("'families' must be a list")
for index, fam in enumerate(fams):
if not isinstance(fam, dict) or not isinstance(fam.get("label"), str):
raise ValueError(f"families[{index}] must be an object with a string label")
remove = override.get("remove", [])
if not isinstance(remove, list) or not all(isinstance(r, str) for r in remove):
raise ValueError("'remove' must be a list of strings")
default = override.get("default")
if default is not None and not isinstance(default, str):
raise ValueError("'default' must be a string")
def merge_override(shipped: Table, override: Table) -> Table:
"""Return a new table: ``shipped`` with ``override`` applied (no mutation)."""
_check_override_shape(override)
merged = copy.deepcopy(shipped)
replacements = {f["label"]: f for f in override.get("families", [])}
removed = set(override.get("remove", []))
result: List[Any] = []
for fam in merged.get("families", []):
label = fam.get("label") if isinstance(fam, dict) else None
if label in removed:
continue
result.append(
copy.deepcopy(replacements.pop(label)) if label in replacements else fam
)
result.extend(
copy.deepcopy(f) for label, f in replacements.items() if label not in removed
)
merged["families"] = result
if isinstance(override.get("default"), str):
merged["default"] = override["default"]
return merged
def _ignored(path: Path, reason: str, shipped: Table) -> Tuple[Table, List[str]]:
message = f"Override ignored: {path}: {reason}"
logger.warning(message)
return shipped, [message]
def _build(path: Path, shipped: Table) -> Tuple[Table, List[str]]:
"""Load, merge and validate the override at ``path``."""
try:
merged = merge_override(shipped, load_table(path))
problems = validate_table(merged)
except (ValueError, RecursionError) as exc:
reason = "nested too deeply" if isinstance(exc, RecursionError) else str(exc)
return _ignored(path, reason, shipped)
if problems:
return _ignored(path, "; ".join(problems[:MAX_REPORTED_PROBLEMS]), shipped)
return merged, []
def current_table() -> Tuple[Table, List[str]]:
"""Return ``(table, warnings)``: shipped data merged with a valid override."""
global _cache
from .families import SHIPPED_DATA
path = override_path()
if path is None:
return SHIPPED_DATA, []
try:
stat = path.stat()
key: _CacheKey = (path, stat.st_mtime_ns, stat.st_size)
except OSError:
return SHIPPED_DATA, []
if _cache is not None and _cache[0] == key:
return _cache[1]
result = _build(path, SHIPPED_DATA)
_cache = (key, result)
return result
@@ -0,0 +1,313 @@
"""Load, validate and build the Model Resolution Picker family table.
The table is plain JSON (shipped as ``families.json``). ``validate_table`` is a
pure function that returns human-readable problems and never raises for data
problems, so the same check can guard the shipped file and a user override.
Unknown keys, such as a per-family ``"notes"`` string, are ignored.
"""
import json
import math
from pathlib import Path
from typing import Any, Dict, List, NamedTuple, Tuple
CHIPS: Tuple[str, ...] = (
"1:1",
"4:5",
"5:4",
"3:4",
"4:3",
"2:3",
"3:2",
"9:16",
"16:9",
"21:9",
"9:21",
)
MIRROR: Dict[str, str] = {
"1:1": "1:1",
"4:5": "5:4",
"5:4": "4:5",
"3:4": "4:3",
"4:3": "3:4",
"2:3": "3:2",
"3:2": "2:3",
"9:16": "16:9",
"16:9": "9:16",
"21:9": "9:21",
"9:21": "21:9",
}
TABLE_VERSION = 1
GROUPS = ("image", "video")
VAE_RATIOS = (8, 16, 32)
CHIP_TOLERANCE = 0.07
# Sanity bounds so absurd values are reported instead of overflowing.
MAX_STEP = 4096
# The empty latent is allocated at 1/8 of the pixel size, so every family step
# must keep rounded sizes divisible by 8 (see empty_latent_batch.logic).
LATENT_STEP = 8
MAX_MEGAPIXELS = 64
# Match the node's width/height widget limits.
MIN_SIDE = 64
MAX_SIDE = 8192
Size = Tuple[int, int]
class Family(NamedTuple):
"""One model family: grid constraints, size tiers and the chip size lists."""
label: str
aliases: Tuple[str, ...]
models: str
group: str
vae_ratio: int
step: int
megapixels: Tuple[float, ...]
default: Size
sizes: Dict[str, Tuple[Size, ...]]
def load_table(path: Path) -> Dict[str, Any]:
"""Read ``path`` as JSON; raise ``ValueError`` naming the path on failure."""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError) as error:
raise ValueError(f"cannot read family table {path}: {error}") from error
if not isinstance(data, dict):
raise ValueError(f"family table {path} must be a JSON object")
return data
def _is_size(value: Any) -> bool:
return (
isinstance(value, (list, tuple))
and len(value) == 2
and all(
isinstance(v, int) and not isinstance(v, bool) and MIN_SIDE <= v <= MAX_SIDE
for v in value
)
)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_finite(value: Any) -> bool:
"""Return True for finite numbers; ints are always finite (and may be huge)."""
return isinstance(value, int) or math.isfinite(value)
def _is_step(value: Any) -> bool:
return (
_is_number(value)
and _is_finite(value)
and 0 < value <= MAX_STEP
and value == int(value)
)
def _chip_ratio(chip: str) -> float:
left, right = chip.split(":")
return int(left) / int(right)
def _validate_top_level(data: Dict[str, Any]) -> List[str]:
problems: List[str] = []
if data.get("version") != TABLE_VERSION:
problems.append(f"version must be {TABLE_VERSION}")
if data.get("chips") != list(CHIPS):
problems.append(f"chips must equal {list(CHIPS)}")
if data.get("mirror") != MIRROR:
problems.append("mirror must pair every chip with its mirrored chip")
if not isinstance(data.get("families"), list) or not data["families"]:
problems.append("families must be a non-empty list")
return problems
def _validate_names(families: List[Any]) -> List[str]:
problems: List[str] = []
seen: Dict[str, str] = {}
for index, fam in enumerate(families):
label = fam.get("label")
if not isinstance(label, str) or not label:
problems.append(f"family #{index + 1}: label must be a non-empty string")
continue
aliases = fam.get("aliases", [])
if not isinstance(aliases, list) or not all(
isinstance(a, str) for a in aliases
):
problems.append(f"family {label!r}: aliases must be a list of strings")
aliases = []
for name in [label, *aliases]:
if name in seen:
problems.append(
f"family {label!r}: duplicate label or alias {name!r} "
f"(already used by {seen[name]!r})"
)
else:
seen[name] = label
return problems
def _validate_scalars(label: str, fam: Dict[str, Any]) -> List[str]:
problems: List[str] = []
if not isinstance(fam.get("models"), str):
problems.append(f"family {label!r}: models must be a string")
if fam.get("group") not in GROUPS:
problems.append(f"family {label!r}: group must be one of {list(GROUPS)}")
if fam.get("vaeRatio") not in VAE_RATIOS:
problems.append(f"family {label!r}: vaeRatio must be one of {list(VAE_RATIOS)}")
if not _is_step(fam.get("step")):
problems.append(f"family {label!r}: step must be a positive integer")
elif int(fam["step"]) % LATENT_STEP:
problems.append(
f"family {label!r}: step must be a multiple of {LATENT_STEP} "
"so sizes land on the latent grid"
)
mp = fam.get("megapixels")
if (
not isinstance(mp, list)
or not mp
or not all(
_is_number(m) and _is_finite(m) and 0 < m <= MAX_MEGAPIXELS for m in mp
)
):
problems.append(f"family {label!r}: megapixels must be a non-empty number list")
return problems
def _validate_chip(
label: str, chip: str, entries: Any, fam: Dict[str, Any]
) -> List[str]:
where = f"family {label!r}, chip {chip}"
if not isinstance(entries, list) or not all(_is_size(s) for s in entries):
return [f"{where}: sizes must be a list of [width, height] integer pairs"]
problems: List[str] = []
step = fam.get("step")
step_ok = _is_step(step)
target = _chip_ratio(chip)
for w, h in entries:
if step_ok and (w % step or h % step):
problems.append(f"{where}: {w}x{h} is not a multiple of step {int(step)}")
if abs(w / h - target) / target > CHIP_TOLERANCE:
problems.append(f"{where}: {w}x{h} is more than 7% off the {chip} ratio")
areas = [w * h for w, h in entries]
if areas != sorted(areas):
problems.append(f"{where}: sizes must be in ascending pixel order")
return problems
def _validate_sizes(label: str, fam: Dict[str, Any]) -> List[str]:
sizes = fam.get("sizes")
if not isinstance(sizes, dict):
return [f"family {label!r}: sizes must be an object keyed by chip"]
problems: List[str] = []
for chip in CHIPS:
if chip not in sizes:
problems.append(f"family {label!r}: sizes is missing chip {chip}")
else:
problems.extend(_validate_chip(label, chip, sizes[chip], fam))
valid = {
chip: [tuple(s) for s in sizes[chip]]
for chip in CHIPS
if chip in sizes
and isinstance(sizes[chip], list)
and all(_is_size(s) for s in sizes[chip])
}
problems.extend(_validate_mirrors(label, valid))
problems.extend(_validate_default(label, fam, valid))
return problems
def _validate_mirrors(label: str, sizes: Dict[str, List[Size]]) -> List[str]:
problems: List[str] = []
owner: Dict[Size, str] = {}
for chip, entries in sizes.items():
mirrored = sizes.get(MIRROR[chip])
for w, h in entries:
if mirrored is not None and (h, w) not in mirrored:
problems.append(
f"family {label!r}, chip {chip}: {w}x{h} has no mirror "
f"{h}x{w} under {MIRROR[chip]}"
)
if (w, h) in owner:
problems.append(
f"family {label!r}, chip {chip}: {w}x{h} is listed under more "
f"than one chip (also {owner[(w, h)]})"
)
else:
owner[(w, h)] = chip
return problems
def _validate_default(
label: str, fam: Dict[str, Any], sizes: Dict[str, Any]
) -> List[str]:
default = fam.get("default")
if not _is_size(default):
return [f"family {label!r}: default must be a [width, height] pair"]
if not any(tuple(default) in entries for entries in sizes.values()):
return [f"family {label!r}: default {list(default)} is not in its sizes"]
return []
def _validate_default_label(default: Any, entries: List[Dict[str, Any]]) -> List[str]:
names = set()
for fam in entries:
if isinstance(fam.get("label"), str):
names.add(fam["label"])
if isinstance(fam.get("aliases"), list):
names.update(a for a in fam["aliases"] if isinstance(a, str))
if isinstance(default, str) and default in names:
return []
return [f"default {default!r} is not a family label"]
def validate_table(data: Any) -> List[str]:
"""Return every problem found in ``data``; an empty list means it is valid.
Bounds: step a multiple of 8 and <= 4096, megapixels in (0, 64], each side
in 64..8192.
"""
if not isinstance(data, dict):
return ["table must be a JSON object"]
problems = _validate_top_level(data)
families = data.get("families")
if not isinstance(families, list):
return problems
entries = [f for f in families if isinstance(f, dict)]
for index, fam in enumerate(families):
if not isinstance(fam, dict):
problems.append(f"family #{index + 1}: must be an object")
problems.extend(_validate_names(entries))
for fam in entries:
label = fam.get("label")
if not isinstance(label, str) or not label:
continue
problems.extend(_validate_scalars(label, fam))
problems.extend(_validate_sizes(label, fam))
problems.extend(_validate_default_label(data.get("default"), entries))
return problems
def build_families(data: Dict[str, Any]) -> Tuple[Family, ...]:
"""Build ``Family`` records from a table that passed ``validate_table``."""
return tuple(
Family(
label=fam["label"],
aliases=tuple(fam.get("aliases", ())),
models=fam["models"],
group=fam["group"],
vae_ratio=fam["vaeRatio"],
step=int(fam["step"]),
megapixels=tuple(fam["megapixels"]),
default=(fam["default"][0], fam["default"][1]),
sizes={
chip: tuple((w, h) for w, h in fam["sizes"].get(chip, ()))
for chip in CHIPS
},
)
for fam in data["families"]
)
@@ -0,0 +1,5 @@
"""Project File Path tool for ComfyUI-KikoTools."""
from .node import ProjectFilePathNode
__all__ = ["ProjectFilePathNode"]
+107
View File
@@ -0,0 +1,107 @@
"""Path building logic for the Project File Path node."""
import os
import re
from datetime import date
from typing import Optional
# Characters that are invalid in Windows paths (and awkward elsewhere)
_UNSAFE_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
_SEPARATORS = re.compile(r"[\\/]+")
# Only these are treated as a file extension and stripped from the filename,
# so names like "render_v1.5" keep their dot.
FILE_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tif",
".tiff",
".mp4",
".webm",
".mov",
".mkv",
".avi",
".wav",
".mp3",
".flac",
".ogg",
".txt",
".json",
".latent",
}
)
SEPARATOR_OPTIONS = ("auto", "forward_slash", "backslash")
def _clean_part(part: str) -> str:
"""Clean one path segment: drop unsafe chars, spaces to underscores."""
part = _UNSAFE_CHARS.sub("", part).strip(" .")
return part.replace(" ", "_")
def sanitize_component(text: str) -> str:
"""Clean a folder component, keeping nested folders as 'a/b'.
Splits on both / and \\, drops empty, '.' and '..' segments, so the
result is always relative and can never climb out of its parent.
"""
parts = (_clean_part(p) for p in _SEPARATORS.split(text or ""))
return "/".join(p for p in parts if p and p not in (".", ".."))
def sanitize_filename(text: str) -> str:
"""Clean a filename: no folders, and a known file extension removed."""
name = _UNSAFE_CHARS.sub("", _SEPARATORS.sub("", text or ""))
stem, extension = os.path.splitext(name)
if extension.lower() in FILE_EXTENSIONS:
name = stem
elif name.strip().lower() in FILE_EXTENSIONS: # just ".png": splitext sees no ext
name = ""
return _clean_part(name)
def _resolve_separator(separator: str) -> str:
if separator == "auto":
return os.sep
if separator == "forward_slash":
return "/"
if separator == "backslash":
return "\\"
raise ValueError(
f"Unknown separator '{separator}'; use one of {', '.join(SEPARATOR_OPTIONS)}"
)
def build_project_path(
root: str,
project_name: str,
subfolder: str,
filename: str,
date_folder: bool = False,
separator: str = "auto",
today: Optional[date] = None,
) -> str:
"""Build 'root/project/[YYYY-MM-DD/]subfolder/filename'.
Raises:
ValueError: If the filename is empty after cleaning, or the
separator is unknown.
"""
sep = _resolve_separator(separator)
clean_filename = sanitize_filename(filename)
if not clean_filename:
raise ValueError(f"filename {filename!r} is empty after removing unsafe parts")
folders = [sanitize_component(root), sanitize_component(project_name)]
if date_folder:
folders.append((today or date.today()).isoformat())
folders.append(sanitize_component(subfolder))
path = "/".join([f for f in folders if f] + [clean_filename])
return path.replace("/", sep)
+89
View File
@@ -0,0 +1,89 @@
"""ComfyUI node implementation for Project File Path."""
from datetime import date
from typing import Any, Dict, Tuple
from ...base import ComfyAssetsBaseNode
from .logic import SEPARATOR_OPTIONS, build_project_path
def _today() -> date:
return date.today()
class ProjectFilePathNode(ComfyAssetsBaseNode):
"""
Builds a save prefix like 'MyProject/2026-10-13/images/image'.
Wire the output into a save node's filename_prefix. Every component is
sanitized so the result stays inside ComfyUI's output folder.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
return {
"required": {
"root": (
"STRING",
{
"default": "",
"tooltip": "Optional top folder inside the output folder",
},
),
"project_name": ("STRING", {"default": "MyProject"}),
"subfolder": (
"STRING",
{"default": "images", "tooltip": "Use a/b for nested folders"},
),
"filename": (
"STRING",
{
"default": "image",
"tooltip": "File extensions like .png are removed",
},
),
},
"optional": {
"date_folder": (
"BOOLEAN",
{
"default": False,
"tooltip": "Insert a YYYY-MM-DD folder after the project",
},
),
"separator": (list(SEPARATOR_OPTIONS), {"default": "auto"}),
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "generate_path"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
@classmethod
def IS_CHANGED(cls, date_folder: bool = False, **kwargs) -> str:
# Output depends only on the inputs, except for the date folder:
# re-run when the day changes so open workflows get the new date.
return _today().isoformat() if date_folder else ""
def generate_path(
self,
root: str,
project_name: str,
subfolder: str,
filename: str,
date_folder: bool = False,
separator: str = "auto",
) -> Tuple[str]:
try:
path = build_project_path(
root,
project_name,
subfolder,
filename,
date_folder=date_folder,
separator=separator,
today=_today(),
)
except ValueError as e:
self.handle_error(str(e), e)
return (path,)
@@ -6,6 +6,8 @@ Pure functions for dimension extraction and scaling calculations
import torch import torch
from typing import Tuple, Optional, Dict from typing import Tuple, Optional, Dict
MIN_DIMENSION = 8
def extract_dimensions( def extract_dimensions(
image: Optional[torch.Tensor] = None, image: Optional[torch.Tensor] = None,
@@ -100,8 +102,9 @@ def calculate_scaled_dimensions(
new_width = int(width * scale_factor) new_width = int(width * scale_factor)
new_height = int(height * scale_factor) new_height = int(height * scale_factor)
# Ensure divisible by 8 # Ensure divisible by 8, never collapsing to zero for tiny inputs
return ensure_divisible_by_8(new_width, new_height) new_width, new_height = ensure_divisible_by_8(new_width, new_height)
return max(new_width, MIN_DIMENSION), max(new_height, MIN_DIMENSION)
def validate_scale_factor( def validate_scale_factor(
+7 -7
View File
@@ -10,14 +10,14 @@ def generate_random_seed() -> int:
Generate a cryptographically strong random seed value. Generate a cryptographically strong random seed value.
Returns: Returns:
Random integer in the valid ComfyUI seed range Random integer in the valid ComfyUI seed range (0 to 2**32 - 1)
""" """
return random.randint(0, 0xFFFFFFFFFFFFFFFF) return random.randint(0, 0xFFFFFFFF) # 2**32 - 1
def validate_seed_value(seed: Any) -> bool: def validate_seed_value(seed: Any) -> bool:
""" """
Validate that a seed value is within acceptable range. Validate that a seed value is within acceptable range (0 to 2**32 - 1).
Args: Args:
seed: Seed value to validate seed: Seed value to validate
@@ -30,7 +30,7 @@ def validate_seed_value(seed: Any) -> bool:
try: try:
seed_int = int(seed) seed_int = int(seed)
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF return 0 <= seed_int <= 0xFFFFFFFF # 2**32 - 1
except (ValueError, TypeError): except (ValueError, TypeError):
return False return False
@@ -54,11 +54,11 @@ def sanitize_seed_value(seed: Any) -> int:
try: try:
seed_int = int(seed) seed_int = int(seed)
# Clamp to valid range # Clamp to valid range (0 to 2**32 - 1)
if seed_int < 0: if seed_int < 0:
seed_int = 0 seed_int = 0
elif seed_int > 0xFFFFFFFFFFFFFFFF: elif seed_int > 0xFFFFFFFF:
seed_int = 0xFFFFFFFFFFFFFFFF seed_int = 0xFFFFFFFF
return seed_int return seed_int

Some files were not shown because too many files have changed in this diff Show More