201 Commits
Author SHA1 Message Date
pixaroma 8acfdc4678 chore(release): v1.3.97 - Set / Get passthrough output + matching colours
Set gains a passthrough output (wire a nearby node directly, far ones via Get),
and a Get takes the colour you give its Set and follows when you recolour it.
Also fixes the value line reading the wrong number on multi-output sources.
2026-06-19 15:29:12 +03:00
pixaromaandClaude Opus 4.8 bed0c9622d feat(set-get): passthrough output on Set, Get matches its Set's colour
- Set now has a passthrough output: wire a nearby node to it directly, or read
  it from afar with Get. Resolves to the same source as the Get nodes. Older
  Sets gain the output on load (flags the workflow modified once).
- You colour a Set yourself (right-click -> Colors); any Get reading it takes
  that colour and follows along when you recolour the Set. The Get dropdown
  tags each name with its Set's colour. Setting "Get matches its Set's colour"
  (on by default) toggles it; mirroring never runs on the load path.
- The value readout now reads the widget matching the wired OUTPUT slot, so a
  source with both width and height shows the right one (height, not width).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:23:35 +03:00
pixaromaandClaude Opus 4.8 c2a49a22ac chore(release): v1.3.96 - Set / Get Pixaroma wireless variable nodes
New Set / Get node pair: wireless named connections that reduce wire
clutter. Set holds any value under a name; Get reads it back anywhere by
picking the name. Pure frontend (virtual), resolves straight to the source
at run time, works in both renderers and inside subgraphs, with a subtle
value preview and toolbar help.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:02:02 +03:00
pixaromaandClaude Opus 4.8 ae658bebc8 fix(set-get): review hardening for the value readout
From a 3-angle code review:
- Guard the live-update poll at window scope so a module re-import can't start
  a second interval in parallel.
- Hide the value line for NaN / Infinity instead of printing the word.
- Skip painting the line on the first frame before the name field is measured,
  instead of guessing a position from the node bottom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:57:35 +03:00
pixaromaandClaude Opus 4.8 57bc3510d8 docs(set-get): clearer subgraph scope help + value-line caveat
Rewrite the Subgraphs help for both nodes with a nested-boxes analogy and the
plain rule (a Set reaches inward, a Get reads outward; put shared Sets in the
main graph). Note that the value line stays blank for a calculated source and
never affects the value that flows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:45:31 +03:00
pixaromaandClaude Opus 4.8 9fd27354cf fix(set-get): anchor the value line to the name field, not the node bottom
The line still floated low because it was painted relative to node.size, and
the node's minimum height leaves slack below the name field. Anchor it to the
name widget's actual drawn position (widget.last_y) and grow the node just
enough, so the line now sits right under the name with no gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:24:18 +03:00
pixaromaandClaude Opus 4.8 e6ce828ba9 fix(set-get): tighten value line under the name + no formula text
- Classic: paint the "= value" line in a reserved row at the bottom of the
  node body instead of a DOM widget, which was getting parked low with a big
  gap above it. Now it sits tight under the name field. Nodes 2.0 keeps the DOM
  row (its grid lays it out tightly).
- A computed source (e.g. a Math Expression node) no longer shows its formula
  text "a + b" for a numeric slot; it shows nothing, since the real value is
  only known at run time. Plain number/text sources still show their value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:17:22 +03:00
pixaromaandClaude Opus 4.8 925ad5d535 fix(set-get): keep value readout inside the node frame + match INT output
- Legacy: refit the node when the value line shows/hides so "= 81" no longer
  spills below the frame (Vue's grid already grows; gated off the load path).
- INT-typed previews now round (half-to-even, matching Number Pixaroma's int
  output), so a 5.5 source wired through an int slot reads "= 6" not "= 5.5".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:02:58 +03:00
pixaromaandClaude Opus 4.8 b5a72d22ea feat(set-get): add metadata backend defs so they show as Set/Get Pixaroma
A pure frontend node shows in the library under "__frontend_only__" with its
raw class id as the name, so searching "set" buried it. Add tiny metadata-only
Python defs (node_set_get.py) so the library shows "Set Pixaroma" / "Get
Pixaroma" under the Logic & Flow category, with brand colors, Info panel, and
good search ranking. The frontend registration runs after the backend defs and
replaces the generated class with the virtual one, so behavior is unchanged and
the stubs are never executed (the nodes stay pruned from the prompt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:51:33 +03:00
pixaromaandClaude Opus 4.8 1e19927ae3 feat(set-get): add Set/Get Pixaroma wireless variable nodes
Pixaroma's own named-variable / wireless-wire node pair in a private
namespace (PixaromaSetNode / PixaromaGetNode) so it coexists with any
other pack's Set/Get-style nodes with zero interference. Pure-frontend
virtual nodes: no Python, never in the prompt; Gets resolve straight
through to the real source via getInputLink (same-graph) +
resolveVirtualOutput (subgraph, native on frontend 1.45.15).

- Set: one any-type input + editable name, no output (minimal). Adopts type.
- Get: dropdown picks which Set to read + one type-matched output.
- Subgraph scoping, robust at any nesting depth.
- Subtle value readout (= 81) for simple types, expanded only, display-only.
- Right-click nav only (Get -> jump to Set; Set -> select all Gets).
- Both renderers (Classic + Nodes 2.0); brand color + Help (?) toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:32:03 +03:00
pixaromaandClaude Opus 4.8 b85239828a chore(release): v1.3.95 - Resize Crop + Portrait Landscape nodes, menu folders
- NEW Resize Crop Pixaroma (crop-to-fill to an exact width x height)
- NEW Portrait Landscape Pixaroma (one-click portrait/landscape flip)
- All nodes reorganized into 7 menu subfolders under the Pixaroma menu
- README changelog + Creative Suite showcase entries added
- CLAUDE.md updated: node families, create-a-node category guidance,
  token-saving rows, and the menu-categorization note

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:09:56 +03:00
pixaromaandClaude Opus 4.8 5908f6a062 fix(portrait-landscape): auto-fit node height to content
The fixed 190px default height (+ a 160px min clamp) left a big empty gap
under the pills and blocked shrinking. Now the fresh-drop height = LiteGraph's
natural content size (title + the two number fields + pill row) via
node.computeSize(), and the height clamp is dropped so LiteGraph floors it at
the content. Width rule unchanged (pills stay uncrowded). Legacy-gated as
before; configure() still overrides for saved/duplicated nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:02:57 +03:00
pixaromaandClaude Opus 4.8 e5243a76aa chore(portrait-landscape): namespace the DOM widget type
Review nit: use a 'pixaroma_*' DOM-widget type (not the generic 'custom')
per the house rule, so it can never collide with a future registered Vue
widget type. Not a live bug (custom isn't a registered type today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:52:01 +03:00
pixaromaandClaude Opus 4.8 d6813bd1c6 feat(portrait-landscape): add Portrait Landscape Pixaroma node
One-click orientation flip: enter (or wire) two dimensions, click Portrait
or Landscape. Portrait outputs the tall arrangement (smaller value as
width), Landscape the wide arrangement (larger value as width) - so the
order you enter the numbers doesn't matter. Replaces the WH + WH + Switch
WH 'flip orientation' setup with a single node.

width/height are native INT widgets (typeable AND wireable). The pill
toggle mirrors Switch WH (state on node.properties + graphToPrompt
injection, Vue Compat #9). Both renderers: size clamps gated legacy-only,
installResizeFloor for the Nodes 2.0 drag floor. Lives in Values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:41:12 +03:00
pixaromaandClaude Opus 4.8 e99610041d fix(resize-crop): match output device to input tensor
Review hardening (CLAUDE.md Image Crop/Uncrop Pattern #6): .to(image.device)
on both outputs so they pair cleanly with GPU-resident tensors downstream.
No-op for the usual CPU IMAGE path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:25:56 +03:00
pixaromaandClaude Opus 4.8 35a3006685 refactor(menu): organize Pixaroma nodes into category subfolders
Group all 40 user-facing nodes under the flat 👑 Pixaroma menu into 7
subfolders via each node's CATEGORY: 🎨 Editors, 🖼️ Image, ✂️ Resize &
Crop, 💬 Prompt & Text, 📝 Notes & Overlay, 🔢 Values, 🔀 Logic & Flow.

Cosmetic Add-Node-menu change only. Brand dark colors are preserved (the
brand hook keys off CATEGORY.startsWith('👑 Pixaroma')) and saved
workflows are unaffected (they reference the node class id, not category).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:21:42 +03:00
pixaromaandClaude Opus 4.8 09eec7b7ba feat(resize-crop): add Resize Crop Pixaroma node
Simple dedicated crop-to-fill node: forces an image (and optional mask)
to an exact width x height. Scales to cover the target then center-crops
the overflow, so output is always exactly WxH with no stretch/letterbox.
width/height are native INT widgets (default 1024) - typeable AND
connectable. Reuses the shared _apply_cover engine; no custom JS, works
in both Classic and Nodes 2.0. Outputs image, mask, width, height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:03:16 +03:00
pixaromaandClaude Opus 4.8 557a09d518 chore(release): v1.3.94 - Load Images from Folder First-box fixes
- First-N box selects live as you type (was button-only) + caps to the
  image count, snapping the box down only on commit (no mid-type digit shift).
- IS_CHANGED now mirrors load()'s realpath+commonpath path guard.
- Docs: CLAUDE.md Load-Images-from-Folder pattern #11 + IS_CHANGED note;
  README changelog (folded into the June 17 entry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:10:39 +03:00
pixaromaandClaude Opus 4.8 33503dfb78 fix(load-images-folder): harden IS_CHANGED path guard + snap First-box only on commit
Two follow-ups from a 3-agent review of the node:

- IS_CHANGED now mirrors load()'s realpath + commonpath guard before os.stat,
  so a crafted '../../x' in the (frontend-supplied) selected state can't stat a
  file outside the chosen folder. Outside paths hash as a constant. Restores the
  documented 'both load() AND IS_CHANGED guard' invariant. (Low impact - only a
  stat folded into a hash - but it was a real gap.)

- The First-N box now snaps down to the image count only on commit (Enter /
  blur / spinner), not on the live debounced path. Pausing mid-type above the
  cap no longer rewrites the box and shifts the next digit. Live typing still
  selects the clamped count, so the 'N / N selected' readout shows the cap.

Review verdict otherwise: node is solid, no correctness/security must-fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:50:15 +03:00
pixaromaandClaude Opus 4.8 250a0697e6 fix(load-images-folder): cap the First-N box to the image count
Typing a number larger than the folder's image count selected correctly
(13/13) but the box still displayed the typed value (e.g. 25), so it looked
like it accepted more than exist.

applyFirstN now returns the clamped count; the box snaps down to it when you
enter too many (via typing, Enter, blur, or the First button), and the
spinner's max attribute is set to the image count so the arrows stop there.
In-range values are never rewritten (no cursor jump).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:35:20 +03:00
pixaromaandClaude Opus 4.8 bc5f8dfac0 fix(load-images-folder): First-N box selects live as you type
The First control was two parts - a button + a number box - but only the
button's click handler ran the 'select first N' logic. Typing in the box
just changed its value and never selected anything, so the box looked broken.

Now the box drives the selection itself: a shared applyFirstN() is wired to
the input (debounced ~220ms so '12' applies once, not 1-then-12), Enter
flushes immediately, and blur/spinner commit via change. Empty box = no
change (use None to deselect). The First button still works and normalizes
the value to >=1. Help bullet updated to say it selects as you type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:29:47 +03:00
pixaromaandClaude Opus 4.8 062e4e3a58 docs(claude): Seed Pixaroma patterns + token-saving row
Capture the Seed node's do-not-regress invariants from the 5-round review:
runtime-only last-run seed (dirty-on-load), Random nonce in IS_CHANGED, measured
getMinHeight with no getMaxHeight + computeLayoutSize, fitSeedFont, surgical
commitNum, the http-LAN Copy fallback, button pills, headless->0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:42:03 +03:00
pixaromaandClaude Opus 4.8 40a41d8be1 chore(release): v1.3.93 - Seed Pixaroma
- pyproject 1.3.92 -> 1.3.93; description adds Seed (462 chars, under the
  registry ~1000-char limit).
- README: changelog entry (June 17) + Creative Suite showcase entry for the new
  Seed Pixaroma node.

Bundles the work on Ioan since v1.3.92: the new Seed Pixaroma node (Random/Fixed
seed source with New fixed random / Use last seed / Copy + a last-run readout,
both renderers, reviewed across 5 agent rounds) and the Inpaint Stitch/helpers
review hardening (mask numel guard + truthful original on a stitch fault).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:36:32 +03:00
pixaromaandClaude Opus 4.8 dbf0bf63d3 fix(inpaint): review hardening (mask numel guard + truthful stitch-error original)
Two zero-risk hardening tweaks from the 3-agent review of the Inpaint pair:
- resolve_inpaint_mask: guard the .max() with numel() so a 0-element mask tensor
  can't crash (not reachable in production today, but it's a public helper).
- Inpaint Stitch: on a genuine stitch_back fault (e.g. CUDA OOM), keep the
  'original' output as the real uncropped image (crop_info['image'], already
  validated 4D by the guard above) instead of echoing the inpainted patch, so a
  downstream before/after compare still shows the true original.

Harness green (28 tests, RECT GUARD PASS); resolve_inpaint_mask precedence
unit-checked (0-element/black->wired, painted wins, neither->None).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:00:59 +03:00
pixaromaandClaude Opus 4.8 13c7249f4e fix(seed): keyboard-reachable mode pill + a11y/doc polish (round 5)
Round-5 review (3 agents), all SHIP-quality. Applied 3 small improvements:
- Random|Fixed pill segments are now real <button>s (were <div>s, so the toggle
  was unreachable by keyboard). Matches the Prompt Pack / Prompt Multi pills;
  added a :focus-visible outline. Click/active/hover styling unchanged.
- Help: 'Use last seed' now notes it is session-only (greys out after a reload
  until you Run once) - that was previously unexplained.
- Hardened the LAN copy fallback (ta.remove() in a finally so it can't leak on
  the throw path) and nudged the muted 'Last run' line contrast up.

Agents otherwise confirmed clean: both recent fixes correct, the Random/Fixed
state machine is fully self-consistent, the dark number box is the correct field
idiom on recolored nodes (not a theming clash), CSS is fully scoped, and the
Brand/Align/Node-Colors/Connection-FX interactions are sound. Docs match the
code and the original design intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:47:46 +03:00
pixaromaandClaude Opus 4.8 1d2d8c9a9c fix(seed): Copy works over http LAN (execCommand fallback) + help notes
Round-4 review (3 agents). One real bug + two doc gaps:
- Copy was dead in INSECURE contexts (ComfyUI over http://<LAN-IP>, phone,
  tunnel) because navigator.clipboard is undefined there -> it just flashed
  'No clipboard'. Added the throwaway-textarea + execCommand('copy') fallback
  the other copy nodes (Version Check / Show Text / Prompt Reader / Note)
  already use; works because the click is a user gesture.
- Help: note that wiring into KSampler greys out KSampler's own seed control
  (this node drives it), and that API/no-browser runs fall back to seed 0.

Agents otherwise verified clean: caching/re-exec vs ComfyUI core (Fixed caches,
Random re-runs, no upstream re-run, unconnected pruned, mute/bypass safe,
partial-exec fine, multi-sampler synced), no prototype-pollution/injection via
seedState, graphToPrompt hook is lazy/cheap, nonce safe, no leaks, edits compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:41:18 +03:00
pixaromaandClaude Opus 4.8 898e3b18ba fix(seed): size body by MEASURED content (no clip, no gap, no guessing)
Two-agent investigation. Root cause: the getMaxHeight cap sat below the real
content (worsened by the last-run line-height bump) so the bottom line clipped
in legacy; the earlier forced node.size[1] had caused the opposite (a gap). The
fix is to stop hand-guessing a pixel constant.

Per the sizing agent (matching Load Image / Prompt Pack):
- getMinHeight returns the MEASURED content height (shared measureRootContent:
  children offsetHeight + gaps + padding), coarse-rounded to 4px so it is stable
  across save/load (no dirty-on-load). NO getMaxHeight (a single-widget node has
  no slack consumer, so an upper cap can only clip the bottom line).
- Add computeLayoutSize({minHeight: measured, minWidth: 1}) for the Nodes 2.0
  grid row.
- Stop forcing node.size[1]; lock WIDTH only and LEGACY-only (in Nodes 2.0 the
  rendered size lives in the Vue layout store). A legacy-only post-layout setSize
  snaps the node to the measured height; coarse-rounding makes it idempotent on
  reload so it never dirties a saved workflow.

Second agent found no other bugs (the dead runaway guard + timer churn are
harmless); all prior-round fixes still hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:27:13 +03:00
pixaromaandClaude Opus 4.8 92d31e51ec fix(seed): layout polish (number auto-fit, descender room, content-sized height)
- Auto-shrink the big seed number to fit the field (fitSeedFont) so a 16-digit
  seed is no longer cut off the right edge in the narrower Nodes 2.0 body. Fits
  on initial render, after typing, and via short retries for the fresh-drop case.
- Fixed number-box height (42px) so the font shrink never changes the box height.
- last-run line gets line-height 1.6 so descenders (the y in 'every') aren't
  clipped at the node's bottom edge.
- Size the body via getMinHeight==getMaxHeight==WIDGET_H and let LiteGraph add
  the title/slot chrome (no forced total height), so the node fits its content
  with no extra gap in legacy. Width stays locked.

Reported from in-ComfyUI testing (Nodes 2.0 number cut; legacy gap + clipped y).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:15:29 +03:00
pixaromaandClaude Opus 4.8 8247ca9a56 fix(seed): review round 2 (surgical number-commit + airtight Random)
- Committing the seed field is now surgical (no full DOM rebuild) and a no-op
  when the value is unchanged: fixes a bare focus/blur silently flipping
  Random->Fixed, and the rebuild-on-blur that could swallow a following click.
- Random mode injects a per-call nonce so the prompt string always differs and
  the node re-runs even on the ~1-in-2^53 chance two rolls collide; Fixed mode
  injects no nonce so it stays cached/repeatable. Python get_seed ignores it.

Round-2 agents otherwise verified the node clean (dirty-on-load, both renderers,
duplication, subgraphs, multi-node, wrap-order, Python fallbacks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:01:24 +03:00
pixaromaandClaude Opus 4.8 d1086bf303 fix(seed): address review round 1 (dirty-on-load, empty field, Copy consistency)
- Move the last-run seed to a session-only runtime field (node._pixSeedLastRun)
  instead of node.properties, so a run never rewrites serialized state and can't
  flag a saved workflow modified (Vue Compat #18). The graphToPrompt hook no
  longer writes node.properties at all.
- Clearing the seed field keeps the existing seed instead of wiping it to 0.
- Copy now copies exactly the seed shown in the big field (matches display +
  tooltip); Use last seed loads the actual last-run seed into the box.
- Replace two em dashes in the node's own text with a colon (house rule).
- Mutate node.size indices instead of replacing the array (UI convention #9).

From a 2-agent review. Rejected non-issues: random-batch-shares-seed (KSampler
varies noise per batch index) and missing onRemoved (no per-node leaks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:54:52 +03:00
pixaromaandClaude Opus 4.8 97c72d7189 fix(seed): populate node body on fresh drop (was blank)
The initial render went through renderUI, which bailed when the DOM widget
element wasn't attached yet (true on a fresh node drop), so the body never
built and nothing re-triggered it. Split out buildSeedBody() and call it on
the captured root element directly from the create microtask (Resolution
pattern), so the content is present the moment LiteGraph draws the widget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:43:30 +03:00
pixaromaandClaude Opus 4.8 9d655d93dc feat(seed): add Seed Pixaroma - Random/Fixed seed source with buttons
New node PixaromaSeed: a seed source you wire into a sampler's seed input.
Random rolls a fresh seed each run (Last-run readout shows the actual seed
used); Fixed keeps it repeatable. Buttons: New fixed random (roll + lock),
Use last seed (recall the previous run's seed), Copy. Typing a number sets an
exact seed and switches to Fixed.

Hidden SeedState input + per-run injection via app.graphToPrompt (Resolution
Pixaroma pattern); IS_CHANGED returns the injected seed so Random re-runs and
Fixed caches. Locked-size DOM widget with adaptive canvasOnly so it works in
both Classic and Nodes 2.0. Registered in __init__.py + central help_defs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:38:53 +03:00
pixaromaandClaude Opus 4.8 5307069260 fix(release): trim pyproject description under the Comfy Registry ~1000-char cap
The v1.3.92 publish pipeline failed (script_failure) because the registry
description grew to 1041 chars when the Inpaint pair was added; the Registry
caps that field near 1000. Trimmed to a plain node-name list (456 chars, no
parenthetical blurbs) so v1.3.92 can publish. Version stays 1.3.92 since it
never reached the registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 06:57:05 +03:00
pixaromaandClaude Opus 4.8 a784f3c527 docs(inpaint): mark the pair RELEASED in v1.3.92 (retire the WIP-handoff label)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:56:18 +03:00
pixaromaandClaude Opus 4.8 fcc6d6b447 chore(release): v1.3.92 - Inpaint Crop + Stitch, no-store cache fix, Node Colors shortcut
- pyproject bumped 1.3.91 -> 1.3.92; description now lists the Inpaint pair.
- README: new changelog entry (June 16) + Creative Suite showcase entries for
  Inpaint Crop Pixaroma + Inpaint Stitch Pixaroma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:51:42 +03:00
pixaromaandClaude Opus 4.8 a331c8a830 feat(node-colors): show the "\" shortcut in the Node/Group Colors menu items
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:42:53 +03:00
pixaromaandClaude Opus 4.8 231d2b7874 feat(node-colors): press "\" to open the color palette for the selected node(s)/group
Discord request. A window keydown listener opens the same Pixaroma Node/Group
Colors popup the right-click menu does, reusing getTargetNodes/getTargetGroups +
openNodeColorsPalette/openGroupColorsPalette. Selected node(s) take priority, else
a selected group. Gated: ignored while typing in a field, with a modifier held, or
when a palette is already open; only swallows the key when something is selected
(a bare "\" otherwise passes through to ComfyUI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:38:37 +03:00
pixaromaandClaude Opus 4.8 61112949e1 docs: note the .mjs browser-cache gotcha + the no-store hook in server_routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:26:50 +03:00
pixaromaandClaude Opus 4.8 f73cc030b2 fix: send Cache-Control: no-store for our own .mjs/.js so updates show on normal reload
ComfyUI core's cache middleware sets no-store on .js/.css but checks
path.endswith(".js"), which misses our .mjs ES modules - so browsers heuristically
cache them and a plugin update isn't seen (missing new buttons) until a hard
refresh. server_routes.py now registers an aiohttp on_response_prepare hook on
PromptServer.instance.app that sets Cache-Control: no-store for files under
/extensions/<this-plugin>/ ending in .mjs or .js (folder name auto-derived, so a
renamed install still works). Only touches this plugin's files. Needs a full
ComfyUI restart to take effect; verified the live server currently returns no
Cache-Control on .mjs (the bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:25:42 +03:00
pixaromaandClaude Opus 4.8 d59954b765 docs(inpaint): note session-7 revert of skip-DT-during-paint (flash) + pan-ring hide
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:15:50 +03:00
pixaromaandClaude Opus 4.8 e9c2504af9 fix(inpaint): no crisp->blur flash while painting; hide brush ring during pan
Two follow-ups from user testing of the zoom/perf work:
- The previous "skip the seam distance-transform while painting" optimization made
  the mask flash crisp (mid-stroke) -> feathered (on release) each stroke - obvious
  at softness 1-20, invisible at 0. Now the FEATHERED seam preview is shown the whole
  time (consistent, no flash); the rAF coalescing keeps it smooth. If a very large
  image lags during a stroke, throttle the preview rather than skipping it.
- The brush ring froze at its old spot while Space-dragging to pan. Now the ring is
  hidden while Space is held / panning (cleared on space-down and pan-start; the
  grab/grabbing cursor shows instead), and reappears on the next normal move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:14:41 +03:00
pixaromaandClaude Opus 4.8 619473bc38 docs(inpaint): record session-6 (brush perf + zoom/pan); fix harness count to 28
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:00:44 +03:00
pixaromaandClaude Opus 4.8 0add5bef5a feat(inpaint): zoom + pan in the mask editor (wheel = zoom, brush size on [ ])
Per the user's pick: scroll wheel zooms toward the cursor (re-rendered from the
full-res source, so the zoomed view is crisp for detail work, not a blurry CSS
upscale); brush size moved fully to [ / ] + the Size slider; pan via Space-drag
or middle-mouse-drag. The brush's source radius shrinks as you zoom in, so a fixed
on-screen brush paints finer detail.

Implementation: _baseScale (fit) + _zoom + _panX/_panY; _scale = _baseScale*_zoom.
_applyZoom keeps the source point under the cursor fixed; _clampPan keeps the image
covering the viewport (forces pan 0 at fit). All paint/cursor/box/tint coords offset
by pan + use _scale; the seam preview uses _baseScale (it's fit-res then drawn at the
zoomed view rect, so _scale would double-count zoom). At zoom 1 / pan 0 everything
reduces to the previous behavior (no regression to the unzoomed case). Pan listeners
detached on cleanup; Space tracked in _bindKeys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:58:53 +03:00
pixaromaandClaude Opus 4.8 bd6067c217 fix(inpaint): show the painted mask outside the crop box while painting
The mask tint was clipped to the crop region even mid-stroke, but the box only
grows on stroke end - so strokes painted beyond the current box were invisible
until release (the "interrupted by the context margin" report). Skip the clip
while _painting; the clipped seam preview returns when the stroke ends.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:46:26 +03:00
pixaromaandClaude Opus 4.8 85c4866ddf perf(inpaint): smooth brush on big images; show B/E + brush-size shortcuts in the UI
The brush was saccadato / stuck on large images because every mouse-move ran a
synchronous full redraw, and that redraw recomputed the seam-feather preview via a
getImageData readback + a per-pixel distance-transform pass - every frame. Fixes:
- coalesce stroke redraws to one per animation frame (_requestRedraw); the cursor
  ring stays immediate on its own canvas.
- skip the seam distance-transform while a stroke is active - tint the mask crisply
  instead; the feathered preview is computed on stroke end. (This is what made it
  feel "interrupted until you release": the redraw queue was backed up; on release
  one full draw caught up + grew the crop box.)
- cancel the pending frame on stroke end + editor cleanup.
Also: the Brush / Erase pills now show their keys (B / E) and the Brush panel shows
the "[ / ] / scroll" size hint, per request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:45:04 +03:00
pixaromaandClaude Opus 4.8 9940582d1f docs(inpaint): record the systematic-audit fixes (session 5); 2 of 3 deferred minors now fixed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:36:42 +03:00
pixaromaandClaude Opus 4.8 f8befb5e19 fix(inpaint): restore node thumbnail from src_path after a tab switch; mutate node.size
- The node mini-preview blanked after a workflow tab switch when the source was
  loaded via the editor's Load Image (the wire is gone, so the preview must rebuild
  from the saved src_path). _pixInpaintRefresh and the widget setValue passed the
  literal "{}" to restoreNodePreview instead of the real stateJson, so it had no
  src_path to rebuild from. Pass stateJson (restoreNodePreview already falls back to
  src_path; a genuinely source-less state still no-ops cleanly).
- node.size default is now set by mutating node.size[0]/[1] instead of replacing the
  array (convention #9), still gated on !isGraphLoading() (fresh drops only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:35:26 +03:00
pixaromaandClaude Opus 4.8 4752a97389 fix(inpaint): editor seam preview matches the result (softness box + whole-crop)
Two preview-only bugs from the audit (no effect on the actual render):
- The Softness slider only redrew the seam tint; it never called _recomputeRegion,
  so the orange crop box + size badge stayed stale (softness grows the crop once it
  exceeds context_px, Option B). At high softness the tint was clipped to the old
  smaller box = the exact "preview != result" the rework was meant to kill. Now it
  recomputes the region (mirror of the Mask-grow slider).
- Whole-crop blend mode previewed the MASK-edge feather, but the stitch feathers the
  whole crop RECTANGLE. _seamAlphaCanvas now has a whole_crop branch that paints the
  inward rectangle-edge ramp (mirror of Python _feather_alpha), so the editor tint
  matches a Whole-crop stitch. (Preview is canvas-rendered - eyeball it in ComfyUI.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:35:11 +03:00
pixaromaandClaude Opus 4.8 dba1417452 fix(inpaint): cap whole-crop feather on small crops; guard Stitch against a bad crop_info rank
Two issues from the systematic audit:
- _feather_alpha (whole_crop + the no-edge fallback) ramped dist/blend from the
  rectangle edges; when the feather exceeded half the crop the ramp never reached
  1, so the WHOLE paste went translucent and ghosted the original through (e.g. a
  small crop + a large Stitch softness override). Cap the feather at ~half the
  smaller side so the interior stays opaque. Only bites on small-crop+big-feather;
  large crops are byte-identical. Locked by test_feather_alpha_caps_on_small_crop.
- Inpaint Stitch's crop_info guard now also requires image.dim()==4, so a malformed
  (non-[B,H,W,C]) image takes the clean passthrough with the clear message instead
  of throwing into the except (which would silently make `original` a copy of the
  result).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:32:38 +03:00
pixaromaandClaude Opus 4.8 1f858232da docs(inpaint): record session-4 (Stitch seam override) in the WIP handoff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:52:00 +03:00
pixaromaandClaude Opus 4.8 ec4c5d8dbf feat(inpaint): Stitch can override seam softness + blend mode (tune without re-sampling)
The seam softness, blend mode and mask grow/blur all lived on the Crop node
(before the sampler), so changing any of them re-ran KSampler even on a fixed
seed. The pure paste-back settings now live on Inpaint Stitch too:

- Stitch gains softness (-1 = inherit the crop's) + blend_mode (from crop / mask
  / whole crop) widgets that override what rode in on crop_info, via the new
  resolve_seam() helper. Because Stitch is downstream of the sampler, changing
  them re-runs only Stitch (KSampler stays cached) - so the blend tunes instantly.
- mask_grow / mask_blur stay Crop-only: they change what the model paints, so
  they genuinely need a re-sample.
- No room-clamp: a Stitch softness bigger than the crop's room may show a harder
  edge (tooltip says to raise the Crop softness); dialing down is always clean.
- Tooltips, DESCRIPTION, toolbar help, and inpaint-crop-math.md updated.

resolve_seam locked by test_resolve_seam_inherit_and_override (harness 28 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:50:40 +03:00
pixaromaandClaude Opus 4.8 e9ade9ad04 docs(inpaint): record session-3 work in the WIP handoff
blend_mode on the node, the paste-into-LoadImage source fix, editor paste/drag,
the kept-strokes hint, and the richer Help Guide (commits 11afe83 + 718960a).
Notes that invert_mask was already under softness (stale screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:27:48 +03:00
pixaromaandClaude Opus 4.8 718960a8bf feat(inpaint): paste/drag into the mask editor, richer Help, kept-strokes hint
- the open mask editor now accepts paste + drag-drop to load a source image
  (window-capture handlers gated on the overlay, preempting ComfyUI's own
  image-drop), same as the Load Image button. Unbound on close.
- the saved-mask loader now shows "Kept your new strokes - the saved mask wasn't
  reloaded." when it skips a late mask load because you already started painting
  (was a silent skip).
- the Help popup is rewritten into a short guide: what the node does, painting,
  crop-size modes, the seam (Softness + Blend mode Mask vs Whole crop), and keys.
  framework layout.mjs gains an optional helpTitle (defaults to the old
  "<name> - Shortcuts", so other editors are unchanged) so it reads
  "Inpaint Crop - Guide".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:26:02 +03:00
pixaromaandClaude Opus 4.8 11afe8369a feat(inpaint): Blend mode is now a node setting; fix paste-into-LoadImage source load
- blend_mode (mask / whole crop) is a Crop node combo widget, placed above
  invert_mask, mirrored by the editor's Blend mode pill (writeBackWidgets /
  readParams; _BLEND_MODE maps label <-> key). run() sets crop_info["blend_mode"]
  from the widget (the source of truth, not state_json). Tooltip + toolbar help +
  docs now explain Mask (replace only the painted area) vs Whole crop (replace the
  entire crop). invert_mask stays the last setting, under softness.
- getUpstreamImageURL now parses a LoadImage value's subfolder + [input]/[output]/
  [temp] annotation, so pasting an image into a Load Image node then opening the
  mask editor loads it instead of "Failed to load the source image" (only paste
  hit this, since it lands in a subfolder).
- the node-body paste handler steps aside when the editor is open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:25:51 +03:00
pixaromaandClaude Opus 4.8 29f336bb5b docs(inpaint): mark the deferred review minors done in the WIP handoff
Records session 2: all 7 deferred minors cleared (1fab596 / cad1586 / 2ef7cd6 /
754e017 / 7b7797a) plus the roundMult banker's parity fix (f7a4f6c), a 2nd
2-agent review verdict (SHIP), the harness count (26), and the remaining
optional polish. Handoff still stands until the pair is released.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:09:30 +03:00
pixaromaandClaude Opus 4.8 f7a4f6cb5a fix(inpaint): editor crop-box size matches the real output at .5 boundaries
geometry.mjs roundMult rounded half-up while Python _round_mult uses banker's
rounding (half-to-even), so at an exact .5 multiple boundary the editor crop-box
size could read one multiple step (8-64px) larger than the actual node output,
contradicting the never-a-different-output-size invariant. roundMult now rounds
half-to-even to match Python; only exact-.5 cases change. Verified the JS and
Python region sizes agree on the boundary case. Surfaced by the 2-agent review;
pre-existing, unrelated to the free-mode fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:06:27 +03:00
pixaromaandClaude Opus 4.8 7b7797a523 docs(inpaint): clarify single-frame mask, conditional round-trip, temp accumulation
Adds a Notes section to inpaint-crop-math.md: MASK is single-frame (mask_to_np
uses frame 0), the pixel-exact round-trip holds only in free+whole_crop+blend=0,
crop_info mask is float32, the no-edge whole-crop feather fallback, and the
editor temp/ source PNGs clear on ComfyUI restart not per run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:54:57 +03:00
pixaromaandClaude Opus 4.8 754e0177d2 fix(inpaint): editor reset-all resets brush tool; wrap onConnectionsChange; load-race guard
- Reset all to default now returns the tool to Brush (it stayed on Erase).
- node.onConnectionsChange is wrapped instead of assigned, so a prototype /
  other-extension handler is chained, not clobbered (forward-safety).
- _loadImageFromURL / _loadImageFromDataURL / _loadMaskFromURL carry a load
  generation token: a late image/mask onload from a prior open no longer
  overwrites the current image, and a saved mask won't clobber fresh strokes
  (bails if painting / has undo history / a painted bbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:53:54 +03:00
pixaromaandClaude Opus 4.8 2ef7cd6cde fix(inpaint): crop_info mask stays float32 on fp16 pipelines
The carried full-frame mask followed image.dtype, so on an fp16 image it came
back float16 while out_mask was float32. Pin it to float32 for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:51:30 +03:00
pixaromaandClaude Opus 4.8 cad1586ec2 fix(inpaint): Stitch feathers the whole crop when there is no mask edge
Wiring an Image Crop (no mask) into Inpaint Stitch gave crop_info an all-zeros
mask; in mask blend-mode that resolved to alpha=0 and _blur_alpha pasted
NOTHING (result == original, inpaint lost). An all-ones mask gave a hard
rectangle. stitch_back now detects an all-zeros/all-ones resolved alpha (no
edge to soften) and falls back to the rectangle feather over the full crop, so
the edited crop is pasted with a soft seam and Softness still works. Also
covers a whole-image inpaint with nothing painted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:50:48 +03:00
pixaromaandClaude Opus 4.8 1fab59686b fix(inpaint): free mode scales both axes on the max_size clamp (no stretch)
Free-mode output capped each axis independently, so a wide/tall crop whose
long side exceeded max_size came out stretched (4:1 source -> ~1.4:1). Now
both axes scale by one factor, preserving the source aspect. Mirrored in
geometry.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:49:59 +03:00
pixaromaandClaude Opus 4.8 a11a8fb208 fix(inpaint): color_match must not mutate the caller's tensor; IS_CHANGED tracks invert_mask
From today's 2-agent code review. (1) stitch_back clones the patch before color_match's in-place writes, so it can't corrupt the inpaint-result tensor ComfyUI caches upstream (the color shift would otherwise compound run-over-run, or leak to a parallel Preview/Compare). (2) invert_mask added to the Crop node's IS_CHANGED key, so toggling it actually re-runs instead of serving a stale cached crop.

Harness +1: test_color_match_no_inplace_mutation (watched fail, now green); 22 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:35:36 +03:00
pixaromaandClaude Opus 4.8 3020887528 refactor(inpaint): move the invert_mask toggle to the end (after softness)
Widget-order only: invert_mask now renders after softness on the Inpaint Crop node (user preference). No logic/behavior change - run() reads it by keyword; the geometry/mask logic and the harness are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:23:32 +03:00
pixaromaandClaude Opus 4.8 5159d13a6b feat(inpaint): invert_mask toggle on Inpaint Crop (built-in Invert Mask)
A new on/off invert_mask widget (default off). When on, the node flips the mask (1 - mask) right before cropping, so the inpaint targets the OPPOSITE area - swap subject and background. Works on a wired mask (e.g. Remove Background) or a painted one; a built-in replacement for an external Invert Mask node. No-op when no mask is connected.

The flip rides through the bbox, the conditioning out_mask, and crop_info['mask'] (so the stitch blend follows automatically - no stitch change). Standard BOOLEAN widget: renders in both node renderers and serializes natively; old workflows load it as False (no behavior change). Editor unchanged (node-only design; it keeps its own paint-Invert button).

Harness +2: test_invert_mask_targets_opposite_area + test_invert_mask_no_mask_is_noop; 21 total green. Tooltip + Description + Help + docs/inpaint-crop-math.md + CLAUDE.md #14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:14:03 +03:00
pixaromaandClaude Opus 4.8 bf745e38a3 fix(inpaint): coerce RGBA input to RGB so Stitch keeps the real original
Remove Background Pixaroma outputs RGBA (rgb = original image, alpha = cut-out). Wired into Inpaint Crop, that 4-channel image rode into crop_info['image']; Inpaint Stitch's paste then mismatched 3-vs-4 channels and threw, and the node's except returned (image, image) - so the 'original' output was a copy of the RESULT and the stitch never actually pasted. (User: the original/compare showed the same image, not the black-background cut-out.)

apply_inpaint_crop now premultiplies an RGBA input over black (rgb*alpha) at the top so crop_info['image'] + the cropped image are always RGB, and a cut-out reads as the subject on black (matching the preview / the expected 'original'). stitch_back also coerces its base defensively for an RGBA crop_info from any other source.

Python-only (full ComfyUI restart). Harness +1: test_rgba_input_coerced_to_rgb (reproduced the symptom, now green); 19 total. docs/inpaint-crop-math.md + CLAUDE.md inpaint #13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:53:51 +03:00
pixaromaandClaude Opus 4.8 5200566a64 fix(inpaint): crop output size derives from the clamped rect (no more stretch)
A big softness (which grows the crop context) on a mask near the image edge made the crop come out squished: in keep/free mode compute_region computed the output size from the UN-clamped wanted region while the source rect was clamped to the image, so the resize used a different aspect. (User: softness 150 stretched an inverted helmet mask; output came back 768x1024 instead of ~square.)

compute_region now derives out_w/out_h from the CLAMPED source rect (rw_i/rh_i) for ALL modes - the size computation moved AFTER the image-bound clamp. This generalizes the earlier force-mode aspect re-impose to keep + free. Mirrored in geometry.mjs.

Harness +1: test_keep_mode_no_stretch_on_clamp (watched fail, now green); 18 total. JS no-stretch confirmed via _inpaint_geom_test.mjs. Verified by 2 agents (Python HIGH confidence + JS parity exact). docs/inpaint-crop-math.md + CLAUDE.md handoff note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:26:49 +03:00
pixaromaandClaude Opus 4.8 487a6ec6ab fix(inpaint): fill_holes no longer fills a large subject hole (mask stays intact)
A cut-out / background mask (white around a subject, e.g. a Load Image MASK from a transparent PNG) came back SOLID white after Inpaint Crop - the painted shape was gone 'after the node'. Cause: scipy binary_fill_holes fills EVERY enclosed hole, so it filled the subject-shaped hole and collapsed the mask to solid (then the crop bbox became the whole image and the model would repaint everything).

fill_holes now keeps only small enclosed holes (<= max(256, 0.005*H*W), ~0.5% of the image) - paint specks/gaps still close, but a subject hole is left intact. A hole-free mask is unchanged (early short-circuit); the no-scipy PIL-close fallback was already limited to small holes. Python-only (the JS editor never fills holes).

Harness +3: test_fill_holes_preserves_large_hole, test_fill_holes_fills_small_speck, test_background_mask_survives_crop (the last reproduces the user's solid-mask symptom, now green); 17 total. docs/inpaint-crop-math.md + CLAUDE.md inpaint #12. POLARITY note in the docs: a transparent-PNG mask targets the BACKGROUND; invert to inpaint the subject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:56:03 +03:00
pixaromaandClaude Opus 4.8 bfd45e7d2a feat(inpaint): editor seam preview matches the real result (Option B, part 2)
The editor's live seam tint used a gaussian approximation, so a moderate Softness looked TIGHTER in the editor than the actual stitched seam - users over-set it. Now the preview uses the same outward smoothstep the stitch does, so what you see is what you get.

- geometry.mjs: new pure seamAlphaFromAlpha() - a 2-pass (1,sqrt2) chamfer distance transform of the outside distance + smoothstep, the canvas mirror of the Python _blur_alpha scipy path. Node-tested for shape (inside=1, fades to 0 at blend px, monotonic, empty/full).

- render.mjs::_seamAlphaCanvas: draws the mask into a downscaled buffer (480px cap), runs the DT, upscales. The feather reaches 0 at exactly blend image px, matching the real seam. No cache needed - _draw runs only on strokes/slider-drags/load (the brush cursor is a separate canvas), and the capped buffer keeps it ~2-3ms.

JS-only (Ctrl+F5, no restart). Reviewed: faithful to the Python seam, high confidence. The softness rework is now complete (parts 1 + 2). docs/inpaint-crop-math.md + CLAUDE.md Patterns 3/5 + handoff updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:41:44 +03:00
pixaromaandClaude Opus 4.8 5b77a154b7 feat(inpaint): softness grows the crop so the seam always blends (Option B, part 1)
The Softness slider widens the paste-back feather. Before, the crop hugged the mask (~context_px), so a big softness had no room: a rect-edge guard crushed the feather to a hard step at the mask edge, and the editor preview understated the seam. Now the crop CONTEXT grows to max(context_px, blend), so the outward feather always reaches 0 inside the crop on its own.

- compute_region context = max(context_px, blend); blend added to DEFAULTS (16) + merge_params; node feeds softness into params['blend'] before the crop. Mirrored in geometry.mjs, so the editor crop box grows with softness for free.

- _blur_alpha drops the rect-edge guard AND the core-opaque clamp - the smoothstep is opaque inside by construction and the crop now has room, so neither is needed. Fixes the original ghost AND the high-softness hard step.

Trade-off: high softness makes the crop a bit larger (visible; small res cost). Harness: 14 green (deleted the rect-guard test, added context-grows + feather-completes + an image-edge-clamp-is-benign invariant test; renamed the survivors). Verified by 2 agents (Python correctness HIGH confidence + JS parity confirmed). docs/inpaint-crop-math.md + CLAUDE.md Patterns 3/5 + handoff + Crop help updated. The editor seam-TINT preview is still approximate (Part 2, next commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:19:25 +03:00
pixaromaandClaude Opus 4.8 5e4bf1c0b5 fix(inpaint): cleared/empty editor mask falls back to the wired mask input
The crop node has two mask sources - the wired mask input and the mask painted in its own editor (saved to mask_path). Clearing the editor saved an all-black mask file while mask_path stayed set, and the old 'disk mask wins whenever it loads (not None)' used that empty mask - so apply_inpaint_crop found no painted area and cropped the whole image, silently ignoring the wired mask. User report: painted, cleared the editor, ran, and it stopped using the wired mask.

New resolve_inpaint_mask() (in _inpaint_helpers.py) makes the editor mask win ONLY when it has painted pixels (max > 1e-6); an empty/absent editor mask falls back to the wired mask; neither -> whole image. run() routes both masks through it. The mask input tooltip was corrected (it no longer claims the wired mask loads into the editor - that 'Part B' is a noted follow-up).

Harness adds 4 mask-precedence tests (watched fail before, pass after); all 12 green. CLAUDE.md inpaint pattern #11 records the invariant + the Part B gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:38:35 +03:00
pixaromaandClaude Opus 4.8 d9c4719bf0 fix(inpaint): keep-mode caps output at max_size; force mode no longer stretches
Two safe compute_region geometry fixes (mirrored 1:1 in geometry.mjs), no behavior change beyond fixing the bugs:

(1) OOM - run the min_size short-side bump BEFORE the max_size clamp so an extreme-aspect (thin-line) mask can't be scaled back past max_size into a giant tensor. max_size is now the hard ceiling (the short side may end below min_size, which is acceptable).

(2) Force-stretch - re-impose the source rect aspect == out_w/out_h AFTER the image-bound clamp, so an oblong image is resized without stretching.

Harness (D:\Claude Tests\_inpaint_v2_test.py) adds test_keep_mode_respects_max_size + test_force_mode_preserves_aspect (both watched fail pre-fix, pass after); all 8 green. docs/inpaint-crop-math.md S1 + the CLAUDE.md handoff updated. The softness rework remains a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:15:16 +03:00
pixaromaandClaude Opus 4.8 10836afd47 docs(inpaint): rewrite the WIP handoff for the editor v2 softness rework (round-2 findings)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:03:55 +03:00
pixaromaandClaude Opus 4.8 7a855722b1 fix(inpaint): rect guard no longer dims the mask core; force-mode preview honors Target
Code-review findings (2 agents + self):
- MAJOR: the rect-edge guard faded alpha within blend px of ALL crop borders,
  including the mask interior. Since the crop hugs the mask (~context_px margin),
  a large softness ghosted the whole inpaint transparent. Now max(.., mask) keeps
  the mask core fully opaque; the guard limits only the outward feather.
- MAJOR: the editor's force-square preview used the default 1024 instead of the
  Target value (params never set target_w/target_h). Mirror Python: target_w =
  target_h = target in the preview's computeRegion call.
- MINOR: harmonize the Stitch blend clamp to 150 (matches the Crop softness cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:36:16 +03:00
pixaromaandClaude Opus 4.8 67b15337f8 fix(inpaint): color match references the unchanged surroundings, not the whole crop
Matching to the whole crop (or the mask) included the masked area, so a deliberate
recolor (red->white dress) got dragged back toward the original = the pink tint the
user saw on subtle/strong. Now it matches only the unmasked context, correcting
lighting/tone drift without undoing the color change. Help/tooltip/docs note it is
for blending into the scene, not for recolors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:14:13 +03:00
pixaromaandClaude Opus 4.8 262ebfaa1a feat(inpaint): Target snaps to the selected multiple (8/16/32/64)
Target now rounds to the active multiple as you drag, and re-snaps when you change
the multiple, so the crop target always lines up (no more e.g. 1149 with multiple 8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:01:19 +03:00
pixaromaandClaude Opus 4.8 6048a3f543 feat(inpaint): 'Reset all to default' resets every editor + node knob, not just brush
Moved to the bottom of the sidebar and relabeled; resets brush, opacity, softness,
mask grow/blur, blend mode, context and crop size to defaults (was brush-size only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:52:37 +03:00
pixaromaandClaude Opus 4.8 9eb2639d6a perf(inpaint): fast separable mask dilation (was ~50s at mask_grow 181)
PIL MaxFilter is O(kernel^2) per pixel, so a 363px box dilation hung for ~50s on
a 480x832 image. Use scipy maximum_filter (separable, O(W*H)) when available, with
a separable numpy fallback - instant even at max mask_grow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:51:00 +03:00
pixaromaandClaude Opus 4.8 5ea7dcf34a docs(inpaint): node help + math doc + patterns for softness-on-node, color match on Stitch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:41:21 +03:00
pixaromaandClaude Opus 4.8 40bb3bc86d feat(inpaint): cap Softness slider at 150, drop color-match pills, label blend mode
Editor mirrors the node 'softness' widget (so it shows on the node), the Softness
slider range is 0-150 (was 0-512: too sensitive low, dead high), color match is
gone from the editor (now a Stitch knob), and the Blend-mode toggle gets a label
and breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:39:43 +03:00
pixaromaandClaude Opus 4.8 ea38087d8a feat(inpaint): softness as a node knob, color match back on Stitch, cap blend at 150
- softness is now a Crop-node INT widget (0-150, mirrored by the editor) so it's
  visible/adjustable on the node; rides crop_info as 'blend'.
- color_match moves back to the Inpaint Stitch node (a post-result tweak with no
  live preview) instead of the editor.
- color_match now matches over the whole crop region instead of weighting by the
  mask (which washed the new content toward the colors it was replacing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:38:13 +03:00
pixaromaandClaude Opus 4.8 495f63750e fix(inpaint): DPR-back the seam tint (HiDPI crispness) + guard non-numeric blend
Code-review findings: the seam preview tint dropped its DPR backing and went
soft on scaled displays; restore backing-res rendering. And the blend int()
coercion sat outside the try, so a null/corrupt state_json could crash the run;
fall back to 16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:17:24 +03:00
pixaromaandClaude Opus 4.8 98fc577162 docs(inpaint): math doc, node help, and CLAUDE.md patterns for editor v2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:08:31 +03:00
pixaromaandClaude Opus 4.8 34bcedb72c feat(inpaint): switchable mask preview color (red/green/blue/yellow/orange)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:05:55 +03:00
pixaromaandClaude Opus 4.8 08476420e2 feat(inpaint): mirror blend mode, color match and crop-size knobs into the editor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:03:43 +03:00
pixaromaandClaude Opus 4.8 4e6967e481 feat(inpaint): live seam preview on the canvas, feathered by Softness
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:02:30 +03:00
pixaromaandClaude Opus 4.8 20ef153849 feat(inpaint): Softness + Mask grow in the editor, saved into crop_info
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:01:04 +03:00
pixaromaandClaude Opus 4.8 18fa190810 feat(inpaint): remove brush Soft edge; the seam Softness slider owns blending now
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:58:26 +03:00
pixaromaandClaude Opus 4.8 4983b0a6bc feat(inpaint): blend settings ride crop_info; Stitch node has no blend knobs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:57:28 +03:00
pixaromaandClaude Opus 4.8 6c405c3b7e fix(inpaint): rect-edge guard so high blend can't make a hard crop-rectangle seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:55:47 +03:00
pixaromaandClaude Opus 4.8 9e8886b1dc feat(inpaint): add "Reset to default" brush button in the mask editor
Resets brush Size + Soft edge to the defaults (80 / 40) via the reset.svg
icon, in the Brush panel. Defaults are now shared constants used by the
constructor and the reset button so they can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:08:12 +03:00
pixaromaandClaude Opus 4.8 b202915641 fix(inpaint): persist brush prefs across opens + open editor on the live image
- Brush size / soft edge / opacity now persist per node across mask-editor opens
  (a fresh editor instance was built each open, discarding the chosen values).
- Default brush 80 / soft edge 40 (was 48 / 50).
- "Open mask editor" loads the CURRENT wired Load Image, not the last run's
  cached source: getUpstreamImageURL now prefers the live upstream, with the
  cached executed-source URL kept only as a fallback for generative chains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:48:07 +03:00
pixaroma 2d482d15b2 docs(claude): add WIP handoff note for the Inpaint pair (cross-machine continue) 2026-06-15 22:41:36 +03:00
pixaromaandClaude Opus 4.8 1235afc8a0 docs(claude): add Inpaint Crop + Inpaint Stitch Patterns section
Records the do-not-regress invariants from the 3-round review: shared crop_info
type, JS<->Python geometry mirror, OUTWARD-only seam feather, NEAREST mask
resizes, per-stroke brush buffer, bbox-scan/region-compute split, DPR canvas
(+ the clearRect-respects-transform note), dirty-on-load gates, batch pairing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:33:45 +03:00
pixaromaandClaude Opus 4.8 e4debc9df8 fix(inpaint): apply round-3 review findings (convergence)
- stitch_back: when the original (B) and inpainted (C) batches differ and both
  are >1, the result is trimmed to min(B,C); now the 'original' output is trimmed
  to match too, so the two outputs always pair (was a hard downstream error).
- editor: undo/redo mid-stroke now abandons the in-progress stroke buffer, so it
  is not re-baked onto the mask on the eventual mouseup.
- _blur_alpha no-scipy fallback is now outward-only too (np.where keeps the
  masked interior at 1.0), matching the scipy path.

3-round agent review complete: 32 -> 25 -> 4 confirmed findings, converged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:32:25 +03:00
pixaromaandClaude Opus 4.8 d7661158ac fix(inpaint): apply round-2 review findings
Seam (the big one): the mask feather is now OUTWARD-only - the masked area stays
fully opaque to its edge and only fades into the surroundings, so the new content
can't go semi-transparent at its own edge (that inward translucency read as a
ghost/halo). Verified: interior + edge = 1.0, clean outward fade.
Editor: Ctrl+S mid-drag now saves the in-progress stroke (_effectiveMaskCanvas);
a close mid-stroke detaches the window listeners (no leak); DPR-fallback footgun
removed (logical dims initialised in the constructor); the red tint overlay is
now DPR-backed (crisp on high-DPI).
Python: stitch tolerates a non-tensor image, warns on batch-size mismatch;
IS_CHANGED is key-order deterministic.
Rejected the cursor-clearRect findings: clearRect respects the canvas transform
per spec, so clearing with logical dims under setTransform(dpr) is correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:22:44 +03:00
pixaromaandClaude Opus 4.8 9d3ebd7b29 fix(inpaint): apply round-1 agent review findings
Seam/mask: mask resizes now NEAREST not bilinear (was adding a gradient halo on
top of the feather, in all 3 spots); color-match loops over the whole batch;
device-match crop outputs; scipy-fallback feather width tuned.
Editor: brush stamps use lighten composite (no slow-stroke buildup) + tighter
interp step (no gaps); per-stroke buffer soft-edge already in; split the mask
bbox scan from the region compute so the context slider no longer rescans the
whole mask each tick; DPR-crisp painting canvas on high-DPI screens.
Node body: node.size gated on !isGraphLoading (dirty-on-load), ResizeObserver
disconnected on remove, unique project ids (no Date.now collision).
Python: IS_CHANGED tracks src_path mtime; stitch validates crop_info keys.
Routes: decode rejects absurd dimensions; inpaint saves return 400 not 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:06:45 +03:00
pixaromaandClaude Opus 4.8 25b0d9a5db fix(inpaint): soft-edge brush via per-stroke buffer + blur-on-bake
Stamps now accumulate on a per-stroke buffer (crisp anti-aliased discs) and bake
onto the mask with a Gaussian edge of width softness*radius. Overlapping stamps
can no longer stack into a hard rim, so the Soft edge slider produces a real,
controllable soft edge (subtle at 0, very soft at 100) instead of barely moving.
Live preview shows the in-progress stroke softened the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:53:04 +03:00
pixaromaandClaude Opus 4.8 e3c89eef3a fix(inpaint): blend behaves monotonically + soft brush edge reaches the model
- stitch blend: distance-transform feather of controlled width centred ON the
  mask edge (smoothstep), replacing the dilate-then-gaussian that pushed a
  full-strength halo outward and made big blend values look WORSE.
- crop: preserve the painted soft-edge (np.maximum of raw + binary core) into
  the conditioning mask + crop_info, instead of thresholding it away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:01:02 +03:00
pixaromaandClaude Opus 4.8 a09a979384 fix(inpaint): mask blend softens the MASK edge, not the rectangle edge
The seam stayed hard at any blend value because stitch fed the mask-aware alpha
through _feather_alpha (distance-to-RECTANGLE-edge), which barely touches a mask
whose edge sits inside the crop. Mask mode now expand-then-blurs the mask's own
edge so the paste fades into the surroundings (verified: 40px soft ramp vs the
old hard step). whole_crop keeps the rectangle feather.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:40:38 +03:00
pixaromaandClaude Opus 4.8 f3b167baf0 fix(inpaint): smooth brush resize on held [ ] keys (bypass OS key-repeat lag)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:29:22 +03:00
pixaromaandClaude Opus 4.8 62d8f751e2 feat(inpaint): add Inpaint Crop + Inpaint Stitch Pixaroma node pair
Paint-a-mask inpaint workflow: Inpaint Crop opens a fullscreen mask painter,
auto-crops a context-aware model-friendly region (multiple-of-8, keep-shape
~1024 default), and outputs image/mask/crop_info/width/height. Inpaint Stitch
blends the inpainted crop back in place (mask-aware + color match) and outputs
the result + the original for before/after compare. Reuses the PIXAROMA_CROP_INFO
wire type. Geometry mirrored JS<->Python (verified round-trip + size parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:05:52 +03:00
pixaroma 6a488be8b9 release: 1.3.91 (Image Uncrop works with pasted/loaded crops)
Fix: Image Crop's disk path now prefers the full source over the pre-cropped
composite when building crop_info, so pasting/loading an image straight into
Image Crop (no upstream Load Image) and then Image Uncrop rebuilds the full
picture instead of returning only the cropped region.
2026-06-15 18:17:48 +03:00
pixaroma 81b47dc43b fix(crop): prefer full source over composite so Uncrop works when image is pasted/loaded directly
When you paste or load an image straight into Image Crop (no upstream Load
Image wired) and then crop, the editor saves a composite_path (pre-cropped)
AND a src_path (full source). Python checked composite_path first and built
an identity crop_info (cropped image, x=0,y=0), so Image Uncrop had no full
original to paste the edited crop back onto - it returned only the cropped
region. The upstream-wired path worked because it uses the live full tensor.

Fix: _load_disk_composite now prefers src_path (the full source) when it
resolves on disk - it crops the full source on the Python side (identical
pixels to the composite) and builds a real crop_info (full original + rect).
The composite is the fallback only when no source is available. Verified with
a disk round-trip test (paste/load -> crop -> uncrop reconstructs the full
image; composite-only fallback still works).

Also documents the precedence in CLAUDE.md (crop/uncrop patterns #7).
2026-06-15 18:12:13 +03:00
pixaroma f2da3a2fd3 release: 1.3.90 (NEW Image Uncrop Pixaroma + Image Crop transparency)
- Image Crop Pixaroma: optional MASK input cropped with the same box, and a
  new crop_info output (full original image + mask + coords) for paste-back.
  Outputs reordered to image, mask, crop_info, width, height.
- NEW Image Uncrop Pixaroma: paste an edited/upscaled crop back onto the
  original at the exact spot; distance-ramp feather for a seamless seam;
  full-frame mask output; works in both renderers (native widgets, no JS).
- README: showcase entry for Image Uncrop + Crop transparency note;
  changelog under June 15 extended to v1.3.90.
2026-06-15 17:52:15 +03:00
pixaromaandClaude Opus 4.8 7661c1b3af docs: tidy README + document Image Crop/Uncrop in CLAUDE.md
README:
- Move the hard-refresh "node looks broken after update" tip to a visible
  callout at the very top (next to the help callout), out of the changelog.
- Top help callout now points to both the #pixaroma-nodes Discord and
  opening a GitLab work item (issue), with links.
- Move the Home + independence/trademark disclaimer OUT of the May 21
  changelog entry (it was wedged mid-entry, orphaning bullets) and into the
  footer "About, Feedback & License" section; rejoin the May 21 bullets.
- Condense the two fattest recent changelog days (Jun 15, Jun 10) to one
  line per change - the changelog had drifted into paragraphs.

CLAUDE.md: add an "Image Crop + Image Uncrop Patterns (do not regress)"
section (crop_info contract, full-frame mask, distance-ramp feather,
device+batch pairing) + a token-saving table row.

Note on colour: GitHub/GitLab markdown strip text colour, so the tip uses a
prominent emoji callout rather than literal orange.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:42:20 +03:00
pixaromaandClaude Opus 4.8 f258e31d78 fix(uncrop): feather now fades to 0 at the edge (real seamless blend)
The box-blur feather bottomed out at ~0.5 at the rectangle boundary, so the
pasted crop was still 50% strength right at the edge then dropped to 0%
just outside it - a visible hard step that bigger feather values never
fixed (verified: edge alpha 0.50 at feather 50/100/500). Replaced with a
distance-to-edge ramp: alpha goes 0 at the edge up to 1 at feather px
inward, so the crop fades all the way to nothing at its boundary and blends
seamlessly. feather 0 still gives a hard edge. Verified with a real
composite (solid patch on a solid base: edge==base, center==patch, smooth
ramp between).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:22:19 +03:00
pixaromaandClaude Opus 4.8 5faf6358a6 fix(uncrop): pair mask output with image device + batch (review fixes)
Code-review pass turned up two real bugs in Image Uncrop:
- Mask output was forced to CPU while the image followed the (possibly
  GPU) base device -> device mismatch that crashes strict downstream mask
  consumers on GPU. Now the mask is returned on the image's device.
- When the image batch grows to match a multi-frame edited crop, the mask
  stayed batch 1. Now the mask is repeated to the image batch.
Plus cheap hardening: squeeze 4-D masks ([B,1,H,W]/[B,H,W,1]), and forward
None (not a garbage value) down the crop_info wire on the bad-input
passthrough. Verified on GPU + batch>1; round-trip stays pixel-exact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:13:47 +03:00
pixaromaandClaude Opus 4.8 bd4164de4f fix(uncrop): forward the wired mask when crop_info is not connected
When Image Uncrop has no crop_info wired (e.g. Load Image -> Uncrop
directly) it falls into the no-op passthrough branch. That branch returned
a zeros mask, blanking the wired mask to black. Now it passes the wired
mask straight through (full-frame), falling back to an opaque default only
when no mask is wired. The crop_info-wired paste-back path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:48:36 +03:00
pixaromaandClaude Opus 4.8 86fb1b365d fix(uncrop): output a FULL-FRAME mask, not just the crop rectangle
Bug: Uncrop's mask output started from zeros and only filled the crop
region with the wired mask, so everything outside the crop went black
(mask stuck in a rectangle, breaking transparency through the workflow).

Fix: Image Crop's crop_info now carries the original FULL mask (the wired
MASK, or a dropped/pasted file's own alpha). Image Uncrop starts the mask
output from that full original mask and only updates the crop region from
the wired mask (or leaves it unchanged when none) - so the whole-frame
transparency passes through. Wiring crop mask -> uncrop mask (parallel)
now round-trips to the original mask exactly.

Also: the mask input no longer limits the IMAGE paste (the whole region is
pasted, feather softens the seam) so image + mask behave symmetrically.
Tooltips + help updated. Verified with a torch test incl. the outside-the-
region-preserved case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:40:41 +03:00
pixaromaandClaude Opus 4.8 99b7e130ed refactor(uncrop): order inputs image, mask, crop_info to mirror Image Crop
Inputs now line up under Image Crop Pixaroma's image/mask/crop_info outputs
so the wires run straight across. ComfyUI draws required inputs before
optional, so image stays required and mask + crop_info are optional (crop_info
still degrades to a clean passthrough when unwired). feather stays last.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:23:44 +03:00
pixaromaandClaude Opus 4.8 a0cfe54a09 refactor(crop): reorder outputs to image, mask, crop_info, width, height
Group the image-related outputs together and keep width/height last.
- Image Crop Pixaroma: (image, mask, crop_info, width, height)
- Image Uncrop Pixaroma: (image, mask, crop_info) - crop_info now passes
  straight through so it can be forwarded without re-routing from the crop.
Help text + tooltips updated to match. Round-trip re-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:19:08 +03:00
pixaromaandClaude Opus 4.8 cca262678d feat(crop): add Image Uncrop Pixaroma + crop_info output (paste crop back)
Image Crop Pixaroma now emits a 'crop_info' output (appended last, so it
does not shift existing slots) carrying the full original image plus where
the crop came from (x, y, w, h). The new Image Uncrop Pixaroma node takes
that crop_info plus an edited crop and pastes it back onto the original at
the exact same spot - the crop / fix / upscale / put-it-back workflow.

- node_uncrop.py: new PixaromaUncrop node. Auto-resizes the edited crop to
  the original region (handles upscaled crops), optional MASK to limit the
  paste, optional feather for a soft seamless edge; outputs (image, mask).
  Native widgets only, so it works in Classic + Nodes 2.0 with no JS.
- node_crop.py: PIXAROMA_CROP_INFO type + crop_info on every return path
  (_make_crop_info for upstream/src full original, _identity for composite).
- __init__.py: register the node.
- help_defs.mjs: help for Image Uncrop + crop_info note on Image Crop.

Verified with a torch round-trip suite: pasting an unchanged crop back
reproduces the original exactly; edits/upscales/mask/feather all correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:13:02 +03:00
pixaromaandClaude Opus 4.8 faef9c95b4 feat(crop): add MASK input/output so Image Crop carries transparency
Image Crop Pixaroma now crops a MASK with the exact same box as the image.
Wire Load Image's MASK into the new 'mask' input to keep transparency
through a crop; the cropped MASK comes out the new 'mask' output (placed
right after 'image'). With no MASK wired it outputs a fully-opaque mask
sized to the crop, and drag-drop/paste of a transparent PNG uses the
file's own alpha automatically.

- node_crop.py: MASK optional input, (IMAGE, MASK, INT, INT) outputs,
  shared _rect_from_meta for image+mask, _crop_mask/_default_mask/
  _derive_disk_mask helpers; disk paths return cropped masks too.
- js/crop/index.js: guard-add the mask input (fresh + legacy workflows).
- help_defs.mjs: document the mask input + output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:53:07 +03:00
pixaromaandClaude Opus 4.8 725e44f277 docs(preview): un-stale the grid note (per-frame contain-fit, not uniform-stretch)
The Load Images from Folder work changed layoutImgGrid to contain-fit each frame
in its cell; the CLAUDE.md file-map row still described the old fill-stretch
behaviour. Corrected so future work doesn't 'restore' the stretch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:45:24 +03:00
pixaromaandClaude Opus 4.8 a4bd7d0ebb release: 1.3.89 (NEW Load Images from Folder Pixaroma + Preview mixed-size grid fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:34:47 +03:00
pixaromaandClaude Opus 4.8 a9bbea7987 docs(load-images-folder): README showcase + changelog entry + CLAUDE.md patterns
- README: add the Load Images from Folder Pixaroma showcase entry (next to Load
  Image) and a changelog bullet under June 15 (v1.3.88-1.3.89), plus the Preview
  mixed-size-grid fix bullet.
- CLAUDE.md: add the 'Load Images from Folder Pixaroma Patterns (do not regress)'
  section (10 invariants: OUTPUT_IS_LIST not queue-driver, Pattern #9 state, the
  dual path-traversal guard, the cross-platform native dialog + foreground/busy,
  off-loop FS/PIL work, dirty-on-load + refit sizing, the refreshListing token,
  the popup/per-node-ref discipline, recursive filename uniqueness, shared resize
  reuse) + a file-map table row.

Changelog version is staged for the next release; pyproject bumps to 1.3.89 at
release time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:26:55 +03:00
pixaromaandClaude Opus 4.8 4b6fd5f5ce fix(load-images-folder): third-round review fixes
- IS_CHANGED skips non-string/empty selection entries (same guard as load();
  a null in a hand-edited workflow no longer throws TypeError from IS_CHANGED).
- native dialog returns busy (not cancelled) when a dialog is already open, so a
  second node's Browse falls back to the in-app browser instead of doing nothing.
- add hover tooltips to the gallery + browse controls (Select all/None/First/
  subfolders/sort/Done/Cancel/Use this folder) - convention #15.
- resize-mode button uses the adaptive rgba overlay instead of a fixed dark hex
  (convention #1 - no jarring patch on a recoloured node).
- remove em dashes from the folder placeholder + the browse empty-state text.

Round 3 otherwise clean: multi-instance, queue-driver interaction, mute/bypass,
copy/paste/duplicate, subgraph, undo, and cross-renderer load all verified correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:10:13 +03:00
pixaromaandClaude Opus 4.8 29c0973636 fix(load-images-folder): second-round review fixes
- refreshListing race-guard: bump the request token BEFORE the no-folder
  early-return (the prior fix left it after, so clearing the folder while a
  listing was in flight could let the stale response repopulate the files).
- loader: skip non-string/empty selection entries instead of crashing on
  os.path.join (a crafted state with null/number in 'selected' no longer raises).
- loader: in recursive mode derive filename from the relative path
  ('sub/cat.png' -> 'sub_cat') so two same-named files in different subfolders
  can't overwrite each other through a Save node.
- list route: walk the folder via run_in_executor (a recursive os.walk + os.stat
  over a huge tree no longer blocks the aiohttp event loop).

Round 2 otherwise clean: execution/resize semantics, caching, both-renderer
sizing, and the other 11 round-1 fixes all verified correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:58:35 +03:00
pixaromaandClaude Opus 4.8 8a6893b043 fix(load-images-folder): multi-agent code-review fixes
Python:
- CRITICAL: validate each selected file stays inside the chosen folder
  (realpath + commonpath) so a crafted '../..' in the hidden state can't open
  files outside the folder.
- thumb route runs PIL decode/resize off the aiohttp event loop (run_in_executor)
  so big images don't stall other ComfyUI requests.
- macOS dialog: sanitize the start path before embedding it in AppleScript
  (?path= is caller-supplied).
- 'total' tooltip/help reworded (it's the loaded batch size, not 'selected').

Frontend:
- Drop the live getMaxHeight + coarse-round getMinHeight (dirty-on-load, Compat #18).
- refit() uses node.setSize so the height sticks across a renderer switch.
- collectNodes walks subgraph/.graph/._graph (Pattern #9 injection robustness).
- add legacy-only onResize MIN_W clamp (convention #7).
- refreshListing race-guard (request token + node-removed bail) - fixes the
  paste+blur double-fetch and stale-render-after-remove.
- onRemoved closes only THIS node's gallery/browse popups, not every node's.
- attachClosePopup skips registering listeners if closed before the setTimeout
  fires (no leaked capture listeners); gallery + browse popups tracked per node.
- gallery subfolder toggle + browse nav bail if the popup closed mid-fetch.
- thumbnail onerror hides broken/deleted files; Browse 'Use this folder' is
  dimmed at the drive-list root.

Contract / registration / shared-component safety / Preview grid fix all
reviewed clean (no changes needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:32:54 +03:00
pixaromaandClaude Opus 4.8 175f50bfa3 fix(preview): grid view fits mixed-size frames instead of stretching them
The Grid layout sized every cell to frame[0]'s aspect and drew each image
filling its cell, so a batch of mixed sizes/aspects (now common via Load Images
from Folder) got stretched in the thumbnails. Mirror the Strip layout's
per-image contain-fit: each frame fits inside its cell preserving its own
aspect, centered. No-op for uniform batches (image fills the cell exactly);
mixed batches letterbox the odd frames instead of distorting. Click-to-expand
and saved files were always correct - this is purely the thumbnail draw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:03:29 +03:00
pixaromaandClaude Opus 4.8 72fcd8594c feat(load-images-folder): wider default node width (fit resize quick-pick chips)
Bump DEFAULT_W 300 -> 370 so the 6 resize chips (e.g. the MP row 0.25/0.5/1/2/4/8)
fit on one line by default instead of wrapping. Apply it on fresh drops only
(gated on !isGraphLoading) since LiteGraph's title-driven default is wider than
MIN_W, so the old < MIN_W guard never fired; saved nodes keep their width via
configure(). Kept the shared 'MP' chip labels (used by Load Image / Image Resize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:41:20 +03:00
pixaromaandClaude Opus 4.8 9171099295 fix(load-images-folder): node fits content height, paste/path handling, native dialog foreground + cross-platform
Round 2 test feedback:
- Height: add refit() (mirrors Image Resize - computeSize + rAF, self-gated on
  isGraphLoading) called after the resize panel expands/collapses, so the node
  grows for Pad and shrinks back for Off instead of staying tall / overflowing.
  Coarse-round the Nodes 2.0 computeLayoutSize to stop height creep.
- Path: normalizePath() (backslash->slash, trim, keep drive root) in setFolder so
  a native-dialog return compares/displays cleanly; commit on the paste event so
  a pasted path lists immediately without needing to blur first.
- Browse: native dialog is now cross-platform (Windows PowerShell+WinForms,
  macOS osascript, Linux zenity/kdialog) and the Windows dialog opens in the
  FOREGROUND via a shown invisible topmost owner form (fixes 'opens behind the
  browser'). Availability check returns unavailable on headless hosts so the UI
  falls back to the in-app browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:27:34 +03:00
pixaromaandClaude Opus 4.8 e13c21202c feat(load-images-folder): width+height outputs, native OS folder dialog, fix sort-closes-gallery
Test feedback from first run:
- Add width + height list outputs (after mask) so the loaded image size can drive
  an empty latent per-image (for aspect-preserving edit workflows). Output order
  is now image, mask, width, height, filename, index, total.
- Browse now opens the REAL native Windows folder dialog (PowerShell + WinForms on
  the ComfyUI host, no extra deps, no image copying, returns a real path); the
  in-app browser stays as the fallback for non-Windows / remote / errors.
- Fix: the sort dropdown (a child popup) no longer closes the Pick-images gallery
  (attachClosePopup learns an ignoreSelector so a spawned sub-popup counts as
  inside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:58:45 +03:00
pixaromaandClaude Opus 4.8 d658c9b315 fix(load-images-folder): review fixes (dirty-on-load gate + input isolation + gallery re-merge)
- refreshListing persists the missing-file reconcile ONLY on a real user action
  (folder change / subfolder toggle), not on the load path - the await outlasts
  isGraphLoading()'s window, so a saved workflow whose folder lost a file no
  longer falsely prompts Save Changes (Vue Compat #18).
- folder + First-N inputs use stopImmediatePropagation (capture-phase canvas
  shortcut isolation, house standard).
- gallery commit() re-reads fresh state and writes only the keys it owns, so a
  concurrent resize/folder edit can't be clobbered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:52:24 +03:00
pixaromaandClaude Opus 4.8 42dbd966a1 feat(load-images-folder): resize panel (reuse shared Load Image engine)
Resize mode dropdown on the node body + the shared buildModePanel per-mode
fields (Off, Max megapixels, Longest side, Scale by, Fit inside, Crop to fill,
Match aspect ratio, Pad), styled via injectResizePanelCSS. Each image is resized
on load. Builders re-read the persisted state so folder/selection are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:43:53 +03:00
pixaromaandClaude Opus 4.8 9a5c40299e feat(load-images-folder): frontend (node body, multi-select gallery, in-app folder browser) + help
- js/load_images_folder/{state,api,ui,index} - DOM-widget node body for both
  renderers, Pick-images gallery (Select all/None/First N, live count, sort),
  in-app folder Browse popup (paste a path OR click through drives/folders).
- server_routes: add the browse route alongside list+thumb.
- help_defs: register Load Images from Folder help for the toolbar ? button.
Resize panel (reuse Load Image engine) lands next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:38:09 +03:00
pixaromaandClaude Opus 4.8 4be46ab502 feat(load-images-folder): backend list+thumb routes + python node with list output
New node PixaromaLoadImagesFolder (Load Images from Folder Pixaroma): loads
selected images from any folder on disk and emits them one-by-one via
OUTPUT_IS_LIST. Reuses the shared _resize_helpers engine. Two aiohttp routes
back the gallery (list) and thumbnails (thumb). Frontend follows next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:27:55 +03:00
pixaroma a9785df53e release: 1.3.88 (registry repo link -> GitHub for live stars + Manager last-update; housekeeping) 2026-06-15 07:50:03 +03:00
pixaroma 231ddb9765 Merge branch 'Ioan' - registry repo link to GitHub + CLAUDE.md notes 2026-06-15 07:49:32 +03:00
pixaroma 8fcdb28e01 chore(registry): point pyproject Repository to GitHub so the Comfy Registry shows a live star count + 'last update' (GitLab stays the publisher) 2026-06-15 07:40:35 +03:00
pixaromaandClaude Opus 4.8 926faa85f3 docs(claude): record reusable Compare canvas techniques (digit centering + Nodes 2.0 top-trim)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:20:42 +03:00
pixaromaandClaude Opus 4.8 d8c38f1815 docs(claude): node help is now the selection-toolbar button (convention #16 rewrite)
Document the getSelectionToolboxCommands mechanism + the i18n tooltip route so
agents don't re-grep the bundle; make 'register help for every new node via
registerNodeHelp' the rule; mark in-body createHelpButton deprecated; update the
directory map, token-saving table, and Text pattern #5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:17:43 +03:00
pixaromaandClaude Opus 4.8 f7b99ceae7 release: 1.3.87 (Image Compare size badges + Help button on every node)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:10:15 +03:00
pixaromaandClaude Opus 4.8 9c6dc3f11f chore(help): remove dead .pix-text-help CSS + stale find-replace comment
Cleanups flagged by the pre-release review (zero runtime impact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:08:34 +03:00
pixaromaandClaude Opus 4.8 14774876bf feat(help): roll out toolbar help to every node; remove in-body ? buttons
- js/help_toolbar/help_defs.mjs: central help content for 32 nodes (one place
  to edit), registered via registerNodeHelp; imported by help_toolbar/index.js
- Text / XY Plot / Find and Replace: register their existing help def for the
  toolbar and remove the in-body createHelpButton (+ its import/usage)
- Compare already registered from its own file (unchanged)

Every Pixaroma node (~36) now shows the orange ? Help button in the selection
toolbar when selected, in both Legacy and Nodes 2.0. Help content drafted from
each node's real Python contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:28:11 +03:00
pixaromaandClaude Opus 4.8 29ffd0f8d4 chore(assets): strip Illustrator metadata from question-mark.svg (47KB -> 391B)
Same path/shape, just removed the <metadata> editing block so the icon file is
tiny like the rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:04:57 +03:00
pixaromaandClaude Opus 4.8 8cb5bf61ce fix(help): use question-mark.svg glyph + tooltip 'Pixaroma Help'
- toolbar icon now masks question-mark.svg (clean tall glyph) instead of
  question.svg (boxy viewBox)
- locale label 'Help' -> 'Pixaroma Help'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:01:30 +03:00
pixaromaandClaude Opus 4.8 aae4bba158 fix(help): toolbar icon uses question.svg, wider popup, native tooltip
- help_toolbar icon: orange circle + the real question.svg glyph (white mask),
  matching the in-body ? on XY Plot / Find and Replace (was a typed '?')
- help.mjs popup widened 680 -> 840px so def text wraps less / cuts less
- locales/en/commands.json: registers the Help command label via ComfyUI's
  official custom-node i18n (/i18n route reads custom_nodes/*/locales), so the
  toolbar ? gets a native dark 'Help' tooltip like the built-in Node Info

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:54:17 +03:00
pixaromaandClaude Opus 4.8 840ea6f4b2 feat(help): Help button in the node selection toolbar (test on Image Compare)
Adds a Help (?) button to ComfyUI's selection toolbar via the official
getSelectionToolboxCommands extension hook (verified against frontend 1.44.19) -
no monkey-patch. Shows an orange-circle '?' (CSS, matches the XY Plot icon) only
when the selected node is a Pixaroma node that registered help; click opens the
shared help popup.

- js/shared/help.mjs: registerNodeHelp/getNodeHelp comfyClass->helpDef registry
- js/help_toolbar/index.js: the command + hook + icon CSS
- js/compare/index.js: registers COMPARE_HELP as the first test node

If it works in both renderers, rollout to all nodes is one registerNodeHelp call
each, and the in-body ? buttons can be removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:44:06 +03:00
pixaromaandClaude Opus 4.8 05e1f03c85 feat(compare): tighten the top gap in Nodes 2.0 (reclaim canvas top padding)
In Nodes 2.0 the title + input slots are Vue-rendered above the canvas, so the
canvas's legacy top padding (ROW1_Y, needed under the title bar in legacy) is
wasted gap below the image2 slot. Shift the DOM-canvas drawing up by TOP_TRIM(8)
and pass a taller logical height so the image fills the bottom; localPos()/H()
add it back so hit-tests + hover stay aligned. Legacy path untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:28:24 +03:00
pixaromaandClaude Opus 4.8 fa35e99d7f fix(compare): vertically center the size text with the buttons/badges
textBaseline:middle centers the em box, so digit-only size text floated high
(the em box reserves descender space digits don't use), most visible at 12px.
New fillTextVCenter centers by the actual glyph box so the sizes line up with
the buttons and the number badges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:20:41 +03:00
pixaromaandClaude Opus 4.8 faa057ba35 fix(compare): tooltip box no longer clips long text
Drop max-width:340px on the canvas-tooltip div. With white-space:nowrap the box
was capped at 340px while the text stayed one line, so the two longest tooltips
(Show toggle, Left Right) overflowed the white background and their tail rendered
as black text on the dark node (looked cut off). The box now grows to fit; the
viewport clamp still keeps it on-screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:17:40 +03:00
pixaromaandClaude Opus 4.8 02408d7e0b feat(compare): hover tooltips on the painted buttons (canvas-tooltip helper)
Canvas-painted controls can't use the native title attribute, so add the
project's canvas-tooltip pattern (CLAUDE.md #8): a single shared div styled like
the Windows native tooltip, ~450ms delay, hide-on-new-control. cmpTooltipFor()
returns the description for Show/mode/Save-Copy buttons; wired into both
renderers' move/leave/down handlers (each has clientX/clientY) + onRemoved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:12:10 +03:00
pixaromaandClaude Opus 4.8 da2a290300 feat(compare): Show-mode buttons use Save D/O verb form (Save D2 / Save O2 / Copy 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:01:36 +03:00
pixaromaandClaude Opus 4.8 b49fb44b14 feat(compare): orange number badges + bigger size text
Replace the '1:' / '2:' text prefixes with filled BRAND-orange number badges
(white 1/2) on the outer corners + larger 12px dimensions. Badge is always
orange (identity marker); the dims turn orange on mismatch, gray when equal.
To keep room alongside the bigger labels: Show-mode buttons shortened to
Disk/Output/Copy N, mode hints trimmed, and the hint auto-shrinks as a safety net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:55:09 +03:00
pixaromaandClaude Opus 4.8 916c8aea84 feat(compare): show image1/image2 sizes flanking row 2 in every mode
Sizes pinned to the left/right edges of the button-row span, labelled 1:/2:
to stay consistent with the image1/image2 inputs. Middle content (hint text
centered, opacity slider, Save/Copy buttons) shrinks into a row2Mid zone to
make room; util buttons auto-fit their font. Sizes turn orange when the two
resolutions differ. Node size unchanged; pure display (no resize control).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:34:02 +03:00
pixaroma 0994e36829 release: 1.3.86 (vertical text direction + Image Composer eraser Restore brush) 2026-06-10 11:56:40 +03:00
pixaroma 751ab587e6 fix(text): direction-aware bbox for align/auto-center/hit-test via shared measureTextDims 2026-06-10 11:51:11 +03:00
pixaroma ca6348bf2a fix(text): bump legacy BASE_H for the new Text Direction row + match align-chip style 2026-06-10 11:42:10 +03:00
pixaroma c14bcf8cf7 docs: vertical text direction pattern (CLAUDE.md) 2026-06-10 11:22:20 +03:00
pixaroma b15d28c294 test(text): vertical parity reference cases (single / columns+pill / uneven-center) 2026-06-10 11:21:47 +03:00
pixaroma 0901211b76 feat(text): Horizontal | Vertical direction toggle in the shared panel 2026-06-10 11:20:39 +03:00
pixaroma c4bc708d32 feat(text): default direction=horizontal in overlay/watermark/composer text 2026-06-10 11:20:02 +03:00
pixaroma 41db7ccc07 feat(text): vertical direction in the PIL renderer + bbox 2026-06-10 11:19:39 +03:00
pixaroma 38c1062d82 feat(text): vertical direction in the browser renderer 2026-06-10 11:18:55 +03:00
pixaroma 1faf0e54d5 docs(text): vertical direction render algorithm 2026-06-10 11:18:16 +03:00
pixaroma 6627f9f6a9 fix(composer): dim Erase/Restore pills until the eraser tool is enabled 2026-06-10 10:48:46 +03:00
pixaroma 870244aab0 fix(composer): suppress restore hint while saved mask loads + gate Alt-release repaint 2026-06-10 10:41:47 +03:00
pixaroma f7f0127609 docs: composer eraser restore pattern (CLAUDE.md) 2026-06-10 10:34:45 +03:00
pixaroma 71a94a0914 feat(composer): Erase/Restore pill toggle + Help rows 2026-06-10 10:33:10 +03:00
pixaroma c010136137 feat(composer): X swap + Alt-hold flip + restore hint for eraser 2026-06-10 10:32:33 +03:00
pixaroma 02c1ba62de feat(composer): restore strokes brush the mask away + orange restore cursor 2026-06-10 10:31:48 +03:00
pixaroma 80ea3d91f1 feat(composer): eraser sub-mode state (erase/restore) 2026-06-10 10:30:46 +03:00
pixaroma e95f9b4a7c release: 1.3.85 (Preview Image filename_prefix keeps Unicode + spaces like native SaveImage) 2026-06-10 10:12:59 +03:00
pixaroma baf9a00659 fix(preview): filename_prefix keeps Unicode (Korean) + spaces like native SaveImage
_safe_prefix flips from ASCII allowlist to Windows-illegal denylist:
only < > : " | ? * + control chars become _, everything else passes
verbatim. Adds reserved-device-name guard (CON/NUL/COM1-9/LPT1-9) and
trailing-dot/space strip per segment. All-Korean prefixes used to
dissolve to nothing -> silent 'Preview' fallback in output root; mixed
ones got mangled (te SEG st -> te_st). Traversal guards unchanged;
folder_paths' commonpath check still backs every save path.

New scripts/save_prefix_check.py locks behavior (31 cases, PREFIX PASS).
Covers save_mode=save, Save Output, Save Disk suggested name, XY Plot save.
2026-06-10 10:08:27 +03:00
pixaroma b7cc30dfe2 release: 1.3.84 (Text Pixaroma: keep curly braces by default + opt-in Dynamic prompts switch + Help button) 2026-06-10 09:32:35 +03:00
pixaroma 95df0bb19d fix(text): gate node.size min-clamp to legacy + add Nodes 2.0 resize floor (code-review fixes)
A 3-agent code review found a Critical Nodes 2.0 bug I introduced: onResize and
onDrawForeground clamped node.size to MIN_W=412 UNCONDITIONALLY (and isVueNodes
was not even imported). In the Vue renderer the rendered size lives in the layout
store, not node.size, so dragging the node smaller snapped node.size back to 412
-> the node jumped bigger on a workflow switch and could false-dirty the workflow.
This is exactly the user-observed "I can drag smaller... only legacy stops me" -
Vue showed the smaller size while node.size was being overwritten.

Fixes:
- Gate both clamps to !isVueNodes() (legacy-only), matching Find and Replace.
- Add installResizeFloor(root, measureTextFloor) (uninstalled in onRemoved) so
  that, with the clamp gated out of Nodes 2.0, a short drag still can't push the
  button row out the bottom of the frame. measureTextFloor uses the textarea CSS
  min (60), not its grown height, so the node can still shrink to fit.
- Resolver fast-path now unescapes a lone \} with no { (Agent 2 minor).
- OUTPUT_TOOLTIPS notes {a|b} is resolved when the switch is on (Agent 3 nit).
- Document the frontend-only/headless resolution caveat + corrected the sizing
  pattern in CLAUDE.md (the gated-clamp behavior, not the old ungated note).

Resolver re-tested (10/10 incl. the new \} case); all files syntax-clean.
2026-06-10 09:25:51 +03:00
pixaroma 4f4fb62331 fix(text): lock min node width to 412 so the ? help never wraps (existing narrow nodes auto-widen on load) 2026-06-10 08:48:23 +03:00
pixaroma 499abe8834 fix(text): single 412 default width for both renderers; drop the unused per-renderer split (and its un-imported isVueNodes ref that would have broken fresh nodes) 2026-06-10 08:41:45 +03:00
pixaroma 87c6834900 fix(text): wider fresh-node default in Nodes 2.0 so the ? help fits on the row (Vue body is narrower than legacy) 2026-06-10 08:25:42 +03:00
pixaroma 75439b54de fix(text): move the ? Help button into the bottom row after the Dynamic prompts switch 2026-06-10 08:14:30 +03:00
pixaroma f11bf0232d docs: record Text Pixaroma dynamic-prompts opt-in switch + Help panel patterns 2026-06-10 08:06:29 +03:00
pixaromaandClaude Opus 4.8 64be3e410a feat(text): make dynamic prompts an opt-in switch (default OFF) + add Help panel
ComfyUI's native dynamicPrompts flag ran on EVERY Text Pixaroma and stripped all
curly braces at queue time, silently destroying JSON prompts (surfaced via the
Find and Replace flow - the braces were already gone before Find and Replace saw
the text). Removed the native flag. Dynamic prompts are now a per-node "Dynamic
prompts" switch in the bottom bar, right after Clear, default OFF so braces stay
literal and JSON just works out of the box.

When ON, a JS resolver (js/text/dynamic_prompts.mjs, unit-verified) handles the
{a|b} random pick + nesting + escaped braces + // and /* */ comment stripping,
applied in an app.graphToPrompt hook (subgraph-safe) only for nodes whose switch
is on. Switch state persists on node.properties (default OFF, so existing saved
workflows are unaffected and no node is force-resized on load). Also added the
standard in-node ? Help panel explaining the node, buttons, and the switch with
examples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:04:44 +03:00
pixaromaandClaude Opus 4.8 6215636fce docs: add GitLab home note + not-affiliated disclaimer to README footer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 07:20:40 +03:00
pixaromaandClaude Opus 4.8 4db6d8ca2e release: 1.3.83 (Load Image: steady size on image load + Nodes 2.0 preview fills)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:11:30 +03:00
pixaromaandClaude Opus 4.8 6356a0b960 docs: update Load Image note - Nodes 2.0 gap FIXED (competing native preview container collapsed), not accepted
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:54:15 +03:00
pixaromaandClaude Opus 4.8 167a8cf7ca load_image: Nodes 2.0 preview now FILLS the node - collapse ComfyUI's competing native image-preview container
The Vue node renders an empty flex:1 image-preview box right after the widget grid for image_upload nodes; being a second flex:1 sibling it split the node height with our widget area, leaving a gap. Hiding it makes our preview the sole grower and fills like native. Found via live height-chain probe + frontend source (gridTemplateRows auto-track + flex:1 mechanism).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:52:49 +03:00
pixaromaandClaude Opus 4.8 7d8c052a29 docs: record Load Image stable-size fix + the accepted Nodes 2.0 over-drag gap (7-output ComfyUI limit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:13:16 +03:00
pixaromaandClaude Opus 4.8 a7397b9d8a load_image: Nodes 2.0 preview fills the node when resized (flex grower) instead of leaving an empty gap
Controls panel becomes the sole grower widget; the preview canvas is flex:1 with a min-height floor and fills the slack. measureContentHeight counts the canvas min so the node can still shrink. Legacy untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:36:03 +03:00
pixaromaandClaude Opus 4.8 07b4c55510 load_image: keep node size stable on image load instead of resizing to the image aspect (issue #1)
Classic: fitPreview now preserves the current preview area (drag-to-resize still sticks). Nodes 2.0: preview box is a fixed height. Image is contained/letterboxed in both, like native Load Image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:22:52 +03:00
pixaromaandClaude Opus 4.8 f1aa173269 release: 1.3.82 (GitLab repo links finished + cache troubleshooting note)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:37:20 +03:00
pixaromaandClaude Opus 4.8 0acca7a106 chore: remove inert .github folder (GitHub-only config; GitLab uses .gitlab-ci.yml + .gitlab/issue_templates)
Recoverable from git history if GitHub is ever reinstated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:27:41 +03:00
pixaromaandClaude Opus 4.8 3b6b480f39 docs: GitLab cache-fix support setup (README note + .gitlab issue template -> issue #2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:19:50 +03:00
pixaromaandClaude Opus 4.8 9bc026abd3 docs: replace stale 580-line directory tree with accurate 35-line module map
Old tree was both bloated and outdated (missing ~14 dirs). New map is generated from the real js/ structure and points to the dedicated Pattern sections instead of duplicating them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:58:40 +03:00
pixaromaandClaude Opus 4.8 c006e4fde3 docs: tighten CLAUDE.md - compact overview, collapse finished Nodes 2.0 status table, fix GitLab/publishing contradictions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:48:47 +03:00
pixaromaandClaude Opus 4.8 e307b6113e workflows: repoint sample Note workflow links from GitHub to GitLab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:39:27 +03:00
pixaromaandClaude Opus 4.8 1f161bb445 release: 1.3.81 (XY Plot: whole-number sizes + per-axis Snap-to-step)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:30:26 +03:00
pixaromaandClaude Opus 4.8 d800dc326a xy_plot: make Snap per-axis (next to Range/List) so it adds no node height
The global "Snap to step" toggle wrapped to a 4th row and grew the node. Moved
it into each number axis's value area, in the empty space beside Range/List, and
it only appears where snapping actually matters (width/height, not cfg/steps).
State moved from global state.snapToStep to per-axis axis.snap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:25:34 +03:00
pixaromaandClaude Opus 4.8 008ada0119 xy_plot: add "Snap to step" toggle (round number values to the field's real step)
New toggle (default ON) snaps number-axis values to the widget's real step
(step2): width/height land on clean multiples (512-1024 x4 -> 512, 688, 848,
1024) instead of 683/853. cfg/steps/denoise are unaffected (their step equals
the precision unit). Turn it off for exact values. Preview + driver both honor
the toggle; snapping never changes the value count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:59:27 +03:00
pixaromaandClaude Opus 4.8 30e07214cf xy_plot: round number axes to the field's precision (integers stay whole)
Width/height/steps/seed are integer fields (ComfyUI precision 0) but a Range
was producing fractional values like 682.66666667 because rounding kept the
interpolated value's own decimals. Now roundToStep rounds to the widget's
declared precision: 0 -> whole numbers, cfg -> 1 decimal, denoise -> 2. Keeps
the cfg 7.1 fix, and tidies cfg ranges (1,1.3,1.7,2). Falls back to the old
decimal-preserving logic only when precision is unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:45:35 +03:00
pixaromaandClaude Opus 4.8 564a3e403c readme: add centered quick-links TOC (Install / Nodes / Tutorials / Changelog / Discord)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:57:06 +03:00
pixaromaandClaude Opus 4.8 96dfcf8901 readme: add Discord #pixaroma-nodes callout near the top
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 15:47:39 +03:00
pixaromaandClaude Opus 4.8 d1d30dd251 release: 1.3.80 (XY Plot: in-node Help panel)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:42:05 +03:00
pixaromaandClaude Opus 4.8 317886996f xy_plot: move Help ? into the X axis header (drop the empty top band)
The dedicated help row was a ~33px mostly-empty band above the cards
(worst in the legacy renderer). Put the ? in the X axis header's right
cluster next to Reset X instead, so the spacing returns to what it was
before the help panel, in both renderers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:30:55 +03:00
pixaromaandClaude Opus 4.8 ee5c2e5c72 xy_plot: add in-node Help (?) panel
Adds the standard Pixaroma Help button (the ? that opens a themed popup
explaining the node) to XY Plot, like Find and Replace has - what it does,
how to use it, how the value box adapts, number entry shorthand, and what
each button/toggle means. Mounted once in a top row; closed on node remove.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:17:38 +03:00
pixaromaandClaude Opus 4.8 5895c156bd release: 1.3.79 (XY Plot: keep typed decimals + per-axis reset + narrow-node fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:05:58 +03:00
pixaromaandClaude Opus 4.8 351c4b7ba7 xy_plot: clearer reset labels + fix narrow-node button overflow in Nodes 2.0
- Reset buttons now read "Reset X" / "Reset Y" (per-axis) and "Reset XY"
  (global), so it's obvious which clears what.
- Toggle row, theme row, and axis headers now flex-wrap, so on a narrow
  node the Lock seed / Draw labels / Save cells pills reflow inside the
  node instead of spilling out the sides.
- Gate the min-size clamp to the legacy renderer only. In Nodes 2.0 the
  rendered size lives in the Vue layout store, not node.size, so clamping
  node.size didn't constrain the width and caused a save/reload size jump;
  the wrapping rows handle narrow widths instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:48:05 +03:00
pixaromaandClaude Opus 4.8 f03a794eca xy_plot: harden picker popup + sizing (review follow-ups)
Pre-existing items surfaced by a code-review pass (not from the precision
fix):
- Picker popup: bail in the deferred listener-attach if another popup
  opened in the same tick, so the first popup can't orphan global
  capture-phase listeners (a leak that also made the new popup dismiss on
  the next outside click).
- Picker popup: clamp the popup's left edge to >=8px so a wide popup near
  the right edge can't render partly off-screen-left.
- DOM widget getMinHeight: coarse-round to a 4px grid so sub-pixel
  measurement jitter can't creep node.size between save/reload
  (dirty-on-load, Vue Compat #18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:30:06 +03:00
pixaromaandClaude Opus 4.8 864453ced0 xy_plot: keep typed float precision on number axes + per-axis reset
Number axes truncated floats (cfg 5,6,7.1 -> 5,6,7; denoise 0.05,0.1 ->
0.1,0.1; cfg 0-1 in 4 steps -> 0,0,1,1) because roundToStep derived
precision from the widget step, which ComfyUI stores x10 (a FLOAT's 0.1
step is stored as 1.0). roundToStep now treats step as only a lower bound
and preserves each value's own decimals, capped at 8 to still trim float
drift. Integers/seeds unaffected.

Also adds a per-axis reset (↺ Reset in each axis header, shown once a
setting is picked) that clears just that axis; the global Reset still
wipes everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:22:19 +03:00
pixaromaandClaude Opus 4.8 6766df464f release: 1.3.78 (move to GitLab + auto-publish to Comfy Registry)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:34:21 +03:00
pixaromaandClaude Opus 4.8 4227b4a2e1 docs: repo-relative README logo + static MIT license badge so they render off GitHub
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:07:17 +03:00
109 changed files with 10837 additions and 1093 deletions
-43
View File
@@ -1,43 +0,0 @@
name: Bug report
description: Something in Pixaroma is genuinely broken (after ruling out the browser-cache fix)
title: "[Bug] "
body:
- type: markdown
attributes:
value: |
**Before filing:** if a node looks blank, broken, or is missing its controls right after an update, it is almost always a browser cache. Please try the 30-second fix first: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
- type: checkboxes
id: cache-check
attributes:
label: Quick check
options:
- label: I hard-refreshed (Ctrl+Shift+R / Cmd+Shift+R) and the problem is still there
required: false
- type: textarea
id: what-happened
attributes:
label: What is happening?
description: Describe the problem. Screenshots help a lot.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: What did you do right before it happened?
validations:
required: false
- type: textarea
id: versions
attributes:
label: Versions
description: "Drop a Version Check Pixaroma node on the canvas, hit its Copy button, and paste here (ComfyUI version, frontend version, node interface, Pixaroma version)."
validations:
required: true
- type: textarea
id: console
attributes:
label: Console errors (if a node is broken or blank)
description: Press F12, click Console, reload the page, then paste or screenshot any red error lines.
validations:
required: false
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: "📌 Node looks blank or broken after updating? Read this first"
url: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
about: "Almost always a browser cache. The fix takes 30 seconds, and most 'broken node' reports turn out to be this. Please check here before filing."
-20
View File
@@ -1,20 +0,0 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
+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.
+217 -637
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 — organized into subfolders via each node's `CATEGORY = "👑 Pixaroma/<subfolder>"` (🎨 Editors, 🖼️ Image, ✂️ Resize & Crop, 💬 Prompt & Text, 📝 Notes & Overlay, 🔢 Values, 🔀 Logic & Flow) — with zero core dependencies (PIL + PyTorch come from ComfyUI's environment). The families:
- **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.
@@ -46,578 +54,45 @@ The frontend is organized into **directory-per-editor** modules under `js/`. Eac
**File extension convention:** Only `index.js` files (entry points that call `app.registerExtension`) use the `.js` extension. All other module files use `.mjs`. This is because ComfyUI auto-loads every `*.js` file as a separate extension — using `.mjs` for non-entry modules prevents them from being loaded twice.
**`.mjs` browser-cache gotcha (fixed in `server_routes.py`):** ComfyUI core's `middleware/cache_middleware.py` sends `Cache-Control: no-store` only for paths ending in `.js`/`.css` (`request.path.endswith(".js")` — which does NOT match `.mjs`). So our `.mjs` modules got no cache header and browsers heuristically cached them, meaning a plugin update wasn't visible (e.g. missing new buttons) until a hard refresh (Ctrl+Shift+R). `server_routes.py` now installs an aiohttp `on_response_prepare` hook (`_pixaroma_install_no_cache`) on `PromptServer.instance.app` that sets `no-store` for files under `/extensions/<this-plugin>/` ending in `.mjs`/`.js` (folder name auto-derived via `os.path.basename`, so a renamed install still works; idempotent via an `_pixaroma_no_cache_installed` flag; only our files). `no-store` makes the browser refetch on every load, so `.mjs` now behaves like core's `.js` — a normal reload (F5) picks up updates instead of requiring Ctrl+Shift+R. (Python change → full ComfyUI restart to take effect; and users need ONE final Ctrl+Shift+R after updating to this version to flush the pre-fix `.mjs` the browser already cached. Verify on a running server: a HEAD to `/extensions/ComfyUI-Pixaroma/<any>.mjs` should show `Cache-Control: no-store`.)
```
js/
├── 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 (Help popup + registerNodeHelp/getNodeHelp registry, convention #16) | color_picker.mjs (Pixaroma Color Picker: compact + live-preview) | nodes2.mjs (applyAdaptiveCanvasOnly, isVueNodes, canvasBackingScale, installZoomRepaint) | graph_undo_guard.mjs (blocks Ctrl+Z escape, Vue Compat #6) | graph_loading.mjs (isGraphLoading, Vue Compat #19) | queue_drivers.mjs (shared lock for queue-driver nodes) | resize_floor.mjs + resize_panel.mjs (Nodes 2.0 resize floor + shared resize-mode panel / makeNumericInput) | index.mjs, label_css.mjs
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
help_toolbar/index.js Pixaroma.HelpToolbar - the orange ? Help button in the node selection toolbar (convention #16); per-node help content lives in the sibling help_defs.mjs
Fullscreen editors (class + mixin pattern, see "Mixin Pattern" below; core.mjs = shell, sibling .mjs add prototype methods, index.js wires it):
paint/ PaintStudio: core canvas render transform events tools history ui engine api
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 +366,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
@@ -914,6 +391,10 @@ Compare was the THIRD painted node migrated (`js/compare/index.js`) and it's the
Net: a fully-painted node migrates as ONE flex DOM canvas reusing the exact legacy draw + hit-test, with SCALE-CORRECTED DOM pointer events feeding the hit-tests, `minWidth:1` so size round-trips, and per-region cursor management.
**Reusable canvas techniques from the Compare size-display feature (June 2026) - apply to ANY node painting on a canvas:**
- **Digit-only text drawn with `textBaseline:"middle"` floats visually HIGH** (the em box reserves descender space digits don't use; the gap scales with font size, so it's obvious on big numbers and invisible on small ones). To vertically center numbers / size readouts / badges against buttons, center by the ACTUAL glyph box instead: `const m = ctx.measureText(text); ctx.textBaseline = "alphabetic"; ctx.fillText(text, x, yMid + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2)` (fall back to `"middle"` if those metrics are absent on an old browser). Reference: `fillTextVCenter` in `js/compare/index.js`. Text WITH descenders (button labels like "Copy") is already fine with plain `"middle"` - only digit/caps-only strings need this.
- **Reclaim the wasted TOP GAP on a Nodes-2.0 DOM-canvas node.** A canvas painted with the legacy top padding (the space the buttons need under the title bar in legacy) wastes that strip in Nodes 2.0, because the title + input slots are Vue-rendered ABOVE the canvas. Shift the WHOLE drawing up by a small `TOP_TRIM` and compensate so hit-tests still line up: in `render()` do `ctx.translate(0, -TOP_TRIM)` and pass `cssH + TOP_TRIM` as the logical H to the painter; then make `localPos()` ADD `+TOP_TRIM` to the pointer y and `H()` return `clientHeight + TOP_TRIM`. The body/image then fills to the bottom and clicks / hover / slider-drag / wheel all stay aligned with what's painted. Legacy is untouched (this lives only in the DOM-widget path). Reference: `TOP_TRIM` in `createCompareDOMWidget`. This does NOT change `node.size`, so it's dirty-on-load safe (Vue Compat #18).
### Title-less / tightly-self-sized DOM node (Label) — the hardest painted case
Label was the LAST node migrated and the trickiest, because it is **title-less, its whole body IS the content, and it self-sizes tightly to that content** (a floating text annotation, not a normal card node). It took ~10 iterations in-ComfyUI; every gotcha below is verified, several via an agent reading the compiled `comfyui_frontend_package` bundle. Reference: `js/label/` (`render.mjs` `applyLabelToDom` + `injectVueLabelCSS`, `index.js` `setupVueLabel`/`openLabelEditor`/`_pixLblFit`, `core.mjs` `saveCfg`). Choose this recipe for any future node that is title-less and/or must hug its content.
@@ -938,45 +419,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 +516,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`.
@@ -1113,12 +556,14 @@ These conventions were locked in during the May-2026 visual consistency pass acr
- **Hover tooltips on controls (JS):** every interactive control gets a native `title` attribute with a one-line "what this does", like Text Overlay's buttons (`b.title = ...` in `js/framework/text_editor.mjs`). For DOM controls (chips, inputs, buttons inside a DOM widget) the native `title` is enough and renders as the dark OS tooltip. For CANVAS-painted controls use the custom tooltip helper (convention #8 - `title` only works on DOM elements).
Reference implementation for BOTH surfaces is the Image Resize node: `nodes/node_image_resize.py` (DESCRIPTION + input `tooltip`s + `OUTPUT_TOOLTIPS`) and `js/image_resize/ui.mjs` (the `title` on mode chips via the `MODE_CHIPS` `title` field, snap chips, resample dropdown + arrows, upscaling toggle). Most existing nodes have input `tooltip`s but NONE had `OUTPUT_TOOLTIPS` and few have control `title`s - they need a sweep. Fill both surfaces from the start on any new node.
16. **Node Help panel - the rich in-node `?` help (shared `js/shared/help.mjs`). Add it to EVERY new node, and to existing nodes when you edit them.** ComfyUI's own Info tab is plain-text and cramped (Vue Compat #14), so this is the richer teaching surface that lives ON the node. It is a small `?` button (the bundled `assets/icons/note/question.svg`, served at `/pixaroma/assets/icons/note/question.svg` and used as a CSS mask so it tints grey -> BRAND orange on hover) that opens a themed, scrollable popup explaining the node. Reference implementation: Find and Replace (`js/find_replace/render.mjs` - the `FR_HELP` content object + the `.pix-fr-toprow` placement). This complements convention #15 (still fill the Python `DESCRIPTION` / input `tooltip`s / `OUTPUT_TOOLTIPS` for ComfyUI's own Info tab); the Help panel does not replace them.
- **Public API** (exported from the shared barrel `js/shared/index.mjs`): `createHelpButton(helpDef, opts?)` returns the `?` `<button>` already wired to open the popup (`opts.title` overrides the hover tooltip); `openHelpPopup(helpDef)` opens it directly (e.g. from a right-click menu item); `closeHelpPopup()`; `injectHelpCSS()` (called lazily by the other two, so you never need to call it yourself).
- **`helpDef` schema:** `{ title, tagline?, sections: [...], footer? }`. Each section is `{ heading, body?, bullets?: string[], defs?: [[term, desc], ...], table?: { headers: string[], rows: string[][] } }` and its blocks render in the order listed (a section can mix several). Any string (tagline / body / bullet / def term+desc / table cell / footer) may contain inline `` `code` `` (backticks) which renders as a monospace chip. ALL text is HTML-escaped first, so author in plain prose. In `body`, a blank line starts a new paragraph; a single `\n` is preserved (`white-space: pre-wrap`).
- **How to wire it into a node:** `import { createHelpButton } from "../shared/index.mjs";`, author a `XXX_HELP` const next to the node's render code, then `someRow.appendChild(createHelpButton(XXX_HELP))`. For a DOM-widget node the button goes in the node body; the popup is a `document.body` overlay so it is renderer-agnostic (works in BOTH legacy and Nodes 2.0) and the button renders in the Vue body because it lives in the DOM widget. For a PAINTED node with no DOM spot, call `openHelpPopup(XXX_HELP)` from a right-click "Help" menu item instead (not yet built; add a `getNodeMenuOptions` item per the Node Colors pattern if needed).
- **PLACEMENT GOTCHA (do not regress):** put the `?` as its OWN flex item in a NON-wrapping top row beside the controls, with `align-self: flex-start`. Do NOT drop it into a `flex-wrap: wrap` control row with `margin-left: auto` - when the node narrows, the wrapping row pushes the `?` onto its own line below the controls (the bug fixed on Find and Replace). The fix shape: `.pix-fr-toprow { display:flex; align-items:flex-start }` containing `.pix-fr-toggles { flex:1 1 auto; flex-wrap:wrap }` (controls wrap among themselves) + the `?` button (`flex:none`, pinned right).
- **Popup behavior:** dark card, BRAND accents, `min(680px, 92vw)` wide, max-height `82vh`, scrollable; closes on the `✕` / `Esc` / click-outside (with the mousedown-on-backdrop guard so a text drag-select that releases on the backdrop does NOT dismiss it - Text Overlay #12). All CSS is `.pix-help-*`, injected once. Only one popup open at a time. NO em dashes in help content (house rule - use colons/hyphens/parentheses).
16. **Node Help - the orange `?` in ComfyUI's node SELECTION TOOLBAR (the floating bar above a selected node, next to the native ⓘ Node Info). EVERY Pixaroma node MUST have registered help; a NEW node is NOT done until its help is registered.** Replaced the old in-body `?` button in the June-2026 rollout: help now lives in the toolbar so it works for ANY node in BOTH renderers and costs ZERO node-body space. ComfyUI's own Info tab is plain-text and cramped (Vue Compat #14), so this themed popup is the richer teaching surface. Reference: `js/help_toolbar/` (the toolbar command + the central content) + `js/shared/help.mjs` (the registry + popup). Complements convention #15 (still fill the Python `DESCRIPTION` / input `tooltip`s / `OUTPUT_TOOLTIPS`); the Help popup does not replace them.
- **HOW TO ADD HELP TO A NODE (do this for EVERY new node):** author a help def (schema below) and call `registerNodeHelp("<exact comfyClass>", HELP_DEF)`. Pick one of two homes: (a) **central, the default for most nodes** - add an entry to the `HELP` map in `js/help_toolbar/help_defs.mjs` (it loops `registerNodeHelp` over the whole map). One file to edit all node help. (b) **node-local** - call `registerNodeHelp(...)` at module load from the node's own JS, keeping the def beside the node code (Image Compare `js/compare/index.js`, Text `js/text/index.js`, XY Plot `js/xy_plot/ui.mjs`, Find and Replace `js/find_replace/render.mjs` do this). Either way the comfyClass key MUST match `NODE_CLASS_MAPPINGS` in the node's Python EXACTLY. Nothing else is needed - the toolbar `?` appears automatically when a node with registered help is selected, in both renderers. (Canvas-only features that add NO node - Align, Node Colors, Connection FX, etc. - need no help.)
- **The mechanism (official extension hook, verified against frontend 1.44.x; recorded so agents do NOT re-grep the minified bundle).** `js/help_toolbar/index.js` calls `app.registerExtension({ commands: [{ id:"Pixaroma.ShowHelp", label, icon, function }], getSelectionToolboxCommands(item) {...} })`. ComfyUI invokes `getSelectionToolboxCommands(item)` on EVERY extension for each selected item (`SelectionToolbox` in `GraphView-*.js`), unions the returned command-id strings into a Set, looks each up in the command store, and renders it via `ExtensionCommandButton.vue` as `<i :class="command.icon">` + `@click` calling `command.function()` (no args). So our hook returns `[CMD_ID]` only when `item.comfyClass && getNodeHelp(item.comfyClass)` (a GROUP has no `comfyClass` -> nothing; ids are deduped, so multi-selecting two different help-nodes shows ONE button = the first found - acceptable). `command.icon` is a CSS CLASS on an `<i>`, so the orange-circle-`?` is pure CSS (a BRAND circle + `question-mark.svg` as a white `::before` mask). On older ComfyUI lacking the hook it is simply never called -> the command is registered but unshown (degrades silently, no error).
- **The hover tooltip uses ComfyUI's i18n, NOT `command.label` directly.** `ExtensionCommandButton` binds `v-tooltip` to `st('commands.<normalizeI18nKey(id)>.label', '')`, and `normalizeI18nKey` (in `formatUtil-*.js`) lowercases DOTS to UNDERSCORES (`Pixaroma.ShowHelp` -> `Pixaroma_ShowHelp`). Custom nodes feed that table via a `locales/<lang>/commands.json` bundle that ComfyUI's server aggregates (`app/custom_node_manager.py` `build_translations()` -> served at `/i18n`, merged by the frontend into `customNodesI18nData`). So the toolbar button's tooltip text lives in `locales/en/commands.json` = `{ "Pixaroma_ShowHelp": { "label": "Pixaroma Help" } }`. The `/i18n` route reads files per request, so a hard refresh usually picks up edits (a ComfyUI restart is the safe fallback). This is also the official way to add ANY custom-node translation (the same folder takes `main.json` / `nodeDefs.json` / `settings.json`).
- **`registerNodeHelp(comfyClass, helpDef)` / `getNodeHelp(comfyClass)`** live in `js/shared/help.mjs` (a module-singleton Map, re-exported from the barrel `js/shared/index.mjs`). The toolbar command's `function` reads the LIVE selection (`app.canvas.selected_nodes` then `selectedItems`), finds the first selected node with registered help, and calls `openHelpPopup(helpDef)`. `openHelpPopup` / `closeHelpPopup` / `injectHelpCSS` are also exported from `help.mjs`.
- **`helpDef` schema:** `{ title, tagline?, sections: [...], footer? }`. Each section is `{ heading, body?, bullets?: string[], defs?: [[term, desc], ...], table?: { headers: string[], rows: string[][] } }`, blocks render in listed order (a section may mix several). Any string may contain inline `` `code` `` (backticks -> monospace chip). ALL text is HTML-escaped, so author in plain prose; in `body` a blank line = new paragraph, a single `\n` is preserved (`white-space: pre-wrap`). **Write it for a non-programmer artist:** what the node DOES + HOW to use it + its real outputs (match `RETURN_NAMES`), never how it is coded. Scale the length to the node (a Number node = tagline + 1 short section; a big editor = 3-4 sections). **NO em dashes** (house rule - colons/hyphens/parentheses). The 36-node rollout in `help_defs.mjs` is the style reference.
- **`createHelpButton(helpDef, opts?)` (the OLD in-body `?` button) is DEPRECATED - do NOT add it to new nodes.** It still exists in `help.mjs` and the popup it opened (`openHelpPopup`) is exactly what the toolbar reuses, but no node should call `createHelpButton` anymore (the in-body buttons were removed from Text / XY Plot / Find and Replace in the rollout). If you see it being added to a node, route the help through `registerNodeHelp` instead.
- **Popup behavior:** dark card, BRAND accents, `min(840px, 94vw)` wide, max-height `82vh`, scrollable; closes on `✕` / `Esc` / click-outside (mousedown-on-backdrop guard so a drag-select that releases on the backdrop does NOT dismiss it - Text Overlay #12). All CSS is `.pix-help-*`, injected once. Only one popup at a time; a global safety-net (`help.mjs` wraps `app.loadGraphData` once) closes it on any workflow load / switch / undo.
### Transparent Background Save-to-Disk
Paint, Composer, and 3D Builder each have a "Transparent BG (Save to Disk)" checkbox next to their BG color picker. It only affects **Save to Disk** — the workflow "Save" path is untouched so existing workflows stay compatible (Python nodes still output RGB tensors).
@@ -1139,6 +584,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).
@@ -1385,6 +832,71 @@ These patterns were hard-won during Note Pixaroma development. Regressing any of
31. **Matching-bg extension MUST be manual; Chrome's native extension misbehaves at inline boundaries.** When `_applyStagedHilite` sees a collapsed cursor inside a span whose bg matches `_stagedHi` (the "extend the existing run" case), DO NOT just `return` and let Chrome handle it. Confirmed via console logging: Chrome's native insertion at a trailing-edge inline position EMPTIES the matching span and merges the typed char (as `&nbsp;`) into the PREVIOUS adjacent span, then jumps the cursor with it. Result: every other space ends up in the wrong colour. The fix is to manually extend the existing text node via `r.startContainer.insertData(r.startOffset, data)` + `e.preventDefault()`, gated on `r.startContainer.nodeType === 3` (text node — element-level matching is rare and falls through to the regular insert which produces a redundant nested span, visually correct). **Two more rules for the manual-extension path**: (a) **Convert typed `" "` (space) to ` ` (nbsp) unconditionally** — CSS `white-space: normal` collapses a regular ASCII space at the trailing edge of inline content, so the FIRST space typed in a fresh bg span would visually disappear ("first space didn't take"). nbsp doesn't collapse and renders reliably. Trade-off: line-wrap cannot break at those spaces, but highlighted phrases are typically short. The same conversion applies to the SPAN-INSERT path's `span.textContent` for the first char in a freshly-escaped span. (b) **`fgMatches` check** — if `_pickedFg` is set and differs from the bg span's `style.color` (user picked a new text colour while the cursor sits inside an existing highlight), DO NOT extend; fall through to the SPAN-INSERT path, which builds a nested `<span bg color>` with the new colour over the same bg. Without this, MANUAL-EXTEND silently writes new text into the existing text node and the span's old `color` style applies — text colour changes inside a highlight silently get ignored. The check uses `colorToHex` to normalise rgb()/hex for comparison. Subsequent same-colour chars then match the new inner span and extend it via MANUAL-EXTEND.
### Image Crop + Image Uncrop Patterns (do not regress)
Image Crop Pixaroma (`nodes/node_crop.py`) and Image Uncrop Pixaroma (`nodes/node_uncrop.py`) are a crop-and-stitch pair: crop a region, edit/upscale it, paste it back at the exact spot. Both renderers (Uncrop is pure-Python with a native INT `feather` widget + native slots — NO JS file, NO `OUTPUT_NODE`). Verified with torch round-trip tests (pasting an unedited crop back is pixel-exact) + a parallel code-review pass (June 2026).
1. **`crop_info` is the contract.** Crop outputs `(image, mask, crop_info, width, height)`; `crop_info` is a plain dict `{"image": FULL original tensor [1,H,W,3], "x","y","w","h","orig_w","orig_h", "mask": FULL original mask [1,H,W] (optional)}` carried over the custom wire type `PIXAROMA_CROP_INFO` (a duplicated string constant in BOTH node files — keep in sync, like `PIXAROMA_PROMPT_LIST`). It carries the FULL original image AND mask so Uncrop can rebuild a full-frame result. Uncrop passes `crop_info` straight through (its 3rd output) for chaining. Output order is `image, mask, crop_info, width, height` (image stays slot 0 so the common wire survives; width/height shifted → one-time wire break for pre-existing workflows, noted in the changelog).
2. **Crop's MASK rides the same rect as the image.** The optional MASK input is cropped with the SAME rect (`_rect_from_meta` is the single rect source for image + mask). No mask wired → a fully-opaque (zeros) mask sized to the crop. Drag-drop/paste of a transparent PNG uses the file's own alpha (`_full_mask_from_pil` / `_derive_disk_mask`). EVERY return path (`load_crop`, `_load_disk_composite`, `_load_image_from_pixaroma`, `_load_src_and_crop`, blank fallback) MUST return the 5-tuple — an arity mismatch crashes at runtime. The `js/crop/index.js` `nodeCreated` guard-adds the `mask` input (so old workflows get the slot); `_CropOptionalInputs` declares both `image` + `mask` in the optional dict.
3. **Uncrop inputs: `image` required; `mask`, `crop_info`, `feather` optional** — ordered so the slots render `image, mask, crop_info` (ComfyUI draws required before optional), lining up under Crop's matching outputs so the wires run straight across. `crop_info` optional → a mis-wire degrades to a clean passthrough, never a crash (forward `None`, not garbage).
4. **Uncrop's mask output is FULL-FRAME, not the crop rectangle.** It starts from `crop_info["mask"]` (the full original) and only updates the crop region from the wired mask (else leaves it unchanged → the whole transparency passes through). Starting from zeros (the original bug) blacked out everything outside the crop. When `crop_info` is NOT wired, `_passthrough_mask` forwards the WIRED mask unchanged — do NOT return zeros (that blanks transparency; this is exactly the "Load Image → Uncrop directly with no crop_info" case a user hit).
5. **Feather is a distance-to-edge INWARD ramp, NOT a box blur.** `_feather_alpha` ramps alpha 0 AT the rectangle edge → 1 at `feather` px inward, so the paste fades to nothing at its boundary and blends seamlessly. The first attempt (avg_pool2d box blur) bottomed out at ~0.5 at the edge → a visible 50% hard step that NO feather value fixed (the user reported "feather does nothing"; diagnosed by printing the edge alpha = 0.50 at feather 50/100/500). `feather=0` = hard edge. The same feathered seam blends both the image paste and the mask-region update.
6. **Uncrop's mask output MUST match the IMAGE output's device AND batch.** Mask math runs on CPU; before returning, `out_mask.to(out.device)` and repeat it to the image batch (the image batch can grow to match a multi-frame edited crop). Otherwise GPU users hit a device-mismatch crash on strict downstream mask nodes, and batch>1 mis-pairs image vs mask. (Both caught in the review pass; the recommended Join Image with Alpha papered over them, which is why they're easy to miss.) Uncrop also resizes the edited crop to the region automatically (handles upscaled crops) and squeezes 4-D masks.
7. **The disk path (paste / drag-drop / editor Load Image) PREFERS `src_path` over `composite_path` for crop_info.** `_load_disk_composite` loads the FULL source (`src_path`) and crops it on the Python side - same pixels as the editor's pre-cropped composite, but it yields a REAL crop_info (full original + rect) instead of the composite's identity crop_info. Without this, "paste/load an image directly into Crop (no upstream Load Image wired), then Uncrop" returned only the cropped region (Uncrop had no full original to paste onto) while the upstream-wired path worked - a real user report (June 2026). Composite is the fallback only when `src_path` is missing/unresolvable. The editor (`js/crop/render.mjs::_save`) saves BOTH `composite_path` and `src_path`, and `js/crop/core.mjs::open` preserves an incoming `src_path` across paste→edit, so the full source is normally on disk. Upstream-wired still wins over the disk path entirely (it has the live tensor).
### Inpaint Crop + Inpaint Stitch Patterns (do not regress)
Inpaint Crop Pixaroma (`nodes/node_inpaint_crop.py`) + Inpaint Stitch Pixaroma (`nodes/node_inpaint_stitch.py`) are the "crop-and-stitch" inpaint pair: paint a mask in a fullscreen editor, auto-crop a model-friendly region around it, run any inpaint model, blend the result back seamlessly. Geometry/mask/blend math lives in the SHARED `nodes/_inpaint_helpers.py`. Editor + node body: `js/inpaint_crop/` (index.js node wiring + paint.mjs brush + render.mjs canvas/save + core.mjs editor shell + geometry.mjs JS mirror). Inpaint Stitch has NO custom JS (native widgets + global brand). Built June 2026; hardened by a 3-round multi-agent code review (32 → 25 → 4 findings). Re-read before touching.
1. **Reuses the SAME `PIXAROMA_CROP_INFO` wire type as Image Crop/Uncrop** (duplicated string constant in both node files, like crop/uncrop). So the inpaint pair and the geometric crop/uncrop pair are cross-compatible. Output/input order matches the suite: Crop outputs `image, mask, crop_info, width, height`; Stitch inputs `image, mask, crop_info`; Stitch outputs `image, original` (the full uncropped original, straight from `crop_info["image"]`, so before/after compare with Image Compare is two short wires from the END of the chain - NOT on the Crop node).
2. **The geometry (`compute_region`) is mirrored JS↔Python and MUST stay in lockstep.** `nodes/_inpaint_helpers.py::compute_region` (Python authoritative, runs at execute) and `js/inpaint_crop/geometry.mjs::computeRegion` (editor's live crop-rectangle preview). bbox → context expand (`context_px` + `context_pct`) → size mode (keep/force/free, multiple-of-8, min/max guards, allow_upscale) → clamp to image. Only accepted drift: sub-pixel rect placement (Python banker's rounding vs JS round-half-up = ±1px preview). The throwaway harness `D:\Claude Tests\_inpaint_test.py` asserts the round-trip + geometry + seam math; re-run it (with the embedded python) after ANY helper change.
3. **THE SEAM is an OUTWARD-only feather (`_blur_alpha`) - do NOT make it centred/inward.** `blend_mode=mask` composites the inpainted crop back using a feathered mask: alpha = 1.0 everywhere INSIDE the mask AND at its edge, ramping to 0 over `blend` px OUTSIDE (scipy `distance_transform_edt` signed distance → `t = clip(signed/k + 1, 0, 1)` → smoothstep; no-scipy fallback = gaussian-blur the binary then `np.where(mask, 1.0, blurred)`). This is the whole point: a CENTRED feather made the masked content semi-transparent at its OWN edge → the old content ghosted through (the user's "visible halo at any blend"). Earlier wrong attempts (do NOT revert to): (a) `_feather_alpha` (distance-to-RECTANGLE-edge) on the mask - barely touches a mask whose edge is inside the crop, so the slider did nothing; (b) dilate-then-gaussian - pushed a FULL-STRENGTH halo outward so BIGGER blend looked WORSE. `_feather_alpha` (rectangle fade) is still correct for `blend_mode=whole_crop` only. Coverage is the crop node's `mask_grow`; the stitch only softens. **v3 (Option B): the feather has NO rect-edge guard and NO core-opaque clamp - instead `compute_region` grows the crop context to `max(context_px, blend)` (Pattern 5 + `docs/inpaint-crop-math.md` §1) so the mask always sits >= blend px from the crop edge and the outward smoothstep reaches 0 before the border on its own (opaque inside by construction). The v2 guard (`min(feather, smoothstep(de/k))` + a `max(.., mask)` core clamp) was REMOVED - it crushed a too-wide feather and could ghost a mask sitting near the crop edge. Trade-off: a big softness makes the crop a bit larger (the crop box grows in the editor). The editor seam-tint PREVIEW (`render.mjs::_seamAlphaCanvas`) now mirrors the scipy smoothstep too - a downscaled chamfer-DT via `geometry.mjs::seamAlphaFromAlpha` - so preview == result. See `docs/inpaint-crop-math.md` §3/§4.**
4. **Mask resizes ALWAYS use NEAREST, never bilinear** (apply_inpaint_crop out-mask + stitch_back alpha). Bilinear adds its OWN gradient halo on top of the feather (a SECOND softening). The brush is CRISP (the Soft-edge slider was removed in v2; `softness=0`), so `softm = np.maximum(raw, proc)` is just the filled+grown binary core; conditioning softness comes only from `mask_blur`. The conditioning out-mask gets the deliberate `mask_blur` gaussian; the stitch seam gets the deliberate `blend` feather - one intentional softening each, never two.
5. **Seam settings are SPLIT three ways (v2 + user feedback): `softness` = Crop node widget (mirrored in the editor), `blend_mode` = Crop node combo widget too (mirrored in the editor, 2026-06-16), `color_match` = Stitch node widget.** `softness` (0-150, the seam feather = stitch `blend`) is a native Crop INT widget AND the editor's Softness slider, mirrored like the geometry knobs (`node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` so the crop CONTEXT grows to fit the feather - `compute_region` uses `max(context_px, blend)`, Option B / Pattern 3 - AND injects it into `crop_info["blend"]`; the editor's `writeBackWidgets` writes the slider back to the `softness` widget on Save; `readParams` reads it). `blend_mode` (mask/whole_crop) is now ALSO a native Crop combo widget (placed just above `invert_mask`), mirrored by the editor's Blend mode pill via `writeBackWidgets`/`readParams` (`_BLEND_MODE` maps the friendly label `mask`/`whole crop` <-> the internal key); `run` sets `crop_info["blend_mode"]` from the WIDGET, not `state_json` (the widget is the source of truth). `color_match` (off/subtle/strong) is the Inpaint STITCH node's OWN widget - a post-result tweak with no live preview (it was briefly in the editor in early v2, moved back per user feedback); it is NOT in `crop_info`. The crop knobs (`size_mode/target/multiple/context_px/mask_grow/mask_blur/softness`) are all native Crop widgets AND mirrored into the editor; on Save the editor writes ALL back via `writeBackWidgets`. The Stitch node reads `blend`+`blend_mode` from `crop_info` (defaults `16`/`mask` when absent - e.g. an Image Crop `crop_info`) and `color_match` from its own widget. **As of 2026-06-16 the Stitch node ALSO has its own `softness` (INT, -1=inherit) + `blend_mode` (combo, `from crop`=inherit) widgets that OVERRIDE crop_info via `_inpaint_helpers.resolve_seam(crop_info, softness, blend_mode)` - so the seam can be tuned at STITCH time (downstream of the sampler) WITHOUT re-running KSampler (cached on a fixed seed). This is the fix for "changing softness re-samples": the user-facing iterate-cheaply settings (softness + blend_mode) now exist on Stitch; mask_grow/mask_blur stay Crop-only because they genuinely change the sample. No room-clamp on the Stitch override (a value larger than the crop's room may show a slightly harder edge - intentional, instant-feedback; the tooltip says to raise the Crop softness for room). Stitch has NO IS_CHANGED - native widget-value change already re-runs only it.** The brush `Soft edge` slider was removed - the brush is crisp; the editor's `Softness` slider owns seam softness with a live (approximate) DPR-backed preview tinted in a switchable color (`Pixaroma.Inpaint.PreviewColor` setting, display-only). `color_match` matches the inpaint over the UNMASKED CONTEXT (surroundings outside the mask), NOT the mask or the whole crop (both include the masked area and drag a deliberately-changed color back - red->white dress goes pink); it corrects only lighting/tone drift in the unchanged surroundings (falls back to uniform if the mask fills the crop). It is for blending an inpaint INTO the scene, not for deliberate recolors. Seam preview math now mirrors `_blur_alpha`'s SCIPY smoothstep (`render.mjs::_seamAlphaCanvas` via `geometry.mjs::seamAlphaFromAlpha`, a downscaled `(1,√2)` chamfer DT) so preview == result; see `docs/inpaint-crop-math.md` §4.
6. **Editor brush: per-stroke buffer + `lighten` composite + blur-on-bake.** Stamps are crisp white discs drawn on `_sctx` (the per-stroke buffer) with `globalCompositeOperation="lighten"` (max-blend → overlapping stamps can't stack into a hard/dark rim regardless of stroke speed); at stroke end `_compositeStroke` bakes the buffer onto the mask with a gaussian of width `softness*radius`. **v2: the Soft-edge slider was removed and `softness` is fixed at 0, so blur-on-bake is off and the brush is CRISP** (the stamps' anti-aliased discs still prevent jaggies); seam softness is the `Softness`/`blend` slider instead. The mask canvas is white-RGB + per-pixel-alpha (export composites over black → grayscale; the brush soft edge survives). `_exportMaskDataURL` uses `_effectiveMaskCanvas()` so a Ctrl+S mid-drag saves the in-progress stroke; undo/redo mid-drag call `_abandonStroke()` so the abandoned stroke isn't re-baked on mouseup.
7. **Split the mask bbox SCAN from the region COMPUTE.** `_rescanBBox()` (expensive full-image `getImageData` + per-pixel scan) runs ONLY on a mask change (stroke end / clear / invert / undo / redo / mask-load / image-load); `_recomputeRegion()` reads the cached `this._bbox` and is cheap, so the context-margin slider can re-preview on every tick without re-scanning. Every mask-mutation path must call `_rescanBBox()` before `_recomputeRegion()`.
8. **The painting canvas is DPR-backed** (`_fitCanvas`: `canvas.width = dispW*dpr`, `canvas.style.width = dispW px`, `ctx.setTransform(dpr,...)`; draw in LOGICAL px via `this._dispW`/`this._dispH`, init 0 in the constructor, set by `_fitCanvas`). The red tint overlay canvas is ALSO DPR-backed (backing `W*dpr`, drawn back at logical `W`) or it's blurry on HiDPI. **`clearRect` RESPECTS the canvas transform** (per the HTML spec - a common misconception says it doesn't), so clearing with logical dims under `setTransform(dpr)` clears the full canvas correctly; do NOT "fix" it to multiply by dpr (the 3-round review raised this as a false positive twice). `_displayPos` returns CSS px from `getBoundingClientRect`; `_scale = dispW/imgW`; pointer + brush + cursor math all stay in logical px - no DPR in the coordinate math.
9. **Dirty-on-load + persistence:** `node.size` is gated on `!isGraphLoading()`; `onConnectionsChange` gated on `isGraphLoading()`; the `InpaintCropWidget` DOM widget carries `{state_json}` (src_path + mask_path + doc dims) accepted via the FlexibleOptionalInputType (same mechanism as Image Crop's CropWidget); `IS_CHANGED` uses `json.dumps(sort_keys=True)` (key-order deterministic) + tracks mask_path AND src_path mtime. Device: crop outputs `.to(image.device)`; stitch pairs `image`+`original` to the same batch (trim BOTH to `min(B,C)`) and device.
10. **The 3-round review lesson:** human-review the agent findings, don't auto-apply. The review (correctly) caught the bilinear halo, the ghosting feather, and the batch-pairing crash; it ALSO raised false positives that needed rejecting (the `clearRect`-ignores-transform misconception; "canvas listener leaks" that are actually GC'd with the fresh-per-open overlay). Default to outward feather, NEAREST mask resize, and one intentional softening per stage.
11. **Mask source precedence: a PAINTED editor mask wins, but an empty/cleared one falls back to the wired `mask` input - via `resolve_inpaint_mask` in `_inpaint_helpers.py`.** The crop node has TWO mask sources - the wired `mask` input and the mask painted in its own editor (saved to `mask_path` in `state_json`). Clearing the editor (or saving without painting) writes an ALL-BLACK mask file while `mask_path` STAYS SET, so the old "disk mask wins whenever it loads (not None)" let that empty file silently override the wired mask -> `apply_inpaint_crop` saw no painted pixels -> `mask_bbox` None -> it cropped the WHOLE image (user report 2026-06-16: "I painted, then cleared the editor and ran, and it stopped using my wired mask"). `run()` now routes both through `resolve_inpaint_mask(disk_mask, upstream_mask)`: the disk mask wins ONLY when it has painted pixels (`max > 1e-6`); empty or absent -> the wired mask is used; neither -> None (whole image). Unit-tested in the harness (`test_cleared_editor_mask_falls_back_to_wired` + `test_painted_editor_mask_wins` + `test_no_editor_mask_uses_wired` + `test_no_masks_returns_none`). **KNOWN GAP (follow-up "Part B", not yet built):** the editor does NOT import the wired mask, so you can't see/paint ON TOP of it - opening the editor starts blank. The `mask` input tooltip was corrected to match the actual Part A behavior (it no longer claims "loads into the editor"); if Part B lands, have Python save the wired mask to temp for the editor to load (mirror `_save_source_temp` + the `pixaroma_inpaint_source` ui-payload flow) and restore the "paint on top" wording.
12. **`fill_holes` closes only SMALL specks, NEVER a large subject-shaped hole - else a cut-out / background mask collapses to SOLID (user report 2026-06-16: a wired white-bg/black-helmet mask came back as a solid-white output mask "gone after the node").** scipy's `binary_fill_holes` fills EVERY enclosed hole, so for a mask that is white AROUND a subject (the subject is a hole), it fills the subject and the whole mask goes solid -> the crop bbox becomes the whole image and the model repaints everything (the painted shape vanishes). `fill_holes` (in `_inpaint_helpers.py`) now labels the holes `binary_fill_holes` would add and keeps only those `<= max(256, 0.005*H*W)` (~0.5% of the image); a subject hole is far bigger and is left UNfilled. A hole-free mask is unchanged (early `added.any()` short-circuit); the no-scipy PIL-close fallback was already limited to ~9px holes. Python-only (the JS editor never fills holes - it uses the painted-pixel bbox directly). Locked by `test_fill_holes_preserves_large_hole` + `test_fill_holes_fills_small_speck` + `test_background_mask_survives_crop`. POLARITY note: a Load Image MASK from a transparent-PNG cut-out is `1 - alpha` (1 on the see-through BACKGROUND), so it targets the background for inpainting (a "replace the background" workflow); to inpaint the SUBJECT instead, the user inverts the mask.
13. **Inpaint Crop coerces an RGBA input to RGB (premultiply over black) - or `crop_info["image"]` carries 4 channels and Inpaint Stitch silently breaks.** Remove Background Pixaroma's `image` output is RGBA (`torch.cat([rgb, alpha], -1)`: rgb = the ORIGINAL image, alpha = the fg cut-out - see `node_remove_background.py`). Wired into Inpaint Crop, that 4-channel image rode into `crop_info["image"]`; at stitch time `stitch_back`'s paste `patch[...,:region.ch] * av + region * (1-av)` mismatched (3 vs 4 channels) and THREW, so `node_inpaint_stitch.py::run`'s `except` returned `(image, image)` - the `original` output became a copy of the RESULT and the stitch didn't actually paste (user report 2026-06-16: "the original output is the same image, no black bg to compare"). FIX: `apply_inpaint_crop` premultiplies an RGBA input over black (`image[...,:3] * image[...,3:4]`) at the top - a cut-out then reads as the subject on BLACK (matching the editor/preview + the user's expected "original"), not the leftover bg rgb still under the alpha; >4 channels slice to 3. `stitch_back` ALSO coerces `base` defensively (for an RGBA `crop_info` from any other source - e.g. an Image Crop fed RGBA). Python-only; locked by `test_rgba_input_coerced_to_rgb`. FOOTGUN: that silent `except -> (image, image)` in the stitch node is what HID this - any future channel/shape bug there reads as "original == result" rather than a crash, though it does `print("[PixaromaInpaintStitch] stitch error: ...")` to the console.
14. **`invert_mask` (node BOOLEAN toggle, default off) flips the mask before cropping - a built-in replacement for an external Invert Mask node.** `apply_inpaint_crop` does `if p["invert_mask"] and mask is not None: raw = 1.0 - raw` right after `mask_to_np`, so the flip rides through the bbox, the conditioning out_mask, AND `crop_info["mask"]` (the stitch blend follows automatically - no stitch change). Flips a WIRED or a painted mask alike. NO-OP when no mask is connected (`mask is None`) - so it never turns "no mask" into "inpaint everything". It lives in `DEFAULTS` (+ `merge_params` bool-coerces it) but deliberately NOT in `geometry.mjs`'s `GEO_DEFAULTS`: it is a mask-processing op, not a `computeRegion` param, and per the node-only design the editor's live preview does NOT reflect it (the editor keeps its own separate paint-Invert button). A standard BOOLEAN widget -> renders in both node renderers + serializes natively (old saved workflows load it as False). `test_invert_mask_targets_opposite_area` + `test_invert_mask_no_mask_is_noop`.
> **📦 Inpaint pair — RELEASED in v1.3.92 (2026-06-16).** Inpaint Crop + Inpaint Stitch + the editor shipped to the Comfy Registry. The session log below is kept as the do-not-regress REFERENCE (softness/seam math, the editor zoom/pan + brush-perf model, the no-store cache fix); the permanent node invariants live in the "Inpaint Crop + Inpaint Stitch Patterns" + "Inpaint editor v2" sections above, and the editor zoom/pan + perf invariants are in sessions 6-7 below (fold into the permanent Patterns in a later cleanup, then this block can be removed). The one remaining deferred minor (duplicate-node shares project_id) is noted near the end.
> **STATE:** the Inpaint pair + its "editor v2" is BUILT and committed on **`Ioan`** (latest commit **`f7a4f6c`**; ALL deferred review minors now cleared - see the session-2 DONE line below); torch harness green. v2 (this session) changed: `softness` is a Crop-node INT widget 0-150 → `crop_info["blend"]`, mirrored in the editor; `color_match` is the STITCH node's own knob and matches the UNMASKED CONTEXT (`a<0.5`), not the mask/whole-crop (so a deliberate recolor isn't dragged back); `_blur_alpha` = outward smoothstep feather (the rect-edge guard + `max(..,mb)` core clamp were REMOVED in softness rework Part 1 - the crop now grows to fit the feather instead); `_dilate` = fast scipy `maximum_filter`/numpy separable (was ~50s PIL MaxFilter); editor lost the Soft-edge slider (crisp brush), gained a Seam panel (Softness + Mask grow + Blend-mode), a live DPR-backed seam preview (`render.mjs::_seamAlphaCanvas`), a switchable preview color (`Pixaroma.Inpaint.PreviewColor`, display-only), "Reset all to default", and Target-snaps-to-multiple. Pattern 5 above is current.
> **DONE — softness rework PART 1 (functional, Option B), committed on `Ioan` 2026-06-16 (separate commit after the safe fixes; absolute-px softness, chosen by the user over a relative/% redesign).** `compute_region` context expand now uses `max(context_px, blend)` (added `blend:16` to DEFAULTS + the merge_params int-list; `node_inpaint_crop.py::run` sets `params["blend"]=clamp(softness)` BEFORE `apply_inpaint_crop`; MIRRORED in `geometry.mjs` GEO_DEFAULTS + `ctx=max(context_px,blend||0)`; the editor already feeds `this.params.blend` via `_recomputeRegion`, so the editor crop box grows with softness for free). `_blur_alpha` DROPPED the rect-edge guard + the `max(..,mb)` core clamp - the feather now always reaches 0 before the crop edge because the crop grew (opaque inside by construction). Fixes BOTH the original ghost AND the v2 HARD-STEP-at-high-softness (a big feather no longer can't-fit). Trade-off: high softness grows the crop (visible crop-box feedback; small inpaint-resolution cost). Harness: deleted `test_rect_guard_kills_high_blend_border`, added `test_context_grows_with_blend` + `test_feather_completes_when_crop_has_room`, renamed the 2 survivors to `test_feather_*`; 13 green. Docs: `inpaint-crop-math.md` §1/§3/§5 + Patterns 3 & 5 + the Crop help def.
> **DONE — softness rework PART 2 (preview parity), committed on `Ioan` 2026-06-16.** `render.mjs::_seamAlphaCanvas` now mirrors the scipy smoothstep instead of the no-scipy gaussian: it draws the mask into a downscaled buffer (long side capped 480px), runs `geometry.mjs::seamAlphaFromAlpha` (a pure, DOM-free 2-pass `(1,√2)` chamfer DT of the OUTSIDE distance, then `alpha = inside ? 1 : smoothstep(clip(1 - d_out/kBuf))`), and upscales. So the editor seam tint now MATCHES the stitched result — the user no longer over-sets softness. NO cache needed: `_draw` runs only on strokes / slider-drags / load (NOT idle mouse-move - the brush cursor draws on a separate canvas), and the capped buffer keeps the DT ~2-3ms. The pure fn is node-tested (`D:\Claude Tests\_inpaint_seam_test.mjs` - inside=1, smoothstep falloff to 0 at k px, monotonic, empty/full); the full canvas path is eye-verified. Docs: `inpaint-crop-math.md` §4 + Patterns 3 & 5 updated. DEEPER (user raised twice, NOT chosen): RELATIVE/% softness - a bigger 'feel' redesign; absolute-px was chosen. **The softness rework is now COMPLETE (Parts 1 + 2); delete this WIP HANDOFF once the pair is RELEASED.**
> **DONE — keep/free stretch fix (committed on `Ioan` 2026-06-16; a follow-up that Part 1 exposed).** Part 1's ctx-growth (a big softness grows the crop region) surfaced a latent stretch: in `keep`/`free` mode `compute_region` computed `out_w/out_h` from the UN-clamped wanted region while the SOURCE rect was clamped to the image, so when the grown region hit the image edge the crop was resized at a different aspect = squished (user report: softness 150 stretched an inverted helmet mask, output came out 768x1024 not square). FIX: `compute_region` now derives `out_w/out_h` from the CLAMPED source rect (`rw_i/rh_i`) for ALL modes (generalises the force re-impose) - moved the size computation AFTER the clamp. Locked by `test_keep_mode_no_stretch_on_clamp`; mirrored in `geometry.mjs` + node-checked (`D:\Claude Tests\_inpaint_geom_test.mjs` `GEOM PARITY PASS`). `docs/inpaint-crop-math.md` §1 rewritten (clamp first, output from the clamped rect).
> **DONE — round-2 safe fixes (committed on `Ioan` 2026-06-16, a separate commit BEFORE the softness rework; no behavior change beyond fixing the bugs).** (1) OOM: `compute_region` keep-mode now runs the min_size short-side bump FIRST and the max_size clamp LAST (hard ceiling), so an extreme-aspect (thin-line) mask can't blow the output past max_size (the short side may then end < min_size - acceptable). Mirrored in `geometry.mjs`. (2) FORCE-STRETCH: force mode now RE-IMPOSES the source aspect == `out_w/out_h` AFTER the image-bound clamp (shrink the over-long axis to the largest aspect-correct rect that fits), so an oblong image is no longer stretched. Mirrored in `geometry.mjs`. Both locked by harness tests `test_keep_mode_respects_max_size` + `test_force_mode_preserves_aspect`; `docs/inpaint-crop-math.md` §1 updated.
> **DONE — ALL 7 deferred review minors + a parity gap (session 2, 2026-06-16, TDD'd small commits on `Ioan`; a 2nd 2-agent review of these = both SHIP, no critical/blocking):** `1fab596` FREE mode scales BOTH axes on the max_size clamp (was per-axis = stretch; Python + `geometry.mjs` mirror). `cad1586` `stitch_back` mask-mode: an all-zeros/all-ones resolved alpha (no edge - an Image Crop crop_info with no per-pixel mask, OR a native whole-image inpaint with nothing painted) falls back to the whole-crop rectangle feather, so the crop IS pasted (soft seam) instead of nothing/hard-rect. `2ef7cd6` `crop_info["mask"]` stays float32 (dropped `image.dtype`). `754e017` editor: Reset-all returns the tool to Brush; `onConnectionsChange` WRAPPED+chained (not assigned); image/mask loaders carry a `_loadToken` generation guard (a late onload can't clobber the current image / fresh strokes - verified the legit saved-mask restore still works). `7b7797a` doc nits in `inpaint-crop-math.md` §6. `f7a4f6c` (review-surfaced) `geometry.mjs` roundMult now rounds half-to-EVEN to match Python's banker's `round()` - the editor crop-box size no longer reads one multiple step off from the real output at exact .5 boundaries (JS+Python cross-verified). OPTIONAL polish still open (NONE block): (UX) `_loadMaskFromURL` could `_setStatus` a hint when it skips a slow saved-mask load after the user painted; (doc) a §1 caveat that free mode drifts aspect only for pathological >40:1 crops.
> **DONE — session 3 (2026-06-16, user testing feedback; commits `11afe83` + `718960a` on `Ioan`):** (1) `blend_mode` (mask / whole crop) is now a Crop NODE combo widget (above `invert_mask`), mirrored by the editor pill via `readParams`/`writeBackWidgets`/`render.mjs` `extra.blend_mode`; `run` sets `crop_info["blend_mode"]` from the widget. Mask vs Whole crop is explained in the widget tooltip + the toolbar help def + the editor Guide + `inpaint-crop-math.md` §5. (2) BUG fix: pasting an image into a Load Image node then opening the mask editor gave "Failed to load the source image" - `getUpstreamImageURL` now parses the LoadImage value's subfolder + `[input]/[output]/[temp]` annotation (`parseAnnotatedImageValue`) instead of jamming it all into `filename=`. (3) the open editor now accepts paste + drag-drop to load a source image (`paint.mjs` `_bindDropPaste`/`_unbindDropPaste`, window-capture, gated on the overlay; the node-body paste handler bails while the editor is open). (4) the saved-mask loader shows a "Kept your new strokes" status hint when it skips a late load (was the session-2 optional UX item - now done). (5) the editor Help popup is rewritten into a short Guide (what it does / painting / crop modes / seam Softness + Blend mode / keys); `framework/layout.mjs` gained an optional `helpTitle` (back-compat default) so it titles "Inpaint Crop — Guide". NOTE: `invert_mask` was ALREADY under softness in code (commit `3020887`) - the user's screenshot was pre-restart. Remaining OPTIONAL: a §1 doc caveat for pathological >40:1 free crops (not a bug).
> **DONE — session 4 (2026-06-16; commit `ec4c5d8` on `Ioan`; brainstormed-then-built per the user's "build straight through" pref - no spec doc/writing-plans):** the user hit "changing softness / mask grow/blur re-runs KSampler even on a fixed seed". Root cause: those settings live on the Crop node (before the sampler), so changing them invalidates the crop -> re-sample; buttons can't fix it (ComfyUI already partial-executes). FIX (user chose "add to Stitch"): the PURE paste-back settings now ALSO live on Inpaint Stitch - new `softness` (INT, -1=inherit crop) + `blend_mode` (combo, `from crop`=inherit) widgets that OVERRIDE crop_info via the new `_inpaint_helpers.resolve_seam(crop_info, softness, blend_mode)`. Stitch is downstream of the sampler, so changing them re-runs ONLY Stitch (KSampler cached on a fixed seed) = instant blend tuning. `mask_grow`/`mask_blur` stay Crop-only (they change what the model paints -> must re-sample; the user's premise that those are "the same image" is wrong, explained to them). NO room-clamp on the override (too-big softness may show a harder edge; tooltip says raise Crop softness for room). Stitch needs NO IS_CHANGED (native widget-value change re-runs only it). `resolve_seam` locked by `test_resolve_seam_inherit_and_override` (harness now 28 green). Backward-compatible: defaults inherit, so existing graphs are unchanged (one-time Stitch re-run + maybe a "save changes?" on first load after the update, from the 2 added widgets - normal for a schema addition). Docs: tooltips + DESCRIPTION + Stitch help def (new Settings block) + `inpaint-crop-math.md` §5 + Pattern 5 above.
> **DONE — session 5 (2026-06-16; systematic 3-agent audit + fixes; commits `dba1417` + `4752a97` + `f8befb5` on `Ioan`; harness 28 green):** the audit fuzzed ~10k Python inputs + 86k JS↔Python parity combos and found the core ROBUST (zero crashes/wrong-output in the math); fixed: (1 MAJOR, JS) the editor Softness slider didn't call `_recomputeRegion` so the crop box/badge went stale at high softness (preview != result) - now mirrors the Mask-grow slider; (2) `_feather_alpha` over-faded the WHOLE paste translucent when the feather exceeded half a small crop (more reachable via the Stitch softness override) - capped at ~half the smaller side, locked by `test_feather_alpha_caps_on_small_crop`; (3, was deferred A1) whole_crop seam preview now reflects the rectangle-edge feather (`_seamAlphaCanvas` whole_crop branch) instead of the mask-edge feather - preview is canvas-rendered so eyeball it; (4, was deferred A3) Inpaint Stitch guard now requires `image.dim()==4` so a malformed crop_info takes the clean passthrough instead of the silent except that made `original` a copy of the result; (5) the node thumbnail blanked after a tab switch when the source was editor-loaded - `restoreNodePreview` now gets the real `stateJson` (rebuilds from `src_path`) not `"{}"`; (6 nit) `node.size` default now mutates `[0]/[1]` per convention #9. Deliberately NOT changed (audit-confirmed correct/edge): invert on an all-zero CONNECTED mask -> whole-image inpaint (mathematically the complement of nothing; tooltip's "no mask connected" claim is literally about `mask=None`); force/whole_crop stretch on a PATHOLOGICAL sub-1px-aspect tiny image; the no-scipy `_blur_alpha` fallback feather being tighter than the scipy preview (documented carve-out, scipy ships with ComfyUI).
> **DONE — session 6 (2026-06-16; editor UX from user testing; commits `85c4866` + `bd6067c` + `0add5be` on `Ioan`; JS-only):** (1 PERF - the brush was saccadato/stuck on big images) every mousemove ran a SYNC full `_draw` that recomputed the seam distance-transform (a getImageData readback + per-pixel pass) PER FRAME. Fix: `_requestRedraw` coalesces stroke redraws to one per rAF (THIS is the perf fix - caps draws at 60fps, kills the event backup). rAF cancelled on endStroke + cleanup. **DO-NOT-REGRESS: never do a SYNCHRONOUS full `_draw` per mousemove - coalesce to rAF.** [SESSION-7 UPDATE: an earlier "skip the seam DT while `_painting`" extra optimization (crisp tint mid-stroke, feather on release) was REVERTED - it made the mask FLASH crisp(painting)->feathered(release) each stroke (obvious at softness 1-20, invisible at 0). The feathered seam DT now runs DURING paint too (coalesced = still smooth at full HD); if a very large image lags mid-stroke, THROTTLE `_seamAlphaCanvas` while `_painting` (cache + recompute ~every 60ms) rather than skipping it. Also session 7: the brush ring is hidden while Space-held/panning (was frozen at its old spot).] (2) the mask tint was clipped to the crop region even mid-stroke, hiding strokes painted beyond the box until release ("interrupted by the context margin") - the clip is now skipped while `_painting`. (3) Brush/Erase pills show (B)/(E); the Brush panel shows `[ ] / scroll=zoom / space-drag=pan` hints. (4 FEATURE - ZOOM + PAN, user chose wheel=zoom) wheel zooms toward the cursor (re-rendered from the full-res source = CRISP, not a CSS upscale), brush size moved to `[ ]` + the Size slider, pan via Space-drag or middle-drag. Coord model: `_baseScale`(fit) + `_zoom` + `_panX/_panY`; `_scale = _baseScale*_zoom`; ALL paint/cursor/box/tint coords offset by `_panX/_panY` + use `_scale`, EXCEPT `_seamAlphaCanvas` uses `_baseScale` (it builds a fit-res canvas drawn at the zoomed view rect, so `_scale` would double-count the zoom). `_applyZoom` keeps the source point under the cursor fixed; `_clampPan` keeps the image covering the viewport (forces pan 0 at fit). The brush's SOURCE radius `(brushSize/2)/_scale` shrinks as you zoom in -> finer detail. At zoom1/pan0 it reduces byte-identically to the prior behavior (no unzoomed regression). Canvas coords are NOT headless-testable -> the USER verifies paint alignment + zoom; all JS so Ctrl+F5.
> **OPEN — older minor (still deferred, the ONLY one left).** Duplicating an Inpaint Crop node (Ctrl+C/V) copies its `state_json` incl. `project_id`, so the clone + parent share the same on-disk mask/src files (editing one can overwrite the other). PRE-EXISTING + suite-wide: `js/crop/core.mjs` (Image Crop) has the identical `data.project_id || "crop_"+Date.now()` pattern. Low impact (files are read-only in `input/pixaroma/`, recoverable; the PASTE path mints a fresh id so it's immune - only editor-save-on-a-fresh-clone collides). Fix if ever: on clone/duplicate, re-mint `project_id` + clear `mask_path`/`src_path` - and fix BOTH Inpaint Crop and Image Crop together.
> **HARNESS:** `D:\Claude Tests\_inpaint_v2_test.py` (machine-local; recreate if absent). Run: `& "D:\ComfyTest\ComfyUI-Easy-Install\python_embeded\python.exe" "D:\Claude Tests\_inpaint_v2_test.py"` → expect `scipy available: True` + `RECT GUARD PASS` (28 tests through session 6, runs each in try/except + tallies; that sentinel string is just the all-green marker - the rect guard itself is gone). Session-2 additions: `test_free_mode_no_stretch_on_max`, `test_image_crop_interop_no_mask_feathers`, `test_crop_info_mask_is_float32`, `test_native_empty_mask_pastes_whole_crop`. Covers `_blur_alpha` (outward feather: `test_feather_normal_blend` / `test_feather_core_stays_opaque` / `test_feather_completes_when_crop_has_room` / `test_blend_zero_is_passthrough`), `_dilate`, `_color_match`, `compute_region` (`test_keep_mode_respects_max_size` / `test_force_mode_preserves_aspect` / `test_context_grows_with_blend` / `test_edge_clamp_keeps_inner_margin` - the last locks "image-edge clamp is benign"), and `resolve_inpaint_mask` (the 4 mask-precedence tests). Part 2 (preview parity) is JS-only and verified BY EYE, not here.
> **REWORK CLEANLY as ONE tested commit** (the half-applied attempt got stuck mid-edit — do Python + JS-mirror + harness + docs TOGETHER, run the harness, THEN a 2-agent verify). DOCS to update: `docs/inpaint-crop-math.md` (§3 feather, §5 flow), Patterns 3 + 5 above, Stitch/Crop `help_defs.mjs`. `.py` → full ComfyUI restart; `.js` → Ctrl+F5. Commit on `Ioan` (local; push only when the user says). Pair is UNRELEASED (v1.3.91): release = README "🎨 Creative Suite" showcase + changelog (one entry/day, plain language) + `pyproject.toml` bump + merge Ioan→main + push `origin` (GitLab CI publishes) + push the `github` backup.
### Preview Image Pixaroma Patterns (do not regress)
These patterns were hard-won during the May-2026 batch + save-mode upgrade. Several of them generalize to ANY canvas-rendered preview node, so re-read this section before building a similar widget.
@@ -1403,7 +915,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 +1070,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.
@@ -1620,6 +1136,32 @@ These patterns were hard-won during the May-2026 implementation. Re-read before
19. **The file dropdown is a thumbnail picker (B2 design), not a text list (May-2026).** `openImageDropdown` in `js/load_image/ui.mjs` renders a popup with: a filter `<input>` (auto-focused on open) + a Small/Large size toggle, an optional left folder sidebar (`All` + each subfolder, with counts), and a right scrollable pane of `thumbnail + filename` rows grouped by folder under sticky section headers. Helpers `thumbURL`/`getThumbSize`/`setThumbSize` sit just above the function. Behavior: opens to **All** (`activeFolder = "__all"`); clicking a sidebar folder narrows the pane; typing in the filter searches **across all folders** (ignores the sidebar selection until cleared); clicking a row routes through `setSelectedImage` (same as arrows/upload) then `closePopup()` then `onPick`. The sidebar is omitted entirely when `input/` has no subfolders (`hasSubfolders` false → single-pane list). Key points to not regress: (a) **thumbnails use a RELATIVE `/view?filename=...&type=input&subfolder=...` URL** (built by `thumbURL`, splitting via `splitFilenameSubfolder`) so they load on ANY host/port — never hard-code a host (the cross-origin grey-thumbnail issue only happens in standalone mockups, not inside ComfyUI); thumbnails are `loading="lazy"` with a gradient placeholder + `onerror`-hide so missing files don't show broken-image icons. (b) **Size persists via the `Pixaroma.LoadImage.ThumbSize` setting** — the S/L toggle reads/writes it (`getThumbSize` fallback "Large"); it's the only Load Image setting so its leaf category `["👑 Pixaroma","Load Image"]` doesn't collide (settings dedupe by leaf). (c) **Close handling is the standard capture-phase mousedown/pointerdown/wheel/keydown set, each gated on `!popup.contains(e.target)`** (Pattern #14) — the wheel gate is what lets you scroll the pane without closing; the empty-values early-return path ALSO registers + later detaches these. (d) **Re-open detaches the prior popup's listeners** by calling its stored `popup._pixClose()` rather than a bare `.remove()`, so a stale node's Escape can't close a new popup. (e) **The popup clamps horizontally and flips ABOVE the row** when it would overflow the viewport bottom (measured post-append). (f) `renderPane` calls `scrollCurrentIntoView()` on every non-search render so the current image re-centers on folder-switch / search-clear (NOT during active search, so typing doesn't yank the view). The combo's `values` are images-only (Python `node_load_image.py` filters via `folder_paths.filter_files_content_types(files, ["image"])`), so non-image input files never reach the picker. Long folder/file names ellipsis-clip (`min-width:0` on the flex children). The `◀ ▶` arrows + PageUp/PageDown still step through `options.values` in flat array order independently of the popup's grouped display.
### Load Images from Folder Pixaroma Patterns (do not regress)
The "folder version" of Load Image: load + multi-select images from ANY folder on disk and emit them one-by-one. Files: `js/load_images_folder/` (index.js = setup + state + graphToPrompt injection + refit + native-dialog Browse handler; ui.mjs = node body + gallery popup + folder-browser popup + CSS; api.mjs = fetch helpers; state.mjs = DEFAULT_STATE/readState/writeState/sortFiles) + `nodes/node_load_images_folder.py` + the `api_lif_*` routes in `server_routes.py`. Built for BOTH renderers from the start; survived 3 multi-agent review rounds. Re-read before touching it.
1. **It uses ComfyUI's LIST mechanism (`OUTPUT_IS_LIST = (True,)*7`), NOT a queue-driver.** The node runs ONCE and returns 7 equal-length lists (image, mask, width, height, filename, index, total); ComfyUI then runs the downstream graph once per item. Pure data-flow - it does NOT patch `app.queuePrompt`, so it never multiplies runs and multiple instances coexist cleanly. Do NOT convert it to a queue-driver. The lists are appended in lockstep in the one successful-load branch; `total = len(images)` = the LOADED count (failed/missing files are skipped with a console warning and excluded from index/total - `index` stays contiguous 1..N over survivors). One Run = N results; the Run "batch count" stays 1.
2. **State = Pattern #9 hidden-input + graphToPrompt injection.** `node.properties.loadImagesFolderState` (folder, recursive, sort, sort_dir, selected[], + the SAME resize keys as `node_load_image.py::DEFAULT_STATE`) is injected into the hidden `LoadImagesFolderState` STRING input by the `app.graphToPrompt` hook (idempotent via `app._pixLifGraphPatched`; `collectNodes` walks `subgraph`/`.graph`/`._graph`; `matchNode` handles composite `a:b` ids). The resize keys MUST stay identical to Load Image's so the SHARED `_resize_frame` (Python) + `buildModePanel` (JS) work unchanged. NO wire inputs (`required:{}` + hidden state, Resolution pattern); `stripInputs` + `onConnectInput = () => false`; no `onConnectionsChange` (nothing to replay, so Vue Compat #17/#19 N/A).
3. **Path-traversal guard is MANDATORY in BOTH `load()` AND `IS_CHANGED`.** `selected` is frontend-supplied, so EVERY file is validated `os.path.realpath` + `os.path.commonpath([path, real_folder]) == real_folder` before any stat/open (a crafted `../../x` must not escape the folder), AND `if not isinstance(rel, str) or not rel: continue` (a `null`/number in hand-edited state must not crash with TypeError - it bit IS_CHANGED in a review round). (June 2026: the realpath+commonpath guard was added to `IS_CHANGED` too - it previously had ONLY the isinstance check and would `os.stat` an escaping path; outside paths now hash as a constant instead.) The routes deliberately read the user's OWN arbitrary folders (NOT constrained to input/ - the whole point), are read-only, and the thumb route additionally guards the per-file path with `_is_path_under`.
4. **The native folder dialog runs on the ComfyUI HOST and returns a REAL path (browsers can't).** Cross-platform, NO extra Python deps (embedded Python lacks tkinter): Windows = PowerShell + WinForms FolderBrowserDialog shown via an invisible TopMost owner form (the owner-form `Add_Shown` trick is what brings it to the FOREGROUND instead of behind the browser); macOS = `osascript choose folder` (start path regex-sanitized, `?path=` is caller-supplied); Linux = zenity/kdialog. All via `run_in_executor` (never block the aiohttp loop) + `timeout=300` + a module lock (one dialog at a time). `_lif_native_folder_dialog` returns path / `""` (cancelled) / `None` (busy = lock held); the route maps None->`{busy:true}` so a SECOND node's Browse falls back to the in-app browser. The JS Browse handler opens the in-app `openBrowsePopup` for anything that isn't ok+path and isn't `cancelled` (so unavailable/busy/error all fall back). `_lif_dialog_available()` gates the route. The classic Windows dialog was kept over a faster ctypes-COM IFileOpenDialog deliberately - subprocess can't crash ComfyUI, raw COM vtable calls can.
5. **Heavy filesystem/PIL work is off the event loop.** The recursive folder walk (`_lif_list_files`) and the thumbnail decode/resize (`_lif_make_thumb`) both run via `run_in_executor`, so a huge folder or a big image can't stall other ComfyUI HTTP requests.
6. **Dirty-on-load + sizing (Vue Compat #18 + Nodes-2.0 sizing rules):** getMinHeight is COARSE-ROUNDED and there is NO getMaxHeight (a live getMaxHeight creeps node.size on load). `refit(node)` (the only shrink/grow driver) mirrors Image Resize: rAF + `node.computeSize()` + `node.setSize([w,h])` (NOT a bare `node.size[1]=` - that reverts across a renderer switch), SELF-GATED on `isGraphLoading()` so it never runs on the load path; called from `renderResize`. Default width is fresh-only (`if (!isGraphLoading()) node.size[0] = DEFAULT_W`; LiteGraph's title-driven default is wider than MIN_W so a `< MIN_W` guard would never fire; DEFAULT_W=370 fits the 6 resize quick-pick chips on one line). MIN_W clamp is in BOTH onResize AND onDrawForeground, gated `!isVueNodes()`. `installResizeFloor` for the Nodes-2.0 drag floor.
7. **`refreshListing(node, userAction)` race-guard: bump the request token on the FIRST line, before the no-folder early-return.** `node._pixLifListReq` is bumped first; after `await listFolder`, bail if `node._pixLifListReq !== myReq || !node._pixLifUI` (a newer refresh superseded this, or the node was removed). Bumping BEFORE the early-return is what makes clearing the folder invalidate an in-flight fetch (a real bug when the bump sat after). The reconcile-write (dropping selections of deleted files) is persisted ONLY on `userAction && !isGraphLoading()` (never on load -> no dirty-on-load; Python tolerates missing files at run).
8. **Popups (gallery + folder-browser + sort mini-menu) are document.body fixed-position, anchored via `getBoundingClientRect` (zoom-correct), closed via the shared `attachClosePopup`** (capture-phase mousedown/pointerdown/wheel/keydown each gated `!inside(e.target)`; the wheel gate lets you scroll inside without closing - Load Image Pattern #14). The gallery passes `ignoreSelector: ".pix-lif-menu"` so its sort sub-menu doesn't close it. `attachClosePopup` skips registering listeners if the popup was closed before its `setTimeout(0)` fires (no leaked capture listeners). Each popup is tracked per-node (`node._pixLifGallery`/`_pixLifBrowsePop`, nulled on close) so `onRemoved` closes ONLY this node's popups, not every node's. Async popup handlers (subfolder re-list, browse nav) bail on `popup._pixClosed` after their await. Thumbnails are `loading="lazy"` with `onerror` hide.
9. **Selection lives as `state.selected` (display/sorted order); the gallery `selSet` mirrors it; `commit()` re-reads fresh state and writes only the gallery-owned keys** (selected/sort/sort_dir/recursive) so a concurrent resize edit can't be clobbered. In RECURSIVE mode the Python `filename` output is the rel path with separators replaced (`sub/cat.png` -> `sub_cat`) so two same-named files in different subfolders can't overwrite each other through a Save node; flat mode uses the basename.
10. **Resize reuses the shared engine WITHOUT panel_polish.** `buildModePanel(state.mode, node, readState(node), writeState, onChange, "loadImagesFolderState", {oneLine:true})` + `injectResizePanelCSS()`. The shared builders re-read `node.properties[stateKey]` and only override one resize key, so folder/selected survive a resize edit. panel_polish (inline labels / W-H grid / anchor picker) is NOT used (its CSS lives in load_image, not the shared module) - so Crop-to-fill uses the center-anchor default; the simple modes (Off/Max MP/Longest side/Scale/Fit/Match/Pad) are fully functional.
11. **Gallery "First N" box selects live + caps to the image count (June 2026).** The `First` control is a number box + a button; the box DRIVES the selection itself via the shared `applyFirstN(value)` (which clamps the SELECTION to the file count and RETURNS the clamped count). Wiring in `ui.mjs`: typing selects live, debounced ~220ms (`input` -> `flushFirst(false)`, so "12" applies once not 1-then-12); Enter / blur / spinner commit via `flushFirst(true)`; the button applies + normalizes the box to `>=1`. An EMPTY box is "no instruction" - the selection is left untouched (use None to deselect), and the `input` handler returns early on empty so clear-to-retype never clobbers. The box CAPS to the count two ways: `updateCounts` sets `firstInput.max = total` (spinner stops there) AND an over-cap typed value snaps the box text down to the count - but ONLY on commit (`snap=true`), NEVER on the debounced `input` path, or pausing mid-type above the cap would rewrite the box and shift the next digit you type. Do NOT collapse this back to a button-only control (the box looked "broken" before - it changed value but never selected). The debounced timer fires after close are guarded by `gal._pixClosed`.
### Prompt Reader Pixaroma Patterns (do not regress)
These patterns were hard-won during the May-2026 implementation and post-review hardening pass. Re-read this section before touching `nodes/_prompt_reader_helpers.py`, `nodes/node_prompt_reader.py`, the `/pixaroma/api/prompt_reader/extract` server route, or any file under `js/prompt_reader/`.
@@ -1658,7 +1200,42 @@ 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)
### Seed Pixaroma Patterns (do not regress)
Seed Pixaroma (`js/seed/index.js` + `nodes/node_seed.py`) is a seed SOURCE: an INT `seed` output wired into KSampler (or any seed input). Compact locked-width DOM-widget node, both renderers. Modeled on Resolution Pixaroma (hidden `SeedState` STRING input + state on `node.properties.seedState` + `app.graphToPrompt` per-run injection). Built + hardened across 5 agent-review rounds (shipped v1.3.93). Re-read before touching.
1. **State split: `{seed, mode}` is serialized on `node.properties.seedState`; the last-run seed is RUNTIME-ONLY on `node._pixSeedLastRun` (NEVER node.properties).** A run records the last-run seed on the runtime field, so it can NEVER rewrite serialized state -> never dirties a saved workflow (Vue Compat #18). Consequence: "Use last seed" is session-only (greys out after a reload until you Run once - documented in the help). The `app.graphToPrompt` hook must NOT write `node.properties` (only `entry.inputs` + the runtime field). This is the single biggest divergence from Resolution (whose hook only reads) and was the original dirty-on-load bug.
2. **Random vs Fixed re-execution = `IS_CHANGED(SeedState)` + a Random-only nonce.** Python `IS_CHANGED` returns the injected `SeedState` string. Random injects `{runSeed, _n: ++nonce}` (a monotonic per-call nonce) so the string ALWAYS differs -> the node re-runs even on the ~1-in-2^53 chance two rolls collide. Fixed injects `{runSeed}` with NO nonce -> constant string -> ComfyUI caches -> repeatable. `get_seed` reads `runSeed` and ignores `_n`.
3. **Body height is MEASURED, never a hand-guessed constant** (this oscillated gap<->clip for several commits before landing here). `getMinHeight` returns `measureSeedHeight(root)` = shared `measureRootContent` (children offsetHeight + gaps + padding), coarse-rounded to a 4px grid so it's dirty-on-load stable. There is NO `getMaxHeight` (a single-widget node has no slack consumer; a max cap CLIPS the bottom line - that was the bug). Plus `computeLayoutSize({minHeight: measured, minWidth: 1})` for the Nodes 2.0 grid row (`minWidth:1` so the locked width round-trips). Do NOT force `node.size[1]` (forcing it caused the gap-then-clip); lock WIDTH only and LEGACY-only (`if (!isVueNodes())`), and a legacy-only post-layout `setSize` snaps the node to the measured height (idempotent via coarse-rounding -> load-safe).
4. **The big number auto-fits; its box has a FIXED height.** `fitSeedFont` shrinks the inline font-size 19->11px until a 16-digit seed fits the narrower Nodes 2.0 body (scheduled on rAF + 2 setTimeouts for the fresh-drop layout race, and on commit). `.pix-seed-num` is `height:42px` FIXED so the font change can't alter `measureSeedHeight`. `rollSeed()` stays in `[0, 2^53)` so it round-trips through JSON exactly; Python clamps to `0..2^64-1`.
5. **`commitNum` is SURGICAL, never a full rebuild.** It no-ops when the value is unchanged (so a bare focus/blur does NOT flip Random->Fixed), and on a real change writes state then updates the pill (`syncModeUI`) + last-run line in place. A full `renderUI` rebuild on blur would destroy the pill/button mid-click (the "first click does nothing" race). The initial render goes through `buildSeedBody` on the captured root directly (NOT a `renderUI` that bails on `isConnected`) so a fresh drop isn't blank.
6. **Copy copies the DISPLAYED number (`state.seed`) and needs an `execCommand` fallback.** WYSIWYG-copy; to grab the actual last-run seed in Random mode, "Use last seed" loads it into the box first. `navigator.clipboard` is undefined in INSECURE contexts (ComfyUI over `http://<LAN-IP>` / phone / tunnel), so `copySeed` falls back to a throwaway-textarea + `document.execCommand("copy")` (same pattern as Show Text / Version Check / Note).
7. **Mode pill segments are real `<button>`s** (keyboard-reachable + `:focus-visible`), not `<div>`s - matches Prompt Pack / Prompt Multi.
8. **Headless/API (no browser) -> seed 0.** The seed is chosen by the frontend `graphToPrompt` hook, so a pure-API run sends the `"{}"` default and `get_seed` returns 0. Inherent to the Pattern #9 architecture (same as Resolution); documented in the help (use Fixed for API pipelines).
### Text Pixaroma Patterns (do not regress)
The Text Pixaroma node (`js/text/index.js` + `js/text/dynamic_prompts.mjs` + `nodes/node_text.py`) is a multi-line text box that hides ComfyUI's native `multiline` widget and renders a custom DOM textarea (mirrored back to the hidden native widget for serialisation - see node UI convention #4). Bottom bar: Copy all / Replace / Clear action buttons + a Dynamic prompts SWITCH; a `?` Help button floats in the text box corner.
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 in-body `?` Help button was REMOVED (June-2026 toolbar-help rollout - convention #16).** Text's help content (`TEXT_HELP`) still lives in `js/text/index.js` but is now shown via the node SELECTION-TOOLBAR `?` button: registered at module load with `registerNodeHelp("PixaromaText", TEXT_HELP)`. The old `.pix-text-help` CSS and the `createHelpButton(TEXT_HELP)` in the bottom bar are gone (the bottom bar is now just Copy all / Replace / Clear + the Dynamic prompts switch). `closeHelpPopup()` is still called in `onRemoved` (harmless safety net for a toolbar-opened popup).
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 +1279,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
@@ -1752,7 +1331,7 @@ Files are named by concern. Match the task to the file:
| Task | Read this file |
|------|---------------|
| Add a new dropdown / popup (any node) | Copy `openImageDropdown` in `js/load_image/ui.mjs` as the template. See Load Image Pixaroma Pattern #14 — wheel handler MUST gate on `!popup.contains(e.target)` or scrolling inside the popup closes it. |
| Add / change a node's Help panel (the `?` button + popup) | `js/shared/help.mjs`: `createHelpButton(helpDef)` returns the `?` button (bundled question.svg mask), `openHelpPopup(helpDef)` opens it. Author a `XXX_HELP` object (title / tagline / sections of body \| bullets \| defs \| table, inline `` `code` `` chips) next to the node's render code and `appendChild` the button into the node body. Reference: Find and Replace (`js/find_replace/render.mjs` `FR_HELP` + the `.pix-fr-toprow` placement so the `?` doesn't wrap below on a narrow node). Full schema + gotchas in node UI convention #16. |
| Add / change a node's Help (the selection-toolbar `?` + popup) | Register the node's help so the toolbar `?` shows for it: add an entry to the `HELP` map in `js/help_toolbar/help_defs.mjs` (central - the default for most nodes), OR call `registerNodeHelp("<exact comfyClass>", HELP_DEF)` from the node's own JS (Compare / Text / XY Plot / Find and Replace do this). Author the `HELP_DEF` (title / tagline / sections of body \| bullets \| defs \| table, inline `` `code` `` chips, plain-English, NO em dashes, real `RETURN_NAMES`). The orange `?` then appears in the node selection toolbar automatically, in both renderers - no per-node button code. The old in-body `createHelpButton` is DEPRECATED (don't add it). The toolbar button's hover tooltip text is in `locales/en/commands.json`. Full schema + the `getSelectionToolboxCommands` mechanism: node UI convention #16. |
| Fix brush/drawing | `js/paint/tools.mjs` |
| Fix layer add/delete | `js/paint/canvas.mjs` or `js/composer/layers.mjs` |
| Fix undo/redo | `js/<editor>/history.mjs` |
@@ -1768,6 +1347,7 @@ Files are named by concern. Match the task to the file:
| Change the per-object Shape panel | `js/3d/shape_params.mjs` |
| Handle GLB/OBJ import behavior | `js/3d/importer.mjs` |
| Add / change Resolution Pixaroma sizes per ratio | `js/resolution/index.js` `SIZES` const + `DEFAULT_PER_RATIO` (per-ratio click default) — keep the spec doc table in sync. Layout sizing (NODE_H / WIDGET_H / list min-height) lives at the top of the same file. State schema on `node.properties.resolutionState` + `app.graphToPrompt` injection hook at the bottom of the file (Pattern #9). |
| Change Seed Pixaroma (Random/Fixed, buttons, sizing) | `js/seed/index.js` + `nodes/node_seed.py`. See "Seed Pixaroma Patterns (do not regress)". Hidden `SeedState` + `app.graphToPrompt` per-run injection (Resolution pattern); the last-run seed is RUNTIME-only (`node._pixSeedLastRun`, NOT node.properties) so a run never dirties the workflow; MEASURED `getMinHeight` via `measureRootContent` with NO `getMaxHeight` + `computeLayoutSize` (no hand-guessed height); Random injects a nonce so `IS_CHANGED` re-runs while Fixed caches; `fitSeedFont` auto-shrinks the number; Copy has an `execCommand` fallback for http LAN; pill segments are real `<button>`s. |
| Fix / extend Note toolbar (buttons, pickers) | `js/note/toolbar.mjs` |
| Add / change a toolbar mask-icon | `js/note/css.mjs` (`.pix-note-tbtn-maskicon` for single-layer, `.pix-note-tbtn-maskicon-multi` for two-layer color pickers) + SVG files in `assets/icons/ui/` (two-layer icons need `<name>-outline.svg` + `<name>-drop.svg`) + `makeMaskIcon`/`makeMaskIconMulti` call in `toolbar.mjs` |
| Change per-note colour pickers (Btn, Ln, Bg, text, highlight) | Two architectures: text + highlight use the COMPACT picker (`openPixaromaCompactColorPickerPopup` in `js/shared/color_picker.mjs` — swatches + Reset + "More colors..." footer; modal opens on More). Bg / Btn / Ln use the live-preview popup (`openPixaromaColorPickerPopup`) via the `makeColorPicker` factory in `js/note/toolbar.mjs`. Sticky-pick state is `editor._pickedFg` (text) and `editor._stagedHi` (highlight); both cleared in `_cleanup`. Text application: `execCommand("foreColor")` at pick + `_restageColors` on every selectionchange (Pattern #25). Highlight application: `beforeinput` handler in `core.mjs` → `_applyStagedHilite` in `toolbar.mjs` does manual char insert with `e.preventDefault()` (Pattern #21). `js/note/render.mjs` writes Btn/Ln CSS vars on canvas body; `core.mjs` `_applyCfgColorsToEditArea` writes same vars on the editor's contenteditable on each open. |
@@ -1787,9 +1367,13 @@ 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` |
| Add a new Python node | `nodes/node_<name>.py` (set `CATEGORY = "👑 Pixaroma/<subfolder>"` — see step 2 of "When creating a new editor") |
| Change Resize Crop Pixaroma (crop-to-fill to an exact W×H) | `nodes/node_resize_crop.py` — pure Python, native INT width/height widgets (typeable AND wireable), reuses `_resize_helpers._apply_cover` (cover-scale + center-crop, `allow_upscale` on, `snap` 0, output ALWAYS exactly W×H). No JS, no custom UI. Help in `help_defs.mjs`. |
| Change Portrait Landscape Pixaroma (one-click orientation flip) | `js/portrait_landscape/index.js` (Portrait/Landscape pill toggle + `app.graphToPrompt` state injection + auto-fit height via `computeSize`, mirrors Switch WH) + `nodes/node_portrait_landscape.py` (min/max orient: Portrait→`(lo,hi)` tall, Landscape→`(hi,lo)` wide). State on `node.properties.portraitLandscapeState`, hidden `PortraitLandscapeState` input (Vue Compat #9). Help in `help_defs.mjs`. |
| Change Image Crop / Image Uncrop (crop_info, MASK, feather, paste-back) | `nodes/node_crop.py` (crop + the MASK input + `crop_info` output: `_rect_from_meta`/`_crop_mask`/`_make_crop_info`/`_full_mask_from_pil`) + `nodes/node_uncrop.py` (paste-back: resize-to-region, distance-ramp `_feather_alpha`, full-frame mask, `_passthrough_mask`, device+batch pairing). `js/crop/` is the editor only (guard-adds the `mask` input); Uncrop has NO JS. Help in `help_defs.mjs`. See "Image Crop + Image Uncrop Patterns (do not regress)". |
| Change XY Plot Pixaroma (axes / value entry / driver loop / grid / themes / save) | `js/xy_plot/` (index.js = lifecycle + the app.queuePrompt N×M driver + app.graphToPrompt value injection + executed listener; core.mjs = state + target enumeration + value parsing; ui.mjs = node body + picker + value entry + theme picker; grid.mjs = grid <img> + Save/Copy/Open). Python in `nodes/node_xy_plot.py` (server-side cell accumulation keyed by sessionId under a threading.RLock + PIL grid assembly + themes). Routes `/pixaroma/api/xy_plot/{save,restyle}` in `server_routes.py`. See "XY Plot Pixaroma Patterns (do not regress)" for the 8 invariants - especially: 3rd queue-driver (shared queue_drivers.mjs lock), injectAxis OVERRIDES wired inputs, per-node seed map by exact key, Python owns grid rendering / JS shows the PNG, picker skips hidden state widgets, grid <img> max-height 360 cap. |
| Change Text Overlay editor / interaction / node | `js/text_overlay/` (core.mjs for lifecycle + canvas + render loop + canvas settings + alignment toolbar, interaction.mjs for mouse + keyboard + snap guides, index.js for node setup + body panel mount + graphToPrompt hook) |
| Change Text Watermark node / placement math / sizing | `js/text_watermark/` (index.js for registration + the shared-panel mount in watermarkMode + graphToPrompt Pattern #9 + wired-text lock + FIXED BASE_H 412 + onRemoved; defaults.mjs for DEFAULT_STATE). Placement + sizing math is Python: `nodes/node_text_watermark.py` (`_stamp` turns anchor + marginX/Y + sizeMode px/%-width into x/y, then calls the SHARED renderer `render_text_layer`). Reuses `nodes/_text_render_helpers.py` unchanged — NO watermark-specific render code, NO parity script. Anchor math gates the italic slant on `_is_synthesized_italic` (real-italic fonts get no extra inset). Defaults MUST stay in lockstep across defaults.mjs + node_text_watermark.py + render_text_layer (Pattern #3). The control panel + the Pixaroma color picker + the solid-triangle number inputs all come from the shared `js/framework/text_editor.mjs` panel — see the next row. |
@@ -1813,8 +1397,9 @@ Files are named by concern. Match the task to the file:
| Change Composer text layer | `js/composer/core.mjs` (`addTextLayer`/`rebuildTextLayer`, the `setMode` eraser/crop guard), `js/composer/ui.mjs` (`_buildTextPanel`, T thumbnail, panel swap in `updateActiveLayerUI`), `js/composer/interaction.mjs` (`_commitTextScaleFold` crisp-resize, save + duplicate carry `isText`/`textState`), `js/composer/render.mjs` (`attemptRestore` carries the fields), `js/composer/layers.mjs` (`captureState` deep-copy). Bitmap renderer is the SHARED `js/framework/text_render.mjs::renderTextToCanvas`; panel is `js/framework/text_editor.mjs` in `composerMode`. Python composites it as a plain image - NO text code in `node_composition.py`. See "Composer Text Layer Patterns". |
| Paint AI Background Removal panel | `js/paint/core.mjs` `_buildBgRemovalPanel` + `_removeBgFromActiveLayer` (button gated on `ly.sourceKind === "image"`, set by the `onAddImage` handler and serialized as `source_kind` in the layer project JSON). Dropdown built by the shared `js/framework/bg_removal_dropdown.mjs` helper (1.3.34) so Paint and Composer render identical model pickers (BiRefNet variants on top, rembg below). Reuses the `/pixaroma/remove_bg` backend route via `PaintAPI.removeBg`. |
| Change AI Remove Background dropdown (Composer + Paint, 1.3.34+) | `js/framework/bg_removal_dropdown.mjs`. Exports `fetchBgRemovalInfo()` (GET /pixaroma/remove_bg_info) and `buildBgRemovalDropdown({ container, info, value, onChange })` (renders `<select>` with `<optgroup label="Pixaroma BiRefNet">` on top and `<optgroup label="rembg">` below; inline `<a href=...>Download {filename}</a>` under the select when the currently selected BiRefNet variant is not installed). Default-picks via `pickDefaultModel(info)`: first installed BiRefNet variant in priority order standard -> HR -> matting, else rembg `auto`. Variant catalog and inventory come from the backend (`BIREFNET_VARIANTS` in `nodes/_bg_removal_helpers.py`) so adding a new variant requires editing that file only. |
| 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). |
| Change Load Images from Folder Pixaroma (folder picker / gallery / native dialog / one-by-one list output / resize) | `js/load_images_folder/` (index.js = setup + state + `app.graphToPrompt` injection + `refit` + native-dialog Browse handler; ui.mjs = node body + multi-select gallery popup + in-app folder-browser popup + CSS + `attachClosePopup`; api.mjs = `listFolder`/`thumbURL`/`browseFolder`/`pickNativeFolder`; state.mjs = DEFAULT_STATE/readState/writeState/sortFiles) + `nodes/node_load_images_folder.py` (OUTPUT_IS_LIST loader + per-file path-traversal guard + IS_CHANGED) + `server_routes.py` routes `api_lif_list` / `api_lif_thumb` / `api_lif_browse` / `api_lif_pick_native` (helpers `_lif_list_files` / `_lif_make_thumb` / `_lif_native_folder_dialog` + the Windows/macOS/Linux dialog branches, all via `run_in_executor`). See "Load Images from Folder Pixaroma Patterns (do not regress)" - OUTPUT_IS_LIST = one-by-one (NOT a queue-driver), resize reuses the shared `buildModePanel` engine with stateKey `loadImagesFolderState`. |
| Preview Image Pixaroma — change button or strip / grid layout / geometry / colors | `js/preview/index.js` constants at the top (`BTN_H`, `BTN_GAP`, `MIN_W`, `MIN_H`, `DEFAULT_W`, `DEFAULT_H`, `IMG_STRIP_GAP`, `IMG_STRIP_V_PAD`, `IMG_STRIP_BORDER_W`, `BADGE_*`, `LAYOUT_TOGGLE_*`, `COLOR_ACTIVE_*` / `COLOR_DISABLED_*`). Button rects computed in `computeButtonRects`, painted in `paintBtn`. Strip (single horizontal row) rects via `layoutImgStrip`; Grid (2D wrap, **iterate-and-maximise area**, exact match for native PreviewImage's `calculateImageGrid` in `dialogService-*.js`) via `layoutImgGrid`. Algorithm: for each candidate `cols` in `1..N`, compute `rows = ceil(N/cols)`, `slotW = innerW/cols`, `slotH = innerH/rows`, CONTAIN scale `min(slotW/imgW, slotH/imgH, 1)`, cellW/H = imgW/H × scale, area = cellW × cellH × N; pick the cols that maximises area. Cell DIMENSIONS come from `frames[0]` scaled (the iterate-and-maximise pass), then EACH frame is CONTAIN-FIT within its OWN cell (per-frame `imgRect`, mirroring `layoutImgStrip`'s fit): a UNIFORM batch fills its cells exactly (no letterbox, since every frame's aspect equals frames[0]'s), a MIXED-size batch (now common via Load Images from Folder Pixaroma) LETTERBOXES the odd-shaped frames instead of STRETCHING them (fixed 2026-06; it stretched before). `slots` stay the full cells (click hit-test uses `slots`, so the whole cell is clickable); the selection border + the `N/M` badge anchor to `imgRect` (the fitted image, not the slot). The whole grid is centered inside `innerW × innerH`. When `frames[0]` hasn't loaded, falls back to `1×N` strip and reflows on the next draw once `naturalWidth/Height` are populated. Layout selection in `createStripWidget().draw` reads `getLayoutMode(node)` which checks `node.properties.pixaromaLayout` ('grid' / 'strip') with fallback to setting `Pixaroma.Preview.DefaultLayout`. Per-node toggle icon painted via `paintLayoutToggle` (top-right, hover-aware, glyph shows the OPPOSITE mode = what you'll switch to on click). Buttons + strip live as `addCustomWidget`s (so they reserve vertical space, draw immediately on node-add, and Vue-compat works). Don't switch back to `onDrawForeground` (Vue Compat #1) and don't return `ui.images` from the Python node (LiteGraph would render its native strip underneath the custom one — use the `pixaroma_preview_frames` custom UI key instead, Save Mp4 pattern). |
| Preview Image Pixaroma — change 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). |
@@ -1833,7 +1418,7 @@ Files are named by concern. Match the task to the file:
### 4. When creating a new editor
Follow the existing directory structure:
1. Create `js/<name>/` with `index.js` (entry point, `.js`), `core.mjs`, and concern-based splits (all `.mjs`)
2. Create `nodes/node_<name>.py` with mappings + `CATEGORY = "👑 Pixaroma"` (the prefix is what makes the node inherit the brand colors via `js/brand/index.js` — do NOT re-add per-node `node.color` / `node.bgcolor` guards)
2. Create `nodes/node_<name>.py` with mappings + `CATEGORY = "👑 Pixaroma/<subfolder>"` (pick the matching subfolder: 🎨 Editors / 🖼️ Image / ✂️ Resize & Crop / 💬 Prompt & Text / 📝 Notes & Overlay / 🔢 Values / 🔀 Logic & Flow). The `👑 Pixaroma` prefix is what makes the node inherit the brand colors via `js/brand/index.js` — it gates on `startsWith("👑 Pixaroma")`, so subfolders keep the colors — do NOT re-add per-node `node.color` / `node.bgcolor` guards)
3. Import and merge in `__init__.py`
4. If it needs backend routes: add to `server_routes.py`
5. Keep every file under ~300 lines
@@ -1869,21 +1454,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.
+95 -10
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,25 @@
</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?** Post in the **#pixaroma-nodes** channel on the [Pixaroma Discord](https://discord.gg/gggpkVgBf3), or open a work item (issue) on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues).
> 💡 **Updated Pixaroma and a node looks broken or old?** Hard-refresh your ComfyUI browser tab with **Ctrl+Shift+R** (**Cmd+Shift+R** on Mac). The browser caches old node visuals, so without a hard refresh you can still see the previous version even though the update installed correctly.
## 🎨 Creative Suite
Pixaroma turns ComfyUI into a powerful, easy-to-use design space. It brings professional editing right into your workflow!
@@ -55,13 +66,22 @@ Audio-reactive image-to-video. **No extra models needed**, just an image and an
Compare settings side by side without setting anything up. Drop it at the end of your workflow and wire your final image in, just like a preview node. Then pick what changes **across** (columns) and **down** (rows) from a dropdown of the nodes already in your graph - no extra wiring. The value box adapts to your pick: a number gives a Start/End/Steps range, a dropdown (sampler, model, scheduler) gives a checklist, and your prompt gives find-and-replace. Hit Run once and every combination fills a labeled grid right in the node, with **Dark/Light/Mono** grid themes and **Save/Copy/Open** buttons. The seed stays **locked** across the grid so the only thing changing is the thing you're testing.
### ✂️ Image Crop
No more guessing crop sizes with numbers! Visually draw your crop box, or set width, height, position and a center/edge alignment right on the node - math expressions like `1024+512` work too. Standard presets (1:1, 16:9, 9:16…) keep social and video aspects locked. Wire **any IMAGE** output into the node (Load Image, VAE Decode, anything) and run the workflow once - the editor and mini-preview will show the live source. Or paste an image straight from the clipboard with **Ctrl+V**.
No more guessing crop sizes with numbers! Visually draw your crop box, or set width, height, position and a center/edge alignment right on the node - math expressions like `1024+512` work too. Standard presets (1:1, 16:9, 9:16…) keep social and video aspects locked. Wire **any IMAGE** output into the node (Load Image, VAE Decode, anything) and run the workflow once - the editor and mini-preview will show the live source. Or paste an image straight from the clipboard with **Ctrl+V**. It keeps **transparency** too (wire a mask in, get a matching cropped mask out) and hands off a **crop_info** wire to the new **Image Uncrop** node, so you can edit the crop and paste it back later.
📥 [Download example workflow](workflows/Crop%20Pixaroma%20Workflow.json)
![Image Crop Node](workflows/Crop%20Pixaroma%20Workflow.jpg?v=3)
![Image Crop Editor](workflows/Crop%20Pixaroma%20Workflow%20v2.jpg?v=2)
### 🧩 Image Uncrop
The other half of crop-and-edit: paste an edited or upscaled crop **back** onto the original image at the exact spot it came from. Wire the **crop_info** from Image Crop into it, run your crop through any nodes you like (upscale, recolor, inpaint, remove background), and Image Uncrop drops the result back in place - everything outside the crop stays untouched. A **feather** slider softens the seam for a seamless blend, and transparency carries straight through.
### 🩹 Inpaint Crop Pixaroma
The easy way to set up an inpaint. Open the fullscreen editor and **paint** over the part you want the AI to redo - brush, erase, invert, an adjustable brush, and **zoom** (scroll) + **pan** (Space-drag) for fine detail. The node automatically finds the area around your mask, adds a margin, and crops a clean, **model-friendly** piece (sized to a multiple of 8 and scaled toward your target) so even a small spot gets enough resolution to look sharp. Set how the result blends back - **softness**, mask grow, and **Mask** (only the painted area) vs **Whole crop** - right here, with a live preview. Wire the cropped **image** and **mask** into your inpaint or edit model (Flux, KSampler, edit models), then send the **crop_info** wire to Inpaint Stitch to paste the result back. Turn on **invert_mask** to inpaint the opposite area (no separate Invert Mask node), and it works with a mask wired in too. Works in both the classic and the new node interface.
### 🪡 Inpaint Stitch Pixaroma
The other half: paste your inpainted crop **back** onto the original, blended so the seam disappears. Wire in the **crop_info** from Inpaint Crop and your processed crop, and by default only the area you painted changes - everything else stays pixel-perfect. Fine-tune the **softness**, **blend mode**, and an optional **color match** right on this node; because it runs after the sampler, changing them re-runs only this node (instant, no re-generating the image). It also hands back the untouched **original**, so you can drop both into Image Compare for an instant before / after.
### 🌓 Image Compare
The best way to see the difference between two images. Easily compare them side-by-side with a slider, overlap them, or highlight exactly what changed between the two versions.
@@ -76,9 +96,15 @@ A checkpoint you drop into your workflow to stop and look at the image before ru
### 🖼️ Load Image Pixaroma
A drop-in replacement for ComfyUI's native LoadImage with everything you'd want in one node. Same upload / drag-drop / Ctrl+V paste / multi-frame / alpha-to-mask behavior as native, plus inline resize: pick from **Off**, **Max megapixels**, **Longest side**, **Scale by ×**, **Fit inside**, **Crop to fill**, or **Match aspect ratio** with a sub-toggle for Crop or Pad (12 ratio presets + Custom, with a Pixaroma color picker for the Pad color). **Snap to /8/16/32/64**, **Resample picker** (Auto / Nearest / Bilinear / Bicubic / Lanczos with one-line hints under each), and an **Allow upscaling** toggle apply on top. Numeric fields accept math expressions (`1024+64`, `512*2`), ↑↓ arrow stepping (Shift = 10×), and have visible +/- spinner buttons. A live **Input → Output** info bar with tiny aspect-ratio rectangles shows you exactly what dimensions the workflow will produce as you tweak settings. Outputs include `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`, `FILENAME` (no extension), `ORIGINAL_WIDTH`, `ORIGINAL_HEIGHT` - eliminates downstream Get Image Size + Image Scale chains in most workflows.
### 📂 Load Images from Folder Pixaroma
Point it at any folder on your computer and batch-process its images through your workflow. Pick which ones in a thumbnail gallery (**Select all**, the **First N**, or hand-pick), then hit Run once and it feeds each selected image through your graph on its own, giving you a finished result for every image (mixed image sizes are fine). Set the folder with the real OS folder dialog (the **Browse** button, Windows / Mac / Linux) or just type or paste a path. It carries the same inline resize options as Load Image Pixaroma (**Max megapixels**, **Longest side**, **Scale by**, **Fit inside**, **Crop to fill**, **Match aspect ratio**, **Pad**), applied to each image as it loads. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`, `FILENAME`, `INDEX`, `TOTAL` - wire WIDTH/HEIGHT into an empty latent so it matches each image, and FILENAME into a Save node so every result keeps its original name. Works in both the classic and the new node interface.
### ↔️ Image Resize Pixaroma
Resize any image (and its mask) anywhere in your workflow with one compact node. Pick a mode - **Off**, **Max megapixels**, **Longest side**, **Scale by ×**, **Fit inside**, **Crop to fill**, **Match aspect ratio**, or **Pad** (add a colored border for outpainting / inpainting, where the new area becomes the editable mask region). **Crop to fill** has a 9-point **anchor** (keep the top, a corner, the center…) and a **Fill / Crop** toggle (scale-and-crop, or cut a piece at original pixels). A live **Input → Output** card with tiny aspect-ratio rectangles shows exactly what you'll get, and turns orange only when the size actually changes. Wire a **width / height** in (e.g. from Resolution Pixaroma): connect just one to scale while keeping the aspect ratio, or both for an exact size, and the controls adapt automatically. **Snap to /8/16/32/64**, a **Resample picker** (Auto / Nearest / Bilinear / Bicubic / Lanczos), and an **Allow upscaling** toggle apply on top; number fields take math like `1024+64`. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`.
### 🔲 Resize Crop Pixaroma
A dead-simple crop-to-fill node. Wire in an image, set a **width** and **height** (type them, or wire them in from another node like WH or Resolution Pixaroma), and it scales the image to completely fill that size and crops the overflow from the center - so the output is **always exactly** the size you asked for, with no stretching or black bars. Smaller images scale up to fill. An optional **mask** is cropped along with it. Outputs `IMAGE`, `MASK`, `WIDTH`, `HEIGHT`. Perfect for forcing images or video frames to a fixed size like 512×896 or 704×1280. Works in both the classic and the new node interface.
### 📝 Note Pixaroma
A beautiful, simple text editor to document your workflows right on the canvas. Write normally with bold, italics, lists, headings, code blocks (with copy button), and inline icons (CLIP, LORA, GGUF, model versions, and 30+ more). Drop in custom-colored **buttons** (Download / View Page / Read More / plain), **separators** (5 line styles: solid, dashed, dotted, double, thick), **tables**, and **folder hints** for download paths. Each block carries its OWN colour, picked from a clean modal that opens centered on the screen, so two separators (or two grids, or two folder hints) in the same note can have totally different looks. YouTube and Discord pills come pre-colored. There is also a Code view for hand-editing the underlying HTML, plus a drop-in LLM prompt at `assets/note-pixaroma-llm-prompt.txt` if you want ChatGPT, Gemini, or a custom GPT to generate notes for you. It perfectly saves and restores exactly how you styled it.
@@ -116,6 +142,9 @@ A handy way to preview your images right on the node, but better! Works with **s
### 📐 Resolution Pixaroma
A simple, one-click resolution picker. Choose from 9 popular aspect ratios - 1:1, 16:9, 9:16, 2:1, 3:2, 2:3, 4:3, 3:4, and 4:5 (Instagram-portrait friendly) - and instantly get the exact width and height you need, including popular sizes for AI video. Type any Custom Ratio (21:9, 16:10, anything) with auto-computed AI-friendly sizes, or use Custom Resolution to type exact dimensions. Math expressions work in the Width and Height fields too - type `1024+128` or `512*2` and it just works. It perfectly saves all your settings with your workflow!
### 🌱 Seed Pixaroma
A dedicated seed node you wire into KSampler (or any node with a seed input). Flip between **Random** - a fresh seed every run - and **Fixed** - the same seed for repeatable results - right on the node. **New fixed random** rolls a new seed and locks it (great for keeping a lucky result while you tweak other things), **Use last seed** brings back the seed from the previous run, and **Copy** puts the current seed on your clipboard. Type any number into the big readout to set it exactly, and in Random mode a **Last run** line tells you which seed actually made the latest image. One Seed node can feed several samplers at once so they all stay in sync. Works in both the classic and the new node interface.
### 📏 WH Pixaroma
A tiny utility node with two number fields for width and height, and matching width/height outputs. Use it when you want to type a target resolution manually somewhere in your workflow. Math expressions like `1024+64` or `512*2` work directly in the fields. Pairs perfectly with **Switch WH Pixaroma** so you can flip between manual values and the size coming from another node.
@@ -134,12 +163,18 @@ A universal multi-input switch for any data type. Connect models, images, prompt
### 🔀 Switch WH Pixaroma
Switch between two width/height sources with a single click. Wire two width+height pairs into the **A** and **B** inputs (for example a Load Image Pixaroma's WIDTH/HEIGHT and a manual size from WH Pixaroma), then click **A** or **B** on the node body to choose which pair flows through. No rewiring cables. If one side has only one cable connected (the other forgotten), the node uses the complete side instead so the workflow doesn't break. If nothing is wired, you get a clear error message.
### 🔄 Portrait Landscape Pixaroma
Flip a size between portrait (tall) and landscape (wide) with one click. Enter your two dimensions (or wire them in), then tap **Portrait** or **Landscape** - Portrait makes the smaller number the width (a tall image), Landscape makes the larger number the width (a wide image), so the order you type them never matters. One node replaces keeping two WH nodes and a Switch WH just to flip orientation. Outputs `WIDTH`, `HEIGHT` - wire them straight into an empty latent. Works in both the classic and the new node interface.
### 🔁 Switch Source Pixaroma
Flip a whole pipeline (or any set of wires) between two sources with one click. Wire your **A** bank and **B** bank for as many rows as you need (works for any wire type: MODEL, CLIP, VAE, IMAGE, LATENT, STRING...), then toggle **A** or **B** to swap them all at once - no rewiring cables. Two common setups: swap a combined Load Checkpoint against three separate model/CLIP/VAE loaders, or flip a "local" pipeline against an "api" one without ticking ten little switches. Output labels are editable per row, and you can pick whether empty rows leave the output blank or show a clear error.
### 🔇 Mute Switch Pixaroma
Skip whole parts of a workflow with one click. Wire the last node of each "scene" (usually a KSampler) into a row, then use the small switches to pick what runs and what doesn't. Two pills at the top: **Single** (only one scene runs at a time, like a radio button) or **Multi** (any combination), and **Mute** (the scene doesn't run) or **Bypass** (each node passes its input through unchanged). Chain Mute Switches together to group scenes: an outer Mute Switch can pick a group, and inner Mute Switches fine-tune which scenes inside that group run. Right-click for **Enable all rows** / **Disable all rows** to flip every row at once in Multi mode. Labels on rows are editable so you can name your scenes.
### 🔗 Set / Get Pixaroma
Wireless connections for a cleaner canvas. Drop a **Set** node, wire anything into it (image, model, number, prompt), and give it a name. The Set has a passthrough output, so a node sitting nearby can wire to it directly, while far ones read it with a **Get** node - pick the name from a dropdown and it carries the same value, no cable stretched across the workflow. Colour a Set however you like and its Gets take the same colour (and follow along when you recolour it), so matching pairs are easy to spot, and the Get dropdown tags each name with its colour. Collapse them to almost nothing so they disappear into the background, and right-click to jump between a Get and its Set. They respect subgraphs (a value you **Set** in the main graph can be read by **Get** nodes inside your subgraphs), show a tiny value preview for plain numbers and text, and at run time they resolve straight to the original source - identical to a direct wire, with no extra cost. Works in both the classic and the new node interface.
### 🔢 Number Pixaroma
A small node with one number field and two outputs: **int** and **float**. Useful when one downstream node wants a whole number and another wants a decimal from the same value, or when you want to convert a decimal into a whole number cleanly in the middle of a workflow. Accepts whole numbers, decimals, and math expressions like `1024+64` or `1024/3`. The int output rounds to the nearest whole number (`3.5` becomes `4`, `3.4` becomes `3`). Range is roughly plus or minus 1 quadrillion, so even very large numbers fit.
@@ -183,9 +218,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.
@@ -252,7 +289,53 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
## 🛠 Changelog
> 💡 **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 19, 2026 · v1.3.95-1.3.97**
- **NEW: Set / Get Pixaroma** - wireless connections that keep your canvas clean. Wire anything (an image, a model, a number, a prompt) into a **Set** node and give it a name; then a **Get** node anywhere reads that same value with no cable running across the screen - just pick the name from a dropdown. The Set also has a passthrough output, so a node sitting nearby can wire to it directly while far ones use a Get. Colour a Set however you like and its Gets take the same colour, following along when you recolour it, so matching pairs stand out. Collapse them to almost nothing, and right-click to jump from a Get to its Set. They work inside subgraphs (a value you Set in the main graph can be read inside your subgraphs), show a tiny value preview for numbers and text, and behave exactly like a real wire when you run, so they never change your result. Works in both the classic and the new node interface.
- **NEW: Resize Crop Pixaroma** - a dead-simple crop-to-fill node. Set a width and height (type them or wire them in), and it scales your image to completely fill that size and trims the overflow from the center - so the result is always exactly the size you asked for, with no stretching or black bars. Smaller images scale up to fill, and an optional mask is cropped along with it. Great for forcing images or video frames to a fixed size like 512×896 or 704×1280.
- **NEW: Portrait Landscape Pixaroma** - flip a size between portrait (tall) and landscape (wide) with one click. Type your two numbers, tap **Portrait** or **Landscape**, and it arranges them into the orientation you picked (the order you type them never matters). One node replaces keeping two WH nodes and a switch just to flip orientation.
- **Tidier menu** - the Pixaroma nodes are now sorted into folders (Editors, Image, Resize & Crop, Prompt & Text, Notes & Overlay, Values, Logic & Flow) instead of one long list, so they're easier to find. Your existing workflows are unaffected.
### **June 17, 2026 · v1.3.93-1.3.94**
- **NEW: Seed Pixaroma** - a dedicated seed node you wire into KSampler (or any node with a seed input). Flip between **Random** (a fresh seed every run) and **Fixed** (the same seed for repeatable results), hit **New fixed random** to lock in a lucky roll, **Use last seed** to bring back the previous run's seed, or **Copy** to grab the number. One Seed node can feed several samplers so they all stay in sync. Works in both the classic and the new node interface.
- **Load Images from Folder - the "First" box now works as you type.** Type a number in the **First** box and it selects that many images right away (before, you had to click the First button for anything to happen). It also caps to how many images are in the folder, so if you ask for more than exist it just selects them all.
### **June 16, 2026 · v1.3.92**
- **NEW: Inpaint Crop + Inpaint Stitch Pixaroma** - the easy way to inpaint. Open the editor, paint over the part you want the AI to change, and the node automatically crops a clean, model-friendly piece around it; run it through your model, then Inpaint Stitch pastes the result back at the exact spot, blended so the seam disappears. The editor has **zoom** (scroll wheel) and **pan** (Space-drag) for fine detail, brush / erase / invert, an adjustable brush, and a live preview of how the edit will blend. Set how it blends back (softness, mask grow, mask vs whole-crop) on the node or in the editor; tweak the blend on the Stitch node afterwards without re-generating.
- **No more hard refresh after every update.** The plugin now tells the browser not to cache its files, so from now on plugin updates show up on a normal page reload. (Do **one** last hard refresh - Ctrl+Shift+R - right after this update to clear the old cache; after that, a normal reload is enough.)
- **Press `\` to open Node Colors.** With a node, several nodes, or a group selected, tap the **`\`** key to open the color palette (it's also labeled in the right-click menu now).
### **June 15, 2026 · v1.3.88-1.3.91**
- **NEW: Image Uncrop Pixaroma** - paste an edited or upscaled crop back onto the original image at the exact spot it came from, with a **feather** slider for a seamless blend.
- **Image Crop now carries transparency** - a new mask input/output plus a `crop_info` wire that feeds Image Uncrop. (Its width and height outputs moved down one spot, so reconnect those once if you used them.)
- **Image Uncrop now works with pasted/loaded crops too** - pasting or loading an image straight into Image Crop (no Load Image node) used to make Uncrop return just the cropped piece; now it rebuilds the full picture either way.
- **NEW: Load Images from Folder Pixaroma** - point it at any folder, pick images in a thumbnail gallery, and Run once to process each one (different sizes are fine). Outputs image, mask, size, filename, and index per image.
- **Preview Image:** mixed-size batches now fit each thumbnail to its own shape instead of stretching.
- **Housekeeping:** the Manager / registry star count and "last updated" date refresh correctly again.
### **June 10, 2026 · v1.3.84-1.3.87**
- **NEW: vertical text (top to bottom)** in Text Overlay, Composer text layers, and Text Watermark - a Horizontal / Vertical switch; Enter starts a new column.
- **NEW: a Restore brush for the Composer eraser** - flip Erase / Restore (or hold Alt, or press X) to paint the original image back; the brush ring turns orange in Restore mode.
- **NEW: a Help button on every node** - select a node and click the orange **?** in the toolbar for a plain-English panel (the small in-node ? buttons were removed to save space).
- **NEW: a Dynamic prompts switch on the Text node** (off by default) - turn it on and `{red|blue|green}` picks one at random each run.
- **NEW: image sizes on Image Compare** - each input shows its resolution, turning orange when the two don't match.
- **Text Pixaroma no longer strips curly braces** `{ }` - they stay exactly as typed, so JSON prompts survive.
- **Save names in any language work** - Korean, Japanese, accented letters, and spaces in the filename are kept as typed (also Save to Disk / Output and XY Plot).
### **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.)
@@ -447,12 +530,14 @@ Master the Pixaroma suite with our video guides and workflow deep-dives:
---
## 📜 Feedback & License
## 📜 About, Feedback & License
> [!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).
💬 **[Join our Discord Community](https://discord.gg/gggpkVgBf3)**
⚖️ **Licensed under [MIT](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/LICENSE)**
🏠 **Home:** ComfyUI-Pixaroma is developed on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma) - the place for the latest code. Any copy hosted elsewhere (such as a GitHub mirror) is a backup.
💡 **Have an idea for a new node or improvement?** Share it in the **#pixaroma-nodes** channel on [Discord](https://discord.gg/gggpkVgBf3).
🐞 **Found a bug?** Open a work item (GitLab's name for an issue) on [GitLab](https://gitlab.com/pixaroma/comfyui-pixaroma/-/issues), or post in **#pixaroma-nodes** on [Discord](https://discord.gg/gggpkVgBf3).
⚖️ **Licensed under [MIT](LICENSE)**
ComfyUI-Pixaroma is an independent, community-made extension. It is not affiliated with, endorsed by, or sponsored by Comfy Org or the ComfyUI project. Product names, logos, and trademarks are the property of their respective owners.
+32
View File
@@ -12,6 +12,12 @@ from .nodes.node_compare import NODE_CLASS_MAPPINGS as _MAPS_COMPARE
from .nodes.node_compare import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_COMPARE
from .nodes.node_crop import NODE_CLASS_MAPPINGS as _MAPS_CROP
from .nodes.node_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_CROP
from .nodes.node_uncrop import NODE_CLASS_MAPPINGS as _MAPS_UNCROP
from .nodes.node_uncrop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_UNCROP
from .nodes.node_inpaint_crop import NODE_CLASS_MAPPINGS as _MAPS_INPAINT_CROP
from .nodes.node_inpaint_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_INPAINT_CROP
from .nodes.node_inpaint_stitch import NODE_CLASS_MAPPINGS as _MAPS_INPAINT_STITCH
from .nodes.node_inpaint_stitch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_INPAINT_STITCH
from .nodes.node_label import NODE_CLASS_MAPPINGS as _MAPS_LABEL
from .nodes.node_label import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LABEL
from .nodes.node_load_image import NODE_CLASS_MAPPINGS as _MAPS_LOAD_IMAGE
@@ -40,8 +46,12 @@ from .nodes.node_mute_switch import NODE_CLASS_MAPPINGS as _MAPS_MUTE_SWITCH
from .nodes.node_mute_switch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_MUTE_SWITCH
from .nodes.node_wh import NODE_CLASS_MAPPINGS as _MAPS_WH
from .nodes.node_wh import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_WH
from .nodes.node_portrait_landscape import NODE_CLASS_MAPPINGS as _MAPS_PORTRAIT_LANDSCAPE
from .nodes.node_portrait_landscape import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PORTRAIT_LANDSCAPE
from .nodes.node_number import NODE_CLASS_MAPPINGS as _MAPS_NUMBER
from .nodes.node_number import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NUMBER
from .nodes.node_seed import NODE_CLASS_MAPPINGS as _MAPS_SEED
from .nodes.node_seed import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SEED
from .nodes.node_text import NODE_CLASS_MAPPINGS as _MAPS_TEXT
from .nodes.node_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT
from .nodes.node_prompt_stack import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_STACK
@@ -58,6 +68,8 @@ from .nodes.node_text_overlay import NODE_CLASS_MAPPINGS as _MAPS_TEXT_OVERLAY
from .nodes.node_text_overlay import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_OVERLAY
from .nodes.node_image_resize import NODE_CLASS_MAPPINGS as _MAPS_IMAGE_RESIZE
from .nodes.node_image_resize import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_IMAGE_RESIZE
from .nodes.node_resize_crop import NODE_CLASS_MAPPINGS as _MAPS_RESIZE_CROP
from .nodes.node_resize_crop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RESIZE_CROP
from .nodes.node_text_watermark import NODE_CLASS_MAPPINGS as _MAPS_TEXT_WATERMARK
from .nodes.node_text_watermark import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_WATERMARK
from .nodes.node_version_check import NODE_CLASS_MAPPINGS as _MAPS_VERSION_CHECK
@@ -68,6 +80,10 @@ from .nodes.node_xy_plot import NODE_CLASS_MAPPINGS as _MAPS_XY_PLOT
from .nodes.node_xy_plot import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_XY_PLOT
from .nodes.node_find_replace import NODE_CLASS_MAPPINGS as _MAPS_FIND_REPLACE
from .nodes.node_find_replace import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_FIND_REPLACE
from .nodes.node_load_images_folder import NODE_CLASS_MAPPINGS as _MAPS_LIF
from .nodes.node_load_images_folder import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LIF
from .nodes.node_set_get import NODE_CLASS_MAPPINGS as _MAPS_SET_GET
from .nodes.node_set_get import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SET_GET
# development mode for loading additional refrence nodes
dev_mode = False
@@ -89,6 +105,9 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_RESOLUTION,
**_MAPS_COMPARE,
**_MAPS_CROP,
**_MAPS_UNCROP,
**_MAPS_INPAINT_CROP,
**_MAPS_INPAINT_STITCH,
**_MAPS_LABEL,
**_MAPS_LOAD_IMAGE,
**_MAPS_NOTE,
@@ -99,7 +118,9 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_SWITCH_SOURCE,
**_MAPS_MUTE_SWITCH,
**_MAPS_WH,
**_MAPS_PORTRAIT_LANDSCAPE,
**_MAPS_NUMBER,
**_MAPS_SEED,
**_MAPS_TEXT,
**_MAPS_PROMPT_STACK,
**_MAPS_PROMPT_MULTI,
@@ -110,11 +131,14 @@ NODE_CLASS_MAPPINGS = {
**_MAPS_REMOVE_BG,
**_MAPS_TEXT_OVERLAY,
**_MAPS_IMAGE_RESIZE,
**_MAPS_RESIZE_CROP,
**_MAPS_TEXT_WATERMARK,
**_MAPS_VERSION_CHECK,
**_MAPS_PAUSE_IMAGE,
**_MAPS_XY_PLOT,
**_MAPS_FIND_REPLACE,
**_MAPS_LIF,
**_MAPS_SET_GET,
}
# combine all node display name mappings
@@ -124,6 +148,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_AUDIO_STUDIO,
**_NAMES_COMPARE,
**_NAMES_CROP,
**_NAMES_UNCROP,
**_NAMES_INPAINT_CROP,
**_NAMES_INPAINT_STITCH,
**_NAMES_LABEL,
**_NAMES_LOAD_IMAGE,
**_NAMES_NOTE,
@@ -134,7 +161,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_SWITCH_SOURCE,
**_NAMES_MUTE_SWITCH,
**_NAMES_WH,
**_NAMES_PORTRAIT_LANDSCAPE,
**_NAMES_NUMBER,
**_NAMES_SEED,
**_NAMES_TEXT,
**_NAMES_PROMPT_STACK,
**_NAMES_PROMPT_MULTI,
@@ -149,11 +178,14 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**_NAMES_REMOVE_BG,
**_NAMES_TEXT_OVERLAY,
**_NAMES_IMAGE_RESIZE,
**_NAMES_RESIZE_CROP,
**_NAMES_TEXT_WATERMARK,
**_NAMES_VERSION_CHECK,
**_NAMES_PAUSE_IMAGE,
**_NAMES_XY_PLOT,
**_NAMES_FIND_REPLACE,
**_NAMES_LIF,
**_NAMES_SET_GET,
}
# web directory for loading js files
+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

+61
View File
@@ -0,0 +1,61 @@
# Inpaint Crop / Stitch math
Single source of truth for the Inpaint Crop Pixaroma + Inpaint Stitch Pixaroma geometry and seam math. The Python in `nodes/_inpaint_helpers.py` is authoritative (runs at execute time); the JS mirrors it for the editor's live preview. Update this doc first, then both sides.
## 1. Crop region (`compute_region`)
Mirrored 1:1 by `js/inpaint_crop/geometry.mjs::computeRegion`. From the painted mask's bounding box and the image size:
1. Context expand: `rw = bw + 2*max(context_px, blend) + bw*context_pct/100` (same for height). Using `max(context_px, blend)` rather than just `context_px` gives the seam feather room to reach 0 inside the crop, so a big softness grows the crop instead of leaving a hard seam (Option B; see section 3).
2. Place + clamp the SOURCE crop rect inside the image (centred on the mask). In `force` mode the wanted region is first grown to the `target_w/target_h` aspect, then AFTER the clamp the source aspect is re-imposed to that aspect (the W/H clamp can clip one axis and not the other - shrink the over-long axis to the largest aspect-correct rect that fits).
3. Compute the OUTPUT size FROM the clamped source rect (NEVER the un-clamped wanted region), so the crop is resized at its real aspect and is never stretched when the image edge clipped the region (e.g. a big softness on a mask near the border - the source clamps to the image, so the output must too, or the crop/mask comes out squished):
- `keep`: scale the cropped rect's long side to `target` (respecting `allow_upscale`); then `min_size` FIRST (bump both sides up so the short side reaches `min_size`), then `max_size` LAST as a hard ceiling (so an extreme-aspect crop can't blow the long side past `max_size` into an OOM tensor; the short side may then end below `min_size` - acceptable). Round each side to `multiple`.
- `force`: the fixed `target_w x target_h` (the source was already re-imposed to that aspect in step 2).
- `free`: the cropped size rounded to `multiple`, capped at `max_size`.
Only accepted drift: sub-pixel rect placement (Python banker's rounding vs JS round-half-up = +/-1 px on `rx`/`ry`).
## 2. Conditioning mask (`apply_inpaint_crop`)
The mask the MODEL sees. `softm` (the `fill_holes`-cleaned + `mask_grow`-dilated core, max'd with the raw painted mask) is cropped to the region, resized NEAREST, then softened by a Gaussian of radius `mask_blur`. NEAREST avoids a second gradient halo; `mask_blur` is the one intended conditioning softening.
`fill_holes` closes only SMALL enclosed specks/gaps (a hole up to ~0.5% of the image: `max(256, 0.005*H*W)` px) — NEVER a large subject-shaped hole. Otherwise a cut-out / background mask (white around a subject) would have its subject hole filled by scipy's `binary_fill_holes` and the whole mask would collapse to solid (so the crop bbox becomes the whole image and the model repaints everything). The no-scipy fallback (a 9px PIL close) is already naturally limited to small holes.
If `invert_mask` is on, `raw` is flipped (`1 - raw`) right after `mask_to_np`, before fill-holes/grow — so the bbox, the conditioning out-mask, AND `crop_info["mask"]` (the stitch blend) all target the OPPOSITE area. It is a no-op when no mask is connected (`mask is None`). It is a mask-processing knob, NOT a geometry param, so it lives in `DEFAULTS` but not in `geometry.mjs`'s `GEO_DEFAULTS` (the editor's live preview does not reflect it - node-only).
`apply_inpaint_crop` first coerces an RGBA input to RGB (premultiply over black: `rgb * alpha`), so `crop_info["image"]` is always 3-channel. A 4-channel cut-out (e.g. Remove Background Pixaroma's `image`) would otherwise make Inpaint Stitch's paste throw on a 3-vs-4 channel mismatch, and the stitch node's `except` silently passes the result through as the "original". Premultiplying over black makes the cut-out read as the subject on black (matching the editor/preview), not the leftover background still under the alpha.
## 3. Seam feather (`_blur_alpha`) — the paste-back blend
This is what the Inpaint Stitch `blend` (the editor's `Softness`) controls. It is an OUTWARD-only feather of the (crisp, grown) mask, NOT a centred one (a centred feather makes the masked content semi-transparent at its own edge = a ghost/halo of the old content).
- Let `k = blend` (px). `k <= 0` returns the mask unchanged (hard seam).
- Binary core `mb = alpha > 0.5`. Empty or full mask returns unchanged.
- With scipy: `signed = dist_in(mb) - dist_out(~mb)`; `t = clip(signed/k + 1, 0, 1)`; `feather = smoothstep(t) = t*t*(3 - 2t)`. So alpha is 1 inside + at the edge, ramping to 0 over `k` px OUTSIDE.
- Without scipy (fallback): `feather = where(mb, 1, gaussian_blur(mb, k/1.7))` — same outward shape, approximate.
The feather is opaque inside by construction (`signed >= 0` -> `t >= 1` -> `smoothstep = 1`), so there is NO core-opaque clamp.
### The feather always has room (Option B)
A feather wider than the context margin used to leave a nonzero alpha at the crop rectangle border = a hard straight line ("high blend = straight edge"). That is now prevented at the SOURCE: `compute_region` expands the context to `max(context_px, blend)` (section 1), so the mask always sits at least `blend` px from the crop edge and the outward feather reaches 0 before the border on its own. The old rect-edge guard (`min(feather, smoothstep(de/k))` plus a `max(.., mask)` core clamp) was removed - growing the crop is cleaner and avoids the guard's failure modes (crushing a too-wide feather; ghosting a mask that sits near the crop edge). Trade-off: a big softness makes the crop a bit larger (visible as the crop box growing in the editor; a small inpaint-resolution cost).
`whole_crop` blend mode uses `_feather_alpha` instead (distance-to-rectangle fade of the whole crop). `_feather_alpha` caps the feather at `~(min(ch,cw)-1)//2` so the interior stays fully opaque: a feather wider than half the crop never reaches 1 anywhere, which would make the WHOLE paste translucent and ghost the original through (reachable on a small crop + a large Stitch softness override).
## 4. Editor live preview (matches the scipy result)
`js/inpaint_crop/render.mjs::_seamAlphaCanvas` mirrors the **scipy smoothstep** of section 3 (not the old no-scipy gaussian): it draws the mask into a downscaled buffer, runs `geometry.mjs::seamAlphaFromAlpha` (a 2-pass `(1, √2)` chamfer distance transform of the OUTSIDE distance, then `alpha = inside ? 1 : smoothstep(clip(1 - d_out/k))`), writes the feathered alpha back, and upscales to the display-res seam canvas. So the editor preview MATCHES the stitched result — a moderate softness no longer looks tighter in the editor than the real seam. The chamfer DT approximates the node's exact Euclidean EDT within a few % (invisible on a soft seam). Downscaled (long side capped at 480px) and computed only on `_draw` (strokes / slider drags, never idle mouse-move), so it stays fast without caching. The tint is filled via `source-in` in the chosen preview color and clipped to the crop region (so it can't spill past the box). The preview color is display-only (never written into the mask, state, or crop_info). In `whole_crop` blend mode `_seamAlphaCanvas` takes a separate branch that paints the inward rectangle-edge ramp of the crop REGION (mirror of `_feather_alpha`), not the mask-edge feather, so the tint matches a whole-crop stitch. The Softness slider calls `_recomputeRegion()` (not just `_draw`) because softness grows the crop box (Option B).
## 5. The settings flow
`softness` (the seam feather = `blend`) is a Crop node INT widget (0-150), mirrored by the editor's Softness slider; `node_inpaint_crop.py::run` feeds it into the crop geometry as `params["blend"]` (so the crop context grows to fit the feather - section 1) AND injects it into `crop_info["blend"]` (clamped 0-150) for the stitch. `blend_mode` (`mask` / `whole crop`) is ALSO a Crop node combo widget now (mirrored by the editor's Blend mode pill, same as the other seam knobs - the node widget is the source of truth; `_BLEND_MODE` maps the friendly label to the internal key). `run` sets `crop_info["blend_mode"]` from the widget (NOT `state_json`). `mask` = only the painted area is replaced; `whole crop` = the entire crop rectangle is replaced (the `_feather_alpha` rectangle fade in `stitch_back`). `node_inpaint_stitch.py` reads `blend` + `blend_mode` from `crop_info` (defaults `16` / `mask` for an Image Crop `crop_info` that lacks them), BUT the Stitch node now has its OWN `softness` (-1 = inherit) + `blend_mode` (`from crop` = inherit) widgets that OVERRIDE crop_info via `resolve_seam(crop_info, softness, blend_mode)`. Because Stitch is downstream of the sampler, changing them re-runs only Stitch (the KSampler stays cached on a fixed seed) - so the seam can be tuned without re-sampling. No room-clamp: a Stitch softness larger than the crop's room may show a slightly harder edge (raise the Crop node's softness for more room); dialing down from the crop's value is always clean.
`color_match` (off/subtle/strong) is the STITCH node's OWN widget, not in `crop_info` (a post-result tweak with no live preview). It shifts the inpainted crop's color stats toward the original over the UNMASKED CONTEXT (the surroundings OUTSIDE the mask) — subtle = match mean, strong = match mean + std. NOT the mask and NOT the whole crop: both include the masked area, so they drag the inpaint's DELIBERATELY changed colors back toward the original (a red->white dress goes pink). Matching the context corrects only the lighting/tone drift in the unchanged surroundings. Falls back to uniform if the mask ~fills the crop (no context). So it is for blending an inpaint INTO the scene, not for deliberate recolors (keep it off for those).
## 6. Notes / caveats
- **MASK is single-frame.** `mask_to_np` collapses a `[B,H,W]` mask to frame 0, so a video / batch crop uses ONE mask (the first frame's) for every frame. The image batch is cropped per-frame with the same rect; only the mask is shared.
- **"Pixel-exact round-trip" is conditional.** Cropping an UN-edited region and stitching it straight back is pixel-exact ONLY in `free` size mode (no resize - and only when the source rect dims are already a `multiple`, so `resize_image_tensor` short-circuits) with `whole_crop` blend mode and `blend = 0` (alpha = 1 everywhere, no feather). `keep` / `force` always resize the crop (so the paste resamples), and any `blend > 0` or `mask` mode feathers the seam - both are intended, not bugs.
- **`crop_info["mask"]` is float32** regardless of the image dtype (the image tensor keeps its own dtype; the carried mask matches `out_mask`).
- **Stitch with no mask edge feathers the whole crop.** If the resolved paste alpha has no edge to soften - an Image Crop `crop_info` whose mask is all-zeros (no mask was wired into Image Crop), an all-ones mask, or a whole-image inpaint with nothing painted - `stitch_back` falls back to the `whole_crop` rectangle feather over the full crop, so the edited crop is still pasted (with a soft seam) instead of pasting nothing (all-zeros) or a hard rectangle (all-ones).
- **The editor's `temp/` source PNG accumulates.** `node_inpaint_crop.py::_save_source_temp` writes one PNG to ComfyUI's `temp/` per run (so the editor can load the upstream pixels of a generative chain). These are cleared on the normal ComfyUI `temp/` lifecycle (restart), not per run.
+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",
+8
View File
@@ -277,6 +277,14 @@ app.registerExtension({
node.addInput("image", "IMAGE");
}
// ── MASK input socket (optional) ────────────────────────────────────
// Lets the node crop transparency alongside the image. Guard-add (like the
// image input) so it shows on fresh nodes AND on workflows saved before this
// input existed. Sits right after the image input.
if (!(node.inputs || []).some((inp) => inp.name === "mask")) {
node.addInput("mask", "MASK");
}
// ── Shared preview system ──
const parts = createNodePreview(
"Image Crop",
+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;
+3 -2
View File
@@ -47,6 +47,7 @@ export function createEditorLayout(config) {
onZoomOut,
onZoomFit,
helpContent = "",
helpTitle = "", // optional; defaults to "<editorName> — Shortcuts" (back-compat)
} = config;
// ── Overlay ──
@@ -186,13 +187,13 @@ export function createEditorLayout(config) {
if (helpContent) {
helpPanel.innerHTML = `
<div class="pxf-help-header">
<h3>${editorName} — Shortcuts</h3>
<h3>${helpTitle || `${editorName} — Shortcuts`}</h3>
<button class="pxf-btn-sm" style="flex-shrink:0;">✕</button>
</div>
<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) {
File diff suppressed because it is too large Load Diff
+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();
},
});
+411
View File
@@ -0,0 +1,411 @@
// ============================================================
// Inpaint Crop Pixaroma — Editor core (constructor, open/close, build UI)
// ============================================================
import {
BRAND,
createEditorLayout,
createPanel,
createButton,
createPillGrid,
createSliderRow,
createInfo,
} from "../framework/index.mjs";
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
export { BRAND };
const UI = "/pixaroma/assets/icons/ui/";
// Brush default size (used by the "Reset to default" button in the Brush panel).
const DEFAULT_BRUSH_SIZE = 80; // px diameter
// Mask/seam preview tint options (display only). Orange recolors the crop box.
export const INPAINT_PREVIEW_COLORS = {
Red: "#f6303a", Green: "#25d366", Blue: "#3a9bff", Yellow: "#ffd21a", Orange: "#ff8c1a",
};
export const InpaintAPI = {
async uploadSrc(projectId, dataURL) {
const { api } = await import("/scripts/api.js");
const res = await api.fetchApi("/pixaroma/api/inpaint/upload_src", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: projectId, image: dataURL }),
});
return await res.json();
},
async saveMask(projectId, dataURL) {
const { api } = await import("/scripts/api.js");
const res = await api.fetchApi("/pixaroma/api/inpaint/save_mask", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: projectId, mask: dataURL }),
});
return await res.json();
},
};
export class InpaintCropEditor {
constructor() {
this.onSave = null; // (stateJsonStr, { context_px }, previewDataURL)
this.onSaveToDisk = null; // (previewDataURL)
this.onClose = null;
this.onLoadImage = null; // host disconnects the upstream wire
this.onPreviewColor = null; // host persists the preview tint setting
this.el = {};
this.layout = null;
this.img = null;
this.imgW = 0;
this.imgH = 0;
this.projectId = null;
this._scale = 1; // effective source->display px = _baseScale * _zoom
this._baseScale = 1; // fit-to-window scale (zoom 1)
this._zoom = 1; // 1 = fit; wheel zooms toward the cursor
this._panX = 0; // display-px offset of the image origin (0 at fit)
this._panY = 0;
this._dispW = 0; // logical display (viewport) size, set by _fitCanvas before any draw
this._dispH = 0;
this._srcPath = "";
this._maskPath = "";
this._pendingSrcDataURL = null;
// brush / mask state
this.tool = "add"; // "add" | "erase"
this.brushSize = DEFAULT_BRUSH_SIZE; // display px (diameter); persists per node across opens
this.softness = 0; // crisp brush; the seam Softness slider owns blending now
this.maskOpacity = 0.5;
this.maskVisible = true;
this.previewColor = "#f6303a"; // mask + seam preview tint (display only)
this._cropBoxColor = null; // crop-box stroke override (white when orange tint)
this._painting = false;
this._lastPt = null;
// geometry params (snapshot of the node knobs)
this.params = {};
this._bbox = null; // raw painted bbox (source px)
this._region = null; // computeRegion() result
// undo
this._undo = [];
this._redo = [];
}
// upstreamUrl: live source from the wired IMAGE input (wins over saved src).
// params: { size_mode, target, multiple, context_px, mask_grow } (internal keys)
open(jsonStr, upstreamUrl, params, prefs) {
let data = {};
try { data = jsonStr && jsonStr !== "{}" ? JSON.parse(jsonStr) : {}; } catch (e) {}
this.projectId = data.project_id || "inpaint_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
this._srcPath = data.src_path || "";
this._maskPath = data.mask_path || "";
this.params = { ...(params || {}) };
if (this.params.blend == null) this.params.blend = 16;
this._fromUpstream = !!upstreamUrl;
// restore brush prefs from a previous open on this node (size / opacity
// persist across opens; absent -> the constructor defaults above).
if (prefs && typeof prefs === "object") {
if (prefs.brushSize != null) this.brushSize = prefs.brushSize;
if (prefs.maskOpacity != null) this.maskOpacity = prefs.maskOpacity;
}
this._buildUI();
this.layout.mount();
this._undoGuardOff = installGraphUndoGuard(() => !!this.el.overlay?.isConnected);
let sourceURL = null;
if (upstreamUrl) sourceURL = upstreamUrl;
else if (this._srcPath) {
const fn = this._srcPath.split(/[\\/]/).pop();
sourceURL = `/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
}
if (sourceURL) {
this._loadImageFromURL(sourceURL, () => {
// restore a saved painted mask (best-effort) once the image is sized
if (this._maskPath) {
const mfn = this._maskPath.split(/[\\/]/).pop();
const murl = `/view?filename=${encodeURIComponent(mfn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
this._loadMaskFromURL(murl);
}
});
} else {
this._setStatus("No source loaded. Wire an IMAGE input and run the workflow once, or click Load Image — or just paste / drag an image in here.");
}
this._bindKeys();
this._bindDropPaste(); // paste / drag-drop an image straight into the editor
}
_close() { this.layout?.unmount(); }
_buildUI() {
const layout = createEditorLayout({
editorName: "Inpaint Crop",
editorId: "pixaroma-inpaint-editor",
showUndoRedo: true,
showZoomBar: false,
showStatusBar: true,
onSave: () => this._save(),
onClose: () => this._close(),
onUndo: () => this._doUndo(),
onRedo: () => this._doRedo(),
helpTitle: "Inpaint Crop — Guide",
helpContent: `
<b style="color:#f66744;">WHAT THIS DOES</b><br>
Paint over the part of the image you want the model to redo. The node finds
the box around your paint, adds a margin, and crops a clean piece to inpaint.
The orange box is exactly what gets sent to the model.<br><br>
<b style="color:#f66744;">PAINTING</b><br>
<b>Brush / Erase:</b> pick a tool or press <kbd>B</kbd> / <kbd>E</kbd> (hold <kbd>X</kbd> to flip briefly)<br>
<b>Brush size:</b> <kbd>[</kbd> / <kbd>]</kbd> or the Size slider<br>
<b>Zoom / Pan:</b> scroll wheel zooms toward the cursor; hold <kbd>Space</kbd> and drag (or middle-drag) to pan; scroll out to fit<br>
<b>Show / hide mask:</b> <kbd>H</kbd><br>
<b>Invert / Clear:</b> buttons in the sidebar (Invert = swap painted &harr; unpainted)<br>
<b>Load a different image:</b> the Load Image button, or just <b>paste</b> / <b>drag</b> an image straight in here<br><br>
<b style="color:#f66744;">CROP SIZE</b> (sidebar + node)<br>
<b>Keep shape:</b> scales your area so its long side hits Target, no stretching (best quality)<br>
<b>Force square:</b> always a Target&times;Target square<br>
<b>Free:</b> natural size, just rounded to the Multiple<br>
<b>Context margin:</b> how much of the surroundings to include around your paint<br><br>
<b style="color:#f66744;">SEAM — HOW IT BLENDS BACK</b><br>
<b>Softness:</b> how far the paste fades into the original at the edge (the live orange tint previews it)<br>
<b>Mask grow:</b> expands the painted area a little before cropping<br>
<b>Blend mode — Mask:</b> only the area you painted is replaced, the rest of the crop keeps the original (the normal inpaint)<br>
<b>Blend mode — Whole crop:</b> the <i>entire</i> box is replaced with the model's version — use when the model also relit / changed the surroundings, or for an img2img pass<br><br>
<b style="color:#f66744;">KEYS</b><br>
<b>Undo / Redo:</b> <kbd>Ctrl+Z</kbd> / <kbd>Ctrl+Shift+Z</kbd> &middot;
<b>Save:</b> <kbd>Ctrl+S</kbd> &middot; <b>Close:</b> <kbd>Escape</kbd>`,
});
this.layout = layout;
layout.onSaveToDisk = () => { this._diskSavePending = true; this._save(); };
layout.onCleanup = () => {
if (this._undoGuardOff) { this._undoGuardOff(); this._undoGuardOff = null; }
this._unbindKeys();
if (this.onClose) this.onClose();
};
this.el.overlay = layout.overlay;
this.el.workspace = layout.workspace;
this._buildLeftSidebar(layout.leftSidebar);
this._buildRightSidebar(layout.rightSidebar, layout.sidebarFooter);
// canvas stack: main image+mask+overlay, plus a cursor canvas on top
const wrap = document.createElement("div");
wrap.style.cssText = "position:relative;display:inline-block;line-height:0;";
this.el.canvasWrap = wrap;
const cvs = document.createElement("canvas");
cvs.width = 100; cvs.height = 100;
this.el.canvas = cvs;
this.el.ctx = cvs.getContext("2d");
const cur = document.createElement("canvas");
cur.width = 100; cur.height = 100;
cur.style.cssText = "position:absolute;left:0;top:0;pointer-events:none;";
this.el.cursor = cur;
this.el.curCtx = cur.getContext("2d");
wrap.append(cvs, cur);
layout.workspace.appendChild(wrap);
this._bindMouse(cvs);
layout.setUndoState({ canUndo: false, canRedo: false });
}
_buildLeftSidebar(sidebar) {
// Mask tools
const secTools = createPanel("Mask");
this._toolGrid = createPillGrid(
[{ label: "Brush (B)", value: "add" }, { label: "Erase (E)", value: "erase" }],
2, (v) => this._setTool(v), { activeValue: "add" },
);
secTools.content.appendChild(this._toolGrid.el);
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:6px;margin-top:8px;";
row.append(
createButton("Invert", { variant: "standard", iconSrc: UI + "swap.svg", onClick: () => this._invertMask() }),
createButton("Clear", { variant: "standard", iconSrc: UI + "delete.svg", onClick: () => this._clearMask() }),
);
for (const b of row.children) b.style.flex = "1";
secTools.content.appendChild(row);
sidebar.appendChild(secTools.el);
// Brush
const secBrush = createPanel("Brush");
this.el.sizeSlider = createSliderRow("Size", 2, 300, this.brushSize, () => {
this.brushSize = parseInt(this.el.sizeSlider.numInput.value) || this.brushSize;
});
const sizeHint = document.createElement("div");
sizeHint.innerHTML = "[ smaller · ] bigger<br>scroll = zoom · space-drag = pan";
sizeHint.style.cssText = "font-size:10px;color:#888;margin-top:5px;line-height:1.5;";
secBrush.content.append(this.el.sizeSlider.el, sizeHint);
sidebar.appendChild(secBrush.el);
// Seam (the stitch blend; live preview on the canvas)
const secSeam = createPanel("Seam — how it blends");
this.el.blendSlider = createSliderRow("Softness", 0, 150, this.params.blend ?? 16, () => {
this.params.blend = parseInt(this.el.blendSlider.numInput.value) || 0;
// softness grows the crop once it exceeds context_px (Option B), so the crop
// box + size badge must recompute - not just the seam tint (mirror Mask grow).
this._recomputeRegion();
this._draw();
});
this.el.growSlider = createSliderRow("Mask grow", 0, 256, this.params.mask_grow ?? 4, () => {
this.params.mask_grow = parseInt(this.el.growSlider.numInput.value) || 0;
this._recomputeRegion();
this._draw();
});
const bmLabel = document.createElement("div");
bmLabel.textContent = "Blend mode";
bmLabel.style.cssText = "font-size:11px;color:#aaa;margin:10px 0 5px;";
this._blendModeGrid = createPillGrid(
[{ label: "Mask", value: "mask" }, { label: "Whole crop", value: "whole_crop" }],
2, (v) => { this.params.blend_mode = v; this._draw(); },
{ activeValue: this.params.blend_mode || "mask" },
);
secSeam.content.append(this.el.blendSlider.el, this.el.growSlider.el,
bmLabel, this._blendModeGrid.el);
sidebar.appendChild(secSeam.el);
// View
const secView = createPanel("Mask overlay");
this._visBtn = createButton("Toggle mask (H)", { variant: "full", iconSrc: UI + "eraser.svg", onClick: () => this._toggleMaskVisible() });
this.el.opacitySlider = createSliderRow("Opacity", 10, 100, Math.round(this.maskOpacity * 100), () => {
this.maskOpacity = (parseInt(this.el.opacitySlider.numInput.value) || 50) / 100;
this._draw();
});
secView.content.append(this._visBtn, this.el.opacitySlider.el);
// preview color swatches (display only; Orange recolors the crop box to white)
const swatchRow = document.createElement("div");
swatchRow.style.cssText = "display:flex;gap:8px;align-items:center;margin-top:8px;";
const swatchLabel = document.createElement("span");
swatchLabel.textContent = "Color";
swatchLabel.style.cssText = "font-size:11px;color:#aaa;margin-right:2px;";
swatchRow.appendChild(swatchLabel);
this._colorSwatches = [];
for (const [name, hex] of Object.entries(INPAINT_PREVIEW_COLORS)) {
const dot = document.createElement("span");
dot.title = name;
dot.style.cssText = `width:20px;height:20px;border-radius:50%;background:${hex};cursor:pointer;box-sizing:border-box;border:2px solid ${this.previewColor === hex ? "#fff" : "transparent"};`;
dot.addEventListener("click", () => this._setPreviewColor(name, hex));
this._colorSwatches.push({ dot, hex });
swatchRow.appendChild(dot);
}
secView.content.appendChild(swatchRow);
sidebar.appendChild(secView.el);
// Context margin (mirrors node context_px, updates the preview live)
const secCtx = createPanel("Context margin");
const ctxStart = this.params.context_px != null ? this.params.context_px : 24;
this.el.ctxSlider = createSliderRow("Pixels", 0, 512, ctxStart, () => {
this.params.context_px = parseInt(this.el.ctxSlider.numInput.value) || 0;
this._recomputeRegion();
this._draw();
});
secCtx.content.appendChild(this.el.ctxSlider.el);
sidebar.appendChild(secCtx.el);
// Crop size (mirrors the node knobs; live preview)
const secCrop = createPanel("Crop size");
this._sizeModeGrid = createPillGrid(
[{ label: "Keep shape", value: "keep" }, { label: "Force square", value: "force" }, { label: "Free", value: "free" }],
3, (v) => { this.params.size_mode = v; this._recomputeRegion(); this._draw(); },
{ activeValue: this.params.size_mode || "keep" },
);
this.el.targetSlider = createSliderRow("Target", 64, 8192, this.params.target ?? 1024, () => {
const m = this.params.multiple || 8;
const raw = parseInt(this.el.targetSlider.numInput.value) || 1024;
const snapped = Math.max(m, Math.round(raw / m) * m); // land on the multiple
this.params.target = snapped;
if (snapped !== raw) this.el.targetSlider.setValue(snapped);
this._recomputeRegion(); this._draw();
});
this._multipleGrid = createPillGrid(
[{ label: "8", value: 8 }, { label: "16", value: 16 }, { label: "32", value: 32 }, { label: "64", value: 64 }],
4, (v) => {
this.params.multiple = v;
const snapped = Math.max(v, Math.round((this.params.target || 1024) / v) * v);
this.params.target = snapped;
this.el.targetSlider?.setValue(snapped); // re-snap target to the new multiple
this._recomputeRegion(); this._draw();
},
{ activeValue: this.params.multiple || 8 },
);
secCrop.content.append(this._sizeModeGrid.el, this.el.targetSlider.el, this._multipleGrid.el);
sidebar.appendChild(secCrop.el);
// Reset all settings to default
const resetAll = createButton("Reset all to default", {
variant: "standard", iconSrc: UI + "reset.svg", onClick: () => this._resetAll(),
});
resetAll.style.marginTop = "10px";
sidebar.appendChild(resetAll);
// Load Image
const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.accept = "image/*"; fileInput.style.display = "none";
fileInput.addEventListener("change", (e) => {
const f = e.target.files?.[0];
if (f) {
const reader = new FileReader();
reader.onload = (ev) => { this._loadImageFromDataURL(ev.target.result); this.onLoadImage?.(); };
reader.readAsDataURL(f);
}
fileInput.value = "";
});
const loadBtn = createButton("Load Image", { variant: "full", iconSrc: UI + "image.svg", onClick: () => fileInput.click() });
loadBtn.style.marginTop = "10px";
sidebar.append(loadBtn, fileInput);
}
_buildRightSidebar(sidebar, footer) {
const sec = createPanel("Output crop");
this._infoBlock = createInfo("Paint a mask to begin");
sidebar.insertBefore(sec.el, footer);
sec.content.appendChild(this._infoBlock.el);
}
_setTool(v) { this.tool = v; }
_resetAll() {
this._setTool("add"); // back to Brush (was left on Erase after a reset)
this.brushSize = DEFAULT_BRUSH_SIZE;
this.maskOpacity = 0.5;
this.params.blend = 16;
this.params.mask_grow = 4;
this.params.mask_blur = 4;
this.params.blend_mode = "mask";
this.params.context_px = 24;
this.params.size_mode = "keep";
this.params.target = 1024;
this.params.multiple = 8;
this.el.sizeSlider?.setValue(this.brushSize);
this.el.opacitySlider?.setValue(50);
this.el.blendSlider?.setValue(16);
this.el.growSlider?.setValue(4);
this.el.ctxSlider?.setValue(24);
this.el.targetSlider?.setValue(1024);
this._toolGrid?.setActive?.("add");
this._blendModeGrid?.setActive?.("mask");
this._sizeModeGrid?.setActive?.("keep");
this._multipleGrid?.setActive?.(8);
this._recomputeRegion();
this._draw();
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
}
_setPreviewColor(name, hex) {
this.previewColor = hex;
this._cropBoxColor = (hex === INPAINT_PREVIEW_COLORS.Orange) ? "#ffffff" : null;
for (const s of this._colorSwatches || [])
s.dot.style.borderColor = (s.hex === hex) ? "#fff" : "transparent";
this.onPreviewColor?.(name);
this._draw();
}
_toggleMaskVisible() {
this.maskVisible = !this.maskVisible;
this._visBtn.classList.toggle("active", !this.maskVisible);
this._draw();
}
_setStatus(msg) { this.layout?.setStatus(msg); }
}
+184
View File
@@ -0,0 +1,184 @@
// ============================================================
// Inpaint Crop Pixaroma — geometry (JS mirror of nodes/_inpaint_helpers.py)
// Keep computeRegion() 1:1 with the Python compute_region so the editor's live
// crop-rectangle preview matches what the node actually produces. Source of
// truth: docs/inpaint-crop-math.md. The ONLY accepted drift is sub-pixel rect
// placement (Python uses banker's rounding, JS uses round-half-up) - a +/-1px
// preview offset, never a different output size.
// ============================================================
export const GEO_DEFAULTS = {
size_mode: "keep", target: 1024, target_w: 1024, target_h: 1024,
multiple: 8, context_px: 24, context_pct: 10, mask_grow: 4, mask_blur: 4,
blend: 16, min_size: 256, max_size: 2048, allow_upscale: true,
};
// round half to EVEN, matching Python's built-in round() (banker's rounding) that
// _round_mult uses. Math.round is half-UP, so without this the editor crop-box size
// could read one `multiple` step off from the real output at exact .5 boundaries
// (e.g. 1056/64 = 16.5 -> Python 16 -> 1024, half-up 17 -> 1088). Only the exact-.5
// case differs; every other value rounds identically.
const roundHalfEven = (x) => {
const f = Math.floor(x), d = x - f;
if (d < 0.5) return f;
if (d > 0.5) return f + 1;
return f % 2 === 0 ? f : f + 1;
};
const roundMult = (v, m) => {
m = Math.max(1, Math.round(m));
return Math.max(m, roundHalfEven(v / m) * m);
};
const clampi = (v, lo, hi) => Math.max(lo, Math.min(hi, Math.round(v)));
export function computeRegion(bbox, W, H, params) {
const p = { ...GEO_DEFAULTS, ...(params || {}) };
W = Math.round(W); H = Math.round(H);
let x0, y0, x1, y1;
if (!bbox) { x0 = 0; y0 = 0; x1 = W; y1 = H; } else { [x0, y0, x1, y1] = bbox; }
const bw = Math.max(1, x1 - x0), bh = Math.max(1, y1 - y0);
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
// context expand uses max(context_px, blend) so the seam feather has room to reach
// 0 inside the crop (Option B - mirror of compute_region; a big softness grows it).
const ctx = Math.max(p.context_px, p.blend || 0);
let rw = bw + 2 * ctx + bw * p.context_pct / 100;
let rh = bh + 2 * ctx + bh * p.context_pct / 100;
const mult = p.multiple, mode = p.size_mode;
// force mode: grow the WANTED region to the target aspect first (output is the
// fixed target size, set below).
let tw = 0, th = 0;
if (mode === "force") {
tw = Math.max(mult, roundMult(p.target_w, mult));
th = Math.max(mult, roundMult(p.target_h, mult));
const ta = tw / th;
if (rw / rh < ta) rw = rh * ta; else rh = rw / ta;
}
// place + clamp the SOURCE region inside the image
let rw_i = Math.max(1, Math.min(Math.round(rw), W));
let rh_i = Math.max(1, Math.min(Math.round(rh), H));
if (mode === "force") {
// keep source aspect == target aspect after the image-bound clamp (mirror).
const aspect = tw / th;
if (rw_i > rh_i * aspect) rw_i = Math.max(1, Math.round(rh_i * aspect));
else rh_i = Math.max(1, Math.round(rw_i / aspect));
}
const rx = clampi(cx - rw_i / 2, 0, W - rw_i);
const ry = clampi(cy - rh_i / 2, 0, H - rh_i);
// output size from the CLAMPED source rect (rw_i, rh_i), never the un-clamped
// region - so the crop is never stretched when the image edge clipped it.
let out_w, out_h;
if (mode === "force") {
out_w = tw; out_h = th;
} else if (mode === "free") {
// keep the cropped rect's own size; when the long side exceeds max_size scale
// BOTH axes by one factor so the aspect is preserved (mirror of compute_region).
let ow = rw_i, oh = rh_i;
const big = Math.max(ow, oh);
if (big > p.max_size) { const k = p.max_size / big; ow *= k; oh *= k; }
out_w = roundMult(ow, mult);
out_h = roundMult(oh, mult);
} else {
const long = Math.max(rw_i, rh_i);
let s = long > 0 ? p.target / long : 1;
if (!p.allow_upscale) s = Math.min(s, 1);
let ow = rw_i * s, oh = rh_i * s;
const small = Math.min(ow, oh);
if (small < p.min_size) { const k = p.min_size / small; ow *= k; oh *= k; }
const big = Math.max(ow, oh);
if (big > p.max_size) { const k = p.max_size / big; ow *= k; oh *= k; }
out_w = roundMult(ow, mult); out_h = roundMult(oh, mult);
}
out_w = Math.max(mult, Math.round(out_w));
out_h = Math.max(mult, Math.round(out_h));
return { rx, ry, rw: rw_i, rh: rh_i, out_w, out_h };
}
// Bounding box of painted pixels from a mask canvas's alpha channel.
// Returns [x0,y0,x1,y1] (x1/y1 exclusive) or null when nothing is painted.
export function maskBBoxFromImageData(data, w, h, thresh = 8) {
let x0 = w, y0 = h, x1 = 0, y1 = 0, found = false;
for (let y = 0; y < h; y++) {
let row = y * w * 4;
for (let x = 0; x < w; x++) {
if (data[row + x * 4 + 3] > thresh) {
found = true;
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
return found ? [x0, y0, x1 + 1, y1 + 1] : null;
}
// Expand a bbox by `px` each side (clamped) - mirrors the effect of the Python
// mask dilation (grow) on the bounding box.
export function growBBox(bbox, px, W, H) {
if (!bbox) return null;
return [
Math.max(0, bbox[0] - px), Math.max(0, bbox[1] - px),
Math.min(W, bbox[2] + px), Math.min(H, bbox[3] + px),
];
}
// Outward seam-feather alpha - the canvas mirror of Python `_blur_alpha`'s scipy
// path (nodes/_inpaint_helpers.py). Reads the ALPHA channel of an RGBA `data`
// buffer (w x h) as the mask, returns a Float32Array alpha: 1.0 INSIDE the mask
// (and at its edge), ramping to 0 over `k` px OUTSIDE via the same smoothstep the
// node uses. Pure + DOM-free for unit testing.
//
// The node uses an exact Euclidean signed-distance transform; here we use a fast
// 2-pass (1, sqrt2) chamfer transform of the OUTSIDE distance (inside is just 1).
// The chamfer approximates Euclidean within a few %, which is invisible on a soft
// seam preview. This is the "preview == result" parity: a moderate softness no
// longer looks tighter in the editor than the real stitched seam.
export function seamAlphaFromAlpha(data, w, h, k) {
const n = w * h;
const INF = 1e9;
const d = new Float32Array(n);
for (let i = 0, p = 3; i < n; i++, p += 4) d[i] = data[p] > 127 ? 0 : INF;
const d1 = 1, d2 = Math.SQRT2;
// forward pass: top-left -> bottom-right (already-visited neighbours)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
let v = d[i];
if (v === 0) continue;
if (x > 0) v = Math.min(v, d[i - 1] + d1);
if (y > 0) {
v = Math.min(v, d[i - w] + d1);
if (x > 0) v = Math.min(v, d[i - w - 1] + d2);
if (x < w - 1) v = Math.min(v, d[i - w + 1] + d2);
}
d[i] = v;
}
}
// backward pass: bottom-right -> top-left
for (let y = h - 1; y >= 0; y--) {
for (let x = w - 1; x >= 0; x--) {
const i = y * w + x;
let v = d[i];
if (v === 0) continue;
if (x < w - 1) v = Math.min(v, d[i + 1] + d1);
if (y < h - 1) {
v = Math.min(v, d[i + w] + d1);
if (x < w - 1) v = Math.min(v, d[i + w + 1] + d2);
if (x > 0) v = Math.min(v, d[i + w - 1] + d2);
}
d[i] = v;
}
}
const out = new Float32Array(n);
const kk = Math.max(1e-3, k);
for (let i = 0; i < n; i++) {
if (d[i] === 0) { out[i] = 1; continue; } // inside the mask + its edge
const t = Math.max(0, Math.min(1, 1 - d[i] / kk));
out[i] = t * t * (3 - 2 * t); // smoothstep, same as the node
}
return out;
}
+374
View File
@@ -0,0 +1,374 @@
// ============================================================
// Inpaint Crop Pixaroma — node entry (open button, preview, source, persist)
// ============================================================
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { InpaintCropEditor, INPAINT_PREVIEW_COLORS } from "./core.mjs";
import "./paint.mjs"; // mixin: brush / mask / keys
import "./render.mjs"; // mixin: canvas render + save
import {
createNodePreview, showNodePreview, restoreNodePreview, activateNodePreview,
downloadDataURL, applyAdaptiveCanvasOnly,
} from "../shared/index.mjs";
const SIZE_MODE_MAP = {
"keep shape (long side)": "keep",
"force size (square)": "force",
"free (multiple only)": "free",
};
// node-widget label <-> internal blend_mode key (the editor pill uses the internal key)
const BLEND_MODE_MAP = { "mask": "mask", "whole crop": "whole_crop" };
function readParams(node) {
const g = (n) => node.widgets?.find((w) => w.name === n)?.value;
return {
size_mode: SIZE_MODE_MAP[g("size_mode")] || "keep",
target: parseInt(g("target")) || 1024,
multiple: parseInt(g("multiple")) || 8,
context_px: g("context_px") != null ? parseInt(g("context_px")) : 24,
mask_grow: g("mask_grow") != null ? parseInt(g("mask_grow")) : 4,
mask_blur: g("mask_blur") != null ? parseInt(g("mask_blur")) : 4,
blend: g("softness") != null ? parseInt(g("softness")) : 16,
// blend_mode is now a node widget too (mirrored by the editor pill).
blend_mode: BLEND_MODE_MAP[g("blend_mode")] || "mask",
};
}
// friendly-label <- internal size-mode key, for writing the editor's choice back
const SIZE_MODE_LABEL = Object.fromEntries(
Object.entries(SIZE_MODE_MAP).map(([k, v]) => [v, k]));
const BLEND_MODE_LABEL = Object.fromEntries(
Object.entries(BLEND_MODE_MAP).map(([k, v]) => [v, k]));
function setNodeWidget(node, name, value) {
const w = node.widgets?.find((x) => x.name === name);
if (w && w.value !== value) { w.value = value; w.callback?.(value); }
}
// write the editor's mirrored geometry knobs back to the native node widgets
function writeBackWidgets(node, extra) {
if (!extra) return;
if (extra.context_px != null) setNodeWidget(node, "context_px", extra.context_px);
if (extra.mask_grow != null) setNodeWidget(node, "mask_grow", extra.mask_grow);
if (extra.mask_blur != null) setNodeWidget(node, "mask_blur", extra.mask_blur);
if (extra.softness != null) setNodeWidget(node, "softness", extra.softness);
if (extra.target != null) setNodeWidget(node, "target", extra.target);
if (extra.multiple != null) setNodeWidget(node, "multiple", extra.multiple);
if (extra.size_mode != null)
setNodeWidget(node, "size_mode", SIZE_MODE_LABEL[extra.size_mode] || "keep shape (long side)");
if (extra.blend_mode != null)
setNodeWidget(node, "blend_mode", BLEND_MODE_LABEL[extra.blend_mode] || "mask");
}
function buildSourceURL(part, bust) {
if (!part || !part.filename) return null;
const url = `/view?filename=${encodeURIComponent(part.filename)}` +
`&subfolder=${encodeURIComponent(part.subfolder || "")}` +
`&type=${encodeURIComponent(part.type || "temp")}`;
return bust ? `${url}&t=${Date.now()}` : url;
}
// A LoadImage combo value can be "name.png", "sub/name.png", or carry an
// annotation like "clipspace/clipboard.png [input]" (this is what PASTING into a
// LoadImage produces - a subfolder + suffix). /view wants filename + subfolder as
// SEPARATE params and no annotation, so split it; otherwise the editor 404s with
// "Failed to load the source image" (only on paste, since a plain pick has no
// subfolder/suffix). Mirrors the image-picker split used elsewhere.
function parseAnnotatedImageValue(value) {
let v = String(value || "");
let type = "input";
const m = v.match(/\s*\[(input|output|temp)\]\s*$/i);
if (m) { type = m[1].toLowerCase(); v = v.slice(0, m.index); }
v = v.replace(/\\/g, "/").trim();
const i = v.lastIndexOf("/");
return {
filename: i >= 0 ? v.slice(i + 1) : v,
subfolder: i >= 0 ? v.slice(0, i) : "",
type,
};
}
function getUpstreamImageURL(node) {
// Prefer the LIVE wired source so a just-changed Load Image (or any live
// preview) is what the editor opens. The cached executed-source URL below is
// only a fallback for generative upstreams whose pixels exist solely as the
// temp PNG the Python node saved on the last run. (Without this order,
// swapping the Load Image file showed the PREVIOUS run's image until re-run.)
const input = (node.inputs || []).find((i) => i.name === "image");
const graph = node.graph;
if (input && input.link != null && graph) {
let link = graph.links?.[input.link];
if (!link && typeof graph.links?.get === "function") link = graph.links.get(input.link);
const src = link && graph.getNodeById(link.origin_id);
if (src) {
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
const w = (src.widgets || []).find((x) => x.name === "image");
if (w && w.value) return buildSourceURL(parseAnnotatedImageValue(w.value), true);
}
if (src.imgs && src.imgs.length > 0) {
const img = src.imgs[link.origin_slot] || src.imgs[0];
if (typeof img === "string") return img;
if (img && img.src) return img.src;
}
}
}
// fallback: source PNG from the last Python execute (generative upstreams, or
// before a live preview exists), and the paste / drag-drop / restored case.
if (node._pixInpaintSourceURL) return node._pixInpaintSourceURL;
return null;
}
// ── clipboard paste → selected Inpaint Crop node ──
let _pasteInstalled = false;
function installPasteHandler() {
if (_pasteInstalled) return;
_pasteInstalled = true;
window.addEventListener("paste", async (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const node = findActiveNode();
if (!node) return;
// editor open -> let the editor's own paste handler load it into the canvas
if (node._pixInpaintEditor?.el?.overlay?.isConnected) return;
const items = e.clipboardData?.items || [];
const it = Array.from(items).find((x) => x.type?.startsWith("image/"));
if (!it) return;
e.preventDefault(); e.stopImmediatePropagation();
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
const idsBefore = new Set((app.graph?._nodes || []).map((n) => n.id));
const blob = it.getAsFile();
if (!blob) return;
const reader = new FileReader();
reader.onload = (ev) => node._pixInpaintPaste(ev.target.result);
reader.readAsDataURL(blob);
setTimeout(() => {
for (const n of app.graph?._nodes || []) {
if (idsBefore.has(n.id)) continue;
if (n.comfyClass !== "LoadImage" && n.type !== "LoadImage") continue;
const w = (n.widgets || []).find((x) => x.name === "image");
if (typeof w?.value === "string" && w.value.startsWith("pasted/")) { try { app.graph.remove(n); } catch {} }
}
}, 50);
}, true);
}
function findActiveNode() {
const c = app.canvas;
if (!c) return null;
const ok = (n) => n && n.comfyClass === "PixaromaInpaintCrop" && typeof n._pixInpaintPaste === "function";
const sel = c.selected_nodes;
if (sel) {
let iter = Array.isArray(sel) ? sel : (typeof sel.values === "function" ? Array.from(sel.values()) : Object.values(sel));
const hit = iter?.find(ok);
if (hit) return hit;
}
if (ok(c.current_node)) return c.current_node;
if (ok(c.node_over)) return c.node_over;
for (const n of app.graph?._nodes || []) if (ok(n) && (n.is_selected || n.flags?.is_selected)) return n;
return null;
}
app.registerExtension({
name: "Pixaroma.InpaintCrop",
settings: [{
id: "Pixaroma.Inpaint.PreviewColor",
name: "Mask preview color",
type: "combo",
defaultValue: "Red",
options: ["Red", "Green", "Blue", "Yellow", "Orange"],
tooltip: "Tint color for the mask + seam preview in the Inpaint editor (display only).",
category: ["👑 Pixaroma", "Inpaint"],
}],
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaInpaintCrop") return;
const origExec = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
origExec?.apply(this, arguments);
this.imgs = null;
};
const origCfg = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const ret = origCfg?.apply(this, arguments);
this.imgs = null;
if (!this._pixInpaintSourceURL && this.properties?.pixInpaintSource) {
this._pixInpaintSourceURL = buildSourceURL(this.properties.pixInpaintSource, true);
}
if (this._pixInpaintRefresh) {
queueMicrotask(() => this._pixInpaintRefresh());
setTimeout(() => this._pixInpaintRefresh?.(), 250);
}
return ret;
};
},
async nodeCreated(node) {
if (node.comfyClass !== "PixaromaInpaintCrop") return;
node.imgs = null;
// Fresh-drop default size only; never on the load path (configure restores
// the saved size, so writing it during load would dirty the workflow). Mutate
// the array elements (convention #9), don't replace the array.
if (!isGraphLoading() && node.size) { node.size[0] = 330; node.size[1] = 500; }
if (!(node.inputs || []).some((i) => i.name === "image")) node.addInput("image", "IMAGE");
if (!(node.inputs || []).some((i) => i.name === "mask")) node.addInput("mask", "MASK");
const parts = createNodePreview(
"Inpaint Crop", "Pixaroma",
"Wire an IMAGE and Run,\nor click 'Open mask editor' to load + paint",
);
let stateJson = "{}";
let widget;
const refreshSourcePreview = () => {
const url = getUpstreamImageURL(node);
if (url) showNodePreview(parts, url, null, node);
};
// ── Open mask editor button ──
node.addWidget("button", "Open mask editor", null, () => {
if (node._pixInpaintEditor?.el?.overlay?.isConnected) return;
refreshSourcePreview(); // sync the node thumbnail to the current upstream image
const editor = new InpaintCropEditor();
node._pixInpaintEditor = editor;
// brush size / opacity persist across opens on this node
const captureBrush = () => {
node._pixInpaintBrush = { brushSize: editor.brushSize, maskOpacity: editor.maskOpacity };
};
// preview tint (display only) - seed from the setting, persist on change
const colName = app.ui.settings?.getSettingValue?.("Pixaroma.Inpaint.PreviewColor") || "Red";
const colHex = INPAINT_PREVIEW_COLORS[colName] || INPAINT_PREVIEW_COLORS.Red;
editor.previewColor = colHex;
editor._cropBoxColor = (colHex === INPAINT_PREVIEW_COLORS.Orange) ? "#ffffff" : null;
editor.onPreviewColor = (name) => {
try { app.ui.settings?.setSettingValueAsync?.("Pixaroma.Inpaint.PreviewColor", name); } catch {}
};
editor.onSave = (jsonStr, extra, preview) => {
stateJson = jsonStr;
if (widget) widget.value = { state_json: jsonStr };
writeBackWidgets(node, extra);
if (preview) showNodePreview(parts, preview, null, node);
if (app.graph) { app.graph.setDirtyCanvas(true, true); app.graph.change?.(); }
captureBrush();
};
editor.onSaveToDisk = (d) => downloadDataURL(d, "pixaroma_inpaint_crop");
editor.onLoadImage = () => {
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
};
editor.onClose = () => { captureBrush(); node._pixInpaintEditor = null; node.setDirtyCanvas(true, true); };
editor.open(stateJson, getUpstreamImageURL(node),
readParams(node), node._pixInpaintBrush);
});
// ── mini-preview DOM widget (also carries the hidden state) ──
widget = node.addDOMWidget("InpaintCropWidget", "custom", parts.container, {
getValue: () => ({ state_json: stateJson }),
setValue: (v) => {
if (!v || typeof v !== "object") return;
stateJson = v.state_json || "{}";
const imgInput = (node.inputs || []).find((i) => i.name === "image");
if (imgInput && imgInput.link != null) queueMicrotask(refreshSourcePreview);
else restoreNodePreview(parts, stateJson, node); // rebuild from src_path when present
},
getMinHeight: () => 200,
margin: 5,
});
applyAdaptiveCanvasOnly(widget);
activateNodePreview(parts, node);
// ── paste / drag-drop a source directly onto the node ──
installPasteHandler();
node._pixInpaintPaste = async (dataURL) => {
try {
const r = await api.fetchApi("/pixaroma/api/inpaint/upload_src", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: "inpaint_paste_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), image: dataURL }),
});
const d = await r.json();
const srcPath = d.path || "";
let meta = {};
try { meta = JSON.parse(stateJson) || {}; } catch {}
meta.src_path = srcPath;
meta.mask_path = ""; // a new source clears the old painted mask
stateJson = JSON.stringify(meta);
if (widget) widget.value = { state_json: stateJson };
if (srcPath) {
const part = { filename: srcPath.split(/[\\/]/).pop(), subfolder: "pixaroma", type: "input" };
node._pixInpaintSourceURL = buildSourceURL(part, true);
if (!node.properties) node.properties = {};
node.properties.pixInpaintSource = part;
}
showNodePreview(parts, dataURL, null, node);
if (app.graph) app.graph.setDirtyCanvas(true, true);
} catch (err) { console.warn("[InpaintCrop] paste failed:", err); }
};
const dropTarget = parts?.container;
if (dropTarget) {
dropTarget.addEventListener("dragover", (e) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); e.stopPropagation(); } });
dropTarget.addEventListener("drop", (e) => {
e.preventDefault(); e.stopPropagation();
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type?.startsWith("image/")) return;
const idx = (node.inputs || []).findIndex((i) => i.name === "image");
if (idx >= 0 && node.inputs[idx].link != null) { try { node.disconnectInput(idx); } catch {} }
const reader = new FileReader();
reader.onload = (ev) => node._pixInpaintPaste(ev.target.result);
reader.readAsDataURL(file);
});
}
// ── source URL caching from Python execute + refresh hooks ──
node._pixInpaintRefresh = () => {
if (getUpstreamImageURL(node)) refreshSourcePreview();
// pass the real state (not "{}") so restoreNodePreview can rebuild from src_path
// - e.g. a source loaded via the editor's Load Image, where the wire is gone.
else restoreNodePreview(parts, stateJson, node);
};
const onExec = (event) => {
const detail = event?.detail;
if (!detail?.output) return;
const matched = app.graph.getNodeById(detail.node) || app.graph.getNodeById(parseInt(detail.node, 10));
if (matched !== node) return;
const frames = detail.output.pixaroma_inpaint_source;
if (!frames?.length) return;
const f = frames[0];
const part = { filename: f.filename, subfolder: f.subfolder || "", type: f.type || "temp" };
node._pixInpaintSourceURL = buildSourceURL(part, true);
if (!node.properties) node.properties = {};
node.properties.pixInpaintSource = part;
refreshSourcePreview();
};
api.addEventListener("executed", onExec);
// wrap (don't clobber) any existing handler from the prototype / another ext;
// forward all args, then run our image-input source-preview logic.
const origConnChange = node.onConnectionsChange;
node.onConnectionsChange = function (type, slotIndex, connected) {
const r = origConnChange?.apply(this, arguments);
if (type === LiteGraph.INPUT && node.inputs?.[slotIndex]?.name === "image" && !isGraphLoading()) {
node._pixInpaintSourceURL = null;
if (node.properties) delete node.properties.pixInpaintSource;
if (connected) refreshSourcePreview();
else restoreNodePreview(parts, "{}", node);
}
return r;
};
const origRemoved = node.onRemoved;
node.onRemoved = () => {
try { if (node._pixInpaintEditor?.el?.overlay?.isConnected) node._pixInpaintEditor._close(); } catch (e) {}
try { parts?.resizeObserver?.disconnect(); } catch (e) {}
origRemoved?.call(node);
try { api.removeEventListener("executed", onExec); } catch {}
};
},
});
+435
View File
@@ -0,0 +1,435 @@
// ============================================================
// Inpaint Crop Pixaroma — mask painting (brush, erase, cursor, undo, keys)
// Mask canvas = white RGB + per-pixel alpha (strength) on a transparent bg, so
// the red overlay tints via destination-in and the export bakes soft edges by
// compositing over black. Self-contained - no dependency on js/paint/.
// ============================================================
import { InpaintCropEditor, BRAND } from "./core.mjs";
const proto = InpaintCropEditor.prototype;
const MAX_UNDO = 15;
proto._ensureMaskCanvas = function () {
if (this._mask && this._mask.width === this.imgW && this._mask.height === this.imgH) return;
const m = document.createElement("canvas");
m.width = this.imgW; m.height = this.imgH;
this._mask = m;
this._mctx = m.getContext("2d");
// per-stroke buffer: stamps accumulate here, then bake onto the mask with a
// blur-on-bake soft edge (so overlapping stamps can't stack into a hard rim).
const sc = document.createElement("canvas");
sc.width = this.imgW; sc.height = this.imgH;
this._stroke = sc;
this._sctx = sc.getContext("2d");
this._strokeHasContent = false;
this._undo = []; this._redo = [];
this.layout?.setUndoState({ canUndo: false, canRedo: false });
};
// ── pointer ──────────────────────────────────────────────────────────────
proto._displayPos = function (e) {
const r = this.el.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
proto._bindMouse = function (cvs) {
let winMove = null, winUp = null;
const detach = () => {
if (winMove) window.removeEventListener("mousemove", winMove), (winMove = null);
if (winUp) window.removeEventListener("mouseup", winUp), (winUp = null);
};
// expose so a close mid-stroke (editor torn down before mouseup) detaches the
// window listeners instead of leaking them for the page lifetime.
this._detachStroke = detach;
let panMove = null, panUp = null;
const detachPan = () => {
if (panMove) window.removeEventListener("mousemove", panMove), (panMove = null);
if (panUp) window.removeEventListener("mouseup", panUp), (panUp = null);
};
this._detachPan = detachPan;
cvs.addEventListener("mousedown", (e) => {
// PAN the zoomed view: middle button, or Space held + left button.
if (e.button === 1 || (e.button === 0 && this._spaceHeld)) {
e.preventDefault();
const x0 = e.clientX, y0 = e.clientY, px0 = this._panX, py0 = this._panY;
this._panning = true;
cvs.style.cursor = "grabbing";
if (this._dispW) this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); // hide the brush ring while panning
this._lastCursorPos = null;
detachPan();
panMove = (ev) => {
this._panX = px0 + (ev.clientX - x0);
this._panY = py0 + (ev.clientY - y0);
this._clampPan();
this._requestRedraw();
};
panUp = () => { this._panning = false; detachPan(); cvs.style.cursor = this._spaceHeld ? "grab" : "none"; };
window.addEventListener("mousemove", panMove);
window.addEventListener("mouseup", panUp);
return;
}
if (!this.img || e.button !== 0) return;
e.preventDefault();
this._beginStroke(e);
detach();
winMove = (ev) => this._strokeMove(ev);
winUp = () => { this._endStroke(); detach(); };
window.addEventListener("mousemove", winMove);
window.addEventListener("mouseup", winUp);
});
cvs.addEventListener("mousemove", (e) => { if (!this._painting && !this._panning && !this._spaceHeld) this._drawCursor(this._displayPos(e)); });
cvs.addEventListener("mouseleave", () => { if (!this._painting && this._dispW) { this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); this._lastCursorPos = null; } });
// wheel = zoom toward the cursor (brush size moved to [ / ] + the Size slider)
cvs.addEventListener("wheel", (e) => {
e.preventDefault();
const p = this._displayPos(e);
this._applyZoom(e.deltaY < 0 ? 1.15 : 1 / 1.15, p.x, p.y);
}, { passive: false });
};
proto._effectiveTool = function () {
// hold X to temporarily flip to erase
return this._xHeld ? (this.tool === "erase" ? "add" : "erase") : this.tool;
};
proto._beginStroke = function (e) {
this._pushUndo();
this._painting = true;
this._lastPt = null;
// lock the add/erase choice for the whole stroke (X can be released mid-drag)
this._strokeTool = this._effectiveTool();
this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
this._strokeMove(e);
};
proto._strokeMove = function (e) {
const p = this._displayPos(e);
const s = this._scale || 1;
const sx = (p.x - this._panX) / s, sy = (p.y - this._panY) / s; // pan/zoom -> source px
if (this._lastPt) this._stampLine(this._lastPt.x, this._lastPt.y, sx, sy);
else this._stampDab(sx, sy);
this._lastPt = { x: sx, y: sy };
this._requestRedraw(); // coalesced (one redraw per frame) - was a sync _draw() per move = the lag
this._drawCursor(p); // cursor stays immediate (cheap, separate canvas)
};
// soft-edge feather width (source px) baked onto the stroke. 0 = crisp.
proto._bakeBlurPx = function () {
const rSrc = (this.brushSize / 2) / (this._scale || 1);
return Math.round(this.softness * rSrc);
};
// composite the (blurred) stroke buffer onto a target ctx using the locked tool
proto._compositeStroke = function (ctx) {
const blur = this._bakeBlurPx();
ctx.save();
if (blur > 0) ctx.filter = `blur(${blur}px)`;
ctx.globalCompositeOperation = this._strokeTool === "erase" ? "destination-out" : "source-over";
ctx.drawImage(this._stroke, 0, 0);
ctx.restore();
};
proto._endStroke = function () {
if (this._drawRaf) { cancelAnimationFrame(this._drawRaf); this._drawRaf = null; }
this._painting = false;
this._lastPt = null;
if (this._strokeHasContent) {
this._compositeStroke(this._mctx); // bake into the mask
this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
}
this._rescanBBox();
this._recomputeRegion();
this._draw();
};
// Drop the in-progress stroke buffer so it is NOT re-baked on the eventual
// mouseup. Used by undo/redo mid-drag (otherwise the undone stroke would
// re-composite when the user releases the mouse).
proto._abandonStroke = function () {
if (this._sctx) this._sctx.clearRect(0, 0, this.imgW, this.imgH);
this._strokeHasContent = false;
this._lastPt = null;
};
// the mask as it should DISPLAY right now (mask + the live stroke during a drag)
proto._effectiveMaskCanvas = function () {
if (!this._painting || !this._strokeHasContent) return this._mask;
if (!this._effMask) this._effMask = document.createElement("canvas");
const c = this._effMask;
if (c.width !== this.imgW || c.height !== this.imgH) { c.width = this.imgW; c.height = this.imgH; }
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, this.imgW, this.imgH);
ctx.drawImage(this._mask, 0, 0);
this._compositeStroke(ctx);
return c;
};
// ── brush stamps: a crisp anti-aliased disc on the STROKE buffer. The soft edge
// is the blur-on-bake (_bakeBlurPx), so overlapping stamps never stack hard.
proto._stampDab = function (sx, sy) {
const ctx = this._sctx;
const r = Math.max(0.5, (this.brushSize / 2) / (this._scale || 1));
const g = ctx.createRadialGradient(sx, sy, Math.max(0, r - 1.2), sx, sy, r);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(1, "rgba(255,255,255,0)");
// "lighten" = max-blend, so overlapping stamps in one stroke can't stack alpha
// (no slow-stroke darkening); the soft edge is the blur-on-bake at stroke end.
ctx.globalCompositeOperation = "lighten";
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = "source-over";
this._strokeHasContent = true;
};
proto._stampLine = function (x0, y0, x1, y1) {
const r = Math.max(0.5, (this.brushSize / 2) / (this._scale || 1));
const dx = x1 - x0, dy = y1 - y0;
const dist = Math.hypot(dx, dy);
const step = Math.max(1, r * 0.12); // tight enough to avoid gaps on fast, zoomed-out strokes
const n = Math.ceil(dist / step);
for (let i = 0; i <= n; i++) {
const t = n === 0 ? 0 : i / n;
this._stampDab(x0 + dx * t, y0 + dy * t);
}
};
// ── cursor ring ────────────────────────────────────────────────────────────
proto._drawCursor = function (p) {
const ctx = this.el.curCtx;
if (!ctx || !this._dispW) return; // canvas not fitted yet (no image loaded)
this._lastCursorPos = p;
ctx.clearRect(0, 0, this._dispW, this._dispH);
const r = this.brushSize / 2;
const erase = this._effectiveTool() === "erase";
ctx.lineWidth = 1.5;
ctx.strokeStyle = erase ? "#ffffff" : BRAND;
if (erase) ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = erase ? "#ffffff" : BRAND;
ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2); ctx.fill();
};
// ── mask ops ────────────────────────────────────────────────────────────────
proto._clearMask = function () {
if (!this._mask) return;
this._pushUndo();
this._mctx.clearRect(0, 0, this.imgW, this.imgH);
this._endStroke();
};
proto._invertMask = function () {
if (!this._mask) return;
this._pushUndo();
const id = this._mctx.getImageData(0, 0, this.imgW, this.imgH);
const d = id.data;
for (let i = 0; i < d.length; i += 4) { d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = 255 - d[i + 3]; }
this._mctx.putImageData(id, 0, 0);
this._endStroke();
};
proto._loadMaskFromURL = function (url) {
const token = this._loadToken; // tie to the image-load generation that requested it
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
// bail if a newer open/load superseded this, OR the user already started working
// (painting / has undo history / a painted bbox) - don't clobber fresh strokes.
if (token !== this._loadToken) return;
if (this._painting || this._undo?.length || this._bbox) {
this._setStatus("Kept your new strokes - the saved mask wasn't reloaded.");
return;
}
this._ensureMaskCanvas();
const tmp = document.createElement("canvas");
tmp.width = this.imgW; tmp.height = this.imgH;
const tctx = tmp.getContext("2d");
tctx.drawImage(img, 0, 0, this.imgW, this.imgH);
const id = tctx.getImageData(0, 0, this.imgW, this.imgH);
const d = id.data;
// grayscale (white = masked) -> white RGB + alpha = luminance
for (let i = 0; i < d.length; i += 4) {
const a = d[i]; // grayscale, r==g==b
d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = a;
}
this._mctx.putImageData(id, 0, 0);
this._undo = []; this._redo = [];
this.layout?.setUndoState({ canUndo: false, canRedo: false });
this._rescanBBox();
this._recomputeRegion();
this._draw();
};
img.onerror = () => {};
img.src = url;
};
// composite the mask over black -> grayscale dataURL (white = masked)
proto._exportMaskDataURL = function () {
const out = document.createElement("canvas");
out.width = this.imgW; out.height = this.imgH;
const o = out.getContext("2d");
o.fillStyle = "#000"; o.fillRect(0, 0, this.imgW, this.imgH);
// include any in-progress stroke so Ctrl+S mid-drag saves what's on screen
const m = (this._effectiveMaskCanvas && this._effectiveMaskCanvas()) || this._mask;
if (m) o.drawImage(m, 0, 0);
return out.toDataURL("image/png");
};
// ── undo / redo ──────────────────────────────────────────────────────────
proto._pushUndo = function () {
if (!this._mask) return;
try { this._undo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH)); } catch (e) { return; }
if (this._undo.length > MAX_UNDO) this._undo.shift();
this._redo = [];
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: false });
};
proto._doUndo = function () {
if (!this._mask || !this._undo.length) return;
this._redo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH));
this._mctx.putImageData(this._undo.pop(), 0, 0);
this._abandonStroke();
this._rescanBBox(); this._recomputeRegion(); this._draw();
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: this._redo.length > 0 });
};
proto._doRedo = function () {
if (!this._mask || !this._redo.length) return;
this._undo.push(this._mctx.getImageData(0, 0, this.imgW, this.imgH));
this._mctx.putImageData(this._redo.pop(), 0, 0);
this._abandonStroke();
this._rescanBBox(); this._recomputeRegion(); this._draw();
this.layout?.setUndoState({ canUndo: this._undo.length > 0, canRedo: this._redo.length > 0 });
};
// ── keys ────────────────────────────────────────────────────────────────────
proto._bindKeys = function () {
this._keyHandler = (e) => {
const ae = document.activeElement;
if ((ae?.tagName === "INPUT" || ae?.tagName === "TEXTAREA" || ae?.tagName === "SELECT") && !ae?.dataset?.pixaromaTrap) return;
const key = e.key.toLowerCase();
const ctrl = e.ctrlKey || e.metaKey;
if (key === "escape") { e.preventDefault(); e.stopImmediatePropagation(); this._close(); return; }
if (ctrl && key === "s") { e.preventDefault(); this._save(); return; }
if (ctrl && key === "z" && !e.shiftKey) { e.preventDefault(); this._doUndo(); return; }
if (ctrl && (key === "y" || (key === "z" && e.shiftKey))) { e.preventDefault(); this._doRedo(); return; }
if (ctrl) return;
if (e.code === "Space") { // hold Space to pan the zoomed view (drag)
e.preventDefault();
if (!this._spaceHeld) {
this._spaceHeld = true;
if (this.el.canvas) this.el.canvas.style.cursor = "grab";
if (this._dispW) this.el.curCtx?.clearRect(0, 0, this._dispW, this._dispH); // hide the brush ring
this._lastCursorPos = null;
}
return;
}
if (key === "b") { e.preventDefault(); this._setTool("add"); this._toolGrid?.setActive?.("add"); return; }
if (key === "e") { e.preventDefault(); this._setTool("erase"); this._toolGrid?.setActive?.("erase"); return; }
if (key === "h") { e.preventDefault(); this._toggleMaskVisible(); return; }
if (key === "x") { this._xHeld = true; return; }
if (key === "[" || key === "]") {
e.preventDefault();
const dir = key === "[" ? -1 : 1;
// ignore OS auto-repeat (laggy) - drive a smooth ramp ourselves while held
if (!e.repeat) { this._adjustBrush(dir * 4); this._startBrushHold(dir); }
return;
}
};
this._keyUpHandler = (e) => {
const k = e.key.toLowerCase();
if (k === "x") this._xHeld = false;
if (k === "[" || k === "]") this._stopBrushHold();
if (e.code === "Space") { this._spaceHeld = false; if (this.el.canvas && !this._panning) this.el.canvas.style.cursor = "none"; }
};
window.addEventListener("keydown", this._keyHandler, { capture: true });
window.addEventListener("keyup", this._keyUpHandler, { capture: true });
};
// brush resize: instant on tap, smooth-accelerating while held (no OS-repeat lag)
proto._adjustBrush = function (delta) {
this.brushSize = Math.max(2, Math.min(300, this.brushSize + delta));
this.el.sizeSlider?.setValue(this.brushSize);
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
};
proto._startBrushHold = function (dir) {
this._stopBrushHold();
this._holdDir = dir;
let rate = 0.5, accum = 0;
const tick = () => {
if (!this.el.overlay?.isConnected) { this._stopBrushHold(); return; }
rate = Math.min(3.4, rate + 0.12);
accum += this._holdDir * rate;
const step = Math.trunc(accum);
if (step !== 0) { accum -= step; this._adjustBrush(step); }
this._holdRaf = requestAnimationFrame(tick);
};
this._holdRaf = requestAnimationFrame(tick);
};
proto._stopBrushHold = function () {
if (this._holdRaf) { cancelAnimationFrame(this._holdRaf); this._holdRaf = null; }
};
// ── paste / drag-drop a source image INTO the open editor ──────────────────
// Loads the dropped/pasted image as the new source (same as the Load Image
// button: _loadImageFromDataURL + onLoadImage to disconnect the upstream wire).
// All three listen at window-capture, gated on the overlay being live, and
// preventDefault + stop so ComfyUI doesn't spawn its own Load Image node behind us.
proto._bindDropPaste = function () {
const alive = () => !!this.el.overlay?.isConnected;
const loadFile = (file) => {
if (!file || !file.type?.startsWith("image/")) return false;
const reader = new FileReader();
reader.onload = (ev) => { this._loadImageFromDataURL(ev.target.result); this.onLoadImage?.(); };
reader.readAsDataURL(file);
return true;
};
this._pasteHandler = (e) => {
if (!alive()) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const it = Array.from(e.clipboardData?.items || []).find((x) => x.type?.startsWith("image/"));
if (!it) return;
const blob = it.getAsFile();
if (!blob) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(blob);
};
this._dragOverHandler = (e) => {
if (!alive()) return;
if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); e.stopImmediatePropagation(); }
};
this._dropHandler = (e) => {
if (!alive()) return;
const file = e.dataTransfer?.files?.[0];
if (!file || !file.type?.startsWith("image/")) return;
e.preventDefault(); e.stopImmediatePropagation();
loadFile(file);
};
window.addEventListener("paste", this._pasteHandler, true);
window.addEventListener("dragover", this._dragOverHandler, true);
window.addEventListener("drop", this._dropHandler, true);
};
proto._unbindDropPaste = function () {
if (this._pasteHandler) window.removeEventListener("paste", this._pasteHandler, true);
if (this._dragOverHandler) window.removeEventListener("dragover", this._dragOverHandler, true);
if (this._dropHandler) window.removeEventListener("drop", this._dropHandler, true);
this._pasteHandler = this._dragOverHandler = this._dropHandler = null;
};
proto._unbindKeys = function () {
this._stopBrushHold();
if (this._drawRaf) { cancelAnimationFrame(this._drawRaf); this._drawRaf = null; }
this._detachStroke?.(); // detach any live stroke window-listeners (close mid-drag)
this._detachPan?.(); // detach any live pan window-listeners
this._unbindDropPaste();
if (this._keyHandler) window.removeEventListener("keydown", this._keyHandler, { capture: true });
if (this._keyUpHandler) window.removeEventListener("keyup", this._keyUpHandler, { capture: true });
};
+365
View File
@@ -0,0 +1,365 @@
// ============================================================
// Inpaint Crop Pixaroma — canvas render + region preview + save
// ============================================================
import { InpaintCropEditor, BRAND, InpaintAPI } from "./core.mjs";
import { computeRegion, maskBBoxFromImageData, growBBox, seamAlphaFromAlpha } from "./geometry.mjs";
const proto = InpaintCropEditor.prototype;
// ── image load ──────────────────────────────────────────────────────────────
proto._loadImageFromURL = function (url, onDone) {
const token = (this._loadToken = (this._loadToken || 0) + 1); // newest load wins
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
if (token !== this._loadToken) return; // a newer open/load superseded this one
this.img = img;
this.imgW = img.naturalWidth;
this.imgH = img.naturalHeight;
this._ensureMaskCanvas();
this._fitCanvas();
this._rescanBBox();
this._recomputeRegion();
this._draw();
this._setStatus(`Loaded: ${this.imgW}×${this.imgH}`);
onDone?.();
};
img.onerror = () => this._setStatus("Failed to load the source image.");
img.src = url;
};
proto._loadImageFromDataURL = function (dataURL) {
const token = (this._loadToken = (this._loadToken || 0) + 1); // newest load wins
const img = new Image();
img.onload = () => {
if (token !== this._loadToken) return; // a newer open/load superseded this one
this.img = img;
this.imgW = img.naturalWidth;
this.imgH = img.naturalHeight;
this._pendingSrcDataURL = dataURL;
this._maskPath = "";
this._mask = null;
this._ensureMaskCanvas();
this._fitCanvas();
this._rescanBBox();
this._recomputeRegion();
this._draw();
this._setStatus(`Loaded: ${this.imgW}×${this.imgH}`);
};
img.src = dataURL;
};
// ── fit + draw ────────────────────────────────────────────────────────────
proto._fitCanvas = function () {
if (!this.img) return;
const ws = this.el.workspace, pad = 40;
const maxW = ws.clientWidth - pad * 2, maxH = ws.clientHeight - pad * 2;
if (maxW <= 0 || maxH <= 0) return;
const asp = this.imgW / this.imgH;
let dw, dh;
if (maxW / maxH > asp) { dh = maxH; dw = dh * asp; } else { dw = maxW; dh = dw / asp; }
this._baseScale = dw / this.imgW;
this._zoom = 1; this._panX = 0; this._panY = 0; // (re)fit resets the view
this._scale = this._baseScale;
this._dispW = Math.round(dw); this._dispH = Math.round(dh);
// backing store at devicePixelRatio so the painting canvas stays crisp on
// high-DPI screens; all drawing is in logical px (the ctx is scaled by dpr).
const dpr = Math.max(1, window.devicePixelRatio || 1);
for (const c of [this.el.canvas, this.el.cursor]) {
c.width = Math.round(this._dispW * dpr); c.height = Math.round(this._dispH * dpr);
c.style.width = this._dispW + "px"; c.style.height = this._dispH + "px";
}
this.el.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.el.curCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.el.canvas.style.cursor = "none";
};
// keep the image covering the viewport (no empty gaps); at zoom 1 this forces pan 0
proto._clampPan = function () {
const iw = this.imgW * this._scale, ih = this.imgH * this._scale;
const minX = Math.min(0, this._dispW - iw), minY = Math.min(0, this._dispH - ih);
this._panX = Math.min(0, Math.max(minX, this._panX));
this._panY = Math.min(0, Math.max(minY, this._panY));
};
// zoom by `factor` keeping the source point under (ancX,ancY display px) fixed
proto._applyZoom = function (factor, ancX, ancY) {
const nz = Math.max(1, Math.min(8, this._zoom * factor));
if (nz === this._zoom) return;
const sx = (ancX - this._panX) / this._scale; // source point under the cursor
const sy = (ancY - this._panY) / this._scale;
this._zoom = nz;
this._scale = this._baseScale * nz;
this._panX = ancX - sx * this._scale;
this._panY = ancY - sy * this._scale;
this._clampPan();
this._draw();
if (this._lastCursorPos) this._drawCursor(this._lastCursorPos);
};
// Feathered seam alpha at display resolution: the outward signed-distance smoothstep
// of the mask over (blend * scale) px - the canvas mirror of the Python _blur_alpha
// SCIPY path, so the editor preview MATCHES the stitched seam (preview == result).
// Computed on a downscaled buffer (the seam is soft, so a chamfer DT + upscale is
// invisible) to stay fast on every _draw - brush strokes + softness drags. _draw is
// NOT called on idle mouse-move (the cursor draws separately), so no cache is needed.
proto._seamAlphaCanvas = function () {
const dpr = Math.max(1, window.devicePixelRatio || 1); // DPR-backed for HiDPI crispness
const W = Math.round(this._dispW * dpr), H = Math.round(this._dispH * dpr);
if (!this._seamCv) this._seamCv = document.createElement("canvas");
const c = this._seamCv;
if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
const ctx = c.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, W, H);
const src = this._effectiveMaskCanvas();
if (!src) return c;
// WHOLE CROP mode: the stitch feathers the whole crop RECTANGLE (Python
// _feather_alpha) - an inward linear ramp from the region edges - NOT the mask
// edge. Show that instead of the mask feather so the preview matches the result.
if (this.params.blend_mode === "whole_crop" && this._region) {
// _baseScale (NOT _scale): the seam canvas is fit-res then drawn at the zoomed
// view rect, which applies the zoom - using _scale would double-count it.
const blendD = Math.max(0, (this.params.blend ?? 16) * (this._baseScale || 1) * dpr);
const CAP = 480;
const sc = Math.min(1, CAP / Math.max(W, H));
const bw = Math.max(1, Math.round(W * sc)), bh = Math.max(1, Math.round(H * sc));
const r = this._region;
const rx = r.rx / this.imgW * bw, ry = r.ry / this.imgH * bh;
const rw = Math.max(1, r.rw / this.imgW * bw), rh = Math.max(1, r.rh / this.imgH * bh);
const kBuf = blendD * (bw / W);
const kEff = Math.min(kBuf, Math.max(0.5, Math.min(rw, rh) / 2 - 0.5)); // keep interior opaque
const b = this._seamBuf || (this._seamBuf = document.createElement("canvas"));
if (b.width !== bw || b.height !== bh) { b.width = bw; b.height = bh; }
const bctx = b.getContext("2d");
bctx.setTransform(1, 0, 0, 1, 0, 0);
const id = bctx.createImageData(bw, bh);
const d = id.data;
for (let y = 0; y < bh; y++) {
for (let x = 0; x < bw; x++) {
let a = 0;
if (x >= rx && x < rx + rw && y >= ry && y < ry + rh) {
const dist = Math.min(x - rx, rx + rw - 1 - x, y - ry, ry + rh - 1 - y);
a = kEff <= 0 ? 1 : Math.max(0, Math.min(1, dist / kEff));
}
const p = (y * bw + x) * 4;
d[p] = 255; d[p + 1] = 255; d[p + 2] = 255; d[p + 3] = Math.round(a * 255);
}
}
bctx.putImageData(id, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(b, 0, 0, bw, bh, 0, 0, W, H);
return c;
}
// _baseScale (NOT _scale): fit-res seam canvas, zoom applied later at the view draw.
const blendDisp = Math.max(0, (this.params.blend ?? 16) * (this._baseScale || 1) * dpr);
if (blendDisp < 0.5) { ctx.drawImage(src, 0, 0, W, H); return c; } // crisp seam
// draw the mask into a small buffer, distance-transform its alpha, write the
// feathered alpha back, then upscale to the display-res seam canvas.
const CAP = 480;
const sc = Math.min(1, CAP / Math.max(W, H));
const bw = Math.max(1, Math.round(W * sc)), bh = Math.max(1, Math.round(H * sc));
const kBuf = blendDisp * (bw / W); // feather width in buffer px
const b = this._seamBuf || (this._seamBuf = document.createElement("canvas"));
if (b.width !== bw || b.height !== bh) { b.width = bw; b.height = bh; }
const bctx = b.getContext("2d");
bctx.setTransform(1, 0, 0, 1, 0, 0);
bctx.clearRect(0, 0, bw, bh);
bctx.drawImage(src, 0, 0, bw, bh);
const id = bctx.getImageData(0, 0, bw, bh);
const alpha = seamAlphaFromAlpha(id.data, bw, bh, kBuf);
const dpx = id.data;
for (let i = 0, p = 0; i < alpha.length; i++, p += 4) {
dpx[p] = 255; dpx[p + 1] = 255; dpx[p + 2] = 255;
dpx[p + 3] = Math.round(alpha[i] * 255);
}
bctx.putImageData(id, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(b, 0, 0, bw, bh, 0, 0, W, H); // upscale the soft alpha
return c;
};
// Coalesce high-frequency redraws (brush strokes) to one per animation frame, so
// fast mouse-moves on a big image don't pile up synchronous full redraws (the lag).
proto._requestRedraw = function () {
if (this._drawRaf) return;
this._drawRaf = requestAnimationFrame(() => { this._drawRaf = null; this._draw(); });
};
proto._draw = function () {
if (!this.img || !this._dispW) return; // _fitCanvas sets _dispW before any draw
const ctx = this.el.ctx, s = this._scale;
const W = this._dispW, H = this._dispH;
ctx.clearRect(0, 0, W, H);
// pan/zoom: the image is drawn at (_panX,_panY) scaled by _scale (= base*zoom);
// at fit (zoom 1) this is (0,0,W,H). Everything below offsets by the same pan.
ctx.drawImage(this.img, this._panX, this._panY, this.imgW * s, this.imgH * s);
// seam preview: tint the FEATHERED seam alpha (Softness) in the chosen color,
// clipped to the crop region. DPR-backed (crisp on HiDPI). Mirrors the Python
// _blur_alpha SCIPY smoothstep, so the tint matches the stitched seam.
if (this.maskVisible && this._mask) {
if (!this._tint) this._tint = document.createElement("canvas");
const t = this._tint;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const tw = Math.round(W * dpr), th = Math.round(H * dpr);
if (t.width !== tw || t.height !== th) { t.width = tw; t.height = th; }
const tc = t.getContext("2d");
tc.setTransform(1, 0, 0, 1, 0, 0);
tc.clearRect(0, 0, tw, th);
// Always show the FEATHERED seam preview (even mid-stroke) so the mask doesn't
// flash crisp-while-painting then blurred-on-release. The rAF coalescing
// (_requestRedraw) keeps it smooth; if a very large image ever lags during a
// stroke, throttle _seamAlphaCanvas while _painting rather than skipping it.
const alphaSrc = this._seamAlphaCanvas();
// draw the mask at the same pan/zoom rect as the image (backing px) so it aligns
tc.drawImage(alphaSrc, this._panX * dpr, this._panY * dpr, this.imgW * s * dpr, this.imgH * s * dpr);
tc.globalCompositeOperation = "source-in";
tc.fillStyle = this.previewColor || "#f6303a";
tc.fillRect(0, 0, tw, th);
tc.globalCompositeOperation = "source-over";
ctx.save();
// Clip the seam tint to the crop box - BUT NOT while actively painting: the crop
// box only grows on stroke end, so clipping mid-stroke hides any mask you paint
// beyond the current box ("interrupted by the context margin" until you release).
// During a stroke show the full mask everywhere; the clipped seam returns on end.
if (this._region && !this._painting) {
const r = this._region;
ctx.beginPath();
ctx.rect(r.rx * s + this._panX, r.ry * s + this._panY, r.rw * s, r.rh * s);
ctx.clip();
}
ctx.globalAlpha = this.maskOpacity;
ctx.drawImage(t, 0, 0, W, H); // backing-res tint at logical size = crisp
ctx.globalAlpha = 1;
ctx.restore();
}
// tight painted bbox (white dashed)
if (this._bbox) {
const [x0, y0, x1, y1] = this._bbox;
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.strokeRect(x0 * s + this._panX + 0.5, y0 * s + this._panY + 0.5, (x1 - x0) * s, (y1 - y0) * s);
ctx.setLineDash([]);
}
// crop region (orange dashed) + handles + size badge
if (this._region) {
const r = this._region;
const rx = r.rx * s + this._panX, ry = r.ry * s + this._panY, rw = r.rw * s, rh = r.rh * s;
const boxColor = this._cropBoxColor || BRAND; // white when the orange tint is active
ctx.strokeStyle = boxColor; ctx.lineWidth = 2; ctx.setLineDash([7, 5]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
ctx.fillStyle = boxColor;
for (const [hx, hy] of [[rx, ry], [rx + rw, ry], [rx, ry + rh], [rx + rw, ry + rh]])
ctx.fillRect(hx - 4, hy - 4, 8, 8);
const label = `${r.out_w} × ${r.out_h}`;
ctx.font = "bold 12px 'Segoe UI', sans-serif";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
const tw = ctx.measureText(label).width + 12;
const by = Math.max(10, ry - 11);
ctx.fillStyle = BRAND;
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(rx, by - 9, tw, 18, 3); ctx.fill(); }
else ctx.fillRect(rx, by - 9, tw, 18);
ctx.fillStyle = "#fff";
ctx.fillText(label, rx + 6, by);
}
};
// ── geometry preview ──────────────────────────────────────────────────────
// Scan the mask for its painted bounding box. This is the expensive part
// (full-image getImageData + per-pixel scan), so it only runs when the mask
// actually changes (stroke end / clear / invert / undo / load), NOT on every
// context-slider tick.
proto._rescanBBox = function () {
if (!this.img || !this._mask) { this._bbox = null; return; }
const id = this._mctx.getImageData(0, 0, this.imgW, this.imgH);
this._bbox = maskBBoxFromImageData(id.data, this.imgW, this.imgH);
};
// Compute the crop region from the CACHED bbox + current knobs. Cheap, so it is
// safe to call on every context-slider tick without re-scanning the mask.
proto._recomputeRegion = function () {
if (!this.img) { this._region = null; this._bbox = null; return; }
const grow = this.params.mask_grow != null ? this.params.mask_grow : 0;
const grown = growBBox(this._bbox, grow, this.imgW, this.imgH);
// force mode is a square of `target` (the node has a single Target knob), so
// mirror Python's _params: target_w = target_h = target, or the preview would
// wrongly show the default 1024 in force mode.
const p = { ...this.params, target_w: this.params.target, target_h: this.params.target };
this._region = computeRegion(grown, this.imgW, this.imgH, p);
this._updateInfo(this._bbox);
};
proto._updateInfo = function (bbox) {
if (!this._infoBlock) return;
if (!bbox) { this._infoBlock.setHTML("Paint a mask to begin.<br>The whole image will be used until you do."); return; }
const r = this._region;
this._infoBlock.setHTML(
`<b>Crop out:</b> ${r.out_w} × ${r.out_h}<br>` +
`<b>Region:</b> ${r.rw} × ${r.rh}px<br>` +
`<b>Context:</b> ${this.params.context_px ?? 0}px`,
);
};
// ── save ────────────────────────────────────────────────────────────────────
proto._buildPreview = function () {
if (!this.img || !this._region) return null;
const r = this._region;
const c = document.createElement("canvas");
c.width = r.out_w; c.height = r.out_h;
c.getContext("2d").drawImage(this.img, r.rx, r.ry, r.rw, r.rh, 0, 0, r.out_w, r.out_h);
return c.toDataURL("image/png");
};
proto._save = async function () {
if (!this.img) { this._setStatus("No image to save"); return; }
this.layout.setSaving();
try {
if (this._pendingSrcDataURL) {
try {
const d = await InpaintAPI.uploadSrc(this.projectId, this._pendingSrcDataURL);
this._srcPath = d.path || this._srcPath;
} catch (e) { console.warn("[InpaintCrop] src upload failed:", e); }
this._pendingSrcDataURL = null;
}
try {
const d = await InpaintAPI.saveMask(this.projectId, this._exportMaskDataURL());
this._maskPath = d.path || this._maskPath;
} catch (e) { console.warn("[InpaintCrop] mask save failed:", e); }
const state = {
project_id: this.projectId,
src_path: this._srcPath,
mask_path: this._maskPath,
doc_w: this.imgW,
doc_h: this.imgH,
blend_mode: this.params.blend_mode || "mask",
};
const extra = {
context_px: this.params.context_px,
mask_grow: this.params.mask_grow,
mask_blur: this.params.mask_blur,
softness: this.params.blend,
size_mode: this.params.size_mode,
target: this.params.target,
multiple: this.params.multiple,
blend_mode: this.params.blend_mode, // mirror the editor pill back to the node widget
};
const preview = this._buildPreview();
if (this.onSave) this.onSave(JSON.stringify(state), extra, preview);
if (this._diskSavePending) {
this._diskSavePending = false;
if (this.onSaveToDisk && preview) this.onSaveToDisk(preview);
}
this.layout.setSaved();
} catch (err) {
console.error("[InpaintCrop] Save error:", err);
this.layout.setSaveError("Save failed: " + err.message);
}
};
+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).
+48
View File
@@ -0,0 +1,48 @@
// Load Images from Folder Pixaroma — backend fetch helpers.
// List image files in a folder. Returns {ok, folder, files:[{file,name,size,mtime}], message?}.
export async function listFolder(folder, recursive) {
try {
const url =
`/pixaroma/api/load_images_folder/list?path=${encodeURIComponent(folder)}` +
`&recursive=${recursive ? 1 : 0}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e), files: [] };
}
}
// Thumbnail URL for one image (served by the backend, scaled to <=192px).
// mtime is folded in as a cache key so an edited file refreshes.
export function thumbURL(folder, rel, mtime) {
return (
`/pixaroma/api/load_images_folder/thumb?path=${encodeURIComponent(folder)}` +
`&file=${encodeURIComponent(rel)}&mt=${Math.floor(mtime || 0)}`
);
}
// Browse the server filesystem for the in-app folder picker (fallback).
// Returns {ok, path, parent, dirs:[{name, path, images}], message?}.
export async function browseFolder(path) {
try {
const url = `/pixaroma/api/load_images_folder/browse?path=${encodeURIComponent(path || "")}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e), dirs: [] };
}
}
// Pop the native OS folder dialog on the ComfyUI host. Returns {ok:true, path},
// {ok:false, cancelled} (user closed it), or {ok:false, unavailable} (non-Windows
// / remote) so the caller can fall back to the in-app browser.
export async function pickNativeFolder(startPath) {
try {
const url = `/pixaroma/api/load_images_folder/pick_native?path=${encodeURIComponent(startPath || "")}`;
const r = await fetch(url);
return await r.json();
} catch (e) {
return { ok: false, message: String(e) };
}
}
+441
View File
@@ -0,0 +1,441 @@
// Load Images from Folder Pixaroma — extension entry point.
// Mirrors Load Image Pixaroma's both-renderer DOM-widget + Pattern #9 state
// injection, adapted for a folder + multi-select gallery + list output.
import { app } from "/scripts/app.js";
import {
applyAdaptiveCanvasOnly,
isVueNodes,
installResizeFloor,
hideJsonWidget,
} from "../shared/index.mjs";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
COMFY_CLASS,
HIDDEN_INPUT_NAME,
readState,
writeState,
} from "./state.mjs";
import { listFolder, pickNativeFolder } from "./api.mjs";
import {
injectCSS,
buildRoot,
openPickGallery,
openBrowsePopup,
openMiniMenu,
} from "./ui.mjs";
import { buildModePanel, injectResizePanelCSS } from "../shared/resize_panel.mjs";
const MIN_W = 280;
const DEFAULT_W = 370; // wide enough for the 6 resize quick-pick chips (e.g. MP) on one line
// Resize modes — values match the shared engine (_resize_helpers / buildModePanel).
const RESIZE_MODES = [
{ value: "off", label: "Off", hint: "full size" },
{ value: "max_mp", label: "Max megapixels", hint: "cap pixels" },
{ value: "longest_side", label: "Longest side", hint: "cap long edge" },
{ value: "scale_factor", label: "Scale by", hint: "× factor" },
{ value: "fit_inside", label: "Fit inside", hint: "W×H box" },
{ value: "cover", label: "Crop to fill", hint: "W×H exact" },
{ value: "match_ratio", label: "Match aspect ratio", hint: "crop / pad" },
{ value: "pad", label: "Pad", hint: "add borders" },
];
// ── node body height (sum visible children; NOT scrollHeight) ─────────────────
function measureContentHeight(root) {
if (!root) return 110;
let h = 0;
let n = 0;
for (const ch of root.children) {
const oh = ch.offsetHeight;
if (oh > 0) {
h += oh;
n++;
}
}
if (n === 0) return 110; // pre-attach placeholder
h += 16; // root vertical padding (8 + 8)
h += (n - 1) * 8; // row gaps
return Math.max(96, h);
}
// Refit the node height to its content (after the resize panel expands/collapses).
// Mirrors Image Resize's refit. Self-gates on isGraphLoading so it can be called
// freely without dirtying a saved workflow on load (Vue Compat #18). rAF so the
// freshly added/removed panel has laid out before we measure via computeSize.
function refit(node) {
if (!node._pixLifUI) return;
requestAnimationFrame(() => {
if (!node._pixLifUI || isGraphLoading()) return;
const sz = node.computeSize?.();
if (sz && Math.abs(node.size[1] - sz[1]) > 1) {
// setSize() is the official resize path; it sticks in both renderers
// (a bare node.size[1] write can be reverted across a renderer switch).
if (node.setSize) node.setSize([node.size[0], sz[1]]);
else node.size[1] = sz[1];
node.setDirtyCanvas?.(true, true);
}
});
}
// Normalize a folder path: backslash -> forward slash, trim, drop a trailing
// slash (but keep a bare drive root as "X:/"). Makes native-dialog returns
// (backslashes on Windows) compare cleanly against typed/pasted paths.
function normalizePath(p) {
if (!p) return "";
let s = String(p).trim().replace(/\\/g, "/").replace(/\/+$/, "");
if (/^[A-Za-z]:$/.test(s)) s += "/"; // "D:" -> "D:/"
return s;
}
function stripInputs(node) {
if (!node?.inputs || node.inputs.length === 0) return;
for (let i = node.inputs.length - 1; i >= 0; i--) {
if (node.inputs[i]?.link != null) {
try {
node.disconnectInput(i);
} catch {}
}
node.removeInput(i);
}
node.setDirtyCanvas?.(true, true);
}
// ── render the node body from state ──────────────────────────────────────────
function renderUI(node) {
const ui = node._pixLifUI;
if (!ui) return;
const state = readState(node);
if (document.activeElement !== ui.folderInput) {
ui.folderInput.value = state.folder || "";
}
const total = (node._pixLifFiles || []).length;
const sel = (state.selected || []).length;
ui.pickBtn.textContent = `Pick images · ${sel} / ${total}`;
ui.pickBtn.classList.toggle("empty", sel === 0);
ui.msgEl.textContent = node._pixLifListError || "";
node.setDirtyCanvas?.(true, true);
}
// ── (re)list the chosen folder + reconcile selection ─────────────────────────
// userAction = the call came from a real user gesture (folder change / subfolder
// toggle), as opposed to a workflow-load path (onNodeCreated / onConfigure).
async function refreshListing(node, userAction = false) {
// Bump FIRST so even the no-folder early-return invalidates any in-flight
// fetch (clear the folder while a listing is loading -> the stale response
// must not repopulate the file list).
const myReq = (node._pixLifListReq = (node._pixLifListReq || 0) + 1);
const state = readState(node);
if (!state.folder) {
node._pixLifFiles = [];
node._pixLifListError = "";
renderUI(node);
return;
}
const res = await listFolder(state.folder, state.recursive);
// a newer refresh superseded this one (e.g. paste + blur), or the node was
// removed while the fetch was in flight - drop this stale response.
if (node._pixLifListReq !== myReq || !node._pixLifUI) return;
if (res && res.ok) {
node._pixLifFiles = res.files || [];
node._pixLifListError = node._pixLifFiles.length
? ""
: "No images found in this folder.";
} else {
node._pixLifFiles = [];
node._pixLifListError = (res && res.message) || "Folder not found.";
}
// Drop selections that no longer exist on disk, but PERSIST that only on a
// genuine user action. The load path must never write serialized state
// (Vue Compat #18) - and isGraphLoading() alone is unreliable here because
// the await above usually outlasts its 300ms trailing window. Python tolerates
// missing files at run time (skips them), so not persisting on load is safe.
const present = new Set((node._pixLifFiles || []).map((f) => f.file));
const st = readState(node);
const before = (st.selected || []).length;
const kept = (st.selected || []).filter((f) => present.has(f));
if (kept.length !== before && userAction && !isGraphLoading()) {
st.selected = kept;
writeState(node, st);
}
renderUI(node);
}
async function setFolder(node, folder) {
const normalized = normalizePath(folder);
const st = readState(node);
const changed = (st.folder || "") !== normalized;
st.folder = normalized;
if (changed) st.selected = []; // new folder → drop stale selection
writeState(node, st);
await refreshListing(node, true);
}
// ── resize control (mode dropdown + the shared per-mode panel) ────────────────
function renderResize(node) {
const ui = node._pixLifUI;
if (!ui) return;
injectResizePanelCSS();
const state = readState(node);
const slot = ui.resizeSlot;
slot.innerHTML = "";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pix-lif-resizebtn";
const label = RESIZE_MODES.find((m) => m.value === state.mode)?.label || "Off";
btn.innerHTML = `<span class="lbl">Resize</span><span class="val">${label} ▾</span>`;
btn.title = "Resize each image as it loads (same options as Load Image)";
btn.addEventListener("click", () => {
openMiniMenu(btn, RESIZE_MODES, readState(node).mode, (val) => {
const st = readState(node);
st.mode = val;
writeState(node, st);
renderResize(node);
node.setDirtyCanvas?.(true, true);
});
});
slot.appendChild(btn);
if (state.mode !== "off") {
// The shared builders re-read node.properties[stateKey] on each edit and
// only override the resize key, so folder/selected are preserved.
const panel = buildModePanel(
state.mode,
node,
readState(node),
writeState,
() => {
node.setDirtyCanvas?.(true, true);
refit(node); // some panels (e.g. match-ratio crop/pad) change height
},
"loadImagesFolderState",
{ oneLine: true }
);
if (panel) slot.appendChild(panel);
}
node.setDirtyCanvas?.(true, true);
refit(node); // grow/shrink the node to fit the (possibly changed) panel
}
// ── per-node setup ───────────────────────────────────────────────────────────
function setupNode(node) {
injectCSS();
try {
hideJsonWidget(node.widgets, HIDDEN_INPUT_NAME);
} catch {}
stripInputs(node);
const ui = buildRoot();
node._pixLifUI = ui;
const widget = node.addDOMWidget("pixaroma_lif_ui", "custom", ui.root, {
getValue: () => null,
setValue: () => {},
// Coarse-round and NO getMaxHeight: a live getMaxHeight tied to a DOM
// measurement can creep node.size on load (Vue Compat #18). getMinHeight
// floors the node; refit() fits the exact height on user actions.
getMinHeight: () => Math.round(measureContentHeight(ui.root) / 4) * 4,
serialize: false,
});
applyAdaptiveCanvasOnly(widget);
node._pixLifWidget = widget;
if (isVueNodes()) {
widget.computeLayoutSize = () => ({
// coarse-round so sub-pixel/font jitter can't creep node.size on switch
minHeight: Math.round(measureContentHeight(ui.root) / 4) * 4,
minWidth: 1,
});
}
// events
ui.folderInput.addEventListener("keydown", (e) => {
// capture-phase canvas shortcuts need stopImmediate, not just stop
e.stopImmediatePropagation();
if (e.key === "Enter") {
e.preventDefault();
ui.folderInput.blur();
}
});
ui.folderInput.addEventListener("change", () =>
setFolder(node, ui.folderInput.value.trim())
);
// 'change' only fires on blur. A paste (Ctrl+V) should list immediately
// without needing to click away first; typing still commits on blur or when
// clicking Pick images.
ui.folderInput.addEventListener("paste", () => {
setTimeout(() => {
const v = ui.folderInput.value.trim();
if (normalizePath(v) !== (readState(node).folder || "")) setFolder(node, v);
}, 0);
});
ui.browseBtn.addEventListener("click", async () => {
const start = readState(node).folder || "";
// Try the native OS folder dialog first (real dialog, real path, no copying).
// Fall back to the in-app browser when it isn't available (non-Windows /
// remote / error). A cancel just does nothing.
const lbl = ui.browseLbl;
const prev = lbl ? lbl.textContent : "";
ui.browseBtn.disabled = true;
if (lbl) lbl.textContent = "Opening…";
let res;
try {
res = await pickNativeFolder(start);
} catch {
res = { ok: false };
}
ui.browseBtn.disabled = false;
if (lbl) lbl.textContent = prev || "Browse";
if (res && res.ok && res.path) {
await setFolder(node, res.path);
return;
}
if (res && res.cancelled) return; // user closed the native dialog
openBrowsePopup(node, ui.browseBtn, {
startPath: start,
onPick: (folder) => setFolder(node, folder),
});
});
ui.pickBtn.addEventListener("click", async () => {
// commit a typed-but-not-blurred path first
const typed = ui.folderInput.value.trim();
if (typed !== (readState(node).folder || "")) await setFolder(node, typed);
const st = readState(node);
if (!st.folder) {
ui.folderInput.focus();
node._pixLifListError = "Set a folder first (type, paste, or Browse).";
renderUI(node);
return;
}
if (!node._pixLifFiles) await refreshListing(node, true);
openPickGallery(node, ui.pickBtn, {
onChange: renderUI,
refreshListing: (n) => refreshListing(n, true),
});
});
try {
node._pixLifFloorOff = installResizeFloor(ui.root, measureContentHeight);
} catch {}
// resize control (reads current state; fresh node = "Off")
renderResize(node);
// default width on a FRESH drop only; on workflow load, leave the width for
// configure() to restore (so a user-resized node keeps its saved width and we
// don't dirty it — Vue Compat #18). LiteGraph's title-driven default is wider
// than MIN_W, so this set (not a < MIN_W guard) is what actually applies it.
if (!node.size) node.size = [DEFAULT_W, 200];
if (!isGraphLoading()) node.size[0] = DEFAULT_W;
// initial populate, deferred so configure()'s state lands first
queueMicrotask(() => refreshListing(node));
}
// ── Pattern #9: inject state into the hidden input at submit time ─────────────
function collectNodes(graph, out) {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (n?.comfyClass === COMFY_CLASS) out.push(n);
// subgraph nodes expose their inner graph under different names by version
const inner = n?.subgraph || n?.graph || n?._graph;
if (inner && inner !== graph) collectNodes(inner, out);
}
}
function matchNode(nodes, promptId) {
let n = nodes.find((x) => String(x.id) === String(promptId));
if (n) return n;
const tail = String(promptId).split(":").pop();
return nodes.find((x) => String(x.id) === tail) || null;
}
function injectState(result) {
const out = result?.output;
if (!out) return;
const lifNodes = [];
collectNodes(app.graph, lifNodes);
if (!lifNodes.length) return;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== COMFY_CLASS) continue;
const node = matchNode(lifNodes, id);
if (!node) continue;
if (!entry.inputs) entry.inputs = {};
entry.inputs[HIDDEN_INPUT_NAME] = JSON.stringify(readState(node));
}
}
function installGraphToPromptHook() {
if (app._pixLifGraphPatched) return;
app._pixLifGraphPatched = true;
const orig = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await orig(...args);
try {
injectState(result);
} catch (e) {
console.warn("[LoadImagesFolder] graphToPrompt inject failed", e);
}
return result;
};
}
app.registerExtension({
name: "Pixaroma.LoadImagesFolder",
setup() {
installGraphToPromptHook();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== COMFY_CLASS) return;
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origCreated?.apply(this, arguments);
setupNode(this);
return r;
};
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = origConfigure?.apply(this, arguments);
stripInputs(this);
queueMicrotask(() => {
renderResize(this);
refreshListing(this);
});
return r;
};
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function () {
const r = origDraw?.apply(this, arguments);
// legacy-only min-width self-heal (Nodes 2.0 size lives in the Vue layout)
if (!isVueNodes() && this.size[0] < MIN_W) this.size[0] = MIN_W;
return r;
};
// Belt-and-braces with onDrawForeground (convention #7): onResize stops the
// drag at MIN_W so the frame is never drawn narrower than the content.
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function () {
if (!isVueNodes() && this.size[0] < MIN_W) this.size[0] = MIN_W;
return origResize?.apply(this, arguments);
};
nodeType.prototype.onConnectInput = function () {
return false;
};
const origRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
try {
this._pixLifFloorOff?.();
} catch {}
// Close only THIS node's popups - deleting one node must not close another
// node's open gallery. Menus are transient, so a global sweep is fine.
this._pixLifGallery?._pixClose?.();
this._pixLifBrowsePop?._pixClose?.();
document.querySelectorAll(".pix-lif-menu").forEach((p) => p._pixClose?.());
return origRemoved?.apply(this, arguments);
};
},
});
+66
View File
@@ -0,0 +1,66 @@
// Load Images from Folder Pixaroma — shared state helpers.
// Imported by both index.js and ui.mjs (no circular import on index.js).
export const COMFY_CLASS = "PixaromaLoadImagesFolder";
export const STATE_PROP = "loadImagesFolderState";
export const HIDDEN_INPUT_NAME = "LoadImagesFolderState";
// Resize keys MUST match nodes/node_load_images_folder.py::DEFAULT_STATE and
// nodes/node_load_image.py::DEFAULT_STATE (shared _resize_frame engine).
export const DEFAULT_STATE = {
version: 1,
folder: "",
recursive: false,
sort: "name", // "name" | "date"
sort_dir: "asc", // "asc" | "desc"
selected: [], // file paths relative to folder, in display order
// ── resize keys ──
mode: "off",
max_mp: 1.0,
longest_side: 1024,
scale_factor: 1.0,
fit_w: 1024, fit_h: 1024,
cover_w: 1024, cover_h: 1024,
ratio_preset: "1:1",
ratio_w: 1, ratio_h: 1,
ratio_action: "crop",
pad_color: "#808080",
pad_top: 0, pad_bottom: 0, pad_left: 0, pad_right: 0,
crop_anchor: "center", crop_scale: true,
snap: 0,
resample: "auto",
allow_upscale: true,
};
export function readState(node) {
const v = node.properties?.[STATE_PROP];
if (typeof v === "string" && v) {
try {
return { ...DEFAULT_STATE, ...JSON.parse(v) };
} catch {
/* fall through to defaults */
}
}
return { ...DEFAULT_STATE };
}
export function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = JSON.stringify(state);
}
// Sort a [{file, name, size, mtime}] list by the current sort + direction.
export function sortFiles(files, sort, dir) {
const arr = [...(files || [])];
arr.sort((a, b) => {
let c;
if (sort === "date") c = (a.mtime || 0) - (b.mtime || 0);
else
c = String(a.file).localeCompare(String(b.file), undefined, {
numeric: true,
sensitivity: "base",
});
return dir === "desc" ? -c : c;
});
return arr;
}
+441
View File
@@ -0,0 +1,441 @@
// Load Images from Folder Pixaroma — DOM (node body, gallery, folder browser) + CSS.
import { thumbURL, browseFolder } from "./api.mjs";
import { readState, writeState, sortFiles } from "./state.mjs";
// folder.svg (assets/icons/ui/folder.svg) inlined so the Browse button + browser
// rows draw the real icon without a network fetch. fill:currentColor follows text.
const FOLDER_SVG =
'<svg viewBox="0 0 64 64" aria-hidden="true"><path d="M52.291,56.817H5.626c-1.006,0-1.922-.594-2.5-1.323-.752-.949-.846-2.209-.483-3.372l7.293-23.34c.522-1.67,1.625-2.992,3.453-3.243h46.148c2.155.308,3.418,2.045,3.193,4.245l-7.097,23.693c-.491,1.64-1.523,2.993-3.343,3.341ZM50.726,14.308h-21.805c-.429-.181-.717-.689-.997-1.031l-3.967-4.843c-.559-.682-1.432-1.249-2.369-1.25H6.186c-1.185,0-2.24.531-3.095,1.272-1.098.952-1.545,2.24-1.818,3.706v31.447c1.841-5.514,3.332-10.857,5.103-16.241.459-1.396,1.126-2.594,2.154-3.621,1.355-1.054,2.862-2.056,4.685-2.057h42.426c.669-2.549-.634-7.369-4.914-7.382Z"/></svg>';
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])
);
}
// ── CSS ──────────────────────────────────────────────────────────────────────
export function injectCSS() {
if (document.getElementById("pix-lif-css")) return;
const css = `
.pix-lif-root { display:flex; flex-direction:column; gap:8px; padding:8px 10px; box-sizing:border-box; font-family:inherit; }
.pix-lif-folderrow { display:flex; gap:6px; }
.pix-lif-folder { flex:1; min-width:0; background:#141414; border:1px solid #3a3a3a; border-radius:5px; padding:7px 8px; color:#cfcfcf; font-size:11px; box-sizing:border-box; }
.pix-lif-folder:focus { outline:none; border-color:#f66744; }
.pix-lif-browse { display:flex; align-items:center; gap:5px; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.16); border-radius:5px; color:#ddd; font-size:11px; padding:0 9px; cursor:pointer; white-space:nowrap; }
.pix-lif-browse:hover { border-color:#f66744; color:#fff; }
.pix-lif-browse svg { width:13px; height:13px; fill:currentColor; }
.pix-lif-pick { background:#f66744; border:1px solid #f66744; border-radius:6px; padding:8px; font-size:12px; color:#fff; text-align:center; font-weight:500; cursor:pointer; }
.pix-lif-pick:hover { filter:brightness(1.08); }
.pix-lif-pick.empty { background:rgba(255,255,255,0.05); border-color:rgba(255,255,255,0.16); color:#9a9a9a; }
.pix-lif-msg { font-size:11px; color:#e0a33e; line-height:1.4; }
.pix-lif-msg:empty { display:none; }
.pix-lif-resize-slot { display:flex; flex-direction:column; gap:6px; }
.pix-lif-resize-slot:empty { display:none; }
.pix-lif-resizebtn { display:flex; align-items:center; gap:8px; width:100%; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.16); border-radius:5px; padding:6px 8px; font-size:11px; color:#cfcfcf; cursor:pointer; box-sizing:border-box; }
.pix-lif-resizebtn:hover { border-color:#f66744; }
.pix-lif-resizebtn .lbl { color:#f66744; font-size:10px; text-transform:uppercase; letter-spacing:.04em; }
.pix-lif-resizebtn .val { margin-left:auto; color:#ddd; }
/* mini dropdown menu (sort, resize mode) */
.pix-lif-menu { position:fixed; z-index:99999; background:#191919; border:1px solid #3a3a3a; border-radius:6px; box-shadow:0 8px 24px rgba(0,0,0,0.5); overflow:hidden; min-width:150px; }
.pix-lif-menu .it { padding:7px 11px; font-size:12px; color:#cfcfcf; cursor:pointer; display:flex; justify-content:space-between; gap:14px; }
.pix-lif-menu .it:hover { background:#2a2a2a; }
.pix-lif-menu .it.on { color:#f66744; }
/* gallery */
.pix-lif-gallery { position:fixed; z-index:99999; background:#191919; border:1px solid #f66744; border-radius:9px; box-shadow:0 14px 40px rgba(0,0,0,0.6); display:flex; flex-direction:column; max-height:80vh; }
.pix-lif-gal-head { padding:9px 12px; border-bottom:1px solid #333; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.pix-lif-tbtn { background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.16); border-radius:5px; padding:5px 10px; font-size:11px; color:#ddd; cursor:pointer; user-select:none; }
.pix-lif-tbtn:hover { border-color:#f66744; color:#fff; }
.pix-lif-firstwrap { display:flex; align-items:center; }
.pix-lif-firstwrap .pix-lif-tbtn { border-radius:5px 0 0 5px; }
.pix-lif-firstn { width:46px; background:#141414; border:1px solid rgba(255,255,255,0.16); border-left:none; border-radius:0 5px 5px 0; color:#f66744; font-size:11px; padding:5px 4px; text-align:center; box-sizing:border-box; }
.pix-lif-firstn:focus { outline:none; border-color:#f66744; }
.pix-lif-count { margin-left:auto; font-size:11px; color:#9a9a9a; white-space:nowrap; }
.pix-lif-count b { color:#f66744; }
.pix-lif-gal-body { padding:10px 12px; overflow:auto; }
.pix-lif-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(84px,1fr)); gap:7px; }
.pix-lif-thumb { position:relative; aspect-ratio:1; border-radius:5px; border:2px solid transparent; cursor:pointer; overflow:hidden; background:#0f0f0f; }
.pix-lif-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.pix-lif-thumb .veil { position:absolute; inset:0; background:rgba(0,0,0,0.45); }
.pix-lif-thumb.sel { border-color:#f66744; }
.pix-lif-thumb.sel .veil { opacity:0; }
.pix-lif-thumb .chk { position:absolute; top:3px; right:3px; width:16px; height:16px; border-radius:50%; background:#f66744; color:#fff; font-size:11px; display:none; align-items:center; justify-content:center; }
.pix-lif-thumb.sel .chk { display:flex; }
.pix-lif-thumb .nm { position:absolute; bottom:0; left:0; right:0; padding:2px 4px; font-size:9px; color:#eee; background:linear-gradient(transparent, rgba(0,0,0,0.75)); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pix-lif-gal-empty { padding:30px; text-align:center; color:#888; font-size:12px; grid-column:1/-1; }
.pix-lif-gal-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:10px; align-items:center; }
.pix-lif-subf { display:flex; align-items:center; gap:6px; font-size:11px; color:#bbb; cursor:pointer; user-select:none; }
.pix-lif-subf .box { width:12px; height:12px; border:1px solid #555; border-radius:3px; }
.pix-lif-subf.on .box { background:#f66744; border-color:#f66744; }
.pix-lif-done { margin-left:auto; background:#f66744; border:1px solid #f66744; border-radius:6px; padding:6px 16px; font-size:12px; color:#fff; cursor:pointer; }
.pix-lif-done:hover { filter:brightness(1.08); }
/* folder browser */
.pix-lif-browse-pop { position:fixed; z-index:99999; background:#191919; border:1px solid #f66744; border-radius:9px; box-shadow:0 14px 40px rgba(0,0,0,0.6); display:flex; flex-direction:column; max-height:72vh; }
.pix-lif-bp-head { padding:9px 12px; border-bottom:1px solid #333; font-size:12px; color:#f66744; font-weight:600; }
.pix-lif-bp-crumb { padding:7px 12px 4px; font-size:11px; color:#999; word-break:break-all; }
.pix-lif-bp-list { padding:6px 10px 10px; overflow:auto; display:flex; flex-direction:column; gap:4px; }
.pix-lif-bp-item { display:flex; align-items:center; gap:8px; padding:7px 9px; background:#141414; border:1px solid #2c2c2c; border-radius:6px; cursor:pointer; font-size:12px; color:#ddd; }
.pix-lif-bp-item:hover { border-color:#f66744; background:#1c1c1c; }
.pix-lif-bp-item svg { width:13px; height:13px; fill:#f66744; flex:0 0 auto; }
.pix-lif-bp-item .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pix-lif-bp-item .cnt { color:#777; font-size:11px; white-space:nowrap; }
.pix-lif-bp-item.up { color:#9a9a9a; }
.pix-lif-bp-empty { padding:14px; text-align:center; color:#777; font-size:12px; }
.pix-lif-bp-foot { padding:9px 12px; border-top:1px solid #333; display:flex; gap:8px; }
`;
const el = document.createElement("style");
el.id = "pix-lif-css";
el.textContent = css;
document.head.appendChild(el);
}
// ── popup helpers ────────────────────────────────────────────────────────────
function positionBelow(popup, anchorEl, width) {
const r = anchorEl.getBoundingClientRect();
popup.style.position = "fixed";
popup.style.width = `${width}px`;
popup.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - width - 8))}px`;
popup.style.top = `${r.bottom + 4}px`;
requestAnimationFrame(() => {
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) {
popup.style.top = `${Math.max(8, window.innerHeight - 8 - pr.height)}px`;
}
});
}
function attachClosePopup(popup, onClose, ignoreSelector) {
const close = () => {
if (popup._pixClosed) return;
popup._pixClosed = true;
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("wheel", onWheel, true);
document.removeEventListener("keydown", onKey, true);
popup.remove();
onClose?.();
};
// gate on !contains so scrolling/clicking INSIDE the popup never closes it.
// ignoreSelector lets a popup treat a spawned sub-popup (e.g. the sort menu)
// as "inside" so interacting with it doesn't close the parent.
const inside = (t) =>
popup.contains(t) || (ignoreSelector && t.closest && t.closest(ignoreSelector));
const onDown = (e) => { if (!inside(e.target)) close(); };
const onWheel = (e) => { if (!inside(e.target)) close(); };
const onKey = (e) => { if (e.key === "Escape") close(); };
popup._pixClose = close;
setTimeout(() => {
if (popup._pixClosed) return; // closed before the listeners were even added
document.addEventListener("mousedown", onDown, true);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("wheel", onWheel, true);
document.addEventListener("keydown", onKey, true);
}, 0);
return close;
}
export function openMiniMenu(anchorEl, items, currentValue, onPick) {
document.querySelectorAll(".pix-lif-menu").forEach((m) => m._pixClose?.());
const menu = document.createElement("div");
menu.className = "pix-lif-menu";
for (const it of items) {
const row = document.createElement("div");
row.className = "it" + (it.value === currentValue ? " on" : "");
row.innerHTML = `<span>${escapeHtml(it.label)}</span>${it.hint ? `<span style="color:#777">${escapeHtml(it.hint)}</span>` : ""}`;
row.addEventListener("click", () => { menu._pixClose?.(); onPick(it.value); });
menu.appendChild(row);
}
document.body.appendChild(menu);
positionBelow(menu, anchorEl, Math.max(150, anchorEl.getBoundingClientRect().width));
attachClosePopup(menu);
}
// ── node body ────────────────────────────────────────────────────────────────
export function buildRoot() {
const root = document.createElement("div");
root.className = "pix-lif-root";
root.innerHTML =
`<div class="pix-lif-folderrow">` +
`<input class="pix-lif-folder" type="text" spellcheck="false" placeholder="Folder path - type, paste, or Browse">` +
`<button class="pix-lif-browse" type="button" title="Browse for a folder on your computer">${FOLDER_SVG}<span class="pix-lif-browse-lbl">Browse</span></button>` +
`</div>` +
`<button class="pix-lif-pick empty" type="button" title="Choose which images to load">Pick images · 0 / 0</button>` +
`<div class="pix-lif-msg"></div>` +
`<div class="pix-lif-resize-slot"></div>`;
return {
root,
folderInput: root.querySelector(".pix-lif-folder"),
browseBtn: root.querySelector(".pix-lif-browse"),
browseLbl: root.querySelector(".pix-lif-browse-lbl"),
pickBtn: root.querySelector(".pix-lif-pick"),
msgEl: root.querySelector(".pix-lif-msg"),
resizeSlot: root.querySelector(".pix-lif-resize-slot"),
};
}
const SORTS = [
{ value: "name|asc", label: "Name ↑" },
{ value: "name|desc", label: "Name ↓" },
{ value: "date|asc", label: "Date ↑" },
{ value: "date|desc", label: "Date ↓" },
];
// ── the multi-select "Pick images" gallery ──────────────────────────────────
// ctx: { onChange(node), refreshListing(node):Promise }
export function openPickGallery(node, anchorEl, ctx) {
document.querySelectorAll(".pix-lif-gallery").forEach((g) => g._pixClose?.());
const gal = document.createElement("div");
gal.className = "pix-lif-gallery";
gal.innerHTML =
`<div class="pix-lif-gal-head">` +
`<div class="pix-lif-tbtn" data-act="all" title="Select every image in this folder">Select all</div>` +
`<div class="pix-lif-tbtn" data-act="none" title="Deselect all">None</div>` +
`<div class="pix-lif-firstwrap"><div class="pix-lif-tbtn" data-act="first" title="Select the first N images (in sort order)">First</div>` +
`<input class="pix-lif-firstn" type="number" min="1" value="5" title="How many images First selects"></div>` +
`<div class="pix-lif-count"><b class="pix-lif-cn">0</b> / <span class="pix-lif-ct">0</span> selected</div>` +
`</div>` +
`<div class="pix-lif-gal-body"><div class="pix-lif-grid"></div></div>` +
`<div class="pix-lif-gal-foot">` +
`<div class="pix-lif-subf" title="Also include images inside sub-folders"><span class="box"></span> Include subfolders</div>` +
`<div class="pix-lif-tbtn" data-act="sort" title="Change the sort order">Sort: Name ↑</div>` +
`<div class="pix-lif-done" data-act="done" title="Apply this selection and close">Done</div>` +
`</div>`;
document.body.appendChild(gal);
const grid = gal.querySelector(".pix-lif-grid");
const cnEl = gal.querySelector(".pix-lif-cn");
const ctEl = gal.querySelector(".pix-lif-ct");
const firstInput = gal.querySelector(".pix-lif-firstn");
const subfEl = gal.querySelector(".pix-lif-subf");
const sortBtn = gal.querySelector('[data-act="sort"]');
let state = readState(node);
const selSet = new Set(state.selected || []);
function sortLabel() {
return SORTS.find((s) => s.value === `${state.sort}|${state.sort_dir}`)?.label || "Name ↑";
}
function updateCounts() {
const total = (node._pixLifFiles || []).length;
cnEl.textContent = selSet.size;
ctEl.textContent = total;
firstInput.max = String(total || 1); // spinner arrows stop at the image count
}
function commit() {
const sorted = sortFiles(node._pixLifFiles || [], state.sort, state.sort_dir);
const selected = sorted.map((f) => f.file).filter((f) => selSet.has(f));
// re-read fresh state so a concurrent body edit (resize/folder) isn't
// clobbered; only write the keys the gallery owns.
const fresh = readState(node);
fresh.selected = selected;
fresh.sort = state.sort;
fresh.sort_dir = state.sort_dir;
fresh.recursive = state.recursive;
writeState(node, fresh);
state.selected = selected; // keep local snapshot in sync
updateCounts();
ctx.onChange?.(node);
}
function renderGrid() {
grid.innerHTML = "";
sortBtn.textContent = `Sort: ${sortLabel()}`;
subfEl.classList.toggle("on", !!state.recursive);
const files = node._pixLifFiles || [];
if (!files.length) {
const empty = document.createElement("div");
empty.className = "pix-lif-gal-empty";
empty.textContent = node._pixLifListError || "No images in this folder.";
grid.appendChild(empty);
updateCounts();
return;
}
const sorted = sortFiles(files, state.sort, state.sort_dir);
for (const f of sorted) {
const cell = document.createElement("div");
cell.className = "pix-lif-thumb" + (selSet.has(f.file) ? " sel" : "");
cell.innerHTML =
`<img loading="lazy" src="${thumbURL(state.folder, f.file, f.mtime)}" onerror="this.style.display='none'">` +
`<div class="veil"></div><div class="chk">✓</div>` +
`<div class="nm">${escapeHtml(f.name)}</div>`;
cell.addEventListener("click", () => {
if (selSet.has(f.file)) selSet.delete(f.file);
else selSet.add(f.file);
cell.classList.toggle("sel");
commit();
});
grid.appendChild(cell);
}
updateCounts();
}
gal.querySelector('[data-act="all"]').addEventListener("click", () => {
(node._pixLifFiles || []).forEach((f) => selSet.add(f.file));
renderGrid();
commit();
});
gal.querySelector('[data-act="none"]').addEventListener("click", () => {
selSet.clear();
renderGrid();
commit();
});
// Shared "select the first N images (in current sort order)" applier, used by
// BOTH the First button AND typing in the number box, so the field drives the
// selection on its own — not just as a silent parameter for the button.
function applyFirstN(value) {
const total = (node._pixLifFiles || []).length;
const n = Math.max(0, Math.min(parseInt(value, 10) || 0, total));
selSet.clear();
sortFiles(node._pixLifFiles || [], state.sort, state.sort_dir)
.slice(0, n)
.forEach((f) => selSet.add(f.file));
renderGrid();
commit();
return n; // the actual count selected (clamped to how many images exist)
}
gal.querySelector('[data-act="first"]').addEventListener("click", () => {
const applied = applyFirstN(Math.max(1, parseInt(firstInput.value, 10) || 1));
firstInput.value = String(Math.max(1, applied)); // show the real, capped count
});
// Live-apply as the user types / steps the number so "type a number → it
// selects" works without a separate click. Debounced so typing "12" applies
// once (12), not 1-then-12. An EMPTY box is left as "no instruction" (the
// selection is untouched — use None to deselect). Enter flushes immediately;
// blur / spinner commit via the change event.
let firstTimer = null;
const flushFirst = (snap) => {
clearTimeout(firstTimer);
firstTimer = null;
if (gal._pixClosed) return;
const raw = firstInput.value.trim();
if (raw === "") return; // empty = no change
const total = (node._pixLifFiles || []).length;
const typed = Math.max(0, parseInt(raw, 10) || 0);
const applied = applyFirstN(typed);
// Snap the box down to the real max when you ask for more than exist, but
// ONLY on commit (Enter / blur / spinner) — never on the live debounced
// path, or pausing mid-type above the cap would rewrite the box and shift
// the next digit you type.
if (snap && total > 0 && typed > total) firstInput.value = String(applied);
};
firstInput.addEventListener("input", () => {
clearTimeout(firstTimer);
firstTimer = null;
if (firstInput.value.trim() === "") return; // don't clobber on clear-to-retype
firstTimer = setTimeout(() => flushFirst(false), 220); // select live, leave the box alone
});
firstInput.addEventListener("change", () => flushFirst(true)); // blur / spinner caps the box
firstInput.addEventListener("keydown", (e) => {
e.stopImmediatePropagation();
if (e.key === "Enter") { e.preventDefault(); flushFirst(true); }
});
sortBtn.addEventListener("click", () => {
openMiniMenu(sortBtn, SORTS, `${state.sort}|${state.sort_dir}`, (val) => {
const [s, d] = val.split("|");
state.sort = s;
state.sort_dir = d;
writeState(node, state);
commit();
renderGrid();
});
});
subfEl.addEventListener("click", async () => {
state.recursive = !state.recursive;
writeState(node, state);
subfEl.classList.toggle("on", state.recursive);
grid.innerHTML = `<div class="pix-lif-gal-empty">Loading…</div>`;
await ctx.refreshListing(node);
if (gal._pixClosed) return; // gallery was closed during the fetch
state = readState(node);
selSet.clear();
(state.selected || []).forEach((f) => selSet.add(f));
renderGrid();
});
gal.querySelector('[data-act="done"]').addEventListener("click", () => gal._pixClose?.());
node._pixLifGallery = gal;
attachClosePopup(
gal,
() => {
document.querySelectorAll(".pix-lif-menu").forEach((m) => m._pixClose?.());
if (node._pixLifGallery === gal) node._pixLifGallery = null;
},
".pix-lif-menu"
);
positionBelow(gal, anchorEl, Math.min(560, window.innerWidth - 16));
renderGrid();
}
// ── the in-app folder browser ────────────────────────────────────────────────
// ctx: { startPath, onPick(folderPath) }
export function openBrowsePopup(node, anchorEl, ctx) {
document.querySelectorAll(".pix-lif-browse-pop").forEach((p) => p._pixClose?.());
const pop = document.createElement("div");
pop.className = "pix-lif-browse-pop";
pop.innerHTML =
`<div class="pix-lif-bp-head">Choose a folder</div>` +
`<div class="pix-lif-bp-crumb"></div>` +
`<div class="pix-lif-bp-list"></div>` +
`<div class="pix-lif-bp-foot">` +
`<div class="pix-lif-tbtn" data-act="cancel" title="Close without changing the folder">Cancel</div>` +
`<div class="pix-lif-done" data-act="use" title="Use the folder shown above">Use this folder</div>` +
`</div>`;
document.body.appendChild(pop);
const crumb = pop.querySelector(".pix-lif-bp-crumb");
const list = pop.querySelector(".pix-lif-bp-list");
const useBtn = pop.querySelector('[data-act="use"]');
let cur = ctx.startPath || "";
async function nav(path) {
list.innerHTML = `<div class="pix-lif-bp-empty">Loading…</div>`;
const res = await browseFolder(path);
if (pop._pixClosed) return; // popup was closed during the fetch
if (!res.ok) {
list.innerHTML = `<div class="pix-lif-bp-empty">${escapeHtml(res.message || "Could not open this folder.")}</div>`;
return;
}
cur = res.path || "";
// The This-PC / drive-list root has no usable path; dim "Use this folder".
useBtn.style.opacity = cur ? "" : "0.4";
useBtn.style.pointerEvents = cur ? "" : "none";
crumb.innerHTML = cur ? `Location: <b style="color:#ddd">${escapeHtml(cur)}</b>` : "This PC";
list.innerHTML = "";
if (res.parent !== null && res.parent !== undefined) {
const up = document.createElement("div");
up.className = "pix-lif-bp-item up";
up.innerHTML = `<span style="width:13px;text-align:center;flex:0 0 auto">↰</span> <span class="nm">.. (up one level)</span>`;
up.addEventListener("click", () => nav(res.parent || ""));
list.appendChild(up);
}
if (!res.dirs.length) {
const e = document.createElement("div");
e.className = "pix-lif-bp-empty";
e.textContent = cur ? "No sub-folders here. Use this folder." : "No drives found.";
list.appendChild(e);
}
for (const d of res.dirs) {
const it = document.createElement("div");
it.className = "pix-lif-bp-item";
const cnt = d.images >= 0 ? `<span class="cnt">${d.images} image${d.images === 1 ? "" : "s"}</span>` : "";
it.innerHTML = `${FOLDER_SVG}<span class="nm">${escapeHtml(d.name)}</span>${cnt}`;
it.addEventListener("click", () => nav(d.path));
list.appendChild(it);
}
}
pop.querySelector('[data-act="cancel"]').addEventListener("click", () => pop._pixClose?.());
pop.querySelector('[data-act="use"]').addEventListener("click", () => {
if (cur) ctx.onPick(cur);
pop._pixClose?.();
});
node._pixLifBrowsePop = pop;
attachClosePopup(pop, () => {
if (node._pixLifBrowsePop === pop) node._pixLifBrowsePop = null;
});
positionBelow(pop, anchorEl, Math.min(440, window.innerWidth - 16));
nav(cur);
}
+31 -2
View File
@@ -1454,7 +1454,7 @@ app.registerExtension({
// Both renderers open the swatch-palette popup (the Vue menu can't
// render the old nested submenu, and the popup is the unified UI).
options.push(null, {
content: `👑 Pixaroma Node Colors${suffix}`,
content: `👑 Pixaroma Node Colors (\\)${suffix}`,
callback: () => openNodeColorsPalette(targets, node),
});
options.push({
@@ -1502,7 +1502,7 @@ app.registerExtension({
const suffix = targets.length > 1 ? ` (${targets.length} groups)` : "";
options.push(null);
options.push({
content: `👑 Pixaroma Group Colors${suffix}`,
content: `👑 Pixaroma Group Colors (\\)${suffix}`,
callback: () => openGroupColorsPalette(targets, group),
});
options.push({
@@ -1525,5 +1525,34 @@ app.registerExtension({
return options;
};
}
// ── Keyboard shortcut: press "\" to open the color palette for the current
// selection — selected node(s) take priority, else a selected group. Ignored
// while typing in a field, with a modifier held, or when a palette is already
// open. Only acts (and swallows the key) when something is selected, so a bare
// "\" otherwise passes through to ComfyUI.
window.addEventListener("keydown", (e) => {
if (e.key !== "\\" || e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
if (document.querySelector(".pix-nc-pal")) return; // a palette is already open
const c = app.canvas;
if (!c) return;
const nodes = c.selected_nodes ? Object.values(c.selected_nodes) : [];
if (nodes.length) {
e.preventDefault(); e.stopPropagation();
openNodeColorsPalette(getTargetNodes(nodes[0]), nodes[0]);
return;
}
const items = c.selectedItems; // a Set mixing nodes + groups (Node Colors pattern)
if (items && typeof items.forEach === "function") {
let group = null;
items.forEach((it) => { if (!group && typeof it?.recomputeInsideNodes === "function") group = it; });
if (group) {
e.preventDefault(); e.stopPropagation();
openGroupColorsPalette(getTargetGroups(group), group);
}
}
}, true);
},
});
+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");
+266
View File
@@ -0,0 +1,266 @@
import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly, isVueNodes, installResizeFloor } from "../shared/index.mjs";
// Portrait Landscape Pixaroma - two pill buttons (Portrait | Landscape) that
// choose the orientation of the width/height the node outputs. The node's
// width/height are native INT widgets (type a value OR wire one in); the
// Portrait/Landscape choice lives on node.properties.portraitLandscapeState
// and is injected into the hidden PortraitLandscapeState input by the
// app.graphToPrompt hook below (Switch WH / Resolution pattern, Vue Compat #9).
const BRAND = "#f66744";
const STATE_PROP = "portraitLandscapeState";
const HIDDEN_INPUT_NAME = "PortraitLandscapeState";
const DEFAULT_STATE = "portrait";
const BTN_H = 30;
const PAD = 6;
const GAP = 6;
const WIDGET_H = BTN_H + PAD * 2;
// Fresh-drop WIDTH (CLAUDE.md UI conventions #5) - wide enough that the two
// pills don't crowd. The HEIGHT is auto-fit to the content (title + the two
// number fields + the pill row) via node.computeSize(), so there's no empty
// gap under the pills and no over-tall default.
const DEFAULT_W = 240;
const MIN_W = 240;
function injectCSS() {
if (document.getElementById("pix-portrait-landscape-css")) return;
const style = document.createElement("style");
style.id = "pix-portrait-landscape-css";
style.textContent = `
.pix-pl-root {
display: flex;
gap: ${GAP}px;
padding: ${PAD}px;
box-sizing: border-box;
width: 100%;
align-items: stretch;
}
.pix-pl-btn {
flex: 1;
min-width: 0;
height: ${BTN_H}px;
border-radius: 6px;
/* Semi-transparent white overlay (not fixed dark grey) so the inactive
button adapts when the user recolours the node via right-click ->
Colors. Matches Switch WH / Text Pixaroma button style. */
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.3px;
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
font-family: inherit;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pix-pl-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
color: #fff;
}
.pix-pl-btn.active {
background: ${BRAND};
color: #fff;
border-color: ${BRAND};
}
`;
document.head.appendChild(style);
}
function readState(node) {
const v = node.properties?.[STATE_PROP];
return v === "portrait" || v === "landscape" ? v : DEFAULT_STATE;
}
function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = state;
}
function buildRoot(node) {
const root = document.createElement("div");
root.className = "pix-pl-root";
const btnP = document.createElement("button");
btnP.className = "pix-pl-btn";
btnP.textContent = "Portrait";
btnP.title = "Tall: the smaller number becomes the width";
btnP.dataset.value = "portrait";
const btnL = document.createElement("button");
btnL.className = "pix-pl-btn";
btnL.textContent = "Landscape";
btnL.title = "Wide: the larger number becomes the width";
btnL.dataset.value = "landscape";
root.appendChild(btnP);
root.appendChild(btnL);
function refresh() {
const s = readState(node);
btnP.classList.toggle("active", s === "portrait");
btnL.classList.toggle("active", s === "landscape");
}
for (const b of [btnP, btnL]) {
b.addEventListener("click", (e) => {
e.stopPropagation();
writeState(node, b.dataset.value);
refresh();
node.graph?.setDirtyCanvas?.(true, true);
});
}
refresh();
return { root, refresh };
}
function setupNode(node) {
injectCSS();
const { root, refresh } = buildRoot(node);
node._pixPlRoot = root;
node._pixPlRefresh = refresh;
const measureHeight = () => WIDGET_H;
const _plWidget = node.addDOMWidget("pixaroma_portrait_landscape_ui", "pixaroma_portrait_landscape", root, {
// canvasOnly set adaptively below (CLAUDE.md Nodes 2.0): true in legacy
// (out of the Parameters tab), false in Nodes 2.0 (renders in Vue body).
getValue: () => null,
setValue: () => {},
getMinHeight: measureHeight,
getMaxHeight: measureHeight,
margin: 4,
serialize: false,
});
applyAdaptiveCanvasOnly(_plWidget);
// Nodes 2.0 drag floor: pin the pill row's min-height ONLY while a resize
// handle is dragged so it can't be dragged out of the node frame (the Vue
// renderer's drag floor is a live DOM measurement, not getMinHeight). No-op
// in legacy. Uninstalled in onRemoved.
node._pixPlFloorOff = installResizeFloor(root, () => WIDGET_H);
// Fresh-drop size: width = DEFAULT_W, height = LiteGraph's natural content
// height (title + the two number fields + the pill row) so the node hugs its
// content with no empty gap. configure() runs AFTER nodeCreated (Vue Compat
// #8) and overwrites both on workflow restore + duplicate, so existing
// workflows keep their saved size; only fresh drops use this. Mutate the
// array rather than replacing it (plays nicer with any reactive proxy).
const snugH = node.computeSize()[1];
node.size[0] = DEFAULT_W;
node.size[1] = snugH;
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "Pixaroma.PortraitLandscape",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaPortraitLandscape") return;
const _origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = _origConfigure?.apply(this, arguments);
// Defer so node.properties is settled before we read it.
queueMicrotask(() => this._pixPlRefresh?.());
return r;
};
// Clamp manual resize WIDTH so the two pills never clip past the right
// edge. Height is left to LiteGraph (its content-based min floors it, so
// the node can't be dragged shorter than the pills + fields, and there's
// no forced empty space). LEGACY-ONLY: in Nodes 2.0 the rendered size
// lives in the Vue layout store, so clamping node.size there desyncs and
// makes the node jump on a workflow switch (CLAUDE.md "Nodes 2.0
// manual-resize MINIMUM").
const _origOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (!isVueNodes()) {
if (size[0] < MIN_W) size[0] = MIN_W;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
}
if (_origOnResize) return _origOnResize.apply(this, arguments);
};
// Self-heal min size on every paint (Preview Image Pattern #11 + UI
// conventions #7), catching resize paths that bypass onResize (Vue
// Compat #13). Also legacy-only for the reason above.
const _origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (_origDraw) _origDraw.call(this, ctx);
if (isVueNodes()) return;
if (this.flags?.collapsed) return;
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
};
const _origOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._pixPlFloorOff?.();
this._pixPlFloorOff = null;
return _origOnRemoved?.apply(this, arguments);
};
},
nodeCreated(node) {
if (node.comfyClass !== "PixaromaPortraitLandscape") return;
setupNode(node);
},
});
// ── app.graphToPrompt hook (subgraph-safe) ──────────────────────────────
// Same walk-and-inject pattern as Switch WH / Resolution Pixaroma. Required
// because PortraitLandscapeState is `hidden` (no widget) so the workflow JSON
// doesn't carry it; we inject from node.properties at submission time.
function buildPixaromaNodeIndex() {
const index = new Map();
const visit = (graph) => {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (!n) continue;
if (n.comfyClass === "PixaromaPortraitLandscape" || n.type === "PixaromaPortraitLandscape") {
index.set(String(n.id), n);
}
const inner = n.subgraph || n.graph || n._graph;
if (inner && inner !== graph) visit(inner);
}
};
visit(app.graph);
return index;
}
function findPixaromaNode(index, promptId) {
const sId = String(promptId);
if (index.has(sId)) return index.get(sId);
const tail = sId.includes(":") ? sId.slice(sId.lastIndexOf(":") + 1) : null;
if (tail && index.has(tail)) return index.get(tail);
return null;
}
const _origGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await _origGraphToPrompt(...args);
const out = result?.output;
if (out) {
let index = null;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== "PixaromaPortraitLandscape") continue;
if (!index) index = buildPixaromaNodeIndex();
const node = findPixaromaNode(index, id);
const state = node?.properties?.[STATE_PROP] || DEFAULT_STATE;
entry.inputs = entry.inputs || {};
entry.inputs[HIDDEN_INPUT_NAME] = state;
}
}
return result;
};
+21 -4
View File
@@ -766,8 +766,9 @@ function layoutImgStrip(widgetWidth, widgetY, widgetHeight, frames) {
// 2D-wrapped grid layout — native PreviewImage's algorithm exactly.
// Iterate cols from 1..N, pick the count that maximises total image area
// inside the available rect. Cell dimensions are SCALED IMAGE dimensions
// (not innerW/cols x innerH/rows), so cells exactly fit the images with
// NO per-cell letterbox. The grid is then centered inside the widget.
// (not innerW/cols x innerH/rows), so cells exactly fit a UNIFORM batch with
// no letterbox; a MIXED-size batch letterboxes the odd frames within their
// cells (the per-frame fit in the loop). The grid is then centered in the widget.
// Result: thumbnails always look as big as native's, cells touch directly,
// any unused space sits at the edges of the grid (not between cells).
function layoutImgGrid(widgetWidth, widgetY, widgetHeight, frames) {
@@ -818,9 +819,25 @@ function layoutImgGrid(widgetWidth, widgetY, widgetHeight, frames) {
const c = i % cols;
const x = startX + c * cellW;
const y = startY + r * cellH;
// Cell == fitted image rect: no per-cell letterbox to center within.
slots.push({ x, y, w: cellW, h: cellH, idx: i });
imgs.push({ x, y, w: cellW, h: cellH });
// Fit THIS frame inside its cell, preserving its OWN aspect, centered.
// For a uniform batch the image fills the cell exactly (cell == scaled
// frame[0] dims == this frame's dims, so no letterbox). For a MIXED-size
// batch (e.g. Load Images from Folder) the odd-shaped frames letterbox
// instead of stretching. Mirrors layoutImgStrip's per-image fit.
const im = frames[i]?.img;
let imgRect = { x, y, w: cellW, h: cellH };
if (im?.complete && im.naturalWidth > 0 && im.naturalHeight > 0) {
const s = Math.min(cellW / im.naturalWidth, cellH / im.naturalHeight, 1);
const w = Math.round(im.naturalWidth * s);
const h = Math.round(im.naturalHeight * s);
imgRect = {
x: x + Math.floor((cellW - w) / 2),
y: y + Math.floor((cellH - h) / 2),
w, h,
};
}
imgs.push(imgRect);
}
return { slots, imgs };
}
+597
View File
@@ -0,0 +1,597 @@
import { app } from "/scripts/app.js";
import { BRAND, hideJsonWidget, applyAdaptiveCanvasOnly, isVueNodes, measureRootContent } from "../shared/index.mjs";
// ─────────────────────────────────────────────────────────────────────────
// Seed Pixaroma — a seed source with Random / Fixed modes + buttons.
//
// Architecture mirrors Resolution Pixaroma: Python declares a single `hidden`
// SeedState input (no widget, no slot dot); the on-node UI is a DOM widget and
// state lives on node.properties.seedState (LiteGraph serializes it). The
// app.graphToPrompt hook at the bottom injects the resolved per-run seed.
//
// Behaviour:
// • Random mode → each Run rolls a fresh seed; "Last run" shows what ran.
// • Fixed mode → the locked seed is used every Run (repeatable).
// • New fixed random → roll a new seed and switch to Fixed (locks a roll).
// • Use last seed → load the previous run's seed and switch to Fixed.
// • Copy → put the current/last seed on the clipboard.
// • Typing a number in the big field sets that exact seed (switches to Fixed).
// Works in both the Classic and Nodes 2.0 renderers (DOM widget + adaptive
// canvasOnly).
// ─────────────────────────────────────────────────────────────────────────
function injectCSS() {
if (document.getElementById("pixaroma-seed-css")) return;
const css = `
.pix-seed-root {
width: 100%;
box-sizing: border-box;
padding: 8px;
background: #2a2a2a;
border-radius: 4px;
color: #ddd;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Big editable seed number. Dark inset box, monospace, brand border on focus. */
.pix-seed-num {
width: 100%;
box-sizing: border-box;
height: 42px; /* fixed box so the auto-fit font change can't alter the height */
background: #171819;
border: 1px solid #3a3d40;
border-radius: 6px;
padding: 9px 8px;
color: #f2f2f2;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
/* font-size is the MAX; fitSeedFont() shrinks it inline so a long (up to
16-digit) seed fits the narrower Nodes 2.0 body without being cut. */
font-size: 19px;
text-align: center;
letter-spacing: 0;
outline: none;
}
.pix-seed-num:focus { border-color: ${BRAND}; }
/* Random | Fixed segmented pill. Active segment = solid brand. */
.pix-seed-pill {
display: flex;
gap: 0;
background: rgba(255,255,255,0.06);
border-radius: 7px;
padding: 3px;
}
/* Real <button>s (keyboard-reachable), styled to look like flat segments. */
.pix-seed-seg {
flex: 1;
text-align: center;
padding: 6px;
border: none;
border-radius: 5px;
background: transparent;
font-family: inherit;
font-size: 12px;
color: rgba(255,255,255,0.55);
cursor: pointer;
user-select: none;
appearance: none;
-webkit-appearance: none;
outline: none;
transition: background 0.08s, color 0.08s;
}
.pix-seed-seg:hover:not(.active) { color: rgba(255,255,255,0.85); }
.pix-seed-seg.active {
background: ${BRAND};
color: #fff;
font-weight: 500;
}
.pix-seed-seg:focus-visible { outline: 2px solid ${BRAND}; outline-offset: -2px; }
/* Action buttons — semi-transparent white surface, brand fill on hover
(matches the Text / Prompt Pack action-button family). */
.pix-seed-btn {
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.14);
color: rgba(255,255,255,0.85);
font-family: inherit;
font-size: 12px;
cursor: pointer;
user-select: none;
text-align: center;
transition: background 0.08s, border-color 0.08s, color 0.08s;
}
.pix-seed-newrandom { width: 100%; }
.pix-seed-btn:hover {
background: ${BRAND};
border-color: ${BRAND};
color: #fff;
}
.pix-seed-btn:disabled { opacity: 0.4; cursor: default; }
.pix-seed-btn:disabled:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.14);
color: rgba(255,255,255,0.85);
}
/* Success flash after Copy — green wins over hover via higher specificity. */
.pix-seed-btn.is-flashing,
.pix-seed-btn.is-flashing:hover {
background: #3ec371;
border-color: #3ec371;
color: #fff;
}
.pix-seed-row { display: flex; gap: 8px; }
.pix-seed-uselast { flex: 1; }
.pix-seed-copy { flex: 0 0 auto; min-width: 64px; }
.pix-seed-lastrun {
font-size: 11px;
line-height: 1.6; /* room so descenders (y, g) aren't clipped at the node edge */
color: rgba(255,255,255,0.55);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const style = document.createElement("style");
style.id = "pixaroma-seed-css";
style.textContent = css;
document.head.appendChild(style);
}
injectCSS();
// Locked node WIDTH; the layout is fixed (no reason to resize), which also
// sidesteps the Nodes 2.0 resize-floor handling a draggable DOM node needs.
const NODE_W = 226;
// Body height is MEASURED from the actual content (see measureSeedHeight), not a
// hand-guessed constant — guessing the constant is what caused the gap-then-clip
// oscillation. This fallback is used ONLY before the body is laid out (a fresh
// drop / first paint); the real measure takes over the instant children exist.
const WIDGET_H_FALLBACK = 216;
const NODE_H_HINT = WIDGET_H_FALLBACK + 48; // starting height (replace-branch only)
const STATE_PROP = "seedState";
const HIDDEN_INPUT_NAME = "SeedState"; // matches Python INPUT_TYPES key
const DEFAULT_STATE = {
seed: 0,
mode: "random", // "random" | "fixed"
};
// The last-run seed is session-only RUNTIME state on node._pixSeedLastRun
// (NOT node.properties), so a run never rewrites serialized state and can never
// dirty a saved workflow (Vue Compat #18). It doesn't survive a reload, which
// matches the "this session's last run" meaning.
// Roll an exact integer in [0, 2^53) — within JS safe-integer range (so it
// round-trips precisely) and well inside ComfyUI's 0..2^64-1 seed bounds.
function rollSeed() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
function clampSeed(n) {
n = Math.floor(Number(n));
if (!Number.isFinite(n) || n < 0) return 0;
if (n > Number.MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER;
return n;
}
// Shrink the seed number's font until it fits the field (a 16-digit seed
// overflows the narrower Nodes 2.0 body at the base 19px). Idempotent and
// cheap; safe to call repeatedly. No-op until the field is laid out.
function fitSeedFont(num) {
if (!num || !num.isConnected) return;
const MAX = 19, MIN = 11;
num.style.fontSize = MAX + "px";
if (!num.clientWidth) return; // not laid out yet — a scheduled retry will catch it
let fs = MAX, guard = 0;
while (fs > MIN && num.scrollWidth > num.clientWidth + 1 && guard++ < 24) {
fs -= 1;
num.style.fontSize = fs + "px";
}
}
// Measure the body's content height (children offsetHeight + gaps + padding) so
// the node sizes itself with NO hand-guessed constant. Coarse-round to a 4px
// grid so font/sub-pixel jitter can't creep node.size across save/load
// (dirty-on-load, Vue Compat #18). Falls back to a placeholder before the body
// is laid out (children have offsetHeight 0 on a fresh drop).
function measureSeedHeight(root) {
const h = root ? measureRootContent(root) : 0;
if (!(h > 20)) return WIDGET_H_FALLBACK;
return Math.round(h / 4) * 4;
}
function readState(node) {
const v = node.properties?.[STATE_PROP];
if (typeof v === "string" && v) {
try { return { ...DEFAULT_STATE, ...JSON.parse(v) }; }
catch { /* fall through */ }
}
return { ...DEFAULT_STATE };
}
function writeState(node, state) {
if (!node.properties) node.properties = {};
node.properties[STATE_PROP] = JSON.stringify(state);
}
// Fill the "Last run" line for the current state (random: actual last seed;
// fixed: a plain hint so the line keeps a constant height = no layout gap).
function refreshLastRunEl(el, mode, lastSeed) {
if (mode === "fixed") {
el.textContent = "Fixed: same seed every run";
} else if (lastSeed != null) {
el.textContent = `Last run: ${lastSeed}`;
} else {
el.textContent = "Last run: not run yet";
}
}
// Lightweight refresh used by the graphToPrompt hook — updates the last-run
// line + the "Use last seed" disabled state WITHOUT rebuilding the DOM (so an
// in-progress number edit isn't disrupted).
function refreshLastRun(node) {
const root = node._pixSeedRoot;
if (!root || !root.isConnected) return;
const state = readState(node);
const lastSeed = node._pixSeedLastRun ?? null;
const lr = root.querySelector(".pix-seed-lastrun");
if (lr) refreshLastRunEl(lr, state.mode, lastSeed);
const useLast = root.querySelector(".pix-seed-uselast");
if (useLast) useLast.disabled = lastSeed == null;
}
// Toggle the Random|Fixed pill's active segment in place (no DOM rebuild), so
// committing the number field by clicking a pill/button never destroys that
// control mid-click.
function syncModeUI(root, mode) {
root.querySelectorAll(".pix-seed-seg").forEach((s) => {
s.classList.toggle("active", s.dataset.mode === mode);
});
}
function copySeed(node, btn) {
const state = readState(node);
// What-you-see-is-what-you-copy: copy exactly the seed shown in the big
// field. To grab the actual last-run seed in Random mode, click "Use last
// seed" first (it loads that seed into the field), then Copy.
const text = String(clampSeed(state.seed));
const flash = (ok) => {
btn.classList.toggle("is-flashing", ok);
btn.textContent = ok ? "Copied" : "No clipboard";
setTimeout(() => { btn.classList.remove("is-flashing"); btn.textContent = "Copy"; }, 700);
};
// Fallback for INSECURE contexts (ComfyUI served over http://<LAN-IP>), where
// navigator.clipboard is undefined — a throwaway textarea + execCommand still
// works because the click is a user gesture. Mirrors Version Check / Show Text.
const legacyCopy = () => {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
let ok = false;
try {
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
} catch (_e) {
ok = false;
} finally {
ta.remove(); // always remove, even if execCommand throws
}
flash(ok);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(() => flash(true)).catch(legacyCopy);
} else {
legacyCopy();
}
}
// Build the node body into `root` (the DOM widget element). Kept separate from
// renderUI so the INITIAL render can target the captured root element even
// before LiteGraph has attached it — bailing on isConnected there (the first
// version did) left the body blank on a fresh drop.
function buildSeedBody(node, root) {
const state = readState(node);
const lastSeed = node._pixSeedLastRun ?? null; // session-only (see DEFAULT_STATE note)
root.innerHTML = "";
// ── big editable seed number ──────────────────────────────────
const num = document.createElement("input");
num.type = "text";
num.spellcheck = false;
num.autocomplete = "off";
num.inputMode = "numeric";
num.className = "pix-seed-num";
num.value = String(state.seed);
num.title = "The seed value. Type a number to set an exact seed (switches to Fixed).";
const commitNum = () => {
const cleaned = num.value.replace(/[^\d]/g, "");
const cur = readState(node);
// Empty / non-numeric input keeps the existing seed instead of wiping to 0.
const v = cleaned === "" ? cur.seed : clampSeed(cleaned);
num.value = String(v); // reflect any clamp
fitSeedFont(num); // a newly-typed long seed may need a smaller font to fit
// No change -> don't flip the mode on a bare focus/blur, and don't rebuild.
if (v === cur.seed) return;
writeState(node, { ...cur, seed: v, mode: "fixed" });
// Surgical UI sync (NOT a full renderUI rebuild) so blurring the field by
// clicking a pill/button can't destroy that control mid-click.
syncModeUI(root, "fixed");
refreshLastRun(node);
};
num.addEventListener("keydown", (e) => {
e.stopPropagation(); // keep ComfyUI canvas shortcuts from firing while typing
if (e.key === "Enter") { e.preventDefault(); num.blur(); }
});
num.addEventListener("blur", commitNum);
root.appendChild(num);
// ── Random | Fixed pill ───────────────────────────────────────
const pill = document.createElement("div");
pill.className = "pix-seed-pill";
for (const [m, label] of [["random", "Random"], ["fixed", "Fixed"]]) {
const seg = document.createElement("button");
seg.type = "button";
seg.className = "pix-seed-seg" + (state.mode === m ? " active" : "");
seg.textContent = label;
seg.dataset.mode = m;
seg.title = m === "random"
? "Roll a new random seed every run."
: "Keep the same seed every run (repeatable result).";
seg.addEventListener("click", () => {
const cur = readState(node);
if (cur.mode === m) return;
writeState(node, { ...cur, mode: m });
renderUI(node);
});
pill.appendChild(seg);
}
root.appendChild(pill);
// ── New fixed random ──────────────────────────────────────────
const newBtn = document.createElement("button");
newBtn.type = "button";
newBtn.className = "pix-seed-btn pix-seed-newrandom";
newBtn.textContent = "New fixed random";
newBtn.title = "Roll a brand-new random seed and lock it (switches to Fixed).";
newBtn.addEventListener("click", () => {
const cur = readState(node);
writeState(node, { ...cur, seed: rollSeed(), mode: "fixed" });
renderUI(node);
});
root.appendChild(newBtn);
// ── Use last seed · Copy ──────────────────────────────────────
const row = document.createElement("div");
row.className = "pix-seed-row";
const useLast = document.createElement("button");
useLast.type = "button";
useLast.className = "pix-seed-btn pix-seed-uselast";
useLast.textContent = "Use last seed";
useLast.title = "Load the seed from the previous run and lock it (Fixed).";
useLast.disabled = lastSeed == null;
useLast.addEventListener("click", () => {
const last = node._pixSeedLastRun;
if (last == null) return;
const cur = readState(node);
writeState(node, { ...cur, seed: clampSeed(last), mode: "fixed" });
renderUI(node);
});
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "pix-seed-btn pix-seed-copy";
copyBtn.textContent = "Copy";
copyBtn.title = "Copy the seed shown above to the clipboard.";
copyBtn.addEventListener("click", () => copySeed(node, copyBtn));
row.append(useLast, copyBtn);
root.appendChild(row);
// ── last-run line ─────────────────────────────────────────────
const lr = document.createElement("div");
lr.className = "pix-seed-lastrun";
refreshLastRunEl(lr, state.mode, lastSeed);
root.appendChild(lr);
// Fit the number font now and shortly after — covers the fresh-drop case
// where the widget isn't laid out on the first frame (in either renderer).
requestAnimationFrame(() => fitSeedFont(num));
setTimeout(() => fitSeedFont(num), 60);
setTimeout(() => fitSeedFont(num), 220);
}
// Resolve the live root element (adopting the widget's element if Vue swapped
// it out) and (re)build the body. Does NOT bail merely because the element
// isn't attached yet — it builds into the cached root so the content is there
// the moment LiteGraph draws the widget. Used for re-renders (clicks, configure).
function renderUI(node) {
let root = node._pixSeedRoot;
if (!root || !root.isConnected) {
const w = (node.widgets || []).find((x) => x.name === "seed_ui");
const el = w?.element;
if (el) {
root = el.classList?.contains("pix-seed-root")
? el
: (el.querySelector(".pix-seed-root") || (() => {
const r = document.createElement("div");
r.className = "pix-seed-root";
el.appendChild(r);
return r;
})());
node._pixSeedRoot = root;
}
}
if (!root) return;
buildSeedBody(node, root);
}
function setupSeedNode(node) {
// Defensive: hide any SeedState widget (none exists with the hidden input).
hideJsonWidget(node.widgets, HIDDEN_INPUT_NAME);
node.resizable = false;
// Lock WIDTH only; do NOT force the height. getMinHeight (measured) is the
// floor and there is no getMaxHeight, so LiteGraph sizes the node to exactly
// chrome + content — no gap, no clip. Forcing a height is what produced the
// gap-then-clip oscillation. (The replace branch needs a starting height; the
// post-layout snap below corrects it immediately.)
if (Array.isArray(node.size)) { node.size[0] = NODE_W; }
else { node.size = [NODE_W, NODE_H_HINT]; }
const root = document.createElement("div");
root.className = "pix-seed-root";
const _widget = node.addDOMWidget("seed_ui", "pixaroma_seed", root, {
getValue: () => readState(node),
setValue: () => {},
// Measured content height, and NO getMaxHeight: a single-widget node has no
// slack consumer, so an upper cap can only clip the bottom line (that was
// the bug). Without a cap, LiteGraph sizes the body to exactly the content.
getMinHeight: () => measureSeedHeight(root),
margin: 4,
serialize: false, // state lives on node.properties, not this widget
});
applyAdaptiveCanvasOnly(_widget);
// Nodes 2.0: a widget WITH computeLayoutSize is the grower row (CLAUDE.md). It's
// the sole visible widget, so it's safely the grower; minWidth:1 keeps the
// locked width round-tripping (Compare gotcha 2).
_widget.computeLayoutSize = () => ({ minHeight: measureSeedHeight(root), minWidth: 1 });
node._pixSeedRoot = root;
// Deferred initial render — nodeCreated fires BEFORE configure() restores a
// saved workflow's properties (Vue Compat #8). A fresh node (no saved state)
// gets a random starting seed so the big number isn't a lonely 0; a restored
// node already has seedState so we leave it untouched (no dirty-on-load).
queueMicrotask(() => {
if (!node.properties?.[STATE_PROP]) {
writeState(node, { ...DEFAULT_STATE, seed: rollSeed() });
}
// Build into the captured `root` directly — it may not be attached to the
// page yet on a fresh drop, but the content shows once LiteGraph draws it.
buildSeedBody(node, root);
// Once the body is laid out, snap the node to the measured content height
// (LEGACY only — Nodes 2.0 sizes via computeLayoutSize). Coarse-rounded, so
// this is idempotent on reload and never dirties a saved workflow. Two
// attempts cover whichever frame the body finishes laying out on.
const snap = () => {
if (!isVueNodes() && typeof node.setSize === "function") {
node.setSize([NODE_W, node.computeSize()[1]]);
}
};
requestAnimationFrame(snap);
setTimeout(snap, 120);
});
}
app.registerExtension({
name: "Pixaroma.Seed",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PixaromaSeed") return;
// Re-render when a different workflow is configured into an existing node.
const _origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const r = _origConfigure?.apply(this, arguments);
if (this._pixSeedRoot) renderUI(this);
return r;
};
// Lock WIDTH only, and only in LEGACY — in Nodes 2.0 the rendered size lives
// in the Vue layout store, so writing node.size there desyncs it. Height is
// governed by the measured getMinHeight / computeLayoutSize.
const _origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
if (!isVueNodes()) this.size[0] = NODE_W;
if (_origResize) return _origResize.call(this, size);
};
},
nodeCreated(node) {
if (node.comfyClass !== "PixaromaSeed") return;
setupSeedNode(node);
},
});
// ── Inject the resolved per-run seed into the API prompt ──────────────────
// Python's hidden SeedState input gets no value from the workflow JSON (no
// widget). On each graphToPrompt (≈ once per Run) we roll a fresh seed for
// Random-mode nodes, record it as the last-run seed, and inject it. Fixed-mode
// nodes inject their locked value (constant → ComfyUI caches → repeatable).
//
// Subgraph-safe: identify entries by class_type and resolve the live node via
// a recursive walk (composite ids like "5:12"), same as Resolution Pixaroma.
// NOTE: graphToPrompt also runs for non-queue actions (e.g. "Save (API
// format)"); a spurious extra roll there only bumps the cosmetic last-run
// readout — harmless.
function buildSeedNodeIndex() {
const index = new Map(); // String(node.id) → node
const visit = (graph) => {
if (!graph) return;
const nodes = graph._nodes || graph.nodes || [];
for (const n of nodes) {
if (!n) continue;
if (n.comfyClass === "PixaromaSeed" || n.type === "PixaromaSeed") {
index.set(String(n.id), n);
}
const inner = n.subgraph || n.graph || n._graph;
if (inner && inner !== graph) visit(inner);
}
};
visit(app.graph);
return index;
}
function findSeedNode(index, promptId) {
const sId = String(promptId);
if (index.has(sId)) return index.get(sId);
const tail = sId.includes(":") ? sId.slice(sId.lastIndexOf(":") + 1) : null;
if (tail && index.has(tail)) return index.get(tail);
return null;
}
let _seedRunNonce = 0; // monotonic per-call nonce for Random mode (see hook below)
const _origGraphToPrompt = app.graphToPrompt.bind(app);
app.graphToPrompt = async function (...args) {
const result = await _origGraphToPrompt(...args);
try {
const out = result?.output;
if (out) {
let index = null;
for (const id in out) {
const entry = out[id];
if (!entry || entry.class_type !== "PixaromaSeed") continue;
if (!index) index = buildSeedNodeIndex();
const node = findSeedNode(index, id);
let runSeed = 0;
let isRandom = false;
if (node) {
const st = readState(node);
isRandom = st.mode === "random";
runSeed = isRandom ? rollSeed() : clampSeed(st.seed);
// Record the last-run seed on a RUNTIME field only (never
// node.properties) so a run can't dirty a saved workflow (Vue Compat #18).
node._pixSeedLastRun = runSeed;
refreshLastRun(node);
}
entry.inputs = entry.inputs || {};
// Random: add a per-call nonce so the injected string ALWAYS differs and
// the node re-runs even on the ~1-in-2^53 chance two rolls collide.
// Fixed: NO nonce, so the string is constant and ComfyUI caches it
// (repeatable). get_seed ignores the nonce.
entry.inputs[HIDDEN_INPUT_NAME] = isRandom
? JSON.stringify({ runSeed, _n: ++_seedRunNonce })
: JSON.stringify({ runSeed });
}
}
} catch (e) {
console.warn("[PixaromaSeed] graphToPrompt inject failed", e);
}
return result;
};
+61
View File
@@ -0,0 +1,61 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - Get mirrors its Set's colour ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// You colour a Set however you like (right-click -> Colors). A Get that reads
// that Set then takes the SAME colour, so a matching pair is easy to spot, and
// the Get follows along if you recolour the Set later. The Get's dropdown also
// tags each name with that Set's colour. Turn the setting off to leave Gets on
// their own colour.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { GET_TYPE, allLiveGraphs, findSetterByName } from "./scope.mjs";
export const SETTING_ID = "Pixaroma.SetGet.ColorMatch";
export const BRAND_TITLE = "#1d1d1d";
export const BRAND_BODY = "#2a2a2a";
export function isColorMatchOn() {
const v = app.ui?.settings?.getSettingValue?.(SETTING_ID);
return v == null ? true : !!v;
}
// The colour a Get should show = the colour of the Set it reads (whatever the
// user picked for that Set), or brand-dark when there is no Set in scope.
export function setColorFor(getNode) {
const node = findSetterByName(getNode.graph, getNode.widgets?.[0]?.value)?.node;
return {
color: node?.color ?? BRAND_TITLE,
bgcolor: node?.bgcolor ?? BRAND_BODY,
};
}
// Make a Get mirror its Set's colour. Skipped during a load (the saved colour
// is restored by configure) and when the setting is off, and it only writes on
// a real change so it never spams redraws or dirties a workflow.
export function inheritSetColor(getNode) {
if (!getNode || !isColorMatchOn()) return;
try {
if (isGraphLoading()) return;
} catch {
/* ignore */
}
const { color, bgcolor } = setColorFor(getNode);
if (getNode.color !== color || getNode.bgcolor !== bgcolor) {
getNode.color = color;
getNode.bgcolor = bgcolor;
getNode.setDirtyCanvas?.(true, true);
}
}
// Re-mirror every Get in the workflow (called when the setting is toggled).
export function recolorAllGets() {
const graph = app.canvas?.graph || app.graph;
for (const g of allLiveGraphs(graph)) {
for (const n of g._nodes || []) {
if (n.type === GET_TYPE) inheritSetColor(n);
}
}
app.canvas?.setDirty(true, true);
}
+270
View File
@@ -0,0 +1,270 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Get Pixaroma - virtual "named variable" source ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Minimal virtual node: a dropdown that picks which Set Pixaroma to read (by
// name, scoped to this graph + ancestors) + ONE output that matches the chosen
// Set's type. Purely frontend (isVirtualNode); at submission it resolves
// straight through to the Set's real upstream source:
// - getInputLink(slot) → same-graph (classic prompt path)
// - resolveVirtualOutput(slot) → cross-graph / subgraph (native 1.45.15 path)
// Both are implemented so it works on every prompt-build path and inside
// subgraphs without any monkey-patch.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
SET_TYPE,
GET_TYPE,
getLink,
findSetterByName,
findRootGraph,
getVisibleSetNames,
pasteRenameMap,
} from "./scope.mjs";
import { ensureValueWidget, refreshValue, paintReadout } from "./value_preview.mjs";
import { inheritSetColor } from "./colors.mjs";
const BRAND_TITLE = "#1d1d1d";
const BRAND_BODY = "#2a2a2a";
const CATEGORY = "👑 Pixaroma/🔀 Logic & Flow";
export function registerPixaromaGetNode() {
const LGraphNode = LiteGraph.LGraphNode;
class PixaromaGetNode extends LGraphNode {
static title = "Get Pixaroma";
static category = CATEGORY;
constructor(title) {
super(title);
this.isVirtualNode = true;
this.serialize_widgets = true;
this.comfyClass = GET_TYPE; // for the Help (?) toolbar button (convention #16)
this.currentSetter = null;
this.properties = this.properties || {};
if (!this.color) this.color = BRAND_TITLE;
if (!this.bgcolor) this.bgcolor = BRAND_BODY;
// Live combo: values are the in-scope Set names, read fresh each render.
const comboOptions = {};
Object.defineProperty(comboOptions, "values", {
get: () => (this.graph ? getVisibleSetNames(this.graph) : []),
enumerable: true,
configurable: true,
});
const widget = this.addWidget(
"combo",
"name",
"",
() => {
if (!app.configuringGraph) this.onRename();
},
comboOptions
);
// Legacy-only: the bundled ComboWidget mis-handles a live-getter values
// list (empty-array edge case), so drive a ContextMenu ourselves. Vue
// mode renders the combo natively and is left untouched.
const origOnClick = widget.onClick?.bind(widget);
widget.onClick = (params) => {
if (LiteGraph.vueNodesMode) return origOnClick?.(params);
const { e, canvas, node } = params;
const x = e.canvasX - node.pos[0];
const width = widget.width || node.size[0];
if (x < 40) return widget.decrementValue?.({ e, node, canvas });
if (x > width - 40) return widget.incrementValue?.({ e, node, canvas });
const values = comboOptions.values;
if (!values.length) return;
const menu = new LiteGraph.ContextMenu(values, {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: "dark",
callback: (v) => widget.setValue?.(v, { e, node, canvas }),
});
// Colour each entry's left edge by that Set's own colour.
const entries = menu.root?.querySelectorAll(".litemenu-entry");
values.forEach((nm, i) => {
const el = entries?.[i];
if (!el) return;
const setter = findSetterByName(this.graph, nm);
el.style.borderLeft = `4px solid ${setter?.node?.bgcolor || "#888"}`;
el.style.paddingLeft = "8px";
});
};
// Nodes 2.0: swap the options reference (live getter preserved) and
// re-splice the widget so Vue re-extracts the values.
this._refreshComboOptions = () => {
const w = this.widgets?.[0];
if (!w) return;
const fresh = {};
Object.defineProperty(
fresh,
"values",
Object.getOwnPropertyDescriptor(comboOptions, "values")
);
w.options = fresh;
const idx = this.widgets.indexOf(w);
if (idx >= 0) {
this.widgets.splice(idx, 1);
this.widgets.splice(idx, 0, w);
}
};
this.addOutput("*", "*");
}
onDrawForeground(ctx) {
super.onDrawForeground?.(ctx);
paintReadout(this, ctx);
}
onConnectionsChange() {
// Skip during load + the post-configure link-restore (Vue Compat #19).
if (app.configuringGraph || isGraphLoading()) return;
this.validateLinks();
}
setName(name) {
this.widgets[0].value = name;
this.onRename();
}
setType(type) {
this.outputs[0].type = type;
this.outputs[0].name = type;
this.validateLinks();
}
onRename() {
const setter = this.findSetter(this.graph);
const name = this.widgets[0].value;
if (setter) {
this.setType(setter.inputs?.[0]?.type ?? "*");
this.title = `Get: ${name}`;
} else {
this.setType("*");
this.title = name ? `Get: ${name}` : "Get Pixaroma";
}
inheritSetColor(this); // take the chosen Set's colour
refreshValue(this);
app.canvas?.setDirty(true, true);
}
// Drop any output links whose target type is now incompatible.
validateLinks() {
const out = this.outputs?.[0];
if (!this.graph || !out || out.type === "*" || !out.links) return;
for (const linkId of [...out.links]) {
const link = getLink(this.graph, linkId);
if (!link) continue;
const target = this.graph.getNodeById(link.target_id);
const tType = target?.inputs?.[link.target_slot]?.type;
if (tType && tType !== "*" && !String(tType).split(",").includes(out.type)) {
this.graph.removeLink(linkId);
}
}
}
findSetter(graph) {
const r = findSetterByName(graph, this.widgets[0].value);
return r ? r.node : undefined;
}
// Classic prompt path: same-graph resolution. Returns the link feeding the
// matching Set's input, so the prompt builder reads straight through.
getInputLink(slot) {
const name = this.widgets[0].value;
if (!name) return null;
const setter = this.graph?._nodes?.find(
(n) => n.type === SET_TYPE && n.widgets?.[0]?.value === name
);
if (!setter) return null;
const slotInfo = setter.inputs?.[slot];
if (!slotInfo || slotInfo.link == null) return null;
return getLink(this.graph, slotInfo.link);
}
// Subgraph-aware path (native in 1.45.15). Returns the REAL source
// {node, slot} for a cross-graph Set; returns undefined for same-graph so
// the classic getInputLink path above handles it.
resolveVirtualOutput(slot) {
const result = findSetterByName(this.graph, this.widgets[0].value);
if (!result) return undefined;
if (result.graph === this.graph) return undefined;
const { node: setter, graph: setterGraph } = result;
const slotInfo = setter.inputs?.[slot];
if (!slotInfo || slotInfo.link == null) return undefined;
const link = getLink(setterGraph, slotInfo.link);
if (!link) return undefined;
const src = setterGraph.getNodeById(link.origin_id);
if (!src) return undefined;
return { node: src, slot: link.origin_slot };
}
onAdded() {
ensureValueWidget(this);
this._justAdded = true;
}
onConfigure() {
if (this._justAdded && !app.configuringGraph && this.widgets[0].value) {
// If our paired Set was de-duplicated during this same paste, follow it.
const renamed = pasteRenameMap.get(this.widgets[0].value);
if (renamed) this.widgets[0].value = renamed;
// Restore type/title from the setter after a paste (skip if removed).
setTimeout(() => {
if (this.graph) this.onRename();
}, 0);
}
this._justAdded = false;
refreshValue(this);
}
clone() {
const cloned = super.clone();
if (cloned.outputs?.[0]) {
cloned.outputs[0].type = "*";
cloned.outputs[0].name = "*";
}
return cloned;
}
goToSetter() {
const setter = this.currentSetter;
if (!setter) return;
const canvas = app.canvas;
const setterGraph = setter.graph;
if (setterGraph && setterGraph !== this.graph && canvas?.setGraph) {
canvas.setGraph(setterGraph);
setTimeout(() => {
canvas.centerOnNode?.(setter);
canvas.selectNode?.(setter, false);
canvas.setDirty(true, true);
}, 0);
} else {
canvas?.centerOnNode?.(setter);
canvas?.selectNode?.(setter, false);
canvas?.setDirty(true, true);
}
}
getExtraMenuOptions(_, options) {
this.currentSetter = this.findSetter(this.graph);
if (!this.currentSetter) return;
const sameGraph = this.currentSetter.graph === this.graph;
const isRoot = this.currentSetter.graph === findRootGraph(this.graph);
options.unshift({
content: sameGraph
? "Jump to its Set Pixaroma"
: `Jump to its Set (in ${isRoot ? "parent graph" : "subgraph"})`,
callback: () => this.goToSetter(),
});
}
}
LiteGraph.registerNodeType(GET_TYPE, PixaromaGetNode);
}
+92
View File
@@ -0,0 +1,92 @@
// Help content for Set / Get Pixaroma, shown by the orange ? button in the node
// selection toolbar (convention #16). Registered against the virtual nodes'
// comfyClass (set in each node's constructor) so the toolbar finds it.
import { registerNodeHelp } from "../shared/index.mjs";
registerNodeHelp("PixaromaSetNode", {
title: "Set Pixaroma",
tagline: "Store any wire under a name, then read it back anywhere with Get Pixaroma.",
sections: [
{
heading: "What it does",
body:
"Set Pixaroma is a wireless wire. Connect anything into it (an image, a model, a number, a prompt) and give it a name. A Get Pixaroma node anywhere in the workflow can then read that same value by picking the name, with no cable running across the canvas.\n\nIt also has a passthrough output: wire it straight to a node sitting nearby, and use Get nodes for the ones far away - same value either way.\n\nIt lives only in the editor. At run time the value flows straight from the original source, exactly as if you had wired it directly, so it never slows anything down or changes the result.",
},
{
heading: "How to use it",
bullets: [
"Drag a connection into the input. The node takes on that wire's type and colour.",
"Type a name in the field (for example length, base_model, positive).",
"Wire the output to a nearby node directly, or add Get Pixaroma nodes for far-apart ones.",
"Keep the node collapsed (click the dot in its title bar) to keep the canvas tidy.",
],
},
{
heading: "Colours",
body:
"Colour a Set however you like (right-click -> Colors). Any Get that reads it takes the same colour, so matching pairs are easy to spot, and the Gets follow along when you recolour the Set later. Turn off 'Get matches its Set's colour' in Settings (under Pixaroma) to leave Gets on their own colour.",
},
{
heading: "Right-click menu",
defs: [
["Select all its Gets", "Highlights every Get node in this graph that reads this name."],
["Add paired Get Pixaroma", "Drops a Get already set to this name next to the node."],
["Jump to Get in subgraph", "For Gets that live inside a subgraph, opens that subgraph at the Get."],
],
},
{
heading: "Subgraphs (important)",
body:
"Think of subgraphs as nested boxes. A Set reaches INWARD, never outward: it can be read inside its own graph and any subgraph nested deeper inside it, but never by the graph outside it.\n\nSo a Set in the MAIN graph can be read by Gets everywhere, including inside your subgraphs. A Set placed INSIDE a subgraph stays private to that subgraph, so a Get outside cannot see it.\n\nTip: put a Set in the main graph for any value you want to reach everywhere. Because each Set stays in its own box, two separate subgraphs can reuse the same name for different things without clashing.",
},
{
heading: "The little value line",
body:
"When the node is expanded and the value is a plain number, text, or true/false, a small grey readout shows it (for example = 81). Images, models, and latents show no preview, just the name.\n\nIt is a quick helper that peeks at the source field, so a plain Number or Text node reads exactly. If the value is calculated by another node (a math node, a sampler), it is only known once you run, so the line stays blank. It is display only, never saved, and never changes the value that actually flows.",
},
],
footer: "Tip: keep Set/Get nodes collapsed. They are meant to disappear into the background.",
});
registerNodeHelp("PixaromaGetNode", {
title: "Get Pixaroma",
tagline: "Read a value that a Set Pixaroma node stored under a name, with no cable.",
sections: [
{
heading: "What it does",
body:
"Get Pixaroma is the other half of the wireless wire. Pick the name of a Set Pixaroma node from its dropdown and the output carries that Set's value, matching its type and colour. Wire the output wherever you need it.\n\nLike Set, it exists only in the editor. At run time the connection resolves straight to the original source, so there is no extra cost and the result is identical to a direct cable.",
},
{
heading: "How to use it",
bullets: [
"Click the dropdown and choose which Set to read (it lists the Sets it can reach from here).",
"Wire the output into any matching input.",
"Add as many Get nodes as you like for the same name to fan one value out.",
],
},
{
heading: "Colours",
body:
"A Get takes the colour of the Set it reads, so a matching pair is easy to spot, and each name in the dropdown is tagged with that Set's colour. Colour the Set (right-click -> Colors) and the Get follows. Turn off 'Get matches its Set's colour' in Settings (under Pixaroma) to leave Gets alone.",
},
{
heading: "Right-click menu",
defs: [
["Jump to its Set Pixaroma", "Selects and centres the Set this Get is reading, even across subgraphs."],
],
},
{
heading: "Subgraphs (important)",
body:
"A Get reads OUTWARD. It looks for its Set in its own subgraph first, then the graph just outside it, and so on out to the main graph. So a Get inside a subgraph can read a Set in the main graph (or any graph that contains it), but it cannot reach a Set buried inside a different subgraph.\n\nShort version: a Set reaches inward, a Get reads outward. Keep shared Sets in the main graph and any Get can read them. If the dropdown does not list a name you made, that Set is in a box this Get cannot see into.",
},
{
heading: "The little value line",
body:
"When expanded, a plain number, text, or true/false shows as a small grey readout (for example = 81), mirroring the chosen Set. A value calculated by another node is only known once you run, so the line stays blank until then. Display only, never saved, never changes the value that flows.",
},
],
footer: "If the dropdown is empty, add a Set Pixaroma node first and give it a name.",
});
+44
View File
@@ -0,0 +1,44 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - wireless "named variable" node pair ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Pixaroma's own wireless "named variable" node pair, in a PRIVATE namespace:
// classes PixaromaSetNode / PixaromaGetNode with their own registry
// (js/set_get/scope.mjs) that only ever scans Pixaroma Set/Get. It coexists with
// any other pack's Set/Get-style nodes in one workflow with zero interference.
//
// Both are pure-frontend VIRTUAL nodes (isVirtualNode = true): no Python, never
// in the prompt. Resolution at submission goes straight through to the real
// source via getInputLink (same-graph) + resolveVirtualOutput (subgraph). Works
// in both Classic and Nodes 2.0, and inside subgraphs (native path verified on
// frontend 1.45.15).
import { app } from "/scripts/app.js";
import { registerPixaromaSetNode } from "./set_node.mjs";
import { registerPixaromaGetNode } from "./get_node.mjs";
import { startValuePoll } from "./value_preview.mjs";
import { SETTING_ID, recolorAllGets } from "./colors.mjs";
import "./help.mjs"; // registers help for both nodes (convention #16)
app.registerExtension({
name: "Pixaroma.SetGet",
settings: [
{
id: SETTING_ID,
name: "Get matches its Set's colour",
type: "boolean",
defaultValue: true,
tooltip:
"A Get node takes the colour you gave its Set, so matching pairs are easy to spot. Turn off to leave Gets on their own colour.",
category: ["👑 Pixaroma", "Set & Get"],
onChange: () => recolorAllGets(),
},
],
registerCustomNodes() {
registerPixaromaSetNode();
registerPixaromaGetNode();
},
setup() {
startValuePoll();
},
});
+169
View File
@@ -0,0 +1,169 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - cross-graph scope helpers ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// PRIVATE NAMESPACE: every helper here scans ONLY PixaromaSetNode /
// PixaromaGetNode. It never touches any other pack's Set/Get-style nodes, so
// they coexist in one workflow with zero interference.
//
// Subgraph scoping (the model the user picked):
// - A Set is visible to its own graph AND every nested child subgraph
// (propagates DOWN).
// - A Get searches its own graph FIRST, then parent, grandparent, ... root
// (looks UP).
// - Duplicate names are allowed in unrelated (sibling) subgraphs.
//
// The traversal is verified against the native resolveVirtualOutput path in
// frontend 1.45.15, so subgraph flattening at submission "just works".
export const SET_TYPE = "PixaromaSetNode";
export const GET_TYPE = "PixaromaGetNode";
// Paste coordination: when a pasted Set's name is de-duplicated (foo -> foo_0),
// it records old->new here so the Set+Get pasted together stay paired. Both
// nodes' onConfigure run synchronously within the same paste cycle, before the
// setTimeout(0) clears the entry.
export const pasteRenameMap = new Map();
// Compat: graph.links/_links may be a Map or a plain object depending on the
// litegraph version. `== null` intentionally catches both null and undefined.
export function getLink(graph, linkId) {
if (!graph || linkId == null) return null;
if (typeof graph.getLink === "function") return graph.getLink(linkId);
const store = graph._links ?? graph.links;
if (store instanceof Map) return store.get(linkId) ?? null;
return store?.[linkId] ?? null;
}
export function findRootGraph(graph) {
if (!graph) return null;
return graph.rootGraph || graph;
}
// Which SubgraphNode in parentGraph wraps the graph that holds innerNode.
export function findSubgraphNodeFor(parentGraph, innerNode) {
if (!parentGraph?._nodes || !innerNode?.graph) return null;
for (const n of parentGraph._nodes) {
if (n.subgraph && n.subgraph === innerNode.graph) return n;
}
return null;
}
// Every live graph in the workflow: root + every nested subgraph instance, at
// any depth. Enumerated by walking real SubgraphNode.subgraph references (the
// same source getGraphDescendants uses), so it does NOT depend on the internal
// semantics of root._subgraphs - robust for deeply nested subgraphs.
export function allLiveGraphs(graph) {
const root = findRootGraph(graph);
if (!root) return [];
return [root, ...getGraphDescendants(root)];
}
// The graph that directly contains a SubgraphNode wrapping `inner`, or null.
function findParentGraph(inner, root) {
for (const g of allLiveGraphs(root)) {
if (g === inner) continue;
for (const n of g._nodes || []) {
if (n.subgraph === inner) return g;
}
}
return null;
}
// Walk from a graph up to root: [graph, parent, grandparent, ..., root].
// Walks UP by repeatedly finding the parent among the live graphs, so it
// handles arbitrary nesting depth (not just one level).
export function getGraphAncestors(graph) {
if (!graph) return [];
const root = findRootGraph(graph);
if (!root || graph === root) return [root];
const chain = [graph];
const visited = new Set([graph]);
let current = graph;
while (current !== root) {
const parent = findParentGraph(current, root);
if (!parent || visited.has(parent)) break;
visited.add(parent);
chain.push(parent);
current = parent;
}
if (!chain.includes(root)) chain.push(root);
return chain;
}
// All descendant subgraphs of a graph (children, grandchildren, ...).
export function getGraphDescendants(graph, _visited) {
if (!graph?._nodes) return [];
const visited = _visited || new Set();
if (visited.has(graph)) return [];
visited.add(graph);
const out = [];
for (const n of graph._nodes) {
if (n.subgraph && !visited.has(n.subgraph)) {
out.push(n.subgraph);
out.push(...getGraphDescendants(n.subgraph, visited));
}
}
return out;
}
export function collectNodesOfType(graphs, type) {
const out = [];
for (const g of graphs) {
if (!g?._nodes) continue;
for (const node of g._nodes) {
if (node.type === type) out.push({ node, graph: g });
}
}
return out;
}
// Every node of `type` across root + all nested subgraphs. Used for global ops.
export function findAllNodesOfType(graph, type) {
return collectNodesOfType(allLiveGraphs(graph), type);
}
// Scoped setter lookup: own graph first, then ancestors (look UP).
export function findSetterByName(graph, name) {
if (!name) return null;
for (const g of getGraphAncestors(graph)) {
if (!g?._nodes) continue;
for (const node of g._nodes) {
if (node.type === SET_TYPE && node.widgets?.[0]?.value === name) {
return { node, graph: g };
}
}
}
return null;
}
// Scoped getter lookup: own graph + descendants (propagate DOWN).
export function findGettersByName(graph, name) {
if (!name) return [];
const graphs = [graph, ...getGraphDescendants(graph)];
return collectNodesOfType(graphs, GET_TYPE)
.filter((e) => e.node.widgets?.[0]?.value === name);
}
// Visible Set names for a Get's dropdown: own graph + ancestors (what's in
// scope), de-duplicated, sorted. No type filtering (the user picks freely).
export function getVisibleSetNames(graph) {
const seen = new Set();
for (const e of collectNodesOfType(getGraphAncestors(graph), SET_TYPE)) {
const name = e.node.widgets?.[0]?.value;
if (name) seen.add(name);
}
return [...seen].sort();
}
// Force every Get node (root + all subgraphs) to re-read its combo values.
// Needed in Nodes 2.0 (Vue) where the combo options are cached until the
// widget.options reference changes.
export function refreshAllGetCombos(graph) {
for (const g of allLiveGraphs(graph)) {
for (const node of g._nodes || []) {
if (node.type === GET_TYPE) node._refreshComboOptions?.();
}
}
}
+320
View File
@@ -0,0 +1,320 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set Pixaroma - virtual "named variable" sink ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// Virtual node: ONE input (adopts whatever type is wired) + an editable name +
// a passthrough OUTPUT. Wire the output straight to a nearby node, or read the
// value back from anywhere with Get Pixaroma - same value either way. Purely
// frontend (isVirtualNode), so it never reaches the backend; both the Get nodes
// and the passthrough output resolve straight to the real upstream source.
import { app } from "/scripts/app.js";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import {
SET_TYPE,
GET_TYPE,
getLink,
getGraphAncestors,
collectNodesOfType,
findGettersByName,
findSubgraphNodeFor,
refreshAllGetCombos,
pasteRenameMap,
} from "./scope.mjs";
import { ensureValueWidget, refreshValue, paintReadout } from "./value_preview.mjs";
const BRAND_TITLE = "#1d1d1d"; // matches js/brand/index.js (auto-applied to Python nodes; we set it here)
const BRAND_BODY = "#2a2a2a";
const CATEGORY = "👑 Pixaroma/🔀 Logic & Flow";
export function registerPixaromaSetNode() {
const LGraphNode = LiteGraph.LGraphNode;
class PixaromaSetNode extends LGraphNode {
static title = "Set Pixaroma";
static category = CATEGORY;
constructor(title) {
super(title);
// isVirtualNode → pruned from the prompt; resolved on the frontend only.
this.isVirtualNode = true;
this.serialize_widgets = true;
// comfyClass lets the Help (?) toolbar button find our help (convention
// #16). ComfyUI sets it on the def class it generates from node_set_get.py,
// but registerCustomNodes replaces that class with this one, so we set it
// here too.
this.comfyClass = SET_TYPE;
this.properties = this.properties || {};
if (this.properties.previousName == null) this.properties.previousName = "";
// Brand dark defaults. js/brand/index.js applies these via
// beforeRegisterNodeDef, but that runs on ComfyUI's generated def class
// which we replace with this one, so we set them here too. Guarded so
// workflow-restore + right-click Colors still win.
if (!this.color) this.color = BRAND_TITLE;
if (!this.bgcolor) this.bgcolor = BRAND_BODY;
this.addWidget(
"text",
"name",
"",
() => {
if (!this.graph || app.configuringGraph) return;
this.validateName(this.graph);
this.refreshTitle();
this.update();
this.properties.previousName = this.widgets[0].value;
},
{}
);
this.addInput("*", "*");
this.addOutput("*", "*"); // passthrough: emits whatever is wired in
this.refreshTitle();
}
refreshTitle() {
const v = this.widgets?.[0]?.value;
this.title = v ? `Set: ${v}` : "Set Pixaroma";
}
onDrawForeground(ctx) {
super.onDrawForeground?.(ctx);
paintReadout(this, ctx);
}
// Set the input AND the passthrough output to one type. Colour is left to
// the user (right-click -> Colors); the Get nodes mirror whatever it is.
setAdoptedType(type) {
const t = type || "*";
if (this.inputs?.[0]) {
this.inputs[0].type = t;
this.inputs[0].name = t;
}
if (this.outputs?.[0]) {
this.outputs[0].type = t;
this.outputs[0].name = t;
}
}
onConnectionsChange(slotType, slot, isConnect, link_info) {
// Skip during load AND the link-restore window that fires after
// configure() (Vue Compat #19) so we never rewrite saved state.
if (app.configuringGraph || isGraphLoading()) return;
// Input disconnect → revert to the wildcard type.
if (slotType === LiteGraph.INPUT && !isConnect) {
this.setAdoptedType("*");
this.update();
refreshValue(this);
return;
}
// Input connect → adopt the upstream type (input + output both take it).
if (link_info && this.graph && slotType === LiteGraph.INPUT && isConnect) {
let type;
if (typeof link_info.resolve === "function") {
const r = link_info.resolve(this.graph);
type = (r?.subgraphInput ?? r?.output)?.type;
}
if (!type) {
// Fallback: read the origin output type directly.
const src = this.graph.getNodeById(link_info.origin_id);
type = src?.outputs?.[link_info.origin_slot]?.type;
}
if (type) {
this.setAdoptedType(type);
this.validateName(this.graph);
this.properties.previousName = this.widgets[0].value;
}
}
// Output event → keep the output type mirrored to the input.
if (slotType === LiteGraph.OUTPUT && this.outputs?.[0]) {
const inType = this.inputs?.[0]?.type ?? "*";
this.outputs[0].type = inType;
this.outputs[0].name = inType;
}
this.update();
refreshValue(this);
}
// Passthrough: a node wired to our output resolves to whatever feeds our
// input (same source the Get nodes read). isVirtualNode + this is the
// reroute contract; cross-graph is rare for a directly-wired output, so the
// same-graph link path covers it.
getInputLink() {
// The Set has a single input; its passthrough output reads from it.
const si = this.inputs?.[0];
if (!si || si.link == null) return null;
return getLink(this.graph, si.link);
}
// Ensure the name is unique within scope (own graph + ancestors). Returns
// true if it had to change. sameGraphOnly is used on paste/clone.
validateName(graph, sameGraphOnly) {
let value = this.widgets[0].value;
if (value === "") return false;
const scope = sameGraphOnly ? [graph] : getGraphAncestors(graph);
const existing = new Set();
for (const e of collectNodesOfType(scope, SET_TYPE)) {
if (e.node !== this) existing.add(e.node.widgets?.[0]?.value);
}
const original = value;
const base = this._justAdded ? value.replace(/_\d+$/, "") : value;
let tries = 0;
while (existing.has(value)) {
value = base + "_" + tries;
tries++;
}
this.widgets[0].value = value;
this.refreshTitle();
return value !== original;
}
// Push type + rename out to this Set's getters, refresh combos + readout.
update() {
if (!this.graph) return;
const type = this.inputs?.[0]?.type ?? "*";
const name = this.widgets[0].value;
findGettersByName(this.graph, name).forEach((e) => e.node.setType?.(type));
const prev = this.properties.previousName;
if (name && prev && prev !== name) {
findGettersByName(this.graph, prev).forEach((e) => e.node.setName?.(name));
}
refreshAllGetCombos(this.graph);
refreshValue(this);
app.canvas?.setDirty(true, true);
}
findGetterEntries(graph) {
return findGettersByName(graph, this.widgets[0].value);
}
onAdded() {
ensureValueWidget(this);
this._justAdded = true;
if (LiteGraph.vueNodesMode && this.graph && !app.configuringGraph) {
refreshAllGetCombos(this.graph);
}
}
onRemoved() {
if (!LiteGraph.vueNodesMode) return;
const g = this.graph;
if (!g) return;
// onRemoved fires before _nodes is spliced - defer so getters no longer
// see this Set when their combos re-read.
setTimeout(() => refreshAllGetCombos(g), 0);
}
onConfigure() {
// Migration: a Set saved before the passthrough output existed has none -
// add it so older workflows gain the output (flags the workflow modified
// once; re-saving settles it).
if (this.outputs?.length === 0) {
this.addOutput("*", "*");
const t = this.inputs?.[0]?.type ?? "*";
this.outputs[0].type = t;
this.outputs[0].name = t;
}
// Only run paste de-duplication when actually pasting, not on load.
if (this._justAdded && this.graph && !app.configuringGraph) {
const oldName = this.widgets[0].value;
this.validateName(this.graph, true);
const newName = this.widgets[0].value;
if (newName !== oldName) {
// A Get pasted in the same cycle reads this to stay paired.
pasteRenameMap.set(oldName, newName);
setTimeout(() => pasteRenameMap.delete(oldName), 0);
}
if (this.inputs[0]?.link == null) {
this.setAdoptedType("*");
}
}
this._justAdded = false;
this.refreshTitle();
refreshValue(this);
}
clone() {
const cloned = super.clone();
if (cloned.inputs?.[0]) {
cloned.inputs[0].type = "*";
cloned.inputs[0].name = "*";
}
if (cloned.outputs?.[0]) {
cloned.outputs[0].type = "*";
cloned.outputs[0].name = "*";
}
cloned.properties.previousName = "";
return cloned;
}
getExtraMenuOptions(_, options) {
const entries = this.findGetterEntries(this.graph);
const sameGraph = entries.filter((e) => e.graph === this.graph).map((e) => e.node);
const crossGraph = entries.filter((e) => e.graph !== this.graph);
options.unshift({
content: `Select all its Gets (${entries.length})`,
disabled: entries.length === 0,
callback: () => {
const canvas = app.canvas;
if (sameGraph.length && canvas?.selectNodes) {
canvas.selectNodes(sameGraph);
} else if (sameGraph.length && canvas?.selectNode) {
canvas.deselectAllNodes?.();
for (const n of sameGraph) canvas.selectNode(n, true);
}
canvas?.setDirty(true, true);
},
});
// Cross-subgraph getters can't be multi-selected from here - offer to jump.
if (crossGraph.length) {
options.unshift({
content: "Jump to Get in subgraph",
has_submenu: true,
submenu: {
options: crossGraph.map((e) => {
const sgNode = findSubgraphNodeFor(this.graph, e.node);
return {
content: `${e.node.title} (${sgNode?.title || sgNode?.type || "subgraph"})`,
callback: () => {
const canvas = app.canvas;
if (sgNode && canvas?.openSubgraph) {
canvas.openSubgraph(sgNode.subgraph, sgNode);
setTimeout(() => {
canvas.centerOnNode?.(e.node);
canvas.selectNode?.(e.node, false);
canvas.setDirty(true, true);
}, 0);
}
},
};
}),
},
});
}
options.unshift({
content: "Add paired Get Pixaroma",
callback: () => {
const getNode = LiteGraph.createNode(GET_TYPE);
if (!getNode) return;
getNode.pos = [this.pos[0] + this.size[0] + 30, this.pos[1]];
this.graph.add(getNode);
if (getNode.widgets?.[0]) getNode.widgets[0].value = this.widgets[0].value;
getNode.onRename?.();
app.canvas?.selectNode?.(getNode, false);
app.canvas?.setDirty(true, true);
},
});
}
}
LiteGraph.registerNodeType(SET_TYPE, PixaromaSetNode);
}
+317
View File
@@ -0,0 +1,317 @@
// ╔═══════════════════════════════════════════════════════════════╗
// ║ Set / Get Pixaroma - best-effort value readout ║
// ╚═══════════════════════════════════════════════════════════════╝
//
// A tiny, SUBTLE "= 81" line, display-only (never serialized), shown only when
// the node is EXPANDED and the wired value is a simple type (INT / FLOAT /
// STRING / BOOLEAN). Image / latent / model / conditioning show nothing.
//
// The value is read best-effort from the upstream source widget (e.g. a Number
// node feeding the Set). If the source is computed (a math node, a sampler,
// etc.) there is no frontend-knowable value, so we show nothing. A light poll
// keeps it live when the user edits the upstream number.
//
// Rendering is per-renderer so the line sits tight under the name field:
// - Classic (LiteGraph): PAINTED in onDrawForeground at the bottom of the
// node body (node grows by one row when shown). Gives exact positioning.
// - Nodes 2.0 (Vue): a DOM element row (the grid lays it out tightly).
import { app } from "/scripts/app.js";
import { applyAdaptiveCanvasOnly } from "../shared/index.mjs";
import { isGraphLoading } from "../shared/graph_loading.mjs";
import { SET_TYPE, GET_TYPE, getLink, findSetterByName } from "./scope.mjs";
import { inheritSetColor } from "./colors.mjs";
const SIMPLE_TYPES = new Set(["INT", "FLOAT", "NUMBER", "STRING", "BOOLEAN", "BOOL"]);
const CSS_ID = "pix-setget-css";
const ROW_H = 18;
function isVue() {
return !!window.LiteGraph?.vueNodesMode;
}
export function isSimpleType(t) {
return typeof t === "string" && SIMPLE_TYPES.has(t.toUpperCase());
}
function injectCSS() {
if (document.getElementById(CSS_ID)) return;
const el = document.createElement("style");
el.id = CSS_ID;
// Semi-transparent white so it adapts if the user recolors the node body
// (CLAUDE.md UI convention #1). Mono digits, single line, never wraps.
el.textContent = `
.pix-sg-val {
box-sizing: border-box;
width: 100%;
height: ${ROW_H}px;
line-height: ${ROW_H}px;
padding: 0 8px;
font: 11px/${ROW_H}px monospace;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
`;
document.head.appendChild(el);
}
// Round half to even, matching Python's round() (and Number Pixaroma's int
// output), so an INT-typed preview reads the same as the real value.
function bankersRound(v) {
if (Math.abs(v - Math.trunc(v)) === 0.5) {
const f = Math.floor(v);
return f % 2 === 0 ? f : f + 1;
}
return Math.round(v);
}
function fmt(v, type) {
if (v == null) return null;
if (typeof v === "boolean") return v ? "true" : "false";
if (typeof v === "number") {
if (!isFinite(v)) return null; // hide the line for NaN / Infinity
// An INT-typed slot emits a whole number even when the source widget holds
// a decimal (e.g. Number Pixaroma 5.5 -> its int output is 6), so round.
if (String(type).toUpperCase() === "INT") return String(bankersRound(v));
if (Number.isInteger(v)) return String(v);
return String(+v.toFixed(4));
}
let s = String(v).replace(/\s+/g, " ").trim();
if (s === "") return null;
if (s.length > 28) s = s.slice(0, 27) + "…";
return s;
}
// Best-effort read of a primitive value from a source node's widgets, for the
// specific OUTPUT slot the Set is wired to.
function readPrimitiveWidget(node, slot, type) {
if (!node || !Array.isArray(node.widgets)) return null;
const T = String(type).toUpperCase();
const wantNum = /INT|FLOAT|NUMBER/.test(T);
const wantBool = /BOOL/.test(T);
// Prefer the widget whose name matches the output slot we are reading, so a
// node with both a "width" and a "height" output reads the right one. Falls
// back to the first matching-kind widget (e.g. a Number node whose "int" and
// "float" outputs both come from one "value" widget).
const order = node.widgets.slice();
const outName = node.outputs?.[slot]?.name;
if (outName) {
const i = order.findIndex(
(w) => w && (w.name || "").toLowerCase() === String(outName).toLowerCase()
);
if (i > 0) order.unshift(order.splice(i, 1)[0]);
}
for (const w of order) {
if (!w || w.hidden) continue;
const name = (w.name || "").toLowerCase();
if (name === "control_after_generate") continue;
const val = w.value;
if (wantBool && typeof val === "boolean") return val;
if (wantNum && typeof val === "number") return val;
if (wantNum && typeof val === "string" && val.trim() !== "" && !isNaN(+val)) return +val;
if (!wantNum && !wantBool && typeof val === "string") return val;
}
// No widget matched the wanted kind. For numeric / boolean slots do NOT fall
// back to an unrelated widget: a math node's formula text "a + b" is not its
// computed value (7), so showing it would mislead. Only STRING falls back to
// the first text widget.
if (!wantNum && !wantBool) {
const first = node.widgets[0]?.value;
return typeof first === "string" ? first : null;
}
return null;
}
// Follow virtual reroute-like nodes (Reroute, our own Get) up to a real source.
function resolveRealSource(graph, node, slot, depth) {
if (!node || depth > 8) return null;
if (node.type === GET_TYPE) {
const setter = findSetterByName(node.graph || graph, node.widgets?.[0]?.value);
return setter ? setSourceOf(setter.node) : null;
}
if (node.isVirtualNode && typeof node.getInputLink === "function") {
const l = node.getInputLink(slot);
if (!l || l.origin_id == null) return null;
const g = node.graph || graph;
return resolveRealSource(g, g.getNodeById(l.origin_id), l.origin_slot, depth + 1);
}
return { graph: node.graph || graph, node, slot };
}
// {graph, node, slot} feeding a Set node's input (resolving virtuals), or null.
function setSourceOf(setNode) {
const g = setNode?.graph;
const input = setNode?.inputs?.[0];
if (!g || !input || input.link == null) return null;
const link = getLink(g, input.link);
if (!link) return null;
return resolveRealSource(g, g.getNodeById(link.origin_id), link.origin_slot, 0);
}
// Value string for a Set node (null if not a simple type / not derivable).
export function deriveSetValue(setNode) {
const type = setNode?.inputs?.[0]?.type;
if (!isSimpleType(type) || setNode.inputs[0].link == null) return null;
const src = setSourceOf(setNode);
if (!src) return null;
return fmt(readPrimitiveWidget(src.node, src.slot, type), type);
}
// Value string for a Get node via its resolved setter.
export function deriveGetValue(getNode) {
const name = getNode?.widgets?.[0]?.value;
if (!name) return null;
const setter = findSetterByName(getNode.graph, name);
return setter ? deriveSetValue(setter.node) : null;
}
// Nodes 2.0 only: create the DOM element row once (the Vue grid lays it out
// tightly under the name). Classic paints instead, so it makes no DOM widget.
// Failure-tolerant so a DOM hiccup can never block node creation.
export function ensureValueWidget(node) {
if (!isVue()) return null;
if (node._pixSgValEl) return node._pixSgValEl;
try {
injectCSS();
const el = document.createElement("div");
el.className = "pix-sg-val";
el.style.display = "none";
const w = node.addDOMWidget("pix_setget_value", "pixaroma_setget_value", el, {
serialize: false,
getMinHeight: () => (node._pixSgValShown ? ROW_H : 0),
getMaxHeight: () => (node._pixSgValShown ? ROW_H : 0),
});
w.computeSize = () => [node.size?.[0] || 140, node._pixSgValShown ? ROW_H : 0];
applyAdaptiveCanvasOnly(w);
node._pixSgValEl = el;
node._pixSgValWidget = w;
return el;
} catch {
return null;
}
}
// Classic only: paint "= value" right under the NAME FIELD. We anchor to the
// name widget's actual drawn position (widget.last_y), NOT the node bottom,
// because the node's minimum height leaves slack below the last widget and
// anchoring to the bottom floated the line low. Vue uses the DOM element.
// Called from each node's onDrawForeground (draw time, so last_y is set).
export function paintReadout(node, ctx) {
if (isVue() || node.flags?.collapsed) return;
if (!node._pixSgValShown || !node._pixSgValText || !ctx) return;
const w = node.size?.[0] || 0;
if (w <= 0) return;
// Bottom of the last widget (the name field) in body coordinates.
const NW_H = window.LiteGraph?.NODE_WIDGET_HEIGHT || 20;
let anchor = 0;
for (const wd of node.widgets || []) {
if (typeof wd.last_y !== "number") continue;
let h = NW_H;
try {
h = wd.computeSize?.(w)?.[1] || NW_H;
} catch {
/* ignore */
}
anchor = Math.max(anchor, wd.last_y + h);
}
// No widget drawn yet (first frame after add) -> skip; we paint next frame
// once last_y is set, rather than guess a position from the node bottom.
if (anchor <= 0) return;
// Grow the node just enough to enclose the line right under the name
// (converges in one frame; never resize mid-load -> Vue Compat #18).
const needed = anchor + ROW_H;
if ((node.size?.[1] || 0) < needed) {
let loading = false;
try {
loading = isGraphLoading();
} catch {
/* ignore */
}
if (!loading) node.setSize?.([node.size[0], needed]);
}
ctx.save();
ctx.beginPath();
ctx.rect(0, anchor, w, ROW_H); // clip so a long value never spills sideways
ctx.clip();
ctx.fillStyle = "rgba(255, 255, 255, 0.55)";
ctx.font = "11px monospace";
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText("= " + node._pixSgValText, 12, anchor + 13);
ctx.restore();
}
// Classic only: when the readout is HIDDEN, shrink the node back to its content
// (drop the row the painted line used). When SHOWN, paintReadout sizes the node
// to the real name-field anchor during draw, so nothing to do here. Vue's grid
// handles its own height. Never resize during a load (Vue Compat #18).
function fitNodeForReadout(node) {
if (isVue() || node._pixSgValShown) return;
try {
if (isGraphLoading()) return;
} catch {
/* ignore */
}
const base = node.computeSize?.();
if (base) node.setSize?.([node.size?.[0] || base[0], base[1]]);
}
// Recompute the readout for one node. Cheap; safe to call often.
export function refreshValue(node) {
let val = null;
try {
val = node.type === SET_TYPE ? deriveSetValue(node) : deriveGetValue(node);
} catch {
val = null;
}
const show = val != null && val !== "";
const prevShown = !!node._pixSgValShown;
const prevText = node._pixSgValText || "";
node._pixSgValShown = show;
node._pixSgValText = show ? val : "";
// Nodes 2.0: update the DOM element (created only in Vue mode).
const el = node._pixSgValEl;
if (el) {
if (show && val !== prevText) el.textContent = "= " + val;
el.style.display = show ? "block" : "none";
}
if (show !== prevShown) fitNodeForReadout(node);
if (show !== prevShown || val !== prevText) node.setDirtyCanvas?.(true, true);
}
// Single shared poll: keeps readouts live when the user edits an upstream
// number. Only touches expanded Set/Get nodes in the currently-viewed graph,
// and only repaints on a real change.
export function startValuePoll() {
// Window-scoped guard so a module re-import (hot reload) cannot start a second
// interval running in parallel.
if (window.__pixSgValPoll) return;
window.__pixSgValPoll = setInterval(() => {
const g = app.canvas?.graph || app.graph;
if (!g?._nodes) return;
for (const n of g._nodes) {
// A Get mirrors its Set's colour - keep it synced even when collapsed.
if (n.type === GET_TYPE) {
try {
inheritSetColor(n);
} catch {
/* ignore */
}
}
if ((n.type === SET_TYPE || n.type === GET_TYPE) && !n.flags?.collapsed) {
refreshValue(n);
}
}
}, 450);
}
+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"
}
}
+638
View File
@@ -0,0 +1,638 @@
"""Shared geometry + mask + blend helpers for Inpaint Crop / Inpaint Stitch
Pixaroma.
This module is the SINGLE Python source of truth for the inpaint region math.
`js/inpaint_crop/geometry.mjs` mirrors `compute_region` 1:1 so the editor's live
crop-rectangle preview matches what the node actually produces. The narrative
spec is `docs/inpaint-crop-math.md` - update the doc first, then BOTH
implementations, then eyeball-verify the editor preview against a real run.
Zero extra dependencies: torch + PIL + numpy all come from ComfyUI's environment.
scipy is used ONLY for true hole-filling when present; a PIL morphological close
is the fallback (wrapped in try/except per the offline-first rule).
"""
import math
import numpy as np
import torch
from PIL import Image, ImageFilter
try:
from scipy import ndimage as _ndimage # optional, for true binary_fill_holes
_HAS_SCIPY = True
except Exception:
_ndimage = None
_HAS_SCIPY = False
# Must match the constant emitted by node_inpaint_crop.py / consumed by
# node_inpaint_stitch.py - and it is deliberately the SAME string the Image Crop
# / Image Uncrop pair uses, so the two pairs are cross-compatible. Duplicated as a
# plain string (no cross-file import chain), exactly like node_crop / node_uncrop.
PIXAROMA_CROP_INFO = "PIXAROMA_CROP_INFO"
_RESAMPLE = {
"lanczos": Image.LANCZOS,
"bicubic": Image.BICUBIC,
"bilinear": Image.BILINEAR,
"nearest": Image.NEAREST,
}
# Default knob values. The JS DEFAULT_STATE in js/inpaint_crop/index.js MUST stay
# in sync with these (same risk class as every other Pixaroma node).
DEFAULTS = {
"size_mode": "keep", # keep | force | free
"target": 1024, # long-side target for keep mode
"target_w": 1024, # force mode
"target_h": 1024, # force mode
"multiple": 8, # 8 | 16 | 32 | 64
"context_px": 24, # absolute padding added each side
"context_pct": 10.0, # extra padding as a fraction of the bbox (total %)
"mask_grow": 4, # dilate the mask before measuring the bbox
"mask_blur": 4, # soften the OUTPUT mask edge (conditioning), px
"blend": 16, # seam feather px (stitch); also grows the crop context
"invert_mask": False, # flip the mask (1 - mask) before cropping
"fill_holes": True,
"min_size": 256,
"max_size": 2048,
"resample": "lanczos",
"allow_upscale": True,
}
# ─────────────────────────────────────────────────────────────────────────────
# small utils
def _round_mult(v, m):
m = max(1, int(m))
return int(max(m, round(float(v) / m) * m))
def _clampi(v, lo, hi):
return int(max(lo, min(hi, int(round(v)))))
def merge_params(p):
"""Fill any missing keys from DEFAULTS and coerce types."""
out = dict(DEFAULTS)
if isinstance(p, dict):
out.update({k: p[k] for k in p if k in DEFAULTS})
out["size_mode"] = str(out["size_mode"]).lower()
if out["size_mode"] not in ("keep", "force", "free"):
out["size_mode"] = "keep"
out["resample"] = str(out["resample"]).lower()
if out["resample"] not in _RESAMPLE:
out["resample"] = "lanczos"
for k in ("target", "target_w", "target_h", "multiple", "context_px",
"mask_grow", "mask_blur", "blend", "min_size", "max_size"):
out[k] = int(round(float(out[k])))
out["context_pct"] = float(out["context_pct"])
out["fill_holes"] = bool(out["fill_holes"])
out["allow_upscale"] = bool(out["allow_upscale"])
out["invert_mask"] = bool(out["invert_mask"])
out["multiple"] = max(1, out["multiple"])
out["min_size"] = max(8, out["min_size"])
out["max_size"] = max(out["min_size"], out["max_size"])
return out
# ─────────────────────────────────────────────────────────────────────────────
# mask helpers (numpy float HxW, 1 = the area to inpaint)
def mask_to_np(mask, h, w):
"""Coerce a ComfyUI MASK ([1,H,W] / [H,W]) to a float HxW numpy in 0..1,
resized to (h, w) if needed. None -> all zeros."""
if mask is None:
return np.zeros((h, w), dtype=np.float32)
m = mask
if isinstance(m, torch.Tensor):
if m.dim() == 4:
m = m[:, 0] if m.shape[1] == 1 else m[..., 0]
if m.dim() == 3:
m = m[0]
m = m.detach().cpu().float().clamp(0, 1).numpy()
m = np.asarray(m, dtype=np.float32)
if m.ndim != 2:
return np.zeros((h, w), dtype=np.float32)
if m.shape != (h, w):
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.resize((w, h), Image.NEAREST)
m = np.asarray(pim, dtype=np.float32) / 255.0
return np.clip(m, 0.0, 1.0)
def _max1d(a, k):
"""1D max filter along axis 1, odd window k, edge-padded. Separable building
block for a fast box dilation: O(W*H*k) instead of PIL MaxFilter's O(W*H*k^2),
which hung for ~50s on a large mask_grow."""
r = k // 2
ap = np.pad(a, ((0, 0), (r, r)), mode="edge")
win = np.lib.stride_tricks.sliding_window_view(ap, k, axis=1)
return win.max(axis=2)
def _dilate(m_bool, px):
if px <= 0:
return m_bool
k = 2 * int(px) + 1
if _HAS_SCIPY:
# separable max filter -> O(W*H), fast even for a huge kernel
return _ndimage.maximum_filter(m_bool, size=k) > 0
# no-scipy: two separable numpy max passes (rows then cols)
a = m_bool.astype(np.uint8)
a = _max1d(a, k)
a = _max1d(np.ascontiguousarray(a.T), k).T
return a > 0
def fill_holes(m_bool):
"""Fill only SMALL enclosed holes (paint specks / gaps), NEVER a large
subject-shaped hole. A cut-out / background mask (white around a subject) would
otherwise have its subject hole filled by scipy's binary_fill_holes, collapsing
the mask to solid - the "mask vanishes after the crop" bug. scipy when available
(size-limited true fill); otherwise a PIL morphological close (small kernel,
already naturally limited to small holes)."""
if _HAS_SCIPY:
try:
filled = _ndimage.binary_fill_holes(m_bool)
added = filled & ~m_bool # pixels binary_fill_holes would fill
if not added.any():
return filled
# keep only holes up to a small fraction of the image (specks/gaps); a
# subject hole is far bigger and is left UNfilled so the mask survives.
H, W = m_bool.shape
limit = max(256, int(0.005 * H * W)) # ~0.5% of the image
lbl, n = _ndimage.label(added)
sizes = np.bincount(lbl.ravel())
small = np.where(sizes <= limit)[0]
small = small[small != 0] # drop label 0 (the non-hole area)
return m_bool | np.isin(lbl, small)
except Exception:
pass
pim = Image.fromarray((m_bool * 255).astype(np.uint8), "L")
k = 9
pim = pim.filter(ImageFilter.MaxFilter(k)).filter(ImageFilter.MinFilter(k))
return np.asarray(pim, dtype=np.uint8) > 127
def gaussian_blur_np(m, px):
if px <= 0:
return m
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.filter(ImageFilter.GaussianBlur(radius=float(px)))
return np.asarray(pim, dtype=np.float32) / 255.0
def preprocess_mask(m, p):
"""fill-holes + grow on a 0..1 float mask -> a float mask used for the bbox
AND carried full-frame into crop_info (the stitch blend feathers it)."""
mb = m > 0.5
if p["fill_holes"]:
mb = fill_holes(mb)
if p["mask_grow"] > 0:
mb = _dilate(mb, p["mask_grow"])
return mb.astype(np.float32)
def mask_bbox(m_bool):
ys, xs = np.where(m_bool)
if xs.size == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def resolve_inpaint_mask(disk_mask, upstream_mask):
"""Choose which mask the crop uses: the editor-painted mask saved on disk, or
the wired upstream MASK input.
The editor mask wins ONLY when it actually has painted pixels. Clearing the
editor (or saving without painting) writes an all-black mask file while
mask_path stays set, so a naive "disk mask wins whenever present" lets that
empty mask silently override a wired mask - the crop then sees no painted area
and falls back to the whole image (the reported bug). So an empty/absent editor
mask falls back to the wired mask. This is what makes the `mask` input tooltip
true: clear it or never paint, and the wired mask is used as-is. Returns the
chosen mask tensor, or None when neither has content."""
if (isinstance(disk_mask, torch.Tensor) and disk_mask.numel()
and float(disk_mask.detach().max()) > 1e-6):
return disk_mask
if isinstance(upstream_mask, torch.Tensor):
return upstream_mask
return disk_mask
# ─────────────────────────────────────────────────────────────────────────────
# THE GEOMETRY (mirror of js/inpaint_crop/geometry.mjs)
def compute_region(bbox, W, H, p):
"""From a mask bbox (x0,y0,x1,y1) and the image size, return the source crop
region + the model-friendly output size.
Returns {rx, ry, rw, rh, out_w, out_h}. `r*` are integer SOURCE pixels (the
rectangle that gets cropped); `out_*` is the size the crop is resized to (and
the node's width/height outputs). If bbox is None the whole image is used.
"""
p = merge_params(p)
W = int(W); H = int(H)
if bbox is None:
x0, y0, x1, y1 = 0, 0, W, H
else:
x0, y0, x1, y1 = bbox
bw = max(1.0, float(x1 - x0))
bh = max(1.0, float(y1 - y0))
cx = (x0 + x1) / 2.0
cy = (y0 + y1) / 2.0
# context expand: max(context_px, blend) each side + context_pct of bbox total.
# The max-with-blend (Option B) gives the seam feather room to reach 0 INSIDE the
# crop, so a big softness grows the crop instead of being clamped at stitch time.
ctx = max(p["context_px"], p["blend"])
rw = bw + 2.0 * ctx + bw * p["context_pct"] / 100.0
rh = bh + 2.0 * ctx + bh * p["context_pct"] / 100.0
mode = p["size_mode"]
mult = p["multiple"]
# force mode: grow the WANTED region to the target aspect first (the output is
# the fixed target size, set below).
tw = th = 0
if mode == "force":
tw = max(mult, _round_mult(p["target_w"], mult))
th = max(mult, _round_mult(p["target_h"], mult))
target_aspect = tw / float(th)
if rw / rh < target_aspect:
rw = rh * target_aspect
else:
rh = rw / target_aspect
# place + clamp the SOURCE region inside the image
rw_i = max(1, min(int(round(rw)), W))
rh_i = max(1, min(int(round(rh)), H))
if mode == "force":
# keep the source aspect == the target aspect after the image-bound clamp
# (which can clip one axis and not the other), so the resize never stretches.
aspect = tw / float(th)
if rw_i > rh_i * aspect:
rw_i = max(1, int(round(rh_i * aspect)))
else:
rh_i = max(1, int(round(rw_i / aspect)))
rx = _clampi(cx - rw_i / 2.0, 0, W - rw_i)
ry = _clampi(cy - rh_i / 2.0, 0, H - rh_i)
# output size is derived from the CLAMPED source rect (rw_i, rh_i), NOT the
# un-clamped wanted region - so the crop is resized at its real aspect and is
# NEVER stretched when the image edge clipped the region (e.g. a big softness on
# a mask near the border: the source clamps to the image, so the output must too,
# or the crop/mask comes out squished).
if mode == "force":
out_w, out_h = tw, th
elif mode == "free":
# free = "multiple only": keep the cropped rect's own size, just round to the
# multiple. When the LONG side exceeds max_size, scale BOTH axes by one factor
# (not each axis independently) so the output keeps the source aspect - an
# independent per-axis cap stretched a wide/tall crop. No min_size bump (free
# preserves the source size; only the max ceiling applies).
ow, oh = float(rw_i), float(rh_i)
big = max(ow, oh)
if big > p["max_size"]:
k = p["max_size"] / big
ow *= k; oh *= k
out_w = _round_mult(ow, mult)
out_h = _round_mult(oh, mult)
else: # keep shape: scale the CROPPED rect's long side to target, keep aspect
long_side = max(rw_i, rh_i)
s = p["target"] / long_side if long_side > 0 else 1.0
if not p["allow_upscale"]:
s = min(s, 1.0)
ow = rw_i * s
oh = rh_i * s
# min_size bump FIRST (scale both up so the short side reaches min_size),
# then the max_size clamp LAST as the hard ceiling - so an extreme-aspect
# crop can't blow the long side past max_size into an OOM tensor (the short
# side may then end up < min_size, which is acceptable).
small = min(ow, oh)
if small < p["min_size"]:
k = p["min_size"] / small
ow *= k; oh *= k
big = max(ow, oh)
if big > p["max_size"]:
k = p["max_size"] / big
ow *= k; oh *= k
out_w = _round_mult(ow, mult)
out_h = _round_mult(oh, mult)
out_w = max(mult, int(out_w))
out_h = max(mult, int(out_h))
return {"rx": rx, "ry": ry, "rw": rw_i, "rh": rh_i,
"out_w": out_w, "out_h": out_h}
# ─────────────────────────────────────────────────────────────────────────────
# image / mask resize (PIL for quality + Lanczos support)
def resize_image_tensor(t, w, h, resample="lanczos"):
"""[B,H,W,3] float 0..1 -> [B,h,w,3]. PIL per frame (handles Lanczos).
Short-circuits when the size already matches so an identity crop stays
pixel-exact (PIL resize to the same size still resamples)."""
if int(w) == int(t.shape[2]) and int(h) == int(t.shape[1]):
return t
filt = _RESAMPLE.get(resample, Image.LANCZOS)
frames = []
for i in range(int(t.shape[0])):
arr = (t[i].clamp(0, 1).cpu().numpy() * 255.0 + 0.5).astype(np.uint8)
pim = Image.fromarray(arr, "RGB").resize((int(w), int(h)), filt)
frames.append(np.asarray(pim, dtype=np.float32) / 255.0)
return torch.from_numpy(np.stack(frames, 0))
def resize_mask_np(m, w, h, resample="bilinear"):
if int(w) == int(m.shape[1]) and int(h) == int(m.shape[0]):
return np.clip(m, 0, 1).astype(np.float32)
filt = _RESAMPLE.get(resample, Image.BILINEAR)
pim = Image.fromarray((np.clip(m, 0, 1) * 255).astype(np.uint8), "L")
pim = pim.resize((int(w), int(h)), filt)
return np.asarray(pim, dtype=np.float32) / 255.0
# ─────────────────────────────────────────────────────────────────────────────
# CROP (called by node_inpaint_crop.py)
def apply_inpaint_crop(image, mask, p):
"""image [B,H,W,3], mask [1,H,W]|None, p = knob dict.
Returns (cropped_image[B,out_h,out_w,3], out_mask[1,out_h,out_w], crop_info,
out_w, out_h). out_mask is the conditioning mask (grown + blurred) at output
resolution; crop_info carries the full original image + full-frame processed
mask so stitch can paste back + produce a full-frame result.
"""
p = merge_params(p)
# Coerce the input to RGB. Remove Background Pixaroma (and any cut-out) hands us a
# 4-channel RGBA image; left as-is it rides into crop_info["image"] and makes
# Inpaint Stitch throw on the 3-vs-4 channel paste (its except then silently passes
# the result through as the "original"). Premultiply RGBA over black so a cut-out
# reads as the subject on black (matching the editor/preview), not the leftover
# background still sitting under the alpha.
if image.shape[-1] == 4:
image = (image[..., :3] * image[..., 3:4]).contiguous()
elif image.shape[-1] > 4:
image = image[..., :3].contiguous()
B, H, W = int(image.shape[0]), int(image.shape[1]), int(image.shape[2])
raw = mask_to_np(mask, H, W)
if p["invert_mask"] and mask is not None:
raw = 1.0 - raw # flip which area is inpainted (Invert toggle)
proc = preprocess_mask(raw, p) # binary core (fill-holes + grow) for the bbox
bbox = mask_bbox(proc > 0.5)
# Keep the brush's painted soft edge: the filled+grown core is opaque, the
# softly-painted rim outside it is preserved (so the soft-edge brush + mask_blur
# actually reach the conditioning mask instead of being thresholded away).
softm = np.maximum(raw, proc)
region = compute_region(bbox, W, H, p)
rx, ry, rw, rh = region["rx"], region["ry"], region["rw"], region["rh"]
out_w, out_h = region["out_w"], region["out_h"]
# crop + resize the image (all batch frames, same rect). Keep the result on
# the input's device/dtype (the resize routes through PIL on the CPU).
crop = image[:, ry:ry + rh, rx:rx + rw, :].contiguous()
cropped_image = resize_image_tensor(crop, out_w, out_h, p["resample"]).to(image.device, image.dtype)
# resize the mask with NEAREST (bilinear would add its own gradient halo);
# the mask_blur Gaussian is the ONLY intended softening of the conditioning.
mreg = softm[ry:ry + rh, rx:rx + rw]
mout = resize_mask_np(mreg, out_w, out_h, "nearest")
mout = gaussian_blur_np(mout, p["mask_blur"])
out_mask = torch.from_numpy(np.clip(mout, 0, 1)[None, ...].astype(np.float32)).to(image.device)
# keep the carried mask float32 (NOT image.dtype) so it matches out_mask and a
# strict downstream mask op never sees a float16 mask on an fp16 image pipeline.
full_mask = torch.from_numpy(softm[None, ...].astype(np.float32)).to(image.device)
crop_info = {
"image": image, "mask": full_mask,
"x": rx, "y": ry, "w": rw, "h": rh,
"orig_w": W, "orig_h": H,
}
return cropped_image, out_mask, crop_info, out_w, out_h
# ─────────────────────────────────────────────────────────────────────────────
# STITCH (called by node_inpaint_stitch.py)
def _feather_alpha(alpha, feather):
"""Ramp the alpha to 0 at the rectangle edge over `feather` px (distance-to-
edge), so a pasted crop dissolves into the original. Same idea as Image
Uncrop's _feather_alpha but operating on an arbitrary [ch,cw] alpha."""
k = int(feather)
if k <= 0:
return alpha
ch, cw = int(alpha.shape[-2]), int(alpha.shape[-1])
# Cap the feather at ~half the smaller side so the interior stays fully opaque. A
# feather wider than that never reaches 1 anywhere, so the WHOLE rectangle paste
# goes translucent and ghosts the original through (only bites on a small crop +
# big feather, e.g. a large Stitch softness override on a tight crop).
k = min(k, max(1, (min(ch, cw) - 1) // 2))
ys = torch.arange(ch, dtype=torch.float32).view(ch, 1)
xs = torch.arange(cw, dtype=torch.float32).view(1, cw)
dist = torch.minimum(torch.minimum(ys, (ch - 1) - ys),
torch.minimum(xs, (cw - 1) - xs))
ramp = (dist / float(k)).clamp(0.0, 1.0)
return (alpha * ramp).clamp(0.0, 1.0)
def _blur_alpha(alpha, blend):
"""Soften a MASK's edge by `blend` px so the masked paste fades smoothly into
the surroundings (mask-aware blend). Distinct from _feather_alpha, which fades
the rectangle boundary (whole-crop mode).
OUTWARD-only feather: alpha is 1.0 everywhere INSIDE the mask and AT its edge,
ramping to 0 over `blend` px OUTSIDE. So the new content fully covers the
masked area (the old content can NEVER show through at the new object's own
edge) and only the transition into the surroundings is softened. A centred
feather made the masked edge itself semi-transparent, which read as a soft
ghost/halo of the old content. Coverage/grow is the crop node's mask_grow
job; this only softens the outer seam.
The crop node now grows the crop context to max(context_px, blend)
(compute_region, Option B), so the mask always sits >= blend px from the crop
edge and the outward feather reaches 0 BEFORE the border on its own. That
replaced the old rect-edge guard + core-opaque clamp (which crushed a too-wide
feather and could ghost a mask near the crop edge); the smoothstep is opaque
inside by construction, so no clamp is needed.
"""
k = int(blend)
if k <= 0:
return alpha
a_np = np.clip(alpha.detach().cpu().numpy(), 0.0, 1.0)
mb = a_np > 0.5
if not mb.any() or mb.all():
return torch.from_numpy(a_np.astype(np.float32))
if _HAS_SCIPY:
# signed distance to the mask edge (+ inside, - outside, in px). Map so
# signed >= 0 -> 1.0 and signed in [-k,0] ramps 1 -> 0 (smoothstep).
signed = (_ndimage.distance_transform_edt(mb)
- _ndimage.distance_transform_edt(~mb))
t = np.clip(signed / float(k) + 1.0, 0.0, 1.0)
soft = (t * t * (3.0 - 2.0 * t)).astype(np.float32)
else:
# fallback (no scipy): gaussian-blur the binary mask for an outward
# falloff, then force the interior back to 1.0 (outward-only).
mbf = mb.astype(np.float32)
blurred = gaussian_blur_np(mbf, max(1, int(k / 1.7)))
soft = np.where(mbf > 0.5, 1.0, blurred).astype(np.float32)
return torch.from_numpy(np.clip(soft, 0.0, 1.0).astype(np.float32))
def _color_match(patch, ref, region_mask, strength):
"""Shift patch color stats toward `ref` within the masked area. strength:
'subtle' = match mean, 'strong' = match mean + std. patch/ref [ch,cw,3]."""
if strength == "off":
return patch
w = region_mask.reshape(-1, 1)
wsum = float(w.sum()) + 1e-6
pf = patch.reshape(-1, 3)
rf = ref.reshape(-1, 3)
pm = (pf * w).sum(0) / wsum
rm = (rf * w).sum(0) / wsum
if strength == "strong":
pv = ((pf - pm) ** 2 * w).sum(0) / wsum
rv = ((rf - rm) ** 2 * w).sum(0) / wsum
scale = (rv.clamp_min(1e-6).sqrt()) / (pv.clamp_min(1e-6).sqrt())
scale = scale.clamp(0.5, 2.0)
out = (pf - pm) * scale + rm
else:
out = pf - pm + rm
return out.reshape(patch.shape).clamp(0.0, 1.0)
def resolve_seam(crop_info, softness, blend_mode):
"""Resolve the effective seam feather (px) + blend mode for the stitch, letting
the Inpaint Stitch node OVERRIDE what rode in on crop_info from the Crop node -
so the blend can be tuned at stitch time WITHOUT re-running the sampler.
softness: int; < 0 (the -1 "inherit" default) keeps crop_info['blend'].
blend_mode: 'from crop' / '' = keep crop_info['blend_mode']; else 'mask' or
'whole crop' (spaces -> underscores). Returns (blend:int 0..150, mode:str).
"""
if not isinstance(crop_info, dict):
crop_info = {}
try:
s = int(softness)
except (TypeError, ValueError):
s = -1
if s < 0:
try:
s = int(crop_info.get("blend", 16))
except (TypeError, ValueError):
s = 16
blend = max(0, min(150, s))
bm = str(blend_mode if blend_mode is not None else "from crop").strip().lower()
if bm in ("", "from crop", "inherit"):
bm = str(crop_info.get("blend_mode", "mask"))
bm = bm.replace(" ", "_")
if bm not in ("mask", "whole_crop"):
bm = "mask"
return blend, bm
def stitch_back(crop_info, image, mask, blend, blend_mode, color_match):
"""Paste the inpainted `image` back onto crop_info['image'] at the recorded
region, blended seamlessly. Returns (result[B,H,W,3], original[B,H,W,3])."""
base = crop_info["image"]
# defensive RGB coercion: a crop_info from an RGBA source (a hand-built dict, or an
# Image Crop fed an RGBA image) would mismatch the RGB patch on the paste below.
# Inpaint Crop already coerces its input, so this only catches other sources.
if isinstance(base, torch.Tensor) and base.dim() == 4 and base.shape[-1] == 4:
base = (base[..., :3] * base[..., 3:4]).contiguous()
elif isinstance(base, torch.Tensor) and base.dim() == 4 and base.shape[-1] > 4:
base = base[..., :3].contiguous()
H, W = int(base.shape[1]), int(base.shape[2])
x = _clampi(crop_info.get("x", 0), 0, W - 1)
y = _clampi(crop_info.get("y", 0), 0, H - 1)
cw = int(max(1, min(int(crop_info.get("w", W)), W - x)))
ch = int(max(1, min(int(crop_info.get("h", H)), H - y)))
patch = image
if not isinstance(patch, torch.Tensor) or patch.dim() != 4:
patch = base.new_zeros((1, ch, cw, base.shape[3]))
if int(patch.shape[1]) != ch or int(patch.shape[2]) != cw:
patch = resize_image_tensor(patch, cw, ch, "lanczos").to(base.device, base.dtype)
# region alpha (1 = take the patch)
if blend_mode == "whole_crop":
a = torch.ones((ch, cw), dtype=torch.float32)
else: # mask-aware: prefer the wired mask, else the painted mask from crop_info
if isinstance(mask, torch.Tensor):
# mask_to_np already resizes to (ch,cw) with NEAREST - keep it crisp;
# the seam softening is _blur_alpha's job (a bilinear resize here would
# smear a second gradient on top of the feather = a visible halo).
a = torch.from_numpy(np.ascontiguousarray(mask_to_np(mask, ch, cw), dtype=np.float32))
elif isinstance(crop_info.get("mask"), torch.Tensor):
fm = mask_to_np(crop_info["mask"], H, W)[y:y + ch, x:x + cw]
a = torch.from_numpy(np.ascontiguousarray(fm, dtype=np.float32))
else:
a = torch.ones((ch, cw), dtype=torch.float32)
# whole_crop: fade the rectangle boundary. mask: soften the mask's OWN edge.
if blend_mode == "whole_crop":
a = _feather_alpha(a.clamp(0, 1), blend)
else:
ac = a.clamp(0, 1)
ab = ac > 0.5
if not bool(ab.any()) or bool(ab.all()):
# No mask EDGE to soften - an Image Crop crop_info with no per-pixel mask
# (all-zeros), an all-ones mask, or a whole-image inpaint with nothing
# painted. _blur_alpha would return zeros (paste NOTHING - the inpaint is
# lost) or ones (a hard rectangle seam). Fall back to the rectangle feather
# over the full crop, so the edited crop IS pasted with a soft seam and the
# Softness slider still works for the Image Crop interop.
a = _feather_alpha(torch.ones((ch, cw), dtype=torch.float32), blend)
else:
a = _blur_alpha(ac, blend)
out = base.clone()
B = int(out.shape[0])
if patch.shape[0] != B:
if patch.shape[0] == 1:
patch = patch.repeat(B, 1, 1, 1)
elif B == 1:
out = out.repeat(patch.shape[0], 1, 1, 1)
B = patch.shape[0]
else:
n = min(B, patch.shape[0])
print(f"[PixaromaInpaintStitch] batch mismatch: original {B} vs inpainted "
f"{patch.shape[0]} - using {n} frames")
out, patch = out[:n], patch[:n]
B = n
patch = patch.to(out.device, out.dtype)
if color_match and color_match != "off":
patch = patch.clone() # don't mutate the caller's tensor in place (it may be cached upstream)
# Reference = the UNMASKED context (the surroundings OUTSIDE the mask), NOT
# the masked area or the whole crop. Matching to anything that includes the
# masked area drags the inpaint's DELIBERATELY changed colors back toward the
# original (a red->white dress goes pink). Matching to the context corrects
# only the lighting/tone drift the model introduced in the unchanged
# surroundings, which is what actually makes the seam vanish.
am = np.clip(a.detach().cpu().numpy(), 0.0, 1.0)
ctx = (am < 0.5).astype(np.float32)
if ctx.sum() < 0.02 * ctx.size: # mask ~fills the crop -> no context to match
ctx = np.ones_like(am, dtype=np.float32)
ac = torch.from_numpy(np.ascontiguousarray(ctx))
for b in range(B): # match EVERY frame, not just frame 0 (video / batch)
region_b = out[b, y:y + ch, x:x + cw, :3].detach().cpu()
p_b = patch[b, :, :, :3].detach().cpu()
matched = _color_match(p_b, region_b, ac, color_match)
patch[b, :, :, :3] = matched.to(patch.device, patch.dtype)
av = a[None, ..., None].to(out.device, out.dtype)
region = out[:, y:y + ch, x:x + cw, :]
out[:, y:y + ch, x:x + cw, :] = patch[..., :region.shape[-1]] * av + region * (1.0 - av)
original = base
if original.shape[0] != out.shape[0]:
if original.shape[0] == 1:
original = original.repeat(out.shape[0], 1, 1, 1)
else:
# the result was trimmed to min(B,C) above - trim original to match,
# or the two outputs would have mismatched batch sizes.
original = original[:out.shape[0]]
return out.clamp(0, 1), original.clamp(0, 1)
+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 =
+1 -1
View File
@@ -14,7 +14,7 @@ class Pixaroma3D:
"optional": FlexibleOptionalInputType(any_type),
}
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🎨 Editors"
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The rendered 3D scene.", "Image width in pixels.", "Image height in pixels.")
+1 -1
View File
@@ -138,7 +138,7 @@ class PixaromaAudioStudio:
"Frames per second of the render.",
)
FUNCTION = "generate"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🎨 Editors"
def generate(self, studio_json="{}", image=None, audio=None):
try:
+1 -1
View File
@@ -37,7 +37,7 @@ class PixaromaCompare:
RETURN_TYPES = ()
FUNCTION = "compare_images"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
def compare_images(self, image1, image2):
results = []
+1 -1
View File
@@ -367,7 +367,7 @@ class PixaromaImageComposition:
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The composited image.", "Image width in pixels.", "Image height in pixels.")
FUNCTION = "load_composite"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🎨 Editors"
OUTPUT_NODE = True
@classmethod
+237 -59
View File
@@ -8,6 +8,13 @@ import folder_paths
from .node_ref import any_type, FlexibleOptionalInputType
# Custom wire type carrying everything Image Uncrop Pixaroma needs to paste an
# edited crop back onto the original: the full original image plus the crop's
# top-left (x, y) and size (w, h). Defined as a plain string so the matching
# constant in node_uncrop.py stays decoupled (no cross-file import chain).
PIXAROMA_CROP_INFO = "PIXAROMA_CROP_INFO"
class _CropOptionalInputs(FlexibleOptionalInputType):
"""FlexibleOptionalInputType that ALSO declares a concrete optional IMAGE
'image' input.
@@ -32,6 +39,16 @@ class _CropOptionalInputs(FlexibleOptionalInputType):
"load the image directly and disconnect this wire."
),
})
self["mask"] = ("MASK", {
"tooltip": (
"Optional. Wire a MASK here (for example Load Image's MASK "
"output) to crop the transparency with the exact same box as the "
"image - the result comes out the 'mask' output. Leave it unwired "
"and the mask output is a fully-opaque mask sized to the crop "
"(unless the loaded file itself has transparency, which is used "
"automatically)."
),
})
def __getitem__(self, key):
# A real declared key ('image') returns its real type; anything else
@@ -59,8 +76,12 @@ class PixaromaCrop:
"friendly aspects, and a Load Image button that lets you swap the "
"source from inside the editor (also auto-disconnects the upstream "
"wire).\n\n"
"Outputs the cropped IMAGE plus its new width and height for downstream "
"nodes.\n\n"
"Outputs the cropped IMAGE, a matching cropped MASK (wire a MASK in - "
"such as Load Image's MASK output - to carry transparency through the "
"crop with the exact same box), the new width and height, and a "
"'crop_info' wire. Feed crop_info into Image Uncrop Pixaroma to paste an "
"edited version of the crop back onto the original image at the exact "
"same spot (crop, fix/upscale, then put it back).\n\n"
"The 'image' input is optional - wire an upstream IMAGE into it, or load "
"an image directly via drag-drop, Ctrl+V paste, or the editor's Load "
"Image button (those override the wire)."
@@ -73,11 +94,22 @@ class PixaromaCrop:
"optional": _CropOptionalInputs(any_type),
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The cropped image.", "Cropped width in pixels.", "Cropped height in pixels.")
RETURN_TYPES = ("IMAGE", "MASK", PIXAROMA_CROP_INFO, "INT", "INT")
RETURN_NAMES = ("image", "mask", "crop_info", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped image.",
"The cropped mask, cut with the exact same box as the image. Comes from "
"the wired MASK input (or, for a dropped/pasted file, its own "
"transparency); a fully-opaque mask sized to the crop when there is no "
"transparency.",
"Crop info for Image Uncrop Pixaroma - carries the original image plus "
"where this crop came from, so an edited crop can be pasted back onto "
"the original at the exact same spot. Wire it into Image Uncrop Pixaroma.",
"Cropped width in pixels.",
"Cropped height in pixels.",
)
FUNCTION = "load_crop"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
OUTPUT_NODE = True
@classmethod
@@ -136,10 +168,12 @@ class PixaromaCrop:
crop_data = kwargs.get("CropWidget")
upstream = kwargs.get("image")
upstream_mask = kwargs.get("mask")
# No widget AND no upstream → return empty
if not crop_data and upstream is None:
return (empty_image, 1024, 1024)
if not crop_data and upstream is None and upstream_mask is None:
return (empty_image, self._default_mask(1024, 1024),
self._identity_crop_info(empty_image), 1024, 1024)
# Parse crop metadata (may be empty if user just wired upstream and never opened editor)
meta = {}
@@ -164,29 +198,59 @@ class PixaromaCrop:
]}
# ── Apply the crop ────────────────────────────────────────────────────
# IMAGE + MASK are cut with the SAME absolute-pixel rect so transparency
# lines up with the cropped image. A wired MASK is cropped to match; with
# no MASK wired the output is a fully-opaque mask sized to the crop.
if isinstance(upstream, torch.Tensor):
try:
result = self._crop_tensor(upstream, meta)
img_t, out_w, out_h = self._crop_tensor(upstream, meta)
mask_t = self._crop_mask(upstream_mask, meta, out_w, out_h)
# Carry the FULL upstream mask (if any) so Uncrop's mask output
# is full-frame, not just the crop rectangle.
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else None
crop_info = self._make_crop_info(upstream, meta, full_mask)
except Exception as e:
print(f"[PixaromaCrop] upstream crop error: {e}")
result = self._load_disk_composite(meta, empty_image)
img_t, mask_t, out_w, out_h, crop_info = self._load_disk_composite(meta, empty_image, upstream_mask)
else:
result = self._load_disk_composite(meta, empty_image)
img_t, mask_t, out_w, out_h, crop_info = self._load_disk_composite(meta, empty_image, upstream_mask)
result = (img_t, mask_t, crop_info, out_w, out_h)
if ui_payload:
return {"ui": ui_payload, "result": result}
return result
# ─────────────────────────────────────────────────────────────────────────
def _rect_from_meta(self, meta, w, h):
"""Resolve the saved crop rect to absolute, clamped pixel bounds
(x0, y0, x1, y1) for a w×h surface, or None when the crop should be a
pass-through (no saved rect, or a degenerate/empty box). Used for BOTH
the image and the mask so they're always cut with the same box.
Coordinates are ABSOLUTE pixels (no proportional rescale from
original_w/original_h). The numeric panel + editor both write literal
pixel values; rescaling on dim mismatch was confusing — typing W=430 on
a 1920-wide source should crop 430 px, not "the same fraction" of the
new image. Out-of-bounds coords are clamped to the surface rect.
"""
if not meta or meta.get("crop_w") in (None, 0):
return None
crop_x = float(meta.get("crop_x", 0))
crop_y = float(meta.get("crop_y", 0))
crop_w = float(meta.get("crop_w", w))
crop_h = float(meta.get("crop_h", h))
x0 = max(0, int(round(crop_x)))
y0 = max(0, int(round(crop_y)))
x1 = min(int(w), int(round(crop_x + crop_w)))
y1 = min(int(h), int(round(crop_y + crop_h)))
if x1 <= x0 or y1 <= y0:
return None
return (x0, y0, x1, y1)
def _crop_tensor(self, tensor, meta):
"""Crop an upstream IMAGE tensor [B,H,W,C] using the saved rect.
Coordinates are treated as ABSOLUTE pixels (no proportional rescale
from original_w/original_h). The numeric panel + editor both write
literal pixel values; rescaling on dim mismatch was confusing — typing
W=430 on a 1920-wide source should crop 430 px, not "the same fraction"
of the new image. Out-of-bounds coords are clamped to the image rect.
If meta is empty (user wired upstream but never opened the editor or
edited the panel), pass through unmodified.
"""
@@ -197,39 +261,108 @@ class PixaromaCrop:
return (tensor, 0, 0)
b, h, w, c = tensor.shape
# No saved rect → pass through (gives the user a sensible preview before
# they open the editor for the first time).
if not meta or meta.get("crop_w") in (None, 0):
return (tensor, int(w), int(h))
crop_x = float(meta.get("crop_x", 0))
crop_y = float(meta.get("crop_y", 0))
crop_w = float(meta.get("crop_w", w))
crop_h = float(meta.get("crop_h", h))
x0 = max(0, int(round(crop_x)))
y0 = max(0, int(round(crop_y)))
x1 = min(w, int(round(crop_x + crop_w)))
y1 = min(h, int(round(crop_y + crop_h)))
if x1 <= x0 or y1 <= y0:
print(f"[PixaromaCrop] degenerate rect ({x0},{y0},{x1},{y1}) for {w}x{h} — passing through")
rect = self._rect_from_meta(meta, w, h)
if rect is None:
# No saved rect → pass through (a sensible preview before the editor
# is opened, or a degenerate box clamped to nothing).
return (tensor, int(w), int(h))
x0, y0, x1, y1 = rect
cropped = tensor[:, y0:y1, x0:x1, :].contiguous()
return (cropped, int(x1 - x0), int(y1 - y0))
def _load_disk_composite(self, meta, empty_image):
def _default_mask(self, w, h):
"""A fully-opaque mask (zeros) sized w×h. ComfyUI convention: a mask
value of 0 means 'keep / opaque', 1 means 'transparent'. Returned when
no transparency is available so the mask output still lines up with the
cropped image if it's recombined downstream."""
return torch.zeros((1, max(1, int(h)), max(1, int(w))), dtype=torch.float32)
def _crop_mask(self, mask, meta, fallback_w, fallback_h):
"""Crop a MASK tensor with the SAME rect as the image. Accepts the usual
ComfyUI [B,H,W] mask (also tolerates a bare [H,W]); the rect is clamped
to the mask's own dimensions. Returns a fully-opaque default mask sized
to the crop when the mask is missing or an unexpected shape."""
if not isinstance(mask, torch.Tensor):
return self._default_mask(fallback_w, fallback_h)
m = mask
if m.dim() == 2:
m = m[None, ...]
if m.dim() != 3:
return self._default_mask(fallback_w, fallback_h)
mh, mw = int(m.shape[-2]), int(m.shape[-1])
rect = self._rect_from_meta(meta, mw, mh)
if rect is None:
return m.contiguous()
x0, y0, x1, y1 = rect
return m[:, y0:y1, x0:x1].contiguous()
def _make_crop_info(self, original, meta, full_mask=None):
"""Bundle what Image Uncrop Pixaroma needs to paste an edited crop back:
the FULL original image and (optionally) the FULL original mask, plus the
crop's top-left (x, y) and size (w, h) computed from the saved rect on
the original's own dimensions. Carrying the full mask lets Uncrop output
a full-frame mask instead of just the crop rectangle."""
if not isinstance(original, torch.Tensor) or original.dim() != 4:
return None
H, W = int(original.shape[1]), int(original.shape[2])
rect = self._rect_from_meta(meta, W, H)
if rect is None:
x0, y0, cw, ch = 0, 0, W, H
else:
x0, y0, x1, y1 = rect
cw, ch = x1 - x0, y1 - y0
info = {"image": original, "x": x0, "y": y0, "w": cw, "h": ch,
"orig_w": W, "orig_h": H}
if isinstance(full_mask, torch.Tensor):
info["mask"] = full_mask
return info
def _identity_crop_info(self, image_t, full_mask=None):
"""Crop info for an already-cropped image (editor composite or the empty
fallback) where the full original isn't available: paste-back becomes a
whole-image replace at (0, 0)."""
if not isinstance(image_t, torch.Tensor) or image_t.dim() != 4:
return None
H, W = int(image_t.shape[1]), int(image_t.shape[2])
info = {"image": image_t, "x": 0, "y": 0, "w": W, "h": H,
"orig_w": W, "orig_h": H}
if isinstance(full_mask, torch.Tensor):
info["mask"] = full_mask
return info
def _full_mask_from_pil(self, pil):
"""The FULL (uncropped) mask from a loaded file's alpha channel
(mask = 1 - alpha), or None when the file has no transparency."""
try:
if "A" in pil.getbands():
alpha = np.array(pil.convert("RGBA").split()[-1]).astype(np.float32) / 255.0
return torch.from_numpy(1.0 - alpha)[None,]
except Exception:
pass
return None
def _load_disk_composite(self, meta, empty_image, upstream_mask=None):
"""Load a saved image from input/pixaroma/. Two paths:
1. composite_path: the editor-saved pre-cropped PNG. Returned as-is
(the editor already did the crop on the JS side).
2. src_path: the uncropped source (e.g. uploaded via Ctrl+V paste).
We load it and apply crop_x/y/w/h on the Python side, mirroring
_crop_tensor's behavior for upstream tensors. This lets the user
change crop dims in the on-node panel and have the workflow output
reflect the change without re-opening the editor.
1. src_path: the FULL uncropped source (paste / drag-drop / the editor's
Load Image button - the editor saves it even when it also saves a
composite). We load it and apply crop_x/y/w/h on the Python side
(same pixels as the editor's composite) AND build a real crop_info
carrying the full original - which Image Uncrop needs to paste an
edited crop back onto the whole image. This is PREFERRED.
2. composite_path: the editor-saved pre-cropped PNG. Used ONLY when no
usable source is on disk. It is already cropped, so its crop_info is
an identity (paste-back can't reconstruct the full original from it).
Why src is preferred: when only the composite is used, crop_info has no
full original, so Image Uncrop has nothing to paste the edited crop back
onto (it returns just the cropped region). Loading from the full source
fixes the "Uncrop doesn't work when I paste/load into Crop directly" case
while producing the identical cropped IMAGE output.
All paths also produce a cropped MASK: a wired MASK input wins, else the
loaded file's own alpha channel is used, else a fully-opaque default.
"""
doc_w = int(meta.get("doc_w", 1024))
doc_h = int(meta.get("doc_h", 1024))
@@ -237,15 +370,41 @@ class PixaromaCrop:
composite_path = meta.get("composite_path", "")
src_path = meta.get("src_path", "")
# Prefer the FULL source so crop_info carries the original for paste-back.
if src_path and self._resolve_pixaroma_path(src_path):
return self._load_src_and_crop(src_path, meta, doc_w, doc_h, empty_image, upstream_mask)
if composite_path:
return self._load_image_from_pixaroma(composite_path, doc_w, doc_h, empty_image)
return self._load_image_from_pixaroma(
composite_path, doc_w, doc_h, empty_image, meta, upstream_mask, already_cropped=True)
if src_path:
return self._load_src_and_crop(src_path, meta, doc_w, doc_h, empty_image)
# Nothing on disk → return a blank doc-sized image
# Nothing on disk → return a blank doc-sized image + matching mask
arr = np.ones((doc_h, doc_w, 3), dtype=np.float32)
return (torch.from_numpy(arr)[None,], doc_w, doc_h)
blank = torch.from_numpy(arr)[None,]
mask_t = self._crop_mask(upstream_mask, meta, doc_w, doc_h)
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else None
return (blank, mask_t, doc_w, doc_h, self._identity_crop_info(blank, full_mask))
def _derive_disk_mask(self, pil, meta, upstream_mask, out_w, out_h, already_cropped):
"""Work out the MASK for a disk-loaded image. Priority:
1. A wired MASK input (cropped with the saved rect).
2. The file's own alpha channel if it has one (mask = 1 - alpha, the
ComfyUI convention). Cropped with the rect for an uncropped source;
used as-is for an editor composite that was already cropped on save.
3. A fully-opaque default mask sized to the crop.
"""
if isinstance(upstream_mask, torch.Tensor):
return self._crop_mask(upstream_mask, meta, out_w, out_h)
try:
if "A" in pil.getbands():
alpha = np.array(pil.convert("RGBA").split()[-1]).astype(np.float32) / 255.0
m = torch.from_numpy(1.0 - alpha)[None,] # [1,H,W], 1 = transparent
if already_cropped:
return m.contiguous()
return self._crop_mask(m, meta, out_w, out_h)
except Exception as e:
print(f"[PixaromaCrop] alpha mask derive failed: {e}")
return self._default_mask(out_w, out_h)
def _resolve_pixaroma_path(self, rel_path):
"""Resolve a saved relative path inside input/pixaroma/, returning
@@ -259,37 +418,56 @@ class PixaromaCrop:
return None
return full_path
def _load_image_from_pixaroma(self, rel_path, doc_w, doc_h, empty_image):
def _load_image_from_pixaroma(self, rel_path, doc_w, doc_h, empty_image,
meta=None, upstream_mask=None, already_cropped=True):
full_path = self._resolve_pixaroma_path(rel_path)
if not full_path:
return (empty_image, doc_w, doc_h)
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
try:
img = Image.open(full_path).convert("RGB")
pil = Image.open(full_path)
img = pil.convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
t = torch.from_numpy(arr)[None,]
# Report the ACTUAL loaded dimensions, not the stale doc_w/doc_h from
# meta — they can disagree if the composite was modified externally,
# and downstream nodes (EmptyLatentImage, etc.) trust these outputs.
return (t, int(t.shape[2]), int(t.shape[1]))
ow, oh = int(t.shape[2]), int(t.shape[1])
mask_t = self._derive_disk_mask(pil, meta or {}, upstream_mask, ow, oh, already_cropped)
# Composite is already cropped on the JS side, so the full original
# isn't available here → identity crop info (paste = whole replace).
# Its mask doubles as the "full" mask for that identity.
return (t, mask_t, ow, oh, self._identity_crop_info(t, mask_t))
except Exception as e:
print(f"[PixaromaCrop] Load error: {e}")
return (empty_image, 1024, 1024)
return (empty_image, self._default_mask(1024, 1024), 1024, 1024,
self._identity_crop_info(empty_image))
def _load_src_and_crop(self, src_path, meta, doc_w, doc_h, empty_image):
def _load_src_and_crop(self, src_path, meta, doc_w, doc_h, empty_image, upstream_mask=None):
"""Load the uncropped source image and apply crop_x/y/w/h. Used when
an image was pasted/uploaded but the editor was never opened to bake
the composite (or the user is tweaking crop dims via the panel)."""
full_path = self._resolve_pixaroma_path(src_path)
if not full_path:
return (empty_image, doc_w, doc_h)
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
try:
img = Image.open(full_path).convert("RGB")
pil = Image.open(full_path)
img = pil.convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr)[None,] # [1, H, W, 3]
return self._crop_tensor(tensor, meta)
tensor = torch.from_numpy(arr)[None,] # [1, H, W, 3] (FULL source)
img_t, ow, oh = self._crop_tensor(tensor, meta)
# Source is the FULL uncropped image, so the mask (wired or from the
# file's own alpha) is cropped with the same rect.
mask_t = self._derive_disk_mask(pil, meta, upstream_mask, ow, oh, already_cropped=False)
# FULL source available → real crop info so paste-back lands right.
# Carry the FULL mask (wired, else the file's own alpha) full-frame.
full_mask = upstream_mask if isinstance(upstream_mask, torch.Tensor) else self._full_mask_from_pil(pil)
return (img_t, mask_t, ow, oh, self._make_crop_info(tensor, meta, full_mask))
except Exception as e:
print(f"[PixaromaCrop] src load error: {e}")
return (empty_image, doc_w, doc_h)
return (empty_image, self._default_mask(doc_w, doc_h), doc_w, doc_h,
self._identity_crop_info(empty_image))
NODE_CLASS_MAPPINGS = {
+1 -1
View File
@@ -61,7 +61,7 @@ class PixaromaFindReplace:
"The text after all your find/replace rules (and Tidy) are applied.",
)
FUNCTION = "apply"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
OUTPUT_NODE = True
def apply(self, text, FindReplaceState="{}"):
+1 -1
View File
@@ -144,7 +144,7 @@ class PixaromaImageResize:
},
}
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "longest_side")
OUTPUT_TOOLTIPS = (
+319
View File
@@ -0,0 +1,319 @@
import os
import json
import uuid
import numpy as np
import torch
from PIL import Image
import folder_paths
from .node_ref import any_type, FlexibleOptionalInputType
from ._inpaint_helpers import (
apply_inpaint_crop, merge_params, resolve_inpaint_mask, PIXAROMA_CROP_INFO, DEFAULTS,
)
# Friendly combo labels -> internal size-mode keys used by the geometry helper.
_SIZE_MODE = {
"keep shape (long side)": "keep",
"force size (square)": "force",
"free (multiple only)": "free",
}
# Seam paste-back mode -> internal key. Mirrored by the editor's Blend mode pill.
_BLEND_MODE = {
"mask": "mask",
"whole crop": "whole_crop",
}
class _InpaintOptionalInputs(FlexibleOptionalInputType):
"""Declares concrete optional IMAGE + MASK inputs (so drag-from-output search
finds the node) while still accepting the hidden InpaintCropWidget DOM-widget
value (and anything else) via the flexible any_type fallback - same trick as
Image Crop's _CropOptionalInputs."""
def __init__(self, type):
super().__init__(type)
self["image"] = ("IMAGE", {
"tooltip": (
"Wire any upstream IMAGE here to inpaint it (Load Image, VAE "
"Decode, anything). You can also drag-drop or paste an image onto "
"the node body - those load it directly and disconnect this wire."
),
})
self["mask"] = ("MASK", {
"tooltip": (
"Optional mask of the area to inpaint (e.g. a transparent PNG's "
"alpha, or any MASK output). It is used as-is whenever you have not "
"painted a mask in the editor - so clearing the editor falls back to "
"this wired mask. A mask painted in the editor takes priority."
),
})
def __getitem__(self, key):
if dict.__contains__(self, key):
return dict.__getitem__(self, key)
return (self.type,)
class PixaromaInpaintCrop:
DESCRIPTION = (
"Inpaint Crop Pixaroma - the easy way to set up an inpaint. Open the "
"fullscreen editor and paint a mask over the area you want to fix (brush, "
"erase, clear, invert, adjustable brush size). The node automatically "
"finds the box around your mask, adds a context margin, and crops a "
"model-friendly piece (sized to a multiple of 8, scaled toward your "
"target so even a small masked area gets enough resolution).\n\n"
"Turn on invert_mask to flip the mask and inpaint the OPPOSITE area (e.g. a "
"cut-out's background instead of its subject), no Invert Mask node needed.\n\n"
"Wire the cropped image and mask into your inpaint model (KSampler, Flux, "
"edit models), then send the crop_info wire into Inpaint Stitch Pixaroma "
"to paste the result back onto the original at the exact spot.\n\n"
"Outputs the cropped image, the matching cropped mask, a crop_info wire, "
"and the crop width and height (handy for an empty latent). The crop_info "
"wire is the same type Image Crop uses, so the two are interchangeable."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"size_mode": (list(_SIZE_MODE.keys()), {
"default": "keep shape (long side)",
"tooltip": (
"Keep shape: scale the masked area so its long side hits "
"the target, no stretching (best quality). Force size: "
"always output a target x target square. Free: natural "
"size, just rounded to the multiple."
),
}),
"target": ("INT", {
"default": 1024, "min": 64, "max": 8192, "step": 8,
"tooltip": "Target long side (keep) or square size (force), in px.",
}),
"multiple": ([8, 16, 32, 64], {
"default": 8,
"tooltip": "Round the crop size to this multiple for model compatibility.",
}),
"context_px": ("INT", {
"default": 24, "min": 0, "max": 1024, "step": 1,
"tooltip": "How many extra pixels of surrounding context to include each side.",
}),
"mask_grow": ("INT", {
"default": 4, "min": 0, "max": 256, "step": 1,
"tooltip": "Expand the painted mask by this many pixels before cropping.",
}),
"mask_blur": ("INT", {
"default": 4, "min": 0, "max": 256, "step": 1,
"tooltip": "Soften the output mask edge by this many pixels for a smoother inpaint.",
}),
"softness": ("INT", {
"default": 16, "min": 0, "max": 150, "step": 1,
"tooltip": (
"How far the seam feathers when Inpaint Stitch pastes the "
"crop back. Previewed live in the mask editor."
),
}),
"blend_mode": (list(_BLEND_MODE.keys()), {
"default": "mask",
"tooltip": (
"How Inpaint Stitch pastes the result back. 'mask': only the "
"area you painted is replaced - the rest of the crop keeps the "
"original (the normal inpaint, the safe default). 'whole crop': "
"the entire cropped region is replaced with the model's version "
"(use when the model also relit / changed the surroundings, or "
"for an img2img-style pass over the whole crop). Previewed live "
"in the mask editor."
),
}),
"invert_mask": ("BOOLEAN", {
"default": False,
"tooltip": (
"Flip the mask so the inpaint targets the OPPOSITE area "
"(swap subject and background). Works on a wired mask or a "
"painted one - a built-in alternative to an Invert Mask node. "
"No effect if no mask is connected."
),
}),
},
"optional": _InpaintOptionalInputs(any_type),
}
RETURN_TYPES = ("IMAGE", "MASK", PIXAROMA_CROP_INFO, "INT", "INT")
RETURN_NAMES = ("image", "mask", "crop_info", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped region, resized to the model-friendly output size.",
"The cropped mask at the same size (grown and blurred as set). Wire it "
"into SetLatentNoiseMask / your inpaint conditioning.",
"Crop info for Inpaint Stitch Pixaroma - carries the original image and "
"where the crop came from so the inpainted result can be pasted back "
"exactly. Same type as Image Crop, so they are interchangeable.",
"Cropped output width in pixels (for an empty latent / edit models).",
"Cropped output height in pixels.",
)
FUNCTION = "run"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
OUTPUT_NODE = True
# ─────────────────────────────────────────────────────────────────────────
@classmethod
def IS_CHANGED(cls, **kwargs):
"""Re-run on any knob change, or when the painted-mask file changes."""
parts = [str(kwargs.get(k)) for k in
("size_mode", "target", "multiple", "context_px", "mask_grow", "mask_blur", "softness", "blend_mode", "invert_mask")]
state = kwargs.get("InpaintCropWidget")
try:
sj = state.get("state_json", "{}") if isinstance(state, dict) else str(state)
meta = json.loads(sj) if sj else {}
parts.append(json.dumps(meta, sort_keys=True)) # deterministic regardless of key order
mp = meta.get("mask_path", "")
if mp:
full = cls._resolve_pixaroma_path(mp)
if full:
parts.append(str(os.path.getmtime(full)))
sp = meta.get("src_path", "")
if sp:
fs = cls._resolve_pixaroma_path(sp)
if fs:
parts.append(str(os.path.getmtime(fs)))
except Exception:
parts.append(str(state))
return "|".join(parts)
@staticmethod
def _resolve_pixaroma_path(rel_path):
if not rel_path:
return None
input_dir = os.path.realpath(folder_paths.get_input_directory())
full_path = os.path.realpath(os.path.join(input_dir, rel_path))
if not full_path.startswith(input_dir + os.sep):
print("[PixaromaInpaintCrop] Security: path escapes input dir, blocked.")
return None
return full_path if os.path.exists(full_path) else None
def _save_source_temp(self, tensor):
"""Stash the input tensor (frame 0) to temp/ so the JS editor can load it."""
try:
if not isinstance(tensor, torch.Tensor) or tensor.dim() != 4 or tensor.shape[0] == 0:
return None
arr = (tensor[0].clamp(0, 1).cpu().numpy() * 255.0 + 0.5).astype(np.uint8)
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
fname = f"pixaroma_inpaint_src_{uuid.uuid4().hex}.png"
Image.fromarray(arr).save(os.path.join(temp_dir, fname), "PNG")
return fname
except Exception as e:
print(f"[PixaromaInpaintCrop] temp source save failed: {e}")
return None
def _load_disk_image(self, rel_path):
full = self._resolve_pixaroma_path(rel_path)
if not full:
return None
try:
arr = np.array(Image.open(full).convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,]
except Exception as e:
print(f"[PixaromaInpaintCrop] source load error: {e}")
return None
def _load_disk_mask(self, rel_path):
full = self._resolve_pixaroma_path(rel_path)
if not full:
return None
try:
arr = np.array(Image.open(full).convert("L")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None,]
except Exception as e:
print(f"[PixaromaInpaintCrop] mask load error: {e}")
return None
def _empty(self):
img = torch.ones((1, 1024, 1024, 3), dtype=torch.float32)
mask = torch.zeros((1, 1024, 1024), dtype=torch.float32)
info = {"image": img, "mask": mask, "x": 0, "y": 0, "w": 1024, "h": 1024,
"orig_w": 1024, "orig_h": 1024}
return (img, mask, info, 1024, 1024)
def _params(self, size_mode, target, multiple, context_px, mask_grow, mask_blur):
mode = _SIZE_MODE.get(size_mode, "keep")
p = dict(DEFAULTS)
p.update({
"size_mode": mode, "target": int(target),
"target_w": int(target), "target_h": int(target),
"multiple": int(multiple), "context_px": int(context_px),
"mask_grow": int(mask_grow), "mask_blur": int(mask_blur),
})
return merge_params(p)
def run(self, size_mode="keep shape (long side)", target=1024, multiple=8,
context_px=24, mask_grow=4, mask_blur=4, softness=16, blend_mode="mask",
invert_mask=False, **kwargs):
upstream = kwargs.get("image")
upstream_mask = kwargs.get("mask")
state = kwargs.get("InpaintCropWidget")
meta = {}
if state is not None:
try:
sj = state.get("state_json", "{}") if isinstance(state, dict) else str(state)
if sj and sj.strip() not in ("", "{}"):
parsed = json.loads(sj)
if isinstance(parsed, dict):
meta = parsed
except Exception as e:
print(f"[PixaromaInpaintCrop] state parse error: {e}")
# ── source image: wired upstream wins, else the editor-saved src on disk
ui_payload = None
image = upstream if isinstance(upstream, torch.Tensor) else None
if image is not None:
src_fname = self._save_source_temp(image)
if src_fname:
ui_payload = {"pixaroma_inpaint_source": [
{"filename": src_fname, "subfolder": "", "type": "temp"}]}
else:
image = self._load_disk_image(meta.get("src_path", ""))
if not isinstance(image, torch.Tensor):
return self._empty()
# ── mask: a PAINTED editor mask wins; a cleared/empty one (mask_path set
# but the saved file is all-black) falls back to the wired mask, so clearing
# the editor uses the wired mask as-is. resolve_inpaint_mask owns the rule.
disk_mask = self._load_disk_mask(meta.get("mask_path", ""))
mask = resolve_inpaint_mask(disk_mask, upstream_mask)
params = self._params(size_mode, target, multiple, context_px, mask_grow, mask_blur)
# Seam softness (the node 'softness' knob, mirrored by the editor): feed it
# into the geometry so the crop CONTEXT grows to fit the feather (Option B -
# compute_region uses max(context_px, blend)), then ride it on crop_info to
# Inpaint Stitch as the seam-feather width.
sb = max(0, min(150, int(softness)))
params["blend"] = sb
params["invert_mask"] = bool(invert_mask) # flip the mask before cropping
try:
img_t, mask_t, crop_info, ow, oh = apply_inpaint_crop(image, mask, params)
except Exception as e:
print(f"[PixaromaInpaintCrop] crop error: {e}")
return self._empty()
# blend mode rides crop_info. It is now a NODE widget (mirrored by the editor's
# Blend mode pill), so the node widget is the source of truth - not state_json.
# (color_match is the Stitch node's own knob.)
crop_info["blend"] = sb
crop_info["blend_mode"] = _BLEND_MODE.get(blend_mode, "mask")
result = (img_t, mask_t, crop_info, ow, oh)
if ui_payload:
return {"ui": ui_payload, "result": result}
return result
NODE_CLASS_MAPPINGS = {
"PixaromaInpaintCrop": PixaromaInpaintCrop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaInpaintCrop": "Inpaint Crop Pixaroma",
}
+136
View File
@@ -0,0 +1,136 @@
import torch
from ._inpaint_helpers import stitch_back, resolve_seam, PIXAROMA_CROP_INFO
class PixaromaInpaintStitch:
DESCRIPTION = (
"Inpaint Stitch Pixaroma - paste your inpainted crop back onto the "
"original image at the exact spot it came from, blended so the seam "
"disappears.\n\n"
"Wire the crop_info output of Inpaint Crop Pixaroma into crop_info here, "
"and wire your inpainted crop (after the model) into image. The node "
"resizes the crop back to the region and blends only the painted area by "
"default, so everything outside the mask stays pixel-perfect.\n\n"
"The seam softness and blend mode come from the Inpaint Crop node on the "
"crop_info wire, but you can OVERRIDE them here (softness -1 = use the "
"crop's). Because this node is after the sampler, changing softness, blend "
"mode or color match re-runs only this node - the sampler stays cached on a "
"fixed seed - so you can fine-tune the blend instantly without re-generating. "
"color match corrects a color/tone shift the model introduced.\n\n"
"Outputs the finished full image, plus the original uncropped image - wire "
"both into Image Compare Pixaroma for an instant before / after."
)
@classmethod
def INPUT_TYPES(cls):
# Slot order image, mask, crop_info lines up under Inpaint Crop's
# image / mask / crop_info outputs so the wires run straight across.
return {
"required": {
"image": ("IMAGE", {
"tooltip": (
"Your inpainted crop (after the model). Resized back to the "
"original crop region automatically if its size differs."
),
}),
},
"optional": {
"mask": ("MASK", {
"tooltip": (
"Optional. Limits the blend to this area (resized to the "
"crop region). If omitted, the painted mask carried in "
"crop_info is used for the mask-aware blend."
),
}),
"crop_info": (PIXAROMA_CROP_INFO, {
"tooltip": (
"Wire the crop_info output of Inpaint Crop Pixaroma here. "
"If left unwired, the image passes straight through."
),
}),
"softness": ("INT", {
"default": -1, "min": -1, "max": 150, "step": 1,
"tooltip": (
"Seam feather, overriding the Inpaint Crop node's softness. "
"-1 = use the Crop node's value. Set 0-150 to tune the blend "
"HERE - because Stitch is after the sampler, only this node "
"re-runs (the sampler stays cached on a fixed seed), so it is "
"instant. Going bigger than the room the crop left may show a "
"slightly harder edge - raise the Crop node's softness for "
"more room."
),
}),
"blend_mode": (["from crop", "mask", "whole crop"], {
"default": "from crop",
"tooltip": (
"Override the Crop node's blend mode. 'from crop' = use what "
"the Crop node set. 'mask' = replace only the painted area. "
"'whole crop' = replace the entire cropped box. Like softness, "
"changing it here re-runs only this node (no re-sample)."
),
}),
"color_match": (["off", "subtle", "strong"], {
"default": "off",
"tooltip": (
"Correct a color/tone shift the model introduced, matching "
"the unchanged surroundings around your mask. Keep it Off "
"when you deliberately changed colors (it would pull them "
"back). No live preview - set it and re-run."
),
}),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("image", "original")
OUTPUT_TOOLTIPS = (
"The original image with the inpainted crop blended back in place.",
"The full original uncropped image (from crop_info) - wire it together "
"with the result into Image Compare Pixaroma for a before / after.",
)
FUNCTION = "run"
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
def run(self, image, crop_info=None, mask=None, softness=-1,
blend_mode="from crop", color_match="off"):
# No valid crop_info -> nothing to paste back; pass the image through as
# both outputs so downstream wiring still works. Require the geometry keys
# too, so a malformed dict doesn't silently paste at (0,0) full-size.
if (not isinstance(crop_info, dict)
or not isinstance(crop_info.get("image"), torch.Tensor)
or crop_info["image"].dim() != 4
or any(k not in crop_info for k in ("x", "y", "w", "h"))):
# malformed / missing crop_info (incl. a non-[B,H,W,C] image) -> pass the
# image through as BOTH outputs so the graph still runs. Checking the rank
# here keeps a bad image out of stitch_back (which would otherwise throw and
# the except below would silently make `original` a copy of the result).
print("[PixaromaInpaintStitch] no valid crop_info wired - passing image through")
return (image, image)
# Seam blend + mode ride in on crop_info from the Crop node, but THIS node's
# softness / blend_mode widgets override them when set (so the blend can be
# tuned here without re-running the sampler). color_match is this node's own
# knob (post-result tweak, no live preview).
blend, blend_mode = resolve_seam(crop_info, softness, blend_mode)
cm = str(color_match)
color_match = cm if cm in ("off", "subtle", "strong") else "off"
try:
result, original = stitch_back(crop_info, image, mask, blend, blend_mode, color_match)
except Exception as e:
# A genuine fault inside stitch_back (e.g. CUDA OOM). Pass the inpainted
# image through as the result, but keep `original` as the TRUE uncropped
# original (crop_info["image"] already passed the dim==4 guard above), so a
# downstream before/after compare shows the real original, not a patch copy.
print(f"[PixaromaInpaintStitch] stitch error: {e}")
return (image, crop_info["image"])
return (result, original)
NODE_CLASS_MAPPINGS = {
"PixaromaInpaintStitch": PixaromaInpaintStitch,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaInpaintStitch": "Inpaint Stitch Pixaroma",
}
+1 -1
View File
@@ -26,7 +26,7 @@ class PixaromaLabel:
# execution also stops ComfyUI from drawing an "X.Xs" timing badge
# above the node every run, which looks weird on what is just a
# styled text caption.
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
def noop(self, label_json):
return {}
+1 -1
View File
@@ -117,7 +117,7 @@ class PixaromaLoadImage:
},
}
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = (
"image", "mask", "width", "height",
+244
View File
@@ -0,0 +1,244 @@
"""Load Images from Folder Pixaroma.
A "folder version" of Load Image Pixaroma: point it at any folder on disk, pick
which images to process (all / first N / hand-picked in a thumbnail gallery), and
on Run feed each selected image through the workflow ONE AT A TIME via ComfyUI's
list mechanism (OUTPUT_IS_LIST) - one finished result per image.
State (folder, selection, options, resize) arrives as a hidden JSON string the
frontend injects via app.graphToPrompt (see js/load_images_folder/). The resize
keys are identical to node_load_image.py::DEFAULT_STATE so the shared
nodes/_resize_helpers._resize_frame engine works unchanged.
"""
import hashlib
import json
import os
import numpy as np
import torch
from PIL import Image, ImageOps, ImageSequence
from ._resize_helpers import _resize_frame
# Resize keys MUST match node_load_image.py::DEFAULT_STATE (shared engine).
DEFAULT_STATE = {
"version": 1,
"folder": "",
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selected": [],
# ── resize keys (mirror Load Image) ──
"mode": "off",
"max_mp": 1.0,
"longest_side": 1024,
"scale_factor": 1.0,
"fit_w": 1024, "fit_h": 1024,
"cover_w": 1024, "cover_h": 1024,
"ratio_preset": "1:1",
"ratio_w": 1, "ratio_h": 1,
"ratio_action": "crop",
"pad_color": "#808080",
"pad_top": 0, "pad_bottom": 0, "pad_left": 0, "pad_right": 0,
"crop_anchor": "center", "crop_scale": True,
"snap": 0,
"resample": "auto",
"allow_upscale": True,
}
def _parse_state(state_json: str) -> dict:
"""Merge the hidden state JSON over DEFAULT_STATE. Falls back to defaults on
any parse error (state may be missing/malformed in subgraph/partial-prompt
cases - CLAUDE.md Vue Compat #9)."""
if not state_json:
return dict(DEFAULT_STATE)
try:
parsed = json.loads(state_json)
merged = dict(DEFAULT_STATE)
merged.update({k: v for k, v in parsed.items() if k in DEFAULT_STATE})
return merged
except Exception:
print("[PixaromaLoadImagesFolder] Malformed state JSON, using defaults")
return dict(DEFAULT_STATE)
def _load_one(path, state, dtype):
"""Open one image file, return (image_tensor, mask_tensor). Uses the first
frame only (folders of stills); applies the shared resize engine."""
img = node_pillow_open(path)
frame = ImageOps.exif_transpose(next(ImageSequence.Iterator(img)))
if frame.mode == "I":
frame = frame.point(lambda px: px * (1 / 255))
rgb = frame.convert("RGB")
orig_w, orig_h = rgb.size
if "A" in frame.getbands():
alpha = np.array(frame.getchannel("A")).astype(np.float32) / 255.0
mask_pil = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
elif frame.mode == "P" and "transparency" in frame.info:
alpha = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask_pil = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
else:
mask_pil = Image.new("L", rgb.size, 0)
rgb_r, mask_r, fw, fh = _resize_frame(rgb, mask_pil, state, orig_w, orig_h)
t = torch.from_numpy(np.array(rgb_r).astype(np.float32) / 255.0)[None,].to(dtype=dtype)
m = torch.from_numpy(np.array(mask_r).astype(np.float32) / 255.0).unsqueeze(0).to(dtype=dtype)
return t, m, int(fw), int(fh)
def node_pillow_open(path):
"""Open with the same defensive wrapper ComfyUI uses (handles truncated
files gracefully where possible)."""
try:
import node_helpers
return node_helpers.pillow(Image.open, path)
except Exception:
return Image.open(path)
class PixaromaLoadImagesFolder:
DESCRIPTION = (
"Load many images from any folder on disk and feed them through your "
"workflow one at a time - one finished result per image. Pick all, the "
"first N, or hand-pick specific images in a thumbnail gallery. Same resize "
"options as Load Image Pixaroma (max megapixels, longest side, scale by, "
"fit inside, crop to fill, match aspect ratio). Outputs are a list: image, "
"mask, width, height, filename, index, total. Wire filename into a Save node "
"so each result keeps its original name, and width/height into an empty latent "
"so it matches each image's size. Hit Run once and leave the batch count at 1."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"LoadImagesFolderState": (
"STRING",
{"default": json.dumps(DEFAULT_STATE)},
),
},
}
CATEGORY = "👑 Pixaroma/🖼️ Image"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "filename", "index", "total")
OUTPUT_IS_LIST = (True, True, True, True, True, True, True)
OUTPUT_TOOLTIPS = (
"Each selected image, one per list item (after any resize).",
"Each image's mask from its alpha channel (blank if it has none).",
"Each image's width in pixels (after any resize) - wire into an empty latent so it matches.",
"Each image's height in pixels (after any resize).",
"Each image's filename without the extension - wire into Save so results keep their original names.",
"1-based position of each image in this batch (1, 2, 3 ...).",
"How many images are in this batch - i.e. how many loaded (same for every item).",
)
FUNCTION = "load"
def load(self, LoadImagesFolderState: str = ""):
state = _parse_state(LoadImagesFolderState)
folder = state.get("folder", "") or ""
selected = state.get("selected", []) or []
if not folder or not os.path.isdir(folder):
raise ValueError(
"Load Images from Folder: folder not found. Pick a folder on the node "
"(type or paste a path, or use Browse)."
)
if not selected:
raise ValueError(
"Load Images from Folder: no images selected. Click 'Pick images' and "
"choose at least one."
)
try:
import comfy.model_management as _mm
dtype = _mm.intermediate_dtype()
except Exception:
dtype = torch.float32
real_folder = os.path.realpath(folder)
recursive = bool(state.get("recursive", False))
images, masks, widths, heights, names, indices = [], [], [], [], [], []
count = 0
for rel in selected:
if not isinstance(rel, str) or not rel:
continue # malformed selection entry (e.g. null/number in state)
# Keep every selected file INSIDE the chosen folder. `selected` comes
# from the (frontend-supplied) hidden state, so a crafted "../../x"
# must not let the loader open files outside the folder.
path = os.path.realpath(os.path.join(folder, rel))
try:
if os.path.commonpath([path, real_folder]) != real_folder:
print(f"[PixaromaLoadImagesFolder] outside folder, skipped: {rel}")
continue
except ValueError:
continue # different drive on Windows
if not os.path.isfile(path):
print(f"[PixaromaLoadImagesFolder] missing, skipped: {rel}")
continue
try:
t, m, fw, fh = _load_one(path, state, dtype)
except Exception as e:
print(f"[PixaromaLoadImagesFolder] failed to load {rel}: {e}")
continue
images.append(t)
masks.append(m)
widths.append(fw)
heights.append(fh)
if recursive:
# keep names unique across subfolders so a Save node can't
# overwrite: "sub/cat.png" -> "sub_cat"
name = os.path.splitext(rel)[0].replace("/", "_").replace("\\", "_")
else:
name = os.path.splitext(os.path.basename(rel))[0]
names.append(name)
count += 1
indices.append(count)
if not images:
raise ValueError(
"Load Images from Folder: none of the selected images could be loaded "
"(missing or unreadable). Re-check the folder and your selection."
)
totals = [count] * len(images)
return (images, masks, widths, heights, names, indices, totals)
@classmethod
def IS_CHANGED(cls, LoadImagesFolderState: str = ""):
state = _parse_state(LoadImagesFolderState)
folder = state.get("folder", "") or ""
real_folder = os.path.realpath(folder)
# Everything except `selected` (options + resize) goes in as a stable blob;
# selected files contribute their per-file mtime so edits on disk re-run.
opts = {k: state[k] for k in state if k != "selected"}
parts = [json.dumps(opts, sort_keys=True)]
for rel in state.get("selected", []) or []:
if not isinstance(rel, str) or not rel:
continue # malformed entry - skip (mirrors load())
# Mirror load()'s guard: never stat a file outside the chosen folder
# (selected is frontend-supplied, so a crafted "../../x" must not reach
# os.stat). Outside paths hash as a constant instead of a real stat.
p = os.path.realpath(os.path.join(folder, rel))
try:
if os.path.commonpath([p, real_folder]) != real_folder:
parts.append(f"{rel}:outside")
continue
except ValueError:
parts.append(f"{rel}:outside") # different drive on Windows
continue
try:
parts.append(f"{rel}:{os.stat(p).st_mtime_ns}")
except OSError:
parts.append(f"{rel}:missing")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
NODE_CLASS_MAPPINGS = {"PixaromaLoadImagesFolder": PixaromaLoadImagesFolder}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaLoadImagesFolder": "Load Images from Folder Pixaroma"}
+1 -1
View File
@@ -67,7 +67,7 @@ class PixaromaMuteSwitch:
)
FUNCTION = "noop"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
def noop(self, **kwargs):
# All muting happens in the JS frontend BEFORE this node is reached.
+1 -1
View File
@@ -46,7 +46,7 @@ class PixaromaNote:
# execution also stops ComfyUI from drawing an "X.Xs" timing badge
# above the note on every run, which looks weird on a decorative
# rich-text annotation.
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
def noop(self, note_json):
return {}
+1 -1
View File
@@ -99,7 +99,7 @@ class NotifyPixaroma:
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "notify"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
def notify(self, any, enabled, sound, volume, label):
if not enabled:
+1 -1
View File
@@ -47,7 +47,7 @@ class PixaromaNumber:
RETURN_NAMES = ("int", "float")
OUTPUT_TOOLTIPS = ("The value as a whole number (rounded to nearest).", "The value as a decimal number, kept as-is.")
FUNCTION = "out"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔢 Values"
def out(self, value):
# Defensive: ComfyUI normally guarantees a numeric `value`, but if a
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaPaint:
RETURN_NAMES = ("image", "width", "height")
OUTPUT_TOOLTIPS = ("The painted image.", "Image width in pixels.", "Image height in pixels.")
FUNCTION = "load_painting"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🎨 Editors"
OUTPUT_NODE = True
@classmethod
+1 -1
View File
@@ -99,7 +99,7 @@ class PixaromaPauseImage:
OUTPUT_TOOLTIPS = ("The image continuing downstream - the live input in Pause/Pass mode, or the reloaded snapshot in Continue mode.",)
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
@classmethod
def IS_CHANGED(cls, **kwargs):
+55
View File
@@ -0,0 +1,55 @@
"""Portrait Landscape Pixaroma — output a width/height in the chosen
orientation. Enter (or wire) two dimensions and click Portrait or Landscape:
Portrait returns the TALL arrangement (smaller value as width), Landscape the
WIDE arrangement (larger value as width). One node replaces the
WH + WH + Switch WH 'flip orientation' setup.
The Portrait/Landscape choice lives on node.properties.portraitLandscapeState
and is injected into the hidden PortraitLandscapeState input by the JS
graphToPrompt hook (Resolution Pixaroma pattern, CLAUDE.md Vue Compat #9), so
no extra input slot is exposed for it."""
class PixaromaPortraitLandscape:
DESCRIPTION = (
"Output a width and height in the orientation you pick. Enter your two "
"dimensions (or wire them in from another node), then click Portrait or "
"Landscape. Portrait gives the tall arrangement (the smaller number "
"becomes the width), Landscape gives the wide arrangement (the larger "
"number becomes the width). Flipping orientation is one click, with no "
"need to keep two WH nodes and a switch. The order you enter the two "
"numbers does not matter. Outputs width and height."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"width": ("INT", {"default": 832, "min": 8, "max": 16384, "step": 8, "tooltip": "One of your two dimensions. Type a value or wire a number in. Portrait/Landscape decides whether this becomes the long or short side, so the order you enter the two numbers does not matter."}),
"height": ("INT", {"default": 1216, "min": 8, "max": 16384, "step": 8, "tooltip": "The other dimension. Type a value or wire a number in."}),
},
"hidden": {
"PortraitLandscapeState": ("STRING", {"default": "portrait"}),
},
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = (
"The output width. In Portrait this is the smaller of your two numbers; in Landscape it is the larger.",
"The output height. In Portrait this is the larger of your two numbers; in Landscape it is the smaller.",
)
FUNCTION = "orient"
CATEGORY = "👑 Pixaroma/🔢 Values"
def orient(self, width=832, height=1216, PortraitLandscapeState="portrait"):
w = int(width)
h = int(height)
lo, hi = min(w, h), max(w, h)
if PortraitLandscapeState == "landscape":
return (hi, lo) # wide
return (lo, hi) # portrait / tall (default)
NODE_CLASS_MAPPINGS = {"PixaromaPortraitLandscape": PixaromaPortraitLandscape}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaPortraitLandscape": "Portrait Landscape Pixaroma"}
+1 -1
View File
@@ -76,7 +76,7 @@ class PixaromaPreview:
OUTPUT_TOOLTIPS = ("The image(s) passed through unchanged, so you can chain a preview inline without breaking the wire.",)
FUNCTION = "preview"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
@classmethod
def IS_CHANGED(cls, **kwargs):
+1 -1
View File
@@ -51,7 +51,7 @@ class PixaromaPromptFromList:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The prompt picked from the list at the chosen index. Empty if the index is out of range.",)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
def pick(self, prompts, index):
if not isinstance(prompts, list):
+1 -1
View File
@@ -63,7 +63,7 @@ class PixaromaPromptMulti:
"The list of all enabled prompts. Use in List mode - wire into Prompt From List Pixaroma nodes.",
)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
@classmethod
def IS_CHANGED(cls, PromptMultiState="{}", **kwargs):
+1 -1
View File
@@ -41,7 +41,7 @@ class PixaromaPromptPack:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The current prompt for this queue run (one prompt from the block). Wire to CLIP Text Encode.",)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
@classmethod
def IS_CHANGED(cls, PromptPackState="{}", **kwargs):
+1 -1
View File
@@ -56,7 +56,7 @@ class PixaromaPromptReader:
},
}
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("The prompt recovered from the image's metadata, or an explanatory message if none was found.",)
+1 -1
View File
@@ -36,7 +36,7 @@ class PixaromaPromptStack:
RETURN_NAMES = ("text",)
OUTPUT_TOOLTIPS = ("All enabled chunks joined into one string using your separator.",)
FUNCTION = "build"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
def build(self, PromptStackState="{}"):
state = self._parse_state(PromptStackState)
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaRemoveBackground:
"Mask where the removed background is white (the foreground mask, inverted).",
)
FUNCTION = "execute"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
DESCRIPTION = (
"Remove an image background with a BiRefNet model and return the "
"cutout (RGBA), the foreground mask, and the inverted mask in one "
+139
View File
@@ -0,0 +1,139 @@
"""Resize Crop Pixaroma — force an image to an exact width x height by
crop-to-fill (cover). Scales the image so it completely fills the target box,
then center-crops the overflow, so the output is ALWAYS exactly width x height
with no stretching or letterboxing. Width/height are plain number widgets you
can type into OR wire from another node. An optional mask is cropped in sync.
Reuses the shared, JS-mirrored resize engine (_resize_helpers._apply_cover via
_resize_frame) so the crop math matches Image Resize Pixaroma's Crop-to-fill
mode exactly. No custom JS — native widgets render in both Classic and
Nodes 2.0 renderers automatically."""
import numpy as np
import torch
from PIL import Image
from ._resize_helpers import _resize_frame
def _tensor_to_pils(image_t):
"""BHWC float tensor -> list of RGB PIL images. Defensive about channel
count (1ch grayscale -> RGB, 4ch RGBA -> alpha dropped) so a stray image
can't crash the run. Mirrors Image Resize Pixaroma's converter."""
arr = (image_t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
out = []
for frame in arr:
if frame.ndim == 2: # (H,W) grayscale
frame = np.stack([frame] * 3, axis=-1)
elif frame.shape[-1] >= 3: # RGB / RGBA (drop alpha)
frame = frame[..., :3]
else: # 1- or 2-channel -> grayscale
frame = np.repeat(frame[..., :1], 3, axis=-1)
out.append(Image.fromarray(frame, "RGB"))
return out
def _mask_to_pils(mask_t, count, size):
"""BHW float tensor -> list of L PIL images, each conformed to `size` (the
image size) so the mask always matches the image. Blank (zeros) when
mask_t is None. NEAREST resize keeps mask edges crisp."""
if mask_t is None:
return [Image.new("L", size, 0) for _ in range(count)]
arr = (mask_t.clamp(0, 1).cpu().numpy() * 255.0).round().astype(np.uint8)
out = []
for m in arr:
pim = Image.fromarray(m, "L")
if pim.size != size:
pim = pim.resize(size, Image.NEAREST)
out.append(pim)
while len(out) < count:
out.append(Image.new("L", size, 0))
return out[:count]
class PixaromaResizeCrop:
DESCRIPTION = (
"Crop an image to an exact width and height. The image is scaled to "
"completely fill the target size, then the overflow is cropped away "
"from the center, so the result is always exactly the width and height "
"you set, with no stretching or letterboxing. Smaller images are scaled "
"up to fill. Type the size into the width and height fields, or wire "
"them from another node (e.g. Resolution Pixaroma or a Number node). An "
"optional mask is cropped the same way. Outputs image, mask, width, and "
"height - handy for forcing image or video frames to a fixed size like "
"512x896 or 704x1280."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "The image (or batch / video frames) to crop to size."}),
"width": ("INT", {"default": 1024, "min": 8, "max": 16384, "step": 8, "tooltip": "Target width in pixels. Type a value or wire a number from another node. The arrows step by 8 (AI/video sizes are usually multiples of 8) but you can type any value."}),
"height": ("INT", {"default": 1024, "min": 8, "max": 16384, "step": 8, "tooltip": "Target height in pixels. Type a value or wire a number from another node. The arrows step by 8 but you can type any value."}),
},
"optional": {
"mask": ("MASK", {"tooltip": "Optional mask. Cropped to the same width and height as the image, with crisp (nearest) edges."}),
},
}
CATEGORY = "👑 Pixaroma/✂️ Resize & Crop"
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height")
OUTPUT_TOOLTIPS = (
"The cropped image, exactly width x height pixels.",
"The cropped mask, matching the output image size (blank when no mask is wired in).",
"The output width in pixels.",
"The output height in pixels.",
)
FUNCTION = "run"
def run(self, image, width, height, mask=None):
tw = max(8, min(int(width), 16384))
th = max(8, min(int(height), 16384))
# Crop-to-fill (cover) via the shared engine: scale to cover, then
# center-crop the overflow. allow_upscale True so small sources fill
# the box; snap 0 so the output is EXACTLY tw x th.
state = {
"mode": "cover",
"cover_w": tw,
"cover_h": th,
"crop_anchor": "center",
"crop_scale": True,
"allow_upscale": True,
"snap": 0,
"resample": "auto",
}
rgb_frames = _tensor_to_pils(image)
orig_w, orig_h = rgb_frames[0].size
mask_frames = _mask_to_pils(mask, len(rgb_frames), (orig_w, orig_h))
out_imgs, out_masks = [], []
final_w = final_h = None
for rgb, m in zip(rgb_frames, mask_frames):
r_rgb, r_mask, fw, fh = _resize_frame(rgb, m, state, orig_w, orig_h)
final_w, final_h = fw, fh
out_imgs.append(
torch.from_numpy(np.array(r_rgb).astype(np.float32) / 255.0)[None,]
)
out_masks.append(
torch.from_numpy(np.array(r_mask).astype(np.float32) / 255.0)[None,]
)
out_image = torch.cat(out_imgs, dim=0) if len(out_imgs) > 1 else out_imgs[0]
out_mask = torch.cat(out_masks, dim=0) if len(out_masks) > 1 else out_masks[0]
# Match the input device so both outputs pair cleanly with GPU-resident
# tensors downstream (CLAUDE.md Image Crop/Uncrop Pattern #6). No-op for
# the usual CPU IMAGE case.
out_image = out_image.to(image.device)
out_mask = out_mask.to(image.device)
if final_w is None:
final_w, final_h = tw, th
return (out_image, out_mask, final_w, final_h)
NODE_CLASS_MAPPINGS = {"PixaromaResizeCrop": PixaromaResizeCrop}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaResizeCrop": "Resize Crop Pixaroma"}
+1 -1
View File
@@ -61,7 +61,7 @@ class PixaromaResolution:
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("The chosen width in pixels.", "The chosen height in pixels.")
FUNCTION = "get_resolution"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔢 Values"
def get_resolution(self, ResolutionState: str):
try:
+1 -1
View File
@@ -130,7 +130,7 @@ class PixaromaSaveMp4:
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🖼️ Image"
def save(self, video_frames, fps, filename_prefix, save_mode, trim_to_audio, audio=None):
if video_frames is None or video_frames.shape[0] == 0:
+72
View File
@@ -0,0 +1,72 @@
"""Seed Pixaroma — a seed source with Random / Fixed modes + buttons.
All UI lives on the JS side; the only Python input is a hidden serialized
state string injected at execution time (same pattern as Resolution Pixaroma).
The JS frontend rolls a fresh random seed each run (Random mode) or passes the
locked value (Fixed mode), writing it into `runSeed` before submission.
"""
import json
# 2^64 - 1 — the upper bound ComfyUI's own seed widgets use, so whatever we
# output is always a valid KSampler seed.
SEED_MAX = 0xFFFFFFFFFFFFFFFF
class PixaromaSeed:
DESCRIPTION = (
"Seed Pixaroma - a seed source you wire into KSampler (or any node "
"with a seed input). One Seed node can feed several samplers at once "
"so they all share the same seed.\n\n"
"Two modes: Random rolls a fresh seed every run; Fixed keeps the same "
"seed for repeatable results. Buttons: New fixed random rolls a new "
"seed and locks it; Use last seed brings back the previous run's seed; "
"Copy puts the current seed on your clipboard. In Random mode a Last "
"run line shows the seed that actually generated the last image.\n\n"
"Outputs the seed as INT. State saves and restores with the workflow."
)
@classmethod
def INPUT_TYPES(cls):
# SeedState is `hidden` (no widget, no input slot). The JS frontend
# stores state on node.properties.seedState and injects the resolved
# per-run seed into the API prompt via app.graphToPrompt.
return {
"required": {},
"hidden": {
"SeedState": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("seed",)
OUTPUT_TOOLTIPS = (
"The chosen seed as a whole number. Wire it into a sampler's seed input.",
)
FUNCTION = "get_seed"
CATEGORY = "👑 Pixaroma/🔢 Values"
@classmethod
def IS_CHANGED(cls, SeedState: str):
# Re-execute whenever the injected seed changes. In Random mode the JS
# hook injects a fresh runSeed each run (string differs -> re-run); in
# Fixed mode it stays constant (string identical -> cached / repeatable).
return SeedState
def get_seed(self, SeedState: str):
try:
state = json.loads(SeedState)
s = int(state.get("runSeed", state.get("seed", 0)))
except Exception:
print("[PixaromaSeed] Malformed state, falling back to seed 0")
s = 0
# Keep inside the valid 0..2^64-1 seed range.
if s < 0:
s = 0
elif s > SEED_MAX:
s = s % (SEED_MAX + 1)
return (s,)
NODE_CLASS_MAPPINGS = {"PixaromaSeed": PixaromaSeed}
NODE_DISPLAY_NAME_MAPPINGS = {"PixaromaSeed": "Seed Pixaroma"}
+98
View File
@@ -0,0 +1,98 @@
"""Set Pixaroma / Get Pixaroma - metadata-only backend definitions.
These two nodes are pure FRONTEND virtual nodes; all of their behavior lives in
js/set_get/. They are declared here ONLY so the node library shows a proper
display name ("Set Pixaroma" / "Get Pixaroma"), the correct category, brand
colors, the Help panel, the Info panel, and good search ranking (a pure
frontend node would otherwise show up under "__frontend_only__" with its raw
class id as the name).
At run time the frontend marks every instance virtual (isVirtualNode), so
ComfyUI prunes them from the prompt and reroutes Get nodes straight to the real
upstream source. These Python classes are therefore NEVER executed; the noop
functions exist only so the class is a valid node definition. The order of
frontend registration (registerCustomNodes runs after the backend defs are
registered) means the JS class is what actually gets instantiated.
"""
from ._type_helpers import ANY
_CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
class PixaromaSetNode:
DESCRIPTION = (
"Store any connection under a name, then read it back anywhere with a "
"Get Pixaroma node. A wireless wire that keeps the canvas clean. It also "
"has a passthrough output, so a nearby node can wire to it directly while "
"far ones read it with a Get. It lives only in the editor: at run time "
"the value flows straight from the original source, so it never changes "
"the result or slows anything down. Wire anything in, type a name, and "
"keep the node collapsed to stay tidy."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"name": (
"STRING",
{
"default": "",
"tooltip": "The variable name. A Get Pixaroma node reads this value by picking this name.",
},
),
},
"optional": {
"value": (
ANY,
{"tooltip": "Connect anything here. The node takes on that wire's type."},
),
},
}
RETURN_TYPES = (ANY,)
RETURN_NAMES = ("*",)
OUTPUT_TOOLTIPS = (
"The same value you wired in. Connect a nearby node directly here, or read it from anywhere with Get Pixaroma.",
)
FUNCTION = "noop"
CATEGORY = _CATEGORY
def noop(self, **kwargs):
return (None,)
class PixaromaGetNode:
DESCRIPTION = (
"Read a value that a Set Pixaroma node stored under a name, with no "
"cable. Pick the name from the dropdown and the output carries that "
"Set's value, matching its type. Like Set Pixaroma it exists only in "
"the editor and resolves straight to the original source at run time, "
"so there is no extra cost. Add as many Get nodes as you like for the "
"same name to fan one value out."
)
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = (ANY,)
RETURN_NAMES = ("*",)
OUTPUT_TOOLTIPS = ("The value of the chosen Set Pixaroma node, matching its type.",)
FUNCTION = "noop"
CATEGORY = _CATEGORY
def noop(self, **kwargs):
return (None,)
NODE_CLASS_MAPPINGS = {
"PixaromaSetNode": PixaromaSetNode,
"PixaromaGetNode": PixaromaGetNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PixaromaSetNode": "Set Pixaroma",
"PixaromaGetNode": "Get Pixaroma",
}
+1 -1
View File
@@ -27,7 +27,7 @@ class PixaromaShowText:
OUTPUT_TOOLTIPS = ("The same text shown in the box, passed through so you can keep chaining it.",)
FUNCTION = "show"
OUTPUT_NODE = True
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
def show(self, source):
try:
+1 -1
View File
@@ -49,7 +49,7 @@ class PixaromaSwitch:
RETURN_NAMES = ("output",)
OUTPUT_TOOLTIPS = ("The input from the active (highlighted) row, passed through unchanged.",)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
def pick(self, SwitchState="1", **kwargs):
try:
+1 -1
View File
@@ -70,7 +70,7 @@ class PixaromaSwitchSource:
for i in range(1, MAX_ROWS + 1)
)
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
def pick(self, SwitchSourceState=_DEFAULT_STATE, **kwargs):
active = "A"
+1 -1
View File
@@ -40,7 +40,7 @@ class PixaromaSwitchWH:
RETURN_NAMES = ("width", "height")
OUTPUT_TOOLTIPS = ("The width from the active (highlighted) source.", "The height from the active (highlighted) source.")
FUNCTION = "pick"
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/🔀 Logic & Flow"
def pick(
self,
+23 -20
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,9 +48,12 @@ 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"
CATEGORY = "👑 Pixaroma/💬 Prompt & Text"
def out(self, text):
return (text,)
+1 -1
View File
@@ -18,7 +18,7 @@ from ._text_render_helpers import render_text_layer, compute_text_bbox
class PixaromaTextOverlay:
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
DESCRIPTION = (
"Adds a single styled text overlay on top of an input image. "
"Tune font, size, weight, italic, alignment, line height, letter "
+8 -2
View File
@@ -31,7 +31,7 @@ _ANCHOR_ROWS = {
class PixaromaTextWatermark:
CATEGORY = "👑 Pixaroma"
CATEGORY = "👑 Pixaroma/📝 Notes & Overlay"
DESCRIPTION = (
"Stamps a styled text watermark onto an image or a whole batch. Pick a "
"9-point anchor (corner, edge or center) plus a margin inset and the "
@@ -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).

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