1 Commits
Author SHA1 Message Date
Makadi 1362d71182 sync with gitlab version = "1.3.87" 2026-06-11 08:31:33 +03:00
76 changed files with 676 additions and 8140 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Bug report
description: Something in Pixaroma is genuinely broken (after ruling out the browser-cache fix)
title: "[Bug] "
body:
- type: markdown
attributes:
value: |
**Before filing:** if a node looks blank, broken, or is missing its controls right after an update, it is almost always a browser cache. Please try the 30-second fix first: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
- type: checkboxes
id: cache-check
attributes:
label: Quick check
options:
- label: I hard-refreshed (Ctrl+Shift+R / Cmd+Shift+R) and the problem is still there
required: false
- type: textarea
id: what-happened
attributes:
label: What is happening?
description: Describe the problem. Screenshots help a lot.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: What did you do right before it happened?
validations:
required: false
- type: textarea
id: versions
attributes:
label: Versions
description: "Drop a Version Check Pixaroma node on the canvas, hit its Copy button, and paste here (ComfyUI version, frontend version, node interface, Pixaroma version)."
validations:
required: true
- type: textarea
id: console
attributes:
label: Console errors (if a node is broken or blank)
description: Press F12, click Console, reload the page, then paste or screenshot any red error lines.
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: "📌 Node looks blank or broken after updating? Read this first"
url: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
about: "Almost always a browser cache. The fix takes 30 seconds, and most 'broken node' reports turn out to be this. Please check here before filing."
+20
View File
@@ -0,0 +1,20 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
+472
View File
@@ -0,0 +1,472 @@
.clauderules
.gitignore
.gitlab-ci.yml
.gitlab/issue_templates/Default.md
CLAUDE.md
LICENSE
README.md
__init__.py
assets/audio_studio_parity/index.html
assets/audio_studio_parity/test_image.png
assets/fonts/Anton-Regular.ttf
assets/fonts/BebasNeue-Regular.ttf
assets/fonts/Caveat-Variable.ttf
assets/fonts/Inter-Variable.ttf
assets/fonts/JetBrainsMono-Variable.ttf
assets/fonts/Lora-Variable.ttf
assets/fonts/Montserrat-Variable.ttf
assets/fonts/NOTICE.md
assets/fonts/Oswald-Variable.ttf
assets/fonts/PlayfairDisplay-Italic-Variable.ttf
assets/fonts/PlayfairDisplay-Variable.ttf
assets/fonts/Roboto-Variable.ttf
assets/icons/3D/add-3d-object.svg
assets/icons/3D/arch.svg
assets/icons/3D/back.svg
assets/icons/3D/bed.svg
assets/icons/3D/blob.svg
assets/icons/3D/bookshelf.svg
assets/icons/3D/bottle.svg
assets/icons/3D/bowl.svg
assets/icons/3D/bunny.svg
assets/icons/3D/cactus.svg
assets/icons/3D/capsule.svg
assets/icons/3D/chair.svg
assets/icons/3D/cloud.svg
assets/icons/3D/cone.svg
assets/icons/3D/couch.svg
assets/icons/3D/crystal.svg
assets/icons/3D/cube.svg
assets/icons/3D/cylinder.svg
assets/icons/3D/dome.svg
assets/icons/3D/drop-on-floor.svg
assets/icons/3D/fence-post.svg
assets/icons/3D/flower.svg
assets/icons/3D/focus.svg
assets/icons/3D/front.svg
assets/icons/3D/gear.svg
assets/icons/3D/goblet.svg
assets/icons/3D/house.svg
assets/icons/3D/lamp-post.svg
assets/icons/3D/load-3d-model.svg
assets/icons/3D/mushroom.svg
assets/icons/3D/pine-tree.svg
assets/icons/3D/plane.svg
assets/icons/3D/plant-pot.svg
assets/icons/3D/prism.svg
assets/icons/3D/pyramid.svg
assets/icons/3D/ring.svg
assets/icons/3D/rock.svg
assets/icons/3D/side.svg
assets/icons/3D/signpost.svg
assets/icons/3D/sphere.svg
assets/icons/3D/table.svg
assets/icons/3D/teapot.svg
assets/icons/3D/terrain.svg
assets/icons/3D/top.svg
assets/icons/3D/torus.svg
assets/icons/3D/tree.svg
assets/icons/3D/tube.svg
assets/icons/3D/vase.svg
assets/icons/layers/add.svg
assets/icons/layers/delete.svg
assets/icons/layers/duplicate.svg
assets/icons/layers/edit.svg
assets/icons/layers/eye-hidden.svg
assets/icons/layers/eye-visible.svg
assets/icons/layers/flatten.svg
assets/icons/layers/fx.svg
assets/icons/layers/lock-locked.svg
assets/icons/layers/lock-unlocked.svg
assets/icons/layers/merge-down.svg
assets/icons/layers/move-down.svg
assets/icons/layers/move-up.svg
assets/icons/layers/upload.svg
assets/icons/note/CLIP.svg
assets/icons/note/GGUF.svg
assets/icons/note/LORA.svg
assets/icons/note/VAE.svg
assets/icons/note/ai-brain.svg
assets/icons/note/arrow-down.svg
assets/icons/note/arrow-left.svg
assets/icons/note/arrow-right.svg
assets/icons/note/arrow-up.svg
assets/icons/note/box.svg
assets/icons/note/checkmark.svg
assets/icons/note/checkpoint-v1.svg
assets/icons/note/checkpoint-v2.svg
assets/icons/note/chip.svg
assets/icons/note/data.svg
assets/icons/note/file.svg
assets/icons/note/folder-v1.svg
assets/icons/note/folder-v2.svg
assets/icons/note/gear.svg
assets/icons/note/github.svg
assets/icons/note/huggingface.svg
assets/icons/note/model-v1.svg
assets/icons/note/model-v2.svg
assets/icons/note/model-v3.svg
assets/icons/note/model-v4.svg
assets/icons/note/model-v5.svg
assets/icons/note/model-v6.svg
assets/icons/note/model-v7.svg
assets/icons/note/node-v1.svg
assets/icons/note/node-v2.svg
assets/icons/note/node-v3.svg
assets/icons/note/node-v4.svg
assets/icons/note/nodes-installed.svg
assets/icons/note/pixaroma.svg
assets/icons/note/plus.svg
assets/icons/note/puzzle.svg
assets/icons/note/question-mark.svg
assets/icons/note/question.svg
assets/icons/note/warning-v1.svg
assets/icons/note/warning-v2.svg
assets/icons/note/workflow-v1.svg
assets/icons/note/workflow-v2.svg
assets/icons/note/workflow-v3.svg
assets/icons/ui/align-bottom.svg
assets/icons/ui/align-center-h.svg
assets/icons/ui/align-center-v.svg
assets/icons/ui/align-left.svg
assets/icons/ui/align-right.svg
assets/icons/ui/align-top.svg
assets/icons/ui/apply.svg
assets/icons/ui/audio.svg
assets/icons/ui/bg-color-drop.svg
assets/icons/ui/bg-color-outline.svg
assets/icons/ui/bg-color.svg
assets/icons/ui/brush.svg
assets/icons/ui/button-color-drop.svg
assets/icons/ui/button-color-outline.svg
assets/icons/ui/button-color.svg
assets/icons/ui/button-design.svg
assets/icons/ui/clear-format.svg
assets/icons/ui/close.svg
assets/icons/ui/code.svg
assets/icons/ui/copy.svg
assets/icons/ui/cursor.svg
assets/icons/ui/delete.svg
assets/icons/ui/diamond.svg
assets/icons/ui/discord.svg
assets/icons/ui/distribute-horizontal.svg
assets/icons/ui/distribute-vertical.svg
assets/icons/ui/download-model.svg
assets/icons/ui/download.svg
assets/icons/ui/eraser.svg
assets/icons/ui/eyedropper.svg
assets/icons/ui/fill.svg
assets/icons/ui/fit-height.svg
assets/icons/ui/fit-width.svg
assets/icons/ui/fit.svg
assets/icons/ui/flat.svg
assets/icons/ui/flip-horizontal.svg
assets/icons/ui/flip-vertical.svg
assets/icons/ui/folder.svg
assets/icons/ui/grid.svg
assets/icons/ui/help.svg
assets/icons/ui/highlight-color-drop.svg
assets/icons/ui/highlight-color-outline.svg
assets/icons/ui/icon-insert.svg
assets/icons/ui/image.svg
assets/icons/ui/line-color-drop.svg
assets/icons/ui/line-color-outline.svg
assets/icons/ui/line-color.svg
assets/icons/ui/link.svg
assets/icons/ui/list-dot.svg
assets/icons/ui/list-number.svg
assets/icons/ui/loop.svg
assets/icons/ui/magnet.svg
assets/icons/ui/minus.svg
assets/icons/ui/move.svg
assets/icons/ui/off.svg
assets/icons/ui/on.svg
assets/icons/ui/pause.svg
assets/icons/ui/pen-opacity.svg
assets/icons/ui/pen-size.svg
assets/icons/ui/pencil.svg
assets/icons/ui/play.svg
assets/icons/ui/plus.svg
assets/icons/ui/read-more.svg
assets/icons/ui/redo.svg
assets/icons/ui/reset.svg
assets/icons/ui/rotate-ccw.svg
assets/icons/ui/rotate-cw.svg
assets/icons/ui/round.svg
assets/icons/ui/save.svg
assets/icons/ui/separator.svg
assets/icons/ui/shape.svg
assets/icons/ui/smudge.svg
assets/icons/ui/square.svg
assets/icons/ui/star.svg
assets/icons/ui/stop.svg
assets/icons/ui/swap.svg
assets/icons/ui/text-color-drop.svg
assets/icons/ui/text-color-outline.svg
assets/icons/ui/triangle.svg
assets/icons/ui/undo.svg
assets/icons/ui/upload.svg
assets/icons/ui/view-model-page.svg
assets/icons/ui/youtube.svg
assets/models/bunny.glb
assets/note-pixaroma-llm-prompt.txt
assets/pixaroma_logo.png
assets/pixaroma_logo.svg
assets/sounds/Checkpoint-Reached.mp3
assets/sounds/Complete-Pixa.mp3
assets/sounds/Dang.mp3
assets/sounds/Echo.mp3
assets/sounds/Ready-PixaGirl.mp3
assets/sounds/Sector-Clear.mp3
assets/sounds/Stage-Clear.mp3
assets/sounds/Tada.mp3
assets/sounds/Tidi.mp3
assets/sounds/Vista.mp3
assets/vendor/three/examples/jsm/controls/OrbitControls.mjs
assets/vendor/three/examples/jsm/controls/TransformControls.mjs
assets/vendor/three/examples/jsm/geometries/TeapotGeometry.mjs
assets/vendor/three/examples/jsm/loaders/GLTFLoader.mjs
assets/vendor/three/examples/jsm/loaders/MTLLoader.mjs
assets/vendor/three/examples/jsm/loaders/OBJLoader.mjs
assets/vendor/three/examples/jsm/postprocessing/EffectComposer.mjs
assets/vendor/three/examples/jsm/postprocessing/OutlinePass.mjs
assets/vendor/three/examples/jsm/postprocessing/OutputPass.mjs
assets/vendor/three/examples/jsm/postprocessing/RenderPass.mjs
assets/vendor/three/examples/jsm/utils/BufferGeometryUtils.mjs
assets/vendor/three/three.mjs
docs/audio-react-math.md
docs/composer-fx-math.md
docs/text-overlay-render.md
js/3d/api.mjs
js/3d/composites.mjs
js/3d/core.mjs
js/3d/engine.mjs
js/3d/importer.mjs
js/3d/index.js
js/3d/interaction.mjs
js/3d/objects.mjs
js/3d/persistence.mjs
js/3d/picker.mjs
js/3d/shape_params.mjs
js/3d/shapes.mjs
js/align/index.js
js/audio_studio/api.mjs
js/audio_studio/audio_analysis.mjs
js/audio_studio/core.mjs
js/audio_studio/index.js
js/audio_studio/render.mjs
js/audio_studio/shaders.mjs
js/audio_studio/transport.mjs
js/audio_studio/ui.mjs
js/brand/index.js
js/compare/index.js
js/composer/api.mjs
js/composer/core.mjs
js/composer/crop.mjs
js/composer/eraser.mjs
js/composer/fx_engine.mjs
js/composer/index.js
js/composer/interaction.mjs
js/composer/layers.mjs
js/composer/placeholder.mjs
js/composer/render.mjs
js/composer/ui.mjs
js/connection_fx/index.js
js/crop/alignments.mjs
js/crop/core.mjs
js/crop/index.js
js/crop/interaction.mjs
js/crop/panel.mjs
js/crop/render.mjs
js/find_replace/core.mjs
js/find_replace/index.js
js/find_replace/interaction.mjs
js/find_replace/render.mjs
js/framework/bg_removal_dropdown.mjs
js/framework/canvas.mjs
js/framework/components.mjs
js/framework/fonts.mjs
js/framework/index.mjs
js/framework/layers.mjs
js/framework/layout.mjs
js/framework/text_editor.mjs
js/framework/text_render.mjs
js/framework/theme.mjs
js/help_toolbar/help_defs.mjs
js/help_toolbar/index.js
js/image_resize/index.js
js/image_resize/ui.mjs
js/label/core.mjs
js/label/index.js
js/label/render.mjs
js/load_image/api.mjs
js/load_image/index.js
js/load_image/panel_polish.mjs
js/load_image/resize_modes.mjs
js/load_image/ui.mjs
js/mute_switch/core.mjs
js/mute_switch/editor.mjs
js/mute_switch/index.js
js/mute_switch/render.mjs
js/mute_switch/upstream.mjs
js/mute_switch/vue_list.mjs
js/node_colors/index.js
js/note/blocks.mjs
js/note/codeview.mjs
js/note/core.mjs
js/note/css.mjs
js/note/icons.mjs
js/note/index.js
js/note/render.mjs
js/note/sanitize.mjs
js/note/toolbar.mjs
js/notify/index.js
js/number/index.js
js/paint/api.mjs
js/paint/canvas.mjs
js/paint/core.mjs
js/paint/engine.mjs
js/paint/events.mjs
js/paint/history.mjs
js/paint/index.js
js/paint/render.mjs
js/paint/tools.mjs
js/paint/transform.mjs
js/paint/ui.mjs
js/pause_image/index.js
js/pause_image/state.mjs
js/pause_image/ui.mjs
js/preview/index.js
js/prompt_multi/core.mjs
js/prompt_multi/index.js
js/prompt_multi/interaction.mjs
js/prompt_multi/render.mjs
js/prompt_pack/core.mjs
js/prompt_pack/index.js
js/prompt_pack/interaction.mjs
js/prompt_pack/render.mjs
js/prompt_reader/index.js
js/prompt_stack/core.mjs
js/prompt_stack/index.js
js/prompt_stack/interaction.mjs
js/prompt_stack/render.mjs
js/reference/index.js
js/resolution/index.js
js/run_button_fx/index.js
js/save_mp4/index.js
js/shared/color_picker.mjs
js/shared/graph_loading.mjs
js/shared/graph_undo_guard.mjs
js/shared/help.mjs
js/shared/index.mjs
js/shared/label_css.mjs
js/shared/nodes2.mjs
js/shared/preview.mjs
js/shared/queue_drivers.mjs
js/shared/resize_floor.mjs
js/shared/resize_panel.mjs
js/shared/utils.mjs
js/showtext/index.js
js/switch/core.mjs
js/switch/editor.mjs
js/switch/index.js
js/switch/render.mjs
js/switch/vue_list.mjs
js/switch_source/core.mjs
js/switch_source/editor.mjs
js/switch_source/index.js
js/switch_wh/index.js
js/text/dynamic_prompts.mjs
js/text/index.js
js/text_overlay/core.mjs
js/text_overlay/defaults.mjs
js/text_overlay/index.js
js/text_overlay/interaction.mjs
js/text_watermark/defaults.mjs
js/text_watermark/index.js
js/version_check/index.js
js/wh/index.js
js/xy_plot/core.mjs
js/xy_plot/grid.mjs
js/xy_plot/index.js
js/xy_plot/ui.mjs
locales/en/commands.json
nodes/_audio_react_engine.py
nodes/_bg_removal_helpers.py
nodes/_font_catalog.py
nodes/_fx_adjust_engine.py
nodes/_prompt_reader_helpers.py
nodes/_resize_helpers.py
nodes/_save_helpers.py
nodes/_text_render_helpers.py
nodes/_type_helpers.py
nodes/node_3d.py
nodes/node_audio_studio.py
nodes/node_compare.py
nodes/node_composition.py
nodes/node_crop.py
nodes/node_find_replace.py
nodes/node_image_resize.py
nodes/node_label.py
nodes/node_load_image.py
nodes/node_mute_switch.py
nodes/node_note.py
nodes/node_notify.py
nodes/node_number.py
nodes/node_paint.py
nodes/node_pause_image.py
nodes/node_preview.py
nodes/node_prompt_from_list.py
nodes/node_prompt_multi.py
nodes/node_prompt_pack.py
nodes/node_prompt_reader.py
nodes/node_prompt_stack.py
nodes/node_ref.py
nodes/node_remove_background.py
nodes/node_resolution.py
nodes/node_save_mp4.py
nodes/node_show_text.py
nodes/node_switch.py
nodes/node_switch_source.py
nodes/node_switch_wh.py
nodes/node_text.py
nodes/node_text_overlay.py
nodes/node_text_watermark.py
nodes/node_version_check.py
nodes/node_wh.py
nodes/node_xy_plot.py
pyproject.toml
scripts/audio_parity_check.py
scripts/build_mute_switch_workflows.py
scripts/download_fonts.py
scripts/font_catalog_check.py
scripts/fx_js_runner.mjs
scripts/fx_parity_check.py
scripts/mute_switch_test.mjs
scripts/save_prefix_check.py
scripts/text_overlay_parity_check.py
server_routes.py
workflows/3D Builder Pixaroma Workflow v2.jpg
workflows/3D Builder Pixaroma Workflow.jpg
workflows/3D Builder Pixaroma Workflow.json
workflows/AudioReact Workflow v2.jpg
workflows/AudioReact Workflow.jpg
workflows/AudioReact Workflow.json
workflows/Crop Pixaroma Workflow v2.jpg
workflows/Crop Pixaroma Workflow.jpg
workflows/Crop Pixaroma Workflow.json
workflows/Image Compare Pixaroma Workflow v2.jpg
workflows/Image Compare Pixaroma Workflow.jpg
workflows/Image Compare Pixaroma Workflow.json
workflows/Image Composer Pixaroma Workflow v2.jpg
workflows/Image Composer Pixaroma Workflow.jpg
workflows/Image Composer Pixaroma Workflow.json
workflows/Labels Pixaroma Workflow v2.jpg
workflows/Labels Pixaroma Workflow.jpg
workflows/Labels Pixaroma Workflow.json
workflows/Note Pixaroma Workflow v2.jpg
workflows/Note Pixaroma Workflow.jpg
workflows/Note Pixaroma Workflow.json
workflows/Paint Pixaroma Workflow v2.jpg
workflows/Paint Pixaroma Workflow.jpg
workflows/Paint Pixaroma Workflow.json
+13 -138
View File
@@ -3,7 +3,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ComfyUI-Pixaroma is a custom-node plugin for ComfyUI: ~40 nodes plus canvas-wide tools, all under the `👑 Pixaroma` menu — organized into subfolders via each node's `CATEGORY = "👑 Pixaroma/<subfolder>"` (🎨 Editors, 🖼️ Image, ✂️ Resize & Crop, 💬 Prompt & Text, 📝 Notes & Overlay, 🔢 Values, 🔀 Logic & Flow) — with zero core dependencies (PIL + PyTorch come from ComfyUI's environment). The families:
ComfyUI-Pixaroma is a custom-node plugin for ComfyUI: ~40 nodes plus canvas-wide tools, all under the `👑 Pixaroma` menu, with zero core dependencies (PIL + PyTorch come from ComfyUI's environment). The families:
- **Fullscreen editors:** 3D Builder, Paint Studio, Image Composer, Image Crop, AudioReact.
- **Prompt tools:** Prompt Multi / Pack / Stack / From List, Find and Replace, Show Text, Text, Prompt Reader.
- **Image I/O & resize:** Load Image, Preview Image, Save Mp4, Image Resize, Remove Background.
@@ -54,8 +54,6 @@ The frontend is organized into **directory-per-editor** modules under `js/`. Eac
**File extension convention:** Only `index.js` files (entry points that call `app.registerExtension`) use the `.js` extension. All other module files use `.mjs`. This is because ComfyUI auto-loads every `*.js` file as a separate extension — using `.mjs` for non-entry modules prevents them from being loaded twice.
**`.mjs` browser-cache gotcha (fixed in `server_routes.py`):** ComfyUI core's `middleware/cache_middleware.py` sends `Cache-Control: no-store` only for paths ending in `.js`/`.css` (`request.path.endswith(".js")` — which does NOT match `.mjs`). So our `.mjs` modules got no cache header and browsers heuristically cached them, meaning a plugin update wasn't visible (e.g. missing new buttons) until a hard refresh (Ctrl+Shift+R). `server_routes.py` now installs an aiohttp `on_response_prepare` hook (`_pixaroma_install_no_cache`) on `PromptServer.instance.app` that sets `no-store` for files under `/extensions/<this-plugin>/` ending in `.mjs`/`.js` (folder name auto-derived via `os.path.basename`, so a renamed install still works; idempotent via an `_pixaroma_no_cache_installed` flag; only our files). `no-store` makes the browser refetch on every load, so `.mjs` now behaves like core's `.js` — a normal reload (F5) picks up updates instead of requiring Ctrl+Shift+R. (Python change → full ComfyUI restart to take effect; and users need ONE final Ctrl+Shift+R after updating to this version to flush the pre-fix `.mjs` the browser already cached. Verify on a running server: a HEAD to `/extensions/ComfyUI-Pixaroma/<any>.mjs` should show `Cache-Control: no-store`.)
```
js/ (only index.js entry points use .js; every other module is .mjs so ComfyUI does not double-load it)
@@ -63,7 +61,7 @@ framework/ Shared UI toolkit - EVERY editor depends on this, so edit with care
index.mjs (barrel, import from here) | theme.mjs (CSS + BRAND #f66744 + _uiIcon) | layout.mjs (createEditorLayout fullscreen shell) | components.mjs (buttons/sliders/inputs/zoom/transform) | layers.mjs (layer panel) | canvas.mjs (canvas settings/toolbar/drag-drop) | fonts.mjs (font registry + custom drop-in fonts) | text_render.mjs (canvas mirror of nodes/_text_render_helpers.py) | text_editor.mjs (shared text-props panel: default / composerMode / watermarkMode) | bg_removal_dropdown.mjs (shared AI Remove-BG picker)
shared/ Shared utilities + Nodes 2.0 helpers:
utils.mjs (BRAND, hideJsonWidget, installFocusTrap) | preview.mjs (ResizeObserver mini-previews) | help.mjs (Help popup + registerNodeHelp/getNodeHelp registry, convention #16) | color_picker.mjs (Pixaroma Color Picker: compact + live-preview) | nodes2.mjs (applyAdaptiveCanvasOnly, isVueNodes, canvasBackingScale, installZoomRepaint) | graph_undo_guard.mjs (blocks Ctrl+Z escape, Vue Compat #6) | graph_loading.mjs (isGraphLoading, Vue Compat #19) | queue_drivers.mjs (shared lock for queue-driver nodes) | resize_floor.mjs + resize_panel.mjs (Nodes 2.0 resize floor + shared resize-mode panel / makeNumericInput) | index.mjs, label_css.mjs
utils.mjs (BRAND, hideJsonWidget, installFocusTrap) | preview.mjs (ResizeObserver mini-previews) | help.mjs (in-node "?" Help panel, convention #16) | color_picker.mjs (Pixaroma Color Picker: compact + live-preview) | nodes2.mjs (applyAdaptiveCanvasOnly, isVueNodes, canvasBackingScale, installZoomRepaint) | graph_undo_guard.mjs (blocks Ctrl+Z escape, Vue Compat #6) | graph_loading.mjs (isGraphLoading, Vue Compat #19) | queue_drivers.mjs (shared lock for queue-driver nodes) | resize_floor.mjs + resize_panel.mjs (Nodes 2.0 resize floor + shared resize-mode panel / makeNumericInput) | index.mjs, label_css.mjs
Canvas-wide features (patch the canvas, add NO node):
brand/index.js Brand dark defaults for ANY node whose CATEGORY starts "👑 Pixaroma" (do NOT re-add per-node color guards)
@@ -71,7 +69,6 @@ Canvas-wide features (patch the canvas, add NO node):
connection_fx/index.js Pixaroma.Connection.FX - wire-drag magnets + connect sparkles
run_button_fx/index.js Pixaroma.RunButton.FX - Run-button visual effects
align/index.js Pixaroma.Align.* - smart-snap + alignment guides
help_toolbar/index.js Pixaroma.HelpToolbar - the orange ? Help button in the node selection toolbar (convention #16); per-node help content lives in the sibling help_defs.mjs
Fullscreen editors (class + mixin pattern, see "Mixin Pattern" below; core.mjs = shell, sibling .mjs add prototype methods, index.js wires it):
paint/ PaintStudio: core canvas render transform events tools history ui engine api
@@ -391,10 +388,6 @@ Compare was the THIRD painted node migrated (`js/compare/index.js`) and it's the
Net: a fully-painted node migrates as ONE flex DOM canvas reusing the exact legacy draw + hit-test, with SCALE-CORRECTED DOM pointer events feeding the hit-tests, `minWidth:1` so size round-trips, and per-region cursor management.
**Reusable canvas techniques from the Compare size-display feature (June 2026) - apply to ANY node painting on a canvas:**
- **Digit-only text drawn with `textBaseline:"middle"` floats visually HIGH** (the em box reserves descender space digits don't use; the gap scales with font size, so it's obvious on big numbers and invisible on small ones). To vertically center numbers / size readouts / badges against buttons, center by the ACTUAL glyph box instead: `const m = ctx.measureText(text); ctx.textBaseline = "alphabetic"; ctx.fillText(text, x, yMid + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2)` (fall back to `"middle"` if those metrics are absent on an old browser). Reference: `fillTextVCenter` in `js/compare/index.js`. Text WITH descenders (button labels like "Copy") is already fine with plain `"middle"` - only digit/caps-only strings need this.
- **Reclaim the wasted TOP GAP on a Nodes-2.0 DOM-canvas node.** A canvas painted with the legacy top padding (the space the buttons need under the title bar in legacy) wastes that strip in Nodes 2.0, because the title + input slots are Vue-rendered ABOVE the canvas. Shift the WHOLE drawing up by a small `TOP_TRIM` and compensate so hit-tests still line up: in `render()` do `ctx.translate(0, -TOP_TRIM)` and pass `cssH + TOP_TRIM` as the logical H to the painter; then make `localPos()` ADD `+TOP_TRIM` to the pointer y and `H()` return `clientHeight + TOP_TRIM`. The body/image then fills to the bottom and clicks / hover / slider-drag / wheel all stay aligned with what's painted. Legacy is untouched (this lives only in the DOM-widget path). Reference: `TOP_TRIM` in `createCompareDOMWidget`. This does NOT change `node.size`, so it's dirty-on-load safe (Vue Compat #18).
### Title-less / tightly-self-sized DOM node (Label) — the hardest painted case
Label was the LAST node migrated and the trickiest, because it is **title-less, its whole body IS the content, and it self-sizes tightly to that content** (a floating text annotation, not a normal card node). It took ~10 iterations in-ComfyUI; every gotcha below is verified, several via an agent reading the compiled `comfyui_frontend_package` bundle. Reference: `js/label/` (`render.mjs` `applyLabelToDom` + `injectVueLabelCSS`, `index.js` `setupVueLabel`/`openLabelEditor`/`_pixLblFit`, `core.mjs` `saveCfg`). Choose this recipe for any future node that is title-less and/or must hug its content.
@@ -556,14 +549,12 @@ These conventions were locked in during the May-2026 visual consistency pass acr
- **Hover tooltips on controls (JS):** every interactive control gets a native `title` attribute with a one-line "what this does", like Text Overlay's buttons (`b.title = ...` in `js/framework/text_editor.mjs`). For DOM controls (chips, inputs, buttons inside a DOM widget) the native `title` is enough and renders as the dark OS tooltip. For CANVAS-painted controls use the custom tooltip helper (convention #8 - `title` only works on DOM elements).
Reference implementation for BOTH surfaces is the Image Resize node: `nodes/node_image_resize.py` (DESCRIPTION + input `tooltip`s + `OUTPUT_TOOLTIPS`) and `js/image_resize/ui.mjs` (the `title` on mode chips via the `MODE_CHIPS` `title` field, snap chips, resample dropdown + arrows, upscaling toggle). Most existing nodes have input `tooltip`s but NONE had `OUTPUT_TOOLTIPS` and few have control `title`s - they need a sweep. Fill both surfaces from the start on any new node.
16. **Node Help - the orange `?` in ComfyUI's node SELECTION TOOLBAR (the floating bar above a selected node, next to the native ⓘ Node Info). EVERY Pixaroma node MUST have registered help; a NEW node is NOT done until its help is registered.** Replaced the old in-body `?` button in the June-2026 rollout: help now lives in the toolbar so it works for ANY node in BOTH renderers and costs ZERO node-body space. ComfyUI's own Info tab is plain-text and cramped (Vue Compat #14), so this themed popup is the richer teaching surface. Reference: `js/help_toolbar/` (the toolbar command + the central content) + `js/shared/help.mjs` (the registry + popup). Complements convention #15 (still fill the Python `DESCRIPTION` / input `tooltip`s / `OUTPUT_TOOLTIPS`); the Help popup does not replace them.
- **HOW TO ADD HELP TO A NODE (do this for EVERY new node):** author a help def (schema below) and call `registerNodeHelp("<exact comfyClass>", HELP_DEF)`. Pick one of two homes: (a) **central, the default for most nodes** - add an entry to the `HELP` map in `js/help_toolbar/help_defs.mjs` (it loops `registerNodeHelp` over the whole map). One file to edit all node help. (b) **node-local** - call `registerNodeHelp(...)` at module load from the node's own JS, keeping the def beside the node code (Image Compare `js/compare/index.js`, Text `js/text/index.js`, XY Plot `js/xy_plot/ui.mjs`, Find and Replace `js/find_replace/render.mjs` do this). Either way the comfyClass key MUST match `NODE_CLASS_MAPPINGS` in the node's Python EXACTLY. Nothing else is needed - the toolbar `?` appears automatically when a node with registered help is selected, in both renderers. (Canvas-only features that add NO node - Align, Node Colors, Connection FX, etc. - need no help.)
- **The mechanism (official extension hook, verified against frontend 1.44.x; recorded so agents do NOT re-grep the minified bundle).** `js/help_toolbar/index.js` calls `app.registerExtension({ commands: [{ id:"Pixaroma.ShowHelp", label, icon, function }], getSelectionToolboxCommands(item) {...} })`. ComfyUI invokes `getSelectionToolboxCommands(item)` on EVERY extension for each selected item (`SelectionToolbox` in `GraphView-*.js`), unions the returned command-id strings into a Set, looks each up in the command store, and renders it via `ExtensionCommandButton.vue` as `<i :class="command.icon">` + `@click` calling `command.function()` (no args). So our hook returns `[CMD_ID]` only when `item.comfyClass && getNodeHelp(item.comfyClass)` (a GROUP has no `comfyClass` -> nothing; ids are deduped, so multi-selecting two different help-nodes shows ONE button = the first found - acceptable). `command.icon` is a CSS CLASS on an `<i>`, so the orange-circle-`?` is pure CSS (a BRAND circle + `question-mark.svg` as a white `::before` mask). On older ComfyUI lacking the hook it is simply never called -> the command is registered but unshown (degrades silently, no error).
- **The hover tooltip uses ComfyUI's i18n, NOT `command.label` directly.** `ExtensionCommandButton` binds `v-tooltip` to `st('commands.<normalizeI18nKey(id)>.label', '')`, and `normalizeI18nKey` (in `formatUtil-*.js`) lowercases DOTS to UNDERSCORES (`Pixaroma.ShowHelp` -> `Pixaroma_ShowHelp`). Custom nodes feed that table via a `locales/<lang>/commands.json` bundle that ComfyUI's server aggregates (`app/custom_node_manager.py` `build_translations()` -> served at `/i18n`, merged by the frontend into `customNodesI18nData`). So the toolbar button's tooltip text lives in `locales/en/commands.json` = `{ "Pixaroma_ShowHelp": { "label": "Pixaroma Help" } }`. The `/i18n` route reads files per request, so a hard refresh usually picks up edits (a ComfyUI restart is the safe fallback). This is also the official way to add ANY custom-node translation (the same folder takes `main.json` / `nodeDefs.json` / `settings.json`).
- **`registerNodeHelp(comfyClass, helpDef)` / `getNodeHelp(comfyClass)`** live in `js/shared/help.mjs` (a module-singleton Map, re-exported from the barrel `js/shared/index.mjs`). The toolbar command's `function` reads the LIVE selection (`app.canvas.selected_nodes` then `selectedItems`), finds the first selected node with registered help, and calls `openHelpPopup(helpDef)`. `openHelpPopup` / `closeHelpPopup` / `injectHelpCSS` are also exported from `help.mjs`.
- **`helpDef` schema:** `{ title, tagline?, sections: [...], footer? }`. Each section is `{ heading, body?, bullets?: string[], defs?: [[term, desc], ...], table?: { headers: string[], rows: string[][] } }`, blocks render in listed order (a section may mix several). Any string may contain inline `` `code` `` (backticks -> monospace chip). ALL text is HTML-escaped, so author in plain prose; in `body` a blank line = new paragraph, a single `\n` is preserved (`white-space: pre-wrap`). **Write it for a non-programmer artist:** what the node DOES + HOW to use it + its real outputs (match `RETURN_NAMES`), never how it is coded. Scale the length to the node (a Number node = tagline + 1 short section; a big editor = 3-4 sections). **NO em dashes** (house rule - colons/hyphens/parentheses). The 36-node rollout in `help_defs.mjs` is the style reference.
- **`createHelpButton(helpDef, opts?)` (the OLD in-body `?` button) is DEPRECATED - do NOT add it to new nodes.** It still exists in `help.mjs` and the popup it opened (`openHelpPopup`) is exactly what the toolbar reuses, but no node should call `createHelpButton` anymore (the in-body buttons were removed from Text / XY Plot / Find and Replace in the rollout). If you see it being added to a node, route the help through `registerNodeHelp` instead.
- **Popup behavior:** dark card, BRAND accents, `min(840px, 94vw)` wide, max-height `82vh`, scrollable; closes on `✕` / `Esc` / click-outside (mousedown-on-backdrop guard so a drag-select that releases on the backdrop does NOT dismiss it - Text Overlay #12). All CSS is `.pix-help-*`, injected once. Only one popup at a time; a global safety-net (`help.mjs` wraps `app.loadGraphData` once) closes it on any workflow load / switch / undo.
16. **Node Help panel - the rich in-node `?` help (shared `js/shared/help.mjs`). Add it to EVERY new node, and to existing nodes when you edit them.** ComfyUI's own Info tab is plain-text and cramped (Vue Compat #14), so this is the richer teaching surface that lives ON the node. It is a small `?` button (the bundled `assets/icons/note/question.svg`, served at `/pixaroma/assets/icons/note/question.svg` and used as a CSS mask so it tints grey -> BRAND orange on hover) that opens a themed, scrollable popup explaining the node. Reference implementation: Find and Replace (`js/find_replace/render.mjs` - the `FR_HELP` content object + the `.pix-fr-toprow` placement). This complements convention #15 (still fill the Python `DESCRIPTION` / input `tooltip`s / `OUTPUT_TOOLTIPS` for ComfyUI's own Info tab); the Help panel does not replace them.
- **Public API** (exported from the shared barrel `js/shared/index.mjs`): `createHelpButton(helpDef, opts?)` returns the `?` `<button>` already wired to open the popup (`opts.title` overrides the hover tooltip); `openHelpPopup(helpDef)` opens it directly (e.g. from a right-click menu item); `closeHelpPopup()`; `injectHelpCSS()` (called lazily by the other two, so you never need to call it yourself).
- **`helpDef` schema:** `{ title, tagline?, sections: [...], footer? }`. Each section is `{ heading, body?, bullets?: string[], defs?: [[term, desc], ...], table?: { headers: string[], rows: string[][] } }` and its blocks render in the order listed (a section can mix several). Any string (tagline / body / bullet / def term+desc / table cell / footer) may contain inline `` `code` `` (backticks) which renders as a monospace chip. ALL text is HTML-escaped first, so author in plain prose. In `body`, a blank line starts a new paragraph; a single `\n` is preserved (`white-space: pre-wrap`).
- **How to wire it into a node:** `import { createHelpButton } from "../shared/index.mjs";`, author a `XXX_HELP` const next to the node's render code, then `someRow.appendChild(createHelpButton(XXX_HELP))`. For a DOM-widget node the button goes in the node body; the popup is a `document.body` overlay so it is renderer-agnostic (works in BOTH legacy and Nodes 2.0) and the button renders in the Vue body because it lives in the DOM widget. For a PAINTED node with no DOM spot, call `openHelpPopup(XXX_HELP)` from a right-click "Help" menu item instead (not yet built; add a `getNodeMenuOptions` item per the Node Colors pattern if needed).
- **PLACEMENT GOTCHA (do not regress):** put the `?` as its OWN flex item in a NON-wrapping top row beside the controls, with `align-self: flex-start`. Do NOT drop it into a `flex-wrap: wrap` control row with `margin-left: auto` - when the node narrows, the wrapping row pushes the `?` onto its own line below the controls (the bug fixed on Find and Replace). The fix shape: `.pix-fr-toprow { display:flex; align-items:flex-start }` containing `.pix-fr-toggles { flex:1 1 auto; flex-wrap:wrap }` (controls wrap among themselves) + the `?` button (`flex:none`, pinned right).
- **Popup behavior:** dark card, BRAND accents, `min(680px, 92vw)` wide, max-height `82vh`, scrollable; closes on the `✕` / `Esc` / click-outside (with the mousedown-on-backdrop guard so a text drag-select that releases on the backdrop does NOT dismiss it - Text Overlay #12). All CSS is `.pix-help-*`, injected once. Only one popup open at a time. NO em dashes in help content (house rule - use colons/hyphens/parentheses).
### Transparent Background Save-to-Disk
Paint, Composer, and 3D Builder each have a "Transparent BG (Save to Disk)" checkbox next to their BG color picker. It only affects **Save to Disk** — the workflow "Save" path is untouched so existing workflows stay compatible (Python nodes still output RGB tensors).
@@ -832,71 +823,6 @@ These patterns were hard-won during Note Pixaroma development. Regressing any of
31. **Matching-bg extension MUST be manual; Chrome's native extension misbehaves at inline boundaries.** When `_applyStagedHilite` sees a collapsed cursor inside a span whose bg matches `_stagedHi` (the "extend the existing run" case), DO NOT just `return` and let Chrome handle it. Confirmed via console logging: Chrome's native insertion at a trailing-edge inline position EMPTIES the matching span and merges the typed char (as `&nbsp;`) into the PREVIOUS adjacent span, then jumps the cursor with it. Result: every other space ends up in the wrong colour. The fix is to manually extend the existing text node via `r.startContainer.insertData(r.startOffset, data)` + `e.preventDefault()`, gated on `r.startContainer.nodeType === 3` (text node — element-level matching is rare and falls through to the regular insert which produces a redundant nested span, visually correct). **Two more rules for the manual-extension path**: (a) **Convert typed `" "` (space) to ` ` (nbsp) unconditionally** — CSS `white-space: normal` collapses a regular ASCII space at the trailing edge of inline content, so the FIRST space typed in a fresh bg span would visually disappear ("first space didn't take"). nbsp doesn't collapse and renders reliably. Trade-off: line-wrap cannot break at those spaces, but highlighted phrases are typically short. The same conversion applies to the SPAN-INSERT path's `span.textContent` for the first char in a freshly-escaped span. (b) **`fgMatches` check** — if `_pickedFg` is set and differs from the bg span's `style.color` (user picked a new text colour while the cursor sits inside an existing highlight), DO NOT extend; fall through to the SPAN-INSERT path, which builds a nested `<span bg color>` with the new colour over the same bg. Without this, MANUAL-EXTEND silently writes new text into the existing text node and the span's old `color` style applies — text colour changes inside a highlight silently get ignored. The check uses `colorToHex` to normalise rgb()/hex for comparison. Subsequent same-colour chars then match the new inner span and extend it via MANUAL-EXTEND.
### Image Crop + Image Uncrop Patterns (do not regress)
Image Crop Pixaroma (`nodes/node_crop.py`) and Image Uncrop Pixaroma (`nodes/node_uncrop.py`) are a crop-and-stitch pair: crop a region, edit/upscale it, paste it back at the exact spot. Both renderers (Uncrop is pure-Python with a native INT `feather` widget + native slots — NO JS file, NO `OUTPUT_NODE`). Verified with torch round-trip tests (pasting an unedited crop back is pixel-exact) + a parallel code-review pass (June 2026).
1. **`crop_info` is the contract.** Crop outputs `(image, mask, crop_info, width, height)`; `crop_info` is a plain dict `{"image": FULL original tensor [1,H,W,3], "x","y","w","h","orig_w","orig_h", "mask": FULL original mask [1,H,W] (optional)}` carried over the custom wire type `PIXAROMA_CROP_INFO` (a duplicated string constant in BOTH node files — keep in sync, like `PIXAROMA_PROMPT_LIST`). It carries the FULL original image AND mask so Uncrop can rebuild a full-frame result. Uncrop passes `crop_info` straight through (its 3rd output) for chaining. Output order is `image, mask, crop_info, width, height` (image stays slot 0 so the common wire survives; width/height shifted → one-time wire break for pre-existing workflows, noted in the changelog).
2. **Crop's MASK rides the same rect as the image.** The optional MASK input is cropped with the SAME rect (`_rect_from_meta` is the single rect source for image + mask). No mask wired → a fully-opaque (zeros) mask sized to the crop. Drag-drop/paste of a transparent PNG uses the file's own alpha (`_full_mask_from_pil` / `_derive_disk_mask`). EVERY return path (`load_crop`, `_load_disk_composite`, `_load_image_from_pixaroma`, `_load_src_and_crop`, blank fallback) MUST return the 5-tuple — an arity mismatch crashes at runtime. The `js/crop/index.js` `nodeCreated` guard-adds the `mask` input (so old workflows get the slot); `_CropOptionalInputs` declares both `image` + `mask` in the optional dict.
3. **Uncrop inputs: `image` required; `mask`, `crop_info`, `feather` optional** — ordered so the slots render `image, mask, crop_info` (ComfyUI draws required before optional), lining up under Crop's matching outputs so the wires run straight across. `crop_info` optional → a mis-wire degrades to a clean passthrough, never a crash (forward `None`, not garbage).
4. **Uncrop's mask output is FULL-FRAME, not the crop rectangle.** It starts from `crop_info["mask"]` (the full original) and only updates the crop region from the wired mask (else leaves it unchanged → the whole transparency passes through). Starting from zeros (the original bug) blacked out everything outside the crop. When `crop_info` is NOT wired, `_passthrough_mask` forwards the WIRED mask unchanged — do NOT return zeros (that blanks transparency; this is exactly the "Load Image → Uncrop directly with no crop_info" case a user hit).
5. **Feather is a distance-to-edge INWARD ramp, NOT a box blur.** `_feather_alpha` ramps alpha 0 AT the rectangle edge → 1 at `feather` px inward, so the paste fades to nothing at its boundary and blends seamlessly. The first attempt (avg_pool2d box blur) bottomed out at ~0.5 at the edge → a visible 50% hard step that NO feather value fixed (the user reported "feather does nothing"; diagnosed by printing the edge alpha = 0.50 at feather 50/100/500). `feather=0` = hard edge. The same feathered seam blends both the image paste and the mask-region update.
6. **Uncrop's mask output MUST match the IMAGE output's device AND batch.** Mask math runs on CPU; before returning, `out_mask.to(out.device)` and repeat it to the image batch (the image batch can grow to match a multi-frame edited crop). Otherwise GPU users hit a device-mismatch crash on strict downstream mask nodes, and batch>1 mis-pairs image vs mask. (Both caught in the review pass; the recommended Join Image with Alpha papered over them, which is why they're easy to miss.) Uncrop also resizes the edited crop to the region automatically (handles upscaled crops) and squeezes 4-D masks.
7. **The disk path (paste / drag-drop / editor Load Image) PREFERS `src_path` over `composite_path` for crop_info.** `_load_disk_composite` loads the FULL source (`src_path`) and crops it on the Python side - same pixels as the editor's pre-cropped composite, but it yields a REAL crop_info (full original + rect) instead of the composite's identity crop_info. Without this, "paste/load an image directly into Crop (no upstream Load Image wired), then Uncrop" returned only the cropped region (Uncrop had no full original to paste onto) while the upstream-wired path worked - a real user report (June 2026). Composite is the fallback only when `src_path` is missing/unresolvable. The editor (`js/crop/render.mjs::_save`) saves BOTH `composite_path` and `src_path`, and `js/crop/core.mjs::open` preserves an incoming `src_path` across paste→edit, so the full source is normally on disk. Upstream-wired still wins over the disk path entirely (it has the live tensor).
### Inpaint Crop + Inpaint Stitch Patterns (do not regress)
Inpaint Crop Pixaroma (`nodes/node_inpaint_crop.py`) + Inpaint Stitch Pixaroma (`nodes/node_inpaint_stitch.py`) are the "crop-and-stitch" inpaint pair: paint a mask in a fullscreen editor, auto-crop a model-friendly region around it, run any inpaint model, blend the result back seamlessly. Geometry/mask/blend math lives in the SHARED `nodes/_inpaint_helpers.py`. Editor + node body: `js/inpaint_crop/` (index.js node wiring + paint.mjs brush + render.mjs canvas/save + core.mjs editor shell + geometry.mjs JS mirror). Inpaint Stitch has NO custom JS (native widgets + global brand). Built June 2026; hardened by a 3-round multi-agent code review (32 → 25 → 4 findings). Re-read before touching.
1. **Reuses the SAME `PIXAROMA_CROP_INFO` wire type as Image Crop/Uncrop** (duplicated string constant in both node files, like crop/uncrop). So the inpaint pair and the geometric crop/uncrop pair are cross-compatible. Output/input order matches the suite: Crop outputs `image, mask, crop_info, width, height`; Stitch inputs `image, mask, crop_info`; Stitch outputs `image, original` (the full uncropped original, straight from `crop_info["image"]`, so before/after compare with Image Compare is two short wires from the END of the chain - NOT on the Crop node).
2. **The geometry (`compute_region`) is mirrored JS↔Python and MUST stay in lockstep.** `nodes/_inpaint_helpers.py::compute_region` (Python authoritative, runs at execute) and `js/inpaint_crop/geometry.mjs::computeRegion` (editor's live crop-rectangle preview). bbox → context expand (`context_px` + `context_pct`) → size mode (keep/force/free, multiple-of-8, min/max guards, allow_upscale) → clamp to image. Only accepted drift: sub-pixel rect placement (Python banker's rounding vs JS round-half-up = ±1px preview). The throwaway harness `D:\Claude Tests\_inpaint_test.py` asserts the round-trip + geometry + seam math; re-run it (with the embedded python) after ANY helper change.
3. **THE SEAM is an OUTWARD-only feather (`_blur_alpha`) - do NOT make it centred/inward.** `blend_mode=mask` composites the inpainted crop back using a feathered mask: alpha = 1.0 everywhere INSIDE the mask AND at its edge, ramping to 0 over `blend` px OUTSIDE (scipy `distance_transform_edt` signed distance → `t = clip(signed/k + 1, 0, 1)` → smoothstep; no-scipy fallback = gaussian-blur the binary then `np.where(mask, 1.0, blurred)`). This is the whole point: a CENTRED feather made the masked content semi-transparent at its OWN edge → the old content ghosted through (the user's "visible halo at any blend"). Earlier wrong attempts (do NOT revert to): (a) `_feather_alpha` (distance-to-RECTANGLE-edge) on the mask - barely touches a mask whose edge is inside the crop, so the slider did nothing; (b) dilate-then-gaussian - pushed a FULL-STRENGTH halo outward so BIGGER blend looked WORSE. `_feather_alpha` (rectangle fade) is still correct for `blend_mode=whole_crop` only. Coverage is the crop node's `mask_grow`; the stitch only softens. **v3 (Option B): the feather has NO rect-edge guard and NO core-opaque clamp - instead `compute_region` grows the crop context to `max(context_px, blend)` (Pattern 5 + `docs/inpaint-crop-math.md` §1) so the mask always sits >= blend px from the crop edge and the outward smoothstep reaches 0 before the border on its own (opaque inside by construction). The v2 guard (`min(feather, smoothstep(de/k))` + a `max(.., mask)` core clamp) was REMOVED - it crushed a too-wide feather and could ghost a mask sitting near the crop edge. Trade-off: a big softness makes the crop a bit larger (the crop box grows in the editor). The editor seam-tint PREVIEW (`render.mjs::_seamAlphaCanvas`) now mirrors the scipy smoothstep too - a downscaled chamfer-DT via `geometry.mjs::seamAlphaFromAlpha` - so preview == result. See `docs/inpaint-crop-math.md` §3/§4.**
4. **Mask resizes ALWAYS use NEAREST, never bilinear** (apply_inpaint_crop out-mask + stitch_back alpha). Bilinear adds its OWN gradient halo on top of the feather (a SECOND softening). The brush is CRISP (the Soft-edge slider was removed in v2; `softness=0`), so `softm = np.maximum(raw, proc)` is just the filled+grown binary core; conditioning softness comes only from `mask_blur`. The conditioning out-mask gets the deliberate `mask_blur` gaussian; the stitch seam gets the deliberate `blend` feather - one intentional softening each, never two.
5. **Seam settings are SPLIT three ways (v2 + user feedback): `softness` = Crop node widget (mirrored in the editor), `blend_mode` = Crop node combo widget too (mirrored in the editor, 2026-06-16), `color_match` = Stitch node widget.** `softness` (0-150, the seam feather = stitch `blend`) is a native Crop INT widget AND the editor's Softness slider, mirrored like the geometry knobs (`node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` so the crop CONTEXT grows to fit the feather - `compute_region` uses `max(context_px, blend)`, Option B / Pattern 3 - AND injects it into `crop_info["blend"]`; the editor's `writeBackWidgets` writes the slider back to the `softness` widget on Save; `readParams` reads it). `blend_mode` (mask/whole_crop) is now ALSO a native Crop combo widget (placed just above `invert_mask`), mirrored by the editor's Blend mode pill via `writeBackWidgets`/`readParams` (`_BLEND_MODE` maps the friendly label `mask`/`whole crop` <-> the internal key); `run` sets `crop_info["blend_mode"]` from the WIDGET, not `state_json` (the widget is the source of truth). `color_match` (off/subtle/strong) is the Inpaint STITCH node's OWN widget - a post-result tweak with no live preview (it was briefly in the editor in early v2, moved back per user feedback); it is NOT in `crop_info`. The crop knobs (`size_mode/target/multiple/context_px/mask_grow/mask_blur/softness`) are all native Crop widgets AND mirrored into the editor; on Save the editor writes ALL back via `writeBackWidgets`. The Stitch node reads `blend`+`blend_mode` from `crop_info` (defaults `16`/`mask` when absent - e.g. an Image Crop `crop_info`) and `color_match` from its own widget. **As of 2026-06-16 the Stitch node ALSO has its own `softness` (INT, -1=inherit) + `blend_mode` (combo, `from crop`=inherit) widgets that OVERRIDE crop_info via `_inpaint_helpers.resolve_seam(crop_info, softness, blend_mode)` - so the seam can be tuned at STITCH time (downstream of the sampler) WITHOUT re-running KSampler (cached on a fixed seed). This is the fix for "changing softness re-samples": the user-facing iterate-cheaply settings (softness + blend_mode) now exist on Stitch; mask_grow/mask_blur stay Crop-only because they genuinely change the sample. No room-clamp on the Stitch override (a value larger than the crop's room may show a slightly harder edge - intentional, instant-feedback; the tooltip says to raise the Crop softness for room). Stitch has NO IS_CHANGED - native widget-value change already re-runs only it.** The brush `Soft edge` slider was removed - the brush is crisp; the editor's `Softness` slider owns seam softness with a live (approximate) DPR-backed preview tinted in a switchable color (`Pixaroma.Inpaint.PreviewColor` setting, display-only). `color_match` matches the inpaint over the UNMASKED CONTEXT (surroundings outside the mask), NOT the mask or the whole crop (both include the masked area and drag a deliberately-changed color back - red->white dress goes pink); it corrects only lighting/tone drift in the unchanged surroundings (falls back to uniform if the mask fills the crop). It is for blending an inpaint INTO the scene, not for deliberate recolors. Seam preview math now mirrors `_blur_alpha`'s SCIPY smoothstep (`render.mjs::_seamAlphaCanvas` via `geometry.mjs::seamAlphaFromAlpha`, a downscaled `(1,√2)` chamfer DT) so preview == result; see `docs/inpaint-crop-math.md` §4.
6. **Editor brush: per-stroke buffer + `lighten` composite + blur-on-bake.** Stamps are crisp white discs drawn on `_sctx` (the per-stroke buffer) with `globalCompositeOperation="lighten"` (max-blend → overlapping stamps can't stack into a hard/dark rim regardless of stroke speed); at stroke end `_compositeStroke` bakes the buffer onto the mask with a gaussian of width `softness*radius`. **v2: the Soft-edge slider was removed and `softness` is fixed at 0, so blur-on-bake is off and the brush is CRISP** (the stamps' anti-aliased discs still prevent jaggies); seam softness is the `Softness`/`blend` slider instead. The mask canvas is white-RGB + per-pixel-alpha (export composites over black → grayscale; the brush soft edge survives). `_exportMaskDataURL` uses `_effectiveMaskCanvas()` so a Ctrl+S mid-drag saves the in-progress stroke; undo/redo mid-drag call `_abandonStroke()` so the abandoned stroke isn't re-baked on mouseup.
7. **Split the mask bbox SCAN from the region COMPUTE.** `_rescanBBox()` (expensive full-image `getImageData` + per-pixel scan) runs ONLY on a mask change (stroke end / clear / invert / undo / redo / mask-load / image-load); `_recomputeRegion()` reads the cached `this._bbox` and is cheap, so the context-margin slider can re-preview on every tick without re-scanning. Every mask-mutation path must call `_rescanBBox()` before `_recomputeRegion()`.
8. **The painting canvas is DPR-backed** (`_fitCanvas`: `canvas.width = dispW*dpr`, `canvas.style.width = dispW px`, `ctx.setTransform(dpr,...)`; draw in LOGICAL px via `this._dispW`/`this._dispH`, init 0 in the constructor, set by `_fitCanvas`). The red tint overlay canvas is ALSO DPR-backed (backing `W*dpr`, drawn back at logical `W`) or it's blurry on HiDPI. **`clearRect` RESPECTS the canvas transform** (per the HTML spec - a common misconception says it doesn't), so clearing with logical dims under `setTransform(dpr)` clears the full canvas correctly; do NOT "fix" it to multiply by dpr (the 3-round review raised this as a false positive twice). `_displayPos` returns CSS px from `getBoundingClientRect`; `_scale = dispW/imgW`; pointer + brush + cursor math all stay in logical px - no DPR in the coordinate math.
9. **Dirty-on-load + persistence:** `node.size` is gated on `!isGraphLoading()`; `onConnectionsChange` gated on `isGraphLoading()`; the `InpaintCropWidget` DOM widget carries `{state_json}` (src_path + mask_path + doc dims) accepted via the FlexibleOptionalInputType (same mechanism as Image Crop's CropWidget); `IS_CHANGED` uses `json.dumps(sort_keys=True)` (key-order deterministic) + tracks mask_path AND src_path mtime. Device: crop outputs `.to(image.device)`; stitch pairs `image`+`original` to the same batch (trim BOTH to `min(B,C)`) and device.
10. **The 3-round review lesson:** human-review the agent findings, don't auto-apply. The review (correctly) caught the bilinear halo, the ghosting feather, and the batch-pairing crash; it ALSO raised false positives that needed rejecting (the `clearRect`-ignores-transform misconception; "canvas listener leaks" that are actually GC'd with the fresh-per-open overlay). Default to outward feather, NEAREST mask resize, and one intentional softening per stage.
11. **Mask source precedence: a PAINTED editor mask wins, but an empty/cleared one falls back to the wired `mask` input - via `resolve_inpaint_mask` in `_inpaint_helpers.py`.** The crop node has TWO mask sources - the wired `mask` input and the mask painted in its own editor (saved to `mask_path` in `state_json`). Clearing the editor (or saving without painting) writes an ALL-BLACK mask file while `mask_path` STAYS SET, so the old "disk mask wins whenever it loads (not None)" let that empty file silently override the wired mask -> `apply_inpaint_crop` saw no painted pixels -> `mask_bbox` None -> it cropped the WHOLE image (user report 2026-06-16: "I painted, then cleared the editor and ran, and it stopped using my wired mask"). `run()` now routes both through `resolve_inpaint_mask(disk_mask, upstream_mask)`: the disk mask wins ONLY when it has painted pixels (`max > 1e-6`); empty or absent -> the wired mask is used; neither -> None (whole image). Unit-tested in the harness (`test_cleared_editor_mask_falls_back_to_wired` + `test_painted_editor_mask_wins` + `test_no_editor_mask_uses_wired` + `test_no_masks_returns_none`). **KNOWN GAP (follow-up "Part B", not yet built):** the editor does NOT import the wired mask, so you can't see/paint ON TOP of it - opening the editor starts blank. The `mask` input tooltip was corrected to match the actual Part A behavior (it no longer claims "loads into the editor"); if Part B lands, have Python save the wired mask to temp for the editor to load (mirror `_save_source_temp` + the `pixaroma_inpaint_source` ui-payload flow) and restore the "paint on top" wording.
12. **`fill_holes` closes only SMALL specks, NEVER a large subject-shaped hole - else a cut-out / background mask collapses to SOLID (user report 2026-06-16: a wired white-bg/black-helmet mask came back as a solid-white output mask "gone after the node").** scipy's `binary_fill_holes` fills EVERY enclosed hole, so for a mask that is white AROUND a subject (the subject is a hole), it fills the subject and the whole mask goes solid -> the crop bbox becomes the whole image and the model repaints everything (the painted shape vanishes). `fill_holes` (in `_inpaint_helpers.py`) now labels the holes `binary_fill_holes` would add and keeps only those `<= max(256, 0.005*H*W)` (~0.5% of the image); a subject hole is far bigger and is left UNfilled. A hole-free mask is unchanged (early `added.any()` short-circuit); the no-scipy PIL-close fallback was already limited to ~9px holes. Python-only (the JS editor never fills holes - it uses the painted-pixel bbox directly). Locked by `test_fill_holes_preserves_large_hole` + `test_fill_holes_fills_small_speck` + `test_background_mask_survives_crop`. POLARITY note: a Load Image MASK from a transparent-PNG cut-out is `1 - alpha` (1 on the see-through BACKGROUND), so it targets the background for inpainting (a "replace the background" workflow); to inpaint the SUBJECT instead, the user inverts the mask.
13. **Inpaint Crop coerces an RGBA input to RGB (premultiply over black) - or `crop_info["image"]` carries 4 channels and Inpaint Stitch silently breaks.** Remove Background Pixaroma's `image` output is RGBA (`torch.cat([rgb, alpha], -1)`: rgb = the ORIGINAL image, alpha = the fg cut-out - see `node_remove_background.py`). Wired into Inpaint Crop, that 4-channel image rode into `crop_info["image"]`; at stitch time `stitch_back`'s paste `patch[...,:region.ch] * av + region * (1-av)` mismatched (3 vs 4 channels) and THREW, so `node_inpaint_stitch.py::run`'s `except` returned `(image, image)` - the `original` output became a copy of the RESULT and the stitch didn't actually paste (user report 2026-06-16: "the original output is the same image, no black bg to compare"). FIX: `apply_inpaint_crop` premultiplies an RGBA input over black (`image[...,:3] * image[...,3:4]`) at the top - a cut-out then reads as the subject on BLACK (matching the editor/preview + the user's expected "original"), not the leftover bg rgb still under the alpha; >4 channels slice to 3. `stitch_back` ALSO coerces `base` defensively (for an RGBA `crop_info` from any other source - e.g. an Image Crop fed RGBA). Python-only; locked by `test_rgba_input_coerced_to_rgb`. FOOTGUN: that silent `except -> (image, image)` in the stitch node is what HID this - any future channel/shape bug there reads as "original == result" rather than a crash, though it does `print("[PixaromaInpaintStitch] stitch error: ...")` to the console.
14. **`invert_mask` (node BOOLEAN toggle, default off) flips the mask before cropping - a built-in replacement for an external Invert Mask node.** `apply_inpaint_crop` does `if p["invert_mask"] and mask is not None: raw = 1.0 - raw` right after `mask_to_np`, so the flip rides through the bbox, the conditioning out_mask, AND `crop_info["mask"]` (the stitch blend follows automatically - no stitch change). Flips a WIRED or a painted mask alike. NO-OP when no mask is connected (`mask is None`) - so it never turns "no mask" into "inpaint everything". It lives in `DEFAULTS` (+ `merge_params` bool-coerces it) but deliberately NOT in `geometry.mjs`'s `GEO_DEFAULTS`: it is a mask-processing op, not a `computeRegion` param, and per the node-only design the editor's live preview does NOT reflect it (the editor keeps its own separate paint-Invert button). A standard BOOLEAN widget -> renders in both node renderers + serializes natively (old saved workflows load it as False). `test_invert_mask_targets_opposite_area` + `test_invert_mask_no_mask_is_noop`.
> **📦 Inpaint pair — RELEASED in v1.3.92 (2026-06-16).** Inpaint Crop + Inpaint Stitch + the editor shipped to the Comfy Registry. The session log below is kept as the do-not-regress REFERENCE (softness/seam math, the editor zoom/pan + brush-perf model, the no-store cache fix); the permanent node invariants live in the "Inpaint Crop + Inpaint Stitch Patterns" + "Inpaint editor v2" sections above, and the editor zoom/pan + perf invariants are in sessions 6-7 below (fold into the permanent Patterns in a later cleanup, then this block can be removed). The one remaining deferred minor (duplicate-node shares project_id) is noted near the end.
> **STATE:** the Inpaint pair + its "editor v2" is BUILT and committed on **`Ioan`** (latest commit **`f7a4f6c`**; ALL deferred review minors now cleared - see the session-2 DONE line below); torch harness green. v2 (this session) changed: `softness` is a Crop-node INT widget 0-150 → `crop_info["blend"]`, mirrored in the editor; `color_match` is the STITCH node's own knob and matches the UNMASKED CONTEXT (`a<0.5`), not the mask/whole-crop (so a deliberate recolor isn't dragged back); `_blur_alpha` = outward smoothstep feather (the rect-edge guard + `max(..,mb)` core clamp were REMOVED in softness rework Part 1 - the crop now grows to fit the feather instead); `_dilate` = fast scipy `maximum_filter`/numpy separable (was ~50s PIL MaxFilter); editor lost the Soft-edge slider (crisp brush), gained a Seam panel (Softness + Mask grow + Blend-mode), a live DPR-backed seam preview (`render.mjs::_seamAlphaCanvas`), a switchable preview color (`Pixaroma.Inpaint.PreviewColor`, display-only), "Reset all to default", and Target-snaps-to-multiple. Pattern 5 above is current.
> **DONE — softness rework PART 1 (functional, Option B), committed on `Ioan` 2026-06-16 (separate commit after the safe fixes; absolute-px softness, chosen by the user over a relative/% redesign).** `compute_region` context expand now uses `max(context_px, blend)` (added `blend:16` to DEFAULTS + the merge_params int-list; `node_inpaint_crop.py::run` sets `params["blend"]=clamp(softness)` BEFORE `apply_inpaint_crop`; MIRRORED in `geometry.mjs` GEO_DEFAULTS + `ctx=max(context_px,blend||0)`; the editor already feeds `this.params.blend` via `_recomputeRegion`, so the editor crop box grows with softness for free). `_blur_alpha` DROPPED the rect-edge guard + the `max(..,mb)` core clamp - the feather now always reaches 0 before the crop edge because the crop grew (opaque inside by construction). Fixes BOTH the original ghost AND the v2 HARD-STEP-at-high-softness (a big feather no longer can't-fit). Trade-off: high softness grows the crop (visible crop-box feedback; small inpaint-resolution cost). Harness: deleted `test_rect_guard_kills_high_blend_border`, added `test_context_grows_with_blend` + `test_feather_completes_when_crop_has_room`, renamed the 2 survivors to `test_feather_*`; 13 green. Docs: `inpaint-crop-math.md` §1/§3/§5 + Patterns 3 & 5 + the Crop help def.
> **DONE — softness rework PART 2 (preview parity), committed on `Ioan` 2026-06-16.** `render.mjs::_seamAlphaCanvas` now mirrors the scipy smoothstep instead of the no-scipy gaussian: it draws the mask into a downscaled buffer (long side capped 480px), runs `geometry.mjs::seamAlphaFromAlpha` (a pure, DOM-free 2-pass `(1,√2)` chamfer DT of the OUTSIDE distance, then `alpha = inside ? 1 : smoothstep(clip(1 - d_out/kBuf))`), and upscales. So the editor seam tint now MATCHES the stitched result — the user no longer over-sets softness. NO cache needed: `_draw` runs only on strokes / slider-drags / load (NOT idle mouse-move - the brush cursor draws on a separate canvas), and the capped buffer keeps the DT ~2-3ms. The pure fn is node-tested (`D:\Claude Tests\_inpaint_seam_test.mjs` - inside=1, smoothstep falloff to 0 at k px, monotonic, empty/full); the full canvas path is eye-verified. Docs: `inpaint-crop-math.md` §4 + Patterns 3 & 5 updated. DEEPER (user raised twice, NOT chosen): RELATIVE/% softness - a bigger 'feel' redesign; absolute-px was chosen. **The softness rework is now COMPLETE (Parts 1 + 2); delete this WIP HANDOFF once the pair is RELEASED.**
> **DONE — keep/free stretch fix (committed on `Ioan` 2026-06-16; a follow-up that Part 1 exposed).** Part 1's ctx-growth (a big softness grows the crop region) surfaced a latent stretch: in `keep`/`free` mode `compute_region` computed `out_w/out_h` from the UN-clamped wanted region while the SOURCE rect was clamped to the image, so when the grown region hit the image edge the crop was resized at a different aspect = squished (user report: softness 150 stretched an inverted helmet mask, output came out 768x1024 not square). FIX: `compute_region` now derives `out_w/out_h` from the CLAMPED source rect (`rw_i/rh_i`) for ALL modes (generalises the force re-impose) - moved the size computation AFTER the clamp. Locked by `test_keep_mode_no_stretch_on_clamp`; mirrored in `geometry.mjs` + node-checked (`D:\Claude Tests\_inpaint_geom_test.mjs` `GEOM PARITY PASS`). `docs/inpaint-crop-math.md` §1 rewritten (clamp first, output from the clamped rect).
> **DONE — round-2 safe fixes (committed on `Ioan` 2026-06-16, a separate commit BEFORE the softness rework; no behavior change beyond fixing the bugs).** (1) OOM: `compute_region` keep-mode now runs the min_size short-side bump FIRST and the max_size clamp LAST (hard ceiling), so an extreme-aspect (thin-line) mask can't blow the output past max_size (the short side may then end < min_size - acceptable). Mirrored in `geometry.mjs`. (2) FORCE-STRETCH: force mode now RE-IMPOSES the source aspect == `out_w/out_h` AFTER the image-bound clamp (shrink the over-long axis to the largest aspect-correct rect that fits), so an oblong image is no longer stretched. Mirrored in `geometry.mjs`. Both locked by harness tests `test_keep_mode_respects_max_size` + `test_force_mode_preserves_aspect`; `docs/inpaint-crop-math.md` §1 updated.
> **DONE — ALL 7 deferred review minors + a parity gap (session 2, 2026-06-16, TDD'd small commits on `Ioan`; a 2nd 2-agent review of these = both SHIP, no critical/blocking):** `1fab596` FREE mode scales BOTH axes on the max_size clamp (was per-axis = stretch; Python + `geometry.mjs` mirror). `cad1586` `stitch_back` mask-mode: an all-zeros/all-ones resolved alpha (no edge - an Image Crop crop_info with no per-pixel mask, OR a native whole-image inpaint with nothing painted) falls back to the whole-crop rectangle feather, so the crop IS pasted (soft seam) instead of nothing/hard-rect. `2ef7cd6` `crop_info["mask"]` stays float32 (dropped `image.dtype`). `754e017` editor: Reset-all returns the tool to Brush; `onConnectionsChange` WRAPPED+chained (not assigned); image/mask loaders carry a `_loadToken` generation guard (a late onload can't clobber the current image / fresh strokes - verified the legit saved-mask restore still works). `7b7797a` doc nits in `inpaint-crop-math.md` §6. `f7a4f6c` (review-surfaced) `geometry.mjs` roundMult now rounds half-to-EVEN to match Python's banker's `round()` - the editor crop-box size no longer reads one multiple step off from the real output at exact .5 boundaries (JS+Python cross-verified). OPTIONAL polish still open (NONE block): (UX) `_loadMaskFromURL` could `_setStatus` a hint when it skips a slow saved-mask load after the user painted; (doc) a §1 caveat that free mode drifts aspect only for pathological >40:1 crops.
> **DONE — session 3 (2026-06-16, user testing feedback; commits `11afe83` + `718960a` on `Ioan`):** (1) `blend_mode` (mask / whole crop) is now a Crop NODE combo widget (above `invert_mask`), mirrored by the editor pill via `readParams`/`writeBackWidgets`/`render.mjs` `extra.blend_mode`; `run` sets `crop_info["blend_mode"]` from the widget. Mask vs Whole crop is explained in the widget tooltip + the toolbar help def + the editor Guide + `inpaint-crop-math.md` §5. (2) BUG fix: pasting an image into a Load Image node then opening the mask editor gave "Failed to load the source image" - `getUpstreamImageURL` now parses the LoadImage value's subfolder + `[input]/[output]/[temp]` annotation (`parseAnnotatedImageValue`) instead of jamming it all into `filename=`. (3) the open editor now accepts paste + drag-drop to load a source image (`paint.mjs` `_bindDropPaste`/`_unbindDropPaste`, window-capture, gated on the overlay; the node-body paste handler bails while the editor is open). (4) the saved-mask loader shows a "Kept your new strokes" status hint when it skips a late load (was the session-2 optional UX item - now done). (5) the editor Help popup is rewritten into a short Guide (what it does / painting / crop modes / seam Softness + Blend mode / keys); `framework/layout.mjs` gained an optional `helpTitle` (back-compat default) so it titles "Inpaint Crop — Guide". NOTE: `invert_mask` was ALREADY under softness in code (commit `3020887`) - the user's screenshot was pre-restart. Remaining OPTIONAL: a §1 doc caveat for pathological >40:1 free crops (not a bug).
> **DONE — session 4 (2026-06-16; commit `ec4c5d8` on `Ioan`; brainstormed-then-built per the user's "build straight through" pref - no spec doc/writing-plans):** the user hit "changing softness / mask grow/blur re-runs KSampler even on a fixed seed". Root cause: those settings live on the Crop node (before the sampler), so changing them invalidates the crop -> re-sample; buttons can't fix it (ComfyUI already partial-executes). FIX (user chose "add to Stitch"): the PURE paste-back settings now ALSO live on Inpaint Stitch - new `softness` (INT, -1=inherit crop) + `blend_mode` (combo, `from crop`=inherit) widgets that OVERRIDE crop_info via the new `_inpaint_helpers.resolve_seam(crop_info, softness, blend_mode)`. Stitch is downstream of the sampler, so changing them re-runs ONLY Stitch (KSampler cached on a fixed seed) = instant blend tuning. `mask_grow`/`mask_blur` stay Crop-only (they change what the model paints -> must re-sample; the user's premise that those are "the same image" is wrong, explained to them). NO room-clamp on the override (too-big softness may show a harder edge; tooltip says raise Crop softness for room). Stitch needs NO IS_CHANGED (native widget-value change re-runs only it). `resolve_seam` locked by `test_resolve_seam_inherit_and_override` (harness now 28 green). Backward-compatible: defaults inherit, so existing graphs are unchanged (one-time Stitch re-run + maybe a "save changes?" on first load after the update, from the 2 added widgets - normal for a schema addition). Docs: tooltips + DESCRIPTION + Stitch help def (new Settings block) + `inpaint-crop-math.md` §5 + Pattern 5 above.
> **DONE — session 5 (2026-06-16; systematic 3-agent audit + fixes; commits `dba1417` + `4752a97` + `f8befb5` on `Ioan`; harness 28 green):** the audit fuzzed ~10k Python inputs + 86k JS↔Python parity combos and found the core ROBUST (zero crashes/wrong-output in the math); fixed: (1 MAJOR, JS) the editor Softness slider didn't call `_recomputeRegion` so the crop box/badge went stale at high softness (preview != result) - now mirrors the Mask-grow slider; (2) `_feather_alpha` over-faded the WHOLE paste translucent when the feather exceeded half a small crop (more reachable via the Stitch softness override) - capped at ~half the smaller side, locked by `test_feather_alpha_caps_on_small_crop`; (3, was deferred A1) whole_crop seam preview now reflects the rectangle-edge feather (`_seamAlphaCanvas` whole_crop branch) instead of the mask-edge feather - preview is canvas-rendered so eyeball it; (4, was deferred A3) Inpaint Stitch guard now requires `image.dim()==4` so a malformed crop_info takes the clean passthrough instead of the silent except that made `original` a copy of the result; (5) the node thumbnail blanked after a tab switch when the source was editor-loaded - `restoreNodePreview` now gets the real `stateJson` (rebuilds from `src_path`) not `"{}"`; (6 nit) `node.size` default now mutates `[0]/[1]` per convention #9. Deliberately NOT changed (audit-confirmed correct/edge): invert on an all-zero CONNECTED mask -> whole-image inpaint (mathematically the complement of nothing; tooltip's "no mask connected" claim is literally about `mask=None`); force/whole_crop stretch on a PATHOLOGICAL sub-1px-aspect tiny image; the no-scipy `_blur_alpha` fallback feather being tighter than the scipy preview (documented carve-out, scipy ships with ComfyUI).
> **DONE — session 6 (2026-06-16; editor UX from user testing; commits `85c4866` + `bd6067c` + `0add5be` on `Ioan`; JS-only):** (1 PERF - the brush was saccadato/stuck on big images) every mousemove ran a SYNC full `_draw` that recomputed the seam distance-transform (a getImageData readback + per-pixel pass) PER FRAME. Fix: `_requestRedraw` coalesces stroke redraws to one per rAF (THIS is the perf fix - caps draws at 60fps, kills the event backup). rAF cancelled on endStroke + cleanup. **DO-NOT-REGRESS: never do a SYNCHRONOUS full `_draw` per mousemove - coalesce to rAF.** [SESSION-7 UPDATE: an earlier "skip the seam DT while `_painting`" extra optimization (crisp tint mid-stroke, feather on release) was REVERTED - it made the mask FLASH crisp(painting)->feathered(release) each stroke (obvious at softness 1-20, invisible at 0). The feathered seam DT now runs DURING paint too (coalesced = still smooth at full HD); if a very large image lags mid-stroke, THROTTLE `_seamAlphaCanvas` while `_painting` (cache + recompute ~every 60ms) rather than skipping it. Also session 7: the brush ring is hidden while Space-held/panning (was frozen at its old spot).] (2) the mask tint was clipped to the crop region even mid-stroke, hiding strokes painted beyond the box until release ("interrupted by the context margin") - the clip is now skipped while `_painting`. (3) Brush/Erase pills show (B)/(E); the Brush panel shows `[ ] / scroll=zoom / space-drag=pan` hints. (4 FEATURE - ZOOM + PAN, user chose wheel=zoom) wheel zooms toward the cursor (re-rendered from the full-res source = CRISP, not a CSS upscale), brush size moved to `[ ]` + the Size slider, pan via Space-drag or middle-drag. Coord model: `_baseScale`(fit) + `_zoom` + `_panX/_panY`; `_scale = _baseScale*_zoom`; ALL paint/cursor/box/tint coords offset by `_panX/_panY` + use `_scale`, EXCEPT `_seamAlphaCanvas` uses `_baseScale` (it builds a fit-res canvas drawn at the zoomed view rect, so `_scale` would double-count the zoom). `_applyZoom` keeps the source point under the cursor fixed; `_clampPan` keeps the image covering the viewport (forces pan 0 at fit). The brush's SOURCE radius `(brushSize/2)/_scale` shrinks as you zoom in -> finer detail. At zoom1/pan0 it reduces byte-identically to the prior behavior (no unzoomed regression). Canvas coords are NOT headless-testable -> the USER verifies paint alignment + zoom; all JS so Ctrl+F5.
> **OPEN — older minor (still deferred, the ONLY one left).** Duplicating an Inpaint Crop node (Ctrl+C/V) copies its `state_json` incl. `project_id`, so the clone + parent share the same on-disk mask/src files (editing one can overwrite the other). PRE-EXISTING + suite-wide: `js/crop/core.mjs` (Image Crop) has the identical `data.project_id || "crop_"+Date.now()` pattern. Low impact (files are read-only in `input/pixaroma/`, recoverable; the PASTE path mints a fresh id so it's immune - only editor-save-on-a-fresh-clone collides). Fix if ever: on clone/duplicate, re-mint `project_id` + clear `mask_path`/`src_path` - and fix BOTH Inpaint Crop and Image Crop together.
> **HARNESS:** `D:\Claude Tests\_inpaint_v2_test.py` (machine-local; recreate if absent). Run: `& "D:\ComfyTest\ComfyUI-Easy-Install\python_embeded\python.exe" "D:\Claude Tests\_inpaint_v2_test.py"` → expect `scipy available: True` + `RECT GUARD PASS` (28 tests through session 6, runs each in try/except + tallies; that sentinel string is just the all-green marker - the rect guard itself is gone). Session-2 additions: `test_free_mode_no_stretch_on_max`, `test_image_crop_interop_no_mask_feathers`, `test_crop_info_mask_is_float32`, `test_native_empty_mask_pastes_whole_crop`. Covers `_blur_alpha` (outward feather: `test_feather_normal_blend` / `test_feather_core_stays_opaque` / `test_feather_completes_when_crop_has_room` / `test_blend_zero_is_passthrough`), `_dilate`, `_color_match`, `compute_region` (`test_keep_mode_respects_max_size` / `test_force_mode_preserves_aspect` / `test_context_grows_with_blend` / `test_edge_clamp_keeps_inner_margin` - the last locks "image-edge clamp is benign"), and `resolve_inpaint_mask` (the 4 mask-precedence tests). Part 2 (preview parity) is JS-only and verified BY EYE, not here.
> **REWORK CLEANLY as ONE tested commit** (the half-applied attempt got stuck mid-edit — do Python + JS-mirror + harness + docs TOGETHER, run the harness, THEN a 2-agent verify). DOCS to update: `docs/inpaint-crop-math.md` (§3 feather, §5 flow), Patterns 3 + 5 above, Stitch/Crop `help_defs.mjs`. `.py` → full ComfyUI restart; `.js` → Ctrl+F5. Commit on `Ioan` (local; push only when the user says). Pair is UNRELEASED (v1.3.91): release = README "🎨 Creative Suite" showcase + changelog (one entry/day, plain language) + `pyproject.toml` bump + merge Ioan→main + push `origin` (GitLab CI publishes) + push the `github` backup.
### Preview Image Pixaroma Patterns (do not regress)
These patterns were hard-won during the May-2026 batch + save-mode upgrade. Several of them generalize to ANY canvas-rendered preview node, so re-read this section before building a similar widget.
@@ -1136,32 +1062,6 @@ These patterns were hard-won during the May-2026 implementation. Re-read before
19. **The file dropdown is a thumbnail picker (B2 design), not a text list (May-2026).** `openImageDropdown` in `js/load_image/ui.mjs` renders a popup with: a filter `<input>` (auto-focused on open) + a Small/Large size toggle, an optional left folder sidebar (`All` + each subfolder, with counts), and a right scrollable pane of `thumbnail + filename` rows grouped by folder under sticky section headers. Helpers `thumbURL`/`getThumbSize`/`setThumbSize` sit just above the function. Behavior: opens to **All** (`activeFolder = "__all"`); clicking a sidebar folder narrows the pane; typing in the filter searches **across all folders** (ignores the sidebar selection until cleared); clicking a row routes through `setSelectedImage` (same as arrows/upload) then `closePopup()` then `onPick`. The sidebar is omitted entirely when `input/` has no subfolders (`hasSubfolders` false → single-pane list). Key points to not regress: (a) **thumbnails use a RELATIVE `/view?filename=...&type=input&subfolder=...` URL** (built by `thumbURL`, splitting via `splitFilenameSubfolder`) so they load on ANY host/port — never hard-code a host (the cross-origin grey-thumbnail issue only happens in standalone mockups, not inside ComfyUI); thumbnails are `loading="lazy"` with a gradient placeholder + `onerror`-hide so missing files don't show broken-image icons. (b) **Size persists via the `Pixaroma.LoadImage.ThumbSize` setting** — the S/L toggle reads/writes it (`getThumbSize` fallback "Large"); it's the only Load Image setting so its leaf category `["👑 Pixaroma","Load Image"]` doesn't collide (settings dedupe by leaf). (c) **Close handling is the standard capture-phase mousedown/pointerdown/wheel/keydown set, each gated on `!popup.contains(e.target)`** (Pattern #14) — the wheel gate is what lets you scroll the pane without closing; the empty-values early-return path ALSO registers + later detaches these. (d) **Re-open detaches the prior popup's listeners** by calling its stored `popup._pixClose()` rather than a bare `.remove()`, so a stale node's Escape can't close a new popup. (e) **The popup clamps horizontally and flips ABOVE the row** when it would overflow the viewport bottom (measured post-append). (f) `renderPane` calls `scrollCurrentIntoView()` on every non-search render so the current image re-centers on folder-switch / search-clear (NOT during active search, so typing doesn't yank the view). The combo's `values` are images-only (Python `node_load_image.py` filters via `folder_paths.filter_files_content_types(files, ["image"])`), so non-image input files never reach the picker. Long folder/file names ellipsis-clip (`min-width:0` on the flex children). The `◀ ▶` arrows + PageUp/PageDown still step through `options.values` in flat array order independently of the popup's grouped display.
### Load Images from Folder Pixaroma Patterns (do not regress)
The "folder version" of Load Image: load + multi-select images from ANY folder on disk and emit them one-by-one. Files: `js/load_images_folder/` (index.js = setup + state + graphToPrompt injection + refit + native-dialog Browse handler; ui.mjs = node body + gallery popup + folder-browser popup + CSS; api.mjs = fetch helpers; state.mjs = DEFAULT_STATE/readState/writeState/sortFiles) + `nodes/node_load_images_folder.py` + the `api_lif_*` routes in `server_routes.py`. Built for BOTH renderers from the start; survived 3 multi-agent review rounds. Re-read before touching it.
1. **It uses ComfyUI's LIST mechanism (`OUTPUT_IS_LIST = (True,)*7`), NOT a queue-driver.** The node runs ONCE and returns 7 equal-length lists (image, mask, width, height, filename, index, total); ComfyUI then runs the downstream graph once per item. Pure data-flow - it does NOT patch `app.queuePrompt`, so it never multiplies runs and multiple instances coexist cleanly. Do NOT convert it to a queue-driver. The lists are appended in lockstep in the one successful-load branch; `total = len(images)` = the LOADED count (failed/missing files are skipped with a console warning and excluded from index/total - `index` stays contiguous 1..N over survivors). One Run = N results; the Run "batch count" stays 1.
2. **State = Pattern #9 hidden-input + graphToPrompt injection.** `node.properties.loadImagesFolderState` (folder, recursive, sort, sort_dir, selected[], + the SAME resize keys as `node_load_image.py::DEFAULT_STATE`) is injected into the hidden `LoadImagesFolderState` STRING input by the `app.graphToPrompt` hook (idempotent via `app._pixLifGraphPatched`; `collectNodes` walks `subgraph`/`.graph`/`._graph`; `matchNode` handles composite `a:b` ids). The resize keys MUST stay identical to Load Image's so the SHARED `_resize_frame` (Python) + `buildModePanel` (JS) work unchanged. NO wire inputs (`required:{}` + hidden state, Resolution pattern); `stripInputs` + `onConnectInput = () => false`; no `onConnectionsChange` (nothing to replay, so Vue Compat #17/#19 N/A).
3. **Path-traversal guard is MANDATORY in BOTH `load()` AND `IS_CHANGED`.** `selected` is frontend-supplied, so EVERY file is validated `os.path.realpath` + `os.path.commonpath([path, real_folder]) == real_folder` before any stat/open (a crafted `../../x` must not escape the folder), AND `if not isinstance(rel, str) or not rel: continue` (a `null`/number in hand-edited state must not crash with TypeError - it bit IS_CHANGED in a review round). (June 2026: the realpath+commonpath guard was added to `IS_CHANGED` too - it previously had ONLY the isinstance check and would `os.stat` an escaping path; outside paths now hash as a constant instead.) The routes deliberately read the user's OWN arbitrary folders (NOT constrained to input/ - the whole point), are read-only, and the thumb route additionally guards the per-file path with `_is_path_under`.
4. **The native folder dialog runs on the ComfyUI HOST and returns a REAL path (browsers can't).** Cross-platform, NO extra Python deps (embedded Python lacks tkinter): Windows = PowerShell + WinForms FolderBrowserDialog shown via an invisible TopMost owner form (the owner-form `Add_Shown` trick is what brings it to the FOREGROUND instead of behind the browser); macOS = `osascript choose folder` (start path regex-sanitized, `?path=` is caller-supplied); Linux = zenity/kdialog. All via `run_in_executor` (never block the aiohttp loop) + `timeout=300` + a module lock (one dialog at a time). `_lif_native_folder_dialog` returns path / `""` (cancelled) / `None` (busy = lock held); the route maps None->`{busy:true}` so a SECOND node's Browse falls back to the in-app browser. The JS Browse handler opens the in-app `openBrowsePopup` for anything that isn't ok+path and isn't `cancelled` (so unavailable/busy/error all fall back). `_lif_dialog_available()` gates the route. The classic Windows dialog was kept over a faster ctypes-COM IFileOpenDialog deliberately - subprocess can't crash ComfyUI, raw COM vtable calls can.
5. **Heavy filesystem/PIL work is off the event loop.** The recursive folder walk (`_lif_list_files`) and the thumbnail decode/resize (`_lif_make_thumb`) both run via `run_in_executor`, so a huge folder or a big image can't stall other ComfyUI HTTP requests.
6. **Dirty-on-load + sizing (Vue Compat #18 + Nodes-2.0 sizing rules):** getMinHeight is COARSE-ROUNDED and there is NO getMaxHeight (a live getMaxHeight creeps node.size on load). `refit(node)` (the only shrink/grow driver) mirrors Image Resize: rAF + `node.computeSize()` + `node.setSize([w,h])` (NOT a bare `node.size[1]=` - that reverts across a renderer switch), SELF-GATED on `isGraphLoading()` so it never runs on the load path; called from `renderResize`. Default width is fresh-only (`if (!isGraphLoading()) node.size[0] = DEFAULT_W`; LiteGraph's title-driven default is wider than MIN_W so a `< MIN_W` guard would never fire; DEFAULT_W=370 fits the 6 resize quick-pick chips on one line). MIN_W clamp is in BOTH onResize AND onDrawForeground, gated `!isVueNodes()`. `installResizeFloor` for the Nodes-2.0 drag floor.
7. **`refreshListing(node, userAction)` race-guard: bump the request token on the FIRST line, before the no-folder early-return.** `node._pixLifListReq` is bumped first; after `await listFolder`, bail if `node._pixLifListReq !== myReq || !node._pixLifUI` (a newer refresh superseded this, or the node was removed). Bumping BEFORE the early-return is what makes clearing the folder invalidate an in-flight fetch (a real bug when the bump sat after). The reconcile-write (dropping selections of deleted files) is persisted ONLY on `userAction && !isGraphLoading()` (never on load -> no dirty-on-load; Python tolerates missing files at run).
8. **Popups (gallery + folder-browser + sort mini-menu) are document.body fixed-position, anchored via `getBoundingClientRect` (zoom-correct), closed via the shared `attachClosePopup`** (capture-phase mousedown/pointerdown/wheel/keydown each gated `!inside(e.target)`; the wheel gate lets you scroll inside without closing - Load Image Pattern #14). The gallery passes `ignoreSelector: ".pix-lif-menu"` so its sort sub-menu doesn't close it. `attachClosePopup` skips registering listeners if the popup was closed before its `setTimeout(0)` fires (no leaked capture listeners). Each popup is tracked per-node (`node._pixLifGallery`/`_pixLifBrowsePop`, nulled on close) so `onRemoved` closes ONLY this node's popups, not every node's. Async popup handlers (subfolder re-list, browse nav) bail on `popup._pixClosed` after their await. Thumbnails are `loading="lazy"` with `onerror` hide.
9. **Selection lives as `state.selected` (display/sorted order); the gallery `selSet` mirrors it; `commit()` re-reads fresh state and writes only the gallery-owned keys** (selected/sort/sort_dir/recursive) so a concurrent resize edit can't be clobbered. In RECURSIVE mode the Python `filename` output is the rel path with separators replaced (`sub/cat.png` -> `sub_cat`) so two same-named files in different subfolders can't overwrite each other through a Save node; flat mode uses the basename.
10. **Resize reuses the shared engine WITHOUT panel_polish.** `buildModePanel(state.mode, node, readState(node), writeState, onChange, "loadImagesFolderState", {oneLine:true})` + `injectResizePanelCSS()`. The shared builders re-read `node.properties[stateKey]` and only override one resize key, so folder/selected survive a resize edit. panel_polish (inline labels / W-H grid / anchor picker) is NOT used (its CSS lives in load_image, not the shared module) - so Crop-to-fill uses the center-anchor default; the simple modes (Off/Max MP/Longest side/Scale/Fit/Match/Pad) are fully functional.
11. **Gallery "First N" box selects live + caps to the image count (June 2026).** The `First` control is a number box + a button; the box DRIVES the selection itself via the shared `applyFirstN(value)` (which clamps the SELECTION to the file count and RETURNS the clamped count). Wiring in `ui.mjs`: typing selects live, debounced ~220ms (`input` -> `flushFirst(false)`, so "12" applies once not 1-then-12); Enter / blur / spinner commit via `flushFirst(true)`; the button applies + normalizes the box to `>=1`. An EMPTY box is "no instruction" - the selection is left untouched (use None to deselect), and the `input` handler returns early on empty so clear-to-retype never clobbers. The box CAPS to the count two ways: `updateCounts` sets `firstInput.max = total` (spinner stops there) AND an over-cap typed value snaps the box text down to the count - but ONLY on commit (`snap=true`), NEVER on the debounced `input` path, or pausing mid-type above the cap would rewrite the box and shift the next digit you type. Do NOT collapse this back to a button-only control (the box looked "broken" before - it changed value but never selected). The debounced timer fires after close are guarded by `gal._pixClosed`.
### Prompt Reader Pixaroma Patterns (do not regress)
These patterns were hard-won during the May-2026 implementation and post-review hardening pass. Re-read this section before touching `nodes/_prompt_reader_helpers.py`, `nodes/node_prompt_reader.py`, the `/pixaroma/api/prompt_reader/extract` server route, or any file under `js/prompt_reader/`.
@@ -1200,26 +1100,6 @@ These patterns were hard-won during the May-2026 implementation and post-review
14. **Walker MUST extract Prompt Multi Pixaroma's active prompt from the hidden state JSON.** Same family of bug as Pattern #13: Prompt Multi has no wired text output either. The JS `app.queuePrompt` patch in `js/prompt_multi/index.js` enqueues ONE workflow per enabled row, with each workflow's hidden `PromptMultiState` input baked at submit time as `{"version":1,"activePrompt":"<the row's text>"}`. So each saved PNG carries the exact prompt that produced THAT image (not all rows - just the one). Recovery is therefore a direct read, NOT a reconstruction from rows: `_walk_for_text` dispatches on `class_type == "PixaromaPromptMulti"` right after the Prompt Stack branch and calls `_pix_prompt_multi_extract(inputs)`, which parses `inputs.PromptMultiState` and returns `state["activePrompt"]` (stripped, None if empty). Do NOT mistakenly try to read `rows` here - the JS hook deliberately strips them and ships only `activePrompt` to keep the embedded workflow small and unambiguous. Bug class this prevents: user generates 3 images via Prompt Multi (red / blue / green), drops the blue PNG back on Prompt Reader, gets "no positive prompt found" when in fact the blue prompt is sitting right there in the hidden state.
### Seed Pixaroma Patterns (do not regress)
Seed Pixaroma (`js/seed/index.js` + `nodes/node_seed.py`) is a seed SOURCE: an INT `seed` output wired into KSampler (or any seed input). Compact locked-width DOM-widget node, both renderers. Modeled on Resolution Pixaroma (hidden `SeedState` STRING input + state on `node.properties.seedState` + `app.graphToPrompt` per-run injection). Built + hardened across 5 agent-review rounds (shipped v1.3.93). Re-read before touching.
1. **State split: `{seed, mode}` is serialized on `node.properties.seedState`; the last-run seed is RUNTIME-ONLY on `node._pixSeedLastRun` (NEVER node.properties).** A run records the last-run seed on the runtime field, so it can NEVER rewrite serialized state -> never dirties a saved workflow (Vue Compat #18). Consequence: "Use last seed" is session-only (greys out after a reload until you Run once - documented in the help). The `app.graphToPrompt` hook must NOT write `node.properties` (only `entry.inputs` + the runtime field). This is the single biggest divergence from Resolution (whose hook only reads) and was the original dirty-on-load bug.
2. **Random vs Fixed re-execution = `IS_CHANGED(SeedState)` + a Random-only nonce.** Python `IS_CHANGED` returns the injected `SeedState` string. Random injects `{runSeed, _n: ++nonce}` (a monotonic per-call nonce) so the string ALWAYS differs -> the node re-runs even on the ~1-in-2^53 chance two rolls collide. Fixed injects `{runSeed}` with NO nonce -> constant string -> ComfyUI caches -> repeatable. `get_seed` reads `runSeed` and ignores `_n`.
3. **Body height is MEASURED, never a hand-guessed constant** (this oscillated gap<->clip for several commits before landing here). `getMinHeight` returns `measureSeedHeight(root)` = shared `measureRootContent` (children offsetHeight + gaps + padding), coarse-rounded to a 4px grid so it's dirty-on-load stable. There is NO `getMaxHeight` (a single-widget node has no slack consumer; a max cap CLIPS the bottom line - that was the bug). Plus `computeLayoutSize({minHeight: measured, minWidth: 1})` for the Nodes 2.0 grid row (`minWidth:1` so the locked width round-trips). Do NOT force `node.size[1]` (forcing it caused the gap-then-clip); lock WIDTH only and LEGACY-only (`if (!isVueNodes())`), and a legacy-only post-layout `setSize` snaps the node to the measured height (idempotent via coarse-rounding -> load-safe).
4. **The big number auto-fits; its box has a FIXED height.** `fitSeedFont` shrinks the inline font-size 19->11px until a 16-digit seed fits the narrower Nodes 2.0 body (scheduled on rAF + 2 setTimeouts for the fresh-drop layout race, and on commit). `.pix-seed-num` is `height:42px` FIXED so the font change can't alter `measureSeedHeight`. `rollSeed()` stays in `[0, 2^53)` so it round-trips through JSON exactly; Python clamps to `0..2^64-1`.
5. **`commitNum` is SURGICAL, never a full rebuild.** It no-ops when the value is unchanged (so a bare focus/blur does NOT flip Random->Fixed), and on a real change writes state then updates the pill (`syncModeUI`) + last-run line in place. A full `renderUI` rebuild on blur would destroy the pill/button mid-click (the "first click does nothing" race). The initial render goes through `buildSeedBody` on the captured root directly (NOT a `renderUI` that bails on `isConnected`) so a fresh drop isn't blank.
6. **Copy copies the DISPLAYED number (`state.seed`) and needs an `execCommand` fallback.** WYSIWYG-copy; to grab the actual last-run seed in Random mode, "Use last seed" loads it into the box first. `navigator.clipboard` is undefined in INSECURE contexts (ComfyUI over `http://<LAN-IP>` / phone / tunnel), so `copySeed` falls back to a throwaway-textarea + `document.execCommand("copy")` (same pattern as Show Text / Version Check / Note).
7. **Mode pill segments are real `<button>`s** (keyboard-reachable + `:focus-visible`), not `<div>`s - matches Prompt Pack / Prompt Multi.
8. **Headless/API (no browser) -> seed 0.** The seed is chosen by the frontend `graphToPrompt` hook, so a pure-API run sends the `"{}"` default and `get_seed` returns 0. Inherent to the Pattern #9 architecture (same as Resolution); documented in the help (use Fixed for API pipelines).
### Text Pixaroma Patterns (do not regress)
The Text Pixaroma node (`js/text/index.js` + `js/text/dynamic_prompts.mjs` + `nodes/node_text.py`) is a multi-line text box that hides ComfyUI's native `multiline` widget and renders a custom DOM textarea (mirrored back to the hidden native widget for serialisation - see node UI convention #4). Bottom bar: Copy all / Replace / Clear action buttons + a Dynamic prompts SWITCH; a `?` Help button floats in the text box corner.
@@ -1234,7 +1114,7 @@ The Text Pixaroma node (`js/text/index.js` + `js/text/dynamic_prompts.mjs` + `no
6. **Dynamic prompts resolve in the FRONTEND only (`app.graphToPrompt` hook) - a pure API / headless / cron run does NOT resolve `{a|b}`.** Because the switch state lives in JS (`node.properties`) and the `{a|b}` resolution happens in the `app.graphToPrompt` hook (so the SAVED workflow keeps the raw template and only the submitted prompt is resolved), a workflow submitted WITHOUT the browser - the ComfyUI `/prompt` REST API directly, a headless/automation script, some batch runners - will send the literal `{a|b}` template to Python, which passes it through unchanged. This is NOT a regression: ComfyUI's native `dynamicPrompts: True` (the thing we removed) ALSO only ran in the frontend's graphToPrompt, so it had the exact same blind spot. Accepted as-is; documented here so it isn't re-investigated as a bug. (If true headless dynamic-prompt resolution is ever needed, it would require a Python-side resolver gated on a hidden switch-state input injected via Pattern #9 + `IS_CHANGED` returning nan for fresh randomness - deliberately not built.)
5. **The in-body `?` Help button was REMOVED (June-2026 toolbar-help rollout - convention #16).** Text's help content (`TEXT_HELP`) still lives in `js/text/index.js` but is now shown via the node SELECTION-TOOLBAR `?` button: registered at module load with `registerNodeHelp("PixaromaText", TEXT_HELP)`. The old `.pix-text-help` CSS and the `createHelpButton(TEXT_HELP)` in the bottom bar are gone (the bottom bar is now just Copy all / Replace / Clear + the Dynamic prompts switch). `closeHelpPopup()` is still called in `onRemoved` (harmless safety net for a toolbar-opened popup).
5. **The `?` Help button is the LAST item in the bottom bar, right after the Dynamic prompts switch** (`.pix-text-help { margin-left:3px; flex:0 0 auto }`; the base `.pix-help-btn` is a 16px mask icon, `align-self:center`). It was originally floated in the top-right corner of the textarea, but the user moved it into the button row (2026-06-10) - so it flex-wraps with the switch on a narrow node rather than living in a non-wrapping top row (a deliberate deviation from node UI convention #16's "own top row" guidance). `createHelpButton(TEXT_HELP)` + `closeHelpPopup()` in `onRemoved`.
These patterns came from the v2 simplification (single text, no effects, mirrored panel). Re-read before touching `js/text_overlay/`, `js/framework/text_render.mjs`, `js/framework/text_editor.mjs`, or `nodes/_text_render_helpers.py`.
@@ -1331,7 +1211,7 @@ Files are named by concern. Match the task to the file:
| Task | Read this file |
|------|---------------|
| Add a new dropdown / popup (any node) | Copy `openImageDropdown` in `js/load_image/ui.mjs` as the template. See Load Image Pixaroma Pattern #14 — wheel handler MUST gate on `!popup.contains(e.target)` or scrolling inside the popup closes it. |
| Add / change a node's Help (the selection-toolbar `?` + popup) | Register the node's help so the toolbar `?` shows for it: add an entry to the `HELP` map in `js/help_toolbar/help_defs.mjs` (central - the default for most nodes), OR call `registerNodeHelp("<exact comfyClass>", HELP_DEF)` from the node's own JS (Compare / Text / XY Plot / Find and Replace do this). Author the `HELP_DEF` (title / tagline / sections of body \| bullets \| defs \| table, inline `` `code` `` chips, plain-English, NO em dashes, real `RETURN_NAMES`). The orange `?` then appears in the node selection toolbar automatically, in both renderers - no per-node button code. The old in-body `createHelpButton` is DEPRECATED (don't add it). The toolbar button's hover tooltip text is in `locales/en/commands.json`. Full schema + the `getSelectionToolboxCommands` mechanism: node UI convention #16. |
| Add / change a node's Help panel (the `?` button + popup) | `js/shared/help.mjs`: `createHelpButton(helpDef)` returns the `?` button (bundled question.svg mask), `openHelpPopup(helpDef)` opens it. Author a `XXX_HELP` object (title / tagline / sections of body \| bullets \| defs \| table, inline `` `code` `` chips) next to the node's render code and `appendChild` the button into the node body. Reference: Find and Replace (`js/find_replace/render.mjs` `FR_HELP` + the `.pix-fr-toprow` placement so the `?` doesn't wrap below on a narrow node). Full schema + gotchas in node UI convention #16. |
| Fix brush/drawing | `js/paint/tools.mjs` |
| Fix layer add/delete | `js/paint/canvas.mjs` or `js/composer/layers.mjs` |
| Fix undo/redo | `js/<editor>/history.mjs` |
@@ -1347,7 +1227,6 @@ Files are named by concern. Match the task to the file:
| Change the per-object Shape panel | `js/3d/shape_params.mjs` |
| Handle GLB/OBJ import behavior | `js/3d/importer.mjs` |
| Add / change Resolution Pixaroma sizes per ratio | `js/resolution/index.js` `SIZES` const + `DEFAULT_PER_RATIO` (per-ratio click default) — keep the spec doc table in sync. Layout sizing (NODE_H / WIDGET_H / list min-height) lives at the top of the same file. State schema on `node.properties.resolutionState` + `app.graphToPrompt` injection hook at the bottom of the file (Pattern #9). |
| Change Seed Pixaroma (Random/Fixed, buttons, sizing) | `js/seed/index.js` + `nodes/node_seed.py`. See "Seed Pixaroma Patterns (do not regress)". Hidden `SeedState` + `app.graphToPrompt` per-run injection (Resolution pattern); the last-run seed is RUNTIME-only (`node._pixSeedLastRun`, NOT node.properties) so a run never dirties the workflow; MEASURED `getMinHeight` via `measureRootContent` with NO `getMaxHeight` + `computeLayoutSize` (no hand-guessed height); Random injects a nonce so `IS_CHANGED` re-runs while Fixed caches; `fitSeedFont` auto-shrinks the number; Copy has an `execCommand` fallback for http LAN; pill segments are real `<button>`s. |
| Fix / extend Note toolbar (buttons, pickers) | `js/note/toolbar.mjs` |
| Add / change a toolbar mask-icon | `js/note/css.mjs` (`.pix-note-tbtn-maskicon` for single-layer, `.pix-note-tbtn-maskicon-multi` for two-layer color pickers) + SVG files in `assets/icons/ui/` (two-layer icons need `<name>-outline.svg` + `<name>-drop.svg`) + `makeMaskIcon`/`makeMaskIconMulti` call in `toolbar.mjs` |
| Change per-note colour pickers (Btn, Ln, Bg, text, highlight) | Two architectures: text + highlight use the COMPACT picker (`openPixaromaCompactColorPickerPopup` in `js/shared/color_picker.mjs` — swatches + Reset + "More colors..." footer; modal opens on More). Bg / Btn / Ln use the live-preview popup (`openPixaromaColorPickerPopup`) via the `makeColorPicker` factory in `js/note/toolbar.mjs`. Sticky-pick state is `editor._pickedFg` (text) and `editor._stagedHi` (highlight); both cleared in `_cleanup`. Text application: `execCommand("foreColor")` at pick + `_restageColors` on every selectionchange (Pattern #25). Highlight application: `beforeinput` handler in `core.mjs` → `_applyStagedHilite` in `toolbar.mjs` does manual char insert with `e.preventDefault()` (Pattern #21). `js/note/render.mjs` writes Btn/Ln CSS vars on canvas body; `core.mjs` `_applyCfgColorsToEditArea` writes same vars on the editor's contenteditable on each open. |
@@ -1370,10 +1249,7 @@ Files are named by concern. Match the task to the file:
| Change Text Pixaroma (textarea / buttons / Dynamic prompts switch / Help) | `js/text/index.js` (DOM textarea + Copy all/Replace/Clear buttons + the Dynamic prompts switch + `?` Help + the `app.graphToPrompt` resolver hook) + `js/text/dynamic_prompts.mjs` (the `{a|b}` resolver, mirror of the documented contract) + `nodes/node_text.py` (trivial passthrough; native `dynamicPrompts` is deliberately OFF). State `node.properties.pixTextDynamicPrompts` (default OFF). See "Text Pixaroma Patterns (do not regress)" - do NOT re-add ComfyUI's native `dynamicPrompts: True` (it ate JSON braces on every node); dynamic prompts are an opt-in switch resolved only when ON. |
| Change Find and Replace Pixaroma (rules / toggles / preview / replace logic) | `js/find_replace/` (index.js entry + lifecycle + `app.graphToPrompt` injection + `onExecuted` preview capture + sizing; core.mjs state + `applyRulesJS`/`tidy`/`diffTokens` word-diff + preview persistence; render.mjs `.pix-fr-*` CSS/DOM toggles+rows+actions+preview; interaction.mjs find/replace textarea editors + drag-reorder + `pixConfirm`). Python in `nodes/node_find_replace.py` (`_apply_rules` + `_tidy`). State `node.properties.findReplaceState` `{caseSensitive,wholeWord,regex,tidy,rules[{id,enabled,find,replace}]}` injected into hidden `FindReplaceState` (Pattern #9); the live-preview input is persisted SEPARATELY in `node.properties.findReplacePreview` (capped ~4 KB, NOT injected). **The replace logic is mirrored JS↔Python and MUST stay in lockstep - Python is authoritative**; literal mode is exact, regex backref syntax differs (`\1` Python / `$1` JS, JS converts best-effort). `text` input is `forceInput` (wire-only); `OUTPUT_NODE=True` so it always runs and can return the `ui` preview payload while still passing its STRING output downstream. Rules apply top-to-bottom in displayed order (drag the handle to reorder); empty `find` skips the rule, empty `replace` deletes the found text. Mirrors Prompt Stack's DOM-row architecture + dirty-on-load guards (render-only on load, grow only on user actions). |
| Add backend route | `server_routes.py` |
| Add a new Python node | `nodes/node_<name>.py` (set `CATEGORY = "👑 Pixaroma/<subfolder>"` — see step 2 of "When creating a new editor") |
| Change Resize Crop Pixaroma (crop-to-fill to an exact W×H) | `nodes/node_resize_crop.py` — pure Python, native INT width/height widgets (typeable AND wireable), reuses `_resize_helpers._apply_cover` (cover-scale + center-crop, `allow_upscale` on, `snap` 0, output ALWAYS exactly W×H). No JS, no custom UI. Help in `help_defs.mjs`. |
| Change Portrait Landscape Pixaroma (one-click orientation flip) | `js/portrait_landscape/index.js` (Portrait/Landscape pill toggle + `app.graphToPrompt` state injection + auto-fit height via `computeSize`, mirrors Switch WH) + `nodes/node_portrait_landscape.py` (min/max orient: Portrait→`(lo,hi)` tall, Landscape→`(hi,lo)` wide). State on `node.properties.portraitLandscapeState`, hidden `PortraitLandscapeState` input (Vue Compat #9). Help in `help_defs.mjs`. |
| Change Image Crop / Image Uncrop (crop_info, MASK, feather, paste-back) | `nodes/node_crop.py` (crop + the MASK input + `crop_info` output: `_rect_from_meta`/`_crop_mask`/`_make_crop_info`/`_full_mask_from_pil`) + `nodes/node_uncrop.py` (paste-back: resize-to-region, distance-ramp `_feather_alpha`, full-frame mask, `_passthrough_mask`, device+batch pairing). `js/crop/` is the editor only (guard-adds the `mask` input); Uncrop has NO JS. Help in `help_defs.mjs`. See "Image Crop + Image Uncrop Patterns (do not regress)". |
| Add a new Python node | `nodes/node_<name>.py` |
| Change XY Plot Pixaroma (axes / value entry / driver loop / grid / themes / save) | `js/xy_plot/` (index.js = lifecycle + the app.queuePrompt N×M driver + app.graphToPrompt value injection + executed listener; core.mjs = state + target enumeration + value parsing; ui.mjs = node body + picker + value entry + theme picker; grid.mjs = grid <img> + Save/Copy/Open). Python in `nodes/node_xy_plot.py` (server-side cell accumulation keyed by sessionId under a threading.RLock + PIL grid assembly + themes). Routes `/pixaroma/api/xy_plot/{save,restyle}` in `server_routes.py`. See "XY Plot Pixaroma Patterns (do not regress)" for the 8 invariants - especially: 3rd queue-driver (shared queue_drivers.mjs lock), injectAxis OVERRIDES wired inputs, per-node seed map by exact key, Python owns grid rendering / JS shows the PNG, picker skips hidden state widgets, grid <img> max-height 360 cap. |
| Change Text Overlay editor / interaction / node | `js/text_overlay/` (core.mjs for lifecycle + canvas + render loop + canvas settings + alignment toolbar, interaction.mjs for mouse + keyboard + snap guides, index.js for node setup + body panel mount + graphToPrompt hook) |
| Change Text Watermark node / placement math / sizing | `js/text_watermark/` (index.js for registration + the shared-panel mount in watermarkMode + graphToPrompt Pattern #9 + wired-text lock + FIXED BASE_H 412 + onRemoved; defaults.mjs for DEFAULT_STATE). Placement + sizing math is Python: `nodes/node_text_watermark.py` (`_stamp` turns anchor + marginX/Y + sizeMode px/%-width into x/y, then calls the SHARED renderer `render_text_layer`). Reuses `nodes/_text_render_helpers.py` unchanged — NO watermark-specific render code, NO parity script. Anchor math gates the italic slant on `_is_synthesized_italic` (real-italic fonts get no extra inset). Defaults MUST stay in lockstep across defaults.mjs + node_text_watermark.py + render_text_layer (Pattern #3). The control panel + the Pixaroma color picker + the solid-triangle number inputs all come from the shared `js/framework/text_editor.mjs` panel — see the next row. |
@@ -1397,8 +1273,7 @@ Files are named by concern. Match the task to the file:
| Change Composer text layer | `js/composer/core.mjs` (`addTextLayer`/`rebuildTextLayer`, the `setMode` eraser/crop guard), `js/composer/ui.mjs` (`_buildTextPanel`, T thumbnail, panel swap in `updateActiveLayerUI`), `js/composer/interaction.mjs` (`_commitTextScaleFold` crisp-resize, save + duplicate carry `isText`/`textState`), `js/composer/render.mjs` (`attemptRestore` carries the fields), `js/composer/layers.mjs` (`captureState` deep-copy). Bitmap renderer is the SHARED `js/framework/text_render.mjs::renderTextToCanvas`; panel is `js/framework/text_editor.mjs` in `composerMode`. Python composites it as a plain image - NO text code in `node_composition.py`. See "Composer Text Layer Patterns". |
| Paint AI Background Removal panel | `js/paint/core.mjs` `_buildBgRemovalPanel` + `_removeBgFromActiveLayer` (button gated on `ly.sourceKind === "image"`, set by the `onAddImage` handler and serialized as `source_kind` in the layer project JSON). Dropdown built by the shared `js/framework/bg_removal_dropdown.mjs` helper (1.3.34) so Paint and Composer render identical model pickers (BiRefNet variants on top, rembg below). Reuses the `/pixaroma/remove_bg` backend route via `PaintAPI.removeBg`. |
| Change AI Remove Background dropdown (Composer + Paint, 1.3.34+) | `js/framework/bg_removal_dropdown.mjs`. Exports `fetchBgRemovalInfo()` (GET /pixaroma/remove_bg_info) and `buildBgRemovalDropdown({ container, info, value, onChange })` (renders `<select>` with `<optgroup label="Pixaroma BiRefNet">` on top and `<optgroup label="rembg">` below; inline `<a href=...>Download {filename}</a>` under the select when the currently selected BiRefNet variant is not installed). Default-picks via `pickDefaultModel(info)`: first installed BiRefNet variant in priority order standard -> HR -> matting, else rembg `auto`. Variant catalog and inventory come from the backend (`BIREFNET_VARIANTS` in `nodes/_bg_removal_helpers.py`) so adding a new variant requires editing that file only. |
| Change Load Images from Folder Pixaroma (folder picker / gallery / native dialog / one-by-one list output / resize) | `js/load_images_folder/` (index.js = setup + state + `app.graphToPrompt` injection + `refit` + native-dialog Browse handler; ui.mjs = node body + multi-select gallery popup + in-app folder-browser popup + CSS + `attachClosePopup`; api.mjs = `listFolder`/`thumbURL`/`browseFolder`/`pickNativeFolder`; state.mjs = DEFAULT_STATE/readState/writeState/sortFiles) + `nodes/node_load_images_folder.py` (OUTPUT_IS_LIST loader + per-file path-traversal guard + IS_CHANGED) + `server_routes.py` routes `api_lif_list` / `api_lif_thumb` / `api_lif_browse` / `api_lif_pick_native` (helpers `_lif_list_files` / `_lif_make_thumb` / `_lif_native_folder_dialog` + the Windows/macOS/Linux dialog branches, all via `run_in_executor`). See "Load Images from Folder Pixaroma Patterns (do not regress)" - OUTPUT_IS_LIST = one-by-one (NOT a queue-driver), resize reuses the shared `buildModePanel` engine with stateKey `loadImagesFolderState`. |
| Preview Image Pixaroma — change button or strip / grid layout / geometry / colors | `js/preview/index.js` constants at the top (`BTN_H`, `BTN_GAP`, `MIN_W`, `MIN_H`, `DEFAULT_W`, `DEFAULT_H`, `IMG_STRIP_GAP`, `IMG_STRIP_V_PAD`, `IMG_STRIP_BORDER_W`, `BADGE_*`, `LAYOUT_TOGGLE_*`, `COLOR_ACTIVE_*` / `COLOR_DISABLED_*`). Button rects computed in `computeButtonRects`, painted in `paintBtn`. Strip (single horizontal row) rects via `layoutImgStrip`; Grid (2D wrap, **iterate-and-maximise area**, exact match for native PreviewImage's `calculateImageGrid` in `dialogService-*.js`) via `layoutImgGrid`. Algorithm: for each candidate `cols` in `1..N`, compute `rows = ceil(N/cols)`, `slotW = innerW/cols`, `slotH = innerH/rows`, CONTAIN scale `min(slotW/imgW, slotH/imgH, 1)`, cellW/H = imgW/H × scale, area = cellW × cellH × N; pick the cols that maximises area. Cell DIMENSIONS come from `frames[0]` scaled (the iterate-and-maximise pass), then EACH frame is CONTAIN-FIT within its OWN cell (per-frame `imgRect`, mirroring `layoutImgStrip`'s fit): a UNIFORM batch fills its cells exactly (no letterbox, since every frame's aspect equals frames[0]'s), a MIXED-size batch (now common via Load Images from Folder Pixaroma) LETTERBOXES the odd-shaped frames instead of STRETCHING them (fixed 2026-06; it stretched before). `slots` stay the full cells (click hit-test uses `slots`, so the whole cell is clickable); the selection border + the `N/M` badge anchor to `imgRect` (the fitted image, not the slot). The whole grid is centered inside `innerW × innerH`. When `frames[0]` hasn't loaded, falls back to `1×N` strip and reflows on the next draw once `naturalWidth/Height` are populated. Layout selection in `createStripWidget().draw` reads `getLayoutMode(node)` which checks `node.properties.pixaromaLayout` ('grid' / 'strip') with fallback to setting `Pixaroma.Preview.DefaultLayout`. Per-node toggle icon painted via `paintLayoutToggle` (top-right, hover-aware, glyph shows the OPPOSITE mode = what you'll switch to on click). Buttons + strip live as `addCustomWidget`s (so they reserve vertical space, draw immediately on node-add, and Vue-compat works). Don't switch back to `onDrawForeground` (Vue Compat #1) and don't return `ui.images` from the Python node (LiteGraph would render its native strip underneath the custom one — use the `pixaroma_preview_frames` custom UI key instead, Save Mp4 pattern). |
| Preview Image Pixaroma — change button or strip / grid layout / geometry / colors | `js/preview/index.js` constants at the top (`BTN_H`, `BTN_GAP`, `MIN_W`, `MIN_H`, `DEFAULT_W`, `DEFAULT_H`, `IMG_STRIP_GAP`, `IMG_STRIP_V_PAD`, `IMG_STRIP_BORDER_W`, `BADGE_*`, `LAYOUT_TOGGLE_*`, `COLOR_ACTIVE_*` / `COLOR_DISABLED_*`). Button rects computed in `computeButtonRects`, painted in `paintBtn`. Strip (single horizontal row) rects via `layoutImgStrip`; Grid (2D wrap, **iterate-and-maximise area**, exact match for native PreviewImage's `calculateImageGrid` in `dialogService-*.js`) via `layoutImgGrid`. Algorithm: for each candidate `cols` in `1..N`, compute `rows = ceil(N/cols)`, `slotW = innerW/cols`, `slotH = innerH/rows`, CONTAIN scale `min(slotW/imgW, slotH/imgH, 1)`, cellW/H = imgW/H × scale, area = cellW × cellH × N; pick the cols that maximises area. Cells are SCALED IMAGE DIMENSIONS (not `innerW/cols × innerH/rows`), so cells exactly fit images with NO per-cell letterbox; the whole grid is then centered inside `innerW × innerH`. Any unused space sits at the edges of the grid, never between cells. Uses `frames[0]` natural dimensions (assumes batch is uniform); when first image hasn't loaded, falls back to `1×N` strip and reflows on the next draw once `naturalWidth/Height` are populated. Layout selection in `createStripWidget().draw` reads `getLayoutMode(node)` which checks `node.properties.pixaromaLayout` ('grid' / 'strip') with fallback to setting `Pixaroma.Preview.DefaultLayout`. Per-node toggle icon painted via `paintLayoutToggle` (top-right, hover-aware, glyph shows the OPPOSITE mode = what you'll switch to on click). Buttons + strip live as `addCustomWidget`s (so they reserve vertical space, draw immediately on node-add, and Vue-compat works). Don't switch back to `onDrawForeground` (Vue Compat #1) and don't return `ui.images` from the Python node (LiteGraph would render its native strip underneath the custom one — use the `pixaroma_preview_frames` custom UI key instead, Save Mp4 pattern). |
| Preview Image Pixaroma — change save flow / routes | Backend: `nodes/node_preview.py` (tensor → PNG, two modes: temp/ for preview, output/ for save with embedded metadata via shared `nodes/_save_helpers._build_pnginfo`) + `server_routes.py` helpers `_embed_workflow_metadata` (thin wrapper), `/pixaroma/api/preview/save`, `/pixaroma/api/preview/prepare`. Both routes validate `filename_prefix` via shared `nodes/_save_helpers._safe_prefix` (denylist: neutralizes only Windows-illegal chars, keeps Unicode/spaces like native SaveImage, rejects `..`; locked by `scripts/save_prefix_check.py`). Prepare route returns JSON `{image_b64, suggested_filename}` — `suggested_filename` peeks `folder_paths.get_save_image_path` to pre-fill the Save-to-Disk picker with the next free counter. Frontend: `js/preview/index.js` `saveToOutput` / `saveToDisk` read the SELECTED frame from `node._pixaromaFrames[node._pixaromaSelectedFrame]`. Both POST a dataURL + the workflow/prompt from `app.graphToPrompt()`. Metadata embedding lives in `nodes/_save_helpers._build_pnginfo` only (single source of truth). |
| Preview Image Pixaroma — add / change save_mode behavior or hidden inputs | `nodes/node_preview.py`. `INPUT_TYPES` declares `save_mode` as a required combo (`preview` / `save`, default `preview`) and `prompt: PROMPT, extra_pnginfo: EXTRA_PNGINFO` as hidden inputs. In `save` mode the node iterates the entire batch, calls `folder_paths.get_save_image_path`, and saves each frame to `output/{subfolder}/{name}_{counter+i:05}_.png` with embedded metadata — drop-in for native SaveImage. In `preview` mode it writes UUID-named PNGs to `temp/` (auto-cleared on ComfyUI restart). Either mode returns `ui.pixaroma_preview_frames` (custom key). |
| Notify Pixaroma — add or swap a sound | Drop a `.mp3` (or `.wav`/`.ogg`) into `assets/sounds/`, restart ComfyUI. `_list_sounds()` in `nodes/node_notify.py` auto-enumerates the folder at every `INPUT_TYPES()` call. No code changes needed. To remove a sound, delete its file. |
@@ -1418,7 +1293,7 @@ Files are named by concern. Match the task to the file:
### 4. When creating a new editor
Follow the existing directory structure:
1. Create `js/<name>/` with `index.js` (entry point, `.js`), `core.mjs`, and concern-based splits (all `.mjs`)
2. Create `nodes/node_<name>.py` with mappings + `CATEGORY = "👑 Pixaroma/<subfolder>"` (pick the matching subfolder: 🎨 Editors / 🖼️ Image / ✂️ Resize & Crop / 💬 Prompt & Text / 📝 Notes & Overlay / 🔢 Values / 🔀 Logic & Flow). The `👑 Pixaroma` prefix is what makes the node inherit the brand colors via `js/brand/index.js` — it gates on `startsWith("👑 Pixaroma")`, so subfolders keep the colors — do NOT re-add per-node `node.color` / `node.bgcolor` guards)
2. Create `nodes/node_<name>.py` with mappings + `CATEGORY = "👑 Pixaroma"` (the prefix is what makes the node inherit the brand colors via `js/brand/index.js` — do NOT re-add per-node `node.color` / `node.bgcolor` guards)
3. Import and merge in `__init__.py`
4. If it needs backend routes: add to `server_routes.py`
5. Keep every file under ~300 lines
+20 -63
View File
@@ -22,9 +22,7 @@
---
> 💬 **Need help, have an idea, or found a bug?** Post in the **#pixaroma-nodes** channel on the [Pixaroma Discord](https://discord.gg/gggpkVgBf3), or open a work item (issue) on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues).
> 💡 **Updated Pixaroma and a node looks broken or old?** Hard-refresh your ComfyUI browser tab with **Ctrl+Shift+R** (**Cmd+Shift+R** on Mac). The browser caches old node visuals, so without a hard refresh you can still see the previous version even though the update installed correctly.
> 💬 **Need help, have an idea, or found a bug?** Hop into the [Pixaroma Discord](https://discord.gg/gggpkVgBf3) and post in the **#pixaroma-nodes** channel.
## 🎨 Creative Suite
@@ -66,22 +64,13 @@ Audio-reactive image-to-video. **No extra models needed**, just an image and an
Compare settings side by side without setting anything up. Drop it at the end of your workflow and wire your final image in, just like a preview node. Then pick what changes **across** (columns) and **down** (rows) from a dropdown of the nodes already in your graph - no extra wiring. The value box adapts to your pick: a number gives a Start/End/Steps range, a dropdown (sampler, model, scheduler) gives a checklist, and your prompt gives find-and-replace. Hit Run once and every combination fills a labeled grid right in the node, with **Dark/Light/Mono** grid themes and **Save/Copy/Open** buttons. The seed stays **locked** across the grid so the only thing changing is the thing you're testing.
### ✂️ Image Crop
No more guessing crop sizes with numbers! Visually draw your crop box, or set width, height, position and a center/edge alignment right on the node - math expressions like `1024+512` work too. Standard presets (1:1, 16:9, 9:16…) keep social and video aspects locked. Wire **any IMAGE** output into the node (Load Image, VAE Decode, anything) and run the workflow once - the editor and mini-preview will show the live source. Or paste an image straight from the clipboard with **Ctrl+V**. It keeps **transparency** too (wire a mask in, get a matching cropped mask out) and hands off a **crop_info** wire to the new **Image Uncrop** node, so you can edit the crop and paste it back later.
No more guessing crop sizes with numbers! Visually draw your crop box, or set width, height, position and a center/edge alignment right on the node - math expressions like `1024+512` work too. Standard presets (1:1, 16:9, 9:16…) keep social and video aspects locked. Wire **any IMAGE** output into the node (Load Image, VAE Decode, anything) and run the workflow once - the editor and mini-preview will show the live source. Or paste an image straight from the clipboard with **Ctrl+V**.
📥 [Download example workflow](workflows/Crop%20Pixaroma%20Workflow.json)
![Image Crop Node](workflows/Crop%20Pixaroma%20Workflow.jpg?v=3)
![Image Crop Editor](workflows/Crop%20Pixaroma%20Workflow%20v2.jpg?v=2)
### 🧩 Image Uncrop
The other half of crop-and-edit: paste an edited or upscaled crop **back** onto the original image at the exact spot it came from. Wire the **crop_info** from Image Crop into it, run your crop through any nodes you like (upscale, recolor, inpaint, remove background), and Image Uncrop drops the result back in place - everything outside the crop stays untouched. A **feather** slider softens the seam for a seamless blend, and transparency carries straight through.
### 🩹 Inpaint Crop Pixaroma
The easy way to set up an inpaint. Open the fullscreen editor and **paint** over the part you want the AI to redo - brush, erase, invert, an adjustable brush, and **zoom** (scroll) + **pan** (Space-drag) for fine detail. The node automatically finds the area around your mask, adds a margin, and crops a clean, **model-friendly** piece (sized to a multiple of 8 and scaled toward your target) so even a small spot gets enough resolution to look sharp. Set how the result blends back - **softness**, mask grow, and **Mask** (only the painted area) vs **Whole crop** - right here, with a live preview. Wire the cropped **image** and **mask** into your inpaint or edit model (Flux, KSampler, edit models), then send the **crop_info** wire to Inpaint Stitch to paste the result back. Turn on **invert_mask** to inpaint the opposite area (no separate Invert Mask node), and it works with a mask wired in too. Works in both the classic and the new node interface.
### 🪡 Inpaint Stitch Pixaroma
The other half: paste your inpainted crop **back** onto the original, blended so the seam disappears. Wire in the **crop_info** from Inpaint Crop and your processed crop, and by default only the area you painted changes - everything else stays pixel-perfect. Fine-tune the **softness**, **blend mode**, and an optional **color match** right on this node; because it runs after the sampler, changing them re-runs only this node (instant, no re-generating the image). It also hands back the untouched **original**, so you can drop both into Image Compare for an instant before / after.
### 🌓 Image Compare
The best way to see the difference between two images. Easily compare them side-by-side with a slider, overlap them, or highlight exactly what changed between the two versions.
@@ -96,15 +85,9 @@ A checkpoint you drop into your workflow to stop and look at the image before ru
### 🖼️ Load Image Pixaroma
A drop-in replacement for ComfyUI's native LoadImage with everything you'd want in one node. Same upload / drag-drop / Ctrl+V paste / multi-frame / alpha-to-mask behavior as native, plus inline resize: pick from **Off**, **Max megapixels**, **Longest side**, **Scale by ×**, **Fit inside**, **Crop to fill**, or **Match aspect ratio** with a sub-toggle for Crop or Pad (12 ratio presets + Custom, with a Pixaroma color picker for the Pad color). **Snap to /8/16/32/64**, **Resample picker** (Auto / Nearest / Bilinear / Bicubic / Lanczos with one-line hints under each), and an **Allow upscaling** toggle apply on top. Numeric fields accept math expressions (`1024+64`, `512*2`), ↑↓ arrow stepping (Shift = 10×), and have visible +/- spinner buttons. A live **Input → Output** info bar with tiny aspect-ratio rectangles shows you exactly what dimensions the workflow will produce as you tweak settings. Outputs include `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`, `FILENAME` (no extension), `ORIGINAL_WIDTH`, `ORIGINAL_HEIGHT` - eliminates downstream Get Image Size + Image Scale chains in most workflows.
### 📂 Load Images from Folder Pixaroma
Point it at any folder on your computer and batch-process its images through your workflow. Pick which ones in a thumbnail gallery (**Select all**, the **First N**, or hand-pick), then hit Run once and it feeds each selected image through your graph on its own, giving you a finished result for every image (mixed image sizes are fine). Set the folder with the real OS folder dialog (the **Browse** button, Windows / Mac / Linux) or just type or paste a path. It carries the same inline resize options as Load Image Pixaroma (**Max megapixels**, **Longest side**, **Scale by**, **Fit inside**, **Crop to fill**, **Match aspect ratio**, **Pad**), applied to each image as it loads. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`, `FILENAME`, `INDEX`, `TOTAL` - wire WIDTH/HEIGHT into an empty latent so it matches each image, and FILENAME into a Save node so every result keeps its original name. Works in both the classic and the new node interface.
### ↔️ Image Resize Pixaroma
Resize any image (and its mask) anywhere in your workflow with one compact node. Pick a mode - **Off**, **Max megapixels**, **Longest side**, **Scale by ×**, **Fit inside**, **Crop to fill**, **Match aspect ratio**, or **Pad** (add a colored border for outpainting / inpainting, where the new area becomes the editable mask region). **Crop to fill** has a 9-point **anchor** (keep the top, a corner, the center…) and a **Fill / Crop** toggle (scale-and-crop, or cut a piece at original pixels). A live **Input → Output** card with tiny aspect-ratio rectangles shows exactly what you'll get, and turns orange only when the size actually changes. Wire a **width / height** in (e.g. from Resolution Pixaroma): connect just one to scale while keeping the aspect ratio, or both for an exact size, and the controls adapt automatically. **Snap to /8/16/32/64**, a **Resample picker** (Auto / Nearest / Bilinear / Bicubic / Lanczos), and an **Allow upscaling** toggle apply on top; number fields take math like `1024+64`. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`.
### 🔲 Resize Crop Pixaroma
A dead-simple crop-to-fill node. Wire in an image, set a **width** and **height** (type them, or wire them in from another node like WH or Resolution Pixaroma), and it scales the image to completely fill that size and crops the overflow from the center - so the output is **always exactly** the size you asked for, with no stretching or black bars. Smaller images scale up to fill. An optional **mask** is cropped along with it. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`. Perfect for forcing images or video frames to a fixed size like 512×896 or 704×1280. Works in both the classic and the new node interface.
### 📝 Note Pixaroma
A beautiful, simple text editor to document your workflows right on the canvas. Write normally with bold, italics, lists, headings, code blocks (with copy button), and inline icons (CLIP, LORA, GGUF, model versions, and 30+ more). Drop in custom-colored **buttons** (Download / View Page / Read More / plain), **separators** (5 line styles: solid, dashed, dotted, double, thick), **tables**, and **folder hints** for download paths. Each block carries its OWN colour, picked from a clean modal that opens centered on the screen, so two separators (or two grids, or two folder hints) in the same note can have totally different looks. YouTube and Discord pills come pre-colored. There is also a Code view for hand-editing the underlying HTML, plus a drop-in LLM prompt at `assets/note-pixaroma-llm-prompt.txt` if you want ChatGPT, Gemini, or a custom GPT to generate notes for you. It perfectly saves and restores exactly how you styled it.
@@ -142,9 +125,6 @@ A handy way to preview your images right on the node, but better! Works with **s
### 📐 Resolution Pixaroma
A simple, one-click resolution picker. Choose from 9 popular aspect ratios - 1:1, 16:9, 9:16, 2:1, 3:2, 2:3, 4:3, 3:4, and 4:5 (Instagram-portrait friendly) - and instantly get the exact width and height you need, including popular sizes for AI video. Type any Custom Ratio (21:9, 16:10, anything) with auto-computed AI-friendly sizes, or use Custom Resolution to type exact dimensions. Math expressions work in the Width and Height fields too - type `1024+128` or `512*2` and it just works. It perfectly saves all your settings with your workflow!
### 🌱 Seed Pixaroma
A dedicated seed node you wire into KSampler (or any node with a seed input). Flip between **Random** - a fresh seed every run - and **Fixed** - the same seed for repeatable results - right on the node. **New fixed random** rolls a new seed and locks it (great for keeping a lucky result while you tweak other things), **Use last seed** brings back the seed from the previous run, and **Copy** puts the current seed on your clipboard. Type any number into the big readout to set it exactly, and in Random mode a **Last run** line tells you which seed actually made the latest image. One Seed node can feed several samplers at once so they all stay in sync. Works in both the classic and the new node interface.
### 📏 WH Pixaroma
A tiny utility node with two number fields for width and height, and matching width/height outputs. Use it when you want to type a target resolution manually somewhere in your workflow. Math expressions like `1024+64` or `512*2` work directly in the fields. Pairs perfectly with **Switch WH Pixaroma** so you can flip between manual values and the size coming from another node.
@@ -163,18 +143,12 @@ A universal multi-input switch for any data type. Connect models, images, prompt
### 🔀 Switch WH Pixaroma
Switch between two width/height sources with a single click. Wire two width+height pairs into the **A** and **B** inputs (for example a Load Image Pixaroma's WIDTH/HEIGHT and a manual size from WH Pixaroma), then click **A** or **B** on the node body to choose which pair flows through. No rewiring cables. If one side has only one cable connected (the other forgotten), the node uses the complete side instead so the workflow doesn't break. If nothing is wired, you get a clear error message.
### 🔄 Portrait Landscape Pixaroma
Flip a size between portrait (tall) and landscape (wide) with one click. Enter your two dimensions (or wire them in), then tap **Portrait** or **Landscape** - Portrait makes the smaller number the width (a tall image), Landscape makes the larger number the width (a wide image), so the order you type them never matters. One node replaces keeping two WH nodes and a Switch WH just to flip orientation. Outputs `WIDTH`, `HEIGHT` - wire them straight into an empty latent. Works in both the classic and the new node interface.
### 🔁 Switch Source Pixaroma
Flip a whole pipeline (or any set of wires) between two sources with one click. Wire your **A** bank and **B** bank for as many rows as you need (works for any wire type: MODEL, CLIP, VAE, IMAGE, LATENT, STRING...), then toggle **A** or **B** to swap them all at once - no rewiring cables. Two common setups: swap a combined Load Checkpoint against three separate model/CLIP/VAE loaders, or flip a "local" pipeline against an "api" one without ticking ten little switches. Output labels are editable per row, and you can pick whether empty rows leave the output blank or show a clear error.
### 🔇 Mute Switch Pixaroma
Skip whole parts of a workflow with one click. Wire the last node of each "scene" (usually a KSampler) into a row, then use the small switches to pick what runs and what doesn't. Two pills at the top: **Single** (only one scene runs at a time, like a radio button) or **Multi** (any combination), and **Mute** (the scene doesn't run) or **Bypass** (each node passes its input through unchanged). Chain Mute Switches together to group scenes: an outer Mute Switch can pick a group, and inner Mute Switches fine-tune which scenes inside that group run. Right-click for **Enable all rows** / **Disable all rows** to flip every row at once in Multi mode. Labels on rows are editable so you can name your scenes.
### 🔗 Set / Get Pixaroma
Wireless connections for a cleaner canvas. Drop a **Set** node, wire anything into it (image, model, number, prompt), and give it a name. The Set has a passthrough output, so a node sitting nearby can wire to it directly, while far ones read it with a **Get** node - pick the name from a dropdown and it carries the same value, no cable stretched across the workflow. Colour a Set however you like and its Gets take the same colour (and follow along when you recolour it), so matching pairs are easy to spot, and the Get dropdown tags each name with its colour. Collapse them to almost nothing so they disappear into the background, and right-click to jump between a Get and its Set. They respect subgraphs (a value you **Set** in the main graph can be read by **Get** nodes inside your subgraphs), show a tiny value preview for plain numbers and text, and at run time they resolve straight to the original source - identical to a direct wire, with no extra cost. Works in both the classic and the new node interface.
### 🔢 Number Pixaroma
A small node with one number field and two outputs: **int** and **float**. Useful when one downstream node wants a whole number and another wants a decimal from the same value, or when you want to convert a decimal into a whole number cleanly in the middle of a workflow. Accepts whole numbers, decimals, and math expressions like `1024+64` or `1024/3`. The int output rounds to the nearest whole number (`3.5` becomes `4`, `3.4` becomes `3`). Range is roughly plus or minus 1 quadrillion, so even very large numbers fit.
@@ -289,37 +263,16 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
## 🛠 Changelog
### **June 19, 2026 · v1.3.95-1.3.97**
- **NEW: Set / Get Pixaroma** - wireless connections that keep your canvas clean. Wire anything (an image, a model, a number, a prompt) into a **Set** node and give it a name; then a **Get** node anywhere reads that same value with no cable running across the screen - just pick the name from a dropdown. The Set also has a passthrough output, so a node sitting nearby can wire to it directly while far ones use a Get. Colour a Set however you like and its Gets take the same colour, following along when you recolour it, so matching pairs stand out. Collapse them to almost nothing, and right-click to jump from a Get to its Set. They work inside subgraphs (a value you Set in the main graph can be read inside your subgraphs), show a tiny value preview for numbers and text, and behave exactly like a real wire when you run, so they never change your result. Works in both the classic and the new node interface.
- **NEW: Resize Crop Pixaroma** - a dead-simple crop-to-fill node. Set a width and height (type them or wire them in), and it scales your image to completely fill that size and trims the overflow from the center - so the result is always exactly the size you asked for, with no stretching or black bars. Smaller images scale up to fill, and an optional mask is cropped along with it. Great for forcing images or video frames to a fixed size like 512×896 or 704×1280.
- **NEW: Portrait Landscape Pixaroma** - flip a size between portrait (tall) and landscape (wide) with one click. Type your two numbers, tap **Portrait** or **Landscape**, and it arranges them into the orientation you picked (the order you type them never matters). One node replaces keeping two WH nodes and a switch just to flip orientation.
- **Tidier menu** - the Pixaroma nodes are now sorted into folders (Editors, Image, Resize & Crop, Prompt & Text, Notes & Overlay, Values, Logic & Flow) instead of one long list, so they're easier to find. Your existing workflows are unaffected.
### **June 17, 2026 · v1.3.93-1.3.94**
- **NEW: Seed Pixaroma** - a dedicated seed node you wire into KSampler (or any node with a seed input). Flip between **Random** (a fresh seed every run) and **Fixed** (the same seed for repeatable results), hit **New fixed random** to lock in a lucky roll, **Use last seed** to bring back the previous run's seed, or **Copy** to grab the number. One Seed node can feed several samplers so they all stay in sync. Works in both the classic and the new node interface.
- **Load Images from Folder - the "First" box now works as you type.** Type a number in the **First** box and it selects that many images right away (before, you had to click the First button for anything to happen). It also caps to how many images are in the folder, so if you ask for more than exist it just selects them all.
### **June 16, 2026 · v1.3.92**
- **NEW: Inpaint Crop + Inpaint Stitch Pixaroma** - the easy way to inpaint. Open the editor, paint over the part you want the AI to change, and the node automatically crops a clean, model-friendly piece around it; run it through your model, then Inpaint Stitch pastes the result back at the exact spot, blended so the seam disappears. The editor has **zoom** (scroll wheel) and **pan** (Space-drag) for fine detail, brush / erase / invert, an adjustable brush, and a live preview of how the edit will blend. Set how it blends back (softness, mask grow, mask vs whole-crop) on the node or in the editor; tweak the blend on the Stitch node afterwards without re-generating.
- **No more hard refresh after every update.** The plugin now tells the browser not to cache its files, so from now on plugin updates show up on a normal page reload. (Do **one** last hard refresh - Ctrl+Shift+R - right after this update to clear the old cache; after that, a normal reload is enough.)
- **Press `\` to open Node Colors.** With a node, several nodes, or a group selected, tap the **`\`** key to open the color palette (it's also labeled in the right-click menu now).
### **June 15, 2026 · v1.3.88-1.3.91**
- **NEW: Image Uncrop Pixaroma** - paste an edited or upscaled crop back onto the original image at the exact spot it came from, with a **feather** slider for a seamless blend.
- **Image Crop now carries transparency** - a new mask input/output plus a `crop_info` wire that feeds Image Uncrop. (Its width and height outputs moved down one spot, so reconnect those once if you used them.)
- **Image Uncrop now works with pasted/loaded crops too** - pasting or loading an image straight into Image Crop (no Load Image node) used to make Uncrop return just the cropped piece; now it rebuilds the full picture either way.
- **NEW: Load Images from Folder Pixaroma** - point it at any folder, pick images in a thumbnail gallery, and Run once to process each one (different sizes are fine). Outputs image, mask, size, filename, and index per image.
- **Preview Image:** mixed-size batches now fit each thumbnail to its own shape instead of stretching.
- **Housekeeping:** the Manager / registry star count and "last updated" date refresh correctly again.
> 💡 **After updating Pixaroma:** hard-refresh your ComfyUI browser tab with **Ctrl+Shift+R** (or **Cmd+Shift+R** on Mac). The browser keeps old node visuals cached, and without a hard refresh you may still see the previous version of a node even though the update installed correctly.
### **June 10, 2026 · v1.3.84-1.3.87**
- **NEW: vertical text (top to bottom)** in Text Overlay, Composer text layers, and Text Watermark - a Horizontal / Vertical switch; Enter starts a new column.
- **NEW: a Restore brush for the Composer eraser** - flip Erase / Restore (or hold Alt, or press X) to paint the original image back; the brush ring turns orange in Restore mode.
- **NEW: a Help button on every node** - select a node and click the orange **?** in the toolbar for a plain-English panel (the small in-node ? buttons were removed to save space).
- **NEW: a Dynamic prompts switch on the Text node** (off by default) - turn it on and `{red|blue|green}` picks one at random each run.
- **NEW: image sizes on Image Compare** - each input shows its resolution, turning orange when the two don't match.
- **Text Pixaroma no longer strips curly braces** `{ }` - they stay exactly as typed, so JSON prompts survive.
- **Save names in any language work** - Korean, Japanese, accented letters, and spaces in the filename are kept as typed (also Save to Disk / Output and XY Plot).
- **NEW: write text vertically (top to bottom).** Text Overlay, the Image Composer text layers, and Text Watermark now have a Horizontal / Vertical switch. Vertical stacks each character going down and starts a fresh column every time you press Enter - great for Japanese-style writing or tall stylish captions. The alignment and "position on canvas" buttons understand the tall narrow shape, so the text lands in the corner or center you actually clicked.
- **NEW: a Restore brush for the Image Composer eraser.** The eraser now has an Erase / Restore switch. Flip it to Restore and brush back over an area you erased to bring the original picture back, with the same soft or hard edge. Hold Alt while painting to flip the brush just for a moment, or press X to swap. The brush ring turns orange in Restore mode so you always know which way it works, and the two buttons stay dimmed until you actually turn the eraser on.
- **Preview Image: save names in any language now work.** If you typed a folder or file name using Korean, Japanese, accented letters, or spaces in the filename box, those characters were being stripped out - and a name made up entirely of non-English letters was ignored completely, dropping the image into the default output folder. Now the name is kept exactly as you typed it and the image saves to the folder you asked for, just like the standard Save Image node. This also applies to the Save to Disk and Save to Output buttons and the XY Plot save.
- **Text Pixaroma no longer removes curly braces.** Curly braces { } were quietly being stripped out of your text when you ran the workflow, which broke JSON prompts and anything else that needs braces. They now stay exactly as you typed them.
- **NEW: a Dynamic prompts switch on the Text node.** That brace-stripping was actually a "pick one at random" feature in disguise - so it's now an opt-in switch, off by default. Turn it on and {red|blue|green} picks one option at random each run (you can nest them, and notes after // or inside /* */ get removed); leave it off and every brace stays put.
- **NEW: see both image sizes on Image Compare.** The node now shows the resolution of each connected image with a little numbered badge, and the two sizes turn orange when they do not match - so you can tell at a glance whether you are comparing like with like. Every button on the node also has a hover tooltip now.
- **NEW: a Help button on every node.** Select any Pixaroma node and an orange **?** appears in the toolbar just above it (next to the info button). Click it for a plain-English panel that explains what the node does, its buttons, and its outputs. This is now the home for node help, so the small in-node ? buttons have been removed to free up space on the nodes.
### **June 9, 2026 · v1.3.82-1.3.83**
- **Load Image Pixaroma keeps a steady size.** Loading images of different shapes (square, tall, wide) no longer makes the node resize itself and shove your other nodes around - it stays put and fits the image inside, like the standard Load Image. In the new node interface the preview now fills the node properly instead of leaving an empty gap below it.
@@ -409,6 +362,12 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
### **May 21, 2026 · v1.3.51-1.3.52**
- **NEW: Image Resize Pixaroma.** Resize an image (and its mask) anywhere in your workflow with one compact node. Choose **Off**, **Max megapixels**, **Longest side**, **Scale by**, **Fit inside**, **Crop to fill**, **Match aspect ratio**, or **Pad** - which adds a border for outpainting and marks the new area as the mask to fill. **Crop to fill** lets you pick which part of the image to keep (the top, a corner, the center…) and whether to scale-and-crop or cut a piece at its original size. A live **Input → Output** preview shows the exact result and lights up only when the size actually changes. Wire a **width or height** in (for example from Resolution Pixaroma): connect just one to scale while keeping the shape, or both for an exact size - the node adapts on its own.
- **Image Resize: wire in a single "longest side" value.** Connect one number and the node scales the longer edge of your image to that size, keeping the shape, so you no longer have to decide between width and height. It also gained a matching **longest side** output. Wired sizes now follow the **Upscaling** toggle, and a value of 0 simply passes the image through untouched.
---
**Home:** ComfyUI-Pixaroma is developed on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma). That is the place for the latest code and to report issues. Any copy hosted elsewhere (such as a GitHub mirror) is a backup.
ComfyUI-Pixaroma is an independent, community-made extension. It is not affiliated with, endorsed by, or sponsored by Comfy Org or the ComfyUI project. Product names, logos, and trademarks are the property of their respective owners.
- **Press Ctrl+Enter to run while typing.** Text, Prompt Pack, Prompt Multi, and Prompt Stack fields no longer swallow the Run shortcut.
- **Every node now explains itself.** Hover any control for a quick tip, and the side Info panel describes what each input and output does.
- **Nodes remember their settings on reload.** Switch, Image Resize, and Crop no longer reset their choices when you reopen a workflow or switch tabs.
@@ -530,14 +489,12 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
---
## 📜 About, Feedback & License
## 📜 Feedback & License
> [!NOTE]
> This suite was developed with significant AI assistance. While thoroughly tested, we welcome bug reports and feedback from the community!
🏠 **Home:** ComfyUI-Pixaroma is developed on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma) - the place for the latest code. Any copy hosted elsewhere (such as a GitHub mirror) is a backup.
💡 **Have an idea for a new node or improvement?** Share it in the **#pixaroma-nodes** channel on [Discord](https://discord.gg/gggpkVgBf3).
🐞 **Found a bug?** Open a work item (GitLab's name for an issue) on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues), or post in **#pixaroma-nodes** on [Discord](https://discord.gg/gggpkVgBf3).
💡 **Have an idea for a new node or improvement?** Share it in our [Discord community](https://discord.gg/gggpkVgBf3).
🐞 **Found a bug or something broken?** Open an [Issue](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues).
💬 **[Join our Discord Community](https://discord.gg/gggpkVgBf3)**
⚖️ **Licensed under [MIT](LICENSE)**
ComfyUI-Pixaroma is an independent, community-made extension. It is not affiliated with, endorsed by, or sponsored by Comfy Org or the ComfyUI project. Product names, logos, and trademarks are the property of their respective owners.
-32
View File
@@ -12,12 +12,6 @@ from .nodes.node_compare import NODE_CLASS_MAPPINGS as _MAPS_COMPARE
from .nodes.node_compare import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_COMPARE
from .nodes.node_crop import NODE_CLASS_MAPPINGS as _MAPS_CROP
from .nodes.node_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_CROP
from .nodes.node_uncrop import NODE_CLASS_MAPPINGS as _MAPS_UNCROP
from .nodes.node_uncrop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_UNCROP
from .nodes.node_inpaint_crop import NODE_CLASS_MAPPINGS as _MAPS_INPAINT_CROP
from .nodes.node_inpaint_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_INPAINT_CROP
from .nodes.node_inpaint_stitch import NODE_CLASS_MAPPINGS as _MAPS_INPAINT_STITCH
from .nodes.node_inpaint_stitch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_INPAINT_STITCH
from .nodes.node_label import NODE_CLASS_MAPPINGS as _MAPS_LABEL
from .nodes.node_label import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LABEL
from .nodes.node_load_image import NODE_CLASS_MAPPINGS as _MAPS_LOAD_IMAGE
@@ -46,12 +40,8 @@ from .nodes.node_mute_switch import NODE_CLASS_MAPPINGS as _MAPS_MUTE_SWITCH
from .nodes.node_mute_switch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_MUTE_SWITCH
from .nodes.node_wh import NODE_CLASS_MAPPINGS as _MAPS_WH
from .nodes.node_wh import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_WH
from .nodes.node_portrait_landscape import NODE_CLASS_MAPPINGS as _MAPS_PORTRAIT_LANDSCAPE
from .nodes.node_portrait_landscape import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PORTRAIT_LANDSCAPE
from .nodes.node_number import NODE_CLASS_MAPPINGS as _MAPS_NUMBER
from .nodes.node_number import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NUMBER
from .nodes.node_seed import NODE_CLASS_MAPPINGS as _MAPS_SEED
from .nodes.node_seed import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SEED
from .nodes.node_text import NODE_CLASS_MAPPINGS as _MAPS_TEXT
from .nodes.node_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT
from .nodes.node_prompt_stack import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_STACK
@@ -68,8 +58,6 @@ from .nodes.node_text_overlay import NODE_CLASS_MAPPINGS as _MAPS_TEXT_OVERLAY
from .nodes.node_text_overlay import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_OVERLAY
from .nodes.node_image_resize import NODE_CLASS_MAPPINGS as _MAPS_IMAGE_RESIZE
from .nodes.node_image_resize import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_IMAGE_RESIZE
from .nodes.node_resize_crop import NODE_CLASS_MAPPINGS as _MAPS_RESIZE_CROP
from .nodes.node_resize_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RESIZE_CROP
from .nodes.node_text_watermark import NODE_CLASS_MAPPINGS as _MAPS_TEXT_WATERMARK
from .nodes.node_text_watermark import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_WATERMARK
from .nodes.node_version_check import NODE_CLASS_MAPPINGS as _MAPS_VERSION_CHECK
@@ -80,10 +68,6 @@ from .nodes.node_xy_plot import NODE_CLASS_MAPPINGS as _MAPS_XY_PLOT
from .nodes.node_xy_plot import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_XY_PLOT
from .nodes.node_find_replace import NODE_CLASS_MAPPINGS as _MAPS_FIND_REPLACE
from .nodes.node_find_replace import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_FIND_REPLACE
from .nodes.node_load_images_folder import NODE_CLASS_MAPPINGS as _MAPS_LIF
from .nodes.node_load_images_folder import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LIF
from .nodes.node_set_get import NODE_CLASS_MAPPINGS as _MAPS_SET_GET
from .nodes.node_set_get import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SET_GET
# development mode for loading additional refrence nodes
dev_mode = False
@@ -105,9 +89,6 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_RESOLUTION,
**_MAPS_COMPARE,
**_MAPS_CROP,
**_MAPS_UNCROP,
**_MAPS_INPAINT_CROP,
**_MAPS_INPAINT_STITCH,
**_MAPS_LABEL,
**_MAPS_LOAD_IMAGE,
**_MAPS_NOTE,
@@ -118,9 +99,7 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_SWITCH_SOURCE,
**_MAPS_MUTE_SWITCH,
**_MAPS_WH,
**_MAPS_PORTRAIT_LANDSCAPE,
**_MAPS_NUMBER,
**_MAPS_SEED,
**_MAPS_TEXT,
**_MAPS_PROMPT_STACK,
**_MAPS_PROMPT_MULTI,
@@ -131,14 +110,11 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_REMOVE_BG,
**_MAPS_TEXT_OVERLAY,
**_MAPS_IMAGE_RESIZE,
**_MAPS_RESIZE_CROP,
**_MAPS_TEXT_WATERMARK,
**_MAPS_VERSION_CHECK,
**_MAPS_PAUSE_IMAGE,
**_MAPS_XY_PLOT,
**_MAPS_FIND_REPLACE,
**_MAPS_LIF,
**_MAPS_SET_GET,
}
# combine all node display name mappings
@@ -148,9 +124,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_AUDIO_STUDIO,
**_NAMES_COMPARE,
**_NAMES_CROP,
**_NAMES_UNCROP,
**_NAMES_INPAINT_CROP,
**_NAMES_INPAINT_STITCH,
**_NAMES_LABEL,
**_NAMES_LOAD_IMAGE,
**_NAMES_NOTE,
@@ -161,9 +134,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_SWITCH_SOURCE,
**_NAMES_MUTE_SWITCH,
**_NAMES_WH,
**_NAMES_PORTRAIT_LANDSCAPE,
**_NAMES_NUMBER,
**_NAMES_SEED,
**_NAMES_TEXT,
**_NAMES_PROMPT_STACK,
**_NAMES_PROMPT_MULTI,
@@ -178,14 +149,11 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_REMOVE_BG,
**_NAMES_TEXT_OVERLAY,
**_NAMES_IMAGE_RESIZE,
**_NAMES_RESIZE_CROP,
**_NAMES_TEXT_WATERMARK,
**_NAMES_VERSION_CHECK,
**_NAMES_PAUSE_IMAGE,
**_NAMES_XY_PLOT,
**_NAMES_FIND_REPLACE,
**_NAMES_LIF,
**_NAMES_SET_GET,
}
# web directory for loading js files
-61
View File
@@ -1,61 +0,0 @@
# Inpaint Crop / Stitch math
Single source of truth for the Inpaint Crop Pixaroma + Inpaint Stitch Pixaroma geometry and seam math. The Python in `nodes/_inpaint_helpers.py` is authoritative (runs at execute time); the JS mirrors it for the editor's live preview. Update this doc first, then both sides.
## 1. Crop region (`compute_region`)
Mirrored 1:1 by `js/inpaint_crop/geometry.mjs::computeRegion`. From the painted mask's bounding box and the image size:
1. Context expand: `rw = bw + 2*max(context_px, blend) + bw*context_pct/100` (same for height). Using `max(context_px, blend)` rather than just `context_px` gives the seam feather room to reach 0 inside the crop, so a big softness grows the crop instead of leaving a hard seam (Option B; see section 3).
2. Place + clamp the SOURCE crop rect inside the image (centred on the mask). In `force` mode the wanted region is first grown to the `target_w/target_h` aspect, then AFTER the clamp the source aspect is re-imposed to that aspect (the W/H clamp can clip one axis and not the other - shrink the over-long axis to the largest aspect-correct rect that fits).
3. Compute the OUTPUT size FROM the clamped source rect (NEVER the un-clamped wanted region), so the crop is resized at its real aspect and is never stretched when the image edge clipped the region (e.g. a big softness on a mask near the border - the source clamps to the image, so the output must too, or the crop/mask comes out squished):
- `keep`: scale the cropped rect's long side to `target` (respecting `allow_upscale`); then `min_size` FIRST (bump both sides up so the short side reaches `min_size`), then `max_size` LAST as a hard ceiling (so an extreme-aspect crop can't blow the long side past `max_size` into an OOM tensor; the short side may then end below `min_size` - acceptable). Round each side to `multiple`.
- `force`: the fixed `target_w x target_h` (the source was already re-imposed to that aspect in step 2).
- `free`: the cropped size rounded to `multiple`, capped at `max_size`.
Only accepted drift: sub-pixel rect placement (Python banker's rounding vs JS round-half-up = +/-1 px on `rx`/`ry`).
## 2. Conditioning mask (`apply_inpaint_crop`)
The mask the MODEL sees. `softm` (the `fill_holes`-cleaned + `mask_grow`-dilated core, max'd with the raw painted mask) is cropped to the region, resized NEAREST, then softened by a Gaussian of radius `mask_blur`. NEAREST avoids a second gradient halo; `mask_blur` is the one intended conditioning softening.
`fill_holes` closes only SMALL enclosed specks/gaps (a hole up to ~0.5% of the image: `max(256, 0.005*H*W)` px) — NEVER a large subject-shaped hole. Otherwise a cut-out / background mask (white around a subject) would have its subject hole filled by scipy's `binary_fill_holes` and the whole mask would collapse to solid (so the crop bbox becomes the whole image and the model repaints everything). The no-scipy fallback (a 9px PIL close) is already naturally limited to small holes.
If `invert_mask` is on, `raw` is flipped (`1 - raw`) right after `mask_to_np`, before fill-holes/grow — so the bbox, the conditioning out-mask, AND `crop_info["mask"]` (the stitch blend) all target the OPPOSITE area. It is a no-op when no mask is connected (`mask is None`). It is a mask-processing knob, NOT a geometry param, so it lives in `DEFAULTS` but not in `geometry.mjs`'s `GEO_DEFAULTS` (the editor's live preview does not reflect it - node-only).
`apply_inpaint_crop` first coerces an RGBA input to RGB (premultiply over black: `rgb * alpha`), so `crop_info["image"]` is always 3-channel. A 4-channel cut-out (e.g. Remove Background Pixaroma's `image`) would otherwise make Inpaint Stitch's paste throw on a 3-vs-4 channel mismatch, and the stitch node's `except` silently passes the result through as the "original". Premultiplying over black makes the cut-out read as the subject on black (matching the editor/preview), not the leftover background still under the alpha.
## 3. Seam feather (`_blur_alpha`) — the paste-back blend
This is what the Inpaint Stitch `blend` (the editor's `Softness`) controls. It is an OUTWARD-only feather of the (crisp, grown) mask, NOT a centred one (a centred feather makes the masked content semi-transparent at its own edge = a ghost/halo of the old content).
- Let `k = blend` (px). `k <= 0` returns the mask unchanged (hard seam).
- Binary core `mb = alpha > 0.5`. Empty or full mask returns unchanged.
- With scipy: `signed = dist_in(mb) - dist_out(~mb)`; `t = clip(signed/k + 1, 0, 1)`; `feather = smoothstep(t) = t*t*(3 - 2t)`. So alpha is 1 inside + at the edge, ramping to 0 over `k` px OUTSIDE.
- Without scipy (fallback): `feather = where(mb, 1, gaussian_blur(mb, k/1.7))` — same outward shape, approximate.
The feather is opaque inside by construction (`signed >= 0` -> `t >= 1` -> `smoothstep = 1`), so there is NO core-opaque clamp.
### The feather always has room (Option B)
A feather wider than the context margin used to leave a nonzero alpha at the crop rectangle border = a hard straight line ("high blend = straight edge"). That is now prevented at the SOURCE: `compute_region` expands the context to `max(context_px, blend)` (section 1), so the mask always sits at least `blend` px from the crop edge and the outward feather reaches 0 before the border on its own. The old rect-edge guard (`min(feather, smoothstep(de/k))` plus a `max(.., mask)` core clamp) was removed - growing the crop is cleaner and avoids the guard's failure modes (crushing a too-wide feather; ghosting a mask that sits near the crop edge). Trade-off: a big softness makes the crop a bit larger (visible as the crop box growing in the editor; a small inpaint-resolution cost).
`whole_crop` blend mode uses `_feather_alpha` instead (distance-to-rectangle fade of the whole crop). `_feather_alpha` caps the feather at `~(min(ch,cw)-1)//2` so the interior stays fully opaque: a feather wider than half the crop never reaches 1 anywhere, which would make the WHOLE paste translucent and ghost the original through (reachable on a small crop + a large Stitch softness override).
## 4. Editor live preview (matches the scipy result)
`js/inpaint_crop/render.mjs::_seamAlphaCanvas` mirrors the **scipy smoothstep** of section 3 (not the old no-scipy gaussian): it draws the mask into a downscaled buffer, runs `geometry.mjs::seamAlphaFromAlpha` (a 2-pass `(1, √2)` chamfer distance transform of the OUTSIDE distance, then `alpha = inside ? 1 : smoothstep(clip(1 - d_out/k))`), writes the feathered alpha back, and upscales to the display-res seam canvas. So the editor preview MATCHES the stitched result — a moderate softness no longer looks tighter in the editor than the real seam. The chamfer DT approximates the node's exact Euclidean EDT within a few % (invisible on a soft seam). Downscaled (long side capped at 480px) and computed only on `_draw` (strokes / slider drags, never idle mouse-move), so it stays fast without caching. The tint is filled via `source-in` in the chosen preview color and clipped to the crop region (so it can't spill past the box). The preview color is display-only (never written into the mask, state, or crop_info). In `whole_crop` blend mode `_seamAlphaCanvas` takes a separate branch that paints the inward rectangle-edge ramp of the crop REGION (mirror of `_feather_alpha`), not the mask-edge feather, so the tint matches a whole-crop stitch. The Softness slider calls `_recomputeRegion()` (not just `_draw`) because softness grows the crop box (Option B).
## 5. The settings flow
`softness` (the seam feather = `blend`) is a Crop node INT widget (0-150), mirrored by the editor's Softness slider; `node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` (so the crop context grows to fit the feather - section 1) AND injects it into `crop_info["blend"]` (clamped 0-150) for the stitch. `blend_mode` (`mask` / `whole crop`) is ALSO a Crop node combo widget now (mirrored by the editor's Blend mode pill, same as the other seam knobs - the node widget is the source of truth; `_BLEND_MODE` maps the friendly label to the internal key). `run` sets `crop_info["blend_mode"]` from the widget (NOT `state_json`). `mask` = only the painted area is replaced; `whole crop` = the entire crop rectangle is replaced (the `_feather_alpha` rectangle fade in `stitch_back`). `node_inpaint_stitch.py` reads `blend` + `blend_mode` from `crop_info` (defaults `16` / `mask` for an Image Crop `crop_info` that lacks them), BUT the Stitch node now has its OWN `softness` (-1 = inherit) + `blend_mode` (`from crop` = inherit) widgets that OVERRIDE crop_info via `resolve_seam(crop_info, softness, blend_mode)`. Because Stitch is downstream of the sampler, changing them re-runs only Stitch (the KSampler stays cached on a fixed seed) - so the seam can be tuned without re-sampling. No room-clamp: a Stitch softness larger than the crop's room may show a slightly harder edge (raise the Crop node's softness for more room); dialing down from the crop's value is always clean.
`color_match` (off/subtle/strong) is the STITCH node's OWN widget, not in `crop_info` (a post-result tweak with no live preview). It shifts the inpainted crop's color stats toward the original over the UNMASKED CONTEXT (the surroundings OUTSIDE the mask) — subtle = match mean, strong = match mean + std. NOT the mask and NOT the whole crop: both include the masked area, so they drag the inpaint's DELIBERATELY changed colors back toward the original (a red->white dress goes pink). Matching the context corrects only the lighting/tone drift in the unchanged surroundings. Falls back to uniform if the mask ~fills the crop (no context). So it is for blending an inpaint INTO the scene, not for deliberate recolors (keep it off for those).
## 6. Notes / caveats
- **MASK is single-frame.** `mask_to_np` collapses a `[B,H,W]` mask to frame 0, so a video / batch crop uses ONE mask (the first frame's) for every frame. The image batch is cropped per-frame with the same rect; only the mask is shared.
- **"Pixel-exact round-trip" is conditional.** Cropping an UN-edited region and stitching it straight back is pixel-exact ONLY in `free` size mode (no resize - and only when the source rect dims are already a `multiple`, so `resize_image_tensor` short-circuits) with `whole_crop` blend mode and `blend = 0` (alpha = 1 everywhere, no feather). `keep` / `force` always resize the crop (so the paste resamples), and any `blend > 0` or `mask` mode feathers the seam - both are intended, not bugs.
- **`crop_info["mask"]` is float32** regardless of the image dtype (the image tensor keeps its own dtype; the carried mask matches `out_mask`).
- **Stitch with no mask edge feathers the whole crop.** If the resolved paste alpha has no edge to soften - an Image Crop `crop_info` whose mask is all-zeros (no mask was wired into Image Crop), an all-ones mask, or a whole-image inpaint with nothing painted - `stitch_back` falls back to the `whole_crop` rectangle feather over the full crop, so the edited crop is still pasted (with a soft seam) instead of pasting nothing (all-zeros) or a hard rectangle (all-ones).
- **The editor's `temp/` source PNG accumulates.** `node_inpaint_crop.py::_save_source_temp` writes one PNG to ComfyUI's `temp/` per run (so the editor can load the upstream pixels of a generative chain). These are cleared on the normal ComfyUI `temp/` lifecycle (restart), not per run.
-8
View File
@@ -277,14 +277,6 @@ app.registerExtension({
node.addInput("image", "IMAGE");
}
// ── MASK input socket (optional) ────────────────────────────────────
// Lets the node crop transparency alongside the image. Guard-add (like the
// image input) so it shows on fresh nodes AND on workflows saved before this
// input existed. Sits right after the image input.
if (!(node.inputs || []).some((inp) => inp.name === "mask")) {
node.addInput("mask", "MASK");
}
// ── Shared preview system ──
const parts = createNodePreview(
"Image Crop",
+1 -2
View File
@@ -47,7 +47,6 @@ export function createEditorLayout(config) {
onZoomOut,
onZoomFit,
helpContent = "",
helpTitle = "", // optional; defaults to "<editorName> — Shortcuts" (back-compat)
} = config;
// ── Overlay ──
@@ -187,7 +186,7 @@ export function createEditorLayout(config) {
if (helpContent) {
helpPanel.innerHTML = `
<div class="pxf-help-header">
<h3>${helpTitle || `${editorName} — Shortcuts`}</h3>
<h3>${editorName} — Shortcuts</h3>
<button class="pxf-btn-sm" style="flex-shrink:0;">✕</button>
</div>
<div class="pxf-help-content">${helpContent}</div>
-244
View File
@@ -18,48 +18,6 @@
import { registerNodeHelp } from "../shared/index.mjs";
const HELP = {
"PixaromaSeed": {
title: "Seed Pixaroma",
tagline: "A seed source with one-click random and fixed control - wire it into any sampler's seed input.",
sections: [
{
heading: "What it does",
body: "Holds a seed number and sends it out as `seed`. Drag the output into the seed input of KSampler (or any node that takes a seed). One Seed node can feed several samplers at once so they all stay on the same seed.",
},
{
heading: "Modes",
defs: [
["Random", "Rolls a fresh seed every time you Run. The Last run line shows the seed that actually made the latest image."],
["Fixed", "Keeps the same seed every Run, so the result is repeatable."],
],
},
{
heading: "Buttons",
defs: [
["New fixed random", "Rolls a brand-new random seed and locks it (switches to Fixed). Use it when a random result is great and you want to keep that exact seed."],
["Use last seed", "Loads the seed from the previous run and locks it. Perfect for making variations off the image you just liked. It works within the current session only, so after you reload or reopen the workflow it stays greyed out until you Run once."],
["Copy", "Copies the current seed to your clipboard."],
],
},
{
heading: "Tips",
bullets: [
"Type a number in the big field to set an exact seed (it switches to Fixed).",
"In Fixed mode the same seed runs every time; in Random mode a new one rolls each Run.",
"When you wire this into KSampler it takes over the seed, so KSampler's own seed control greys out. Use the Random and Fixed buttons here instead.",
"The seed is picked in your browser. If you run a workflow through the API with no browser open, use Fixed with a set number (Random falls back to 0 there).",
],
},
{
heading: "Outputs",
defs: [
["seed", "The chosen seed as a whole number."],
],
},
],
footer: "Found a seed you love in Random mode? Hit Use last seed (or New fixed random) to lock it, then tweak the rest of your workflow with confidence.",
},
"Pixaroma3D": {
title: "3D Builder Pixaroma",
tagline: "A full 3D scene editor inside ComfyUI - build, light, and render scenes without leaving your workflow.",
@@ -160,19 +118,15 @@ const HELP = {
heading: "How to use",
bullets: [
"Wire an upstream IMAGE, drag a file onto the node, or paste with `Ctrl+V`.",
"To keep transparency, also wire a MASK into the `mask` input (for example Load Image's MASK output) - it gets cut with the exact same box.",
"Adjust the crop rectangle using the panel fields or click `Open Crop Editor` for the fullscreen editor with handles.",
"Choose a preset ratio (1:1, 16:9, 9:16, and more) or leave it on `Free`.",
"Run the workflow to output the cropped result.",
"To put an edited crop back later, wire the `crop_info` output into Image Uncrop Pixaroma.",
],
},
{
heading: "Outputs",
defs: [
["image", "The cropped image."],
["mask", "The cropped mask, cut with the same box as the image. Wire a MASK in (such as Load Image's MASK) to carry transparency through the crop; otherwise it is a fully-opaque mask sized to the crop."],
["crop_info", "Feed this into Image Uncrop Pixaroma to paste an edited version of the crop back onto the original image at the exact same spot."],
["width", "Width of the cropped area in pixels."],
["height", "Height of the cropped area in pixels."],
],
@@ -180,113 +134,6 @@ const HELP = {
],
},
"PixaromaUncrop": {
title: "Image Uncrop Pixaroma",
tagline: "Paste an edited crop back onto the original image at the exact spot it came from.",
sections: [
{
heading: "What it does",
body: "The other half of the crop, fix, then put it back workflow. After you crop a region with Image Crop Pixaroma and edit it (upscale, inpaint, face-fix, color work, anything), this node drops the edited piece back onto the full original image at the exact same place, leaving everything else untouched.\n\nIt knows where to paste from the `crop_info` wire, and it automatically resizes the edited crop to fit the original region (so upscaling the crop first is fine). Transparency travels through too: the `mask` output is the whole image's mask, not just the cropped rectangle.",
},
{
heading: "How to use",
bullets: [
"Wire the `crop_info` output of Image Crop Pixaroma into this node's `crop_info`.",
"Wire your edited crop into `image`.",
"To keep transparency, wire Image Crop's `mask` straight across into `mask` (it lines up under image). The mask comes back out full-frame.",
"Optionally raise `feather` for a soft, seamless edge where the crop meets the original.",
"Run the workflow to get the recombined full image.",
],
},
{
heading: "Outputs",
defs: [
["image", "The original image with the edited crop pasted back in place."],
["mask", "The full-frame mask: the original mask with the crop region updated. Use it to keep transparency (e.g. into Join Image with Alpha)."],
["crop_info", "The same crop_info passed straight through, so you can forward it on without re-routing the wire from Image Crop."],
],
},
],
},
"PixaromaInpaintCrop": {
title: "Inpaint Crop Pixaroma",
tagline: "The easy way to set up an inpaint - paint a mask, get a model-ready crop automatically.",
sections: [
{
heading: "What it does",
body: "Open the fullscreen editor and paint a mask over the part of the image you want to fix. The node finds the box around your mask, adds a context margin so the model can see the surroundings, and crops a clean, model-friendly piece (sized to a multiple of 8 and scaled toward your target, so even a small masked area gets enough resolution to look sharp).\n\nWire the cropped `image` and `mask` into your inpaint model (KSampler, Flux, edit models), then send `crop_info` into Inpaint Stitch Pixaroma to drop the result back in place. The orange box in the editor shows exactly what will be cropped as you paint. You also set how the seam blends here, with the `Softness` slider, and watch it preview live in a tint color you can switch (red, green, blue, yellow, orange) so it stays visible on any subject.",
},
{
heading: "How to use",
bullets: [
"Wire an image in, or drag-drop / paste one onto the node body.",
"Click `Open mask editor` and paint over the area to change. Use Brush / Erase (B / E), the size slider or `[` `]`, Clear and Invert. Scroll the wheel to zoom in for fine detail; hold Space and drag (or middle-drag) to pan.",
"In the editor, drag `Softness` to set how soft the seam blends - the preview shows it live. Softness, blend mode, mask grow, crop size and context are on the node AND mirrored in the editor, so set them wherever is handier.",
"Click `Save`, then wire `image` and `mask` into your inpaint model.",
"Send `crop_info` to Inpaint Stitch Pixaroma.",
],
},
{
heading: "Settings",
defs: [
["softness", "How far the seam feathers when the crop is pasted back. Higher values grow the crop a little so the soft blend has room (you'll see the crop box expand). The editor previews it live; it travels to Inpaint Stitch automatically."],
["blend mode", "How the result is pasted back. Mask: only the area you painted is replaced, the rest of the crop keeps the original (the normal inpaint). Whole crop: the ENTIRE cropped box is replaced with the model's version - use when the model also relit or changed the surroundings, or for an img2img-style pass."],
["size mode", "Keep shape scales the long side to the target with no stretching (best quality). Force size outputs a square. Free keeps the natural size."],
["context px", "How much surrounding area to include around your mask."],
["mask grow / blur", "Expand the mask before cropping, and soften the mask the model sees."],
["invert mask", "Flip the mask so the inpaint hits the OPPOSITE area (swap subject and background). Works on a wired or painted mask - no separate Invert Mask node needed."],
],
},
{
heading: "Outputs",
defs: [
["image", "The cropped region, sized for the model."],
["mask", "The matching cropped mask. Wire it into SetLatentNoiseMask or your inpaint conditioning."],
["crop_info", "Carries the original and where the crop came from. Wire it into Inpaint Stitch Pixaroma."],
["width / height", "The crop size in pixels, handy for an empty latent or edit models."],
],
},
],
footer: "The `crop_info` wire is the same type Image Crop uses, so the two are interchangeable.",
},
"PixaromaInpaintStitch": {
title: "Inpaint Stitch Pixaroma",
tagline: "Paste your inpainted crop back onto the original, blended so the seam disappears.",
sections: [
{
heading: "What it does",
body: "The other half of the inpaint workflow. After Inpaint Crop Pixaroma cropped a region and you ran your model on it, this node resizes the result back and blends it into the original at the exact spot. By default only the painted area changes, so everything else stays pixel-perfect.\n\nThe seam softness and blend mode start from the Inpaint Crop Pixaroma node and ride the `crop_info` wire, but you can OVERRIDE them right here (`softness` -1 = use the crop's) along with `color match`. The big win: this node is AFTER the sampler, so changing any of them re-runs only this node - the sampler stays cached on a fixed seed - meaning you can fine-tune the blend instantly without re-generating the image. `color match` corrects a color or tone shift the model introduced by matching the unchanged surroundings around your mask (keep it Off when you deliberately changed colors). It also hands back the untouched `original` so you can compare before and after.",
},
{
heading: "How to use",
bullets: [
"Wire the `crop_info` output of Inpaint Crop Pixaroma into `crop_info`.",
"Wire your inpainted crop (after the model) into `image`. It is resized back automatically.",
"Run the workflow to get the finished full image.",
"To fine-tune the blend WITHOUT re-generating: change `softness`, `blend mode` or `color match` HERE and run again - only this node re-runs, the sampler is cached (fixed seed), so it's instant.",
"Wire `image` and `original` into Image Compare Pixaroma for an instant before / after.",
],
},
{
heading: "Settings",
defs: [
["softness", "Seam feather, overriding the Crop node's. -1 = use the Crop node's value; 0-150 tunes the blend here, instantly (no re-sample). Bigger than the room the crop left may show a slightly harder edge - raise the Crop node's softness for more room."],
["blend mode", "from crop = use what the Crop node set. mask = replace only the painted area. whole crop = replace the entire cropped box. Changing it here re-runs only this node."],
["color match", "Correct a color/tone shift the model introduced, matching the unchanged surroundings around your mask. Off for deliberate color changes (it would pull them back). No live preview - set it and re-run."],
],
},
{
heading: "Outputs",
defs: [
["image", "The original image with the inpainted crop blended back in place."],
["original", "The full original uncropped image, for a before / after compare."],
],
},
],
},
"PixaromaAudioStudio": {
title: "AudioReact Pixaroma",
tagline: "Turn a still image into an audio-reactive video - motion and effects that move with the beat.",
@@ -557,39 +404,6 @@ const HELP = {
footer: "Replaces Get Image Size + Image Scale chains in most workflows.",
},
"PixaromaLoadImagesFolder": {
title: "Load Images from Folder Pixaroma",
tagline: "Load many images from any folder and run your workflow on each one, one at a time.",
sections: [
{
heading: "What it does",
body: "Point it at a folder of images and pick which ones to process. When you Run, it feeds the selected images through your workflow one at a time and gives you a finished result for each. It is a drop-in for Load Image when you want to batch a whole folder: swap it in, wire it the same way, and one Run handles every image. Images can be different sizes.\n\nIt has the same resize options as Load Image Pixaroma (`Max megapixels`, `Longest side`, `Scale by`, `Fit inside`, `Crop to fill`, `Match aspect ratio`), applied to each image as it loads. Resize is `Off` by default.",
},
{
heading: "How to use",
bullets: [
"Set the folder: type or paste a path into the folder box, or click `Browse` to navigate your drives and folders and pick one.",
"Click `Pick images` to open the gallery. Use `Select all`, type a number in the `First` box to grab the first few (it selects as you type), or click individual thumbnails to choose exactly what you want.",
"Wire `image` into your workflow (upscale, restyle, etc.) and `filename` into a Save node so each result keeps its original name.",
"Hit Run once and leave the batch count at 1. The node processes every selected image by itself.",
],
},
{
heading: "Outputs",
defs: [
["image", "Each selected image, one per item, after any resize."],
["mask", "Each image's mask from its alpha channel (blank if it has none)."],
["width", "Each image's width in pixels (after any resize). Wire into an empty latent so it matches the image's size."],
["height", "Each image's height in pixels (after any resize)."],
["filename", "Each image's filename without the extension, for naming saved results."],
["index", "The 1-based position of each image in this batch (1, 2, 3 ...)."],
["total", "How many images are in this batch (how many loaded; same for every item)."],
],
},
],
footer: "Pick 5 images and you get 5 results from one Run. No need to match any batch number.",
},
"PixaromaPreview": {
title: "Preview Image Pixaroma",
tagline: "Inline image preview with Save, Copy, and Open buttons - batch-aware, with a Strip or Grid layout.",
@@ -681,64 +495,6 @@ const HELP = {
],
},
"PixaromaResizeCrop": {
title: "Resize Crop Pixaroma",
tagline: "Force an image to an exact width and height by crop-to-fill - no stretching, no letterboxing.",
sections: [
{
heading: "What it does",
body: "Scales the image so it completely fills the target box, then crops the overflow from the center. The result is always exactly the width and height you set. Smaller images are scaled up to fill. Great for forcing image or video frames to a fixed size like 512x896 or 704x1280.",
},
{
heading: "How to use",
bullets: [
"Wire an image into `image`.",
"Type the size into the `width` and `height` fields, or wire a number into them from another node (e.g. Resolution Pixaroma or a Number node).",
"The fields default to 1024 x 1024. The up/down arrows step by 8 since AI/video sizes are usually multiples of 8, but you can type any exact value.",
"Optionally wire a `mask` - it gets cropped to the same size.",
],
},
{
heading: "Outputs",
defs: [
["image", "The cropped image, exactly width x height pixels."],
["mask", "The cropped mask, matching the output size (blank when no mask is wired in)."],
["width", "The output width in pixels."],
["height", "The output height in pixels."],
],
},
],
footer: "Need to scale without cropping, fit inside a box, or pad instead? Use Image Resize Pixaroma, which has all eight resize modes.",
},
"PixaromaPortraitLandscape": {
title: "Portrait Landscape Pixaroma",
tagline: "Flip a width and height between portrait (tall) and landscape (wide) with one click.",
sections: [
{
heading: "What it does",
body: "Takes two dimensions and outputs them in the orientation you choose. Click Portrait and the smaller number becomes the width (a tall image); click Landscape and the larger number becomes the width (a wide image). One node replaces keeping two WH nodes and a switch just to flip orientation.",
},
{
heading: "How to use",
bullets: [
"Enter your two sizes in the width and height fields, or wire them in from another node (e.g. WH Pixaroma or Resolution Pixaroma).",
"Click Portrait or Landscape - the active one lights up orange.",
"Wire the width and height outputs into your Empty Latent (or anywhere a size is needed).",
"The order you type the two numbers does not matter: Portrait always gives the tall arrangement, Landscape the wide one.",
],
},
{
heading: "Outputs",
defs: [
["width", "The output width. Portrait = the smaller of your two numbers, Landscape = the larger."],
["height", "The output height. Portrait = the larger of your two numbers, Landscape = the smaller."],
],
},
],
footer: "Need two genuinely different sizes (not just the same numbers swapped)? Use Switch WH Pixaroma instead, which picks between two separate width/height pairs.",
},
"PixaromaRemoveBackground": {
title: "Remove Background Pixaroma",
tagline: "Cut out the foreground from an image using a BiRefNet AI model.",
-411
View File
@@ -1,411 +0,0 @@
// ============================================================
// Inpaint Crop Pixaroma — Editor core (constructor, open/close, build UI)
// ============================================================
import {
BRAND,
createEditorLayout,
createPanel,
createButton,
createPillGrid,
createSliderRow,
createInfo,
} from "../framework/index.mjs";
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
export { BRAND };
const UI = "/pixaroma/assets/icons/ui/";
// Brush default size (used by the "Reset to default" button in the Brush panel).
const DEFAULT_BRUSH_SIZE = 80; // px diameter
// Mask/seam preview tint options (display only). Orange recolors the crop box.
export const INPAINT_PREVIEW_COLORS = {
Red: "#f6303a", Green: "#25d366", Blue: "#3a9bff", Yellow: "#ffd21a", Orange: "#ff8c1a",
};
export const InpaintAPI = {
async uploadSrc(projectId, dataURL) {
const { api } = await import("/scripts/api.js");
const res = await api.fetchApi("/pixaroma/api/inpaint/upload_src", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: projectId, image: dataURL }),
});
return await res.json();
},
async saveMask(projectId, dataURL) {
const { api } = await import("/scripts/api.js");
const res = await api.fetchApi("/pixaroma/api/inpaint/save_mask", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: projectId, mask: dataURL }),
});
return await res.json();
},
};
export class InpaintCropEditor {
constructor() {
this.onSave = null; // (stateJsonStr, { context_px }, previewDataURL)
this.onSaveToDisk = null; // (previewDataURL)
this.onClose = null;
this.onLoadImage = null; // host disconnects the upstream wire
this.onPreviewColor = null; // host persists the preview tint setting
this.el = {};
this.layout = null;
this.img = null;
this.imgW = 0;
this.imgH = 0;
this.projectId = null;
this._scale = 1; // effective source->display px = _baseScale * _zoom
this._baseScale = 1; // fit-to-window scale (zoom 1)
this._zoom = 1; // 1 = fit; wheel zooms toward the cursor
this._panX = 0; // display-px offset of the image origin (0 at fit)
this._panY = 0;
this._dispW = 0; // logical display (viewport) size, set by _fitCanvas before any draw
this._dispH = 0;
this._srcPath = "";
this._maskPath = "";
this._pendingSrcDataURL = null;
// brush / mask state
this.tool = "add"; // "add" | "erase"
this.brushSize = DEFAULT_BRUSH_SIZE; // display px (diameter); persists per node across opens
this.softness = 0; // crisp brush; the seam Softness slider owns blending now
this.maskOpacity = 0.5;
this.maskVisible = true;
this.previewColor = "#f6303a"; // mask + seam preview tint (display only)
this._cropBoxColor = null; // crop-box stroke override (white when orange tint)
this._painting = false;
this._lastPt = null;
// geometry params (snapshot of the node knobs)
this.params = {};
this._bbox = null; // raw painted bbox (source px)
this._region = null; // computeRegion() result
// undo
this._undo = [];
this._redo = [];
}
// upstreamUrl: live source from the wired IMAGE input (wins over saved src).
// params: { size_mode, target, multiple, context_px, mask_grow } (internal keys)
open(jsonStr, upstreamUrl, params, prefs) {
let data = {};
try { data = jsonStr && jsonStr !== "{}" ? JSON.parse(jsonStr) : {}; } catch (e) {}
this.projectId = data.project_id || "inpaint_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
this._srcPath = data.src_path || "";
this._maskPath = data.mask_path || "";
this.params = { ...(params || {}) };
if (this.params.blend == null) this.params.blend = 16;
this._fromUpstream = !!upstreamUrl;
// restore brush prefs from a previous open on this node (size / opacity
// persist across opens; absent -> the constructor defaults above).
if (prefs && typeof prefs === "object") {
if (prefs.brushSize != null) this.brushSize = prefs.brushSize;
if (prefs.maskOpacity != null) this.maskOpacity = prefs.maskOpacity;
}
this._buildUI();
this.layout.mount();
this._undoGuardOff = installGraphUndoGuard(() => !!this.el.overlay?.isConnected);
let sourceURL = null;
if (upstreamUrl) sourceURL = upstreamUrl;
else if (this._srcPath) {
const fn = this._srcPath.split(/[\\/]/).pop();
sourceURL = `/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
}
if (sourceURL) {
this._loadImageFromURL(sourceURL, () => {
// restore a saved painted mask (best-effort) once the image is sized
if (this._maskPath) {
const mfn = this._maskPath.split(/[\\/]/).pop();
const murl = `/view?filename=${encodeURIComponent(mfn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
this._loadMaskFromURL(murl);
}
});
} else {
this._setStatus("No source loaded. Wire an IMAGE input and run the workflow once, or click Load Image — or just paste / drag an image in here.");
}
this._bindKeys();
this._bindDropPaste(); // paste / drag-drop an image straight into the editor
}
_close() { this.layout?.unmount(); }
_buildUI() {
const layout = createEditorLayout({
editorName: "Inpaint Crop",
editorId: "pixaroma-inpaint-editor",
showUndoRedo: true,
showZoomBar: false,
showStatusBar: true,
onSave: () => this._save(),
onClose: () => this._close(),
onUndo: () => this._doUndo(),
onRedo: () => this._doRedo(),
helpTitle: "Inpaint Crop — Guide",
helpContent: `
<b style="color:#f66744;">WHAT THIS DOES</b><br>
Paint over the part of the image you want the model to redo. The node finds
the box around your paint, adds a margin, and crops a clean piece to inpaint.
The orange box is exactly what gets sent to the model.<br><br>
<b style="color:#f66744;">PAINTING</b><br>
<b>Brush / Erase:</b> pick a tool or press <kbd>B</kbd> / <kbd>E</kbd> (hold <kbd>X</kbd> to flip briefly)<br>
<b>Brush size:</b> <kbd>[</kbd> / <kbd>]</kbd> or the Size slider<br>
<b>Zoom / Pan:</b> scroll wheel zooms toward the cursor; hold <kbd>Space</kbd> and drag (or middle-drag) to pan; scroll out to fit<br>
<b>Show / hide mask:</b> <kbd>H</kbd><br>
<b>Invert / Clear:</b> buttons in the sidebar (Invert = swap painted &harr; unpainted)<br>
<b>Load a different image:</b> the Load Image button, or just <b>paste</b> / <b>drag</b> an image straight in here<br><br>
<b style="color:#f66744;">CROP SIZE</b> (sidebar + node)<br>
<b>Keep shape:</b> scales your area so its long side hits Target, no stretching (best quality)<br>
<b>Force square:</b> always a Target&times;Target square<br>
<b>Free:</b> natural size, just rounded to the Multiple<br>
<b>Context margin:</b> how much of the surroundings to include around your paint<br><br>
<b style="color:#f66744;">SEAM — HOW IT BLENDS BACK</b><br>
<b>Softness:</b> how far the paste fades into the original at the edge (the live orange tint previews it)<br>
<b>Mask grow:</b> expands the painted area a little before cropping<br>
<b>Blend mode — Mask:</b> only the area you painted is replaced, the rest of the crop keeps the original (the normal inpaint)<br>
<b>Blend mode — Whole crop:</b> the <i>entire</i> box is replaced with the model's version — use when the model also relit / changed the surroundings, or for an img2img pass<br><br>
<b style="color:#f66744;">KEYS</b><br>
<b>Undo / Redo:</b> <kbd>Ctrl+Z</kbd> / <kbd>Ctrl+Shift+Z</kbd> &middot;
<b>Save:</b> <kbd>Ctrl+S</kbd> &middot; <b>Close:</b> <kbd>Escape</kbd>`,
});
this.layout = layout;
layout.onSaveToDisk = () => { this._diskSavePending = true; this._save(); };
layout.onCleanup = () => {
if (this._undoGuardOff) { this._undoGuardOff(); this._undoGuardOff = null; }
this._unbindKeys();
if (this.onClose) this.onClose();
};
this.el.overlay = layout.overlay;
this.el.workspace = layout.workspace;
this._buildLeftSidebar(layout.leftSidebar);
this._buildRightSidebar(layout.rightSidebar, layout.sidebarFooter);
// canvas stack: main image+mask+overlay, plus a cursor canvas on top
const wrap = document.createElement("div");
wrap.style.cssText = "position:relative;display:inline-block;line-height:0;";
this.el.canvasWrap = wrap;
const cvs = document.createElement("canvas");
cvs.width = 100; cvs.height = 100;
this.el.canvas = cvs;
this.el.ctx = cvs.getContext("2d");
const cur = document.createElement("canvas");
cur.width = 100; cur.height = 100;
cur.style.cssText = "position:absolute;left:0;top:0;pointer-events:none;";
this.el.cursor = cur;
this.el.curCtx = cur.getContext("2d");
wrap.append(cvs, cur);
layout.workspace.appendChild(wrap);
this._bindMouse(cvs);
layout.setUndoState({ canUndo: false, canRedo: false });
}
_buildLeftSidebar(sidebar) {
// Mask tools
const secTools = createPanel("Mask");
this._toolGrid = createPillGrid(
[{ label: "Brush (B)", value: "add" }, { label: "Erase (E)", value: "erase" }],
2, (v) => this._setTool(v), { activeValue: "add" },
);
secTools.content.appendChild(this._toolGrid.el);
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:6px;margin-top:8px;";
row.append(
createButton("Invert", { variant: "standard", iconSrc: UI + "swap.svg", onClick: () => this._invertMask() }),
createButton("Clear", { variant: "standard", iconSrc: UI + "delete.svg", onClick: () => this._clearMask() }),
);
for (const b of row.children) b.style.flex = "1";
secTools.content.appendChild(row);
sidebar.appendChild(secTools.el);
// Brush
const secBrush = createPanel("Brush");
this.el.sizeSlider = createSliderRow("Size", 2, 300, this.brushSize, () => {
this.brushSize = parseInt(this.el.sizeSlider.numInput.value) || this.brushSize;
});
const sizeHint = document.createElement("div");
sizeHint.innerHTML = "[ smaller · ] bigger<br>scroll = zoom · space-drag = pan";
sizeHint.style.cssText = "font-size:10px;color:#888;margin-top:5px;line-height:1.5;";
secBrush.content.append(this.el.sizeSlider.el, sizeHint);
sidebar.appendChild(secBrush.el);
// Seam (the stitch blend; live preview on the canvas)
const secSeam = createPanel("Seam — how it blends");
this.el.blendSlider = createSliderRow("Softness", 0, 150, this.params.blend ?? 16, () => {
this.params.blend = parseInt(this.el.blendSlider.numInput.value) || 0;
// softness grows the crop once it exceeds context_px (Option B), so the crop
// box + size badge must recompute - not just the seam tint (mirror Mask grow).
this._recomputeRegion();
this._draw();
});
this.el.growSlider = createSliderRow("Mask grow", 0, 256, this.params.mask_grow ?? 4, () => {
this.params.mask_grow = parseInt(this.el.growSlider.numInput.value) || 0;
this._recomputeRegion();
this._draw();
});
const bmLabel = document.createElement("div");
bmLabel.textContent = "Blend mode";
bmLabel.style.cssText = "font-size:11px;color:#aaa;margin:10px 0 5px;";
this._blendModeGrid = createPillGrid(
[{ label: "Mask", value: "mask" }, { label: "Whole crop", value: "whole_crop" }],
2, (v) => { this.params.blend_mode = v; this._draw(); },
{ activeValue: this.params.blend_mode || "mask" },
);
secSeam.content.append(this.el.blendSlider.el, this.el.growSlider.el,
bmLabel, this._blendModeGrid.el);
sidebar.appendChild(secSeam.el);
// View
const secView = createPanel("Mask overlay");
this._visBtn = createButton("Toggle mask (H)", { variant: "full", iconSrc: UI + "eraser.svg", onClick: () => this._toggleMaskVisible() });
this.el.opacitySlider = createSliderRow("Opacity", 10, 100, Math.round(this.maskOpacity * 100), () => {
this.maskOpacity = (parseInt(this.el.opacitySlider.numInput.value) || 50) / 100;
this._draw();
});
secView.content.append(this._visBtn, this.el.opacitySlider.el);
// preview color swatches (display only; Orange recolors the crop box to white)
const swatchRow = document.createElement("div");
swatchRow.style.cssText = "display:flex;gap:8px;align-items:center;margin-top:8px;";
const swatchLabel = document.createElement("span");
swatchLabel.textContent = "Color";
swatchLabel.style.cssText = "font-size:11px;color:#aaa;margin-right:2px;";
swatchRow.appendChild(swatchLabel);
this._colorSwatches = [];
for (const [name, hex] of Object.entries(INPAINT_PREVIEW_COLORS)) {
const dot = document.createElement("span");
dot.title = name;
dot.style.cssText = `width:20px;height:20px;border-radius:50%;background:${hex};cursor:pointer;box-sizing:border-box;border:2px solid ${this.previewColor === hex ? "#fff" : "transparent"};`;
dot.addEventListener("click", () => this._setPreviewColor(name, hex));
this._colorSwatches.push({ dot, hex });
swatchRow.appendChild(dot);
}
secView.content.appendChild(swatchRow);
sidebar.appendChild(secView.el);
// Context margin (mirrors node context_px, updates the preview live)
const secCtx = createPanel("Context margin");
const ctxStart = this.params.context_px != null ? this.params.context_px : 24;
this.el.ctxSlider = createSliderRow("Pixels", 0, 512, ctxStart, () => {
this.params.context_px = parseInt(this.el.ctxSlider.numInput.value) || 0;
this._recomputeRegion();
this._draw();
});
secCtx.content.appendChild(this.el.ctxSlider.el);
sidebar.appendChild(secCtx.el);
// Crop size (mirrors the node knobs; live preview)
const secCrop = createPanel("Crop size");
this._sizeModeGrid = createPillGrid(
[{ label: "Keep shape", value: "keep" }, { label: "Force square", value: "force" }, { label: "Free", value: "free" }],
3, (v) => { this.params.size_mode = v; this._recomputeRegion(); this._draw(); },
{ activeValue: this.params.size_mode || "keep" },
);
this.el.targetSlider = createSliderRow("Target", 64, 8192, this.params.target ?? 1024, () => {
const m = this.params.multiple || 8;
const raw = parseInt(this.el.targetSlider.numInput.value) || 1024;
const snapped = Math.max(m, Math.round(raw / m) * m); // land on the multiple
this.params.target = snapped;
if (snapped !== raw) this.el.targetSlider.setValue(snapped);
this._recomputeRegion(); this._draw();
});
this._multipleGrid = createPillGrid(
[{ label: "8", value: 8 }, { label: "16", value: 16 }, { label: "32", value: 32 }, { label: "64", value: 64 }],
4, (v) => {
this.params.multiple = v;
const snapped = Math.max(v, Math.round((this.params.target || 1024) / v) * v);
this.params.target = snapped;
this.el.targetSlider?.setValue(snapped); // re-snap target to the new multiple
this._recomputeRegion(); this._draw();
},
{ activeValue: this.params.multiple || 8 },
);
secCrop.content.append(this._sizeModeGrid.el, this.el.targetSlider.el, this._multipleGrid.el);
sidebar.appendChild(secCrop.el);
// Reset all settings to default
const resetAll = createButton("Reset all to default", {
variant: "standard", iconSrc: UI + "reset.svg", onClick: () => this._resetAll(),
});
resetAll.style.marginTop = "10px";
sidebar.appendChild(resetAll);
// Load Image
const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.accept = "image/*"; fileInput.style.display = "none";
fileInput.addEventListener("change", (e) => {
const f = e.target.files?.[0];
if (f) {
const reader = new FileReader();
reader.onload = (ev) => { this._loadImageFromDataURL(ev.target.result); this.onLoadImage?.(); };
reader.readAsDataURL(f);
}
fileInput.value = "";
});
const loadBtn = createButton("Load Image", { variant: "full", iconSrc: UI + "image.svg", onClick: () => fileInput.click() });
loadBtn.style.marginTop = "10px";
sidebar.append(loadBtn, fileInput);
}
_buildRightSidebar(sidebar, footer) {
const sec = createPanel("Output crop");
this._infoBlock = createInfo("Paint a mask to begin");
sidebar.insertBefore(sec.el, footer);
sec.content.appendChild(this._infoBlock.el);
}
_setTool(v) { this.tool = v; }
_resetAll() {
this._setTool("add"); // back to Brush (was left on Erase after a reset)
this.brushSize = DEFAULT_BRUSH_SIZE;
this.maskOpacity = 0.5;
this.params.blend = 16;
this.params.mask_grow = 4;
this.params.mask_blur = 4;
this.params.blend_mode = "mask";
this.params.context_px = 24;
this.params.size_mode = "keep";
this.params.target = 1024;
this.params.multiple = 8;
this.el.sizeSlider?.setValue(this.brushSize);
this.el.opacitySlider?.setValue(50);
this.el.blendSlider?.setValue(16);
this.el.growSlider?.setValue(4);
this.el.ctxSlider?.setValue(24);
this.el.targetSlider?.setValue(1024);
this._toolGrid?.setActive?.("add");
this._blendModeGrid?.setActive?.("mask");
this._sizeModeGrid?.setActive?.("keep");
this._multipleGrid?.setActive?.(8);
this._recomputeRegion();
this._draw();
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
}
_setPreviewColor(name, hex) {
this.previewColor = hex;
this._cropBoxColor = (hex === INPAINT_PREVIEW_COLORS.Orange) ? "#ffffff" : null;
for (const s of this._colorSwatches || [])
s.dot.style.borderColor = (s.hex === hex) ? "#fff" : "transparent";
this.onPreviewColor?.(name);
this._draw();
}
_toggleMaskVisible() {
this.maskVisible = !this.maskVisible;
this._visBtn.classList.toggle("active", !this.maskVisible);
this._draw();
}
_setStatus(msg) { this.layout?.setStatus(msg); }
}
-184
View File
@@ -1,184 +0,0 @@
// ============================================================
// Inpaint Crop Pixaroma — geometry (JS mirror of nodes/_inpaint_helpers.py)
// Keep computeRegion() 1:1 with the Python compute_region so the editor's live
// crop-rectangle preview matches what the node actually produces. Source of
// truth: docs/inpaint-crop-math.md. The ONLY accepted drift is sub-pixel rect
// placement (Python uses banker's rounding, JS uses round-half-up) - a +/-1px
// preview offset, never a different output size.
// ============================================================
export const GEO_DEFAULTS = {
size_mode: "keep", target: 1024, target_w: 1024, target_h: 1024,
multiple: 8, context_px: 24, context_pct: 10, mask_grow: 4, mask_blur: 4,
blend: 16, min_size: 256, max_size: 2048, allow_upscale: true,
};
// round half to EVEN, matching Python's built-in round() (banker's rounding) that
// _round_mult uses. Math.round is half-UP, so without this the editor crop-box size
// could read one `multiple` step off from the real output at exact .5 boundaries
// (e.g. 1056/64 = 16.5 -> Python 16 -> 1024, half-up 17 -> 1088). Only the exact-.5
// case differs; every other value rounds identically.
const roundHalfEven = (x) => {
const f = Math.floor(x), d = x - f;
if (d < 0.5) return f;
if (d > 0.5) return f + 1;
return f % 2 === 0 ? f : f + 1;
};
const roundMult = (v, m) => {
m = Math.max(1, Math.round(m));
return Math.max(m, roundHalfEven(v / m) * m);
};
const clampi = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v)));
export function computeRegion(bbox, W, H, params) {
const p = { ...GEO_DEFAULTS, ...(params || {}) };
W = Math.round(W); H = Math.round(H);
let x0, y0, x1, y1;
if (!bbox) { x0 = 0; y0 = 0; x1 = W; y1 = H; } else { [x0, y0, x1, y1] = bbox; }
const bw = Math.max(1, x1 - x0), bh = Math.max(1, y1 - y0);
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
// context expand uses max(context_px, blend) so the seam feather has room to reach
// 0 inside the crop (Option B - mirror of compute_region; a big softness grows it).
const ctx = Math.max(p.context_px, p.blend || 0);
let rw = bw + 2 * ctx + bw * p.context_pct / 100;
let rh = bh + 2 * ctx + bh * p.context_pct / 100;
const mult = p.multiple, mode = p.size_mode;
// force mode: grow the WANTED region to the target aspect first (output is the
// fixed target size, set below).
let tw = 0, th = 0;
if (mode === "force") {
tw = Math.max(mult, roundMult(p.target_w, mult));
th = Math.max(mult, roundMult(p.target_h, mult));
const ta = tw / th;
if (rw / rh < ta) rw = rh * ta; else rh = rw / ta;
}
// place + clamp the SOURCE region inside the image
let rw_i = Math.max(1, Math.min(Math.round(rw), W));
let rh_i = Math.max(1, Math.min(Math.round(rh), H));
if (mode === "force") {
// keep source aspect == target aspect after the image-bound clamp (mirror).
const aspect = tw / th;
if (rw_i > rh_i * aspect) rw_i = Math.max(1, Math.round(rh_i * aspect));
else rh_i = Math.max(1, Math.round(rw_i / aspect));
}
const rx = clampi(cx - rw_i / 2, 0, W - rw_i);
const ry = clampi(cy - rh_i / 2, 0, H - rh_i);
// output size from the CLAMPED source rect (rw_i, rh_i), never the un-clamped
// region - so the crop is never stretched when the image edge clipped it.
let out_w, out_h;
if (mode === "force") {
out_w = tw; out_h = th;
} else if (mode === "free") {
// keep the cropped rect's own size; when the long side exceeds max_size scale
// BOTH axes by one factor so the aspect is preserved (mirror of compute_region).
let ow = rw_i, oh = rh_i;
const big = Math.max(ow, oh);
if (big > p.max_size) { const k = p.max_size / big; ow *= k; oh *= k; }
out_w = roundMult(ow, mult);
out_h = roundMult(oh, mult);
} else {
const long = Math.max(rw_i, rh_i);
let s = long > 0 ? p.target / long : 1;
if (!p.allow_upscale) s = Math.min(s, 1);
let ow = rw_i * s, oh = rh_i * s;
const small = Math.min(ow, oh);
if (small < p.min_size) { const k = p.min_size / small; ow *= k; oh *= k; }
const big = Math.max(ow, oh);
if (big > p.max_size) { const k = p.max_size / big; ow *= k; oh *= k; }
out_w = roundMult(ow, mult); out_h = roundMult(oh, mult);
}
out_w = Math.max(mult, Math.round(out_w));
out_h = Math.max(mult, Math.round(out_h));
return { rx, ry, rw: rw_i, rh: rh_i, out_w, out_h };
}
// Bounding box of painted pixels from a mask canvas's alpha channel.
// Returns [x0,y0,x1,y1] (x1/y1 exclusive) or null when nothing is painted.
export function maskBBoxFromImageData(data, w, h, thresh = 8) {
let x0 = w, y0 = h, x1 = 0, y1 = 0, found = false;
for (let y = 0; y < h; y++) {
let row = y * w * 4;
for (let x = 0; x < w; x++) {
if (data[row + x * 4 + 3] > thresh) {
found = true;
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
return found ? [x0, y0, x1 + 1, y1 + 1] : null;
}
// Expand a bbox by `px` each side (clamped) - mirrors the effect of the Python
// mask dilation (grow) on the bounding box.
export function growBBox(bbox, px, W, H) {
if (!bbox) return null;
return [
Math.max(0, bbox[0] - px), Math.max(0, bbox[1] - px),
Math.min(W, bbox[2] + px), Math.min(H, bbox[3] + px),
];
}
// Outward seam-feather alpha - the canvas mirror of Python `_blur_alpha`'s scipy
// path (nodes/_inpaint_helpers.py). Reads the ALPHA channel of an RGBA `data`
// buffer (w x h) as the mask, returns a Float32Array alpha: 1.0 INSIDE the mask
// (and at its edge), ramping to 0 over `k` px OUTSIDE via the same smoothstep the
// node uses. Pure + DOM-free for unit testing.
//
// The node uses an exact Euclidean signed-distance transform; here we use a fast
// 2-pass (1, sqrt2) chamfer transform of the OUTSIDE distance (inside is just 1).
// The chamfer approximates Euclidean within a few %, which is invisible on a soft
// seam preview. This is the "preview == result" parity: a moderate softness no
// longer looks tighter in the editor than the real stitched seam.
export function seamAlphaFromAlpha(data, w, h, k) {
const n = w * h;
const INF = 1e9;
const d = new Float32Array(n);
for (let i = 0, p = 3; i < n; i++, p += 4) d[i] = data[p] > 127 ? 0 : INF;
const d1 = 1, d2 = Math.SQRT2;
// forward pass: top-left -> bottom-right (already-visited neighbours)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
let v = d[i];
if (v === 0) continue;
if (x > 0) v = Math.min(v, d[i - 1] + d1);
if (y > 0) {
v = Math.min(v, d[i - w] + d1);
if (x > 0) v = Math.min(v, d[i - w - 1] + d2);
if (x < w - 1) v = Math.min(v, d[i - w + 1] + d2);
}
d[i] = v;
}
}
// backward pass: bottom-right -> top-left
for (let y = h - 1; y >= 0; y--) {
for (let x = w - 1; x >= 0; x--) {
const i = y * w + x;
let v = d[i];
if (v === 0) continue;
if (x < w - 1) v = Math.min(v, d[i + 1] + d1);
if (y < h - 1) {
v = Math.min(v, d[i + w] + d1);
if (x < w - 1) v = Math.min(v, d[i + w + 1] + d2);
if (x > 0) v = Math.min(v, d[i + w - 1] + d2);
}
d[i] = v;
}
}
const out = new Float32Array(n);
const kk = Math.max(1e-3, k);
for (let i = 0; i < n; i++) {
if (d[i] === 0) { out[i] = 1; continue; } // inside the mask + its edge
const t = Math.max(0, Math.min(1, 1 - d[i] / kk));
out[i] = t * t * (3 - 2 * t); // smoothstep, same as the node
}
return out;
}
-374
View File
@@ -1,374 +0,0 @@
// ============================================================
// Inpaint Crop Pixaroma — node entry (open button, preview, source, persist)
// ============================================================
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { InpaintCropEditor, INPAINT_PREVIEW_COLORS } from "./core.mjs";
import "./paint.mjs"; // mixin: brush / mask / keys
import "./render.mjs"; // mixin: canvas render + save
import {
createNodePreview, showNodePreview, restoreNodePreview, activateNodePreview,
downloadDataURL, applyAdaptiveCanvasOnly,
} from "../shared/index.mjs";
const SIZE_MODE_MAP = {
"keep shape (long side)": "keep",
"force size (square)": "force",
"free (multiple only)": "free",
};
// node-widget label <-> internal blend_mode key (the editor pill uses the internal key)
const BLEND_MODE_MAP = { "mask": "mask", "whole crop": "whole_crop" };
function readParams(node) {
const g = (n) => node.widgets?.find((w) => w.name === n)?.value;
return {
size_mode: SIZE_MODE_MAP[g("size_mode")] || "keep",
target: parseInt(g("target")) || 1024,
multiple: parseInt(g("multiple")) || 8,
context_px: g("context_px") != null ? parseInt(g("context_px")) : 24,
mask_grow: g("mask_grow") != null ? parseInt(g("mask_grow")) : 4,
mask_blur: g("mask_blur") != null ? parseInt(g("mask_blur")) : 4,
blend: g("softness") != null ? parseInt(g("softness")) : 16,
// blend_mode is now a node widget too (mirrored by the editor pill).
blend_mode: BLEND_MODE_MAP[g("blend_mode")] || "mask",
};
}
// friendly-label <- internal size-mode key, for writing the editor's choice back
const SIZE_MODE_LABEL = Object.fromEntries(
Object.entries(SIZE_MODE_MAP).map(([k, v]) => [v, k]));
const BLEND_MODE_LABEL = Object.fromEntries(
Object.entries(BLEND_MODE_MAP).map(([k, v]) => [v, k]));
function setNodeWidget(node, name, value) {
const w = node.widgets?.find((x) => x.name === name);
if (w && w.value !== value) { w.value = value; w.callback?.(value); }
}
// write the editor's mirrored geometry knobs back to the native node widgets
function writeBackWidgets(node, extra) {
if (!extra) return;
if (extra.context_px != null) setNodeWidget(node, "context_px", extra.context_px);
if (extra.mask_grow != null) setNodeWidget(node, "mask_grow", extra.mask_grow);
if (extra.mask_blur != null) setNodeWidget(node, "mask_blur", extra.mask_blur);
if (extra.softness != null) setNodeWidget(node, "softness", extra.softness);
if (extra.target != null) setNodeWidget(node, "target", extra.target);
if (extra.multiple != null) setNodeWidget(node, "multiple", extra.multiple);
if (extra.size_mode != null)
setNodeWidget(node, "size_mode", SIZE_MODE_LABEL[extra.size_mode] || "keep shape (long side)");
if (extra.blend_mode != null)
setNodeWidget(node, "blend_mode", BLEND_MODE_LABEL[extra.blend_mode] || "mask");
}
function buildSourceURL(part, bust) {
if (!part || !part.filename) return null;
const url = `/view?filename=${encodeURIComponent(part.filename)}` +
`&subfolder=${encodeURIComponent(part.subfolder || "")}` +
`&type=${encodeURIComponent(part.type || "temp")}`;
return bust ? `${url}&t=${Date.now()}` : url;
}
// A LoadImage combo value can be "name.png", "sub/name.png", or carry an
// annotation like "clipspace/clipboard.png [input]" (this is what PASTING into a
// LoadImage produces - a subfolder + suffix). /view wants filename + subfolder as
// SEPARATE params and no annotation, so split it; otherwise the editor 404s with
// "Failed to load the source image" (only on paste, since a plain pick has no
// subfolder/suffix). Mirrors the image-picker split used elsewhere.
function parseAnnotatedImageValue(value) {
let v = String(value || "");
let type = "input";
const m = v.match(/\s*\[(input|output|temp)\]\s*$/i);
if (m) { type = m[1].toLowerCase(); v = v.slice(0, m.index); }
v = v.replace(/\\/g, "/").trim();
const i = v.lastIndexOf("/");
return {
filename: i >= 0 ? v.slice(i + 1) : v,
subfolder: i >= 0 ? v.slice(0, i) : "",
type,
};
}
function getUpstreamImageURL(node) {
// Prefer the LIVE wired source so a just-changed Load Image (or any live
// preview) is what the editor opens. The cached executed-source URL below is
// only a fallback for generative upstreams whose pixels exist solely as the
// temp PNG the Python node saved on the last run. (Without this order,
// swapping the Load Image file showed the PREVIOUS run's image until re-run.)
const input = (node.inputs || []).find((i) => i.name === "image");
const graph = node.graph;
if (input && input.link != null && graph) {
let link = graph.links?.[input.link];
if (!link && typeof graph.links?.get === "function") link = graph.links.get(input.link);
const src = link && graph.getNodeById(link.origin_id);
if (src) {
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
const w = (src.widgets || []).find((x) => x.name === "image");
if (w && w.value) return buildSourceURL(parseAnnotatedImageValue(w.value), true);
}
if (src.imgs && src.imgs.length > 0) {
const img = src.imgs[link.origin_slot] || src.imgs[0];
if (typeof img === "string") return img;
if (img && img.src) return img.src;
}
}
}
// fallback: source PNG from the last Python execute (generative upstreams, or
// before a live preview exists), and the paste / drag-drop / restored case.
if (node._pixInpaintSourceURL) return node._pixInpaintSourceURL;
return null;
}
// ── clipboard paste → selected Inpaint Crop node ──
let _pasteInstalled = false;
function installPasteHandler() {
if (_pasteInstalled) return;
_pasteInstalled = true;
window.addEventListener("paste", async (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const node = findActiveNode();
if (!node) return;
// editor open -> let the editor's own paste handler load it into the canvas
if (node._pixInpaintEditor?.el?.overlay?.isConnected) return;
const items = e.clipboardData?.items || [];
const it = Array.from(items).find((x) => x.type?.startsWith("image/"));
if (!it) return;
e.preventDefault(); e.stopImmediatePropagation();
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
const idsBefore = new Set((app.graph?._nodes || []).map((n) => n.id));
const blob = it.getAsFile();
if (!blob) return;
const reader = new FileReader();
reader.onload = (ev) => node._pixInpaintPaste(ev.target.result);
reader.readAsDataURL(blob);
setTimeout(() => {
for (const n of app.graph?._nodes || []) {
if (idsBefore.has(n.id)) continue;
if (n.comfyClass !== "LoadImage" && n.type !== "LoadImage") continue;
const w = (n.widgets || []).find((x) => x.name === "image");
if (typeof w?.value === "string" && w.value.startsWith("pasted/")) { try { app.graph.remove(n); } catch {} }
}
}, 50);
}, true);
}
function findActiveNode() {
const c = app.canvas;
if (!c) return null;
const ok = (n) => n && n.comfyClass === "PixaromaInpaintCrop" && typeof n._pixInpaintPaste === "function";
const sel = c.selected_nodes;
if (sel) {
let iter = Array.isArray(sel) ? sel : (typeof sel.values === "function" ? Array.from(sel.values()) : Object.values(sel));
const hit = iter?.find(ok);
if (hit) return hit;
}
if (ok(c.current_node)) return c.current_node;
if (ok(c.node_over)) return c.node_over;
for (const n of app.graph?._nodes || []) if (ok(n) && (n.is_selected || n.flags?.is_selected)) return n;
return null;
}
app.registerExtension({
name: "Pixaroma.InpaintCrop",
settings: [{
id: "Pixaroma.Inpaint.PreviewColor",
name: "Mask preview color",
type: "combo",
defaultValue: "Red",
options: ["Red", "Green", "Blue", "Yellow", "Orange"],
tooltip: "Tint color for the mask + seam preview in the Inpaint editor (display only).",
category: ["👑 Pixaroma", "Inpaint"],
}],
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaInpaintCrop") return;
const origExec = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
origExec?.apply(this, arguments);
this.imgs = null;
};
const origCfg = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const ret = origCfg?.apply(this, arguments);
this.imgs = null;
if (!this._pixInpaintSourceURL && this.properties?.pixInpaintSource) {
this._pixInpaintSourceURL = buildSourceURL(this.properties.pixInpaintSource, true);
}
if (this._pixInpaintRefresh) {
queueMicrotask(() => this._pixInpaintRefresh());
setTimeout(() => this._pixInpaintRefresh?.(), 250);
}
return ret;
};
},
async nodeCreated(node) {
if (node.comfyClass !== "PixaromaInpaintCrop") return;
node.imgs = null;
// Fresh-drop default size only; never on the load path (configure restores
// the saved size, so writing it during load would dirty the workflow). Mutate
// the array elements (convention #9), don't replace the array.
if (!isGraphLoading() && node.size) { node.size[0] = 330; node.size[1] = 500; }
if (!(node.inputs || []).some((i) => i.name === "image")) node.addInput("image", "IMAGE");
if (!(node.inputs || []).some((i) => i.name === "mask")) node.addInput("mask", "MASK");
const parts = createNodePreview(
"Inpaint Crop", "Pixaroma",
"Wire an IMAGE and Run,\nor click 'Open mask editor' to load + paint",
);
let stateJson = "{}";
let widget;
const refreshSourcePreview = () => {
const url = getUpstreamImageURL(node);
if (url) showNodePreview(parts, url, null, node);
};
// ── Open mask editor button ──
node.addWidget("button", "Open mask editor", null, () => {
if (node._pixInpaintEditor?.el?.overlay?.isConnected) return;
refreshSourcePreview(); // sync the node thumbnail to the current upstream image
const editor = new InpaintCropEditor();
node._pixInpaintEditor = editor;
// brush size / opacity persist across opens on this node
const captureBrush = () => {
node._pixInpaintBrush = { brushSize: editor.brushSize, maskOpacity: editor.maskOpacity };
};
// preview tint (display only) - seed from the setting, persist on change
const colName = app.ui.settings?.getSettingValue?.("Pixaroma.Inpaint.PreviewColor") || "Red";
const colHex = INPAINT_PREVIEW_COLORS[colName] || INPAINT_PREVIEW_COLORS.Red;
editor.previewColor = colHex;
editor._cropBoxColor = (colHex === INPAINT_PREVIEW_COLORS.Orange) ? "#ffffff" : null;
editor.onPreviewColor = (name) => {
try { app.ui.settings?.setSettingValueAsync?.("Pixaroma.Inpaint.PreviewColor", name); } catch {}
};
editor.onSave = (jsonStr, extra, preview) => {
stateJson = jsonStr;
if (widget) widget.value = { state_json: jsonStr };
writeBackWidgets(node, extra);
if (preview) showNodePreview(parts, preview, null, node);
if (app.graph) { app.graph.setDirtyCanvas(true, true); app.graph.change?.(); }
captureBrush();
};
editor.onSaveToDisk = (d) => downloadDataURL(d, "pixaroma_inpaint_crop");
editor.onLoadImage = () => {
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
};
editor.onClose = () => { captureBrush(); node._pixInpaintEditor = null; node.setDirtyCanvas(true, true); };
editor.open(stateJson, getUpstreamImageURL(node),
readParams(node), node._pixInpaintBrush);
});
// ── mini-preview DOM widget (also carries the hidden state) ──
widget = node.addDOMWidget("InpaintCropWidget", "custom", parts.container, {
getValue: () => ({ state_json: stateJson }),
setValue: (v) => {
if (!v || typeof v !== "object") return;
stateJson = v.state_json || "{}";
const imgInput = (node.inputs || []).find((i) => i.name === "image");
if (imgInput && imgInput.link != null) queueMicrotask(refreshSourcePreview);
else restoreNodePreview(parts, stateJson, node); // rebuild from src_path when present
},
getMinHeight: () => 200,
margin: 5,
});
applyAdaptiveCanvasOnly(widget);
activateNodePreview(parts, node);
// ── paste / drag-drop a source directly onto the node ──
installPasteHandler();
node._pixInpaintPaste = async (dataURL) => {
try {
const r = await api.fetchApi("/pixaroma/api/inpaint/upload_src", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: "inpaint_paste_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), image: dataURL }),
});
const d = await r.json();
const srcPath = d.path || "";
let meta = {};
try { meta = JSON.parse(stateJson) || {}; } catch {}
meta.src_path = srcPath;
meta.mask_path = ""; // a new source clears the old painted mask
stateJson = JSON.stringify(meta);
if (widget) widget.value = { state_json: stateJson };
if (srcPath) {
const part = { filename: srcPath.split(/[\\/]/).pop(), subfolder: "pixaroma", type: "input" };
node._pixInpaintSourceURL = buildSourceURL(part, true);
if (!node.properties) node.properties = {};
node.properties.pixInpaintSource = part;
}
showNodePreview(parts, dataURL, null, node);
if (app.graph) app.graph.setDirtyCanvas(true, true);
} catch (err) { console.warn("[InpaintCrop] paste failed:", err); }
};
const dropTarget = parts?.container;
if (dropTarget) {
dropTarget.addEventListener("dragover", (e) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); e.stopPropagation(); } });
dropTarget.addEventListener("drop", (e) => {
e.preventDefault(); e.stopPropagation();
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type?.startsWith("image/")) return;
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
const reader = new FileReader();
reader.onload = (ev) => node._pixInpaintPaste(ev.target.result);
reader.readAsDataURL(file);
});
}
// ── source URL caching from Python execute + refresh hooks ──
node._pixInpaintRefresh = () => {
if (getUpstreamImageURL(node)) refreshSourcePreview();
// pass the real state (not "{}") so restoreNodePreview can rebuild from src_path
// - e.g. a source loaded via the editor's Load Image, where the wire is gone.
else restoreNodePreview(parts, stateJson, node);
};
const onExec = (event) => {
const detail = event?.detail;
if (!detail?.output) return;
const matched = app.graph.getNodeById(detail.node) || app.graph.getNodeById(parseInt(detail.node, 10));
if (matched !== node) return;
const frames = detail.output.pixaroma_inpaint_source;
if (!frames?.length) return;
const f = frames[0];
const part = { filename: f.filename, subfolder: f.subfolder || "", type: f.type || "temp" };
node._pixInpaintSourceURL = buildSourceURL(part, true);
if (!node.properties) node.properties = {};
node.properties.pixInpaintSource = part;
refreshSourcePreview();
};
api.addEventListener("executed", onExec);
// wrap (don't clobber) any existing handler from the prototype / another ext;
// forward all args, then run our image-input source-preview logic.
const origConnChange = node.onConnectionsChange;
node.onConnectionsChange = function (type, slotIndex, connected) {
const r = origConnChange?.apply(this, arguments);
if (type === LiteGraph.INPUT && node.inputs?.[slotIndex]?.name === "image" && !isGraphLoading()) {
node._pixInpaintSourceURL = null;
if (node.properties) delete node.properties.pixInpaintSource;
if (connected) refreshSourcePreview();
else restoreNodePreview(parts, "{}", node);
}
return r;
};
const origRemoved = node.onRemoved;
node.onRemoved = () => {
try { if (node._pixInpaintEditor?.el?.overlay?.isConnected) node._pixInpaintEditor._close(); } catch (e) {}
try { parts?.resizeObserver?.disconnect(); } catch (e) {}
origRemoved?.call(node);
try { api.removeEventListener("executed", onExec); } catch {}
};
},
});
-435
View File
@@ -1,435 +0,0 @@
// ============================================================
// Inpaint Crop Pixaroma — mask painting (brush, erase, cursor, undo, keys)
// Mask canvas = white RGB + per-pixel alpha (strength) on a transparent bg, so
// the red overlay tints via destination-in and the export bakes soft edges by
// compositing over black. Self-contained - no dependency on js/paint/.
// ============================================================
import { InpaintCropEditor, BRAND } from "./core.mjs";
const proto = InpaintCropEditor.prototype;
const MAX_UNDO = 15;
proto._ensureMaskCanvas = function () {
if (this._mask && this._mask.width === this.imgW && this._mask.height === this.imgH) return;
const m = document.createElement("canvas");
m.width = this.imgW; m.height = this.imgH;
this._mask = m;
this._mctx = m.getContext("2d");
// per-stroke buffer: stamps accumulate here, then bake onto the mask with a
// blur-on-bake soft edge (so overlapping stamps can't stack into a hard rim).
const sc = document.createElement("canvas");
sc.width = this.imgW; sc.height = this.imgH;
this._stroke = sc;
this._sctx = sc.getContext("2d");
this._strokeHasContent = false;
this._undo = []; this._redo = [];
this.layout?.setUndoState({ canUndo: false, canRedo: false });
};
// ── pointer ──────────────────────────────────────────────────────────────
proto._displayPos = function (e) {
const r = this.el.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
proto._bindMouse = function (cvs) {
let winMove = null, winUp = null;
const detach = () => {
if (winMove) window.removeEventListener("mousemove", winMove), (winMove = null);
if (winUp) window.removeEventListener("mouseup", winUp), (winUp = null);
};
// expose so a close mid-stroke (editor torn down before mouseup) detaches the
// window listeners instead of leaking them for the page lifetime.
this._detachStroke = detach;
let panMove = null, panUp = null;
const detachPan = () => {
if (panMove) window.removeEventListener("mousemove", panMove), (panMove = null);
if (panUp) window.removeEventListener("mouseup", panUp), (panUp = null);
};
this._detachPan = detachPan;
cvs.addEventListener("mousedown", (e) => {
// PAN the zoomed view: middle button, or Space held + left button.
if (e.button === 1 || (e.button === 0 && this._spaceHeld)) {
e.preventDefault();
const x0 = e.clientX, y0 = e.clientY, px0 = this._panX, py0 = this._panY;
this._panning = true;
cvs.style.cursor = "grabbing";
if (this._dispW) this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); // hide the brush ring while panning
this._lastCursorPos = null;
detachPan();
panMove = (ev) => {
this._panX = px0 + (ev.clientX - x0);
this._panY = py0 + (ev.clientY - y0);
this._clampPan();
this._requestRedraw();
};
panUp = () => { this._panning = false; detachPan(); cvs.style.cursor = this._spaceHeld ? "grab" : "none"; };
window.addEventListener("mousemove", panMove);
window.addEventListener("mouseup", panUp);
return;
}
if (!this.img || e.button !== 0) return;
e.preventDefault();
this._beginStroke(e);
detach();
winMove = (ev) => this._strokeMove(ev);
winUp = () => { this._endStroke(); detach(); };
window.addEventListener("mousemove", winMove);
window.addEventListener("mouseup", winUp);
});
cvs.addEventListener("mousemove", (e) => { if (!this._painting && !this._panning && !this._spaceHeld) this._drawCursor(this._displayPos(e)); });
cvs.addEventListener("mouseleave", () => { if (!this._painting && this._dispW) { this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); this._lastCursorPos = null; } });
// wheel = zoom toward the cursor (brush size moved to [ / ] + the Size slider)
cvs.addEventListener("wheel", (e) => {
e.preventDefault();
const p = this._displayPos(e);
this._applyZoom(e.deltaY < 0 ? 1.15 : 1 / 1.15, p.x, p.y);
}, { passive: false });
};
proto._effectiveTool = function () {
// hold X to temporarily flip to erase
return this._xHeld ? (this.tool === "erase" ? "add" : "erase") : this.tool;
};
proto._beginStroke = function (e) {
this._pushUndo();
this._painting = true;
this._lastPt = null;
// lock the add/erase choice for the whole stroke (X can be released mid-drag)
this._strokeTool = this._effectiveTool();
this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
this._strokeMove(e);
};
proto._strokeMove = function (e) {
const p = this._displayPos(e);
const s = this._scale || 1;
const sx = (p.x - this._panX) / s, sy = (p.y - this._panY) / s; // pan/zoom -> source px
if (this._lastPt) this._stampLine(this._lastPt.x, this._lastPt.y, sx, sy);
else this._stampDab(sx, sy);
this._lastPt = { x: sx, y: sy };
this._requestRedraw(); // coalesced (one redraw per frame) - was a sync _draw() per move = the lag
this._drawCursor(p); // cursor stays immediate (cheap, separate canvas)
};
// soft-edge feather width (source px) baked onto the stroke. 0 = crisp.
proto._bakeBlurPx = function () {
const rSrc = (this.brushSize / 2) / (this._scale || 1);
return Math.round(this.softness * rSrc);
};
// composite the (blurred) stroke buffer onto a target ctx using the locked tool
proto._compositeStroke = function (ctx) {
const blur = this._bakeBlurPx();
ctx.save();
if (blur > 0) ctx.filter = `blur(${blur}px)`;
ctx.globalCompositeOperation = this._strokeTool === "erase" ? "destination-out" : "source-over";
ctx.drawImage(this._stroke, 0, 0);
ctx.restore();
};
proto._endStroke = function () {
if (this._drawRaf) { cancelAnimationFrame(this._drawRaf); this._drawRaf = null; }
this._painting = false;
this._lastPt = null;
if (this._strokeHasContent) {
this._compositeStroke(this._mctx); // bake into the mask
this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
}
this._rescanBBox();
this._recomputeRegion();
this._draw();
};
// Drop the in-progress stroke buffer so it is NOT re-baked on the eventual
// mouseup. Used by undo/redo mid-drag (otherwise the undone stroke would
// re-composite when the user releases the mouse).
proto._abandonStroke = function () {
if (this._sctx) this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
this._lastPt = null;
};
// the mask as it should DISPLAY right now (mask + the live stroke during a drag)
proto._effectiveMaskCanvas = function () {
if (!this._painting || !this._strokeHasContent) return this._mask;
if (!this._effMask) this._effMask = document.createElement("canvas");
const c = this._effMask;
if (c.width !== this.imgW || c.height !== this.imgH) { c.width = this.imgW; c.height = this.imgH; }
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, this.imgW, this.imgH);
ctx.drawImage(this._mask, 0, 0);
this._compositeStroke(ctx);
return c;
};
// ── brush stamps: a crisp anti-aliased disc on the STROKE buffer. The soft edge
// is the blur-on-bake (_bakeBlurPx), so overlapping stamps never stack hard.
proto._stampDab = function (sx, sy) {
const ctx = this._sctx;
const r = Math.max(0.5, (this.brushSize / 2) / (this._scale || 1));
const g = ctx.createRadialGradient(sx, sy, Math.max(0, r - 1.2), sx, sy, r);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(1, "rgba(255,255,255,0)");
// "lighten" = max-blend, so overlapping stamps in one stroke can't stack alpha
// (no slow-stroke darkening); the soft edge is the blur-on-bake at stroke end.
ctx.globalCompositeOperation = "lighten";
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = "source-over";
this._strokeHasContent = true;
};
proto._stampLine = function (x0, y0, x1, y1) {
const r = Math.max(0.5, (this.brushSize / 2) / (this._scale || 1));
const dx = x1 - x0, dy = y1 - y0;
const dist = Math.hypot(dx, dy);
const step = Math.max(1, r * 0.12); // tight enough to avoid gaps on fast, zoomed-out strokes
const n = Math.ceil(dist / step);
for (let i = 0; i <= n; i++) {
const t = n === 0 ? 0 : i / n;
this._stampDab(x0 + dx * t, y0 + dy * t);
}
};
// ── cursor ring ────────────────────────────────────────────────────────────
proto._drawCursor = function (p) {
const ctx = this.el.curCtx;
if (!ctx || !this._dispW) return; // canvas not fitted yet (no image loaded)
this._lastCursorPos = p;
ctx.clearRect(0, 0, this._dispW, this._dispH);
const r = this.brushSize / 2;
const erase = this._effectiveTool() === "erase";
ctx.lineWidth = 1.5;
ctx.strokeStyle = erase ? "#ffffff" : BRAND;
if (erase) ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = erase ? "#ffffff" : BRAND;
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
};
// ── mask ops ────────────────────────────────────────────────────────────────
proto._clearMask = function () {
if (!this._mask) return;
this._pushUndo();
this._mctx.clearRect(0, 0, this.imgW, this.imgH);
this._endStroke();
};
proto._invertMask = function () {
if (!this._mask) return;
this._pushUndo();
const id = this._mctx.getImageData(0, 0, this.imgW, this.imgH);
const d = id.data;
for (let i = 0; i < d.length; i += 4) { d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = 255 - d[i + 3]; }
this._mctx.putImageData(id, 0, 0);
this._endStroke();
};
proto._loadMaskFromURL = function (url) {
const token = this._loadToken; // tie to the image-load generation that requested it
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// bail if a newer open/load superseded this, OR the user already started working
// (painting / has undo history / a painted bbox) - don't clobber fresh strokes.
if (token !== this._loadToken) return;
if (this._painting || this._undo?.length || this._bbox) {
this._setStatus("Kept your new strokes - the saved mask wasn't reloaded.");
return;
}
this._ensureMaskCanvas();
const tmp = document.createElement("canvas");
tmp.width = this.imgW; tmp.height = this.imgH;
const tctx = tmp.getContext("2d");
tctx.drawImage(img, 0, 0, this.imgW, this.imgH);
const id = tctx.getImageData(0, 0, this.imgW, this.imgH);
const d = id.data;
// grayscale (white = masked) -> white RGB + alpha = luminance
for (let i = 0; i < d.length; i += 4) {
const a = d[i]; // grayscale, r==g==b
d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = a;
}
this._mctx.putImageData(id, 0, 0);
this._undo = []; this._redo = [];
this.layout?.setUndoState({ canUndo: false, canRedo: false });
this._rescanBBox();
this._recomputeRegion();
this._draw();
};
img.onerror = () => {};
img.src = url;
};
// composite the mask over black -> grayscale dataURL (white = masked)
proto._exportMaskDataURL = function () {
const out = document.createElement("canvas");
out.width = this.imgW; out.height = this.imgH;
const o = out.getContext("2d");
o.fillStyle = "#000"; o.fillRect(0, 0, this.imgW, this.imgH);
// include any in-progress stroke so Ctrl+S mid-drag saves what's on screen
const m = (this._effectiveMaskCanvas && this._effectiveMaskCanvas()) || this._mask;
if (m) o.drawImage(m, 0, 0);
return out.toDataURL("image/png");
};
// ── undo / redo ──────────────────────────────────────────────────────────
proto._pushUndo = function () {
if (!this._mask) return;
try { this._undo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH)); } catch (e) { return; }
if (this._undo.length > MAX_UNDO) this._undo.shift();
this._redo = [];
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: false });
};
proto._doUndo = function () {
if (!this._mask || !this._undo.length) return;
this._redo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH));
this._mctx.putImageData(this._undo.pop(), 0, 0);
this._abandonStroke();
this._rescanBBox(); this._recomputeRegion(); this._draw();
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: this._redo.length > 0 });
};
proto._doRedo = function () {
if (!this._mask || !this._redo.length) return;
this._undo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH));
this._mctx.putImageData(this._redo.pop(), 0, 0);
this._abandonStroke();
this._rescanBBox(); this._recomputeRegion(); this._draw();
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: this._redo.length > 0 });
};
// ── keys ────────────────────────────────────────────────────────────────────
proto._bindKeys = function () {
this._keyHandler = (e) => {
const ae = document.activeElement;
if ((ae?.tagName === "INPUT" || ae?.tagName === "TEXTAREA" || ae?.tagName === "SELECT") && !ae?.dataset?.pixaromaTrap) return;
const key = e.key.toLowerCase();
const ctrl = e.ctrlKey || e.metaKey;
if (key === "escape") { e.preventDefault(); e.stopImmediatePropagation(); this._close(); return; }
if (ctrl && key === "s") { e.preventDefault(); this._save(); return; }
if (ctrl && key === "z" && !e.shiftKey) { e.preventDefault(); this._doUndo(); return; }
if (ctrl && (key === "y" || (key === "z" && e.shiftKey))) { e.preventDefault(); this._doRedo(); return; }
if (ctrl) return;
if (e.code === "Space") { // hold Space to pan the zoomed view (drag)
e.preventDefault();
if (!this._spaceHeld) {
this._spaceHeld = true;
if (this.el.canvas) this.el.canvas.style.cursor = "grab";
if (this._dispW) this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); // hide the brush ring
this._lastCursorPos = null;
}
return;
}
if (key === "b") { e.preventDefault(); this._setTool("add"); this._toolGrid?.setActive?.("add"); return; }
if (key === "e") { e.preventDefault(); this._setTool("erase"); this._toolGrid?.setActive?.("erase"); return; }
if (key === "h") { e.preventDefault(); this._toggleMaskVisible(); return; }
if (key === "x") { this._xHeld = true; return; }
if (key === "[" || key === "]") {
e.preventDefault();
const dir = key === "[" ? -1 : 1;
// ignore OS auto-repeat (laggy) - drive a smooth ramp ourselves while held
if (!e.repeat) { this._adjustBrush(dir * 4); this._startBrushHold(dir); }
return;
}
};
this._keyUpHandler = (e) => {
const k = e.key.toLowerCase();
if (k === "x") this._xHeld = false;
if (k === "[" || k === "]") this._stopBrushHold();
if (e.code === "Space") { this._spaceHeld = false; if (this.el.canvas && !this._panning) this.el.canvas.style.cursor = "none"; }
};
window.addEventListener("keydown", this._keyHandler, { capture: true });
window.addEventListener("keyup", this._keyUpHandler, { capture: true });
};
// brush resize: instant on tap, smooth-accelerating while held (no OS-repeat lag)
proto._adjustBrush = function (delta) {
this.brushSize = Math.max(2, Math.min(300, this.brushSize + delta));
this.el.sizeSlider?.setValue(this.brushSize);
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
};
proto._startBrushHold = function (dir) {
this._stopBrushHold();
this._holdDir = dir;
let rate = 0.5, accum = 0;
const tick = () => {
if (!this.el.overlay?.isConnected) { this._stopBrushHold(); return; }
rate = Math.min(3.4, rate + 0.12);
accum += this._holdDir * rate;
const step = Math.trunc(accum);
if (step !== 0) { accum -= step; this._adjustBrush(step); }
this._holdRaf = requestAnimationFrame(tick);
};
this._holdRaf = requestAnimationFrame(tick);
};
proto._stopBrushHold = function () {
if (this._holdRaf) { cancelAnimationFrame(this._holdRaf); this._holdRaf = null; }
};
// ── paste / drag-drop a source image INTO the open editor ──────────────────
// Loads the dropped/pasted image as the new source (same as the Load Image
// button: _loadImageFromDataURL + onLoadImage to disconnect the upstream wire).
// All three listen at window-capture, gated on the overlay being live, and
// preventDefault + stop so ComfyUI doesn't spawn its own Load Image node behind us.
proto._bindDropPaste = function () {
const alive = () => !!this.el.overlay?.isConnected;
const loadFile = (file) => {
if (!file || !file.type?.startsWith("image/")) return false;
const reader = new FileReader();
reader.onload = (ev) => { this._loadImageFromDataURL(ev.target.result); this.onLoadImage?.(); };
reader.readAsDataURL(file);
return true;
};
this._pasteHandler = (e) => {
if (!alive()) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const it = Array.from(e.clipboardData?.items || []).find((x) => x.type?.startsWith("image/"));
if (!it) return;
const blob = it.getAsFile();
if (!blob) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(blob);
};
this._dragOverHandler = (e) => {
if (!alive()) return;
if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); e.stopImmediatePropagation(); }
};
this._dropHandler = (e) => {
if (!alive()) return;
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type?.startsWith("image/")) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(file);
};
window.addEventListener("paste", this._pasteHandler, true);
window.addEventListener("dragover", this._dragOverHandler, true);
window.addEventListener("drop", this._dropHandler, true);
};
proto._unbindDropPaste = function () {
if (this._pasteHandler) window.removeEventListener("paste", this._pasteHandler, true);
if (this._dragOverHandler) window.removeEventListener("dragover", this._dragOverHandler, true);
if (this._dropHandler) window.removeEventListener("drop", this._dropHandler, true);
this._pasteHandler = this._dragOverHandler = this._dropHandler = null;
};
proto._unbindKeys = function () {
this._stopBrushHold();
if (this._drawRaf) { cancelAnimationFrame(this._drawRaf); this._drawRaf = null; }
this._detachStroke?.(); // detach any live stroke window-listeners (close mid-drag)
this._detachPan?.(); // detach any live pan window-listeners
this._unbindDropPaste();
if (this._keyHandler) window.removeEventListener("keydown", this._keyHandler, { capture: true });
if (this._keyUpHandler) window.removeEventListener("keyup", this._keyUpHandler, { capture: true });
};
-365
View File
@@ -1,365 +0,0 @@
// ============================================================
// Inpaint Crop Pixaroma — canvas render + region preview + save
// ============================================================
import { InpaintCropEditor, BRAND, InpaintAPI } from "./core.mjs";
import { computeRegion, maskBBoxFromImageData, growBBox, seamAlphaFromAlpha } from "./geometry.mjs";
const proto = InpaintCropEditor.prototype;
// ── image load ──────────────────────────────────────────────────────────────
proto._loadImageFromURL = function (url, onDone) {
const token = (this._loadToken = (this._loadToken || 0) + 1); // newest load wins
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
if (token !== this._loadToken) return; // a newer open/load superseded this one
this.img = img;
this.imgW = img.naturalWidth;
this.imgH = img.naturalHeight;
this._ensureMaskCanvas();
this._fitCanvas();
this._rescanBBox();
this._recomputeRegion();
this._draw();
this._setStatus(`Loaded: ${this.imgW}×${this.imgH}`);
onDone?.();
};
img.onerror = () => this._setStatus("Failed to load the source image.");
img.src = url;
};
proto._loadImageFromDataURL = function (dataURL) {
const token = (this._loadToken = (this._loadToken || 0) + 1); // newest load wins
const img = new Image();
img.onload = () => {
if (token !== this._loadToken) return; // a newer open/load superseded this one
this.img = img;
this.imgW = img.naturalWidth;
this.imgH = img.naturalHeight;
this._pendingSrcDataURL = dataURL;
this._maskPath = "";
this._mask = null;
this._ensureMaskCanvas();
this._fitCanvas();
this._rescanBBox();
this._recomputeRegion();
this._draw();
this._setStatus(`Loaded: ${this.imgW}×${this.imgH}`);
};
img.src = dataURL;
};
// ── fit + draw ────────────────────────────────────────────────────────────
proto._fitCanvas = function () {
if (!this.img) return;
const ws = this.el.workspace, pad = 40;
const maxW = ws.clientWidth - pad * 2, maxH = ws.clientHeight - pad * 2;
if (maxW <= 0 || maxH <= 0) return;
const asp = this.imgW / this.imgH;
let dw, dh;
if (maxW / maxH > asp) { dh = maxH; dw = dh * asp; } else { dw = maxW; dh = dw / asp; }
this._baseScale = dw / this.imgW;
this._zoom = 1; this._panX = 0; this._panY = 0; // (re)fit resets the view
this._scale = this._baseScale;
this._dispW = Math.round(dw); this._dispH = Math.round(dh);
// backing store at devicePixelRatio so the painting canvas stays crisp on
// high-DPI screens; all drawing is in logical px (the ctx is scaled by dpr).
const dpr = Math.max(1, window.devicePixelRatio || 1);
for (const c of [this.el.canvas, this.el.cursor]) {
c.width = Math.round(this._dispW * dpr); c.height = Math.round(this._dispH * dpr);
c.style.width = this._dispW + "px"; c.style.height = this._dispH + "px";
}
this.el.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.el.curCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.el.canvas.style.cursor = "none";
};
// keep the image covering the viewport (no empty gaps); at zoom 1 this forces pan 0
proto._clampPan = function () {
const iw = this.imgW * this._scale, ih = this.imgH * this._scale;
const minX = Math.min(0, this._dispW - iw), minY = Math.min(0, this._dispH - ih);
this._panX = Math.min(0, Math.max(minX, this._panX));
this._panY = Math.min(0, Math.max(minY, this._panY));
};
// zoom by `factor` keeping the source point under (ancX,ancY display px) fixed
proto._applyZoom = function (factor, ancX, ancY) {
const nz = Math.max(1, Math.min(8, this._zoom * factor));
if (nz === this._zoom) return;
const sx = (ancX - this._panX) / this._scale; // source point under the cursor
const sy = (ancY - this._panY) / this._scale;
this._zoom = nz;
this._scale = this._baseScale * nz;
this._panX = ancX - sx * this._scale;
this._panY = ancY - sy * this._scale;
this._clampPan();
this._draw();
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
};
// Feathered seam alpha at display resolution: the outward signed-distance smoothstep
// of the mask over (blend * scale) px - the canvas mirror of the Python _blur_alpha
// SCIPY path, so the editor preview MATCHES the stitched seam (preview == result).
// Computed on a downscaled buffer (the seam is soft, so a chamfer DT + upscale is
// invisible) to stay fast on every _draw - brush strokes + softness drags. _draw is
// NOT called on idle mouse-move (the cursor draws separately), so no cache is needed.
proto._seamAlphaCanvas = function () {
const dpr = Math.max(1, window.devicePixelRatio || 1); // DPR-backed for HiDPI crispness
const W = Math.round(this._dispW * dpr), H = Math.round(this._dispH * dpr);
if (!this._seamCv) this._seamCv = document.createElement("canvas");
const c = this._seamCv;
if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
const ctx = c.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
const src = this._effectiveMaskCanvas();
if (!src) return c;
// WHOLE CROP mode: the stitch feathers the whole crop RECTANGLE (Python
// _feather_alpha) - an inward linear ramp from the region edges - NOT the mask
// edge. Show that instead of the mask feather so the preview matches the result.
if (this.params.blend_mode === "whole_crop" && this._region) {
// _baseScale (NOT _scale): the seam canvas is fit-res then drawn at the zoomed
// view rect, which applies the zoom - using _scale would double-count it.
const blendD = Math.max(0, (this.params.blend ?? 16) * (this._baseScale || 1) * dpr);
const CAP = 480;
const sc = Math.min(1, CAP / Math.max(W, H));
const bw = Math.max(1, Math.round(W * sc)), bh = Math.max(1, Math.round(H * sc));
const r = this._region;
const rx = r.rx / this.imgW * bw, ry = r.ry / this.imgH * bh;
const rw = Math.max(1, r.rw / this.imgW * bw), rh = Math.max(1, r.rh / this.imgH * bh);
const kBuf = blendD * (bw / W);
const kEff = Math.min(kBuf, Math.max(0.5, Math.min(rw, rh) / 2 - 0.5)); // keep interior opaque
const b = this._seamBuf || (this._seamBuf = document.createElement("canvas"));
if (b.width !== bw || b.height !== bh) { b.width = bw; b.height = bh; }
const bctx = b.getContext("2d");
bctx.setTransform(1, 0, 0, 1, 0, 0);
const id = bctx.createImageData(bw, bh);
const d = id.data;
for (let y = 0; y < bh; y++) {
for (let x = 0; x < bw; x++) {
let a = 0;
if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) {
const dist = Math.min(x - rx, rx + rw - 1 - x, y - ry, ry + rh - 1 - y);
a = kEff <= 0 ? 1 : Math.max(0, Math.min(1, dist / kEff));
}
const p = (y * bw + x) * 4;
d[p] = 255; d[p + 1] = 255; d[p + 2] = 255; d[p + 3] = Math.round(a * 255);
}
}
bctx.putImageData(id, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(b, 0, 0, bw, bh, 0, 0, W, H);
return c;
}
// _baseScale (NOT _scale): fit-res seam canvas, zoom applied later at the view draw.
const blendDisp = Math.max(0, (this.params.blend ?? 16) * (this._baseScale || 1) * dpr);
if (blendDisp < 0.5) { ctx.drawImage(src, 0, 0, W, H); return c; } // crisp seam
// draw the mask into a small buffer, distance-transform its alpha, write the
// feathered alpha back, then upscale to the display-res seam canvas.
const CAP = 480;
const sc = Math.min(1, CAP / Math.max(W, H));
const bw = Math.max(1, Math.round(W * sc)), bh = Math.max(1, Math.round(H * sc));
const kBuf = blendDisp * (bw / W); // feather width in buffer px
const b = this._seamBuf || (this._seamBuf = document.createElement("canvas"));
if (b.width !== bw || b.height !== bh) { b.width = bw; b.height = bh; }
const bctx = b.getContext("2d");
bctx.setTransform(1, 0, 0, 1, 0, 0);
bctx.clearRect(0, 0, bw, bh);
bctx.drawImage(src, 0, 0, bw, bh);
const id = bctx.getImageData(0, 0, bw, bh);
const alpha = seamAlphaFromAlpha(id.data, bw, bh, kBuf);
const dpx = id.data;
for (let i = 0, p = 0; i < alpha.length; i++, p += 4) {
dpx[p] = 255; dpx[p + 1] = 255; dpx[p + 2] = 255;
dpx[p + 3] = Math.round(alpha[i] * 255);
}
bctx.putImageData(id, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(b, 0, 0, bw, bh, 0, 0, W, H); // upscale the soft alpha
return c;
};
// Coalesce high-frequency redraws (brush strokes) to one per animation frame, so
// fast mouse-moves on a big image don't pile up synchronous full redraws (the lag).
proto._requestRedraw = function () {
if (this._drawRaf) return;
this._drawRaf = requestAnimationFrame(() => { this._drawRaf = null; this._draw(); });
};
proto._draw = function () {
if (!this.img || !this._dispW) return; // _fitCanvas sets _dispW before any draw
const ctx = this.el.ctx, s = this._scale;
const W = this._dispW, H = this._dispH;
ctx.clearRect(0, 0, W, H);
// pan/zoom: the image is drawn at (_panX,_panY) scaled by _scale (= base*zoom);
// at fit (zoom 1) this is (0,0,W,H). Everything below offsets by the same pan.
ctx.drawImage(this.img, this._panX, this._panY, this.imgW * s, this.imgH * s);
// seam preview: tint the FEATHERED seam alpha (Softness) in the chosen color,
// clipped to the crop region. DPR-backed (crisp on HiDPI). Mirrors the Python
// _blur_alpha SCIPY smoothstep, so the tint matches the stitched seam.
if (this.maskVisible && this._mask) {
if (!this._tint) this._tint = document.createElement("canvas");
const t = this._tint;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const tw = Math.round(W * dpr), th = Math.round(H * dpr);
if (t.width !== tw || t.height !== th) { t.width = tw; t.height = th; }
const tc = t.getContext("2d");
tc.setTransform(1, 0, 0, 1, 0, 0);
tc.clearRect(0, 0, tw, th);
// Always show the FEATHERED seam preview (even mid-stroke) so the mask doesn't
// flash crisp-while-painting then blurred-on-release. The rAF coalescing
// (_requestRedraw) keeps it smooth; if a very large image ever lags during a
// stroke, throttle _seamAlphaCanvas while _painting rather than skipping it.
const alphaSrc = this._seamAlphaCanvas();
// draw the mask at the same pan/zoom rect as the image (backing px) so it aligns
tc.drawImage(alphaSrc, this._panX * dpr, this._panY * dpr, this.imgW * s * dpr, this.imgH * s * dpr);
tc.globalCompositeOperation = "source-in";
tc.fillStyle = this.previewColor || "#f6303a";
tc.fillRect(0, 0, tw, th);
tc.globalCompositeOperation = "source-over";
ctx.save();
// Clip the seam tint to the crop box - BUT NOT while actively painting: the crop
// box only grows on stroke end, so clipping mid-stroke hides any mask you paint
// beyond the current box ("interrupted by the context margin" until you release).
// During a stroke show the full mask everywhere; the clipped seam returns on end.
if (this._region && !this._painting) {
const r = this._region;
ctx.beginPath();
ctx.rect(r.rx * s + this._panX, r.ry * s + this._panY, r.rw * s, r.rh * s);
ctx.clip();
}
ctx.globalAlpha = this.maskOpacity;
ctx.drawImage(t, 0, 0, W, H); // backing-res tint at logical size = crisp
ctx.globalAlpha = 1;
ctx.restore();
}
// tight painted bbox (white dashed)
if (this._bbox) {
const [x0, y0, x1, y1] = this._bbox;
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.strokeRect(x0 * s + this._panX + 0.5, y0 * s + this._panY + 0.5, (x1 - x0) * s, (y1 - y0) * s);
ctx.setLineDash([]);
}
// crop region (orange dashed) + handles + size badge
if (this._region) {
const r = this._region;
const rx = r.rx * s + this._panX, ry = r.ry * s + this._panY, rw = r.rw * s, rh = r.rh * s;
const boxColor = this._cropBoxColor || BRAND; // white when the orange tint is active
ctx.strokeStyle = boxColor; ctx.lineWidth = 2; ctx.setLineDash([7, 5]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
ctx.fillStyle = boxColor;
for (const [hx, hy] of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]])
ctx.fillRect(hx - 4, hy - 4, 8, 8);
const label = `${r.out_w} × ${r.out_h}`;
ctx.font = "bold 12px 'Segoe UI', sans-serif";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
const tw = ctx.measureText(label).width + 12;
const by = Math.max(10, ry - 11);
ctx.fillStyle = BRAND;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(rx, by - 9, tw, 18, 3); ctx.fill(); }
else ctx.fillRect(rx, by - 9, tw, 18);
ctx.fillStyle = "#fff";
ctx.fillText(label, rx + 6, by);
}
};
// ── geometry preview ──────────────────────────────────────────────────────
// Scan the mask for its painted bounding box. This is the expensive part
// (full-image getImageData + per-pixel scan), so it only runs when the mask
// actually changes (stroke end / clear / invert / undo / load), NOT on every
// context-slider tick.
proto._rescanBBox = function () {
if (!this.img || !this._mask) { this._bbox = null; return; }
const id = this._mctx.getImageData(0, 0, this.imgW, this.imgH);
this._bbox = maskBBoxFromImageData(id.data, this.imgW, this.imgH);
};
// Compute the crop region from the CACHED bbox + current knobs. Cheap, so it is
// safe to call on every context-slider tick without re-scanning the mask.
proto._recomputeRegion = function () {
if (!this.img) { this._region = null; this._bbox = null; return; }
const grow = this.params.mask_grow != null ? this.params.mask_grow : 0;
const grown = growBBox(this._bbox, grow, this.imgW, this.imgH);
// force mode is a square of `target` (the node has a single Target knob), so
// mirror Python's _params: target_w = target_h = target, or the preview would
// wrongly show the default 1024 in force mode.
const p = { ...this.params, target_w: this.params.target, target_h: this.params.target };
this._region = computeRegion(grown, this.imgW, this.imgH, p);
this._updateInfo(this._bbox);
};
proto._updateInfo = function (bbox) {
if (!this._infoBlock) return;
if (!bbox) { this._infoBlock.setHTML("Paint a mask to begin.<br>The whole image will be used until you do."); return; }
const r = this._region;
this._infoBlock.setHTML(
`<b>Crop out:</b> ${r.out_w} × ${r.out_h}<br>` +
`<b>Region:</b> ${r.rw} × ${r.rh}px<br>` +
`<b>Context:</b> ${this.params.context_px ?? 0}px`,
);
};
// ── save ────────────────────────────────────────────────────────────────────
proto._buildPreview = function () {
if (!this.img || !this._region) return null;
const r = this._region;
const c = document.createElement("canvas");
c.width = r.out_w; c.height = r.out_h;
c.getContext("2d").drawImage(this.img, r.rx, r.ry, r.rw, r.rh, 0, 0, r.out_w, r.out_h);
return c.toDataURL("image/png");
};
proto._save = async function () {
if (!this.img) { this._setStatus("No image to save"); return; }
this.layout.setSaving();
try {
if (this._pendingSrcDataURL) {
try {
const d = await InpaintAPI.uploadSrc(this.projectId, this._pendingSrcDataURL);
this._srcPath = d.path || this._srcPath;
} catch (e) { console.warn("[InpaintCrop] src upload failed:", e); }
this._pendingSrcDataURL = null;
}
try {
const d = await InpaintAPI.saveMask(this.projectId, this._exportMaskDataURL());
this._maskPath = d.path || this._maskPath;
} catch (e) { console.warn("[InpaintCrop] mask save failed:", e); }
const state = {
project_id: this.projectId,
src_path: this._srcPath,
mask_path: this._maskPath,
doc_w: this.imgW,
doc_h: this.imgH,
blend_mode: this.params.blend_mode || "mask",
};
const extra = {
context_px: this.params.context_px,
mask_grow: this.params.mask_grow,
mask_blur: this.params.mask_blur,
softness: this.params.blend,
size_mode: this.params.size_mode,
target: this.params.target,
multiple: this.params.multiple,
blend_mode: this.params.blend_mode, // mirror the editor pill back to the node widget
};
const preview = this._buildPreview();
if (this.onSave) this.onSave(JSON.stringify(state), extra, preview);
if (this._diskSavePending) {
this._diskSavePending = false;
if (this.onSaveToDisk && preview) this.onSaveToDisk(preview);
}
this.layout.setSaved();
} catch (err) {
console.error("[InpaintCrop] Save error:", err);
this.layout.setSaveError("Save failed: " + err.message);
}
};
-48
View File
@@ -1,48 +0,0 @@
// Load Images from Folder Pixaroma — backend fetch helpers.
// List image files in a folder. Returns {ok, folder, files:[{file,name,size,mtime}], message?}.
export async function listFolder(folder, recursive) {
try {
const url =
`/pixaroma/api/load_images_folder/list?path=${encodeURIComponent(folder)}` +
`&recursive=${recursive ? 1 : 0}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e), files: [] };
}
}
// Thumbnail URL for one image (served by the backend, scaled to <=192px).
// mtime is folded in as a cache key so an edited file refreshes.
export function thumbURL(folder, rel, mtime) {
return (
`/pixaroma/api/load_images_folder/thumb?path=${encodeURIComponent(folder)}` +
`&file=${encodeURIComponent(rel)}&mt=${Math.floor(mtime || 0)}`
);
}
// Browse the server filesystem for the in-app folder picker (fallback).
// Returns {ok, path, parent, dirs:[{name, path, images}], message?}.
export async function browseFolder(path) {
try {
const url = `/pixaroma/api/load_images_folder/browse?path=${encodeURIComponent(path || "")}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e), dirs: [] };
}
}
// Pop the native OS folder dialog on the ComfyUI host. Returns {ok:true, path},
// {ok:false, cancelled} (user closed it), or {ok:false, unavailable} (non-Windows
// / remote) so the caller can fall back to the in-app browser.
export async function pickNativeFolder(startPath) {
try {
const url = `/pixaroma/api/load_images_folder/pick_native?path=${encodeURIComponent(startPath || "")}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e) };
}
}
-441
View File
@@ -1,441 +0,0 @@
// Load Images from Folder Pixaroma — extension entry point.
// Mirrors Load Image Pixaroma's both-renderer DOM-widget + Pattern #9 state
// injection, adapted for a folder + multi-select gallery + list output.
import { app } from "/scripts/app.js";
import {
applyAdaptiveCanvasOnly,
isVueNodes,
installResizeFloor,
hideJsonWidget,
} from "../shared/index.mjs";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
COMFY_CLASS,
HIDDEN_INPUT_NAME,
readState,
writeState,
} from "./state.mjs";
import { listFolder, pickNativeFolder } from "./api.mjs";
import {
injectCSS,
buildRoot,
openPickGallery,
openBrowsePopup,
openMiniMenu,
} from "./ui.mjs";
import { buildModePanel, injectResizePanelCSS } from "../shared/resize_panel.mjs";
const MIN_W = 280;
const DEFAULT_W = 370; // wide enough for the 6 resize quick-pick chips (e.g. MP) on one line
// Resize modes — values match the shared engine (_resize_helpers / buildModePanel).
const RESIZE_MODES = [
{ value: "off", label: "Off", hint: "full size" },
{ value: "max_mp", label: "Max megapixels", hint: "cap pixels" },
{ value: "longest_side", label: "Longest side", hint: "cap long edge" },
{ value: "scale_factor", label: "Scale by", hint: "× factor" },
{ value: "fit_inside", label: "Fit inside", hint: "W×H box" },
{ value: "cover", label: "Crop to fill", hint: "W×H exact" },
{ value: "match_ratio", label: "Match aspect ratio", hint: "crop / pad" },
{ value: "pad", label: "Pad", hint: "add borders" },
];
// ── node body height (sum visible children; NOT scrollHeight) ─────────────────
function measureContentHeight(root) {
if (!root) return 110;
let h = 0;
let n = 0;
for (const ch of root.children) {
const oh = ch.offsetHeight;
if (oh > 0) {
h += oh;
n++;
}
}
if (n === 0) return 110; // pre-attach placeholder
h += 16; // root vertical padding (8 + 8)
h += (n - 1) * 8; // row gaps
return Math.max(96, h);
}
// Refit the node height to its content (after the resize panel expands/collapses).
// Mirrors Image Resize's refit. Self-gates on isGraphLoading so it can be called
// freely without dirtying a saved workflow on load (Vue Compat #18). rAF so the
// freshly added/removed panel has laid out before we measure via computeSize.
function refit(node) {
if (!node._pixLifUI) return;
requestAnimationFrame(() => {
if (!node._pixLifUI || isGraphLoading()) return;
const sz = node.computeSize?.();
if (sz && Math.abs(node.size[1] - sz[1]) > 1) {
// setSize() is the official resize path; it sticks in both renderers
// (a bare node.size[1] write can be reverted across a renderer switch).
if (node.setSize) node.setSize([node.size[0], sz[1]]);
else node.size[1] = sz[1];
node.setDirtyCanvas?.(true, true);
}
});
}
// Normalize a folder path: backslash -> forward slash, trim, drop a trailing
// slash (but keep a bare drive root as "X:/"). Makes native-dialog returns
// (backslashes on Windows) compare cleanly against typed/pasted paths.
function normalizePath(p) {
if (!p) return "";
let s = String(p).trim().replace(/\\/g, "/").replace(/\/+$/, "");
if (/^[A-Za-z]:$/.test(s)) s += "/"; // "D:" -> "D:/"
return s;
}
function stripInputs(node) {
if (!node?.inputs || node.inputs.length === 0) return;
for (let i = node.inputs.length - 1; i >= 0; i--) {
if (node.inputs[i]?.link != null) {
try {
node.disconnectInput(i);
} catch {}
}
node.removeInput(i);
}
node.setDirtyCanvas?.(true, true);
}
// ── render the node body from state ──────────────────────────────────────────
function renderUI(node) {
const ui = node._pixLifUI;
if (!ui) return;
const state = readState(node);
if (document.activeElement !== ui.folderInput) {
ui.folderInput.value = state.folder || "";
}
const total = (node._pixLifFiles || []).length;
const sel = (state.selected || []).length;
ui.pickBtn.textContent = `Pick images · ${sel} / ${total}`;
ui.pickBtn.classList.toggle("empty", sel === 0);
ui.msgEl.textContent = node._pixLifListError || "";
node.setDirtyCanvas?.(true, true);
}
// ── (re)list the chosen folder + reconcile selection ─────────────────────────
// userAction = the call came from a real user gesture (folder change / subfolder
// toggle), as opposed to a workflow-load path (onNodeCreated / onConfigure).
async function refreshListing(node, userAction = false) {
// Bump FIRST so even the no-folder early-return invalidates any in-flight
// fetch (clear the folder while a listing is loading -> the stale response
// must not repopulate the file list).
const myReq = (node._pixLifListReq = (node._pixLifListReq || 0) + 1);
const state = readState(node);
if (!state.folder) {
node._pixLifFiles = [];
node._pixLifListError = "";
renderUI(node);
return;
}
const res = await listFolder(state.folder, state.recursive);
// a newer refresh superseded this one (e.g. paste + blur), or the node was
// removed while the fetch was in flight - drop this stale response.
if (node._pixLifListReq !== myReq || !node._pixLifUI) return;
if (res && res.ok) {
node._pixLifFiles = res.files || [];
node._pixLifListError = node._pixLifFiles.length
? ""
: "No images found in this folder.";
} else {
node._pixLifFiles = [];
node._pixLifListError = (res && res.message) || "Folder not found.";
}
// Drop selections that no longer exist on disk, but PERSIST that only on a
// genuine user action. The load path must never write serialized state
// (Vue Compat #18) - and isGraphLoading() alone is unreliable here because
// the await above usually outlasts its 300ms trailing window. Python tolerates
// missing files at run time (skips them), so not persisting on load is safe.
const present = new Set((node._pixLifFiles || []).map((f) => f.file));
const st = readState(node);
const before = (st.selected || []).length;
const kept = (st.selected || []).filter((f) => present.has(f));
if (kept.length !== before && userAction && !isGraphLoading()) {
st.selected = kept;
writeState(node, st);
}
renderUI(node);
}
async function setFolder(node, folder) {
const normalized = normalizePath(folder);
const st = readState(node);
const changed = (st.folder || "") !== normalized;
st.folder = normalized;
if (changed) st.selected = []; // new folder → drop stale selection
writeState(node, st);
await refreshListing(node, true);
}
// ── resize control (mode dropdown + the shared per-mode panel) ────────────────
function renderResize(node) {
const ui = node._pixLifUI;
if (!ui) return;
injectResizePanelCSS();
const state = readState(node);
const slot = ui.resizeSlot;
slot.innerHTML = "";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pix-lif-resizebtn";
const label = RESIZE_MODES.find((m) => m.value === state.mode)?.label || "Off";
btn.innerHTML = `<span class="lbl">Resize</span><span class="val">${label} ▾</span>`;
btn.title = "Resize each image as it loads (same options as Load Image)";
btn.addEventListener("click", () => {
openMiniMenu(btn, RESIZE_MODES, readState(node).mode, (val) => {
const st = readState(node);
st.mode = val;
writeState(node, st);
renderResize(node);
node.setDirtyCanvas?.(true, true);
});
});
slot.appendChild(btn);
if (state.mode !== "off") {
// The shared builders re-read node.properties[stateKey] on each edit and
// only override the resize key, so folder/selected are preserved.
const panel = buildModePanel(
state.mode,
node,
readState(node),
writeState,
() => {
node.setDirtyCanvas?.(true, true);
refit(node); // some panels (e.g. match-ratio crop/pad) change height
},
"loadImagesFolderState",
{ oneLine: true }
);
if (panel) slot.appendChild(panel);
}
node.setDirtyCanvas?.(true, true);
refit(node); // grow/shrink the node to fit the (possibly changed) panel
}
// ── per-node setup ───────────────────────────────────────────────────────────
function setupNode(node) {
injectCSS();
try {
hideJsonWidget(node.widgets, HIDDEN_INPUT_NAME);
} catch {}
stripInputs(node);
const ui = buildRoot();
node._pixLifUI = ui;
const widget = node.addDOMWidget("pixaroma_lif_ui", "custom", ui.root, {
getValue: () => null,
setValue: () => {},
// Coarse-round and NO getMaxHeight: a live getMaxHeight tied to a DOM
// measurement can creep node.size on load (Vue Compat #18). getMinHeight
// floors the node; refit() fits the exact height on user actions.
getMinHeight: () => Math.round(measureContentHeight(ui.root) / 4) * 4,
serialize: false,
});
applyAdaptiveCanvasOnly(widget);
node._pixLifWidget = widget;
if (isVueNodes()) {
widget.computeLayoutSize = () => ({
// coarse-round so sub-pixel/font jitter can't creep node.size on switch
minHeight: Math.round(measureContentHeight(ui.root) / 4) * 4,
minWidth: 1,
});
}
// events
ui.folderInput.addEventListener("keydown", (e) => {
// capture-phase canvas shortcuts need stopImmediate, not just stop
e.stopImmediatePropagation();
if (e.key === "Enter") {
e.preventDefault();
ui.folderInput.blur();
}
});
ui.folderInput.addEventListener("change", () =>
setFolder(node, ui.folderInput.value.trim())
);
// 'change' only fires on blur. A paste (Ctrl+V) should list immediately
// without needing to click away first; typing still commits on blur or when
// clicking Pick images.
ui.folderInput.addEventListener("paste", () => {
setTimeout(() => {
const v = ui.folderInput.value.trim();
if (normalizePath(v) !== (readState(node).folder || "")) setFolder(node, v);
}, 0);
});
ui.browseBtn.addEventListener("click", async () => {
const start = readState(node).folder || "";
// Try the native OS folder dialog first (real dialog, real path, no copying).
// Fall back to the in-app browser when it isn't available (non-Windows /
// remote / error). A cancel just does nothing.
const lbl = ui.browseLbl;
const prev = lbl ? lbl.textContent : "";
ui.browseBtn.disabled = true;
if (lbl) lbl.textContent = "Opening…";
let res;
try {
res = await pickNativeFolder(start);
} catch {
res = { ok: false };
}
ui.browseBtn.disabled = false;
if (lbl) lbl.textContent = prev || "Browse";
if (res && res.ok && res.path) {
await setFolder(node, res.path);
return;
}
if (res && res.cancelled) return; // user closed the native dialog
openBrowsePopup(node, ui.browseBtn, {
startPath: start,
onPick: (folder) => setFolder(node, folder),
});
});
ui.pickBtn.addEventListener("click", async () => {
// commit a typed-but-not-blurred path first
const typed = ui.folderInput.value.trim();
if (typed !== (readState(node).folder || "")) await setFolder(node, typed);
const st = readState(node);
if (!st.folder) {
ui.folderInput.focus();
node._pixLifListError = "Set a folder first (type, paste, or Browse).";
renderUI(node);
return;
}
if (!node._pixLifFiles) await refreshListing(node, true);
openPickGallery(node, ui.pickBtn, {
onChange: renderUI,
refreshListing: (n) => refreshListing(n, true),
});
});
try {
node._pixLifFloorOff = installResizeFloor(ui.root, measureContentHeight);
} catch {}
// resize control (reads current state; fresh node = "Off")
renderResize(node);
// default width on a FRESH drop only; on workflow load, leave the width for
// configure() to restore (so a user-resized node keeps its saved width and we
// don't dirty it — Vue Compat #18). LiteGraph's title-driven default is wider
// than MIN_W, so this set (not a < MIN_W guard) is what actually applies it.
if (!node.size) node.size = [DEFAULT_W, 200];
if (!isGraphLoading()) node.size[0] = DEFAULT_W;
// initial populate, deferred so configure()'s state lands first
queueMicrotask(() => refreshListing(node));
}
// ── Pattern #9: inject state into the hidden input at submit time ─────────────
function collectNodes(graph, out) {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (n?.comfyClass === COMFY_CLASS) out.push(n);
// subgraph nodes expose their inner graph under different names by version
const inner = n?.subgraph || n?.graph || n?._graph;
if (inner && inner !== graph) collectNodes(inner, out);
}
}
function matchNode(nodes, promptId) {
let n = nodes.find((x) => String(x.id) === String(promptId));
if (n) return n;
const tail = String(promptId).split(":").pop();
return nodes.find((x) => String(x.id) === tail) || null;
}
function injectState(result) {
const out = result?.output;
if (!out) return;
const lifNodes = [];
collectNodes(app.graph, lifNodes);
if (!lifNodes.length) return;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== COMFY_CLASS) continue;
const node = matchNode(lifNodes, id);
if (!node) continue;
if (!entry.inputs) entry.inputs = {};
entry.inputs[HIDDEN_INPUT_NAME] = JSON.stringify(readState(node));
}
}
function installGraphToPromptHook() {
if (app._pixLifGraphPatched) return;
app._pixLifGraphPatched = true;
const orig = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await orig(...args);
try {
injectState(result);
} catch (e) {
console.warn("[LoadImagesFolder] graphToPrompt inject failed", e);
}
return result;
};
}
app.registerExtension({
name: "Pixaroma.LoadImagesFolder",
setup() {
installGraphToPromptHook();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== COMFY_CLASS) return;
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated?.apply(this, arguments);
setupNode(this);
return r;
};
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = origConfigure?.apply(this, arguments);
stripInputs(this);
queueMicrotask(() => {
renderResize(this);
refreshListing(this);
});
return r;
};
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function () {
const r = origDraw?.apply(this, arguments);
// legacy-only min-width self-heal (Nodes 2.0 size lives in the Vue layout)
if (!isVueNodes() && this.size[0] < MIN_W) this.size[0] = MIN_W;
return r;
};
// Belt-and-braces with onDrawForeground (convention #7): onResize stops the
// drag at MIN_W so the frame is never drawn narrower than the content.
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function () {
if (!isVueNodes() && this.size[0] < MIN_W) this.size[0] = MIN_W;
return origResize?.apply(this, arguments);
};
nodeType.prototype.onConnectInput = function () {
return false;
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
try {
this._pixLifFloorOff?.();
} catch {}
// Close only THIS node's popups - deleting one node must not close another
// node's open gallery. Menus are transient, so a global sweep is fine.
this._pixLifGallery?._pixClose?.();
this._pixLifBrowsePop?._pixClose?.();
document.querySelectorAll(".pix-lif-menu").forEach((p) => p._pixClose?.());
return origRemoved?.apply(this, arguments);
};
},
});
-66
View File
@@ -1,66 +0,0 @@
// Load Images from Folder Pixaroma — shared state helpers.
// Imported by both index.js and ui.mjs (no circular import on index.js).
export const COMFY_CLASS = "PixaromaLoadImagesFolder";
export const STATE_PROP = "loadImagesFolderState";
export const HIDDEN_INPUT_NAME = "LoadImagesFolderState";
// Resize keys MUST match nodes/node_load_images_folder.py::DEFAULT_STATE and
// nodes/node_load_image.py::DEFAULT_STATE (shared _resize_frame engine).
export const DEFAULT_STATE = {
version: 1,
folder: "",
recursive: false,
sort: "name", // "name" | "date"
sort_dir: "asc", // "asc" | "desc"
selected: [], // file paths relative to folder, in display order
// ── resize keys ──
mode: "off",
max_mp: 1.0,
longest_side: 1024,
scale_factor: 1.0,
fit_w: 1024, fit_h: 1024,
cover_w: 1024, cover_h: 1024,
ratio_preset: "1:1",
ratio_w: 1, ratio_h: 1,
ratio_action: "crop",
pad_color: "#808080",
pad_top: 0, pad_bottom: 0, pad_left: 0, pad_right: 0,
crop_anchor: "center", crop_scale: true,
snap: 0,
resample: "auto",
allow_upscale: true,
};
export function readState(node) {
const v = node.properties?.[STATE_PROP];
if (typeof v === "string" && v) {
try {
return { ...DEFAULT_STATE, ...JSON.parse(v) };
} catch {
/* fall through to defaults */
}
}
return { ...DEFAULT_STATE };
}
export function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = JSON.stringify(state);
}
// Sort a [{file, name, size, mtime}] list by the current sort + direction.
export function sortFiles(files, sort, dir) {
const arr = [...(files || [])];
arr.sort((a, b) => {
let c;
if (sort === "date") c = (a.mtime || 0) - (b.mtime || 0);
else
c = String(a.file).localeCompare(String(b.file), undefined, {
numeric: true,
sensitivity: "base",
});
return dir === "desc" ? -c : c;
});
return arr;
}
-441
View File
@@ -1,441 +0,0 @@
// Load Images from Folder Pixaroma — DOM (node body, gallery, folder browser) + CSS.
import { thumbURL, browseFolder } from "./api.mjs";
import { readState, writeState, sortFiles } from "./state.mjs";
// folder.svg (assets/icons/ui/folder.svg) inlined so the Browse button + browser
// rows draw the real icon without a network fetch. fill:currentColor follows text.
const FOLDER_SVG =
'<svg viewBox="0 0 64 64" aria-hidden="true"><path d="M52.291,56.817H5.626c-1.006,0-1.922-.594-2.5-1.323-.752-.949-.846-2.209-.483-3.372l7.293-23.34c.522-1.67,1.625-2.992,3.453-3.243h46.148c2.155.308,3.418,2.045,3.193,4.245l-7.097,23.693c-.491,1.64-1.523,2.993-3.343,3.341ZM50.726,14.308h-21.805c-.429-.181-.717-.689-.997-1.031l-3.967-4.843c-.559-.682-1.432-1.249-2.369-1.25H6.186c-1.185,0-2.24.531-3.095,1.272-1.098.952-1.545,2.24-1.818,3.706v31.447c1.841-5.514,3.332-10.857,5.103-16.241.459-1.396,1.126-2.594,2.154-3.621,1.355-1.054,2.862-2.056,4.685-2.057h42.426c.669-2.549-.634-7.369-4.914-7.382Z"/></svg>';
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])
);
}
// ── CSS ──────────────────────────────────────────────────────────────────────
export function injectCSS() {
if (document.getElementById("pix-lif-css")) return;
const css = `
.pix-lif-root { display:flex; flex-direction:column; gap:8px; padding:8px 10px; box-sizing:border-box; font-family:inherit; }
.pix-lif-folderrow { display:flex; gap:6px; }
.pix-lif-folder { flex:1; min-width:0; background:#141414; border:1px solid #3a3a3a; border-radius:5px; padding:7px 8px; color:#cfcfcf; font-size:11px; box-sizing:border-box; }
.pix-lif-folder:focus { outline:none; border-color:#f66744; }
.pix-lif-browse { display:flex; align-items:center; gap:5px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.16); border-radius:5px; color:#ddd; font-size:11px; padding:0 9px; cursor:pointer; white-space:nowrap; }
.pix-lif-browse:hover { border-color:#f66744; color:#fff; }
.pix-lif-browse svg { width:13px; height:13px; fill:currentColor; }
.pix-lif-pick { background:#f66744; border:1px solid #f66744; border-radius:6px; padding:8px; font-size:12px; color:#fff; text-align:center; font-weight:500; cursor:pointer; }
.pix-lif-pick:hover { filter:brightness(1.08); }
.pix-lif-pick.empty { background:rgba(255,255,255,0.05); border-color:rgba(255,255,255,0.16); color:#9a9a9a; }
.pix-lif-msg { font-size:11px; color:#e0a33e; line-height:1.4; }
.pix-lif-msg:empty { display:none; }
.pix-lif-resize-slot { display:flex; flex-direction:column; gap:6px; }
.pix-lif-resize-slot:empty { display:none; }
.pix-lif-resizebtn { display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.16); border-radius:5px; padding:6px 8px; font-size:11px; color:#cfcfcf; cursor:pointer; box-sizing:border-box; }
.pix-lif-resizebtn:hover { border-color:#f66744; }
.pix-lif-resizebtn .lbl { color:#f66744; font-size:10px; text-transform:uppercase; letter-spacing:.04em; }
.pix-lif-resizebtn .val { margin-left:auto; color:#ddd; }
/* mini dropdown menu (sort, resize mode) */
.pix-lif-menu { position:fixed; z-index:99999; background:#191919; border:1px solid #3a3a3a; border-radius:6px; box-shadow:0 8px 24px rgba(0,0,0,0.5); overflow:hidden; min-width:150px; }
.pix-lif-menu .it { padding:7px 11px; font-size:12px; color:#cfcfcf; cursor:pointer; display:flex; justify-content:space-between; gap:14px; }
.pix-lif-menu .it:hover { background:#2a2a2a; }
.pix-lif-menu .it.on { color:#f66744; }
/* gallery */
.pix-lif-gallery { position:fixed; z-index:99999; background:#191919; border:1px solid #f66744; border-radius:9px; box-shadow:0 14px 40px rgba(0,0,0,0.6); display:flex; flex-direction:column; max-height:80vh; }
.pix-lif-gal-head { padding:9px 12px; border-bottom:1px solid #333; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.pix-lif-tbtn { background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.16); border-radius:5px; padding:5px 10px; font-size:11px; color:#ddd; cursor:pointer; user-select:none; }
.pix-lif-tbtn:hover { border-color:#f66744; color:#fff; }
.pix-lif-firstwrap { display:flex; align-items:center; }
.pix-lif-firstwrap .pix-lif-tbtn { border-radius:5px 0 0 5px; }
.pix-lif-firstn { width:46px; background:#141414; border:1px solid rgba(255,255,255,0.16); border-left:none; border-radius:0 5px 5px 0; color:#f66744; font-size:11px; padding:5px 4px; text-align:center; box-sizing:border-box; }
.pix-lif-firstn:focus { outline:none; border-color:#f66744; }
.pix-lif-count { margin-left:auto; font-size:11px; color:#9a9a9a; white-space:nowrap; }
.pix-lif-count b { color:#f66744; }
.pix-lif-gal-body { padding:10px 12px; overflow:auto; }
.pix-lif-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(84px,1fr)); gap:7px; }
.pix-lif-thumb { position:relative; aspect-ratio:1; border-radius:5px; border:2px solid transparent; cursor:pointer; overflow:hidden; background:#0f0f0f; }
.pix-lif-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.pix-lif-thumb .veil { position:absolute; inset:0; background:rgba(0,0,0,0.45); }
.pix-lif-thumb.sel { border-color:#f66744; }
.pix-lif-thumb.sel .veil { opacity:0; }
.pix-lif-thumb .chk { position:absolute; top:3px; right:3px; width:16px; height:16px; border-radius:50%; background:#f66744; color:#fff; font-size:11px; display:none; align-items:center; justify-content:center; }
.pix-lif-thumb.sel .chk { display:flex; }
.pix-lif-thumb .nm { position:absolute; bottom:0; left:0; right:0; padding:2px 4px; font-size:9px; color:#eee; background:linear-gradient(transparent, rgba(0,0,0,0.75)); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pix-lif-gal-empty { padding:30px; text-align:center; color:#888; font-size:12px; grid-column:1/-1; }
.pix-lif-gal-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:10px; align-items:center; }
.pix-lif-subf { display:flex; align-items:center; gap:6px; font-size:11px; color:#bbb; cursor:pointer; user-select:none; }
.pix-lif-subf .box { width:12px; height:12px; border:1px solid #555; border-radius:3px; }
.pix-lif-subf.on .box { background:#f66744; border-color:#f66744; }
.pix-lif-done { margin-left:auto; background:#f66744; border:1px solid #f66744; border-radius:6px; padding:6px 16px; font-size:12px; color:#fff; cursor:pointer; }
.pix-lif-done:hover { filter:brightness(1.08); }
/* folder browser */
.pix-lif-browse-pop { position:fixed; z-index:99999; background:#191919; border:1px solid #f66744; border-radius:9px; box-shadow:0 14px 40px rgba(0,0,0,0.6); display:flex; flex-direction:column; max-height:72vh; }
.pix-lif-bp-head { padding:9px 12px; border-bottom:1px solid #333; font-size:12px; color:#f66744; font-weight:600; }
.pix-lif-bp-crumb { padding:7px 12px 4px; font-size:11px; color:#999; word-break:break-all; }
.pix-lif-bp-list { padding:6px 10px 10px; overflow:auto; display:flex; flex-direction:column; gap:4px; }
.pix-lif-bp-item { display:flex; align-items:center; gap:8px; padding:7px 9px; background:#141414; border:1px solid #2c2c2c; border-radius:6px; cursor:pointer; font-size:12px; color:#ddd; }
.pix-lif-bp-item:hover { border-color:#f66744; background:#1c1c1c; }
.pix-lif-bp-item svg { width:13px; height:13px; fill:#f66744; flex:0 0 auto; }
.pix-lif-bp-item .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pix-lif-bp-item .cnt { color:#777; font-size:11px; white-space:nowrap; }
.pix-lif-bp-item.up { color:#9a9a9a; }
.pix-lif-bp-empty { padding:14px; text-align:center; color:#777; font-size:12px; }
.pix-lif-bp-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:8px; }
`;
const el = document.createElement("style");
el.id = "pix-lif-css";
el.textContent = css;
document.head.appendChild(el);
}
// ── popup helpers ────────────────────────────────────────────────────────────
function positionBelow(popup, anchorEl, width) {
const r = anchorEl.getBoundingClientRect();
popup.style.position = "fixed";
popup.style.width = `${width}px`;
popup.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - width - 8))}px`;
popup.style.top = `${r.bottom + 4}px`;
requestAnimationFrame(() => {
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) {
popup.style.top = `${Math.max(8, window.innerHeight - 8 - pr.height)}px`;
}
});
}
function attachClosePopup(popup, onClose, ignoreSelector) {
const close = () => {
if (popup._pixClosed) return;
popup._pixClosed = true;
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("wheel", onWheel, true);
document.removeEventListener("keydown", onKey, true);
popup.remove();
onClose?.();
};
// gate on !contains so scrolling/clicking INSIDE the popup never closes it.
// ignoreSelector lets a popup treat a spawned sub-popup (e.g. the sort menu)
// as "inside" so interacting with it doesn't close the parent.
const inside = (t) =>
popup.contains(t) || (ignoreSelector && t.closest && t.closest(ignoreSelector));
const onDown = (e) => { if (!inside(e.target)) close(); };
const onWheel = (e) => { if (!inside(e.target)) close(); };
const onKey = (e) => { if (e.key === "Escape") close(); };
popup._pixClose = close;
setTimeout(() => {
if (popup._pixClosed) return; // closed before the listeners were even added
document.addEventListener("mousedown", onDown, true);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("wheel", onWheel, true);
document.addEventListener("keydown", onKey, true);
}, 0);
return close;
}
export function openMiniMenu(anchorEl, items, currentValue, onPick) {
document.querySelectorAll(".pix-lif-menu").forEach((m) => m._pixClose?.());
const menu = document.createElement("div");
menu.className = "pix-lif-menu";
for (const it of items) {
const row = document.createElement("div");
row.className = "it" + (it.value === currentValue ? " on" : "");
row.innerHTML = `<span>${escapeHtml(it.label)}</span>${it.hint ? `<span style="color:#777">${escapeHtml(it.hint)}</span>` : ""}`;
row.addEventListener("click", () => { menu._pixClose?.(); onPick(it.value); });
menu.appendChild(row);
}
document.body.appendChild(menu);
positionBelow(menu, anchorEl, Math.max(150, anchorEl.getBoundingClientRect().width));
attachClosePopup(menu);
}
// ── node body ────────────────────────────────────────────────────────────────
export function buildRoot() {
const root = document.createElement("div");
root.className = "pix-lif-root";
root.innerHTML =
`<div class="pix-lif-folderrow">` +
`<input class="pix-lif-folder" type="text" spellcheck="false" placeholder="Folder path - type, paste, or Browse">` +
`<button class="pix-lif-browse" type="button" title="Browse for a folder on your computer">${FOLDER_SVG}<span class="pix-lif-browse-lbl">Browse</span></button>` +
`</div>` +
`<button class="pix-lif-pick empty" type="button" title="Choose which images to load">Pick images · 0 / 0</button>` +
`<div class="pix-lif-msg"></div>` +
`<div class="pix-lif-resize-slot"></div>`;
return {
root,
folderInput: root.querySelector(".pix-lif-folder"),
browseBtn: root.querySelector(".pix-lif-browse"),
browseLbl: root.querySelector(".pix-lif-browse-lbl"),
pickBtn: root.querySelector(".pix-lif-pick"),
msgEl: root.querySelector(".pix-lif-msg"),
resizeSlot: root.querySelector(".pix-lif-resize-slot"),
};
}
const SORTS = [
{ value: "name|asc", label: "Name ↑" },
{ value: "name|desc", label: "Name ↓" },
{ value: "date|asc", label: "Date ↑" },
{ value: "date|desc", label: "Date ↓" },
];
// ── the multi-select "Pick images" gallery ──────────────────────────────────
// ctx: { onChange(node), refreshListing(node):Promise }
export function openPickGallery(node, anchorEl, ctx) {
document.querySelectorAll(".pix-lif-gallery").forEach((g) => g._pixClose?.());
const gal = document.createElement("div");
gal.className = "pix-lif-gallery";
gal.innerHTML =
`<div class="pix-lif-gal-head">` +
`<div class="pix-lif-tbtn" data-act="all" title="Select every image in this folder">Select all</div>` +
`<div class="pix-lif-tbtn" data-act="none" title="Deselect all">None</div>` +
`<div class="pix-lif-firstwrap"><div class="pix-lif-tbtn" data-act="first" title="Select the first N images (in sort order)">First</div>` +
`<input class="pix-lif-firstn" type="number" min="1" value="5" title="How many images First selects"></div>` +
`<div class="pix-lif-count"><b class="pix-lif-cn">0</b> / <span class="pix-lif-ct">0</span> selected</div>` +
`</div>` +
`<div class="pix-lif-gal-body"><div class="pix-lif-grid"></div></div>` +
`<div class="pix-lif-gal-foot">` +
`<div class="pix-lif-subf" title="Also include images inside sub-folders"><span class="box"></span> Include subfolders</div>` +
`<div class="pix-lif-tbtn" data-act="sort" title="Change the sort order">Sort: Name ↑</div>` +
`<div class="pix-lif-done" data-act="done" title="Apply this selection and close">Done</div>` +
`</div>`;
document.body.appendChild(gal);
const grid = gal.querySelector(".pix-lif-grid");
const cnEl = gal.querySelector(".pix-lif-cn");
const ctEl = gal.querySelector(".pix-lif-ct");
const firstInput = gal.querySelector(".pix-lif-firstn");
const subfEl = gal.querySelector(".pix-lif-subf");
const sortBtn = gal.querySelector('[data-act="sort"]');
let state = readState(node);
const selSet = new Set(state.selected || []);
function sortLabel() {
return SORTS.find((s) => s.value === `${state.sort}|${state.sort_dir}`)?.label || "Name ↑";
}
function updateCounts() {
const total = (node._pixLifFiles || []).length;
cnEl.textContent = selSet.size;
ctEl.textContent = total;
firstInput.max = String(total || 1); // spinner arrows stop at the image count
}
function commit() {
const sorted = sortFiles(node._pixLifFiles || [], state.sort, state.sort_dir);
const selected = sorted.map((f) => f.file).filter((f) => selSet.has(f));
// re-read fresh state so a concurrent body edit (resize/folder) isn't
// clobbered; only write the keys the gallery owns.
const fresh = readState(node);
fresh.selected = selected;
fresh.sort = state.sort;
fresh.sort_dir = state.sort_dir;
fresh.recursive = state.recursive;
writeState(node, fresh);
state.selected = selected; // keep local snapshot in sync
updateCounts();
ctx.onChange?.(node);
}
function renderGrid() {
grid.innerHTML = "";
sortBtn.textContent = `Sort: ${sortLabel()}`;
subfEl.classList.toggle("on", !!state.recursive);
const files = node._pixLifFiles || [];
if (!files.length) {
const empty = document.createElement("div");
empty.className = "pix-lif-gal-empty";
empty.textContent = node._pixLifListError || "No images in this folder.";
grid.appendChild(empty);
updateCounts();
return;
}
const sorted = sortFiles(files, state.sort, state.sort_dir);
for (const f of sorted) {
const cell = document.createElement("div");
cell.className = "pix-lif-thumb" + (selSet.has(f.file) ? " sel" : "");
cell.innerHTML =
`<img loading="lazy" src="${thumbURL(state.folder, f.file, f.mtime)}" onerror="this.style.display='none'">` +
`<div class="veil"></div><div class="chk">✓</div>` +
`<div class="nm">${escapeHtml(f.name)}</div>`;
cell.addEventListener("click", () => {
if (selSet.has(f.file)) selSet.delete(f.file);
else selSet.add(f.file);
cell.classList.toggle("sel");
commit();
});
grid.appendChild(cell);
}
updateCounts();
}
gal.querySelector('[data-act="all"]').addEventListener("click", () => {
(node._pixLifFiles || []).forEach((f) => selSet.add(f.file));
renderGrid();
commit();
});
gal.querySelector('[data-act="none"]').addEventListener("click", () => {
selSet.clear();
renderGrid();
commit();
});
// Shared "select the first N images (in current sort order)" applier, used by
// BOTH the First button AND typing in the number box, so the field drives the
// selection on its own — not just as a silent parameter for the button.
function applyFirstN(value) {
const total = (node._pixLifFiles || []).length;
const n = Math.max(0, Math.min(parseInt(value, 10) || 0, total));
selSet.clear();
sortFiles(node._pixLifFiles || [], state.sort, state.sort_dir)
.slice(0, n)
.forEach((f) => selSet.add(f.file));
renderGrid();
commit();
return n; // the actual count selected (clamped to how many images exist)
}
gal.querySelector('[data-act="first"]').addEventListener("click", () => {
const applied = applyFirstN(Math.max(1, parseInt(firstInput.value, 10) || 1));
firstInput.value = String(Math.max(1, applied)); // show the real, capped count
});
// Live-apply as the user types / steps the number so "type a number → it
// selects" works without a separate click. Debounced so typing "12" applies
// once (12), not 1-then-12. An EMPTY box is left as "no instruction" (the
// selection is untouched — use None to deselect). Enter flushes immediately;
// blur / spinner commit via the change event.
let firstTimer = null;
const flushFirst = (snap) => {
clearTimeout(firstTimer);
firstTimer = null;
if (gal._pixClosed) return;
const raw = firstInput.value.trim();
if (raw === "") return; // empty = no change
const total = (node._pixLifFiles || []).length;
const typed = Math.max(0, parseInt(raw, 10) || 0);
const applied = applyFirstN(typed);
// Snap the box down to the real max when you ask for more than exist, but
// ONLY on commit (Enter / blur / spinner) — never on the live debounced
// path, or pausing mid-type above the cap would rewrite the box and shift
// the next digit you type.
if (snap && total > 0 && typed > total) firstInput.value = String(applied);
};
firstInput.addEventListener("input", () => {
clearTimeout(firstTimer);
firstTimer = null;
if (firstInput.value.trim() === "") return; // don't clobber on clear-to-retype
firstTimer = setTimeout(() => flushFirst(false), 220); // select live, leave the box alone
});
firstInput.addEventListener("change", () => flushFirst(true)); // blur / spinner caps the box
firstInput.addEventListener("keydown", (e) => {
e.stopImmediatePropagation();
if (e.key === "Enter") { e.preventDefault(); flushFirst(true); }
});
sortBtn.addEventListener("click", () => {
openMiniMenu(sortBtn, SORTS, `${state.sort}|${state.sort_dir}`, (val) => {
const [s, d] = val.split("|");
state.sort = s;
state.sort_dir = d;
writeState(node, state);
commit();
renderGrid();
});
});
subfEl.addEventListener("click", async () => {
state.recursive = !state.recursive;
writeState(node, state);
subfEl.classList.toggle("on", state.recursive);
grid.innerHTML = `<div class="pix-lif-gal-empty">Loading…</div>`;
await ctx.refreshListing(node);
if (gal._pixClosed) return; // gallery was closed during the fetch
state = readState(node);
selSet.clear();
(state.selected || []).forEach((f) => selSet.add(f));
renderGrid();
});
gal.querySelector('[data-act="done"]').addEventListener("click", () => gal._pixClose?.());
node._pixLifGallery = gal;
attachClosePopup(
gal,
() => {
document.querySelectorAll(".pix-lif-menu").forEach((m) => m._pixClose?.());
if (node._pixLifGallery === gal) node._pixLifGallery = null;
},
".pix-lif-menu"
);
positionBelow(gal, anchorEl, Math.min(560, window.innerWidth - 16));
renderGrid();
}
// ── the in-app folder browser ────────────────────────────────────────────────
// ctx: { startPath, onPick(folderPath) }
export function openBrowsePopup(node, anchorEl, ctx) {
document.querySelectorAll(".pix-lif-browse-pop").forEach((p) => p._pixClose?.());
const pop = document.createElement("div");
pop.className = "pix-lif-browse-pop";
pop.innerHTML =
`<div class="pix-lif-bp-head">Choose a folder</div>` +
`<div class="pix-lif-bp-crumb"></div>` +
`<div class="pix-lif-bp-list"></div>` +
`<div class="pix-lif-bp-foot">` +
`<div class="pix-lif-tbtn" data-act="cancel" title="Close without changing the folder">Cancel</div>` +
`<div class="pix-lif-done" data-act="use" title="Use the folder shown above">Use this folder</div>` +
`</div>`;
document.body.appendChild(pop);
const crumb = pop.querySelector(".pix-lif-bp-crumb");
const list = pop.querySelector(".pix-lif-bp-list");
const useBtn = pop.querySelector('[data-act="use"]');
let cur = ctx.startPath || "";
async function nav(path) {
list.innerHTML = `<div class="pix-lif-bp-empty">Loading…</div>`;
const res = await browseFolder(path);
if (pop._pixClosed) return; // popup was closed during the fetch
if (!res.ok) {
list.innerHTML = `<div class="pix-lif-bp-empty">${escapeHtml(res.message || "Could not open this folder.")}</div>`;
return;
}
cur = res.path || "";
// The This-PC / drive-list root has no usable path; dim "Use this folder".
useBtn.style.opacity = cur ? "" : "0.4";
useBtn.style.pointerEvents = cur ? "" : "none";
crumb.innerHTML = cur ? `Location: <b style="color:#ddd">${escapeHtml(cur)}</b>` : "This PC";
list.innerHTML = "";
if (res.parent !== null && res.parent !== undefined) {
const up = document.createElement("div");
up.className = "pix-lif-bp-item up";
up.innerHTML = `<span style="width:13px;text-align:center;flex:0 0 auto">↰</span> <span class="nm">.. (up one level)</span>`;
up.addEventListener("click", () => nav(res.parent || ""));
list.appendChild(up);
}
if (!res.dirs.length) {
const e = document.createElement("div");
e.className = "pix-lif-bp-empty";
e.textContent = cur ? "No sub-folders here. Use this folder." : "No drives found.";
list.appendChild(e);
}
for (const d of res.dirs) {
const it = document.createElement("div");
it.className = "pix-lif-bp-item";
const cnt = d.images >= 0 ? `<span class="cnt">${d.images} image${d.images === 1 ? "" : "s"}</span>` : "";
it.innerHTML = `${FOLDER_SVG}<span class="nm">${escapeHtml(d.name)}</span>${cnt}`;
it.addEventListener("click", () => nav(d.path));
list.appendChild(it);
}
}
pop.querySelector('[data-act="cancel"]').addEventListener("click", () => pop._pixClose?.());
pop.querySelector('[data-act="use"]').addEventListener("click", () => {
if (cur) ctx.onPick(cur);
pop._pixClose?.();
});
node._pixLifBrowsePop = pop;
attachClosePopup(pop, () => {
if (node._pixLifBrowsePop === pop) node._pixLifBrowsePop = null;
});
positionBelow(pop, anchorEl, Math.min(440, window.innerWidth - 16));
nav(cur);
}
+2 -31
View File
@@ -1454,7 +1454,7 @@ app.registerExtension({
// Both renderers open the swatch-palette popup (the Vue menu can't
// render the old nested submenu, and the popup is the unified UI).
options.push(null, {
content: `👑 Pixaroma Node Colors (\\)${suffix}`,
content: `👑 Pixaroma Node Colors${suffix}`,
callback: () => openNodeColorsPalette(targets, node),
});
options.push({
@@ -1502,7 +1502,7 @@ app.registerExtension({
const suffix = targets.length > 1 ? ` (${targets.length} groups)` : "";
options.push(null);
options.push({
content: `👑 Pixaroma Group Colors (\\)${suffix}`,
content: `👑 Pixaroma Group Colors${suffix}`,
callback: () => openGroupColorsPalette(targets, group),
});
options.push({
@@ -1525,34 +1525,5 @@ app.registerExtension({
return options;
};
}
// ── Keyboard shortcut: press "\" to open the color palette for the current
// selection — selected node(s) take priority, else a selected group. Ignored
// while typing in a field, with a modifier held, or when a palette is already
// open. Only acts (and swallows the key) when something is selected, so a bare
// "\" otherwise passes through to ComfyUI.
window.addEventListener("keydown", (e) => {
if (e.key !== "\\" || e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
if (document.querySelector(".pix-nc-pal")) return; // a palette is already open
const c = app.canvas;
if (!c) return;
const nodes = c.selected_nodes ? Object.values(c.selected_nodes) : [];
if (nodes.length) {
e.preventDefault(); e.stopPropagation();
openNodeColorsPalette(getTargetNodes(nodes[0]), nodes[0]);
return;
}
const items = c.selectedItems; // a Set mixing nodes + groups (Node Colors pattern)
if (items && typeof items.forEach === "function") {
let group = null;
items.forEach((it) => { if (!group && typeof it?.recomputeInsideNodes === "function") group = it; });
if (group) {
e.preventDefault(); e.stopPropagation();
openGroupColorsPalette(getTargetGroups(group), group);
}
}
}, true);
},
});
-266
View File
@@ -1,266 +0,0 @@
import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly, isVueNodes, installResizeFloor } from "../shared/index.mjs";
// Portrait Landscape Pixaroma - two pill buttons (Portrait | Landscape) that
// choose the orientation of the width/height the node outputs. The node's
// width/height are native INT widgets (type a value OR wire one in); the
// Portrait/Landscape choice lives on node.properties.portraitLandscapeState
// and is injected into the hidden PortraitLandscapeState input by the
// app.graphToPrompt hook below (Switch WH / Resolution pattern, Vue Compat #9).
const BRAND = "#f66744";
const STATE_PROP = "portraitLandscapeState";
const HIDDEN_INPUT_NAME = "PortraitLandscapeState";
const DEFAULT_STATE = "portrait";
const BTN_H = 30;
const PAD = 6;
const GAP = 6;
const WIDGET_H = BTN_H + PAD * 2;
// Fresh-drop WIDTH (CLAUDE.md UI conventions #5) - wide enough that the two
// pills don't crowd. The HEIGHT is auto-fit to the content (title + the two
// number fields + the pill row) via node.computeSize(), so there's no empty
// gap under the pills and no over-tall default.
const DEFAULT_W = 240;
const MIN_W = 240;
function injectCSS() {
if (document.getElementById("pix-portrait-landscape-css")) return;
const style = document.createElement("style");
style.id = "pix-portrait-landscape-css";
style.textContent = `
.pix-pl-root {
display: flex;
gap: ${GAP}px;
padding: ${PAD}px;
box-sizing: border-box;
width: 100%;
align-items: stretch;
}
.pix-pl-btn {
flex: 1;
min-width: 0;
height: ${BTN_H}px;
border-radius: 6px;
/* Semi-transparent white overlay (not fixed dark grey) so the inactive
button adapts when the user recolours the node via right-click ->
Colors. Matches Switch WH / Text Pixaroma button style. */
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.3px;
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
font-family: inherit;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pix-pl-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
color: #fff;
}
.pix-pl-btn.active {
background: ${BRAND};
color: #fff;
border-color: ${BRAND};
}
`;
document.head.appendChild(style);
}
function readState(node) {
const v = node.properties?.[STATE_PROP];
return v === "portrait" || v === "landscape" ? v : DEFAULT_STATE;
}
function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = state;
}
function buildRoot(node) {
const root = document.createElement("div");
root.className = "pix-pl-root";
const btnP = document.createElement("button");
btnP.className = "pix-pl-btn";
btnP.textContent = "Portrait";
btnP.title = "Tall: the smaller number becomes the width";
btnP.dataset.value = "portrait";
const btnL = document.createElement("button");
btnL.className = "pix-pl-btn";
btnL.textContent = "Landscape";
btnL.title = "Wide: the larger number becomes the width";
btnL.dataset.value = "landscape";
root.appendChild(btnP);
root.appendChild(btnL);
function refresh() {
const s = readState(node);
btnP.classList.toggle("active", s === "portrait");
btnL.classList.toggle("active", s === "landscape");
}
for (const b of [btnP, btnL]) {
b.addEventListener("click", (e) => {
e.stopPropagation();
writeState(node, b.dataset.value);
refresh();
node.graph?.setDirtyCanvas?.(true, true);
});
}
refresh();
return { root, refresh };
}
function setupNode(node) {
injectCSS();
const { root, refresh } = buildRoot(node);
node._pixPlRoot = root;
node._pixPlRefresh = refresh;
const measureHeight = () => WIDGET_H;
const _plWidget = node.addDOMWidget("pixaroma_portrait_landscape_ui", "pixaroma_portrait_landscape", root, {
// canvasOnly set adaptively below (CLAUDE.md Nodes 2.0): true in legacy
// (out of the Parameters tab), false in Nodes 2.0 (renders in Vue body).
getValue: () => null,
setValue: () => {},
getMinHeight: measureHeight,
getMaxHeight: measureHeight,
margin: 4,
serialize: false,
});
applyAdaptiveCanvasOnly(_plWidget);
// Nodes 2.0 drag floor: pin the pill row's min-height ONLY while a resize
// handle is dragged so it can't be dragged out of the node frame (the Vue
// renderer's drag floor is a live DOM measurement, not getMinHeight). No-op
// in legacy. Uninstalled in onRemoved.
node._pixPlFloorOff = installResizeFloor(root, () => WIDGET_H);
// Fresh-drop size: width = DEFAULT_W, height = LiteGraph's natural content
// height (title + the two number fields + the pill row) so the node hugs its
// content with no empty gap. configure() runs AFTER nodeCreated (Vue Compat
// #8) and overwrites both on workflow restore + duplicate, so existing
// workflows keep their saved size; only fresh drops use this. Mutate the
// array rather than replacing it (plays nicer with any reactive proxy).
const snugH = node.computeSize()[1];
node.size[0] = DEFAULT_W;
node.size[1] = snugH;
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "Pixaroma.PortraitLandscape",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaPortraitLandscape") return;
const _origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = _origConfigure?.apply(this, arguments);
// Defer so node.properties is settled before we read it.
queueMicrotask(() => this._pixPlRefresh?.());
return r;
};
// Clamp manual resize WIDTH so the two pills never clip past the right
// edge. Height is left to LiteGraph (its content-based min floors it, so
// the node can't be dragged shorter than the pills + fields, and there's
// no forced empty space). LEGACY-ONLY: in Nodes 2.0 the rendered size
// lives in the Vue layout store, so clamping node.size there desyncs and
// makes the node jump on a workflow switch (CLAUDE.md "Nodes 2.0
// manual-resize MINIMUM").
const _origOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (!isVueNodes()) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
}
if (_origOnResize) return _origOnResize.apply(this, arguments);
};
// Self-heal min size on every paint (Preview Image Pattern #11 + UI
// conventions #7), catching resize paths that bypass onResize (Vue
// Compat #13). Also legacy-only for the reason above.
const _origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (_origDraw) _origDraw.call(this, ctx);
if (isVueNodes()) return;
if (this.flags?.collapsed) return;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
};
const _origOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._pixPlFloorOff?.();
this._pixPlFloorOff = null;
return _origOnRemoved?.apply(this, arguments);
};
},
nodeCreated(node) {
if (node.comfyClass !== "PixaromaPortraitLandscape") return;
setupNode(node);
},
});
// ── app.graphToPrompt hook (subgraph-safe) ──────────────────────────────
// Same walk-and-inject pattern as Switch WH / Resolution Pixaroma. Required
// because PortraitLandscapeState is `hidden` (no widget) so the workflow JSON
// doesn't carry it; we inject from node.properties at submission time.
function buildPixaromaNodeIndex() {
const index = new Map();
const visit = (graph) => {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (!n) continue;
if (n.comfyClass === "PixaromaPortraitLandscape" || n.type === "PixaromaPortraitLandscape") {
index.set(String(n.id), n);
}
const inner = n.subgraph || n.graph || n._graph;
if (inner && inner !== graph) visit(inner);
}
};
visit(app.graph);
return index;
}
function findPixaromaNode(index, promptId) {
const sId = String(promptId);
if (index.has(sId)) return index.get(sId);
const tail = sId.includes(":") ? sId.slice(sId.lastIndexOf(":") + 1) : null;
if (tail && index.has(tail)) return index.get(tail);
return null;
}
const _origGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await _origGraphToPrompt(...args);
const out = result?.output;
if (out) {
let index = null;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== "PixaromaPortraitLandscape") continue;
if (!index) index = buildPixaromaNodeIndex();
const node = findPixaromaNode(index, id);
const state = node?.properties?.[STATE_PROP] || DEFAULT_STATE;
entry.inputs = entry.inputs || {};
entry.inputs[HIDDEN_INPUT_NAME] = state;
}
}
return result;
};
+4 -21
View File
@@ -766,9 +766,8 @@ function layoutImgStrip(widgetWidth, widgetY, widgetHeight, frames) {
// 2D-wrapped grid layout — native PreviewImage's algorithm exactly.
// Iterate cols from 1..N, pick the count that maximises total image area
// inside the available rect. Cell dimensions are SCALED IMAGE dimensions
// (not innerW/cols x innerH/rows), so cells exactly fit a UNIFORM batch with
// no letterbox; a MIXED-size batch letterboxes the odd frames within their
// cells (the per-frame fit in the loop). The grid is then centered in the widget.
// (not innerW/cols x innerH/rows), so cells exactly fit the images with
// NO per-cell letterbox. The grid is then centered inside the widget.
// Result: thumbnails always look as big as native's, cells touch directly,
// any unused space sits at the edges of the grid (not between cells).
function layoutImgGrid(widgetWidth, widgetY, widgetHeight, frames) {
@@ -819,25 +818,9 @@ function layoutImgGrid(widgetWidth, widgetY, widgetHeight, frames) {
const c = i % cols;
const x = startX + c * cellW;
const y = startY + r * cellH;
// Cell == fitted image rect: no per-cell letterbox to center within.
slots.push({ x, y, w: cellW, h: cellH, idx: i });
// Fit THIS frame inside its cell, preserving its OWN aspect, centered.
// For a uniform batch the image fills the cell exactly (cell == scaled
// frame[0] dims == this frame's dims, so no letterbox). For a MIXED-size
// batch (e.g. Load Images from Folder) the odd-shaped frames letterbox
// instead of stretching. Mirrors layoutImgStrip's per-image fit.
const im = frames[i]?.img;
let imgRect = { x, y, w: cellW, h: cellH };
if (im?.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
const s = Math.min(cellW / im.naturalWidth, cellH / im.naturalHeight, 1);
const w = Math.round(im.naturalWidth * s);
const h = Math.round(im.naturalHeight * s);
imgRect = {
x: x + Math.floor((cellW - w) / 2),
y: y + Math.floor((cellH - h) / 2),
w, h,
};
}
imgs.push(imgRect);
imgs.push({ x, y, w: cellW, h: cellH });
}
return { slots, imgs };
}
-597
View File
@@ -1,597 +0,0 @@
import { app } from "/scripts/app.js";
import { BRAND, hideJsonWidget, applyAdaptiveCanvasOnly, isVueNodes, measureRootContent } from "../shared/index.mjs";
// ─────────────────────────────────────────────────────────────────────────
// Seed Pixaroma — a seed source with Random / Fixed modes + buttons.
//
// Architecture mirrors Resolution Pixaroma: Python declares a single `hidden`
// SeedState input (no widget, no slot dot); the on-node UI is a DOM widget and
// state lives on node.properties.seedState (LiteGraph serializes it). The
// app.graphToPrompt hook at the bottom injects the resolved per-run seed.
//
// Behaviour:
// • Random mode → each Run rolls a fresh seed; "Last run" shows what ran.
// • Fixed mode → the locked seed is used every Run (repeatable).
// • New fixed random → roll a new seed and switch to Fixed (locks a roll).
// • Use last seed → load the previous run's seed and switch to Fixed.
// • Copy → put the current/last seed on the clipboard.
// • Typing a number in the big field sets that exact seed (switches to Fixed).
// Works in both the Classic and Nodes 2.0 renderers (DOM widget + adaptive
// canvasOnly).
// ─────────────────────────────────────────────────────────────────────────
function injectCSS() {
if (document.getElementById("pixaroma-seed-css")) return;
const css = `
.pix-seed-root {
width: 100%;
box-sizing: border-box;
padding: 8px;
background: #2a2a2a;
border-radius: 4px;
color: #ddd;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Big editable seed number. Dark inset box, monospace, brand border on focus. */
.pix-seed-num {
width: 100%;
box-sizing: border-box;
height: 42px; /* fixed box so the auto-fit font change can't alter the height */
background: #171819;
border: 1px solid #3a3d40;
border-radius: 6px;
padding: 9px 8px;
color: #f2f2f2;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
/* font-size is the MAX; fitSeedFont() shrinks it inline so a long (up to
16-digit) seed fits the narrower Nodes 2.0 body without being cut. */
font-size: 19px;
text-align: center;
letter-spacing: 0;
outline: none;
}
.pix-seed-num:focus { border-color: ${BRAND}; }
/* Random | Fixed segmented pill. Active segment = solid brand. */
.pix-seed-pill {
display: flex;
gap: 0;
background: rgba(255,255,255,0.06);
border-radius: 7px;
padding: 3px;
}
/* Real <button>s (keyboard-reachable), styled to look like flat segments. */
.pix-seed-seg {
flex: 1;
text-align: center;
padding: 6px;
border: none;
border-radius: 5px;
background: transparent;
font-family: inherit;
font-size: 12px;
color: rgba(255,255,255,0.55);
cursor: pointer;
user-select: none;
appearance: none;
-webkit-appearance: none;
outline: none;
transition: background 0.08s, color 0.08s;
}
.pix-seed-seg:hover:not(.active) { color: rgba(255,255,255,0.85); }
.pix-seed-seg.active {
background: ${BRAND};
color: #fff;
font-weight: 500;
}
.pix-seed-seg:focus-visible { outline: 2px solid ${BRAND}; outline-offset: -2px; }
/* Action buttons — semi-transparent white surface, brand fill on hover
(matches the Text / Prompt Pack action-button family). */
.pix-seed-btn {
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.14);
color: rgba(255,255,255,0.85);
font-family: inherit;
font-size: 12px;
cursor: pointer;
user-select: none;
text-align: center;
transition: background 0.08s, border-color 0.08s, color 0.08s;
}
.pix-seed-newrandom { width: 100%; }
.pix-seed-btn:hover {
background: ${BRAND};
border-color: ${BRAND};
color: #fff;
}
.pix-seed-btn:disabled { opacity: 0.4; cursor: default; }
.pix-seed-btn:disabled:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.14);
color: rgba(255,255,255,0.85);
}
/* Success flash after Copy — green wins over hover via higher specificity. */
.pix-seed-btn.is-flashing,
.pix-seed-btn.is-flashing:hover {
background: #3ec371;
border-color: #3ec371;
color: #fff;
}
.pix-seed-row { display: flex; gap: 8px; }
.pix-seed-uselast { flex: 1; }
.pix-seed-copy { flex: 0 0 auto; min-width: 64px; }
.pix-seed-lastrun {
font-size: 11px;
line-height: 1.6; /* room so descenders (y, g) aren't clipped at the node edge */
color: rgba(255,255,255,0.55);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const style = document.createElement("style");
style.id = "pixaroma-seed-css";
style.textContent = css;
document.head.appendChild(style);
}
injectCSS();
// Locked node WIDTH; the layout is fixed (no reason to resize), which also
// sidesteps the Nodes 2.0 resize-floor handling a draggable DOM node needs.
const NODE_W = 226;
// Body height is MEASURED from the actual content (see measureSeedHeight), not a
// hand-guessed constant — guessing the constant is what caused the gap-then-clip
// oscillation. This fallback is used ONLY before the body is laid out (a fresh
// drop / first paint); the real measure takes over the instant children exist.
const WIDGET_H_FALLBACK = 216;
const NODE_H_HINT = WIDGET_H_FALLBACK + 48; // starting height (replace-branch only)
const STATE_PROP = "seedState";
const HIDDEN_INPUT_NAME = "SeedState"; // matches Python INPUT_TYPES key
const DEFAULT_STATE = {
seed: 0,
mode: "random", // "random" | "fixed"
};
// The last-run seed is session-only RUNTIME state on node._pixSeedLastRun
// (NOT node.properties), so a run never rewrites serialized state and can never
// dirty a saved workflow (Vue Compat #18). It doesn't survive a reload, which
// matches the "this session's last run" meaning.
// Roll an exact integer in [0, 2^53) — within JS safe-integer range (so it
// round-trips precisely) and well inside ComfyUI's 0..2^64-1 seed bounds.
function rollSeed() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
function clampSeed(n) {
n = Math.floor(Number(n));
if (!Number.isFinite(n) || n < 0) return 0;
if (n > Number.MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER;
return n;
}
// Shrink the seed number's font until it fits the field (a 16-digit seed
// overflows the narrower Nodes 2.0 body at the base 19px). Idempotent and
// cheap; safe to call repeatedly. No-op until the field is laid out.
function fitSeedFont(num) {
if (!num || !num.isConnected) return;
const MAX = 19, MIN = 11;
num.style.fontSize = MAX + "px";
if (!num.clientWidth) return; // not laid out yet — a scheduled retry will catch it
let fs = MAX, guard = 0;
while (fs > MIN && num.scrollWidth > num.clientWidth + 1 && guard++ < 24) {
fs -= 1;
num.style.fontSize = fs + "px";
}
}
// Measure the body's content height (children offsetHeight + gaps + padding) so
// the node sizes itself with NO hand-guessed constant. Coarse-round to a 4px
// grid so font/sub-pixel jitter can't creep node.size across save/load
// (dirty-on-load, Vue Compat #18). Falls back to a placeholder before the body
// is laid out (children have offsetHeight 0 on a fresh drop).
function measureSeedHeight(root) {
const h = root ? measureRootContent(root) : 0;
if (!(h > 20)) return WIDGET_H_FALLBACK;
return Math.round(h / 4) * 4;
}
function readState(node) {
const v = node.properties?.[STATE_PROP];
if (typeof v === "string" && v) {
try { return { ...DEFAULT_STATE, ...JSON.parse(v) }; }
catch { /* fall through */ }
}
return { ...DEFAULT_STATE };
}
function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = JSON.stringify(state);
}
// Fill the "Last run" line for the current state (random: actual last seed;
// fixed: a plain hint so the line keeps a constant height = no layout gap).
function refreshLastRunEl(el, mode, lastSeed) {
if (mode === "fixed") {
el.textContent = "Fixed: same seed every run";
} else if (lastSeed != null) {
el.textContent = `Last run: ${lastSeed}`;
} else {
el.textContent = "Last run: not run yet";
}
}
// Lightweight refresh used by the graphToPrompt hook — updates the last-run
// line + the "Use last seed" disabled state WITHOUT rebuilding the DOM (so an
// in-progress number edit isn't disrupted).
function refreshLastRun(node) {
const root = node._pixSeedRoot;
if (!root || !root.isConnected) return;
const state = readState(node);
const lastSeed = node._pixSeedLastRun ?? null;
const lr = root.querySelector(".pix-seed-lastrun");
if (lr) refreshLastRunEl(lr, state.mode, lastSeed);
const useLast = root.querySelector(".pix-seed-uselast");
if (useLast) useLast.disabled = lastSeed == null;
}
// Toggle the Random|Fixed pill's active segment in place (no DOM rebuild), so
// committing the number field by clicking a pill/button never destroys that
// control mid-click.
function syncModeUI(root, mode) {
root.querySelectorAll(".pix-seed-seg").forEach((s) => {
s.classList.toggle("active", s.dataset.mode === mode);
});
}
function copySeed(node, btn) {
const state = readState(node);
// What-you-see-is-what-you-copy: copy exactly the seed shown in the big
// field. To grab the actual last-run seed in Random mode, click "Use last
// seed" first (it loads that seed into the field), then Copy.
const text = String(clampSeed(state.seed));
const flash = (ok) => {
btn.classList.toggle("is-flashing", ok);
btn.textContent = ok ? "Copied" : "No clipboard";
setTimeout(() => { btn.classList.remove("is-flashing"); btn.textContent = "Copy"; }, 700);
};
// Fallback for INSECURE contexts (ComfyUI served over http://<LAN-IP>), where
// navigator.clipboard is undefined — a throwaway textarea + execCommand still
// works because the click is a user gesture. Mirrors Version Check / Show Text.
const legacyCopy = () => {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
let ok = false;
try {
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
} catch (_e) {
ok = false;
} finally {
ta.remove(); // always remove, even if execCommand throws
}
flash(ok);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(() => flash(true)).catch(legacyCopy);
} else {
legacyCopy();
}
}
// Build the node body into `root` (the DOM widget element). Kept separate from
// renderUI so the INITIAL render can target the captured root element even
// before LiteGraph has attached it — bailing on isConnected there (the first
// version did) left the body blank on a fresh drop.
function buildSeedBody(node, root) {
const state = readState(node);
const lastSeed = node._pixSeedLastRun ?? null; // session-only (see DEFAULT_STATE note)
root.innerHTML = "";
// ── big editable seed number ──────────────────────────────────
const num = document.createElement("input");
num.type = "text";
num.spellcheck = false;
num.autocomplete = "off";
num.inputMode = "numeric";
num.className = "pix-seed-num";
num.value = String(state.seed);
num.title = "The seed value. Type a number to set an exact seed (switches to Fixed).";
const commitNum = () => {
const cleaned = num.value.replace(/[^\d]/g, "");
const cur = readState(node);
// Empty / non-numeric input keeps the existing seed instead of wiping to 0.
const v = cleaned === "" ? cur.seed : clampSeed(cleaned);
num.value = String(v); // reflect any clamp
fitSeedFont(num); // a newly-typed long seed may need a smaller font to fit
// No change -> don't flip the mode on a bare focus/blur, and don't rebuild.
if (v === cur.seed) return;
writeState(node, { ...cur, seed: v, mode: "fixed" });
// Surgical UI sync (NOT a full renderUI rebuild) so blurring the field by
// clicking a pill/button can't destroy that control mid-click.
syncModeUI(root, "fixed");
refreshLastRun(node);
};
num.addEventListener("keydown", (e) => {
e.stopPropagation(); // keep ComfyUI canvas shortcuts from firing while typing
if (e.key === "Enter") { e.preventDefault(); num.blur(); }
});
num.addEventListener("blur", commitNum);
root.appendChild(num);
// ── Random | Fixed pill ───────────────────────────────────────
const pill = document.createElement("div");
pill.className = "pix-seed-pill";
for (const [m, label] of [["random", "Random"], ["fixed", "Fixed"]]) {
const seg = document.createElement("button");
seg.type = "button";
seg.className = "pix-seed-seg" + (state.mode === m ? " active" : "");
seg.textContent = label;
seg.dataset.mode = m;
seg.title = m === "random"
? "Roll a new random seed every run."
: "Keep the same seed every run (repeatable result).";
seg.addEventListener("click", () => {
const cur = readState(node);
if (cur.mode === m) return;
writeState(node, { ...cur, mode: m });
renderUI(node);
});
pill.appendChild(seg);
}
root.appendChild(pill);
// ── New fixed random ──────────────────────────────────────────
const newBtn = document.createElement("button");
newBtn.type = "button";
newBtn.className = "pix-seed-btn pix-seed-newrandom";
newBtn.textContent = "New fixed random";
newBtn.title = "Roll a brand-new random seed and lock it (switches to Fixed).";
newBtn.addEventListener("click", () => {
const cur = readState(node);
writeState(node, { ...cur, seed: rollSeed(), mode: "fixed" });
renderUI(node);
});
root.appendChild(newBtn);
// ── Use last seed · Copy ──────────────────────────────────────
const row = document.createElement("div");
row.className = "pix-seed-row";
const useLast = document.createElement("button");
useLast.type = "button";
useLast.className = "pix-seed-btn pix-seed-uselast";
useLast.textContent = "Use last seed";
useLast.title = "Load the seed from the previous run and lock it (Fixed).";
useLast.disabled = lastSeed == null;
useLast.addEventListener("click", () => {
const last = node._pixSeedLastRun;
if (last == null) return;
const cur = readState(node);
writeState(node, { ...cur, seed: clampSeed(last), mode: "fixed" });
renderUI(node);
});
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "pix-seed-btn pix-seed-copy";
copyBtn.textContent = "Copy";
copyBtn.title = "Copy the seed shown above to the clipboard.";
copyBtn.addEventListener("click", () => copySeed(node, copyBtn));
row.append(useLast, copyBtn);
root.appendChild(row);
// ── last-run line ─────────────────────────────────────────────
const lr = document.createElement("div");
lr.className = "pix-seed-lastrun";
refreshLastRunEl(lr, state.mode, lastSeed);
root.appendChild(lr);
// Fit the number font now and shortly after — covers the fresh-drop case
// where the widget isn't laid out on the first frame (in either renderer).
requestAnimationFrame(() => fitSeedFont(num));
setTimeout(() => fitSeedFont(num), 60);
setTimeout(() => fitSeedFont(num), 220);
}
// Resolve the live root element (adopting the widget's element if Vue swapped
// it out) and (re)build the body. Does NOT bail merely because the element
// isn't attached yet — it builds into the cached root so the content is there
// the moment LiteGraph draws the widget. Used for re-renders (clicks, configure).
function renderUI(node) {
let root = node._pixSeedRoot;
if (!root || !root.isConnected) {
const w = (node.widgets || []).find((x) => x.name === "seed_ui");
const el = w?.element;
if (el) {
root = el.classList?.contains("pix-seed-root")
? el
: (el.querySelector(".pix-seed-root") || (() => {
const r = document.createElement("div");
r.className = "pix-seed-root";
el.appendChild(r);
return r;
})());
node._pixSeedRoot = root;
}
}
if (!root) return;
buildSeedBody(node, root);
}
function setupSeedNode(node) {
// Defensive: hide any SeedState widget (none exists with the hidden input).
hideJsonWidget(node.widgets, HIDDEN_INPUT_NAME);
node.resizable = false;
// Lock WIDTH only; do NOT force the height. getMinHeight (measured) is the
// floor and there is no getMaxHeight, so LiteGraph sizes the node to exactly
// chrome + content — no gap, no clip. Forcing a height is what produced the
// gap-then-clip oscillation. (The replace branch needs a starting height; the
// post-layout snap below corrects it immediately.)
if (Array.isArray(node.size)) { node.size[0] = NODE_W; }
else { node.size = [NODE_W, NODE_H_HINT]; }
const root = document.createElement("div");
root.className = "pix-seed-root";
const _widget = node.addDOMWidget("seed_ui", "pixaroma_seed", root, {
getValue: () => readState(node),
setValue: () => {},
// Measured content height, and NO getMaxHeight: a single-widget node has no
// slack consumer, so an upper cap can only clip the bottom line (that was
// the bug). Without a cap, LiteGraph sizes the body to exactly the content.
getMinHeight: () => measureSeedHeight(root),
margin: 4,
serialize: false, // state lives on node.properties, not this widget
});
applyAdaptiveCanvasOnly(_widget);
// Nodes 2.0: a widget WITH computeLayoutSize is the grower row (CLAUDE.md). It's
// the sole visible widget, so it's safely the grower; minWidth:1 keeps the
// locked width round-tripping (Compare gotcha 2).
_widget.computeLayoutSize = () => ({ minHeight: measureSeedHeight(root), minWidth: 1 });
node._pixSeedRoot = root;
// Deferred initial render — nodeCreated fires BEFORE configure() restores a
// saved workflow's properties (Vue Compat #8). A fresh node (no saved state)
// gets a random starting seed so the big number isn't a lonely 0; a restored
// node already has seedState so we leave it untouched (no dirty-on-load).
queueMicrotask(() => {
if (!node.properties?.[STATE_PROP]) {
writeState(node, { ...DEFAULT_STATE, seed: rollSeed() });
}
// Build into the captured `root` directly — it may not be attached to the
// page yet on a fresh drop, but the content shows once LiteGraph draws it.
buildSeedBody(node, root);
// Once the body is laid out, snap the node to the measured content height
// (LEGACY only — Nodes 2.0 sizes via computeLayoutSize). Coarse-rounded, so
// this is idempotent on reload and never dirties a saved workflow. Two
// attempts cover whichever frame the body finishes laying out on.
const snap = () => {
if (!isVueNodes() && typeof node.setSize === "function") {
node.setSize([NODE_W, node.computeSize()[1]]);
}
};
requestAnimationFrame(snap);
setTimeout(snap, 120);
});
}
app.registerExtension({
name: "Pixaroma.Seed",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaSeed") return;
// Re-render when a different workflow is configured into an existing node.
const _origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = _origConfigure?.apply(this, arguments);
if (this._pixSeedRoot) renderUI(this);
return r;
};
// Lock WIDTH only, and only in LEGACY — in Nodes 2.0 the rendered size lives
// in the Vue layout store, so writing node.size there desyncs it. Height is
// governed by the measured getMinHeight / computeLayoutSize.
const _origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (!isVueNodes()) this.size[0] = NODE_W;
if (_origResize) return _origResize.call(this, size);
};
},
nodeCreated(node) {
if (node.comfyClass !== "PixaromaSeed") return;
setupSeedNode(node);
},
});
// ── Inject the resolved per-run seed into the API prompt ──────────────────
// Python's hidden SeedState input gets no value from the workflow JSON (no
// widget). On each graphToPrompt (≈ once per Run) we roll a fresh seed for
// Random-mode nodes, record it as the last-run seed, and inject it. Fixed-mode
// nodes inject their locked value (constant → ComfyUI caches → repeatable).
//
// Subgraph-safe: identify entries by class_type and resolve the live node via
// a recursive walk (composite ids like "5:12"), same as Resolution Pixaroma.
// NOTE: graphToPrompt also runs for non-queue actions (e.g. "Save (API
// format)"); a spurious extra roll there only bumps the cosmetic last-run
// readout — harmless.
function buildSeedNodeIndex() {
const index = new Map(); // String(node.id) → node
const visit = (graph) => {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (!n) continue;
if (n.comfyClass === "PixaromaSeed" || n.type === "PixaromaSeed") {
index.set(String(n.id), n);
}
const inner = n.subgraph || n.graph || n._graph;
if (inner && inner !== graph) visit(inner);
}
};
visit(app.graph);
return index;
}
function findSeedNode(index, promptId) {
const sId = String(promptId);
if (index.has(sId)) return index.get(sId);
const tail = sId.includes(":") ? sId.slice(sId.lastIndexOf(":") + 1) : null;
if (tail && index.has(tail)) return index.get(tail);
return null;
}
let _seedRunNonce = 0; // monotonic per-call nonce for Random mode (see hook below)
const _origGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await _origGraphToPrompt(...args);
try {
const out = result?.output;
if (out) {
let index = null;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== "PixaromaSeed") continue;
if (!index) index = buildSeedNodeIndex();
const node = findSeedNode(index, id);
let runSeed = 0;
let isRandom = false;
if (node) {
const st = readState(node);
isRandom = st.mode === "random";
runSeed = isRandom ? rollSeed() : clampSeed(st.seed);
// Record the last-run seed on a RUNTIME field only (never
// node.properties) so a run can't dirty a saved workflow (Vue Compat #18).
node._pixSeedLastRun = runSeed;
refreshLastRun(node);
}
entry.inputs = entry.inputs || {};
// Random: add a per-call nonce so the injected string ALWAYS differs and
// the node re-runs even on the ~1-in-2^53 chance two rolls collide.
// Fixed: NO nonce, so the string is constant and ComfyUI caches it
// (repeatable). get_seed ignores the nonce.
entry.inputs[HIDDEN_INPUT_NAME] = isRandom
? JSON.stringify({ runSeed, _n: ++_seedRunNonce })
: JSON.stringify({ runSeed });
}
}
} catch (e) {
console.warn("[PixaromaSeed] graphToPrompt inject failed", e);
}
return result;
};
-61
View File
@@ -1,61 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - Get mirrors its Set's colour ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// You colour a Set however you like (right-click -> Colors). A Get that reads
// that Set then takes the SAME colour, so a matching pair is easy to spot, and
// the Get follows along if you recolour the Set later. The Get's dropdown also
// tags each name with that Set's colour. Turn the setting off to leave Gets on
// their own colour.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { GET_TYPE, allLiveGraphs, findSetterByName } from "./scope.mjs";
export const SETTING_ID = "Pixaroma.SetGet.ColorMatch";
export const BRAND_TITLE = "#1d1d1d";
export const BRAND_BODY = "#2a2a2a";
export function isColorMatchOn() {
const v = app.ui?.settings?.getSettingValue?.(SETTING_ID);
return v == null ? true : !!v;
}
// The colour a Get should show = the colour of the Set it reads (whatever the
// user picked for that Set), or brand-dark when there is no Set in scope.
export function setColorFor(getNode) {
const node = findSetterByName(getNode.graph, getNode.widgets?.[0]?.value)?.node;
return {
color: node?.color ?? BRAND_TITLE,
bgcolor: node?.bgcolor ?? BRAND_BODY,
};
}
// Make a Get mirror its Set's colour. Skipped during a load (the saved colour
// is restored by configure) and when the setting is off, and it only writes on
// a real change so it never spams redraws or dirties a workflow.
export function inheritSetColor(getNode) {
if (!getNode || !isColorMatchOn()) return;
try {
if (isGraphLoading()) return;
} catch {
/* ignore */
}
const { color, bgcolor } = setColorFor(getNode);
if (getNode.color !== color || getNode.bgcolor !== bgcolor) {
getNode.color = color;
getNode.bgcolor = bgcolor;
getNode.setDirtyCanvas?.(true, true);
}
}
// Re-mirror every Get in the workflow (called when the setting is toggled).
export function recolorAllGets() {
const graph = app.canvas?.graph || app.graph;
for (const g of allLiveGraphs(graph)) {
for (const n of g._nodes || []) {
if (n.type === GET_TYPE) inheritSetColor(n);
}
}
app.canvas?.setDirty(true, true);
}
-270
View File
@@ -1,270 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Get Pixaroma - virtual "named variable" source ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Minimal virtual node: a dropdown that picks which Set Pixaroma to read (by
// name, scoped to this graph + ancestors) + ONE output that matches the chosen
// Set's type. Purely frontend (isVirtualNode); at submission it resolves
// straight through to the Set's real upstream source:
// - getInputLink(slot) → same-graph (classic prompt path)
// - resolveVirtualOutput(slot) → cross-graph / subgraph (native 1.45.15 path)
// Both are implemented so it works on every prompt-build path and inside
// subgraphs without any monkey-patch.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
SET_TYPE,
GET_TYPE,
getLink,
findSetterByName,
findRootGraph,
getVisibleSetNames,
pasteRenameMap,
} from "./scope.mjs";
import { ensureValueWidget, refreshValue, paintReadout } from "./value_preview.mjs";
import { inheritSetColor } from "./colors.mjs";
const BRAND_TITLE = "#1d1d1d";
const BRAND_BODY = "#2a2a2a";
const CATEGORY = "👑 Pixaroma/🔀 Logic & Flow";
export function registerPixaromaGetNode() {
const LGraphNode = LiteGraph.LGraphNode;
class PixaromaGetNode extends LGraphNode {
static title = "Get Pixaroma";
static category = CATEGORY;
constructor(title) {
super(title);
this.isVirtualNode = true;
this.serialize_widgets = true;
this.comfyClass = GET_TYPE; // for the Help (?) toolbar button (convention #16)
this.currentSetter = null;
this.properties = this.properties || {};
if (!this.color) this.color = BRAND_TITLE;
if (!this.bgcolor) this.bgcolor = BRAND_BODY;
// Live combo: values are the in-scope Set names, read fresh each render.
const comboOptions = {};
Object.defineProperty(comboOptions, "values", {
get: () => (this.graph ? getVisibleSetNames(this.graph) : []),
enumerable: true,
configurable: true,
});
const widget = this.addWidget(
"combo",
"name",
"",
() => {
if (!app.configuringGraph) this.onRename();
},
comboOptions
);
// Legacy-only: the bundled ComboWidget mis-handles a live-getter values
// list (empty-array edge case), so drive a ContextMenu ourselves. Vue
// mode renders the combo natively and is left untouched.
const origOnClick = widget.onClick?.bind(widget);
widget.onClick = (params) => {
if (LiteGraph.vueNodesMode) return origOnClick?.(params);
const { e, canvas, node } = params;
const x = e.canvasX - node.pos[0];
const width = widget.width || node.size[0];
if (x < 40) return widget.decrementValue?.({ e, node, canvas });
if (x > width - 40) return widget.incrementValue?.({ e, node, canvas });
const values = comboOptions.values;
if (!values.length) return;
const menu = new LiteGraph.ContextMenu(values, {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: "dark",
callback: (v) => widget.setValue?.(v, { e, node, canvas }),
});
// Colour each entry's left edge by that Set's own colour.
const entries = menu.root?.querySelectorAll(".litemenu-entry");
values.forEach((nm, i) => {
const el = entries?.[i];
if (!el) return;
const setter = findSetterByName(this.graph, nm);
el.style.borderLeft = `4px solid ${setter?.node?.bgcolor || "#888"}`;
el.style.paddingLeft = "8px";
});
};
// Nodes 2.0: swap the options reference (live getter preserved) and
// re-splice the widget so Vue re-extracts the values.
this._refreshComboOptions = () => {
const w = this.widgets?.[0];
if (!w) return;
const fresh = {};
Object.defineProperty(
fresh,
"values",
Object.getOwnPropertyDescriptor(comboOptions, "values")
);
w.options = fresh;
const idx = this.widgets.indexOf(w);
if (idx >= 0) {
this.widgets.splice(idx, 1);
this.widgets.splice(idx, 0, w);
}
};
this.addOutput("*", "*");
}
onDrawForeground(ctx) {
super.onDrawForeground?.(ctx);
paintReadout(this, ctx);
}
onConnectionsChange() {
// Skip during load + the post-configure link-restore (Vue Compat #19).
if (app.configuringGraph || isGraphLoading()) return;
this.validateLinks();
}
setName(name) {
this.widgets[0].value = name;
this.onRename();
}
setType(type) {
this.outputs[0].type = type;
this.outputs[0].name = type;
this.validateLinks();
}
onRename() {
const setter = this.findSetter(this.graph);
const name = this.widgets[0].value;
if (setter) {
this.setType(setter.inputs?.[0]?.type ?? "*");
this.title = `Get: ${name}`;
} else {
this.setType("*");
this.title = name ? `Get: ${name}` : "Get Pixaroma";
}
inheritSetColor(this); // take the chosen Set's colour
refreshValue(this);
app.canvas?.setDirty(true, true);
}
// Drop any output links whose target type is now incompatible.
validateLinks() {
const out = this.outputs?.[0];
if (!this.graph || !out || out.type === "*" || !out.links) return;
for (const linkId of [...out.links]) {
const link = getLink(this.graph, linkId);
if (!link) continue;
const target = this.graph.getNodeById(link.target_id);
const tType = target?.inputs?.[link.target_slot]?.type;
if (tType && tType !== "*" && !String(tType).split(",").includes(out.type)) {
this.graph.removeLink(linkId);
}
}
}
findSetter(graph) {
const r = findSetterByName(graph, this.widgets[0].value);
return r ? r.node : undefined;
}
// Classic prompt path: same-graph resolution. Returns the link feeding the
// matching Set's input, so the prompt builder reads straight through.
getInputLink(slot) {
const name = this.widgets[0].value;
if (!name) return null;
const setter = this.graph?._nodes?.find(
(n) => n.type === SET_TYPE && n.widgets?.[0]?.value === name
);
if (!setter) return null;
const slotInfo = setter.inputs?.[slot];
if (!slotInfo || slotInfo.link == null) return null;
return getLink(this.graph, slotInfo.link);
}
// Subgraph-aware path (native in 1.45.15). Returns the REAL source
// {node, slot} for a cross-graph Set; returns undefined for same-graph so
// the classic getInputLink path above handles it.
resolveVirtualOutput(slot) {
const result = findSetterByName(this.graph, this.widgets[0].value);
if (!result) return undefined;
if (result.graph === this.graph) return undefined;
const { node: setter, graph: setterGraph } = result;
const slotInfo = setter.inputs?.[slot];
if (!slotInfo || slotInfo.link == null) return undefined;
const link = getLink(setterGraph, slotInfo.link);
if (!link) return undefined;
const src = setterGraph.getNodeById(link.origin_id);
if (!src) return undefined;
return { node: src, slot: link.origin_slot };
}
onAdded() {
ensureValueWidget(this);
this._justAdded = true;
}
onConfigure() {
if (this._justAdded && !app.configuringGraph && this.widgets[0].value) {
// If our paired Set was de-duplicated during this same paste, follow it.
const renamed = pasteRenameMap.get(this.widgets[0].value);
if (renamed) this.widgets[0].value = renamed;
// Restore type/title from the setter after a paste (skip if removed).
setTimeout(() => {
if (this.graph) this.onRename();
}, 0);
}
this._justAdded = false;
refreshValue(this);
}
clone() {
const cloned = super.clone();
if (cloned.outputs?.[0]) {
cloned.outputs[0].type = "*";
cloned.outputs[0].name = "*";
}
return cloned;
}
goToSetter() {
const setter = this.currentSetter;
if (!setter) return;
const canvas = app.canvas;
const setterGraph = setter.graph;
if (setterGraph && setterGraph !== this.graph && canvas?.setGraph) {
canvas.setGraph(setterGraph);
setTimeout(() => {
canvas.centerOnNode?.(setter);
canvas.selectNode?.(setter, false);
canvas.setDirty(true, true);
}, 0);
} else {
canvas?.centerOnNode?.(setter);
canvas?.selectNode?.(setter, false);
canvas?.setDirty(true, true);
}
}
getExtraMenuOptions(_, options) {
this.currentSetter = this.findSetter(this.graph);
if (!this.currentSetter) return;
const sameGraph = this.currentSetter.graph === this.graph;
const isRoot = this.currentSetter.graph === findRootGraph(this.graph);
options.unshift({
content: sameGraph
? "Jump to its Set Pixaroma"
: `Jump to its Set (in ${isRoot ? "parent graph" : "subgraph"})`,
callback: () => this.goToSetter(),
});
}
}
LiteGraph.registerNodeType(GET_TYPE, PixaromaGetNode);
}
-92
View File
@@ -1,92 +0,0 @@
// Help content for Set / Get Pixaroma, shown by the orange ? button in the node
// selection toolbar (convention #16). Registered against the virtual nodes'
// comfyClass (set in each node's constructor) so the toolbar finds it.
import { registerNodeHelp } from "../shared/index.mjs";
registerNodeHelp("PixaromaSetNode", {
title: "Set Pixaroma",
tagline: "Store any wire under a name, then read it back anywhere with Get Pixaroma.",
sections: [
{
heading: "What it does",
body:
"Set Pixaroma is a wireless wire. Connect anything into it (an image, a model, a number, a prompt) and give it a name. A Get Pixaroma node anywhere in the workflow can then read that same value by picking the name, with no cable running across the canvas.\n\nIt also has a passthrough output: wire it straight to a node sitting nearby, and use Get nodes for the ones far away - same value either way.\n\nIt lives only in the editor. At run time the value flows straight from the original source, exactly as if you had wired it directly, so it never slows anything down or changes the result.",
},
{
heading: "How to use it",
bullets: [
"Drag a connection into the input. The node takes on that wire's type and colour.",
"Type a name in the field (for example length, base_model, positive).",
"Wire the output to a nearby node directly, or add Get Pixaroma nodes for far-apart ones.",
"Keep the node collapsed (click the dot in its title bar) to keep the canvas tidy.",
],
},
{
heading: "Colours",
body:
"Colour a Set however you like (right-click -> Colors). Any Get that reads it takes the same colour, so matching pairs are easy to spot, and the Gets follow along when you recolour the Set later. Turn off 'Get matches its Set's colour' in Settings (under Pixaroma) to leave Gets on their own colour.",
},
{
heading: "Right-click menu",
defs: [
["Select all its Gets", "Highlights every Get node in this graph that reads this name."],
["Add paired Get Pixaroma", "Drops a Get already set to this name next to the node."],
["Jump to Get in subgraph", "For Gets that live inside a subgraph, opens that subgraph at the Get."],
],
},
{
heading: "Subgraphs (important)",
body:
"Think of subgraphs as nested boxes. A Set reaches INWARD, never outward: it can be read inside its own graph and any subgraph nested deeper inside it, but never by the graph outside it.\n\nSo a Set in the MAIN graph can be read by Gets everywhere, including inside your subgraphs. A Set placed INSIDE a subgraph stays private to that subgraph, so a Get outside cannot see it.\n\nTip: put a Set in the main graph for any value you want to reach everywhere. Because each Set stays in its own box, two separate subgraphs can reuse the same name for different things without clashing.",
},
{
heading: "The little value line",
body:
"When the node is expanded and the value is a plain number, text, or true/false, a small grey readout shows it (for example = 81). Images, models, and latents show no preview, just the name.\n\nIt is a quick helper that peeks at the source field, so a plain Number or Text node reads exactly. If the value is calculated by another node (a math node, a sampler), it is only known once you run, so the line stays blank. It is display only, never saved, and never changes the value that actually flows.",
},
],
footer: "Tip: keep Set/Get nodes collapsed. They are meant to disappear into the background.",
});
registerNodeHelp("PixaromaGetNode", {
title: "Get Pixaroma",
tagline: "Read a value that a Set Pixaroma node stored under a name, with no cable.",
sections: [
{
heading: "What it does",
body:
"Get Pixaroma is the other half of the wireless wire. Pick the name of a Set Pixaroma node from its dropdown and the output carries that Set's value, matching its type and colour. Wire the output wherever you need it.\n\nLike Set, it exists only in the editor. At run time the connection resolves straight to the original source, so there is no extra cost and the result is identical to a direct cable.",
},
{
heading: "How to use it",
bullets: [
"Click the dropdown and choose which Set to read (it lists the Sets it can reach from here).",
"Wire the output into any matching input.",
"Add as many Get nodes as you like for the same name to fan one value out.",
],
},
{
heading: "Colours",
body:
"A Get takes the colour of the Set it reads, so a matching pair is easy to spot, and each name in the dropdown is tagged with that Set's colour. Colour the Set (right-click -> Colors) and the Get follows. Turn off 'Get matches its Set's colour' in Settings (under Pixaroma) to leave Gets alone.",
},
{
heading: "Right-click menu",
defs: [
["Jump to its Set Pixaroma", "Selects and centres the Set this Get is reading, even across subgraphs."],
],
},
{
heading: "Subgraphs (important)",
body:
"A Get reads OUTWARD. It looks for its Set in its own subgraph first, then the graph just outside it, and so on out to the main graph. So a Get inside a subgraph can read a Set in the main graph (or any graph that contains it), but it cannot reach a Set buried inside a different subgraph.\n\nShort version: a Set reaches inward, a Get reads outward. Keep shared Sets in the main graph and any Get can read them. If the dropdown does not list a name you made, that Set is in a box this Get cannot see into.",
},
{
heading: "The little value line",
body:
"When expanded, a plain number, text, or true/false shows as a small grey readout (for example = 81), mirroring the chosen Set. A value calculated by another node is only known once you run, so the line stays blank until then. Display only, never saved, never changes the value that flows.",
},
],
footer: "If the dropdown is empty, add a Set Pixaroma node first and give it a name.",
});
-44
View File
@@ -1,44 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - wireless "named variable" node pair ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Pixaroma's own wireless "named variable" node pair, in a PRIVATE namespace:
// classes PixaromaSetNode / PixaromaGetNode with their own registry
// (js/set_get/scope.mjs) that only ever scans Pixaroma Set/Get. It coexists with
// any other pack's Set/Get-style nodes in one workflow with zero interference.
//
// Both are pure-frontend VIRTUAL nodes (isVirtualNode = true): no Python, never
// in the prompt. Resolution at submission goes straight through to the real
// source via getInputLink (same-graph) + resolveVirtualOutput (subgraph). Works
// in both Classic and Nodes 2.0, and inside subgraphs (native path verified on
// frontend 1.45.15).
import { app } from "/scripts/app.js";
import { registerPixaromaSetNode } from "./set_node.mjs";
import { registerPixaromaGetNode } from "./get_node.mjs";
import { startValuePoll } from "./value_preview.mjs";
import { SETTING_ID, recolorAllGets } from "./colors.mjs";
import "./help.mjs"; // registers help for both nodes (convention #16)
app.registerExtension({
name: "Pixaroma.SetGet",
settings: [
{
id: SETTING_ID,
name: "Get matches its Set's colour",
type: "boolean",
defaultValue: true,
tooltip:
"A Get node takes the colour you gave its Set, so matching pairs are easy to spot. Turn off to leave Gets on their own colour.",
category: ["👑 Pixaroma", "Set & Get"],
onChange: () => recolorAllGets(),
},
],
registerCustomNodes() {
registerPixaromaSetNode();
registerPixaromaGetNode();
},
setup() {
startValuePoll();
},
});
-169
View File
@@ -1,169 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - cross-graph scope helpers ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// PRIVATE NAMESPACE: every helper here scans ONLY PixaromaSetNode /
// PixaromaGetNode. It never touches any other pack's Set/Get-style nodes, so
// they coexist in one workflow with zero interference.
//
// Subgraph scoping (the model the user picked):
// - A Set is visible to its own graph AND every nested child subgraph
// (propagates DOWN).
// - A Get searches its own graph FIRST, then parent, grandparent, ... root
// (looks UP).
// - Duplicate names are allowed in unrelated (sibling) subgraphs.
//
// The traversal is verified against the native resolveVirtualOutput path in
// frontend 1.45.15, so subgraph flattening at submission "just works".
export const SET_TYPE = "PixaromaSetNode";
export const GET_TYPE = "PixaromaGetNode";
// Paste coordination: when a pasted Set's name is de-duplicated (foo -> foo_0),
// it records old->new here so the Set+Get pasted together stay paired. Both
// nodes' onConfigure run synchronously within the same paste cycle, before the
// setTimeout(0) clears the entry.
export const pasteRenameMap = new Map();
// Compat: graph.links/_links may be a Map or a plain object depending on the
// litegraph version. `== null` intentionally catches both null and undefined.
export function getLink(graph, linkId) {
if (!graph || linkId == null) return null;
if (typeof graph.getLink === "function") return graph.getLink(linkId);
const store = graph._links ?? graph.links;
if (store instanceof Map) return store.get(linkId) ?? null;
return store?.[linkId] ?? null;
}
export function findRootGraph(graph) {
if (!graph) return null;
return graph.rootGraph || graph;
}
// Which SubgraphNode in parentGraph wraps the graph that holds innerNode.
export function findSubgraphNodeFor(parentGraph, innerNode) {
if (!parentGraph?._nodes || !innerNode?.graph) return null;
for (const n of parentGraph._nodes) {
if (n.subgraph && n.subgraph === innerNode.graph) return n;
}
return null;
}
// Every live graph in the workflow: root + every nested subgraph instance, at
// any depth. Enumerated by walking real SubgraphNode.subgraph references (the
// same source getGraphDescendants uses), so it does NOT depend on the internal
// semantics of root._subgraphs - robust for deeply nested subgraphs.
export function allLiveGraphs(graph) {
const root = findRootGraph(graph);
if (!root) return [];
return [root, ...getGraphDescendants(root)];
}
// The graph that directly contains a SubgraphNode wrapping `inner`, or null.
function findParentGraph(inner, root) {
for (const g of allLiveGraphs(root)) {
if (g === inner) continue;
for (const n of g._nodes || []) {
if (n.subgraph === inner) return g;
}
}
return null;
}
// Walk from a graph up to root: [graph, parent, grandparent, ..., root].
// Walks UP by repeatedly finding the parent among the live graphs, so it
// handles arbitrary nesting depth (not just one level).
export function getGraphAncestors(graph) {
if (!graph) return [];
const root = findRootGraph(graph);
if (!root || graph === root) return [root];
const chain = [graph];
const visited = new Set([graph]);
let current = graph;
while (current !== root) {
const parent = findParentGraph(current, root);
if (!parent || visited.has(parent)) break;
visited.add(parent);
chain.push(parent);
current = parent;
}
if (!chain.includes(root)) chain.push(root);
return chain;
}
// All descendant subgraphs of a graph (children, grandchildren, ...).
export function getGraphDescendants(graph, _visited) {
if (!graph?._nodes) return [];
const visited = _visited || new Set();
if (visited.has(graph)) return [];
visited.add(graph);
const out = [];
for (const n of graph._nodes) {
if (n.subgraph && !visited.has(n.subgraph)) {
out.push(n.subgraph);
out.push(...getGraphDescendants(n.subgraph, visited));
}
}
return out;
}
export function collectNodesOfType(graphs, type) {
const out = [];
for (const g of graphs) {
if (!g?._nodes) continue;
for (const node of g._nodes) {
if (node.type === type) out.push({ node, graph: g });
}
}
return out;
}
// Every node of `type` across root + all nested subgraphs. Used for global ops.
export function findAllNodesOfType(graph, type) {
return collectNodesOfType(allLiveGraphs(graph), type);
}
// Scoped setter lookup: own graph first, then ancestors (look UP).
export function findSetterByName(graph, name) {
if (!name) return null;
for (const g of getGraphAncestors(graph)) {
if (!g?._nodes) continue;
for (const node of g._nodes) {
if (node.type === SET_TYPE && node.widgets?.[0]?.value === name) {
return { node, graph: g };
}
}
}
return null;
}
// Scoped getter lookup: own graph + descendants (propagate DOWN).
export function findGettersByName(graph, name) {
if (!name) return [];
const graphs = [graph, ...getGraphDescendants(graph)];
return collectNodesOfType(graphs, GET_TYPE)
.filter((e) => e.node.widgets?.[0]?.value === name);
}
// Visible Set names for a Get's dropdown: own graph + ancestors (what's in
// scope), de-duplicated, sorted. No type filtering (the user picks freely).
export function getVisibleSetNames(graph) {
const seen = new Set();
for (const e of collectNodesOfType(getGraphAncestors(graph), SET_TYPE)) {
const name = e.node.widgets?.[0]?.value;
if (name) seen.add(name);
}
return [...seen].sort();
}
// Force every Get node (root + all subgraphs) to re-read its combo values.
// Needed in Nodes 2.0 (Vue) where the combo options are cached until the
// widget.options reference changes.
export function refreshAllGetCombos(graph) {
for (const g of allLiveGraphs(graph)) {
for (const node of g._nodes || []) {
if (node.type === GET_TYPE) node._refreshComboOptions?.();
}
}
}
-320
View File
@@ -1,320 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set Pixaroma - virtual "named variable" sink ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Virtual node: ONE input (adopts whatever type is wired) + an editable name +
// a passthrough OUTPUT. Wire the output straight to a nearby node, or read the
// value back from anywhere with Get Pixaroma - same value either way. Purely
// frontend (isVirtualNode), so it never reaches the backend; both the Get nodes
// and the passthrough output resolve straight to the real upstream source.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
SET_TYPE,
GET_TYPE,
getLink,
getGraphAncestors,
collectNodesOfType,
findGettersByName,
findSubgraphNodeFor,
refreshAllGetCombos,
pasteRenameMap,
} from "./scope.mjs";
import { ensureValueWidget, refreshValue, paintReadout } from "./value_preview.mjs";
const BRAND_TITLE = "#1d1d1d"; // matches js/brand/index.js (auto-applied to Python nodes; we set it here)
const BRAND_BODY = "#2a2a2a";
const CATEGORY = "👑 Pixaroma/🔀 Logic & Flow";
export function registerPixaromaSetNode() {
const LGraphNode = LiteGraph.LGraphNode;
class PixaromaSetNode extends LGraphNode {
static title = "Set Pixaroma";
static category = CATEGORY;
constructor(title) {
super(title);
// isVirtualNode → pruned from the prompt; resolved on the frontend only.
this.isVirtualNode = true;
this.serialize_widgets = true;
// comfyClass lets the Help (?) toolbar button find our help (convention
// #16). ComfyUI sets it on the def class it generates from node_set_get.py,
// but registerCustomNodes replaces that class with this one, so we set it
// here too.
this.comfyClass = SET_TYPE;
this.properties = this.properties || {};
if (this.properties.previousName == null) this.properties.previousName = "";
// Brand dark defaults. js/brand/index.js applies these via
// beforeRegisterNodeDef, but that runs on ComfyUI's generated def class
// which we replace with this one, so we set them here too. Guarded so
// workflow-restore + right-click Colors still win.
if (!this.color) this.color = BRAND_TITLE;
if (!this.bgcolor) this.bgcolor = BRAND_BODY;
this.addWidget(
"text",
"name",
"",
() => {
if (!this.graph || app.configuringGraph) return;
this.validateName(this.graph);
this.refreshTitle();
this.update();
this.properties.previousName = this.widgets[0].value;
},
{}
);
this.addInput("*", "*");
this.addOutput("*", "*"); // passthrough: emits whatever is wired in
this.refreshTitle();
}
refreshTitle() {
const v = this.widgets?.[0]?.value;
this.title = v ? `Set: ${v}` : "Set Pixaroma";
}
onDrawForeground(ctx) {
super.onDrawForeground?.(ctx);
paintReadout(this, ctx);
}
// Set the input AND the passthrough output to one type. Colour is left to
// the user (right-click -> Colors); the Get nodes mirror whatever it is.
setAdoptedType(type) {
const t = type || "*";
if (this.inputs?.[0]) {
this.inputs[0].type = t;
this.inputs[0].name = t;
}
if (this.outputs?.[0]) {
this.outputs[0].type = t;
this.outputs[0].name = t;
}
}
onConnectionsChange(slotType, slot, isConnect, link_info) {
// Skip during load AND the link-restore window that fires after
// configure() (Vue Compat #19) so we never rewrite saved state.
if (app.configuringGraph || isGraphLoading()) return;
// Input disconnect → revert to the wildcard type.
if (slotType === LiteGraph.INPUT && !isConnect) {
this.setAdoptedType("*");
this.update();
refreshValue(this);
return;
}
// Input connect → adopt the upstream type (input + output both take it).
if (link_info && this.graph && slotType === LiteGraph.INPUT && isConnect) {
let type;
if (typeof link_info.resolve === "function") {
const r = link_info.resolve(this.graph);
type = (r?.subgraphInput ?? r?.output)?.type;
}
if (!type) {
// Fallback: read the origin output type directly.
const src = this.graph.getNodeById(link_info.origin_id);
type = src?.outputs?.[link_info.origin_slot]?.type;
}
if (type) {
this.setAdoptedType(type);
this.validateName(this.graph);
this.properties.previousName = this.widgets[0].value;
}
}
// Output event → keep the output type mirrored to the input.
if (slotType === LiteGraph.OUTPUT && this.outputs?.[0]) {
const inType = this.inputs?.[0]?.type ?? "*";
this.outputs[0].type = inType;
this.outputs[0].name = inType;
}
this.update();
refreshValue(this);
}
// Passthrough: a node wired to our output resolves to whatever feeds our
// input (same source the Get nodes read). isVirtualNode + this is the
// reroute contract; cross-graph is rare for a directly-wired output, so the
// same-graph link path covers it.
getInputLink() {
// The Set has a single input; its passthrough output reads from it.
const si = this.inputs?.[0];
if (!si || si.link == null) return null;
return getLink(this.graph, si.link);
}
// Ensure the name is unique within scope (own graph + ancestors). Returns
// true if it had to change. sameGraphOnly is used on paste/clone.
validateName(graph, sameGraphOnly) {
let value = this.widgets[0].value;
if (value === "") return false;
const scope = sameGraphOnly ? [graph] : getGraphAncestors(graph);
const existing = new Set();
for (const e of collectNodesOfType(scope, SET_TYPE)) {
if (e.node !== this) existing.add(e.node.widgets?.[0]?.value);
}
const original = value;
const base = this._justAdded ? value.replace(/_\d+$/, "") : value;
let tries = 0;
while (existing.has(value)) {
value = base + "_" + tries;
tries++;
}
this.widgets[0].value = value;
this.refreshTitle();
return value !== original;
}
// Push type + rename out to this Set's getters, refresh combos + readout.
update() {
if (!this.graph) return;
const type = this.inputs?.[0]?.type ?? "*";
const name = this.widgets[0].value;
findGettersByName(this.graph, name).forEach((e) => e.node.setType?.(type));
const prev = this.properties.previousName;
if (name && prev && prev !== name) {
findGettersByName(this.graph, prev).forEach((e) => e.node.setName?.(name));
}
refreshAllGetCombos(this.graph);
refreshValue(this);
app.canvas?.setDirty(true, true);
}
findGetterEntries(graph) {
return findGettersByName(graph, this.widgets[0].value);
}
onAdded() {
ensureValueWidget(this);
this._justAdded = true;
if (LiteGraph.vueNodesMode && this.graph && !app.configuringGraph) {
refreshAllGetCombos(this.graph);
}
}
onRemoved() {
if (!LiteGraph.vueNodesMode) return;
const g = this.graph;
if (!g) return;
// onRemoved fires before _nodes is spliced - defer so getters no longer
// see this Set when their combos re-read.
setTimeout(() => refreshAllGetCombos(g), 0);
}
onConfigure() {
// Migration: a Set saved before the passthrough output existed has none -
// add it so older workflows gain the output (flags the workflow modified
// once; re-saving settles it).
if (this.outputs?.length === 0) {
this.addOutput("*", "*");
const t = this.inputs?.[0]?.type ?? "*";
this.outputs[0].type = t;
this.outputs[0].name = t;
}
// Only run paste de-duplication when actually pasting, not on load.
if (this._justAdded && this.graph && !app.configuringGraph) {
const oldName = this.widgets[0].value;
this.validateName(this.graph, true);
const newName = this.widgets[0].value;
if (newName !== oldName) {
// A Get pasted in the same cycle reads this to stay paired.
pasteRenameMap.set(oldName, newName);
setTimeout(() => pasteRenameMap.delete(oldName), 0);
}
if (this.inputs[0]?.link == null) {
this.setAdoptedType("*");
}
}
this._justAdded = false;
this.refreshTitle();
refreshValue(this);
}
clone() {
const cloned = super.clone();
if (cloned.inputs?.[0]) {
cloned.inputs[0].type = "*";
cloned.inputs[0].name = "*";
}
if (cloned.outputs?.[0]) {
cloned.outputs[0].type = "*";
cloned.outputs[0].name = "*";
}
cloned.properties.previousName = "";
return cloned;
}
getExtraMenuOptions(_, options) {
const entries = this.findGetterEntries(this.graph);
const sameGraph = entries.filter((e) => e.graph === this.graph).map((e) => e.node);
const crossGraph = entries.filter((e) => e.graph !== this.graph);
options.unshift({
content: `Select all its Gets (${entries.length})`,
disabled: entries.length === 0,
callback: () => {
const canvas = app.canvas;
if (sameGraph.length && canvas?.selectNodes) {
canvas.selectNodes(sameGraph);
} else if (sameGraph.length && canvas?.selectNode) {
canvas.deselectAllNodes?.();
for (const n of sameGraph) canvas.selectNode(n, true);
}
canvas?.setDirty(true, true);
},
});
// Cross-subgraph getters can't be multi-selected from here - offer to jump.
if (crossGraph.length) {
options.unshift({
content: "Jump to Get in subgraph",
has_submenu: true,
submenu: {
options: crossGraph.map((e) => {
const sgNode = findSubgraphNodeFor(this.graph, e.node);
return {
content: `${e.node.title} (${sgNode?.title || sgNode?.type || "subgraph"})`,
callback: () => {
const canvas = app.canvas;
if (sgNode && canvas?.openSubgraph) {
canvas.openSubgraph(sgNode.subgraph, sgNode);
setTimeout(() => {
canvas.centerOnNode?.(e.node);
canvas.selectNode?.(e.node, false);
canvas.setDirty(true, true);
}, 0);
}
},
};
}),
},
});
}
options.unshift({
content: "Add paired Get Pixaroma",
callback: () => {
const getNode = LiteGraph.createNode(GET_TYPE);
if (!getNode) return;
getNode.pos = [this.pos[0] + this.size[0] + 30, this.pos[1]];
this.graph.add(getNode);
if (getNode.widgets?.[0]) getNode.widgets[0].value = this.widgets[0].value;
getNode.onRename?.();
app.canvas?.selectNode?.(getNode, false);
app.canvas?.setDirty(true, true);
},
});
}
}
LiteGraph.registerNodeType(SET_TYPE, PixaromaSetNode);
}
-317
View File
@@ -1,317 +0,0 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - best-effort value readout ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// A tiny, SUBTLE "= 81" line, display-only (never serialized), shown only when
// the node is EXPANDED and the wired value is a simple type (INT / FLOAT /
// STRING / BOOLEAN). Image / latent / model / conditioning show nothing.
//
// The value is read best-effort from the upstream source widget (e.g. a Number
// node feeding the Set). If the source is computed (a math node, a sampler,
// etc.) there is no frontend-knowable value, so we show nothing. A light poll
// keeps it live when the user edits the upstream number.
//
// Rendering is per-renderer so the line sits tight under the name field:
// - Classic (LiteGraph): PAINTED in onDrawForeground at the bottom of the
// node body (node grows by one row when shown). Gives exact positioning.
// - Nodes 2.0 (Vue): a DOM element row (the grid lays it out tightly).
import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly } from "../shared/index.mjs";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { SET_TYPE, GET_TYPE, getLink, findSetterByName } from "./scope.mjs";
import { inheritSetColor } from "./colors.mjs";
const SIMPLE_TYPES = new Set(["INT", "FLOAT", "NUMBER", "STRING", "BOOLEAN", "BOOL"]);
const CSS_ID = "pix-setget-css";
const ROW_H = 18;
function isVue() {
return !!window.LiteGraph?.vueNodesMode;
}
export function isSimpleType(t) {
return typeof t === "string" && SIMPLE_TYPES.has(t.toUpperCase());
}
function injectCSS() {
if (document.getElementById(CSS_ID)) return;
const el = document.createElement("style");
el.id = CSS_ID;
// Semi-transparent white so it adapts if the user recolors the node body
// (CLAUDE.md UI convention #1). Mono digits, single line, never wraps.
el.textContent = `
.pix-sg-val {
box-sizing: border-box;
width: 100%;
height: ${ROW_H}px;
line-height: ${ROW_H}px;
padding: 0 8px;
font: 11px/${ROW_H}px monospace;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
`;
document.head.appendChild(el);
}
// Round half to even, matching Python's round() (and Number Pixaroma's int
// output), so an INT-typed preview reads the same as the real value.
function bankersRound(v) {
if (Math.abs(v - Math.trunc(v)) === 0.5) {
const f = Math.floor(v);
return f % 2 === 0 ? f : f + 1;
}
return Math.round(v);
}
function fmt(v, type) {
if (v == null) return null;
if (typeof v === "boolean") return v ? "true" : "false";
if (typeof v === "number") {
if (!isFinite(v)) return null; // hide the line for NaN / Infinity
// An INT-typed slot emits a whole number even when the source widget holds
// a decimal (e.g. Number Pixaroma 5.5 -> its int output is 6), so round.
if (String(type).toUpperCase() === "INT") return String(bankersRound(v));
if (Number.isInteger(v)) return String(v);
return String(+v.toFixed(4));
}
let s = String(v).replace(/\s+/g, " ").trim();
if (s === "") return null;
if (s.length > 28) s = s.slice(0, 27) + "…";
return s;
}
// Best-effort read of a primitive value from a source node's widgets, for the
// specific OUTPUT slot the Set is wired to.
function readPrimitiveWidget(node, slot, type) {
if (!node || !Array.isArray(node.widgets)) return null;
const T = String(type).toUpperCase();
const wantNum = /INT|FLOAT|NUMBER/.test(T);
const wantBool = /BOOL/.test(T);
// Prefer the widget whose name matches the output slot we are reading, so a
// node with both a "width" and a "height" output reads the right one. Falls
// back to the first matching-kind widget (e.g. a Number node whose "int" and
// "float" outputs both come from one "value" widget).
const order = node.widgets.slice();
const outName = node.outputs?.[slot]?.name;
if (outName) {
const i = order.findIndex(
(w) => w && (w.name || "").toLowerCase() === String(outName).toLowerCase()
);
if (i > 0) order.unshift(order.splice(i, 1)[0]);
}
for (const w of order) {
if (!w || w.hidden) continue;
const name = (w.name || "").toLowerCase();
if (name === "control_after_generate") continue;
const val = w.value;
if (wantBool && typeof val === "boolean") return val;
if (wantNum && typeof val === "number") return val;
if (wantNum && typeof val === "string" && val.trim() !== "" && !isNaN(+val)) return +val;
if (!wantNum && !wantBool && typeof val === "string") return val;
}
// No widget matched the wanted kind. For numeric / boolean slots do NOT fall
// back to an unrelated widget: a math node's formula text "a + b" is not its
// computed value (7), so showing it would mislead. Only STRING falls back to
// the first text widget.
if (!wantNum && !wantBool) {
const first = node.widgets[0]?.value;
return typeof first === "string" ? first : null;
}
return null;
}
// Follow virtual reroute-like nodes (Reroute, our own Get) up to a real source.
function resolveRealSource(graph, node, slot, depth) {
if (!node || depth > 8) return null;
if (node.type === GET_TYPE) {
const setter = findSetterByName(node.graph || graph, node.widgets?.[0]?.value);
return setter ? setSourceOf(setter.node) : null;
}
if (node.isVirtualNode && typeof node.getInputLink === "function") {
const l = node.getInputLink(slot);
if (!l || l.origin_id == null) return null;
const g = node.graph || graph;
return resolveRealSource(g, g.getNodeById(l.origin_id), l.origin_slot, depth + 1);
}
return { graph: node.graph || graph, node, slot };
}
// {graph, node, slot} feeding a Set node's input (resolving virtuals), or null.
function setSourceOf(setNode) {
const g = setNode?.graph;
const input = setNode?.inputs?.[0];
if (!g || !input || input.link == null) return null;
const link = getLink(g, input.link);
if (!link) return null;
return resolveRealSource(g, g.getNodeById(link.origin_id), link.origin_slot, 0);
}
// Value string for a Set node (null if not a simple type / not derivable).
export function deriveSetValue(setNode) {
const type = setNode?.inputs?.[0]?.type;
if (!isSimpleType(type) || setNode.inputs[0].link == null) return null;
const src = setSourceOf(setNode);
if (!src) return null;
return fmt(readPrimitiveWidget(src.node, src.slot, type), type);
}
// Value string for a Get node via its resolved setter.
export function deriveGetValue(getNode) {
const name = getNode?.widgets?.[0]?.value;
if (!name) return null;
const setter = findSetterByName(getNode.graph, name);
return setter ? deriveSetValue(setter.node) : null;
}
// Nodes 2.0 only: create the DOM element row once (the Vue grid lays it out
// tightly under the name). Classic paints instead, so it makes no DOM widget.
// Failure-tolerant so a DOM hiccup can never block node creation.
export function ensureValueWidget(node) {
if (!isVue()) return null;
if (node._pixSgValEl) return node._pixSgValEl;
try {
injectCSS();
const el = document.createElement("div");
el.className = "pix-sg-val";
el.style.display = "none";
const w = node.addDOMWidget("pix_setget_value", "pixaroma_setget_value", el, {
serialize: false,
getMinHeight: () => (node._pixSgValShown ? ROW_H : 0),
getMaxHeight: () => (node._pixSgValShown ? ROW_H : 0),
});
w.computeSize = () => [node.size?.[0] || 140, node._pixSgValShown ? ROW_H : 0];
applyAdaptiveCanvasOnly(w);
node._pixSgValEl = el;
node._pixSgValWidget = w;
return el;
} catch {
return null;
}
}
// Classic only: paint "= value" right under the NAME FIELD. We anchor to the
// name widget's actual drawn position (widget.last_y), NOT the node bottom,
// because the node's minimum height leaves slack below the last widget and
// anchoring to the bottom floated the line low. Vue uses the DOM element.
// Called from each node's onDrawForeground (draw time, so last_y is set).
export function paintReadout(node, ctx) {
if (isVue() || node.flags?.collapsed) return;
if (!node._pixSgValShown || !node._pixSgValText || !ctx) return;
const w = node.size?.[0] || 0;
if (w <= 0) return;
// Bottom of the last widget (the name field) in body coordinates.
const NW_H = window.LiteGraph?.NODE_WIDGET_HEIGHT || 20;
let anchor = 0;
for (const wd of node.widgets || []) {
if (typeof wd.last_y !== "number") continue;
let h = NW_H;
try {
h = wd.computeSize?.(w)?.[1] || NW_H;
} catch {
/* ignore */
}
anchor = Math.max(anchor, wd.last_y + h);
}
// No widget drawn yet (first frame after add) -> skip; we paint next frame
// once last_y is set, rather than guess a position from the node bottom.
if (anchor <= 0) return;
// Grow the node just enough to enclose the line right under the name
// (converges in one frame; never resize mid-load -> Vue Compat #18).
const needed = anchor + ROW_H;
if ((node.size?.[1] || 0) < needed) {
let loading = false;
try {
loading = isGraphLoading();
} catch {
/* ignore */
}
if (!loading) node.setSize?.([node.size[0], needed]);
}
ctx.save();
ctx.beginPath();
ctx.rect(0, anchor, w, ROW_H); // clip so a long value never spills sideways
ctx.clip();
ctx.fillStyle = "rgba(255, 255, 255, 0.55)";
ctx.font = "11px monospace";
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText("= " + node._pixSgValText, 12, anchor + 13);
ctx.restore();
}
// Classic only: when the readout is HIDDEN, shrink the node back to its content
// (drop the row the painted line used). When SHOWN, paintReadout sizes the node
// to the real name-field anchor during draw, so nothing to do here. Vue's grid
// handles its own height. Never resize during a load (Vue Compat #18).
function fitNodeForReadout(node) {
if (isVue() || node._pixSgValShown) return;
try {
if (isGraphLoading()) return;
} catch {
/* ignore */
}
const base = node.computeSize?.();
if (base) node.setSize?.([node.size?.[0] || base[0], base[1]]);
}
// Recompute the readout for one node. Cheap; safe to call often.
export function refreshValue(node) {
let val = null;
try {
val = node.type === SET_TYPE ? deriveSetValue(node) : deriveGetValue(node);
} catch {
val = null;
}
const show = val != null && val !== "";
const prevShown = !!node._pixSgValShown;
const prevText = node._pixSgValText || "";
node._pixSgValShown = show;
node._pixSgValText = show ? val : "";
// Nodes 2.0: update the DOM element (created only in Vue mode).
const el = node._pixSgValEl;
if (el) {
if (show && val !== prevText) el.textContent = "= " + val;
el.style.display = show ? "block" : "none";
}
if (show !== prevShown) fitNodeForReadout(node);
if (show !== prevShown || val !== prevText) node.setDirtyCanvas?.(true, true);
}
// Single shared poll: keeps readouts live when the user edits an upstream
// number. Only touches expanded Set/Get nodes in the currently-viewed graph,
// and only repaints on a real change.
export function startValuePoll() {
// Window-scoped guard so a module re-import (hot reload) cannot start a second
// interval running in parallel.
if (window.__pixSgValPoll) return;
window.__pixSgValPoll = setInterval(() => {
const g = app.canvas?.graph || app.graph;
if (!g?._nodes) return;
for (const n of g._nodes) {
// A Get mirrors its Set's colour - keep it synced even when collapsed.
if (n.type === GET_TYPE) {
try {
inheritSetColor(n);
} catch {
/* ignore */
}
}
if ((n.type === SET_TYPE || n.type === GET_TYPE) && !n.flags?.collapsed) {
refreshValue(n);
}
}
}, 450);
}
-638
View File
@@ -1,638 +0,0 @@
"""Shared geometry + mask + blend helpers for Inpaint Crop / Inpaint Stitch
Pixaroma.
This module is the SINGLE Python source of truth for the inpaint region math.
`js/inpaint_crop/geometry.mjs` mirrors `compute_region` 1:1 so the editor's live
crop-rectangle preview matches what the node actually produces. The narrative
spec is `docs/inpaint-crop-math.md` - update the doc first, then BOTH
implementations, then eyeball-verify the editor preview against a real run.
Zero extra dependencies: torch + PIL + numpy all come from ComfyUI's environment.
scipy is used ONLY for true hole-filling when present; a PIL morphological close
is the fallback (wrapped in try/except per the offline-first rule).
"""
import math
import numpy as np
import torch
from PIL import Image, ImageFilter
try:
from scipy import ndimage as _ndimage # optional, for true binary_fill_holes
_HAS_SCIPY = True
except Exception:
_ndimage = None
_HAS_SCIPY = False
# Must match the constant emitted by node_inpaint_crop.py / consumed by
# node_inpaint_stitch.py - and it is deliberately the SAME string the Image Crop
# / Image Uncrop pair uses, so the two pairs are cross-compatible. Duplicated as a
# plain string (no cross-file import chain), exactly like node_crop / node_uncrop.
PIXAROMA_CROP_INFO = "PIXAROMA_CROP_INFO"
_RESAMPLE = {
"lanczos": Image.LANCZOS,
"bicubic": Image.BICUBIC,
"bilinear": Image.BILINEAR,
"nearest": Image.NEAREST,
}
# Default knob values. The JS DEFAULT_STATE in js/inpaint_crop/index.js MUST stay
# in sync with these (same risk class as every other Pixaroma node).
DEFAULTS = {
"size_mode": "keep", # keep | force | free
"target": 1024, # long-side target for keep mode
"target_w": 1024, # force mode
"target_h": 1024, # force mode
"multiple": 8, # 8 | 16 | 32 | 64
"context_px": 24, # absolute padding added each side
"context_pct": 10.0, # extra padding as a fraction of the bbox (total %)
"mask_grow": 4, # dilate the mask before measuring the bbox
"mask_blur": 4, # soften the OUTPUT mask edge (conditioning), px
"blend": 16, # seam feather px (stitch); also grows the crop context
"invert_mask": False, # flip the mask (1 - mask) before cropping
"fill_holes": True,
"min_size": 256,
"max_size": 2048,
"resample": "lanczos",
"allow_upscale": True,
}
# ─────────────────────────────────────────────────────────────────────────────
# small utils
def _round_mult(v, m):
m = max(1, int(m))
return int(max(m, round(float(v) / m) * m))
def _clampi(v, lo, hi):
return int(max(lo, min(hi, int(round(v)))))
def merge_params(p):
"""Fill any missing keys from DEFAULTS and coerce types."""
out = dict(DEFAULTS)
if isinstance(p, dict):
out.update({k: p[k] for k in p if k in DEFAULTS})
out["size_mode"] = str(out["size_mode"]).lower()
if out["size_mode"] not in ("keep", "force", "free"):
out["size_mode"] = "keep"
out["resample"] = str(out["resample"]).lower()
if out["resample"] not in _RESAMPLE:
out["resample"] = "lanczos"
for k in ("target", "target_w", "target_h", "multiple", "context_px",
"mask_grow", "mask_blur", "blend", "min_size", "max_size"):
out[k] = int(round(float(out[k])))
out["context_pct"] = float(out["context_pct"])
out["fill_holes"] = bool(out["fill_holes"])
out["allow_upscale"] = bool(out["allow_upscale"])
out["invert_mask"] = bool(out["invert_mask"])
out["multiple"] = max(1, out["multiple"])
out["min_size"] = max(8, out["min_size"])
out["max_size"] = max(out["min_size"], out["max_size"])
return out
# ─────────────────────────────────────────────────────────────────────────────
# mask helpers (numpy float HxW, 1 = the area to inpaint)
def mask_to_np(mask, h, w):
"""Coerce a ComfyUI MASK ([1,H,W] / [H,W]) to a float HxW numpy in 0..1,
resized to (h, w) if needed. None -> all zeros."""
if mask is None:
return np.zeros((h, w), dtype=np.float32)
m = mask
if isinstance(m, torch.Tensor):
if m.dim() == 4:
m = m[:, 0] if m.shape[1] == 1 else m[..., 0]
if m.dim() == 3:
m = m[0]
m = m.detach().cpu().float().clamp(0, 1).numpy()
m = np.asarray(m, dtype=np.float32)
if m.ndim != 2:
return np.zeros((h, w), dtype=np.float32)
if m.shape != (h, w):
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.resize((w, h), Image.NEAREST)
m = np.asarray(pim, dtype=np.float32) / 255.0
return np.clip(m, 0.0, 1.0)
def _max1d(a, k):
"""1D max filter along axis 1, odd window k, edge-padded. Separable building
block for a fast box dilation: O(W*H*k) instead of PIL MaxFilter's O(W*H*k^2),
which hung for ~50s on a large mask_grow."""
r = k // 2
ap = np.pad(a, ((0, 0), (r, r)), mode="edge")
win = np.lib.stride_tricks.sliding_window_view(ap, k, axis=1)
return win.max(axis=2)
def _dilate(m_bool, px):
if px <= 0:
return m_bool
k = 2 * int(px) + 1
if _HAS_SCIPY:
# separable max filter -> O(W*H), fast even for a huge kernel
return _ndimage.maximum_filter(m_bool, size=k) > 0
# no-scipy: two separable numpy max passes (rows then cols)
a = m_bool.astype(np.uint8)
a = _max1d(a, k)
a = _max1d(np.ascontiguousarray(a.T), k).T
return a > 0
def fill_holes(m_bool):
"""Fill only SMALL enclosed holes (paint specks / gaps), NEVER a large
subject-shaped hole. A cut-out / background mask (white around a subject) would
otherwise have its subject hole filled by scipy's binary_fill_holes, collapsing
the mask to solid - the "mask vanishes after the crop" bug. scipy when available
(size-limited true fill); otherwise a PIL morphological close (small kernel,
already naturally limited to small holes)."""
if _HAS_SCIPY:
try:
filled = _ndimage.binary_fill_holes(m_bool)
added = filled & ~m_bool # pixels binary_fill_holes would fill
if not added.any():
return filled
# keep only holes up to a small fraction of the image (specks/gaps); a
# subject hole is far bigger and is left UNfilled so the mask survives.
H, W = m_bool.shape
limit = max(256, int(0.005 * H * W)) # ~0.5% of the image
lbl, n = _ndimage.label(added)
sizes = np.bincount(lbl.ravel())
small = np.where(sizes <= limit)[0]
small = small[small != 0] # drop label 0 (the non-hole area)
return m_bool | np.isin(lbl, small)
except Exception:
pass
pim = Image.fromarray((m_bool * 255).astype(np.uint8), "L")
k = 9
pim = pim.filter(ImageFilter.MaxFilter(k)).filter(ImageFilter.MinFilter(k))
return np.asarray(pim, dtype=np.uint8) > 127
def gaussian_blur_np(m, px):
if px <= 0:
return m
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.filter(ImageFilter.GaussianBlur(radius=float(px)))
return np.asarray(pim, dtype=np.float32) / 255.0
def preprocess_mask(m, p):
"""fill-holes + grow on a 0..1 float mask -> a float mask used for the bbox
AND carried full-frame into crop_info (the stitch blend feathers it)."""
mb = m > 0.5
if p["fill_holes"]:
mb = fill_holes(mb)
if p["mask_grow"] > 0:
mb = _dilate(mb, p["mask_grow"])
return mb.astype(np.float32)
def mask_bbox(m_bool):
ys, xs = np.where(m_bool)
if xs.size == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def resolve_inpaint_mask(disk_mask, upstream_mask):
"""Choose which mask the crop uses: the editor-painted mask saved on disk, or
the wired upstream MASK input.
The editor mask wins ONLY when it actually has painted pixels. Clearing the
editor (or saving without painting) writes an all-black mask file while
mask_path stays set, so a naive "disk mask wins whenever present" lets that
empty mask silently override a wired mask - the crop then sees no painted area
and falls back to the whole image (the reported bug). So an empty/absent editor
mask falls back to the wired mask. This is what makes the `mask` input tooltip
true: clear it or never paint, and the wired mask is used as-is. Returns the
chosen mask tensor, or None when neither has content."""
if (isinstance(disk_mask, torch.Tensor) and disk_mask.numel()
and float(disk_mask.detach().max()) > 1e-6):
return disk_mask
if isinstance(upstream_mask, torch.Tensor):
return upstream_mask
return disk_mask
# ─────────────────────────────────────────────────────────────────────────────
# THE GEOMETRY (mirror of js/inpaint_crop/geometry.mjs)
def compute_region(bbox, W, H, p):
"""From a mask bbox (x0,y0,x1,y1) and the image size, return the source crop
region + the model-friendly output size.
Returns {rx, ry, rw, rh, out_w, out_h}. `r*` are integer SOURCE pixels (the
rectangle that gets cropped); `out_*` is the size the crop is resized to (and
the node's width/height outputs). If bbox is None the whole image is used.
"""
p = merge_params(p)
W = int(W); H = int(H)
if bbox is None:
x0, y0, x1, y1 = 0, 0, W, H
else:
x0, y0, x1, y1 = bbox
bw = max(1.0, float(x1 - x0))
bh = max(1.0, float(y1 - y0))
cx = (x0 + x1) / 2.0
cy = (y0 + y1) / 2.0
# context expand: max(context_px, blend) each side + context_pct of bbox total.
# The max-with-blend (Option B) gives the seam feather room to reach 0 INSIDE the
# crop, so a big softness grows the crop instead of being clamped at stitch time.
ctx = max(p["context_px"], p["blend"])
rw = bw + 2.0 * ctx + bw * p["context_pct"] / 100.0
rh = bh + 2.0 * ctx + bh * p["context_pct"] / 100.0
mode = p["size_mode"]
mult = p["multiple"]
# force mode: grow the WANTED region to the target aspect first (the output is
# the fixed target size, set below).
tw = th = 0
if mode == "force":
tw = max(mult, _round_mult(p["target_w"], mult))
th = max(mult, _round_mult(p["target_h"], mult))
target_aspect = tw / float(th)
if rw / rh < target_aspect:
rw = rh * target_aspect
else:
rh = rw / target_aspect
# place + clamp the SOURCE region inside the image
rw_i = max(1, min(int(round(rw)), W))
rh_i = max(1, min(int(round(rh)), H))
if mode == "force":
# keep the source aspect == the target aspect after the image-bound clamp
# (which can clip one axis and not the other), so the resize never stretches.
aspect = tw / float(th)
if rw_i > rh_i * aspect:
rw_i = max(1, int(round(rh_i * aspect)))
else:
rh_i = max(1, int(round(rw_i / aspect)))
rx = _clampi(cx - rw_i / 2.0, 0, W - rw_i)
ry = _clampi(cy - rh_i / 2.0, 0, H - rh_i)
# output size is derived from the CLAMPED source rect (rw_i, rh_i), NOT the
# un-clamped wanted region - so the crop is resized at its real aspect and is
# NEVER stretched when the image edge clipped the region (e.g. a big softness on
# a mask near the border: the source clamps to the image, so the output must too,
# or the crop/mask comes out squished).
if mode == "force":
out_w, out_h = tw, th
elif mode == "free":
# free = "multiple only": keep the cropped rect's own size, just round to the
# multiple. When the LONG side exceeds max_size, scale BOTH axes by one factor
# (not each axis independently) so the output keeps the source aspect - an
# independent per-axis cap stretched a wide/tall crop. No min_size bump (free
# preserves the source size; only the max ceiling applies).
ow, oh = float(rw_i), float(rh_i)
big = max(ow, oh)
if big > p["max_size"]:
k = p["max_size"] / big
ow *= k; oh *= k
out_w = _round_mult(ow, mult)
out_h = _round_mult(oh, mult)
else: # keep shape: scale the CROPPED rect's long side to target, keep aspect
long_side = max(rw_i, rh_i)
s = p["target"] / long_side if long_side > 0 else 1.0
if not p["allow_upscale"]:
s = min(s, 1.0)
ow = rw_i * s
oh = rh_i * s
# min_size bump FIRST (scale both up so the short side reaches min_size),
# then the max_size clamp LAST as the hard ceiling - so an extreme-aspect
# crop can't blow the long side past max_size into an OOM tensor (the short
# side may then end up < min_size, which is acceptable).
small = min(ow, oh)
if small < p["min_size"]:
k = p["min_size"] / small
ow *= k; oh *= k
big = max(ow, oh)
if big > p["max_size"]:
k = p["max_size"] / big
ow *= k; oh *= k
out_w = _round_mult(ow, mult)
out_h = _round_mult(oh, mult)
out_w = max(mult, int(out_w))
out_h = max(mult, int(out_h))
return {"rx": rx, "ry": ry, "rw": rw_i, "rh": rh_i,
"out_w": out_w, "out_h": out_h}
# ─────────────────────────────────────────────────────────────────────────────
# image / mask resize (PIL for quality + Lanczos support)
def resize_image_tensor(t, w, h, resample="lanczos"):
"""[B,H,W,3] float 0..1 -> [B,h,w,3]. PIL per frame (handles Lanczos).
Short-circuits when the size already matches so an identity crop stays
pixel-exact (PIL resize to the same size still resamples)."""
if int(w) == int(t.shape[2]) and int(h) == int(t.shape[1]):
return t
filt = _RESAMPLE.get(resample, Image.LANCZOS)
frames = []
for i in range(int(t.shape[0])):
arr = (t[i].clamp(0, 1).cpu().numpy() * 255.0 + 0.5).astype(np.uint8)
pim = Image.fromarray(arr, "RGB").resize((int(w), int(h)), filt)
frames.append(np.asarray(pim, dtype=np.float32) / 255.0)
return torch.from_numpy(np.stack(frames, 0))
def resize_mask_np(m, w, h, resample="bilinear"):
if int(w) == int(m.shape[1]) and int(h) == int(m.shape[0]):
return np.clip(m, 0, 1).astype(np.float32)
filt = _RESAMPLE.get(resample, Image.BILINEAR)
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.resize((int(w), int(h)), filt)
return np.asarray(pim, dtype=np.float32) / 255.0
# ─────────────────────────────────────────────────────────────────────────────
# CROP (called by node_inpaint_crop.py)
def apply_inpaint_crop(image, mask, p):
"""image [B,H,W,3], mask [1,H,W]|None, p = knob dict.
Returns (cropped_image[B,out_h,out_w,3], out_mask[1,out_h,out_w], crop_info,
out_w, out_h). out_mask is the conditioning mask (grown + blurred) at output
resolution; crop_info carries the full original image + full-frame processed
mask so stitch can paste back + produce a full-frame result.
"""
p = merge_params(p)
# Coerce the input to RGB. Remove Background Pixaroma (and any cut-out) hands us a
# 4-channel RGBA image; left as-is it rides into crop_info["image"] and makes
# Inpaint Stitch throw on the 3-vs-4 channel paste (its except then silently passes
# the result through as the "original"). Premultiply RGBA over black so a cut-out
# reads as the subject on black (matching the editor/preview), not the leftover
# background still sitting under the alpha.
if image.shape[-1] == 4:
image = (image[..., :3] * image[..., 3:4]).contiguous()
elif image.shape[-1] > 4:
image = image[..., :3].contiguous()
B, H, W = int(image.shape[0]), int(image.shape[1]), int(image.shape[2])
raw = mask_to_np(mask, H, W)
if p["invert_mask"] and mask is not None:
raw = 1.0 - raw # flip which area is inpainted (Invert toggle)
proc = preprocess_mask(raw, p) # binary core (fill-holes + grow) for the bbox
bbox = mask_bbox(proc > 0.5)
# Keep the brush's painted soft edge: the filled+grown core is opaque, the
# softly-painted rim outside it is preserved (so the soft-edge brush + mask_blur
# actually reach the conditioning mask instead of being thresholded away).
softm = np.maximum(raw, proc)
region = compute_region(bbox, W, H, p)
rx, ry, rw, rh = region["rx"], region["ry"], region["rw"], region["rh"]
out_w, out_h = region["out_w"], region["out_h"]
# crop + resize the image (all batch frames, same rect). Keep the result on
# the input's device/dtype (the resize routes through PIL on the CPU).
crop = image[:, ry:ry + rh, rx:rx + rw, :].contiguous()
cropped_image = resize_image_tensor(crop, out_w, out_h, p["resample"]).to(image.device, image.dtype)
# resize the mask with NEAREST (bilinear would add its own gradient halo);
# the mask_blur Gaussian is the ONLY intended softening of the conditioning.
mreg = softm[ry:ry + rh, rx:rx + rw]
mout = resize_mask_np(mreg, out_w, out_h, "nearest")
mout = gaussian_blur_np(mout, p["mask_blur"])
out_mask = torch.from_numpy(np.clip(mout, 0, 1)[None, ...].astype(np.float32)).to(image.device)
# keep the carried mask float32 (NOT image.dtype) so it matches out_mask and a
# strict downstream mask op never sees a float16 mask on an fp16 image pipeline.
full_mask = torch.from_numpy(softm[None, ...].astype(np.float32)).to(image.device)
crop_info = {
"image": image, "mask": full_mask,
"x": rx, "y": ry, "w": rw, "h": rh,
"orig_w": W, "orig_h": H,
}
return cropped_image, out_mask, crop_info, out_w, out_h
# ─────────────────────────────────────────────────────────────────────────────
# STITCH (called by node_inpaint_stitch.py)
def _feather_alpha(alpha, feather):
"""Ramp the alpha to 0 at the rectangle edge over `feather` px (distance-to-
edge), so a pasted crop dissolves into the original. Same idea as Image
Uncrop's _feather_alpha but operating on an arbitrary [ch,cw] alpha."""
k = int(feather)
if k <= 0:
return alpha
ch, cw = int(alpha.shape[-2]), int(alpha.shape[-1])
# Cap the feather at ~half the smaller side so the interior stays fully opaque. A
# feather wider than that never reaches 1 anywhere, so the WHOLE rectangle paste
# goes translucent and ghosts the original through (only bites on a small crop +
# big feather, e.g. a large Stitch softness override on a tight crop).
k = min(k, max(1, (min(ch, cw) - 1) // 2))
ys = torch.arange(ch, dtype=torch.float32).view(ch, 1)
xs = torch.arange(cw, dtype=torch.float32).view(1, cw)
dist = torch.minimum(torch.minimum(ys, (ch - 1) - ys),
torch.minimum(xs, (cw - 1) - xs))
ramp = (dist / float(k)).clamp(0.0, 1.0)
return (alpha * ramp).clamp(0.0, 1.0)
def _blur_alpha(alpha, blend):
"""Soften a MASK's edge by `blend` px so the masked paste fades smoothly into
the surroundings (mask-aware blend). Distinct from _feather_alpha, which fades
the rectangle boundary (whole-crop mode).
OUTWARD-only feather: alpha is 1.0 everywhere INSIDE the mask and AT its edge,
ramping to 0 over `blend` px OUTSIDE. So the new content fully covers the
masked area (the old content can NEVER show through at the new object's own
edge) and only the transition into the surroundings is softened. A centred
feather made the masked edge itself semi-transparent, which read as a soft
ghost/halo of the old content. Coverage/grow is the crop node's mask_grow
job; this only softens the outer seam.
The crop node now grows the crop context to max(context_px, blend)
(compute_region, Option B), so the mask always sits >= blend px from the crop
edge and the outward feather reaches 0 BEFORE the border on its own. That
replaced the old rect-edge guard + core-opaque clamp (which crushed a too-wide
feather and could ghost a mask near the crop edge); the smoothstep is opaque
inside by construction, so no clamp is needed.
"""
k = int(blend)
if k <= 0:
return alpha
a_np = np.clip(alpha.detach().cpu().numpy(), 0.0, 1.0)
mb = a_np > 0.5
if not mb.any() or mb.all():
return torch.from_numpy(a_np.astype(np.float32))
if _HAS_SCIPY:
# signed distance to the mask edge (+ inside, - outside, in px). Map so
# signed >= 0 -> 1.0 and signed in [-k,0] ramps 1 -> 0 (smoothstep).
signed = (_ndimage.distance_transform_edt(mb)
- _ndimage.distance_transform_edt(~mb))
t = np.clip(signed / float(k) + 1.0, 0.0, 1.0)
soft = (t * t * (3.0 - 2.0 * t)).astype(np.float32)
else:
# fallback (no scipy): gaussian-blur the binary mask for an outward
# falloff, then force the interior back to 1.0 (outward-only).
mbf = mb.astype(np.float32)
blurred = gaussian_blur_np(mbf, max(1, int(k / 1.7)))
soft = np.where(mbf > 0.5, 1.0, blurred).astype(np.float32)
return torch.from_numpy(np.clip(soft, 0.0, 1.0).astype(np.float32))
def _color_match(patch, ref, region_mask, strength):
"""Shift patch color stats toward `ref` within the masked area. strength:
'subtle' = match mean, 'strong' = match mean + std. patch/ref [ch,cw,3]."""
if strength == "off":
return patch
w = region_mask.reshape(-1, 1)
wsum = float(w.sum()) + 1e-6
pf = patch.reshape(-1, 3)
rf = ref.reshape(-1, 3)
pm = (pf * w).sum(0) / wsum
rm = (rf * w).sum(0) / wsum
if strength == "strong":
pv = ((pf - pm) ** 2 * w).sum(0) / wsum
rv = ((rf - rm) ** 2 * w).sum(0) / wsum
scale = (rv.clamp_min(1e-6).sqrt()) / (pv.clamp_min(1e-6).sqrt())
scale = scale.clamp(0.5, 2.0)
out = (pf - pm) * scale + rm
else:
out = pf - pm + rm
return out.reshape(patch.shape).clamp(0.0, 1.0)
def resolve_seam(crop_info, softness, blend_mode):
"""Resolve the effective seam feather (px) + blend mode for the stitch, letting
the Inpaint Stitch node OVERRIDE what rode in on crop_info from the Crop node -
so the blend can be tuned at stitch time WITHOUT re-running the sampler.
softness: int; < 0 (the -1 "inherit" default) keeps crop_info['blend'].
blend_mode: 'from crop' / '' = keep crop_info['blend_mode']; else 'mask' or
'whole crop' (spaces -> underscores). Returns (blend:int 0..150, mode:str).
"""
if not isinstance(crop_info, dict):
crop_info = {}
try:
s = int(softness)
except (TypeError, ValueError):
s = -1
if s < 0:
try:
s = int(crop_info.get("blend", 16))
except (TypeError, ValueError):
s = 16
blend = max(0, min(150, s))
bm = str(blend_mode if blend_mode is not None else "from crop").strip().lower()
if bm in ("", "from crop", "inherit"):
bm = str(crop_info.get("blend_mode", "mask"))
bm = bm.replace(" ", "_")
if bm not in ("mask", "whole_crop"):
bm = "mask"
return blend, bm
def stitch_back(crop_info, image, mask, blend, blend_mode, color_match):
"""Paste the inpainted `image` back onto crop_info['image'] at the recorded
region, blended seamlessly. Returns (result[B,H,W,3], original[B,H,W,3])."""
base = crop_info["image"]
# defensive RGB coercion: a crop_info from an RGBA source (a hand-built dict, or an
# Image Crop fed an RGBA image) would mismatch the RGB patch on the paste below.
# Inpaint Crop already coerces its input, so this only catches other sources.
if isinstance(base, torch.Tensor) and base.dim() == 4 and base.shape[-1] == 4:
base = (base[..., :3] * base[..., 3:4]).contiguous()
elif isinstance(base, torch.Tensor) and base.dim() == 4 and base.shape[-1] > 4:
base = base[..., :3].contiguous()
H, W = int(base.shape[1]), int(base.shape[2])
x = _clampi(crop_info.get("x", 0), 0, W - 1)
y = _clampi(crop_info.get("y", 0), 0, H - 1)
cw = int(max(1, min(int(crop_info.get("w", W)), W - x)))
ch = int(max(1, min(int(crop_info.get("h", H)), H - y)))
patch = image
if not isinstance(patch, torch.Tensor) or patch.dim() != 4:
patch = base.new_zeros((1, ch, cw, base.shape[3]))
if int(patch.shape[1]) != ch or int(patch.shape[2]) != cw:
patch = resize_image_tensor(patch, cw, ch, "lanczos").to(base.device, base.dtype)
# region alpha (1 = take the patch)
if blend_mode == "whole_crop":
a = torch.ones((ch, cw), dtype=torch.float32)
else: # mask-aware: prefer the wired mask, else the painted mask from crop_info
if isinstance(mask, torch.Tensor):
# mask_to_np already resizes to (ch,cw) with NEAREST - keep it crisp;
# the seam softening is _blur_alpha's job (a bilinear resize here would
# smear a second gradient on top of the feather = a visible halo).
a = torch.from_numpy(np.ascontiguousarray(mask_to_np(mask, ch, cw), dtype=np.float32))
elif isinstance(crop_info.get("mask"), torch.Tensor):
fm = mask_to_np(crop_info["mask"], H, W)[y:y + ch, x:x + cw]
a = torch.from_numpy(np.ascontiguousarray(fm, dtype=np.float32))
else:
a = torch.ones((ch, cw), dtype=torch.float32)
# whole_crop: fade the rectangle boundary. mask: soften the mask's OWN edge.
if blend_mode == "whole_crop":
a = _feather_alpha(a.clamp(0, 1), blend)
else:
ac = a.clamp(0, 1)
ab = ac > 0.5
if not bool(ab.any()) or bool(ab.all()):
# No mask EDGE to soften - an Image Crop crop_info with no per-pixel mask
# (all-zeros), an all-ones mask, or a whole-image inpaint with nothing
# painted. _blur_alpha would return zeros (paste NOTHING - the inpaint is
# lost) or ones (a hard rectangle seam). Fall back to the rectangle feather
# over the full crop, so the edited crop IS pasted with a soft seam and the
# Softness slider still works for the Image Crop interop.
a = _feather_alpha(torch.ones((ch, cw), dtype=torch.float32), blend)
else:
a = _blur_alpha(ac, blend)
out = base.clone()
B = int(out.shape[0])
if patch.shape[0] != B:
if patch.shape[0] == 1:
patch = patch.repeat(B, 1, 1, 1)
elif B == 1:
out = out.repeat(patch.shape[0], 1, 1, 1)
B = patch.shape[0]
else:
n = min(B, patch.shape[0])
print(f"[PixaromaInpaintStitch] batch mismatch: original {B} vs inpainted "
f"{patch.shape[0]} - using {n} frames")
out, patch = out[:n], patch[:n]
B = n
patch = patch.to(out.device, out.dtype)
if color_match and color_match != "off":
patch = patch.clone() # don't mutate the caller's tensor in place (it may be cached upstream)
# Reference = the UNMASKED context (the surroundings OUTSIDE the mask), NOT
# the masked area or the whole crop. Matching to anything that includes the
# masked area drags the inpaint's DELIBERATELY changed colors back toward the
# original (a red->white dress goes pink). Matching to the context corrects
# only the lighting/tone drift the model introduced in the unchanged
# surroundings, which is what actually makes the seam vanish.
am = np.clip(a.detach().cpu().numpy(), 0.0, 1.0)
ctx = (am < 0.5).astype(np.float32)
if ctx.sum() < 0.02 * ctx.size: # mask ~fills the crop -> no context to match
ctx = np.ones_like(am, dtype=np.float32)
ac = torch.from_numpy(np.ascontiguousarray(ctx))
for b in range(B): # match EVERY frame, not just frame 0 (video / batch)
region_b = out[b, y:y + ch, x:x + cw, :3].detach().cpu()
p_b = patch[b, :, :, :3].detach().cpu()
matched = _color_match(p_b, region_b, ac, color_match)
patch[b, :, :, :3] = matched.to(patch.device, patch.dtype)
av = a[None, ..., None].to(out.device, out.dtype)
region = out[:, y:y + ch, x:x + cw, :]
out[:, y:y + ch, x:x + cw, :] = patch[..., :region.shape[-1]] * av + region * (1.0 - av)
original = base
if original.shape[0] != out.shape[0]:
if original.shape[0] == 1:
original = original.repeat(out.shape[0], 1, 1, 1)
else:
# the result was trimmed to min(B,C) above - trim original to match,
# or the two outputs would have mismatched batch sizes.
original = original[:out.shape[0]]
return out.clamp(0, 1), original.clamp(0, 1)
+1 -1
View File
@@ -14,7 +14,7 @@ class Pixaroma3D:
"optional": FlexibleOptionalInputType(any_type),
}
CATEGORY = "👑 Pixaroma/🎨 Editors"
CATEGORY = "👑 Pixaroma"
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The rendered 3D scene.", "Image width in pixels.", "Image height in pixels.")
+1 -1
View File
@@ -138,7 +138,7 @@ class PixaromaAudioStudio:
"Frames per second of the render.",
)
FUNCTION = "generate"
CATEGORY = "👑 Pixaroma/🎨 Editors"
CATEGORY = "👑 Pixaroma"
def generate(self, studio_json="{}", image=None, audio=None):
try:
+1 -1
View File
@@ -37,7 +37,7 @@ class PixaromaCompare:
RETURN_TYPES = ()
FUNCTION = "compare_images"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
def compare_images(self, image1, image2):
results = []
+1 -1
View File
@@ -367,7 +367,7 @@ class PixaromaImageComposition:
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The composited image.", "Image width in pixels.", "Image height in pixels.")
FUNCTION = "load_composite"
CATEGORY = "👑 Pixaroma/🎨 Editors"
CATEGORY = "👑 Pixaroma"
OUTPUT_NODE = True
@classmethod
+59 -237
View File
@@ -8,13 +8,6 @@ import folder_paths
from .node_ref import any_type, FlexibleOptionalInputType
# Custom wire type carrying everything Image Uncrop Pixaroma needs to paste an
# edited crop back onto the original: the full original image plus the crop's
# top-left (x, y) and size (w, h). Defined as a plain string so the matching
# constant in node_uncrop.py stays decoupled (no cross-file import chain).
PIXAROMA_CROP_INFO = "PIXAROMA_CROP_INFO"
class _CropOptionalInputs(FlexibleOptionalInputType):
"""FlexibleOptionalInputType that ALSO declares a concrete optional IMAGE
'image' input.
@@ -39,16 +32,6 @@ class _CropOptionalInputs(FlexibleOptionalInputType):
"load the image directly and disconnect this wire."
),
})
self["mask"] = ("MASK", {
"tooltip": (
"Optional. Wire a MASK here (for example Load Image's MASK "
"output) to crop the transparency with the exact same box as the "
"image - the result comes out the 'mask' output. Leave it unwired "
"and the mask output is a fully-opaque mask sized to the crop "
"(unless the loaded file itself has transparency, which is used "
"automatically)."
),
})
def __getitem__(self, key):
# A real declared key ('image') returns its real type; anything else
@@ -76,12 +59,8 @@ class PixaromaCrop:
"friendly aspects, and a Load Image button that lets you swap the "
"source from inside the editor (also auto-disconnects the upstream "
"wire).\n\n"
"Outputs the cropped IMAGE, a matching cropped MASK (wire a MASK in - "
"such as Load Image's MASK output - to carry transparency through the "
"crop with the exact same box), the new width and height, and a "
"'crop_info' wire. Feed crop_info into Image Uncrop Pixaroma to paste an "
"edited version of the crop back onto the original image at the exact "
"same spot (crop, fix/upscale, then put it back).\n\n"
"Outputs the cropped IMAGE plus its new width and height for downstream "
"nodes.\n\n"
"The 'image' input is optional - wire an upstream IMAGE into it, or load "
"an image directly via drag-drop, Ctrl+V paste, or the editor's Load "
"Image button (those override the wire)."
@@ -94,22 +73,11 @@ class PixaromaCrop:
"optional": _CropOptionalInputs(any_type),
}
RETURN_TYPES = ("IMAGE", "MASK", PIXAROMA_CROP_INFO, "INT", "INT")
RETURN_NAMES = ("image", "mask", "crop_info", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped image.",
"The cropped mask, cut with the exact same box as the image. Comes from "
"the wired MASK input (or, for a dropped/pasted file, its own "
"transparency); a fully-opaque mask sized to the crop when there is no "
"transparency.",
"Crop info for Image Uncrop Pixaroma - carries the original image plus "
"where this crop came from, so an edited crop can be pasted back onto "
"the original at the exact same spot. Wire it into Image Uncrop Pixaroma.",
"Cropped width in pixels.",
"Cropped height in pixels.",
)
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The cropped image.", "Cropped width in pixels.", "Cropped height in pixels.")
FUNCTION = "load_crop"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
CATEGORY = "👑 Pixaroma"
OUTPUT_NODE = True
@classmethod
@@ -168,12 +136,10 @@ class PixaromaCrop:
crop_data = kwargs.get("CropWidget")
upstream = kwargs.get("image")
upstream_mask = kwargs.get("mask")
# No widget AND no upstream → return empty
if not crop_data and upstream is None and upstream_mask is None:
return (empty_image, self._default_mask(1024, 1024),
self._identity_crop_info(empty_image), 1024, 1024)
if not crop_data and upstream is None:
return (empty_image, 1024, 1024)
# Parse crop metadata (may be empty if user just wired upstream and never opened editor)
meta = {}
@@ -198,59 +164,29 @@ class PixaromaCrop:
]}
# ── Apply the crop ────────────────────────────────────────────────────
# IMAGE + MASK are cut with the SAME absolute-pixel rect so transparency
# lines up with the cropped image. A wired MASK is cropped to match; with
# no MASK wired the output is a fully-opaque mask sized to the crop.
if isinstance(upstream, torch.Tensor):
try:
img_t, out_w, out_h = self._crop_tensor(upstream, meta)
mask_t = self._crop_mask(upstream_mask, meta, out_w, out_h)
# Carry the FULL upstream mask (if any) so Uncrop's mask output
# is full-frame, not just the crop rectangle.
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else None
crop_info = self._make_crop_info(upstream, meta, full_mask)
result = self._crop_tensor(upstream, meta)
except Exception as e:
print(f"[PixaromaCrop] upstream crop error: {e}")
img_t, mask_t, out_w, out_h, crop_info = self._load_disk_composite(meta, empty_image, upstream_mask)
result = self._load_disk_composite(meta, empty_image)
else:
img_t, mask_t, out_w, out_h, crop_info = self._load_disk_composite(meta, empty_image, upstream_mask)
result = self._load_disk_composite(meta, empty_image)
result = (img_t, mask_t, crop_info, out_w, out_h)
if ui_payload:
return {"ui": ui_payload, "result": result}
return result
# ─────────────────────────────────────────────────────────────────────────
def _rect_from_meta(self, meta, w, h):
"""Resolve the saved crop rect to absolute, clamped pixel bounds
(x0, y0, x1, y1) for a w×h surface, or None when the crop should be a
pass-through (no saved rect, or a degenerate/empty box). Used for BOTH
the image and the mask so they're always cut with the same box.
Coordinates are ABSOLUTE pixels (no proportional rescale from
original_w/original_h). The numeric panel + editor both write literal
pixel values; rescaling on dim mismatch was confusing — typing W=430 on
a 1920-wide source should crop 430 px, not "the same fraction" of the
new image. Out-of-bounds coords are clamped to the surface rect.
"""
if not meta or meta.get("crop_w") in (None, 0):
return None
crop_x = float(meta.get("crop_x", 0))
crop_y = float(meta.get("crop_y", 0))
crop_w = float(meta.get("crop_w", w))
crop_h = float(meta.get("crop_h", h))
x0 = max(0, int(round(crop_x)))
y0 = max(0, int(round(crop_y)))
x1 = min(int(w), int(round(crop_x + crop_w)))
y1 = min(int(h), int(round(crop_y + crop_h)))
if x1 <= x0 or y1 <= y0:
return None
return (x0, y0, x1, y1)
def _crop_tensor(self, tensor, meta):
"""Crop an upstream IMAGE tensor [B,H,W,C] using the saved rect.
Coordinates are treated as ABSOLUTE pixels (no proportional rescale
from original_w/original_h). The numeric panel + editor both write
literal pixel values; rescaling on dim mismatch was confusing — typing
W=430 on a 1920-wide source should crop 430 px, not "the same fraction"
of the new image. Out-of-bounds coords are clamped to the image rect.
If meta is empty (user wired upstream but never opened the editor or
edited the panel), pass through unmodified.
"""
@@ -261,108 +197,39 @@ class PixaromaCrop:
return (tensor, 0, 0)
b, h, w, c = tensor.shape
rect = self._rect_from_meta(meta, w, h)
if rect is None:
# No saved rect → pass through (a sensible preview before the editor
# is opened, or a degenerate box clamped to nothing).
# No saved rect → pass through (gives the user a sensible preview before
# they open the editor for the first time).
if not meta or meta.get("crop_w") in (None, 0):
return (tensor, int(w), int(h))
crop_x = float(meta.get("crop_x", 0))
crop_y = float(meta.get("crop_y", 0))
crop_w = float(meta.get("crop_w", w))
crop_h = float(meta.get("crop_h", h))
x0 = max(0, int(round(crop_x)))
y0 = max(0, int(round(crop_y)))
x1 = min(w, int(round(crop_x + crop_w)))
y1 = min(h, int(round(crop_y + crop_h)))
if x1 <= x0 or y1 <= y0:
print(f"[PixaromaCrop] degenerate rect ({x0},{y0},{x1},{y1}) for {w}x{h} — passing through")
return (tensor, int(w), int(h))
x0, y0, x1, y1 = rect
cropped = tensor[:, y0:y1, x0:x1, :].contiguous()
return (cropped, int(x1 - x0), int(y1 - y0))
def _default_mask(self, w, h):
"""A fully-opaque mask (zeros) sized w×h. ComfyUI convention: a mask
value of 0 means 'keep / opaque', 1 means 'transparent'. Returned when
no transparency is available so the mask output still lines up with the
cropped image if it's recombined downstream."""
return torch.zeros((1, max(1, int(h)), max(1, int(w))), dtype=torch.float32)
def _crop_mask(self, mask, meta, fallback_w, fallback_h):
"""Crop a MASK tensor with the SAME rect as the image. Accepts the usual
ComfyUI [B,H,W] mask (also tolerates a bare [H,W]); the rect is clamped
to the mask's own dimensions. Returns a fully-opaque default mask sized
to the crop when the mask is missing or an unexpected shape."""
if not isinstance(mask, torch.Tensor):
return self._default_mask(fallback_w, fallback_h)
m = mask
if m.dim() == 2:
m = m[None, ...]
if m.dim() != 3:
return self._default_mask(fallback_w, fallback_h)
mh, mw = int(m.shape[-2]), int(m.shape[-1])
rect = self._rect_from_meta(meta, mw, mh)
if rect is None:
return m.contiguous()
x0, y0, x1, y1 = rect
return m[:, y0:y1, x0:x1].contiguous()
def _make_crop_info(self, original, meta, full_mask=None):
"""Bundle what Image Uncrop Pixaroma needs to paste an edited crop back:
the FULL original image and (optionally) the FULL original mask, plus the
crop's top-left (x, y) and size (w, h) computed from the saved rect on
the original's own dimensions. Carrying the full mask lets Uncrop output
a full-frame mask instead of just the crop rectangle."""
if not isinstance(original, torch.Tensor) or original.dim() != 4:
return None
H, W = int(original.shape[1]), int(original.shape[2])
rect = self._rect_from_meta(meta, W, H)
if rect is None:
x0, y0, cw, ch = 0, 0, W, H
else:
x0, y0, x1, y1 = rect
cw, ch = x1 - x0, y1 - y0
info = {"image": original, "x": x0, "y": y0, "w": cw, "h": ch,
"orig_w": W, "orig_h": H}
if isinstance(full_mask, torch.Tensor):
info["mask"] = full_mask
return info
def _identity_crop_info(self, image_t, full_mask=None):
"""Crop info for an already-cropped image (editor composite or the empty
fallback) where the full original isn't available: paste-back becomes a
whole-image replace at (0, 0)."""
if not isinstance(image_t, torch.Tensor) or image_t.dim() != 4:
return None
H, W = int(image_t.shape[1]), int(image_t.shape[2])
info = {"image": image_t, "x": 0, "y": 0, "w": W, "h": H,
"orig_w": W, "orig_h": H}
if isinstance(full_mask, torch.Tensor):
info["mask"] = full_mask
return info
def _full_mask_from_pil(self, pil):
"""The FULL (uncropped) mask from a loaded file's alpha channel
(mask = 1 - alpha), or None when the file has no transparency."""
try:
if "A" in pil.getbands():
alpha = np.array(pil.convert("RGBA").split()[-1]).astype(np.float32) / 255.0
return torch.from_numpy(1.0 - alpha)[None,]
except Exception:
pass
return None
def _load_disk_composite(self, meta, empty_image, upstream_mask=None):
def _load_disk_composite(self, meta, empty_image):
"""Load a saved image from input/pixaroma/. Two paths:
1. src_path: the FULL uncropped source (paste / drag-drop / the editor's
Load Image button - the editor saves it even when it also saves a
composite). We load it and apply crop_x/y/w/h on the Python side
(same pixels as the editor's composite) AND build a real crop_info
carrying the full original - which Image Uncrop needs to paste an
edited crop back onto the whole image. This is PREFERRED.
2. composite_path: the editor-saved pre-cropped PNG. Used ONLY when no
usable source is on disk. It is already cropped, so its crop_info is
an identity (paste-back can't reconstruct the full original from it).
Why src is preferred: when only the composite is used, crop_info has no
full original, so Image Uncrop has nothing to paste the edited crop back
onto (it returns just the cropped region). Loading from the full source
fixes the "Uncrop doesn't work when I paste/load into Crop directly" case
while producing the identical cropped IMAGE output.
All paths also produce a cropped MASK: a wired MASK input wins, else the
loaded file's own alpha channel is used, else a fully-opaque default.
1. composite_path: the editor-saved pre-cropped PNG. Returned as-is
(the editor already did the crop on the JS side).
2. src_path: the uncropped source (e.g. uploaded via Ctrl+V paste).
We load it and apply crop_x/y/w/h on the Python side, mirroring
_crop_tensor's behavior for upstream tensors. This lets the user
change crop dims in the on-node panel and have the workflow output
reflect the change without re-opening the editor.
"""
doc_w = int(meta.get("doc_w", 1024))
doc_h = int(meta.get("doc_h", 1024))
@@ -370,41 +237,15 @@ class PixaromaCrop:
composite_path = meta.get("composite_path", "")
src_path = meta.get("src_path", "")
# Prefer the FULL source so crop_info carries the original for paste-back.
if src_path and self._resolve_pixaroma_path(src_path):
return self._load_src_and_crop(src_path, meta, doc_w, doc_h, empty_image, upstream_mask)
if composite_path:
return self._load_image_from_pixaroma(
composite_path, doc_w, doc_h, empty_image, meta, upstream_mask, already_cropped=True)
return self._load_image_from_pixaroma(composite_path, doc_w, doc_h, empty_image)
# Nothing on disk → return a blank doc-sized image + matching mask
if src_path:
return self._load_src_and_crop(src_path, meta, doc_w, doc_h, empty_image)
# Nothing on disk → return a blank doc-sized image
arr = np.ones((doc_h, doc_w, 3), dtype=np.float32)
blank = torch.from_numpy(arr)[None,]
mask_t = self._crop_mask(upstream_mask, meta, doc_w, doc_h)
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else None
return (blank, mask_t, doc_w, doc_h, self._identity_crop_info(blank, full_mask))
def _derive_disk_mask(self, pil, meta, upstream_mask, out_w, out_h, already_cropped):
"""Work out the MASK for a disk-loaded image. Priority:
1. A wired MASK input (cropped with the saved rect).
2. The file's own alpha channel if it has one (mask = 1 - alpha, the
ComfyUI convention). Cropped with the rect for an uncropped source;
used as-is for an editor composite that was already cropped on save.
3. A fully-opaque default mask sized to the crop.
"""
if isinstance(upstream_mask, torch.Tensor):
return self._crop_mask(upstream_mask, meta, out_w, out_h)
try:
if "A" in pil.getbands():
alpha = np.array(pil.convert("RGBA").split()[-1]).astype(np.float32) / 255.0
m = torch.from_numpy(1.0 - alpha)[None,] # [1,H,W], 1 = transparent
if already_cropped:
return m.contiguous()
return self._crop_mask(m, meta, out_w, out_h)
except Exception as e:
print(f"[PixaromaCrop] alpha mask derive failed: {e}")
return self._default_mask(out_w, out_h)
return (torch.from_numpy(arr)[None,], doc_w, doc_h)
def _resolve_pixaroma_path(self, rel_path):
"""Resolve a saved relative path inside input/pixaroma/, returning
@@ -418,56 +259,37 @@ class PixaromaCrop:
return None
return full_path
def _load_image_from_pixaroma(self, rel_path, doc_w, doc_h, empty_image,
meta=None, upstream_mask=None, already_cropped=True):
def _load_image_from_pixaroma(self, rel_path, doc_w, doc_h, empty_image):
full_path = self._resolve_pixaroma_path(rel_path)
if not full_path:
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
return (empty_image, doc_w, doc_h)
try:
pil = Image.open(full_path)
img = pil.convert("RGB")
img = Image.open(full_path).convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
t = torch.from_numpy(arr)[None,]
# Report the ACTUAL loaded dimensions, not the stale doc_w/doc_h from
# meta — they can disagree if the composite was modified externally,
# and downstream nodes (EmptyLatentImage, etc.) trust these outputs.
ow, oh = int(t.shape[2]), int(t.shape[1])
mask_t = self._derive_disk_mask(pil, meta or {}, upstream_mask, ow, oh, already_cropped)
# Composite is already cropped on the JS side, so the full original
# isn't available here → identity crop info (paste = whole replace).
# Its mask doubles as the "full" mask for that identity.
return (t, mask_t, ow, oh, self._identity_crop_info(t, mask_t))
return (t, int(t.shape[2]), int(t.shape[1]))
except Exception as e:
print(f"[PixaromaCrop] Load error: {e}")
return (empty_image, self._default_mask(1024, 1024), 1024, 1024,
self._identity_crop_info(empty_image))
return (empty_image, 1024, 1024)
def _load_src_and_crop(self, src_path, meta, doc_w, doc_h, empty_image, upstream_mask=None):
def _load_src_and_crop(self, src_path, meta, doc_w, doc_h, empty_image):
"""Load the uncropped source image and apply crop_x/y/w/h. Used when
an image was pasted/uploaded but the editor was never opened to bake
the composite (or the user is tweaking crop dims via the panel)."""
full_path = self._resolve_pixaroma_path(src_path)
if not full_path:
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
return (empty_image, doc_w, doc_h)
try:
pil = Image.open(full_path)
img = pil.convert("RGB")
img = Image.open(full_path).convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr)[None,] # [1, H, W, 3] (FULL source)
img_t, ow, oh = self._crop_tensor(tensor, meta)
# Source is the FULL uncropped image, so the mask (wired or from the
# file's own alpha) is cropped with the same rect.
mask_t = self._derive_disk_mask(pil, meta, upstream_mask, ow, oh, already_cropped=False)
# FULL source available → real crop info so paste-back lands right.
# Carry the FULL mask (wired, else the file's own alpha) full-frame.
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else self._full_mask_from_pil(pil)
return (img_t, mask_t, ow, oh, self._make_crop_info(tensor, meta, full_mask))
tensor = torch.from_numpy(arr)[None,] # [1, H, W, 3]
return self._crop_tensor(tensor, meta)
except Exception as e:
print(f"[PixaromaCrop] src load error: {e}")
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
return (empty_image, doc_w, doc_h)
NODE_CLASS_MAPPINGS = {
+1 -1
View File
@@ -61,7 +61,7 @@ class PixaromaFindReplace:
"The text after all your find/replace rules (and Tidy) are applied.",
)
FUNCTION = "apply"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
OUTPUT_NODE = True
def apply(self, text, FindReplaceState="{}"):
+1 -1
View File
@@ -144,7 +144,7 @@ class PixaromaImageResize:
},
}
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
CATEGORY = "👑 Pixaroma"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "longest_side")
OUTPUT_TOOLTIPS = (
-319
View File
@@ -1,319 +0,0 @@
import os
import json
import uuid
import numpy as np
import torch
from PIL import Image
import folder_paths
from .node_ref import any_type, FlexibleOptionalInputType
from ._inpaint_helpers import (
apply_inpaint_crop, merge_params, resolve_inpaint_mask, PIXAROMA_CROP_INFO, DEFAULTS,
)
# Friendly combo labels -> internal size-mode keys used by the geometry helper.
_SIZE_MODE = {
"keep shape (long side)": "keep",
"force size (square)": "force",
"free (multiple only)": "free",
}
# Seam paste-back mode -> internal key. Mirrored by the editor's Blend mode pill.
_BLEND_MODE = {
"mask": "mask",
"whole crop": "whole_crop",
}
class _InpaintOptionalInputs(FlexibleOptionalInputType):
"""Declares concrete optional IMAGE + MASK inputs (so drag-from-output search
finds the node) while still accepting the hidden InpaintCropWidget DOM-widget
value (and anything else) via the flexible any_type fallback - same trick as
Image Crop's _CropOptionalInputs."""
def __init__(self, type):
super().__init__(type)
self["image"] = ("IMAGE", {
"tooltip": (
"Wire any upstream IMAGE here to inpaint it (Load Image, VAE "
"Decode, anything). You can also drag-drop or paste an image onto "
"the node body - those load it directly and disconnect this wire."
),
})
self["mask"] = ("MASK", {
"tooltip": (
"Optional mask of the area to inpaint (e.g. a transparent PNG's "
"alpha, or any MASK output). It is used as-is whenever you have not "
"painted a mask in the editor - so clearing the editor falls back to "
"this wired mask. A mask painted in the editor takes priority."
),
})
def __getitem__(self, key):
if dict.__contains__(self, key):
return dict.__getitem__(self, key)
return (self.type,)
class PixaromaInpaintCrop:
DESCRIPTION = (
"Inpaint Crop Pixaroma - the easy way to set up an inpaint. Open the "
"fullscreen editor and paint a mask over the area you want to fix (brush, "
"erase, clear, invert, adjustable brush size). The node automatically "
"finds the box around your mask, adds a context margin, and crops a "
"model-friendly piece (sized to a multiple of 8, scaled toward your "
"target so even a small masked area gets enough resolution).\n\n"
"Turn on invert_mask to flip the mask and inpaint the OPPOSITE area (e.g. a "
"cut-out's background instead of its subject), no Invert Mask node needed.\n\n"
"Wire the cropped image and mask into your inpaint model (KSampler, Flux, "
"edit models), then send the crop_info wire into Inpaint Stitch Pixaroma "
"to paste the result back onto the original at the exact spot.\n\n"
"Outputs the cropped image, the matching cropped mask, a crop_info wire, "
"and the crop width and height (handy for an empty latent). The crop_info "
"wire is the same type Image Crop uses, so the two are interchangeable."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"size_mode": (list(_SIZE_MODE.keys()), {
"default": "keep shape (long side)",
"tooltip": (
"Keep shape: scale the masked area so its long side hits "
"the target, no stretching (best quality). Force size: "
"always output a target x target square. Free: natural "
"size, just rounded to the multiple."
),
}),
"target": ("INT", {
"default": 1024, "min": 64, "max": 8192, "step": 8,
"tooltip": "Target long side (keep) or square size (force), in px.",
}),
"multiple": ([8, 16, 32, 64], {
"default": 8,
"tooltip": "Round the crop size to this multiple for model compatibility.",
}),
"context_px": ("INT", {
"default": 24, "min": 0, "max": 1024, "step": 1,
"tooltip": "How many extra pixels of surrounding context to include each side.",
}),
"mask_grow": ("INT", {
"default": 4, "min": 0, "max": 256, "step": 1,
"tooltip": "Expand the painted mask by this many pixels before cropping.",
}),
"mask_blur": ("INT", {
"default": 4, "min": 0, "max": 256, "step": 1,
"tooltip": "Soften the output mask edge by this many pixels for a smoother inpaint.",
}),
"softness": ("INT", {
"default": 16, "min": 0, "max": 150, "step": 1,
"tooltip": (
"How far the seam feathers when Inpaint Stitch pastes the "
"crop back. Previewed live in the mask editor."
),
}),
"blend_mode": (list(_BLEND_MODE.keys()), {
"default": "mask",
"tooltip": (
"How Inpaint Stitch pastes the result back. 'mask': only the "
"area you painted is replaced - the rest of the crop keeps the "
"original (the normal inpaint, the safe default). 'whole crop': "
"the entire cropped region is replaced with the model's version "
"(use when the model also relit / changed the surroundings, or "
"for an img2img-style pass over the whole crop). Previewed live "
"in the mask editor."
),
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": (
"Flip the mask so the inpaint targets the OPPOSITE area "
"(swap subject and background). Works on a wired mask or a "
"painted one - a built-in alternative to an Invert Mask node. "
"No effect if no mask is connected."
),
}),
},
"optional": _InpaintOptionalInputs(any_type),
}
RETURN_TYPES = ("IMAGE", "MASK", PIXAROMA_CROP_INFO, "INT", "INT")
RETURN_NAMES = ("image", "mask", "crop_info", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped region, resized to the model-friendly output size.",
"The cropped mask at the same size (grown and blurred as set). Wire it "
"into SetLatentNoiseMask / your inpaint conditioning.",
"Crop info for Inpaint Stitch Pixaroma - carries the original image and "
"where the crop came from so the inpainted result can be pasted back "
"exactly. Same type as Image Crop, so they are interchangeable.",
"Cropped output width in pixels (for an empty latent / edit models).",
"Cropped output height in pixels.",
)
FUNCTION = "run"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
OUTPUT_NODE = True
# ─────────────────────────────────────────────────────────────────────────
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Re-run on any knob change, or when the painted-mask file changes."""
parts = [str(kwargs.get(k)) for k in
("size_mode", "target", "multiple", "context_px", "mask_grow", "mask_blur", "softness", "blend_mode", "invert_mask")]
state = kwargs.get("InpaintCropWidget")
try:
sj = state.get("state_json", "{}") if isinstance(state, dict) else str(state)
meta = json.loads(sj) if sj else {}
parts.append(json.dumps(meta, sort_keys=True)) # deterministic regardless of key order
mp = meta.get("mask_path", "")
if mp:
full = cls._resolve_pixaroma_path(mp)
if full:
parts.append(str(os.path.getmtime(full)))
sp = meta.get("src_path", "")
if sp:
fs = cls._resolve_pixaroma_path(sp)
if fs:
parts.append(str(os.path.getmtime(fs)))
except Exception:
parts.append(str(state))
return "|".join(parts)
@staticmethod
def _resolve_pixaroma_path(rel_path):
if not rel_path:
return None
input_dir = os.path.realpath(folder_paths.get_input_directory())
full_path = os.path.realpath(os.path.join(input_dir, rel_path))
if not full_path.startswith(input_dir + os.sep):
print("[PixaromaInpaintCrop] Security: path escapes input dir, blocked.")
return None
return full_path if os.path.exists(full_path) else None
def _save_source_temp(self, tensor):
"""Stash the input tensor (frame 0) to temp/ so the JS editor can load it."""
try:
if not isinstance(tensor, torch.Tensor) or tensor.dim() != 4 or tensor.shape[0] == 0:
return None
arr = (tensor[0].clamp(0, 1).cpu().numpy() * 255.0 + 0.5).astype(np.uint8)
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
fname = f"pixaroma_inpaint_src_{uuid.uuid4().hex}.png"
Image.fromarray(arr).save(os.path.join(temp_dir, fname), "PNG")
return fname
except Exception as e:
print(f"[PixaromaInpaintCrop] temp source save failed: {e}")
return None
def _load_disk_image(self, rel_path):
full = self._resolve_pixaroma_path(rel_path)
if not full:
return None
try:
arr = np.array(Image.open(full).convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,]
except Exception as e:
print(f"[PixaromaInpaintCrop] source load error: {e}")
return None
def _load_disk_mask(self, rel_path):
full = self._resolve_pixaroma_path(rel_path)
if not full:
return None
try:
arr = np.array(Image.open(full).convert("L")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,]
except Exception as e:
print(f"[PixaromaInpaintCrop] mask load error: {e}")
return None
def _empty(self):
img = torch.ones((1, 1024, 1024, 3), dtype=torch.float32)
mask = torch.zeros((1, 1024, 1024), dtype=torch.float32)
info = {"image": img, "mask": mask, "x": 0, "y": 0, "w": 1024, "h": 1024,
"orig_w": 1024, "orig_h": 1024}
return (img, mask, info, 1024, 1024)
def _params(self, size_mode, target, multiple, context_px, mask_grow, mask_blur):
mode = _SIZE_MODE.get(size_mode, "keep")
p = dict(DEFAULTS)
p.update({
"size_mode": mode, "target": int(target),
"target_w": int(target), "target_h": int(target),
"multiple": int(multiple), "context_px": int(context_px),
"mask_grow": int(mask_grow), "mask_blur": int(mask_blur),
})
return merge_params(p)
def run(self, size_mode="keep shape (long side)", target=1024, multiple=8,
context_px=24, mask_grow=4, mask_blur=4, softness=16, blend_mode="mask",
invert_mask=False, **kwargs):
upstream = kwargs.get("image")
upstream_mask = kwargs.get("mask")
state = kwargs.get("InpaintCropWidget")
meta = {}
if state is not None:
try:
sj = state.get("state_json", "{}") if isinstance(state, dict) else str(state)
if sj and sj.strip() not in ("", "{}"):
parsed = json.loads(sj)
if isinstance(parsed, dict):
meta = parsed
except Exception as e:
print(f"[PixaromaInpaintCrop] state parse error: {e}")
# ── source image: wired upstream wins, else the editor-saved src on disk
ui_payload = None
image = upstream if isinstance(upstream, torch.Tensor) else None
if image is not None:
src_fname = self._save_source_temp(image)
if src_fname:
ui_payload = {"pixaroma_inpaint_source": [
{"filename": src_fname, "subfolder": "", "type": "temp"}]}
else:
image = self._load_disk_image(meta.get("src_path", ""))
if not isinstance(image, torch.Tensor):
return self._empty()
# ── mask: a PAINTED editor mask wins; a cleared/empty one (mask_path set
# but the saved file is all-black) falls back to the wired mask, so clearing
# the editor uses the wired mask as-is. resolve_inpaint_mask owns the rule.
disk_mask = self._load_disk_mask(meta.get("mask_path", ""))
mask = resolve_inpaint_mask(disk_mask, upstream_mask)
params = self._params(size_mode, target, multiple, context_px, mask_grow, mask_blur)
# Seam softness (the node 'softness' knob, mirrored by the editor): feed it
# into the geometry so the crop CONTEXT grows to fit the feather (Option B -
# compute_region uses max(context_px, blend)), then ride it on crop_info to
# Inpaint Stitch as the seam-feather width.
sb = max(0, min(150, int(softness)))
params["blend"] = sb
params["invert_mask"] = bool(invert_mask) # flip the mask before cropping
try:
img_t, mask_t, crop_info, ow, oh = apply_inpaint_crop(image, mask, params)
except Exception as e:
print(f"[PixaromaInpaintCrop] crop error: {e}")
return self._empty()
# blend mode rides crop_info. It is now a NODE widget (mirrored by the editor's
# Blend mode pill), so the node widget is the source of truth - not state_json.
# (color_match is the Stitch node's own knob.)
crop_info["blend"] = sb
crop_info["blend_mode"] = _BLEND_MODE.get(blend_mode, "mask")
result = (img_t, mask_t, crop_info, ow, oh)
if ui_payload:
return {"ui": ui_payload, "result": result}
return result
NODE_CLASS_MAPPINGS = {
"PixaromaInpaintCrop": PixaromaInpaintCrop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaInpaintCrop": "Inpaint Crop Pixaroma",
}
-136
View File
@@ -1,136 +0,0 @@
import torch
from ._inpaint_helpers import stitch_back, resolve_seam, PIXAROMA_CROP_INFO
class PixaromaInpaintStitch:
DESCRIPTION = (
"Inpaint Stitch Pixaroma - paste your inpainted crop back onto the "
"original image at the exact spot it came from, blended so the seam "
"disappears.\n\n"
"Wire the crop_info output of Inpaint Crop Pixaroma into crop_info here, "
"and wire your inpainted crop (after the model) into image. The node "
"resizes the crop back to the region and blends only the painted area by "
"default, so everything outside the mask stays pixel-perfect.\n\n"
"The seam softness and blend mode come from the Inpaint Crop node on the "
"crop_info wire, but you can OVERRIDE them here (softness -1 = use the "
"crop's). Because this node is after the sampler, changing softness, blend "
"mode or color match re-runs only this node - the sampler stays cached on a "
"fixed seed - so you can fine-tune the blend instantly without re-generating. "
"color match corrects a color/tone shift the model introduced.\n\n"
"Outputs the finished full image, plus the original uncropped image - wire "
"both into Image Compare Pixaroma for an instant before / after."
)
@classmethod
def INPUT_TYPES(cls):
# Slot order image, mask, crop_info lines up under Inpaint Crop's
# image / mask / crop_info outputs so the wires run straight across.
return {
"required": {
"image": ("IMAGE", {
"tooltip": (
"Your inpainted crop (after the model). Resized back to the "
"original crop region automatically if its size differs."
),
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": (
"Optional. Limits the blend to this area (resized to the "
"crop region). If omitted, the painted mask carried in "
"crop_info is used for the mask-aware blend."
),
}),
"crop_info": (PIXAROMA_CROP_INFO, {
"tooltip": (
"Wire the crop_info output of Inpaint Crop Pixaroma here. "
"If left unwired, the image passes straight through."
),
}),
"softness": ("INT", {
"default": -1, "min": -1, "max": 150, "step": 1,
"tooltip": (
"Seam feather, overriding the Inpaint Crop node's softness. "
"-1 = use the Crop node's value. Set 0-150 to tune the blend "
"HERE - because Stitch is after the sampler, only this node "
"re-runs (the sampler stays cached on a fixed seed), so it is "
"instant. Going bigger than the room the crop left may show a "
"slightly harder edge - raise the Crop node's softness for "
"more room."
),
}),
"blend_mode": (["from crop", "mask", "whole crop"], {
"default": "from crop",
"tooltip": (
"Override the Crop node's blend mode. 'from crop' = use what "
"the Crop node set. 'mask' = replace only the painted area. "
"'whole crop' = replace the entire cropped box. Like softness, "
"changing it here re-runs only this node (no re-sample)."
),
}),
"color_match": (["off", "subtle", "strong"], {
"default": "off",
"tooltip": (
"Correct a color/tone shift the model introduced, matching "
"the unchanged surroundings around your mask. Keep it Off "
"when you deliberately changed colors (it would pull them "
"back). No live preview - set it and re-run."
),
}),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("image", "original")
OUTPUT_TOOLTIPS = (
"The original image with the inpainted crop blended back in place.",
"The full original uncropped image (from crop_info) - wire it together "
"with the result into Image Compare Pixaroma for a before / after.",
)
FUNCTION = "run"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
def run(self, image, crop_info=None, mask=None, softness=-1,
blend_mode="from crop", color_match="off"):
# No valid crop_info -> nothing to paste back; pass the image through as
# both outputs so downstream wiring still works. Require the geometry keys
# too, so a malformed dict doesn't silently paste at (0,0) full-size.
if (not isinstance(crop_info, dict)
or not isinstance(crop_info.get("image"), torch.Tensor)
or crop_info["image"].dim() != 4
or any(k not in crop_info for k in ("x", "y", "w", "h"))):
# malformed / missing crop_info (incl. a non-[B,H,W,C] image) -> pass the
# image through as BOTH outputs so the graph still runs. Checking the rank
# here keeps a bad image out of stitch_back (which would otherwise throw and
# the except below would silently make `original` a copy of the result).
print("[PixaromaInpaintStitch] no valid crop_info wired - passing image through")
return (image, image)
# Seam blend + mode ride in on crop_info from the Crop node, but THIS node's
# softness / blend_mode widgets override them when set (so the blend can be
# tuned here without re-running the sampler). color_match is this node's own
# knob (post-result tweak, no live preview).
blend, blend_mode = resolve_seam(crop_info, softness, blend_mode)
cm = str(color_match)
color_match = cm if cm in ("off", "subtle", "strong") else "off"
try:
result, original = stitch_back(crop_info, image, mask, blend, blend_mode, color_match)
except Exception as e:
# A genuine fault inside stitch_back (e.g. CUDA OOM). Pass the inpainted
# image through as the result, but keep `original` as the TRUE uncropped
# original (crop_info["image"] already passed the dim==4 guard above), so a
# downstream before/after compare shows the real original, not a patch copy.
print(f"[PixaromaInpaintStitch] stitch error: {e}")
return (image, crop_info["image"])
return (result, original)
NODE_CLASS_MAPPINGS = {
"PixaromaInpaintStitch": PixaromaInpaintStitch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaInpaintStitch": "Inpaint Stitch Pixaroma",
}
+1 -1
View File
@@ -26,7 +26,7 @@ class PixaromaLabel:
# execution also stops ComfyUI from drawing an "X.Xs" timing badge
# above the node every run, which looks weird on what is just a
# styled text caption.
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
CATEGORY = "👑 Pixaroma"
def noop(self, label_json):
return {}
+1 -1
View File
@@ -117,7 +117,7 @@ class PixaromaLoadImage:
},
}
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = (
"image", "mask", "width", "height",
-244
View File
@@ -1,244 +0,0 @@
"""Load Images from Folder Pixaroma.
A "folder version" of Load Image Pixaroma: point it at any folder on disk, pick
which images to process (all / first N / hand-picked in a thumbnail gallery), and
on Run feed each selected image through the workflow ONE AT A TIME via ComfyUI's
list mechanism (OUTPUT_IS_LIST) - one finished result per image.
State (folder, selection, options, resize) arrives as a hidden JSON string the
frontend injects via app.graphToPrompt (see js/load_images_folder/). The resize
keys are identical to node_load_image.py::DEFAULT_STATE so the shared
nodes/_resize_helpers._resize_frame engine works unchanged.
"""
import hashlib
import json
import os
import numpy as np
import torch
from PIL import Image, ImageOps, ImageSequence
from ._resize_helpers import _resize_frame
# Resize keys MUST match node_load_image.py::DEFAULT_STATE (shared engine).
DEFAULT_STATE = {
"version": 1,
"folder": "",
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selected": [],
# ── resize keys (mirror Load Image) ──
"mode": "off",
"max_mp": 1.0,
"longest_side": 1024,
"scale_factor": 1.0,
"fit_w": 1024, "fit_h": 1024,
"cover_w": 1024, "cover_h": 1024,
"ratio_preset": "1:1",
"ratio_w": 1, "ratio_h": 1,
"ratio_action": "crop",
"pad_color": "#808080",
"pad_top": 0, "pad_bottom": 0, "pad_left": 0, "pad_right": 0,
"crop_anchor": "center", "crop_scale": True,
"snap": 0,
"resample": "auto",
"allow_upscale": True,
}
def _parse_state(state_json: str) -> dict:
"""Merge the hidden state JSON over DEFAULT_STATE. Falls back to defaults on
any parse error (state may be missing/malformed in subgraph/partial-prompt
cases - CLAUDE.md Vue Compat #9)."""
if not state_json:
return dict(DEFAULT_STATE)
try:
parsed = json.loads(state_json)
merged = dict(DEFAULT_STATE)
merged.update({k: v for k, v in parsed.items() if k in DEFAULT_STATE})
return merged
except Exception:
print("[PixaromaLoadImagesFolder] Malformed state JSON, using defaults")
return dict(DEFAULT_STATE)
def _load_one(path, state, dtype):
"""Open one image file, return (image_tensor, mask_tensor). Uses the first
frame only (folders of stills); applies the shared resize engine."""
img = node_pillow_open(path)
frame = ImageOps.exif_transpose(next(ImageSequence.Iterator(img)))
if frame.mode == "I":
frame = frame.point(lambda px: px * (1 / 255))
rgb = frame.convert("RGB")
orig_w, orig_h = rgb.size
if "A" in frame.getbands():
alpha = np.array(frame.getchannel("A")).astype(np.float32) / 255.0
mask_pil = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
elif frame.mode == "P" and "transparency" in frame.info:
alpha = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask_pil = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
else:
mask_pil = Image.new("L", rgb.size, 0)
rgb_r, mask_r, fw, fh = _resize_frame(rgb, mask_pil, state, orig_w, orig_h)
t = torch.from_numpy(np.array(rgb_r).astype(np.float32) / 255.0)[None,].to(dtype=dtype)
m = torch.from_numpy(np.array(mask_r).astype(np.float32) / 255.0).unsqueeze(0).to(dtype=dtype)
return t, m, int(fw), int(fh)
def node_pillow_open(path):
"""Open with the same defensive wrapper ComfyUI uses (handles truncated
files gracefully where possible)."""
try:
import node_helpers
return node_helpers.pillow(Image.open, path)
except Exception:
return Image.open(path)
class PixaromaLoadImagesFolder:
DESCRIPTION = (
"Load many images from any folder on disk and feed them through your "
"workflow one at a time - one finished result per image. Pick all, the "
"first N, or hand-pick specific images in a thumbnail gallery. Same resize "
"options as Load Image Pixaroma (max megapixels, longest side, scale by, "
"fit inside, crop to fill, match aspect ratio). Outputs are a list: image, "
"mask, width, height, filename, index, total. Wire filename into a Save node "
"so each result keeps its original name, and width/height into an empty latent "
"so it matches each image's size. Hit Run once and leave the batch count at 1."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"LoadImagesFolderState": (
"STRING",
{"default": json.dumps(DEFAULT_STATE)},
),
},
}
CATEGORY = "👑 Pixaroma/🖼️ Image"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "filename", "index", "total")
OUTPUT_IS_LIST = (True, True, True, True, True, True, True)
OUTPUT_TOOLTIPS = (
"Each selected image, one per list item (after any resize).",
"Each image's mask from its alpha channel (blank if it has none).",
"Each image's width in pixels (after any resize) - wire into an empty latent so it matches.",
"Each image's height in pixels (after any resize).",
"Each image's filename without the extension - wire into Save so results keep their original names.",
"1-based position of each image in this batch (1, 2, 3 ...).",
"How many images are in this batch - i.e. how many loaded (same for every item).",
)
FUNCTION = "load"
def load(self, LoadImagesFolderState: str = ""):
state = _parse_state(LoadImagesFolderState)
folder = state.get("folder", "") or ""
selected = state.get("selected", []) or []
if not folder or not os.path.isdir(folder):
raise ValueError(
"Load Images from Folder: folder not found. Pick a folder on the node "
"(type or paste a path, or use Browse)."
)
if not selected:
raise ValueError(
"Load Images from Folder: no images selected. Click 'Pick images' and "
"choose at least one."
)
try:
import comfy.model_management as _mm
dtype = _mm.intermediate_dtype()
except Exception:
dtype = torch.float32
real_folder = os.path.realpath(folder)
recursive = bool(state.get("recursive", False))
images, masks, widths, heights, names, indices = [], [], [], [], [], []
count = 0
for rel in selected:
if not isinstance(rel, str) or not rel:
continue # malformed selection entry (e.g. null/number in state)
# Keep every selected file INSIDE the chosen folder. `selected` comes
# from the (frontend-supplied) hidden state, so a crafted "../../x"
# must not let the loader open files outside the folder.
path = os.path.realpath(os.path.join(folder, rel))
try:
if os.path.commonpath([path, real_folder]) != real_folder:
print(f"[PixaromaLoadImagesFolder] outside folder, skipped: {rel}")
continue
except ValueError:
continue # different drive on Windows
if not os.path.isfile(path):
print(f"[PixaromaLoadImagesFolder] missing, skipped: {rel}")
continue
try:
t, m, fw, fh = _load_one(path, state, dtype)
except Exception as e:
print(f"[PixaromaLoadImagesFolder] failed to load {rel}: {e}")
continue
images.append(t)
masks.append(m)
widths.append(fw)
heights.append(fh)
if recursive:
# keep names unique across subfolders so a Save node can't
# overwrite: "sub/cat.png" -> "sub_cat"
name = os.path.splitext(rel)[0].replace("/", "_").replace("\\", "_")
else:
name = os.path.splitext(os.path.basename(rel))[0]
names.append(name)
count += 1
indices.append(count)
if not images:
raise ValueError(
"Load Images from Folder: none of the selected images could be loaded "
"(missing or unreadable). Re-check the folder and your selection."
)
totals = [count] * len(images)
return (images, masks, widths, heights, names, indices, totals)
@classmethod
def IS_CHANGED(cls, LoadImagesFolderState: str = ""):
state = _parse_state(LoadImagesFolderState)
folder = state.get("folder", "") or ""
real_folder = os.path.realpath(folder)
# Everything except `selected` (options + resize) goes in as a stable blob;
# selected files contribute their per-file mtime so edits on disk re-run.
opts = {k: state[k] for k in state if k != "selected"}
parts = [json.dumps(opts, sort_keys=True)]
for rel in state.get("selected", []) or []:
if not isinstance(rel, str) or not rel:
continue # malformed entry - skip (mirrors load())
# Mirror load()'s guard: never stat a file outside the chosen folder
# (selected is frontend-supplied, so a crafted "../../x" must not reach
# os.stat). Outside paths hash as a constant instead of a real stat.
p = os.path.realpath(os.path.join(folder, rel))
try:
if os.path.commonpath([p, real_folder]) != real_folder:
parts.append(f"{rel}:outside")
continue
except ValueError:
parts.append(f"{rel}:outside") # different drive on Windows
continue
try:
parts.append(f"{rel}:{os.stat(p).st_mtime_ns}")
except OSError:
parts.append(f"{rel}:missing")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
NODE_CLASS_MAPPINGS = {"PixaromaLoadImagesFolder": PixaromaLoadImagesFolder}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaLoadImagesFolder": "Load Images from Folder Pixaroma"}
+1 -1
View File
@@ -67,7 +67,7 @@ class PixaromaMuteSwitch:
)
FUNCTION = "noop"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def noop(self, **kwargs):
# All muting happens in the JS frontend BEFORE this node is reached.
+1 -1
View File
@@ -46,7 +46,7 @@ class PixaromaNote:
# execution also stops ComfyUI from drawing an "X.Xs" timing badge
# above the note on every run, which looks weird on a decorative
# rich-text annotation.
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
CATEGORY = "👑 Pixaroma"
def noop(self, note_json):
return {}
+1 -1
View File
@@ -99,7 +99,7 @@ class NotifyPixaroma:
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "notify"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def notify(self, any, enabled, sound, volume, label):
if not enabled:
+1 -1
View File
@@ -47,7 +47,7 @@ class PixaromaNumber:
RETURN_NAMES = ("int", "float")
OUTPUT_TOOLTIPS = ("The value as a whole number (rounded to nearest).", "The value as a decimal number, kept as-is.")
FUNCTION = "out"
CATEGORY = "👑 Pixaroma/🔢 Values"
CATEGORY = "👑 Pixaroma"
def out(self, value):
# Defensive: ComfyUI normally guarantees a numeric `value`, but if a
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaPaint:
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The painted image.", "Image width in pixels.", "Image height in pixels.")
FUNCTION = "load_painting"
CATEGORY = "👑 Pixaroma/🎨 Editors"
CATEGORY = "👑 Pixaroma"
OUTPUT_NODE = True
@classmethod
+1 -1
View File
@@ -99,7 +99,7 @@ class PixaromaPauseImage:
OUTPUT_TOOLTIPS = ("The image continuing downstream - the live input in Pause/Pass mode, or the reloaded snapshot in Continue mode.",)
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
@classmethod
def IS_CHANGED(cls, **kwargs):
-55
View File
@@ -1,55 +0,0 @@
"""Portrait Landscape Pixaroma — output a width/height in the chosen
orientation. Enter (or wire) two dimensions and click Portrait or Landscape:
Portrait returns the TALL arrangement (smaller value as width), Landscape the
WIDE arrangement (larger value as width). One node replaces the
WH + WH + Switch WH 'flip orientation' setup.
The Portrait/Landscape choice lives on node.properties.portraitLandscapeState
and is injected into the hidden PortraitLandscapeState input by the JS
graphToPrompt hook (Resolution Pixaroma pattern, CLAUDE.md Vue Compat #9), so
no extra input slot is exposed for it."""
class PixaromaPortraitLandscape:
DESCRIPTION = (
"Output a width and height in the orientation you pick. Enter your two "
"dimensions (or wire them in from another node), then click Portrait or "
"Landscape. Portrait gives the tall arrangement (the smaller number "
"becomes the width), Landscape gives the wide arrangement (the larger "
"number becomes the width). Flipping orientation is one click, with no "
"need to keep two WH nodes and a switch. The order you enter the two "
"numbers does not matter. Outputs width and height."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 832, "min": 8, "max": 16384, "step": 8, "tooltip": "One of your two dimensions. Type a value or wire a number in. Portrait/Landscape decides whether this becomes the long or short side, so the order you enter the two numbers does not matter."}),
"height": ("INT", {"default": 1216, "min": 8, "max": 16384, "step": 8, "tooltip": "The other dimension. Type a value or wire a number in."}),
},
"hidden": {
"PortraitLandscapeState": ("STRING", {"default": "portrait"}),
},
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = (
"The output width. In Portrait this is the smaller of your two numbers; in Landscape it is the larger.",
"The output height. In Portrait this is the larger of your two numbers; in Landscape it is the smaller.",
)
FUNCTION = "orient"
CATEGORY = "👑 Pixaroma/🔢 Values"
def orient(self, width=832, height=1216, PortraitLandscapeState="portrait"):
w = int(width)
h = int(height)
lo, hi = min(w, h), max(w, h)
if PortraitLandscapeState == "landscape":
return (hi, lo) # wide
return (lo, hi) # portrait / tall (default)
NODE_CLASS_MAPPINGS = {"PixaromaPortraitLandscape": PixaromaPortraitLandscape}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaPortraitLandscape": "Portrait Landscape Pixaroma"}
+1 -1
View File
@@ -76,7 +76,7 @@ class PixaromaPreview:
OUTPUT_TOOLTIPS = ("The image(s) passed through unchanged, so you can chain a preview inline without breaking the wire.",)
FUNCTION = "preview"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
@classmethod
def IS_CHANGED(cls, **kwargs):
+1 -1
View File
@@ -51,7 +51,7 @@ class PixaromaPromptFromList:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The prompt picked from the list at the chosen index. Empty if the index is out of range.",)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
def pick(self, prompts, index):
if not isinstance(prompts, list):
+1 -1
View File
@@ -63,7 +63,7 @@ class PixaromaPromptMulti:
"The list of all enabled prompts. Use in List mode - wire into Prompt From List Pixaroma nodes.",
)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
@classmethod
def IS_CHANGED(cls, PromptMultiState="{}", **kwargs):
+1 -1
View File
@@ -41,7 +41,7 @@ class PixaromaPromptPack:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The current prompt for this queue run (one prompt from the block). Wire to CLIP Text Encode.",)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
@classmethod
def IS_CHANGED(cls, PromptPackState="{}", **kwargs):
+1 -1
View File
@@ -56,7 +56,7 @@ class PixaromaPromptReader:
},
}
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The prompt recovered from the image's metadata, or an explanatory message if none was found.",)
+1 -1
View File
@@ -36,7 +36,7 @@ class PixaromaPromptStack:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("All enabled chunks joined into one string using your separator.",)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
def build(self, PromptStackState="{}"):
state = self._parse_state(PromptStackState)
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaRemoveBackground:
"Mask where the removed background is white (the foreground mask, inverted).",
)
FUNCTION = "execute"
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
DESCRIPTION = (
"Remove an image background with a BiRefNet model and return the "
"cutout (RGBA), the foreground mask, and the inverted mask in one "
-139
View File
@@ -1,139 +0,0 @@
"""Resize Crop Pixaroma — force an image to an exact width x height by
crop-to-fill (cover). Scales the image so it completely fills the target box,
then center-crops the overflow, so the output is ALWAYS exactly width x height
with no stretching or letterboxing. Width/height are plain number widgets you
can type into OR wire from another node. An optional mask is cropped in sync.
Reuses the shared, JS-mirrored resize engine (_resize_helpers._apply_cover via
_resize_frame) so the crop math matches Image Resize Pixaroma's Crop-to-fill
mode exactly. No custom JS — native widgets render in both Classic and
Nodes 2.0 renderers automatically."""
import numpy as np
import torch
from PIL import Image
from ._resize_helpers import _resize_frame
def _tensor_to_pils(image_t):
"""BHWC float tensor -> list of RGB PIL images. Defensive about channel
count (1ch grayscale -> RGB, 4ch RGBA -> alpha dropped) so a stray image
can't crash the run. Mirrors Image Resize Pixaroma's converter."""
arr = (image_t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
out = []
for frame in arr:
if frame.ndim == 2: # (H,W) grayscale
frame = np.stack([frame] * 3, axis=-1)
elif frame.shape[-1] >= 3: # RGB / RGBA (drop alpha)
frame = frame[..., :3]
else: # 1- or 2-channel -> grayscale
frame = np.repeat(frame[..., :1], 3, axis=-1)
out.append(Image.fromarray(frame, "RGB"))
return out
def _mask_to_pils(mask_t, count, size):
"""BHW float tensor -> list of L PIL images, each conformed to `size` (the
image size) so the mask always matches the image. Blank (zeros) when
mask_t is None. NEAREST resize keeps mask edges crisp."""
if mask_t is None:
return [Image.new("L", size, 0) for _ in range(count)]
arr = (mask_t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
out = []
for m in arr:
pim = Image.fromarray(m, "L")
if pim.size != size:
pim = pim.resize(size, Image.NEAREST)
out.append(pim)
while len(out) < count:
out.append(Image.new("L", size, 0))
return out[:count]
class PixaromaResizeCrop:
DESCRIPTION = (
"Crop an image to an exact width and height. The image is scaled to "
"completely fill the target size, then the overflow is cropped away "
"from the center, so the result is always exactly the width and height "
"you set, with no stretching or letterboxing. Smaller images are scaled "
"up to fill. Type the size into the width and height fields, or wire "
"them from another node (e.g. Resolution Pixaroma or a Number node). An "
"optional mask is cropped the same way. Outputs image, mask, width, and "
"height - handy for forcing image or video frames to a fixed size like "
"512x896 or 704x1280."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "The image (or batch / video frames) to crop to size."}),
"width": ("INT", {"default": 1024, "min": 8, "max": 16384, "step": 8, "tooltip": "Target width in pixels. Type a value or wire a number from another node. The arrows step by 8 (AI/video sizes are usually multiples of 8) but you can type any value."}),
"height": ("INT", {"default": 1024, "min": 8, "max": 16384, "step": 8, "tooltip": "Target height in pixels. Type a value or wire a number from another node. The arrows step by 8 but you can type any value."}),
},
"optional": {
"mask": ("MASK", {"tooltip": "Optional mask. Cropped to the same width and height as the image, with crisp (nearest) edges."}),
},
}
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped image, exactly width x height pixels.",
"The cropped mask, matching the output image size (blank when no mask is wired in).",
"The output width in pixels.",
"The output height in pixels.",
)
FUNCTION = "run"
def run(self, image, width, height, mask=None):
tw = max(8, min(int(width), 16384))
th = max(8, min(int(height), 16384))
# Crop-to-fill (cover) via the shared engine: scale to cover, then
# center-crop the overflow. allow_upscale True so small sources fill
# the box; snap 0 so the output is EXACTLY tw x th.
state = {
"mode": "cover",
"cover_w": tw,
"cover_h": th,
"crop_anchor": "center",
"crop_scale": True,
"allow_upscale": True,
"snap": 0,
"resample": "auto",
}
rgb_frames = _tensor_to_pils(image)
orig_w, orig_h = rgb_frames[0].size
mask_frames = _mask_to_pils(mask, len(rgb_frames), (orig_w, orig_h))
out_imgs, out_masks = [], []
final_w = final_h = None
for rgb, m in zip(rgb_frames, mask_frames):
r_rgb, r_mask, fw, fh = _resize_frame(rgb, m, state, orig_w, orig_h)
final_w, final_h = fw, fh
out_imgs.append(
torch.from_numpy(np.array(r_rgb).astype(np.float32) / 255.0)[None,]
)
out_masks.append(
torch.from_numpy(np.array(r_mask).astype(np.float32) / 255.0)[None,]
)
out_image = torch.cat(out_imgs, dim=0) if len(out_imgs) > 1 else out_imgs[0]
out_mask = torch.cat(out_masks, dim=0) if len(out_masks) > 1 else out_masks[0]
# Match the input device so both outputs pair cleanly with GPU-resident
# tensors downstream (CLAUDE.md Image Crop/Uncrop Pattern #6). No-op for
# the usual CPU IMAGE case.
out_image = out_image.to(image.device)
out_mask = out_mask.to(image.device)
if final_w is None:
final_w, final_h = tw, th
return (out_image, out_mask, final_w, final_h)
NODE_CLASS_MAPPINGS = {"PixaromaResizeCrop": PixaromaResizeCrop}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaResizeCrop": "Resize Crop Pixaroma"}
+1 -1
View File
@@ -61,7 +61,7 @@ class PixaromaResolution:
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("The chosen width in pixels.", "The chosen height in pixels.")
FUNCTION = "get_resolution"
CATEGORY = "👑 Pixaroma/🔢 Values"
CATEGORY = "👑 Pixaroma"
def get_resolution(self, ResolutionState: str):
try:
+1 -1
View File
@@ -130,7 +130,7 @@ class PixaromaSaveMp4:
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🖼️ Image"
CATEGORY = "👑 Pixaroma"
def save(self, video_frames, fps, filename_prefix, save_mode, trim_to_audio, audio=None):
if video_frames is None or video_frames.shape[0] == 0:
-72
View File
@@ -1,72 +0,0 @@
"""Seed Pixaroma — a seed source with Random / Fixed modes + buttons.
All UI lives on the JS side; the only Python input is a hidden serialized
state string injected at execution time (same pattern as Resolution Pixaroma).
The JS frontend rolls a fresh random seed each run (Random mode) or passes the
locked value (Fixed mode), writing it into `runSeed` before submission.
"""
import json
# 2^64 - 1 — the upper bound ComfyUI's own seed widgets use, so whatever we
# output is always a valid KSampler seed.
SEED_MAX = 0xFFFFFFFFFFFFFFFF
class PixaromaSeed:
DESCRIPTION = (
"Seed Pixaroma - a seed source you wire into KSampler (or any node "
"with a seed input). One Seed node can feed several samplers at once "
"so they all share the same seed.\n\n"
"Two modes: Random rolls a fresh seed every run; Fixed keeps the same "
"seed for repeatable results. Buttons: New fixed random rolls a new "
"seed and locks it; Use last seed brings back the previous run's seed; "
"Copy puts the current seed on your clipboard. In Random mode a Last "
"run line shows the seed that actually generated the last image.\n\n"
"Outputs the seed as INT. State saves and restores with the workflow."
)
@classmethod
def INPUT_TYPES(cls):
# SeedState is `hidden` (no widget, no input slot). The JS frontend
# stores state on node.properties.seedState and injects the resolved
# per-run seed into the API prompt via app.graphToPrompt.
return {
"required": {},
"hidden": {
"SeedState": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
OUTPUT_TOOLTIPS = (
"The chosen seed as a whole number. Wire it into a sampler's seed input.",
)
FUNCTION = "get_seed"
CATEGORY = "👑 Pixaroma/🔢 Values"
@classmethod
def IS_CHANGED(cls, SeedState: str):
# Re-execute whenever the injected seed changes. In Random mode the JS
# hook injects a fresh runSeed each run (string differs -> re-run); in
# Fixed mode it stays constant (string identical -> cached / repeatable).
return SeedState
def get_seed(self, SeedState: str):
try:
state = json.loads(SeedState)
s = int(state.get("runSeed", state.get("seed", 0)))
except Exception:
print("[PixaromaSeed] Malformed state, falling back to seed 0")
s = 0
# Keep inside the valid 0..2^64-1 seed range.
if s < 0:
s = 0
elif s > SEED_MAX:
s = s % (SEED_MAX + 1)
return (s,)
NODE_CLASS_MAPPINGS = {"PixaromaSeed": PixaromaSeed}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaSeed": "Seed Pixaroma"}
-98
View File
@@ -1,98 +0,0 @@
"""Set Pixaroma / Get Pixaroma - metadata-only backend definitions.
These two nodes are pure FRONTEND virtual nodes; all of their behavior lives in
js/set_get/. They are declared here ONLY so the node library shows a proper
display name ("Set Pixaroma" / "Get Pixaroma"), the correct category, brand
colors, the Help panel, the Info panel, and good search ranking (a pure
frontend node would otherwise show up under "__frontend_only__" with its raw
class id as the name).
At run time the frontend marks every instance virtual (isVirtualNode), so
ComfyUI prunes them from the prompt and reroutes Get nodes straight to the real
upstream source. These Python classes are therefore NEVER executed; the noop
functions exist only so the class is a valid node definition. The order of
frontend registration (registerCustomNodes runs after the backend defs are
registered) means the JS class is what actually gets instantiated.
"""
from ._type_helpers import ANY
_CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
class PixaromaSetNode:
DESCRIPTION = (
"Store any connection under a name, then read it back anywhere with a "
"Get Pixaroma node. A wireless wire that keeps the canvas clean. It also "
"has a passthrough output, so a nearby node can wire to it directly while "
"far ones read it with a Get. It lives only in the editor: at run time "
"the value flows straight from the original source, so it never changes "
"the result or slows anything down. Wire anything in, type a name, and "
"keep the node collapsed to stay tidy."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": (
"STRING",
{
"default": "",
"tooltip": "The variable name. A Get Pixaroma node reads this value by picking this name.",
},
),
},
"optional": {
"value": (
ANY,
{"tooltip": "Connect anything here. The node takes on that wire's type."},
),
},
}
RETURN_TYPES = (ANY,)
RETURN_NAMES = ("*",)
OUTPUT_TOOLTIPS = (
"The same value you wired in. Connect a nearby node directly here, or read it from anywhere with Get Pixaroma.",
)
FUNCTION = "noop"
CATEGORY = _CATEGORY
def noop(self, **kwargs):
return (None,)
class PixaromaGetNode:
DESCRIPTION = (
"Read a value that a Set Pixaroma node stored under a name, with no "
"cable. Pick the name from the dropdown and the output carries that "
"Set's value, matching its type. Like Set Pixaroma it exists only in "
"the editor and resolves straight to the original source at run time, "
"so there is no extra cost. Add as many Get nodes as you like for the "
"same name to fan one value out."
)
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = (ANY,)
RETURN_NAMES = ("*",)
OUTPUT_TOOLTIPS = ("The value of the chosen Set Pixaroma node, matching its type.",)
FUNCTION = "noop"
CATEGORY = _CATEGORY
def noop(self, **kwargs):
return (None,)
NODE_CLASS_MAPPINGS = {
"PixaromaSetNode": PixaromaSetNode,
"PixaromaGetNode": PixaromaGetNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaSetNode": "Set Pixaroma",
"PixaromaGetNode": "Get Pixaroma",
}
+1 -1
View File
@@ -27,7 +27,7 @@ class PixaromaShowText:
OUTPUT_TOOLTIPS = ("The same text shown in the box, passed through so you can keep chaining it.",)
FUNCTION = "show"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
def show(self, source):
try:
+1 -1
View File
@@ -49,7 +49,7 @@ class PixaromaSwitch:
RETURN_NAMES = ("output",)
OUTPUT_TOOLTIPS = ("The input from the active (highlighted) row, passed through unchanged.",)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def pick(self, SwitchState="1", **kwargs):
try:
+1 -1
View File
@@ -70,7 +70,7 @@ class PixaromaSwitchSource:
for i in range(1, MAX_ROWS + 1)
)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def pick(self, SwitchSourceState=_DEFAULT_STATE, **kwargs):
active = "A"
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaSwitchWH:
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("The width from the active (highlighted) source.", "The height from the active (highlighted) source.")
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def pick(
self,
+1 -1
View File
@@ -53,7 +53,7 @@ class PixaromaText:
"Dynamic prompts switch is on (otherwise sent exactly as typed).",
)
FUNCTION = "out"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
CATEGORY = "👑 Pixaroma"
def out(self, text):
return (text,)
+1 -1
View File
@@ -18,7 +18,7 @@ from ._text_render_helpers import render_text_layer, compute_text_bbox
class PixaromaTextOverlay:
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
CATEGORY = "👑 Pixaroma"
DESCRIPTION = (
"Adds a single styled text overlay on top of an input image. "
"Tune font, size, weight, italic, alignment, line height, letter "
+1 -1
View File
@@ -31,7 +31,7 @@ _ANCHOR_ROWS = {
class PixaromaTextWatermark:
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
CATEGORY = "👑 Pixaroma"
DESCRIPTION = (
"Stamps a styled text watermark onto an image or a whole batch. Pick a "
"9-point anchor (corner, edge or center) plus a margin inset and the "
-277
View File
@@ -1,277 +0,0 @@
import torch
import torch.nn.functional as F
# Must match the constant emitted by node_crop.py's crop_info output. Kept as a
# plain duplicated string (not a cross-file import) to avoid an import chain.
PIXAROMA_CROP_INFO = "PIXAROMA_CROP_INFO"
class PixaromaUncrop:
DESCRIPTION = (
"Image Uncrop Pixaroma - paste an edited crop back onto the original "
"image at the exact spot it came from. The classic crop, fix or upscale, "
"then put it back workflow.\n\n"
"Wire the 'crop_info' output of Image Crop Pixaroma into 'crop_info' "
"here, and wire your edited crop (after upscaling, inpainting, color "
"work, anything) into 'image'. The node resizes the edited crop to the "
"original crop region if needed and composites it onto the full original "
"image, leaving everything outside the crop untouched.\n\n"
"Transparency travels through full-frame: the 'mask' output is the "
"original mask with the crop region updated, so wiring Image Crop's mask "
"straight across keeps the whole image's transparency (or wire an edited "
"region mask to change just that area). 'feather' softens the seam for a "
"seamless blend.\n\n"
"Outputs the recombined full image plus the full-frame mask."
)
@classmethod
def INPUT_TYPES(cls):
# Slot order is image, mask, crop_info so they line up under Image Crop
# Pixaroma's image/mask/crop_info outputs (wires run straight across).
# ComfyUI always draws required inputs before optional ones, so to keep
# that order with mask staying optional, crop_info is optional too - it
# degrades to a clean passthrough if left unwired (handled in uncrop()).
return {
"required": {
"image": ("IMAGE", {
"tooltip": (
"The edited crop to paste back (after upscaling, "
"inpainting, color work, etc). It is resized to the "
"original crop region automatically if its size differs."
),
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": (
"Optional. The mask for the crop region - it updates that "
"area of the full-frame 'mask' output (the rest keeps the "
"original mask). Wire Image Crop's mask straight across to "
"carry the whole transparency through. Resized to the crop "
"region automatically. It does NOT limit the image paste; "
"the whole region is pasted."
),
}),
"crop_info": (PIXAROMA_CROP_INFO, {
"tooltip": (
"Wire the 'crop_info' output of Image Crop Pixaroma here. "
"It carries the original image and where the crop came "
"from, so the edited crop can be placed back exactly. "
"If left unwired, the edited image just passes through."
),
}),
"feather": ("INT", {
"default": 0, "min": 0, "max": 1024, "step": 1,
"tooltip": (
"Softens the edge of the pasted area by this many pixels "
"so it blends into the original. 0 = hard edge."
),
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", PIXAROMA_CROP_INFO)
RETURN_NAMES = ("image", "mask", "crop_info")
OUTPUT_TOOLTIPS = (
"The original image with the edited crop pasted back in place.",
"The full-frame mask: the original mask with the crop region updated from "
"the wired mask (or unchanged if none). Use it to keep transparency, e.g. "
"feed it into Join Image with Alpha.",
"The same crop_info passed straight through, so you can forward it to "
"another node without re-routing the wire from Image Crop.",
)
FUNCTION = "uncrop"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
# ─────────────────────────────────────────────────────────────────────────
def _resize_bhwc(self, t, target_w, target_h):
"""Resize an image tensor [B,H,W,3] to [B,target_h,target_w,3]."""
x = t.permute(0, 3, 1, 2) # [B,3,H,W]
x = F.interpolate(x, size=(int(target_h), int(target_w)),
mode="bilinear", align_corners=False)
return x.permute(0, 2, 3, 1).contiguous()
def _resize_mask(self, m, target_w, target_h):
"""Resize a mask tensor [B,H,W] to [B,target_h,target_w]."""
x = m[:, None, ...] # [B,1,H,W]
x = F.interpolate(x, size=(int(target_h), int(target_w)),
mode="bilinear", align_corners=False)
return x[:, 0, ...].contiguous()
def _feather_alpha(self, alpha, feather):
"""Feather the alpha INWARD: ramp from 0 at the rectangle edge up to the
alpha's own value `feather` pixels inward, so a pasted crop fades all the
way to nothing at its boundary and blends seamlessly into the original.
NOTE: a box blur was tried first and was wrong - it bottomed out at ~0.5
at the edge (a 50%/50% blend right at the boundary), leaving a visible
hard step. A distance-to-edge ramp reaches a true 0 at the edge."""
k = int(feather)
if k <= 0:
return alpha
ch, cw = int(alpha.shape[-2]), int(alpha.shape[-1])
ys = torch.arange(ch, dtype=torch.float32).view(ch, 1)
xs = torch.arange(cw, dtype=torch.float32).view(1, cw)
dist_y = torch.minimum(ys, (ch - 1) - ys)
dist_x = torch.minimum(xs, (cw - 1) - xs)
dist = torch.minimum(dist_y, dist_x) # px to the nearest edge
ramp = (dist / float(k)).clamp(0.0, 1.0) # 0 at edge -> 1 at k px in
return (alpha * ramp).clamp(0.0, 1.0)
def _build_alpha(self, mask, cw, ch, feather):
"""Alpha map [ch,cw] in 0..1 for the paste: from the optional mask (else
all-ones), with the edges feathered by `feather` px."""
if isinstance(mask, torch.Tensor):
m = mask
if m.dim() == 2:
m = m[None, ...]
if m.dim() == 3:
a = self._resize_mask(m[:1], cw, ch)[0] # [ch,cw]
else:
a = torch.ones((ch, cw), dtype=torch.float32)
else:
a = torch.ones((ch, cw), dtype=torch.float32)
a = a.to(torch.float32)
return self._feather_alpha(a.clamp(0.0, 1.0), feather)
def _passthrough_mask(self, mask, image):
"""When there's nothing to paste (no crop_info), forward the WIRED mask
unchanged so transparency survives. Falls back to all-zeros (every pixel
opaque, ComfyUI convention) sized to the image only when no mask is
wired. Returned on the image's device so the two outputs pair up."""
dev = image.device if isinstance(image, torch.Tensor) else "cpu"
if isinstance(mask, torch.Tensor):
m = mask
if m.dim() == 4: # squeeze a singleton channel dim ([B,1,H,W] / [B,H,W,1])
if m.shape[1] == 1:
m = m[:, 0]
elif m.shape[-1] == 1:
m = m[..., 0]
if m.dim() == 2:
m = m[None, ...]
if m.dim() == 3:
return m.to(dev, torch.float32)
h = int(image.shape[1]) if isinstance(image, torch.Tensor) and image.dim() == 4 else 1
w = int(image.shape[2]) if isinstance(image, torch.Tensor) and image.dim() == 4 else 1
return torch.zeros((1, h, w), dtype=torch.float32, device=dev)
def uncrop(self, image, crop_info=None, mask=None, feather=0):
# No crop_info wired -> nothing to paste back, so pass the image AND the
# mask straight through (the mask must NOT be blanked, or transparency is
# lost - this is the "Load Image -> Uncrop directly" case).
if not isinstance(crop_info, dict) or not isinstance(crop_info.get("image"), torch.Tensor):
print("[PixaromaUncrop] no crop_info wired - passing image + mask through")
# Don't forward a non-dict garbage value down the typed wire.
ci_out = crop_info if isinstance(crop_info, dict) else None
return (image, self._passthrough_mask(mask, image), ci_out)
base = crop_info["image"]
if base.dim() != 4:
return (image, self._passthrough_mask(mask, image), crop_info)
H, W = int(base.shape[1]), int(base.shape[2])
x = int(crop_info.get("x", 0))
y = int(crop_info.get("y", 0))
cw = int(crop_info.get("w", image.shape[2] if image.dim() == 4 else W))
ch = int(crop_info.get("h", image.shape[1] if image.dim() == 4 else H))
# Clamp the paste region to the base image bounds (defensive against a
# hand-edited / stale crop_info).
x = max(0, min(x, W - 1))
y = max(0, min(y, H - 1))
cw = max(1, min(cw, W - x))
ch = max(1, min(ch, H - y))
# Resize the edited crop to exactly fill the original crop region. This
# handles the common upscale-then-paste-back case (and is a no-op when
# the crop was edited at its original size).
patch = image
if patch.dim() != 4:
patch = base.new_zeros((1, ch, cw, base.shape[3]))
if int(patch.shape[1]) != ch or int(patch.shape[2]) != cw:
patch = self._resize_bhwc(patch, cw, ch)
# Match the patch's channels to the base (drop alpha, pad gray if needed).
if patch.shape[3] != base.shape[3]:
if patch.shape[3] > base.shape[3]:
patch = patch[..., :base.shape[3]]
else:
pad_c = base.shape[3] - patch.shape[3]
patch = torch.cat([patch, patch[..., -1:].repeat(1, 1, 1, pad_c)], dim=-1)
# ---- IMAGE: paste the edited crop into the whole region --------------
# The mask input is NOT a paste-limiter: the entire crop rectangle is
# pasted and only `feather` softens the seam, so an edited/upscaled crop
# drops straight back in. (Mask travels separately, below.)
seam = self._build_alpha(None, cw, ch, feather) # [ch,cw] ones, feathered edges (cpu)
a = seam[None, ..., None].to(base.device, base.dtype) # [1,ch,cw,1]
out = base.clone()
B = int(out.shape[0])
# Align the patch batch to the base batch.
if patch.shape[0] != B:
if patch.shape[0] == 1:
patch = patch.repeat(B, 1, 1, 1)
elif B == 1:
out = out.repeat(patch.shape[0], 1, 1, 1)
B = patch.shape[0]
else:
n = min(B, patch.shape[0])
out = out[:n]
patch = patch[:n]
B = n
patch = patch.to(out.device, out.dtype)
region = out[:, y:y + ch, x:x + cw, :]
out[:, y:y + ch, x:x + cw, :] = patch * a + region * (1.0 - a)
# ---- MASK: full-frame original mask, region updated if one is wired ---
# crop_info carries the original full mask so the output is the whole
# frame (not just the crop rectangle). With no mask wired, the region
# keeps the original mask, so the full transparency passes through
# unchanged. Wire an edited region mask in to replace just that area.
base_mask = crop_info.get("mask")
if isinstance(base_mask, torch.Tensor):
bm = base_mask
if bm.dim() == 4: # squeeze a singleton channel dim ([B,1,H,W] / [B,H,W,1])
if bm.shape[1] == 1:
bm = bm[:, 0]
elif bm.shape[-1] == 1:
bm = bm[..., 0]
if bm.dim() == 2:
bm = bm[None, ...]
if bm.dim() == 3 and int(bm.shape[-2]) == H and int(bm.shape[-1]) == W:
bm = bm[:1].detach().to("cpu", torch.float32)
else:
bm = torch.zeros((1, H, W), dtype=torch.float32)
else:
bm = torch.zeros((1, H, W), dtype=torch.float32)
out_mask = bm.clone()
if isinstance(mask, torch.Tensor):
region_mask = self._build_alpha(mask, cw, ch, 0).detach().to("cpu", torch.float32)
sa = seam.detach().to("cpu", torch.float32) # feathered rectangle seam (region blend)
cur = out_mask[:, y:y + ch, x:x + cw]
out_mask[:, y:y + ch, x:x + cw] = region_mask[None, ...] * sa + cur * (1.0 - sa)
out_mask = out_mask.clamp(0.0, 1.0)
# Pair the mask with the IMAGE output: same device, and same batch (the
# image batch may have grown to match a multi-frame edited crop).
out_mask = out_mask.to(out.device)
if out_mask.shape[0] == 1 and out.shape[0] > 1:
out_mask = out_mask.repeat(out.shape[0], 1, 1, 1)
# Pass crop_info straight through so it can be forwarded downstream.
return (out.clamp(0.0, 1.0), out_mask, crop_info)
NODE_CLASS_MAPPINGS = {
"PixaromaUncrop": PixaromaUncrop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaUncrop": "Image Uncrop Pixaroma",
}
+1 -1
View File
@@ -20,7 +20,7 @@ class PixaromaVersionCheck:
# OUTPUT_NODE intentionally NOT set: ComfyUI skips this node on Run, so no
# "X.Xs" timing badge is drawn over what is just an info panel. All the
# work happens in js/version_check/index.js.
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
def noop(self):
return {}
+1 -1
View File
@@ -43,7 +43,7 @@ class PixaromaWH:
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("The width you typed.", "The height you typed.")
FUNCTION = "out"
CATEGORY = "👑 Pixaroma/🔢 Values"
CATEGORY = "👑 Pixaroma"
def out(self, width, height):
return (int(width), int(height))
+1 -1
View File
@@ -360,7 +360,7 @@ class PixaromaXYPlot:
OUTPUT_TOOLTIPS = ("The assembled comparison grid. During a plot it's the grid built so far; after the last cell it's complete. Wire it onward (e.g. to upscale or save) if you like.",)
FUNCTION = "execute"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
CATEGORY = "👑 Pixaroma"
@classmethod
def IS_CHANGED(cls, **kwargs):
+3 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-Pixaroma"
description = "Creative suite for ComfyUI: Image Composer, Paint Pixaroma, 3D Builder, AudioReact, Save Mp4, Image Crop, Inpaint Crop + Inpaint Stitch, Image Compare, Pause Image, XY Plot, Note, Label, Resolution, Preview Image, Show Text, Prompt Reader, Load Image, Load Images from Folder, Notify, Align, WH, Switch, Switch Source, Switch WH, Set/Get, Number, Seed, Text, Prompt Stack, Remove Background, with layers, transforms, AI background removal, audio-reactive video, and more."
version = "1.3.97"
description = "Creative suite for ComfyUI: Image Composer, Paint Pixaroma, 3D Builder, AudioReact, Save Mp4, Image Crop, Image Compare, Pause Image (pause a workflow to preview before the rest runs), XY Plot (sweep any setting into a labeled comparison grid), Note, Label, Resolution, Preview Image, Show Text, Prompt Reader (read prompts from PNG metadata), Load Image (with inline resize), Notify (sound-on-execute), Align (canvas snap & alignment guides), WH (manual width+height), Switch (multi-input router for any data type), Switch Source (flip A/B banks of wires with one toggle), Switch WH (A/B picker), Number (int+float outputs), Text (multi-line prompts), Prompt Stack (toggleable prompt chunks joined into one output), Remove Background (one-node cutout + mask + inverted mask), with layers, transforms, AI background removal, audio-reactive video, and more."
version = "1.3.87"
license = { file = "LICENSE.txt" }
keywords = ["comfyui", "image-editor", "3d", "paint", "layers", "compositor", "compare", "crop", "label", "audio-reactive", "video", "mp4", "pixaroma"]
@@ -14,7 +14,7 @@ dependencies = []
rembg = ["rembg>=2.0.50", "onnxruntime>=1.14.0"]
[project.urls]
Repository = "https://github.com/pixaroma/ComfyUI-Pixaroma"
Repository = "https://gitlab.com/pixaroma/comfyui-pixaroma"
Issues = "https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues"
YouTube = "https://www.youtube.com/@pixaroma"
+1 -388
View File
@@ -45,40 +45,6 @@ PIXAROMA_VENDOR_DIR = os.path.realpath(
os.path.join(PIXAROMA_ASSETS_DIR, "vendor")
)
# ─────────────────────────────────────────────────────────────────────────────
# Browser-cache fix for our .mjs files.
# ComfyUI core's cache middleware sets "Cache-Control: no-store" on .js/.css
# responses, but it checks request.path.endswith(".js") - which does NOT match our
# .mjs ES modules (we serve almost all code as .mjs at /extensions/<folder>/...). So
# the browser heuristically caches them, and after a plugin update users see the node
# but not the new .mjs code until a hard refresh. Mirror ComfyUI's behavior for OUR
# OWN .mjs/.js files via an on_response_prepare hook, so a normal reload picks up
# updates. Python change -> needs a full ComfyUI restart. Only touches this plugin's
# files; the served folder name is auto-derived so a renamed install still works.
_PIX_DIR = os.path.basename(os.path.dirname(os.path.abspath(__file__)))
_PIX_PREFIX = "/extensions/" + _PIX_DIR + "/"
async def _pixaroma_no_cache(request, response):
p = request.path
if p.startswith(_PIX_PREFIX) and (p.endswith(".mjs") or p.endswith(".js")):
response.headers["Cache-Control"] = "no-store"
def _pixaroma_install_no_cache():
try:
inst = getattr(PromptServer, "instance", None)
app = getattr(inst, "app", None) if inst else None
if app is None or getattr(app, "_pixaroma_no_cache_installed", False):
return
app.on_response_prepare.append(_pixaroma_no_cache)
app._pixaroma_no_cache_installed = True
except Exception as e:
print("[Pixaroma] no-cache hook not installed:", e)
_pixaroma_install_no_cache()
# Offline-first vendored third-party assets (Three.js, OrbitControls, loaders…).
# Served with arbitrary path depth so `three/examples/jsm/…` resolves.
_VENDOR_PATH_RE = re.compile(r"^[A-Za-z0-9_\-./]+$")
@@ -340,12 +306,7 @@ def _decode_image(b64_data: str) -> Image.Image | None:
try:
_, b64_raw = b64_data.split(",", 1)
image_data = base64.b64decode(b64_raw)
img = Image.open(io.BytesIO(image_data))
# Reject absurd dimensions before convert() allocates memory
# (decompression-bomb guard; no legitimate source exceeds this).
if img.width > 16384 or img.height > 16384:
return None
return img
return Image.open(io.BytesIO(image_data))
except Exception:
return None
@@ -563,57 +524,6 @@ async def upload_crop_source(request):
return web.json_response({"status": "success", "path": relative_path})
# ────────────────────────────────────────────────────────────
# Inpaint Crop Pixaroma — source + painted-mask upload
# ────────────────────────────────────────────────────────────
@PromptServer.instance.routes.post("/pixaroma/api/inpaint/upload_src")
async def upload_inpaint_source(request):
data = await request.json()
raw_id = data.get("project_id", "")
project_id = _sanitize_id(raw_id, str(uuid.uuid4()).replace("-", ""))
img = _decode_image(data.get("image", ""))
if img is None:
return web.json_response({"error": "Invalid image data"}, status=400)
filename = f"inpaint_src_{project_id}.png"
file_path = _safe_path(filename)
if file_path is None:
return web.json_response({"error": "Invalid project id"}, status=400)
try:
img.convert("RGB").save(file_path, "PNG")
except Exception as e:
return web.json_response({"error": f"Failed to process image: {e}"}, status=400)
relative_path = os.path.join("pixaroma", filename).replace("\\", "/")
return web.json_response({"status": "success", "path": relative_path})
@PromptServer.instance.routes.post("/pixaroma/api/inpaint/save_mask")
async def save_inpaint_mask(request):
data = await request.json()
raw_id = data.get("project_id", "")
project_id = _sanitize_id(raw_id, str(uuid.uuid4()).replace("-", ""))
img = _decode_image(data.get("mask", ""))
if img is None:
return web.json_response({"error": "Invalid mask data"}, status=400)
filename = f"inpaint_mask_{project_id}.png"
file_path = _safe_path(filename)
if file_path is None:
return web.json_response({"error": "Invalid project id"}, status=400)
# Painted mask: white = inpaint here. Store as 8-bit grayscale.
try:
img.convert("L").save(file_path, "PNG")
except Exception as e:
return web.json_response({"error": f"Failed to process mask: {e}"}, status=400)
relative_path = os.path.join("pixaroma", filename).replace("\\", "/")
return web.json_response({"status": "success", "path": relative_path})
# ────────────────────────────────────────────────────────────
# AudioReact Pixaroma — inline image / audio upload
# ────────────────────────────────────────────────────────────
@@ -1221,300 +1131,3 @@ async def api_prompt_reader_extract(request):
"message": f"Could not read metadata: {e}",
})
return web.json_response(result)
# ── Load Images from Folder Pixaroma ─────────────────────────────────────────
# These routes back the node's gallery + thumbnails. They read the user's OWN
# chosen folder on the local machine (the whole point of the node), so they are
# NOT constrained to input/. They are read-only, validate the path is a real
# directory, only touch image files, and guard the per-file thumbnail against
# path-traversal out of the chosen folder via _is_path_under.
_LIF_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif")
def _lif_is_image(name: str) -> bool:
return name.lower().endswith(_LIF_IMAGE_EXTS)
def _lif_list_files(real, recursive):
"""Walk a folder and return its image files. Blocking (os.walk + os.stat on
a big tree can take seconds) - run off the event loop."""
files = []
if recursive:
for root, _dirs, names in os.walk(real):
for n in names:
if not _lif_is_image(n):
continue
full = os.path.join(root, n)
try:
st = os.stat(full)
except OSError:
continue
rel = os.path.relpath(full, real).replace("\\", "/")
files.append({"file": rel, "name": n, "size": st.st_size, "mtime": st.st_mtime})
else:
for n in os.listdir(real):
full = os.path.join(real, n)
if os.path.isfile(full) and _lif_is_image(n):
try:
st = os.stat(full)
except OSError:
continue
files.append({"file": n, "name": n, "size": st.st_size, "mtime": st.st_mtime})
return files
@PromptServer.instance.routes.get("/pixaroma/api/load_images_folder/list")
async def api_lif_list(request):
"""List image files in a folder. ?path=<folder>&recursive=0|1
Returns {ok, folder, files:[{file, name, size, mtime}]} (file = path
relative to the folder, forward-slashed)."""
folder = request.query.get("path", "")
recursive = request.query.get("recursive", "0") == "1"
if not folder or not os.path.isdir(folder):
return web.json_response({"ok": False, "message": "Folder not found.", "files": []})
real = os.path.realpath(folder)
try:
import asyncio
loop = asyncio.get_running_loop()
files = await loop.run_in_executor(None, _lif_list_files, real, recursive)
except Exception as e:
return web.json_response({"ok": False, "message": f"Could not read folder: {e}", "files": []})
return web.json_response({"ok": True, "folder": real, "files": files})
def _lif_make_thumb(full):
"""Decode + downscale one image to a small JPEG. Blocking - run off the loop."""
from PIL import ImageOps
im = Image.open(full)
im = ImageOps.exif_transpose(im).convert("RGB")
im.thumbnail((192, 192))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=80)
return buf.getvalue()
@PromptServer.instance.routes.get("/pixaroma/api/load_images_folder/thumb")
async def api_lif_thumb(request):
"""Serve a small JPEG thumbnail for one image. ?path=<folder>&file=<rel>"""
folder = request.query.get("path", "")
rel = request.query.get("file", "")
if not folder or not rel or not os.path.isdir(folder):
return web.Response(status=404)
full = os.path.realpath(os.path.join(folder, rel))
if (
not _is_path_under(full, folder)
or not os.path.isfile(full)
or not _lif_is_image(os.path.basename(full))
):
return web.Response(status=403)
try:
import asyncio
loop = asyncio.get_running_loop()
# PIL decode/resize/encode can be slow for big images - keep it off the
# aiohttp event loop so other ComfyUI requests don't stall.
body = await loop.run_in_executor(None, _lif_make_thumb, full)
return web.Response(
body=body,
content_type="image/jpeg",
headers={"Cache-Control": "no-cache"},
)
except Exception:
return web.Response(status=404)
@PromptServer.instance.routes.get("/pixaroma/api/load_images_folder/browse")
async def api_lif_browse(request):
"""Navigate the server filesystem for the in-app folder picker.
?path=<dir> (empty = list drives on Windows / '/' on POSIX).
Returns {ok, path, parent, dirs:[{name, path, images}]}; images = -1 means
'not counted' (skipped for folders with many sub-folders, to stay fast)."""
path = request.query.get("path", "")
try:
if not path:
dirs = []
if os.name == "nt":
import string
for letter in string.ascii_uppercase:
d = f"{letter}:\\"
if os.path.isdir(d):
dirs.append({"name": d, "path": d, "images": -1})
else:
dirs.append({"name": "/", "path": "/", "images": -1})
return web.json_response({"ok": True, "path": "", "parent": None, "dirs": dirs})
if not os.path.isdir(path):
return web.json_response({"ok": False, "message": "Folder not found.", "dirs": []})
real = os.path.realpath(path)
parent = os.path.dirname(real)
if parent == real: # already at a drive / filesystem root
parent = ""
subdirs = []
try:
for n in sorted(os.listdir(real), key=str.lower):
full = os.path.join(real, n)
if os.path.isdir(full):
subdirs.append((n, full))
except OSError as e:
return web.json_response({"ok": False, "message": f"Could not read folder: {e}", "dirs": []})
# Only tally per-folder image counts when cheap (few sub-folders), so
# browsing into e.g. C:\Windows doesn't stat hundreds of directories.
do_count = len(subdirs) <= 60
dirs = []
for n, full in subdirs:
cnt = -1
if do_count:
try:
cnt = sum(1 for fn in os.listdir(full) if _lif_is_image(fn))
except OSError:
cnt = -1
dirs.append({"name": n, "path": full, "images": cnt})
return web.json_response({"ok": True, "path": real, "parent": parent, "dirs": dirs})
except Exception as e:
return web.json_response({"ok": False, "message": str(e), "dirs": []})
# Native OS folder picker. The ComfyUI server runs on the user's own machine for
# local installs, so it can pop a REAL folder dialog and return the chosen path -
# no image copying, like a desktop app. Cross-platform with NO extra Python deps:
# Windows = PowerShell + WinForms (the embedded Python lacks tkinter); macOS =
# osascript; Linux = zenity / kdialog. Each fails fast on a headless/remote host
# so the frontend falls back to the in-app browser. Never hangs (subprocess
# timeout); a module lock allows only one dialog at a time.
import threading as _threading
_LIF_DIALOG_LOCK = _threading.Lock()
def _lif_dialog_available():
"""True if SOME native folder dialog tool exists for this platform."""
import sys
import shutil
if sys.platform == "win32":
return shutil.which("powershell") is not None
if sys.platform == "darwin":
return shutil.which("osascript") is not None
return shutil.which("zenity") is not None or shutil.which("kdialog") is not None
def _lif_dialog_windows(start_path):
import subprocess
# Show an invisible TopMost owner form, then open the folder dialog inside its
# Shown event so it inherits the foreground (fixes "opens behind the browser").
# Start path goes through an env var to avoid quoting issues.
ps = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$r='';"
"$o=New-Object System.Windows.Forms.Form;"
"$o.TopMost=$true;$o.ShowInTaskbar=$false;$o.FormBorderStyle='None';"
"$o.Width=1;$o.Height=1;$o.Opacity=0;$o.StartPosition='CenterScreen';"
"$o.Add_Shown({"
"$o.Activate();"
"$d=New-Object System.Windows.Forms.FolderBrowserDialog;"
"$d.Description='Choose a folder of images';$d.ShowNewFolderButton=$false;"
"if($env:LIF_START){try{$d.SelectedPath=$env:LIF_START}catch{}};"
"if($d.ShowDialog($o) -eq [System.Windows.Forms.DialogResult]::OK){$script:r=$d.SelectedPath};"
"$o.Close()"
"});"
"[void]$o.ShowDialog();"
"[Console]::Out.Write($r)"
)
env = dict(os.environ)
env["LIF_START"] = start_path or ""
out = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=300, env=env,
creationflags=0x08000000, # CREATE_NO_WINDOW (no console flash)
)
return (out.stdout or "").strip()
def _lif_dialog_macos(start_path):
import subprocess
import re
script = 'POSIX path of (choose folder with prompt "Choose a folder of images")'
# Only seed the start location when it's a real dir whose path has no chars
# that could break out of the AppleScript string literal (?path= is supplied
# by the caller, so treat it as untrusted).
if start_path and os.path.isdir(start_path) and re.match(r'^[^"\\\x00-\x1f]+$', start_path):
script = (
'POSIX path of (choose folder with prompt "Choose a folder of images" '
f'default location POSIX file "{start_path}")'
)
try:
out = subprocess.run(["osascript", "-e", script], capture_output=True, text=True, timeout=300)
return (out.stdout or "").strip().rstrip("/") if out.returncode == 0 else ""
except (subprocess.TimeoutExpired, FileNotFoundError):
return ""
def _lif_dialog_linux(start_path):
import shutil
import subprocess
start = start_path if (start_path and os.path.isdir(start_path)) else os.path.expanduser("~")
if shutil.which("zenity"):
try:
out = subprocess.run(
["zenity", "--file-selection", "--directory",
"--title=Choose a folder of images", f"--filename={start}/"],
capture_output=True, text=True, timeout=300,
)
return (out.stdout or "").strip() if out.returncode == 0 else ""
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
if shutil.which("kdialog"):
try:
out = subprocess.run(
["kdialog", "--getexistingdirectory", start, "--title", "Choose a folder of images"],
capture_output=True, text=True, timeout=300,
)
return (out.stdout or "").strip() if out.returncode == 0 else ""
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return ""
def _lif_native_folder_dialog(start_path=""):
"""Open the native OS folder picker; return the chosen path, "" (cancelled),
or None (busy - a dialog is already open). Runs in a thread (caller uses
run_in_executor); only one at a time via the module lock."""
if not _LIF_DIALOG_LOCK.acquire(blocking=False):
return None # a dialog is already open elsewhere -> caller falls back
try:
import sys
if sys.platform == "win32":
return _lif_dialog_windows(start_path)
if sys.platform == "darwin":
return _lif_dialog_macos(start_path)
return _lif_dialog_linux(start_path)
except Exception as e:
print(f"[PixaromaLoadImagesFolder] native folder dialog failed: {e}")
return ""
finally:
try:
_LIF_DIALOG_LOCK.release()
except Exception:
pass
@PromptServer.instance.routes.get("/pixaroma/api/load_images_folder/pick_native")
async def api_lif_pick_native(request):
"""Pop the native OS folder dialog on the ComfyUI host; return the chosen path.
{ok:true, path} on pick; {ok:false, cancelled} on cancel; {ok:false,
unavailable} when no native dialog tool exists (so the UI falls back)."""
if not _lif_dialog_available():
return web.json_response({"ok": False, "unavailable": True})
start = request.query.get("path", "")
try:
import asyncio
loop = asyncio.get_running_loop()
path = await loop.run_in_executor(None, _lif_native_folder_dialog, start)
if path is None:
return web.json_response({"ok": False, "busy": True})
if path and os.path.isdir(path):
return web.json_response({"ok": True, "path": path})
return web.json_response({"ok": False, "cancelled": True})
except Exception as e:
return web.json_response({"ok": False, "message": str(e)})