1 Commits
Author SHA1 Message Date
Makadi 1362d71182 sync with gitlab version = "1.3.87" 2026-06-11 08:31:33 +03:00
42 changed files with 3191 additions and 911 deletions
+25
View File
@@ -0,0 +1,25 @@
# Auto-publish ComfyUI-Pixaroma to the Comfy Registry (registry.comfy.org).
# This mirrors the old GitHub Action: it runs the same comfy-cli commands.
#
# It runs when:
# - pyproject.toml changes on the main branch (i.e. you bumped the version), or
# - you trigger it by hand from GitLab: Build > Pipelines > "Run pipeline".
#
# REQUIRED: add your registry API key as a CI/CD variable named
# REGISTRY_ACCESS_TOKEN (Settings > CI/CD > Variables, Masked).
stages:
- publish
publish-to-comfy-registry:
stage: publish
image: python:3.12
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- pyproject.toml
script:
- pip install comfy-cli
- comfy --skip-prompt --no-enable-telemetry env
- comfy node publish --token "$REGISTRY_ACCESS_TOKEN"
+28
View File
@@ -0,0 +1,28 @@
<!--
Most "broken node" reports are a browser cache and are solved in 30 seconds.
Please read the section below before filing. If this is a feature request or a
question, you can ignore the bug fields and just describe what you need.
-->
### Before you file: is a node blank, broken, or missing its buttons after an update?
This is almost always a **browser cache**, not a bug. Please try the 30-second fix first:
[Read this first](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues/2)
Hard-refresh with **Ctrl + Shift + R** (Windows / Linux) or **Cmd + Shift + R** (Mac). If that fixes it, you do not need to file anything.
---
If it is still broken after a hard refresh (or this is a different bug), please fill in the details below and delete the section above:
**What is happening?**
Describe the problem. Screenshots help a lot.
**Steps to reproduce**
What did you do right before it happened?
**Versions** (please include)
Drop a **Version Check Pixaroma** node on the canvas, click its **Copy** button, and paste the result here.
**Console errors** (if a node is blank or broken)
Press **F12**, click **Console**, reload the page, then paste or screenshot any red error lines.
+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
+82 -627
View File
@@ -3,7 +3,15 @@
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 that adds interactive visual editors (3D Builder, Paint Studio, Image Composer, Image Crop, Note Pixaroma - a rich-text annotation node, Preview Image Pixaroma - an in-node image previewer with Save Disk / Save Output / Copy / Open buttons, Notify Pixaroma - a terminal node that plays a sound from `assets/sounds/` when reached, useful as a workflow-completion alert when working in another window, Load Image Pixaroma - a drop-in replacement for native LoadImage with inline resize controls and 7 outputs IMAGE/MASK/WIDTH/HEIGHT/FILENAME/ORIGINAL_WIDTH/ORIGINAL_HEIGHT, Prompt Multi Pixaroma - hold a list of prompt variants on one node with two run modes via a pill toggle: Queue Text (one workflow run per enabled row) or List Prompts (one run, the whole list goes out a `prompts` output for downstream Prompt From List nodes to pick from), Prompt From List Pixaroma - tiny picker node that grabs one prompt from a Prompt Multi `prompts` list by 1-based index, drop several to fan one library out to different scenes, Prompt Pack Pixaroma - paste a block of prompts and queue one workflow run per prompt with a pill toggle to pick Paragraph (blank-line splits) or Line (newline splits) and a counter pill in the corner showing total / active, Text Overlay Pixaroma - simple single-text overlay on a required upstream image. The node body has full editing controls (text, font, weight, italic, alignment, size, line-height, letter-spacing, position X/Y, opacity, rotation, text color, bg pill color); clicking "Open Text Editor" launches a fullscreen WYSIWYG editor with drag-to-move, drag-corner-resize, rotation handle, snap guides, and a 6-icon align-to-canvas toolbar. Both surfaces edit the SAME state object so they stay in lockstep. Text Watermark Pixaroma - a no-editor sibling of Text Overlay for stamping a text watermark onto an image or batch: position via a 9-point anchor + a margin inset (so it lands in the same spot regardless of image size), and size as fixed pixels OR a percentage of each image's width (consistent across mixed-size batches); reuses Text Overlay's render engine and the shared text-properties panel in watermarkMode, with no fullscreen editor, Mute Switch Pixaroma - dynamic N-row canvas-painted control node that toggles whole branches of a workflow on/off by writing `node.mode = 2` (mute) or `4` (bypass) on the directly-wired upstream node. Pills at top: Single (radio - exactly one ON) vs Multi (any combination), and Mute vs Bypass mode. Cascades through inner Mute Switches via a phantom `out` output of custom type `PIXAROMA_MUTE_CHAIN` so an outer switch can disable a group of inner switches and everything they control), XY Plot Pixaroma - a single drop-in node (wire your final image into its `image` input, like a Preview node) that re-runs the workflow over every X×Y value combination and assembles the results into a labeled comparison grid. Pick what to vary on X (columns) and Y (rows) from a dropdown of the graph's nodes + their widgets (no wiring); the value-entry UI adapts to the picked widget type (number → range/list, combo → checklist, text → full-list or find-&-replace). It is the THIRD queue-driver (after Prompt Multi/Pack): patches `app.queuePrompt` to loop N×M runs (shared `queue_drivers.mjs` lock) and `app.graphToPrompt` to inject each cell's value into the target node's widget (overriding a wired input) + a per-node seed lock; Python accumulates cells server-side keyed by a sessionId and renders the labeled grid PNG (PIL) which JS shows as an `<img>` with Dark/Light/Mono themes + Save/Copy/Open) directly inside ComfyUI workflows. Find and Replace Pixaroma - a drop-in node you wire into a text wire (between an LLM/Show Text and whatever uses the prompt) that applies a stacked list of find->replace rules (per-rule on/off pill, drag to reorder, empty replace = delete) with global Case/Whole-word/Regex/Tidy toggles and a live before/after preview right on the node; the rules + last-run sample are saved into the workflow so a shared example shows the preview on open. It also includes Align Pixaroma, a toggleable canvas-wide smart-snap and alignment-guide system (no nodes added; patches `LGraphCanvas` so any node drag/resize snaps to nearby edges and centers with orange guide lines). It has zero core dependencies — PIL and PyTorch come from ComfyUI's environment. All nodes share the `👑 Pixaroma` menu category.
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.
- **Layout & utility:** Resolution, Switch / Switch WH / Switch Source, Mute Switch, XY Plot, Number, WH, Notify, Version Check.
- **Annotation & overlay:** Note, Text Overlay, Text Watermark, Label, Compare.
- **Canvas-wide features (no node added):** Align, Node Colors, Brand defaults, Connection FX, Run Button FX.
Each node's behavior + invariants live in its own "Patterns (do not regress)" section below; the directory map and the Token-Saving table point you to the exact file per task.
## Development Setup
No build step. Install by placing this folder in `ComfyUI/custom_nodes/`. ComfyUI auto-imports `__init__.py` on startup.
@@ -47,577 +55,41 @@ 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.
```
js/
├── framework/ # Shared UI toolkit (all editors depend on this)
│ ├── index.mjs # Barrel re-export (import from here)
│ ├── theme.mjs # CSS injection, brand colors, _uiIcon helper
│ ├── layout.mjs # createEditorLayout() — fullscreen overlay shell
│ ├── components.mjs # Buttons, panels, sliders, inputs, tool grids, zoom, transform
│ ├── layers.mjs # Photoshop-style layer panel with drag reorder
│ ├── canvas.mjs # Canvas settings, frame overlay, toolbar + drag-drop
│ ├── fonts.mjs # Font registry + FontFace loader (Text Overlay + future Composer text layers)
│ ├── text_render.mjs # Pure renderTextLayer(ctx, layer) - canvas API mirror of nodes/_text_render_helpers.py
│ └── text_editor.mjs # Per-layer text properties panel (right sidebar UI, reusable)
│
├── shared/ # Shared utilities (constants, node preview, helpers)
│ ├── index.mjs # Barrel re-export
│ ├── utils.mjs # BRAND, installFocusTrap, hideJsonWidget, downloadDataURL
│ ├── preview.mjs # createNodePreview, showNodePreview, restoreNodePreview
│ ├── label_css.mjs # injectLabelCSS() for label editor
│ └── help.mjs # createHelpButton(helpDef) / openHelpPopup() - the
│ # reusable node Help panel (a ? button using the
│ # bundled question.svg + a themed popup). See the
│ # "Node Help Panel" convention (#16) for the schema +
│ # how to add it to any node.
│
├── brand/ # Brand defaults (single global extension)
│ └── index.js # Pixaroma.BrandDefaults extension. Hooks
│ # beforeRegisterNodeDef for ANY node whose Python
│ # CATEGORY starts with "👑 Pixaroma" and applies the
│ # dark title bar (#1d1d1d) + body (#2a2a2a) defaults.
│ # Guarded with `if (!this.color)` / `!this.bgcolor`
│ # so saved workflow colors and right-click → Colors
│ # picks both win. ANY new Pixaroma node automatically
│ # inherits the brand colors via the category prefix —
│ # do NOT re-add per-node color guards.
│
├── node_colors/ # Right-click "Pixaroma colors" tools (single
│ └── index.js # global extension, ~900 lines). Wraps
│ # LGraphCanvas.prototype.getNodeMenuOptions (nodes)
│ # AND LGraphGroup.prototype.getMenuOptions (groups)
│ # to append color tools to ANY node/group right-
│ # click menu. Submenu via LiteGraph.ContextMenu
│ # callback pattern. May 2026 v4: the node
│ # presets are the user's 127 hand-curated
│ # finals (HUE_FOLDERS), organized as a leading
│ # "Dark" folder (Default #1d1d1d/#2a2a2a =
│ # brand default, Onyx, Charcoal, + 6 dark
│ # low-sat color tints) then ONE FOLDER PER
│ # HUE: Red, Orange, Gold, Green, Teal, Cyan,
│ # Blue, Indigo, Purple, Pink.
│ # Each folder holds that hue's title+body
│ # shades ordered dark -> light: Deepest /
│ # Moody / Deep / Jewel / Mid / Rich / Bold /
│ # Vivid / Bright, then the muted variants
│ # (Muted Deep / Slate / Muted / Muted Light),
│ # then specials (Two-tone = colored title
│ # with an analogous-hue body; Accent /
│ # Accent Dark = colored title on a neutral
│ # gray body #242424 / #1f1f1f). title ->
│ # node.color, body -> node.bgcolor. The OLD
│ # Plain/Pixa/Neutrals theme grouping (HUES /
│ # PRESETS / BOLD_PRESETS / STANDALONES) was
│ # REPLACED by this hue-folder set. To add or
│ # remove a shade, edit the relevant hue's
│ # `presets` array in js/node_colors/index.js.
│ # --- May 2026 v3: copy/paste + 4 favorites +
│ # groups. Top-level labels are Node/Group-qualified:
│ # NODE menu top-level: "👑 Pixaroma Node Colors"
│ # submenu + "👑 Copy Node Colors" + "👑 Paste Node
│ # Colors" (shown only once something is copied) +
│ # "👑 Reset Node Colors". The submenu LEADS with
│ # the filled Favorites, then "Save these colors
│ # to ▸" (4 slots) + "Pick custom...", then the
│ # presets tucked into 11 subfolders (Dark +
│ # Red ... Pink) so favorites/save are
│ # reachable without scanning every swatch.
│ # Favorites are 4 fixed slots persisted as ONE
│ # compact JSON value via an UNREGISTERED settings
│ # id "Pixaroma.NodeColors.Favorites" (no panel
│ # clutter; unregistered ids DO persist - backend
│ # is a plain JSON merge, verified May 2026). An
│ # in-memory write-through cache (_favoritesCache)
│ # is authoritative so rapid saves can't race the
│ # async setter. The OLD single Favorite (the two
│ # FavoriteTitle/Body "color" settings) was REMOVED
│ # from the panel; a non-default legacy value
│ # migrates into Favorite 1 (eagerly persisted).
│ # "Pick custom..." opens the side-by-side
│ # two-color modal (createPixaromaColorPicker
│ # twice, NOT openPixaromaColorPickerModal) seeded
│ # from the clipboard / first favorite; Apply feeds
│ # the clipboard, NOT a favorite slot.
│ # GROUP menu: TOP-LEVEL of the canvas right-click
│ # menu (like nodes, NOT under "Edit Group"). Wraps
│ # LGraphCanvas.prototype.getCanvasMenuOptions and
│ # gates on a group under the cursor via
│ # this.graph_mouse + graph.getGroupOnPos
│ # (getCanvasMenuOptions takes NO event arg, so the
│ # position comes from graph_mouse; processContextMenu
│ # appends "Edit Group" right after, so our items land
│ # just above it; node right-clicks use
│ # getNodeMenuOptions so there's no double-add).
│ # "👑 Pixaroma Group Colors ▸" (favorites + Save this
│ # color to ▸ + Pick custom (single-color modal) + the
│ # hand-picked GROUP_COLORS list, no subfolders) +
│ # "👑 Copy Group Color" + "👑 Paste Group Color" +
│ # "👑 Reset Group Color". A group has ONE fill
│ # color (group.color), serialized in workflow
│ # groups[]. Its ~25% transparency is LiteGraph's
│ # hardcoded render (0.25 fill / 1.0 stroke ×
│ # editor_alpha) and is left untouched.
│ # CROSS-TYPE: the clipboard + the 4 favorites are
│ # SHARED between nodes and groups; pickGroupColor()
│ # maps a node {title,body} pair to a group's single
│ # color by picking the more saturated of the two
│ # (Plain→body, Pixa→title, neutral→either). Saving
│ # a group color into a favorite stores a flat
│ # title==body pair. Group multi-select filters
│ # canvas.selectedItems by the group's class (there
│ # is no selected_groups map). Colors are written
│ # onto each node's .color/.bgcolor (or group.color)
│ # so they serialize into the workflow JSON and
│ # travel to recipients without this plugin. The
│ # Dark preset reuses brand/index.js's hex values.
│ # Custom modal CSS is `pix-nc-*` prefixed, injected
│ # once via injectCSS() guarded by `#pix-nc-css` ID;
│ # the single-color group variant adds
│ # .pix-nc-modal-single + a .pix-nc-grouppreview that
│ # shows the fill at 0.25 alpha. Click-outside-to-
│ # cancel uses the mousedown-on-backdrop guard so an
│ # SV-plane drag that releases off the modal does not
│ # discard the pick (same pattern Text Overlay #12
│ # documents). Multi-select aware: 2+ nodes (or
│ # groups) selected + the right-clicked one among
│ # them → applies to all; labels show "(N nodes)" /
│ # "(N groups)".
│
├── connection_fx/ # Connection FX (single file, ~210 lines)
│ └── index.js # Frontend-only patch (no Python node). One
│ # boolean setting `Pixaroma.Connection.FX`
│ # under category ["👑 Pixaroma", "Connections"],
│ # default OFF. When ON: (a) wraps
│ # LGraphCanvas.prototype.drawFrontCanvas to
│ # paint a pulsing BRAND #f66744 dot + halo at
│ # every type-compatible target slot within
│ # ~110 graph-units of the cursor while a wire
│ # is being dragged - alpha scales with both
│ # proximity and a sin(t*5) pulse, halo radius
│ # too. Calls setDirty(true,true) per frame so
│ # the pulse keeps animating during the drag.
│ # Detects the drag via canvas.connecting_links
│ # (newer API) with fallback to connecting_node
│ # + connecting_output (older API). Wildcard
│ # "*" types match everything. (b) wraps
│ # LGraphNode.prototype.connect (which fires
│ # once per successful connection, regardless of
│ # per-node onConnectionsChange overrides like
│ # the Switch Pixaroma configuring-gate) and
│ # spawns 10 yellow position:fixed sparkle divs
│ # in a circle at the target slot's screen
│ # position. Slot graph -> screen conversion
│ # uses (pos + ds.offset) * ds.scale + canvas
│ # bounding rect offset. Hooks WRAP, never
│ # replace; gated on `enabled` boolean so the
│ # toggling cost is zero when off. CSS class
│ # `pix-conn-fx-sparkle` injected once via
│ # injectCSS() guarded by #pix-conn-fx-css ID.
│
├── run_button_fx/ # Run Button FX (single file, ~290 lines)
│ └── index.js # Frontend-only patch (no Python node). One ComfyUI
│ # setting `Pixaroma.RunButton.FX` (combo) under
│ # category ["👑 Pixaroma", "Run Button"] with
│ # options: None / Pixaroma Orange / Flash /
│ # Ignition / Thor / Sparkle / Rocket /
│ # Shockwave.
│ # Default OFF (None) - zero work when disabled.
│ # Run button found by text-content match
│ # (textContent === "Run" && has SVG) so it stays
│ # reliable across dock/undock - the button is the
│ # same DOM node when Vue just moves it, a new node
│ # when Vue recreates it. MutationObserver on
│ # document.body subtree re-finds the button only
│ # when the cached reference falls out of DOM (early
│ # return when document.body.contains(currentButton)),
│ # batched via requestAnimationFrame so the high
│ # mutation rate of Vue's reactivity layer is cheap.
│ # FX implementation patterns: CONTINUOUS effects
│ # (Pixaroma Orange / Pulse / Aurora) are CSS-class
│ # toggles on the button itself with !important to
│ # override Vue's built-in button styles. PER-CLICK
│ # effects (Ignition / Lightning / Rocket) are
│ # position:fixed overlay divs absolute-positioned
│ # via button.getBoundingClientRect() into
│ # document.body, so they can extend OUTSIDE the
│ # button frame (flame to the left, arc beyond
│ # border, rocket flame below) without fighting
│ # the button's overflow:hidden. Each overlay
│ # removes itself via setTimeout after its CSS
│ # animation completes. CONTINUOUS particle
│ # emission (Sparkle) uses setInterval spawning
│ # body-level overlay divs at random x within the
│ # button rect - interval cleared via the
│ # cleanupCurrent closure when FX changes.
│ # Each attachX(button) returns a cleanup fn;
│ # applyFx() calls cleanupCurrent() before swapping
│ # FX. Rocket shake uses the
│ # classList.remove + void offsetWidth + add
│ # trick to force CSS animation restart on each
│ # click. All overlay class names are
│ # `pix-rb-fx-*` (sparkle, flame, arc,
│ # rocketflame) and button-applied class names
│ # are `pix-rb-orange / -pulse / -aurora /
│ # -rocket-shake`. CSS injected once via
│ # injectCSS() guarded by #pix-rb-fx-css ID.
│ # Zero perf cost when FX === "None" because
│ # the observer is never started.
│
├── paint/ # Paint Studio (PaintStudio class, mixin pattern)
│ ├── index.js # Entry: ComfyUI extension registration
│ ├── core.mjs # Class shell: constructor, open/close, UI building
│ ├── canvas.mjs # Canvas init, layer CRUD (add/delete/merge/flatten)
│ ├── render.mjs # Layer rendering with transforms, grid
│ ├── transform.mjs # Transform handles, hit-test, zoom/pan
│ ├── events.mjs # Mouse/keyboard event binding & routing
│ ├── tools.mjs # Brush, pencil, eraser, smudge, fill, pick, shape
│ ├── history.mjs # Undo/redo snapshots
│ ├── ui.mjs # Color picker, tool options, layer panel sync
│ ├── engine.mjs # BrushEngine class, color conversion utils
│ └── api.mjs # PaintAPI backend calls
│
├── 3d/ # 3D Builder (Pixaroma3DEditor class, mixin pattern)
│ ├── index.js # Entry: ComfyUI extension registration
│ ├── core.mjs # Class shell, UI building, Three.js lazy loading
│ ├── engine.mjs # Three.js scene/renderer/camera init, animation
│ ├── objects.mjs # Object CRUD, selection, geometry, materials, layer thumbs
│ ├── shapes.mjs # Shape registry: id → { icon, label, build, params, defaults, live }
│ ├── shape_params.mjs # Per-object Shape panel (right sidebar) + geometry rebuild
│ ├── composites.mjs # Multi-mesh Groups (tree, house, flower, …) registry + builders
│ ├── picker.mjs # "Add 3D Object" modal picker (categorised grid)
│ ├── importer.mjs # GLB/OBJ lazy loaders + wrapImportPivot + _addImportedGroup
│ ├── interaction.mjs # Tools, camera views, keyboard, undo/redo
│ ├── persistence.mjs # Save/restore scene JSON, background image
│ └── api.mjs # ThreeDAPI backend calls
│
├── composer/ # Image Composer (PixaromaEditor class, mixin pattern)
│ ├── index.js # Entry: ComfyUI extension registration
│ ├── core.mjs # Class shell, state management
│ ├── eraser.mjs # Eraser mode, mask creation/loading
│ ├── interaction.mjs # Events, alignment, keyboard, transforms
│ ├── render.mjs # Rendering, history/undo
│ ├── ui.mjs # Sidebar panel builder
│ ├── layers.mjs # Layer helper module
│ └── api.mjs # PixaromaAPI backend calls
│
├── crop/ # Image Crop (CropEditor class, mixin pattern)
│ ├── index.js # Entry: ComfyUI extension registration
│ ├── core.mjs # Class shell, UI building
│ ├── interaction.mjs # Mouse/keyboard, crop handle dragging
│ └── render.mjs # Canvas rendering, aspect ratio logic, save
│
├── label/ # Label Editor (function-based, not a class)
│ ├── index.js # Entry: ComfyUI extension registration
│ ├── core.mjs # LabelEditor class, UI building
│ └── render.mjs # Canvas text rendering, typography helpers
│
├── note/ # Note Pixaroma (NoteEditor class, mixin pattern)
│ ├── index.js # Entry: node lifecycle, DEFAULT_CFG, parseCfg, onConfigure/onResize
│ ├── core.mjs # Class shell: open/close, save, undo history, Ctrl+Z neutering,
│ │ # code/preview view toggle, _applyEditAreaBg, _normalizeEditArea
│ ├── toolbar.mjs # _buildToolbar: bold/italic/headings/colour pickers/link/code/HR/
│ │ # Button Design/YT/Discord entries, undo/redo, view toggle, SWATCHES,
│ │ # _promptLinkUrl + _promptCodeBlock themed modals
│ ├── blocks.mjs # Button Design rich dialog (icon picker, live preview, toggles),
│ │ # YouTube + Discord generic block dialogs, validateUrl helper,
│ │ # renderButtonHTML, insertAtSavedRange, saveRange/restoreRange
│ ├── render.mjs # createNoteDOMWidget, renderContent, attachEditButton,
│ │ # attachCanvasClickDelegation, injectCopyButtons (for <pre>)
│ ├── sanitize.mjs # Allowlist-based HTML sanitizer (tags, attrs, classes, styles, href)
│ └── css.mjs # injectCSS — all note styles (overlay, editarea, pills, toggles)
│
├── resolution/ # Resolution Pixaroma (single file, ~640 lines)
│ └── index.js # 3x3 ratio chip grid + 8-row size list + Custom mode
│ # (W/H inputs, swap, snap chips, aspect preview).
│ # State on node.properties + graphToPrompt hook.
│
├── align/ # Align Pixaroma: toggleable smart-snap + alignment
│ └── index.js # guides for the node canvas (~640 lines, single
│ # file). Frontend-only patch, no Python node.
│ # Hooks: window-level pointermove for snap math
│ # (NOT LGraphCanvas.processMouseMove, which Vue
│ # does not invoke), drawFrontCanvas wrap for guide
│ # rendering (NOT onDrawForeground, unreliable in
│ # Vue per Compat #1). Drag-origin + cursor-delta
│ # model: state.dragInfo captures cursorX/Y + each
│ # selected node's pos at drag start; per-tick
│ # desired = orig + cursorDelta; snap is a lateral
│ # correction. Visual node bounds include the title
│ # bar (LiteGraph.NODE_TITLE_HEIGHT) so snap edges
│ # match what the user sees. Change-detection cache
│ # identifies the dragged node (more reliable than
│ # selected_nodes, which a resize-handle click does
│ # not update). Hysteresis stickyG = snapGraph * 1.5
│ # to prevent wiggle. Multi-select is a rigid bbox
│ # move; resize uses per-edge tracking with sticky
│ # "moving" flags. Settings: Pixaroma.Align.Enabled
│ # + .SnapDistance under DISTINCT leaf categories
│ # (Vue UI dedupes by leaf name). Toolbar button
│ # mounted via app.menu.settingsGroup.element.before
│ # (rgthree pattern). Default OFF, zero cost when
│ # disabled. Shift bypasses snap (Alt is taken by
│ # ComfyUI for "duplicate during drag").
│
├── compare/ # Compare Viewer (single file, 413 lines)
│ └── index.js # Full compare widget (LiteGraph node drawing)
│
├── xy_plot/ # XY Plot Pixaroma - re-run the workflow over every
│ │ # X×Y value combo -> labeled comparison grid. THIRD
│ │ # queue-driver (after Prompt Multi/Pack); built for
│ │ # both renderers from the start. See "XY Plot Pixaroma
│ │ # Patterns" for the invariants.
│ ├── index.js # Entry: lifecycle (DOM widget + applyAdaptiveCanvasOnly,
│ │ # no dirty-on-load), the app.queuePrompt N×M driver loop
│ │ # (shared queue_drivers.mjs lock + feedsOnlyInactiveSwitch
│ │ # + imageWired gate so a non-participating node falls
│ │ # through to a normal run), the app.graphToPrompt hook
│ │ # (injectAxis writes each cell's value into the TARGET
│ │ # node's inputs[widget] - findPromptEntry matches subgraph
│ │ # keys by tail id - OVERRIDING a wired input; applySeedLock
│ │ # pins each seed by EXACT prompt key via captureSeedMap),
│ │ # the executed listener (caches exec prompt/workflow for
│ │ # Save + shows the grid PNG), growNode/fitNode + a legacy
│ │ # one-shot shrink for over-tall cross-renderer carryover,
│ │ # re-entrancy guard (_pixXyRunning) + cancelable confirm.
│ ├── core.mjs # State on node.properties.xyPlotState; enumerateTargets +
│ │ # classifyWidget (skips $$/pixaroma_*/hidden/canvasOnly);
│ │ # value parsing: parseNumberList/rangeToList (MAX_AXIS_VALUES
│ │ # cap + drift-free integer-index range), roundToStep (sci-
│ │ # notation safe), resolveAxisValues (combo filtered vs live
│ │ # options), computeCounts.
│ ├── ui.mjs # .pix-xy-* CSS + node body: axis cards, custom node+widget
│ │ # dropdown (value previews disambiguate same-titled nodes),
│ │ # adaptive value entry, counter, Lock seed/Draw labels/Save
│ │ # cells toggles, Dark/Light/Mono theme picker (instant
│ │ # re-skin via /restyle), Reset. closePopupIfOwner.
│ └── grid.mjs # In-node grid <img> (preload-before-swap, max-height 360
│ # cap) + Save Disk/Output/Copy/Open (exec-time metadata).
│
├── find_replace/ # Find and Replace Pixaroma - intercept a STRING in the
│ │ # wire (between an LLM/Show Text and whatever uses the
│ │ # prompt) and apply a stacked list of find->replace
│ │ # rules, with a live before/after preview on the node.
│ │ # Mirrors the Prompt Stack architecture (DOM rows +
│ │ # node.properties state + graphToPrompt Pattern #9 +
│ │ # pixConfirm + dirty-on-load guards). Built for BOTH
│ │ # renderers.
│ ├── index.js # Entry: lifecycle, sizing (setNodeHeight/grow/fit),
│ │ # makeHandlers, the app.graphToPrompt hook injecting the
│ │ # rules state (MINUS the preview) into hidden
│ │ # FindReplaceState, onExecuted caching the run's input
│ │ # text for the preview.
│ ├── core.mjs # State node.properties.findReplaceState
│ │ # {caseSensitive,wholeWord,regex,tidy,rules[{id,enabled,
│ │ # find,replace}]} + mutators; applyRulesJS + tidy (1:1
│ │ # mirror of nodes/node_find_replace.py::_apply_rules,
│ │ # Python authoritative); LCS word-diff for the before/
│ │ # after highlight; preview persistence in a SEPARATE
│ │ # node.properties.findReplacePreview (capped, NOT
│ │ # injected into the prompt).
│ ├── render.mjs # .pix-fr-* CSS + DOM: 4 global toggle pills (Case /
│ │ # Whole word / Regex / Tidy; Whole word greys out while
│ │ # Regex is on), rule rows (handle / ON-OFF / find ->
│ │ # replace auto-grow textareas / delete), action row
│ │ # (+ Add rule / Reset), live before/after preview box.
│ │ # renderPreview recomputes from the cached input + the
│ │ # current rules so it updates as you edit (no re-run).
│ └── interaction.mjs # find/replace textarea editors (auto-grow + live preview
│ # refresh), drag-reorder (handle is the drag source),
│ # pix-fr- themed pixConfirm.
│
├── preview/ # Preview Image Pixaroma (single file, ~570 lines)
│ └── index.js # Four orange buttons in one row — Save Disk / Save
│ # Output / Copy / Open — plus a custom strip widget
│ # rendering all batch frames with click-to-select
│ # (orange BRAND border + "i / N" badge). All buttons
│ # act on the SELECTED frame. Listens to
│ # api.addEventListener("executed", ...) for
│ # pixaroma_preview_frames (custom UI key — Save Mp4
│ # pattern, NOT ui.images, so LiteGraph doesn't render
│ # its native strip underneath). saveToOutput posts to
│ # /pixaroma/api/preview/save; saveToDisk posts to
│ # /pixaroma/api/preview/prepare and uses the route's
│ # suggested_filename (auto-counter peek) for the Save
│ # dialog, then writes via window.showSaveFilePicker
│ # with <a download> fallback. copyToClipboard writes
│ # the selected frame's PNG via navigator.clipboard
│ # .write([new ClipboardItem({"image/png": blob})]) —
│ # one-click replacement for ComfyUI's right-click
│ # Copy (Clipspace) which only copies to ComfyUI's
│ # internal clipspace, not the OS clipboard.
│ # openInNewTab opens the selected frame's URL in a
│ # new browser tab (window.open(..., "_blank",
│ # "noopener")) for full-screen viewing or comparing
│ # multiple images side-by-side.
│
├── notify/ # Notify Pixaroma (single file, ~65 lines)
│ └── index.js # Terminal node that plays a sound from
│ # assets/sounds/ when reached. Listens to
│ # api.addEventListener("executed", ...) for
│ # pixaroma_notify (custom UI key, Save Mp4
│ # pattern). Settings master toggle
│ # Pixaroma.Notify.Enabled. Native ▶ Preview
│ # button bypasses both master and per-node
│ # toggles (manual override - the user is
│ # actively asking to hear the sound now,
│ # toggles only gate automatic notifications).
│ # Sounds served by existing /pixaroma/assets/
│ # <subdir>/<filename> route, no new backend.
│ # Python returns float("nan") from IS_CHANGED
│ # so notification fires on every Run, even
│ # when upstream is fully cached.
│
├── text_overlay/ # Text Overlay Pixaroma — single-text overlay
│ ├── index.js # Entry: registration, mounts shared text_editor.mjs panel
│ │ # on the node body, app.graphToPrompt hook (Pattern #9),
│ │ # state migration (v1 multi-layer → v2 defaults).
│ ├── core.mjs # TextOverlayEditor class — lifecycle, save (just sets
│ │ # dirty canvas, no thumbnail), undo stack, zoom, canvas
│ │ # settings panel (dims + Fit W/H), alignment-to-canvas
│ │ # toolbar, mounts the SAME text_editor.mjs panel in the
│ │ # right sidebar that's on the node body.
│ └── interaction.mjs # Canvas mouse + keyboard: drag bbox to move, corner
│ # handles scale fontSize, rotation handle (Shift = 15°
│ # snap), Arrow keys nudge, Delete clears text. Single
│ # text — no multi-layer hit test, no Alt-duplicate.
│
├── text_watermark/ # Text Watermark Pixaroma — anchored, margin-based watermark
│ ├── index.js # Entry: mounts the shared text_editor.mjs panel in
│ │ # watermarkMode on the node body (NO fullscreen editor),
│ │ # app.graphToPrompt hook (Pattern #9), wired-text lock,
│ │ # FIXED getMinHeight (BASE_H 412, Vue Compat #18),
│ │ # onRemoved panel teardown. Chains app.graphToPrompt
│ │ # alongside Text Overlay's hook (verified to compose).
│ └── defaults.mjs # DEFAULT_STATE (anchor "bottom-right", marginX/Y 20,
│ # sizeMode "px") + resetStateInPlace; MUST match the
│ # Python per-field fallbacks (Pattern #3). No core/
│ # interaction files — the node reuses Text Overlay's
│ # render engine; placement (anchor + margin + px/%-width)
│ # is computed in Python (nodes/node_text_watermark.py).
│
├── audio_studio/ # AudioReact Pixaroma — fullscreen editor for audio-reactive effects
│ ├── index.js # Entry: button widget on the node, app.graphToPrompt hook
│ │ # (Pattern #9), nodeCreated lifecycle. DEFAULT_CFG mirrors
│ │ # Params() defaults in nodes/_audio_react_engine.py.
│ ├── core.mjs # AudioStudioEditor class — open/close/save/discard,
│ │ # Vue-compat Ctrl+Z neutering, undo/redo stack, source
│ │ # resolution + drag-drop, header/sidebar building.
│ ├── transport.mjs # Mixin — transport bar UI (play/scrub/sparkline/frame
│ │ # stepper), Web Audio playback synced to playhead.
│ ├── audio_analysis.mjs # Decode (Web Audio API), inline Cooley-Tukey real
│ │ # FFT (no deps), 4-band envelope/onset packed for
│ │ # RGBA32F upload, encodeWav() for upload conversion.
│ ├── render.mjs # Mixin — WebGL2 pipeline init + 2-pass render
│ │ # (motion → intermediate FBO → overlay → screen).
│ ├── shaders.mjs # 8 motion shader fragments + combined-overlay shader,
│ │ # compileProgram() with WeakMap cache.
│ ├── ui.mjs # Mixin — tabbed sidebar (Motion / Overlays / Audio /
│ │ # Output), control factories, helpers.
│ └── api.mjs # Backend wrappers — uploadSource (multipart POST),
│ # getUpstreamImageUrl (Vue links Map/object dual access),
│ # getInlineSourceUrl.
│
├── showtext/ # Show Text Pixaroma (single file, ~85 lines)
│ └── index.js # Read-only DOM <textarea> via addDOMWidget for native
│ # text selection / copy + scrollbar. Free resize (min
│ # 200x120, default 280x200). STRING output named
│ # "text" so it chains downstream. Widget value
│ # serialized so last-shown text restores on workflow
│ # load. No onDrawForeground (Vue Compat #1).
│
├── reference/ # Reference node (single file, 140 lines)
│ └── index.js
│
├── switch/ # Switch Pixaroma - dynamic N-to-1 typed router
│ ├── index.js # Entry: app.registerExtension; patches onNodeCreated,
│ │ # onRemoved, onConfigure, onConnectionsChange,
│ │ # onDrawForeground, onMouseDown. Configure replay gate
│ │ # via _pixSwitchConfiguring try/finally (Vue Compat #17).
│ │ # app.graphToPrompt hook injects state.activeIndex into
│ │ # the hidden SwitchState input at submission (Pattern #9).
│ ├── core.mjs # Slot management. State: node.properties.switchState =
│ │ # { activeIndex, labels, visibleCount }. normalizeSlots
│ │ # is idempotent: trims to (connected + 1 trailing),
│ │ # renames input_1..N, maintains the trailing-empty
│ │ # invariant. handleDisconnect defers via setTimeout(0)
│ │ # so a wire-replace (drop a new wire onto a wired slot,
│ │ # LG fires disconnect-then-connect) can be cancelled
│ │ # by handleConnect via the _pendingDisconnects Map.
│ │ # getUpstreamType traces a slot's link to the upstream
│ │ # output type for both updateOutputType and the row's
│ │ # default-label placeholder. Auto-recovery of
│ │ # activeIndex only fires when out of range, NEVER on
│ │ # link presence (links not yet set during
│ │ # restoreFromProperties on workflow load).
│ ├── render.mjs # onDrawForeground paint. rowCenterY math matches LG
│ │ # NODE_SLOT_HEIGHT so labels and toggles sit at the
│ │ # same Y as native input dots (Vue Compat #16, mirrors
│ │ # Image Compare). drawToggle pill is BRAND #f66744 when
│ │ # active, dim grey when off. drawLabel falls through
│ │ # hasUserText > "(empty)" for trailing > upstream type
│ │ # name (filtered against the "*" wildcard) > "Label..."
│ │ # grey. labelScreenRect converts body-local to viewport
│ │ # pixels for editor.mjs.
│ └── editor.mjs # Inline DOM <input> overlay for label edit-in-place.
│ # Module singleton activeEditor. Font / padding / border
│ # scale by app.canvas.ds.scale at open time so the input
│ # matches canvas-painted text at any zoom. setTimeout(0)
│ # defers focus + blur listener install so the opening
│ # mousedown propagation doesn't ghost-blur the input.
│ # Window-capture keydown with stopImmediatePropagation
│ # blocks Ctrl+Z escape. Commit deletes labels[slotIdx]
│ # on empty value so a cleared label reverts to the
│ # type-name placeholder.
│
├── load_image/ # Load Image Pixaroma (5 files, ~1700 lines).
│ # Uses the Image Resize design language, applied
│ # scoped to .pix-li-root so Image Resize is untouched:
│ # 4-col mode chips incl. Pad, painted INPUT->OUTPUT
│ # cards in the output-slot dead space, centered snap
│ # footer, resample picker with prev/next arrows,
│ # "Upscaling: On/Off" toggle button, full mode-panel
│ # polish (see Load Image Pixaroma Pattern #15).
├── index.js # Entry: extension registration, lifecycle,
│ # app.graphToPrompt hook (subgraph-safe injection of
│ # loadImagePixState into hidden LoadImagePixState
│ # Python input). Owns renderUI which rebuilds chips +
│ # mode panel + global controls. onDrawForeground paints
│ # the INPUT->OUTPUT cards (Pattern #15). updateInfoBar
│ # now just toggles the upload hint + setDirtyCanvas
│ # (the old DOM dims bar was removed in favour of the
│ # painted cards). MIN_W (360) self-healed in onResize +
│ # onDrawForeground (Pixaroma UI convention #7).
├── ui.mjs # All CSS via injectCSS() (base .pix-li-* + the
│ # .pix-li-root scoped Image Resize restyle); buildRoot
│ # constructs upload btn + hint + file row (no dims bar).
│ # hideNativeImageCombo hides EVERY auto-created widget —
│ # image_upload: True creates two (image combo + upload
│ # button), see Load Image Pixaroma Pattern #1. renderChips
│ # (4-col, Pad), renderGlobalControls (snap footer +
│ # resample picker arrows + upscale toggle),
│ # openImageDropdown (collapsible subfolders: loose files
│ # pinned at top, subfolders collapsed by default, the
│ # current image's folder auto-expands + scrolls into
│ # view), openResamplePopup.
├── panel_polish.mjs # applyInlineLabel / applyWHLayout / applyCoverControls —
│ # the three per-mode panel post-processors adapted from
│ # Image Resize, rewritten to the .pix-li-* class family
│ # the scoped CSS targets (inline-labeled inputs, the
│ # two-column Fit/Crop layout, the Fill/Crop toggle + 3x3
│ # crop-anchor grid). Wired into renderUI.
├── resize_modes.mjs # Re-exports previewResize, formatMP, buildModePanel
│ # from the shared js/shared/resize_panel.mjs (which now
│ # drives all 8 modes incl. Pad). makeNumericInput
│ # (text-type input + custom +/- spinners + Arrow stepping
│ # + stopImmediatePropagation) lives in the shared module.
│ # RATIO_PRESETS matches Resolution Pixaroma's 9 + 21:9
│ # and 5:4 + Custom (12 total).
└── api.mjs # uploadImageToInput (multipart POST to /upload/image),
# pickAndUploadFile (hidden <input type="file">),
# pasteFromClipboard (Ctrl+V handler), and
# updateNativePreview (manual node.imgs = [Image]
# refresh because setting widget.value programmatically
# doesn't fire ComfyUI's image_upload setter — see
# Load Image Pixaroma Pattern #3).
js/ (only index.js entry points use .js; every other module is .mjs so ComfyUI does not double-load it)
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 (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)
node_colors/index.js Right-click Node/Group color palette popup (favorites, copy/paste); presets in HUE_FOLDERS (see Vue Compat #20)
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
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
3d/ Pixaroma3DEditor (Three.js vendored, see Offline-first): core engine objects shapes shape_params composites picker importer interaction persistence api
composer/ PixaromaEditor (+ FX-grade + text layers): core eraser crop placeholder render ui layers fx_engine api
crop/ CropEditor: core interaction render panel alignments
note/ NoteEditor (rich-text): core toolbar blocks render sanitize css codeview icons
audio_studio/ AudioStudioEditor (WebGL): core transport audio_analysis render shaders ui api
label/ Title-less DOM label + fullscreen editor: core render
Switch family (per-renderer split; Nodes 2.0 = DOM row list, Vue Compat #16/#17):
switch/ (N-to-1 router: core render editor vue_list) | mute_switch/ (toggle branches via PIXAROMA_MUTE_CHAIN: core render editor upstream vue_list) | switch_source/ (A/B banks: core editor) | switch_wh/ (fixed A/B W-H)
Prompt + text nodes:
prompt_stack/ prompt_multi/ prompt_pack/ (each: core render interaction) | find_replace/ (core render interaction) | text/ showtext/ prompt_reader/
Image I/O + resize (load_image + image_resize paint INPUT->OUTPUT cards in onDrawForeground):
load_image/ (ui panel_polish resize_modes api) | image_resize/ (ui) | preview/ | save_mp4/ | pause_image/ (state ui)
Overlay / annotation / utility:
text_overlay/ (core interaction defaults) + text_watermark/ (defaults) share framework/text_editor.mjs + nodes/_text_render_helpers.py | compare/ | resolution/ | xy_plot/ (core ui grid - 3rd queue-driver -> grid PNG) | reference/ | notify/ | number/ | wh/ | version_check/ (Nodes 2.0 diagnostic node)
```
### Mixin Pattern (how editor classes are split)
@@ -891,6 +363,8 @@ Load Image was the SECOND painted node migrated (`js/load_image/index.js`) and i
6. **The cards painter is ONE shared function (`paintCardsInto(ctx, node, leftPad, midY, pairW)`)** used by BOTH legacy `onDrawForeground` (painting in the left dead-space beside the output dots) AND the Nodes 2.0 cards canvas. `onDrawForeground` early-returns `if (isVueNodes())`.
7. **Stable preview size + full Nodes 2.0 fill (issue #1, 2026-06-09).** Load Image used to resize the node to the loaded image's ASPECT (`fitPreview` set `previewH = innerW * imageAspect`), so a tall portrait BALLOONED the node and overlapped neighbours (real user report). Fix: LEGACY `fitPreview` now PRESERVES the current preview area (loading a different-shaped image never resizes the node; a manual drag-taller sticks; only a mode/controls-height change shifts it). NODES 2.0: the preview canvas is a flex grower (`flex:1 1 0; min-height:LI_PREVIEW_FILL_MIN`) inside the controls panel, and the controls panel is made the node's SOLE grower widget (`widget.computeLayoutSize = () => ({minHeight: measureContentHeight(), minWidth:1})`); `measureContentHeight` counts the canvas at its MIN (not its grown offsetHeight) so the node can still shrink, and `renderLoadPreviewCanvas` reads the canvas's flex-resolved `clientHeight`. FULL FILL (FIXED 2026-06-09 via a live height-chain probe + the frontend source `dialogService-*.js`): a widget gets a stretchy `auto` grid row when it has a `computeLayoutSize` fn (`hasLayoutSize`), and `lg-node-widgets` gets `flex:1` whenever its `gridTemplateRows` includes `auto` — so our controls panel (with `computeLayoutSize`) DID correctly get the grower flag. The gap's REAL cause was NOT the 7 outputs (red herring): on `image_upload` nodes the Vue node ALSO renders a native image-preview CONTAINER — an EMPTY `flex:1` div immediately AFTER the widget grid — and being a SECOND `flex:1` sibling of the node content it SPLIT the free height with our widget area (each got ~half), leaving a big gap below our preview. FIX in `injectLoadImageNodes2CSS`: collapse that container with `.lg-node:has(.pix-li-root) .lg-node-widgets + div.flex-1{display:none !important}` so our widget area is the SOLE grower and fills the node exactly like native Load Image (we draw our own preview, so hiding the native container loses nothing). Verified in BOTH renderers + multiple aspect ratios. **Do NOT remove that CSS rule** or the gap returns. The `.image-preview{display:none}` rule alone is NOT enough (it hides the IMAGE but the flex:1 CONTAINER keeps hogging height).
**Generalised rule for any painted node with MANY outputs / hidden widgets:** don't trust a second DOM widget to size correctly — prefer rendering into canvas children of the one DOM widget that's already proven to render. And remember the asymmetry: Vue grows the node to content automatically, but you must `setSize` (gated on `!isGraphLoading`) to shrink it.
### Single full-body painted node (Compare) — the simplest painted case, ONE DOM canvas
@@ -938,45 +412,7 @@ Label was the LAST node migrated and the trickiest, because it is **title-less,
**Version Check renderer toggle now reloads the page** (`js/version_check/index.js`): clicking the "Node UI" row persists `Comfy.VueNodes.Enabled` then `location.reload()`s, because per-renderer-widget nodes (chosen once at `onNodeCreated`) misbehave until a refresh after a live switch. So A/B testing a migration is now seamless (click → auto-reload into the other renderer).
**Per-node migration status** (update as we go; all NOT STARTED at kickoff 2026-05):
| Node | Render approach today | 2.0 risk | Status |
|---|---|---|---|
| Switch Source | DOM control strip + native A/B + output labels | 🟡 (slot align n/a) | ✅ DONE + VERIFIED both renderers 2026-05-29 — `applyAdaptiveCanvasOnly` on the control strip (it had a static `canvasOnly:true` and vanished in 2.0); native A/B input + output labels render in both; canvas click-to-rename gated to legacy. Inline Vue rename fields were built then REMOVED at the user's request (too tall) - Nodes 2.0 shows wire-type output labels, rename is legacy-only. `resizeToRows` now uses `node.setSize`. |
| Switch | `onDrawForeground` slot-aligned toggles + labels | 🔴 hard (slot align) | ✅ DONE + VERIFIED both renderers 2026-05-29 — per-renderer split: legacy untouched (canvas paint + onMouseDown, both early-return in 2.0); Nodes 2.0 = DOM row list `js/switch/vue_list.mjs` (one row per input below the dots: `[input N (fixed, matches the dot)] [editable field: placeholder = wire type, value = custom name that overrides it] [mutex toggle]`, click row to activate / click field to rename). Vue input DOTS show a stable `input N` (a type/name dot can't live-refresh - shallowReactive, see the dot-label VERIFIED note above). Shared `setActiveRow()` in core.mjs; `node._pixSwRefresh` re-renders the list on slot/state change. Toggle-on-dot-row + type/name-on-dot are IMPOSSIBLE/stale in 2.0 - the body list is the answer. Establishes the Switch-family Vue pattern Mute Switch reuses. |
| Mute Switch | `onDrawForeground` pills | 🔴 hard (slot align) | ✅ DONE + VERIFIED both renderers 2026-06-01 — per-renderer split (mirrors Switch): legacy untouched (canvas paint + onMouseDown, both early-return when `isVueNodes()`); Nodes 2.0 = ONE DOM widget `js/mute_switch/vue_list.mjs` — a mode bar of two segmented toggles (Single\|Multi + Mute\|Bypass) on top, then one row per input below the dots: `input N` + editable name field (placeholder = wire type, writes `state.rows[i].label`) + an ON/OFF toggle that is the ONLY click target (runs the existing `togglePillRow`, so Single-mode "exactly one ON" still holds; the rest of the row is not clickable — user's choice). `node._pixMsRefresh?.()` wired into every state mutator in core.mjs re-renders the list. Input dots show a stable `input N`, the phantom output dot is labelled `out` (both 2.0-only via the new `slotDisplayLabel`/`outputDisplayLabel` helpers; `"​"` in legacy). `applyAdaptiveCanvasOnly` + namespaced type `pixaroma_mute_switch_list` + getMinHeight==getMaxHeight. Engine (cascade / originalModes / self-heal sweep / "Enable·Disable all rows" right-click menu / phantom `PIXAROMA_MUTE_CHAIN` output) is renderer-agnostic and unchanged. **LEGACY resize fix (UI convention #7):** MIN_H must be DYNAMIC `computeNodeHeight(rowCount)` in BOTH onResize + onDrawForeground (a fixed MIN_H let the node be dragged shorter than its rows → bottom rows spilled out the frame); mode pills made responsive (`modePillWidth`) + an onResize width clamp added (LG draws the frame at the dragged size BEFORE onDrawForeground, so a width clamp there alone left a 1-frame overflow during the drag). **The Switch family is now FULLY migrated; only Mute Switch slot-alignment was the last hard painted node.** |
| Preview Image | custom canvas widgets + painted buttons | 🔴 high (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — **per-renderer widget split** (`if (isVueNodes())` in onNodeCreated): LEGACY keeps the two `addCustomWidget` canvas widgets (buttons + strip) UNCHANGED (they fill via draw()'s `node.size[1]-y`); NODES 2.0 uses a DOM-flex buttons row (`createButtonsDOMWidget`) + a DOM strip (`createStripDOMWidget`) that reuses the canvas widget's `draw()`/`mouse()` via a throwaway `createStripWidget()` logic object rendered into its own `<canvas>` (ResizeObserver-sized, no bridge). Native preview suppressed via `node.hideOutputImages=true` (keeps ui.images for Assets refresh). See the dedicated "Preview Image / painted-node Nodes 2.0 fill" section below for the full hard-won recipe (canvas-bridge growth loop, DOM-widget flex-fill, grid auto-vs-min-content rows). Hover on the legacy canvas controls (toggle/×) is the only Nodes 2.0 cosmetic loss; buttons hover works (real DOM). |
| Compare | full canvas node draw (`onDrawForeground` body + mouse hooks) | 🔴 high | ✅ DONE + VERIFIED both renderers 2026-05 (incl. zoom-coord, size round-trip, cursor, hover — see "Single full-body painted node" Gotchas) — **per-renderer split** (`if (isVueNodes())` in onNodeCreated). The ENTIRE body is one painted surface (Show/mode buttons + opacity slider/hint + Copy button + the image-compare area), so unlike Preview (buttons row + strip) it migrates as ONE DOM `<canvas>` widget (type `pixaroma_compare`, flex `1 1 0` + `min-height:0`, ResizeObserver-sized, DPR-scaled) that reuses the SAME `paintCompare(ctx,node,W,H,mouse)` + `cmpDown/Move/Up/Wheel/Leave(node,lx,ly,W,H)` logic. LEGACY keeps the original `onDrawForeground` + mouse hooks UNCHANGED (they just delegate to the same helpers with `this.size`); each hook early-returns to the original when `isVueNodes()`. DOM `pointerdown/move/up/leave/wheel` feed the hit-tests with canvas-local coords; hover-to-slide swipe works because pointermove fires on hover; Copy-button hover uses the last DOM pointer pos (legacy uses `graph_mouse`); wheel only `preventDefault`s when it consumes (Overlay opacity) so graph zoom still works otherwise; slider drag uses `setPointerCapture`. `cmpRepaint(node)` = `setDirtyCanvas` (legacy) + `node._cmpDomRender?.()` (2.0, our own canvas isn't bridged so setDirtyCanvas alone never repaints it). Native preview suppressed via `node.hideOutputImages=true` + the existing `onExecuted`-no-orig override (node.imgs never populated). ResizeObserver released in `onRemoved`. See "Single full-body painted node" note below. |
| Reference (dev example) | DOM widget (`addDOMWidget`, type `custom`) | 🟢 (+canvasOnly) | ✅ DONE — pending user test 2026-05 — NOT a painted node (the status table mislabeled it). `nodes/node_ref.py`'s `PixaromaReferenceNode` is a developer example ("test dom HTML object") and was already a DOM-widget node; it only had a static `canvasOnly:true` (→ vanished in 2.0). Fix = `applyAdaptiveCanvasOnly(widget)` (the standard DOM-node recipe). `Pixaroma_VueReferenceNode` is a separate dev example already written against the native Nodes 2.0 `comfy_api.latest.io` schema API — needs nothing. |
| Prompt Pack | DOM body + (was) canvas mode pills | 🟡→🟢 | ✅ DONE + VERIFIED both renderers 2026-05 — converted Paragraph/Line pills from canvas (`onDrawForeground` paint + `onMouseDown` hit-test + floating tooltip) to a DOM segmented toggle (`.pix-pp-modebar`/`.pix-pp-modepill`) at the top of the body; wired in interaction.mjs; active-state in applyState; deleted all canvas-pill code; `applyAdaptiveCanvasOnly`; heights bumped (pill bar now in body); pills use native `title` tooltips. **Template for Prompt Multi.** Pending user test. |
| Prompt Multi | DOM rows + (was) canvas mode pills | 🟡→🟢 | ✅ DONE + VERIFIED both renderers 2026-05 — same conversion as Prompt Pack: Queue Text/List Prompts pills moved from canvas to a DOM segmented toggle built INSIDE `renderRows` (it does `innerHTML=""`, so the modebar is rebuilt each render), wired via the existing `onSetMode` handler, active-class from `state.mode`; deleted all canvas-pill code; `applyAdaptiveCanvasOnly`; grow/fit use `setNodeHeight`(`setSize`) for the cross-renderer resize fix; `CHROME_ALLOWANCE` 68→40 (pills now in body). Confirmed Queue Text + List Prompts (with Prompt From List downstream). |
| Load Image | DOM panel + `onDrawForeground` cards | 🟡 partial | ✅ DONE + VERIFIED both renderers 2026-05 — LEGACY untouched (DOM controls panel + `onDrawForeground` cards in the left dead-space + native bottom preview + `fitPreview`). NODES 2.0: `applyAdaptiveCanvasOnly` on the controls panel; the INPUT→OUTPUT cards (shared `paintCardsInto`) + the image preview are drawn into TWO `<canvas>` children of the controls panel root (cards prepended at top, image appended at bottom) — NOT a second DOM widget (that collapsed to 0 on this node). Native `.image-preview` hidden via `.lg-node:has(.pix-li-root)` and replaced by our own image (updates on every pick). Preview height capped (~240, `LI_PREVIEW_MAX_IMG_H`) so the auto-growing node stays compact. See the "Load Image / painted-node-with-many-outputs" section below. Input-slot stripping (Pattern #17) + image-picker patterns (#1-8) unchanged. (A Compact/Medium/Large preview-size setting was built then reverted at the user's request — the fixed compact size is the shipped state.) |
| Show Text | DOM textarea widget | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED 2026-05 — adaptive canvasOnly + unique widget type (`pixaroma_showtext`). Confirmed both renderers: value populates, chaining works, Copy button + multiline OK. **The two-step recipe (adaptive canvasOnly + `pixaroma_*` type) is the template for every DOM-widget node.** |
| Prompt Reader | DOM widget + `image_upload` | 🟢 | ✅ DONE + VERIFIED both renderers 2026-05 — adaptive canvasOnly + hide the stale Nodes 2.0 `.image-preview` panel via the `:has()` CSS rule above (no thumbnail by design). |
| Text / Resolution | DOM widgets (type `custom`, no collision) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED 2026-05 — `applyAdaptiveCanvasOnly` on each node's own widget (their hidden NATIVE widgets keep static `canvasOnly:true`, correct — want them gone in both modes). Confirmed Nodes 2.0 before/after: Text box+buttons, Resolution chips+list, Prompt Reader readout all render. Legacy unchanged by design (getter returns the same `true` legacy used statically). |
| Switch WH | DOM A/B buttons (type `custom`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; `onDrawForeground` is min-size self-heal only (no painted controls). |
| Prompt Stack | DOM rows (type `div`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly` + `setNodeHeight` uses `node.setSize()` so cross-renderer Reset shrinks (was stuck tall when rows grown in the other renderer; see resize gotcha above). |
| Text Watermark | shared text_editor panel (type `div`, fixed getMinHeight) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; `onDrawForeground` is min-width clamp only. Full panel + watermark render confirmed. |
| Text Overlay | shared text_editor panel (type `div`, fixed getMinHeight) + fullscreen editor | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; node body panel renders AND the fullscreen editor opens + works under Nodes 2.0 (first editor confirmed in 2.0 — see editor note below). |
| Note | DOM rich-text body (type `custom`) + fullscreen editor + hidden `note_json` STRING | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly` on the body widget + `hideJsonWidget` now sets `canvasOnly` so the raw `note_json` JSON no longer shows at the top in Nodes 2.0. Editor + rendered content confirmed. |
| Image Crop | native button + 2 DOM widgets (panel + mini-preview, type `custom`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly` on BOTH widgets; "Open Crop" native button; node body + fullscreen crop editor + placeholder state + width/height outputs all confirmed. |
| Paint | native button + 1 DOM widget (mini-preview, type `custom`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; "Open Paint" native button; node body + full Paint Studio editor (brushes/layers/AI bg removal/transforms) + image+w/h outputs all confirmed. |
| 3D Builder | native button + 1 DOM widget (SceneWidget preview, type `custom`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; "Open 3D Builder" native button; node body + full WebGL 3D editor (shapes/transform/camera/materials) + image+w/h outputs all confirmed. |
| Image Composer | native button + 1 DOM widget (ComposerWidget preview, type `custom`) | 🟢 (+canvasOnly) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly`; "Open Image Composer" native button; node body + full compositor (layers/transform/eraser/crop/AI bg/FX/text) + image+w/h outputs all confirmed. |
| Save Mp4 | native widgets + 1 DOM `<video>` widget (type `video_preview`) | 🟢 (+canvasOnly +image-preview hide) | ✅ DONE + VERIFIED both renderers 2026-05 — `applyAdaptiveCanvasOnly` so the `<video>` renders; hid the broken native `.image-preview` (mp4≠image from `ui.images`) via `.lg-node:has(.pix-mp4-root)`. `<video>` plays the result, no broken box. |
| AudioReact | native button + WebGL overlay (NO DOM widget, NO canvasOnly; Pattern #9 hidden input) | 🟢 | ✅ DONE + VERIFIED both renderers 2026-05 — NO CODE CHANGE NEEDED. Node body is just a native `addWidget("button","Open AudioReact")` which renders in Vue; state is a Pattern #9 hidden input (no STRING widget to leak); editor is a renderer-agnostic WebGL overlay. Verify only. |
| Image Resize | DOM panel + `onDrawForeground` cards | 🟡 painted | ✅ DONE + VERIFIED both renderers 2026-06-01 — was MISSED in the original table (painted-node-with-cards class, same as Load Image). Legacy untouched (DOM controls panel + `onDrawForeground` INPUT→OUTPUT size cards in the slot dead-space). Nodes 2.0: `applyAdaptiveCanvasOnly` on the controls panel (it had a static `canvasOnly:true` → vanished); the readout paint extracted into shared module fns `refreshReadout` (live DOM-cell updates) + `paintReadout` (the two-card / message painter, parameterized by width + midY) so BOTH renderers draw identical pixels; `setupVueCards` renders the cards into a `<canvas>` child of the controls panel (`renderUI` re-prepends it each render, fixed 100px), ResizeObserver-sized + DPR-correct, with a 250ms CHANGE-GATED poll replacing the lost `onDrawForeground` loop for live upstream-value changes (Vue Compat #1); teardown (clearInterval + RO.disconnect) in `onRemoved`. graphToPrompt injection + executed-dims handler + auto-swap + all dirty-on-load guards unchanged. Trade-off (same as Load Image): the cards cost ~100px of body height in 2.0 (no slot dead-space to paint in). |
| Label | full-body `onDrawForeground` paint (transparent node) | 🔴 painted | ✅ DONE + VERIFIED both renderers 2026-06-01 — the LAST node migrated, and the hardest (title-less, whole-body, tightly-self-sized). Per-renderer split: legacy untouched (`onDrawForeground` paints the label, gated to early-return in 2.0). Nodes 2.0: a crisp-HTML DOM widget (`applyLabelToDom` mirrors `renderLabelToCanvas` cfg→CSS) rendered into a `pix-lbl-vue` div; the fullscreen editor is renderer-agnostic (untouched). See the dedicated "Title-less / tightly-self-sized DOM node (Label)" section below for the FULL recipe — it solves problems no other node hit: a title-less node's body is the DOM widget, so it must be `pointer-events:none` on the ENTIRE widget subtree (else the place/drag click is eaten and the node sticks to the cursor); double-click-to-edit is restored via a document-level dblclick + element-rect hit-test (LG `onDblClick` never fires for a pointer-events:none body) plus a right-click "Edit Label" menu; and hugging the box to the label means overriding a whole stack of Vue node chrome (225px min-width, the widget grid's 80+125px column floors + gutter, body `pt-1 pb-3 gap-1`, the `min-h = size[1]+NODE_TITLE_HEIGHT` floor, the -7px selection outline) + writing `node.size` DIRECTLY (setSize re-clamps width to 225) + hiding the now-useless resize handles. **With Label done, EVERY Pixaroma node is migrated to Nodes 2.0. The two non-node canvas features (Align, Connection FX) and Node Colors/Brand were then completed too (2026-06-01) — the Nodes 2.0 migration is FULLY COMPLETE.** |
| Native utility nodes (Notify, Number, WH, Prompt From List, Remove Background, Version Check) | native widgets only | 🟢 safe | n/a — no custom canvas/DOM body, so the Vue renderer handles them automatically. Verified none use `addDOMWidget`/`addCustomWidget`/`onDrawForeground`/`canvasOnly`. Version Check is the diagnostic node, built with the recipe. |
| Align | window pointermove + `drawFrontCanvas` wrap | 🔴 canvas paint | ✅ DONE + VERIFIED 2026-06-01 — snap math + orange guide lines work in Nodes 2.0 (drawFrontCanvas fires in both renderers — this is what proved drawFrontCanvas survives, which Connection FX then relied on). |
| Connection FX | `drawFrontCanvas` wrap + DOM overlays | 🔴 canvas paint | ✅ DONE + VERIFIED 2026-06-01 — see "Connection FX Nodes 2.0" note below. Sparkles already worked (DOM SVG). The approach **magnets + particle flow were rebuilt as DOM overlays** for Nodes 2.0 because the front-canvas ctx space doesn't match the Vue DOM slot positions AND the canvas doesn't redraw continuously during a wire drag; legacy keeps the canvas painter (early-returns in 2.0). |
| Node Colors / Brand | `LGraphCanvas` menu + `node.color` + swatch-palette popup | ❓→✅ | ✅ DONE + VERIFIED 2026-06-01 — see "Node Colors Nodes 2.0" note below. `node.color`/`bgcolor` render AND live-update; groups still canvas-painted; the menu monkey-patches still fire via the legacy-compat layer; the old 3-level nested submenu was replaced by a **DOM swatch-palette popup used in BOTH renderers**. |
| Run Button FX | DOM toolbar (no node) | 🟢 safe | n/a |
**Reference sources (re-check; they evolve):**
- `Comfy-Org/ComfyUI_frontend`: `src/lib/litegraph/src/LGraphCanvas.ts` (`drawNode` vueNodesMode early-return ~L5614), `src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue` (canvas-widget bridge), `WidgetDOM.vue` (DOM bridge), `widgets/registry/widgetRegistry.ts` (`shouldRenderAsVue` + type table), `composables/useProcessedWidgets.ts` (dispatch), `composables/useVueFeatureFlags.ts` (the flag).
- KJNodes: `web/js/nodeswap.js` + `setgetnodes.js` (`LiteGraph.vueNodesMode` dual-branch), `image_transform.js` (`addDOMWidget` + onDrawForeground-as-polling, breaks in 2.0).
- Docs (when published): `docs.comfy.org/interface/nodes-2`, `docs.comfy.org/custom-nodes/v3_migration`.
**Nodes 2.0 migration: COMPLETE (2026-06-01).** Every node (~30) AND every canvas feature (Align, Connection FX, Node Colors / Brand) works in BOTH renderers, verified each way. Reusable recipes are in the sections above: DOM-widget nodes use `applyAdaptiveCanvasOnly` + a `pixaroma_*` widget type; painted nodes follow the Preview Image / Load Image / Compare / Label recipes; the Switch family uses a per-renderer split (legacy canvas paint + a Nodes-2.0 DOM row list). Per-node specifics live in each node's Pattern section. **New nodes MUST be built for both renderers from the start** (migration playbook above). The painted nodes that needed special handling (and carry detailed recipes): Preview Image, Load Image, Image Resize, Compare, Label, and the Switch family. To re-verify a Nodes-2.0 fact, read the live `ComfyUI_frontend` source: `LGraphCanvas.ts` (drawNode vueNodesMode early-return), `WidgetLegacy.vue` / `WidgetDOM.vue` (widget bridges), `widgetRegistry.ts` (shouldRenderAsVue), `useProcessedWidgets.ts` (dispatch).
### Node Colors Nodes 2.0 (the swatch-palette popup) — recipe + gotchas
@@ -1073,7 +509,7 @@ These conventions were locked in during the May-2026 visual consistency pass acr
4. **Hide native `multiline: True` widget + build custom DOM widget for full styling control.** ComfyUI's native `STRING` widget with `multiline: True` cannot be styled cleanly from outside (its DOM is owned by the framework and may change between versions). For nodes where the textarea interior must match the rest of the Pixaroma suite (currently Text Pixaroma), the pattern is: keep `multiline: True` in `INPUT_TYPES` (so the workflow JSON serialises the value correctly), then in `onNodeCreated` find the native widget, hide it via the standard `hideJsonWidget`-style recipe (`hidden = true; computeSize = () => [0, -4]; element.style.display = "none"; inputEl.style.display = "none"; options.canvasOnly = true`), and add a custom DOM widget with your own `<textarea>` that mirrors values back to the hidden widget on every `input` event. Cache the native widget reference on the node (e.g. `node._pixTextNative`) so the mirror is easy. On `onConfigure` restore, copy the native widget's restored value back into the DOM textarea via a `queueMicrotask` so the configure-then-display ordering doesn't flash defaults. Reference: `js/text/index.js`.
5. **Default size = minimum size pattern for utility / display nodes.** For nodes where the user's most common need is a compact widget on the canvas (Show Text Pixaroma, Switch Pixaroma, Switch WH Pixaroma, Text Pixaroma, Number nodes, etc), set `DEFAULT_W` and `DEFAULT_H` to the SAME values as `MIN_W` / `MIN_H`. Fresh-on-canvas drop = the smallest comfortable size; user resizes outward when they need more room. Avoid the older anti-pattern of "default is bigger than min", because (a) every user spends two seconds resizing every fresh node down, and (b) when `default > min`, dragging the corner inward visibly grows the node before clamping, which feels like a bug. Use the sizer console snippet (see Token-Saving Rules #6) to find the right numbers empirically rather than guessing. Current values: Show Text 210×118, Text 290×158, Switch WH 210×140.
5. **Default size = minimum size pattern for utility / display nodes.** For nodes where the user's most common need is a compact widget on the canvas (Show Text Pixaroma, Switch Pixaroma, Switch WH Pixaroma, Text Pixaroma, Number nodes, etc), set `DEFAULT_W` and `DEFAULT_H` to the SAME values as `MIN_W` / `MIN_H`. Fresh-on-canvas drop = the smallest comfortable size; user resizes outward when they need more room. Avoid the older anti-pattern of "default is bigger than min", because (a) every user spends two seconds resizing every fresh node down, and (b) when `default > min`, dragging the corner inward visibly grows the node before clamping, which feels like a bug. Use the sizer console snippet (see Token-Saving Rules #6) to find the right numbers empirically rather than guessing. Current values: Show Text 210×118, Text 412×158 (width raised from 290 so the bottom button row + the ? help fit on one line in both renderers - see Text Pixaroma Patterns #4), Switch WH 210×140.
6. **`WIDGET_MIN_H` vs `MIN_H` - two different mins for two different things.** ComfyUI's natural layout floor for a node is roughly `addDOMWidget`'s `getMinHeight` return value + slot rows. If you want the resize handle to clamp the WHOLE NODE at a specific min (e.g. `MIN_H = 118`) but the DOM widget itself can be visually small (e.g. `WIDGET_MIN_H = 80` for a textarea that should be allowed to shrink to label-strip height), the two values must be different. Otherwise the natural floor becomes `MIN_H + slot_rows` and the user can't actually reach `MIN_H`. Pattern: declare both constants, pass `WIDGET_MIN_H` to `getMinHeight`, enforce `MIN_H` in `onResize` and `onDrawForeground`. Reference: `js/showtext/index.js`.
@@ -1139,6 +575,8 @@ Paint, Composer, and 3D Builder each have a "Transparent BG (Save to Disk)" chec
5. **Post-run client recomposite (`rebuildPreview`) only adds value when there are placeholder layers** — for the no-placeholder case, Python's fast path loads the saved `composite_path` PNG (which has the user's bg color baked into pixels via line 760-762 of saveBtn), and that's exactly what `restoreNodePreview` already shows. Calling `rebuildPreview` after run in this case re-renders client-side, which can drift from the saved view (different scaling rounding, missing styles, etc.). The `onExecuted` and `executing(null)` handlers in `js/composer/index.js` gate on `node._pixaromaHasPlaceholders?.()` to skip the rebuild for the fast path. The polling rebuild (upstream LoadImage swap before run) is unaffected — it only matters when placeholders exist anyway.
6. **Eraser Restore mode rides the SAME mask canvas - never fork a second mask.** The Erase | Restore pill (plus X swap and Alt-hold temp flip) all funnel through `eraserIsRestore()` in `js/composer/eraser.mjs`; restore strokes draw on `layer.eraserMaskCanvas_internal` with `globalCompositeOperation = "destination-out"`. Because the mask file format and the render path are untouched, save/restore/undo/Python need zero changes - any future "restore" feature that introduces a second canvas or a separate upload loses all of that for free. `drawEraserLine` early-returns on restore-with-no-mask so a no-op stroke can't create or dirty a mask; only ERASE strokes set `hasMask_internal`. The brush cursor color IS the mode indicator (white ring = erase, BRAND orange ring = restore, branched on the EFFECTIVE mode so Alt-hold recolors live) - keep `drawEraserPreview`, the pill state (`core.eraserModePills`), and the keyboard paths in sync via `_refreshEraserPreview()`. `_eraserAltHeld` is synced from `e.altKey` on every eraser mousemove AND cleared in `_composerBlur` (Alt+Tab safety); the X/Alt keydown handlers only run while `activeMode === "eraser"`.
### Composer FX Layer Patterns (do not regress)
These cover the FX/adjustment layer (Photoshop-style color-grade layer that grades every layer below it). Ships sliders + 15 cinematic presets (Original + 14: Cinema, Vivid, Teal, Amber, Sienna, Safari, Tropic, Bloom, Forest, Emerald, Nordic, Airy, Crisp, Street) in a 5-col chip grid; Curves/Levels/letterbox/mask still deferred. Spec: `docs/superpowers/specs/2026-05-26-composer-fx-layer-design.md` (gitignored).
@@ -1403,7 +841,7 @@ These patterns were hard-won during the May-2026 batch + save-mode upgrade. Seve
7. **Save-to-Disk filename auto-increment via per-node session offset (`_pixaromaDiskOffset`).** `folder_paths.get_save_image_path` only sees files in ComfyUI's `output/`, not the user's chosen disk location. So every Save-to-Disk click would suggest the same filename. Fix: track a per-node click counter (`node._pixaromaDiskOffset`), bump after each successful save, apply via `bumpFilenameCounter("img_00002_.png", offset)` to the server-suggested name. Reset to 0 on each `executed` event (new run = fresh counter base from output/). NOT persisted across workflow saves — accepted trade-off since disk save locations are user-chosen anyway.
8. **`_safe_prefix(s)` sanitizer — relative-import shared helper module + dual contract (None vs fallback string).** Lives in `nodes/_save_helpers.py`. Returns the sanitized prefix, or `None` if input is unrecoverable. Caller decides: backend node uses `_safe_prefix(s) or "Preview"` (don't crash workflow); server route uses `if not prefix: return 400` (surface error to JS toast). **Sanitizes, does not reject.** Any char outside `[A-Za-z0-9_\-%]` in a segment is replaced with `_`, then repeated `_` collapsed and edge `_` stripped. So an upstream filename wired into `filename_prefix` (e.g. `Bunny WithCubes - Copy.png`) becomes `Bunny_WithCubes_-_Copy_png` and saves successfully instead of erroring. Path segments are separated by `/`; `\\` normalized to `/` (Windows convenience). Returns `None` only for: non-string, empty input, length > 256, leading `/`, any segment that is literally `..` (path traversal), any segment that sanitizes to empty. **`%` is permitted in segments** so two layers of token expansion both work: (a) **VHS-style `%date:FMT%`** is expanded BEFORE sanitization by `_expand_date_tokens` (Java-style codes `yyyy yy MM dd HH mm ss`, two-pass sentinel swap to avoid `yyyy`/`yy` collision, bad format strings leave the token literal so the user can spot the typo); (b) **native ComfyUI tokens** `%year% %month% %day% %hour% %minute% %second% %width% %height%` survive sanitization as literal `%`-delimited segments and are expanded later by `folder_paths.get_save_image_path.compute_vars`. Net result: `%date:yyyy-MM-dd%/images/fish_` and `%year%-%month%-%day%/img` both work. Path traversal is impossible: explicit `..` segment rejection, ComfyUI does its own `os.path.commonpath` check after its expansion, and Java date codes can only emit digits. `nodes/_save_helpers.py` is imported via `from .nodes._save_helpers import _build_pnginfo, _safe_prefix` (relative import, package-aware) — NEVER `from nodes._save_helpers` because that collides with ComfyUI's top-level `nodes.py`. The `nodes/` directory is an implicit namespace package (no `__init__.py`) and that's fine.
8. **`_safe_prefix(s)` sanitizer — relative-import shared helper module + fallback contract (None means "use the default prefix").** Lives in `nodes/_save_helpers.py`. Returns the sanitized prefix, or `None` if input is unrecoverable. Every caller falls back so a save never crashes: `node_preview.py` + both preview routes use `_safe_prefix(s) or "Preview"`, the XY Plot save route uses `or "xy_plot"`. **Sanitizes, does not reject — and it is a DENYLIST, not an allowlist (flipped June 2026 after the Korean `filename_prefix` report).** Per segment, ONLY Windows-illegal chars (`< > : " | ? *` plus control chars) are replaced with `_` (repeated `_` collapsed; edge whitespace, edge `_`, and trailing dots/spaces stripped — Windows itself silently strips trailing dots/spaces at create time, so stripping keeps the reported path identical to what lands on disk); Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9 — bare or with any extension, any case) get a `_` suffix so the OS can't hard-error mid-run. EVERYTHING else passes through verbatim — non-Latin scripts (`프로젝트/테스트/이미지` saves like native), accented letters, spaces, dots — matching native SaveImage, which does no character filtering at all (its only guard is folder_paths' "stay inside output/" commonpath check, which still runs after ours on every save path). The old `[^A-Za-z0-9_\-%]` allowlist dissolved an all-Korean prefix to nothing → silent "Preview" fallback in the output root, and mangled mixed ones (`te 스 st` → `te_st`). An upstream filename wired into `filename_prefix` (e.g. `Bunny WithCubes - Copy.png`) now stays verbatim. Path segments are separated by `/`; `\\` normalized to `/` (Windows convenience). Returns `None` only for: non-string, empty input, length > 256, leading `/`, any segment that is literally `..` (path traversal), or nothing usable left (e.g. `???`). **`scripts/save_prefix_check.py` locks all of this (31 cases, prints `PREFIX PASS`) — re-run it after ANY change to the sanitizer.** **`%` is permitted in segments** so two layers of token expansion both work: (a) **VHS-style `%date:FMT%`** is expanded BEFORE sanitization by `_expand_date_tokens` (Java-style codes `yyyy yy MM dd HH mm ss`, two-pass sentinel swap to avoid `yyyy`/`yy` collision, bad format strings leave the token literal so the user can spot the typo); (b) **native ComfyUI tokens** `%year% %month% %day% %hour% %minute% %second% %width% %height%` survive sanitization as literal `%`-delimited segments and are expanded later by `folder_paths.get_save_image_path.compute_vars`. Net result: `%date:yyyy-MM-dd%/images/fish_` and `%year%-%month%-%day%/img` both work. Path traversal is impossible: explicit `..` segment rejection, ComfyUI does its own `os.path.commonpath` check after its expansion, and Java date codes can only emit digits. `nodes/_save_helpers.py` is imported via `from .nodes._save_helpers import _build_pnginfo, _safe_prefix` (relative import, package-aware) — NEVER `from nodes._save_helpers` because that collides with ComfyUI's top-level `nodes.py`. The `nodes/` directory is an implicit namespace package (no `__init__.py`) and that's fine.
9. **Tracking the "active" preview node for global keybindings (`_activePreviewNode`).** Module-scope `let _activePreviewNode = null`. Set on click that enters expanded mode; clear on X close, Esc, OR `nodeType.prototype.onRemoved` (so deleted nodes don't dangle a reference and prevent GC of their loaded images). Window-level `keydown` listener routes ←/→/Esc to whichever node is active, with `INPUT/TEXTAREA/contentEditable` exclusion so the user can still type. Use capture-phase (`true` 3rd arg to `addEventListener`) so we preempt ComfyUI's canvas-pan keybindings. Same pattern can be reused for any other node that needs document-level keyboard control while focused.
@@ -1558,6 +996,10 @@ These were hardened over four agent-review waves + live testing (June 2026). Re-
8. **`readState` identity stability + in-place resets.** `readState`/`backfillAxis` mutate the axis objects in place (never replace `s.x`/`s.y`/`raw` with fresh objects) and `selectChoice` resets `axis.raw` IN PLACE — because value-field handlers capture `axis.raw` by reference; swapping the object reintroduces the "editing one axis clobbers the other" bug. The picker popup is a module singleton; `closePopupIfOwner(node)` (not bare `closePopup`) is called from `onRemoved` so deleting one node doesn't close another's open picker.
9. **Number-axis rounding uses the widget's declared `precision`, NOT the `step` (June 2026; refined after integer ranges came out fractional).** ComfyUI tags every number widget with `precision` = decimals allowed (0 = integer width/height/steps/seed/batch_size, 1 = cfg, 2 = denoise) AND carries the REAL increment in `step2` (width 16, cfg 0.1), while the plain `step` option is ×10-inflated (width 160, cfg 1.0) and unreliable. `classifyWidget` captures `precision`; `roundToStep(v, step, precision)` rounds to `precision` when present. History (two passes): the FIRST fix derived decimals from `max(stepDecimals, valueDecimals)` to stop floats truncating (`cfg 5,6,7.1` -> `5,6,7`; `denoise 0.05,0.1` -> `0.1,0.1`; `cfg 0-1 x4` -> `0,0,1,1`), but that then made INTEGER ranges fractional (`width 512-1024 in 4 steps` -> `512, 682.66666667, 853.33333333, 1024`, because an interpolated value's own decimal count is 8). Switching to `precision` fixes BOTH: width/height/steps/seed stay whole, cfg keeps 1 decimal, denoise 2, and `cfg 1-2 x4` tidies to `1, 1.3, 1.7, 2`. Fallback (precision missing - old saved axis / non-ComfyUI widget) is the `max(stepDecimals, valueDecimals)` cap-8 logic. `axis.precision` is set in `selectChoice` and self-healed each render in `renderValueArea` (so a reloaded pre-fix axis still rounds right). `step2` (the real increment, captured as `axis.realStep`) drives a PER-AXIS **Snap** toggle (`axis.snap`, default ON): `resolveAxisValues(axis)` reads `axis.snap` and snaps each number value to the nearest multiple of `realStep`, then re-rounds to precision - so width/height land on clean multiples (512-1024 x4 -> 512, 688, 848, 1024) while cfg/steps/denoise are no-ops (their step equals the precision unit). PLACEMENT: the Snap toggle renders INSIDE the number axis's value area, in the free space beside the Range/List segment (a `.pix-xy-moderow`), so it adds NO node height; and it shows ONLY when snapping would actually change values (`realStep > 10^-precision`, i.e. width/height), not for cfg/steps. (A single global toggle in the options row was tried first but wrapped to a 4th line and added height - user-rejected; per-axis-in-the-value-area is the placement.) Snapping never changes the value COUNT (no dedupe), so `computeCounts`/`axisReady` stay consistent with the driver. The injection path (`index.js` `injectAxis`), Python (`node_xy_plot.py`), and the grid labels all consume the resolved list untouched, so `roundToStep` is the SINGLE precision authority for number axes. (Verified with a throwaway Node harness asserting int/float/range/fallback cases.)
10. **Per-axis reset (`resetAxis` in core.mjs) clears ONE axis in place; the global Reset still wipes everything.** `resetAxis(node, axisKey)` blanks just `state[axisKey]`, mutating the existing axis object IN PLACE (Pattern #8's aliasing rule), leaving the other axis + toggles + theme + any shown grid intact. It's exposed through the node's `handlers.resetAxis` (so node-fitting stays in `index.js`, like `handlers.reset`) and rendered as a small `↺ Reset` in each axis header, shown only once a setting is picked (nothing to reset on an empty axis). It calls `fitNode` (grow OR shrink) so the node tightens after a tall value area collapses - a user action, so it can't trip dirty-on-load (#18). Must be threaded into BOTH the live `makeHandlers` object AND the `_pixXyRenderOnly` handler object, or the button is inert right after a workflow load.
### Image-picker node patterns (do not regress)
Any Pixaroma node that picks an image from `input/` via ComfyUI's `image_upload: True` widget is an "image-picker node". Current members: **Load Image Pixaroma** (`js/load_image/`, `nodes/node_load_image.py`) and **Prompt Reader Pixaroma** (`js/prompt_reader/`, `nodes/node_prompt_reader.py`). The two share an upload-button + hidden-native-combo + custom-dropdown + (now) prev/next arrows surface. If you add a third such node (e.g. a future "Load Mask Pixaroma"), follow every rule below or you will regress hard-won fixes.
@@ -1658,7 +1100,22 @@ 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.
### Text Overlay Pixaroma Patterns (do not regress)
### 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.
1. **Dynamic prompts are an OPT-IN per-node switch (default OFF) - NOT ComfyUI's native `dynamicPrompts: True`.** The native flag was REMOVED from `nodes/node_text.py` (commit 2026-06-10). It ran on EVERY Text Pixaroma and stripped ALL curly braces at queue time (ComfyUI's frontend resolves `{a|b}` wildcards during `graphToPrompt`, eating the braces), which silently destroyed JSON prompts - reported as a "Find and Replace removes my braces" bug, but the braces were already gone UPSTREAM, before Find and Replace ever saw the text (Text Pixaroma -> Find and Replace flow). Empirically: input `cat {dog}m, [test]{}{{t}}` came out `cat dogm, [test]t` (every `{`/`}` deleted, `[test]` kept) - exactly ComfyUI's nested `{...|...}` resolver. The native flag was a ~3-week-old regression (added 2026-05-22, `feat(text): enable dynamic prompts`). Do NOT re-add `dynamicPrompts: True` to the `text` input - it can't be toggled per-instance (it's a per-node-TYPE frontend flag) and forces brace-eating on everyone.
2. **When the switch is ON, resolution happens in `js/text/dynamic_prompts.mjs::resolveDynamicPrompt`, applied in an `app.graphToPrompt` hook (bottom of `js/text/index.js`) ONLY for nodes whose switch is on.** The resolver mirrors the documented contract: `{a|b|c}` random pick (uniform), nesting `{a|{b|c}}` (loops the innermost `\{[^{}]*\}` until stable), `\{` `\}` escapes (protected with private-use placeholders built via `String.fromCharCode(0xE000/0xE001)` so the source stays pure ASCII - do NOT paste literal private-use chars, they get stripped to empty strings on write), and `//` + `/* */` comment stripping. A pipe-less `{x}` -> `x` and `{}` -> empty (matches native, so ON reproduces the pre-removal behavior exactly). The hook resolves `entry.inputs.text` per submit (fresh random each Run, matching native caching: a different resolved value = a re-run), gated `typeof v === "string"` (skips a WIRED text input). Subgraph-safe node lookup mirrors `js/find_replace/index.js` (composite `5:12` ids). The resolver is unit-verified (162 cases incl. the user's exact case + escaping + comments + 50 random draws each landing on a valid option) - if you change the resolver, re-verify with a node test against `dynamic_prompts.mjs`.
3. **Switch state lives on `node.properties.pixTextDynamicPrompts` (boolean, default OFF via `isDynamicOn` returning false when the key is absent).** Written ONLY on user click (`setDynamic`); `applyDynamicSwitch` (visual) and `isDynamicOn` (hook) only READ it - so an existing saved workflow gets NO new property on load and is never false-dirtied (Vue Compat #18). Reflected onto the DOM switch in BOTH `setupNode`'s microtask AND `onConfigure`'s microtask (belt-and-braces, whichever runs after `configure()` restores properties - Vue Compat #8). Default OFF means existing workflows now treat braces literally (un-regresses JSON); `{a|b}` users opt in once and it persists.
4. **Sizing: `DEFAULT_W = 412` (fresh-node width so the bottom row + `?` fit on one line), `MIN_W = 412` BUT the `node.size` min-clamp is GATED LEGACY-ONLY; `WIDGET_MIN_H = 120`; bottom bar `flex-wrap`; `installResizeFloor` for the Nodes 2.0 height floor.** `onResize` and `onDrawForeground` clamp `node.size` to MIN_W/MIN_H ONLY when `!isVueNodes()`. THIS IS MANDATORY (a review caught an earlier ungated version): in Nodes 2.0 the rendered size lives in the Vue layout store, NOT `node.size`, so clamping there desyncs them - the user drags smaller, Vue keeps the smaller render, but `node.size` snaps back to 412, so the node JUMPS bigger on a workflow switch + can false-dirty (the CLAUDE.md "Nodes 2.0 manual-resize MINIMUM" rule). Consequences by renderer: **LEGACY** floors at 412 (can't drag narrower; an existing legacy node saved <412 auto-widens to 412 on load = a one-time "save changes?", Vue Compat #18, accepted). **NODES 2.0** has NO `node.size` floor - the user CAN drag the node narrower and it PERSISTS (no jump); the bottom bar `flex-wrap`s the trailing items (the `?` drops to a second line gracefully) and `installResizeFloor(root, measureTextFloor)` pins a during-drag min-height so the button row can't spill out the bottom of the frame (uninstalled in `onRemoved`). `measureTextFloor` uses the textarea's CSS MIN (60px), NOT its grown height, so the floor lets the node shrink to where content just fits and no further. A fresh Nodes 2.0 node still opens at 412 (the synchronous setup write at `if (node.size[0] < MIN_W) node.size[0] = DEFAULT_W`), but an EXISTING Nodes 2.0 node keeps its saved width (may wrap the `?` if it was saved narrow - widen once to fix). 412 was MEASURED with the sizer snippet (Token-Saving Rules #6) on Nodes 2.0 (the tighter renderer: 400 wrapped, 410 fit). History/lesson: an earlier per-renderer `DEFAULT_W` vs `DEFAULT_W_VUE` split (branched on `isVueNodes()` while it was NOT imported - a latent crash for fresh nodes) was WRONG and removed; the original "Nodes 2.0 wraps the `?`" report was a STALE-CACHE render before a hard refresh, and once the real CSS loaded ~410 fit in BOTH renderers. ALWAYS hard-refresh + re-measure before "fixing" a Nodes 2.0 layout gap. The switch is disabled+dimmed when the text input is wired (it only affects TYPED text, which is ignored when wired).
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 `?` 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`.
@@ -1702,6 +1159,8 @@ These patterns came from the v2 simplification (single text, no effects, mirrore
20. **`_alignPending` round-trip mirrors `_autoCenterPending` (Pattern #16) for node-body canvas positioning when image dims aren't known yet.** When the user clicks a "Position on canvas" button on the node body, `alignOnCanvasFromBody` resolves the upstream image dims (upstream `imgs[0]`, else the stashed base-image PNG from Pattern #17, loaded + cached on `node._textOverlayBaseImageEl`) and applies the position INSTANTLY. If dims are unknown (generative chain never run), it records `state._alignPending = mode` (and clears `_autoCenterPending` — an explicit choice wins over default centering) + toasts "Position will apply on the next run". Resolution happens in two places, same as auto-center: (a) the `graphToPrompt` hook resolves `_alignPending` pre-submit when upstream IS available; (b) Python `node_text_overlay.py::build` resolves it from the real `image.shape` (an `if align_mode … elif state.get("_autoCenterPending")` block, align taking priority) and ships the x/y back via the SAME `pixaroma_text_overlay_autocentered` ui key; the `executed` handler in `index.js` persists x/y and clears BOTH pending flags. Keep the 6 mode branches in the Python block in sync with `applyAlignMode` in `index.js`.
21. **Vertical direction is a renderer branch gated by `state.direction` ("horizontal" default), mirrored in JS + Python + the math doc §5b.** `renderVerticalToCanvas` (`js/framework/text_render.mjs`) and `_render_vertical_layer` (`nodes/_text_render_helpers.py`) must stay in lockstep; `compute_text_bbox` has the matching vertical bbox branch (auto-center). In vertical mode Line Height = per-character vertical step (`charStep = round(fontSize*lineHeight)`), Letter Spacing = column gap, the align chips remap to top/middle/bottom of shorter columns, columns advance LEFT-to-RIGHT, and synthesized italic is skipped (upright). The field is ADDITIVE + optional — do NOT bump the Text Overlay schema `version` (would trip `ensureValidState` and wipe saved text); both engines read `direction ?? "horizontal"` so old graphs are byte-identical (no dirty-on-load). After ANY vertical-math change: update `docs/text-overlay-render.md` §5b, BOTH engines, then `python scripts/text_overlay_parity_check.py --regenerate` (cases 11-13; goldens are gitignored, regenerated locally). The Horizontal | Vertical toggle lives in the shared `js/framework/text_editor.mjs` panel (`ui.dirChips`, `.pix-to-dir-*` CSS), so Text Overlay + Composer text layers + Text Watermark all get it from the one renderer + panel. **ALL JS bbox measurement goes through the shared `measureTextDims(ctx, state)` in `text_render.mjs`** (caller sets `ctx.font`, helper does direction-aware w/h) - `js/text_overlay/index.js::measureTextBbox` (Position-on-canvas buttons + auto-center) and `core.mjs::_textBbox` (editor align toolbar, drag bbox, Fit W/H) both call it; do NOT re-inline horizontal-only math there (the original vertical bug: right/bottom align flung vertical text off-canvas because the measure said wide-short while the render was narrow-tall). Watermark's Python `_stamp` gates its synthesized-italic slant compensation on `direction != "vertical"` (vertical renders upright, no slant). Adding a row to the shared panel? Bump BOTH legacy `BASE_H` constants (`js/text_overlay/index.js` + `js/text_watermark/index.js`, currently 464) or the legacy panel clips (Pattern #8's maintenance cost).
### Offline-first: Vendored Three.js
The 3D Builder used to `import("https://esm.sh/three@0.170.0/…")` at runtime, which
broke with `ERR_CONNECTION_RESET` for any user running ComfyUI offline or behind a
@@ -1787,6 +1246,7 @@ Files are named by concern. Match the task to the file:
| Change Prompt Multi Pixaroma rows / queue-loop behavior / mode toggle / size / outputs | `js/prompt_multi/` (index.js for queuePrompt patch + extension wiring + graphToPrompt hook + DEFAULT sizing; core.mjs for state schema + MODE_QUEUE / MODE_LIST constants + setMode; render.mjs for DOM including the mode pill bar + hint text; interaction.mjs for events). Python in `nodes/node_prompt_multi.py`. State on `node.properties.promptMultiState` (`{version:2, mode:"queue"|"list", rows:[{id,enabled,label,text}], activeIndex}`) + `app.graphToPrompt` hook injecting `{version, mode, activePrompt, rowTexts}` into hidden `PromptMultiState` (rowTexts is the ENABLED non-empty rows only, in display order — so a downstream From List index 1 maps to the first enabled row). `app.queuePrompt` patch in index.js — on every Run, finds the first Prompt Multi node via `findFirstPromptMultiNode` (top-level pass first then subgraph recurse), CHECKS `pmNode.properties.promptMultiState.mode`: if `MODE_LIST` falls through to a single `_origQueuePrompt` call (no loop); else loops via `enabledRowsWithIndex` writing `state.activeIndex` (ABSOLUTE index, NOT enabled-filtered) before each `_origQueuePrompt(num, 1)`. 0 enabled non-empty → toast + bail without calling original (covers both all-OFF and all-empty cases). **Two outputs (text + prompts) ALWAYS visible** — do NOT try to dynamically swap slots based on mode (Prompt Multi Pattern #10 explains why the rename-and-remove approach is fundamentally broken). Mode pill at top of node body is two `.pix-pm-modepill` segments inside `.pix-pm-modebar`, orange when active; click the inactive one to switch. Labels `Queue Text` and `List Prompts` directly name the output to wire (Prompt Multi Pattern #11). Hint text under the pills repeats which output to wire. CSS prefix `.pix-pm-*` keeps it isolated from Prompt Stack's `.pix-ps-*`. Visual UX of rows mirrors Prompt Stack (drag handle, ON/OFF pill, label, textarea, ✕ delete with `pixConfirm`). Default fresh-node: 2 empty rows both ON in Queue mode, size `DEFAULT_W = 420` / `DEFAULT_H = 290` with `CHROME_ALLOWANCE = 70` in grow/fit calculations so the action button row stays inside the frame. PIXAROMA_PROMPT_LIST custom wire type is duplicated as a constant in both `nodes/node_prompt_multi.py` and `nodes/node_prompt_from_list.py` (deliberate to avoid a cross-file import chain — keep in sync). |
| Add / change Prompt From List Pixaroma | `nodes/node_prompt_from_list.py` (single Python file, ~80 lines, no JS at all — uses ComfyUI's native widget rendering deliberately to stay tiny). Input: `prompts` (PIXAROMA_PROMPT_LIST). Widget: `index` (INT, 1-based, default 1, max 9999). Output: `text` (STRING). Out-of-range returns `""` instead of raising (workflow with mistyped index still runs). `isinstance(prompts, list)` guard required for defensive type safety. Prompt Reader walker chases this node via `_pix_prompt_from_list_resolve` in `nodes/_prompt_reader_helpers.py` — reads the `index` widget from the saved prompt JSON, follows the `prompts` input link to its upstream node, confirms it's a `PixaromaPromptMulti`, and returns `rowTexts[index-1]` from the upstream's hidden state. If you ever add a NEW list-source node (not Multi), extend `_pix_prompt_from_list_resolve` with a class_type branch. |
| Change Prompt Pack Pixaroma textarea / pill / counter / queue-loop / output | `js/prompt_pack/` (index.js for queuePrompt patch + extension wiring + graphToPrompt hook + DEFAULT sizing; core.mjs for state schema + MODE_PARAGRAPH / MODE_LINE constants + `parsePrompts(text, mode)`; render.mjs for DOM including the mode pill bar + textarea + counter pill; interaction.mjs for events). Python in `nodes/node_prompt_pack.py`. State on `node.properties.promptPackState` (`{version:1, mode:"paragraph"|"line", text:"...", activePrompt:""}`) + `app.graphToPrompt` hook injecting `{version, activePrompt}` into hidden `PromptPackState` (Pattern #9). `app.queuePrompt` patch in index.js finds the first Prompt Pack node, parses text via `parsePrompts`, loops one `_origQueuePrompt(num, 1)` per non-empty prompt (mutates `activePrompt` before each call so graphToPrompt captures the right one), bails with toast when 0 prompts. Per-iteration enqueue errors logged + skipped (don't abort). Single `text` STRING output to match Prompt Multi / Prompt Stack / Show Text. Counter pill in the textarea's bottom-right corner shows `N prompts` idle / `i / N` (orange) during run / `0 prompts` (grey) empty. CSS prefix `.pix-pp-*` keeps it isolated from Prompt Stack's `.pix-ps-*` and Prompt Multi's `.pix-pm-*`. DOM widget MUST have `canvasOnly: true` (Vue Compat #15) or pill + textarea also render in the right-sidebar Parameters tab. Prompt Reader walker chases this node via `_pix_prompt_pack_extract` in `nodes/_prompt_reader_helpers.py` - mirrors Prompt Multi extract since both ship `{activePrompt}` in their hidden state. |
| 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` |
@@ -1814,7 +1274,7 @@ Files are named by concern. Match the task to the file:
| 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. |
| 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` (allows `subfolder/prefix` with `[A-Za-z0-9_-]` segments, no `..`). 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 — 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. |
| Notify Pixaroma — change widgets / Python contract | `nodes/node_notify.py` (single file, ~80 lines). `INPUT_TYPES` declares `any` (AnyType wire), `enabled` (BOOLEAN), `sound` (combo from `_list_sounds()`), `volume` (INT 0-100), `label` (STRING). `IS_CHANGED` returns `float("nan")` so the node always re-executes (notification fires on every Run, even when upstream is fully cached). `notify()` returns `{"ui": {"pixaroma_notify": [{sound, volume, label}]}}` when enabled, else `{"ui": {}}`. AnyType class overrides `__ne__` to bypass ComfyUI's strict type matching. Per-node `enabled=false` is symmetric silent on Python and JS (no print, no event). |
@@ -1869,21 +1329,16 @@ Defaults should reflect the node's CONTENT, not a generic minimum. A text-heavy
## Important Note
After major changes, please update this file (@CLUADE.me). Keep this file up-to-date with the project's status.
## Git Workflow (Ioan branch)
## Git Workflow
The user works on the `Ioan` branch. Two commit destinations:
Home is now **GitLab** (`gitlab.com/pixaroma/comfyui-pixaroma`); the GitHub remote is suspended, so "push" means GitLab. The user develops on a working branch (historically `Ioan`, integrated to `main` for releases). Confirm the current branch with `git branch --show-current` rather than assuming.
1. **Local commits** — after any non-trivial working change, create a local commit on `Ioan` as a checkpoint. This is the **default** — no confirmation needed, just do it. The user relies on these to roll back if something breaks (`git stash`, `git reset --hard HEAD~1`, or `git checkout <sha>`).
1. **Local commits (default, no confirmation needed):** after any working change, make a small local checkpoint commit. The user relies on these to roll back (`git stash`, `git reset --hard HEAD~1`, `git checkout <sha>`).
2. **Push (only when the user explicitly asks)** ("push to gitlab", "push it", "release it"). Never push proactively.
2. **Push to Ioan on GitHub** — only when the user **explicitly** says "push to Ioan", "push to github", "commit to Ioan github", or similar. Never push proactively.
**Pattern:** edit → verify it works → `git add -A && git commit -m "scope: description"` (local). Keep commits small and focused. Never amend a pushed commit; amend local-only WIP freely. If work breaks, roll back to the previous checkpoint.
**Pattern:**
- Make the edit → verify it parses / works → `git add -A && git commit -m "scope: description"` LOCAL
- Keep commits small and focused: one coherent change per commit
- Never amend a pushed commit; only amend local-only commits if still WIP
- If work breaks something, the user can roll back to the previous local checkpoint
**Do not** push to origin unless asked. **Do** commit locally after every working change.
**Release** is an explicit user trigger ("release it" / "publish new version"): bump the `pyproject.toml` version, merge the working branch to `main`, push to GitLab → CI publishes to the Comfy Registry. Add a README changelog entry (one entry per day, plain language).
## Publishing
CI/CD auto-publishes to the ComfyUI registry when `pyproject.toml` is pushed to `main`. Do not modify `pyproject.toml`, `LICENSE`, or `.clauderules` or `.github/workflows/publish.yml`.
The plugin auto-publishes to the ComfyUI Registry via **GitLab CI** (`.gitlab-ci.yml` + the `REGISTRY_ACCESS_TOKEN` CI variable) when a version bump lands on GitLab `main`. (The legacy `.github/workflows/publish.yml` is inactive; GitHub is suspended.) During normal work do NOT modify `pyproject.toml`, `LICENSE`, `.clauderules`, `.gitlab-ci.yml`, or `.github/workflows/publish.yml`. The ONE exception is an explicit release, where bumping the `pyproject.toml` version is exactly how a release is cut.
+48 -6
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="https://raw.githubusercontent.com/pixaroma/ComfyUI-Pixaroma/main/assets/pixaroma_logo.svg" width="120" alt="Pixaroma Logo">
<img src="assets/pixaroma_logo.svg" width="120" alt="Pixaroma Logo">
<h1>ComfyUI Pixaroma</h1>
<p align="center">
<strong>Useful ComfyUI nodes for everyday workflows.</strong><br />
@@ -7,14 +7,23 @@
</p>
<p align="center">
<a href="https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/LICENSE"><img src="https://img.shields.io/github/license/pixaroma/ComfyUI-Pixaroma?style=flat-square&color=blue" alt="License"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License"></a>
<a href="https://discord.gg/gggpkVgBf3"><img src="https://img.shields.io/badge/discord-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.youtube.com/@pixaroma"><img src="https://img.shields.io/badge/youtube-red?style=flat-square&logo=youtube" alt="YouTube"></a>
</p>
<p align="center">
<a href="#-getting-started">📥 Install</a> &nbsp;·&nbsp;
<a href="#-creative-suite">🎨 Nodes</a> &nbsp;·&nbsp;
<a href="#-learning-resources">📺 Tutorials</a> &nbsp;·&nbsp;
<a href="#-changelog">🛠 Changelog</a> &nbsp;·&nbsp;
<a href="https://discord.gg/gggpkVgBf3">💬 Discord</a>
</p>
</div>
---
> 💬 **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
Pixaroma turns ComfyUI into a powerful, easy-to-use design space. It brings professional editing right into your workflow!
@@ -183,9 +192,11 @@ If you use [ComfyUI Easy Install](https://github.com/Tavris1/ComfyUI-Easy-Instal
#### **Method C: Manual Installation**
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/pixaroma/ComfyUI-Pixaroma.git
git clone https://gitlab.com/pixaroma/comfyui-pixaroma.git
```
> **⚠️ Node looks blank or broken right after updating?** This is almost always your browser cache, not a bug. Hard-refresh with **Ctrl + Shift + R** (Windows / Linux) or **Cmd + Shift + R** (Mac). If a node is still broken after that, see [Read this first](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues/2).
### 2. Optional: AI Background Removal
AI Remove Background is used in three places in Pixaroma: the **Remove Background Pixaroma** node, the **Image Composer** editor's AI Background Removal button, and the **Paint Pixaroma** editor's AI Background Removal button.
@@ -254,6 +265,31 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
> 💡 **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: 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.
- **Finished the GitLab move.** The bundled sample workflow's buttons now point to GitLab too - they were the last spot still linking to the old GitHub home. The nodes themselves are unchanged.
- **Quicker fix for "a node looks broken after updating".** The README now flags up front that this is almost always a browser cache, with the 30-second hard-refresh fix, so it is faster to sort out.
### **June 8, 2026 · v1.3.79-1.3.81**
- **XY Plot: whole-number settings stay whole.** Width, height and steps now come out as round numbers - a range from 512 to 1024 gives proper values instead of odd decimals (no more 682.66).
- **XY Plot: new Snap toggle for sizes.** Each number axis gets a Snap toggle (next to Range/List) that rounds width/height to clean step multiples, so a range gives tidy sizes like 512, 688, 848, 1024. It's on by default; switch it off for exact values.
- **XY Plot: added a Help button.** Click the **?** at the top of the node (next to Reset X) for a panel that explains what the node does, how to use it, how the value box works, and what every option means - the same kind of in-node help that Find and Replace has.
- **XY Plot: decimal values stick now.** Typing a value like 7.1 (or 0.05) for a setting such as cfg or denoise now keeps the decimal instead of rounding it off to a whole number. Works for both the comma list and the Start/End/Steps range.
- **XY Plot: reset each axis on its own.** Each axis now has its own button (Reset X and Reset Y) so you can clear just one without touching the other. The button at the bottom (Reset XY) still clears everything at once.
- **XY Plot: buttons no longer spill out when the node is narrow.** In the new node interface, making the node smaller now lets the Lock seed / Draw labels / Save cells row wrap to fit inside the node instead of hanging off the edges.
### **June 7, 2026 · v1.3.78**
- **Project moved to GitLab.** The repository and the in-app and README links (the Help footers and the background-removal help link) now point to the new home at gitlab.com/pixaroma/comfyui-pixaroma. The nodes themselves are unchanged.
### **June 3, 2026 · v1.3.73–1.3.77**
- **NEW: a Help button on Find and Replace.** Click the **?** in the top corner of the node to open a clear panel that explains what the node does, how to use it, what each switch means, and a regex cheat-sheet with worked examples - so you can learn it without leaving ComfyUI. (This is the first node to get it; more will follow.)
- **Find and Replace: a tricky pattern can no longer freeze things.** If you turn on Regex and type a pattern that could lock up the browser or the run, the node now spots it, skips that one rule, and shows a short warning instead of hanging - and the live preview stays smooth while you type. Everyday patterns are unaffected.
@@ -326,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.
@@ -452,7 +494,7 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
> [!NOTE]
> This suite was developed with significant AI assistance. While thoroughly tested, we welcome bug reports and feedback from the community!
💡 **Have an idea for a new node or improvement?** Post it in [Discussions](https://github.com/pixaroma/ComfyUI-Pixaroma/discussions).
🐞 **Found a bug or something broken?** Open an [Issue](https://github.com/pixaroma/ComfyUI-Pixaroma/issues).
💡 **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](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/LICENSE)**
⚖️ **Licensed under [MIT](LICENSE)**
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38.919 62.785"><path d="M18.893,62.785c-3.92,0-7.096-3.177-7.096-7.096s3.177-7.096,7.096-7.096,7.096,3.177,7.096,7.096-3.177,7.096-7.096,7.096ZM37.249,22.577c-4.623,10.141-12.973,9.056-13.259,20.596h-11.173c.105-14.638,6.563-14.584,11.257-21.06,8.317-11.504-11.153-17.145-11.299-4.66H0C.409-10.7,48.359-1.712,37.25,22.577h-.001Z"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+37
View File
@@ -85,6 +85,42 @@ Python:
4. Rotate: `layer_img.rotate(-rotation, expand=True, resample=BICUBIC)` (compensate paste origin for `expand=True` growth)
5. `base_img.alpha_composite(layer_img, dest=(paste_x, paste_y))`
## 5b. Vertical Direction
When `layer.direction == "vertical"` (default `"horizontal"`), the §2-§4 horizontal path is replaced by:
- `lines = text.split("\n")` — each line is a COLUMN, placed left-to-right (line 0 leftmost).
- Characters stack top-to-bottom, upright, iterated by code point.
- `charStep = round(fontSize * lineHeight)` is the vertical advance per character (Line Height remaps to character spacing).
- `colGap = letterSpacing` is the horizontal gap between columns (Letter Spacing remaps to column gap; it does NOT feed per-character measurement here).
- `ascender`, `descender` are the `"Mg"` ink metric, same as horizontal.
- Per column j: `colWidth_j = max(0, glyphWidth(each char))`, `numChars_j = count`.
```
maxChars = max(numChars_j)
contentH = ascender + descender + max(0, maxChars - 1) * charStep
totalColW = sum(colWidth_j) + max(0, numCols - 1) * colGap
bbox_width = max(1, ceil(totalColW + 2*padX))
bbox_height = max(1, ceil(contentH + 2*padY))
```
Draw (bg pill fills the whole bbox first, exactly as §3):
```
colOriginX = padX
for column j:
colContentH = ascender + descender + max(0, numChars_j - 1) * charStep
vOffset = align=="center" ? (contentH - colContentH)/2
: align=="right" ? (contentH - colContentH)
: 0 # align=="left" -> top
for i, ch in column:
cx = colOriginX + (colWidth_j - glyphWidth(ch))/2 # center in column
ly = padY + vOffset + ascender + i*charStep # shared baseline grid
drawChar(ch, cx, ly)
colOriginX += colWidth_j + colGap
```
Align chips keep the left/center/right icons; their EFFECT becomes top/middle/bottom of shorter columns. Position (x/y), rotation, opacity, and the bg pill act on the finished bitmap unchanged.
## 6. Color Format
All `color` and `bgColor` fields are 6-char hex strings (`"#RRGGBB"`). `bgColor` is `null` (or empty string) when no pill.
@@ -98,3 +134,4 @@ All `color` and `bgColor` fields are 6-char hex strings (`"#RRGGBB"`). `bgColor`
- Synthesized italic skew differs slightly between canvas (matrix transform) and PIL (Image transform). Acceptable.
- Sub-pixel positioning rounded to nearest integer pixel in both renderers.
- Variable font interpolation: browser's variable-font rasterizer and PIL's `set_variation_by_axes` use different interpolation paths.
- Vertical direction skips synthesized italic (characters render upright, no shear). Real-italic font files still render their italic glyphs upright-stacked.
+314 -61
View File
@@ -1,16 +1,49 @@
import { app } from "/scripts/app.js";
import { BRAND } from "../shared/index.mjs";
import { BRAND, registerNodeHelp } from "../shared/index.mjs";
import { applyAdaptiveCanvasOnly, isVueNodes } from "../shared/nodes2.mjs";
// Help shown by the selection-toolbar Help button (js/help_toolbar) when an
// Image Compare node is selected. Registered at module load.
const COMPARE_HELP = {
title: "Image Compare Pixaroma",
tagline: "See the difference between two images at a glance.",
sections: [
{
heading: "Setup",
body: "Wire any two IMAGE outputs into `image1` and `image2` (before / after upscaling, original / inpainted, two checkpoints, ...) and run the workflow once. Each image's size is shown on the node with a `1` / `2` badge - the sizes turn orange when the two differ.",
},
{
heading: "View modes",
defs: [
["Show 1 / Show 2", "View one image full-size. Click to switch between the two."],
["Left Right / Right Left", "Side-by-side split - hover the image to wipe across. Right Left swaps which side each image is on."],
["Up Down", "Top / bottom split - hover to wipe up and down."],
["Overlay", "Stack both images and drag the slider to fade between them."],
["Difference", "Highlight only the pixels that differ between the two."],
],
},
{
heading: "Save buttons (in Show 1 / Show 2)",
defs: [
["Save D", "Save the shown image to disk - opens a dialog so you pick where."],
["Save O", "Save the shown image to the ComfyUI output folder."],
["Copy", "Copy the shown image to the clipboard."],
],
},
],
footer: "Set the default view mode in Settings -> Pixaroma -> Image Compare.",
};
registerNodeHelp("PixaromaCompare", COMPARE_HELP);
// Buttons: Show 1 | Left Right | Up Down | Overlay | Difference
// "Show 1" toggles: Show 1 → Show 2 → back to compare (deselects)
const MODES = ["Left Right", "Right Left", "Up Down", "Overlay", "Difference"];
const SLIDER_PAD = 50; // "Opacity" label width
const MODE_HINTS = [
"↔ Hover image to slide left / right",
"↔ Hover image to slide right / left",
"↕ Hover image to slide up / down",
"↔ Hover to slide left / right",
"↔ Hover to slide right / left",
"↕ Hover to slide up / down",
"",
"Shows pixel differences between images",
"Shows pixel differences",
];
// Layout constants
const BTN_GAP = 3;
@@ -54,36 +87,67 @@ function modeRect(W, i) {
const L = rowLayout(W);
return { x: L.leftPad + (i + 1) * (L.bw + L.gap), y: ROW1_Y, w: L.bw, h: BTN_H };
}
function hintRect(W) {
// ── Image-size labels (image1 / image2 resolution) ───────────
// Shown flanking row 2: image1's size pinned to the LEFT edge, image2's to the
// RIGHT edge, in EVERY mode. The mode content (hint / slider / util buttons)
// lives in the MIDDLE zone (row2Mid) between them, shrinking to make room.
// Labelled "1:" / "2:" so it's always clear which input is which (on the legacy
// node the left of row 2 sits next to the image2 dot, so position alone would be
// ambiguous). When the two sizes differ they turn BRAND orange — a quick
// "you're comparing different resolutions" cue. Size = loaded naturalWidth/Height.
const SIZE_FONT = "12px 'Segoe UI',sans-serif"; // bigger size text (was 9px)
const BADGE_R = 8; // orange number-badge radius
const BADGE_FONT = "10px 'Segoe UI',sans-serif";
const SIZE_RESERVE = 86; // px reserved on each flank (badge + bigger dims)
const SIZE_GAP = 6; // gap between a size label and the middle content
function dimsText(img) {
return img ? `${img.naturalWidth}×${img.naturalHeight}` : "";
}
function sizesDiffer(node) {
const a = node?._cmpImg1, b = node?._cmpImg2;
if (!a || !b) return false;
return a.naturalWidth !== b.naturalWidth || a.naturalHeight !== b.naturalHeight;
}
// Full row-2 span (matches row 1's right edge) — the old hintRect.
function row2Full(W) {
const L = rowLayout(W);
return { x: L.leftPad, y: ROW2_Y, w: L.bw * 6 + L.gap * 5, h: BTN_H };
}
// MIDDLE zone of row 2: the full span minus a reserved flank for each size label
// that's currently showing. Pure function of (node images, W) so paint and
// hit-test always agree without stashing geometry during a paint.
function row2Mid(node, W) {
const full = row2Full(W);
const resL = node?._cmpImg1 ? SIZE_RESERVE + SIZE_GAP : 0;
const resR = node?._cmpImg2 ? SIZE_RESERVE + SIZE_GAP : 0;
const x = full.x + resL;
const w = Math.max(40, full.w - resL - resR);
return { x, y: ROW2_Y, w, h: BTN_H };
}
// In Show 1 / Show 2 the three utility buttons (Save -> output, Disk -> file
// dialog, Copy -> clipboard) fill the FULL width of row 2 - each spans two of
// row 1's six columns, so they align to the grid with no dead gap. They act on
// the CURRENTLY SHOWN image. Hidden in comparison modes, where row 2 is the
// mode hint / opacity slider.
function utilBtnRects(W) {
const L = rowLayout(W);
const rightEdge = L.leftPad + L.bw * 6 + L.gap * 5; // matches row 1's right edge
const bw = Math.floor((rightEdge - L.leftPad - L.gap * 2) / 3);
return [0, 1, 2].map((i) => ({ x: L.leftPad + i * (bw + L.gap), y: ROW2_Y, w: bw, h: BTN_H }));
// dialog, Copy -> clipboard) fill the MIDDLE zone (between the size flanks).
// They act on the CURRENTLY SHOWN image. Hidden in comparison modes, where the
// middle is the mode hint / opacity slider instead.
function utilBtnRects(node, W) {
const mid = row2Mid(node, W);
const bw = Math.floor((mid.w - BTN_GAP * 2) / 3);
return [0, 1, 2].map((i) => ({ x: mid.x + i * (bw + BTN_GAP), y: ROW2_Y, w: bw, h: BTN_H }));
}
// Left -> right: Save to disk · Save to output · Copy image.
function diskRect(W) { return utilBtnRects(W)[0]; }
function saveRect(W) { return utilBtnRects(W)[1]; }
function copyRect(W) { return utilBtnRects(W)[2]; }
// Opacity-slider track geometry, derived PURELY from the body width. The
// hit-test (cmpDown/cmpMove) computes this on demand instead of reading a value
// stashed during the last paint — important in Nodes 2.0 where the canvas only
// repaints on demand (a tap with no preceding move could otherwise see a stale/
// null geometry). Legacy repainted every frame so it never hit this, but
// deriving it keeps both renderers correct.
function sliderGeo(W) {
const r2 = hintRect(W);
const trackX = r2.x + SLIDER_PAD;
const trackW = r2.w - SLIDER_PAD - 36;
const trackY = r2.y + r2.h / 2 - 3;
function diskRect(node, W) { return utilBtnRects(node, W)[0]; }
function saveRect(node, W) { return utilBtnRects(node, W)[1]; }
function copyRect(node, W) { return utilBtnRects(node, W)[2]; }
// Opacity-slider track geometry, derived from the MIDDLE zone so the size labels
// on the flanks always have room. The hit-test (cmpDown/cmpMove) computes this
// on demand instead of reading a value stashed during the last paint — important
// in Nodes 2.0 where the canvas only repaints on demand (a tap with no preceding
// move could otherwise see a stale/null geometry). Legacy repainted every frame
// so it never hit this, but deriving it keeps both renderers correct.
function sliderGeo(node, W) {
const mid = row2Mid(node, W);
const trackX = mid.x + SLIDER_PAD;
const trackW = Math.max(20, mid.w - SLIDER_PAD - 36);
const trackY = mid.y + mid.h / 2 - 3;
const trackH = 6;
return { trackX, trackW, trackY, trackH };
}
@@ -98,9 +162,9 @@ function cmpCursor(node, lx, ly, W, H) {
if (inside(p, showRect(W))) return "pointer";
for (let i = 0; i < 5; i++) if (inside(p, modeRect(W, i))) return "pointer";
if (node._cmpShowWhich !== 0 &&
(inside(p, saveRect(W)) || inside(p, diskRect(W)) || inside(p, copyRect(W)))) return "pointer";
(inside(p, saveRect(node, W)) || inside(p, diskRect(node, W)) || inside(p, copyRect(node, W)))) return "pointer";
if (node._cmpShowWhich === 0 && node._cmpMode === 3) {
const g = sliderGeo(W);
const g = sliderGeo(node, W);
if (lx >= g.trackX - 8 && lx <= g.trackX + g.trackW + 8 &&
ly >= g.trackY - 6 && ly <= g.trackY + g.trackH + 6) return "pointer";
}
@@ -116,6 +180,80 @@ function inside(pos, r) {
pos[0] >= r.x && pos[0] <= r.x + r.w && pos[1] >= r.y && pos[1] <= r.y + r.h
);
}
// ── Canvas tooltips (CLAUDE.md convention #8) ────────────────
// The buttons are painted on a canvas, so they can't use the native `title`
// attribute. We show a single shared <div> styled to match the Windows native
// tooltip (same look as the DOM-button tooltips elsewhere in Pixaroma), driven
// by the move handlers in BOTH renderers (each has clientX/clientY) and hidden
// on leave. A ~450ms delay + hide-on-new-control mimics the native behaviour.
const MODE_TIPS = [
"Split view. Hover the image to wipe between the two, left to right.",
"Split view with the sides swapped. Hover to wipe right to left.",
"Split view. Hover the image to wipe top to bottom.",
"Overlay the two images. Drag the slider to fade between them.",
"Show only the pixels that differ between the two images.",
];
function cmpTooltipFor(node, lx, ly, W) {
const p = [lx, ly];
if (inside(p, showRect(W)))
return "Show one image full-size. Click to switch between image 1 and 2.";
for (let i = 0; i < 5; i++) if (inside(p, modeRect(W, i))) return MODE_TIPS[i];
if (node._cmpShowWhich !== 0) {
const n = node._cmpShowWhich;
if (inside(p, diskRect(node, W))) return `Save image ${n} to your disk (choose where).`;
if (inside(p, saveRect(node, W))) return `Save image ${n} to the ComfyUI output folder.`;
if (inside(p, copyRect(node, W))) return `Copy image ${n} to the clipboard.`;
}
return "";
}
let _cmpTipEl = null, _cmpTipTimer = null, _cmpTipText = null, _cmpTipX = 0, _cmpTipY = 0;
function cmpTipDiv() {
if (_cmpTipEl) return _cmpTipEl;
const d = document.createElement("div");
// No max-width: with white-space:nowrap the box must grow to fit the full
// one-line text, or a long tooltip overflows the white background and the tail
// ("…right" -> "rig") renders as black text on the dark node. cmpPositionTip
// clamps it to the viewport so a wide box can't run off-screen.
d.style.cssText =
"position:fixed;z-index:99999;pointer-events:none;display:none;white-space:nowrap;" +
"background:#ffffff;color:#000000;border:1px solid #767676;" +
"border-radius:0;padding:3px 7px;font:12px 'Segoe UI',sans-serif;" +
"box-shadow:0 2px 4px rgba(0,0,0,0.15);";
document.body.appendChild(d);
_cmpTipEl = d;
return d;
}
function cmpPositionTip(x, y) {
const d = _cmpTipEl;
if (!d || d.style.display !== "block") return;
let nx = x + 14, ny = y + 18;
const r = d.getBoundingClientRect();
if (nx + r.width > window.innerWidth - 4) nx = window.innerWidth - r.width - 4;
if (ny + r.height > window.innerHeight - 4) ny = y - r.height - 8;
d.style.left = `${Math.max(4, nx)}px`;
d.style.top = `${Math.max(4, ny)}px`;
}
function cmpShowTooltip(text, x, y) {
if (!text) { cmpHideTooltip(); return; }
_cmpTipX = x; _cmpTipY = y;
if (text === _cmpTipText) return; // same control — leave the (pending/shown) tip as-is
_cmpTipText = text;
clearTimeout(_cmpTipTimer);
if (_cmpTipEl) _cmpTipEl.style.display = "none"; // hide the previous control's tip immediately
_cmpTipTimer = setTimeout(() => {
const d = cmpTipDiv();
d.textContent = text;
d.style.display = "block";
cmpPositionTip(_cmpTipX, _cmpTipY);
}, 450);
}
function cmpHideTooltip() {
clearTimeout(_cmpTipTimer);
_cmpTipText = null;
if (_cmpTipEl) _cmpTipEl.style.display = "none";
}
function paintBtn(ctx, r, label, on, hovered) {
ctx.fillStyle = on ? BRAND : "#2a2c2e";
// Hover on a non-active bordered control: border -> BRAND + text brightens,
@@ -151,13 +289,94 @@ function paintUtilBtn(ctx, r, label, hover, flash) {
ctx.fill();
ctx.stroke();
ctx.fillStyle = flash ? "#fff" : (hover ? "#ddd" : "#999");
ctx.font = "9px 'Segoe UI',sans-serif";
// Auto-fit the label: row 2's middle zone is narrower now (size labels flank
// it), so shrink the font (down to 7px) until the full label fits. This is the
// "make the buttons smaller" behaviour — labels stay complete, just compact.
let fs = 9;
ctx.font = `${fs}px 'Segoe UI',sans-serif`;
while (fs > 7 && ctx.measureText(label).width > r.w - 8) {
fs -= 0.5;
ctx.font = `${fs}px 'Segoe UI',sans-serif`;
}
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, r.x + r.w / 2, r.y + r.h / 2);
ctx.restore();
}
// Draw text vertically centered on `yMid` by its ACTUAL glyph box. Canvas
// textBaseline:"middle" centers the EM box instead, which makes digit-only text
// (the size labels) sit slightly HIGH — the em box reserves descender space the
// digits don't use. Centering the visible glyphs lines the sizes up with the
// buttons. Falls back to "middle" if the metrics aren't available.
function fillTextVCenter(ctx, text, x, yMid) {
const m = ctx.measureText(text);
if (m && m.actualBoundingBoxAscent != null && m.actualBoundingBoxDescent != null) {
ctx.textBaseline = "alphabetic";
ctx.fillText(text, x, yMid + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2);
} else {
ctx.textBaseline = "middle";
ctx.fillText(text, x, yMid);
}
}
// A small filled BRAND-orange circle with a white number — the image1 / image2
// marker. Always orange (it's an identity badge, not a state badge); the SIZE
// text next to it carries the match/mismatch colour.
function drawSizeBadge(ctx, cx, cy, n) {
ctx.save();
ctx.fillStyle = BRAND;
ctx.beginPath();
ctx.arc(cx, cy, BADGE_R, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = BADGE_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(n), cx, cy + 0.5);
ctx.restore();
}
// Draw the image-size labels on the flanks of row 2: an orange number badge on
// the OUTER corner + the WxH dimensions. image1 left, image2 right. The
// dimensions turn orange when the two differ, gray when they match (the badge
// stays orange either way). Each flank is clipped so a huge dimension can't run
// into the middle content. Called in every mode from paintCompare.
function drawSizeLabels(ctx, node, W) {
const has1 = !!node._cmpImg1, has2 = !!node._cmpImg2;
if (!has1 && !has2) return;
const full = row2Full(W);
const rightEdge = full.x + full.w;
const yMid = ROW2_Y + BTN_H / 2;
const sizeCol = sizesDiffer(node) ? BRAND : "#cfcfcf";
if (has1) {
ctx.save();
ctx.beginPath();
ctx.rect(full.x, ROW2_Y, SIZE_RESERVE, BTN_H);
ctx.clip();
const cx = full.x + BADGE_R;
drawSizeBadge(ctx, cx, yMid, 1);
ctx.font = SIZE_FONT;
ctx.fillStyle = sizeCol;
ctx.textAlign = "left";
fillTextVCenter(ctx, dimsText(node._cmpImg1), cx + BADGE_R + 5, yMid);
ctx.restore();
}
if (has2) {
ctx.save();
ctx.beginPath();
ctx.rect(rightEdge - SIZE_RESERVE, ROW2_Y, SIZE_RESERVE, BTN_H);
ctx.clip();
const cx = rightEdge - BADGE_R;
drawSizeBadge(ctx, cx, yMid, 2);
ctx.font = SIZE_FONT;
ctx.fillStyle = sizeCol;
ctx.textAlign = "right";
fillTextVCenter(ctx, dimsText(node._cmpImg2), cx - BADGE_R - 5, yMid);
ctx.restore();
}
}
// Setting ID and option list
const SETTING_DEFAULT_MODE = "Pixaroma.Compare.DefaultMode";
const DEFAULT_MODE_OPTIONS = [
@@ -404,10 +623,10 @@ function paintCompare(ctx, node, W, H, mouse) {
// ── Row 2: opacity slider or hint text (same height) ──
ctx.save();
const r2 = hintRect(W);
const mid = row2Mid(node, W);
if (node._cmpShowWhich === 0 && node._cmpMode === 3) {
// Slider track (geometry from sliderGeo(W) so paint + hit-test always agree)
const { trackX, trackW, trackY, trackH } = sliderGeo(W);
// Slider track (geometry from sliderGeo so paint + hit-test always agree)
const { trackX, trackW, trackY, trackH } = sliderGeo(node, W);
const pct = node._cmpOpacity;
const thumbX = trackX + trackW * pct;
@@ -416,7 +635,7 @@ function paintCompare(ctx, node, W, H, mouse) {
ctx.fillStyle = "#999";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("Opacity", r2.x, r2.y + r2.h / 2);
ctx.fillText("Opacity", mid.x, mid.y + mid.h / 2);
// Track bg
ctx.fillStyle = "#2a2c2e";
@@ -436,11 +655,11 @@ function paintCompare(ctx, node, W, H, mouse) {
// Thumb
ctx.fillStyle = BRAND;
ctx.beginPath();
ctx.arc(thumbX, r2.y + r2.h / 2, 6, 0, Math.PI * 2);
ctx.arc(thumbX, mid.y + mid.h / 2, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(thumbX, r2.y + r2.h / 2, 2.5, 0, Math.PI * 2);
ctx.arc(thumbX, mid.y + mid.h / 2, 2.5, 0, Math.PI * 2);
ctx.fill();
// Value
@@ -450,34 +669,50 @@ function paintCompare(ctx, node, W, H, mouse) {
ctx.fillText(
`${Math.round(pct * 100)}%`,
trackX + trackW + 6,
r2.y + r2.h / 2,
mid.y + mid.h / 2,
);
} else if (node._cmpShowWhich === 0) {
// Compare mode: row 2 shows the mode hint. (In Show 1 / Show 2 the
// Save/Disk/Copy buttons fill row 2 instead, so no hint is drawn there.)
// Compare mode: the mode hint, CENTERED in the middle zone between the size
// flanks. Auto-shrinks (down to 7.5px) + clipped so a long hint can't run
// into the sizes now that the flanks are wider.
const hint = MODE_HINTS[node._cmpMode] || "";
ctx.save();
ctx.beginPath();
ctx.rect(mid.x, ROW2_Y, mid.w, BTN_H);
ctx.clip();
let hfs = 9;
ctx.font = `${hfs}px 'Segoe UI',sans-serif`;
while (hfs > 7.5 && ctx.measureText(hint).width > mid.w - 6) {
hfs -= 0.5;
ctx.font = `${hfs}px 'Segoe UI',sans-serif`;
}
ctx.fillStyle = "#999";
ctx.font = "9px 'Segoe UI',sans-serif";
ctx.textAlign = "left";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(MODE_HINTS[node._cmpMode] || "", r2.x, r2.y + r2.h / 2);
ctx.fillText(hint, mid.x + mid.w / 2, mid.y + mid.h / 2);
ctx.restore();
}
ctx.restore();
// Size labels flank row 2 in EVERY mode — image1 size on the left, image2 on
// the right. Painted last so they sit cleanly in the reserved flanks.
drawSizeLabels(ctx, node, W);
// ── Utility buttons (Show 1 / Show 2 only): Save · Disk · Copy ──
// Fill the full width of row 2 (utilBtnRects), each spanning two of row 1's
// columns. They act on the CURRENTLY SHOWN image (Save N -> output, Disk N ->
// file dialog, Copy N -> clipboard). Same height as row 1 so the node never
// changes height when toggling Show 1/2. Hidden in comparison modes, where
// row 2 is the mode hint / opacity slider instead.
// Fill the MIDDLE zone of row 2 (between the size flanks). They act on the
// CURRENTLY SHOWN image (Save N -> output, Disk N -> file dialog, Copy N ->
// clipboard). Same height as row 1 so the node never changes height when
// toggling Show 1/2. Hidden in comparison modes, where the middle zone is the
// mode hint / opacity slider instead.
if (node._cmpShowWhich !== 0) {
const which = node._cmpShowWhich;
const fk = node._cmpFlashKey;
const ft = node._cmpFlashText;
const dR = diskRect(W), sR = saveRect(W), cR = copyRect(W);
paintUtilBtn(ctx, dR, fk === "disk" ? ft : `Save to disk ${which}`, hov(dR), fk === "disk");
paintUtilBtn(ctx, sR, fk === "save" ? ft : `Save to output ${which}`, hov(sR), fk === "save");
paintUtilBtn(ctx, cR, fk === "copy" ? ft : `Copy image ${which}`, hov(cR), fk === "copy");
const dR = diskRect(node, W), sR = saveRect(node, W), cR = copyRect(node, W);
paintUtilBtn(ctx, dR, fk === "disk" ? ft : `Save D${which}`, hov(dR), fk === "disk");
paintUtilBtn(ctx, sR, fk === "save" ? ft : `Save O${which}`, hov(sR), fk === "save");
paintUtilBtn(ctx, cR, fk === "copy" ? ft : `Copy ${which}`, hov(cR), fk === "copy");
}
// ── Image area ──
@@ -613,9 +848,9 @@ function cmpDown(node, lx, ly, W, H) {
// Save / Disk / Copy (only visible in Show 1/2). Checked first; their rects
// don't overlap the toggle/mode buttons so this is just belt-and-braces.
if (node._cmpShowWhich !== 0) {
if (inside(pos, diskRect(W))) { saveShownToDisk(node); return true; }
if (inside(pos, saveRect(W))) { saveShownToOutput(node); return true; }
if (inside(pos, copyRect(W))) { copyShownImage(node); return true; }
if (inside(pos, diskRect(node, W))) { saveShownToDisk(node); return true; }
if (inside(pos, saveRect(node, W))) { saveShownToOutput(node); return true; }
if (inside(pos, copyRect(node, W))) { copyShownImage(node); return true; }
}
// Show toggle: toggles between Show 1 and Show 2
if (inside(pos, showRect(W))) {
@@ -634,7 +869,7 @@ function cmpDown(node, lx, ly, W, H) {
// Opacity slider drag start (geometry derived from W — no dependency on a
// prior paint having stashed it)
if (node._cmpMode === 3) {
const g = sliderGeo(W);
const g = sliderGeo(node, W);
const hx = g.trackX, hw = g.trackW, hy = g.trackY - 6, hh = g.trackH + 12;
if (lx >= hx - 8 && lx <= hx + hw + 8 && ly >= hy && ly <= hy + hh) {
node._cmpOpacity = Math.max(0, Math.min(1, (lx - hx) / hw));
@@ -649,7 +884,7 @@ function cmpMove(node, lx, ly, W, H) {
// Slider drag (works while the pointer is down; mode is always 3 while a
// slider drag is in progress)
if (node._cmpDragging && node._cmpMode === 3) {
const g = sliderGeo(W);
const g = sliderGeo(node, W);
node._cmpOpacity = Math.max(0, Math.min(1, (lx - g.trackX) / g.trackW));
return true;
}
@@ -748,6 +983,14 @@ function createCompareDOMWidget(node) {
if (longCss * s > BACKING_CAP) s = BACKING_CAP / longCss;
return s;
};
// In Nodes 2.0 the title + input slots are drawn by Vue ABOVE this canvas, so
// the canvas's top padding (the ROW1_Y space the buttons need under the title
// bar in the legacy renderer) is just wasted gap below the slots. Shift the
// whole drawing up by TOP_TRIM and pass a TOP_TRIM-taller logical height so the
// image still fills to the bottom. localPos() + H() add TOP_TRIM back so the
// hit-tests + hover stay aligned with what's painted. Legacy is untouched (this
// code path only runs in Nodes 2.0).
const TOP_TRIM = 8;
const render = () => {
const cssW = root.clientWidth;
const cssH = root.clientHeight;
@@ -760,9 +1003,10 @@ function createCompareDOMWidget(node) {
const ctx = canvas.getContext("2d");
ctx.setTransform(s, 0, 0, s, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
ctx.translate(0, -TOP_TRIM);
node._cmpDomW = cssW;
node._cmpDomH = cssH;
paintCompare(ctx, node, cssW, cssH, node._cmpDomMouse || null);
paintCompare(ctx, node, cssW, cssH + TOP_TRIM, node._cmpDomMouse || null);
};
node._cmpDomRender = render;
@@ -790,12 +1034,15 @@ function createCompareDOMWidget(node) {
// down (e.g. clicking Overlay selects Difference; the Copy button is missed).
const sx = r.width ? root.clientWidth / r.width : 1;
const sy = r.height ? root.clientHeight / r.height : 1;
return [(e.clientX - r.left) * sx, (e.clientY - r.top) * sy];
// +TOP_TRIM: the drawing is shifted up by TOP_TRIM, so a screen-space y maps
// to a logical y that's TOP_TRIM larger.
return [(e.clientX - r.left) * sx, (e.clientY - r.top) * sy + TOP_TRIM];
};
const W = () => node._cmpDomW || root.clientWidth;
const H = () => node._cmpDomH || root.clientHeight;
const H = () => (node._cmpDomH || root.clientHeight) + TOP_TRIM;
root.addEventListener("pointerdown", (e) => {
cmpHideTooltip();
const [lx, ly] = localPos(e);
if (cmpDown(node, lx, ly, W(), H())) {
e.stopPropagation();
@@ -811,6 +1058,7 @@ function createCompareDOMWidget(node) {
node._cmpDomMouse = { x: lx, y: ly };
root.style.cursor = cmpCursor(node, lx, ly, W(), H());
cmpMove(node, lx, ly, W(), H());
cmpShowTooltip(cmpTooltipFor(node, lx, ly, W()), e.clientX, e.clientY);
render(); // also refreshes the Copy-button hover state
});
root.addEventListener("pointerup", (e) => {
@@ -820,6 +1068,7 @@ function createCompareDOMWidget(node) {
});
root.addEventListener("pointerleave", () => {
node._cmpDomMouse = null;
cmpHideTooltip();
cmpLeave(node);
render();
});
@@ -963,6 +1212,7 @@ app.registerExtension({
const _origDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e, pos) {
if (isVueNodes()) return _origDown ? _origDown.call(this, e, pos) : undefined;
cmpHideTooltip();
if (cmpDown(this, pos[0], pos[1], this.size[0], this.size[1])) {
app.graph.setDirtyCanvas(true, true);
return true;
@@ -976,6 +1226,7 @@ app.registerExtension({
if (cmpMove(this, pos[0], pos[1], this.size[0], this.size[1])) {
app.graph.setDirtyCanvas(true, true);
}
cmpShowTooltip(cmpTooltipFor(this, pos[0], pos[1], this.size[0]), e?.clientX ?? 0, e?.clientY ?? 0);
if (_origMove) return _origMove.call(this, e, pos);
};
@@ -999,6 +1250,7 @@ app.registerExtension({
const _origLeave = nodeType.prototype.onMouseLeave;
nodeType.prototype.onMouseLeave = function (e) {
if (isVueNodes()) return _origLeave ? _origLeave.call(this, e) : undefined;
cmpHideTooltip();
cmpLeave(this);
app.graph.setDirtyCanvas(true, true);
if (_origLeave) return _origLeave.call(this, e);
@@ -1020,6 +1272,7 @@ app.registerExtension({
nodeType.prototype.onRemoved = function () {
try { this._cmpDomRO?.disconnect(); } catch {}
try { cancelAnimationFrame(this._cmpZoomRaf); } catch {}
cmpHideTooltip();
this._cmpDomRO = null;
this._cmpDomRender = null;
return _origRemoved ? _origRemoved.apply(this, arguments) : undefined;
+7
View File
@@ -10,6 +10,7 @@ import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
const DEFAULT_TEXT_STATE = {
text: "Your text", font: "Roboto", weight: 400, italic: false,
fontSize: 96, lineHeight: 1.2, letterSpacing: 0, align: "center",
direction: "horizontal",
color: "#FFFFFF", bgColor: null,
};
@@ -30,6 +31,10 @@ export class PixaromaEditor {
this.activeMode = null;
this.brushSize = 25;
this.brushHardness = 0.5;
// Eraser sub-mode: "erase" removes pixels, "restore" paints erased pixels
// back. Per-session tool state - resets to "erase" on every editor open.
this.eraserSubMode = "erase";
this._eraserAltHeld = false; // held Alt temporarily flips the sub-mode
this.handleSize = 12;
this.isMouseDown = false;
this.startX = 0;
@@ -158,6 +163,8 @@ export class PixaromaEditor {
}
}
}
// Keep the Erase | Restore pills lit only while the eraser tool is on.
this._syncEraserPillsEnabled();
this.verifySelection();
this.draw();
}
+50 -5
View File
@@ -1,5 +1,6 @@
// Eraser mode methods — mixed into PixaromaEditor.prototype
import { PixaromaEditor } from "./core.mjs";
import { BRAND } from "../framework/index.mjs";
PixaromaEditor.prototype.setupEraserOnSelection = function () {
const layer = this.getActiveLayer();
@@ -8,6 +9,36 @@ PixaromaEditor.prototype.setupEraserOnSelection = function () {
}
};
// Effective brush direction: the pill's sub-mode, flipped while Alt is held.
PixaromaEditor.prototype.eraserIsRestore = function () {
return (this.eraserSubMode === "restore") !== !!this._eraserAltHeld;
};
// The Erase | Restore pills are a sub-control of the eraser TOOL: they only
// make sense once the eraser is enabled. When it's off, dim + disable them so
// they don't read as "active" (orange) while a canvas click still just moves
// the layer (select mode). Funnelled through setMode + the panel refresh so
// the pills always track the real tool state.
PixaromaEditor.prototype._syncEraserPillsEnabled = function () {
if (!this.eraserModePills) return;
const on = this.activeMode === "eraser";
const el = this.eraserModePills.el;
el.style.opacity = on ? "1" : "0.4";
// pointer-events:none also blocks hover, so a title here wouldn't show -
// the dim state is the cue that the eraser must be enabled first.
el.style.pointerEvents = on ? "auto" : "none";
};
// Repaint the brush ring at its last known position. Keyboard-only changes
// (X swap / Alt hold-release) have no mousemove to recolor the cursor, so the
// pill click, X handler, and Alt handlers call this directly.
PixaromaEditor.prototype._refreshEraserPreview = function () {
if (this.activeMode === "eraser" && this._lastEraserCoords) {
this._pendingEraserPreview = this._lastEraserCoords;
}
this.draw();
};
PixaromaEditor.prototype.prepareLayerMask = function (
layer,
existingMaskUrl = null,
@@ -51,12 +82,19 @@ PixaromaEditor.prototype.clearEraserMask = function (layer, skipRefresh) {
};
PixaromaEditor.prototype.drawEraserLine = function (layer, start, end) {
const restore = this.eraserIsRestore();
// Restoring a layer with no mask: nothing to recover, and we must NOT mark
// the mask dirty or existing (a no-op stroke would otherwise create one).
if (restore && !layer.hasMask_internal) return;
const ctx = layer.eraserMaskCtx_internal;
// De-scale the brush by the geometric mean of BOTH axes so the erased size
// matches the cursor on non-uniformly-scaled layers (the cursor is de-scaled
// on X and Y, so a scaleX-only divisor mis-sizes the brush when scaleX≠scaleY).
const scaleDivisor = Math.max(0.01, Math.sqrt(Math.abs(layer.scaleX * layer.scaleY)));
ctx.save();
// Restore brushes the mask AWAY (destination-out on the mask itself), so the
// destination-out render in render.mjs has less to punch out -> image returns.
if (restore) ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.moveTo(start.lx, start.ly);
ctx.lineTo(end.lx, end.ly);
@@ -78,19 +116,21 @@ PixaromaEditor.prototype.drawEraserLine = function (layer, start, end) {
// see the save handler's dirty/never-uploaded check that prevents a new
// mask file being written on every Save).
layer.maskDirty_internal = true;
if (!layer.hasMask_internal) {
if (!restore && !layer.hasMask_internal) {
layer.hasMask_internal = true;
this.ui.updateActiveLayerUI();
}
};
PixaromaEditor.prototype.drawEraserPreview = function (coords) {
const restore = this.eraserIsRestore();
this.ctx.save();
this.ctx.translate(coords.x, coords.y);
this.ctx.beginPath();
this.ctx.arc(0, 0, this.brushSize, 0, Math.PI * 2);
this.ctx.strokeStyle = "rgba(255,255,255,0.8)";
this.ctx.lineWidth = 1;
// Orange ring = restore (paints pixels back); white ring = erase.
this.ctx.strokeStyle = restore ? BRAND : "rgba(255,255,255,0.8)";
this.ctx.lineWidth = restore ? 1.5 : 1;
this.ctx.stroke();
if (this.isMouseDown && this.activeMode === "eraser") {
@@ -102,8 +142,13 @@ PixaromaEditor.prototype.drawEraserPreview = function (coords) {
0,
this.brushSize,
);
radGrad.addColorStop(this.brushHardness, "rgba(0,0,0,0.6)");
radGrad.addColorStop(1, "rgba(0,0,0,0)");
if (restore) {
radGrad.addColorStop(this.brushHardness, "rgba(246, 103, 68, 0.45)");
radGrad.addColorStop(1, "rgba(246, 103, 68, 0)");
} else {
radGrad.addColorStop(this.brushHardness, "rgba(0,0,0,0.6)");
radGrad.addColorStop(1, "rgba(0,0,0,0)");
}
this.ctx.fillStyle = radGrad;
this.ctx.fill();
}
+45
View File
@@ -226,6 +226,26 @@ PixaromaEditor.prototype.attachEvents = function () {
e.preventDefault();
this.setMode(null);
}
// X swaps Erase <-> Restore while the eraser tool is active.
if (e.code === "KeyX" && !e.ctrlKey && !e.metaKey) {
if (this.activeMode === "eraser") {
e.preventDefault();
this.eraserSubMode =
this.eraserSubMode === "erase" ? "restore" : "erase";
if (this.eraserModePills)
this.eraserModePills.setActive(this.eraserSubMode);
this._refreshEraserPreview();
}
}
// Holding Alt temporarily flips the brush (erase <-> restore). The
// preventDefault stops the browser's menu-bar focus on a bare Alt tap.
if (e.key === "Alt" && this.activeMode === "eraser") {
e.preventDefault();
if (!this._eraserAltHeld) {
this._eraserAltHeld = true;
this._refreshEraserPreview();
}
}
const key = e.key.toLowerCase();
if (e.ctrlKey && key === "z") {
e.preventDefault();
@@ -258,6 +278,12 @@ PixaromaEditor.prototype.attachEvents = function () {
this.spacePressed = false;
this.workspace.classList.remove("panning");
}
if (e.key === "Alt" && this._eraserAltHeld) {
this._eraserAltHeld = false;
// Only repaint when the eraser is live (Alt can be released after
// exiting eraser mode - clearing the flag is all that's needed then).
if (this.activeMode === "eraser") this._refreshEraserPreview();
}
};
window.addEventListener("keydown", this._composerKeyDown, { capture: true });
window.addEventListener("keyup", this._composerKeyUp, { capture: true });
@@ -971,6 +997,20 @@ PixaromaEditor.prototype.attachEvents = function () {
if (this.activeMode === "eraser") {
if (this.selectedLayerIds.size === 1) {
this.setupEraserOnSelection();
// Restore with nothing erased is a no-op - tell the user why. Skip the
// hint when savedMaskPath_internal is set: a workflow-restored mask
// loads async (prepareLayerMask), so for a moment hasMask is false
// even though a mask exists - "nothing erased" would be a lie there.
const al = this.getActiveLayer();
if (
this.eraserIsRestore() &&
al &&
!al.hasMask_internal &&
!al.savedMaskPath_internal &&
this._layout
) {
this._layout.setStatus("Nothing erased on this layer yet", "warn");
}
this.ui.updateActiveLayerUI();
} else {
this.isMouseDown = false;
@@ -1037,6 +1077,10 @@ PixaromaEditor.prototype.attachEvents = function () {
const coords = this.getCanvasCoordinates(e);
if (this.activeMode === "eraser") {
// Mouse events carry the live Alt state - keeps the temp-flip honest
// even if a keydown/keyup was swallowed by another handler.
if (this._eraserAltHeld !== e.altKey) this._eraserAltHeld = e.altKey;
this._lastEraserCoords = coords;
if (this.isMouseDown && this.selectedLayerIds.size === 1) {
const canvasRect = this.canvas.getBoundingClientRect();
const isOverCanvas =
@@ -1106,6 +1150,7 @@ PixaromaEditor.prototype.attachEvents = function () {
this._composerBlur = () => {
this.spacePressed = false;
this._eraserAltHeld = false; // Alt+Tab leaves keyup unseen - don't stick
if (this.isPanning) {
this.isPanning = false;
this.workspace.classList.remove("panning");
+32 -2
View File
@@ -4,6 +4,7 @@ import {
createPanel,
createButton,
createSliderRow,
createPillGrid,
createDivider,
createCanvasSettings,
createLayerPanel,
@@ -266,6 +267,9 @@ export class PixaromaUI {
"warn",
);
}
// Re-assert pill enabled-state after the panel un-dims (the panel opacity
// is independent of the eraser on/off state the pills track).
core._syncEraserPillsEnabled?.();
// Crop requires exactly one NON-placeholder layer selected
{
@@ -718,10 +722,13 @@ export class PixaromaUI {
</div>
</div>
<div class="pxf-help-section">
<h4>Eraser</h4>
<h4>Eraser / Restore</h4>
<div class="pxf-help-grid">
<b>E</b><span>Toggle eraser on / off</span>
<b>V</b><span>Return to select mode</span>
<b>X</b><span>Swap Erase / Restore</span>
<b>Hold Alt</b><span>Temporarily use the opposite brush</span>
<b>Restore</b><span>Paints erased pixels back (orange cursor)</span>
<b>Enable Eraser</b><span>Select a layer first, then click the button</span>
<b>Reset Mask</b><span>Restore the layer's original pixels</span>
</div>
@@ -1372,7 +1379,7 @@ export class PixaromaUI {
layout.rightSidebar.insertBefore(core._layerPanel.el, layout.sidebarFooter);
// --- 2. Eraser Panel ---
const eraserPanel = createPanel("Eraser", {
const eraserPanel = createPanel("Eraser / Restore", {
collapsible: true,
collapsed: false,
});
@@ -1398,6 +1405,29 @@ export class PixaromaUI {
core.btnEraserToggle.style.marginBottom = "8px";
eraserPanel.content.appendChild(core.btnEraserToggle);
// Erase | Restore segmented toggle. The mask is non-destructive, so
// Restore simply paints erased pixels back. X swaps; held Alt temp-flips.
core.eraserModePills = createPillGrid(
[
{ label: "Erase", value: "erase" },
{ label: "Restore", value: "restore" },
],
2,
(v) => {
core.eraserSubMode = v;
core._refreshEraserPreview();
},
{ activeValue: "erase" },
);
core.eraserModePills.pills[0].title =
"Brush removes pixels - X swaps, hold Alt for the opposite";
core.eraserModePills.pills[1].title =
"Brush paints erased pixels back - X swaps, hold Alt for the opposite";
core.eraserModePills.el.style.marginBottom = "8px";
eraserPanel.content.appendChild(core.eraserModePills.el);
// Eraser starts OFF, so the pills start dimmed/disabled until Enable [E].
core._syncEraserPillsEnabled();
// Brush Size
const sizeRow = createSliderRow("Size", 1, 200, core.brushSize, null, {
labelWidth: "52px",
+6 -10
View File
@@ -12,7 +12,7 @@ import {
getPreviewInput,
} from "./core.mjs";
import { attachFieldEditor, attachDragHandlers } from "./interaction.mjs";
import { createHelpButton } from "../shared/index.mjs";
import { registerNodeHelp } from "../shared/index.mjs";
const CSS_ID = "pix-find-replace-css";
@@ -36,8 +36,7 @@ const CSS = `
here would collapse to 0 under that measurement and break the floor. */
}
/* ---- top row: toggle pills (wrap among themselves) + the Help ? button
pinned at the right so it never drops to its own line when the node narrows. */
/* ---- top row: the toggle pills (wrap among themselves on a narrow node). */
.pix-fr-toprow { display: flex; align-items: flex-start; gap: 6px; flex: 0 0 auto; }
.pix-fr-toggles { display: flex; gap: 6px; flex-wrap: wrap; flex: 1 1 auto; min-width: 0; }
.pix-fr-tog {
@@ -377,12 +376,15 @@ const FR_HELP = {
"Backreferences use `\\1`, `\\2` (Python style), not `$1`. The node always runs the real Python regex, even for advanced patterns the on-node preview can't show perfectly.",
};
// Help is shown by the selection-toolbar Help button (js/help_toolbar).
registerNodeHelp("PixaromaFindReplace", FR_HELP);
// renderAll: clears root and rebuilds the whole body.
export function renderAll(node, root, handlers) {
const state = readState(node);
root.innerHTML = "";
// -- top row: toggles + Help button --
// -- top row: toggles --
const toprow = document.createElement("div");
toprow.className = "pix-fr-toprow";
const toggles = document.createElement("div");
@@ -397,12 +399,6 @@ export function renderAll(node, root, handlers) {
toggles.appendChild(pill);
}
toprow.appendChild(toggles);
// Help (?) button - its own flex item pinned at the right, top-aligned so it
// stays next to the first pill row even when the pills wrap on a narrow node.
const help = createHelpButton(FR_HELP);
help.style.alignSelf = "flex-start";
help.style.marginTop = "5px";
toprow.appendChild(help);
root.appendChild(toprow);
// -- rule rows --
+1 -1
View File
@@ -140,7 +140,7 @@ export function buildBgRemovalDropdown({ container, info, value, onChange }) {
// One canonical doc page covering BOTH BiRefNet downloads and rembg
// install steps, so the user lands somewhere maintained instead of
// bouncing to whichever upstream repo we happened to link.
const HELP_DOCS_URL = "https://github.com/pixaroma/ComfyUI-Pixaroma#2-optional-ai-background-removal";
const HELP_DOCS_URL = "https://gitlab.com/pixaroma/comfyui-pixaroma#2-optional-ai-background-removal";
function refreshHelpRow() {
const id = select.value;
+1 -1
View File
@@ -192,7 +192,7 @@ export function createEditorLayout(config) {
<div class="pxf-help-content">${helpContent}</div>
<div class="pxf-help-footer">
Designed by <a href="https://www.youtube.com/@pixaroma" target="_blank">Pixaroma</a>
· <a href="https://github.com/pixaroma/ComfyUI-Pixaroma" target="_blank">GitHub</a><br>
· <a href="https://gitlab.com/pixaroma/comfyui-pixaroma" target="_blank">GitLab</a><br>
</div>
`;
helpPanel
+29
View File
@@ -108,6 +108,30 @@ export function createTextEditorPanel({ mount, onChange, onReset, onAlignCanvas,
center: "/pixaroma/assets/icons/ui/align-center-h.svg",
right: "/pixaroma/assets/icons/ui/align-right.svg",
};
// Text direction: Horizontal | Vertical. Vertical stacks each character
// top-to-bottom; line breaks become columns (left to right). Sits above the
// align row because it changes what align means (lines<->columns).
root.appendChild(caption("Text direction"));
const dirRow = el("div", "pix-to-dir-row");
root.appendChild(dirRow);
ui.dirChips = [["horizontal", "Horizontal"], ["vertical", "Vertical"]].map(([val, label]) => {
const b = el("button", "pix-to-chip pix-to-dir-chip");
b.type = "button";
b.dataset.dir = val;
b.textContent = label;
b.title = val === "vertical"
? "Stack characters top-to-bottom; new lines make columns (left to right)"
: "Normal left-to-right horizontal text";
b.addEventListener("click", () => {
const l = layerNow(); if (!l) return;
l.direction = val;
ui.dirChips.forEach((c) => c.classList.toggle("active", c.dataset.dir === val));
fireChange();
});
dirRow.appendChild(b);
return b;
});
root.appendChild(caption("Text align (within block)"));
const alignRow = el("div", "pix-to-row3");
root.appendChild(alignRow);
@@ -335,6 +359,7 @@ export function createTextEditorPanel({ mount, onChange, onReset, onAlignCanvas,
ui.fontDropdownName.style.fontFamily = `"Pix-${fontId}", system-ui`;
ui.boldBtn.classList.toggle("active", (layer.weight ?? 400) === 700);
ui.italicBtn.classList.toggle("active", !!layer.italic);
if (ui.dirChips) ui.dirChips.forEach((c) => c.classList.toggle("active", c.dataset.dir === (layer.direction ?? "horizontal")));
ui.alignChips.forEach((c) => c.classList.toggle("active", c.dataset.align === (layer.align ?? "center")));
ui.sizeInput.setValue(layer.fontSize ?? 96);
ui.lineInput.setValue(layer.lineHeight ?? 1.2);
@@ -870,6 +895,10 @@ function injectCSS() {
/* Brief click acknowledgement for the momentary "Position on canvas"
buttons (they have no persistent active state). */
.pix-to-chip.is-flashing { background: ${BRAND}; border-color: ${BRAND}; }
/* Direction row: 2 chips. Reuse .pix-to-chip (hover/active/height) so the
row visually matches the align row directly below it; this class only
sets the 2-column grid. */
.pix-to-dir-row { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; }
.pix-to-align-chip img {
width: 14px; height: 14px;
pointer-events: none;
+131
View File
@@ -35,6 +35,13 @@ export async function renderTextToCanvas(state) {
return empty;
}
// Vertical direction: stack characters per column (docs §5b). Font is already
// loaded (fontStr); lineHeightPx = round(fontSize*lineHeight) IS the per-char
// vertical step, letterSpacing IS the column gap.
if (state.direction === "vertical") {
return renderVerticalToCanvas(state, fontStr, lineHeightPx, letterSpacing);
}
const lines = text.split("\n");
// Measure each line + font metrics on a throwaway context.
@@ -112,6 +119,130 @@ export async function renderTextLayer(ctx, layer) {
ctx.restore();
}
/** Measure the rendered bbox {w, h} of state's text, direction-aware.
* `ctx.font` MUST already be set to the resolved variant's font string by the
* caller (font resolution differs between the node-body and editor callers).
* Mirrors the bbox math of renderTextToCanvas / renderVerticalToCanvas
* (docs §2 + §5b) EXCEPT the synthesized-italic slant widen, which the
* align/hit-test callers never accounted for (approximation predates this
* helper). Used by the Position-on-canvas buttons, auto-center, and the
* editor's align toolbar / drag bbox / Fit W / Fit H. */
export function measureTextDims(ctx, state) {
const fontSize = state.fontSize || 96;
const lines = String(state.text ?? "").split("\n");
const letterSpacing = state.letterSpacing || 0;
const stepPx = Math.round(fontSize * (state.lineHeight ?? 1.2));
const m = ctx.measureText("Mg");
const ascender = m.actualBoundingBoxAscent || fontSize * 0.78;
const descender = m.actualBoundingBoxDescent || fontSize * 0.22;
const padX = state.bgColor ? BG_PAD_X : 0;
const padY = state.bgColor ? BG_PAD_Y : 0;
if (state.direction === "vertical") {
// Columns advance left-to-right; chars stack by stepPx; letterSpacing is
// the column gap (docs §5b - lockstep with renderVerticalToCanvas).
let totalColW = 0;
let maxChars = 0;
const cols = lines.map((line) => Array.from(line));
for (const chars of cols) {
let colW = 0;
for (const ch of chars) colW = Math.max(colW, ctx.measureText(ch).width);
totalColW += colW;
maxChars = Math.max(maxChars, chars.length);
}
totalColW += Math.max(0, cols.length - 1) * letterSpacing;
return {
w: Math.max(1, Math.ceil(totalColW + 2 * padX)),
h: Math.max(1, Math.ceil(ascender + descender + Math.max(0, maxChars - 1) * stepPx + 2 * padY)),
};
}
const lineWidths = lines.map((ln) => {
if (letterSpacing === 0) return ctx.measureText(ln).width;
let w = 0;
for (const ch of ln) w += ctx.measureText(ch).width;
return w + Math.max(0, ln.length - 1) * letterSpacing;
});
const maxLineW = Math.max(0, ...lineWidths);
return {
w: Math.max(1, Math.ceil(maxLineW + 2 * padX)),
h: Math.max(1, Math.ceil(ascender + descender + Math.max(0, lines.length - 1) * stepPx + 2 * padY)),
};
}
/** Vertical (top-to-bottom, upright) text. Columns = lines, left-to-right.
* charStep = per-character vertical step; colGap = gap between columns.
* Mirror of nodes/_text_render_helpers.py::_render_vertical_layer. */
function renderVerticalToCanvas(state, fontStr, charStep, colGap) {
const text = String(state.text ?? "");
const lines = text.split("\n");
const align = state.align || "center";
const meas = document.createElement("canvas").getContext("2d");
meas.font = fontStr;
const m = meas.measureText("Mg");
const ascender = m.actualBoundingBoxAscent || state.fontSize * 0.78;
const descender = m.actualBoundingBoxDescent || state.fontSize * 0.22;
const bgColor = state.bgColor || null;
const padX = bgColor ? BG_PAD_X : 0;
const padY = bgColor ? BG_PAD_Y : 0;
// Per-column glyphs + max width
const cols = lines.map((line) => {
const chars = Array.from(line);
let colW = 0;
for (const ch of chars) colW = Math.max(colW, meas.measureText(ch).width);
return { chars, colW };
});
const maxChars = Math.max(0, ...cols.map((c) => c.chars.length));
const contentH = ascender + descender + Math.max(0, maxChars - 1) * charStep;
const totalColW =
cols.reduce((s, c) => s + c.colW, 0) + Math.max(0, cols.length - 1) * colGap;
const bboxW = Math.max(1, Math.ceil(totalColW + 2 * padX));
const bboxH = Math.max(1, Math.ceil(contentH + 2 * padY));
const scratch = document.createElement("canvas");
scratch.width = bboxW;
scratch.height = bboxH;
const sctx = scratch.getContext("2d");
if (bgColor) {
const r = Math.min(BG_RADIUS, bboxW / 2, bboxH / 2);
sctx.save();
sctx.fillStyle = bgColor;
roundRect(sctx, 0, 0, bboxW, bboxH, r);
sctx.fill();
sctx.restore();
}
sctx.save();
sctx.font = fontStr;
sctx.textBaseline = "alphabetic";
sctx.fillStyle = state.color || "#FFFFFF";
let colOriginX = padX;
for (const col of cols) {
const colContentH =
ascender + descender + Math.max(0, col.chars.length - 1) * charStep;
const vOffset =
align === "center" ? (contentH - colContentH) / 2
: align === "right" ? contentH - colContentH
: 0;
for (let i = 0; i < col.chars.length; i++) {
const ch = col.chars[i];
const gw = meas.measureText(ch).width;
const cx = colOriginX + (col.colW - gw) / 2;
const ly = padY + vOffset + ascender + i * charStep;
sctx.fillText(ch, cx, ly);
}
colOriginX += col.colW + colGap;
}
sctx.restore();
return scratch;
}
// ── helpers ───────────────────────────────────────────────────────────────────
function measureLine(ctx, text, letterSpacing) {
+851
View File
@@ -0,0 +1,851 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Pixaroma - Help content for the selection-toolbar Help button ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// One place to edit the in-app help for most Pixaroma nodes. Each entry maps a
// node's comfyClass -> a help-definition object (see js/shared/help.mjs for the
// schema). The selection-toolbar Help button (js/help_toolbar/index.js) shows
// the matching help whenever one of these nodes is selected.
//
// A few nodes register their OWN help from their node file because the help def
// lives next to that node's 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). Everything else lives here.
//
// To edit a node's help: find its entry below and change the text. To add help
// for a NEW node: add an entry keyed by its exact comfyClass.
import { registerNodeHelp } from "../shared/index.mjs";
const HELP = {
"Pixaroma3D": {
title: "3D Builder Pixaroma",
tagline: "A full 3D scene editor inside ComfyUI - build, light, and render scenes without leaving your workflow.",
sections: [
{
heading: "What it does",
body: "Opens a fullscreen 3D editor where you can place primitives (cubes, spheres, cylinders, torus, blob, terrain, rock, teapot, hollow vessels), composite assets (trees, houses, furniture, flowers), or import your own `.glb` / `.obj` models.\n\nThe editor includes orbit / pan / zoom camera controls, realistic lighting, and live preview. Keyboard shortcuts follow the Blender layout: `G` to grab, `S` to scale, `R` to rotate, `Shift+D` to duplicate, and `Numpad 1/3/7` for view presets. Full undo / redo is built in.",
},
{
heading: "How to use",
bullets: [
"Click `Open 3D Builder` on the node to open the fullscreen editor.",
"Add shapes from the toolbar or import a `.glb` / `.obj` file.",
"Position, scale, and rotate objects using the handles or keyboard shortcuts.",
"Adjust the background image, lighting, and canvas size in the settings panel.",
"Click `Save` to write the render back to the node.",
"Run the workflow to get the rendered image as output.",
],
},
{
heading: "Outputs",
defs: [
["image", "The rendered 3D scene as a flat RGB image."],
["width", "Width of the render in pixels."],
["height", "Height of the render in pixels."],
],
},
],
footer: "Wire `image` into a ControlNet preprocessor for instant depth maps, line art, or normal maps.",
},
"PixaromaPaint": {
title: "Paint Pixaroma",
tagline: "A layered painting editor inside ComfyUI - draw, retouch, and compose on stacked layers.",
sections: [
{
heading: "What it does",
body: "Opens a fullscreen painting canvas with multiple layers, Photoshop-style controls (drag to reorder, opacity, blend modes, merge, flatten), and a full brush engine. Tools include pencil, brush, eraser, smudge, fill, shape, and color picker. The smudge tool gives smooth color blending; brush hardness and opacity are fully adjustable.\n\nDrag an image file onto the closed node body to load it as a new top layer and open the editor automatically. The `AI Remove Background` button on image layers extracts subjects with one click. A `Transparent BG` checkbox in the toolbar lets you save a PNG with a transparent background directly to disk.",
},
{
heading: "How to use",
bullets: [
"Drag an image onto the node body to pre-load a layer, or click `Open Paint Studio` to start from scratch.",
"Use the layer panel on the right to add, reorder, merge, or delete layers.",
"Pick a tool from the left toolbar and paint on the canvas.",
"Click `Save` when done; run the workflow to output the result.",
],
},
{
heading: "Outputs",
defs: [
["image", "The final composited painting."],
["width", "Width of the canvas in pixels."],
["height", "Height of the canvas in pixels."],
],
},
],
},
"PixaromaImageComposition": {
title: "Image Composer Pixaroma",
tagline: "A layer-based image compositor inside ComfyUI - arrange, blend, and grade multiple images on one canvas.",
sections: [
{
heading: "What it does",
body: "Opens a fullscreen compositor where you stack images as layers and control each one independently: move, scale, rotate, set opacity, choose a blend mode, apply Gaussian blur, or remove its background with AI.\n\nThe eraser tool masks any layer non-destructively, with a `Restore` mode to paint areas back in. An FX adjustment layer (like a Photoshop grade layer) applies color-grade effects (exposure, contrast, saturation, tone) plus 14 cinematic presets to everything below it. Text layers let you add styled, editable text directly on the canvas.\n\nDrag an image onto the closed node body to add it as a new layer. Wire upstream IMAGE inputs into placeholder slots for generative workflows.",
},
{
heading: "How to use",
bullets: [
"Drag an image onto the node body to add a layer and open the editor, or click `Open Image Composer`.",
"Add more layers via drag-drop, paste, the `+` button, or by wiring an upstream IMAGE into a placeholder slot.",
"Select a layer in the panel to move / scale / rotate it with handles, or use the right sidebar to set opacity, blend mode, and blur.",
"Add an FX layer to apply a color grade or cinematic preset above any layer group.",
"Click `Save` when done; run the workflow to composite and output the result.",
],
},
{
heading: "Outputs",
defs: [
["image", "The fully composited image."],
["width", "Width of the canvas in pixels."],
["height", "Height of the canvas in pixels."],
],
},
],
},
"PixaromaCrop": {
title: "Image Crop Pixaroma",
tagline: "Crop any image visually with a draggable handle - no typing pixel coordinates.",
sections: [
{
heading: "What it does",
body: "Shows a draggable crop rectangle over your image with corner and edge handles. The on-node panel exposes `Width`, `Height`, `X`, `Y`, `Ratio`, and `Alignment` fields - math expressions like `1024+512` or `512*2` work in any number field. Picking a non-Free alignment auto-centers the crop rectangle.\n\nThree ways to load a source image: wire any upstream IMAGE into the input slot, drag and drop a file onto the node body, or paste from the clipboard with `Ctrl+V`. Drag-drop and paste auto-disconnect the upstream wire so your loaded image takes over.",
},
{
heading: "How to use",
bullets: [
"Wire an upstream IMAGE, drag a file onto the node, or paste with `Ctrl+V`.",
"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.",
],
},
{
heading: "Outputs",
defs: [
["image", "The cropped image."],
["width", "Width of the cropped area in pixels."],
["height", "Height of the cropped area in pixels."],
],
},
],
},
"PixaromaAudioStudio": {
title: "AudioReact Pixaroma",
tagline: "Turn a still image into an audio-reactive video - motion and effects that move with the beat.",
sections: [
{
heading: "What it does",
body: "Opens a fullscreen editor with a live WebGL preview that reacts to audio in real time as you drag sliders. Choose one of 15 motion modes (Pulse Zoom, Camera Shake, Glitch, Pinch, Wave, Tilt, Pixelate, RGB Split, and more) and layer up to 8 overlay effects (chroma shift, bloom, vignette, hue shift, cinematic grade, letterbox, scanlines, film grain).\n\nBoth the image and audio inputs are optional - you can wire upstream sources or load them inline inside the editor by drag-drop or file pick. No extra models are needed; requires WebGL2.",
},
{
heading: "How to use",
bullets: [
"Wire an upstream IMAGE and AUDIO, or click `Open AudioReact` and load them inside the editor.",
"Pick a motion mode and adjust its intensity and other settings in the sidebar.",
"Add overlay effects from the effects panel.",
"Use the transport bar to scrub and preview the animation in real time.",
"Click `Save` to store the settings; run the workflow to render all frames.",
],
},
{
heading: "Outputs",
defs: [
["video_frames", "All rendered animation frames as a batch of images."],
["audio", "The audio track passed through for muxing downstream."],
["fps", "Frames per second of the render."],
],
},
],
footer: "Pair with `Save Mp4 Pixaroma` to mux the audio and frames into a single MP4 file.",
},
"PixaromaNote": {
title: "Note Pixaroma",
tagline: "A rich-text annotation that lives on your canvas - no wires, no outputs.",
sections: [
{
heading: "What it does",
body: "Lets you write styled, multi-paragraph notes directly on the canvas. Double-click (or use the pencil button) to open a fullscreen editor.\n\nSupported formatting: bold, italic, underline, strikethrough, headings, bulleted and numbered lists, code blocks with a copy button, tables, horizontal rules, and inline icons (CLIP, LORA, GGUF, model badges and 30+ more). You can also insert custom-colored buttons (Download, View Page, Read More, or plain links) and pre-styled YouTube and Discord pills.",
},
{
heading: "How to use",
bullets: [
"Drop the node anywhere on the canvas.",
"Double-click the node body (or click the pencil icon) to open the editor.",
"Write and format your content using the toolbar buttons.",
"Click `Save` to close and persist your note.",
"Right-click the node to change its background and title color via the standard Colors menu.",
],
},
],
footer: "This node never runs during a workflow - it is pure decoration and shows no timing badge.",
},
"PixaromaLabel": {
title: "Label Pixaroma",
tagline: "A clean text label for captioning parts of your workflow.",
sections: [
{
heading: "What it does",
body: "Renders a single styled line of text directly on the canvas as a floating caption. No inputs, no outputs - purely decorative. You can pick the font family, font size (up to 256 px), text color, and background pill color.",
},
{
heading: "How to use",
bullets: [
"Drop the node where you want the label.",
"Double-click the node to open the editor.",
"Type your text, choose a font and colors, then click `Save`.",
"The label resizes itself to hug its text - no manual resizing needed.",
],
},
],
footer: "This node never runs during a workflow.",
},
"PixaromaPromptMulti": {
title: "Prompt Multi Pixaroma",
tagline: "A prompt library with two run modes - queue one image per prompt, or send all prompts as a list.",
sections: [
{
heading: "What it does",
body: "Holds a list of prompts you can toggle on/off, label, and reorder. A pill at the top switches between two behaviors:\n\n`Queue Text` mode: clicking Run queues the workflow once per enabled prompt in sequence, one prompt per run. Wire the `text` output to CLIP Text Encode to generate one image per prompt.\n\n`List Prompts` mode: clicking Run fires the workflow once and makes the full list available as a bundle. Wire the `prompts` output into Prompt From List Pixaroma nodes downstream so different parts of the workflow can each pull a different prompt.",
},
{
heading: "How to use",
bullets: [
"Click `+ Add prompt` to add a row, then type a prompt into each.",
"Toggle the orange pill on each row to include or exclude it from runs.",
"Drag the handle on the left edge of a row to reorder.",
"Pick `Queue Text` or `List Prompts` mode using the pills at the top.",
"Wire `text` to CLIP Text Encode (Queue mode) or wire `prompts` to Prompt From List nodes (List mode).",
],
},
{
heading: "Outputs",
defs: [
["text", "The active row's prompt for the current queue run. Use this in Queue mode."],
["prompts", "All enabled prompts as a list. Use this in List mode with Prompt From List Pixaroma nodes."],
],
},
],
footer: "Both outputs are always visible - the mode pill only controls whether the queue loops.",
},
"PixaromaPromptPack": {
title: "Prompt Pack Pixaroma",
tagline: "Paste a block of prompts and queue one workflow run per prompt automatically.",
sections: [
{
heading: "What it does",
body: "You paste multiple prompts into one big textarea. When you click Run the node queues the workflow once per non-empty prompt, looping automatically. The counter pill in the textarea's corner shows the total count at idle, then `current / total` during a run.\n\nA split-mode pill at the top controls how prompts are separated: `Paragraph` splits on blank lines (good for long, multi-sentence prompts) and `Line` splits on every newline (good for short one-liners).",
},
{
heading: "How to use",
bullets: [
"Paste or type your prompts into the textarea.",
"Choose `Paragraph` or `Line` split mode using the pill at the top.",
"Wire the `text` output to CLIP Text Encode.",
"Click Run - the node queues one workflow per prompt automatically.",
"Empty or whitespace-only entries are silently skipped.",
],
},
{
heading: "Outputs",
defs: [
["text", "The current prompt for this queue run - one entry from the block."],
],
},
],
footer: "If the textarea is empty when you click Run, nothing queues and a message appears.",
},
"PixaromaPromptStack": {
title: "Prompt Stack Pixaroma",
tagline: "Build one combined prompt from a list of toggleable chunks.",
sections: [
{
heading: "What it does",
body: "Holds an ordered list of prompt pieces. All enabled pieces are joined into a single string using your chosen separator (default: comma + space, configurable in Settings under `Pixaroma`).\n\nUseful for building prompts modularly: you can mute a style chunk, a color description, or a quality suffix without deleting it.",
},
{
heading: "How to use",
bullets: [
"Click `+ Add row` to add a chunk, then type a piece of your prompt into each.",
"Click the orange pill on a row to toggle it on or off.",
"Drag the handle on the left edge of a row to reorder.",
"Wire the `text` output to CLIP Text Encode or a downstream text node.",
],
},
{
heading: "Outputs",
defs: [
["text", "All enabled chunks joined into one string, in top-to-bottom order."],
],
},
],
footer: "Trailing commas on each chunk are stripped automatically, so you can be loose with punctuation.",
},
"PixaromaPromptFromList": {
title: "Prompt From List Pixaroma",
tagline: "Picks one prompt by number from a Prompt Multi list.",
sections: [
{
heading: "What it does",
body: "A tiny single-purpose node. Connect the `prompts` output of a Prompt Multi Pixaroma node (set to List Prompts mode) and set the `index` to choose which prompt you want. Index 1 returns the first enabled prompt, index 2 the second, and so on.\n\nDrop several of these in a workflow - all wired to the same Prompt Multi - so each section uses a different prompt from the same library.",
},
{
heading: "How to use",
bullets: [
"Set your Prompt Multi node to `List Prompts` mode.",
"Wire its `prompts` output to this node's `prompts` input.",
"Set `index` to the position of the prompt you want (1-based).",
"Wire `text` to CLIP Text Encode or any downstream text input.",
],
},
{
heading: "Outputs",
defs: [
["text", "The prompt at the chosen index. Returns an empty string if the index is out of range."],
],
},
],
},
"PixaromaShowText": {
title: "Show Text Pixaroma",
tagline: "Inspect anything flowing through your workflow - tensors, prompts, numbers - in a readable text box.",
sections: [
{
heading: "What it does",
body: "Displays a compact, human-readable description of whatever you wire in. Strings and numbers print as-is. Image tensors show shape, data type, min, and max values. Latents show their sample shape.\n\nThe text box is read-only and scrollable, so long output never forces the node to grow. Hover over the node to reveal a `Copy` button in the corner.",
},
{
heading: "How to use",
bullets: [
"Wire any output - prompt text, an IMAGE, a latent, a number - into `source`.",
"The box updates on every Run.",
"Hover the node and click `Copy` to copy the displayed text.",
"The same text is also on the `text` output, so you can chain it forward.",
],
},
{
heading: "Outputs",
defs: [
["text", "The text shown in the box, passed through unchanged so you can keep wiring it downstream."],
],
},
],
footer: "Useful for checking what a text node produces, or confirming an image tensor's dimensions before a sampler.",
},
"PixaromaPromptReader": {
title: "Prompt Reader Pixaroma",
tagline: "Pull the positive prompt saved inside any ComfyUI or Automatic1111 PNG back out as usable text.",
sections: [
{
heading: "What it does",
body: "Reads the metadata embedded in a PNG and extracts the positive prompt that generated it. Works with ComfyUI workflows (including chained text nodes, SDXL dual encoders, and switch/combine graphs) and with Automatic1111 / Forge images.\n\nThe readout updates the moment you pick a file, so you can read the prompt before running. If the image has no embedded prompt (a JPEG, a screenshot, or a PNG that lost its metadata), the readout explains why.",
},
{
heading: "How to use",
bullets: [
"Click `Upload Image` to pick a PNG, or drag one onto the node.",
"Or use the file dropdown to choose from images already in ComfyUI's input folder.",
"The prompt appears in the text area immediately.",
"Wire the `text` output into a CLIP Text Encode or any other text input to reuse the prompt.",
],
},
{
heading: "Outputs",
defs: [
["text", "The recovered prompt, or an explanatory message when none was found."],
],
},
],
footer: "Use the Previous / Next arrow buttons to step through images in the input folder quickly.",
},
"PixaromaLoadImage": {
title: "Load Image Pixaroma",
tagline: "Load an image with built-in resize - max megapixels, longest side, fit inside, crop to fill, and more - in one node.",
sections: [
{
heading: "What it does",
body: "A drop-in replacement for the native Load Image node with an inline resize panel, so you rarely need a separate resize node downstream. Supports all native features: upload, drag-drop, paste, animated images, and alpha-to-mask extraction.\n\nResize modes: `Off`, `Max megapixels`, `Longest side`, `Scale by`, `Fit inside`, `Crop to fill`, `Match aspect ratio`, and `Pad`. Snap chips, a resample picker, and an upscale toggle round out the controls.",
},
{
heading: "How to use",
bullets: [
"Click `Upload`, drag a file onto the node, or paste from the clipboard to load an image.",
"Use the thumbnail dropdown (with subfolder groups) or the `◀ ▶` arrows to switch between images.",
"Pick a resize mode chip in the panel. Its controls appear below.",
"The node shows a live INPUT to OUTPUT size card so you can confirm the final dimensions before running.",
],
},
{
heading: "Outputs",
defs: [
["image", "The loaded image, after any resize."],
["mask", "Alpha channel as a mask (blank if the image has no alpha)."],
["width", "Output width in pixels, after any resize."],
["height", "Output height in pixels, after any resize."],
["filename", "The image filename (without extension)."],
["original_width", "Width of the original image before any resize."],
["original_height", "Height of the original image before any resize."],
],
},
],
footer: "Replaces Get Image Size + Image Scale chains in most workflows.",
},
"PixaromaPreview": {
title: "Preview Image Pixaroma",
tagline: "Inline image preview with Save, Copy, and Open buttons - batch-aware, with a Strip or Grid layout.",
sections: [
{
heading: "What it does",
body: "Shows every image in a batch as thumbnails on the node. Click a thumbnail to expand it; use the `◀ ▶` arrow keys (or click the image) to flip through the batch; press `Esc` to collapse. Toggle `Strip` (one row) and `Grid` (fills the body) layouts with the small icon in the preview's top-right corner.\n\nFour buttons act on the selected frame: `Save Disk` saves to a folder you choose, `Save Output` writes to ComfyUI's output folder, `Copy` puts the frame on your clipboard, `Open` opens it in a new tab. Both Save buttons embed the workflow into the PNG so you can drag it back in later.\n\nSet `save_mode` to `save` and the node becomes a drop-in for the native Save Image node: every frame is written to output on each Run.",
},
{
heading: "How to use",
bullets: [
"Wire any IMAGE source into `image`.",
"Leave `save_mode` on `preview` while iterating; switch to `save` for automatic saves.",
"Use `filename_prefix` to control the output name. Supports subfolders with `/` and date tokens like `%date:yyyy-MM-dd%`.",
"Click a thumbnail to expand it, then use the arrow keys to browse the batch.",
],
},
{
heading: "Outputs",
defs: [
["image", "The image(s) passed through unchanged so you can keep wiring downstream."],
],
},
],
footer: "Preview state (selected frame, layout) survives switching workflow tabs.",
},
"PixaromaSaveMp4": {
title: "Save Mp4 Pixaroma",
tagline: "Encode a frame batch to an H.264 mp4 with optional audio, and watch it play right on the node.",
sections: [
{
heading: "What it does",
body: "Takes an IMAGE batch and an optional AUDIO track and encodes them to a single mp4 using ffmpeg. A video preview plays on the node body so you can check the result without leaving ComfyUI. Audio is muxed in the same pass.\n\nThe ffmpeg binary is auto-located: if imageio-ffmpeg is installed its bundled exe is used; otherwise ffmpeg on your system PATH is tried.",
},
{
heading: "How to use",
bullets: [
"Wire an IMAGE batch into `video_frames` and the frame rate into `fps` (from AudioReact, connect its `video_frames` and `fps`).",
"Optionally connect an `audio` output to add a soundtrack.",
"Set `save_mode` to `preview` (temp, auto-cleared on restart) or `save` (kept in output).",
"Set `filename_prefix` to name the file; a 5-digit counter is added automatically.",
"Enable `trim_to_audio` to end the video at the audio length.",
],
},
{
heading: "Requirements",
body: "Install imageio-ffmpeg (`pip install imageio-ffmpeg`) for a bundled ffmpeg with no system setup. Width and height must both be even numbers.",
},
],
footer: "This is a terminal save node - it has no outputs.",
},
"PixaromaImageResize": {
title: "Image Resize Pixaroma",
tagline: "Resize an image (and its mask) mid-workflow using a set of smart resize modes.",
sections: [
{
heading: "What it does",
body: "Applies one of eight resize modes to the wired image and its optional mask, then outputs the result with its final pixel dimensions. You can also wire a width, height, or longest-side value from another node to drive the size automatically.",
},
{
heading: "Resize modes",
defs: [
["Off", "Passes the image through unchanged."],
["Max megapixels", "Scales down so the image does not exceed a total pixel count. Uses 1 MP = 1024x1024 to stay aligned with standard AI sizes."],
["Longest side", "Scales so the longer edge hits the number you set. Works for portrait or landscape without picking an axis."],
["Scale by", "Multiplies both dimensions by a factor (2.0 doubles, 0.5 halves)."],
["Fit inside", "Fits the image inside a W x H box while keeping the whole image visible and the aspect ratio intact."],
["Crop to fill", "Scales and center-crops so the image completely fills a W x H box. Edges may be trimmed."],
["Match aspect ratio", "Resizes to a chosen preset ratio, either cropping or padding."],
["Pad", "Adds a colored border to reach the target size. The padded area becomes white in the mask (the inpaint region)."],
],
},
{
heading: "Wiring a target size",
body: "Connect a value to `width`, `height`, or `longest_side` from another node (e.g. Resolution Pixaroma) to drive the size. Wire only width or only height for an aspect-preserving scale. Wire both for an exact box. `longest_side` takes priority when connected.",
},
{
heading: "Outputs",
defs: [
["image", "The resized image."],
["mask", "The resized mask. In Pad mode the added border is white."],
["width", "Final output width in pixels."],
["height", "Final output height in pixels."],
["longest_side", "The longer of the output width and height."],
],
},
],
},
"PixaromaRemoveBackground": {
title: "Remove Background Pixaroma",
tagline: "Cut out the foreground from an image using a BiRefNet AI model.",
sections: [
{
heading: "What it does",
body: "Runs a BiRefNet neural network over the image and returns a cutout with a transparent background (RGBA), plus a foreground mask and an inverted mask - all three in one pass.",
},
{
heading: "How to use",
bullets: [
"Wire an image into `image` and pick a model from the `model` dropdown.",
"Models live in `ComfyUI/models/background_removal/`. If the dropdown is empty, download one from HuggingFace first.",
"Filenames containing `matt` or `hr` preprocess at 2048px - better for hair and fine edges. Others use 1024px.",
"Connect the cutout `image` for compositing, `mask` for inpainting (white = kept foreground), or `inverted_mask`.",
],
},
{
heading: "Outputs",
defs: [
["image", "The cutout in RGBA - the background is fully transparent."],
["mask", "Foreground mask: white where the subject was kept."],
["inverted_mask", "Background mask: the foreground mask flipped."],
],
},
],
footer: "Common models: `birefnet.safetensors` (standard), `birefnet-hr.safetensors` (high-res), `birefnet-matting.safetensors` (best for hair / fur).",
},
"PixaromaPauseImage": {
title: "Pause Image Pixaroma",
tagline: "Pause your workflow mid-run to review an image before the expensive part runs.",
sections: [
{
heading: "What it does",
body: "Acts as an inline gate on any IMAGE wire. Drop it between an image source (e.g. a KSampler) and expensive downstream work (an upscale, a second pass). In Pause mode only the upstream runs and the image is shown; you then decide whether to continue.",
},
{
heading: "The three modes",
defs: [
["Pause", "Run stops here. The image is shown and a snapshot is saved. The downstream does not run yet."],
["Continue", "The upstream is skipped and the saved snapshot is fed downstream. Use this after reviewing, so only the cheaper downstream work runs."],
["Pass", "The whole workflow runs end to end, as if this node were not there."],
],
},
{
heading: "How to use",
bullets: [
"Wire your image source into `image` and wire the output onward.",
"Set the toggle to `Pause` and press Run. Review the image.",
"Switch to `Continue` and press Run again - only the downstream runs, fed from the image you saw.",
"Use `Regenerate` to roll a fresh image at the same point.",
],
},
{
heading: "Outputs",
defs: [
["image", "The image continuing downstream: the live input in Pause/Pass, or the saved snapshot in Continue."],
],
},
],
},
"PixaromaSwitch": {
title: "Switch Pixaroma",
tagline: "Route one of many wired inputs to a single output by clicking a row toggle.",
sections: [
{
heading: "What it does",
body: "Accepts up to 32 wired inputs of any type (MODEL, CLIP, IMAGE, STRING, AUDIO, and so on) and passes exactly one of them through to the output unchanged. You choose which row is active by clicking its toggle on the node. Only the active row's upstream branch runs - the others are skipped.",
},
{
heading: "How to use",
bullets: [
"Wire upstream nodes into the rows. A new empty row appears as you fill each one.",
"Click a row's toggle to make it active (the orange highlight marks it).",
"Click a row's label to rename it so you remember what each input is.",
"Disconnect a wire to remove its row.",
],
},
{
heading: "Outputs",
defs: [
["output", "The value from the active row, passed through unchanged. The output type adapts to whatever is connected."],
],
},
],
},
"PixaromaSwitchWH": {
title: "Switch WH Pixaroma",
tagline: "Toggle between two width/height pairs with one click.",
sections: [
{
heading: "What it does",
body: "Takes two pairs of width and height values (A and B) and passes one pair to the output. Click `A` or `B` on the node to choose. Useful for flipping between a source image's native size and a chosen resolution without rewiring.",
},
{
heading: "How to use",
bullets: [
"Wire a width/height pair (e.g. from Load Image Pixaroma) into `width_a` and `height_a`.",
"Wire a second pair (e.g. from Resolution Pixaroma) into `width_b` and `height_b`.",
"Click `A` or `B` to pick which pair flows out.",
"Always wire both width and height for a side.",
],
},
{
heading: "Outputs",
defs: [
["width", "The width from the active source."],
["height", "The height from the active source."],
],
},
],
},
"PixaromaSwitchSource": {
title: "Switch Source Pixaroma",
tagline: "Flip a whole set of wires between two setups (A and B) with one toggle.",
sections: [
{
heading: "What it does",
body: "Each row has an A input and a B input and one output. A single A/B toggle picks which side feeds every row at once. Designed for swapping an entire pipeline in one click - for example switching between local models and API nodes without touching individual wires. Only the chosen side runs.",
},
{
heading: "How to use",
bullets: [
"Set the number of rows you need using the `Rows` field.",
"Wire your first setup into the A inputs (`a_1`, `a_2`, ...) and your second into the B inputs.",
"Click `A` or `B` to choose which bank runs.",
"Click an output label to rename it for the row.",
"`Use connected` lets a row fall back to the other side if the active side is empty; `Strict` raises a clear error instead.",
],
},
{
heading: "Outputs",
defs: [
["output_1 ... output_N", "Each row's output carries its A or B input, depending on the active toggle."],
],
},
],
},
"PixaromaMuteSwitch": {
title: "Mute Switch Pixaroma",
tagline: "Toggle whole workflow branches on and off with per-scene pills.",
sections: [
{
heading: "What it does",
body: "Wire the last node of each branch (usually a KSampler) into a row. Clicking a row's pill skips or enables that entire upstream branch on the next Run. Useful for workflows with several scenes or style variants where you only want to render a subset at a time.",
},
{
heading: "Mode pills",
defs: [
["Single / Multi", "Single is like a radio button - exactly one scene runs. Multi lets any combination run together."],
["Mute / Bypass", "Mute means an off scene does not run at all. Bypass means each node passes its input through unchanged."],
],
},
{
heading: "How to use",
bullets: [
"Wire the last node of each branch into a row (`input_1`, `input_2`, ...).",
"Click a row's pill to toggle it on (orange) or off (grey).",
"Rename a row by clicking its label.",
"Right-click the node for `Enable all rows` / `Disable all rows` shortcuts in Multi mode.",
],
},
{
heading: "Outputs",
defs: [
["out", "A phantom pass-through used only to chain Mute Switches together. Carries no real data - do not wire it into regular nodes."],
],
},
],
},
"PixaromaResolution": {
title: "Resolution Pixaroma",
tagline: "One-click resolution picker for AI image sizes, with ratio chips, snap, custom ratios, and math.",
sections: [
{
heading: "What it does",
body: "Shows a grid of aspect-ratio chips (1:1, 16:9, 9:16, 2:1, 3:2, 2:3, 4:3, 3:4, 4:5). Click a ratio to see its most popular sizes, then click a size to set it. Outputs `width` and `height` as integers ready to wire into an Empty Latent Image or any size input.",
},
{
heading: "Modes",
defs: [
["Preset", "Pick a ratio chip, then a preset size for that ratio. Covers SDXL, AI video, and social media sizes."],
["Custom Ratio", "Type any W:H (such as 21:9) and get auto-computed AI-friendly sizes."],
["Custom Resolution", "Type exact pixel dimensions. Math expressions like `1024+128` or `512*2` work in the W and H fields."],
],
},
{
heading: "Snap",
body: "The `16`, `32`, and `64` snap chips round the output to that multiple (VAE-friendly). Use `none` to turn it off.",
},
{
heading: "Outputs",
defs: [
["width", "Chosen width in pixels."],
["height", "Chosen height in pixels."],
],
},
],
},
"PixaromaNumber": {
title: "Number Pixaroma",
tagline: "One number field that outputs the same value as both INT and FLOAT.",
sections: [
{
heading: "What it does",
body: "Type a number once and get two outputs: `int` (rounded to the nearest whole number) and `float` (kept with decimals). Handy when one downstream node needs an integer and another needs a decimal from the same value. The field accepts whole numbers, decimals, and math like `1024+64` or `512*2`.",
},
{
heading: "Outputs",
defs: [
["int", "The value rounded to the nearest whole number."],
["float", "The value as-is, with decimals preserved."],
],
},
],
},
"PixaromaWH": {
title: "WH Pixaroma",
tagline: "Two number fields for width and height, passed straight through as outputs.",
sections: [
{
heading: "What it does",
body: "A minimal node with just a `width` and `height` field. Type the size you want and the values come out the other side. Pair it with Switch WH Pixaroma to toggle between a typed size and a size coming from another node.",
},
{
heading: "Outputs",
defs: [
["width", "The width you typed, in pixels."],
["height", "The height you typed, in pixels."],
],
},
],
},
"PixaromaTextOverlay": {
title: "Text Overlay Pixaroma",
tagline: "Adds a styled text layer on top of an image, with a fullscreen editor for precise placement.",
sections: [
{
heading: "What it does",
body: "Draws a single block of styled text over the input image. Configure font, size, weight, italic, alignment, line height, letter spacing, color, opacity, rotation, position, and an optional background bar on the node panel. Click `Open Text Editor` for a fullscreen canvas where you can drag the text to move it, drag a corner to scale, drag the handle to rotate, use snap guides, or align to canvas edges.",
},
{
heading: "Inputs",
defs: [
["image", "Required image to draw the text on."],
["text", "Optional wire from any STRING source. When connected it replaces whatever text is typed on the panel (the box greys out as a reminder)."],
],
},
{
heading: "Outputs",
defs: [
["image", "The input image with your text drawn on top."],
],
},
],
footer: "Vertical text direction is supported via the `Horizontal | Vertical` toggle in the panel.",
},
"PixaromaTextWatermark": {
title: "Text Watermark Pixaroma",
tagline: "Stamps a styled text watermark at a fixed anchor on every image in a batch.",
sections: [
{
heading: "What it does",
body: "Adds a text watermark to an image or a whole batch. Unlike Text Overlay there is no fullscreen editor - you configure everything on the node panel and hit Run. Position is set by choosing one of nine anchor points (a corner, edge midpoint, or center) plus a margin inset, so the watermark lands in the same relative spot on every image regardless of its size.",
},
{
heading: "Size mode",
body: "Switch between `px` (a fixed pixel size) and `% width` (a percentage of each image's width). Percentage mode keeps the watermark visually the same across a batch of mixed resolutions.",
},
{
heading: "Inputs",
defs: [
["image", "Image or batch to stamp the watermark onto."],
["text", "Optional wire from any STRING source. When connected it replaces the panel text (the box greys out)."],
],
},
{
heading: "Outputs",
defs: [
["image", "The input image(s) with the watermark drawn on top."],
],
},
],
footer: "Vertical text direction is supported via the `Horizontal | Vertical` toggle.",
},
"NotifyPixaroma": {
title: "Notify Pixaroma",
tagline: "Plays a sound in your browser when this node is reached during a workflow run.",
sections: [
{
heading: "What it does",
body: "Drop one at the end of a workflow to hear when rendering is done, or branch one off any node to get an audio alert at a checkpoint. Useful when you are in another tab or app while ComfyUI runs. The sound fires every Run even when upstream is cached.",
},
{
heading: "Inputs",
defs: [
["any", "Wire any node output here. The data passes through untouched - this node only listens for when it is reached."],
["enabled", "Per-node mute switch. Turn it off to silence just this node."],
["sound", "Which sound to play. Lists every `.mp3`, `.wav`, and `.ogg` in the `assets/sounds/` folder. Add your own there (then restart ComfyUI)."],
["volume", "Playback volume from 0 (silent) to 100 (full)."],
["label", "Optional name shown in the browser console when the node fires. Helpful with several Notify nodes in one workflow."],
],
},
],
footer: "The `Preview` button plays the sound now, ignoring the toggles. A global on/off lives in Settings - Pixaroma - Notify.",
},
"PixaromaReferenceNode": {
title: "Reference Node",
tagline: "Developer reference node demonstrating the DOM widget pattern. Not for production workflows.",
sections: [
{
heading: "What it does",
body: "An internal example node used during development to test the custom DOM widget pattern. It outputs a STRING and is not useful in regular workflows.",
},
],
},
"Pixaroma_VueReferenceNode": {
title: "Pixaroma Vue Reference Node",
tagline: "Developer reference node for the Nodes 2.0 API. Not for production workflows.",
sections: [
{
heading: "What it does",
body: "An internal example node used during development to test the Nodes 2.0 (Vue) API. It inverts an input image as a demo and is not useful in regular workflows.",
},
],
},
"PixaromaVersionCheck": {
title: "Version Check Pixaroma",
tagline: "Shows your ComfyUI, frontend, node renderer, and Pixaroma version numbers in one place.",
sections: [
{
heading: "What it does",
body: "Displays four lines: ComfyUI backend version, ComfyUI frontend version, which node interface is active (`Nodes 2.0` or `Legacy`), and the installed Pixaroma version. Click `Copy` to copy all four as plain text for a bug report. Click the `Node UI` row to switch renderers (the page reloads). Click `Refresh` for a hard cache-clearing reload.",
},
],
footer: "No inputs, no outputs, no work on Run - it is a pure info panel.",
},
};
for (const [cls, def] of Object.entries(HELP)) registerNodeHelp(cls, def);
+89
View File
@@ -0,0 +1,89 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Pixaroma - Help button in the node selection toolbar ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Adds a single Help (?) button to ComfyUI's selection toolbar (the floating
// bar above a selected node, next to the native ⓘ Node Info). It appears ONLY
// when the selected node is a Pixaroma node that has registered help via
// registerNodeHelp(comfyClass, helpDef) (js/shared/help.mjs). Clicking it opens
// the same themed help popup the in-body ? buttons use.
//
// This is the OFFICIAL extension path (verified against frontend 1.44.19), not a
// monkey-patch: ComfyUI calls `getSelectionToolboxCommands(item)` on every
// extension to collect command IDs to show, looks each up in the command store,
// and renders it via ExtensionCommandButton as `<i :class="command.icon">` +
// `@click="command.function()"`. So we register one command and answer the hook.
// On older ComfyUI builds that lack the hook it's simply never called -> the
// command is registered but never shown (harmless, no error).
import { app } from "/scripts/app.js";
import { openHelpPopup, getNodeHelp } from "../shared/index.mjs";
import "./help_defs.mjs"; // registers help for most Pixaroma nodes (one place to edit)
const CMD_ID = "Pixaroma.ShowHelp";
const ICON_CLASS = "pix-help-toolbar-icon";
const CSS_ID = "pix-help-toolbar-css";
const BRAND = "#f66744";
const QUESTION_ICON = "/pixaroma/assets/icons/note/question-mark.svg";
// command.icon renders as the class on an <i>, so we draw the orange circle + ?
// purely in CSS: a filled BRAND circle with the SAME question.svg glyph (white)
// the in-body ? buttons use, so it matches the XY Plot / Find-and-Replace icon.
function injectIconCSS() {
if (document.getElementById(CSS_ID)) return;
const el = document.createElement("style");
el.id = CSS_ID;
el.textContent = `
.${ICON_CLASS} {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%;
background: ${BRAND};
}
.${ICON_CLASS}::before {
content: ""; width: 10px; height: 10px; background-color: #fff;
-webkit-mask: url("${QUESTION_ICON}") center / contain no-repeat;
mask: url("${QUESTION_ICON}") center / contain no-repeat;
}
`;
document.head.appendChild(el);
}
// The first selected Pixaroma node that has registered help (or null). Reads
// both selection maps: selected_nodes (the node map in both renderers) and, as a
// fallback, selectedItems (a Set that can mix nodes + groups).
function selectedHelp() {
const c = app.canvas;
if (!c) return null;
const nodes = [];
if (c.selected_nodes) nodes.push(...Object.values(c.selected_nodes));
if (c.selectedItems) for (const it of c.selectedItems) if (it && it.comfyClass) nodes.push(it);
for (const n of nodes) {
const help = getNodeHelp(n && n.comfyClass);
if (help) return help;
}
return null;
}
app.registerExtension({
name: "Pixaroma.HelpToolbar",
commands: [
{
id: CMD_ID,
label: "Help",
icon: ICON_CLASS,
function: () => {
const help = selectedHelp();
if (help) openHelpPopup(help);
},
},
],
// ComfyUI asks each extension which toolbar commands to show for a selected
// item. Show our Help button only for a Pixaroma node that registered help.
getSelectionToolboxCommands(item) {
if (item && item.comfyClass && getNodeHelp(item.comfyClass)) return [CMD_ID];
return [];
},
setup() {
injectIconCSS();
},
});
+71 -56
View File
@@ -230,13 +230,16 @@ function renderLoadPreviewCanvas(node) {
}
// --- Image canvas (bottom) ---
// Height is controlled by flex (the canvas is `flex:1` inside the controls
// panel), so we READ the resolved clientHeight rather than setting it - the
// ResizeObserver re-renders when the node is dragged, so the image fills
// whatever height the canvas is given (contained / letterboxed).
const imgCv = node._pixLiImageCanvas;
if (imgCv && imgCv.clientWidth > 0) {
if (imgCv && imgCv.clientWidth > 0 && imgCv.clientHeight > 0) {
const cssW = imgCv.clientWidth;
const cssH = imgCv.clientHeight;
const DIMS_H = 18;
const imgAreaH = liPreviewImgH(node);
const cssH = imgAreaH + DIMS_H;
if (imgCv.style.height !== cssH + "px") imgCv.style.height = cssH + "px";
const imgAreaH = Math.max(20, cssH - DIMS_H);
const ctx = _liSizeCanvas(imgCv, cssW, cssH);
const im = _liCurrentImage(node);
if (im) {
@@ -362,21 +365,20 @@ function renderUI(node) {
node.graph?.setDirtyCanvas?.(true, true);
}
// Auto-fit the node height so the native image preview hugs the controls with
// a small, consistent gap in every mode (the "Hug the controls" choice). The
// native preview reserves a FIXED minimum (~220px), so a square/landscape image
// floats inside it. Instead we size the node so the preview area matches the
// image's ASPECT (previewTop + width x aspect), so the picture fills the area
// in every shape — square, portrait, landscape. Deterministic (same image +
// mode + node width => same height) so a reload recomputes the saved height and
// never dirties (Vue Compat #18). Layout (measured): slot area above the
// controls widget = outputs * NODE_SLOT_HEIGHT + 6; controls height =
// measureContentHeight; preview area = the rest. Deferred to rAF so the
// freshly-rendered panel has laid out before measuring; gated on
// !isGraphLoading so it never resizes during a workflow load (Vue Compat #19).
// Legacy node-height fitting. STABLE preview area (issue #1): the node must NOT
// resize itself to the loaded image's aspect ratio (that ballooned the node for
// tall / wide images and overlapped neighbouring nodes). Instead we PRESERVE the
// current preview area, so loading a different-shaped image leaves the node
// height where it is - the native bottom preview just contains the new image
// inside the existing area, exactly like native Load Image. Only a CONTROLS
// height change (a mode switch) shifts the node, and the user can still drag the
// node taller to enlarge the preview (the new size is then preserved). Gated on
// !isGraphLoading so it never resizes during a workflow load (Vue Compat #18/#19).
const LI_LEGACY_PREVIEW_MIN = 120; // below this there is no real preview area yet -> use the default
const LI_LEGACY_PREVIEW_DEFAULT = 260; // comfortable preview area for a fresh / too-short node
function fitPreview(node) {
// Legacy only: resizes node.size to hug the native bottom preview. In Nodes
// 2.0 our preview widget flex-fills, so this would fight the Vue layout.
// Legacy only: in Nodes 2.0 the preview is a flex-grower canvas child of the
// controls panel (it fills the node body), so sizing is handled by the panel there.
if (isVueNodes()) return;
if (isGraphLoading()) return;
requestAnimationFrame(() => {
@@ -384,15 +386,11 @@ function fitPreview(node) {
const SLOT_H = (typeof LiteGraph !== "undefined" && LiteGraph.NODE_SLOT_HEIGHT) || 20;
const aboveControls = (node.outputs?.length || 7) * SLOT_H + 6; // slot area above the controls
const controlsH = node._pixLiMeasureHeight?.() || 280;
const img = node.imgs?.[0];
let previewH;
if (img?.naturalWidth) {
const innerW = Math.max(40, (node.size[0] || 360) - 16); // preview spans node width minus side margin
previewH = Math.round(innerW * img.naturalHeight / img.naturalWidth) + 22; // + dims label row
previewH = Math.max(90, Math.min(previewH, 1400));
} else {
previewH = 180; // no image yet — modest default
}
// Keep whatever preview area the node already has (so image swaps don't
// resize it, and a manual drag-taller sticks); fall back to a default only
// when there is no sensible area yet (fresh / collapsed node).
const curPreviewH = (node.size?.[1] || 0) - aboveControls - controlsH;
const previewH = curPreviewH >= LI_LEGACY_PREVIEW_MIN ? curPreviewH : LI_LEGACY_PREVIEW_DEFAULT;
const target = aboveControls + controlsH + previewH;
if (Math.abs((node.size?.[1] || 0) - target) > 1) {
node.size[1] = target;
@@ -480,30 +478,33 @@ function injectLoadImageNodes2CSS() {
if (document.getElementById("pix-li-nodes2-css")) return;
const s = document.createElement("style");
s.id = "pix-li-nodes2-css";
s.textContent = ".lg-node:has(.pix-li-root) .image-preview{display:none !important;}";
s.textContent =
// Hide ComfyUI's native input image-preview for this node (we draw our own).
".lg-node:has(.pix-li-root) .image-preview{display:none !important;}" +
// CRITICAL (issue #1 Nodes 2.0 fill): the Vue node ALSO renders a native
// image-preview CONTAINER (a `flex:1` box) right after the widget grid on
// image_upload nodes. It is a SECOND `flex:1` child of the node content, so
// it SPLITS the free vertical height with our widget area (also `flex:1`),
// leaving a large empty gap below our preview when the node is dragged tall.
// Collapse that container so our widget area is the SOLE grower and fills the
// node, like native Load Image. It is the only `flex:1` div immediately after
// the widget grid; we render our own preview, so hiding it loses nothing.
".lg-node:has(.pix-li-root) .lg-node-widgets + div.flex-1{display:none !important;}";
document.head.appendChild(s);
}
// Cards strip height + image-area clamp constants for the Nodes 2.0 preview.
// LI_PREVIEW_MAX_IMG_H caps the image preview so the node stays COMPACT in Nodes
// 2.0 (where it auto-grows to content): a square/portrait image is contained
// within this height rather than ballooning to full-width-aspect. Legacy is
// untouched (drag-to-resize fill). Tune for a bigger/smaller default preview.
// Cards strip height for the Nodes 2.0 preview.
const LI_CARDS_H = 124;
const LI_PREVIEW_MIN_IMG_H = 80;
const LI_PREVIEW_MAX_IMG_H = 240;
// Compute the image-area height for the Nodes 2.0 preview: fit the selected
// image to the node's content width at its OWN aspect (so the picture fills the
// width with no letterbox), clamped. Deterministic (same image + node width =>
// same height) so a reload recomputes the same value and never dirties (Vue
// Compat #18). Falls back to a square assumption before the image dims arrive.
function liPreviewImgH(node) {
const cw = Math.max(80, (node.size?.[0] || 400) - 24);
const im = node.imgs?.[0];
const aspect = im?.naturalWidth ? im.naturalHeight / im.naturalWidth : 1;
return Math.max(LI_PREVIEW_MIN_IMG_H, Math.min(Math.round(cw * aspect), LI_PREVIEW_MAX_IMG_H));
}
// Nodes 2.0 preview-canvas FLOOR (issue #1). The preview canvas is a flex
// grower inside the controls panel (the node's sole grower widget), so it fills
// any extra node height: drag the node taller and the preview grows instead of
// leaving an empty gap, exactly like native Load Image; at the smallest node
// size it sits at this floor. It never grows the node on its OWN (loading a
// different-shaped image keeps the node put), and the image is contained (fit +
// letterboxed) inside whatever height the canvas ends up at. Value = image area
// (~240) + the dims label row (18). A constant floor is dirty-proof on reload
// (Vue Compat #18). Tune for a bigger / smaller minimum preview.
const LI_PREVIEW_FILL_MIN = 258;
// Nodes 2.0 preview: a second DOM widget's host collapses to 0 on this node (the
@@ -524,10 +525,13 @@ function createLoadImagePreviewCanvas(node) {
root.insertBefore(cardsCv, root.firstChild);
node._pixLiCardsCanvas = cardsCv;
// Image canvas — at the BOTTOM (fills, like Legacy's bottom preview).
// Image canvas — at the BOTTOM. flex:1 so it FILLS the controls panel's free
// vertical space (the panel is the node's grower widget): dragging the node
// taller grows the preview instead of leaving an empty gap, like native Load
// Image. min-height is the floor.
const imgCv = document.createElement("canvas");
imgCv.className = "pix-li-preview-canvas";
imgCv.style.cssText = "display:block;width:100%;box-sizing:border-box;";
imgCv.style.cssText = `display:block;width:100%;box-sizing:border-box;flex:1 1 0;min-height:${LI_PREVIEW_FILL_MIN}px;`;
root.appendChild(imgCv);
node._pixLiImageCanvas = imgCv;
@@ -582,6 +586,11 @@ function setupLoadImageNode(node) {
const style = window.getComputedStyle(child);
if (style.position === "absolute" || style.position === "fixed") continue;
if (style.display === "none") continue;
// The Nodes 2.0 image preview canvas is a flex grower (fills extra node
// height when the node is dragged taller). Count only its MINIMUM in this
// floor, not its grown size - otherwise the grown canvas would re-inflate
// the node's min height and it could never shrink back.
if (child === node._pixLiImageCanvas) { totalH += LI_PREVIEW_FILL_MIN; visible += 1; continue; }
totalH += child.offsetHeight;
visible += 1;
}
@@ -627,7 +636,14 @@ function setupLoadImageNode(node) {
// getMinHeight/getMaxHeight and the native bottom preview fills.) The renderer
// is fixed per page load, so this branch runs once per node instance.
if (isVueNodes()) {
widget.computeLayoutSize = undefined; // min-content row (CSS grid), not a flex grower
// Make the controls panel the node's GROWER (auto row). Its min is the
// content floor (controls + the preview floor); any extra node height (user
// drags the node taller) is absorbed here, and the image canvas inside
// (flex:1) fills that slack so the preview grows instead of leaving an empty
// gap, like native Load Image. minWidth:1 so the saved node width round-trips
// (Compare gotcha 2). It is the node's only visible widget, so it is safely
// the sole grower.
widget.computeLayoutSize = () => ({ minHeight: measureContentHeight(), minWidth: 1 });
createLoadImagePreviewCanvas(node);
injectLoadImageNodes2CSS();
}
@@ -649,9 +665,8 @@ function setupLoadImageNode(node) {
// Called by api.mjs updateNativePreview() once a freshly-loaded image has
// its naturalWidth/naturalHeight available (arrow / dropdown / upload / paste
// picks all route through here). Refreshes the dims readout AND runs the
// pending auto-fit so the preview re-sizes to the NEW image's aspect — this
// is the path that was missing the fit, so a portrait picked after a square
// stayed squeezed (onImageReady consumes _pixLiFitPending).
// pending fit, which now keeps a STABLE preview area instead of resizing to
// the loaded image's aspect (issue #1; onImageReady consumes _pixLiFitPending).
node._pixLiOnImageLoaded = () => onImageReady();
// Refresh info bar once node.imgs[0] is loaded — covers both the
@@ -663,9 +678,9 @@ function setupLoadImageNode(node) {
// cleanup picks up the same handle.
// When the image is ready, refresh the readout and — only if a fit was
// requested by a user action (fresh drop / pick / upload / paste / drop), not
// a workflow restore — auto-fit the node so the preview hugs the controls at
// the image's aspect. _pixLiFitPending guards against firing on restore (the
// saved height is trusted then; Vue Compat #18).
// a workflow restore — re-fit the node to a STABLE preview area (it no longer
// resizes to the image's aspect, issue #1). _pixLiFitPending guards against
// firing on restore (the saved height is trusted then; Vue Compat #18).
function onImageReady() {
updateInfoBar(node);
// Nodes 2.0: refresh our own DOM image preview (native one is hidden/stale).
+2 -2
View File
@@ -1002,7 +1002,7 @@ export class NoteEditor {
For the list of HTML tags / styles / classes allowed in Code
view, click <b>? Code</b> in the footer.<br>
Designed by <a href="https://www.youtube.com/@pixaroma" target="_blank" rel="noopener noreferrer">Pixaroma</a>
&middot; <a href="https://github.com/pixaroma/ComfyUI-Pixaroma" target="_blank" rel="noopener noreferrer">GitHub</a>
&middot; <a href="https://gitlab.com/pixaroma/comfyui-pixaroma" target="_blank" rel="noopener noreferrer">GitLab</a>
</div>
`;
const close = h.querySelector(".pix-note-help-close");
@@ -1127,7 +1127,7 @@ export class NoteEditor {
</div>
<div class="pix-note-help-footer">
Designed by <a href="https://www.youtube.com/@pixaroma" target="_blank" rel="noopener noreferrer">Pixaroma</a>
&middot; <a href="https://github.com/pixaroma/ComfyUI-Pixaroma" target="_blank" rel="noopener noreferrer">GitHub</a>
&middot; <a href="https://gitlab.com/pixaroma/comfyui-pixaroma" target="_blank" rel="noopener noreferrer">GitLab</a>
</div>
`;
const close = h.querySelector(".pix-note-help-close");
+14 -1
View File
@@ -44,6 +44,19 @@ const CSS_ID = "pix-help-css";
const QUESTION_ICON = "/pixaroma/assets/icons/note/question.svg";
const BRAND = "#f66744";
// ── Per-node help registry ───────────────────────────────────
// Maps a node's comfyClass -> its help definition. A single shared surface (the
// selection-toolbar Help button, js/help_toolbar) can then show the right help
// for whichever Pixaroma node is selected, without each node wiring its own
// in-body ? button. A node opts in with: registerNodeHelp("MyClass", MY_HELP).
const _nodeHelp = new Map();
export function registerNodeHelp(comfyClass, helpDef) {
if (comfyClass && helpDef) _nodeHelp.set(comfyClass, helpDef);
}
export function getNodeHelp(comfyClass) {
return comfyClass ? _nodeHelp.get(comfyClass) || null : null;
}
const CSS = `
/* ---- the ? button nodes drop into their body ---- */
.pix-help-btn {
@@ -64,7 +77,7 @@ const CSS = `
}
.pix-help-card {
background: #1d1d1d; border: 1px solid #333; border-radius: 8px;
width: min(680px, 92vw); max-height: 82vh; display: flex; flex-direction: column;
width: min(840px, 94vw); max-height: 82vh; display: flex; flex-direction: column;
box-shadow: 0 14px 52px rgba(0,0,0,0.6); overflow: hidden; color: #cfcfcf;
animation: pix-help-in 0.14s ease;
}
+2
View File
@@ -40,4 +40,6 @@ export {
openHelpPopup,
closeHelpPopup,
injectHelpCSS,
registerNodeHelp,
getNodeHelp,
} from "./help.mjs";
+54
View File
@@ -0,0 +1,54 @@
// Text Pixaroma - dynamic-prompt resolver.
//
// Only runs when the node's "Dynamic prompts" switch is ON (default OFF). When
// OFF, the typed text is passed through verbatim - every { } stays literal, no
// comment is stripped - so JSON prompts and braces survive untouched. This is
// the opt-in replacement for ComfyUI's native `dynamicPrompts: True` flag, which
// processed EVERY Text Pixaroma unconditionally and ate JSON braces (the brace
// eating is by design for the {a|b} feature, just no longer forced on everyone).
//
// Implements the documented contract from node_text.py:
// - {a|b|c} -> one option picked at random each queue (nest freely: {a|{b|c}})
// - \{ \} -> a literal brace (escaped, never treated as a group)
// - // ... -> line comment, stripped to end of line
// - /* ... */-> block comment, stripped
// A pipe-less group {x} resolves to x (braces removed) and {} resolves to empty,
// matching ComfyUI's native behavior so turning the switch ON reproduces exactly
// what the node did before this toggle existed.
// Private-use-area placeholders for escaped braces - astronomically unlikely to
// appear in real prompt text, so they round-trip without colliding with content.
// Built with fromCharCode so the source stays pure ASCII (no invisible chars).
const ESC_OPEN = String.fromCharCode(0xe000);
const ESC_CLOSE = String.fromCharCode(0xe001);
export function resolveDynamicPrompt(input) {
if (typeof input !== "string") return input;
// Fast path: nothing a resolver would touch - no brace, no comment marker, and
// no backslash (a lone escaped brace like \} with no { still needs unescaping
// to a literal }, so any backslash takes the full path; a non-escape backslash
// is left untouched by the full path, so this is safe, just slightly slower).
if (input.indexOf("{") === -1 && input.indexOf("\\") === -1 &&
input.indexOf("//") === -1 && input.indexOf("/*") === -1) {
return input;
}
// 1) Strip block comments, then line comments (matches the documented order).
let s = input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n\r]*/g, "");
// 2) Protect escaped braces so they survive resolution as literals.
s = s.split("\\{").join(ESC_OPEN).split("\\}").join(ESC_CLOSE);
// 3) Resolve the innermost {a|b|c} groups repeatedly until none remain. The
// [^{}] body can't span a nested group, so each pass collapses the deepest
// level; looping handles nesting. The guard caps pathological inputs.
let prev;
let guard = 0;
do {
prev = s;
s = s.replace(/\{([^{}]*)\}/g, (_m, body) => {
const opts = body.split("|");
return opts[Math.floor(Math.random() * opts.length)];
});
} while (s !== prev && ++guard < 1000);
// 4) Restore the escaped braces as literal characters.
s = s.split(ESC_OPEN).join("{").split(ESC_CLOSE).join("}");
return s;
}
+297 -23
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { BRAND, applyAdaptiveCanvasOnly } from "../shared/index.mjs";
import { BRAND, applyAdaptiveCanvasOnly, registerNodeHelp, closeHelpPopup, isVueNodes, installResizeFloor } from "../shared/index.mjs";
import { resolveDynamicPrompt } from "./dynamic_prompts.mjs";
// Text Pixaroma: multi-line text field with a STRING output. The native
// ComfyUI multiline widget is HIDDEN; we render our own DOM widget so the
@@ -14,9 +15,21 @@ import { BRAND, applyAdaptiveCanvasOnly } from "../shared/index.mjs";
// Default = minimum, so fresh-on-canvas drops are compact and the user
// grows the node only when they need more typing room. Matches the
// approach used by Show Text Pixaroma. Values verified by sizer overlay.
const DEFAULT_W = 290;
// DEFAULT_W = 412: a FRESH node opens wide enough for the whole bottom row
// (Copy all / Replace / Clear / Dynamic prompts / ? help) on ONE line. 412 was
// MEASURED in-ComfyUI with the sizer snippet (Nodes 2.0, the tighter renderer):
// node width 400 wrapped the ?, 410 fit, so 412 is a small margin.
// MIN_W = 412 is the LEGACY resize floor (legacy can't be dragged narrower than
// where the ? fits). In NODES 2.0 the node.size min-clamp is GATED OFF (see the
// gated onResize/onDrawForeground below + the CLAUDE.md "Nodes 2.0 manual-resize
// MINIMUM" rule): clamping node.size there desyncs the Vue layout store (drag
// smaller -> node.size silently snaps back to 412 -> the node JUMPS bigger on a
// workflow switch + can false-dirty). So in Nodes 2.0 the user CAN drag the node
// narrower; the bottom bar flex-wraps the trailing items gracefully and
// installResizeFloor keeps the row from spilling out the bottom of the frame.
const DEFAULT_W = 412;
const DEFAULT_H = 158;
const MIN_W = 290;
const MIN_W = 412;
const MIN_H = 158;
const WIDGET_MIN_H = 120;
@@ -63,6 +76,11 @@ function injectCSS() {
align-items: center;
flex: 0 0 auto;
gap: 4px;
/* Wrap the Dynamic prompts switch onto a second line when the node is
too narrow to fit the whole row (e.g. an existing 290-wide node).
row-gap keeps a little space between the wrapped lines. */
flex-wrap: wrap;
row-gap: 4px;
padding: 0 2px;
/* Stop text selection bleeding from the textarea into the button
labels when the user drag-selects to the edge of the field. */
@@ -71,7 +89,7 @@ function injectCSS() {
.pix-text-actbtn {
/* box-sizing border-box so min-width is total including padding */
box-sizing: border-box;
min-width: 86px;
min-width: 72px;
user-select: none;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
@@ -123,10 +141,118 @@ function injectCSS() {
flex: 0 0 auto;
user-select: none;
}
/* Dynamic prompts switch - a STICKY on/off state, styled distinctly from
the click-and-done action buttons so it doesn't read as a 4th button.
OFF = adaptive semi-transparent white (subtle on any node color);
ON = solid BRAND orange. Hover on OFF lifts the border to BRAND (no fill)
per the Pixaroma bordered-control convention. */
.pix-text-switch {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
user-select: none;
white-space: nowrap;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font: 11px sans-serif;
padding: 4px 9px;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.pix-text-switch:hover {
border-color: ${BRAND};
color: rgba(255, 255, 255, 0.92);
}
.pix-text-switch-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.55);
background: transparent;
flex: 0 0 auto;
box-sizing: border-box;
}
.pix-text-switch.is-on {
background: ${BRAND};
border-color: ${BRAND};
color: #fff;
}
.pix-text-switch.is-on .pix-text-switch-dot {
background: #fff;
border-color: #fff;
}
.pix-text-switch[disabled] {
opacity: 0.4;
cursor: default;
}
.pix-text-switch[disabled]:hover {
border-color: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
}
`;
document.head.appendChild(style);
}
// In-node Help (?) panel content. Plain prose + inline `code` chips (the help
// renderer escapes HTML, so write code/syntax inside backticks). See
// js/shared/help.mjs for the schema.
const TEXT_HELP = {
title: "Text Pixaroma",
tagline: "A multi-line text box with a single text output. Type once, wire it into as many nodes as you like.",
sections: [
{
heading: "The text box",
body:
"Type any text or prompt here and it comes out the `text` output. The box grows with the node - drag the bottom-right corner to make it bigger. Press `Ctrl+Enter` to run the workflow without leaving the box.\n\n" +
"Tip: to RECEIVE text from another node instead of typing, use Show Text Pixaroma - this node is for typing.",
},
{
heading: "Buttons",
defs: [
["Copy all", "Copy everything in the box to your clipboard."],
["Replace", "Paste over the whole box with text from your clipboard. An image or empty clipboard is ignored, so you can't wipe your text by accident."],
["Clear", "Empty the box instantly."],
],
},
{
heading: "Dynamic prompts switch",
body:
"Off by default. It decides whether curly braces are treated as a random-pick instruction.\n\n" +
"Leave it OFF for normal text and JSON: everything you type is sent exactly as-is, every `{` and `}` kept.\n\n" +
"Turn it ON to use wildcards:",
bullets: [
"`{red|blue|green}` picks one option at random every time you run.",
"Nest them freely, e.g. `a {big|small} {cat|dog}`.",
"Use `\\{` and `\\}` when you want a real brace while the switch is on.",
"Comments are removed when on: `//` to the end of a line, and `/* ... */` blocks.",
],
},
{
heading: "What the switch does to your text",
table: {
headers: ["You type", "Switch ON", "Switch OFF"],
rows: [
["a photo of a {cat|dog}", "a photo of a cat (or dog)", "a photo of a {cat|dog}"],
['{ \"style\": \"noir\" }', '\"style\": \"noir\"', '{ \"style\": \"noir\" }'],
["see http://site.com", "see http:", "see http://site.com"],
],
},
},
{
heading: "Editing JSON? Keep it OFF",
body:
"With the switch ON, every `{` and `}` is eaten and anything after `//` (like a URL) is stripped - that is the wildcard feature working as designed, not a bug. So for JSON prompts, or any text where braces matter, leave the switch OFF (its default).",
},
],
};
// Help is shown by the selection-toolbar Help button (js/help_toolbar).
registerNodeHelp("PixaromaText", TEXT_HELP);
function toast(severity, msg) {
const t = app?.extensionManager?.toast;
if (t?.add) t.add({ severity, summary: "Text Pixaroma", detail: msg, life: 2000 });
@@ -207,7 +333,20 @@ function buildRoot() {
clearBtn.textContent = "Clear";
clearBtn.title = "Empty the textarea instantly (no confirm)";
bottombar.append(copyBtn, replaceBtn, clearBtn);
// Dynamic prompts switch - sits right after Clear, styled as a sticky on/off
// switch (not a 4th action button). State lives on node.properties (default
// OFF); the graphToPrompt hook resolves {a|b} only when it is ON.
const dynSwitch = document.createElement("button");
dynSwitch.type = "button";
dynSwitch.className = "pix-text-switch";
dynSwitch.title = "Dynamic prompts: {a|b} picks one at random each run. Turn OFF to keep literal { } braces (e.g. for JSON).";
const dynDot = document.createElement("span");
dynDot.className = "pix-text-switch-dot";
const dynLabel = document.createElement("span");
dynLabel.textContent = "Dynamic prompts";
dynSwitch.append(dynDot, dynLabel);
bottombar.append(copyBtn, replaceBtn, clearBtn, dynSwitch);
// Hint shown when the text input is wired (hidden by default).
const lockHint = document.createElement("div");
@@ -216,7 +355,7 @@ function buildRoot() {
lockHint.style.display = "none";
root.append(tawrap, lockHint, bottombar);
root._pixText = { ta, copyBtn, replaceBtn, clearBtn, lockHint };
root._pixText = { ta, copyBtn, replaceBtn, clearBtn, dynSwitch, lockHint };
return root;
}
@@ -229,10 +368,31 @@ function isTextInputWired(node) {
return false;
}
// --- Dynamic prompts switch state (persisted on node.properties) -----------
const DYN_PROP = "pixTextDynamicPrompts";
function isDynamicOn(node) {
return !!node.properties?.[DYN_PROP];
}
function setDynamic(node, on) {
node.properties = node.properties || {};
node.properties[DYN_PROP] = !!on;
}
// Reflect the saved switch state onto the DOM switch (orange dot + .is-on).
function applyDynamicSwitch(node) {
const root = node._pixTextRoot;
const els = root && root._pixText;
if (!els || !els.dynSwitch) return;
els.dynSwitch.classList.toggle("is-on", isDynamicOn(node));
}
function refreshTextLock(node) {
const root = node._pixTextRoot;
if (!root || !root._pixText) return;
const { ta, lockHint, copyBtn, replaceBtn, clearBtn } = root._pixText;
const { ta, lockHint, copyBtn, replaceBtn, clearBtn, dynSwitch } = root._pixText;
const wired = isTextInputWired(node);
if (wired) {
ta.readOnly = true;
@@ -242,11 +402,15 @@ function refreshTextLock(node) {
// still want to copy whatever they typed before wiring).
replaceBtn.disabled = true;
clearBtn.disabled = true;
// The switch only affects TYPED text; when the input is wired the typed
// text is ignored, so the switch is moot - dim it for clarity.
if (dynSwitch) dynSwitch.disabled = true;
} else {
ta.readOnly = false;
ta.classList.remove("pix-text-locked");
lockHint.style.display = "none";
replaceBtn.disabled = false;
if (dynSwitch) dynSwitch.disabled = false;
updateClearEnabled(root);
}
}
@@ -319,7 +483,14 @@ function wireEvents(node, root) {
updateClearEnabled(root);
});
for (const b of [els.copyBtn, els.replaceBtn, els.clearBtn]) {
els.dynSwitch.addEventListener("click", (e) => {
e.stopPropagation();
if (els.dynSwitch.disabled) return;
setDynamic(node, !isDynamicOn(node));
applyDynamicSwitch(node);
});
for (const b of [els.copyBtn, els.replaceBtn, els.clearBtn, els.dynSwitch]) {
b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
}
@@ -331,6 +502,27 @@ function updateClearEnabled(root) {
els.clearBtn.disabled = !(els.ta.value && els.ta.value.length > 0);
}
// Minimum content height for the Nodes 2.0 resize floor: the textarea at its CSS
// minimum (.pix-text-tawrap min-height 60) + the bottom bar's CURRENT height
// (which already accounts for flex-wrap) + the wired-hint when shown + the root
// gaps + vertical padding. Uses the textarea MIN (not its grown height) so the
// floor lets the node shrink down to where content just fits, then no further.
function measureTextFloor(root) {
if (!root) return 0;
const TAWRAP_MIN = 60; // keep in sync with .pix-text-tawrap min-height
const bar = root.querySelector(".pix-text-bottombar");
const hint = root.querySelector(".pix-text-lockhint");
const cs = getComputedStyle(root);
const gap = parseFloat(cs.rowGap || cs.gap) || 0;
const padV = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
let h = TAWRAP_MIN;
let count = 1;
if (bar) { h += bar.offsetHeight; count += 1; }
if (hint && hint.offsetParent !== null) { h += hint.offsetHeight; count += 1; }
if (count > 1) h += gap * (count - 1);
return h + padV;
}
function setupNode(node) {
injectCSS();
const nativeWidget = hideNativeTextWidget(node);
@@ -357,6 +549,13 @@ function setupNode(node) {
});
applyAdaptiveCanvasOnly(_textWidget);
// Nodes 2.0 height floor: while a resize handle is dragged, pin the widget's
// min-height to its content height so the button row can't be dragged out of
// the bottom of the frame. The legacy onResize/onDrawForeground size-clamp is
// gated OFF in Nodes 2.0 (it desyncs the Vue layout store), so this is what
// floors the height there. Uninstalled in onRemoved.
node._pixTextFloorOff = installResizeFloor(root, measureTextFloor);
wireEvents(node, root);
// Apply default size with a MIN guard rather than unconditional set.
@@ -369,8 +568,13 @@ function setupNode(node) {
if (node.size[0] < MIN_W) node.size[0] = DEFAULT_W;
if (node.size[1] < MIN_H) node.size[1] = DEFAULT_H;
// Initial lock-state check deferred until node.inputs is populated by
// configure() (Vue Compat #8 - configure runs after nodeCreated).
queueMicrotask(() => refreshTextLock(node));
// configure() (Vue Compat #8 - configure runs after nodeCreated). Also
// reflect the saved Dynamic prompts switch state here AND in onConfigure
// (belt-and-braces - whichever runs after configure restores properties).
queueMicrotask(() => {
refreshTextLock(node);
applyDynamicSwitch(node);
});
node.setDirtyCanvas(true, true);
}
@@ -395,6 +599,7 @@ app.registerExtension({
updateClearEnabled(root);
}
refreshTextLock(this);
applyDynamicSwitch(this);
});
return r;
};
@@ -409,34 +614,45 @@ app.registerExtension({
return r;
};
// Clamp manual resize so the bottom button row never overflows past
// the node frame. Mutate BOTH the parameter AND this.size defensively
// (some LiteGraph forks treat the param as the new size).
// Clamp manual resize so the bottom button row never overflows past the node
// frame. LEGACY ONLY. In Nodes 2.0 the RENDERED size lives in the Vue layout
// store, not node.size; clamping node.size here desyncs the two - the user
// drags smaller, Vue keeps the smaller render, but node.size snaps back to
// MIN, so the node JUMPS bigger on a workflow switch and can false-dirty the
// workflow (CLAUDE.md "Nodes 2.0 manual-resize MINIMUM"). Nodes 2.0 instead
// floors the height via installResizeFloor and wraps the width via flex-wrap.
const origOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (size[1] < MIN_H) size[1] = MIN_H;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
if (!isVueNodes()) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (size[1] < MIN_H) size[1] = MIN_H;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
}
if (origOnResize) return origOnResize.apply(this, arguments);
};
// Self-heal min size on every paint (Preview Image Pattern #11).
// Catches resize paths that bypass onResize - Vue Compat #13 notes
// some DOM-widget resizes never fire onResize, and Align Pixaroma
// (Align Pattern #6) writes node.size directly via cursor delta.
// Without this safety net the buttons can spill past the node frame
// after grow-then-shrink.
// Self-heal min size on every paint (Preview Image Pattern #11). Catches
// resize paths that bypass onResize (Vue Compat #13) + Align's direct
// node.size writes (Align Pattern #6). LEGACY ONLY for the same desync
// reason as onResize above.
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (origDraw) origDraw.call(this, ctx);
if (this.flags?.collapsed) return;
if (isVueNodes()) return;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
// Close any open Help panel so its document-level Esc listener can't
// leak when the node is deleted while the panel is open.
closeHelpPopup();
// Detach the Nodes 2.0 resize-floor window listeners.
this._pixTextFloorOff?.();
this._pixTextFloorOff = null;
this._pixTextRoot = null;
this._pixTextNative = null;
if (origRemoved) return origRemoved.apply(this, arguments);
@@ -459,3 +675,61 @@ app.registerExtension({
setupNode(node);
},
});
// --- graphToPrompt hook: resolve {a|b} dynamic prompts at submit time -------
// ComfyUI's native `dynamicPrompts: True` was removed from node_text.py (it ate
// JSON braces on EVERY Text Pixaroma). Instead we resolve here, ONLY for nodes
// whose Dynamic prompts switch is ON. Runs per submit, so each queue gets a
// fresh random pick - matching the old native behavior and its caching (a
// different resolved value = a re-run). Subgraph-safe lookup mirrors
// js/find_replace/index.js (a node inside a subgraph gets a composite "5:12" id).
function buildPixTextNodeIndex() {
const index = new Map(); // composite id ("5:12") OR bare id -> node
const visit = (graph, prefix) => {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (!n) continue;
const fullId = prefix + String(n.id);
if (n.comfyClass === "PixaromaText" || n.type === "PixaromaText") {
index.set(fullId, n);
}
const inner = n.subgraph || n.graph || n._graph;
if (inner && inner !== graph) visit(inner, fullId + ":");
}
};
visit(app.graph, "");
return index;
}
function findPixTextNode(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 _origTextGraphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function (...args) {
const result = await _origTextGraphToPrompt.apply(this, args);
try {
const prompt = result?.output;
if (prompt && typeof prompt === "object") {
let index = null;
for (const key of Object.keys(prompt)) {
const entry = prompt[key];
if (!entry || entry.class_type !== "PixaromaText") continue;
if (!index) index = buildPixTextNodeIndex();
const node = findPixTextNode(index, key);
if (!node || !isDynamicOn(node)) continue; // switch OFF -> text stays literal
const v = entry.inputs?.text;
if (typeof v !== "string") continue; // wired input or missing -> nothing to resolve
entry.inputs.text = resolveDynamicPrompt(v);
}
}
} catch (err) {
console.error("Pixaroma.Text: dynamic-prompt graphToPrompt hook failed", err);
}
return result;
};
+5 -14
View File
@@ -8,7 +8,7 @@
import { createEditorLayout } from "../framework/layout.mjs";
import { createTextEditorPanel } from "../framework/text_editor.mjs";
import { resetStateInPlace } from "./defaults.mjs";
import { renderTextLayer } from "../framework/text_render.mjs";
import { renderTextLayer, measureTextDims } from "../framework/text_render.mjs";
import { getFontCatalog, resolveFontVariant, loadFontForLayer } from "../framework/fonts.mjs";
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
@@ -621,19 +621,10 @@ export class TextOverlayEditor {
}
const fam = `Pix-${fontId}${italic ? "-Italic" : ""}`;
ctx.font = `${italic ? "italic " : ""}${weight} ${state.fontSize}px "${fam}"`;
const lines = String(state.text ?? "").split("\n");
const widths = lines.map((ln) => ctx.measureText(ln).width + Math.max(0, ln.length - 1) * (state.letterSpacing || 0));
const lineHeightPx = Math.round(state.fontSize * (state.lineHeight || 1.2));
const m = ctx.measureText("Mg");
const asc = m.actualBoundingBoxAscent || state.fontSize * 0.78;
const desc = m.actualBoundingBoxDescent || state.fontSize * 0.22;
let w = Math.max(0, ...widths);
let h = (asc + desc) + Math.max(0, lines.length - 1) * lineHeightPx;
if (state.bgColor) {
w += 2 * 16; // BG_PAD_X
h += 2 * 10; // BG_PAD_Y
}
return { x: state.x, y: state.y, w: Math.max(20, w), h: Math.max(20, h) };
// Shared direction-aware measure (framework/text_render.mjs) so vertical
// mode aligns / hit-tests / Fit W-H against its real tall-narrow bbox.
const dims = measureTextDims(ctx, state);
return { x: state.x, y: state.y, w: Math.max(20, dims.w), h: Math.max(20, dims.h) };
}
// ── Zoom ──
+1
View File
@@ -17,6 +17,7 @@ export const DEFAULT_STATE = {
weight: 400,
italic: false,
align: "center",
direction: "horizontal",
fontSize: 64,
lineHeight: 1.2,
letterSpacing: 0,
+6 -19
View File
@@ -11,6 +11,7 @@ import { applyAdaptiveCanvasOnly } from "../shared/index.mjs";
import { TextOverlayEditor } from "./core.mjs";
import { createTextEditorPanel } from "../framework/text_editor.mjs";
import { loadFontForLayer, canvasFontString } from "../framework/fonts.mjs";
import { measureTextDims } from "../framework/text_render.mjs";
import { DEFAULT_STATE, resetStateInPlace } from "./defaults.mjs";
import "./interaction.mjs"; // side-effect: registers prototype methods
@@ -169,7 +170,8 @@ function setupTextOverlayNode(node) {
// restored consistently from the saved graph. BASE_H is the panel's content
// height with the hint hidden; if the panel layout changes (add/remove a
// row), update BASE_H to match (the only maintenance cost of this approach).
const BASE_H = 412; // content height, text input NOT wired
const BASE_H = 464; // content height, text input NOT wired
// (was 412; +52 for the Text Direction caption + chip row)
const HINT_H = 18; // extra height when the text input IS wired (lock hint)
function panelHeight() {
const wired = node.inputs?.find((i) => i.name === "text")?.link != null;
@@ -489,6 +491,8 @@ function getUpstreamImage(node) {
// Measure the rendered bbox of the state's text using the same canvas-text
// math as the live editor (and the Python renderer). Returns { w, h }.
// Direction-aware via the shared measureTextDims (framework/text_render.mjs),
// so vertical mode aligns/auto-centers with its real tall-narrow bbox.
let _measureCanvas = null;
async function measureTextBbox(state) {
const variant = await loadFontForLayer(state.font || "Roboto", state.weight || 400, !!state.italic);
@@ -500,24 +504,7 @@ async function measureTextBbox(state) {
}
const ctx = _measureCanvas.getContext("2d");
ctx.font = fontStr;
const lines = String(state.text ?? "").split("\n");
const letterSpacing = state.letterSpacing || 0;
const lineWidths = lines.map((ln) => {
if (letterSpacing === 0) return ctx.measureText(ln).width;
let w = 0; for (const c of ln) w += ctx.measureText(c).width;
return w + Math.max(0, ln.length - 1) * letterSpacing;
});
const maxLineW = Math.max(0, ...lineWidths);
const m = ctx.measureText("Mg");
const ascender = m.actualBoundingBoxAscent || (state.fontSize || 96) * 0.78;
const descender = m.actualBoundingBoxDescent || (state.fontSize || 96) * 0.22;
const lineHeightPx = Math.round((state.fontSize || 96) * (state.lineHeight || 1.2));
const padX = state.bgColor ? 16 : 0;
const padY = state.bgColor ? 10 : 0;
return {
w: Math.ceil(maxLineW + 2 * padX),
h: Math.ceil(ascender + descender + Math.max(0, lines.length - 1) * lineHeightPx + 2 * padY),
};
return measureTextDims(ctx, state);
}
// ── "Position on canvas" from the node body ──────────────────────────────
+1
View File
@@ -16,6 +16,7 @@ export const DEFAULT_STATE = {
weight: 400,
italic: false,
align: "center",
direction: "horizontal",
fontSize: 64,
lineHeight: 1.2,
letterSpacing: 0,
+1 -1
View File
@@ -21,7 +21,7 @@ const HIDDEN_INPUT_NAME = "TextWatermarkState";
// height with the text-lock hint hidden; if the panel layout changes
// (add/remove a row), update BASE_H to match. HINT_H is the extra height when
// the `text` input is wired (the lock-hint row appears).
const BASE_H = 412;
const BASE_H = 464; // (was 412; +52 for the Text Direction caption + chip row)
const HINT_H = 18;
const MIN_W = 320;
+86 -29
View File
@@ -20,6 +20,9 @@ function emptyAxis() {
widgetType: null, // "number" | "combo" | "text" | null
mode: null, // number: "range"|"list"; text: "fulllist"|"sr"
step: 1,
precision: null, // number: decimals the field allows (0=int, 1=cfg, 2=denoise)
realStep: null, // number: the field's true increment (step2), for snap-to-step
snap: true, // number: round values to realStep (per-axis Snap toggle)
options: [], // combo: the widget's option list (cached at pick)
raw: { start: "", end: "", steps: "", listText: "", checked: [], srFind: "", srReplace: "" },
};
@@ -84,6 +87,27 @@ export function resetState(node) {
return s;
}
// Clear a SINGLE axis back to empty IN PLACE (preserving the object identity that
// editor handlers captured by reference - same aliasing rule as backfillAxis /
// selectChoice). The OTHER axis + the toggles/theme are left untouched. Backs the
// per-axis ↺ reset button.
export function resetAxis(node, axisKey) {
const state = readState(node);
const axis = state[axisKey];
if (!axis) return state;
axis.nodeId = null;
axis.widgetName = null;
axis.widgetType = null;
axis.mode = null;
axis.step = 1;
axis.options = [];
const r = axis.raw || (axis.raw = {});
r.start = ""; r.end = ""; r.steps = ""; r.listText = "";
r.checked = []; r.srFind = ""; r.srReplace = "";
writeState(node, state);
return state;
}
export function restoreFromProperties(node) {
// Idempotent: readState creates+stores a default when absent.
readState(node);
@@ -121,9 +145,16 @@ export function classifyWidget(w) {
if (typeof step !== "number" || step <= 0) {
step = Number.isInteger(w.value) ? 1 : 0.01;
}
// ComfyUI multiplies the displayed step by 10 internally for some builds;
// we only need a precision hint, so the raw step is fine.
return { name, type: "number", step, min: opts.min, max: opts.max, cur };
// ComfyUI declares how many decimals a number field allows via `precision`
// (0 = integer like width/height/steps/seed, 1 = cfg, 2 = denoise). That is
// the AUTHORITATIVE rounding source - the `step` option is ×10-inflated and
// unreliable. roundToStep() rounds to `precision` when present (integer fields
// stay whole; 7.1 keeps its decimal) and only falls back to step/value decimals
// when precision is missing (old saved axis / non-ComfyUI widget).
const precision = (typeof opts.precision === "number") ? opts.precision : null;
// step2 is the REAL increment (width 16, cfg 0.1); used by the Snap-to-step toggle.
const realStep = (typeof opts.step2 === "number" && opts.step2 > 0) ? opts.step2 : null;
return { name, type: "number", step, precision, realStep, min: opts.min, max: opts.max, cur };
}
if (t === "combo") {
let vals = w.options?.values;
@@ -194,24 +225,39 @@ export function lookupWidgetMeta(node, axis) {
// freeze the browser. A comparison grid is only useful for a handful of cells.
export const MAX_AXIS_VALUES = 100;
// Trim float drift to the step's decimal precision. Handles scientific-notation
// steps (e.g. 1e-7) which `String(step).split(".")` would mis-read as 0 decimals
// (collapsing every value to 0).
export function roundToStep(v, step) {
if (!step || step <= 0 || !isFinite(v)) return v;
let decimals;
const s = String(step);
if (s.indexOf("e") >= 0 || s.indexOf("E") >= 0) {
// scientific notation: derive decimals from the exponent
decimals = Math.max(0, Math.ceil(-Math.log10(Math.abs(step))));
} else {
decimals = (s.split(".")[1] || "").length;
// Count a number's decimal places, handling scientific notation (e.g. 1e-7 -> 7,
// 1.5e-3 -> 4) which a naive String().split(".") mis-reads as 0 decimals.
function decimalsOf(n) {
if (!isFinite(n)) return 0;
const s = String(n);
const e = s.search(/[eE]/);
if (e >= 0) {
const mantDec = (s.slice(0, e).split(".")[1] || "").length;
const exp = parseInt(s.slice(e + 1), 10) || 0;
return Math.max(0, mantDec - exp);
}
return Number(v.toFixed(Math.min(decimals, 8)));
return (s.split(".")[1] || "").length;
}
// Round a value to the right number of decimals. `precision` (from the widget:
// 0 = integer width/height/steps/seed, 1 = cfg, 2 = denoise) is authoritative and
// is used when present - so integer fields stay WHOLE (e.g. a 512->1024 range no
// longer yields 682.66666667) while cfg keeps 1 decimal and denoise 2. When
// precision is unknown, fall back to preserving the value's own decimals (capped
// at 8 to trim float-accumulation drift, which lives ~15-17 places down).
// Scientific-notation safe via decimalsOf().
export function roundToStep(v, step, precision) {
if (!isFinite(v)) return v;
if (typeof precision === "number" && precision >= 0) {
return Number(v.toFixed(Math.min(precision, 8)));
}
const stepDec = (typeof step === "number" && step > 0) ? decimalsOf(step) : 0;
const dec = Math.min(8, Math.max(stepDec, decimalsOf(v)));
return Number(v.toFixed(dec));
}
// Comma list, each item a number or an A1111 range: a-b (+s) | a-b [n]
export function parseNumberList(text, step) {
export function parseNumberList(text, step, precision) {
const out = [];
for (const part of String(text || "").split(",")) {
const s = part.trim();
@@ -221,49 +267,60 @@ export function parseNumberList(text, step) {
if (mStep) {
const a = parseFloat(mStep[1]); const b = parseFloat(mStep[2]); let st = parseFloat(mStep[3]);
if (!isFinite(a) || !isFinite(b)) continue;
if (st === 0 || !isFinite(st)) { out.push(roundToStep(a, step)); continue; }
if (st === 0 || !isFinite(st)) { out.push(roundToStep(a, step, precision)); continue; }
if ((b - a) * st < 0) st = -st;
// Iterate by integer index (count = how many steps fit), NOT by repeated
// `v += st`: accumulation drift would drop or duplicate the endpoint, and
// a tiny step (e.g. +0.0000001) would loop millions of times. Capped.
const count = Math.min(MAX_AXIS_VALUES - 1, Math.max(0, Math.floor((b - a) / st + 1e-9)));
for (let i = 0; i <= count && out.length < MAX_AXIS_VALUES; i++) {
out.push(roundToStep(a + i * st, step));
out.push(roundToStep(a + i * st, step, precision));
}
} else if (mCount) {
const a = parseFloat(mCount[1]); const b = parseFloat(mCount[2]); let n = parseInt(mCount[3], 10);
if (!isFinite(a)) continue;
if (!isFinite(b) || n <= 1) { out.push(roundToStep(a, step)); continue; }
if (!isFinite(b) || n <= 1) { out.push(roundToStep(a, step, precision)); continue; }
n = Math.min(n, MAX_AXIS_VALUES);
for (let i = 0; i < n && out.length < MAX_AXIS_VALUES; i++) out.push(roundToStep(a + (b - a) * i / (n - 1), step));
for (let i = 0; i < n && out.length < MAX_AXIS_VALUES; i++) out.push(roundToStep(a + (b - a) * i / (n - 1), step, precision));
} else {
const v = parseFloat(s);
if (isFinite(v) && out.length < MAX_AXIS_VALUES) out.push(roundToStep(v, step));
if (isFinite(v) && out.length < MAX_AXIS_VALUES) out.push(roundToStep(v, step, precision));
}
if (out.length >= MAX_AXIS_VALUES) break;
}
return out;
}
export function rangeToList(start, end, steps, step) {
export function rangeToList(start, end, steps, step, precision) {
const a = parseFloat(start); const b = parseFloat(end); let n = parseInt(steps, 10);
if (!isFinite(a)) return [];
if (!isFinite(b) || !isFinite(n) || n <= 1) return [roundToStep(a, step)];
if (!isFinite(b) || !isFinite(n) || n <= 1) return [roundToStep(a, step, precision)];
n = Math.min(n, MAX_AXIS_VALUES);
const out = [];
for (let i = 0; i < n; i++) out.push(roundToStep(a + (b - a) * i / (n - 1), step));
for (let i = 0; i < n; i++) out.push(roundToStep(a + (b - a) * i / (n - 1), step, precision));
return out;
}
// Snap a number to the nearest multiple of the field's real step (e.g. width to
// multiples of 16), then clean any float artifact to the field's precision.
function snapToGrid(v, realStep, precision) {
if (!realStep || realStep <= 0 || !isFinite(v)) return v;
return roundToStep(Math.round(v / realStep) * realStep, realStep, precision);
}
// Resolve an axis to its ordered list of cell values (numbers or strings).
// For text "sr" mode the values are the replacement strings; the actual text
// substitution happens at inject time against the target's current value.
// Number values are snapped to the field's real step when the axis's Snap toggle
// is on (axis.snap, default true). For text "sr" mode the values are the
// replacement strings; substitution happens at inject time against the target.
export function resolveAxisValues(axis) {
if (!axis || !axis.widgetType) return [];
const raw = axis.raw || {};
if (axis.widgetType === "number") {
if (axis.mode === "list") return parseNumberList(raw.listText, axis.step);
return rangeToList(raw.start, raw.end, raw.steps, axis.step);
let vals = (axis.mode === "list")
? parseNumberList(raw.listText, axis.step, axis.precision)
: rangeToList(raw.start, raw.end, raw.steps, axis.step, axis.precision);
if (axis.snap !== false && axis.realStep) vals = vals.map((v) => snapToGrid(v, axis.realStep, axis.precision));
return vals;
}
if (axis.widgetType === "combo") {
const checked = Array.isArray(raw.checked) ? raw.checked.slice() : [];
+28 -8
View File
@@ -1,12 +1,12 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import {
readState, restoreFromProperties, resetState,
readState, restoreFromProperties, resetState, resetAxis as resetAxisCore,
resolveAxisValues, axisReady, computeCounts,
} from "./core.mjs";
import { injectCSS, buildRoot, renderBody, measureContentHeight, closePopupIfOwner } from "./ui.mjs";
import { buildGridPreview } from "./grid.mjs";
import { applyAdaptiveCanvasOnly, isVueNodes } from "../shared/index.mjs";
import { applyAdaptiveCanvasOnly, isVueNodes, closeHelpPopup } from "../shared/index.mjs";
import { isQueueLoopActive, runQueueLoop, feedsOnlyInactiveSwitch } from "../shared/queue_drivers.mjs";
const NODE = "PixaromaXYPlot";
@@ -156,6 +156,15 @@ function makeHandlers(node, root) {
handlers.rerender();
fitNode(node, root);
},
// Clear ONE axis (the per-axis ↺ button); the other axis + toggles + any
// shown grid stay. Fit (not just grow) so the node tightens back up after a
// tall value area collapses. User action only, so fitNode can't trip the
// dirty-on-load tracker (Vue Compat #18).
resetAxis: (axisKey) => {
resetAxisCore(node, axisKey);
handlers.rerender();
fitNode(node, root);
},
};
return handlers;
}
@@ -187,11 +196,13 @@ app.registerExtension({
node._pixXyFit = () => fitNode(node, root); // called from grid.mjs on <img> load
// DOM-only render (no auto-grow) for the load path so the saved size
// is trusted and the workflow isn't falsely flagged modified (#18).
node._pixXyRenderOnly = () => renderBody(node, root, { rerender: handlers.rerender, growth: null, reset: handlers.reset });
node._pixXyRenderOnly = () => renderBody(node, root, { rerender: handlers.rerender, growth: null, reset: handlers.reset, resetAxis: handlers.resetAxis });
const widget = node.addDOMWidget("xyplot", "pixaroma_xy_plot", root, {
serialize: false,
getMinHeight: () => measureContentHeight(root) + 4,
// Coarse-round to a 4px grid so sub-pixel/font measurement jitter can't
// creep node.size between save and reload (dirty-on-load, Vue Compat #18).
getMinHeight: () => Math.round((measureContentHeight(root) + 4) / 4) * 4,
});
applyAdaptiveCanvasOnly(widget);
@@ -233,10 +244,17 @@ app.registerExtension({
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (size[1] < MIN_H) size[1] = MIN_H;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
// Min-size clamp is LEGACY ONLY. In Nodes 2.0 the rendered node size lives
// in the Vue layout store, not node.size; clamping node.size here desyncs
// them (drag smaller -> switch workflow -> it jumps back bigger) and the
// clamp doesn't even constrain the rendered width. The body rows flex-wrap
// so narrow widths reflow cleanly instead of spilling buttons out the side.
if (!isVueNodes()) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (size[1] < MIN_H) size[1] = MIN_H;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
}
if (origResize) return origResize.apply(this, arguments);
};
@@ -244,6 +262,7 @@ app.registerExtension({
nodeType.prototype.onDrawForeground = function (ctx) {
if (origDraw) origDraw.call(this, ctx);
if (this.flags?.collapsed) return;
if (isVueNodes()) return; // size clamp is legacy-only (see onResize)
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
};
@@ -251,6 +270,7 @@ app.registerExtension({
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
try { closePopupIfOwner(this); } catch (_e) {} // only close OUR popup, not another node's
try { closeHelpPopup(); } catch (_e) {} // close the Help panel if it's open
try { this._pixXyCancelConfirm?.(); } catch (_e) {}
this._pixXyRoot = null;
this._pixXyRerender = null;
+106 -7
View File
@@ -11,9 +11,64 @@ import {
enumerateTargets, lookupWidgetMeta, currentValuePreview,
resolveAxisValues, computeCounts, axisReady,
} from "./core.mjs";
import { registerNodeHelp } from "../shared/index.mjs";
const BRAND = "#f66744";
// Node Help panel content (the ? button -> themed popup; node UI convention #16).
// The popup is a document.body overlay, so it works in BOTH renderers.
const XY_HELP = {
title: "XY Plot Pixaroma",
tagline: "Compare settings side by side in one labeled grid - no extra wiring.",
sections: [
{
heading: "What it does",
body: "Drop this at the end of your workflow and wire your final image into it, like a Preview node. Pick what changes ACROSS (X = columns) and DOWN (Y = rows), press Run once, and every combination fills a labeled grid right here on the node.",
},
{
heading: "How to use",
bullets: [
"Wire your workflow's final image into the `image` input.",
"In the X card, pick a setting to vary across the columns. Do the same in the Y card for the rows (you can use just one axis if you like).",
"Enter the values you want to try in the value box.",
"Press Run once. The grid builds as each run finishes.",
],
},
{
heading: "The value box adapts to what you pick",
defs: [
["Number", "A `Range` (Start / End / Steps) or a `List` of values."],
["Dropdown", "A checklist - tick the samplers / models / schedulers you want to compare."],
["Prompt text", "`Full list` (one full prompt per line) or `Find & replace` (swap a word for each value)."],
],
},
{
heading: "Entering numbers",
bullets: [
"List: values separated by commas, e.g. `5, 6, 7.1, 10`. Decimals are kept exactly.",
"Range: set Start, End and Steps (how many). 5 to 15 in 3 steps gives 5, 10, 15.",
"Shorthand inside a list also works: `4-10 (+2)` steps by 2, and `4-10 [4]` gives 4 evenly spaced values.",
],
},
{
heading: "Buttons and options",
defs: [
["Lock seed", "Keeps the seed the same for every square so the only thing changing is what you're testing. Turns off on its own if you're plotting the seed."],
["Draw labels", "Show the value labels and axis names on the grid."],
["Save cells", "Also save each square on its own, not just the whole grid."],
["Grid: Dark / Light / Mono", "The grid background and label style. Switching re-skins the grid you already have, instantly."],
["Reset X / Reset Y", "Clear just that one axis."],
["Reset XY", "Clear both axes and all selections, back to a fresh node."],
["Save Disk / Save Output / Copy / Open", "Act on the finished grid: save it to your computer or to ComfyUI's output, copy it, or open it in a new tab."],
],
},
],
footer: "Tip: start small (a few values each way). The node asks you to confirm before running more than 25 squares, since each square is a full workflow run.",
};
// Help is shown by the selection-toolbar Help button (js/help_toolbar).
registerNodeHelp("PixaromaXYPlot", XY_HELP);
function xyToast(detail, severity = "info") {
const tm = app.extensionManager?.toast;
if (tm && typeof tm.add === "function") {
@@ -28,9 +83,13 @@ export function injectCSS() {
const css = `
.pix-xy-root{display:flex;flex-direction:column;gap:9px;padding:8px 9px 16px;font-family:'Segoe UI',system-ui,sans-serif;color:#e0e0e0;box-sizing:border-box;}
.pix-xy-axis{border:1px solid rgba(255,255,255,.14);border-radius:7px;padding:9px 10px 10px;background:rgba(0,0,0,.18);}
.pix-xy-axis-head{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;margin-bottom:8px;}
.pix-xy-axis-head{display:flex;align-items:center;flex-wrap:wrap;gap:7px;font-size:12px;font-weight:600;margin-bottom:8px;}
.pix-xy-badge{background:${BRAND};color:#fff;border-radius:4px;width:18px;height:18px;display:grid;place-items:center;font-size:11px;font-weight:700;flex:0 0 auto;}
.pix-xy-axis-dir{color:#9a9a9a;font-weight:500;font-size:11px;}
.pix-xy-head-right{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;}
.pix-xy-axis-reset{display:flex;align-items:center;gap:5px;font-size:10.5px;font-weight:500;color:#9a9a9a;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:5px;padding:3px 8px;cursor:pointer;user-select:none;}
.pix-xy-axis-reset:hover{border-color:${BRAND};color:#fff;}
.pix-xy-axis-reset .pix-xy-axis-reset-ic{font-size:12px;line-height:1;}
.pix-xy-row{display:flex;align-items:center;gap:7px;}
.pix-xy-curhint{font-size:10.5px;color:#8a8a8a;font-style:italic;margin:5px 2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* custom dropdown (value + ▼ + ◀▶), Pixaroma convention - never native <select> */
@@ -57,6 +116,8 @@ export function injectCSS() {
/* value area */
.pix-xy-valuearea{margin-top:9px;}
.pix-xy-seg{display:inline-flex;background:rgba(0,0,0,.3);border-radius:6px;padding:2px;gap:2px;margin-bottom:8px;}
.pix-xy-moderow{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;}
.pix-xy-moderow .pix-xy-seg{margin-bottom:0;}
.pix-xy-seg span{font-size:11.5px;padding:4px 11px;border-radius:4px;color:#9a9a9a;cursor:pointer;user-select:none;}
.pix-xy-seg span.on{background:${BRAND};color:#fff;font-weight:600;}
.pix-xy-range{display:flex;gap:7px;margin-bottom:7px;}
@@ -79,9 +140,9 @@ textarea.pix-xy-input{resize:vertical;min-height:46px;white-space:pre;}
/* counter chip + options */
.pix-xy-counter{text-align:center;font-size:13px;font-weight:600;color:#fff;background:${BRAND};border-radius:6px;padding:7px;}
.pix-xy-counter.muted{background:rgba(255,255,255,.06);color:#9a9a9a;font-weight:500;}
.pix-xy-opts{display:flex;gap:8px;flex-wrap:nowrap;justify-content:center;}
.pix-xy-opts{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;}
.pix-xy-opts2{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;}
.pix-xy-themewrap{display:flex;align-items:center;gap:7px;}
.pix-xy-themewrap{display:flex;align-items:center;flex-wrap:wrap;gap:7px;}
.pix-xy-themelbl{font-size:11.5px;color:#9a9a9a;}
.pix-xy-themeseg{margin-bottom:0;}
.pix-xy-themeseg span{padding:4px 10px;}
@@ -200,6 +261,8 @@ function selectChoice(node, axisKey, choice, rerender) {
axis.widgetName = choice.w.name;
axis.widgetType = choice.w.type;
axis.step = choice.w.step || 1;
axis.precision = (typeof choice.w.precision === "number") ? choice.w.precision : null;
axis.realStep = (typeof choice.w.realStep === "number") ? choice.w.realStep : null;
axis.options = choice.w.type === "combo" ? (choice.w.options || []) : [];
if (changed) {
// Reset entry to a sensible default for the new widget type. Mutate the
@@ -273,7 +336,7 @@ function openPicker(node, axisKey, anchorEl, rerender) {
document.body.appendChild(popup);
// position under the anchor, clamped to viewport
const r = anchorEl.getBoundingClientRect();
popup.style.left = Math.min(r.left, window.innerWidth - popup.offsetWidth - 8) + "px";
popup.style.left = Math.max(8, Math.min(r.left, window.innerWidth - popup.offsetWidth - 8)) + "px";
let top = r.bottom + 4;
if (top + popup.offsetHeight > window.innerHeight - 8) top = Math.max(8, r.top - popup.offsetHeight - 4);
popup.style.top = top + "px";
@@ -282,6 +345,11 @@ function openPicker(node, axisKey, anchorEl, rerender) {
const onWheel = (e) => { if (!popup.contains(e.target)) closePopup(); };
const onKey = (e) => { if (e.key === "Escape") closePopup(); };
setTimeout(() => {
// If another picker opened in the same tick, closePopup() already ran THIS
// popup's _cleanup and _openPopup now points at the newer one - bail so we
// don't attach orphaned, never-removed global listeners (a real leak that
// also makes the newer popup dismiss on the next outside click).
if (_openPopup !== popup) return;
document.addEventListener("mousedown", onDown, true);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("wheel", onWheel, true);
@@ -372,13 +440,30 @@ function renderValueArea(node, axisKey, mount, refreshCounter, rerender) {
};
if (axis.widgetType === "number") {
// Keep precision synced with the live widget (0 = integer width/height/steps,
// 1 = cfg, 2 = denoise) so a reloaded axis rounds correctly even if it was
// saved before precision was tracked.
const nmeta = lookupWidgetMeta(node, axis);
if (nmeta && typeof nmeta.precision === "number") axis.precision = nmeta.precision;
if (nmeta && typeof nmeta.realStep === "number") axis.realStep = nmeta.realStep;
const seg = el("div", "pix-xy-seg");
const sRange = el("span", null, "Range"); const sList = el("span", null, "List");
(axis.mode === "list" ? sList : sRange).classList.add("on");
sRange.addEventListener("click", () => { axis.mode = "range"; save(); rerender(); });
sList.addEventListener("click", () => { axis.mode = "list"; save(); rerender(); });
seg.appendChild(sRange); seg.appendChild(sList);
mount.appendChild(seg);
const modeRow = el("div", "pix-xy-moderow");
modeRow.appendChild(seg);
// Per-axis Snap toggle - lives in the free space next to Range/List so it
// adds no node height, and only shows when snapping has an effect (the field's
// step is coarser than its precision, e.g. width/height snap to /16).
const snapUnit = Math.pow(10, -(axis.precision != null ? axis.precision : 0));
if (axis.realStep && axis.realStep > snapUnit + 1e-9) {
const snapT = buildToggle("Snap", axis.snap !== false, (v) => { axis.snap = v; save(); refreshPreview(); });
snapT.title = "Round values to this setting's real step (e.g. width to multiples of 16). Off = exact.";
modeRow.appendChild(snapT);
}
mount.appendChild(modeRow);
if (axis.mode === "list") {
const inp = isolate(el("input", "pix-xy-input"));
@@ -571,6 +656,20 @@ export function renderBody(node, root, handlers) {
head.appendChild(el("span", "pix-xy-badge", axisKey.toUpperCase()));
head.appendChild(document.createTextNode(axisKey === "x" ? "across" : "down"));
head.appendChild(el("span", "pix-xy-axis-dir", axisKey === "x" ? "➡ columns" : "⬇ rows"));
// Right-aligned header cluster: each axis shows its own Reset once a setting
// is picked. (Node help moved to the selection-toolbar Help button.)
const headRight = el("div", "pix-xy-head-right");
// Per-axis reset (clears just this axis; the other axis + toggles stay).
// Only shown once a setting is picked - nothing to reset on an empty axis.
if (handlers.resetAxis && state[axisKey] && state[axisKey].widgetType) {
const axReset = el("div", "pix-xy-axis-reset");
axReset.appendChild(el("span", "pix-xy-axis-reset-ic", "↺"));
axReset.appendChild(el("span", null, "Reset " + axisKey.toUpperCase()));
axReset.title = `Reset the ${axisKey.toUpperCase()} axis only - clears its setting and values. The other axis and your toggles stay.`;
axReset.addEventListener("click", () => handlers.resetAxis(axisKey));
headRight.appendChild(axReset);
}
if (headRight.children.length) head.appendChild(headRight);
card.appendChild(head);
const pickRow = el("div", "pix-xy-row");
card.appendChild(pickRow);
@@ -603,8 +702,8 @@ export function renderBody(node, root, handlers) {
if (handlers.reset) {
const reset = el("div", "pix-xy-resetbtn");
reset.appendChild(el("span", null, "↺"));
reset.appendChild(el("span", null, "Reset"));
reset.title = "Clear both axes and selections, back to a fresh node.";
reset.appendChild(el("span", null, "Reset XY"));
reset.title = "Clear BOTH axes, all selections, and the toggles - back to a fresh node.";
reset.addEventListener("click", () => handlers.reset());
opts2.appendChild(reset);
}
+5
View File
@@ -0,0 +1,5 @@
{
"Pixaroma_ShowHelp": {
"label": "Pixaroma Help"
}
}
+43 -12
View File
@@ -99,35 +99,66 @@ def _expand_date_tokens(s):
# ---- prefix sanitization ----
# Characters allowed verbatim in a segment. '%' is permitted so native
# ComfyUI tokens like %year% survive and reach folder_paths.get_save_image_path
# for final expansion. Anything else is replaced with '_' (sanitization,
# not rejection), so wiring an upstream filename like "Bunny Cubes - Copy.png"
# just works instead of failing the save.
_DISALLOWED_CHAR_RE = re.compile(r"[^A-Za-z0-9_\-%]")
# DENYLIST, not allowlist (June-2026 Korean filename_prefix report): only the
# characters Windows forbids in file/folder names (< > : " | ? *) plus ASCII
# control chars are replaced with '_'. Everything else - non-Latin scripts
# (Korean, Japanese, ...), accented letters, spaces, dots - passes through
# verbatim, matching native ComfyUI SaveImage (which does no character
# filtering at all; folder_paths.get_save_image_path only enforces that the
# final path stays inside output/). The old [^A-Za-z0-9_\-%] allowlist
# dissolved an all-Korean prefix to nothing -> "Preview" fallback in the
# output root. Sanitization, not rejection: wiring an upstream filename like
# "Bunny Cubes - Copy.png" just works instead of failing the save.
# '/' (separator) and '\\' (normalized to '/') are handled before splitting;
# '%' stays legal so native tokens like %year% survive for final expansion.
# scripts/save_prefix_check.py locks this behavior - keep it green.
_DISALLOWED_CHAR_RE = re.compile(r'[<>:"|?*\x00-\x1f\x7f]')
_MULTI_UNDERSCORE_RE = re.compile(r"_+")
_PREFIX_MAX_LEN = 256 # input cap (reject obvious garbage early)
_PREFIX_OUTPUT_MAX = 100 # output cap so pasted paragraphs / multi-line
# text don't blow past Windows MAX_PATH
# Names Windows reserves for devices - a folder/file with one of these names
# (bare or with any extension, any case) can't be created normally and would
# hard-error the save mid-run. Suffixed with '_' instead.
_WIN_RESERVED_NAMES = frozenset(
{"CON", "PRN", "AUX", "NUL"}
| {f"COM{i}" for i in range(1, 10)}
| {f"LPT{i}" for i in range(1, 10)}
)
def _sanitize_segment(seg):
"""Replace disallowed chars with '_', collapse repeats, strip edges.
"""Neutralize Windows-illegal chars to '_', tidy edges, guard reserved names.
Caller must reject '..' before calling. Returns "" if nothing usable
survives (e.g. segment was all dots / whitespace).
survives (e.g. segment was all dots / whitespace / illegal chars).
Trailing dots/spaces are stripped because Windows itself silently strips
them at create time - stripping here keeps the reported path identical
to what actually lands on disk.
"""
cleaned = _DISALLOWED_CHAR_RE.sub("_", seg)
cleaned = _MULTI_UNDERSCORE_RE.sub("_", cleaned)
return cleaned.strip("_")
# Loop until stable: edge whitespace, edge underscores, and trailing
# dots/spaces can shadow each other (e.g. "test._" needs two passes).
prev = None
while prev != cleaned:
prev = cleaned
cleaned = cleaned.strip().strip("_").rstrip(". ")
if cleaned and cleaned.split(".", 1)[0].upper() in _WIN_RESERVED_NAMES:
cleaned += "_"
return cleaned
def _safe_prefix(s):
"""Return sanitized prefix string, or None if input is unrecoverable.
Pipeline: expand %date:FMT% tokens, then per segment replace any
char outside [A-Za-z0-9_\\-%] with '_', collapse repeated '_', strip
leading/trailing '_'. Path segments are separated by '/'.
Pipeline: expand %date:FMT% tokens, then per segment replace only
Windows-illegal chars (< > : " | ? * and control chars) with '_',
collapse repeated '_', strip edge whitespace/underscores/trailing
dots, and suffix Windows-reserved device names (CON, NUL, ...) with
'_'. Everything else - non-Latin scripts, accents, spaces - passes
through verbatim, like native SaveImage. Segments separated by '/'.
Empty segments (e.g. trailing slashes, doubled slashes) are silently
dropped — so 'teasda......////' becomes 'teasda' rather than failing.
+67 -1
View File
@@ -135,6 +135,51 @@ def _round_rect(draw, x, y, w, h, r, fill):
draw.rounded_rectangle((x, y, x + w, y + h), radius=r, fill=fill)
def _render_vertical_layer(pil_font, lines, char_step, col_gap, align,
ascender, descender, fill_color, bg_color,
pad_x, pad_y, bg_radius):
"""Vertical (top-to-bottom, upright) text. Columns = lines, left-to-right.
Returns (layer_img, bbox_w, bbox_h). Mirror of
js/framework/text_render.mjs::renderVerticalToCanvas. Docs section 5b."""
cols = []
for line in lines:
chars = list(line)
col_w = 0.0
for ch in chars:
col_w = max(col_w, pil_font.getlength(ch))
cols.append((chars, col_w))
max_chars = max((len(c[0]) for c in cols), default=0)
content_h = ascender + descender + max(0, max_chars - 1) * char_step
total_col_w = sum(c[1] for c in cols) + max(0, len(cols) - 1) * col_gap
bbox_w = max(1, int(round(total_col_w + 2 * pad_x)))
bbox_h = max(1, int(round(content_h + 2 * pad_y)))
layer_img = Image.new("RGBA", (bbox_w, bbox_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer_img)
if bg_color:
bg_rgba = _hex_to_rgb(bg_color) + (255,)
r = min(bg_radius, bbox_w // 2, bbox_h // 2)
_round_rect(draw, 0, 0, bbox_w - 1, bbox_h - 1, r, bg_rgba)
col_origin_x = pad_x
for chars, col_w in cols:
col_content_h = ascender + descender + max(0, len(chars) - 1) * char_step
if align == "center":
v_off = (content_h - col_content_h) / 2.0
elif align == "right":
v_off = content_h - col_content_h
else:
v_off = 0
for i, ch in enumerate(chars):
gw = pil_font.getlength(ch)
cx = col_origin_x + (col_w - gw) / 2.0
ly = pad_y + v_off + ascender + i * char_step
draw.text((cx, ly), ch, font=pil_font, fill=fill_color, anchor="ls")
col_origin_x += col_w + col_gap
return layer_img, bbox_w, bbox_h
# ────────────────────────────────────────────────────────────────────────────
# Hardcoded bg-pill defaults (user can't tune; spec §3, §4)
# ────────────────────────────────────────────────────────────────────────────
@@ -174,6 +219,17 @@ def compute_text_bbox(layer):
except TypeError:
ascender, descender = pil_font.getmetrics()
glyph_h = ascender + descender
if layer.get("direction", "horizontal") == "vertical":
# Mirror _render_vertical_layer's bbox math (ss=1 here). char_step =
# line_height_px; col_gap = letter_spacing.
cols = [list(ln) for ln in lines]
col_widths = [max((pil_font.getlength(ch) for ch in c), default=0) for c in cols]
max_chars = max((len(c) for c in cols), default=0)
content_h = glyph_h + max(0, max_chars - 1) * line_height_px
total_col_w = sum(col_widths) + max(0, len(cols) - 1) * letter_spacing
bbox_w = max(1, int(round(total_col_w + 2 * pad_x)))
bbox_h = max(1, int(round(content_h + 2 * pad_y)))
return (bbox_w, bbox_h)
bbox_w = max(1, int(round(max_line_w + 2 * pad_x)))
bbox_h = max(1, int(round(glyph_h + max(0, len(lines) - 1) * line_height_px + 2 * pad_y)))
return (bbox_w, bbox_h)
@@ -254,7 +310,17 @@ def render_text_layer(base_img, layer):
bbox_h = max(1, int(round(glyph_h + max(0, len(lines) - 1) * line_height_px + 2 * pad_y)))
fill_color = _hex_to_rgb(color_hex) + (255,)
if synthesized_italic:
direction = layer.get("direction", "horizontal")
if direction == "vertical":
# Vertical skips synthesized italic (upright stacking). char_step =
# line_height_px (already round(font_size_eff*line_height_mult));
# col_gap = letter_spacing_eff. Docs section 5b.
layer_img, bbox_w, bbox_h = _render_vertical_layer(
pil_font, lines, line_height_px, letter_spacing_eff, align,
ascender, descender, fill_color, bg_color, pad_x, pad_y,
_BG_RADIUS * ss,
)
elif synthesized_italic:
# Italic: pill stays axis-aligned; ONLY the text is skewed. The skew
# shifts the bottom of glyphs LEFT by m*bbox_h, so widen the canvas by
# that overhang (slant) and shift the text RIGHT by it (AFFINE c =
+22 -19
View File
@@ -10,12 +10,12 @@ class PixaromaText:
"The text field grows with the node: drag the bottom-right "
"corner to make the field taller or wider when working on a "
"long prompt.\n\n"
"Dynamic prompts: write {day|night} and one option is picked at "
"random each time you queue (nest freely, e.g. {a|{b|c}}). To keep "
"literal braces in the text, escape them as \\{ and \\}. Comments "
"are stripped at queue time: // to end of line, and /* ... */ "
"blocks. The field always shows your raw text; the random pick and "
"comment removal happen only in what gets sent downstream."
"Dynamic prompts switch (OFF by default): when off, the text is "
"sent exactly as typed and every curly brace is kept, which is what "
"you want for JSON prompts. Turn the switch on to enable {a|b} "
"wildcards (one option picked at random each queue, nest freely like "
"{a|{b|c}}), use \\{ and \\} for literal braces, and strip comments "
"(// to end of line and /* ... */ blocks)."
)
@classmethod
@@ -27,19 +27,19 @@ class PixaromaText:
{
"default": "",
"multiline": True,
# Enable ComfyUI's wildcard/dynamic-prompt processing
# (resolves {a|b|c} to a random pick + strips // and
# /* */ comments) at queue time. Works with our custom
# textarea because the raw value is mirrored into this
# native (hidden) widget, and ComfyUI applies the
# transform during graphToPrompt - no extra wiring.
"dynamicPrompts": True,
# NOTE: ComfyUI's native `dynamicPrompts: True` is
# deliberately NOT set here. It processed EVERY Text
# Pixaroma unconditionally and stripped curly braces at
# queue time, which silently destroyed JSON prompts.
# Dynamic prompts are now an opt-in per-node switch
# (default OFF) handled entirely on the frontend - see
# the "Dynamic prompts" switch + the graphToPrompt
# resolver in js/text/index.js + js/text/dynamic_prompts.mjs.
"tooltip": (
"The text to output. Supports multiple lines. "
"Dynamic prompts: {a|b|c} picks one at random each "
"queue; escape literal braces as \\{ \\}. // and "
"/* */ comments are stripped when queued. The field "
"shows your raw text; resolution happens downstream."
"The text to output. Supports multiple lines. Sent "
"exactly as typed unless the Dynamic prompts switch "
"is on - then {a|b} picks one at random each queue "
"and // /* */ comments are stripped."
),
},
),
@@ -48,7 +48,10 @@ class PixaromaText:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The text typed into the field.",)
OUTPUT_TOOLTIPS = (
"The text from the field, with {a|b} dynamic prompts resolved if the "
"Dynamic prompts switch is on (otherwise sent exactly as typed).",
)
FUNCTION = "out"
CATEGORY = "👑 Pixaroma"
+7 -1
View File
@@ -124,8 +124,14 @@ class PixaromaTextWatermark:
# genuine italic renders upright metrics with no overhang, so adding the
# slant would over-inset right/center anchors. Gate on the same
# synthesized-italic decision the renderer makes.
# Vertical direction renders upright (the renderer skips the synthesized
# lean entirely), so no slant compensation there.
eff_w = bbox_w
if bool(state.get("italic", False)) and self._is_synthesized_italic(state):
if (
state.get("direction", "horizontal") != "vertical"
and bool(state.get("italic", False))
and self._is_synthesized_italic(state)
):
slant = int(math.ceil(math.tan(math.radians(12)) * bbox_h))
# + a small size-relative cushion for italic ink that overhangs the
# advance width (the bbox measurement misses it).
+4 -4
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, 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.77"
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,11 +14,11 @@ dependencies = []
rembg = ["rembg>=2.0.50", "onnxruntime>=1.14.0"]
[project.urls]
Repository = "https://github.com/pixaroma/ComfyUI-Pixaroma"
Issues = "https://github.com/pixaroma/ComfyUI-Pixaroma/issues"
Repository = "https://gitlab.com/pixaroma/comfyui-pixaroma"
Issues = "https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues"
YouTube = "https://www.youtube.com/@pixaroma"
[tool.comfy]
PublisherId = "pixaroma"
DisplayName = "Pixaroma"
Icon = "https://raw.githubusercontent.com/pixaroma/ComfyUI-Pixaroma/main/assets/pixaroma_logo.png"
Icon = "https://gitlab.com/pixaroma/comfyui-pixaroma/-/raw/main/assets/pixaroma_logo.png"
+101
View File
@@ -0,0 +1,101 @@
"""Check _safe_prefix (filename_prefix sanitizer) behavior.
Run: python scripts/save_prefix_check.py
Exits 0 on PREFIX PASS, 1 on any mismatch.
Locks in the June-2026 fix for the Korean filename_prefix report: the
sanitizer must only neutralize genuinely dangerous characters (Windows-
illegal chars, control chars, path traversal) and let every other
character through verbatim - non-Latin scripts, accented letters, and
spaces included - matching native ComfyUI SaveImage, which does no
character filtering at all (folder_paths.get_save_image_path only
enforces that the final path stays inside output/).
"""
import os
import sys
import time
# Import the helper directly off the nodes/ dir. Do NOT use `from nodes._save...`
# - `nodes` collides with ComfyUI's top-level nodes.py when it's on sys.path.
# _save_helpers.py has no relative imports, so loading it standalone is safe.
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nodes"))
from _save_helpers import _safe_prefix # noqa: E402
_today = time.strftime("%Y-%m-%d")
# (input, expected) - expected None means "unrecoverable, caller falls back"
CASES = [
# ── The reported Korean cases (parity with native SaveImage) ──────────
("프로젝트/테스트/이미지", "프로젝트/테스트/이미지"),
("project/te 스 st/image", "project/te 스 st/image"),
# ── Unicode + spaces survive verbatim ─────────────────────────────────
("café/naïve", "café/naïve"),
("日本語/画像", "日本語/画像"),
("Bunny WithCubes - Copy.png", "Bunny WithCubes - Copy.png"),
("a b", "a b"), # interior spaces untouched
(" padded /img", "padded/img"), # segment-edge whitespace trimmed
# ── Windows-illegal characters are neutralized to '_' ─────────────────
('a<b>c:d"e|f?g*h', "a_b_c_d_e_f_g_h"),
("a\tb", "a_b"), # control chars too
("C:\\Users\\x", "C/Users/x"), # drive colon neutralized, \ -> /
# ── Windows reserved device names get a '_' suffix ────────────────────
("CON/img", "CON_/img"),
("nul.txt/img", "nul.txt_/img"),
("com1", "com1_"),
# ── Windows trailing-dot/space rule (OS strips them silently) ─────────
("test.../img", "test/img"),
("teasda......////", "teasda"), # docstring example, must keep working
# ── Unchanged ASCII behavior (regression guards) ──────────────────────
("img", "img"),
("SDXL/portrait", "SDXL/portrait"),
("a__b", "a_b"), # underscore collapse retained
("_edge_", "edge"), # edge-underscore strip retained
("???", None), # nothing usable -> fallback
# ── Tokens ────────────────────────────────────────────────────────────
("%date:yyyy-MM-dd%/img", _today + "/img"),
("%year%-%month%/img", "%year%-%month%/img"), # native tokens preserved
# ── Traversal / absolute / garbage rejection (must stay rejected) ─────
("../evil", None),
("a/../b", None),
("/absolute", None),
("", None),
(" ", None),
(None, None),
(123, None),
("a" * 300, None), # input length cap
]
def main():
failures = []
for raw, expected in CASES:
got = _safe_prefix(raw)
if got != expected:
failures.append((raw, expected, got))
# Output-length cap: 121-char input truncates to 100
long_in = "x" * 60 + "/" + "y" * 60
long_got = _safe_prefix(long_in)
if long_got is None or len(long_got) > 100 or not long_got.startswith("x" * 60 + "/"):
failures.append((long_in, "<=100 chars keeping x.../y...", long_got))
if failures:
print(f"PREFIX FAIL - {len(failures)} case(s):")
for raw, expected, got in failures:
# ascii() so a cp1252 Windows console can't choke on Korean output
print(f" input={ascii(raw)}\n expected={ascii(expected)}\n got ={ascii(got)}")
return 1
print(f"PREFIX PASS ({len(CASES) + 1} cases)")
return 0
if __name__ == "__main__":
sys.exit(main())
+18
View File
@@ -96,6 +96,24 @@ REFERENCE_CASES = [
"fontSize": 48, "lineHeight": 1.2, "letterSpacing": 2, "align": "left",
"color": "#00ff88", "opacity": 1.0, "x": 80, "y": 90, "rotation": 0}
]),
("11_vertical_single.png", (256, 512), "#1a2a4a", [
{"text": "Vertical", "font": "Inter", "weight": 700, "italic": False,
"fontSize": 48, "lineHeight": 1.2, "letterSpacing": 0, "align": "left",
"color": "#FFFFFF", "opacity": 1.0, "direction": "vertical",
"x": 100, "y": 40, "rotation": 0}
]),
("12_vertical_columns_pill.png", (320, 512), "#2a1a3a", [
{"text": "Like\nThis", "font": "Roboto", "weight": 700, "italic": False,
"fontSize": 44, "lineHeight": 1.2, "letterSpacing": 8, "align": "center",
"color": "#FFFFFF", "opacity": 1.0, "bgColor": "#f66744", "direction": "vertical",
"x": 60, "y": 40, "rotation": 0}
]),
("13_vertical_uneven_center.png", (320, 512), "#1a3a2a", [
{"text": "Long\nA\nMid", "font": "Inter", "weight": 400, "italic": False,
"fontSize": 40, "lineHeight": 1.3, "letterSpacing": 6, "align": "center",
"color": "#FFFFFF", "opacity": 1.0, "direction": "vertical",
"x": 60, "y": 30, "rotation": 0}
]),
]
+1 -1
View File
@@ -82,7 +82,7 @@
"Node name for S&R": "PixaromaNote"
},
"widgets_values": [
"{\"version\":1,\"content\":\"<h2><span data-ic=\\\"pixaroma\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #f66744\\\"></span>&nbsp;Pixaroma Community</h2><p>\\n\\n</p><p>Tutorials, workflows, and updates from the Pixaroma team.<br>Everything lives in one place — pick your favourite:</p><p>\\n\\n</p><h3>Connect</h3><p>\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-yt\\\" href=\\\"https://www.youtube.com/@pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">Subscribe on YouTube</a>\\n</span>&nbsp;\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-discord\\\" href=\\\"https://discord.com/invite/gggpkVgBf3\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">Join the Discord</a>\\n</span>&nbsp;\\n\\n</p><h3>Resources</h3><p>\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-btn-plain\\\" style=\\\"background-color: #f66744\\\" href=\\\"https://github.com/pixaroma/ComfyUI-Pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">GitHub Repository</a>\\n</span>&nbsp;\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-btn-plain\\\" style=\\\"background-color: #f66744\\\" href=\\\"https://huggingface.co/pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">HuggingFace Profile</a>\\n</span>&nbsp;\\n\\n</p><h3>Quick Links</h3><p>\\n\\n</p><ul>\\n <li><span data-ic=\\\"github\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #ffffff\\\"></span>&nbsp;<b>Source:</b> <a href=\\\"https://github.com/pixaroma/ComfyUI-Pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">github.com/pixaroma</a></li>\\n <li><span data-ic=\\\"huggingface\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #facc15\\\"></span>&nbsp;<b>Models:</b> <a href=\\\"https://huggingface.co/pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">huggingface.co/pixaroma</a></li>\\n <li><span data-ic=\\\"question\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #22c5d8\\\"></span>&nbsp;<b>Help:</b> open an <a href=\\\"https://github.com/pixaroma/ComfyUI-Pixaroma/issues\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">issue on GitHub</a></li>\\n</ul><p>\\n\\n</p><hr class=\\\"pix-note-hr-solid\\\" style=\\\"color: #f66744\\\"><p>\\n\\n</p><p style=\\\"text-align: center\\\"><span style=\\\"color: #f66744\\\"><b><i>Thank you for using Pixaroma nodes!</i></b></span></p>\",\"buttonColor\":\"#f66744\",\"lineColor\":\"#f66744\",\"width\":401.81818181818153,\"height\":395.7576127485794,\"backgroundColor\":\"#0f1f1e\"}",
"{\"version\":1,\"content\":\"<h2><span data-ic=\\\"pixaroma\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #f66744\\\"></span>&nbsp;Pixaroma Community</h2><p>\\n\\n</p><p>Tutorials, workflows, and updates from the Pixaroma team.<br>Everything lives in one place — pick your favourite:</p><p>\\n\\n</p><h3>Connect</h3><p>\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-yt\\\" href=\\\"https://www.youtube.com/@pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">Subscribe on YouTube</a>\\n</span>&nbsp;\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-discord\\\" href=\\\"https://discord.com/invite/gggpkVgBf3\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">Join the Discord</a>\\n</span>&nbsp;\\n\\n</p><h3>Resources</h3><p>\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-btn-plain\\\" style=\\\"background-color: #f66744\\\" href=\\\"https://gitlab.com/pixaroma/comfyui-pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">GitLab Repository</a>\\n</span>&nbsp;\\n\\n<span class=\\\"pix-note-btnblock\\\">\\n <a class=\\\"pix-note-btn-plain\\\" style=\\\"background-color: #f66744\\\" href=\\\"https://huggingface.co/pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">HuggingFace Profile</a>\\n</span>&nbsp;\\n\\n</p><h3>Quick Links</h3><p>\\n\\n</p><ul>\\n <li><span data-ic=\\\"pixaroma\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #ffffff\\\"></span>&nbsp;<b>Source:</b> <a href=\\\"https://gitlab.com/pixaroma/comfyui-pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">gitlab.com/pixaroma</a></li>\\n <li><span data-ic=\\\"huggingface\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #facc15\\\"></span>&nbsp;<b>Models:</b> <a href=\\\"https://huggingface.co/pixaroma\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">huggingface.co/pixaroma</a></li>\\n <li><span data-ic=\\\"question\\\" class=\\\"pix-note-ic\\\" contenteditable=\\\"false\\\" style=\\\"color: #22c5d8\\\"></span>&nbsp;<b>Help:</b> open an <a href=\\\"https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues\\\" target=\\\"_blank\\\" rel=\\\"noopener noreferrer\\\">issue on GitLab</a></li>\\n</ul><p>\\n\\n</p><hr class=\\\"pix-note-hr-solid\\\" style=\\\"color: #f66744\\\"><p>\\n\\n</p><p style=\\\"text-align: center\\\"><span style=\\\"color: #f66744\\\"><b><i>Thank you for using Pixaroma nodes!</i></b></span></p>\",\"buttonColor\":\"#f66744\",\"lineColor\":\"#f66744\",\"width\":401.81818181818153,\"height\":395.7576127485794,\"backgroundColor\":\"#0f1f1e\"}",
""
],
"color": "#0b1615",